textmachine/backend/cmd/tmctl/panic_exit_test.go

100 lines
4 KiB
Go

package main
import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"testing"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/pipeline"
)
// panic_exit_test.go: a crash must leave through a FAILING exit code (row 176). Unrecovered, a Go panic
// is the runtime's own exit 2 — the number this contract gives to "completed with flags" — and the
// platform's intake reads 2 as a finished run with flagged chapters, so a process that died mid-book was
// recorded as `ready` with its money read from a `status --json` that knows nothing about the crash.
func TestPanicOnTheMainGoroutineExitsAsFailure(t *testing.T) {
var diag bytes.Buffer
code := exitOf(&diag, func() error { panic("something ate a nil map") })
if code != 1 {
t.Fatalf("a panic must exit 1 (infra failure), got %d", code)
}
// 2 is the one number that would be actively harmful, so it gets its own sentence.
if code == 2 {
t.Fatal("a panic must never exit 2 — the platform records that as completed-with-flags")
}
out := diag.String()
for _, want := range []string{"tmctl:", "PANIC in tmctl", "something ate a nil map", "goroutine"} {
if !strings.Contains(out, want) {
t.Errorf("the panic and its stack must reach the diagnostics sink; %q missing from: %s", want, out)
}
}
}
func TestPanicFromAWaveWorkerExitsAsFailure(t *testing.T) {
// The shape the driver actually returns: the wave's error, wrapped on its way up through translate().
err := fmt.Errorf("pipeline: draft wave: %w", obs.NewPanicError("wave worker", "index out of range"))
if code := exitCode(err); code != 1 {
t.Fatalf("a wrapped worker panic must exit 1, got %d", code)
}
var diag bytes.Buffer
if code := exitOf(&diag, func() error { return err }); code != 1 {
t.Fatalf("exitOf must map it the same way, got %d", code)
}
if !strings.Contains(diag.String(), "goroutine") {
t.Errorf("the worker's stack must reach the diagnostics sink; got: %s", diag.String())
}
}
// TestPanicTakesNoNonFailingCodeOfTheDictionary is the trap: the fix moves no number, so the only way a
// panic could still be read as success is by MATCHING one of the typed sentinels the dictionary is read
// through. Each case panics with the very sentinel it must not become.
func TestPanicTakesNoNonFailingCodeOfTheDictionary(t *testing.T) {
for _, tc := range []struct {
name string
value any
notCode int
}{
{"completed with flags", &pipeline.CompletedWithFlags{Flagged: 1, Total: 3}, 2},
{"signature stop", &pipeline.WaveSignatureStop{Terms: 2}, 3},
{"context cancellation", context.Canceled, 5},
} {
t.Run(tc.name, func(t *testing.T) {
err := obs.NewPanicError("wave worker", tc.value)
code := exitCode(err)
if code == tc.notCode {
t.Fatalf("a panic carrying %s leaked into exit %d", tc.name, code)
}
if code != 1 {
t.Fatalf("a panic must exit 1, got %d", code)
}
})
}
// And the refusal band, whose promise is the opposite of a crash: "nothing reached a provider,
// nothing was spent, nothing was written".
code := exitCode(obs.NewPanicError("wave worker", pipeline.RefuseConfig(errors.New("bad"))))
if code >= refusalFirst && code <= refusalLast {
t.Fatalf("a panic must never land in the refusal band, got %d", code)
}
}
// TestPanicWinsWhenJoinedWithASentinel pins the ORDER of the switch, not just its content: errors.Join
// puts a panic and a sentinel in one chain, and both errors.As calls would match. The panic must win, or
// a crash joined with a ceiling halt would leave as exit 4 — which the platform records as `paused`.
func TestPanicWinsWhenJoinedWithASentinel(t *testing.T) {
for _, sentinel := range []error{
&pipeline.CompletedWithFlags{Flagged: 1, Total: 2},
&pipeline.WaveSignatureStop{Terms: 1},
context.Canceled,
pipeline.RefuseConfig(errors.New("bad config")),
} {
joined := errors.Join(sentinel, obs.NewPanicError("wave worker", "boom"))
if code := exitCode(joined); code != 1 {
t.Errorf("a panic joined with %T must still exit 1, got %d", sentinel, code)
}
}
}