mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(dsh-plugin-browserskill): strict session ownership on a shared daemon
Review hardening for daemon-sharing deployments (other agents, terminals, or dsh instances on the same bsk daemon): - Ownership split: only sessions created by browser_session_start are 'owned'. Explicitly referenced foreign sessions are still tracked for the current-session pointer, but browser_session_stop refuses them and unload cleanup stops exactly the owned set — no path can stop a session this plugin did not create. - Start race: the capacity check is now a synchronous reserveStart/completeStart/abandonStart protocol, so two concurrent starts can never both pass the cap (check-and-reserve is atomic on the event loop); a rejected or failed start never leaks a session. - Stale handles: dispose already swallowed per-stop errors; the owned set is computed at dispose time so an externally stopped session is a no-op, not a failure. Tests (+7): reservation race (two concurrent starts, cap 1 -> exactly one spawn), foreign-stop refusal (no stop command reaches the daemon), reference adoption never becoming owned, resolveForStop pointer stability, and an apply-level dispose test proving only owned sessions are stopped while stale stops are tolerated. e2e: a manually started foreign session survived the plugin's full lifecycle while both plugin sessions were cleaned up.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
// Unload-cleanup ownership discipline: dispose must stop exactly the sessions
|
||||
// this plugin created — never referenced/foreign sessions on the shared
|
||||
// daemon — and a stale handle (already stopped elsewhere, daemon restarted)
|
||||
// must not abort the remaining stops.
|
||||
|
||||
import type { ToolDefinition, ToolRunContext } from "@deepseek-ai/dsh-tools";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { apply } from "../src/index";
|
||||
import type { BskRunOptions, BskRunResult } from "../src/runner";
|
||||
|
||||
interface FakeCall {
|
||||
args: string[];
|
||||
}
|
||||
|
||||
function ok(payload: unknown): BskRunResult {
|
||||
return { code: 0, stdout: JSON.stringify(payload), stderr: "", timedOut: false, aborted: false };
|
||||
}
|
||||
|
||||
function fail(message: string): BskRunResult {
|
||||
return {
|
||||
code: 4,
|
||||
stdout: JSON.stringify({ code: "session_not_found", message }),
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeExec(): ToolRunContext {
|
||||
return {
|
||||
callId: "t",
|
||||
name: "t",
|
||||
signal: new AbortController().signal,
|
||||
} as unknown as ToolRunContext;
|
||||
}
|
||||
|
||||
describe("dispose cleanup ownership", () => {
|
||||
it("stops only plugin-owned sessions and tolerates stale handles", async () => {
|
||||
const calls: FakeCall[] = [];
|
||||
const startReplies = [
|
||||
{ session_id: "own1", browser_instance_id: "b1" },
|
||||
{ session_id: "own2", browser_instance_id: "b1" },
|
||||
];
|
||||
const runner = {
|
||||
async run(args: string[], _options: BskRunOptions = {}): Promise<BskRunResult> {
|
||||
calls.push({ args });
|
||||
const joined = args.join(" ");
|
||||
if (joined.startsWith("status")) return ok({});
|
||||
if (joined.startsWith("session start")) return ok(startReplies.shift());
|
||||
if (joined.startsWith("snapshot")) {
|
||||
return ok({ text: "x", ref_count: 1, tab_id: 7, truncated: false });
|
||||
}
|
||||
if (joined === "session stop own1") return ok({});
|
||||
if (joined === "session stop own2") {
|
||||
// Stale handle: already stopped by someone else / daemon restarted.
|
||||
return fail("session not found");
|
||||
}
|
||||
return fail(`unexpected command: ${joined}`);
|
||||
},
|
||||
killAll() {},
|
||||
};
|
||||
const tools = new Map<string, ToolDefinition>();
|
||||
const disposers: Array<() => Promise<void>> = [];
|
||||
const ctx = {
|
||||
tools: { register: (def: ToolDefinition) => tools.set(def.name, def) },
|
||||
get: () => undefined,
|
||||
effect: (fn: () => () => Promise<void>) => {
|
||||
disposers.push(fn());
|
||||
},
|
||||
};
|
||||
apply(ctx as never, { maxSessions: 5 }, { runnerFactory: () => runner });
|
||||
|
||||
const start = tools.get("browser_session_start");
|
||||
const snapshot = tools.get("browser_snapshot");
|
||||
if (start === undefined || snapshot === undefined) throw new Error("tools not registered");
|
||||
await start.execute({}, makeExec());
|
||||
await start.execute({}, makeExec());
|
||||
// A foreign session gets referenced (current-pointer bookkeeping) but never owned.
|
||||
await snapshot.execute({ session: "ext9" }, makeExec());
|
||||
|
||||
await Promise.all(disposers.map((dispose) => dispose()));
|
||||
|
||||
const stops = calls
|
||||
.map((call) => call.args.join(" "))
|
||||
.filter((joined) => joined.startsWith("session stop"));
|
||||
expect(stops.sort()).toEqual(["session stop own1", "session stop own2"]);
|
||||
expect(stops.some((joined) => joined.includes("ext9"))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user