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