From ee7ab461c529bb868de8751ebff2db06c867cb1f Mon Sep 17 00:00:00 2001 From: forever Date: Tue, 6 Oct 2026 14:59:51 -0700 Subject: [PATCH] gateway tests: a Claude Code run a test keeps for the next turn ends with the test, so -count no longer gathers 11 fake Claude Code processes a round (#1060) Six tests have their server keep a Claude Code run for a conversation's next turn, which the bridge holds for idleLongest (an hour): TestClaudeRunKeptForTheNextTurn, TestClaudeOneOffAskNotKept, TestClaudeRunLetGoWhenTheConversationMovedOn and TestClaudeRunKeptAcrossEffort (claude_subscription_test.go), TestClaudeRunKeptAfterTheClientsOwnToolCall and TestCodexThreadKeepsItsClaudeRunAcrossAMemoryCall. Nothing ended those runs, so each test's fake Claude Code ran on until the test binary exited, 11 more each round: under -race -count=50, gateway.test had 77 of them after about 8 rounds. Each now ends its server's runs as the gateway does when it stops, t.Cleanup(s.subscription.abortAll), like the tests that already keep runs. fakeClaude, and TestClaudeRunKeptAcrossEffort's own fake, register runsEndWithTest before New(), so it runs after the test's cleanups: it fails the test when a run of that fake is still in its server's bridge and not ended, and ends it. Like the park sweep, it counts a run killed or exited, on its way out of the bridge, as ended. Without the six abortAll cleanups the six tests fail: claude_subscription_foreign_test.go:19: Claude Code runs the test started, still going after it: 3 --- FAIL: TestClaudeRunKeptAfterTheClientsOwnToolCall claude_subscription_test.go:148: Claude Code runs the test started, still going after it: 3 --- FAIL: TestClaudeRunKeptForTheNextTurn claude_subscription_test.go:192: Claude Code runs the test started, still going after it: 1 --- FAIL: TestClaudeOneOffAskNotKept claude_subscription_test.go:349: Claude Code runs the test started, still going after it: 2 --- FAIL: TestClaudeRunLetGoWhenTheConversationMovedOn claude_subscription_test.go:571: Claude Code runs the test started, still going after it: 1 --- FAIL: TestClaudeRunKeptAcrossEffort codex_developer_cache_test.go:73: Claude Code runs the test started, still going after it: 1 --- FAIL: TestCodexThreadKeepsItsClaudeRunAcrossAMemoryCall With them they pass. The test binary's processes after each round of -count=3, each test run alone with a probe that lists gateway.test's descendants after it: before, 3, 6, 9 (TestClaudeRunKeptForTheNextTurn, TestClaudeRunKeptAfterTheClientsOwnToolCall), 2, 4, 6 (TestClaudeRunLetGoWhenTheConversationMovedOn) and 1, 2, 3 (the other three); the six in one run 11, 22, 33. After, none for each. The package's other tests, run the same way one by one, left none running into their next round: in a first round the probe saw only the stand-ins TestMain runs once (testenv.StandIns), and once a fake of TestClaudeBridgeSafeguardContextUpdates that abortAll had just killed. The 21 that need bun, which end their plugin host with plugin.Settle, and the live Codex test were skipped here. -race -count=20 of the six passes. gofmt -l, go vet ./..., go build ./..., GOOS=linux go build -tags nogui ./..., GOOS=windows go build ./... and go test -tags nogui ./... pass under a temp HOME with Go's caches pinned. Tests only. --- .../claude_subscription_foreign_test.go | 1 + internal/gateway/claude_subscription_test.go | 45 +++++++++++++++++++ .../gateway/codex_developer_cache_test.go | 1 + 3 files changed, 47 insertions(+) diff --git a/internal/gateway/claude_subscription_foreign_test.go b/internal/gateway/claude_subscription_foreign_test.go index 0a6b8f90..5057530e 100644 --- a/internal/gateway/claude_subscription_foreign_test.go +++ b/internal/gateway/claude_subscription_foreign_test.go @@ -18,6 +18,7 @@ import ( func TestClaudeRunKeptAfterTheClientsOwnToolCall(t *testing.T) { fakeClaude(t) s := New() + t.Cleanup(s.subscription.abortAll) p := provider.Provider{ID: "claude", Account: &provider.Account{Agent: "claude", User: "u"}} ask := func(msgs string) string { t.Helper() diff --git a/internal/gateway/claude_subscription_test.go b/internal/gateway/claude_subscription_test.go index 142e4838..75e3a24f 100644 --- a/internal/gateway/claude_subscription_test.go +++ b/internal/gateway/claude_subscription_test.go @@ -80,6 +80,7 @@ func TestCleanClaudeEnvRemovesGatewayOverrides(t *testing.T) { // fakeClaude answers each line it is given with the process it runs in and // how many turns that process has had, as Claude Code's stream-json does. +// A test calls it before New(), for runsEndWithTest. func fakeClaude(t *testing.T) { t.Helper() if runtime.GOOS == "windows" { @@ -99,6 +100,45 @@ done ` testenv.Program(t, filepath.Join(dir, "claude"), script) t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + runsEndWithTest(t, dir) +} + +// runsEndWithTest fails the test, and ends the runs, when a Claude Code run +// of the fake in dir is still going after the test's cleanups. A run kept +// for the next turn waits idleLongest, so the test ends its server's runs +// with t.Cleanup(s.subscription.abortAll), registered after this call, as +// cleanups run last first. +func runsEndWithTest(t *testing.T, dir string) { + t.Helper() + dir = filepath.Clean(dir) // as exec.LookPath's filepath.Join gives it + t.Cleanup(func() { + t.Helper() + var left []*subscriptionRun + bridges.Range(func(key, _ any) bool { + b := key.(*subscriptionBridge) + b.mu.Lock() + for _, run := range b.runs { + if run.cmd == nil || filepath.Dir(run.cmd.Path) != dir { + continue + } + // a run killed, or exited, may not have left b.runs yet + run.mu.Lock() + closed := run.closed + run.mu.Unlock() + if !closed { + left = append(left, run) + } + } + b.mu.Unlock() + return true + }) + if len(left) > 0 { + t.Errorf("Claude Code runs the test started, still going after it: %d", len(left)) + } + for _, run := range left { + run.abort() + } + }) } // A conversation's next turn goes to the Claude Code that had its last, @@ -107,6 +147,7 @@ done func TestClaudeRunKeptForTheNextTurn(t *testing.T) { fakeClaude(t) s := New() + t.Cleanup(s.subscription.abortAll) p := provider.Provider{ID: "claude", Account: &provider.Account{Agent: "claude", User: "u"}} ask := func(msgs string) string { t.Helper() @@ -151,6 +192,7 @@ func TestClaudeRunKeptForTheNextTurn(t *testing.T) { func TestClaudeOneOffAskNotKept(t *testing.T) { fakeClaude(t) s := New() + t.Cleanup(s.subscription.abortAll) p := provider.Provider{ID: "claude", Account: &provider.Account{Agent: "claude", User: "u"}} ask := func(msgs string) string { t.Helper() @@ -308,6 +350,7 @@ func TestClaudeLimits(t *testing.T) { func TestClaudeRunLetGoWhenTheConversationMovedOn(t *testing.T) { fakeClaude(t) s := New() + t.Cleanup(s.subscription.abortAll) p := provider.Provider{ID: "claude", Account: &provider.Account{Agent: "claude", User: "u"}} ask := func(model, msgs string) string { t.Helper() @@ -528,7 +571,9 @@ done ` testenv.Program(t, filepath.Join(dir, "claude"), script) t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + runsEndWithTest(t, dir) s := New() + t.Cleanup(s.subscription.abortAll) p := provider.Provider{ID: "claude", Account: &provider.Account{Agent: "claude", User: "u"}} ask := func(effort, msgs string) string { t.Helper() diff --git a/internal/gateway/codex_developer_cache_test.go b/internal/gateway/codex_developer_cache_test.go index 58cbde4b..de968c94 100644 --- a/internal/gateway/codex_developer_cache_test.go +++ b/internal/gateway/codex_developer_cache_test.go @@ -72,6 +72,7 @@ func TestCodexLaterDeveloperMessageKeepsTheSystem(t *testing.T) { func TestCodexThreadKeepsItsClaudeRunAcrossAMemoryCall(t *testing.T) { fakeClaude(t) s := New() + t.Cleanup(s.subscription.abortAll) p := provider.Provider{ID: "claude", Account: &provider.Account{Agent: "claude", User: "u"}} said := regexp.MustCompile(`pid \d+ turn \d+`) ask := func(body string) string {