fix(dsh-plugin-browserskill): address review findings (leaks, trust fence, queue race)

Blocking:
- B1: delete screenshot scratch PNGs — observation frames reuse one fixed
  per-session path and unlink after every read (finally); browser_screenshot
  unlinks once the bytes are in the attachment store (kept only when the file
  itself is the model-facing artifact).
- B2: replicate dsh's browser-trust fence on /bsk-observation/*: loopback
  Host only, Origin must match Host, sec-fetch-site: cross-site refused, POST
  requires application/json; README documents the loopback trust premise and
  the 0.0.0.0 warning.

Major:
- M1: KeyedExecutor tail chains previous+task (allSettled) so a task aborted
  while queued cannot release the next one into the running session.
- M2: client tracks the live frame per session — replacing/removing/reset
  revokes the old blob URL at once; loads settling after replacement never
  resurrect it.

Minor: drop replaced thumbRefs (+on remove); beginAction fires inside the
queue (no label overwrite while queued, no idle flash); half-initialized
session cleanup stops through the queue with one retry; SSE (re)open refetches
/state (+ snapshot flag renamed subscribed); install probe uses --version (no
daemon spawn); emulate mobile documents the width+height requirement (the
daemon refuses it alone — verified).

Nit: single BskRunOptions declaration; tails map entry dropped on drain; SSE
cleanup on res close; popOut catches requestWindow rejections; resize handle
gains keyboard control (arrows, 16px steps, aria value attrs).

