Repository navigation
fix(server): remote viewers reach a tab the desktop cannot paint #27735
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
631eb3c
603919b
bedd8a8
0477d3a
b229c1a
e45a986
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string>(); | ||
| const adoptedPages = new Map<string, { readonly page: Page; readonly openerTabId: string }>(); | ||
| /** What a tab keeps while it moves to a new headless page. */ | ||
| const movedTabs = new Map< | ||
| string, | ||
| { readonly url: string; readonly downloads: Array<ServerDownload> } | ||
| >(); | ||
| const closedSessions = new Set<string>(); | ||
| 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<ServerTab> => { | ||
| 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<typeof setTimeout> | null = null; | ||
| yield* Effect.addFinalizer(() => | ||
|
|
@@ -2437,8 +2461,10 @@ const make = Effect.gen(function* () { | |
| }), | ||
| ); | ||
| let screencastStarted = false; | ||
| const firstLiveFrame = Promise.withResolvers<void>(); | ||
| 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High When a desktop tab with completed downloads is minimized, attaching a viewer triggers this fallback and deletes those files, so previously returned download paths and IDs stop working even though the logical tab remains open. 🤖 Copy this AI Prompt to have your agent fix this:
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| } else { | ||
| yield* firstFrame; | ||
| } | ||
| return { | ||
| output, | ||
| input: (raw: unknown) => | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Deliver reconnect before waiting for browser startup.
attachViewercannot return its queuedreconnectuntilfindTabfinishes. On a first headless launch,PreviewBrowsercan wait up to 45 seconds for installation, so the viewer remains connecting well beyond the five-second deadline. Start replacement creation independently and return the reconnect promptly. (raw.githubusercontent.com)