86 lines
4 KiB
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)
|
|
}
|
|
})
|
|
}
|
|
}
|