From 954c96cf5b6932b6719fad83d4646b7e74674b07 Mon Sep 17 00:00:00 2001 From: Rongsong Shen Date: Sat, 10 Oct 2026 10:24:05 +0000 Subject: [PATCH] feat(xcecli): jobs kill --signal and --force The control API already takes `signal` (kill only: that signal once, instead of the graceful INT, TERM, KILL sequence) and `force_kill` (SIGKILL at once); xcecli could not send either. `jobs kill` now has -s/--signal (names in any case, SIG prefix optional, sent upper case without it) and --force, in both the job-ID and the filter request; using both at once, or an empty signal, is refused before any request. stop and resume get neither (the server rejects a signal there). Kill help and docs/xce.md "Killing and stopping jobs" document the flags. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/docs/xce.md | 12 +- src/frontend/xcecli/cmd/jobs/controller.go | 43 +++++- .../xcecli/cmd/jobs/controller_test.go | 130 ++++++++++++++++++ 3 files changed, 178 insertions(+), 7 deletions(-) diff --git a/src/docs/xce.md b/src/docs/xce.md index a304415a..18b3b5d0 100644 --- a/src/docs/xce.md +++ b/src/docs/xce.md @@ -64,9 +64,15 @@ Job control follows LSF's TERMINATE and SUSPEND actions: - **Stop** (`xcecli jobs stop `): SIGTSTP first, so the job can catch it, prepare and stop itself; SIGSTOP follows after the interval if it has not stopped. **Resume** sends SIGCONT and cancels a stop still in progress. -- **Explicit signal**: that signal only, once (REST API: `signal` in the - control request, kill action; `xcecli` has no signal option yet). -- **Force kill**: SIGKILL at once (REST API: `force_kill: true`). +- **Explicit signal**: that signal only, once (`xcecli jobs kill -s + `, e.g. `-s TERM`, `-s SIGUSR1`, `-s 9`; REST API: + `signal` in the control request, kill action only). +- **Force kill**: SIGKILL at once (`xcecli jobs kill --force `; REST + API: `force_kill: true`). + +`--signal` and `--force` work with job IDs and with the filters (`-J`, `-q`, +`-u`, `-a`), and cannot be used together. `stop` and `resume` take no +signal. The interval is the API server's `job.terminate_interval` setting (`config/app.yaml`, seconds; default 10 when unset or 0, as LSF's diff --git a/src/frontend/xcecli/cmd/jobs/controller.go b/src/frontend/xcecli/cmd/jobs/controller.go index 1c1401a3..711a03c1 100755 --- a/src/frontend/xcecli/cmd/jobs/controller.go +++ b/src/frontend/xcecli/cmd/jobs/controller.go @@ -18,6 +18,8 @@ type JobController struct { action string filter models.JobControlFilter jobIDs []string + signal string // kill only: send this signal once (number or name) + forceKill bool // kill only: SIGKILL at once } // NewJobController create job controller @@ -61,7 +63,9 @@ func (c *JobController) controlJobsByIDs(apiClient *request.APIClient, token str url := fmt.Sprintf("/xce/v1/jobs/%s/control", strings.Join(apiIDs, ",")) req := models.JobControlJobIDRequest{ - Action: c.action, + Action: c.action, + Signal: c.signal, + ForceKill: c.forceKill, } // encode request data to JSON bytes return c.printJobControlResponse(req, apiClient, token, url) @@ -79,8 +83,10 @@ func (c *JobController) controlJobsByFilter(apiClient *request.APIClient, token url := "/xce/v1/jobs/control" req := models.JobControlRequest{ - Action: c.action, - Filter: c.filter, + Action: c.action, + Filter: c.filter, + Signal: c.signal, + ForceKill: c.forceKill, } // encode request data to JSON bytes return c.printJobControlResponse(req, apiClient, token, url) @@ -143,6 +149,22 @@ func (c *JobController) ParseCmdArgs(cmd *cobra.Command, args []string) error { return nil } +// parseKillOptions checks the kill-only -s/--signal and --force options: +// they cannot be combined, and a signal name is sent in the API server's +// form (upper case, without the SIG prefix; the server checks it is known). +func (c *JobController) parseKillOptions(cmd *cobra.Command) error { + signalSet := cmd != nil && cmd.Flags().Changed("signal") + if signalSet && c.forceKill { + return fmt.Errorf("--signal and --force cannot be used together") + } + c.signal = strings.ToUpper(strings.TrimSpace(c.signal)) + if signalSet && c.signal == "" { + return fmt.Errorf("empty signal: give a signal name or number") + } + c.signal = strings.TrimPrefix(c.signal, "SIG") + return nil +} + // NewKillJobsCmd create kill job command func NewKillJobsCmd(configManager *config.ConfigManager) *cobra.Command { controller := NewJobController(configManager, "kill") @@ -156,6 +178,11 @@ The job gets SIGINT, then SIGTERM, then SIGKILL, one interval apart (the API server's job.terminate_interval, default 10 s), each only while it is still running, so it can catch SIGINT/SIGTERM and clean up. + -s, --signal send only this signal, once, instead of that + sequence (e.g. TERM, SIGUSR1, 9) + --force send SIGKILL at once +--signal and --force cannot be used together. + Examples: xce jobs kill jobid1,jobid2,... # kill jobs by job IDs xce jobs kill [k] # kill element k of an array @@ -163,12 +190,18 @@ Examples: xce jobs kill -J jobname # kill jobs by job name xce jobs kill -J 'jobname[k]' # kill element k of every array named jobname xce jobs kill -q queue_name # kill jobs in a specific queue - xce jobs kill -u username # kill jobs by user`, + xce jobs kill -u username # kill jobs by user + xce jobs kill -s USR1 jobid # send SIGUSR1 to a job, once + xce jobs kill --force jobid # SIGKILL a job at once`, RunE: func(cmd *cobra.Command, args []string) error { if err := controller.ParseCmdArgs(cmd, args); err != nil { fmt.Println(strings.ToLower(err.Error())) return nil } + if err := controller.parseKillOptions(cmd); err != nil { + fmt.Println(strings.ToLower(err.Error())) + return nil + } if err := controller.RunJobsControl(); err != nil { fmt.Println(strings.ToLower(err.Error())) return nil @@ -183,6 +216,8 @@ Examples: flags.StringVarP(&controller.filter.QueueName, "queue", "q", "", "filter by queue name") flags.StringVarP(&controller.filter.Users, "user", "u", "", "filter by user") flags.BoolVarP(&controller.filter.AllJobs, "all", "a", false, "select all jobs") + flags.StringVarP(&controller.signal, "signal", "s", "", "send only this signal (name or number), once") + flags.BoolVar(&controller.forceKill, "force", false, "send SIGKILL at once") return cmd } diff --git a/src/frontend/xcecli/cmd/jobs/controller_test.go b/src/frontend/xcecli/cmd/jobs/controller_test.go index ff401c9b..9edbfa51 100644 --- a/src/frontend/xcecli/cmd/jobs/controller_test.go +++ b/src/frontend/xcecli/cmd/jobs/controller_test.go @@ -14,7 +14,10 @@ import ( "xcecli/common/format" "xcecli/common/request" + "xcecli/config" "xcecli/models" + + "github.com/spf13/cobra" ) // Array-capable identity used by the tests below: tag 0, sequence 32768, so @@ -232,3 +235,130 @@ func TestListHelpShowsElementForms(t *testing.T) { } } } + +// bodyServer is an httptest API server that records every request as +// "PATH BODY" and answers with an empty control result. +type bodyServer struct { + srv *httptest.Server + mu sync.Mutex + reqs []string +} + +func newBodyServer(t *testing.T) *bodyServer { + t.Helper() + bs := &bodyServer{} + bs.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + bs.mu.Lock() + bs.reqs = append(bs.reqs, r.URL.Path+" "+string(body)) + bs.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(models.JobControlResponse{Code: 200, Msg: "ok", + Data: &models.JobControlResponseData{}}) + })) + t.Cleanup(bs.srv.Close) + return bs +} + +func (bs *bodyServer) requests() []string { + bs.mu.Lock() + defer bs.mu.Unlock() + return append([]string(nil), bs.reqs...) +} + +// runJobsCmd runs a jobs control command against url and returns its output. +func runJobsCmd(t *testing.T, newCmd func(*config.ConfigManager) *cobra.Command, url string, args ...string) string { + t.Helper() + cm := config.NewConfigManagerWithPath(t.TempDir() + "/config.json") + if err := cm.SaveConfig(&config.Config{APIServerInfo: []config.APIServerInfo{{URL: url, Token: "tok"}}}); err != nil { + t.Fatal(err) + } + cmd := newCmd(cm) + cmd.SetArgs(args) + out, err := captureStdout(t, cmd.Execute) + if err != nil { + t.Fatalf("%v: %v", args, err) + } + return out +} + +// kill -s/--signal and --force reach the request body, in both the job-ID +// and the filter form; a signal name may be given in any case, with or +// without the SIG prefix. +func TestKillSignalAndForceInRequestBody(t *testing.T) { + byID := "/xce/v1/jobs/" + strconv.FormatUint(testArrayKey, 10) + "[1]/control " + byFilter := "/xce/v1/jobs/control " + cases := []struct { + args []string + want string + }{ + {[]string{testArrayWritten + "[1]"}, byID + `{"action":"kill"}`}, + {[]string{"-s", "term", testArrayWritten + "[1]"}, byID + `{"action":"kill","signal":"TERM"}`}, + {[]string{"--signal", "SIGUSR1", testArrayWritten + "[1]"}, byID + `{"action":"kill","signal":"USR1"}`}, + {[]string{"-s", "9", testArrayWritten + "[1]"}, byID + `{"action":"kill","signal":"9"}`}, + {[]string{"--force", testArrayWritten + "[1]"}, byID + `{"action":"kill","force_kill":true}`}, + {[]string{"-s", "Int", "-J", "foo"}, byFilter + `{"filter":{"job_name":"foo"},"action":"kill","signal":"INT"}`}, + {[]string{"--force", "-u", "bob"}, byFilter + `{"filter":{"users":"bob"},"action":"kill","force_kill":true}`}, + } + for _, tc := range cases { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + bs := newBodyServer(t) + out := runJobsCmd(t, NewKillJobsCmd, bs.srv.URL, tc.args...) + if got := bs.requests(); len(got) != 1 || got[0] != tc.want { + t.Fatalf("requests = %q, want [%q] (output %q)", got, tc.want, out) + } + }) + } +} + +// --signal and --force together, or an empty signal, are refused before any +// request is sent. +func TestKillSignalAndForceRejected(t *testing.T) { + cases := []struct { + args []string + wantSub string + }{ + {[]string{"-s", "TERM", "--force", testArrayWritten + "[1]"}, "cannot be used together"}, + {[]string{"--force", "--signal", "9", "-J", "foo"}, "cannot be used together"}, + {[]string{"-s", " ", testArrayWritten + "[1]"}, "empty signal"}, + } + for _, tc := range cases { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + bs := newBodyServer(t) + out := runJobsCmd(t, NewKillJobsCmd, bs.srv.URL, tc.args...) + if !strings.Contains(out, tc.wantSub) { + t.Errorf("output %q does not contain %q", out, tc.wantSub) + } + if n := len(bs.requests()); n != 0 { + t.Errorf("%d request(s) sent, want none", n) + } + }) + } +} + +// The signal options belong to kill only (the API server rejects a signal +// for stop and resume), and the kill help documents them. +func TestKillSignalOptionsOnlyOnKill(t *testing.T) { + kill := NewKillJobsCmd(nil) + for _, name := range []string{"signal", "force"} { + if kill.Flags().Lookup(name) == nil { + t.Errorf("kill lacks --%s", name) + } + if !strings.Contains(kill.Long, "--"+name) { + t.Errorf("kill help does not document --%s", name) + } + } + if f := kill.Flags().Lookup("signal"); f != nil && f.Shorthand != "s" { + t.Errorf("--signal shorthand = %q, want s", f.Shorthand) + } + for name, cmd := range map[string]*cobra.Command{ + "stop": NewStopJobsCmd(nil), + "resume": NewResumeJobsCmd(nil), + } { + for _, flag := range []string{"signal", "force"} { + if cmd.Flags().Lookup(flag) != nil { + t.Errorf("%s has --%s", name, flag) + } + } + } +}