mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
f8ce95aa65
## Defect `ScreencastManager` refcounts consumers of a single shared CDP screencast: 0→1 fires `Page.startScreencast`, the last dropper fires `Page.stopScreencast`. The real consumer is `GET /stream.mjpeg`, which does `session = await startScreencast(...)` and, on failure, ends the response without ever holding a session to stop. The old code incremented `activeCount` **before** awaiting `Page.startScreencast`. If that CDP call rejected, `start()` threw with `activeCount` stuck at 1 and no session handed out. Every later `start()` then saw `activeCount !== 1`, skipped re-issuing `Page.startScreencast`, and returned a session subscribed to a stream that was never running — so a single transient failure wedged `/stream.mjpeg` (frozen, frame-less) for the rest of the device's lifetime. ## Fix Rework `start()` into a single-flight design: - The first subscriber publishes an in-flight `startInFlight` promise; concurrent joiners await that same promise, so a transient failure fails them too instead of leaving them attached to a dead stream. - The refcount is taken **only after** `Page.startScreencast` actually resolves, so a failed start nets 0 (no leak); a successful start nets exactly +1. - Each session carries an `acquired` flag; a session that lost a `forceStop()` race returns a no-op `stop()`. Identity guards (`startInFlight === inFlight`) on the success/catch/join paths handle a `forceStop()` landing mid-start, and `forceStop()` nulls `startInFlight`. ## Test `test/chromium-screencast-refcount.test.ts` drives start→(fail)→start→stop against a CDP mock whose first `Page.startScreencast` rejects, and asserts the failed start drains to 0, the next start re-issues exactly one `Page.startScreencast`, `stop()` balances back to 0 with one `Page.stopScreencast`, a successful start nets +1, and a double `stop()` is a safe no-op. It fails on the pre-fix code (which leaks the count and never re-issues).
237 lines
9.6 KiB
TypeScript
237 lines
9.6 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { TypedEventEmitter } from "@argent/registry";
|
|
import { ScreencastManager } from "../src/chromium-server/screencast";
|
|
|
|
describe("ScreencastManager recovers after a failed start", () => {
|
|
it("re-issues Page.startScreencast after a transient failure", async () => {
|
|
let failNext = true;
|
|
const send = vi.fn(async (method: string) => {
|
|
if (method === "Page.startScreencast" && failNext) throw new Error("transient");
|
|
return {};
|
|
});
|
|
const cdp = { events: new TypedEventEmitter(), send } as never;
|
|
const events = new TypedEventEmitter() as never;
|
|
const fps = { recordFrame: () => {} } as never;
|
|
const mgr = new ScreencastManager(cdp, events, fps);
|
|
await expect(mgr.start()).rejects.toThrow(/transient/);
|
|
failNext = false;
|
|
send.mockClear();
|
|
await mgr.start();
|
|
expect(send.mock.calls.filter((c) => c[0] === "Page.startScreencast").length).toBe(1);
|
|
});
|
|
|
|
it("does not strand a concurrent joiner when the owner's start rejects", async () => {
|
|
// The owner's Page.startScreencast is gated so a second caller can join
|
|
// while it is in flight, then the owner's start rejects. The joiner must
|
|
// reject too (not hold a session for a screencast that never started), the
|
|
// refcount must drain to 0, and a later start must re-issue cleanly.
|
|
let releaseFirst!: (fail: boolean) => void;
|
|
const firstGate = new Promise<boolean>((resolve) => {
|
|
releaseFirst = resolve;
|
|
});
|
|
let calls = 0;
|
|
const send = vi.fn(async (method: string) => {
|
|
if (method === "Page.startScreencast") {
|
|
calls += 1;
|
|
if (calls === 1 && (await firstGate)) throw new Error("transient");
|
|
}
|
|
return {};
|
|
});
|
|
const cdp = { events: new TypedEventEmitter(), send } as never;
|
|
const mgr = new ScreencastManager(
|
|
cdp,
|
|
new TypedEventEmitter() as never,
|
|
{ recordFrame: () => {} } as never
|
|
);
|
|
|
|
const owner = mgr.start(); // owner: startScreencast is in flight (gated)
|
|
const joiner = mgr.start(); // joiner: enters while the owner's start is in flight
|
|
// Attach rejection handlers before releasing the gate (no unhandled reject).
|
|
const ownerRejects = expect(owner).rejects.toThrow(/transient/);
|
|
const joinerRejects = expect(joiner).rejects.toThrow(/transient/);
|
|
await Promise.resolve();
|
|
releaseFirst(true); // fail the owner's start
|
|
await ownerRejects;
|
|
await joinerRejects;
|
|
|
|
// Fully drained: a fresh start re-issues Page.startScreencast and succeeds.
|
|
send.mockClear();
|
|
const recovered = await mgr.start();
|
|
expect(recovered).toBeTruthy();
|
|
expect(send.mock.calls.filter((c) => c[0] === "Page.startScreencast").length).toBe(1);
|
|
});
|
|
|
|
it("takes no phantom refcount when forceStop races a successful start", async () => {
|
|
// forceStop() (dispose) tears the screencast down while the owner's
|
|
// Page.startScreencast is still in flight; when it then SUCCEEDS the owner
|
|
// must NOT take a refcount for the now-torn-down screencast, or a later
|
|
// start() would see activeCount != 0 and skip re-issuing.
|
|
let releaseStart!: () => void;
|
|
const startGate = new Promise<void>((resolve) => {
|
|
releaseStart = resolve;
|
|
});
|
|
let startCalls = 0;
|
|
const send = vi.fn(async (method: string) => {
|
|
if (method === "Page.startScreencast") {
|
|
startCalls += 1;
|
|
if (startCalls === 1) await startGate;
|
|
}
|
|
return {};
|
|
});
|
|
const cdp = { events: new TypedEventEmitter(), send } as never;
|
|
const mgr = new ScreencastManager(
|
|
cdp,
|
|
new TypedEventEmitter() as never,
|
|
{ recordFrame: () => {} } as never
|
|
);
|
|
|
|
const starting = mgr.start(); // owner: startScreencast gated (in flight)
|
|
await Promise.resolve();
|
|
await mgr.forceStop(); // dispose-style teardown mid-start
|
|
releaseStart(); // the gated start now resolves
|
|
await starting;
|
|
|
|
// No phantom refcount ⇒ a fresh start re-issues Page.startScreencast.
|
|
send.mockClear();
|
|
await mgr.start();
|
|
expect(send.mock.calls.filter((c) => c[0] === "Page.startScreencast").length).toBe(1);
|
|
});
|
|
|
|
it("takes no phantom refcount when forceStop races a successful start with a joiner", async () => {
|
|
// Same race, but a JOINER is present when forceStop tears the screencast
|
|
// down and the owner's start then succeeds — the joiner must not take a
|
|
// phantom refcount either.
|
|
let releaseStart!: () => void;
|
|
const startGate = new Promise<void>((resolve) => {
|
|
releaseStart = resolve;
|
|
});
|
|
let startCalls = 0;
|
|
const send = vi.fn(async (method: string) => {
|
|
if (method === "Page.startScreencast") {
|
|
startCalls += 1;
|
|
if (startCalls === 1) await startGate;
|
|
}
|
|
return {};
|
|
});
|
|
const cdp = { events: new TypedEventEmitter(), send } as never;
|
|
const mgr = new ScreencastManager(
|
|
cdp,
|
|
new TypedEventEmitter() as never,
|
|
{ recordFrame: () => {} } as never
|
|
);
|
|
|
|
const owner = mgr.start(); // owner: startScreencast gated (in flight)
|
|
const joiner = mgr.start(); // joiner: joins the in-flight start
|
|
await Promise.resolve();
|
|
await mgr.forceStop(); // dispose-style teardown mid-start
|
|
releaseStart(); // the gated start now resolves
|
|
await owner;
|
|
await joiner;
|
|
|
|
// Neither owner nor joiner took a refcount ⇒ a fresh start re-issues.
|
|
send.mockClear();
|
|
await mgr.start();
|
|
expect(send.mock.calls.filter((c) => c[0] === "Page.startScreencast").length).toBe(1);
|
|
});
|
|
|
|
it("a phantom session's stop() does not steal a later live session's refcount", async () => {
|
|
// forceStop() supersedes an owner's in-flight start; that start then
|
|
// SUCCEEDS anyway (this is the same race the two tests above cover, which
|
|
// assert no refcount is taken). This test goes one step further: a real
|
|
// session B then starts on the same manager, and the phantom session's
|
|
// stop() must be a true no-op — not decrement activeCount and potentially
|
|
// tear down B's live stream out from under it.
|
|
let releaseStart!: () => void;
|
|
const startGate = new Promise<void>((resolve) => {
|
|
releaseStart = resolve;
|
|
});
|
|
let startCalls = 0;
|
|
const send = vi.fn(async (method: string) => {
|
|
if (method === "Page.startScreencast") {
|
|
startCalls += 1;
|
|
if (startCalls === 1) await startGate;
|
|
}
|
|
return {};
|
|
});
|
|
const cdp = { events: new TypedEventEmitter(), send } as never;
|
|
const mgr = new ScreencastManager(
|
|
cdp,
|
|
new TypedEventEmitter() as never,
|
|
{ recordFrame: () => {} } as never
|
|
);
|
|
const stopCount = () => send.mock.calls.filter((c) => c[0] === "Page.stopScreencast").length;
|
|
|
|
const phantomStarting = mgr.start(); // startScreencast gated (in flight)
|
|
await Promise.resolve();
|
|
await mgr.forceStop(); // supersedes the in-flight start (calls stopScreencast once)
|
|
releaseStart(); // the superseded start now resolves successfully anyway
|
|
const phantom = await phantomStarting;
|
|
|
|
// A real session B starts fresh (activeCount was drained to 0 by forceStop).
|
|
send.mockClear();
|
|
const b = await mgr.start();
|
|
expect(stopCount()).toBe(0);
|
|
|
|
// The phantom's stop() must not touch B's session.
|
|
await phantom.stop();
|
|
expect(stopCount()).toBe(0);
|
|
|
|
// B's own stop() still works normally.
|
|
await b.stop();
|
|
expect(stopCount()).toBe(1);
|
|
});
|
|
|
|
it("a joiner on a succeeding in-flight start shares one screencast and stops it once on the last drop", async () => {
|
|
// Happy-path counterpart to "does not strand a concurrent joiner when the
|
|
// owner's start rejects": the owner's Page.startScreencast is gated so a
|
|
// joiner enters while it is in flight, then the start SUCCEEDS. Both callers
|
|
// must take a refcount (active count 2) off a SINGLE Page.startScreencast,
|
|
// and on teardown Page.stopScreencast must fire exactly once — on the last
|
|
// drop, not per session and not before the final consumer leaves.
|
|
let releaseFirst!: () => void;
|
|
const firstGate = new Promise<void>((resolve) => {
|
|
releaseFirst = resolve;
|
|
});
|
|
let calls = 0;
|
|
const send = vi.fn(async (method: string) => {
|
|
if (method === "Page.startScreencast") {
|
|
calls += 1;
|
|
if (calls === 1) await firstGate;
|
|
}
|
|
return {};
|
|
});
|
|
const cdp = { events: new TypedEventEmitter(), send } as never;
|
|
const mgr = new ScreencastManager(
|
|
cdp,
|
|
new TypedEventEmitter() as never,
|
|
{ recordFrame: () => {} } as never
|
|
);
|
|
// activeCount is a compile-time `private`; read it through a cast to assert
|
|
// the refcount transitions directly (0 → 2 → 1 → 0).
|
|
const activeCount = () => (mgr as unknown as { activeCount: number }).activeCount;
|
|
const startCount = () => send.mock.calls.filter((c) => c[0] === "Page.startScreencast").length;
|
|
const stopCount = () => send.mock.calls.filter((c) => c[0] === "Page.stopScreencast").length;
|
|
|
|
const owner = mgr.start(); // owner: startScreencast is in flight (gated)
|
|
const joiner = mgr.start(); // joiner: enters while the owner's start is in flight
|
|
await Promise.resolve();
|
|
releaseFirst(); // the owner's start now succeeds
|
|
const ownerSession = await owner;
|
|
const joinerSession = await joiner;
|
|
|
|
// Both callers acquired off a single Page.startScreencast.
|
|
expect(startCount()).toBe(1);
|
|
expect(activeCount()).toBe(2);
|
|
|
|
// First drop: refcount falls to 1, Page.stopScreencast must NOT fire yet.
|
|
await ownerSession.stop();
|
|
expect(activeCount()).toBe(1);
|
|
expect(stopCount()).toBe(0);
|
|
|
|
// Last drop: refcount hits 0, Page.stopScreencast fires exactly once.
|
|
await joinerSession.stop();
|
|
expect(activeCount()).toBe(0);
|
|
expect(stopCount()).toBe(1);
|
|
});
|
|
});
|