mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-04 13:07:11 +00:00
fix(producer): a laptop that sleeps mid-render no longer counts the sleep as a capture stall (#7096)
* fix(producer): a laptop that sleeps mid-render no longer counts the sleep as a capture stall * style(producer): format the stall watchdog timers
This commit is contained in:
@@ -215,6 +215,43 @@ describe("RenderObservabilityRecorder", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("the stall watchdog across a system sleep", () => {
|
||||
const watch = () => {
|
||||
const recorder = new RenderObservabilityRecorder({
|
||||
pipelineStartMs: Date.now(),
|
||||
log: makeLog(),
|
||||
renderJobId: "render-stall-sleep",
|
||||
});
|
||||
const outcome: { error?: unknown } = {};
|
||||
observeRenderStage(recorder, "capture_disk", undefined, () => new Promise<void>(() => {}), {
|
||||
stall: { progress: () => 0, noProgressMs: 10_000, firstProgressMs: 10_000 },
|
||||
}).catch((error: unknown) => {
|
||||
outcome.error = error;
|
||||
});
|
||||
return outcome;
|
||||
};
|
||||
|
||||
it("does not read a ten-hour sleep as no progress", async () => {
|
||||
vi.useFakeTimers();
|
||||
const outcome = watch();
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
vi.setSystemTime(Date.now() + 10 * 3_600_000);
|
||||
await vi.advanceTimersByTimeAsync(9_000);
|
||||
expect(outcome.error).toBeUndefined();
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(outcome.error).toBeInstanceOf(Error);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("still stalls when the process runs on time and no progress lands", async () => {
|
||||
vi.useFakeTimers();
|
||||
const outcome = watch();
|
||||
await vi.advanceTimersByTimeAsync(11_000);
|
||||
expect((outcome.error as Error).message).toContain("no progress for 10000ms");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
it("tells the stall rule each time a capture attempt opens", async () => {
|
||||
const recorder = new RenderObservabilityRecorder({
|
||||
pipelineStartMs: Date.now(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createStallClock } from "./stallClock.js";
|
||||
import { redactTelemetryString } from "@hyperframes/core";
|
||||
import { CaptureFailure } from "@hyperframes/engine";
|
||||
import type { ProducerLogger } from "../../logger.js";
|
||||
@@ -646,35 +647,33 @@ function watchStall(phase: string, rule: StageStallRule) {
|
||||
const detail = rule.detail ?? (() => undefined);
|
||||
let last = rule.progress();
|
||||
let budgetMs = rule.firstProgressMs;
|
||||
let lastChangeAt = Date.now();
|
||||
const silence = createStallClock();
|
||||
const pollMs = Math.min(STALL_POLL_MS, rule.noProgressMs, rule.firstProgressMs);
|
||||
let attempt: AbortController | undefined;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let inTail = false;
|
||||
const restartWindow = (ms: number) => {
|
||||
last = rule.progress();
|
||||
budgetMs = ms;
|
||||
lastChangeAt = Date.now();
|
||||
silence.reset();
|
||||
};
|
||||
const tripped = new Promise<never>((_, reject) => {
|
||||
timer = setInterval(
|
||||
() => {
|
||||
const current = rule.progress();
|
||||
if (rule.complete?.() && !inTail) {
|
||||
inTail = true;
|
||||
return restartWindow(rule.tailMs ?? rule.noProgressMs);
|
||||
}
|
||||
if (current !== last) return restartWindow(rule.noProgressMs);
|
||||
if (Date.now() - lastChangeAt < budgetMs) return;
|
||||
const error = new RenderStageStalledError(phase, budgetMs, current, detail());
|
||||
if (attempt && !attempt.signal.aborted) {
|
||||
attempt.abort(error);
|
||||
return restartWindow(rule.noProgressMs);
|
||||
}
|
||||
clearInterval(timer);
|
||||
reject(error);
|
||||
},
|
||||
Math.min(STALL_POLL_MS, rule.noProgressMs, rule.firstProgressMs),
|
||||
);
|
||||
timer = setInterval(() => {
|
||||
const current = rule.progress();
|
||||
if (rule.complete?.() && !inTail) {
|
||||
inTail = true;
|
||||
return restartWindow(rule.tailMs ?? rule.noProgressMs);
|
||||
}
|
||||
if (current !== last) return restartWindow(rule.noProgressMs);
|
||||
if (silence.idle(pollMs) < budgetMs) return;
|
||||
const error = new RenderStageStalledError(phase, budgetMs, current, detail());
|
||||
if (attempt && !attempt.signal.aborted) {
|
||||
attempt.abort(error);
|
||||
return restartWindow(rule.noProgressMs);
|
||||
}
|
||||
clearInterval(timer);
|
||||
reject(error);
|
||||
}, pollMs);
|
||||
});
|
||||
const openAttempt: OpenStallAttempt = (parent) => {
|
||||
attempt = new AbortController();
|
||||
|
||||
@@ -518,6 +518,82 @@ describe("runCaptureStreamingStage", () => {
|
||||
expect(input.abortSignal).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("a system sleep during capture", () => {
|
||||
const settled = (promise: Promise<unknown>) => {
|
||||
const state = { rejected: undefined as unknown, done: false };
|
||||
promise.then(
|
||||
() => (state.done = true),
|
||||
(error) => {
|
||||
state.done = true;
|
||||
state.rejected = error;
|
||||
},
|
||||
);
|
||||
return state;
|
||||
};
|
||||
const after = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const realNow = Date.now;
|
||||
const sleepFor = (ms: number) => {
|
||||
const skew = ms;
|
||||
Date.now = () => realNow() + skew;
|
||||
};
|
||||
const input = {
|
||||
session: { captureMode: "screenshot", captureStep: "" },
|
||||
frameIndex: 3,
|
||||
totalFrames: 9,
|
||||
stallTimeoutMs: 400,
|
||||
} as const;
|
||||
|
||||
it("does not trip a sequential capture the moment the machine wakes", async () => {
|
||||
const { raceAgainstStall } = await import("./captureStreamingStage.js");
|
||||
const state = settled(raceAgainstStall(new Promise<never>(() => {}), 400, input));
|
||||
await after(100);
|
||||
sleepFor(10 * 3_600_000);
|
||||
try {
|
||||
await after(500);
|
||||
expect(state.done).toBe(false);
|
||||
await after(1_000);
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
expect((state.rejected as Error).name).toBe("SequentialCaptureStallError");
|
||||
});
|
||||
|
||||
it("trips a sequential capture that makes no progress on time", async () => {
|
||||
const { raceAgainstStall } = await import("./captureStreamingStage.js");
|
||||
const state = settled(raceAgainstStall(new Promise<never>(() => {}), 200, input));
|
||||
await after(700);
|
||||
expect((state.rejected as Error).name).toBe("SequentialCaptureStallError");
|
||||
});
|
||||
|
||||
it("does not trip the parallel watchdog the moment the machine wakes", async () => {
|
||||
hangParallelUntilAbort = true;
|
||||
const prev = process.env.HF_DE_STALL_MS;
|
||||
process.env.HF_DE_STALL_MS = "100";
|
||||
const { runCaptureStreamingStage } = await import("./captureStreamingStage.js");
|
||||
const baseInput = createInput({ forceScreenshot: false, ffmpegStreamingTimeout: 3_600_000 });
|
||||
const started = Date.now();
|
||||
const run = runCaptureStreamingStage({
|
||||
...baseInput,
|
||||
totalFrames: 100,
|
||||
plan: { ...baseInput.plan, workerCount: 2, forceParallelStream: true },
|
||||
parallelCaptureLabel: "screenshot",
|
||||
}).catch((error: unknown) => error);
|
||||
await after(30);
|
||||
sleepFor(10 * 3_600_000);
|
||||
let caught: unknown;
|
||||
try {
|
||||
caught = await run;
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
hangParallelUntilAbort = false;
|
||||
if (prev === undefined) delete process.env.HF_DE_STALL_MS;
|
||||
else process.env.HF_DE_STALL_MS = prev;
|
||||
}
|
||||
expect((caught as Error).name).toBe("ParallelCaptureStallError");
|
||||
expect(Date.now() - started).toBeGreaterThanOrEqual(190);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a parallel capture stall", () => {
|
||||
const run = async (
|
||||
afterFrames: number | number[],
|
||||
|
||||
@@ -79,6 +79,7 @@ import {
|
||||
pushWorkerPageErrors,
|
||||
withWorkerPageErrors,
|
||||
} from "../perfSummary.js";
|
||||
import { createStallClock } from "../stallClock.js";
|
||||
import { ensureFrameWritten } from "./captureHdrFrameShared.js";
|
||||
import { reportEncodeProgress, reportFrameProgress, reportWorkerStartup } from "../shared.js";
|
||||
import { encoderFailureError } from "../encoderInterruption.js";
|
||||
@@ -220,24 +221,31 @@ export function raceAgainstStall<T>(
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
reject(
|
||||
signal?.aborted
|
||||
? new Error(
|
||||
`[Render] Sequential ${captureModeLabel(input.session.captureMode)} capture aborted ` +
|
||||
`while a capture call was in flight (frame ${input.frameIndex}/${input.totalFrames}).`,
|
||||
)
|
||||
: new SequentialCaptureStallError(
|
||||
input.session,
|
||||
input.stallTimeoutMs,
|
||||
input.frameIndex,
|
||||
input.totalFrames,
|
||||
),
|
||||
);
|
||||
},
|
||||
Math.max(0, deadlineMs),
|
||||
);
|
||||
const clock = createStallClock();
|
||||
const trip = () =>
|
||||
reject(
|
||||
signal?.aborted
|
||||
? new Error(
|
||||
`[Render] Sequential ${captureModeLabel(input.session.captureMode)} capture aborted ` +
|
||||
`while a capture call was in flight (frame ${input.frameIndex}/${input.totalFrames}).`,
|
||||
)
|
||||
: new SequentialCaptureStallError(
|
||||
input.session,
|
||||
input.stallTimeoutMs,
|
||||
input.frameIndex,
|
||||
input.totalFrames,
|
||||
),
|
||||
);
|
||||
const deadline = Math.max(0, deadlineMs);
|
||||
let wait = Math.min(deadline, DE_STALL_POLL_MS);
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const look = () => {
|
||||
const idle = clock.idle(wait);
|
||||
if (idle >= deadline) return trip();
|
||||
wait = Math.min(deadline - idle, DE_STALL_POLL_MS);
|
||||
timer = setTimeout(look, wait);
|
||||
};
|
||||
timer = setTimeout(look, wait);
|
||||
promise.then(
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
@@ -514,21 +522,21 @@ async function runWorkerEncodePipelineLoop(
|
||||
item.trusted ? Promise.resolve(buf) : guard(session, item.idx, buf);
|
||||
const captureRisk = async (idx: number): Promise<Buffer> => {
|
||||
await risk!.open();
|
||||
lastProgressAt = Date.now();
|
||||
quiet.reset();
|
||||
return raceAgainstStall(
|
||||
risk!.capture(idx, frameTime(idx)),
|
||||
stallTimeoutMs - (Date.now() - lastProgressAt),
|
||||
stallTimeoutMs - quiet.idle(0),
|
||||
{ session, frameIndex: idx, totalFrames, stallTimeoutMs },
|
||||
abortSignal,
|
||||
);
|
||||
};
|
||||
|
||||
const stallTimeoutMs = resolveCaptureStallTimeoutMs();
|
||||
let lastProgressAt = Date.now();
|
||||
const quiet = createStallClock();
|
||||
const captureWithStallGuard = <T>(idx: number, promise: Promise<T>): Promise<T> =>
|
||||
raceAgainstStall(
|
||||
promise,
|
||||
stallTimeoutMs - (Date.now() - lastProgressAt),
|
||||
stallTimeoutMs - quiet.idle(0),
|
||||
{ session, frameIndex: idx, totalFrames, stallTimeoutMs },
|
||||
abortSignal,
|
||||
);
|
||||
@@ -545,7 +553,7 @@ async function runWorkerEncodePipelineLoop(
|
||||
reorderBuffer.advanceTo(prev.idx + 1);
|
||||
job.framesRendered = prev.idx + 1;
|
||||
resume.written = prev.idx + 1;
|
||||
lastProgressAt = Date.now();
|
||||
quiet.reset();
|
||||
reportFrameProgress(
|
||||
job,
|
||||
`Streaming frame ${prev.idx + 1}/${totalFrames}`,
|
||||
@@ -574,7 +582,7 @@ async function runWorkerEncodePipelineLoop(
|
||||
reorderBuffer.advanceTo(item.idx + 1);
|
||||
job.framesRendered = item.idx + 1;
|
||||
resume.written = item.idx + 1;
|
||||
lastProgressAt = Date.now();
|
||||
quiet.reset();
|
||||
reportFrameProgress(
|
||||
job,
|
||||
`Streaming frame ${item.idx + 1}/${totalFrames}`,
|
||||
@@ -837,7 +845,7 @@ export async function runCaptureStreamingStage(
|
||||
}
|
||||
const stallTimeoutMs = resolveCaptureStallTimeoutMs();
|
||||
let lastCapturedFrames = 0;
|
||||
let lastProgressAt = Date.now();
|
||||
const quiet = createStallClock();
|
||||
const workerPhases = new Map<number, string>();
|
||||
const phaseDetail = (phase: ParallelWorkerPhaseDiagnostic) =>
|
||||
`phase=${phase.phase} frame=${phase.frameIndex ?? "n/a"} step=${phase.readStep?.() ?? "n/a"} ` +
|
||||
@@ -856,23 +864,21 @@ export async function runCaptureStreamingStage(
|
||||
captureLabel === "BeginFrame",
|
||||
cfg,
|
||||
);
|
||||
const stallTimer = setInterval(
|
||||
() => {
|
||||
if (!watchdogArmed || Date.now() - lastProgressAt <= budget()) return;
|
||||
stalled = true;
|
||||
const stallErr = new ParallelCaptureStallError({
|
||||
variant: "drain",
|
||||
captureLabel,
|
||||
stallTimeoutMs: budget(),
|
||||
lastCapturedFrames: resumeFrom + lastCapturedFrames,
|
||||
totalFrames,
|
||||
phaseSummary: phaseSummary(),
|
||||
});
|
||||
parallelReorder.abort(stallErr);
|
||||
stallController.abort();
|
||||
},
|
||||
Math.min(budget(), DE_STALL_POLL_MS),
|
||||
);
|
||||
const pollMs = Math.min(budget(), DE_STALL_POLL_MS);
|
||||
const stallTimer = setInterval(() => {
|
||||
if (!watchdogArmed || quiet.idle(pollMs) <= budget()) return;
|
||||
stalled = true;
|
||||
const stallErr = new ParallelCaptureStallError({
|
||||
variant: "drain",
|
||||
captureLabel,
|
||||
stallTimeoutMs: budget(),
|
||||
lastCapturedFrames: resumeFrom + lastCapturedFrames,
|
||||
totalFrames,
|
||||
phaseSummary: phaseSummary(),
|
||||
});
|
||||
parallelReorder.abort(stallErr);
|
||||
stallController.abort();
|
||||
}, pollMs);
|
||||
|
||||
try {
|
||||
workerResults = await executeParallelCapture(
|
||||
@@ -888,7 +894,7 @@ export async function runCaptureStreamingStage(
|
||||
workerPhases.set(phase.workerId, phaseDetail(phase));
|
||||
if (phase.phase === "frame_capture" && !watchdogArmed) {
|
||||
watchdogArmed = true;
|
||||
lastProgressAt = Date.now();
|
||||
quiet.reset();
|
||||
}
|
||||
log.info("[Render] Parallel capture worker phase", {
|
||||
workerId: phase.workerId,
|
||||
@@ -904,7 +910,7 @@ export async function runCaptureStreamingStage(
|
||||
}
|
||||
if (progress.capturedFrames > lastCapturedFrames) {
|
||||
lastCapturedFrames = progress.capturedFrames;
|
||||
lastProgressAt = Date.now();
|
||||
quiet.reset();
|
||||
watchdogArmed = true;
|
||||
}
|
||||
const doneFrames = resumeFrom + progress.capturedFrames;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createStallClock } from "./stallClock.js";
|
||||
|
||||
describe("createStallClock", () => {
|
||||
it("does not count a suspended stretch as silence", () => {
|
||||
let now = 0;
|
||||
const clock = createStallClock(() => now);
|
||||
now = 5_000;
|
||||
expect(clock.idle(5_000)).toBe(5_000);
|
||||
now += 10 * 3_600_000;
|
||||
expect(clock.idle(5_000)).toBe(5_000);
|
||||
now += 5_000;
|
||||
expect(clock.idle(5_000)).toBe(10_000);
|
||||
});
|
||||
|
||||
it("counts on-time looks without progress, and starts again at progress", () => {
|
||||
let now = 0;
|
||||
const clock = createStallClock(() => now);
|
||||
let idle = 0;
|
||||
for (let i = 0; i < 13; i++) {
|
||||
now += 5_000;
|
||||
idle = clock.idle(5_000);
|
||||
}
|
||||
expect(idle).toBe(65_000);
|
||||
clock.reset();
|
||||
now += 5_000;
|
||||
expect(clock.idle(5_000)).toBe(5_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/** A gap this much past a look's expected delay means the process was suspended, not that nothing landed. */
|
||||
const SUSPEND_GAP_MS = 15_000;
|
||||
|
||||
export interface StallClock {
|
||||
/** Progress landed: the silence starts again. */
|
||||
reset(): void;
|
||||
/** Silent ms, not counting a suspended stretch; `expectedMs` is how long after the last look this one was due. */
|
||||
idle(expectedMs: number): number;
|
||||
}
|
||||
|
||||
export function createStallClock(now: () => number = () => Date.now()): StallClock {
|
||||
let since = now();
|
||||
let seen = since;
|
||||
return {
|
||||
reset() {
|
||||
since = seen = now();
|
||||
},
|
||||
idle(expectedMs) {
|
||||
const at = now();
|
||||
const gap = at - seen;
|
||||
seen = at;
|
||||
if (gap > expectedMs + SUSPEND_GAP_MS) since += gap;
|
||||
return at - since;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user