| 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(®istration, "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 |