Files
BrowserSkill/packages/dsh-plugin-browserskill/src/queue.ts
T
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

64 lines
2.2 KiB
TypeScript

/**
* Per-key FIFO executor. The bsk daemon serializes commands per session (a
* second command while one is unfinished is rejected), so every plugin
* command — model-facing tool calls AND observation captures — funnels
* through one queue per session. A queued task rejects early if its abort
* signal fires before it starts; once running, cancellation is owned by the
* task's own signal handling (the runner kills the child).
*/
export class KeyedExecutor {
private readonly tails = new Map<string, Promise<void>>();
/** Run `fn` after every previously queued task for `key` settled. */
run<T>(key: string, fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
const previous = this.tails.get(key) ?? Promise.resolve();
const task = new Promise<T>((resolve, reject) => {
let settled = false;
const onAbort = () => {
if (!settled) {
settled = true;
reject(abortError());
}
};
signal?.addEventListener("abort", onAbort, { once: true });
void previous.then(() => {
if (settled) return;
if (signal?.aborted) {
settled = true;
reject(abortError());
return;
}
// Running now: cancellation flows through the task's own signal.
signal?.removeEventListener("abort", onAbort);
fn().then(
(value) => {
settled = true;
resolve(value);
},
(error: unknown) => {
settled = true;
reject(error);
},
);
});
});
// The stored tail never rejects, so one failure never strands the queue;
// and it chains BOTH the previous tail and this task, so a task aborted
// while still queued cannot let a later task overlap the one before it.
const tail = Promise.allSettled([previous, task]).then(() => {});
this.tails.set(key, tail);
// Drop the entry once the queue drains, so the map tracks live keys only.
void tail.then(() => {
if (this.tails.get(key) === tail) this.tails.delete(key);
});
return task;
}
}
function abortError(): Error {
const error = new Error("tool call aborted");
error.name = "AbortError";
return error;
}