返回 DeepSeek-Reasonix
remote_tab_provisional_route_test.go
根目录 / desktop / remote_tab_provisional_route_test.go
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 events := &eventLog{}
78 a.remoteEventHook = events.add
79 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "old", SessionPath: oldPath})
80 statePrefix := "remote-tab:" + meta.ID + ":state "
81 readyPrefix := statePrefix + `{"state":"ready"`
82 waitForRemoteEventCount(t, events, readyPrefix, 1)
83 a.remoteTabMu.Lock()
84 initialGeneration := a.remoteTabs[meta.ID].gen
85 a.remoteTabMu.Unlock()
86 fs.mu.Lock()
87 fs.resumeDropCount = 1
88 fs.sessionsFailCount = 1
89 fs.mu.Unlock()
90
91 if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{SessionName: "target", SessionPath: targetPath}); err != nil {
92 t.Fatal(err)
93 }
94 // Reattach has no artificial delay in this fixture. The whole reconnect can
95 // finish between polls, so observe its retained publication sequence, not a
96 // transient state value. Wait for recovery first to exercise a late observer.
97 waitForRemoteEventCount(t, events, readyPrefix, 2)
98 if events.count(statePrefix+`{"state":"reconnecting"`) != 1 {
99 t.Fatalf("expected one published reconnect before recovery: %v", events.recorded())
100 }
101 sawReconnect, sawRecovered := false, false
102 for _, event := range events.recorded() {
103 if strings.HasPrefix(event, statePrefix+`{"state":"reconnecting"`) {
104 sawReconnect = true
105 } else if sawReconnect && strings.HasPrefix(event, readyPrefix) {
106 sawRecovered = true
107 }
108 }
109 if !sawRecovered {
110 t.Fatalf("ready was not published after reconnect: %v", events.recorded())
111 }
112 a.remoteTabMu.Lock()
113 tab := a.remoteTabs[meta.ID]
114 gate, route, state, generation := tab.routing.rehydratingPath, tab.routing.currentPath, tab.state, tab.gen
115 a.remoteTabMu.Unlock()
116 if state != "ready" || generation <= initialGeneration {
117 t.Fatalf("recovery state/generation = %q/%d, want ready after generation %d", state, generation, initialGeneration)
118 }
119 if gate != "" {
120 t.Fatalf("recovered tab still gated on %q", gate)
121 }
122 if route != targetPath {
123 t.Fatalf("recovered route = %q, want the selected %q", route, targetPath)
124 }
125 if err := a.SubmitRemoteTab(meta.ID, "after recovery"); err != nil {
126 t.Fatalf("submit after recovered resume: %v", err)
127 }
128 if _, err := a.RemoteTabStatus(meta.ID); err != nil {
129 t.Fatalf("status after recovered resume: %v", err)
130 }
131 }
132
133 // Re-selecting the session a ready tab already shows is not a switch: the
134 // registration must not open the provisional gate, so commands keep flowing
135 // while the idempotent /resume is in flight.
136 func TestReselectingCurrentRemoteSessionDoesNotEnterSwitchingState(t *testing.T) {
137 const path = "/sessions/s1.jsonl"
138 fs, a := reattachSelectionFixture(t, []serveSessionEntry{{Name: "s1", Path: path, Current: true}})
139 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "s1", SessionPath: path})
140
141 started := make(chan string, 1)
142 release := make(chan struct{})
143 fs.mu.Lock()
144 fs.resumeStarted, fs.resumeRelease = started, release
145 fs.mu.Unlock()
146 t.Cleanup(func() {
147 select {
148 case <-release:
149 default:
150 close(release)
151 }
152 })
153 if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{SessionName: "s1", SessionPath: path}); err != nil {
154 t.Fatal(err)
155 }
156 a.remoteTabMu.Lock()
157 gate := a.remoteTabs[meta.ID].routing.rehydratingPath
158 a.remoteTabMu.Unlock()
159 if gate != "" {
160 t.Fatalf("re-click of the current session opened the provisional gate on %q", gate)
161 }
162 select {
163 case <-started:
164 case <-time.After(3 * time.Second):
165 t.Fatal("re-click did not reach /resume")
166 }
167 if err := a.SubmitRemoteTab(meta.ID, "still here"); err != nil {
168 t.Fatalf("submit while the same-route resume is in flight: %v", err)
169 }
170 close(release)
171 waitForRemoteSessionIdentity(t, a, meta.ID, "s1", path)
172 if fenced := fs.recordedExpectedPaths(); len(fenced) == 0 || fenced[len(fenced)-1] != path {
173 t.Fatalf("submit fenced against %v, want %q", fenced, path)
174 }
175 for _, call := range fs.recorded() {
176 if strings.HasPrefix(call, "POST /submit") && !strings.Contains(call, "still here") {
177 t.Fatalf("unexpected submit recorded: %q", call)
178 }
179 }
180 }
181
181 lines GO