| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | // Retiring a pump generation must close the provisional route epoch: every |
| 11 | // commit and rollback of the in-flight /resume fences on that generation, so |
| 12 | // nothing else could ever clear the gate and the tab would refuse commands |
| 13 | // ("switching sessions") and buffer live frames forever. |
| 14 | func TestRetiringPumpGenerationClosesProvisionalRouteEpoch(t *testing.T) { |
| 15 | newTab := func() (*App, *remoteTab) { |
| 16 | tab := &remoteTab{ |
| 17 | id: "remote-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "ready", gen: 4, |
| 18 | routing: remoteTabSessionRouting{ |
| 19 | currentPath: "session-id:target", rehydratingPath: "session-id:target", |
| 20 | rehydratingFrames: []json.RawMessage{json.RawMessage(`{"kind":"notice"}`)}, |
| 21 | }, |
| 22 | } |
| 23 | return &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}, tab |
| 24 | } |
| 25 | assertClosed := func(t *testing.T, tab *remoteTab) { |
| 26 | t.Helper() |
| 27 | if tab.routing.rehydratingPath != "" || tab.routing.rehydratingFrames != nil { |
| 28 | t.Fatalf("retired generation left the provisional epoch open: path=%q frames=%d", tab.routing.rehydratingPath, len(tab.routing.rehydratingFrames)) |
| 29 | } |
| 30 | if tab.routing.currentPath != "session-id:target" { |
| 31 | t.Fatalf("retirement changed the committed route to %q", tab.routing.currentPath) |
| 32 | } |
| 33 | } |
| 34 | t.Run("reconnect", func(t *testing.T) { |
| 35 | a, tab := newTab() |
| 36 | if !a.reconnectRemoteTabGeneration(tab.id, 4) { |
| 37 | t.Fatal("current generation did not enter reconnecting") |
| 38 | } |
| 39 | assertClosed(t, tab) |
| 40 | }) |
| 41 | t.Run("retire", func(t *testing.T) { |
| 42 | a, tab := newTab() |
| 43 | a.retireRemoteTabGeneration(tab.id, 4) |
| 44 | assertClosed(t, tab) |
| 45 | }) |
| 46 | t.Run("suspend", func(t *testing.T) { |
| 47 | a, tab := newTab() |
| 48 | a.suspendRemoteTabPumps("box", "reconnecting", "") |
| 49 | assertClosed(t, tab) |
| 50 | }) |
| 51 | t.Run("park", func(t *testing.T) { |
| 52 | a, tab := newTab() |
| 53 | a.parkRemoteTabsForServer("box", "~/app", "serve_down", "stopped") |
| 54 | assertClosed(t, tab) |
| 55 | }) |
| 56 | t.Run("stale generation keeps the epoch", func(t *testing.T) { |
| 57 | a, tab := newTab() |
| 58 | a.retireRemoteTabGeneration(tab.id, 3) |
| 59 | if tab.routing.rehydratingPath != "session-id:target" { |
| 60 | t.Fatal("a stale generation retired the live epoch") |
| 61 | } |
| 62 | }) |
| 63 | } |
| 64 | |
| 65 | // A /resume that Serve commits but whose response is lost to the tunnel, with |
| 66 | // Serve unreachable for the reconcile probe, hands the tab to the reattach |
| 67 | // loop. The reattach finds Serve already on the selected session, so no |
| 68 | // transition re-installs the route; the recovered tab must still accept |
| 69 | // commands because the provisional gate belonged to the dead generation. |
| 70 | func TestRemoteResumeTransportFailureReattachAcceptsCommands(t *testing.T) { |
| 71 | const oldPath = "/sessions/old.jsonl" |
| 72 | const targetPath = "/sessions/target.jsonl" |
| 73 | fs, a := reattachSelectionFixture(t, []serveSessionEntry{ |
| 74 | {Name: "old", Path: oldPath, Current: true}, |
| 75 | {Name: "target", Path: targetPath}, |
| 76 | }) |
| 77 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "old", SessionPath: oldPath}) |
| 78 | fs.mu.Lock() |
| 79 | fs.resumeDropCount = 1 |
| 80 | fs.sessionsFailCount = 1 |
| 81 | fs.mu.Unlock() |
| 82 | |
| 83 | if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{SessionName: "target", SessionPath: targetPath}); err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | waitForTabState(t, a, meta.ID, "reconnecting") |
| 87 | waitForTabState(t, a, meta.ID, "ready") |
| 88 | a.remoteTabMu.Lock() |
| 89 | tab := a.remoteTabs[meta.ID] |
| 90 | gate, route := tab.routing.rehydratingPath, tab.routing.currentPath |
| 91 | a.remoteTabMu.Unlock() |
| 92 | if gate != "" { |
| 93 | t.Fatalf("recovered tab still gated on %q", gate) |
| 94 | } |
| 95 | if route != targetPath { |
| 96 | t.Fatalf("recovered route = %q, want the selected %q", route, targetPath) |
| 97 | } |
| 98 | if err := a.SubmitRemoteTab(meta.ID, "after recovery"); err != nil { |
| 99 | t.Fatalf("submit after recovered resume: %v", err) |
| 100 | } |
| 101 | if _, err := a.RemoteTabStatus(meta.ID); err != nil { |
| 102 | t.Fatalf("status after recovered resume: %v", err) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | // Re-selecting the session a ready tab already shows is not a switch: the |
| 107 | // registration must not open the provisional gate, so commands keep flowing |
| 108 | // while the idempotent /resume is in flight. |
| 109 | func TestReselectingCurrentRemoteSessionDoesNotEnterSwitchingState(t *testing.T) { |
| 110 | const path = "/sessions/s1.jsonl" |
| 111 | fs, a := reattachSelectionFixture(t, []serveSessionEntry{{Name: "s1", Path: path, Current: true}}) |
| 112 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "s1", SessionPath: path}) |
| 113 | |
| 114 | started := make(chan string, 1) |
| 115 | release := make(chan struct{}) |
| 116 | fs.mu.Lock() |
| 117 | fs.resumeStarted, fs.resumeRelease = started, release |
| 118 | fs.mu.Unlock() |
| 119 | t.Cleanup(func() { |
| 120 | select { |
| 121 | case <-release: |
| 122 | default: |
| 123 | close(release) |
| 124 | } |
| 125 | }) |
| 126 | if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{SessionName: "s1", SessionPath: path}); err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | a.remoteTabMu.Lock() |
| 130 | gate := a.remoteTabs[meta.ID].routing.rehydratingPath |
| 131 | a.remoteTabMu.Unlock() |
| 132 | if gate != "" { |
| 133 | t.Fatalf("re-click of the current session opened the provisional gate on %q", gate) |
| 134 | } |
| 135 | select { |
| 136 | case <-started: |
| 137 | case <-time.After(3 * time.Second): |
| 138 | t.Fatal("re-click did not reach /resume") |
| 139 | } |
| 140 | if err := a.SubmitRemoteTab(meta.ID, "still here"); err != nil { |
| 141 | t.Fatalf("submit while the same-route resume is in flight: %v", err) |
| 142 | } |
| 143 | close(release) |
| 144 | waitForRemoteSessionIdentity(t, a, meta.ID, "s1", path) |
| 145 | if fenced := fs.recordedExpectedPaths(); len(fenced) == 0 || fenced[len(fenced)-1] != path { |
| 146 | t.Fatalf("submit fenced against %v, want %q", fenced, path) |
| 147 | } |
| 148 | for _, call := range fs.recorded() { |
| 149 | if strings.HasPrefix(call, "POST /submit") && !strings.Contains(call, "still here") { |
| 150 | t.Fatalf("unexpected submit recorded: %q", call) |
| 151 | } |
| 152 | } |
| 153 | } |
| 154 |