fix(extension): version every overlay message per worker instance

Each overlay state and reset message now takes its own version from one
background counter, together with an epoch that names the worker
instance. The page admits a message only if it is not older than the
last one it applied: generations order messages from one worker, a new
epoch means the worker restarted and its counter started over, and a
replaced worker's messages stay dropped. Ownership changes that send a
new state without a session change, such as an observed tab's release,
and resets are ordered too, so a delayed control state cannot restore
the blocker after either. The input-passthrough cleanup from main is kept
in applyOverlayState.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kevin9327
2026-09-28 19:36:27 +09:00
co-authored by Claude Opus 5.5
parent d7f7fb939f
commit 2aca02abda
10 changed files with 227 additions and 51 deletions
@@ -97,14 +97,20 @@ it.each([false, true])("hides an observed tab after detachment, remote=%s", asyn
f.task.observedTabs = new Set([20]);
f.deps.onAgentTabClaimed?.(20, 10);
expect(shouldShowAgentControlOverlay(f.overlay.snapshot())).toBe(true);
const claimed = f.sendMessage.mock.lastCall![1];
f.onDetached.emit(20);
expect(isAgentControlledTab(f.task, 20)).toBe(false);
expect(f.sendMessage).toHaveBeenLastCalledWith(20, {
const [tabId, released] = f.sendMessage.mock.lastCall!;
expect(tabId).toBe(20);
expect(released).toMatchObject({
type: OVERLAY_AGENT_STATE,
sessionId: null,
mode: "hidden",
generation: 0,
});
// The release is ordered after the claim, although no session changed.
expect(released.epoch).toEqual(expect.any(String));
expect(released.epoch).toBe(claimed.epoch);
expect(released.generation).toBeGreaterThan(claimed.generation);
expect(shouldShowAgentControlOverlay(f.overlay.snapshot())).toBe(false);
expect(f.deps.cdp?.releaseSessionTab).toHaveBeenCalledWith("one", 20);
});
@@ -1,9 +1,10 @@
import { afterEach, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { INPUT_PASSTHROUGH, INPUT_PASSTHROUGH_ATTR } from "@/lib/input-passthrough-bridge";
import {
OVERLAY_AGENT_OVERLAY_RESET,
OVERLAY_AGENT_STATE,
type OverlayMode,
type OverlayVersion,
} from "@/lib/overlay-bridge";
import { RECORD_START, RECORD_STOP } from "@/lib/record-bridge";
@@ -81,12 +82,21 @@ async function fixture() {
},
});
const send = (message: unknown) => receive(message, {}, vi.fn());
const state = (sessionId = "one", mode: OverlayMode = "control") =>
send({ type: OVERLAY_AGENT_STATE, sessionId, mode, generation: 0 });
// Versions in the order one background worker hands them out.
let generation = 0;
const version = (): OverlayVersion => ({ epoch: "worker", generation: ++generation });
const state = (
sessionId: string | null = "one",
mode: OverlayMode = "control",
v: OverlayVersion = version(),
) => send({ type: OVERLAY_AGENT_STATE, sessionId, mode, ...v });
const reset = (sessionId: string, v: OverlayVersion = version()) =>
send({ type: OVERLAY_AGENT_OVERLAY_RESET, sessionId, ...v });
const begin = (id = "click-one") => send({ type: INPUT_PASSTHROUGH, phase: "begin", id });
const active = () => host.hasAttribute(INPUT_PASSTHROUGH_ATTR);
const blocking = () => host.hasAttribute("data-bsk-overlay-blocking");
state();
return { send, state, begin, active, remount: () => mount() };
return { send, version, state, reset, begin, active, blocking, remount: () => mount() };
}
it.each([
@@ -121,10 +131,10 @@ it("clears old session leases without letting stale reset/end clear the new sess
f.state("two");
expect(f.active()).toBe(false);
f.begin("click-two");
f.send({ type: OVERLAY_AGENT_OVERLAY_RESET, sessionId: "one" });
f.reset("one");
f.send({ type: INPUT_PASSTHROUGH, phase: "end", id: "click-one" });
expect(f.active()).toBe(true);
f.send({ type: OVERLAY_AGENT_OVERLAY_RESET, sessionId: "two" });
f.reset("two");
expect(f.active()).toBe(false);
expect(vi.getTimerCount()).toBe(0);
f.remount();
@@ -149,3 +159,54 @@ it("removes passthrough and timers when the content context is invalidated", asy
expect(f.active()).toBe(false);
expect(vi.getTimerCount()).toBe(0);
});
describe("overlay state ordering", () => {
it("drops a control state that arrives after a newer hidden state", async () => {
const f = await fixture();
const late = f.version();
f.state(null, "hidden");
f.state("one", "control", late);
expect(f.blocking()).toBe(false);
});
it("drops a control state sent before a reset", async () => {
const f = await fixture();
expect(f.blocking()).toBe(true);
const late = f.version();
f.reset("one");
expect(f.blocking()).toBe(false);
f.state("one", "control", late);
expect(f.blocking()).toBe(false);
});
it("drops a reset sent before a newer control state", async () => {
const f = await fixture();
const late = f.version();
f.state("one", "control");
f.reset("one", late);
expect(f.blocking()).toBe(true);
});
it("follows a restarted worker although its generation starts over", async () => {
const f = await fixture();
f.state("one", "control", { epoch: "worker", generation: 20 });
f.state(null, "hidden", { epoch: "restarted", generation: 1 });
expect(f.blocking()).toBe(false);
f.state("one", "control", { epoch: "restarted", generation: 2 });
expect(f.blocking()).toBe(true);
});
it("lets a restarted worker claim a page it had hidden", async () => {
const f = await fixture();
f.state(null, "hidden", { epoch: "worker", generation: 20 });
f.state("one", "control", { epoch: "restarted", generation: 1 });
expect(f.blocking()).toBe(true);
});
it("keeps dropping the replaced worker's messages", async () => {
const f = await fixture();
f.state(null, "hidden", { epoch: "restarted", generation: 1 });
f.state("one", "control", { epoch: "worker", generation: 30 });
expect(f.blocking()).toBe(false);
});
});
+4 -6
View File
@@ -27,6 +27,7 @@ import {
type OverlayMessage,
type OverlayMode,
} from "@/lib/overlay-bridge";
import { nextOverlayVersion } from "@/lib/overlay-version";
import { POPUP_PORT_NAME, type PopupInbound, type PopupOutbound } from "@/lib/popup-bridge";
import { recordFrameCoordinator } from "@/lib/recording/frame-coordinator";
import { attachSessionsLiveFlag } from "@/lib/sessions-live-flag";
@@ -99,7 +100,6 @@ export default defineBackground(() => {
});
const sessionsLive = attachSessionsLiveFlag({ manager: sessions });
const popupSnapshotRefreshers = new Set<() => void>();
let overlayGeneration = 0;
const controlModes = new Map<string, OverlayMode>();
watchRemoteAuthorization();
@@ -140,7 +140,6 @@ export default defineBackground(() => {
function setControlMode(sessionId: string, mode: OverlayMode): void {
if (controlModes.get(sessionId) === mode) return;
controlModes.set(sessionId, mode);
overlayGeneration += 1;
const ctx = sessions.get(sessionId);
if (ctx) void pushOverlayStateForWindow(ctx.agentWindowId);
}
@@ -152,14 +151,14 @@ export default defineBackground(() => {
type: OVERLAY_AGENT_STATE,
sessionId: null,
mode: "hidden",
generation: overlayGeneration,
...nextOverlayVersion(),
};
}
return {
type: OVERLAY_AGENT_STATE,
sessionId: ctx.sessionId,
mode: controlModes.get(ctx.sessionId) ?? "control",
generation: overlayGeneration,
...nextOverlayVersion(),
};
}
@@ -177,7 +176,7 @@ export default defineBackground(() => {
type: OVERLAY_AGENT_STATE,
sessionId: null,
mode: "hidden",
generation: overlayGeneration,
...nextOverlayVersion(),
};
}
@@ -221,7 +220,6 @@ export default defineBackground(() => {
for (const sessionId of controlModes.keys()) {
if (!liveSessionIds.has(sessionId)) controlModes.delete(sessionId);
}
overlayGeneration += 1;
pushAllAgentOverlayStates();
for (const refresh of popupSnapshotRefreshers) refresh();
}
+9 -5
View File
@@ -43,7 +43,7 @@ import {
type OverlayAgentOverlayResetMessage,
type OverlayAgentStateMessage,
type OverlayAutomationBypassMessage,
shouldApplyOverlayAgentState,
OverlayVersionGate,
} from "@/lib/overlay-bridge";
import { sendInterrupt } from "@/lib/overlay-interrupt-client";
import {
@@ -82,6 +82,7 @@ export default defineContentScript({
let overlayHost: HTMLElement | null = null;
let overlayContainer: HTMLElement | null = null;
let activeAgentState: OverlayAgentStateMessage | null = null;
const overlayVersions = new OverlayVersionGate();
let hostLossReported = false;
let remountInProgress = false;
@@ -228,8 +229,11 @@ export default defineContentScript({
resetAgentOverlayState(sessionId);
}
function receiveOverlayState(state: OverlayAgentStateMessage): void {
if (overlayVersions.admit(state)) applyOverlayState(state);
}
function applyOverlayState(state: OverlayAgentStateMessage): void {
if (!shouldApplyOverlayAgentState(activeAgentState, state)) return;
if (overlays.snapshot().activeSessionId !== state.sessionId) inputPassthrough.reset();
activeAgentState = state;
overlays.applyAgentControlMode(state.sessionId, state.mode);
@@ -333,12 +337,12 @@ export default defineContentScript({
}
if (isOverlayAgentStateMessage(message)) {
applyOverlayState(message);
receiveOverlayState(message);
return false;
}
if (isOverlayAgentOverlayResetMessage(message)) {
resetAgentOverlayState(message.sessionId);
if (overlayVersions.admit(message)) resetAgentOverlayState(message.sessionId);
return false;
}
@@ -483,7 +487,7 @@ export default defineContentScript({
kind: OVERLAY_MSG_READY,
})) as OverlayAgentStateMessage | undefined;
if (state && isOverlayAgentStateMessage(state)) {
applyOverlayState(state);
receiveOverlayState(state);
}
void refreshAuxiliaryOverlayState();
} catch (err) {
@@ -1,13 +1,13 @@
import { describe, expect, it } from "vitest";
import {
isOverlayAgentOverlayResetMessage,
isOverlayAgentStateMessage,
OVERLAY_AGENT_OVERLAY_RESET,
OVERLAY_AGENT_STATE,
OVERLAY_MSG_INTERRUPT,
type OverlayAgentStateMessage,
type OverlayInterruptRequest,
type OverlayInterruptResponse,
shouldApplyOverlayAgentState,
OverlayVersionGate,
} from "@/lib/overlay-bridge";
describe("OVERLAY_MSG_INTERRUPT", () => {
@@ -31,13 +31,24 @@ describe("OVERLAY_MSG_INTERRUPT", () => {
});
describe("isOverlayAgentOverlayResetMessage", () => {
it("accepts reset messages with a session id", () => {
it("accepts reset messages with a session id and a version", () => {
expect(
isOverlayAgentOverlayResetMessage({
type: OVERLAY_AGENT_OVERLAY_RESET,
sessionId: "sess-1",
epoch: "worker",
generation: 1,
}),
).toBe(true);
});
it("rejects reset messages without a version", () => {
expect(
isOverlayAgentOverlayResetMessage({
type: OVERLAY_AGENT_OVERLAY_RESET,
sessionId: "sess-1",
}),
).toBe(true);
).toBe(false);
});
it("rejects reset messages without a session id", () => {
@@ -49,24 +60,33 @@ describe("isOverlayAgentOverlayResetMessage", () => {
});
});
function state(
generation: number,
mode: OverlayAgentStateMessage["mode"] = "control",
): OverlayAgentStateMessage {
return { type: OVERLAY_AGENT_STATE, sessionId: "sess-1", mode, generation };
}
describe("isOverlayAgentStateMessage", () => {
it("requires a version", () => {
const message = { type: OVERLAY_AGENT_STATE, sessionId: "sess-1", mode: "control" };
expect(isOverlayAgentStateMessage({ ...message, epoch: "worker", generation: 1 })).toBe(true);
expect(isOverlayAgentStateMessage({ ...message, generation: 1 })).toBe(false);
});
});
describe("shouldApplyOverlayAgentState", () => {
it("applies the first overlay state", () => {
expect(shouldApplyOverlayAgentState(null, state(1))).toBe(true);
describe("OverlayVersionGate", () => {
it("admits the first message and the same message again", () => {
const gate = new OverlayVersionGate();
expect(gate.admit({ epoch: "worker", generation: 4 })).toBe(true);
expect(gate.admit({ epoch: "worker", generation: 4 })).toBe(true);
});
it("applies an equal or newer generation", () => {
expect(shouldApplyOverlayAgentState(state(4, "control"), state(4, "paused"))).toBe(true);
expect(shouldApplyOverlayAgentState(state(4, "control"), state(5, "hidden"))).toBe(true);
it("orders messages from one worker by generation", () => {
const gate = new OverlayVersionGate();
expect(gate.admit({ epoch: "worker", generation: 6 })).toBe(true);
expect(gate.admit({ epoch: "worker", generation: 5 })).toBe(false);
expect(gate.admit({ epoch: "worker", generation: 7 })).toBe(true);
});
it("drops a stale control state after a newer hide", () => {
expect(shouldApplyOverlayAgentState(state(6, "hidden"), state(5, "control"))).toBe(false);
it("follows a restarted worker and drops the one it replaced", () => {
const gate = new OverlayVersionGate();
expect(gate.admit({ epoch: "worker", generation: 20 })).toBe(true);
expect(gate.admit({ epoch: "restarted", generation: 1 })).toBe(true);
expect(gate.admit({ epoch: "worker", generation: 21 })).toBe(false);
expect(gate.admit({ epoch: "restarted", generation: 2 })).toBe(true);
});
});
@@ -0,0 +1,28 @@
import { afterEach, expect, it, vi } from "vitest";
import { OVERLAY_AGENT_OVERLAY_RESET } from "@/lib/overlay-bridge";
import { nextOverlayVersion } from "@/lib/overlay-version";
import { chromeAgentOverlayResetApi } from "@/tools/tabs";
afterEach(() => {
vi.unstubAllGlobals();
});
it("hands out increasing generations under one epoch", () => {
const first = nextOverlayVersion();
const second = nextOverlayVersion();
expect(second.epoch).toBe(first.epoch);
expect(second.generation).toBeGreaterThan(first.generation);
});
it("versions a reset like a state, so the page can order the two", async () => {
const sendMessage = vi.fn(async () => {});
vi.stubGlobal("chrome", { tabs: { sendMessage } });
const before = nextOverlayVersion();
await chromeAgentOverlayResetApi.resetAgentOverlays(3, "sess-1");
expect(sendMessage).toHaveBeenCalledWith(3, {
type: OVERLAY_AGENT_OVERLAY_RESET,
sessionId: "sess-1",
epoch: before.epoch,
generation: before.generation + 1,
});
});
+53 -14
View File
@@ -68,11 +68,20 @@ export type OverlayMode = "control" | "interrupting" | "paused" | "hidden";
/** Background → content: complete, authoritative control-overlay state. */
export const OVERLAY_AGENT_STATE = "bh-agent-overlay-state";
export interface OverlayAgentStateMessage {
/**
* Orders the overlay state and reset messages one background worker sends:
* `generation` counts them, and `epoch` names the worker instance, whose
* counter starts over when the worker restarts.
*/
export interface OverlayVersion {
epoch: string;
generation: number;
}
export interface OverlayAgentStateMessage extends OverlayVersion {
type: typeof OVERLAY_AGENT_STATE;
sessionId: string | null;
mode: OverlayMode;
generation: number;
}
export interface OverlayInterruptRequest {
@@ -95,29 +104,58 @@ export interface OverlayAutomationBypassMessage {
/** Background → content: clear overlays that belong only inside an Agent tab. */
export const OVERLAY_AGENT_OVERLAY_RESET = "bh-agent-overlay-reset";
export interface OverlayAgentOverlayResetMessage {
export interface OverlayAgentOverlayResetMessage extends OverlayVersion {
type: typeof OVERLAY_AGENT_OVERLAY_RESET;
sessionId: string;
}
function isOverlayVersion(candidate: { epoch?: unknown; generation?: unknown }): boolean {
return typeof candidate.epoch === "string" && typeof candidate.generation === "number";
}
export function isOverlayAgentOverlayResetMessage(
message: unknown,
): message is OverlayAgentOverlayResetMessage {
if (!message || typeof message !== "object") return false;
const candidate = message as { type?: unknown; sessionId?: unknown };
return candidate.type === OVERLAY_AGENT_OVERLAY_RESET && typeof candidate.sessionId === "string";
const candidate = message as {
type?: unknown;
sessionId?: unknown;
epoch?: unknown;
generation?: unknown;
};
return (
candidate.type === OVERLAY_AGENT_OVERLAY_RESET &&
typeof candidate.sessionId === "string" &&
isOverlayVersion(candidate)
);
}
/**
* Drop overlay state that arrived late. Generation is monotonic in the
* background worker; a stale `control` frame after a newer `hidden` /
* `paused` frame would pin the full-viewport blocker on the page.
* Page side: admits overlay state and reset messages in the order the
* background worker sent them, dropping one that arrives after a newer one.
* A late `control` state after a newer `hidden` state or reset would
* otherwise pin the full-viewport blocker on the page. A message from
* another worker instance is newer, as a worker never outlives its
* successor, and messages from the instance it replaced stay dropped.
*/
export function shouldApplyOverlayAgentState(
current: OverlayAgentStateMessage | null,
incoming: OverlayAgentStateMessage,
): boolean {
return current === null || incoming.generation >= current.generation;
export class OverlayVersionGate {
private last: OverlayVersion | null = null;
private readonly retiredEpochs = new Set<string>();
/** Whether the message with this version is current; records it if so. */
admit(version: OverlayVersion): boolean {
const last = this.last;
if (last && version.epoch === last.epoch) {
// The same message again, such as a re-sent state, is still current.
if (version.generation < last.generation) return false;
} else if (this.retiredEpochs.has(version.epoch)) {
return false;
} else if (last) {
this.retiredEpochs.add(last.epoch);
}
this.last = { epoch: version.epoch, generation: version.generation };
return true;
}
}
export function isOverlayAgentStateMessage(message: unknown): message is OverlayAgentStateMessage {
@@ -126,6 +164,7 @@ export function isOverlayAgentStateMessage(message: unknown): message is Overlay
type?: unknown;
sessionId?: unknown;
mode?: unknown;
epoch?: unknown;
generation?: unknown;
};
return (
@@ -135,7 +174,7 @@ export function isOverlayAgentStateMessage(message: unknown): message is Overlay
candidate.mode === "interrupting" ||
candidate.mode === "paused" ||
candidate.mode === "hidden") &&
typeof candidate.generation === "number"
isOverlayVersion(candidate)
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { OverlayVersion } from "./overlay-bridge";
// Names this worker instance. A restarted worker counts from zero again, and
// its new epoch tells a page that kept the old counter that it is not stale.
const epoch = crypto.randomUUID();
let generation = 0;
/**
* The version of the next overlay state or reset message the background
* worker sends to a page. Every message gets its own, so the page can order
* any two of them, including two different states for the same change.
*/
export function nextOverlayVersion(): OverlayVersion {
generation += 1;
return { epoch, generation };
}
@@ -194,6 +194,8 @@ it.each([
expect(sendMessage).toHaveBeenCalledWith(20, {
type: "bh-agent-overlay-reset",
sessionId: "one",
epoch: expect.any(String),
generation: expect.any(Number),
});
});
+2
View File
@@ -8,6 +8,7 @@ import {
OVERLAY_AGENT_OVERLAY_RESET,
type OverlayAgentOverlayResetMessage,
} from "@/lib/overlay-bridge";
import { nextOverlayVersion } from "@/lib/overlay-version";
import {
isAgentControlledTab,
type SessionContext,
@@ -182,6 +183,7 @@ export const chromeAgentOverlayResetApi: AgentOverlayResetApi = {
const message: OverlayAgentOverlayResetMessage = {
type: OVERLAY_AGENT_OVERLAY_RESET,
sessionId,
...nextOverlayVersion(),
};
await chrome.tabs.sendMessage(tabId, message);
},