返回 DeepSeek-Reasonix
remote_tab_pending_selection_test.go
根目录 / desktop / remote_tab_pending_selection_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "io"
6 "net/http"
7 "strings"
8 "testing"
9 "time"
10 )
11
12 func TestRemoteTabReconnectDefersCachedSessionSelectionUntilReady(t *testing.T) {
13 const oldPath = "/sessions/old.jsonl"
14 const targetPath = "/sessions/target.jsonl"
15 fs := newFakeServe(t, "s3cret", []serveSessionEntry{
16 {Name: "old", Path: oldPath, Title: "Old", Current: true},
17 {Name: "target", Path: targetPath, Title: "Target"},
18 })
19 kernel := &fakeRemoteKernel{
20 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
21 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
22 }
23 seedBridgeTestHost(t, "box")
24 a := &App{remoteRuntime: kernel}
25 cleanupRemoteTabPumps(t, a)
26 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "old", SessionPath: oldPath})
27
28 started := make(chan string, 2)
29 fs.mu.Lock()
30 fs.resumeStarted = started
31 fs.mu.Unlock()
32 a.remoteTabsHostStatus("box", "reconnecting", "")
33 waitForTabState(t, a, meta.ID, "reconnecting")
34 if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{
35 SessionName: "target", SessionPath: targetPath, SessionTitle: "Target",
36 }); err != nil {
37 t.Fatal(err)
38 }
39
40 a.remoteTabMu.Lock()
41 tab := a.remoteTabs[meta.ID]
42 state, sessionPath, route := tab.state, tab.session.path, tab.routing.currentPath
43 pending := tab.pendingSelection
44 a.remoteTabMu.Unlock()
45 if state != "reconnecting" || sessionPath != oldPath || route != oldPath || pending == nil || pending.path != targetPath {
46 t.Fatalf("deferred state/session/route/pending = %q/%q/%q/%+v", state, sessionPath, route, pending)
47 }
48 select {
49 case path := <-started:
50 t.Fatalf("selection resumed %q before the tab was ready", path)
51 default:
52 }
53
54 a.remoteTabsHostStatus("box", "connected", "")
55 select {
56 case path := <-started:
57 if path != targetPath {
58 t.Fatalf("ready resume path = %q, want %q", path, targetPath)
59 }
60 case <-time.After(3 * time.Second):
61 t.Fatal("deferred selection was not resumed after reconnect")
62 }
63 deadline := time.Now().Add(3 * time.Second)
64 for {
65 a.remoteTabMu.Lock()
66 state, sessionPath, route = tab.state, tab.session.path, tab.routing.currentPath
67 pending = tab.pendingSelection
68 a.remoteTabMu.Unlock()
69 if state == "ready" && sessionPath == targetPath && route == targetPath && pending == nil {
70 break
71 }
72 if time.Now().After(deadline) {
73 t.Fatalf("final state/session/route/pending = %q/%q/%q/%+v", state, sessionPath, route, pending)
74 }
75 time.Sleep(time.Millisecond)
76 }
77 select {
78 case path := <-started:
79 t.Fatalf("deferred selection resumed more than once; extra path %q", path)
80 case <-time.After(100 * time.Millisecond):
81 }
82 }
83
84 func TestRemoteTabDeferredResumeRejectsSupersededSelectionRevision(t *testing.T) {
85 requests := 0
86 client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
87 requests++
88 return &http.Response{
89 StatusCode: http.StatusNoContent, Header: make(http.Header),
90 Body: io.NopCloser(strings.NewReader("")), Request: req,
91 }, nil
92 })}
93 tab := &remoteTab{
94 id: "remote-1", state: "ready", client: client, base: "http://127.0.0.1:43210", gen: 7,
95 selectionRevision: 2,
96 routing: remoteTabSessionRouting{currentPath: "/sessions/newer.jsonl", running: map[string]bool{}},
97 }
98 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
99 a.resumeRemoteTabSessionPathForSelection(tab.id, "stale", "/sessions/stale.jsonl", "Stale", 1)
100 if requests != 0 {
101 t.Fatalf("superseded deferred selection sent %d Serve requests", requests)
102 }
103 }
104
105 func TestReadyTabRapidSelectionsRollbackToServeAuthoritativeSnapshot(t *testing.T) {
106 const oldPath = "/sessions/old.jsonl"
107 const firstPath = "/sessions/first.jsonl"
108 const secondPath = "/sessions/second.jsonl"
109 requests := 0
110 client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
111 requests++
112 return &http.Response{
113 StatusCode: http.StatusConflict, Header: make(http.Header),
114 Body: io.NopCloser(strings.NewReader("busy")), Request: req,
115 }, nil
116 })}
117 ref := RemoteTabRef{HostID: "box", Workspace: "~/app"}
118 tab := &remoteTab{
119 id: "remote-1", ref: ref, state: "ready", client: client, base: "http://127.0.0.1:43210", gen: 7,
120 session: remoteTabSessionState{name: "old", path: oldPath}, topicTitle: "Old",
121 routing: remoteTabSessionRouting{currentPath: oldPath, running: map[string]bool{}},
122 }
123 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
124
125 firstOpts := RemoteTabOpenOptions{SessionName: "first", SessionPath: firstPath, SessionTitle: "First"}
126 first := a.registerRemoteTabOpen(&remoteTab{id: "unused-first", ref: ref}, "Box", firstOpts)
127 if !a.commitRemoteTabOpenRegistration(&first, "Box", firstOpts) || tab.pendingSelection == nil {
128 t.Fatal("first ready selection was not retained until its resume started")
129 }
130 secondOpts := RemoteTabOpenOptions{SessionName: "second", SessionPath: secondPath, SessionTitle: "Second"}
131 second := a.registerRemoteTabOpen(&remoteTab{id: "unused-second", ref: ref}, "Box", secondOpts)
132 if !a.commitRemoteTabOpenRegistration(&second, "Box", secondOpts) {
133 t.Fatal("second ready selection was not committed")
134 }
135 if second.previousSelection == nil || second.previousSelection.currentPath != oldPath {
136 t.Fatalf("second rollback snapshot = %+v, want Serve-authoritative %q", second.previousSelection, oldPath)
137 }
138
139 if handled := a.resumeRemoteTabSessionPathForOpenSelection(tab.id, "first", firstPath, "First", first.selection.revision, first.previousSelection); !handled {
140 t.Fatal("superseded first selection requested rollback")
141 }
142 if requests != 0 {
143 t.Fatalf("superseded first selection sent %d Serve requests", requests)
144 }
145 if handled := a.resumeRemoteTabSessionPathForOpenSelection(tab.id, "second", secondPath, "Second", second.selection.revision, second.previousSelection); handled {
146 t.Fatal("rejected second selection was treated as committed")
147 }
148 if requests != 1 || tab.routing.currentPath != oldPath || tab.session.path != oldPath || tab.topicTitle != "Old" {
149 t.Fatalf("rejected rapid selection left requests/route/session/title = %d/%q/%q/%q", requests, tab.routing.currentPath, tab.session.path, tab.topicTitle)
150 }
151 }
152
153 func TestReadyTabInFlightSelectionsSerializeThroughDefinitiveRollback(t *testing.T) {
154 isolateDesktopUserDirs(t)
155 const oldPath = "/sessions/old.jsonl"
156 const firstPath = "/sessions/first.jsonl"
157 const secondPath = "/sessions/second.jsonl"
158 firstStarted := make(chan struct{}, 1)
159 releaseFirst := make(chan struct{})
160 secondStarted := make(chan struct{}, 1)
161 client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
162 var payload struct {
163 Path string `json:"path"`
164 }
165 _ = json.NewDecoder(req.Body).Decode(&payload)
166 switch payload.Path {
167 case firstPath:
168 firstStarted <- struct{}{}
169 <-releaseFirst
170 case secondPath:
171 secondStarted <- struct{}{}
172 default:
173 t.Fatalf("unexpected resume path %q", payload.Path)
174 }
175 return &http.Response{
176 StatusCode: http.StatusConflict, Header: make(http.Header),
177 Body: io.NopCloser(strings.NewReader("busy")), Request: req,
178 }, nil
179 })}
180 ref := RemoteTabRef{HostID: "box", Workspace: "~/app"}
181 tab := &remoteTab{
182 id: "remote-1", ref: ref, state: "ready", client: client, base: "http://127.0.0.1:43210", gen: 7,
183 session: remoteTabSessionState{name: "old", path: oldPath}, topicTitle: "Old",
184 routing: remoteTabSessionRouting{currentPath: oldPath, running: map[string]bool{}},
185 }
186 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
187
188 firstOpts := RemoteTabOpenOptions{SessionName: "first", SessionPath: firstPath, SessionTitle: "First"}
189 first := a.registerRemoteTabOpen(&remoteTab{id: "unused-first", ref: ref}, "Box", firstOpts)
190 if !a.commitRemoteTabOpenRegistration(&first, "Box", firstOpts) {
191 t.Fatal("first ready selection was not committed")
192 }
193 a.resumeRemoteTabOpenAsync(tab.id, "first", firstPath, "First", first.previousSelection)
194 select {
195 case <-firstStarted:
196 case <-time.After(2 * time.Second):
197 t.Fatal("first resume did not start")
198 }
199
200 secondOpts := RemoteTabOpenOptions{SessionName: "second", SessionPath: secondPath, SessionTitle: "Second"}
201 second := a.registerRemoteTabOpen(&remoteTab{id: "unused-second", ref: ref}, "Box", secondOpts)
202 if !a.commitRemoteTabOpenRegistration(&second, "Box", secondOpts) {
203 t.Fatal("second selection was not queued")
204 }
205 a.remoteTabMu.Lock()
206 queued, sessionPath := tab.pendingSelection, tab.session.path
207 a.remoteTabMu.Unlock()
208 if queued != second.selection || !queued.deferred || sessionPath != firstPath {
209 t.Fatalf("in-flight selection queue/session = %+v/%q, want deferred second/%q", queued, sessionPath, firstPath)
210 }
211 select {
212 case <-secondStarted:
213 t.Fatal("second resume started before the first resolved")
214 default:
215 }
216 close(releaseFirst)
217 select {
218 case <-secondStarted:
219 case <-time.After(2 * time.Second):
220 t.Fatal("queued second resume did not start after first rollback")
221 }
222 if second.selection.previous == nil || second.selection.previous.currentPath != oldPath {
223 t.Fatalf("second rollback snapshot = %+v, want authoritative %q", second.selection.previous, oldPath)
224 }
225
226 deadline := time.Now().Add(2 * time.Second)
227 for {
228 a.remoteTabMu.Lock()
229 path, sessionPath, title, pending := tab.routing.currentPath, tab.session.path, tab.topicTitle, tab.pendingSelection
230 a.remoteTabMu.Unlock()
231 if path == oldPath && sessionPath == oldPath && title == "Old" && pending == nil {
232 break
233 }
234 if time.Now().After(deadline) {
235 t.Fatalf("rejected in-flight selections left route/session/title/pending = %q/%q/%q/%+v", path, sessionPath, title, pending)
236 }
237 time.Sleep(time.Millisecond)
238 }
239 a.remoteTabTasks.Wait()
240 }
241
242 func TestRemoteTabResumeRequeuesSelectionWhenReadinessDrops(t *testing.T) {
243 const oldPath = "/sessions/old.jsonl"
244 const targetPath = "/sessions/target.jsonl"
245 started := make(chan string, 1)
246 client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
247 if req.Method == http.MethodPost && req.URL.Path == "/resume" {
248 started <- targetPath
249 return &http.Response{StatusCode: http.StatusNoContent, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("")), Request: req}, nil
250 }
251 return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"running":false}`)), Request: req}, nil
252 })}
253 previous := &remoteTabOpenSelection{
254 session: remoteTabSessionState{name: "old", path: oldPath}, topicTitle: "Old",
255 currentPath: oldPath, revision: 1,
256 }
257 tab := &remoteTab{
258 id: "remote-1", state: "reconnecting", gen: 7, selectionRevision: 1,
259 topicTitle: "Target", session: remoteTabSessionState{name: "target", path: targetPath},
260 routing: remoteTabSessionRouting{currentPath: targetPath, running: map[string]bool{}},
261 }
262 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
263 if deferred := a.resumeRemoteTabSessionPathForOpenSelection(tab.id, "target", targetPath, "Target", 1, previous); !deferred {
264 t.Fatal("readiness loss did not preserve the committed selection")
265 }
266 if tab.pendingSelection == nil || !tab.pendingSelection.identityCommitted || tab.pendingSelection.previous != previous {
267 t.Fatalf("queued selection = %+v, want committed selection with original rollback", tab.pendingSelection)
268 }
269 select {
270 case <-started:
271 t.Fatal("resume ran while the tab was reconnecting")
272 default:
273 }
274
275 a.remoteTabMu.Lock()
276 tab.state, tab.client, tab.base = "ready", client, "http://127.0.0.1:43210"
277 a.remoteTabMu.Unlock()
278 a.applyPendingRemoteTabOpenSelection(tab.id)
279 select {
280 case path := <-started:
281 if path != targetPath {
282 t.Fatalf("resumed path = %q, want %q", path, targetPath)
283 }
284 case <-time.After(2 * time.Second):
285 t.Fatal("queued selection was not resumed after readiness returned")
286 }
287 }
288
289 func TestRemoteTabReadinessLossPreservesNewerQueuedSelection(t *testing.T) {
290 const oldPath = "/sessions/old.jsonl"
291 const firstPath = "/sessions/first.jsonl"
292 const secondPath = "/sessions/second.jsonl"
293 ref := RemoteTabRef{HostID: "box", Workspace: "~/app"}
294 previous := &remoteTabOpenSelection{
295 session: remoteTabSessionState{name: "old", path: oldPath}, topicTitle: "Old",
296 currentPath: oldPath, revision: 1,
297 }
298 firstPending := &remoteTabPendingOpenSelection{
299 name: "first", path: firstPath, title: "First", revision: 1,
300 identityCommitted: true, previous: previous,
301 }
302 tab := &remoteTab{
303 id: "remote-1", ref: ref, state: "ready", selectionRevision: 1,
304 session: remoteTabSessionState{name: "first", path: firstPath}, topicTitle: "First",
305 routing: remoteTabSessionRouting{currentPath: firstPath, running: map[string]bool{}},
306 pendingSelection: firstPending,
307 }
308 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
309
310 tab.sessionMu.Lock()
311 a.resumeRemoteTabOpenAsync(tab.id, "first", firstPath, "First", previous)
312 deadline := time.Now().Add(2 * time.Second)
313 for tab.selectionMu.TryLock() {
314 tab.selectionMu.Unlock()
315 if time.Now().After(deadline) {
316 t.Fatal("first selection did not acquire the lifecycle lock")
317 }
318 time.Sleep(time.Millisecond)
319 }
320 secondOpts := RemoteTabOpenOptions{SessionName: "second", SessionPath: secondPath, SessionTitle: "Second"}
321 second := a.registerRemoteTabOpen(&remoteTab{id: "unused-second", ref: ref}, "Box", secondOpts)
322 if !a.commitRemoteTabOpenRegistration(&second, "Box", secondOpts) {
323 t.Fatal("newer selection was not queued")
324 }
325 a.remoteTabMu.Lock()
326 tab.state = "reconnecting"
327 a.remoteTabMu.Unlock()
328 tab.sessionMu.Unlock()
329 a.remoteTabTasks.Wait()
330
331 a.remoteTabMu.Lock()
332 pending := tab.pendingSelection
333 a.remoteTabMu.Unlock()
334 if pending != second.selection || !pending.deferred || pending.path != secondPath || pending.revision != 0 {
335 t.Fatalf("readiness loss replaced newer selection: %+v", pending)
336 }
337 }
338
339 func TestRemoteTabNewSessionSupersedesDeferredCachedSelection(t *testing.T) {
340 const oldPath = "/sessions/old.jsonl"
341 const targetPath = "/sessions/target.jsonl"
342 const freshPath = "/sessions/fresh.jsonl"
343 fs := newFakeServe(t, "s3cret", []serveSessionEntry{
344 {Name: "old", Path: oldPath, Title: "Old", Current: true},
345 {Name: "target", Path: targetPath, Title: "Target"},
346 })
347 kernel := &fakeRemoteKernel{
348 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
349 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
350 }
351 seedBridgeTestHost(t, "box")
352 a := &App{remoteRuntime: kernel}
353 cleanupRemoteTabPumps(t, a)
354 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "old", SessionPath: oldPath})
355
356 resumeStarted := make(chan string, 1)
357 newStarted := make(chan struct{}, 1)
358 fs.mu.Lock()
359 fs.resumeStarted, fs.newStarted, fs.newSessionPath = resumeStarted, newStarted, freshPath
360 fs.mu.Unlock()
361 a.remoteTabsHostStatus("box", "reconnecting", "")
362 waitForTabState(t, a, meta.ID, "reconnecting")
363 if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{SessionName: "target", SessionPath: targetPath}); err != nil {
364 t.Fatal(err)
365 }
366 if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true}); err != nil {
367 t.Fatal(err)
368 }
369 a.remoteTabMu.Lock()
370 tab := a.remoteTabs[meta.ID]
371 pending := tab.pendingSelection
372 a.remoteTabMu.Unlock()
373 if pending == nil || !pending.newSession || pending.path != "" {
374 t.Fatalf("pending selection = %+v, want newest New Session intent", pending)
375 }
376
377 a.remoteTabsHostStatus("box", "connected", "")
378 select {
379 case <-newStarted:
380 case <-time.After(3 * time.Second):
381 t.Fatal("deferred New Session did not rotate after reconnect")
382 }
383 select {
384 case path := <-resumeStarted:
385 t.Fatalf("superseded cached session resumed %q", path)
386 default:
387 }
388 deadline := time.Now().Add(3 * time.Second)
389 for {
390 a.remoteTabMu.Lock()
391 state, path, reset, currentPending := tab.state, tab.routing.currentPath, tab.session.reset, tab.pendingSelection
392 a.remoteTabMu.Unlock()
393 if state == "ready" && path == freshPath && reset && currentPending == nil {
394 break
395 }
396 if time.Now().After(deadline) {
397 t.Fatalf("final state/path/reset/pending = %q/%q/%v/%+v", state, path, reset, currentPending)
398 }
399 time.Sleep(time.Millisecond)
400 }
401 }
402
403 func TestRemoteTabNewSessionRestoresRouteFromCommittedDeferredSelection(t *testing.T) {
404 const oldPath = "/sessions/old.jsonl"
405 const targetPath = "/sessions/target.jsonl"
406 const freshPath = "/sessions/fresh.jsonl"
407 ref := RemoteTabRef{HostID: "box", Workspace: "~/app"}
408 previous := &remoteTabOpenSelection{
409 session: remoteTabSessionState{name: "old", path: oldPath}, topicTitle: "Old",
410 currentPath: oldPath, revision: 1,
411 }
412 tab := &remoteTab{
413 id: "remote-1", ref: ref, state: "reconnecting", selectionRevision: 1,
414 session: remoteTabSessionState{name: "target", path: targetPath}, topicTitle: "Target",
415 routing: remoteTabSessionRouting{currentPath: targetPath, running: map[string]bool{}},
416 pendingSelection: &remoteTabPendingOpenSelection{
417 name: "target", path: targetPath, title: "Target", revision: 1,
418 deferred: true, identityCommitted: true, previous: previous,
419 },
420 }
421 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
422 opts := RemoteTabOpenOptions{NewSession: true}
423 registration := a.registerRemoteTabOpen(&remoteTab{id: "unused", ref: ref}, "Box", opts)
424 if !a.commitRemoteTabOpenRegistration(&registration, "Box", opts) {
425 t.Fatal("New Session did not reuse the reconnecting tab")
426 }
427 if tab.session.path != oldPath || tab.routing.currentPath != oldPath || tab.pendingSelection == nil || !tab.pendingSelection.newSession {
428 t.Fatalf("supersession left session/route/pending = %q/%q/%+v", tab.session.path, tab.routing.currentPath, tab.pendingSelection)
429 }
430
431 expectedPath := make(chan string, 1)
432 client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
433 expectedPath <- req.Header.Get(expectedSessionPathHeader)
434 header := make(http.Header)
435 header.Set("X-Reasonix-Session-Path", freshPath)
436 return &http.Response{StatusCode: http.StatusNoContent, Header: header, Body: io.NopCloser(strings.NewReader("")), Request: req}, nil
437 })}
438 a.remoteTabMu.Lock()
439 tab.state, tab.client, tab.base = "ready", client, "http://127.0.0.1:43210"
440 a.remoteTabMu.Unlock()
441 a.applyPendingRemoteTabOpenSelection(tab.id)
442 select {
443 case got := <-expectedPath:
444 if got != oldPath {
445 t.Fatalf("/new expected-session path = %q, want authoritative %q", got, oldPath)
446 }
447 case <-time.After(2 * time.Second):
448 t.Fatal("deferred New Session did not reach Serve")
449 }
450 }
451
452 func TestRemoteTabDeferredNewSessionRechecksCurrentBlankState(t *testing.T) {
453 const oldPath = "/sessions/old.jsonl"
454 const freshPath = "/sessions/fresh.jsonl"
455 requested := make(chan struct{}, 1)
456 client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
457 if req.Method != http.MethodPost || req.URL.Path != "/new" {
458 t.Fatalf("request = %s %s, want POST /new", req.Method, req.URL.Path)
459 }
460 requested <- struct{}{}
461 header := make(http.Header)
462 header.Set("X-Reasonix-Session-Path", freshPath)
463 return &http.Response{StatusCode: http.StatusNoContent, Header: header, Body: io.NopCloser(strings.NewReader("")), Request: req}, nil
464 })}
465 tab := &remoteTab{
466 id: "remote-1", state: "ready", client: client, base: "http://127.0.0.1:43210",
467 session: remoteTabSessionState{name: "old", path: oldPath, reset: false},
468 routing: remoteTabSessionRouting{currentPath: oldPath, running: map[string]bool{}},
469 selectionRevision: 1,
470 pendingSelection: &remoteTabPendingOpenSelection{
471 newSession: true, reuseBlank: true, revision: 1, deferred: true,
472 },
473 }
474 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
475 a.applyPendingRemoteTabOpenSelection(tab.id)
476
477 select {
478 case <-requested:
479 case <-time.After(2 * time.Second):
480 t.Fatal("deferred New Session was discarded using the stale blank snapshot")
481 }
482 a.remoteTabMu.Lock()
483 path, reset := tab.routing.currentPath, tab.session.reset
484 a.remoteTabMu.Unlock()
485 if path != freshPath || !reset {
486 t.Fatalf("rotated state path/reset = %q/%v, want %q/true", path, reset, freshPath)
487 }
488 }
489
489 lines GO