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