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
@@ -111,3 +111,87 @@ describe("ObservationClientStore", () => {
expect(store.getSnapshot().sessions).toHaveLength(0);
});
});
describe("thumbnail blob URL lifecycle", () => {
function withRevokeSpy() {
const revoke = vi.fn();
(URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = revoke;
return revoke;
}
it("replacing a frame revokes the old blob URL and drops its entry", async () => {
const revoke = withRevokeSpy();
const { store, esInstances } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "a1" }]);
store.start();
await vi.waitFor(() => expect(store.getSnapshot().sessions).toHaveLength(1));
store.ensureThumbnail("a1");
await vi.waitFor(() =>
expect(store.getSnapshot().thumbnails["a1"]).toEqual({ status: "ready", url: "blob:url-a1" }),
);
emit(esInstances[0], {
type: "upsert",
session: { ...OBS_IDLE, thumbnailAttachmentId: "a2" },
});
expect(store.getSnapshot().thumbnails["a1"]).toBeUndefined();
expect(revoke).toHaveBeenCalledWith("blob:url-a1");
store.stop();
});
it("remove and reset revoke the session's tracked frame", async () => {
const revoke = withRevokeSpy();
const { store, esInstances } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "a1" }]);
store.start();
await vi.waitFor(() => expect(store.getSnapshot().sessions).toHaveLength(1));
store.ensureThumbnail("a1");
await vi.waitFor(() => expect(store.getSnapshot().thumbnails["a1"].status).toBe("ready"));
emit(esInstances[0], {
type: "remove",
session: { sessionId: "s1", action: "idle", since: 0 },
});
expect(revoke).toHaveBeenCalledWith("blob:url-a1");
expect(store.getSnapshot().thumbnails["a1"]).toBeUndefined();
store.stop();
});
it("a load that finishes after replacement never resurrects the old frame", async () => {
const revoke = withRevokeSpy();
const { store, esInstances, loadImage } = harness([
{ ...OBS_IDLE, thumbnailAttachmentId: "a1" },
]);
let resolveA1!: (url: string) => void;
loadImage.mockImplementation((id: string) =>
id === "a1"
? new Promise<string>((resolve) => {
resolveA1 = resolve;
})
: Promise.resolve(`blob:url-${id}`),
);
store.start();
await vi.waitFor(() => expect(store.getSnapshot().sessions).toHaveLength(1));
store.ensureThumbnail("a1");
// the frame is replaced while a1 is still loading
emit(esInstances[0], {
type: "upsert",
session: { ...OBS_IDLE, thumbnailAttachmentId: "a2" },
});
resolveA1("blob:url-a1-late");
await new Promise((resolve) => setImmediate(resolve));
expect(store.getSnapshot().thumbnails["a1"]).toBeUndefined();
expect(revoke).toHaveBeenCalledWith("blob:url-a1-late");
store.stop();
});
});
describe("SSE (re)open resync", () => {
it("refetches the full state when the stream (re)opens", async () => {
const { store, fetches, esInstances } = harness([OBS_IDLE]);
store.start();
await vi.waitFor(() => expect(store.getSnapshot().sessions).toHaveLength(1));
const before = fetches.filter((f) => f.url === "/bsk-observation/state").length;
esInstances[0].onopen?.();
await vi.waitFor(() =>
expect(fetches.filter((f) => f.url === "/bsk-observation/state").length).toBe(before + 1),
);
store.stop();
});
});