返回 DeepSeek-Reasonix
close_idempotent_test.go
根目录 / internal / control / close_idempotent_test.go
1 package control
2
3 import (
4 "context"
5 "sync/atomic"
6 "testing"
7
8 "reasonix/internal/hook"
9 "reasonix/internal/session"
10 )
11
12 // TestCloseIsIdempotent guards the desktop tab-lifecycle contract: rebind,
13 // model switch, CloseTab, and shutdown can race to Close the same controller,
14 // so a duplicate Close must not re-fire SessionEnd hooks or re-run cleanup.
15 func TestCloseIsIdempotent(t *testing.T) {
16 var sessionEnds atomic.Int32
17 hooks := hook.NewRunner([]hook.ResolvedHook{{
18 HookConfig: hook.HookConfig{Command: "session-end"},
19 Event: hook.SessionEnd,
20 Scope: hook.ScopeGlobal,
21 }}, t.TempDir(), func(context.Context, hook.SpawnInput) hook.SpawnResult {
22 sessionEnds.Add(1)
23 return hook.SpawnResult{ExitCode: 0}
24 }, nil)
25 c := newOwnedTestController(t, Options{Runner: &fakeTurnRunner{}, Hooks: hooks})
26 // A completed turn arms startedOnce so SessionEnd is eligible to fire.
27 if err := c.Run(context.Background(), "hi"); err != nil {
28 t.Fatal(err)
29 }
30
31 done := make(chan struct{})
32 go func() {
33 c.Close()
34 close(done)
35 }()
36 c.Close()
37 <-done
38
39 if got := sessionEnds.Load(); got != 1 {
40 t.Fatalf("SessionEnd hooks fired %d times across concurrent Close calls, want 1", got)
41 }
42 }
43
44 func TestCloseFinalizesRunningMarkerWithoutLiveTurn(t *testing.T) {
45 finalized := make(chan struct{})
46 c := New(Options{Cleanup: func() { close(finalized) }})
47 t.Cleanup(c.finalizeControllerClose)
48 c.mu.Lock()
49 c.turns.phase = session.RuntimeRunning
50 c.mu.Unlock()
51
52 c.Close()
53 select {
54 case <-finalized:
55 default:
56 t.Fatal("close treated a running phase without a live turn as active")
57 }
58 }
59
59 lines GO