test(export): share finalization watchdog logic

This commit is contained in:
wiiiii123
2026-04-20 10:19:25 +07:00
parent 271dddcc32
commit 6783eeb55c
4 changed files with 184 additions and 119 deletions
+87 -1
View File
@@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
type FinalizationProgressWatchdog,
getExportFinalizationIdleTimeoutMs,
getExportFinalizationTimeoutMs,
withFinalizationTimeout,
} from "./finalizationTimeout";
describe("finalizationTimeout", () => {
@@ -73,5 +75,89 @@ describe("finalizationTimeout", () => {
effectiveDurationSec: 2_700,
}),
).toBe(300_000);
expect(
getExportFinalizationIdleTimeoutMs({
workload: "audio",
effectiveDurationSec: 0,
}),
).toBe(150_000);
expect(
getExportFinalizationIdleTimeoutMs({
workload: "audio",
effectiveDurationSec: Number.NaN,
}),
).toBe(150_000);
});
it("rejects when a progress-aware finalization stage stops reporting progress", async () => {
vi.useFakeTimers();
try {
const idleTimeoutMs = getExportFinalizationIdleTimeoutMs({
workload: "audio",
effectiveDurationSec: 1_200,
});
const pendingStage = withFinalizationTimeout({
promise: new Promise<never>(() => {}),
stage: "audio processing",
workload: "audio",
effectiveDurationSec: 1_200,
progressAware: true,
});
const rejection = pendingStage.then(
() => null,
(error) => (error instanceof Error ? error.message : String(error)),
);
await vi.advanceTimersByTimeAsync(idleTimeoutMs + 1);
await expect(rejection).resolves.toContain("without observable progress");
} finally {
vi.useRealTimers();
}
});
it("resets the idle watchdog when finalization progress continues", async () => {
vi.useFakeTimers();
try {
const idleTimeoutMs = getExportFinalizationIdleTimeoutMs({
workload: "audio",
effectiveDurationSec: 1_200,
});
let watchdog: FinalizationProgressWatchdog | null = null;
const pendingStage = withFinalizationTimeout({
promise: new Promise<never>(() => {}),
stage: "audio processing",
workload: "audio",
effectiveDurationSec: 1_200,
progressAware: true,
onWatchdogChanged: (nextWatchdog) => {
watchdog = nextWatchdog;
},
});
const rejection = pendingStage.then(
() => null,
(error) => (error instanceof Error ? error.message : String(error)),
);
await vi.advanceTimersByTimeAsync(idleTimeoutMs - 1_000);
expect(watchdog).not.toBeNull();
watchdog?.refreshProgress();
await vi.advanceTimersByTimeAsync(idleTimeoutMs - 1_000);
const pendingSentinel = Symbol("pending");
await expect(Promise.race([rejection, Promise.resolve(pendingSentinel)])).resolves.toBe(
pendingSentinel,
);
await vi.advanceTimersByTimeAsync(1_001);
await expect(rejection).resolves.toContain("without observable progress");
} finally {
vi.useRealTimers();
}
});
});
+77
View File
@@ -1,4 +1,7 @@
export type FinalizationTimeoutWorkload = "default" | "audio";
export type FinalizationProgressWatchdog = {
refreshProgress: () => void;
};
const BASE_FINALIZATION_TIMEOUT_MS = 10 * 60_000;
const AUDIO_TIMEOUT_HEADROOM_PER_OUTPUT_SECOND_MS = 500;
@@ -53,3 +56,77 @@ export function getExportFinalizationIdleTimeoutMs({
MAX_PROGRESS_IDLE_TIMEOUT_MS,
);
}
export async function withFinalizationTimeout<T>({
promise,
stage,
effectiveDurationSec,
workload = "default",
progressAware = false,
onWatchdogChanged,
}: {
promise: Promise<T>;
stage: string;
effectiveDurationSec?: number | null;
workload?: FinalizationTimeoutWorkload;
progressAware?: boolean;
onWatchdogChanged?: (watchdog: FinalizationProgressWatchdog | null) => void;
}): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let idleTimeoutId: ReturnType<typeof setTimeout> | null = null;
const timeoutMs = getExportFinalizationTimeoutMs({
effectiveDurationSec,
workload,
});
const idleTimeoutMs = progressAware
? getExportFinalizationIdleTimeoutMs({
effectiveDurationSec,
workload,
})
: null;
const watchdog: FinalizationProgressWatchdog | null =
progressAware && idleTimeoutMs !== null && idleTimeoutMs !== undefined
? {
refreshProgress: () => undefined,
}
: null;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
const rejectWithMessage = (message: string) => {
reject(new Error(message));
};
if (watchdog && idleTimeoutMs !== null) {
const refreshProgress = () => {
if (idleTimeoutId) {
clearTimeout(idleTimeoutId);
}
idleTimeoutId = setTimeout(() => {
rejectWithMessage(
`Export timed out during ${stage} after ${Math.ceil(idleTimeoutMs / 1000)} seconds without observable progress`,
);
}, idleTimeoutMs);
};
watchdog.refreshProgress = refreshProgress;
onWatchdogChanged?.(watchdog);
refreshProgress();
}
timeoutId = setTimeout(() => {
rejectWithMessage(
`Export timed out during ${stage} after ${Math.ceil(timeoutMs / 60_000)} minutes`,
);
}, timeoutMs);
}),
]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (idleTimeoutId) {
clearTimeout(idleTimeoutId);
}
onWatchdogChanged?.(null);
}
}
+10 -59
View File
@@ -23,9 +23,9 @@ import {
getWebCodecsKeyFrameInterval,
} from "./exportTuning";
import {
type FinalizationProgressWatchdog,
type FinalizationTimeoutWorkload,
getExportFinalizationIdleTimeoutMs,
getExportFinalizationTimeoutMs,
withFinalizationTimeout,
} from "./finalizationTimeout";
import { FrameRenderer as ModernFrameRenderer } from "./modernFrameRenderer";
import {
@@ -154,7 +154,7 @@ export class ModernVideoExporter {
private nativeWriteTimeMs = 0;
private finalizationTimeMs = 0;
private processedFrameCount = 0;
private activeFinalizationProgressWatchdog: { refreshProgress: () => void } | null = null;
private activeFinalizationProgressWatchdog: FinalizationProgressWatchdog | null = null;
private lastProgressSampleTimeMs = 0;
private lastProgressSampleFrame = 0;
@@ -650,65 +650,16 @@ export class ModernVideoExporter {
workload: FinalizationTimeoutWorkload = "default",
progressAware = false,
): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let idleTimeoutId: ReturnType<typeof setTimeout> | null = null;
const timeoutMs = getExportFinalizationTimeoutMs({
return withFinalizationTimeout({
promise,
stage,
effectiveDurationSec: this.effectiveDurationSec,
workload,
progressAware,
onWatchdogChanged: (watchdog) => {
this.activeFinalizationProgressWatchdog = watchdog;
},
});
const idleTimeoutMs = progressAware
? getExportFinalizationIdleTimeoutMs({
effectiveDurationSec: this.effectiveDurationSec,
workload,
})
: null;
const watchdog: { refreshProgress: () => void } | null =
progressAware && idleTimeoutMs
? {
refreshProgress: () => undefined,
}
: null;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
const rejectWithMessage = (message: string) => {
reject(new Error(message));
};
if (watchdog) {
this.activeFinalizationProgressWatchdog = watchdog;
const refreshProgress = () => {
if (idleTimeoutId) {
clearTimeout(idleTimeoutId);
}
idleTimeoutId = setTimeout(() => {
rejectWithMessage(
`Export timed out during ${stage} after ${Math.ceil(idleTimeoutMs! / 1000)} seconds without observable progress`,
);
}, idleTimeoutMs!);
};
watchdog.refreshProgress = refreshProgress;
refreshProgress();
}
timeoutId = setTimeout(() => {
rejectWithMessage(
`Export timed out during ${stage} after ${Math.ceil(timeoutMs / 60_000)} minutes`,
);
}, timeoutMs);
}),
]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (idleTimeoutId) {
clearTimeout(idleTimeoutId);
}
if (this.activeFinalizationProgressWatchdog === watchdog) {
this.activeFinalizationProgressWatchdog = null;
}
}
}
private getNativeVideoSourcePath(): string | null {
+10 -59
View File
@@ -14,9 +14,9 @@ import type {
} from "@/components/video-editor/types";
import { AudioProcessor, isAacAudioEncodingSupported } from "./audioEncoder";
import {
type FinalizationProgressWatchdog,
type FinalizationTimeoutWorkload,
getExportFinalizationIdleTimeoutMs,
getExportFinalizationTimeoutMs,
withFinalizationTimeout,
} from "./finalizationTimeout";
import { FrameRenderer } from "./frameRenderer";
import type { SupportedMp4EncoderPath } from "./mp4Support";
@@ -112,7 +112,7 @@ export class VideoExporter {
private nativeWriteError: Error | null = null;
private maxNativeWriteInFlight = 1;
private nativeEncoderError: Error | null = null;
private activeFinalizationProgressWatchdog: { refreshProgress: () => void } | null = null;
private activeFinalizationProgressWatchdog: FinalizationProgressWatchdog | null = null;
constructor(config: VideoExporterConfig) {
this.config = config;
@@ -382,65 +382,16 @@ export class VideoExporter {
workload: FinalizationTimeoutWorkload = "default",
progressAware = false,
): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let idleTimeoutId: ReturnType<typeof setTimeout> | null = null;
const timeoutMs = getExportFinalizationTimeoutMs({
return withFinalizationTimeout({
promise,
stage,
effectiveDurationSec: this.effectiveDurationSec,
workload,
progressAware,
onWatchdogChanged: (watchdog) => {
this.activeFinalizationProgressWatchdog = watchdog;
},
});
const idleTimeoutMs = progressAware
? getExportFinalizationIdleTimeoutMs({
effectiveDurationSec: this.effectiveDurationSec,
workload,
})
: null;
const watchdog: { refreshProgress: () => void } | null =
progressAware && idleTimeoutMs
? {
refreshProgress: () => undefined,
}
: null;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
const rejectWithMessage = (message: string) => {
reject(new Error(message));
};
if (watchdog) {
this.activeFinalizationProgressWatchdog = watchdog;
const refreshProgress = () => {
if (idleTimeoutId) {
clearTimeout(idleTimeoutId);
}
idleTimeoutId = setTimeout(() => {
rejectWithMessage(
`Export timed out during ${stage} after ${Math.ceil(idleTimeoutMs! / 1000)} seconds without observable progress`,
);
}, idleTimeoutMs!);
};
watchdog.refreshProgress = refreshProgress;
refreshProgress();
}
timeoutId = setTimeout(() => {
rejectWithMessage(
`Export timed out during ${stage} after ${Math.ceil(timeoutMs / 60_000)} minutes`,
);
}, timeoutMs);
}),
]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (idleTimeoutId) {
clearTimeout(idleTimeoutId);
}
if (this.activeFinalizationProgressWatchdog === watchdog) {
this.activeFinalizationProgressWatchdog = null;
}
}
}
private getNativeVideoSourcePath(): string | null {