Files
BrowserSkill/packages/dsh-plugin-browserskill/tests/queue.test.ts
BB-fat a4e668542d 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.
2026-08-14 11:56:02 +00:00

86 lines
2.6 KiB
TypeScript

// KeyedExecutor: per-key FIFO guarantees, including the abort-while-queued
// race that used to release the tail early (letting a later task overlap the
// one still running on the same session).
import { describe, expect, it } from "vitest";
import { KeyedExecutor } from "../src/queue";
describe("KeyedExecutor", () => {
it("runs same-key tasks in FIFO order", async () => {
const queue = new KeyedExecutor();
const order: string[] = [];
const gate = new Promise<void>((resolve) => setTimeout(resolve, 5));
await Promise.all([
queue.run("k", async () => {
await gate;
order.push("a");
}),
queue.run("k", async () => {
order.push("b");
}),
queue.run("k", async () => {
order.push("c");
}),
]);
expect(order).toEqual(["a", "b", "c"]);
});
it("a task aborted while queued does not let the next one overlap the runner", async () => {
const queue = new KeyedExecutor();
let releaseA!: () => void;
const aRuns = new Promise<void>((resolve) => {
releaseA = resolve;
});
const events: string[] = [];
// A holds the session until releaseA.
const a = queue.run("s1", async () => {
events.push("a:start");
await aRuns;
events.push("a:end");
});
// B is queued behind A and aborted before A finishes.
const abortB = new AbortController();
const b = queue.run(
"s1",
async () => {
events.push("b:start");
},
abortB.signal,
);
// C is queued behind B.
const c = queue.run("s1", async () => {
events.push("c:start");
});
abortB.abort();
await expect(b).rejects.toMatchObject({ name: "AbortError" });
// Give C every chance to (wrongly) start while A is still in flight.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(events).toEqual(["a:start"]);
releaseA();
await a;
await c;
expect(events).toEqual(["a:start", "a:end", "c:start"]);
});
it("a failed task never strands the queue", async () => {
const queue = new KeyedExecutor();
await expect(
queue.run("k", async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
await expect(queue.run("k", async () => "ok")).resolves.toBe("ok");
});
it("drops the tail entry once the queue drains", async () => {
const queue = new KeyedExecutor();
const internals = queue as unknown as { tails: Map<string, Promise<void>> };
await queue.run("k", async () => {});
await new Promise((resolve) => setImmediate(resolve));
expect(internals.tails.size).toBe(0);
});
});