Skip to content

Commit 3a35491

Browse files
committed
fix(producer): close orphaned probe session before verify-triggered retries
On a parallel-capture disk-verify or streaming-drain breach, the outer catch cleared probeSession without first closing the still-owned session, orphaning the probe Chrome process precisely when the retry was recovering from GPU/memory pressure. Introduce closeOrphanedProbeForRetry so both retry catches close the session (with defensive .catch that logs on close error) before releasing the reference, and cover it with a focused unit test asserting closure-before-clear and the swallow-and-warn behaviour. Addresses Magi's REQUEST_CHANGES on heygen-com#2749; also closes Rames' sibling concern at the streaming-retry path (renderOrchestrator.ts:3093). — Via
1 parent 59cfbb9 commit 3a35491

2 files changed

Lines changed: 108 additions & 2 deletions

File tree

packages/producer/src/services/renderOrchestrator.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ vi.mock("@hyperframes/engine", async (importOriginal) => {
1717
import {
1818
buildMissingFrameRetryBatches,
1919
captureAttemptMadeProgress,
20+
closeOrphanedProbeForRetry,
2021
describeMemoryExhaustion,
2122
executeDiskCaptureWithAdaptiveRetry,
2223
collectVideoMetadataHints,
@@ -2130,3 +2131,63 @@ describe("shouldStreamParallelCapture (non-DE parallel streaming router)", () =>
21302131
expect(shouldStreamParallelCapture({ ...eligible, layeredOrEffectRoute: true })).toBe(false);
21312132
});
21322133
});
2134+
2135+
describe("closeOrphanedProbeForRetry (probe cleanup before verify-triggered retry)", () => {
2136+
// Enough of a CaptureSession stand-in to exercise the closer path — the
2137+
// helper never inspects the object; it just hands it to the injected closer.
2138+
const stubSession = { browserConsoleBuffer: [] } as unknown as Parameters<
2139+
typeof closeOrphanedProbeForRetry
2140+
>[0];
2141+
2142+
it("hands the still-owned probe to the closer before the caller clears it", async () => {
2143+
const closer = vi.fn(async () => {});
2144+
const log = { warn: vi.fn() };
2145+
2146+
await closeOrphanedProbeForRetry(stubSession, closer, log, "streaming");
2147+
2148+
expect(closer).toHaveBeenCalledTimes(1);
2149+
expect(closer).toHaveBeenCalledWith(stubSession);
2150+
expect(log.warn).not.toHaveBeenCalled();
2151+
});
2152+
2153+
it("swallows a close failure with a warn so the caller's retry can proceed", async () => {
2154+
const closer = vi.fn(async () => {
2155+
throw new Error("chrome zombie");
2156+
});
2157+
const log = { warn: vi.fn() };
2158+
2159+
await expect(
2160+
closeOrphanedProbeForRetry(stubSession, closer, log, "disk verify"),
2161+
).resolves.toBeUndefined();
2162+
2163+
expect(closer).toHaveBeenCalledTimes(1);
2164+
expect(log.warn).toHaveBeenCalledTimes(1);
2165+
const [message, meta] = log.warn.mock.calls[0];
2166+
expect(message).toContain("disk verify");
2167+
expect((meta as { error: string }).error).toBe("chrome zombie");
2168+
});
2169+
2170+
it("preserves the retry context in the warn message so the audit trail names which retry path leaked", async () => {
2171+
const closer = vi.fn(async () => {
2172+
throw new Error("session already closed");
2173+
});
2174+
const log = { warn: vi.fn() };
2175+
2176+
await closeOrphanedProbeForRetry(stubSession, closer, log, "streaming");
2177+
2178+
expect(log.warn.mock.calls[0][0]).toContain("streaming");
2179+
expect(log.warn.mock.calls[0][0]).not.toContain("disk verify");
2180+
});
2181+
2182+
it("stringifies non-Error rejections so the log entry still names the cause", async () => {
2183+
const closer = vi.fn(async () => Promise.reject("string-only rejection"));
2184+
const log = { warn: vi.fn() };
2185+
2186+
await closeOrphanedProbeForRetry(stubSession, closer, log, "streaming");
2187+
2188+
expect(log.warn).toHaveBeenCalledTimes(1);
2189+
expect((log.warn.mock.calls[0][1] as { error: string }).error).toBe(
2190+
"string-only rejection",
2191+
);
2192+
});
2193+
});

