Preview recording warmup timeout: behavioral proof Source: 1eb70db873408c760606c925e4c14e6c9439a7d9 Comparison source: 13aa882c22d584de5388dfce3b3ca413387ba059 Platform: Windows Change: 93 added lines, 8 deleted lines across Manager.ts and Manager.test.ts. A native capturePage warmup that never settles holds the per-tab recording lifecycle lock and leaves STOP queued. Each of the two best-effort warmup attempts now has the existing one-second capture deadline. After both deadlines, recording can continue to its native handoff and queued STOP can finish. The regression uses Effect TestClock, without sleeps or polling. Against the comparison Manager source, it fails because STOP is still queued after 2,000 ms. With the fix, it verifies two attempts, completed STOP, restored background throttling and cursor state, and rejection of display-media access after late warmup completion. The native recording handoff is mocked and settles normally. Validation command receipts install Command: vp i --frozen-lockfile Exit code: 0 target-red Command: vp test run apps/desktop/src/preview/Manager.test.ts -t "releases queued STOP" Exit code: 1 manager Command: vp test run apps/desktop/src/preview/Manager.test.ts Exit code: 0 typecheck Command: vp run --filter @t3tools/desktop typecheck Exit code: 0 lint Command: vp lint apps/desktop/src/preview/Manager.ts apps/desktop/src/preview/Manager.test.ts --report-unused-disable-directives Exit code: 0 format Command: vp fmt --check apps/desktop/src/preview/Manager.ts apps/desktop/src/preview/Manager.test.ts Exit code: 0 Limits: native capturePage promises remain uncancellable. The tests establish bounded Effect waits and queued STOP with a settling mocked handoff. They do not establish native resource reclamation, real compositor recovery, OS display-state transitions, or cross-platform runtime behavior. No integrated UI or mobile test was performed because the change is confined to the Electron main-process Manager service. One existing lint warning and two advisory typecheck suggestions remain on unchanged lines. === target-red output === RUN v5.0.1 stdout | apps/desktop/src/preview/Manager.test.ts > PreviewManager > releases queued STOP after stalled recording warmups and ignores late completion [20:07:32.702] ERROR (#1): Error [AssertionError]: expected false to be true // Object.is equality at apps/desktop/src/preview/Manager.test.ts:3376:29 { actual: false, expected: true, showDiff: true, operator: 'strictEqual', [cause]: undefined } ❯ apps/desktop/src/preview/Manager.test.ts (103 tests | 1 failed | 102 skipped) 209ms ❯ PreviewManager (89) × releases queued STOP after stalled recording warmups and ignores late completion 205ms Test Files 1 failed (1) Tests 1 failed | 102 skipped (103) Start at 20:06:23 Duration 70.21s (transform 73%, import 21%, setup 6%) Transform transforming modules took 49.32s · 73% of tracked time, re-done on every run persist transforms across runs with fsModuleCache: true learn more: https://vitest.dev/guide/improving-performance#caching-between-reruns note: You are running `vp test` as a Vite+ built-in command. If you meant to run the test npm script, use `vpr test` instead. ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ FAIL apps/desktop/src/preview/Manager.test.ts > PreviewManager > releases queued STOP after stalled recording warmups and ignores late completion AssertionError: expected false to be true // Object.is equality - Expected + Received - true + false ❯ apps/desktop/src/preview/Manager.test.ts:3376:29 3374| expect(host.executeJavaScript).not.toHaveBeenCalled(); 3375| yield* TestClock.adjust(1_000); 3376| expect(stopped).toBe(true); | ^ 3377| yield* Fiber.join(start); 3378| yield* Fiber.join(stop); ❯ IteratorImpl.~effect/Effect/successCont node_modules/.pnpm/effect@4.0.5/node_modules/effect/src/internal/effect.ts:1422:28 ❯ ExitPrimitive.~effect/Effect/evaluate node_modules/.pnpm/effect@4.0.5/node_modules/effect/src/internal/core.ts:585:30 ❯ FiberImpl.runLoop node_modules/.pnpm/effect@4.0.5/node_modules/effect/src/internal/effect.ts:654:41 ❯ FiberImpl.evaluate node_modules/.pnpm/effect@4.0.5/node_modules/effect/src/internal/effect.ts:595:23 ❯ FiberImpl.evaluate node_modules/.pnpm/effect@4.0.5/node_modules/effect/src/internal/effect.ts:589:27 ❯ node_modules/.pnpm/effect@4.0.5/node_modules/effect/src/internal/effect.ts:1159:17 ❯ Array. node_modules/.pnpm/effect@4.0.5/node_modules/effect/src/internal/effect.ts:804:44 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ === manager output === note: You are running `vp test` as a Vite+ built-in command. If you meant to run the test npm script, use `vpr test` instead. RUN v5.0.1 Test Files 1 passed (1) Tests 103 passed (103) Start at 20:08:18 Duration 47.61s (transform 49%, import 40%, tests 6%, setup 4%) Transform transforming modules took 21.42s · 49% of tracked time, re-done on every run persist transforms across runs with fsModuleCache: true learn more: https://vitest.dev/guide/improving-performance#caching-between-reruns === typecheck output === ~/apps/desktop$ tsc-rs --noEmit ⊘ cache disabled src/app/DesktopClerk.test.ts(282,27): suggestion TS377117: `Effect.succeedSome` expresses this Option success value directly. effect(preferSucceedSomeOrNone) src/app/DesktopWebLinks.test.ts(29,31): suggestion TS377117: `Effect.succeedSome` expresses this Option success value directly. effect(preferSucceedSomeOrNone) === lint output === note: You are running `vp lint` as a Vite+ built-in command. If you meant to run the lint npm script, use `vpr lint` instead. apps/desktop/src/preview/Manager.test.ts:238:18: warning typescript(no-extraneous-class): Unexpected class with only a constructor. help: Try replacing this class with a standalone function or deleting it entirely === format output === note: You are running `vp fmt` as a Vite+ built-in command. If you meant to run the fmt npm script, use `vpr fmt` instead. Checking formatting... All matched files use the correct format. Finished in 5597ms on 2 files using 16 threads. === Source patch === diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index c58ce6440a..23347ab5f7 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -3322,6 +3322,85 @@ describe("PreviewManager", () => { ), ); + effectIt.effect( + "releases queued STOP after stalled recording warmups and ignores late completion", + () => + withManager((manager) => + Effect.gen(function* () { + const warmupStarted = Promise.withResolvers(); + const firstWarmup = Promise.withResolvers(); + const lateWarmup = Promise.withResolvers(); + const capturePage = vi + .fn<() => Promise>() + .mockImplementationOnce(() => { + warmupStarted.resolve(); + return firstWarmup.promise; + }) + .mockImplementation(() => lateWarmup.promise); + const host = makeTestHostWebContents(); + const webContents = makeTestPreviewWebContents(capturePage, 42, host); + const windowThrottling = vi.fn(); + fromId.mockReturnValue(webContents); + yield* manager.createTab("tab_recording_stalled_warmup"); + yield* manager.registerWebview("tab_recording_stalled_warmup", 42); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: { setBackgroundThrottling: windowThrottling }, + } as never); + + const image = { + toJPEG: () => Buffer.from("late-recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + }; + try { + const start = yield* manager + .startRecording("tab_recording_stalled_warmup") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => warmupStarted.promise); + let stopped = false; + const stop = yield* manager.stopRecording("tab_recording_stalled_warmup").pipe( + Effect.andThen( + Effect.sync(() => { + stopped = true; + }), + ), + Effect.forkChild({ startImmediately: true }), + ); + expect(webContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); + expect(windowThrottling.mock.calls).toEqual([[false]]); + yield* TestClock.adjust(1_000); + expect(stopped).toBe(false); + expect(host.executeJavaScript).not.toHaveBeenCalled(); + yield* TestClock.adjust(1_000); + expect(stopped).toBe(true); + yield* Fiber.join(start); + yield* Fiber.join(stop); + expect(capturePage).toHaveBeenCalledTimes(2); + expect(host.executeJavaScript).toHaveBeenCalledOnce(); + expect(webContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(windowThrottling.mock.calls).toEqual([[false], [true]]); + expect(webviewSend).toHaveBeenLastCalledWith("preview:recording-cursor", false); + + lateWarmup.resolve(image); + yield* TestClock.adjust(0); + const streams: Array<{ video?: unknown }> = []; + host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => streams.push(value)); + expect(streams).toEqual([{}]); + expect(capturePage).toHaveBeenCalledTimes(2); + expect(host.executeJavaScript).toHaveBeenCalledOnce(); + expect(webContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(windowThrottling.mock.calls).toEqual([[false], [true]]); + expect(webviewSend).toHaveBeenLastCalledWith("preview:recording-cursor", false); + } finally { + // Release stalled native calls after a failing assertion against unchanged base. + firstWarmup.resolve(image); + lateWarmup.resolve(image); + } + }), + ), + ); + effectIt.effect("continues native recording when the source warmup fails", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index b4eca14575..b7637c4013 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -3095,14 +3095,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* attempt({ operation: "recording.cursor", tabId, webContentsId: wc.id }, () => wc.send(RECORDING_CURSOR_CHANNEL, true, options, tab?.controller), ); - yield* attemptPromise( - { - operation: "recording.warmSource", - tabId, - webContentsId: wc.id, - }, - () => wc.capturePage().then(() => undefined), - ).pipe(Effect.retry({ times: 1 }), Effect.ignore); + const warmupContext = { operation: "recording.warmSource", tabId, webContentsId: wc.id }; + yield* Effect.tryPromise({ + // Electron cannot cancel capturePage, but a stalled warmup must release the tab lock. + try: (_signal) => wc.capturePage(), + catch: (cause) => new PreviewOperationError({ ...warmupContext, cause }), + }).pipe( + Effect.timeout(CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail(new PreviewOperationError({ ...warmupContext, cause })), + }), + Effect.retry({ times: 1 }), + Effect.ignore, + ); const currentWebContents = yield* requireWebContents(tabId); if (currentWebContents !== wc || wc.isDestroyed()) { return yield* new PreviewWebContentsNotFoundError({