textmachine/platform/internal/runs/gracekill_test.go

86 lines
4 KiB
Go

package runs
import (
"testing"
"time"
"textmachine/platform/internal/pgstore"
"textmachine/platform/internal/runner"
)
// ⛔ A RUN THIS PLATFORM KILLED BY ITS OWN DEADLINE IS NOT A BROKEN DEPLOYMENT.
//
// `TimeoutStopSec` is the grace this package sets on every run unit; when it expires systemd SIGKILLs
// the process and writes `timeout/killed/KILL`. That used to be reported as `service_error`, whose
// contract meaning is «do not promise a retry will help» — so a user whose run we cut off by our own
// rule was told their deployment was broken and sent away from the one action that would have worked.
//
// ⚠ THE CONTROL IS IN THE TABLE AND IT IS THE WHOLE POINT: `oom-kill` must still answer
// `service_error`, because for THAT one the group's argument holds — the next spawn meets the same
// limit. A change that moved both would not be a fix, it would be the same mistake mirrored.
func TestAKillByOurOwnGraceIsNotReportedAsABrokenDeployment(t *testing.T) {
at := time.Date(2026, 9, 11, 12, 0, 0, 0, time.UTC)
for _, tc := range []struct {
name string
result string
want string
}{
// What systemd actually writes when TimeoutStopSec expires — measured on a unit of this shape.
{"our own stop deadline", "timeout", "interrupted"},
// The sibling the group's argument is about, and it must not move.
{"killed for its memory", "oom-kill", "service_error"},
{"the watchdog fired", "watchdog", "service_error"},
{"the host ran out", "resources", "service_error"},
// An ending nobody described stays what it was. ⚠ systemd's own word here is `signal` and NOT
// `success`: `success` on a killed unit is a different branch entirely — `outcome` answers
// `stopped` for it («a stop nobody recorded») and never reaches a failure reason. Written
// wrongly the first time and caught by this test's own guard below, which is what that guard
// is for.
{"a signal nobody described", "signal", "interrupted"},
} {
t.Run(tc.name, func(t *testing.T) {
m := runner.Marker{Unit: "u", Result: tc.result, Code: "killed", Status: "KILL", At: at}
status, _, exit := outcome(pgstore.LiveRun{RunID: "r"}, m)
if status != "failed" {
t.Fatalf("a %q kill ended as %q, not failed: this test is about the reason a FAILED run carries, "+
"so it now measures nothing", tc.result, status)
}
if got := failureReason(status, exit, m); got != tc.want {
t.Errorf("a %q kill is reported to the client as %q, want %q", tc.result, got, tc.want)
}
})
}
}
// ⚠ TWO BRANCHES CARRY `timeout` INTO THAT ANSWER AND BOTH ARE PINNED, because a fix that covered one
// of them would look complete and be half. Which branch a run takes is decided by the stop intent's
// timestamp against the marker's — and the two are not even the same clock (the marker carries the
// host's, the intent this platform's), so «the intent is newer» is an ordinary state and not a corner.
//
// The middle row is the control that proves the other two are not vacuous: with an intent OLDER than
// the marker the run is `stopped` and never reaches the failure reason at all.
func TestBothWaysAGraceKillReachesItsReasonAreCovered(t *testing.T) {
at := time.Date(2026, 9, 11, 12, 0, 0, 0, time.UTC)
older, newer := at.Add(-time.Minute), at.Add(time.Minute)
m := runner.Marker{Unit: "u", Result: "timeout", Code: "killed", Status: "KILL", At: at}
for _, tc := range []struct {
name string
intent *time.Time
wantStatus string
wantReason string
}{
{"nobody recorded a stop — a reboot, or a hand stop", nil, "failed", "interrupted"},
{"the stop was asked for BEFORE the kill", &older, "stopped", ""},
{"the stop was asked for AFTER the kill", &newer, "failed", "interrupted"},
} {
t.Run(tc.name, func(t *testing.T) {
status, _, exit := outcome(pgstore.LiveRun{RunID: "r", StopRequestedAt: tc.intent}, m)
if status != tc.wantStatus {
t.Errorf("status %q, want %q", status, tc.wantStatus)
}
if got := failureReason(status, exit, m); got != tc.wantReason {
t.Errorf("failure reason %q, want %q", got, tc.wantReason)
}
})
}
}