diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 2f09e2b084613..51b88e4255514 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -100,6 +100,11 @@ 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(); + 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. return { data: (input as { format: string }).format === "png" ? PNG_HEADER : "ZnJhbWU=" }; } @@ -287,6 +292,10 @@ 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 desktopCaptureRejects = false; +let desktopCaptureStarted = Promise.withResolvers(); const desktopDetaches = new NodeEvents.EventEmitter(); let desktopDetachReady = Promise.withResolvers(); let desktopReleased = Promise.withResolvers(); @@ -433,6 +442,9 @@ beforeEach(() => { desktopTabs.clear(); desktopRendersNext = false; desktopSizeAnswer = null; + desktopPaintsNothing = false; + desktopCaptureRejects = false; + desktopCaptureStarted = Promise.withResolvers(); desktopDetachReady = Promise.withResolvers(); desktopReleased = Promise.withResolvers(); releasedDesktopTabs.length = 0; @@ -549,6 +561,113 @@ 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, + }); + 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; + 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"); + 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" }), + ); + const kept = yield* browser.openDownload({ + threadId: scope.thread.threadId, + tabId, + downloadId, + }); + // Cleanup is not awaited and has no milestone, so only real time can show it never ran. + yield* TestClock.withLive(Effect.sleep("100 millis")); + expect(yield* fs.readFileString(Option.getOrThrow(kept).path)).toBe("a,b"); + }), + ).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 57e35be778634..fb260c1cc1112 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); @@ -476,6 +477,11 @@ const make = Effect.gen(function* () { const closedPendingTabs = new Set(); const adoptedPages = 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; @@ -729,13 +735,29 @@ 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 (!movedTabs.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 = Effect.fnUntraced(function* (tab: ServerTab) { + const key = tabKey(tab.threadId, tab.tabId); + 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 (movedTabs.delete(key)) { + void NodeFSP.rm(downloadDir(tab), { recursive: true, force: true }).catch(constVoid); + } + }); + const desktopRenders = (snapshot: PreviewSessionSnapshot) => desktopChannel.available && !snapshot.headless ? runPromise( @@ -771,6 +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 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) @@ -829,7 +853,7 @@ const make = Effect.gen(function* () { desktop: desktop === null ? null : { close: desktop.close }, profileId: snapshot.profileId, openerTabId: adopted?.openerTabId, - downloads: [], + downloads: moved?.downloads ?? [], fileChooser: null, dialog: null, setting: snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, @@ -970,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); }); @@ -2429,6 +2452,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(() => @@ -2437,8 +2461,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(); @@ -2482,10 +2508,22 @@ 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()) || !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)); + // 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) =>