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