diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 3af55fefbc0e2..1b57dba45b6c0 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -2616,6 +2616,70 @@ it.live("a full server takes back the slot of the longest idle agent tab nobody ).pipe(Effect.provide(layer)), ); +it.effect( + "a viewer whose screencast Chromium never starts still attaches and lets snapshots run", + () => + Effect.scoped( + Effect.gen(function* () { + const { browser, broker, tabId } = yield* ready; + const context = contexts[0]!; + const stalledStarts = yield* Queue.unbounded(); + const openSession = context.newCDPSession; + context.newCDPSession = async (target: unknown) => { + const session = await openSession(target); + const answer = session.send.getMockImplementation()!; + session.send.mockImplementation(async (method: string, input?: unknown) => { + if (method !== "Page.startScreencast") return answer(method, input); + Queue.offerUnsafe(stalledStarts, undefined); + return new Promise(() => {}); + }); + return session; + }; + const attaching = yield* browser + .attachViewer(viewerInput(tabId, true)) + .pipe(Effect.forkScoped); + yield* Queue.take(stalledStarts); + yield* TestClock.adjust("5 seconds"); + const viewer = yield* Fiber.join(attaching); + expect((yield* Queue.takeAll(viewer.output)).map((item) => item._tag)).toContain("frame"); + const snapshotting = yield* broker + .invoke({ scope, tabId, operation: "snapshot", input: {} }) + .pipe(Effect.forkScoped); + yield* Queue.take(stalledStarts); + yield* TestClock.adjust("5 seconds"); + expect((yield* Fiber.join(snapshotting)).url).toBeDefined(); + }), + ).pipe(Effect.provide(layer)), +); + +it.effect("viewer cleanup detaches after a stalled stop command", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const viewerScope = yield* Scope.make(); + yield* browser + .attachViewer(viewerInput(tabId, true)) + .pipe(Effect.provideService(Scope.Scope, viewerScope)); + const session = contexts[0]!.sessions.at(-1)!; + const stopped = yield* Queue.unbounded(); + const stalledStop = Promise.withResolvers>(); + const answer = session.send.getMockImplementation()!; + session.send.mockImplementation(async (method: string, input?: unknown) => { + if (method !== "Page.stopScreencast") return answer(method, input); + Queue.offerUnsafe(stopped, undefined); + return stalledStop.promise; + }); + const closing = yield* Scope.close(viewerScope, Exit.void).pipe(Effect.forkScoped); + yield* Queue.take(stopped); + yield* TestClock.adjust("5 seconds"); + const detached = session.detach.mock.calls.length; + stalledStop.resolve({}); + yield* Fiber.join(closing); + expect(detached).toBe(1); + }), + ).pipe(Effect.provide(layer)), +); + const certificateError = { url: "https://device.local/", code: -202, diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 18d08e65acd08..24499058f1aad 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -106,6 +106,7 @@ const AGENT_CURSOR_MOVE_MS = 160; const AGENT_CURSOR_CLICK_LEAD_MS = 40; /** A dialog blocks page script, so a desktop size read gives up after this. */ const DESKTOP_VIEWPORT_READ_TIMEOUT = "1 second"; +const VIEWER_COMMAND_TIMEOUT = "5 seconds"; const sleepUntil = (deadline: number) => new Promise((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now()))); @@ -1805,6 +1806,14 @@ const make = Effect.gen(function* () { return started; }; + const withinViewerDeadline = (command: Promise): Promise => + runPromise( + Effect.promise(() => command.catch(() => null)).pipe( + Effect.timeoutOption(VIEWER_COMMAND_TIMEOUT), + Effect.map(Option.getOrNull), + ), + ); + const withScreencastsPaused = (tab: ServerTab, capture: () => Promise): Promise => withCaptureLock(tab, async () => { tab.capturing += 1; @@ -2569,9 +2578,7 @@ const make = Effect.gen(function* () { }), (session) => Effect.promise(() => - session - .send("Page.stopScreencast") - .catch(constVoid) + withinViewerDeadline(session.send("Page.stopScreencast")) .then(() => session.detach()) .catch(constVoid), ), @@ -2588,15 +2595,15 @@ const make = Effect.gen(function* () { screencastScale = scale; screencastParams = screencastParams.then(async () => { if (tab.capturing > 0) return; - await session.send("Page.stopScreencast").catch(constVoid); - await session - .send("Page.startScreencast", { + await withinViewerDeadline(session.send("Page.stopScreencast")); + await withinViewerDeadline( + session.send("Page.startScreencast", { format: "jpeg", quality: screencastScale < 1 ? Math.min(quality, SCREENCAST_MOTION_QUALITY) : quality, maxWidth: Math.max(1, Math.round(input.maxWidth * screencastScale)), maxHeight: Math.max(1, Math.round(input.maxHeight * screencastScale)), - }) - .catch(constVoid); + }), + ); }); return screencastParams; }; @@ -2629,9 +2636,9 @@ const make = Effect.gen(function* () { } }, pause: () => { - screencastParams = screencastParams.then(() => - session.send("Page.stopScreencast").then(constVoid, constVoid), - ); + screencastParams = screencastParams + .then(() => withinViewerDeadline(session.send("Page.stopScreencast"))) + .then(constVoid); return screencastParams; }, resume: () => startScreencast(screencastScale), @@ -2658,7 +2665,9 @@ const make = Effect.gen(function* () { pushFileChooser(tab); const pushStill = async () => { const data = await withCaptureLock(tab, () => - ServerBrowserPage.captureViewport(session, { format: "jpeg", quality }), + withinViewerDeadline( + ServerBrowserPage.captureViewport(session, { format: "jpeg", quality }), + ), ).catch(() => null); if (data) viewer.push({ _tag: "frame", data: Buffer.from(data, "base64"), ack: Effect.void }); };