fix: settle closed browser captures and validate persisted layout fixtures

Destroying a guest during frame subscription left its screenshot pending until the capture deadline and blocked queued captures. Reject at destruction, remove the listener on every exit, and return browser_tab_closed. The regression proves immediate settlement and queue release.

Reuse the unchanged persisted layout schema from a UI-independent module so the recovery fixture validates storage before editing it.
This commit is contained in:
Mohamed Boudra
2026-09-15 00:36:46 +02:00
parent dcc1415f0c
commit 0f90f13bff
6 changed files with 245 additions and 142 deletions
@@ -6,6 +6,9 @@
// interactive in the [agent | New | explorer] state.
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "../../src/stores/workspace-layout-storage";
import type { WorkspaceTab } from "../../src/workspace-tabs/model";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
@@ -259,34 +262,49 @@ async function openAgentDraftFromLauncher(page: Page): Promise<void> {
}
async function persistHiddenGeneratedExplorer(page: Page): Promise<void> {
await page.evaluate(() => {
const stored = JSON.parse(localStorage.getItem("workspace-layout-state")!);
const key = Object.keys(stored.state.layoutByWorkspace)[0];
const tabs = [
{ tabId: "files", target: { kind: "files" }, createdAt: 1 },
{ tabId: "changes_tree", target: { kind: "changes_tree" }, createdAt: 1 },
...Array.from({ length: 2250 }, (_, index) => ({
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
if (raw === null) throw new Error("Explorer fixture: workspace layout was not persisted");
const stored = z
.strictObject({
state: WorkspaceLayoutPersistedStateSchema,
version: z.number().int().nonnegative().optional(),
})
.parse(JSON.parse(raw), { error: "Explorer fixture: invalid persisted workspace layout" });
const [key] = Object.keys(stored.state.layoutByWorkspace);
if (!key) throw new Error("Explorer fixture: persisted layout contains no workspace");
const tabs: WorkspaceTab[] = [
{ tabId: "files", target: { kind: "files" }, createdAt: 1 },
{ tabId: "changes_tree", target: { kind: "changes_tree" }, createdAt: 1 },
...Array.from(
{ length: 2250 },
(_, index): WorkspaceTab => ({
tabId: `draft_saved_${index}`,
target: { kind: "draft", draftId: `draft_saved_${index}` },
createdAt: index + 2,
})),
];
stored.state.layoutByWorkspace[key] = {
root: {
kind: "pane",
pane: {
id: "pane_generated_report_equivalent",
hidden: true,
tabIds: tabs.map((tab) => tab.tabId),
tabs,
focusedTabId: tabs[tabs.length - 1].tabId,
},
}),
),
];
stored.state.layoutByWorkspace[key] = {
root: {
kind: "pane",
pane: {
id: "pane_generated_report_equivalent",
hidden: true,
tabIds: tabs.map((tab) => tab.tabId),
tabs,
focusedTabId: tabs[tabs.length - 1].tabId,
},
focusedPaneId: null,
};
stored.state.explorerPaneIdByWorkspace[key] = "pane_generated_report_equivalent";
localStorage.setItem("workspace-layout-state", JSON.stringify(stored));
});
},
focusedPaneId: null,
};
stored.state.explorerPaneIdByWorkspace = {
...stored.state.explorerPaneIdByWorkspace,
[key]: "pane_generated_report_equivalent",
};
await page.evaluate(
(value) => localStorage.setItem("workspace-layout-state", value),
JSON.stringify(stored),
);
}
// Explorer cannot replace the ordinary workspace canvas, including on restore.
@@ -0,0 +1,114 @@
import { z } from "zod";
import type { SplitNode, WorkspaceLayout } from "./workspace-layout-actions";
const WorkspaceDraftTabSetupStorageSchema = z.strictObject({
provider: z.string(),
cwd: z.string(),
modeId: z.string().nullable(),
model: z.string().nullable(),
thinkingOptionId: z.string().nullable(),
featureValues: z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])),
});
const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [
z.strictObject({ kind: z.literal("new_tab") }),
z.strictObject({
kind: z.literal("draft"),
draftId: z.string(),
setup: WorkspaceDraftTabSetupStorageSchema.optional(),
}),
z.strictObject({ kind: z.literal("agent"), agentId: z.string() }),
z.strictObject({
kind: z.literal("provider_subagent"),
parentAgentId: z.string(),
subagentId: z.string(),
}),
z.strictObject({ kind: z.literal("terminal"), terminalId: z.string() }),
z.strictObject({ kind: z.literal("browser"), browserId: z.string() }),
z.strictObject({ kind: z.literal("changes_tree") }),
z.strictObject({ kind: z.literal("files") }),
z.strictObject({ kind: z.literal("pull_request") }),
z.strictObject({
kind: z.literal("file"),
path: z.string(),
lineStart: z.number().int().positive().optional(),
lineEnd: z.number().int().positive().optional(),
}),
z.strictObject({
kind: z.literal("working_diff"),
focusPath: z.string().optional(),
focusRequestId: z.number().optional(),
// COMPAT(workingDiffTarget): accepted from pre-canonical tab ids; normalization removes them.
mode: z.enum(["uncommitted", "base"]).optional(),
baseRef: z.string().nullable().optional(),
ignoreWhitespace: z.boolean().optional(),
}),
z.strictObject({ kind: z.literal("setup"), workspaceId: z.string() }),
z.strictObject({ kind: z.literal("commit_diff"), sha: z.string() }),
z.discriminatedUnion("context", [
z.strictObject({
kind: z.literal("plugin"),
pluginId: z.string(),
panelId: z.string(),
context: z.literal("workspace"),
}),
z.strictObject({
kind: z.literal("plugin"),
pluginId: z.string(),
panelId: z.string(),
context: z.literal("agent"),
agentId: z.string(),
}),
]),
]);
const WorkspaceTabStorageSchema = z.strictObject({
tabId: z.string(),
target: WorkspaceTabTargetStorageSchema,
createdAt: z.number(),
state: z.json().optional(),
});
const SplitNodeStorageSchema: z.ZodType<SplitNode> = z.lazy(() =>
z.discriminatedUnion("kind", [
z.strictObject({
kind: z.literal("pane"),
pane: z.strictObject({
id: z.string(),
tabIds: z.array(z.string()),
focusedTabId: z.string().nullable(),
tabs: z.array(WorkspaceTabStorageSchema).optional(),
hidden: z.boolean().optional(),
}),
}),
z.strictObject({
kind: z.literal("group"),
group: z.strictObject({
id: z.string(),
direction: z.enum(["horizontal", "vertical"]),
children: z.array(SplitNodeStorageSchema),
sizes: z.array(z.number()),
}),
}),
]),
);
const WorkspaceLayoutStorageSchema: z.ZodType<WorkspaceLayout> = z.strictObject({
root: SplitNodeStorageSchema,
focusedPaneId: z.string().nullable(),
parentTabIdByTabId: z.record(z.string(), z.string()).optional(),
});
export const WorkspaceLayoutPersistedStateSchema = z.strictObject({
pinnedAgentIdsByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema),
splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(),
explorerSidebarWidthByWorkspace: z.record(z.string(), z.number()).optional(),
// COMPAT(explorerSidebarWidth): added in v0.6, remove after 2027-08-25.
explorerSidebarRatioByWorkspace: z.record(z.string(), z.number()).optional(),
// COMPAT(explorerSidebarNaming): accepted from builds that called this dock the Side panel.
sidePanelRatioByWorkspace: z.record(z.string(), z.number()).optional(),
// The persisted keys keep their pre-rename spelling: the schema is strict, so a
// rename here would fail every existing blob and wipe the layout it describes.
explorerPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
explorerSidebarPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
sidePaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
// COMPAT(pullRequestAutoAdd): PR detection stopped opening a tab in v0.5; accepted
// and ignored so upgrading does not discard the layout. Remove after 2027-08-20.
acknowledgedPullRequestByWorkspace: z.record(z.string(), z.string()).optional(),
});
@@ -2,7 +2,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect, useState } from "react";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { z } from "zod";
import type { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "./workspace-layout-storage";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
import {
@@ -174,118 +175,6 @@ interface WorkspaceFocusRestorationState {
// Preserve four user-created split levels beneath that bookkeeping node.
const MAX_TREE_DEPTH = 5;
const WorkspaceDraftTabSetupStorageSchema = z.strictObject({
provider: z.string(),
cwd: z.string(),
modeId: z.string().nullable(),
model: z.string().nullable(),
thinkingOptionId: z.string().nullable(),
featureValues: z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])),
});
const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [
z.strictObject({ kind: z.literal("new_tab") }),
z.strictObject({
kind: z.literal("draft"),
draftId: z.string(),
setup: WorkspaceDraftTabSetupStorageSchema.optional(),
}),
z.strictObject({ kind: z.literal("agent"), agentId: z.string() }),
z.strictObject({
kind: z.literal("provider_subagent"),
parentAgentId: z.string(),
subagentId: z.string(),
}),
z.strictObject({ kind: z.literal("terminal"), terminalId: z.string() }),
z.strictObject({ kind: z.literal("browser"), browserId: z.string() }),
z.strictObject({ kind: z.literal("changes_tree") }),
z.strictObject({ kind: z.literal("files") }),
z.strictObject({ kind: z.literal("pull_request") }),
z.strictObject({
kind: z.literal("file"),
path: z.string(),
lineStart: z.number().int().positive().optional(),
lineEnd: z.number().int().positive().optional(),
}),
z.strictObject({
kind: z.literal("working_diff"),
focusPath: z.string().optional(),
focusRequestId: z.number().optional(),
// COMPAT(workingDiffTarget): accepted from pre-canonical tab ids; normalization removes them.
mode: z.enum(["uncommitted", "base"]).optional(),
baseRef: z.string().nullable().optional(),
ignoreWhitespace: z.boolean().optional(),
}),
z.strictObject({ kind: z.literal("setup"), workspaceId: z.string() }),
z.strictObject({ kind: z.literal("commit_diff"), sha: z.string() }),
z.discriminatedUnion("context", [
z.strictObject({
kind: z.literal("plugin"),
pluginId: z.string(),
panelId: z.string(),
context: z.literal("workspace"),
}),
z.strictObject({
kind: z.literal("plugin"),
pluginId: z.string(),
panelId: z.string(),
context: z.literal("agent"),
agentId: z.string(),
}),
]),
]);
const WorkspaceTabStorageSchema = z.strictObject({
tabId: z.string(),
target: WorkspaceTabTargetStorageSchema,
createdAt: z.number(),
state: z.json().optional(),
});
const SplitNodeStorageSchema: z.ZodType<SplitNode> = z.lazy(() =>
z.discriminatedUnion("kind", [
z.strictObject({
kind: z.literal("pane"),
pane: z.strictObject({
id: z.string(),
tabIds: z.array(z.string()),
focusedTabId: z.string().nullable(),
tabs: z.array(WorkspaceTabStorageSchema).optional(),
hidden: z.boolean().optional(),
}),
}),
z.strictObject({
kind: z.literal("group"),
group: z.strictObject({
id: z.string(),
direction: z.enum(["horizontal", "vertical"]),
children: z.array(SplitNodeStorageSchema),
sizes: z.array(z.number()),
}),
}),
]),
);
const WorkspaceLayoutStorageSchema: z.ZodType<WorkspaceLayout> = z.strictObject({
root: SplitNodeStorageSchema,
focusedPaneId: z.string().nullable(),
parentTabIdByTabId: z.record(z.string(), z.string()).optional(),
});
const WorkspaceLayoutPersistedStateSchema = z.strictObject({
pinnedAgentIdsByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema),
splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(),
explorerSidebarWidthByWorkspace: z.record(z.string(), z.number()).optional(),
// COMPAT(explorerSidebarWidth): added in v0.6, remove after 2027-08-25.
explorerSidebarRatioByWorkspace: z.record(z.string(), z.number()).optional(),
// COMPAT(explorerSidebarNaming): accepted from builds that called this dock the Side panel.
sidePanelRatioByWorkspace: z.record(z.string(), z.number()).optional(),
// The persisted keys keep their pre-rename spelling: the schema is strict, so a
// rename here would fail every existing blob and wipe the layout it describes.
explorerPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
explorerSidebarPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
sidePaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
// COMPAT(pullRequestAutoAdd): PR detection stopped opening a tab in v0.5; accepted
// and ignored so upgrading does not discard the layout. Remove after 2027-08-20.
acknowledgedPullRequestByWorkspace: z.record(z.string(), z.string()).optional(),
});
const LEGACY_EXPLORER_SIDEBAR_REFERENCE_WIDTH = 1440;
const WORKSPACE_LAYOUT_PERSIST_VERSION = 2;
@@ -1,5 +1,5 @@
import { describe, expect, test, vi } from "vitest";
import type { TabImage } from "./service.js";
import { executeAutomationCommand, type BrowserRegistry, type TabImage } from "./service.js";
import { adaptWebContents, HostSnapshotEngineRegistry } from "./ipc.js";
import type { IsolatedKeyboardInputEvent } from "./trusted-input.js";
@@ -122,7 +122,7 @@ class FakeWebContents {
public endedFrameSubscriptions = 0;
public readonly invalidations: string[] = [];
private consoleMessageListener: ConsoleMessageListener | null = null;
private destroyedListener: (() => void) | null = null;
public readonly destroyedListeners = new Set<() => void>();
public destroyed = false;
public constructor(private readonly webContentsId: number) {}
@@ -197,7 +197,12 @@ class FakeWebContents {
public once(event: "destroyed", listener: () => void): void {
expect(event).toBe("destroyed");
this.destroyedListener = listener;
this.destroyedListeners.add(listener);
}
public removeListener(event: "destroyed", listener: () => void): void {
expect(event).toBe("destroyed");
this.destroyedListeners.delete(listener);
}
public emitConsoleMessage(input: {
@@ -215,7 +220,11 @@ class FakeWebContents {
public destroy(): void {
this.destroyed = true;
this.debugger.emitDetach();
this.destroyedListener?.();
this.frameListener = null;
for (const listener of this.destroyedListeners) {
this.destroyedListeners.delete(listener);
listener();
}
}
}
@@ -270,6 +279,62 @@ describe("browser automation IPC adapter", () => {
expect(contents.endedFrameSubscriptions).toBe(1);
});
test("closing a guest settles its screenshot and releases the next queued capture", async () => {
vi.useFakeTimers();
try {
const closing = new FakeWebContents(2002);
const next = new FakeWebContents(2003);
const tabs = new Map([
["closing", adaptWebContents(closing)],
["next", adaptWebContents(next)],
]);
const registry: BrowserRegistry = {
listRegisteredBrowserIds: () => [...tabs.keys()],
listRegisteredBrowserIdsForWorkspace: () => [...tabs.keys()],
getTabContents: (id) => tabs.get(id) ?? null,
getBrowserWorkspaceId: () => null,
getWorkspaceActiveBrowserId: () => null,
};
const capture = (browserId: string) =>
executeAutomationCommand(
{
type: "browser.automation.execute.request",
requestId: browserId,
command: { command: "screenshot", args: { browserId, fullPage: false } },
},
registry,
);
let closedResult: unknown;
const closedCapture = Promise.resolve(capture("closing")).then((result) => {
closedResult = result;
return result;
});
const nextCapture = capture("next");
await vi.advanceTimersByTimeAsync(0);
expect(closing.frameListener).not.toBeNull();
closing.destroy();
await vi.advanceTimersByTimeAsync(0);
expect(closedResult).toEqual({
requestId: "closing",
ok: false,
error: {
code: "browser_tab_closed",
message: "Browser tab closing has been closed",
retryable: false,
},
});
expect(closing.destroyedListeners.size).toBe(0);
expect(next.frameListener).not.toBeNull();
next.frameListener?.(new FakeImage());
await expect(nextCapture).resolves.toMatchObject({ ok: true });
await closedCapture;
expect(next.destroyedListeners.size).toBe(1);
} finally {
await vi.runAllTimersAsync();
vi.useRealTimers();
}
});
test("collects console messages until the guest is destroyed", () => {
const contents = new FakeWebContents(21);
const tab = adaptWebContents(contents);
@@ -15,7 +15,7 @@ import {
promptShimInstallScript,
promptShimRestoreScript,
} from "./dialog-handling.js";
import { executeAutomationCommand } from "./service.js";
import { BrowserTabClosedError, executeAutomationCommand } from "./service.js";
import { BrowserSnapshotEngine } from "./snapshot-engine.js";
import {
listRegisteredPaseoBrowserIds,
@@ -91,6 +91,7 @@ interface ConsoleMessageEmitter {
}
interface BrowserAutomationWebContents extends ConsoleMessageEmitter {
removeListener(event: "destroyed", listener: () => void): void;
readonly id: number;
readonly debugger: WebContentsDebugger;
getURL(): string;
@@ -162,13 +163,19 @@ function captureViewportFrame(
return new Promise((resolve, reject) => {
const stop = () => {
signal.removeEventListener("abort", abort);
contents.removeListener("destroyed", destroyed);
if (!contents.isDestroyed()) contents.endFrameSubscription();
};
const abort = () => {
stop();
reject(signal.reason);
};
const destroyed = () => {
stop();
reject(new BrowserTabClosedError());
};
signal.addEventListener("abort", abort, { once: true });
contents.once("destroyed", destroyed);
try {
// A resized resident guest can paint while capturePage's surface-copy
// request remains pending. Subscribe to its rendered frames instead.
@@ -94,6 +94,13 @@ async function withDialogCapture(
return dialogs.length > 0 ? { ...result, dialogs } : result;
}
export class BrowserTabClosedError extends Error {
public constructor() {
super("Browser tab has been closed");
this.name = "BrowserTabClosedError";
}
}
class ScreenshotNoFrameError extends Error {
public constructor(message = SCREENSHOT_NO_FRAME_MESSAGE) {
super(message);
@@ -1228,6 +1235,9 @@ async function executeScreenshot(
try {
image = await capturePaintedViewport(target.contents);
} catch (error) {
if (error instanceof BrowserTabClosedError) {
return fail(requestId, "browser_tab_closed", `Browser tab ${browserId} has been closed`);
}
if (isScreenshotNoFrameError(error)) {
return screenshotNoFrameFailure(requestId, error);
}