mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 07:16:02 +00:00
test(export): share finalization watchdog logic
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user