Tests: +14 (fence rules, scratch lifecycle, queue race, blob revocation,
instrumentation timing, SSE resync). 113/113 green.
This commit is contained in:
BB-fat
2026-08-14 11:56:02 +00:00
parent 52aa356153
commit a4e668542d
13 changed files with 784 additions and 104 deletions
@@ -434,10 +434,15 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
const popOut = async (): Promise<void> => {
const pip = pipApi();
if (pip === undefined) return;
const win = await pip.requestWindow({ width: size.w, height: size.h });
cloneStylesInto(win);
win.addEventListener("pagehide", () => setPipWindow(null));
setPipWindow(win);
try {
const win = await pip.requestWindow({ width: size.w, height: size.h });
cloneStylesInto(win);
win.addEventListener("pagehide", () => setPipWindow(null));
setPipWindow(win);
} catch {
// requestWindow rejects without a user gesture (or when the window was
// closed mid-request): stay on the in-page card, no state change.
}
};
// Hidden while no owned session exists (and no PiP is up).
@@ -496,7 +501,28 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
data-testid="obs-resize"
aria-label="Resize overlay"
role="separator"
aria-valuenow={size.w}
aria-valuetext={`${Math.round(size.w)} by ${Math.round(size.h)} pixels`}
aria-valuemin={MIN_SIZE.w}
aria-valuemax={Math.round(viewport().w * 0.8)}
tabIndex={0}
onPointerDown={beginDrag("resize")}
onKeyDown={(event) => {
const step = 16;
const delta =
event.key === "ArrowRight"
? { w: step, h: 0 }
: event.key === "ArrowLeft"
? { w: -step, h: 0 }
: event.key === "ArrowDown"
? { w: 0, h: step }
: event.key === "ArrowUp"
? { w: 0, h: -step }
: undefined;
if (delta === undefined) return;
event.preventDefault();
setSize(clampSize({ w: size.w + delta.w, h: size.h + delta.h }, viewport()));
}}
/>
</div>
);
@@ -1,14 +1,17 @@
/**
* Client-side data layer for the observation overlay: initial state fetch,
* SSE increment stream, on-demand thumbnail blob loading through the
* session-authorized readAttachment path, and the interrupt call. All I/O is
* injected so tests never touch a network.
* SSE increment stream (with a state refetch on every (re)open — events that
* fired while the stream was down are otherwise lost forever), on-demand
* thumbnail blob loading through the session-authorized readAttachment path,
* and the interrupt call. All I/O is injected so tests never touch a network.
*/
import type { ObservationEvent, SessionObservation } from "../observation";
export interface EventSourceLike {
onmessage: ((event: { data: string }) => void) | null;
/** Fires on the initial connect AND on every automatic reconnect. */
onopen?: (() => void) | null;
close(): void;
}
@@ -32,7 +35,8 @@ export interface ThumbnailState {
export interface OverlaySnapshot {
readonly sessions: readonly SessionObservation[];
readonly connected: boolean;
/** Whether the SSE increment stream is currently subscribed. */
readonly subscribed: boolean;
readonly thumbnails: Readonly<Record<string, ThumbnailState>>;
/** False when the host reports the browser/daemon as unreachable. */
readonly available: boolean;
@@ -42,14 +46,22 @@ const STATE_URL = "/bsk-observation/state";
const EVENTS_URL = "/bsk-observation/events";
const INTERRUPT_URL = "/bsk-observation/interrupt";
function revoke(url: string | undefined): void {
if (url !== undefined && typeof URL.revokeObjectURL === "function") {
URL.revokeObjectURL(url);
}
}
export class ObservationClientStore {
private sessions = new Map<string, SessionObservation>();
private thumbs = new Map<string, ThumbnailState>();
/** sessionId → the attachment id currently displayed for it (leak guard). */
private readonly thumbBySession = new Map<string, string>();
private listeners = new Set<() => void>();
private events: EventSourceLike | undefined;
private snapshot: OverlaySnapshot = {
sessions: [],
connected: false,
subscribed: false,
thumbnails: {},
available: true,
};
@@ -68,17 +80,15 @@ export class ObservationClientStore {
private publish(): void {
this.snapshot = {
sessions: [...this.sessions.values()],
connected: this.events !== undefined,
subscribed: this.events !== undefined,
thumbnails: Object.fromEntries(this.thumbs),
available: this.available,
};
for (const listener of [...this.listeners]) listener();
}
/** Initial fetch + SSE subscription. Idempotent. */
start(): void {
if (this.started) return;
this.started = true;
/** (Re)pull the full state: initial load and every SSE (re)open. */
private refreshState(): void {
void this.deps
.fetchFn(STATE_URL)
.then(async (res) => {
@@ -87,11 +97,25 @@ export class ObservationClientStore {
sessions?: SessionObservation[];
available?: boolean;
};
this.sessions = new Map((body.sessions ?? []).map((s) => [s.sessionId, s]));
const sessions = body.sessions ?? [];
this.sessions = new Map(sessions.map((s) => [s.sessionId, s]));
// Prune thumbnails of sessions that vanished while we were away.
const alive = new Set(sessions.map((s) => s.sessionId));
for (const sessionId of [...this.thumbBySession.keys()]) {
if (!alive.has(sessionId)) this.dropThumb(sessionId);
}
for (const s of sessions) this.trackThumb(s.sessionId, s.thumbnailAttachmentId);
if (typeof body.available === "boolean") this.available = body.available;
this.publish();
})
.catch(() => {});
}
/** Initial fetch + SSE subscription. Idempotent. */
start(): void {
if (this.started) return;
this.started = true;
this.refreshState();
const events = this.deps.eventSourceFactory(EVENTS_URL);
events.onmessage = (message) => {
let event: ObservationEvent;
@@ -102,6 +126,10 @@ export class ObservationClientStore {
}
this.apply(event);
};
events.onopen = () => {
// Reconnects lose every event fired during the outage — resync.
if (this.events === events) this.refreshState();
};
this.events = events;
this.publish();
}
@@ -110,23 +138,45 @@ export class ObservationClientStore {
this.events?.close();
this.events = undefined;
this.started = false;
for (const thumb of this.thumbs.values()) {
if (thumb.url !== undefined && typeof URL.revokeObjectURL === "function") {
URL.revokeObjectURL(thumb.url);
}
}
for (const thumb of this.thumbs.values()) revoke(thumb.url);
this.thumbs.clear();
this.thumbBySession.clear();
this.sessions.clear();
this.publish();
}
/** Forget one session's tracked thumbnail, revoking its blob URL. */
private dropThumb(sessionId: string): void {
const attachmentId = this.thumbBySession.get(sessionId);
if (attachmentId === undefined) return;
this.thumbBySession.delete(sessionId);
revoke(this.thumbs.get(attachmentId)?.url);
this.thumbs.delete(attachmentId);
}
/**
* Track the frame a session currently shows; replacing a frame revokes the
* old blob URL immediately — blob URLs must not accumulate one per frame.
*/
private trackThumb(sessionId: string, attachmentId: string | undefined): void {
const previous = this.thumbBySession.get(sessionId);
if (previous === attachmentId) return;
this.dropThumb(sessionId);
if (attachmentId !== undefined) this.thumbBySession.set(sessionId, attachmentId);
}
private apply(event: ObservationEvent): void {
if (event.type === "reset") {
this.sessions.clear();
for (const thumb of this.thumbs.values()) revoke(thumb.url);
this.thumbs.clear();
this.thumbBySession.clear();
} else if (event.type === "remove" && event.session !== undefined) {
this.sessions.delete(event.session.sessionId);
this.dropThumb(event.session.sessionId);
} else if (event.type === "upsert" && event.session !== undefined) {
this.sessions.set(event.session.sessionId, event.session);
this.trackThumb(event.session.sessionId, event.session.thumbnailAttachmentId);
} else if (event.type === "availability") {
this.available = event.available;
}
@@ -144,6 +194,11 @@ export class ObservationClientStore {
this.publish();
this.deps.loadImage(attachmentId).then(
(url) => {
if (!this.thumbs.has(attachmentId)) {
// Replaced or removed while loading: never resurrect (or leak) it.
revoke(url);
return;
}
this.thumbs.set(attachmentId, { status: "ready", url });
this.publish();
},