Skip to content

Commit b584620

Browse files
committed
fix(preview): make deadline retries idempotent
- Bound failed recording-start cleanup and always release its slot - Retry appearance changes when a replacement rejects the stale command - Reuse validated artifact keys across desktop save timeout retries 🤖 Co-authored by GPT-5 in Codex via T3 Code
1 parent 6716ed7 commit b584620

9 files changed

Lines changed: 178 additions & 30 deletions

File tree

‎BRANCH_DETAILS.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ This branch does not add a second recording/PiP capture lifecycle or another hid
2020
Expected behavior:
2121

2222
- Every Electron automation operation has a bounded control-session lifetime. The desktop manager reserves response grace inside the requested timeout without making the execution budget shrink when the caller increases a short timeout, always finalizes controller and action-timeline state, and detaches a timed-out debugger session while still holding an acquired control permit when a CDP command may be pending. Session removal and debugger teardown are atomic with respect to new session acquisition and bound to the exact acquired session, so late interruption or snapshot cleanup cannot detach a healthy replacement. Operations already queued on the retired semaphore detect that stale session and retry against its replacement. A request that times out while queued behind another action does not detach that action's shared debugger session.
23-
- Click, type, press, scroll, and wait operations clamp their caller-supplied timeout to the remaining renderer host budget before entering the desktop control-session boundary. Color-scheme changes and recording startup likewise receive the remaining deadline after overlay readiness; a timed-out color-scheme command does not persist a late preference, and timed-out recording startup tears down its frame-capture session and renderer recording state. Timed appearance persistence re-reads current tab state after CDP settles and reapplies to the current guest if the webview was replaced instead of trusting a pre-await target snapshot. Recording stop bounds desktop capture shutdown, MediaRecorder settlement, blob conversion, and artifact persistence to the remaining deadline; renderer and desktop-originated deadline failures retain captured chunks and the recording slot so finalization can be retried instead of silently losing the artifact. An in-flight artifact-save promise is shared with that retry, preventing duplicate timestamped files when the renderer deadline wins before desktop IPC settles. Operations without a caller timeout use the remaining bounded request budget rather than restarting the desktop default after renderer readiness work.
23+
- Click, type, press, scroll, and wait operations clamp their caller-supplied timeout to the remaining renderer host budget before entering the desktop control-session boundary. Color-scheme changes and recording startup likewise receive the remaining deadline after overlay readiness; a timed-out color-scheme command does not persist a late preference, and timed-out recording startup bounds cleanup to that deadline while always releasing its renderer recording slot. Timed appearance persistence re-reads current tab state after CDP settles and retries against the current guest if replacement rejects the stale guest's command. Recording stop bounds desktop capture shutdown, MediaRecorder settlement, blob conversion, and artifact persistence to the remaining deadline; renderer and desktop-originated deadline failures retain captured chunks and the recording slot so finalization can be retried instead of silently losing the artifact. An in-flight artifact-save promise is shared with that retry, and every retry reuses a validated desktop artifact idempotency key, preventing duplicate files whether the renderer deadline wins before desktop IPC settles or the desktop reports a timeout after writing. Operations without a caller timeout use the remaining bounded request budget rather than restarting the desktop default after renderer readiness work.
2424
- Snapshot collection keeps active-tab capture on CDP `Page.captureScreenshot` from the compositor surface. For an unselected tab, the renderer stages the still-mounted guest at effectively transparent opacity for two compositor frames, but only for the snapshot itself. The desktop manager captures that compositor surface without focusing the guest or calling `Page.bringToFront`; either activation call can make Electron promote the native guest over the host window and keep the T3 interface covered after staging ends. A separately bounded `webContents.capturePage` attempt provides a fallback, using `stayHidden: true` for background guests and normal visible-page capture for the foreground. Primary and fallback screenshot waits are clamped to the remaining control-session deadline, with budget reserved for fallback and result settlement, so a tight caller deadline can still return semantic data instead of being preempted by the outer session timeout. Every returned PNG, including resized output, is validated and bounded. Final screenshot failure or timeout is logged, an actually timed-out CDP capture resets the session before releasing its control permit, queued work reattaches before issuing its first command, and a capture skipped before CDP runs leaves the healthy session attached. The semantic page state, interactive elements, accessibility tree, diagnostics, and action timeline still return with `screenshot: null` instead of failing the complete snapshot.
2525
- Desktop preview guests following the system color scheme create their CDP debugger session lazily, with initialization included in the automation operation deadline. This prevents an offscreen Chromium guest from leaving `Runtime.enable` pending while holding the synchronized session lock, which previously made every later evaluation or snapshot against that tab time out even after it became presentable. `apps/web/src/browser/desktopTabLifetime.ts` passes the upstream browser appearance default through `DesktopPreviewCreateTabInputSchema` in `packages/contracts/src/ipc.ts`; `apps/desktop/src/preview/Manager.ts` normalizes that value. A non-system color-scheme override is restored after webview registration or detached DevTools closes through a separately bounded recovery path, while tabs following the system scheme stay detached until the next automation operation.
2626
- Building on upstream's retained hidden guest, automation background snapshot presentation is reference-counted independently from the normal surface lease and composes with upstream's fitted-source content and corner-radius presentation. `PreviewAutomationHosts.tsx` passes the epoch-scoped runtime tab id into `previewAutomationPresentation.ts`; every surface lookup, staging marker, readiness check, diagnostic read, lease, and desktop capture targets that exact runtime guest, while selection and errors retain the stable server tab id. The presentation helper API has no state-derived or server-id compatibility fallback. Only a one-shot automation snapshot acquires this lease; upstream recording and picture-in-picture continue to use their shared frame-capture lifecycle, while navigation, color-scheme changes, evaluation, waits, and input operations do not acquire an automation presentation lease. Staging always restores the offscreen position and does not change the human-selected surface. The entire lease, including compositor-frame staging and desktop IPC, is bounded by the operation's remaining response budget and reports a typed timeout if it stalls. If the server epoch replaces the runtime guest while staging is pending, the snapshot fails immediately with `PreviewAutomationTargetUnavailableError` instead of waiting on the stale staging marker. If the user foregrounds the target in either surface while staging is pending, that visible presentation satisfies readiness. A never-presented tab does not depend on another browser surface having supplied a panel rectangle: automation staging falls back to a deterministic rectangle fitted inside the renderer viewport.
@@ -76,7 +76,7 @@ vp test run scripts/dev-runner.test.ts apps/desktop/src/app/DesktopAppIdentity.t
7676

