package main import ( "context" "encoding/json" "errors" "fmt" "go/ast" "go/parser" "go/token" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "sync" "syscall" "testing" "time" "textmachine/backend/internal/obs" "textmachine/backend/internal/pipeline" "textmachine/backend/internal/runevents" "textmachine/backend/internal/store" ) // exitcontract_test.go drives the REAL binary, because the shell contract is only a contract at the // shell: a supervisor learns how a run ended from the number the process exits with, and the acceptance // of the platform's runner found that out the hard way — its fake probe died FROM the signal, so it // never saw that the engine catches SIGTERM and left with 1 (PD-152). var ( tmctlOnce sync.Once tmctlPath string tmctlDir string tmctlErr error ) // TestMain removes the directory buildTmctl compiles into. Without it every `go test ./cmd/tmctl/` // left a 20 MB binary in the system temp dir forever: t.TempDir cannot serve here (the binary is // shared by the whole package, outliving any one test), and the sync.Once has no teardown of its own. // Measured, not theorised — 249 abandoned `tmctl-bin*` directories, ~5 GB, filled the machine's /tmp // during this pack and turned the -race battery into "no space left on device", which reads exactly // like a broken build. func TestMain(m *testing.M) { code := m.Run() if tmctlDir != "" { _ = os.RemoveAll(tmctlDir) } os.Exit(code) } // buildTmctl compiles the CLI once for the whole package. func buildTmctl(t *testing.T) string { t.Helper() tmctlOnce.Do(func() { dir, err := os.MkdirTemp("", "tmctl-bin") if err != nil { tmctlErr = err return } tmctlDir = dir // TestMain removes it; see the note there tmctlPath = filepath.Join(dir, "tmctl") out, err := exec.Command("go", "build", "-o", tmctlPath, ".").CombinedOutput() if err != nil { tmctlErr = fmt.Errorf("build tmctl: %w\n%s", err, out) } }) if tmctlErr != nil { t.Fatal(tmctlErr) } return tmctlPath } func exitCodeOf(t *testing.T, err error) int { t.Helper() if err == nil { return 0 } var ee *exec.ExitError if !errors.As(err, &ee) { t.Fatalf("not an exit status: %v", err) } return ee.ExitCode() } // slowProvider answers after `delay`, so a signal can arrive while a call is in flight. func slowProvider(t *testing.T, delay time.Duration) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(delay) fmt.Fprint(w, `{"id":"fake","model":"fake-model","choices":[{"message":{"content":"ПЕРЕВОД"},"finish_reason":"stop"}], "usage":{"prompt_tokens":100,"completion_tokens":50}}`) })) t.Cleanup(srv.Close) return srv } func journalOf(t *testing.T, bookPath string) []runevents.Envelope { t.Helper() body, err := os.ReadFile(filepath.Join(filepath.Dir(bookPath), runevents.JournalFile)) if err != nil { t.Fatalf("read journal: %v", err) } var out []runevents.Envelope for _, l := range strings.Split(strings.TrimSpace(string(body)), "\n") { if l == "" { continue } var ev runevents.Envelope if err := json.Unmarshal([]byte(l), &ev); err != nil { t.Fatalf("malformed journal line %q: %v", l, err) } out = append(out, ev) } return out } func lastOutcome(t *testing.T, envs []runevents.Envelope) string { t.Helper() if len(envs) == 0 { t.Fatal("empty journal") } last := envs[len(envs)-1] if last.Type != runevents.TypeFinished { t.Fatalf("the stream's last line is %s, not the terminal event", last.Type) } var f runevents.Finished if err := json.Unmarshal(last.Data, &f); err != nil { t.Fatal(err) } return f.Outcome } func TestACeilingHaltLeavesItsOwnExitCode(t *testing.T) { // PD-113: the platform's contract calls this stop `paused` and forbids `failed`, and exit 1 was the // only thing it had to go on. Since the ceiling argument is set flush against the hold (PD-158), this // is the number that separates "the money ran out" from "the machine broke". if testing.Short() { t.Skip("builds and runs the binary") } bin := buildTmctl(t) srv := slowProvider(t, 0) bookPath := setupCLIProject(t, srv.URL) raiseCeiling(t, bookPath, "ceilings: { book_usd: 0.0000001, day_usd: 2.0 }") err := exec.Command(bin, "translate", "--config", bookPath).Run() if got := exitCodeOf(t, err); got != 4 { t.Fatalf("a ceiling halt exited %d, want 4", got) } envs := journalOf(t, bookPath) if got := lastOutcome(t, envs); got != runevents.OutcomeCeiling { t.Fatalf("terminal outcome %q, want %q", got, runevents.OutcomeCeiling) } found := false for _, ev := range envs { if ev.Type == runevents.TypeCeiling { found = true } } if !found { t.Fatal("the stream must carry the ceiling event too: the exit code and the event fail independently") } } func TestAGracefulStopLeavesItsOwnExitCode(t *testing.T) { // The other half of row 165 and of PD-152: tmctl CATCHES SIGTERM, so it does not die from the signal — // it winds down and exits. While that exit was 1, a host reboot closed every live run as `failed`. if testing.Short() { t.Skip("builds and runs the binary") } bin := buildTmctl(t) srv := slowProvider(t, 3*time.Second) bookPath := setupCLIProject(t, srv.URL) cmd := exec.Command(bin, "translate", "--config", bookPath) cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { t.Fatal(err) } waitForJournal(t, bookPath) if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { t.Fatal(err) } if got := exitCodeOf(t, cmd.Wait()); got != 5 { t.Fatalf("a caught SIGTERM exited %d, want 5", got) } if got := lastOutcome(t, journalOf(t, bookPath)); got != runevents.OutcomeStopped { t.Fatalf("terminal outcome %q, want %q", got, runevents.OutcomeStopped) } } // TestTheRefusalTableIsTotal: a class with no entry in refusalExit does not fail — it falls to // exitRefusedOther, silently, and the distinction the class was introduced for is gone. The table is the // only place that mapping exists, so its completeness is asserted rather than assumed. // refusalClassesFromSource reads the refusal vocabulary out of the SOURCE that declares it. // // A hand-kept list here would be the very thing the gate is against: a class added to // pipeline.RefusalClass and to nobody's list reads as the catch-all 19 at the shell, and the test that // was supposed to notice would be one of the places that had not been updated. The acceptance planted // exactly that and the battery stayed green. // // ⚠ It walks GenDecl/ValueSpec, NOT case-clause literals. The sibling gate over the dispatch switch is // the precedent for the TECHNIQUE and a trap as an implementation: it keys on *ast.BasicLit inside a // `case`, so a command declared as a CONSTANT is invisible to it (its own ping). These values are // constants, so copying that shape would have reproduced the blindness one file over. func refusalClassesFromSource(t *testing.T) map[string]bool { t.Helper() // The whole PACKAGE, not one file: a class declared in a sibling file of package pipeline would be // invisible to a single-file read, and invisible is exactly the state this gate exists to end. // (Walked by hand rather than with parser.ParseDir, which the linter refuses as deprecated.) const dir = "../../internal/pipeline" entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("the refusal vocabulary must be readable under %s: %v", dir, err) } fset := token.NewFileSet() var decls []ast.Decl read := 0 for _, e := range entries { name := e.Name() if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { continue } f, perr := parser.ParseFile(fset, filepath.Join(dir, name), nil, 0) if perr != nil { t.Fatalf("parse %s: %v", name, perr) } decls = append(decls, f.Decls...) read++ } if read == 0 { t.Fatalf("no source files read under %s — the gate would pass on anything", dir) } out := map[string]bool{} for _, d := range decls { gd, ok := d.(*ast.GenDecl) if !ok || gd.Tok != token.CONST { continue } for _, sp := range gd.Specs { vs, ok := sp.(*ast.ValueSpec) if !ok { continue } typed := false if id, ok := vs.Type.(*ast.Ident); ok && id.Name == "RefusalClass" { typed = true } for _, v := range vs.Values { // Two spellings reach the same vocabulary: `X RefusalClass = "s"` and the CONVERSION // `X = RefusalClass("s")`, which carries no Type on the spec at all. Reading only the // first would leave the second invisible — the same class of blindness as reading one file. lit, ok := v.(*ast.BasicLit) if !ok { call, isCall := v.(*ast.CallExpr) if !isCall || len(call.Args) != 1 { continue } if id, isID := call.Fun.(*ast.Ident); !isID || id.Name != "RefusalClass" { continue } lit, ok = call.Args[0].(*ast.BasicLit) if !ok { t.Fatalf("a RefusalClass conversion of something that is not a string literal: %#v", v) } } else if !typed { continue } if lit.Kind != token.STRING { t.Fatalf("a RefusalClass constant whose value is not a string literal: %#v", v) } value, uerr := strconv.Unquote(lit.Value) if uerr != nil { t.Fatal(uerr) } out[value] = true } } } if len(out) == 0 { t.Fatalf("no RefusalClass constants found under %s — the gate is reading the wrong shape and would pass on anything", dir) } return out } func TestTheRefusalTableIsTotal(t *testing.T) { declared := refusalClassesFromSource(t) var classes []pipeline.RefusalClass for c := range declared { classes = append(classes, pipeline.RefusalClass(c)) } sort.Slice(classes, func(i, j int) bool { return classes[i] < classes[j] }) if len(refusalExit) != len(classes) { t.Fatalf("refusalExit maps %d classes, pipeline declares %d (%v) — a class added to pipeline.RefusalClass "+ "without a number here reads as the catch-all %d", len(refusalExit), len(classes), classes, exitRefusedOther) } for c := range refusalExit { if !declared[string(c)] { t.Errorf("refusalExit numbers %q, which pipeline no longer declares", c) } } seen := map[int]pipeline.RefusalClass{} for _, c := range classes { code, ok := refusalExit[c] if !ok { t.Errorf("class %q has no number and would arrive as the catch-all %d", c, exitRefusedOther) continue } if code < refusalFirst || code > refusalLast { t.Errorf("class %q maps to %d, outside the band [%d,%d]", c, code, refusalFirst, refusalLast) } if prior, dup := seen[code]; dup { t.Errorf("classes %q and %q share the number %d — a caller cannot tell them apart", prior, c, code) } seen[code] = c } } func TestEveryRefusalClassHasItsOwnNumberInsideTheBand(t *testing.T) { // PD-196: the platform's intake rejects a book as `source_unreadable` after five failures and deletes // the upload. While every refusal was exit 1, an operator's typo in a hand-written book.yaml looked // exactly like an unusable source. if testing.Short() { t.Skip("builds and runs the binary") } bin := buildTmctl(t) srv := slowProvider(t, 0) t.Run("a broken config", func(t *testing.T) { bookPath := setupCLIProject(t, srv.URL) writeCLIFile(t, bookPath, "book_id: cli-book\n bad indentation: [\n") if got := exitCodeOf(t, exec.Command(bin, "translate", "--config", bookPath).Run()); got != exitConfigInvalid { t.Fatalf("exit %d, want %d", got, exitConfigInvalid) } }) t.Run("a source the config names but the host does not have", func(t *testing.T) { // The CONFIG class, not the text class, and the difference is a user's file. A missing path says // something about the deployment — a template naming a filename the upload route did not use, a // mount that went away — and exit 11 is what an automated intake acts on by deleting the upload. bookPath := setupCLIProject(t, srv.URL) if err := os.Remove(filepath.Join(filepath.Dir(bookPath), "source.txt")); err != nil { t.Fatal(err) } if got := exitCodeOf(t, exec.Command(bin, "translate", "--config", bookPath).Run()); got != exitConfigInvalid { t.Fatalf("exit %d, want %d — a missing path must not be reported as the user's text being unusable", got, exitConfigInvalid) } }) t.Run("a source that exists but cannot be read", func(t *testing.T) { // Passes the config's existence check and fails at the READ — a permission, an I/O error, a path // that is not a file. Still the config/host class: nothing here says the user's text is bad, and // exit 11 is what deletes it. bookPath := setupCLIProject(t, srv.URL) src := filepath.Join(filepath.Dir(bookPath), "source.txt") if err := os.Remove(src); err != nil { t.Fatal(err) } if err := os.Mkdir(src, 0o755); err != nil { // stat succeeds, every read fails t.Fatal(err) } if got := exitCodeOf(t, exec.Command(bin, "translate", "--config", bookPath).Run()); got != exitConfigInvalid { t.Fatalf("exit %d, want %d — an unreadable path must not be reported as the user's text being unusable", got, exitConfigInvalid) } }) t.Run("a source that reads but holds no book", func(t *testing.T) { // The ONE thing the engine can assert about the TEXT with no configuration in the way: the bytes // were read and cut, and there is nothing to translate. No `encoding`, `source_lang` or path // setting explains an empty result from a successful read. bookPath := setupCLIProject(t, srv.URL) writeCLIFile(t, filepath.Join(filepath.Dir(bookPath), "source.txt"), " \n\n") if got := exitCodeOf(t, exec.Command(bin, "translate", "--config", bookPath).Run()); got != exitSourceUnreadable { t.Fatalf("exit %d, want %d", got, exitSourceUnreadable) } }) t.Run("a project another process holds", func(t *testing.T) { bookPath := setupCLIProject(t, srv.URL) held, err := store.Open(filepath.Join(filepath.Dir(bookPath), "cli-book.db")) if err != nil { t.Fatal(err) } defer held.Close() if got := exitCodeOf(t, exec.Command(bin, "translate", "--config", bookPath).Run()); got != exitProjectLocked { t.Fatalf("exit %d, want %d", got, exitProjectLocked) } }) // The band is the contract, not the individual numbers: a caller that treats [10,19] as "refused" // stays safe when a class it has never heard of appears. for _, code := range []int{exitConfigInvalid, exitSourceUnreadable, exitProjectLocked, exitSchemaMismatch, exitDecisionsRejected, exitRefusedOther} { if code < refusalFirst || code > refusalLast { t.Fatalf("refusal code %d is outside the band [%d,%d] every consumer keys on", code, refusalFirst, refusalLast) } } } func TestTheCallerMayNameTheRun(t *testing.T) { // Row 102: a run the platform spawned should be ONE trace, and the id is also the `engine_run_id` // half of the seam's idempotency key — so letting the caller supply it makes that namespace theirs // by construction instead of something they have to learn from the handshake. if testing.Short() { t.Skip("builds and runs the binary") } bin := buildTmctl(t) srv := slowProvider(t, 0) bookPath := setupCLIProject(t, srv.URL) cmd := exec.Command(bin, "translate", "--config", bookPath) cmd.Env = append(os.Environ(), "TM_TRACE_ID=platform-run-42") cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { t.Fatalf("run: %v", err) } envs := journalOf(t, bookPath) var h runevents.Hello if err := json.Unmarshal(envs[0].Data, &h); err != nil { t.Fatal(err) } if h.EngineRunID != "platform-run-42" { t.Fatalf("engine_run_id = %q, want the id the caller passed", h.EngineRunID) } } // raiseCeiling rewrites the fixture's ceilings line. func raiseCeiling(t *testing.T, bookPath, line string) { t.Helper() body, err := os.ReadFile(bookPath) if err != nil { t.Fatal(err) } patched := strings.Replace(string(body), "ceilings: { book_usd: 1.0, day_usd: 2.0 }", line, 1) if patched == string(body) { t.Fatalf("the ceilings line moved; fixture needs updating:\n%s", body) } writeCLIFile(t, bookPath, patched) } // waitForJournal blocks until the run has written its handshake, so a signal cannot arrive before the // process has started working. func waitForJournal(t *testing.T, bookPath string) { t.Helper() path := filepath.Join(filepath.Dir(bookPath), runevents.JournalFile) deadline := time.Now().Add(30 * time.Second) for { if body, err := os.ReadFile(path); err == nil && strings.Contains(string(body), `"hello"`) { return } if time.Now().After(deadline) { t.Fatal("the run wrote no handshake in 30s") } time.Sleep(5 * time.Millisecond) } } // TestACrashJoinedWithACeilingStillExitsAsACrash is the exit-code half of fix-list ФЧ-7. A worker that // panicked while a sibling had already caught a spend ceiling now departs carrying BOTH facts, so the // stream can still announce the money the run ran out of (`internal/pipeline`, runWave). The RANKING must // not travel with them: exit 4 tells a supervisor «resumable, add money and continue», and a process that // crashed is not that. The panic branch is checked before every deliberate stop for exactly this shape, // which is what its comment means by «even if a layer joined it with one». // // The halt is a REAL one, taken off a run that actually hit its ceiling, rather than a hand-built value: // the type carries an unexported error and a constructor made only for a test would be a second way to // build a money verdict. func TestACrashJoinedWithACeilingStillExitsAsACrash(t *testing.T) { srv := slowProvider(t, 0) bookPath := setupCLIProject(t, srv.URL) raiseCeiling(t, bookPath, "ceilings: { book_usd: 0.0000001, day_usd: 2.0 }") err := translate(context.Background(), bookPath, false, pipeline.RebillConsent{}, false, 0, 0) var halt *pipeline.CeilingHalt if !errors.As(err, &halt) { t.Fatalf("precondition: the run was supposed to stop on its book ceiling, got %v", err) } if code := exitCode(err); code != 4 { t.Fatalf("a plain ceiling halt must still exit 4, got %d", code) } joined := errors.Join(obs.NewPanicError("wave worker", "boom"), err) if code := exitCode(joined); code != 1 { t.Fatalf("a crash carrying a ceiling exited %d, want 1 — exit 4 would tell a supervisor to resume a process that died", code) } }