返回 DeepSeek-Reasonix
remote_switch_async_test.go
根目录 / desktop / remote_switch_async_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "os"
6 "testing"
7 "time"
8
9 "reasonix/internal/config"
10 )
11
12 // OpenRemoteProjectTab adopts the clicked identity immediately; the Serve
13 // resume round trip runs in the background.
14
15 func TestOpenRemoteProjectTabResumeReturnsBeforeServeRoundTrip(t *testing.T) {
16 fs := newFakeServe(t, "s3cret", []serveSessionEntry{
17 {Name: "s1", Path: "/remote/sessions/s1.jsonl", Title: "First", Turns: 1, Current: true},
18 {Name: "s2", Path: "/remote/sessions/s2.jsonl", Title: "Second", Turns: 1},
19 })
20 kernel := &fakeRemoteKernel{
21 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
22 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
23 ensureToken: "s3cret",
24 }
25 seedBridgeTestHost(t, "box")
26 a := &App{remoteRuntime: kernel}
27 cleanupRemoteTabPumps(t, a)
28 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "s1", SessionPath: "/remote/sessions/s1.jsonl"})
29
30 started, release := make(chan string, 1), make(chan struct{})
31 fs.mu.Lock()
32 fs.resumeStarted, fs.resumeRelease = started, release
33 fs.mu.Unlock()
34 t.Cleanup(func() {
35 select {
36 case <-release:
37 default:
38 close(release)
39 }
40 })
41
42 type openResult struct {
43 meta TabMeta
44 err error
45 }
46 result := make(chan openResult, 1)
47 go func() {
48 meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{
49 SessionName: "s2", SessionPath: "/remote/sessions/s2.jsonl", SessionTitle: "Second",
50 })
51 result <- openResult{meta: meta, err: err}
52 }()
53 var switched TabMeta
54 select {
55 case got := <-result:
56 if got.err != nil {
57 t.Fatal(got.err)
58 }
59 switched = got.meta
60 case <-time.After(5 * time.Second):
61 t.Fatal("open blocked on the held resume round trip")
62 }
63 if want := "box\x00~/app\x00s2"; switched.TopicID != want {
64 t.Fatalf("returned meta TopicID = %q, want the adopted s2 identity %q", switched.TopicID, want)
65 }
66 select {
67 case path := <-started:
68 if path != "/remote/sessions/s2.jsonl" {
69 t.Fatalf("resume path = %q, want s2", path)
70 }
71 case <-time.After(5 * time.Second):
72 t.Fatal("background resume did not reach Serve")
73 }
74 a.remoteTabMu.Lock()
75 tab := a.remoteTabs[switched.ID]
76 gen := tab.gen
77 route := tab.routing.currentPath
78 a.remoteTabMu.Unlock()
79 if route != "/remote/sessions/s2.jsonl" {
80 t.Fatalf("route while resume is held = %q, want target", route)
81 }
82 if !a.routeRemoteTabFrame(switched.ID, gen, route, "approval_request") {
83 t.Fatal("target frame was rejected while resume request was held")
84 }
85 a.cacheRemotePendingEvent(switched.ID, gen, "approval_request", json.RawMessage(`{"kind":"approval_request","callId":"during-resume"}`))
86 close(release)
87 waitForRemoteSessionIdentity(t, a, meta.ID, "s2", "/remote/sessions/s2.jsonl")
88 a.remoteTabMu.Lock()
89 pending := len(a.remoteTabs[switched.ID].pendingEvents)
90 a.remoteTabMu.Unlock()
91 if pending != 1 {
92 t.Fatalf("pending target event count after resume = %d, want 1", pending)
93 }
94 cleanupRemoteTabPumps(t, a)
95 }
96
97 // A slow resume that lands after a newer switch must not stomp the newer
98 // session's identity or re-emit ready out of order.
99 func TestOpenRemoteProjectTabLateResumeCannotStompNewerSwitch(t *testing.T) {
100 fs := newFakeServe(t, "s3cret", []serveSessionEntry{
101 {Name: "s1", Path: "/remote/sessions/s1.jsonl", Title: "First", Turns: 1, Current: true},
102 {Name: "s2", Path: "/remote/sessions/s2.jsonl", Title: "Second", Turns: 1},
103 {Name: "s3", Path: "/remote/sessions/s3.jsonl", Title: "Third", Turns: 1},
104 })
105 kernel := &fakeRemoteKernel{
106 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
107 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
108 ensureToken: "s3cret",
109 }
110 seedBridgeTestHost(t, "box")
111 a := &App{remoteRuntime: kernel}
112 cleanupRemoteTabPumps(t, a)
113 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "s1", SessionPath: "/remote/sessions/s1.jsonl"})
114
115 started, release := make(chan string, 2), 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
127 if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{SessionName: "s2", SessionPath: "/remote/sessions/s2.jsonl"}); err != nil {
128 t.Fatal(err)
129 }
130 select {
131 case path := <-started:
132 if path != "/remote/sessions/s2.jsonl" {
133 t.Fatalf("first resume path = %q, want s2", path)
134 }
135 case <-time.After(5 * time.Second):
136 t.Fatal("first resume did not reach Serve")
137 }
138 // Switch again while the first Serve request is held: s3 must win.
139 if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{SessionName: "s3", SessionPath: "/remote/sessions/s3.jsonl"}); err != nil {
140 t.Fatal(err)
141 }
142 close(release)
143 select {
144 case path := <-started:
145 if path != "/remote/sessions/s3.jsonl" {
146 t.Fatalf("second resume path = %q, want s3", path)
147 }
148 case <-time.After(5 * time.Second):
149 t.Fatal("newer resume did not reach Serve after the held request completed")
150 }
151 waitForRemoteSessionIdentity(t, a, meta.ID, "s3", "/remote/sessions/s3.jsonl")
152 cleanupRemoteTabPumps(t, a)
153 }
154
155 func TestOpenRemoteProjectTabRejectedResumeRestoresPreviousIdentity(t *testing.T) {
156 const oldPath = "/remote/sessions/s1.jsonl"
157 const targetPath = "/remote/sessions/s2.jsonl"
158 fs := newFakeServe(t, "s3cret", []serveSessionEntry{
159 {Name: "s1", Path: oldPath, Title: "First", Turns: 1, Current: true},
160 {Name: "s2", Path: targetPath, Title: "Second", Turns: 1},
161 })
162 kernel := &fakeRemoteKernel{
163 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
164 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
165 ensureToken: "s3cret",
166 }
167 seedBridgeTestHost(t, "box")
168 a := &App{remoteRuntime: kernel}
169 cleanupRemoteTabPumps(t, a)
170 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "s1", SessionPath: oldPath, SessionTitle: "First"})
171
172 fs.mu.Lock()
173 fs.failEnter = "session is already leased by another process"
174 fs.mu.Unlock()
175 if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{
176 SessionName: "s2", SessionPath: targetPath, SessionTitle: "Second",
177 }); err != nil {
178 t.Fatal(err)
179 }
180 waitForRemoteTabError(t, a, meta.ID, "already leased")
181
182 a.remoteTabMu.Lock()
183 tab := a.remoteTabs[meta.ID]
184 name, path, route, title := tab.session.name, tab.session.path, tab.routing.currentPath, tab.topicTitle
185 a.remoteTabMu.Unlock()
186 if name != "s1" || path != oldPath || route != oldPath || title != "First" {
187 t.Fatalf("rejected async resume kept target identity: name/path/route/title = %q/%q/%q/%q", name, path, route, title)
188 }
189 }
190
191 func waitForRemoteSessionIdentity(t *testing.T, a *App, tabID, name, path string) {
192 t.Helper()
193 deadline := time.Now().Add(5 * time.Second)
194 for time.Now().Before(deadline) {
195 a.remoteTabMu.Lock()
196 tab := a.remoteTabs[tabID]
197 matches := tab != nil && tab.session.name == name && tab.session.path == path && tab.state == "ready"
198 a.remoteTabMu.Unlock()
199 if matches {
200 return
201 }
202 time.Sleep(time.Millisecond)
203 }
204 t.Fatalf("remote tab %q did not settle on %q at %q", tabID, name, path)
205 }
206
207 // Re-registering an already-pinned remote project must not rewrite the user
208 // config file: OpenRemoteProjectTab re-adds on every click, and the repeated
209 // disk write shows up as switch latency.
210 func TestAddRemoteProjectSkipsConfigRewriteWhenPinned(t *testing.T) {
211 seedBridgeTestHost(t, "box")
212 if _, err := addRemoteProjectForTest(t, "box", "~/app"); err != nil {
213 t.Fatal(err)
214 }
215 path := config.UserConfigPath()
216 before, err := os.ReadFile(path)
217 if err != nil {
218 t.Fatal(err)
219 }
220 statBefore, err := os.Stat(path)
221 if err != nil {
222 t.Fatal(err)
223 }
224 time.Sleep(20 * time.Millisecond)
225
226 view, err := addRemoteProjectForTest(t, "box", "~/app")
227 if err != nil {
228 t.Fatal(err)
229 }
230 if !view.Merged {
231 t.Fatalf("re-add did not merge into the existing pin: %+v", view)
232 }
233 after, err := os.ReadFile(path)
234 if err != nil {
235 t.Fatal(err)
236 }
237 statAfter, err := os.Stat(path)
238 if err != nil {
239 t.Fatal(err)
240 }
241 if statAfter.ModTime() != statBefore.ModTime() {
242 t.Fatalf("config rewritten on re-add: mtime %v -> %v", statBefore.ModTime(), statAfter.ModTime())
243 }
244 if string(after) != string(before) {
245 t.Fatalf("config content changed on re-add")
246 }
247 }
248
249 func TestAddRemoteProjectSerializesNoOpDecisionWithRemoval(t *testing.T) {
250 seedBridgeTestHost(t, "box")
251 if _, err := addRemoteProjectForTest(t, "box", "~/app"); err != nil {
252 t.Fatal(err)
253 }
254
255 unlock := config.LockUserConfigEdits()
256 result := make(chan error, 1)
257 go func() {
258 _, err := addRemoteProjectForTest(t, "box", "~/app")
259 result <- err
260 }()
261 select {
262 case err := <-result:
263 unlock()
264 t.Fatalf("AddRemoteProject bypassed the edit lock: %v", err)
265 case <-time.After(20 * time.Millisecond):
266 }
267
268 path := config.UserConfigPath()
269 cfg := config.LoadForEdit(path)
270 if cfg == nil || !cfg.RemoveRemoteProject("box", "~/app") {
271 unlock()
272 t.Fatal("failed to stage concurrent project removal")
273 }
274 if err := cfg.SaveTo(path); err != nil {
275 unlock()
276 t.Fatal(err)
277 }
278 unlock()
279 if err := <-result; err != nil {
280 t.Fatal(err)
281 }
282 loaded, err := config.Load()
283 if err != nil {
284 t.Fatal(err)
285 }
286 if _, ok := loaded.RemoteProject("box", "~/app"); !ok {
287 t.Fatal("successful AddRemoteProject was lost behind a concurrent removal")
288 }
289 }
290
291 func addRemoteProjectForTest(t *testing.T, hostID, workspace string) (RemoteProjectView, error) {
292 t.Helper()
293 a := &App{}
294 return a.AddRemoteProject(hostID, workspace)
295 }
296
296 lines GO