102 lines
4.2 KiB
Go
102 lines
4.2 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 FLOOR is the opposite of a crash: nothing reached a provider, nothing
|
|
// was spent, no work needs rolling back and a retry is safe. («Nothing was written» is not the
|
|
// band's promise — it is a clause of the individual classes, and class 15 legitimately answers with
|
|
// files on disk; see pipeline/refusal.go.)
|
|
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)
|
|
}
|
|
}
|
|
}
|