diff --git a/.wolf/buglog.json b/.wolf/buglog.json index 0fc1d04a..c7561007 100644 --- a/.wolf/buglog.json +++ b/.wolf/buglog.json @@ -20164,6 +20164,32 @@ ], "occurrences": 1, "last_seen": "2026-08-30T22:15:00Z" + }, + { + "id": "bug-1742", + "timestamp": "2026-09-01T04:10:00Z", + "gitea_issue": 45, + "error_message": "Gitea issue #45: `xcecli jobs kill ` fails for every job-control action (kill/stop/resume/checkpoint) with `request to /xce/v1/jobs//control failed: []` -- an empty error body, not a normal ReturnError JSON payload. Reproduced by static analysis of the full CLI->apiserver call path (job ID conversion verified correct by hand: MakeJobID(1,0,1788186846,0,1) == 73932643848159233, matching the URL in the report, so the numeric-vs-string ID mismatch in the bug report is a red herring).", + "file": "src/frontend/apiserver/controllers/jobs.go (validateJobControl:2997-3072, foundJob:3077-3089, ControlJobByID:2515-2599), src/frontend/apiserver/models/jobs.go (JobInfo.JobStatus/.ArrayIdx:489,493), src/frontend/apiserver/store/convert.go (StructToOrderMap:10-35), src/frontend/apiserver/router/routers.go (gin.Default() Recovery middleware:19), src/frontend/xcecli (request.go:219 HandleErrorResponse empty-body fallback)", + "root_cause": "models.JobInfo declares JobStatus and ArrayIdx as plain Go `int` (jobs.go:489,493), not `int64`. store.StructToOrderMap builds jobinfo.Data via reflection (val.Field(i).Interface()), which preserves the field's concrete runtime type -- so Data[\"job_status\"] and Data[\"array_idx\"] are boxed as `int`, never `int64`. validateJobControl's guard at jobs.go:3014 (`if jobStatus, ok := rawStatus.(int64); ok`) therefore ALWAYS evaluates false for every job, unconditionally routing into the `else` error branch (3052-3060). That branch immediately does an UNCHECKED single-return assertion at jobs.go:3057, `int(jobinfo.Data[\"array_idx\"].(int64))` -- since array_idx is actually `int`, this panics with `interface conversion: interface {} is int, not int64` on every single job-control call, regardless of job status, action, or whether the job is an array member (the reporter's job happening to be an array index is incidental; the same unsafe `.(int64)` pattern is repeated at jobs.go:3024/3031/3040/3048/3066, and in foundJob at 3081-3082 for the array-index-lookup path). Gin's default Recovery() middleware (routers.go:19) catches the panic and returns an empty 500 body with no JSON, which is exactly what the xcecli client's error-fallback path (request.go:219, `HandleErrorResponse`) renders as `\"%s failed: [%s]\"` with an empty httpResp.String() -- producing the reported `failed: []` message. Confirmed the type mismatch directly by reading the struct declarations; confirmed the panic site's code by reading validateJobControl in full; not yet confirmed via a live repro/server-log stack trace (static analysis only this session).", + "fix": "FIXED (2026-09-01). validateJobControl (jobs.go:2997) now converts job_status and array_idx via utils.IntOrInt64() (already used elsewhere for this exact int/int64 mismatch, e.g. processJobData) instead of bare `.(int64)` assertions -- a failed conversion is now reported as a normal failedJobs entry with a descriptive msg instead of panicking. foundJob (jobs.go:3077) gets the same fix for its array-index lookup path. TDD: added TestValidateJobControl_HandlesRealJobInfoTypes (builds a real models.JobInfo and converts it via store.StructToOrderMap(), the same reflection path production uses, then asserts validateJobControl doesn't panic and returns a correctly-typed success job -- this is the test that would have caught the original bug, since it exercises the real int/int64 boundary instead of hand-built int64 map literals), TestValidateJobControl_RejectsNonKillableStatus, and TestFoundJob_MatchesRealArrayIdxType (jobs_test.go). Confirmed red (panic at jobs.go:3057, \"interface conversion: interface {} is int, not int64\") before the fix, green after. Full apiserver suite (store/utils/controllers/backend/backend/auth) passes with zero regressions.", + "tags": [ + "apiserver", + "panic", + "type-assertion", + "interface-conversion", + "job-control", + "kill", + "xcecli", + "recovery-middleware", + "empty-error-body", + "new", + "found-static-analysis", + "needs-live-repro", + "fixed" + ], + "occurrences": 1, + "last_seen": "2026-09-01T04:40:00Z" } ] } diff --git a/src/frontend/apiserver/controllers/jobs.go b/src/frontend/apiserver/controllers/jobs.go index f8a5d0df..f6295e03 100644 --- a/src/frontend/apiserver/controllers/jobs.go +++ b/src/frontend/apiserver/controllers/jobs.go @@ -3010,8 +3010,24 @@ func validateJobControl(jobinfo utils.OrderMap, action string, actionType = models.JOB_KILL } + // bug-1742: array_idx is a plain Go `int` on models.JobInfo, not `int64` + // -- utils.IntOrInt64 accepts either, unlike a bare `.(int64)` assertion. + arrayIdx, arrayOk := utils.IntOrInt64(jobinfo.Data["array_idx"]) + if !arrayOk { + logger.Error("array_idx is not an int/int64", zap.Any("array_idx", jobinfo.Data["array_idx"])) + failedJobs = append(failedJobs, map[string]interface{}{ + "unique_job_id": jobinfo.Data["unique_job_id"].(int64), + "job_id": jobinfo.Data["job_id"].(int64), + "array_idx": arrayIdx, + "msg": fmt.Sprintf("Invalid array_idx"), + }) + return successJobs, failedJobs + } + if rawStatus, exists := jobinfo.Data["job_status"]; exists { - if jobStatus, ok := rawStatus.(int64); ok { + // bug-1742: job_status is likewise a plain Go `int`, not `int64`. + if jobStatusInt, ok := utils.IntOrInt64(rawStatus); ok { + jobStatus := int64(jobStatusInt) db_job_status := int(jobStatus) job_status := models.JobStateStr(db_job_status) jobStatus = int64(models.JOBSTAT[job_status]) @@ -3021,14 +3037,14 @@ func validateJobControl(jobinfo utils.OrderMap, action string, failedJobs = append(failedJobs, map[string]interface{}{ "unique_job_id": jobinfo.Data["unique_job_id"].(int64), "job_id": jobinfo.Data["job_id"].(int64), - "array_idx": int(jobinfo.Data["array_idx"].(int64)), + "array_idx": arrayIdx, "msg": fmt.Sprintf("job cannot be or "), }) } else { successJobs = append(successJobs, map[string]interface{}{ "unique_job_id": jobinfo.Data["unique_job_id"].(int64), "job_id": jobinfo.Data["job_id"].(int64), - "array_idx": int(jobinfo.Data["array_idx"].(int64)), + "array_idx": arrayIdx, "msg": fmt.Sprintf("job is being <%s>", action), }) } @@ -3037,7 +3053,7 @@ func validateJobControl(jobinfo utils.OrderMap, action string, successJobs = append(successJobs, map[string]interface{}{ "unique_job_id": jobinfo.Data["unique_job_id"].(int64), "job_id": jobinfo.Data["job_id"].(int64), - "array_idx": int(jobinfo.Data["array_idx"].(int64)), + "array_idx": arrayIdx, "msg": fmt.Sprintf("job is being <%s>", action), }) } else { @@ -3045,16 +3061,16 @@ func validateJobControl(jobinfo utils.OrderMap, action string, failedJobs = append(failedJobs, map[string]interface{}{ "unique_job_id": jobinfo.Data["unique_job_id"].(int64), "job_id": jobinfo.Data["job_id"].(int64), - "array_idx": int(jobinfo.Data["array_idx"].(int64)), + "array_idx": arrayIdx, "msg": fmt.Sprintf("job cannot be <%s>", action), }) } } else { - logger.Error("job_status is not an int64", zap.Any("job_status", rawStatus)) + logger.Error("job_status is not an int/int64", zap.Any("job_status", rawStatus)) failedJobs = append(failedJobs, map[string]interface{}{ "unique_job_id": jobinfo.Data["unique_job_id"].(int64), "job_id": jobinfo.Data["job_id"].(int64), - "array_idx": int(jobinfo.Data["array_idx"].(int64)), + "array_idx": arrayIdx, "msg": fmt.Sprintf("Invalid job_status"), }) } @@ -3063,7 +3079,7 @@ func validateJobControl(jobinfo utils.OrderMap, action string, failedJobs = append(failedJobs, map[string]interface{}{ "unique_job_id": jobinfo.Data["unique_job_id"].(int64), "job_id": jobinfo.Data["job_id"].(int64), - "array_idx": int(jobinfo.Data["array_idx"].(int64)), + "array_idx": arrayIdx, "msg": fmt.Sprintf("job_status not found for job"), }) } @@ -3078,8 +3094,11 @@ func foundJob(jobid int64, array_idx int, jobinfos []utils.OrderMap) ([]utils.Or jobs := make([]utils.OrderMap, 0) for _, jobinfo := range jobinfos { if array_idx > 0 { - if jobinfo.Data["job_id"].(int64) == jobid && - int(jobinfo.Data["array_idx"].(int64)) == array_idx { + // bug-1742: array_idx is a plain Go `int` on models.JobInfo, not + // `int64` -- utils.IntOrInt64 accepts either. + jobinfoArrayIdx, ok := utils.IntOrInt64(jobinfo.Data["array_idx"]) + if ok && jobinfo.Data["job_id"].(int64) == jobid && + jobinfoArrayIdx == array_idx { jobs = append(jobs, jobinfo) } } else { diff --git a/src/frontend/apiserver/controllers/jobs_test.go b/src/frontend/apiserver/controllers/jobs_test.go index f2cb58f1..1512df28 100644 --- a/src/frontend/apiserver/controllers/jobs_test.go +++ b/src/frontend/apiserver/controllers/jobs_test.go @@ -119,3 +119,76 @@ func TestPreCheckJobRequest_EmptyQueue(t *testing.T) { t.Errorf("empty queue should not trigger reserved-name error: %v", err) } } + +// TestValidateJobControl_HandlesRealJobInfoTypes is a regression guard for +// bug-1742 (gitea #45): models.JobInfo.JobStatus and .ArrayIdx are plain Go +// `int` (see the JobInfo struct in models/jobs.go), but validateJobControl +// asserted them as `int64` -- a mismatch invisible to any test that +// hand-built its utils.OrderMap with int64 literals instead of going through +// the same store.StructToOrderMap() reflection path production actually +// uses. That mismatch made every job-control call panic with "interface +// conversion: interface {} is int, not int64", surfaced to xcecli as an +// empty `failed: []` (Gin's Recovery() middleware swallows the panic). +func TestValidateJobControl_HandlesRealJobInfoTypes(t *testing.T) { + resetJobSubmitState() + + jobInfo := models.JobInfo{ + Unique_JobID: 73932643848159233, + JobID: 1788186846, + ArrayIdx: 1, + SubmitUser: "admin", + QueueName: "normal", + JobStatus: models.JOBSTAT["RUN"], + } + orderMap := store.StructToOrderMap(jobInfo) + + successJobs, failedJobs := validateJobControl(orderMap, "kill", "") + + if len(successJobs) != 1 || len(failedJobs) != 0 { + t.Fatalf("expected exactly one success job for a killable RUN job, got successJobs=%v failedJobs=%v", successJobs, failedJobs) + } + if got := successJobs[0]["array_idx"]; got != 1 { + t.Errorf("array_idx = %v (%T), want 1 (int)", got, got) + } +} + +// TestValidateJobControl_RejectsNonKillableStatus verifies a job in a +// terminal state is reported as a failure (not silently dropped, and +// without panicking) -- this path also reads array_idx before returning. +func TestValidateJobControl_RejectsNonKillableStatus(t *testing.T) { + resetJobSubmitState() + + jobInfo := models.JobInfo{ + Unique_JobID: 1, + JobID: 1, + ArrayIdx: 0, + JobStatus: models.JOBSTAT["DONE"], + } + orderMap := store.StructToOrderMap(jobInfo) + + successJobs, failedJobs := validateJobControl(orderMap, "kill", "") + + if len(successJobs) != 0 || len(failedJobs) != 1 { + t.Fatalf("expected exactly one failed job for a DONE job, got successJobs=%v failedJobs=%v", successJobs, failedJobs) + } +} + +// TestFoundJob_MatchesRealArrayIdxType is a regression guard for the same +// bug-1742 type mismatch inside foundJob's array-index lookup path. +func TestFoundJob_MatchesRealArrayIdxType(t *testing.T) { + resetJobSubmitState() + + jobInfo := models.JobInfo{ + JobID: 1788186846, + ArrayIdx: 1, + } + orderMap := store.StructToOrderMap(jobInfo) + + found, err := foundJob(1788186846, 1, []utils.OrderMap{orderMap}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(found) != 1 { + t.Fatalf("expected to find 1 job, got %d", len(found)) + } +}