返回 DeepSeek-Reasonix
remote_tab_review_regressions_test.go
根目录 / desktop / remote_tab_review_regressions_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "errors"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "reasonix/internal/config"
15 )
16
17 func TestSetActiveTabRepublishesTerminalRemoteState(t *testing.T) {
18 log := &eventLog{}
19 tab := &remoteTab{
20 id: "remote-terminal", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"},
21 state: "serve_down", err: "bootstrap failed",
22 routing: remoteTabSessionRouting{running: map[string]bool{}},
23 }
24 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}, remoteEventHook: log.add}
25 if err := a.SetActiveTab(tab.id); err != nil {
26 t.Fatal(err)
27 }
28 events := strings.Join(log.recorded(), "\n")
29 if !strings.Contains(events, `remote-tab:remote-terminal:state {"state":"serve_down","error":"bootstrap failed"}`) {
30 t.Fatalf("terminal activation events = %s", events)
31 }
32 }
33
34 func TestRemoteTabServeDownSavedSessionClearsPendingBeforeDelayedMarker(t *testing.T) {
35 const oldPath = "/sessions/old.jsonl"
36 const savedPath = "/sessions/saved.jsonl"
37 feed := make(chan string, 1)
38 fs := newFakeServe(t, "s3cret", []serveSessionEntry{
39 {Name: "old", Path: oldPath, Title: "Old", Current: true},
40 {Name: "saved", Path: savedPath, Title: "Saved"},
41 })
42 kernel := &fakeRemoteKernel{
43 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
44 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
45 }
46 seedBridgeTestHost(t, "box")
47 log := &eventLog{}
48 a := &App{remoteRuntime: kernel, remoteEventHook: log.add}
49 cleanupRemoteTabPumps(t, a)
50 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "old", SessionPath: oldPath})
51
52 a.remoteTabMu.Lock()
53 tab := a.remoteTabs[meta.ID]
54 if tab.cancel != nil {
55 tab.cancel()
56 }
57 tab.gen++
58 tab.cancel, tab.client, tab.base, tab.token = nil, nil, "", ""
59 tab.state = "serve_down"
60 tab.pendingEvents = map[string]json.RawMessage{
61 "approval_request:old": json.RawMessage(`{"kind":"approval_request","approval":{"id":"old"}}`),
62 }
63 tab.runtime = remoteTabRuntimeState{pendingPrompt: true, cancellable: true}
64 a.remoteTabMu.Unlock()
65 fs.mu.Lock()
66 fs.eventFeed = feed
67 fs.mu.Unlock()
68
69 if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{SessionName: "saved", SessionPath: savedPath, SessionTitle: "Saved"}); err != nil {
70 t.Fatal(err)
71 }
72 waitForTabState(t, a, meta.ID, "ready")
73 a.remoteTabMu.Lock()
74 path, pending, prompt := tab.routing.currentPath, len(tab.pendingEvents), tab.runtime.pendingPrompt
75 a.remoteTabMu.Unlock()
76 if path != savedPath || pending != 0 || prompt {
77 t.Fatalf("saved attach route/pending/prompt = %q/%d/%v, want %q/0/false", path, pending, prompt, savedPath)
78 }
79
80 eventPrefix := "remote-tab:" + meta.ID + ":event"
81 before := log.count(eventPrefix)
82 feed <- `{"kind":"session_changed","sessionPath":"/sessions/saved.jsonl","sessionCurrent":true}`
83 waitForRemoteEventCount(t, log, eventPrefix, before+1)
84 a.remoteTabMu.Lock()
85 pending, prompt = len(tab.pendingEvents), tab.runtime.pendingPrompt
86 a.remoteTabMu.Unlock()
87 if pending != 0 || prompt {
88 t.Fatalf("delayed saved-session marker restored stale prompt: pending=%d prompt=%v", pending, prompt)
89 }
90 }
91
92 func TestExternalResumeRefreshesAdoptedSessionTitle(t *testing.T) {
93 const firstPath = "/sessions/first.jsonl"
94 const nextPath = "/sessions/next.jsonl"
95 feed := make(chan string, 1)
96 fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "first", Path: firstPath, Title: "First title", Current: true}})
97 kernel := &fakeRemoteKernel{
98 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
99 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
100 }
101 seedBridgeTestHost(t, "box")
102 log := &eventLog{}
103 a := &App{remoteRuntime: kernel, remoteEventHook: log.add}
104 cleanupRemoteTabPumps(t, a)
105 fs.mu.Lock()
106 fs.eventFeed = feed
107 fs.mu.Unlock()
108 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "first", SessionPath: firstPath})
109 fs.mu.Lock()
110 fs.sessions = []serveSessionEntry{{Name: "next", Path: nextPath, Title: "Next title", Current: true}}
111 fs.mu.Unlock()
112
113 eventPrefix := "remote-tab:" + meta.ID + ":event"
114 before := log.count(eventPrefix)
115 feed <- `{"kind":"session_changed","sessionPath":"/sessions/next.jsonl","sessionCurrent":true}`
116 waitForRemoteEventCount(t, log, eventPrefix, before+1)
117 deadline := time.Now().Add(2 * time.Second)
118 for {
119 a.remoteTabMu.Lock()
120 title, path := a.remoteTabs[meta.ID].topicTitle, a.remoteTabs[meta.ID].routing.currentPath
121 a.remoteTabMu.Unlock()
122 if title == "Next title" && path == nextPath {
123 break
124 }
125 if time.Now().After(deadline) {
126 t.Fatalf("externally adopted title/path = %q/%q, want Next title/%q", title, path, nextPath)
127 }
128 time.Sleep(time.Millisecond)
129 }
130 }
131
132 func TestRemoteAttachRetiringSessionConflictKeepsCurrentReady(t *testing.T) {
133 const currentPath = "/sessions/current.jsonl"
134 fs := newFakeServe(t, "s3cret", []serveSessionEntry{
135 {Name: "current", Path: currentPath, Title: "Current title", Current: true},
136 {Name: "retiring", Path: "/sessions/retiring.jsonl", Title: "Retiring title"},
137 })
138 fs.mu.Lock()
139 fs.failEnter = "session is finishing background teardown; retry shortly"
140 fs.mu.Unlock()
141 kernel := &fakeRemoteKernel{
142 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
143 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
144 }
145 seedBridgeTestHost(t, "box")
146 a := &App{remoteRuntime: kernel}
147 cleanupRemoteTabPumps(t, a)
148 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "retiring", SessionPath: "/sessions/retiring.jsonl"})
149 a.remoteTabMu.Lock()
150 tab := a.remoteTabs[meta.ID]
151 state, path, title := tab.state, tab.routing.currentPath, tab.topicTitle
152 a.remoteTabMu.Unlock()
153 if state != "ready" || path != currentPath || title != "Current title" {
154 t.Fatalf("soft attach state/path/title = %q/%q/%q, want ready/%q/Current title", state, path, title, currentPath)
155 }
156 }
157
158 func TestRemoteResumeTransportFailureReconcilesCommittedTarget(t *testing.T) {
159 const oldPath = "/sessions/old.jsonl"
160 const targetPath = "/sessions/target.jsonl"
161 seedBridgeTestHost(t, "box")
162 postCalls := 0
163 client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
164 switch {
165 case req.Method == http.MethodPost && req.URL.Path == "/resume":
166 postCalls++
167 // Model a response lost after Serve has already committed targetPath.
168 return nil, io.ErrUnexpectedEOF
169 case req.Method == http.MethodGet && req.URL.Path == "/sessions":
170 body := `[{"name":"target","path":"/sessions/target.jsonl","title":"Target","current":true}]`
171 return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
172 default:
173 return nil, errors.New("unexpected request")
174 }
175 })}
176 tab := &remoteTab{
177 id: "remote-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "ready",
178 client: client, base: "http://127.0.0.1:43210", gen: 7,
179 topicTitle: "Old",
180 session: remoteTabSessionState{name: "old", path: oldPath},
181 routing: remoteTabSessionRouting{currentPath: oldPath, running: map[string]bool{}},
182 }
183 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
184 a.resumeRemoteTabSessionPath(tab.id, "target", targetPath, "Target")
185 a.remoteTabMu.Lock()
186 state, path, sessionPath, title := tab.state, tab.routing.currentPath, tab.session.path, tab.topicTitle
187 rehydrating := tab.routing.rehydratingPath
188 a.remoteTabMu.Unlock()
189 if postCalls != 1 || state != "ready" || path != targetPath || sessionPath != targetPath || title != "Target" || rehydrating != "" {
190 t.Fatalf("reconciled resume calls/state/route/session/title/rehydrating = %d/%q/%q/%q/%q/%q", postCalls, state, path, sessionPath, title, rehydrating)
191 }
192 }
193
194 func TestRemoteResumeReplayStopsAfterLaterSessionAdoption(t *testing.T) {
195 const oldPath = "/sessions/old.jsonl"
196 const targetPath = "/sessions/target.jsonl"
197 const laterPath = "/sessions/later.jsonl"
198 client := &http.Client{}
199 tab := &remoteTab{
200 id: "remote-1", state: "ready", client: client, gen: 7,
201 session: remoteTabSessionState{name: "target", path: targetPath},
202 routing: remoteTabSessionRouting{
203 currentPath: targetPath, rehydratingPath: targetPath, running: map[string]bool{},
204 rehydratingFrames: []json.RawMessage{
205 json.RawMessage(`{"kind":"text","text":"target-first","sessionPath":"/sessions/target.jsonl"}`),
206 json.RawMessage(`{"kind":"text","text":"target-second","sessionPath":"/sessions/target.jsonl"}`),
207 },
208 },
209 }
210 log := &eventLog{}
211 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
212 firstFrameEntered := make(chan struct{})
213 releaseFirstFrame := make(chan struct{})
214 var firstFrameOnce sync.Once
215 a.remoteEventHook = func(name string, payload any) {
216 log.add(name, payload)
217 if name == "remote-tab:"+tab.id+":event" {
218 data, _ := json.Marshal(payload)
219 if strings.Contains(string(data), "target-first") {
220 firstFrameOnce.Do(func() { close(firstFrameEntered) })
221 <-releaseFirstFrame
222 }
223 }
224 }
225 route := remoteTabProvisionalResume{targetPath: targetPath, previousPath: oldPath, active: true}
226 resumeDone := make(chan struct{})
227 go func() {
228 a.publishRemoteTabResumeReady(tab.id, tab, client, tab.gen, route)
229 close(resumeDone)
230 }()
231 select {
232 case <-firstFrameEntered:
233 case <-time.After(2 * time.Second):
234 t.Fatal("first replay frame did not reach publication")
235 }
236 adoptDone := make(chan struct{})
237 go func() {
238 a.adoptRemoteTabFrameCurrent(tab.id, tab.gen, laterPath, true)
239 close(adoptDone)
240 }()
241 select {
242 case <-adoptDone:
243 t.Fatal("later route adoption overtook an in-flight frame publication")
244 case <-time.After(100 * time.Millisecond):
245 }
246 close(releaseFirstFrame)
247 select {
248 case <-adoptDone:
249 case <-time.After(2 * time.Second):
250 t.Fatal("later route adoption did not complete after publication")
251 }
252 select {
253 case <-resumeDone:
254 case <-time.After(2 * time.Second):
255 t.Fatal("resume replay did not stop after later adoption")
256 }
257 events := strings.Join(log.recorded(), "\n")
258 secondFrame := strings.Index(events, "target-second")
259 laterReady := strings.LastIndex(events, "remote-tab:"+tab.id+":state")
260 if !strings.Contains(events, "target-first") || secondFrame < 0 || laterReady < secondFrame {
261 t.Fatalf("later adoption overtook the ordered replay: %s", events)
262 }
263 a.remoteTabMu.Lock()
264 path, rehydrating := tab.routing.currentPath, tab.routing.rehydratingPath
265 a.remoteTabMu.Unlock()
266 if path != laterPath || rehydrating != "" {
267 t.Fatalf("later route/rehydration = %q/%q, want %q/empty", path, rehydrating, laterPath)
268 }
269 }
270
271 func TestRemoteResumeReplaysPromptArrivingDuringDrain(t *testing.T) {
272 const targetPath = "/sessions/target.jsonl"
273 client := &http.Client{}
274 tab := &remoteTab{
275 id: "remote-1", state: "ready", client: client, gen: 7,
276 routing: remoteTabSessionRouting{
277 currentPath: targetPath, rehydratingPath: targetPath, running: map[string]bool{},
278 rehydratingFrames: []json.RawMessage{
279 json.RawMessage(`{"kind":"text","text":"drain-start","sessionPath":"/sessions/target.jsonl"}`),
280 },
281 },
282 }
283 firstPublished := make(chan struct{})
284 release := make(chan struct{})
285 log := &eventLog{}
286 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
287 a.remoteEventHook = func(name string, payload any) {
288 log.add(name, payload)
289 data, _ := json.Marshal(payload)
290 if name == "remote-tab:"+tab.id+":event" && strings.Contains(string(data), "drain-start") {
291 close(firstPublished)
292 <-release
293 }
294 }
295 done := make(chan struct{})
296 go func() {
297 a.publishRemoteTabResumeReady(tab.id, tab, client, tab.gen, remoteTabProvisionalResume{targetPath: targetPath, active: true})
298 close(done)
299 }()
300 select {
301 case <-firstPublished:
302 case <-time.After(time.Second):
303 t.Fatal("resume drain did not publish its first frame")
304 }
305 // The aggregate snapshot has already copied an empty prompt set. A prompt
306 // arriving now must therefore reach the frontend through the fenced drain.
307 a.remoteTabMu.Lock()
308 snapshotPending := len(tab.pendingEvents)
309 a.remoteTabMu.Unlock()
310 if snapshotPending != 0 {
311 t.Fatalf("snapshot pending events = %d, want 0", snapshotPending)
312 }
313 approval := json.RawMessage(`{"kind":"approval_request","approval":{"id":"during-drain"},"sessionPath":"/sessions/target.jsonl","sessionCurrent":true}`)
314 if !a.bufferRemoteTabResumeFrame(tab.id, tab.gen, targetPath, "approval_request", approval) {
315 t.Fatal("prompt arriving during drain was not buffered")
316 }
317 close(release)
318 select {
319 case <-done:
320 case <-time.After(time.Second):
321 t.Fatal("resume drain did not finish")
322 }
323 events := strings.Join(log.recorded(), "\n")
324 if !strings.Contains(events, `"id":"during-drain"`) {
325 t.Fatalf("prompt arriving after snapshot was not replayed: %s", events)
326 }
327 a.remoteTabMu.Lock()
328 pending, rehydrating := len(tab.pendingEvents), tab.routing.rehydratingPath
329 a.remoteTabMu.Unlock()
330 if pending != 1 || rehydrating != "" {
331 t.Fatalf("post-drain pending/rehydrating = %d/%q, want 1/empty", pending, rehydrating)
332 }
333 }
334
335 func TestRemoteRotationResponseCannotOverwriteLaterRouteAdoption(t *testing.T) {
336 const oldPath = "/sessions/old.jsonl"
337 const responsePath = "/sessions/response.jsonl"
338 const laterPath = "/sessions/later.jsonl"
339 requestEntered := make(chan struct{})
340 releaseResponse := make(chan struct{})
341 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
342 if r.Method != http.MethodPost || r.URL.Path != "/new" {
343 http.NotFound(w, r)
344 return
345 }
346 close(requestEntered)
347 <-releaseResponse
348 w.Header().Set("X-Reasonix-Session-Path", responsePath)
349 w.WriteHeader(http.StatusNoContent)
350 }))
351 defer server.Close()
352 tab := &remoteTab{
353 id: "remote-1", state: "ready", client: server.Client(), base: server.URL, gen: 7,
354 session: remoteTabSessionState{name: "old", path: oldPath},
355 routing: remoteTabSessionRouting{currentPath: oldPath, running: map[string]bool{}},
356 }
357 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
358 done := make(chan error, 1)
359 go func() { done <- a.rotateRemoteTabSession(tab.id, "/new") }()
360 select {
361 case <-requestEntered:
362 case <-time.After(2 * time.Second):
363 t.Fatal("rotation request did not reach Serve")
364 }
365 a.adoptRemoteTabFrameCurrent(tab.id, tab.gen, laterPath, true)
366 close(releaseResponse)
367 select {
368 case err := <-done:
369 if err != nil {
370 t.Fatal(err)
371 }
372 case <-time.After(2 * time.Second):
373 t.Fatal("rotation did not finish")
374 }
375 a.remoteTabMu.Lock()
376 path, sessionPath := tab.routing.currentPath, tab.session.path
377 a.remoteTabMu.Unlock()
378 if path != laterPath || sessionPath == responsePath {
379 t.Fatalf("stale rotation response replaced later route: routing/session = %q/%q, want later route %q", path, sessionPath, laterPath)
380 }
381 }
382
383 func TestRemoteStatusAdoptionWaitsForFramePublication(t *testing.T) {
384 const currentPath = "/sessions/current.jsonl"
385 const laterPath = "/sessions/later.jsonl"
386 client := &http.Client{}
387 tab := &remoteTab{
388 id: "remote-1", state: "ready", client: client, gen: 7,
389 session: remoteTabSessionState{path: currentPath},
390 routing: remoteTabSessionRouting{currentPath: currentPath, running: map[string]bool{}},
391 runtime: remoteTabRuntimeState{revision: 11},
392 }
393 frameEntered := make(chan struct{})
394 releaseFrame := make(chan struct{})
395 log := &eventLog{}
396 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
397 a.remoteEventHook = func(name string, payload any) {
398 log.add(name, payload)
399 if name == "remote-tab:"+tab.id+":event" {
400 close(frameEntered)
401 <-releaseFrame
402 }
403 }
404 frameDone := make(chan bool, 1)
405 go func() {
406 frame := json.RawMessage(`{"kind":"text","text":"current-frame","sessionPath":"/sessions/current.jsonl"}`)
407 frameDone <- a.publishRemoteTabFrame(tab.id, tab.gen, currentPath, "text", frame)
408 }()
409 select {
410 case <-frameEntered:
411 case <-time.After(2 * time.Second):
412 t.Fatal("current frame did not reach publication")
413 }
414 statusDone := make(chan bool, 1)
415 go func() {
416 statusDone <- a.recordRemoteTabSessionStatus(tab.id, client, tab.gen, 11, json.RawMessage(`{"sessionPath":"/sessions/later.jsonl"}`))
417 }()
418 select {
419 case <-statusDone:
420 t.Fatal("status route adoption overtook an in-flight frame publication")
421 case <-time.After(100 * time.Millisecond):
422 }
423 close(releaseFrame)
424 if !<-frameDone || !<-statusDone {
425 t.Fatal("ordered frame publication or status adoption was rejected")
426 }
427 events := strings.Join(log.recorded(), "\n")
428 frameIndex := strings.Index(events, "current-frame")
429 readyIndex := strings.LastIndex(events, "remote-tab:"+tab.id+":state")
430 if frameIndex < 0 || readyIndex < frameIndex {
431 t.Fatalf("status ready barrier overtook the current frame: %s", events)
432 }
433 a.remoteTabMu.Lock()
434 adoptedPath := tab.routing.currentPath
435 a.remoteTabMu.Unlock()
436 if adoptedPath != laterPath {
437 t.Fatalf("status route = %q, want %q", adoptedPath, laterPath)
438 }
439 }
440
441 func TestRemoteRejectedResumeReconciliationWaitsForFramePublication(t *testing.T) {
442 const previousPath = "/sessions/previous.jsonl"
443 const targetPath = "/sessions/target.jsonl"
444 const authoritativePath = "/sessions/authoritative.jsonl"
445 client := &http.Client{}
446 tab := &remoteTab{
447 id: "remote-1", state: "ready", client: client, gen: 7,
448 session: remoteTabSessionState{path: targetPath},
449 routing: remoteTabSessionRouting{
450 currentPath: targetPath, rehydratingPath: targetPath, running: map[string]bool{},
451 },
452 }
453 frameEntered := make(chan struct{})
454 releaseFrame := make(chan struct{})
455 log := &eventLog{}
456 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
457 a.remoteEventHook = func(name string, payload any) {
458 log.add(name, payload)
459 if name == "remote-tab:"+tab.id+":event" {
460 close(frameEntered)
461 <-releaseFrame
462 }
463 }
464 frameDone := make(chan bool, 1)
465 go func() {
466 frame := json.RawMessage(`{"kind":"text","text":"target-frame","sessionPath":"/sessions/target.jsonl"}`)
467 frameDone <- a.publishRemoteTabFrameForRoute(tab.id, tab, client, tab.gen, targetPath, true, "text", frame)
468 }()
469 select {
470 case <-frameEntered:
471 case <-time.After(2 * time.Second):
472 t.Fatal("target frame did not reach publication")
473 }
474 reconcileDone := make(chan struct{})
475 go func() {
476 route := remoteTabProvisionalResume{targetPath: targetPath, previousPath: previousPath, active: true}
477 a.reconcileRemoteTabRejectedResume(tab.id, tab, client, tab.gen, route, serveSessionEntry{Path: authoritativePath}, errors.New("resume response lost"))
478 close(reconcileDone)
479 }()
480 select {
481 case <-reconcileDone:
482 t.Fatal("ambiguous-resume reconciliation overtook an in-flight frame")
483 case <-time.After(100 * time.Millisecond):
484 }
485 close(releaseFrame)
486 if !<-frameDone {
487 t.Fatal("target frame was rejected before authoritative reconciliation")
488 }
489 select {
490 case <-reconcileDone:
491 case <-time.After(2 * time.Second):
492 t.Fatal("ambiguous-resume reconciliation did not complete")
493 }
494 events := strings.Join(log.recorded(), "\n")
495 frameIndex := strings.Index(events, "target-frame")
496 readyIndex := strings.LastIndex(events, "remote-tab:"+tab.id+":state")
497 if frameIndex < 0 || readyIndex < frameIndex {
498 t.Fatalf("authoritative ready barrier overtook the target frame: %s", events)
499 }
500 a.remoteTabMu.Lock()
501 currentPath := tab.routing.currentPath
502 a.remoteTabMu.Unlock()
503 if currentPath != authoritativePath {
504 t.Fatalf("reconciled route = %q, want %q", currentPath, authoritativePath)
505 }
506 }
507
508 func TestRemoteRejectedResumeReconciliationCannotOverwriteNewerAdoption(t *testing.T) {
509 const previousPath = "/sessions/previous.jsonl"
510 const targetPath = "/sessions/target.jsonl"
511 const staleAuthoritativePath = "/sessions/stale-authoritative.jsonl"
512 const newerPath = "/sessions/newer.jsonl"
513 client := &http.Client{}
514 tab := &remoteTab{
515 id: "remote-1", state: "ready", client: client, gen: 7,
516 session: remoteTabSessionState{path: targetPath},
517 routing: remoteTabSessionRouting{
518 currentPath: targetPath, rehydratingPath: targetPath, running: map[string]bool{},
519 },
520 }
521 log := &eventLog{}
522 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}, remoteEventHook: log.add}
523 // This route marker arrives after the reconciliation query returned C but
524 // before its caller acquired the route/publication mutex.
525 a.adoptRemoteTabFrameCurrent(tab.id, tab.gen, newerPath, true)
526 eventsBefore := len(log.recorded())
527 route := remoteTabProvisionalResume{targetPath: targetPath, previousPath: previousPath, active: true}
528 a.reconcileRemoteTabRejectedResume(tab.id, tab, client, tab.gen, route, serveSessionEntry{Path: staleAuthoritativePath}, errors.New("resume response lost"))
529 a.remoteTabMu.Lock()
530 currentPath := tab.routing.currentPath
531 a.remoteTabMu.Unlock()
532 if currentPath != newerPath {
533 t.Fatalf("stale reconciliation replaced newer route with %q, want %q", currentPath, newerPath)
534 }
535 if eventsAfter := len(log.recorded()); eventsAfter != eventsBefore {
536 t.Fatalf("stale reconciliation emitted %d events after newer adoption, want 0", eventsAfter-eventsBefore)
537 }
538 }
539
540 func TestRemoteRejectedResumeReconcilesReselectedCurrentSession(t *testing.T) {
541 const currentPath = "/sessions/current.jsonl"
542 const authoritativePath = "/sessions/authoritative.jsonl"
543 client := &http.Client{}
544 tab := &remoteTab{
545 id: "remote-1", state: "ready", client: client, gen: 7,
546 session: remoteTabSessionState{path: currentPath},
547 routing: remoteTabSessionRouting{currentPath: currentPath, pathRevision: 9, running: map[string]bool{}},
548 }
549 log := &eventLog{}
550 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}, remoteEventHook: log.add}
551 route := a.beginRemoteTabProvisionalResume(tab.id, tab, client, tab.gen, currentPath)
552 if route.active || route.pathRevision != 9 {
553 t.Fatalf("reselection route = %+v, want inactive revision 9", route)
554 }
555 a.reconcileRemoteTabRejectedResume(tab.id, tab, client, tab.gen, route, serveSessionEntry{Path: authoritativePath}, errors.New("resume response lost"))
556 a.remoteTabMu.Lock()
557 got := tab.routing.currentPath
558 a.remoteTabMu.Unlock()
559 if got != authoritativePath {
560 t.Fatalf("reselected current route = %q, want authoritative %q", got, authoritativePath)
561 }
562 if log.count("remote-tab:"+tab.id+":state") != 1 {
563 t.Fatalf("authoritative reconciliation did not publish one ready barrier: %v", log.recorded())
564 }
565 }
566
567 func TestRemoteRejectedResumeRollbackCannotMarkNewerRouteErrored(t *testing.T) {
568 const previousPath = "/sessions/previous.jsonl"
569 const targetPath = "/sessions/target.jsonl"
570 const newerPath = "/sessions/newer.jsonl"
571 client := &http.Client{}
572 tab := &remoteTab{
573 id: "remote-1", state: "ready", client: client, gen: 7,
574 session: remoteTabSessionState{path: previousPath},
575 routing: remoteTabSessionRouting{currentPath: previousPath, running: map[string]bool{}},
576 }
577 log := &eventLog{}
578 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}, remoteEventHook: log.add}
579 route := a.beginRemoteTabProvisionalResume(tab.id, tab, client, tab.gen, targetPath)
580 a.adoptRemoteTabFrameCurrent(tab.id, tab.gen, newerPath, true)
581 eventsBefore := len(log.recorded())
582 a.reconcileRemoteTabRejectedResume(tab.id, tab, client, tab.gen, route, serveSessionEntry{Path: previousPath}, errors.New("resume response lost"))
583 a.remoteTabMu.Lock()
584 got := tab.routing.currentPath
585 a.remoteTabMu.Unlock()
586 if got != newerPath {
587 t.Fatalf("stale rollback replaced newer route with %q, want %q", got, newerPath)
588 }
589 if eventsAfter := len(log.recorded()); eventsAfter != eventsBefore {
590 t.Fatalf("stale rollback emitted %d events after newer adoption, want 0", eventsAfter-eventsBefore)
591 }
592 }
593
594 func TestRemoteReselectionSuccessCannotOverwriteNewerAdoption(t *testing.T) {
595 const previousPath = "/sessions/previous.jsonl"
596 const newerPath = "/sessions/newer.jsonl"
597 client := &http.Client{}
598 tab := &remoteTab{
599 id: "remote-1", state: "ready", client: client, gen: 7,
600 session: remoteTabSessionState{path: previousPath},
601 routing: remoteTabSessionRouting{currentPath: previousPath, pathRevision: 3, running: map[string]bool{}},
602 }
603 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
604 route := a.beginRemoteTabProvisionalResume(tab.id, tab, client, tab.gen, previousPath)
605 a.adoptRemoteTabFrameCurrent(tab.id, tab.gen, newerPath, true)
606 if _, committed := a.commitRemoteTabResume(tab.id, tab, client, tab.gen, route, serveSessionEntry{Path: previousPath}, "Previous"); committed {
607 t.Fatal("late reselection success overwrote a newer route")
608 }
609 a.remoteTabMu.Lock()
610 got := tab.routing.currentPath
611 a.remoteTabMu.Unlock()
612 if got != newerPath {
613 t.Fatalf("late reselection changed route to %q, want %q", got, newerPath)
614 }
615 }
616
617 func TestRemoteResumeCommitPublicationBlocksNewerAdoption(t *testing.T) {
618 isolateDesktopUserDirs(t)
619 const targetPath = "/sessions/target.jsonl"
620 const newerPath = "/sessions/newer.jsonl"
621 client := &http.Client{}
622 tab := &remoteTab{
623 id: "remote-1", state: "ready", client: client, gen: 7,
624 session: remoteTabSessionState{path: targetPath},
625 routing: remoteTabSessionRouting{
626 currentPath: targetPath, rehydratingPath: targetPath, pathRevision: 4, running: map[string]bool{},
627 },
628 }
629 metadataEntered := make(chan struct{})
630 releaseMetadata := make(chan struct{})
631 var once sync.Once
632 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
633 a.remoteEventHook = func(name string, _ any) {
634 if name == "remote-tab:updated" {
635 once.Do(func() {
636 close(metadataEntered)
637 <-releaseMetadata
638 })
639 }
640 }
641 route := remoteTabProvisionalResume{targetPath: targetPath, active: true}
642 resumeDone := make(chan bool, 1)
643 go func() {
644 resumeDone <- a.commitAndPublishRemoteTabResume(tab.id, tab, client, tab.gen, route, serveSessionEntry{Path: targetPath}, "Target")
645 }()
646 select {
647 case <-metadataEntered:
648 case <-time.After(time.Second):
649 t.Fatal("resume metadata publication did not start")
650 }
651 adoptDone := make(chan struct{})
652 go func() {
653 a.adoptRemoteTabFrameCurrent(tab.id, tab.gen, newerPath, true)
654 close(adoptDone)
655 }()
656 select {
657 case <-adoptDone:
658 t.Fatal("newer adoption overtook the in-flight resume publication")
659 case <-time.After(100 * time.Millisecond):
660 }
661 close(releaseMetadata)
662 select {
663 case committed := <-resumeDone:
664 if !committed {
665 t.Fatal("resume commit was unexpectedly rejected")
666 }
667 case <-time.After(time.Second):
668 t.Fatal("resume publication did not finish")
669 }
670 select {
671 case <-adoptDone:
672 case <-time.After(time.Second):
673 t.Fatal("newer adoption remained blocked after resume publication")
674 }
675 a.remoteTabMu.Lock()
676 path := tab.routing.currentPath
677 a.remoteTabMu.Unlock()
678 if path != newerPath {
679 t.Fatalf("foreground route = %q, want newer adoption %q", path, newerPath)
680 }
681 }
682
683 func TestRemoteAttachResponseCannotOverwriteNewerAdoption(t *testing.T) {
684 const responsePath = "/sessions/response.jsonl"
685 const newerPath = "/sessions/newer.jsonl"
686 tab := &remoteTab{
687 id: "remote-1", gen: 7, topicTitle: "Newer",
688 session: remoteTabSessionState{name: "newer", path: newerPath},
689 routing: remoteTabSessionRouting{currentPath: newerPath, pathRevision: 5, running: map[string]bool{}},
690 }
691 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
692 committed := a.commitRemoteTabAttachResponse(tab.id, tab, tab.gen, 4, serveSessionEntry{
693 Name: "response", Path: responsePath, Title: "Stale response",
694 }, false)
695 if committed {
696 t.Fatal("stale attach response overwrote a newer route adoption")
697 }
698 a.remoteTabMu.Lock()
699 path, name, title := tab.routing.currentPath, tab.session.name, tab.topicTitle
700 a.remoteTabMu.Unlock()
701 if path != newerPath || name != "newer" || title != "Newer" {
702 t.Fatalf("stale attach response changed newer identity: path=%q name=%q title=%q", path, name, title)
703 }
704 }
705
706 func TestExternalSessionAdoptionResetsAndSeedsForegroundRuntime(t *testing.T) {
707 const oldPath = "/sessions/old.jsonl"
708 const targetPath = "/sessions/target.jsonl"
709 tab := &remoteTab{
710 routing: remoteTabSessionRouting{currentPath: oldPath, running: map[string]bool{targetPath: true}},
711 session: remoteTabSessionState{name: "old", path: oldPath},
712 pendingEvents: map[string]json.RawMessage{
713 "approval_request:old": json.RawMessage(`{"kind":"approval_request"}`),
714 },
715 runtime: remoteTabRuntimeState{
716 running: true, turnStartedAt: 99, backgroundJobs: 3,
717 pendingPrompt: true, cancelRequested: true, cancellable: true,
718 },
719 }
720 if !adoptRemoteTabSessionPathLocked(tab, targetPath) {
721 t.Fatal("target session was not adopted")
722 }
723 if len(tab.pendingEvents) != 0 || !tab.runtime.running || tab.runtime.turnStartedAt != 0 ||
724 tab.runtime.backgroundJobs != 0 || tab.runtime.pendingPrompt || tab.runtime.cancelRequested || !tab.runtime.cancellable {
725 t.Fatalf("adopted runtime retained old controller state: %+v pending=%d", tab.runtime, len(tab.pendingEvents))
726 }
727 }
728
729 func TestRevivedSessionSelectionWaitsForVisibilityCommit(t *testing.T) {
730 seedBridgeTestHost(t, "box")
731 if err := editUserConfig(func(c *config.Config) error { return c.SetDesktopLayoutStyle("workbench") }); err != nil {
732 t.Fatal(err)
733 }
734 const oldPath = "/sessions/old.jsonl"
735 const targetPath = "/sessions/target.jsonl"
736 remote := &remoteTab{
737 id: "remote-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "disconnected",
738 topicTitle: "Old title",
739 session: remoteTabSessionState{name: "old", path: oldPath},
740 routing: remoteTabSessionRouting{currentPath: oldPath, running: map[string]bool{}},
741 }
742 a := &App{
743 tabs: map[string]*WorkspaceTab{
744 "local-1": {ID: "local-1", Ctrl: &snapshotErrorSessionController{err: errors.New("snapshot failed")}},
745 },
746 remoteTabs: map[string]*remoteTab{remote.id: remote},
747 }
748 a.remoteTabLayout.activeID = remote.id
749 a.remoteTabLayout.order = []string{remote.id}
750 _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{
751 SessionName: "target", SessionPath: targetPath, SessionTitle: "Target title",
752 })
753 if err == nil || !strings.Contains(err.Error(), "snapshot failed") {
754 t.Fatalf("revived open error = %v, want visibility snapshot failure", err)
755 }
756 a.remoteTabMu.Lock()
757 state, name, sessionPath := remote.state, remote.session.name, remote.session.path
758 route, title := remote.routing.currentPath, remote.topicTitle
759 a.remoteTabMu.Unlock()
760 if state != "disconnected" || name != "old" || sessionPath != oldPath || route != oldPath || title != "Old title" {
761 t.Fatalf("failed visibility commit changed revived shell: state=%q name=%q session=%q route=%q title=%q", state, name, sessionPath, route, title)
762 }
763 }
764
765 // TestSpectatorRouteResistsForegroundCurrentMarker pins the regression where
766 // the serve foreground's sessionCurrent marker re-routed a spectator tab that
767 // had explicitly selected a mirrored session: the banner stayed on the watched
768 // session while routing (and every reclaim/submit) silently moved to the
769 // foreground, so reclaim looked successful and messages landed in the wrong
770 // transcript.
771 func TestSpectatorRouteResistsForegroundCurrentMarker(t *testing.T) {
772 const watched = "/sessions/watched.jsonl"
773 const foreground = "/sessions/foreground.jsonl"
774 client := &http.Client{}
775 tab := &remoteTab{
776 id: "remote-1", state: "ready", client: client, gen: 3,
777 session: remoteTabSessionState{path: watched, takenOver: true},
778 routing: remoteTabSessionRouting{currentPath: watched, running: map[string]bool{}},
779 }
780 log := &eventLog{}
781 a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}, remoteEventHook: log.add}
782 eventsBefore := len(log.recorded())
783
784 a.adoptRemoteTabFrameCurrent(tab.id, tab.gen, foreground, false)
785 a.remoteTabMu.Lock()
786 path, taken := tab.routing.currentPath, tab.session.takenOver
787 a.remoteTabMu.Unlock()
788 if path != watched || !taken {
789 t.Fatalf("foreground marker stole the spectator route: path=%q takenOver=%v", path, taken)
790 }
791 if eventsAfter := len(log.recorded()); eventsAfter != eventsBefore {
792 t.Fatalf("blocked adoption emitted %d events, want 0", eventsAfter-eventsBefore)
793 }
794
795 // Once the spectator pin lifts (reclaim landed or the probe cleared it),
796 // foreground rotation frames adopt again.
797 a.remoteTabMu.Lock()
798 tab.session.takenOver = false
799 a.remoteTabMu.Unlock()
800 a.adoptRemoteTabFrameCurrent(tab.id, tab.gen, foreground, false)
801 a.remoteTabMu.Lock()
802 path = tab.routing.currentPath
803 a.remoteTabMu.Unlock()
804 if path != foreground {
805 t.Fatalf("foreground adoption after the spectator pin lifted was blocked: %q", path)
806 }
807 }
808
808 lines GO