| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "testing" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | // writeRemoteOnlyTabsFile persists a layout whose only surface is a remote |
| 12 | // shell: the state a single-surface remote user leaves behind on quit. |
| 13 | func writeRemoteOnlyTabsFile(t *testing.T) { |
| 14 | t.Helper() |
| 15 | dir := desktopConfigDir() |
| 16 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 17 | t.Fatal(err) |
| 18 | } |
| 19 | body := `{"tabs":null,"activeTab":"r1","remoteTabs":[{"id":"r1","hostId":"box","workspace":"~/app"}],"remoteTabOrder":["r1"],"tabOrder":["r1"]}` |
| 20 | if err := os.WriteFile(filepath.Join(dir, tabsFileName), []byte(body), 0o644); err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | // A remote-only layout restores one dormant workspace tab so local commands |
| 26 | // have a target. It must satisfy the base ownership contract on that first |
| 27 | // build — a resolvable workspace identity — while never taking the visible |
| 28 | // surface from the remote shell it was restored beside. |
| 29 | func TestRemoteOnlyRestoreBuildsOwnedDormantTabWithoutTakingTheSurface(t *testing.T) { |
| 30 | isolateDesktopUserDirs(t) |
| 31 | seedBridgeTestHost(t, "box") |
| 32 | writeRemoteOnlyTabsFile(t) |
| 33 | a := NewApp() |
| 34 | a.ctx = t.Context() |
| 35 | a.readyHook = func() {} |
| 36 | a.remoteRuntime = &fakeRemoteKernel{} |
| 37 | t.Cleanup(func() { a.shutdown(context.Background()) }) |
| 38 | |
| 39 | a.restoreOrBuildTabs() |
| 40 | |
| 41 | dormant, ctrl := a.activeOrSingleLocalTab() |
| 42 | if dormant == nil { |
| 43 | t.Fatal("remote-only restore left local commands without a target tab") |
| 44 | } |
| 45 | if ctrl != nil { |
| 46 | t.Fatal("dormant tab built a runtime at startup") |
| 47 | } |
| 48 | // 8955c156f resolves ownership by workspace identity, so the tab must |
| 49 | // carry one before its first build rather than deriving it later. |
| 50 | if dormant.SessionWorkspace.ID == "" || dormant.SessionWorkspace.ID != desktopWorkspaceID("global", globalTabWorkspaceRoot()) { |
| 51 | t.Fatalf("dormant workspace identity = %q, want the global workspace id", dormant.SessionWorkspace.ID) |
| 52 | } |
| 53 | if dormant.Scope != "global" || dormant.WorkspaceRoot != globalTabWorkspaceRoot() { |
| 54 | t.Fatalf("dormant scope/root = %q/%q, want the global workspace", dormant.Scope, dormant.WorkspaceRoot) |
| 55 | } |
| 56 | a.mu.RLock() |
| 57 | activeID, tabCount := a.activeTabID, len(a.tabs) |
| 58 | a.mu.RUnlock() |
| 59 | if activeID != "" || tabCount != 1 { |
| 60 | t.Fatalf("restore activeTabID=%q localTabs=%d, want one inactive dormant tab", activeID, tabCount) |
| 61 | } |
| 62 | |
| 63 | // The remote shell owns the visible surface; no fallback Global tab may |
| 64 | // claim it, before or after the frontend activates the remote surface. |
| 65 | a.remoteTabMu.Lock() |
| 66 | a.remoteTabLayout.activeID = "r1" |
| 67 | a.remoteTabMu.Unlock() |
| 68 | for _, meta := range a.ListTabs() { |
| 69 | if meta.Remote == nil && meta.Active { |
| 70 | t.Fatalf("dormant local tab claimed the visible surface: %+v", meta) |
| 71 | } |
| 72 | if meta.Remote != nil && !meta.Active { |
| 73 | t.Fatalf("remote surface lost the visible surface: %+v", meta) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | // Activating the dormant tab is the first demand for a runtime. |
| 78 | if err := a.SetActiveTab(dormant.ID); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | deadline := time.Now().Add(20 * time.Second) |
| 82 | for a.controllerForTab(dormant) == nil { |
| 83 | if time.Now().After(deadline) { |
| 84 | t.Fatal("activating the dormant tab never built a runtime") |
| 85 | } |
| 86 | time.Sleep(5 * time.Millisecond) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | // The dormant tab is resolved before it owns a runtime, so a concurrent |
| 91 | // activation can publish one while the open is still waiting for the rebuild |
| 92 | // lock. Adopting the runtime the tab owns at that point — not the one read |
| 93 | // before the wait — keeps the open from failing with a spurious |
| 94 | // "tab runtime changed" error. |
| 95 | func TestOpenSessionAdoptsRuntimePublishedWhileWaitingForTheRebuildLock(t *testing.T) { |
| 96 | app, tab, ctrl, _ := auditMigratedTab(t) |
| 97 | ref, ok := ctrl.SessionRef() |
| 98 | if !ok { |
| 99 | t.Fatal("fixture controller has no session ref") |
| 100 | } |
| 101 | // Model the caller that resolved the tab while it was still dormant. |
| 102 | app.mu.Lock() |
| 103 | tab.Ctrl = nil |
| 104 | app.mu.Unlock() |
| 105 | |
| 106 | // Holding turnStartMu parks the open after it takes runtimeRebuildMu, so |
| 107 | // the publication below is ordered strictly after the resolve it races. |
| 108 | tab.turnStartMu.Lock() |
| 109 | done := make(chan error, 1) |
| 110 | go func() { |
| 111 | _, err := app.resumeCanonicalSessionForTranscript(tab, nil, sessionRoute(ref.SessionID), defaultHistoryPageTurns, false) |
| 112 | done <- err |
| 113 | }() |
| 114 | // The open owns the rebuild lock once TryLock fails; it is then parked on |
| 115 | // turnStartMu, strictly after the resolve this publication races. |
| 116 | deadline := time.Now().Add(20 * time.Second) |
| 117 | for app.runtimeRebuildMu.TryLock() { |
| 118 | app.runtimeRebuildMu.Unlock() |
| 119 | if time.Now().After(deadline) { |
| 120 | tab.turnStartMu.Unlock() |
| 121 | t.Fatal("the open never reached the rebuild lock") |
| 122 | } |
| 123 | time.Sleep(time.Millisecond) |
| 124 | } |
| 125 | app.mu.Lock() |
| 126 | tab.Ctrl = ctrl |
| 127 | app.mu.Unlock() |
| 128 | tab.turnStartMu.Unlock() |
| 129 | |
| 130 | select { |
| 131 | case err := <-done: |
| 132 | if err != nil { |
| 133 | t.Fatalf("open adopted a stale nil runtime: %v", err) |
| 134 | } |
| 135 | case <-time.After(30 * time.Second): |
| 136 | t.Fatal("open did not finish after the runtime was published") |
| 137 | } |
| 138 | if got := app.controllerForTab(tab); got != ctrl { |
| 139 | t.Fatalf("tab controller after open = %p, want the published runtime %p", got, ctrl) |
| 140 | } |
| 141 | } |
| 142 |