返回 DeepSeek-Reasonix
runtime_state_binding_test.go
根目录 / desktop / runtime_state_binding_test.go
1 package main
2
3 import (
4 "reflect"
5 "sync"
6 "testing"
7 "time"
8
9 "reasonix/internal/control"
10 "reasonix/internal/event"
11 )
12
13 // The gate pauses a real projection read after App bindings were copied. Its
14 // result may then belong to a controller whose session was rotated meanwhile.
15 type bindingRuntimeReader struct {
16 stubSessionAPI
17 mu sync.Mutex
18 state event.RuntimeStateSnapshot
19 entered chan struct{}
20 release chan struct{}
21 once sync.Once
22 resolved []control.PromptIdentity
23 }
24
25 func (*bindingRuntimeReader) AutoApproveTools() bool { return false }
26 func (*bindingRuntimeReader) PlanMode() bool { return false }
27 func (*bindingRuntimeReader) Goal() string { return "" }
28 func (*bindingRuntimeReader) GoalStatus() string { return control.GoalStatusStopped }
29 func (*bindingRuntimeReader) GoalRuntime() control.GoalRuntimeView {
30 return control.GoalRuntimeView{}
31 }
32 func (*bindingRuntimeReader) ToolApprovalMode() string { return "ask" }
33
34 func (r *bindingRuntimeReader) ResolvePromptExact(identity control.PromptIdentity, _ control.PromptAnswer) error {
35 r.mu.Lock()
36 defer r.mu.Unlock()
37 r.resolved = append(r.resolved, identity)
38 return nil
39 }
40
41 func (r *bindingRuntimeReader) RuntimeStateSnapshot() event.RuntimeStateSnapshot {
42 r.once.Do(func() {
43 if r.entered != nil {
44 close(r.entered)
45 <-r.release
46 }
47 })
48 r.mu.Lock()
49 defer r.mu.Unlock()
50 return r.state
51 }
52
53 func TestRuntimeStateProjectionDoesNotHoldMutexAcrossControllerRead(t *testing.T) {
54 reader := &bindingRuntimeReader{
55 state: event.RuntimeStateSnapshot{SchemaVersion: 1, Phase: "executing", Running: true},
56 entered: make(chan struct{}),
57 release: make(chan struct{}),
58 }
59 tab := &WorkspaceTab{ID: "running", Scope: "global", TopicID: "topic", SessionPath: "/run.jsonl", Ctrl: reader}
60 app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, detachedSessions: map[string]*WorkspaceTab{}}
61 done := make(chan RuntimeStateProjection, 1)
62 go func() { done <- app.GetRuntimeStateSnapshot() }()
63 select {
64 case <-reader.entered:
65 case <-time.After(2 * time.Second):
66 t.Fatal("projection did not reach controller read")
67 }
68 if !app.runtimeStateProjection.mu.TryLock() {
69 close(reader.release)
70 t.Fatal("GetRuntimeStateSnapshot held its projection mutex across a controller read")
71 }
72 app.runtimeStateProjection.mu.Unlock()
73 close(reader.release)
74 select {
75 case <-done:
76 case <-time.After(2 * time.Second):
77 t.Fatal("gated projection did not finish")
78 }
79 }
80
81 func TestMetaForTabRevalidatesBindingAndReadsControllerUnlocked(t *testing.T) {
82 old := &bindingRuntimeReader{
83 state: event.RuntimeStateSnapshot{SchemaVersion: 1, ProjectionEpoch: "old-producer", RuntimeEpoch: "old-runtime", Revision: 5,
84 Phase: "executing", Running: true, Todos: []event.Todo{{Content: "old", Status: "in_progress"}}},
85 entered: make(chan struct{}), release: make(chan struct{}),
86 }
87 newState := event.RuntimeStateSnapshot{SchemaVersion: 1, ProjectionEpoch: "new-producer", RuntimeEpoch: "new-runtime", Revision: 1,
88 Phase: "idle", Todos: []event.Todo{{Content: "new", Status: "pending"}}}
89 next := &bindingRuntimeReader{state: newState}
90 tab := &WorkspaceTab{ID: "meta-binding", Scope: "global", WorkspaceRoot: "/tmp/meta-binding", SessionPath: "/old.jsonl", SessionID: "old-session", SessionGeneration: 1, Ctrl: old, Ready: true}
91 // Keep the test focused on the runtime sample. A cache miss schedules an
92 // unrelated metadata refresh which briefly takes App.mu and can make the
93 // lock assertion nondeterministic under the race detector.
94 tab.metaExtras.Store(&tabMetaExtras{controller: old, workspaceRoot: tab.WorkspaceRoot, fetchedAt: time.Now()})
95 a := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, detachedSessions: map[string]*WorkspaceTab{}}
96 done := make(chan Meta, 1)
97 go func() { done <- a.MetaForTab(tab.ID) }()
98 select {
99 case <-old.entered:
100 case <-time.After(5 * time.Second):
101 t.Fatal("MetaForTab did not reach controller snapshot")
102 }
103 if !a.mu.TryLock() {
104 close(old.release)
105 t.Fatal("MetaForTab held App.mu while reading the controller")
106 }
107 tab.Ctrl = next
108 tab.SessionID = "new-session"
109 tab.SessionPath = "/new.jsonl"
110 tab.SessionGeneration = 2
111 a.mu.Unlock()
112 close(old.release)
113 select {
114 case got := <-done:
115 if got.SessionID != "new-session" || got.SessionGeneration != 2 || got.RuntimeStateSnapshot == nil ||
116 !reflect.DeepEqual(*got.RuntimeStateSnapshot, newState) || got.CanonicalTodos == nil || len(*got.CanonicalTodos) != 1 || (*got.CanonicalTodos)[0].Content != "new" {
117 t.Fatalf("MetaForTab paired stale identity and state: %+v", got)
118 }
119 case <-time.After(5 * time.Second):
120 t.Fatal("MetaForTab did not finish after controller replacement")
121 }
122 }
123
124 func TestResolvePromptForSessionRejectsStaleBindingBeforeController(t *testing.T) {
125 reader := &bindingRuntimeReader{}
126 tab := &WorkspaceTab{ID: "prompt", SessionID: "session-a", SessionGeneration: 3, Ctrl: reader}
127 a := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}}
128 target := InteractionTargetView{TabID: tab.ID, HostID: localDesktopHostID, SessionID: tab.SessionID,
129 SessionGeneration: 2, PromptID: "p1", TurnID: "t1", RuntimeEpoch: "r1", Kind: "ask"}
130 if err := a.ResolvePromptForSession(target, PromptAnswerView{}); err == nil {
131 t.Fatal("stale session generation reached prompt resolver")
132 }
133 reader.mu.Lock()
134 if len(reader.resolved) != 0 {
135 t.Fatalf("stale target called controller: %+v", reader.resolved)
136 }
137 reader.mu.Unlock()
138 target.SessionGeneration = 3
139 if err := a.ResolvePromptForSession(target, PromptAnswerView{}); err != nil {
140 t.Fatalf("current target rejected: %v", err)
141 }
142 reader.mu.Lock()
143 defer reader.mu.Unlock()
144 if len(reader.resolved) != 1 || reader.resolved[0].PromptID != "p1" {
145 t.Fatalf("current target calls = %+v", reader.resolved)
146 }
147 }
148
149 func TestResolvePromptForSessionAcceptsInitialGeneration(t *testing.T) {
150 reader := &bindingRuntimeReader{}
151 // New and restored tabs start at generation zero until a rotation occurs.
152 tab := &WorkspaceTab{ID: "initial", SessionID: "session-a", Ctrl: reader}
153 a := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}}
154 target := InteractionTargetView{TabID: tab.ID, HostID: localDesktopHostID,
155 SessionID: tab.SessionID, SessionGeneration: tab.SessionGeneration,
156 PromptID: "p1", TurnID: "t1", RuntimeEpoch: "r1", Kind: "approval"}
157 if err := a.ResolvePromptForSession(target, PromptAnswerView{Allow: true}); err != nil {
158 t.Fatalf("initial generation rejected: %v", err)
159 }
160 // A delayed answer from generation zero must not authorize the rotated tab.
161 tab.SessionGeneration++
162 if err := a.ResolvePromptForSession(target, PromptAnswerView{Allow: true}); err == nil {
163 t.Fatal("initial generation authorized a rotated session")
164 }
165 reader.mu.Lock()
166 defer reader.mu.Unlock()
167 if len(reader.resolved) != 1 || reader.resolved[0].PromptID != target.PromptID {
168 t.Fatalf("initial prompt calls = %+v", reader.resolved)
169 }
170 }
171
172 func TestLocalBindingUsesControllerIdentityNotMutableContents(t *testing.T) {
173 first, second := &bindingRuntimeReader{}, &bindingRuntimeReader{}
174 tab := &WorkspaceTab{ID: "identity"}
175 sampled := localRuntimeBinding{tab: tab, ctrl: first}
176 current := sampled
177 current.ctrl = second
178 if sameLocalRuntimeBinding(current, sampled) {
179 t.Fatal("different controller instances were treated as the same binding")
180 }
181 first.mu.Lock()
182 defer first.mu.Unlock()
183 if !sameLocalRuntimeBinding(sampled, sampled) {
184 t.Fatal("controller's mutable lock state changed its binding identity")
185 }
186 }
187
188 func TestRuntimeStateProjectionRevalidatesLocalBindingAfterSampling(t *testing.T) {
189 for _, mutation := range []string{"controller", "generation", "path", "scope", "tab", "detach", "close"} {
190 t.Run(mutation, func(t *testing.T) {
191 old := &bindingRuntimeReader{state: event.RuntimeStateSnapshot{SchemaVersion: 1, RuntimeEpoch: "old", Revision: 1, Phase: "executing", Running: true},
192 entered: make(chan struct{}), release: make(chan struct{})}
193 nextState := event.RuntimeStateSnapshot{SchemaVersion: 1, RuntimeEpoch: "new", Revision: 2, Phase: "idle"}
194 next := &bindingRuntimeReader{state: nextState}
195 tab := &WorkspaceTab{ID: "binding", Scope: "global", SessionPath: "/old.jsonl", SessionGeneration: 1, Ctrl: old}
196 a := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, detachedSessions: map[string]*WorkspaceTab{}}
197 done := make(chan RuntimeStateProjection, 1)
198 go func() { done <- a.GetRuntimeStateSnapshot() }()
199 select {
200 case <-old.entered:
201 case <-time.After(5 * time.Second):
202 t.Fatal("projection did not reach controller read")
203 }
204 // Acquiring App.mu here is also the deterministic proof that the
205 // runtime reader never runs while holding the application lock.
206 a.mu.Lock()
207 switch mutation {
208 case "controller":
209 tab.Ctrl = next
210 case "generation":
211 tab.SessionGeneration++
212 case "path":
213 tab.SessionPath = "/new.jsonl"
214 case "scope":
215 tab.Scope, tab.WorkspaceRoot = "project", "/workspace"
216 case "tab":
217 tab = &WorkspaceTab{ID: tab.ID, Scope: "global", SessionPath: "/new.jsonl", SessionGeneration: 2, Ctrl: next}
218 a.tabs[tab.ID] = tab
219 case "detach":
220 delete(a.tabs, tab.ID)
221 a.detachedSessions[tab.SessionPath] = tab
222 case "close":
223 delete(a.tabs, tab.ID)
224 }
225 old.mu.Lock()
226 if mutation != "controller" && mutation != "tab" {
227 old.state = nextState
228 }
229 old.mu.Unlock()
230 wantPath, wantGeneration := tab.SessionPath, tab.SessionGeneration
231 wantScope, wantRoot := tab.Scope, tab.WorkspaceRoot
232 a.mu.Unlock()
233 close(old.release)
234 var got RuntimeStateProjection
235 select {
236 case got = <-done:
237 case <-time.After(5 * time.Second):
238 t.Fatal("projection did not finish after binding replacement")
239 }
240 if mutation == "close" {
241 if len(got.Sessions) != 0 {
242 t.Fatalf("closed binding leaked into projection: %+v", got.Sessions)
243 }
244 return
245 }
246 if len(got.Sessions) != 1 {
247 t.Fatalf("expected one current binding: %+v", got.Sessions)
248 }
249 view := got.Sessions[0]
250 if view.SessionPath != wantPath || view.SessionGeneration != wantGeneration || view.Scope != wantScope || view.WorkspaceRoot != wantRoot || !reflect.DeepEqual(view.State, nextState) || view.Open != (mutation != "detach") {
251 t.Fatalf("projection paired state with stale binding: %+v", view)
252 }
253 if fresh := a.GetRuntimeStateSnapshot(); fresh.Revision != got.Revision {
254 t.Fatalf("binding repair required an unrelated subsequent read: first=%+v fresh=%+v", got, fresh)
255 }
256 })
257 }
258 }
259
259 lines GO