packages/producer/src/services/renderOrchestrator.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1468,6 +1468,32 @@ export function shouldRetryViaPinnedFallback(args: {
14681468
return args.deWorkerInversion === "inverted" || args.deParallelRouter === "routed";
14691469
}
14701470

1471+
/**
1472+
* When a self-verify (or pinned-fallback) retry is triggered mid-capture, the
1473+
* caller may still hold a live probe session that the failed stage was passed
1474+
* but did not (or could not) close in its own `finally` before it threw. Left
1475+
* behind, that session's Chrome process orphans until the containing render
1476+
* exits — precisely when we are recovering from GPU/memory pressure and can
1477+
* least afford an unaccounted Chrome. Close it before the caller clears its
1478+
* reference; swallow any close error with a warn so the retry itself is never
1479+
* derailed by a shutdown hiccup.
1480+
*/
1481+
export async function closeOrphanedProbeForRetry(
1482+
probe: CaptureSession,
1483+
closer: (session: CaptureSession) => Promise<void>,
1484+
log: Pick<ProducerLogger, "warn">,
1485+
retryContext: string,
1486+
): Promise<void> {
1487+
try {
1488+
await closer(probe);
1489+
} catch (closeErr) {
1490+
log.warn(
1491+
`[Render] probe close before ${retryContext} retry failed; continuing with retry`,
1492+
{ error: closeErr instanceof Error ? closeErr.message : String(closeErr) },
1493+
);
1494+
}
1495+
}
1496+
14711497
/**
14721498
* Parallel-streaming router for NON-drawElement capture (screenshot on
14731499
* macOS/Windows/forced-screenshot, BeginFrame on Linux): should this
@@ -3090,7 +3116,16 @@ async function executeRenderPipeline(input: {
30903116
deWorkerInversion,
30913117
deParallelRouter,
30923118
});
3093-
probeSession = null;
3119+
// Streaming stage aims to close the probe in its own finally; if it
3120+
// threw before doing so, the Chrome process would orphan through the
3121+
// pinned-fallback retry. Close defensively before we release the
3122+
// reference — see closeOrphanedProbeForRetry.
3123+
if (probeSession) {
3124+
lastBrowserConsole = probeSession.browserConsoleBuffer;
3125+
const orphaned = probeSession;
3126+
probeSession = null;
3127+
await closeOrphanedProbeForRetry(orphaned, closeCaptureSession, log, "streaming");
3128+
}
30943129
if (failedRouting === "worker_inversion") {
30953130
// The inversion bet on drawElement and lost — re-render on the
30963131
// pre-inversion parallel screenshot path instead of single-worker
@@ -3243,7 +3278,17 @@ async function executeRenderPipeline(input: {
32433278
resetCaptureAttemptProgress(job);
32443279
dedupPerfs.length = 0;
32453280
cfg.useDrawElement = false;
3246-
probeSession = null;
3281+
// Same shape as the streaming retry above: `runCaptureStage` was
3282+
// passed the probe and threw before it could close it, so we must
3283+
// release the Chrome process ourselves before starting the
3284+
// screenshot-baseline retry — otherwise it orphans until render
3285+
// exit. See closeOrphanedProbeForRetry.
3286+
if (probeSession) {
3287+
lastBrowserConsole = probeSession.browserConsoleBuffer;
3288+
const orphaned = probeSession;
3289+
probeSession = null;
3290+
await closeOrphanedProbeForRetry(orphaned, closeCaptureSession, log, "disk verify");
3291+
}
32473292
capturePlan = replanAfterFailure(capturePlan, { kind: "draw_element_verification" });
32483293
syncCapturePlan();
32493294
updateCaptureObservability({

0 commit comments

Comments
 (0)