7777
Current verification:
7878

79-
- The branch-focused suite passed 284 tests across 19 files on Windows, including bounded post-overlay and queued resize mutations, retryable and deduplicated recording finalization across renderer and desktop timeouts, recording-start cleanup, replacement-guest appearance persistence, short-deadline viewport polling, post-read runtime-replacement, and background-guest accessibility regressions. The two unchanged desktop path-fixture files in the command above currently produce eight POSIX-versus-Windows path assertion failures; the affected runtime behavior passes in the remaining focused suite.
79+
- The branch-focused suite passed 287 tests across 19 files on Windows, including bounded post-overlay and queued resize mutations, retryable and idempotent recording finalization across renderer and desktop timeouts, deadline-bounded recording-start cleanup, replacement-guest appearance retries, short-deadline viewport polling, post-read runtime-replacement, and background-guest accessibility regressions. The two unchanged desktop path-fixture files in the command above currently produce eight POSIX-versus-Windows path assertion failures; the affected runtime behavior passes in the remaining focused suite.
8080
- The incoming browser-default, provider-access, pull-request budget, tooltip, update-copy, and settings coverage passed 552 tests across 26 files. The focused open-policy and open-session subset passes 18 tests, including a shared explicit-over-default presentation decision and a reused rendered tab that remains in the background without a visibility wait.
8181
- Desktop, server, contracts, and mobile typechecks pass. Web typecheck currently reports 16 existing errors in `BranchToolbarBranchSelector.tsx`, `ModelPickerContent.tsx`, `PreviewAutomationHosts.tsx` at the pre-existing registry access, `FontFamilyPicker.tsx`, `use-atom-command.ts`, and `use-atom-query-runner.ts`; none are in the browser-default integration changed here.
8282
- An isolated web client on ports `5744`/`13784` paired and loaded successfully. Settings → Integrations exposed the browser defaults and agent-access setting; agent browser access could be disabled and restored. The non-Electron client correctly disabled desktop-only viewport, zoom, and appearance controls.

