| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "testing" |
| 5 | ) |
| 6 | |
| 7 | func reattachSelectionFixture(t *testing.T, sessions []serveSessionEntry) (*fakeServe, *App) { |
| 8 | t.Helper() |
| 9 | fs := newFakeServe(t, "s3cret", sessions) |
| 10 | kernel := &fakeRemoteKernel{ |
| 11 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 12 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL, InstanceID: "serve-1"}, ensureToken: "s3cret", |
| 13 | } |
| 14 | seedBridgeTestHost(t, "box") |
| 15 | a := &App{remoteRuntime: kernel} |
| 16 | cleanupRemoteTabPumps(t, a) |
| 17 | previousDelays := remoteTabReattachDelays |
| 18 | remoteTabReattachDelays = nil |
| 19 | t.Cleanup(func() { remoteTabReattachDelays = previousDelays }) |
| 20 | return fs, a |
| 21 | } |
| 22 | |
| 23 | // dropRemoteTabPump retires the live pump the way a dead stream does, leaving |
| 24 | // the tab reconnecting on the same Serve instance. |
| 25 | func dropRemoteTabPump(a *App, tabID string) { |
| 26 | a.remoteTabMu.Lock() |
| 27 | defer a.remoteTabMu.Unlock() |
| 28 | tab := a.remoteTabs[tabID] |
| 29 | if tab.cancel != nil { |
| 30 | tab.cancel() |
| 31 | } |
| 32 | tab.gen++ |
| 33 | tab.cancel, tab.client, tab.base, tab.token = nil, nil, "", "" |
| 34 | tab.state = "reconnecting" |
| 35 | } |
| 36 | |
| 37 | // A reattach on a surviving Serve must land on the session the tab was opened |
| 38 | // for. Another client can move Serve's foreground while the stream is down; |
| 39 | // publishing ready without re-entering would let the next /status adopt that |
| 40 | // foreign session and silently swap the user's conversation. |
| 41 | func TestRemoteTabReattachReentersSelectedSessionWhenServeMoved(t *testing.T) { |
| 42 | const selected = "/sessions/s2.jsonl" |
| 43 | fs, a := reattachSelectionFixture(t, []serveSessionEntry{ |
| 44 | {Name: "s1", Path: "/sessions/s1.jsonl", Current: true}, |
| 45 | {Name: "s2", Path: selected}, |
| 46 | }) |
| 47 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "s2", SessionPath: selected}) |
| 48 | |
| 49 | fs.mu.Lock() |
| 50 | for i := range fs.sessions { |
| 51 | fs.sessions[i].Current = fs.sessions[i].Name == "s1" |
| 52 | } |
| 53 | fs.resumePath = "" |
| 54 | fs.mu.Unlock() |
| 55 | dropRemoteTabPump(a, meta.ID) |
| 56 | |
| 57 | if !a.reattachRemoteTabOnce(meta.ID) { |
| 58 | t.Fatal("reattach on the surviving serve failed") |
| 59 | } |
| 60 | _, resumed, _ := fs.snapshot() |
| 61 | if resumed != selected { |
| 62 | t.Fatalf("reattach resumed %q, want the tab's selected session %q re-entered", resumed, selected) |
| 63 | } |
| 64 | a.remoteTabMu.Lock() |
| 65 | tab := a.remoteTabs[meta.ID] |
| 66 | state, route, name := tab.state, tab.routing.currentPath, tab.session.name |
| 67 | a.remoteTabMu.Unlock() |
| 68 | if state != "ready" || route != selected || name != "s2" { |
| 69 | t.Fatalf("reattached state/route/name = %q/%q/%q, want ready on %q", state, route, name, selected) |
| 70 | } |
| 71 | sessions, err := a.RemoteProjectSessions("box", "~/app") |
| 72 | if err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | for _, session := range sessions { |
| 76 | if session.Current && session.Path != selected { |
| 77 | t.Fatalf("serve foreground after reattach = %+v, want %q", session, selected) |
| 78 | } |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | // When Serve still runs the selected session the reattach only rebuilds the |
| 83 | // stream: no redundant /resume transition is issued. |
| 84 | func TestRemoteTabReattachKeepsStreamWhenServeStillRunsSelection(t *testing.T) { |
| 85 | const selected = "/sessions/s2.jsonl" |
| 86 | fs, a := reattachSelectionFixture(t, []serveSessionEntry{ |
| 87 | {Name: "s1", Path: "/sessions/s1.jsonl", Current: true}, |
| 88 | {Name: "s2", Path: selected}, |
| 89 | }) |
| 90 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "s2", SessionPath: selected}) |
| 91 | fs.mu.Lock() |
| 92 | fs.resumePath = "" |
| 93 | fs.mu.Unlock() |
| 94 | dropRemoteTabPump(a, meta.ID) |
| 95 | |
| 96 | if !a.reattachRemoteTabOnce(meta.ID) { |
| 97 | t.Fatal("reattach on the surviving serve failed") |
| 98 | } |
| 99 | if _, resumed, _ := fs.snapshot(); resumed != "" { |
| 100 | t.Fatalf("reattach re-entered %q although serve still ran the selection", resumed) |
| 101 | } |
| 102 | a.remoteTabMu.Lock() |
| 103 | state, route := a.remoteTabs[meta.ID].state, a.remoteTabs[meta.ID].routing.currentPath |
| 104 | a.remoteTabMu.Unlock() |
| 105 | if state != "ready" || route != selected { |
| 106 | t.Fatalf("reattached state/route = %q/%q, want ready on %q", state, route, selected) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | // A first-open New Topic whose /events stream is refused never sent /new. The |
| 111 | // reattach loop that recovers it must still create the requested blank instead |
| 112 | // of publishing ready on whatever session Serve happens to run. |
| 113 | func TestRemoteTabFirstOpenReattachEntersRequestedNewSession(t *testing.T) { |
| 114 | const fresh = "/sessions/fresh.jsonl" |
| 115 | fs, a := reattachSelectionFixture(t, []serveSessionEntry{{Name: "s1", Path: "/sessions/s1.jsonl", Current: true}}) |
| 116 | fs.mu.Lock() |
| 117 | fs.eventsFailCount = 1 |
| 118 | fs.newSessionPath = fresh |
| 119 | fs.mu.Unlock() |
| 120 | |
| 121 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true}) |
| 122 | newCalled, _, _ := fs.snapshot() |
| 123 | if newCalled != 1 { |
| 124 | t.Fatalf("POST /new called %d times, want exactly one from the recovering reattach", newCalled) |
| 125 | } |
| 126 | a.remoteTabMu.Lock() |
| 127 | tab := a.remoteTabs[meta.ID] |
| 128 | route, reset, name := tab.routing.currentPath, tab.session.reset, tab.session.name |
| 129 | a.remoteTabMu.Unlock() |
| 130 | if route != fresh || !reset || name != "" { |
| 131 | t.Fatalf("recovered New Topic route/reset/name = %q/%v/%q, want the fresh blank %q", route, reset, name, fresh) |
| 132 | } |
| 133 | if err := a.SubmitRemoteTab(meta.ID, "hi"); err != nil { |
| 134 | t.Fatalf("submit after recovered first open: %v", err) |
| 135 | } |
| 136 | if fenced := fs.recordedExpectedPaths(); len(fenced) == 0 || fenced[len(fenced)-1] != fresh { |
| 137 | t.Fatalf("submit fenced against %v, want the recovered blank %q", fenced, fresh) |
| 138 | } |
| 139 | } |
| 140 |