返回 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 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
154 lines GO