‎apps/desktop/src/ipc/methods/preview.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,10 +376,11 @@ export const saveRecording = DesktopIpc.makeIpcMethod({
376376
tabId,
377377
mimeType,
378378
data,
379+
idempotencyKey,
379380
timeoutMs,
380381
}) {
381382
const manager = yield* PreviewManager.PreviewManager;
382-
return yield* manager.saveRecording(tabId, mimeType, data, timeoutMs);
383+
return yield* manager.saveRecording(tabId, mimeType, data, idempotencyKey, timeoutMs);
383384
}),
384385
});
385386

‎apps/desktop/src/preload.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,11 +215,12 @@ contextBridge.exposeInMainWorld("desktopBridge", {
215215
ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId, timeoutMs }),
216216
stopScreencast: (tabId, timeoutMs) =>
217217
ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, { tabId, timeoutMs }),
218-
save: (tabId, mimeType, data, timeoutMs) =>
218+
save: (tabId, mimeType, data, idempotencyKey, timeoutMs) =>
219219
ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, {
220220
tabId,
221221
mimeType,
222222
data,
223+
idempotencyKey,
223224
timeoutMs,
224225
}),
225226
onFrame: (listener) => {

‎apps/desktop/src/preview/Manager.test.ts‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1861,18 +1861,18 @@ describe("PreviewManager", () => {
18611861
),
18621862
);
18631863

1864-
effectIt.effect("reapplies a bounded color scheme after the webview is replaced", () =>
1864+
effectIt.effect("retries a bounded color scheme when replacement rejects the old command", () =>
18651865
withManager((manager) =>
18661866
Effect.gen(function* () {
1867-
let finishFirstGuest: (() => void) | undefined;
1867+
let rejectFirstGuest: ((cause: unknown) => void) | undefined;
18681868
const firstSendCommand = vi.fn(
18691869
async (method: string, parameters?: { features?: ReadonlyArray<{ value: string }> }) => {
18701870
if (
18711871
method === "Emulation.setEmulatedMedia" &&
18721872
parameters?.features?.[0]?.value === "dark"
18731873
) {
1874-
await new Promise<void>((resolve) => {
1875-
finishFirstGuest = resolve;
1874+
await new Promise<void>((_resolve, reject) => {
1875+
rejectFirstGuest = reject;
18761876
});
18771877
}
18781878
return undefined;
@@ -1907,6 +1907,7 @@ describe("PreviewManager", () => {
19071907
}),
19081908
detach: vi.fn(() => {
19091909
attached = false;
1910+
if (id === 42) rejectFirstGuest?.(new Error("old guest detached"));
19101911
}),
19111912
sendCommand,
19121913
on: vi.fn(),
@@ -1933,7 +1934,6 @@ describe("PreviewManager", () => {
19331934
yield* Effect.yieldNow;
19341935
yield* manager.registerWebview("tab_scheme_replacement", 43);
19351936
yield* Effect.yieldNow;
1936-
finishFirstGuest?.();
19371937
yield* Fiber.join(mutation);
19381938

19391939
expect(replacementSendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", {
@@ -1944,6 +1944,31 @@ describe("PreviewManager", () => {
19441944
),
19451945
);
19461946

1947+
effectIt.effect("uses an idempotent artifact path for recording save retries", () =>
1948+
withManager((manager) =>
1949+
Effect.gen(function* () {
1950+
const data = new Uint8Array([1, 2, 3]);
1951+
const first = yield* manager.saveRecording(
1952+
"tab_recording_save",
1953+
"video/webm",
1954+
data,
1955+
"8edc2f33-7bb4-4a30-97e8-e78f1d84513a",
1956+
);
1957+
const retry = yield* manager.saveRecording(
1958+
"tab_recording_save",
1959+
"video/webm",
1960+
data,
1961+
"8edc2f33-7bb4-4a30-97e8-e78f1d84513a",
1962+
);
1963+
1964+
expect(retry.id).toBe(first.id);
1965+
expect(retry.path).toBe(first.path);
1966+
expect(writeFile).toHaveBeenCalledTimes(2);
1967+
expect(writeFile.mock.calls[1]?.[0]).toBe(writeFile.mock.calls[0]?.[0]);
1968+
}),
1969+
),
1970+
);
1971+
19471972
effectIt.effect("blocks late webview and capture starts during tab close", () =>
19481973
withManager((manager) =>
19491974
Effect.gen(function* () {

‎apps/desktop/src/preview/Manager.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2345,7 +2345,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
23452345
if (remainingTimeoutMs <= 0) {
23462346
return yield* new PreviewAutomationTimeoutError({ tabId, timeoutMs });
23472347
}
2348-
yield* withControlSession(
2348+
const commandExit = yield* withControlSession(
23492349
tabId,
23502350
target,
23512351
"set-color-scheme",
@@ -2359,7 +2359,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
23592359
],
23602360
}),
23612361
remainingTimeoutMs,
2362-
);
2362+
).pipe(Effect.exit);
23632363
const currentTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
23642364
if (!currentTab) {
23652365
return yield* new PreviewTabNotFoundError({ tabId });
@@ -2368,6 +2368,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
23682368
target = yield* requireWebContents(tabId);
23692369
continue;
23702370
}
2371+
if (Exit.isFailure(commandExit)) {
2372+
return yield* Effect.failCause(commandExit.cause);
2373+
}
23712374
if (currentTab.colorScheme !== colorScheme) {
23722375
yield* update(tabId, { colorScheme });
23732376
}
@@ -2956,9 +2959,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
29562959
tabId: string,
29572960
mimeType: string,
29582961
data: Uint8Array,
2962+
idempotencyKey: string,
29592963
) {
2960-
const [createdAt, millis] = yield* Effect.all([currentIso, currentMillis]);
2961-
const id = `browser-recording-${millis.toString(36)}`;
2964+
const createdAt = yield* currentIso;
2965+
const id = `browser-recording-${idempotencyKey}`;
29622966
const extension = mimeType.includes("mp4") ? "mp4" : "webm";
29632967
const artifactPath = path.join(resolvedArtifactDirectory, `${id}.${extension}`);
29642968
yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe(
@@ -2997,9 +3001,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
29973001
tabId: string,
29983002
mimeType: string,
29993003
data: Uint8Array,
3004+
idempotencyKey: string,
30003005
timeoutMs?: number,
30013006
) {
3002-
const save = performSaveRecording(tabId, mimeType, data);
3007+
const save = performSaveRecording(tabId, mimeType, data, idempotencyKey);
30033008
if (timeoutMs === undefined) return yield* save;
30043009
const result = yield* save.pipe(Effect.timeoutOption(automationExecutionBudget(timeoutMs)));
30053010
if (Option.isSome(result)) return result.value;
@@ -4376,6 +4381,7 @@ export class PreviewManager extends Context.Service<
43764381
tabId: string,
43774382
mimeType: string,
43784383
data: Uint8Array,
4384+
idempotencyKey: string,
43794385
timeoutMs?: number,
43804386
) => Effect.Effect<DesktopPreviewRecordingArtifact, PreviewManagerError>;
43814387
readonly automationStatus: (

‎apps/web/src/browser/browserRecording.test.ts‎

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,22 @@ const {
3939
value.tabIds.size === 0 ? "clear" : `publish:${Array.from(value.tabIds).join(",")}`,
4040
);
4141
}),
42-
save: vi.fn(async (tabId: string) => ({
43-
id: "recording-test",
44-
tabId,
45-
path: "/tmp/recording-test.webm",
46-
mimeType: "video/webm" as const,
47-
sizeBytes: 0,
48-
createdAt: "2026-06-26T00:00:00.000Z",
49-
})),
42+
save: vi.fn(
43+
async (
44+
tabId: string,
45+
_mimeType?: string,
46+
_data?: Uint8Array,
47+
_idempotencyKey?: string,
48+
_timeoutMs?: number,
49+
) => ({
50+
id: "recording-test",
51+
tabId,
52+
path: "/tmp/recording-test.webm",
53+
mimeType: "video/webm" as const,
54+
sizeBytes: 0,
55+
createdAt: "2026-06-26T00:00:00.000Z",
56+
}),
57+
),
5058
startScreencast: vi.fn(async (tabId: string) => {
5159
events.push("start-screencast");
5260
const surface = surfaceState.byTabId[tabId] as
@@ -237,7 +245,30 @@ describe("browser recording", () => {
237245

238246
await rejection;
239247
expect(startScreencast).toHaveBeenCalledWith("recording-tab", 40);
240-
expect(stopScreencast).toHaveBeenCalledWith("recording-tab");
248+
expect(stopScreencast).toHaveBeenCalledWith("recording-tab", 1);
249+
expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());
250+
});
251+
252+
it("clears a timed-out startup even when screencast cleanup stalls", async () => {
253+
vi.useFakeTimers();
254+
startScreencast.mockImplementationOnce(async () => {
255+
events.push("start-screencast");
256+
});
257+
stopScreencast.mockImplementationOnce(
258+
async () => await new Promise<undefined>(() => undefined),
259+
);
260+
261+
const startPromise = startBrowserRecording("recording-tab", null, "recording-tab", 40);
262+
const rejection = expect(startPromise).rejects.toMatchObject({
263+
operation: "wait-first-frame",
264+
tabId: "recording-tab",
265+
});
266+
await Promise.resolve();
267+
await vi.advanceTimersByTimeAsync(40);
268+
await vi.runOnlyPendingTimersAsync();
269+
270+
await rejection;
271+
expect(stopScreencast).toHaveBeenCalledWith("recording-tab", 1);
241272
expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());
242273
});
243274

@@ -578,6 +609,29 @@ describe("browser recording", () => {
578609
expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());
579610
});
580611

612+
it("reuses the artifact idempotency key after a desktop save timeout", async () => {
613+
save.mockRejectedValueOnce({
614+
_tag: "PreviewAutomationTimeoutError",
615+
tabId: "recording-tab",
616+
timeoutMs: 40,
617+
});
618+
await startBrowserRecording("recording-tab");
619+
620+
await expect(stopBrowserRecording("recording-tab", 40)).rejects.toMatchObject({
621+
operation: "stop-deadline",
622+
tabId: "recording-tab",
623+
});
624+
expect(readActiveBrowserRecordingTabIds()).toEqual(new Set(["recording-tab"]));
625+
626+
await expect(stopBrowserRecording("recording-tab")).resolves.toMatchObject({
627+
tabId: "recording-tab",
628+
});
629+
expect(save).toHaveBeenCalledTimes(2);
630+
expect(save.mock.calls[0]?.[3]).toEqual(expect.any(String));
631+
expect(save.mock.calls[1]?.[3]).toBe(save.mock.calls[0]?.[3]);
632+
expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());
633+
});
634+
581635
it("finishes startup before stopping so an active recording yields an artifact", async () => {
582636
let finishStartingScreencast: (() => void) | undefined;
583637
startScreencast.mockImplementationOnce(async () => {

0 commit comments

Comments
 (0)