返回 DeepSeek-Reasonix
session_reclaim_test.go
根目录 / desktop / session_reclaim_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "sync"
9 "testing"
10 "time"
11 )
12
13 func TestOwnershipProbeFailurePreservesSpectatorPin(t *testing.T) {
14 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
15 http.Error(w, "temporary failure", http.StatusServiceUnavailable)
16 }))
17 defer srv.Close()
18 app := NewApp()
19 app.remoteTabs = map[string]*remoteTab{}
20 tab := &remoteTab{
21 id: "remote-1", gen: 4, client: srv.Client(), selectionRevision: 9,
22 routing: remoteTabSessionRouting{currentPath: "/sessions/a.jsonl"},
23 session: remoteTabSessionState{takenOver: true},
24 }
25 app.remoteTabs[tab.id] = tab
26 app.markRemoteTabSpectatorIfLocalOwned(context.Background(), tab.id, tab.client, srv.URL, tab.gen)
27 if !tab.session.takenOver {
28 t.Fatal("failed ownership probe cleared the spectator pin")
29 }
30 }
31
32 func TestLateOwnershipProbeCannotChangeNewSelection(t *testing.T) {
33 started := make(chan struct{})
34 release := make(chan struct{})
35 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
36 close(started)
37 <-release
38 _ = json.NewEncoder(w).Encode(SessionTakeoverView{Holder: "external", Mirrored: true})
39 }))
40 defer srv.Close()
41 app := NewApp()
42 app.remoteTabs = map[string]*remoteTab{}
43 tab := &remoteTab{
44 id: "remote-1", gen: 4, client: srv.Client(), selectionRevision: 9,
45 routing: remoteTabSessionRouting{currentPath: "/sessions/old.jsonl"},
46 }
47 app.remoteTabs[tab.id] = tab
48 done := make(chan struct{})
49 go func() {
50 app.markRemoteTabSpectatorIfLocalOwned(context.Background(), tab.id, tab.client, srv.URL, tab.gen)
51 close(done)
52 }()
53 <-started
54 app.remoteTabMu.Lock()
55 tab.selectionRevision++
56 tab.routing.currentPath = "/sessions/new.jsonl"
57 app.remoteTabMu.Unlock()
58 close(release)
59 <-done
60 if tab.session.takenOver {
61 t.Fatal("late ownership probe marked the newer selection read-only")
62 }
63 }
64
65 func TestLateReclaimSuccessCannotUnlockNewSelection(t *testing.T) {
66 started := make(chan struct{})
67 release := make(chan struct{})
68 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69 if r.URL.Path == "/reclaim" {
70 close(started)
71 <-release
72 w.WriteHeader(http.StatusNoContent)
73 return
74 }
75 http.Error(w, "not available", http.StatusServiceUnavailable)
76 }))
77 defer srv.Close()
78 app := NewApp()
79 app.remoteTabs = map[string]*remoteTab{}
80 tab := &remoteTab{
81 id: "remote-1", state: "ready", gen: 4, client: srv.Client(), base: srv.URL, selectionRevision: 9,
82 routing: remoteTabSessionRouting{currentPath: "/sessions/old.jsonl"},
83 session: remoteTabSessionState{takenOver: true},
84 capabilities: map[string]bool{serveCapabilityExecutionV2: true, serveCapabilitySessions: true, serveCapabilitySessionIdentityV1: true, serveCapabilitySessionOwnershipV1: true},
85 }
86 app.remoteTabs[tab.id] = tab
87 done := make(chan error, 1)
88 go func() { done <- app.ReclaimRemoteTabSession(tab.id) }()
89 <-started
90 app.remoteTabMu.Lock()
91 tab.selectionRevision++
92 tab.routing.currentPath = "/sessions/new.jsonl"
93 tab.session.takenOver = true
94 app.remoteTabMu.Unlock()
95 close(release)
96 if err := <-done; err != nil {
97 t.Fatal(err)
98 }
99 if !tab.session.takenOver {
100 t.Fatal("late reclaim response unlocked the newer selection")
101 }
102 }
103
104 func TestFailedReclaimKeepsSpectatorUntilOwnershipProbeCompletes(t *testing.T) {
105 probeStarted := make(chan struct{})
106 probeRelease := make(chan struct{})
107 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
108 switch r.URL.Path {
109 case "/reclaim":
110 http.Error(w, "mirror generation changed", http.StatusConflict)
111 case "/ownership":
112 close(probeStarted)
113 <-probeRelease
114 _ = json.NewEncoder(w).Encode(SessionTakeoverView{Holder: "external", Mirrored: true})
115 default:
116 w.WriteHeader(http.StatusNotFound)
117 }
118 }))
119 defer srv.Close()
120 app := NewApp()
121 app.remoteTabs = map[string]*remoteTab{}
122 tab := &remoteTab{
123 id: "remote-1", state: "ready", gen: 4, client: srv.Client(), base: srv.URL, selectionRevision: 9,
124 routing: remoteTabSessionRouting{currentPath: "/sessions/a.jsonl"},
125 session: remoteTabSessionState{takenOver: true},
126 capabilities: map[string]bool{serveCapabilityExecutionV2: true, serveCapabilitySessions: true, serveCapabilitySessionIdentityV1: true, serveCapabilitySessionOwnershipV1: true},
127 }
128 app.remoteTabs[tab.id] = tab
129 if err := app.ReclaimRemoteTabSession(tab.id); err == nil {
130 t.Fatal("failed reclaim unexpectedly succeeded")
131 }
132 <-probeStarted
133 if !tab.session.takenOver {
134 t.Fatal("ambiguous reclaim failure unlocked input before ownership proof")
135 }
136 close(probeRelease)
137 app.remoteTabTasks.Wait()
138 if !tab.session.takenOver {
139 t.Fatal("external owner probe cleared spectator state")
140 }
141 }
142
143 func TestSuccessfulReclaimRepublishesReadyBarrier(t *testing.T) {
144 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
145 if r.URL.Path == "/reclaim" {
146 w.WriteHeader(http.StatusNoContent)
147 return
148 }
149 http.Error(w, "not available", http.StatusServiceUnavailable)
150 }))
151 defer srv.Close()
152 app := NewApp()
153 app.remoteTabs = map[string]*remoteTab{}
154 tab := &remoteTab{
155 id: "remote-1", state: "ready", gen: 4, client: srv.Client(), base: srv.URL, selectionRevision: 9,
156 routing: remoteTabSessionRouting{currentPath: "/sessions/held.jsonl"},
157 session: remoteTabSessionState{takenOver: true},
158 capabilities: map[string]bool{serveCapabilityExecutionV2: true, serveCapabilitySessions: true, serveCapabilitySessionIdentityV1: true, serveCapabilitySessionOwnershipV1: true},
159 }
160 app.remoteTabs[tab.id] = tab
161
162 var mu sync.Mutex
163 var events []string
164 app.remoteEventHook = func(name string, _ any) {
165 mu.Lock()
166 events = append(events, name)
167 mu.Unlock()
168 }
169 if err := app.ReclaimRemoteTabSession(tab.id); err != nil {
170 t.Fatal(err)
171 }
172 if tab.session.takenOver {
173 t.Fatal("successful reclaim left the spectator pin in place")
174 }
175 mu.Lock()
176 defer mu.Unlock()
177 sawReady := false
178 for _, name := range events {
179 if name == "remote-tab:remote-1:state" {
180 sawReady = true
181 }
182 }
183 if !sawReady {
184 t.Fatalf("reclaim did not republish the ready barrier: %v", events)
185 }
186 }
187
188 func TestReclaimBarrierDefersWhileTurnInFlight(t *testing.T) {
189 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
190 switch r.URL.Path {
191 case "/reclaim":
192 w.WriteHeader(http.StatusNoContent)
193 return
194 case "/status":
195 // The post-reclaim refresh observes the surface idle and free.
196 w.Header().Set("Content-Type", "application/json")
197 _, _ = w.Write([]byte(`{"sessionPath":"/sessions/held.jsonl","sessionId":"held","running":false,"takenOver":false}`))
198 return
199 }
200 http.Error(w, "not available", http.StatusServiceUnavailable)
201 }))
202 defer srv.Close()
203 app := NewApp()
204 app.remoteTabs = map[string]*remoteTab{}
205 tab := &remoteTab{
206 id: "remote-1", state: "ready", gen: 4, client: srv.Client(), base: srv.URL, selectionRevision: 9,
207 routing: remoteTabSessionRouting{currentPath: "/sessions/held.jsonl"},
208 session: remoteTabSessionState{takenOver: true},
209 runtime: remoteTabRuntimeState{running: true},
210 capabilities: map[string]bool{serveCapabilityExecutionV2: true, serveCapabilitySessions: true, serveCapabilitySessionIdentityV1: true, serveCapabilitySessionOwnershipV1: true},
211 }
212 app.remoteTabs[tab.id] = tab
213
214 var mu sync.Mutex
215 var events []string
216 app.remoteEventHook = func(name string, _ any) {
217 mu.Lock()
218 events = append(events, name)
219 mu.Unlock()
220 }
221 if err := app.ReclaimRemoteTabSession(tab.id); err != nil {
222 t.Fatal(err)
223 }
224 mu.Lock()
225 for _, name := range events {
226 if name == "remote-tab:remote-1:state" {
227 mu.Unlock()
228 t.Fatal("reclaim published the ready barrier while a turn was in flight")
229 }
230 }
231 mu.Unlock()
232 if !tab.ownership.readyBarrierPending {
233 t.Fatal("reclaim did not defer the barrier for the running turn")
234 }
235
236 // The deferred barrier fires once polling observes the surface idle. The
237 // reclaim's own asynchronous status refresh drives that poll against the
238 // serve, so the test only waits for the ready publication.
239 deadline := time.Now().Add(2 * time.Second)
240 for {
241 mu.Lock()
242 sawReady := false
243 for _, name := range events {
244 if name == "remote-tab:remote-1:state" {
245 sawReady = true
246 }
247 }
248 mu.Unlock()
249 pending := func() bool {
250 app.remoteTabMu.Lock()
251 defer app.remoteTabMu.Unlock()
252 return tab.ownership.readyBarrierPending
253 }()
254 if sawReady && !pending {
255 break
256 }
257 if time.Now().After(deadline) {
258 t.Fatalf("deferred barrier did not fire on idle: ready=%v pending=%v", sawReady, pending)
259 }
260 time.Sleep(5 * time.Millisecond)
261 }
262 }
263
264 // An in-flight /status response reserved before an explicit reclaim can land
265 // after ownership returned, still mirroring the pre-reclaim takenOver=true.
266 // The reclaim epoch must keep such a payload from re-pinning the banner.
267 func TestStaleStatusCannotRepinTakenOverAfterReclaim(t *testing.T) {
268 a := &App{remoteTabs: map[string]*remoteTab{}}
269 client := &http.Client{}
270 tab := &remoteTab{
271 id: "remote-1", state: "ready", gen: 2, client: client,
272 routing: remoteTabSessionRouting{currentPath: "session-id:held"},
273 session: remoteTabSessionState{takenOver: false},
274 // The reclaim stamped epoch 5; the in-flight poll reserved revision 3.
275 runtime: remoteTabRuntimeState{revision: 3},
276 }
277 tab.ownership.reclaimRevision = 5
278 a.remoteTabs[tab.id] = tab
279 payload := []byte(`{"sessionId":"held","takenOver":true}`)
280
281 if !a.recordRemoteTabSessionStatus(tab.id, client, 2, 3, payload) {
282 t.Fatal("fenced recorder rejected the payload entirely")
283 }
284 if tab.session.takenOver {
285 t.Fatal("pre-reclaim status payload re-pinned the spectator banner")
286 }
287
288 // A post-reclaim observation (reserved after the epoch) still applies in
289 // both directions — including a genuine re-takeover by the local runtime.
290 tab.runtime.revision = 5
291 if !a.recordRemoteTabSessionStatus(tab.id, client, 2, 5, payload) {
292 t.Fatal("post-reclaim status payload was rejected")
293 }
294 if !tab.session.takenOver {
295 t.Fatal("post-reclaim ownership observation did not apply")
296 }
297 tab.runtime.revision = 6
298 tab.ownership.reclaimRevision = 6
299 released := []byte(`{"sessionId":"held","takenOver":false}`)
300 if !a.recordRemoteTabSessionStatus(tab.id, client, 2, 6, released) {
301 t.Fatal("fresh release observation was rejected")
302 }
303 if tab.session.takenOver {
304 t.Fatal("release observation did not clear the pin")
305 }
306 }
307
307 lines GO