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:
@@ -1,13 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SessionRegistry } from "../src/sessions";
|
||||
|
||||
/** Drive one full successful start through the reservation protocol. */
|
||||
function start(registry: SessionRegistry, sessionId: string): void {
|
||||
registry.reserveStart();
|
||||
registry.completeStart({ sessionId, startedAtMs: Date.now() });
|
||||
}
|
||||
|
||||
describe("SessionRegistry", () => {
|
||||
it("tracks the current session across add and remove", () => {
|
||||
it("tracks the current session across start and remove", () => {
|
||||
const registry = new SessionRegistry(5);
|
||||
expect(registry.current()).toBeUndefined();
|
||||
registry.add({ sessionId: "s1", startedAtMs: 1 });
|
||||
start(registry, "s1");
|
||||
expect(registry.current()).toBe("s1");
|
||||
registry.add({ sessionId: "s2", startedAtMs: 2 });
|
||||
start(registry, "s2");
|
||||
expect(registry.current()).toBe("s2");
|
||||
registry.remove("s2");
|
||||
expect(registry.current()).toBe("s1");
|
||||
@@ -15,27 +21,42 @@ describe("SessionRegistry", () => {
|
||||
expect(registry.current()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enforces the concurrency cap", () => {
|
||||
it("enforces the concurrency cap including in-flight starts", () => {
|
||||
const registry = new SessionRegistry(2);
|
||||
registry.add({ sessionId: "s1", startedAtMs: 1 });
|
||||
registry.add({ sessionId: "s2", startedAtMs: 2 });
|
||||
expect(() => registry.add({ sessionId: "s3", startedAtMs: 3 })).toThrow(/session limit/);
|
||||
start(registry, "s1");
|
||||
start(registry, "s2");
|
||||
expect(() => registry.reserveStart()).toThrow(/session limit/);
|
||||
registry.remove("s1");
|
||||
registry.add({ sessionId: "s3", startedAtMs: 3 });
|
||||
registry.reserveStart();
|
||||
registry.completeStart({ sessionId: "s3", startedAtMs: Date.now() });
|
||||
expect(registry.size()).toBe(2);
|
||||
});
|
||||
|
||||
it("resolve prefers the explicit session and adopts unknown ids", () => {
|
||||
it("counts reserved (not yet spawned) starts against the cap — the race case", () => {
|
||||
const registry = new SessionRegistry(2);
|
||||
registry.reserveStart();
|
||||
registry.reserveStart();
|
||||
// Both slots are in flight; a third concurrent start must reject even
|
||||
// though no session has been registered yet.
|
||||
expect(() => registry.reserveStart()).toThrow(/session limit/);
|
||||
registry.abandonStart();
|
||||
registry.reserveStart();
|
||||
registry.completeStart({ sessionId: "s1", startedAtMs: Date.now() });
|
||||
expect(registry.ownedIds()).toEqual(["s1"]);
|
||||
});
|
||||
|
||||
it("resolve prefers the explicit session and adopts it as a reference", () => {
|
||||
const registry = new SessionRegistry(5);
|
||||
registry.add({ sessionId: "s1", startedAtMs: 1 });
|
||||
expect(registry.resolve("s9", "tool")).toBe("s9");
|
||||
expect(registry.current()).toBe("s9");
|
||||
expect(registry.list().map((s) => s.sessionId)).toContain("s9");
|
||||
start(registry, "s1");
|
||||
expect(registry.resolve("foreign", "tool")).toBe("foreign");
|
||||
expect(registry.current()).toBe("foreign");
|
||||
expect(registry.isOwned("foreign")).toBe(false);
|
||||
expect(registry.ownedIds()).toEqual(["s1"]);
|
||||
});
|
||||
|
||||
it("resolve falls back to the current session", () => {
|
||||
const registry = new SessionRegistry(5);
|
||||
registry.add({ sessionId: "s1", startedAtMs: 1 });
|
||||
start(registry, "s1");
|
||||
expect(registry.resolve(undefined, "tool")).toBe("s1");
|
||||
});
|
||||
|
||||
@@ -46,10 +67,36 @@ describe("SessionRegistry", () => {
|
||||
|
||||
it("touch refreshes recency so fallback picks the most recent survivor", () => {
|
||||
const registry = new SessionRegistry(5);
|
||||
registry.add({ sessionId: "s1", startedAtMs: 1 });
|
||||
registry.add({ sessionId: "s2", startedAtMs: 2 });
|
||||
start(registry, "s1");
|
||||
start(registry, "s2");
|
||||
registry.touch("s1");
|
||||
registry.remove("s1");
|
||||
expect(registry.current()).toBe("s2");
|
||||
});
|
||||
|
||||
it("resolveForStop accepts owned sessions and refuses foreign ones", () => {
|
||||
const registry = new SessionRegistry(5);
|
||||
start(registry, "s1");
|
||||
expect(registry.resolveForStop(undefined)).toBe("s1");
|
||||
expect(registry.resolveForStop("s1")).toBe("s1");
|
||||
registry.resolve("foreign", "browser_snapshot");
|
||||
expect(() => registry.resolveForStop("foreign")).toThrow(/not created by this plugin/);
|
||||
expect(() => registry.resolveForStop("never-seen")).toThrow(/not created by this plugin/);
|
||||
});
|
||||
|
||||
it("a refused stop does not move the current pointer", () => {
|
||||
const registry = new SessionRegistry(5);
|
||||
start(registry, "s1");
|
||||
expect(() => registry.resolveForStop("foreign")).toThrow();
|
||||
expect(registry.current()).toBe("s1");
|
||||
});
|
||||
|
||||
it("ownedIds excludes referenced sessions even after heavy interleaving", () => {
|
||||
const registry = new SessionRegistry(5);
|
||||
start(registry, "own1");
|
||||
registry.resolve("ext1", "tool");
|
||||
start(registry, "own2");
|
||||
registry.resolve("ext2", "tool");
|
||||
expect(registry.ownedIds().sort()).toEqual(["own1", "own2"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user