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 <name|number> (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) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-10-10 10:24:05 +00:00
parent 0379823098
commit 954c96cf5b
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C
3 changed files with 178 additions and 7 deletions

View file

@ -64,9 +64,15 @@ Job control follows LSF's TERMINATE and SUSPEND actions:
- **Stop** (`xcecli jobs stop <id>`): 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
<name|number> <id>`, 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 <id>`; 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

View file

@ -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 <name|number> 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 <array-id>[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
}

View file

@ -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)
}
}
}
}