返回 DeepSeek-Reasonix
remote_window_test.go
根目录 / desktop / remote_window_test.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "runtime"
12 "strings"
13 "testing"
14 "time"
15
16 "reasonix/internal/config"
17 "reasonix/internal/remote"
18 "reasonix/internal/remote/bootstrap"
19 "reasonix/internal/remote/sshtest"
20 )
21
22 type reconnectWindowSink struct {
23 app *App
24 statuses chan RemoteConnectionStatusView
25 }
26
27 func (s *reconnectWindowSink) onStatus(v RemoteConnectionStatusView) {
28 s.app.onStatus(v)
29 select {
30 case s.statuses <- v:
31 default:
32 }
33 }
34
35 func (s *reconnectWindowSink) onForwards(hostID string, forwards []RemoteForwardView) {
36 s.app.onForwards(hostID, forwards)
37 }
38
39 func (s *reconnectWindowSink) onServer(v RemoteServerView) { s.app.onServer(v) }
40
41 // TestRemoteWindowHelperProcess is the target of registry tests that need a
42 // real live child process: it is spawned via os.Args[0] with a marker env and
43 // blocks until the test kills it.
44 func TestRemoteWindowHelperProcess(t *testing.T) {
45 if os.Getenv("REMOTE_WINDOW_HELPER_PROCESS") != "1" {
46 return
47 }
48 time.Sleep(120 * time.Second)
49 }
50
51 func spawnRemoteWindowHelper(t *testing.T) *os.Process {
52 t.Helper()
53 cmd := exec.Command(os.Args[0], "-test.run=TestRemoteWindowHelperProcess")
54 cmd.Env = append(os.Environ(), "REMOTE_WINDOW_HELPER_PROCESS=1")
55 if err := cmd.Start(); err != nil {
56 t.Fatal(err)
57 }
58 return cmd.Process
59 }
60
61 func waitRemoteWindowHelperExit(t *testing.T, proc *os.Process) {
62 t.Helper()
63 done := make(chan struct{})
64 go func() {
65 _, _ = proc.Wait()
66 close(done)
67 }()
68 select {
69 case <-done:
70 case <-time.After(10 * time.Second):
71 t.Fatal("remote window helper process did not exit after kill")
72 }
73 }
74
75 func TestRemoteWindowTicketRoundTripAndRemoval(t *testing.T) {
76 launch := remoteWindowLaunch{
77 URL: "http://127.0.0.1:54321/?token=secret-token",
78 Title: "Reasonix [SSH: box]",
79 HostKey: "host-key-digest",
80 }
81 ticket, err := writeRemoteWindowLaunch(launch)
82 if err != nil {
83 t.Fatal(err)
84 }
85 if strings.Contains(ticket, "secret-token") || filepath.Base(ticket) != ticket {
86 t.Fatalf("ticket leaked URL data or path: %q", ticket)
87 }
88 path, err := remoteWindowTicketPath(ticket)
89 if err != nil {
90 t.Fatal(err)
91 }
92 if runtime.GOOS != "windows" {
93 info, err := os.Stat(path)
94 if err != nil {
95 t.Fatal(err)
96 }
97 if got := info.Mode().Perm(); got != 0o600 {
98 t.Fatalf("ticket permissions = %o, want 600", got)
99 }
100 }
101 got, err := consumeRemoteWindowLaunch(ticket)
102 if err != nil {
103 t.Fatal(err)
104 }
105 if *got != launch {
106 t.Fatalf("launch = %+v, want %+v", *got, launch)
107 }
108 if _, err := os.Stat(path); !os.IsNotExist(err) {
109 t.Fatalf("ticket was not removed after consumption: %v", err)
110 }
111 }
112
113 func TestConsumeInitialRemoteWindowLaunchIsIdempotentAcrossDomReady(t *testing.T) {
114 launch := remoteWindowLaunch{
115 URL: "http://127.0.0.1:54321/?token=secret-token",
116 Title: "Reasonix [SSH: box]",
117 HostKey: "host-key-digest",
118 }
119 ticket, err := writeRemoteWindowLaunch(launch)
120 if err != nil {
121 t.Fatal(err)
122 }
123 a := &App{remoteWindowTicket: ticket}
124
125 got, first, err := a.consumeInitialRemoteWindowLaunch()
126 if err != nil {
127 t.Fatal(err)
128 }
129 if !first || got == nil || *got != launch {
130 t.Fatalf("first consume = (%+v, %v), want (%+v, true)", got, first, launch)
131 }
132 if _, err := os.Stat(filepath.Join(config.MemoryUserDir(), ticket)); !os.IsNotExist(err) {
133 t.Fatalf("initial ticket was not removed: %v", err)
134 }
135
136 got, first, err = a.consumeInitialRemoteWindowLaunch()
137 if err != nil {
138 t.Fatalf("repeated domReady returned an error: %v", err)
139 }
140 if first || got != nil {
141 t.Fatalf("repeated consume = (%+v, %v), want (nil, false)", got, first)
142 }
143 }
144
145 func TestRemoteWindowTicketRejectsUnsafeInputs(t *testing.T) {
146 for _, raw := range []string{
147 "https://127.0.0.1:5000/?token=x",
148 "http://example.com:5000/?token=x",
149 "file:///tmp/index.html",
150 "javascript:alert(1)",
151 } {
152 if _, err := writeRemoteWindowLaunch(remoteWindowLaunch{URL: raw, HostKey: "k"}); err == nil {
153 t.Fatalf("unsafe URL accepted: %q", raw)
154 }
155 }
156 if _, err := writeRemoteWindowLaunch(remoteWindowLaunch{URL: "http://127.0.0.1:5000/"}); err == nil {
157 t.Fatal("ticket without host identity accepted")
158 }
159 for _, ticket := range []string{"", "../.remote-window-x", "/tmp/.remote-window-x", "unrelated"} {
160 if _, err := remoteWindowTicketPath(ticket); err == nil {
161 t.Fatalf("unsafe ticket accepted: %q", ticket)
162 }
163 }
164 }
165
166 func TestConsumeRemoteWindowTicketRejectsBroadPermissions(t *testing.T) {
167 if runtime.GOOS == "windows" {
168 t.Skip("Windows does not expose Unix permission bits through os.Stat")
169 }
170 dir := config.MemoryUserDir()
171 if err := os.MkdirAll(dir, 0o700); err != nil {
172 t.Fatal(err)
173 }
174 ticket := remoteWindowTicketPrefix + "insecure"
175 path := filepath.Join(dir, ticket)
176 if err := os.WriteFile(path, []byte(`{"url":"http://127.0.0.1:5000/","hostKey":"k"}`), 0o644); err != nil {
177 t.Fatal(err)
178 }
179 t.Cleanup(func() { _ = os.Remove(path) })
180 if _, err := consumeRemoteWindowLaunch(ticket); err == nil {
181 t.Fatal("ticket with broad permissions was accepted")
182 }
183 }
184
185 func TestConsumeRemoteWindowTicketRejectsOversizedDescriptor(t *testing.T) {
186 dir := config.MemoryUserDir()
187 if err := os.MkdirAll(dir, 0o700); err != nil {
188 t.Fatal(err)
189 }
190 ticket := remoteWindowTicketPrefix + "oversized"
191 path := filepath.Join(dir, ticket)
192 if err := os.WriteFile(path, make([]byte, remoteWindowTicketMaxBytes+1), 0o600); err != nil {
193 t.Fatal(err)
194 }
195 if _, err := consumeRemoteWindowLaunch(ticket); err == nil {
196 t.Fatal("oversized ticket was accepted")
197 }
198 if _, err := os.Stat(path); !os.IsNotExist(err) {
199 t.Fatalf("rejected ticket was not removed: %v", err)
200 }
201 }
202
203 func TestConsumeRemoteWindowTicketRejectsExpiredTicket(t *testing.T) {
204 launch := remoteWindowLaunch{URL: "http://127.0.0.1:54321/", HostKey: "k"}
205 ticket, err := writeRemoteWindowLaunch(launch)
206 if err != nil {
207 t.Fatal(err)
208 }
209 path, err := remoteWindowTicketPath(ticket)
210 if err != nil {
211 t.Fatal(err)
212 }
213 // Age the ticket beyond the TTL so consumption must reject it even though
214 // the spawning process's AfterFunc backstop never ran.
215 old := time.Now().Add(-remoteWindowTicketTTL - time.Minute)
216 if err := os.Chtimes(path, old, old); err != nil {
217 t.Fatal(err)
218 }
219 if _, err := consumeRemoteWindowLaunch(ticket); err == nil {
220 t.Fatal("expired ticket was accepted")
221 }
222 if _, err := os.Stat(path); !os.IsNotExist(err) {
223 t.Fatalf("expired ticket was not removed: %v", err)
224 }
225 }
226
227 func TestRemoteWindowNavigationJSEscapesURL(t *testing.T) {
228 js, err := remoteWindowNavigationJS("http://127.0.0.1:5000/?token=x%22);alert(1)//")
229 if err != nil {
230 t.Fatal(err)
231 }
232 if !strings.HasPrefix(js, "window.location.replace(\"") || !strings.HasSuffix(js, "\");") {
233 t.Fatalf("unexpected navigation JS: %q", js)
234 }
235 if strings.Contains(js, "\");alert") {
236 t.Fatalf("URL escaped the JS string: %q", js)
237 }
238 }
239
240 func TestRemoteWindowHostKeyDistinguishesHosts(t *testing.T) {
241 a := remoteWindowHostKey("host-a")
242 b := remoteWindowHostKey("host-b")
243 ownerA := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
244 ownerB := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
245 if a == b {
246 t.Fatal("distinct hosts share a window identity")
247 }
248 if remoteWindowHostKey("host-a") != a {
249 t.Fatal("host key is not stable for the same host")
250 }
251 if strings.Contains(a, "host-a") || strings.Contains(b, "host-b") {
252 t.Fatal("host key leaks the host label")
253 }
254 if remoteWindowInstanceID(a, ownerA) == remoteWindowInstanceID(b, ownerA) {
255 t.Fatal("instance IDs collide across hosts")
256 }
257 if remoteWindowInstanceID(a, ownerA) == remoteWindowInstanceID(a, ownerB) {
258 t.Fatal("a restarted Desktop would adopt the previous owner's child window")
259 }
260 firstInstanceID := remoteWindowInstanceID(a, ownerA)
261 secondInstanceID := remoteWindowInstanceID(a, ownerA)
262 if firstInstanceID != secondInstanceID {
263 t.Fatal("instance ID is not stable within one Desktop owner")
264 }
265 if !strings.HasPrefix(firstInstanceID, remoteWindowInstancePrefix) {
266 t.Fatalf("instance ID = %q, want %q prefix", firstInstanceID, remoteWindowInstancePrefix)
267 }
268 }
269
270 func TestRemoteWindowOwnerIdentityIsRandomAndValid(t *testing.T) {
271 a := newRemoteWindowOwnerID()
272 b := newRemoteWindowOwnerID()
273 if !isRemoteWindowOwnerID(a) || !isRemoteWindowOwnerID(b) {
274 t.Fatalf("invalid owner identities: %q %q", a, b)
275 }
276 if a == b {
277 t.Fatal("two Desktop processes received the same remote window owner identity")
278 }
279 for _, invalid := range []string{"", "short", strings.Repeat("g", 32), strings.Repeat("a", 31)} {
280 if isRemoteWindowOwnerID(invalid) {
281 t.Fatalf("invalid owner identity accepted: %q", invalid)
282 }
283 }
284 }
285
286 func TestRemoteWindowOwnerWaitDetectsParentExit(t *testing.T) {
287 cmd := exec.Command(os.Args[0], "-test.run=TestRemoteWindowHelperProcess")
288 cmd.Env = append(os.Environ(), "REMOTE_WINDOW_HELPER_PROCESS=1")
289 if err := cmd.Start(); err != nil {
290 t.Fatal(err)
291 }
292 ctx, cancel := context.WithCancel(context.Background())
293 defer cancel()
294 exited := make(chan bool, 1)
295 go func() { exited <- waitForRemoteWindowOwnerExit(ctx, cmd.Process.Pid) }()
296 if err := cmd.Process.Kill(); err != nil {
297 t.Fatal(err)
298 }
299 _, _ = cmd.Process.Wait()
300 select {
301 case detected := <-exited:
302 if !detected {
303 t.Fatal("owner watcher stopped without detecting process exit")
304 }
305 case <-time.After(10 * time.Second):
306 t.Fatal("owner watcher did not detect process exit")
307 }
308 }
309
310 func TestRemoteWindowRegistryHandoffExitKeepsLiveWindow(t *testing.T) {
311 r := newRemoteWindowRegistry()
312 key := "host-a"
313
314 // W1 is the live window for the host. Re-opening the host spawns W2, which
315 // exits at the Wails single-instance gate after handing its ticket to W1.
316 // W2's Wait must clear only W2's own entry — W1 stays registered so
317 // disconnect/stop/quit can still close it and reconnect can re-point it.
318 live := spawnRemoteWindowHelper(t)
319 defer func() { _ = live.Kill(); waitRemoteWindowHelperExit(t, live) }()
320 liveGen := r.record(key, live)
321 handoff := spawnRemoteWindowHelper(t)
322 defer func() { _ = handoff.Kill(); waitRemoteWindowHelperExit(t, handoff) }()
323 handoffGen := r.record(key, handoff)
324
325 r.clearIf(key, handoffGen, handoff.Pid)
326 if !r.has(key) {
327 t.Fatal("handoff Wait cleared the live window registration")
328 }
329
330 // The live window's own exit clears the host's registration.
331 r.clearIf(key, liveGen, live.Pid)
332 if r.has(key) {
333 t.Fatal("registration not cleared by the live window's own Wait")
334 }
335 }
336
337 func TestRemoteWindowHostLifecycleSkipsSupersededOperation(t *testing.T) {
338 var registry remoteWindowLifecycleRegistry
339 stale := registry.begin("box")
340 current := registry.begin("box")
341
342 staleCalled := false
343 if err := stale.run(func(func() bool) error {
344 staleCalled = true
345 return nil
346 }); err != nil {
347 t.Fatal(err)
348 }
349 if staleCalled {
350 t.Fatal("superseded host lifecycle operation executed")
351 }
352
353 currentCalled := false
354 if err := current.run(func(isCurrent func() bool) error {
355 currentCalled = true
356 if !isCurrent() {
357 t.Fatal("current host lifecycle operation lost its generation")
358 }
359 return nil
360 }); err != nil {
361 t.Fatal(err)
362 }
363 if !currentCalled {
364 t.Fatal("latest host lifecycle operation did not execute")
365 }
366 }
367
368 func TestRemoteWindowRegistryGenerationProtectsNewerWindow(t *testing.T) {
369 r := newRemoteWindowRegistry()
370 key := "host-b"
371
372 p1 := spawnRemoteWindowHelper(t)
373 defer func() { _ = p1.Kill(); waitRemoteWindowHelperExit(t, p1) }()
374 g1 := r.record(key, p1)
375 if !r.has(key) {
376 t.Fatal("first child not registered")
377 }
378
379 // A newer spawn for the same host gets a distinct entry and generation. A
380 // stale Wait from the old child cannot remove the new entry.
381 p2 := spawnRemoteWindowHelper(t)
382 defer func() { _ = p2.Kill(); waitRemoteWindowHelperExit(t, p2) }()
383 g2 := r.record(key, p2)
384 if g2 <= g1 {
385 t.Fatalf("generation did not advance: %d then %d", g1, g2)
386 }
387 r.clearIf(key, g1, p1.Pid)
388 if !r.has(key) {
389 t.Fatal("stale Wait removed the newer registration")
390 }
391 r.clearIf(key, g2, p2.Pid)
392 if r.has(key) {
393 t.Fatal("registration not cleared by its own Wait")
394 }
395 }
396
397 func TestRemoteWindowRegistryCloseTerminatesChild(t *testing.T) {
398 r := newRemoteWindowRegistry()
399 key := "host-c"
400 p := spawnRemoteWindowHelper(t)
401 r.record(key, p)
402 r.close(key)
403 waitRemoteWindowHelperExit(t, p)
404 if r.has(key) {
405 t.Fatal("closed child still registered")
406 }
407 }
408
409 func TestRemoteWindowRegistryCloseAllTerminatesAll(t *testing.T) {
410 r := newRemoteWindowRegistry()
411 p1 := spawnRemoteWindowHelper(t)
412 defer waitRemoteWindowHelperExit(t, p1)
413 p2 := spawnRemoteWindowHelper(t)
414 defer waitRemoteWindowHelperExit(t, p2)
415 r.record("host-a", p1)
416 r.record("host-b", p2)
417 r.closeAll()
418 waitRemoteWindowHelperExit(t, p1)
419 waitRemoteWindowHelperExit(t, p2)
420 if r.has("host-a") || r.has("host-b") {
421 t.Fatal("closeAll left registrations behind")
422 }
423 }
424
425 func TestRemoteWindowLifecycleSkipsPrimaryRuntime(t *testing.T) {
426 a := NewApp()
427 a.remoteWindowTicket = remoteWindowTicketPrefix + "test"
428 a.startup(context.Background())
429 if a.tabsRestored != nil {
430 t.Fatal("remote window initialized local tab restore")
431 }
432 if a.heartbeat != nil || a.tray != nil || a.remoteRuntime != nil {
433 t.Fatal("remote window initialized primary-process runtime")
434 }
435 if a.beforeClose(context.Background()) {
436 t.Fatal("remote window close was intercepted")
437 }
438 a.shutdown(context.Background())
439 }
440
441 func TestRemoteWindowAssetMiddlewareDoesNotLoadPrimaryFrontend(t *testing.T) {
442 a := &App{remoteWindowTicket: remoteWindowTicketPrefix + "shell"}
443 nextCalled := false
444 h := a.remoteWindowAssetMiddleware()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
445 nextCalled = true
446 }))
447 rec := httptest.NewRecorder()
448 h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
449 if nextCalled {
450 t.Fatal("remote shell loaded the primary asset handler")
451 }
452 if strings.Contains(rec.Body.String(), "<script") {
453 t.Fatal("remote shell bootstrap unexpectedly contains frontend scripts")
454 }
455 if got := rec.Header().Get("Cache-Control"); got != "no-store" {
456 t.Fatalf("Cache-Control = %q", got)
457 }
458 }
459
460 func TestRemoteWindowAssetMiddlewarePassesThroughMainApp(t *testing.T) {
461 a := &App{}
462 nextCalled := false
463 h := a.remoteWindowAssetMiddleware()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
464 nextCalled = true
465 }))
466 h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
467 if !nextCalled {
468 t.Fatal("main app shell intercepted by the remote window middleware")
469 }
470 }
471
472 func TestRemoteWindowTitleSanitizesHostLabel(t *testing.T) {
473 if got := remoteWindowTitle(" box\nprod "); got != "Reasonix [SSH: boxprod]" {
474 t.Fatalf("title = %q", got)
475 }
476 }
477
478 func TestServeURLWithToken(t *testing.T) {
479 cases := []struct {
480 localURL, token, want string
481 }{
482 {"http://127.0.0.1:54321/", "tok-1", "http://127.0.0.1:54321?token=tok-1"},
483 {"http://127.0.0.1:54321", "tok-1", "http://127.0.0.1:54321?token=tok-1"},
484 {"http://127.0.0.1:54321/?token=old", "tok-1", "http://127.0.0.1:54321/?token=old"},
485 {"http://127.0.0.1:54321/", "", "http://127.0.0.1:54321/"},
486 }
487 for _, c := range cases {
488 if got := serveURLWithToken(c.localURL, c.token); got != c.want {
489 t.Fatalf("serveURLWithToken(%q, %q) = %q, want %q", c.localURL, c.token, got, c.want)
490 }
491 }
492 }
493
494 func TestOpenRemoteWorkspaceOpensWebWindow(t *testing.T) {
495 fake := &fakeRemoteKernel{
496 ensureView: RemoteServerView{HostID: "box", Workspace: "/srv", State: "ready", LocalURL: "http://127.0.0.1:54321/"},
497 ensureToken: "tok-123",
498 }
499 a := NewApp()
500 a.remoteRuntime = fake
501 calls := make(chan remoteWindowLaunch, 2)
502 a.remoteWindowOpener = func(l remoteWindowLaunch) error {
503 calls <- l
504 return nil
505 }
506 if err := a.OpenRemoteWorkspace("box", "/srv"); err != nil {
507 t.Fatal(err)
508 }
509 select {
510 case l := <-calls:
511 want := "http://127.0.0.1:54321?token=tok-123"
512 if l.URL != want {
513 t.Fatalf("window URL = %q, want %q", l.URL, want)
514 }
515 if !strings.Contains(l.Title, "box") {
516 t.Fatalf("window title = %q, want host label", l.Title)
517 }
518 if l.HostKey != remoteWindowHostKey("box") {
519 t.Fatalf("window host key = %q", l.HostKey)
520 }
521 case <-time.After(2 * time.Second):
522 t.Fatal("no web window opened")
523 }
524 if got := a.RemoteLastWorkspace("box"); got != "/srv" {
525 t.Fatalf("last workspace = %q, want /srv", got)
526 }
527 }
528
529 func TestOpenRemoteWorkspaceFailureKeepsWindowUntouched(t *testing.T) {
530 const hostID = "failing-box"
531 fake := &fakeRemoteKernel{ensureErr: errors.New("serve failed")}
532 a := NewApp()
533 a.remoteRuntime = fake
534 a.remoteWindowOpener = func(remoteWindowLaunch) error {
535 t.Fatal("window opened on serve failure")
536 return nil
537 }
538 if err := a.OpenRemoteWorkspace(hostID, "/srv"); err == nil {
539 t.Fatal("expected serve failure to surface")
540 }
541 if got := a.RemoteLastWorkspace(hostID); got != "" {
542 t.Fatalf("failed open recorded last workspace %q", got)
543 }
544 }
545
546 // TestOpenRemoteWorkspaceWorkspaceSwitchRequiresServerSuccess covers the atomic
547 // switch contract: a new Serve + tunnel must be established before the window
548 // is re-pointed; a failed switch keeps the previous window and last workspace.
549 func TestOpenRemoteWorkspaceWorkspaceSwitchRequiresServerSuccess(t *testing.T) {
550 fake := &fakeRemoteKernel{
551 ensureView: RemoteServerView{HostID: "box", Workspace: "/srv", State: "ready", LocalURL: "http://127.0.0.1:54321/"},
552 ensureToken: "t1",
553 }
554 a := NewApp()
555 a.remoteRuntime = fake
556 calls := make(chan remoteWindowLaunch, 4)
557 a.remoteWindowOpener = func(l remoteWindowLaunch) error {
558 calls <- l
559 return nil
560 }
561 if err := a.OpenRemoteWorkspace("box", "/srv"); err != nil {
562 t.Fatal(err)
563 }
564 if l := <-calls; !strings.HasSuffix(l.URL, "token=t1") {
565 t.Fatalf("first window URL = %q", l.URL)
566 }
567
568 // Successful switch to a new workspace re-points the window.
569 fake.ensureView = RemoteServerView{HostID: "box", Workspace: "/srv2", State: "ready", LocalURL: "http://127.0.0.1:5555/"}
570 fake.ensureToken = "t2"
571 if err := a.OpenRemoteWorkspace("box", "/srv2"); err != nil {
572 t.Fatal(err)
573 }
574 if l := <-calls; !strings.HasSuffix(l.URL, "token=t2") {
575 t.Fatalf("switched window URL = %q", l.URL)
576 }
577
578 // Failed switch leaves the window and last workspace untouched.
579 fake.ensureErr = errors.New("boom")
580 if err := a.OpenRemoteWorkspace("box", "/srv3"); err == nil {
581 t.Fatal("expected switch failure to surface")
582 }
583 select {
584 case l := <-calls:
585 t.Fatalf("window re-pointed on failed switch: %q", l.URL)
586 case <-time.After(200 * time.Millisecond):
587 }
588 if got := a.RemoteLastWorkspace("box"); got != "/srv2" {
589 t.Fatalf("last workspace after failed switch = %q, want /srv2", got)
590 }
591 }
592
593 func TestRemoteWindowCloseOnTerminalDisconnectKeepsTransient(t *testing.T) {
594 a := NewApp()
595 key := remoteWindowHostKey("box")
596 p := spawnRemoteWindowHelper(t)
597 defer waitRemoteWindowHelperExit(t, p)
598 a.remoteWindows.record(key, p)
599
600 // Transient reconnect states keep the window.
601 a.onStatus(RemoteConnectionStatusView{HostID: "box", State: "reconnecting"})
602 a.onStatus(RemoteConnectionStatusView{HostID: "box", State: "degraded"})
603 if !a.hasRemoteWindow("box") {
604 t.Fatal("window closed during transient reconnect")
605 }
606
607 // A deterministic terminal failure closes it.
608 a.onStatus(RemoteConnectionStatusView{HostID: "box", State: "stopped", Error: "auth failed"})
609 waitRemoteWindowHelperExit(t, p)
610 if a.hasRemoteWindow("box") {
611 t.Fatal("window survived terminal disconnect")
612 }
613 }
614
615 func TestRemoteWindowReconnectRepointsWindow(t *testing.T) {
616 fake := &fakeRemoteKernel{
617 ensureView: RemoteServerView{HostID: "box", Workspace: "/srv", State: "ready", LocalURL: "http://127.0.0.1:9999/"},
618 ensureToken: "tok-re",
619 }
620 a := NewApp()
621 a.remoteRuntime = fake
622 key := remoteWindowHostKey("box")
623 p := spawnRemoteWindowHelper(t)
624 defer func() { _ = p.Kill(); waitRemoteWindowHelperExit(t, p) }()
625 a.remoteWindows.record(key, p)
626 calls := make(chan remoteWindowLaunch, 2)
627 a.remoteWindowOpener = func(l remoteWindowLaunch) error {
628 calls <- l
629 return nil
630 }
631
632 a.onStatus(RemoteConnectionStatusView{HostID: "box", State: "connected"})
633 select {
634 case l := <-calls:
635 if l.URL != "http://127.0.0.1:9999?token=tok-re" {
636 t.Fatalf("re-pointed URL = %q", l.URL)
637 }
638 case <-time.After(3 * time.Second):
639 t.Fatal("window not re-pointed after reconnect")
640 }
641 }
642
643 func TestRemoteWindowRecoversAcrossRealSSHDrop(t *testing.T) {
644 const hostID = "box"
645 sshServer := sshtest.Start(t, sshtest.Options{Password: "test-password"})
646 serve := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
647 _, _ = w.Write([]byte("remote-serve-ok"))
648 }))
649 defer serve.Close()
650
651 host, err := remote.ResolveHost(nil, "test@"+sshServer.Addr, nil)
652 if err != nil {
653 t.Fatal(err)
654 }
655 knownHostsDir := t.TempDir()
656 policy := &remote.HostKeyPolicy{
657 SystemKnownHosts: []string{filepath.Join(knownHostsDir, "none")},
658 ManagedPath: filepath.Join(knownHostsDir, "known_hosts"),
659 Prompt: func(context.Context, remote.HostKeyQuestion) (bool, error) {
660 return true, nil
661 },
662 }
663
664 seedLifecycleHost(t, hostID)
665 a := NewApp()
666 sink := &reconnectWindowSink{app: a, statuses: make(chan RemoteConnectionStatusView, 32)}
667 mgr := newDesktopRemoteManager(sink)
668 a.remoteRuntime = mgr
669 mgr.newClient = func(opts remote.Options) (desktopSSHClient, error) {
670 opts.Host = host
671 opts.HostKeys = policy
672 opts.Auth = remote.AuthOptions{
673 DisableAgent: true,
674 Password: func() (string, error) { return "test-password", nil },
675 }
676 opts.Keepalive = remote.KeepalivePolicy{Interval: 25 * time.Millisecond, MaxMisses: 1, Timeout: 200 * time.Millisecond}
677 opts.Backoff = remote.BackoffPolicy{Initial: time.Millisecond, Max: 10 * time.Millisecond}
678 return remote.New(opts)
679 }
680 serveAddr := strings.TrimPrefix(serve.URL, "http://")
681 mgr.ensureServe = func(_ context.Context, _ bootstrap.Conn, opts bootstrap.Options) (bootstrap.Result, error) {
682 return bootstrap.Result{
683 State: bootstrap.ServeState{Addr: serveAddr, Workspace: opts.Workspace},
684 Token: "reconnect-token",
685 Reused: true,
686 }, nil
687 }
688
689 launches := make(chan remoteWindowLaunch, 4)
690 a.remoteWindowOpener = func(launch remoteWindowLaunch) error {
691 launches <- launch
692 return nil
693 }
694 window := spawnRemoteWindowHelper(t)
695 t.Cleanup(func() {
696 _ = mgr.Disconnect(hostID)
697 _ = window.Kill()
698 waitRemoteWindowHelperExit(t, window)
699 })
700
701 if err := mgr.Connect(hostID); err != nil {
702 t.Fatal(err)
703 }
704 waitForRemoteWindowStatus(t, sink.statuses, "connected", 0)
705 a.remoteWindows.record(remoteWindowHostKey(hostID), window)
706 if err := a.OpenRemoteWorkspace(hostID, "/srv/project"); err != nil {
707 t.Fatal(err)
708 }
709 first := waitForRemoteWindowLaunch(t, launches)
710 assertRemoteServeReachable(t, first.URL)
711
712 sshServer.DropConnections()
713 waitForRemoteWindowStatus(t, sink.statuses, "reconnecting", 1)
714 waitForRemoteWindowStatus(t, sink.statuses, "connected", 1)
715 refreshed := waitForRemoteWindowLaunch(t, launches)
716 if refreshed.URL != first.URL {
717 t.Fatalf("reconnected window URL = %q, want persistent forward URL %q", refreshed.URL, first.URL)
718 }
719 assertRemoteServeReachable(t, refreshed.URL)
720 }
721
722 func waitForRemoteWindowStatus(t *testing.T, statuses <-chan RemoteConnectionStatusView, state string, minAttempt int) {
723 t.Helper()
724 timer := time.NewTimer(10 * time.Second)
725 defer timer.Stop()
726 for {
727 select {
728 case status := <-statuses:
729 if status.State == state && status.Attempt >= minAttempt {
730 return
731 }
732 case <-timer.C:
733 t.Fatalf("remote status did not reach %s at attempt >= %d", state, minAttempt)
734 }
735 }
736 }
737
738 func waitForRemoteWindowLaunch(t *testing.T, launches <-chan remoteWindowLaunch) remoteWindowLaunch {
739 t.Helper()
740 select {
741 case launch := <-launches:
742 return launch
743 case <-time.After(10 * time.Second):
744 t.Fatal("remote window was not opened")
745 return remoteWindowLaunch{}
746 }
747 }
748
749 func assertRemoteServeReachable(t *testing.T, rawURL string) {
750 t.Helper()
751 client := &http.Client{Timeout: 5 * time.Second}
752 resp, err := client.Get(rawURL)
753 if err != nil {
754 t.Fatalf("GET remote Serve through SSH forward: %v", err)
755 }
756 defer resp.Body.Close()
757 if resp.StatusCode != http.StatusOK {
758 t.Fatalf("remote Serve status = %d, want 200", resp.StatusCode)
759 }
760 }
761
762 // TestRemoteWindowDisconnectClosesLiveWindowAfterHandoff is the real
763 // single-instance sequence: the live window stays registered while a
764 // short-lived handoff process (spawned by re-opening the host) exits at the
765 // gate. An explicit disconnect must still close the live window.
766 func TestRemoteWindowDisconnectClosesLiveWindowAfterHandoff(t *testing.T) {
767 fake := &fakeRemoteKernel{}
768 a := NewApp()
769 a.remoteRuntime = fake
770 key := remoteWindowHostKey("box")
771
772 live := spawnRemoteWindowHelper(t)
773 a.remoteWindows.record(key, live)
774 handoff := spawnRemoteWindowHelper(t)
775 defer func() { _ = handoff.Kill(); waitRemoteWindowHelperExit(t, handoff) }()
776 handoffGen := a.remoteWindows.record(key, handoff)
777 a.remoteWindows.clearIf(key, handoffGen, handoff.Pid)
778 if !a.hasRemoteWindow("box") {
779 t.Fatal("live window registration lost after handoff exit")
780 }
781
782 if err := a.DisconnectRemoteHost("box"); err != nil {
783 t.Fatal(err)
784 }
785 // Disconnect kills the live window; its Wait clears the registration.
786 waitRemoteWindowHelperExit(t, live)
787 if a.hasRemoteWindow("box") {
788 t.Fatal("live window survived explicit disconnect after handoff")
789 }
790 }
791
792 // TestOpenRemoteWorkspaceConcurrentDisconnectClosesLateWindow forces an
793 // explicit disconnect to begin while the child window opener is still in
794 // flight. The per-host lifecycle must let the opener finish registration first
795 // and then close that exact process; otherwise disconnect can miss the late
796 // registration and leave a window pointing at a dead tunnel.
797 func TestOpenRemoteWorkspaceConcurrentDisconnectClosesLateWindow(t *testing.T) {
798 fake := &fakeRemoteKernel{
799 ensureView: RemoteServerView{HostID: "box", Workspace: "/srv", State: "ready", LocalURL: "http://127.0.0.1:54321/"},
800 ensureToken: "tok-concurrent",
801 }
802 a := NewApp()
803 a.remoteRuntime = fake
804 key := remoteWindowHostKey("box")
805 live := spawnRemoteWindowHelper(t)
806 waited := false
807 defer func() {
808 if !waited {
809 _ = live.Kill()
810 waitRemoteWindowHelperExit(t, live)
811 }
812 }()
813
814 openerEntered := make(chan struct{})
815 releaseOpener := make(chan struct{})
816 a.remoteWindowOpener = func(remoteWindowLaunch) error {
817 close(openerEntered)
818 <-releaseOpener
819 a.remoteWindows.record(key, live)
820 return nil
821 }
822
823 openDone := make(chan error, 1)
824 go func() { openDone <- a.OpenRemoteWorkspace("box", "/srv") }()
825 select {
826 case <-openerEntered:
827 case <-time.After(5 * time.Second):
828 t.Fatal("remote window opener did not start")
829 }
830
831 value, ok := a.remoteWindowLifecycles.hosts.Load(key)
832 if !ok {
833 t.Fatal("host lifecycle was not registered")
834 }
835 hostLifecycle := value.(*remoteWindowHostLifecycle)
836 openGeneration := hostLifecycle.generation.Load()
837 disconnectDone := make(chan error, 1)
838 go func() { disconnectDone <- a.DisconnectRemoteHost("box") }()
839 deadline := time.Now().Add(5 * time.Second)
840 for hostLifecycle.generation.Load() == openGeneration {
841 if time.Now().After(deadline) {
842 t.Fatal("disconnect did not enter the host lifecycle")
843 }
844 runtime.Gosched()
845 }
846
847 close(releaseOpener)
848 select {
849 case err := <-openDone:
850 if err != nil {
851 t.Fatalf("OpenRemoteWorkspace: %v", err)
852 }
853 case <-time.After(5 * time.Second):
854 t.Fatal("OpenRemoteWorkspace did not finish")
855 }
856 select {
857 case err := <-disconnectDone:
858 if err != nil {
859 t.Fatalf("DisconnectRemoteHost: %v", err)
860 }
861 case <-time.After(5 * time.Second):
862 t.Fatal("DisconnectRemoteHost did not finish")
863 }
864
865 waitRemoteWindowHelperExit(t, live)
866 waited = true
867 if a.hasRemoteWindow("box") {
868 t.Fatal("late window registration survived concurrent disconnect")
869 }
870 }
871
872 // TestOpenRemoteWorkspaceWindowOpenFailureKeepsServeReady covers the two-phase
873 // switch contract: when the Serve and tunnel succeeded but the window open
874 // fails, the error surfaces, the serve stays ready for the new workspace, and
875 // no stale last-workspace is recorded.
876 func TestOpenRemoteWorkspaceWindowOpenFailureKeepsServeReady(t *testing.T) {
877 const hostID = "open-fail-box"
878 fake := &fakeRemoteKernel{
879 ensureView: RemoteServerView{HostID: hostID, Workspace: "/srv2", State: "ready", LocalURL: "http://127.0.0.1:6666/"},
880 ensureToken: "tok-fail",
881 }
882 a := NewApp()
883 a.remoteRuntime = fake
884 a.remoteWindowOpener = func(remoteWindowLaunch) error {
885 return errors.New("window spawn failed")
886 }
887 err := a.OpenRemoteWorkspace(hostID, "/srv2")
888 if err == nil || !strings.Contains(err.Error(), "window spawn failed") {
889 t.Fatalf("open error = %v, want the opener failure", err)
890 }
891 // The serve is up and ready for the new workspace; the recorded last
892 // workspace matches the running serve so the next open reuses it.
893 status, _ := a.RemoteServerStatus(hostID)
894 if status.State != "ready" || status.Workspace != "/srv2" {
895 t.Fatalf("serve state after failed open = %+v, want ready /srv2", status)
896 }
897 if got := a.RemoteLastWorkspace(hostID); got != "/srv2" {
898 t.Fatalf("last workspace = %q, want /srv2 (the running serve)", got)
899 }
900 }
901
902 func TestRemoteWindowDisconnectAndStopCloseWindow(t *testing.T) {
903 fake := &fakeRemoteKernel{}
904 a := NewApp()
905 a.remoteRuntime = fake
906 key := remoteWindowHostKey("box")
907 p := spawnRemoteWindowHelper(t)
908 defer waitRemoteWindowHelperExit(t, p)
909 a.remoteWindows.record(key, p)
910
911 if err := a.DisconnectRemoteHost("box"); err != nil {
912 t.Fatal(err)
913 }
914 waitRemoteWindowHelperExit(t, p)
915 if a.hasRemoteWindow("box") {
916 t.Fatal("window survived explicit disconnect")
917 }
918
919 p2 := spawnRemoteWindowHelper(t)
920 defer waitRemoteWindowHelperExit(t, p2)
921 a.remoteWindows.record(key, p2)
922 if err := a.StopRemoteServer("box"); err != nil {
923 t.Fatal(err)
924 }
925 waitRemoteWindowHelperExit(t, p2)
926 if a.hasRemoteWindow("box") {
927 t.Fatal("window survived stop-server")
928 }
929 }
930
930 lines GO