From 631eb3cd8e8159b5313eaf0aee2397942b22b024 Mon Sep 17 00:00:00 2001 From: Parker Brown Date: Fri, 30 Oct 2026 13:06:46 -0500 Subject: [PATCH 1/6] fix(server): remote viewers reach a tab the desktop cannot paint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A desktop-rendered tab only produces frames while its host window is composited. With the window minimized, the first capture never answers, so the stream socket never upgrades and the viewer stays on "Connecting…" forever. Give desktop tabs a deadline for the first frame. When it passes, mark the tab headless and tell the viewer to reconnect, which reopens the page in headless Chromium. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/Manager.ts | 8 ++- apps/server/src/preview/ServerBrowser.test.ts | 54 +++++++++++++++++++ apps/server/src/preview/ServerBrowser.ts | 24 +++++++-- 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 55156d605a152..8cdff1c6fa4e8 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -44,7 +44,7 @@ export class PreviewManager extends Context.Service< ) => Effect.Effect; /** Records that a server tab runs headless. It stays headless until it closes. */ readonly markHeadless: ( - input: PreviewCloseInput & { readonly tabId: string }, + input: PreviewCloseInput & { readonly tabId: string; readonly reload?: boolean }, ) => Effect.Effect; readonly requestReveal: ( input: PreviewCloseInput & { readonly tabId: string; readonly force: boolean }, @@ -278,8 +278,14 @@ export const make = Effect.gen(function* PreviewManagerMake() { input.tabId, Effect.fn("PreviewManager.markSessionHeadless")(function* (session) { if (session.snapshot.headless) return { next: session, emit: null, result: undefined }; + const { navStatus } = session.snapshot; const snapshot: PreviewSessionSnapshot = { ...session.snapshot, + // A new headless page only loads a tab that is still marked as loading. + navStatus: + input.reload && navStatus._tag === "Success" + ? { ...navStatus, _tag: "Loading" } + : navStatus, headless: true, updatedAt: yield* currentIsoTimestamp, }; diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 2f09e2b084613..799324d93a572 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -100,6 +100,10 @@ function makeSession( if (method === "Page.getNavigationHistory") return { currentIndex: 0, entries: [{}] }; if (method === "Page.getLayoutMetrics") return { cssVisualViewport: { pageX: 0, pageY: 0 } }; if (method === "Page.captureScreenshot") { + if (desktopPaintsNothing) { + desktopCaptureStarted.resolve(); + return new Promise(() => {}); + } // A snapshot sizes its screenshot from the PNG header; viewers get JPEG frames as-is. return { data: (input as { format: string }).format === "png" ? PNG_HEADER : "ZnJhbWU=" }; } @@ -287,6 +291,9 @@ let initialNavigation: (() => Promise) | null = null; let desktopRendersNext = false; /** Replaces the answer to the desktop page's size reads. */ let desktopSizeAnswer: (() => Promise>) | null = null; +/** A minimized host: the capture never answers because the guest paints no frame. */ +let desktopPaintsNothing = false; +let desktopCaptureStarted = Promise.withResolvers(); const desktopDetaches = new NodeEvents.EventEmitter(); let desktopDetachReady = Promise.withResolvers(); let desktopReleased = Promise.withResolvers(); @@ -433,6 +440,8 @@ beforeEach(() => { desktopTabs.clear(); desktopRendersNext = false; desktopSizeAnswer = null; + desktopPaintsNothing = false; + desktopCaptureStarted = Promise.withResolvers(); desktopDetachReady = Promise.withResolvers(); desktopReleased = Promise.withResolvers(); releasedDesktopTabs.length = 0; @@ -549,6 +558,51 @@ it.effect("a tab the desktop does not draw is marked headless, so the desktop st ).pipe(Effect.provide(layer)), ); +it.effect("a desktop tab that paints nothing moves to headless and its viewer reconnects", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + yield* ready; + desktopRendersNext = true; + const url = "https://example.com/account"; + const { tabId } = yield* manager.open({ + threadId: scope.thread.threadId, + runtime: "server", + url, + }); + yield* manager.reportStatus({ + threadId: scope.thread.threadId, + tabId, + navStatus: { _tag: "Success", url, title: "Account" }, + canGoBack: false, + canGoForward: false, + serverControlled: true, + }); + desktopPaintsNothing = true; + const attaching = yield* browser + .attachViewer(viewerInput(tabId, true)) + .pipe(Effect.forkScoped); + yield* Effect.promise(() => desktopCaptureStarted.promise); + yield* TestClock.adjust("5 seconds"); + const stalled = yield* Fiber.join(attaching); + + const items = yield* Queue.clear(stalled.output); + expect(items.map((item) => item._tag)).toContain("reconnect"); + const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); + const moved = sessions.find((session) => session.tabId === tabId); + expect(moved?.headless).toBe(true); + + desktopPaintsNothing = false; + yield* browser.attachViewer(viewerInput(tabId, true)); + expect(contexts.at(-1)!.page.goto).toHaveBeenCalledWith( + url, + expect.objectContaining({ waitUntil: "commit" }), + ); + }), + ).pipe(Effect.provide(layer)), +); + it.live.each([ { method: "goto" as const, message: { type: "navigate", url: "http://10.255.255.1/" } }, { method: "goBack" as const, message: { type: "history", delta: -1 } }, diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 57e35be778634..cd2b70e43578a 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -97,6 +97,7 @@ const SCREENCAST_MOTION_WINDOW_MS = 300; const SCREENCAST_MOTION_QUALITY = 50; const HOST_RECONNECT_DELAY = "1 second"; const DESKTOP_ATTACH_TIMEOUT = "10 seconds"; +const DESKTOP_FIRST_FRAME_TIMEOUT = "5 seconds"; const VIEWER_OUTPUT_LIMIT = 64; const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } as const; const decodeViewportSetting = Schema.decodeUnknownSync(PreviewViewportSettingSchema); @@ -736,6 +737,11 @@ const make = Effect.gen(function* () { } }; + const moveTabToHeadless = (tab: ServerTab) => + manager + .markHeadless({ threadId: ThreadId.make(tab.threadId), tabId: tab.tabId, reload: true }) + .pipe(Effect.ignore, Effect.andThen(Effect.sync(() => dropTab(tab, false)))); + const desktopRenders = (snapshot: PreviewSessionSnapshot) => desktopChannel.available && !snapshot.headless ? runPromise( @@ -2482,10 +2488,20 @@ const make = Effect.gen(function* () { }); }); void broadcastViewport(tab).catch(constVoid); - yield* Effect.promise(() => startScreencast(1)); - // An idle page does not repaint for a new screencast, so the viewer - // starts from a still unless a live frame beat it. - if (!screencastStarted) yield* Effect.promise(pushStill); + const firstFrame = Effect.promise(async () => { + await startScreencast(1); + // An idle page does not repaint for a new screencast, so the viewer + // starts from a still unless a live frame beat it. + if (!screencastStarted) await pushStill(); + }); + if (tab.desktop) { + const painted = yield* firstFrame.pipe(Effect.timeoutOption(DESKTOP_FIRST_FRAME_TIMEOUT)); + // A minimized host window paints no guest, so this page can never reach the viewer. + // Reconnecting moves the tab to headless Chromium, which needs no display. + if (Option.isNone(painted) && !screencastStarted) yield* moveTabToHeadless(tab); + } else { + yield* firstFrame; + } return { output, input: (raw: unknown) => From 603919b4286242a686a7a9ff58f4f9138cb70163 Mon Sep 17 00:00:00 2001 From: Parker Brown Date: Fri, 30 Oct 2026 13:15:09 -0500 Subject: [PATCH 2/6] fix(server): a tab keeps its downloads when it moves to headless Moving a desktop tab dropped it through the normal cleanup, which deleted its download folder even though the tab stays open. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 20 ++++++++++++++ apps/server/src/preview/ServerBrowser.ts | 27 ++++++++++++++----- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 799324d93a572..f7e54606d2137 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -579,6 +579,20 @@ it.effect("a desktop tab that paints nothing moves to headless and its viewer re canGoForward: false, serverControlled: true, }); + const painting = yield* browser.attachViewer(viewerInput(tabId, true)); + const fs = yield* FileSystem.FileSystem; + const original = `${yield* fs.makeTempDirectoryScoped()}/chromium-download`; + yield* fs.writeFileString(original, "a,b"); + desktopConnections[0]!.context.page.emit("download", { + failure: async () => null, + delete: async () => {}, + saveAs: (path: string) => NodeFSP.copyFile(original, path), + suggestedFilename: () => "report.csv", + url: () => "https://example.com/report.csv", + }); + let offered = yield* Queue.take(painting.output); + while (offered._tag !== "download") offered = yield* Queue.take(painting.output); + const downloadId = offered.id; desktopPaintsNothing = true; const attaching = yield* browser .attachViewer(viewerInput(tabId, true)) @@ -599,6 +613,12 @@ it.effect("a desktop tab that paints nothing moves to headless and its viewer re url, expect.objectContaining({ waitUntil: "commit" }), ); + const kept = yield* browser.openDownload({ + threadId: scope.thread.threadId, + tabId, + downloadId, + }); + expect(yield* fs.readFileString(Option.getOrThrow(kept).path)).toBe("a,b"); }), ).pipe(Effect.provide(layer)), ); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index cd2b70e43578a..82cf602847e31 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -477,6 +477,8 @@ const make = Effect.gen(function* () { const closedPendingTabs = new Set(); const adoptedPages = new Map(); + /** Downloads a tab keeps while it moves to a new headless page. */ + const movedDownloads = new Map>(); const closedSessions = new Set(); let hostConnectionId: string | null = null; let viewerResizeOrder = 0; @@ -730,17 +732,28 @@ const make = Effect.gen(function* () { else void tab.page.close().catch(constVoid); if (tab.isolatedContext) void tab.page.context().close().catch(constVoid); void tab.recording?.encoder.close().catch(constVoid); - void NodeFSP.rm(downloadDir(tab), { recursive: true, force: true }).catch(constVoid); + if (!movedDownloads.has(key)) { + void NodeFSP.rm(downloadDir(tab), { recursive: true, force: true }).catch(constVoid); + } reportLiveTabs(); if (closeSession) { runFork(manager.close({ threadId: tab.threadId, tabId: tab.tabId }).pipe(Effect.ignore)); } }; - const moveTabToHeadless = (tab: ServerTab) => - manager - .markHeadless({ threadId: ThreadId.make(tab.threadId), tabId: tab.tabId, reload: true }) - .pipe(Effect.ignore, Effect.andThen(Effect.sync(() => dropTab(tab, false)))); + const moveTabToHeadless = Effect.fnUntraced(function* (tab: ServerTab) { + const key = tabKey(tab.threadId, tab.tabId); + yield* manager + .markHeadless({ threadId: tab.threadId, tabId: tab.tabId, reload: true }) + .pipe(Effect.ignore); + movedDownloads.set(key, tab.downloads); + dropTab(tab, false); + yield* findTab(tab.threadId, tab.tabId).pipe(Effect.ignore); + // The session closed before the new page opened, so nothing owns the files. + if (movedDownloads.delete(key)) { + void NodeFSP.rm(downloadDir(tab), { recursive: true, force: true }).catch(constVoid); + } + }); const desktopRenders = (snapshot: PreviewSessionSnapshot) => desktopChannel.available && !snapshot.headless @@ -777,6 +790,8 @@ const make = Effect.gen(function* () { const createTab = async (snapshot: PreviewSessionSnapshot): Promise => { const adopted = adoptedPages.get(tabKey(snapshot.threadId, snapshot.tabId)); adoptedPages.delete(tabKey(snapshot.threadId, snapshot.tabId)); + const keptDownloads = movedDownloads.get(tabKey(snapshot.threadId, snapshot.tabId)); + movedDownloads.delete(tabKey(snapshot.threadId, snapshot.tabId)); const desktop = adopted === undefined && (await desktopRenders(snapshot)) ? await connectDesktop(snapshot) @@ -835,7 +850,7 @@ const make = Effect.gen(function* () { desktop: desktop === null ? null : { close: desktop.close }, profileId: snapshot.profileId, openerTabId: adopted?.openerTabId, - downloads: [], + downloads: keptDownloads ?? [], fileChooser: null, dialog: null, setting: snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, From bedd8a87285c991dad2096165c673e4281dc87a7 Mon Sep 17 00:00:00 2001 From: Parker Brown Date: Fri, 30 Oct 2026 13:16:45 -0500 Subject: [PATCH 3/6] test(server): wait for a stray delete in the moved download test Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index f7e54606d2137..a36789b69adbb 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -618,6 +618,9 @@ it.effect("a desktop tab that paints nothing moves to headless and its viewer re tabId, downloadId, }); + // Cleanup is not awaited and has no milestone, so only real time can show it never ran. + // oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- proving a delete did not land + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 100))); expect(yield* fs.readFileString(Option.getOrThrow(kept).path)).toBe("a,b"); }), ).pipe(Effect.provide(layer)), From 0477d3a62b933bca2f6dc2dfb884e5450b54e83f Mon Sep 17 00:00:00 2001 From: Parker Brown Date: Fri, 30 Oct 2026 13:23:56 -0500 Subject: [PATCH 4/6] test(server): use the live clock for the moved download wait Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index a36789b69adbb..8239dbdf33024 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -619,8 +619,7 @@ it.effect("a desktop tab that paints nothing moves to headless and its viewer re downloadId, }); // Cleanup is not awaited and has no milestone, so only real time can show it never ran. - // oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- proving a delete did not land - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 100))); + yield* TestClock.withLive(Effect.sleep("100 millis")); expect(yield* fs.readFileString(Option.getOrThrow(kept).path)).toBe("a,b"); }), ).pipe(Effect.provide(layer)), From b229c1a07ef6fe51f3c71c63eba7fbc23d840d8d Mon Sep 17 00:00:00 2001 From: Parker Brown Date: Fri, 30 Oct 2026 13:28:57 -0500 Subject: [PATCH 5/6] fix(server): a failed first capture still gets a viewer to a frame pushStill swallows capture errors, so a desktop tab whose still failed looked finished and never moved to headless, leaving the viewer without a picture. Wait for a live frame until the deadline instead, because one failed still does not prove the page cannot paint. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 28 +++++++++++++++++++ apps/server/src/preview/ServerBrowser.ts | 7 ++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 8239dbdf33024..e15c68f1bcfcf 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -102,6 +102,7 @@ function makeSession( if (method === "Page.captureScreenshot") { if (desktopPaintsNothing) { desktopCaptureStarted.resolve(); + if (desktopCaptureRejects) throw new Error("capture failed"); return new Promise(() => {}); } // A snapshot sizes its screenshot from the PNG header; viewers get JPEG frames as-is. @@ -293,6 +294,7 @@ let desktopRendersNext = false; let desktopSizeAnswer: (() => Promise>) | null = null; /** A minimized host: the capture never answers because the guest paints no frame. */ let desktopPaintsNothing = false; +let desktopCaptureRejects = false; let desktopCaptureStarted = Promise.withResolvers(); const desktopDetaches = new NodeEvents.EventEmitter(); let desktopDetachReady = Promise.withResolvers(); @@ -441,6 +443,7 @@ beforeEach(() => { desktopRendersNext = false; desktopSizeAnswer = null; desktopPaintsNothing = false; + desktopCaptureRejects = false; desktopCaptureStarted = Promise.withResolvers(); desktopDetachReady = Promise.withResolvers(); desktopReleased = Promise.withResolvers(); @@ -625,6 +628,31 @@ it.effect("a desktop tab that paints nothing moves to headless and its viewer re ).pipe(Effect.provide(layer)), ); +it.effect("a desktop tab whose first capture fails moves to headless without a frame", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + yield* ready; + desktopRendersNext = true; + const { tabId } = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + desktopPaintsNothing = true; + desktopCaptureRejects = true; + const attaching = yield* browser + .attachViewer(viewerInput(tabId, true)) + .pipe(Effect.forkScoped); + yield* Effect.promise(() => desktopCaptureStarted.promise); + yield* TestClock.adjust("5 seconds"); + const viewer = yield* Fiber.join(attaching); + + const items = yield* Queue.clear(viewer.output); + expect(items.map((item) => item._tag)).toContain("reconnect"); + const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); + expect(sessions.find((session) => session.tabId === tabId)?.headless).toBe(true); + }), + ).pipe(Effect.provide(layer)), +); + it.live.each([ { method: "goto" as const, message: { type: "navigate", url: "http://10.255.255.1/" } }, { method: "goBack" as const, message: { type: "history", delta: -1 } }, diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 82cf602847e31..8f57cfc40a31d 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -2450,6 +2450,7 @@ const make = Effect.gen(function* () { ServerBrowserPage.captureViewport(session, { format: "jpeg", quality }), ).catch(() => null); if (data) viewer.push({ _tag: "frame", data: Buffer.from(data, "base64"), ack: Effect.void }); + return data !== null; }; let settleTimer: ReturnType | null = null; yield* Effect.addFinalizer(() => @@ -2458,8 +2459,10 @@ const make = Effect.gen(function* () { }), ); let screencastStarted = false; + const firstLiveFrame = Promise.withResolvers(); session.on("Page.screencastFrame", (frame) => { screencastStarted = true; + firstLiveFrame.resolve(); if (framesInFlight > 0) mayHaveDropped = true; framesInFlight += 1; const arrivedAt = Date.now(); @@ -2507,7 +2510,9 @@ const make = Effect.gen(function* () { await startScreencast(1); // An idle page does not repaint for a new screencast, so the viewer // starts from a still unless a live frame beat it. - if (!screencastStarted) await pushStill(); + if (screencastStarted || (await pushStill()) || !tab.desktop) return; + // A failed still is not proof the page cannot paint; a live frame may still come. + await firstLiveFrame.promise; }); if (tab.desktop) { const painted = yield* firstFrame.pipe(Effect.timeoutOption(DESKTOP_FIRST_FRAME_TIMEOUT)); From e45a986719cc1ad166a1fbbc0856404b156d80b2 Mon Sep 17 00:00:00 2001 From: Parker Brown Date: Fri, 30 Oct 2026 13:39:51 -0500 Subject: [PATCH 6/6] fix(server): a moved tab keeps its URL and moves only once A late status report could put the tab back to Success before the new page opened, so it stayed on about:blank. Carry the URL with the move instead of relying on the session's status. Two viewers timing out together could also restore stale downloads and delete the new tab's folder, so the second move now stops when the tab has already been replaced. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/Manager.ts | 8 +---- apps/server/src/preview/ServerBrowser.test.ts | 15 ++++++++ apps/server/src/preview/ServerBrowser.ts | 36 ++++++++++--------- 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 8cdff1c6fa4e8..55156d605a152 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -44,7 +44,7 @@ export class PreviewManager extends Context.Service< ) => Effect.Effect; /** Records that a server tab runs headless. It stays headless until it closes. */ readonly markHeadless: ( - input: PreviewCloseInput & { readonly tabId: string; readonly reload?: boolean }, + input: PreviewCloseInput & { readonly tabId: string }, ) => Effect.Effect; readonly requestReveal: ( input: PreviewCloseInput & { readonly tabId: string; readonly force: boolean }, @@ -278,14 +278,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { input.tabId, Effect.fn("PreviewManager.markSessionHeadless")(function* (session) { if (session.snapshot.headless) return { next: session, emit: null, result: undefined }; - const { navStatus } = session.snapshot; const snapshot: PreviewSessionSnapshot = { ...session.snapshot, - // A new headless page only loads a tab that is still marked as loading. - navStatus: - input.reload && navStatus._tag === "Success" - ? { ...navStatus, _tag: "Loading" } - : navStatus, headless: true, updatedAt: yield* currentIsoTimestamp, }; diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index e15c68f1bcfcf..51b88e4255514 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -596,13 +596,28 @@ it.effect("a desktop tab that paints nothing moves to headless and its viewer re let offered = yield* Queue.take(painting.output); while (offered._tag !== "download") offered = yield* Queue.take(painting.output); const downloadId = offered.id; + yield* Effect.promise(() => desktopConnections[0]!.context.page.goto(url)); desktopPaintsNothing = true; const attaching = yield* browser .attachViewer(viewerInput(tabId, true)) .pipe(Effect.forkScoped); yield* Effect.promise(() => desktopCaptureStarted.promise); + const secondViewer = yield* browser + .attachViewer(viewerInput(tabId, true)) + .pipe(Effect.forkScoped); + // It queues behind the first capture, so its screencast is the milestone. + const desktopSessions = desktopConnections[0]!.context.sessions; + yield* Effect.promise(() => + vi.waitFor(() => { + expect(desktopSessions.at(-1)!.send).toHaveBeenCalledWith( + "Page.startScreencast", + expect.anything(), + ); + }), + ); yield* TestClock.adjust("5 seconds"); const stalled = yield* Fiber.join(attaching); + yield* Fiber.join(secondViewer); const items = yield* Queue.clear(stalled.output); expect(items.map((item) => item._tag)).toContain("reconnect"); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 8f57cfc40a31d..fb260c1cc1112 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -477,8 +477,11 @@ const make = Effect.gen(function* () { const closedPendingTabs = new Set(); const adoptedPages = new Map(); - /** Downloads a tab keeps while it moves to a new headless page. */ - const movedDownloads = new Map>(); + /** What a tab keeps while it moves to a new headless page. */ + const movedTabs = new Map< + string, + { readonly url: string; readonly downloads: Array } + >(); const closedSessions = new Set(); let hostConnectionId: string | null = null; let viewerResizeOrder = 0; @@ -732,7 +735,7 @@ const make = Effect.gen(function* () { else void tab.page.close().catch(constVoid); if (tab.isolatedContext) void tab.page.context().close().catch(constVoid); void tab.recording?.encoder.close().catch(constVoid); - if (!movedDownloads.has(key)) { + if (!movedTabs.has(key)) { void NodeFSP.rm(downloadDir(tab), { recursive: true, force: true }).catch(constVoid); } reportLiveTabs(); @@ -743,14 +746,14 @@ const make = Effect.gen(function* () { const moveTabToHeadless = Effect.fnUntraced(function* (tab: ServerTab) { const key = tabKey(tab.threadId, tab.tabId); - yield* manager - .markHeadless({ threadId: tab.threadId, tabId: tab.tabId, reload: true }) - .pipe(Effect.ignore); - movedDownloads.set(key, tab.downloads); + yield* manager.markHeadless({ threadId: tab.threadId, tabId: tab.tabId }).pipe(Effect.ignore); + // Another viewer that timed out on this tab may have moved it already. + if (tabs.get(key) !== tab) return; + movedTabs.set(key, { url: tab.page.url(), downloads: tab.downloads }); dropTab(tab, false); yield* findTab(tab.threadId, tab.tabId).pipe(Effect.ignore); // The session closed before the new page opened, so nothing owns the files. - if (movedDownloads.delete(key)) { + if (movedTabs.delete(key)) { void NodeFSP.rm(downloadDir(tab), { recursive: true, force: true }).catch(constVoid); } }); @@ -790,8 +793,8 @@ const make = Effect.gen(function* () { const createTab = async (snapshot: PreviewSessionSnapshot): Promise => { const adopted = adoptedPages.get(tabKey(snapshot.threadId, snapshot.tabId)); adoptedPages.delete(tabKey(snapshot.threadId, snapshot.tabId)); - const keptDownloads = movedDownloads.get(tabKey(snapshot.threadId, snapshot.tabId)); - movedDownloads.delete(tabKey(snapshot.threadId, snapshot.tabId)); + const moved = movedTabs.get(tabKey(snapshot.threadId, snapshot.tabId)); + movedTabs.delete(tabKey(snapshot.threadId, snapshot.tabId)); const desktop = adopted === undefined && (await desktopRenders(snapshot)) ? await connectDesktop(snapshot) @@ -850,7 +853,7 @@ const make = Effect.gen(function* () { desktop: desktop === null ? null : { close: desktop.close }, profileId: snapshot.profileId, openerTabId: adopted?.openerTabId, - downloads: keptDownloads ?? [], + downloads: moved?.downloads ?? [], fileChooser: null, dialog: null, setting: snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, @@ -991,15 +994,14 @@ const make = Effect.gen(function* () { tabs.set(key, tab); reportLiveTabs(); // Existing desktop and popup PDFs can start loading before interception attaches. - if ( - snapshot.navStatus._tag === "Loading" && - ((!adopted && !desktop) || isPdfAssetUrl(snapshot.navStatus.url)) - ) { + const initialUrl = + moved?.url ?? (snapshot.navStatus._tag === "Loading" ? snapshot.navStatus.url : null); + if (initialUrl !== null && ((!adopted && !desktop) || isPdfAssetUrl(initialUrl))) { const options = { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS } as const; tab.initialNavigation = ( - (desktop || adopted) && page.url() === snapshot.navStatus.url + (desktop || adopted) && page.url() === initialUrl ? page.reload(options) - : page.goto(snapshot.navStatus.url, options) + : page.goto(initialUrl, options) ).then(constVoid, (cause: unknown) => { throw ServerBrowserPage.toOperationError(cause, NAVIGATION_TIMEOUT_MS); });