| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "testing" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | func TestNewerTerminalStateOverridesStaleRunningListingRow(t *testing.T) { |
| 12 | testTerminalListingRace(t, false, false) |
| 13 | } |
| 14 | |
| 15 | func TestNewerTerminalStateRetainsBackgroundRunningListingRow(t *testing.T) { |
| 16 | testTerminalListingRace(t, true, false) |
| 17 | } |
| 18 | |
| 19 | func TestRecoveredTerminalPathRetriesStaleRunningListingRow(t *testing.T) { |
| 20 | testTerminalListingRace(t, false, true) |
| 21 | } |
| 22 | |
| 23 | func testTerminalListingRace(t *testing.T, serveStillRunning, recovered bool) { |
| 24 | t.Helper() |
| 25 | const path = "/sessions/current.jsonl" |
| 26 | fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "current", Path: path, Current: true, Running: true}}) |
| 27 | kernel := &fakeRemoteKernel{ |
| 28 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 29 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret", |
| 30 | } |
| 31 | seedBridgeTestHost(t, "box") |
| 32 | a := &App{remoteRuntime: kernel} |
| 33 | cleanupRemoteTabPumps(t, a) |
| 34 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionPath: path}) |
| 35 | fs.mu.Lock() |
| 36 | fs.sessionsStarted, fs.sessionsRelease = make(chan struct{}, 1), make(chan struct{}) |
| 37 | started, release := fs.sessionsStarted, fs.sessionsRelease |
| 38 | fs.mu.Unlock() |
| 39 | t.Cleanup(func() { closeTestSignal(release) }) |
| 40 | type result struct { |
| 41 | sessions []RemoteSessionView |
| 42 | err error |
| 43 | } |
| 44 | done := make(chan result, 1) |
| 45 | go func() { |
| 46 | sessions, err := a.RemoteProjectSessions("box", "~/app") |
| 47 | done <- result{sessions, err} |
| 48 | }() |
| 49 | waitTestSignal(t, started, "session listing did not start") |
| 50 | a.remoteTabMu.Lock() |
| 51 | gen := a.remoteTabs[meta.ID].gen |
| 52 | a.remoteTabMu.Unlock() |
| 53 | terminalPath := path |
| 54 | if recovered { |
| 55 | terminalPath = "/sessions/current-recovery.jsonl" |
| 56 | } |
| 57 | if routed := a.routeRemoteTabFrame(meta.ID, gen, terminalPath, "turn_done"); routed != !recovered { |
| 58 | t.Fatalf("terminal route result = %v, recovered=%v", routed, recovered) |
| 59 | } |
| 60 | fs.mu.Lock() |
| 61 | fs.sessions[0] = serveSessionEntry{Name: "current", Path: terminalPath, Current: true, Running: serveStillRunning} |
| 62 | fs.mu.Unlock() |
| 63 | closeTestSignal(release) |
| 64 | var got result |
| 65 | select { |
| 66 | case got = <-done: |
| 67 | case <-time.After(time.Second): |
| 68 | t.Fatal("session listing did not finish") |
| 69 | } |
| 70 | if got.err != nil { |
| 71 | t.Fatal(got.err) |
| 72 | } |
| 73 | for _, session := range got.sessions { |
| 74 | if session.Path == terminalPath && session.Running == serveStillRunning { |
| 75 | return |
| 76 | } |
| 77 | } |
| 78 | t.Fatalf("running row did not reconcile to %v: %+v", serveStillRunning, got.sessions) |
| 79 | } |
| 80 | |
| 81 | func TestAttachRemoteTabServeRejectsResponseAfterNewerAdoption(t *testing.T) { |
| 82 | const responsePath, newerPath = "/sessions/response.jsonl", "/sessions/newer.jsonl" |
| 83 | feed := make(chan string, 1) |
| 84 | fs := newFakeServe(t, "s3cret", nil) |
| 85 | fs.mu.Lock() |
| 86 | fs.eventFeed, fs.newSessionPath = feed, responsePath |
| 87 | fs.newStarted, fs.newRelease = make(chan struct{}, 1), make(chan struct{}) |
| 88 | started, release := fs.newStarted, fs.newRelease |
| 89 | fs.mu.Unlock() |
| 90 | t.Cleanup(func() { closeTestSignal(release) }) |
| 91 | tab := &remoteTab{id: "remote-1", state: "connecting", routing: remoteTabSessionRouting{running: map[string]bool{}}} |
| 92 | a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}} |
| 93 | ctx, cancel := context.WithCancel(t.Context()) |
| 94 | defer cancel() |
| 95 | type result struct { |
| 96 | entered bool |
| 97 | err error |
| 98 | } |
| 99 | done := make(chan result, 1) |
| 100 | go func() { |
| 101 | entered, err := a.attachRemoteTabServe(ctx, tab.id, fs.server.URL, "s3cret", "serve-1", RemoteTabOpenOptions{NewSession: true}) |
| 102 | done <- result{entered, err} |
| 103 | }() |
| 104 | waitTestSignal(t, started, "new-session attach did not start") |
| 105 | feed <- `{"kind":"session_changed","sessionPath":"/sessions/newer.jsonl","sessionCurrent":true}` |
| 106 | waitRemoteTestPath(t, a, tab, newerPath) |
| 107 | closeTestSignal(release) |
| 108 | select { |
| 109 | case got := <-done: |
| 110 | if got.err != nil || got.entered { |
| 111 | t.Fatalf("rejected attach result = entered %v, err %v", got.entered, got.err) |
| 112 | } |
| 113 | case <-time.After(time.Second): |
| 114 | t.Fatal("new-session attach did not finish") |
| 115 | } |
| 116 | waitRemoteTestPath(t, a, tab, newerPath) |
| 117 | } |
| 118 | |
| 119 | func TestRemoteModelResponseCannotLabelNewerForegroundSession(t *testing.T) { |
| 120 | testRemoteModelRouteFence(t, false) |
| 121 | } |
| 122 | |
| 123 | func TestRemoteModelResponseLabelsExpectedSessionAfterABARouteChange(t *testing.T) { |
| 124 | testRemoteModelRouteFence(t, true) |
| 125 | } |
| 126 | |
| 127 | func testRemoteModelRouteFence(t *testing.T, returnToExpected bool) { |
| 128 | t.Helper() |
| 129 | isolateDesktopUserDirs(t) |
| 130 | const oldPath, newerPath = "/sessions/old.jsonl", "/sessions/newer.jsonl" |
| 131 | started, release := make(chan struct{}), make(chan struct{}) |
| 132 | var requestPath string |
| 133 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 134 | requestPath = r.Header.Get(expectedSessionPathHeader) |
| 135 | close(started) |
| 136 | <-release |
| 137 | w.WriteHeader(http.StatusNoContent) |
| 138 | })) |
| 139 | defer server.Close() |
| 140 | tab := &remoteTab{ |
| 141 | id: "remote-1", state: "ready", client: server.Client(), base: server.URL, gen: 7, model: "old-model", |
| 142 | routing: remoteTabSessionRouting{currentPath: oldPath, pathRevision: 2, running: map[string]bool{}}, |
| 143 | } |
| 144 | a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}} |
| 145 | done := make(chan error, 1) |
| 146 | go func() { done <- a.SetRemoteTabModel(tab.id, "next-model") }() |
| 147 | waitTestSignal(t, started, "model request did not start") |
| 148 | a.adoptRemoteTabFrameCurrent(tab.id, tab.gen, newerPath, true) |
| 149 | if returnToExpected { |
| 150 | a.adoptRemoteTabFrameCurrent(tab.id, tab.gen, oldPath, true) |
| 151 | } |
| 152 | close(release) |
| 153 | if err := <-done; err != nil { |
| 154 | t.Fatal(err) |
| 155 | } |
| 156 | a.remoteTabMu.Lock() |
| 157 | model, path := tab.model, tab.routing.currentPath |
| 158 | a.remoteTabMu.Unlock() |
| 159 | wantPath, wantModel := newerPath, "old-model" |
| 160 | if returnToExpected { |
| 161 | wantPath, wantModel = oldPath, "next-model" |
| 162 | } |
| 163 | if requestPath != oldPath || path != wantPath || model != wantModel { |
| 164 | t.Fatalf("request/model/path = %q/%q/%q, want %q/%q/%q", requestPath, model, path, oldPath, wantModel, wantPath) |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | func closeTestSignal(ch chan struct{}) { |
| 169 | select { |
| 170 | case <-ch: |
| 171 | default: |
| 172 | close(ch) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | func waitTestSignal(t *testing.T, ch <-chan struct{}, message string) { |
| 177 | t.Helper() |
| 178 | select { |
| 179 | case <-ch: |
| 180 | case <-time.After(time.Second): |
| 181 | t.Fatal(message) |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | func waitRemoteTestPath(t *testing.T, a *App, tab *remoteTab, want string) { |
| 186 | t.Helper() |
| 187 | deadline := time.Now().Add(time.Second) |
| 188 | for { |
| 189 | a.remoteTabMu.Lock() |
| 190 | path := tab.routing.currentPath |
| 191 | a.remoteTabMu.Unlock() |
| 192 | if path == want { |
| 193 | return |
| 194 | } |
| 195 | if time.Now().After(deadline) { |
| 196 | t.Fatalf("remote path = %q, want %q", path, want) |
| 197 | } |
| 198 | time.Sleep(time.Millisecond) |
| 199 | } |
| 200 | } |
| 201 |