Skip to content

Commit 2b3a5f0

Browse files
committed
Improve process timeouts and async cleanup handling
1 parent 138001b commit 2b3a5f0

File tree

2 files changed

+91
-10
lines changed

2 files changed

+91
-10
lines changed

apps/media-server/src/__tests__/lib/ffmpeg-video.integration.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
generateThumbnail,
77
normalizeVideoInputExtension,
88
processVideo,
9+
withTimeout,
910
} from "../../lib/ffmpeg-video";
1011
import { probeVideo } from "../../lib/ffprobe";
1112

@@ -87,6 +88,34 @@ describe("generateThumbnail integration tests", () => {
8788
});
8889

8990
describe("processVideo integration tests", () => {
91+
test("waits for async cleanup before rejecting timed out work", async () => {
92+
let resolveCleanup: (() => void) | undefined;
93+
let settled = false;
94+
const cleanupFinished = new Promise<void>((resolve) => {
95+
resolveCleanup = resolve;
96+
});
97+
98+
const timedOutWork = withTimeout(
99+
new Promise<never>(() => {}),
100+
1,
101+
async () => {
102+
await cleanupFinished;
103+
},
104+
);
105+
106+
void timedOutWork.catch(() => {
107+
settled = true;
108+
});
109+
110+
await Bun.sleep(25);
111+
expect(settled).toBe(false);
112+
113+
resolveCleanup?.();
114+
115+
await expect(timedOutWork).rejects.toThrow("Operation timed out after 1ms");
116+
expect(settled).toBe(true);
117+
});
118+
90119
test("normalizes input extensions", () => {
91120
expect(normalizeVideoInputExtension(undefined)).toBe(".mp4");
92121
expect(normalizeVideoInputExtension("webm")).toBe(".webm");
@@ -103,7 +132,7 @@ describe("processVideo integration tests", () => {
103132
TEST_VIDEO_WITH_AUDIO,
104133
metadata,
105134
{ maxWidth: 640, maxHeight: 360 },
106-
(progress, message) => {
135+
(progress, _message) => {
107136
expect(progress).toBeGreaterThanOrEqual(lastProgress);
108137
progressUpdates.push(progress);
109138
lastProgress = progress;

apps/media-server/src/lib/ffmpeg-video.ts

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { file, type Subprocess, spawn } from "bun";
22
import type { VideoMetadata } from "./job-manager";
33
import { createTempFile, type TempFileHandle } from "./temp-files";
44

5-
const PROCESS_TIMEOUT_MS = 30 * 60 * 1000;
5+
const PROCESS_TIMEOUT_MS = 45 * 60 * 1000;
6+
const PROCESS_TIMEOUT_PER_SECOND_MS = 20_000;
7+
const MAX_PROCESS_TIMEOUT_MS = 2 * 60 * 60 * 1000;
8+
const PROCESS_EXIT_WAIT_MS = 5_000;
69
const THUMBNAIL_TIMEOUT_MS = 60_000;
710
const DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000;
811
const MAX_STDERR_BYTES = 64 * 1024;
@@ -15,6 +18,7 @@ export interface VideoProcessingOptions {
1518
crf?: number;
1619
preset?: "ultrafast" | "fast" | "medium" | "slow";
1720
remuxOnly?: boolean;
21+
timeoutMs?: number;
1822
}
1923

2024
const DEFAULT_OPTIONS: Required<VideoProcessingOptions> = {
@@ -25,6 +29,7 @@ const DEFAULT_OPTIONS: Required<VideoProcessingOptions> = {
2529
crf: 23,
2630
preset: "medium",
2731
remuxOnly: false,
32+
timeoutMs: PROCESS_TIMEOUT_MS,
2833
};
2934

3035
export interface ThumbnailOptions {
@@ -64,15 +69,38 @@ function killProcess(proc: Subprocess): void {
6469
} catch {}
6570
}
6671

67-
async function withTimeout<T>(
72+
async function waitForProcessExit(
73+
proc: Pick<Subprocess, "exited">,
74+
timeoutMs = PROCESS_EXIT_WAIT_MS,
75+
): Promise<void> {
76+
await Promise.race([
77+
proc.exited.then(
78+
() => undefined,
79+
() => undefined,
80+
),
81+
new Promise<void>((resolve) => {
82+
setTimeout(resolve, timeoutMs);
83+
}),
84+
]);
85+
}
86+
87+
async function terminateProcess(proc: Subprocess): Promise<void> {
88+
killProcess(proc);
89+
await waitForProcessExit(proc);
90+
}
91+
92+
export async function withTimeout<T>(
6893
promise: Promise<T>,
6994
timeoutMs: number,
70-
cleanup?: () => void,
95+
cleanup?: () => void | Promise<void>,
7196
): Promise<T> {
7297
let timeoutId: ReturnType<typeof setTimeout> | undefined;
98+
let cleanupPromise: Promise<void> | undefined;
7399
const timeoutPromise = new Promise<never>((_, reject) => {
74100
timeoutId = setTimeout(() => {
75-
cleanup?.();
101+
cleanupPromise = (async () => {
102+
await cleanup?.();
103+
})().catch(() => undefined);
76104
reject(new Error(`Operation timed out after ${timeoutMs}ms`));
77105
}, timeoutMs);
78106
});
@@ -82,6 +110,9 @@ async function withTimeout<T>(
82110
if (timeoutId) clearTimeout(timeoutId);
83111
return result;
84112
} catch (err) {
113+
if (cleanupPromise) {
114+
await cleanupPromise;
115+
}
85116
if (timeoutId) clearTimeout(timeoutId);
86117
throw err;
87118
}
@@ -283,7 +314,7 @@ export async function repairContainer(
283314
);
284315
})(),
285316
REPAIR_TIMEOUT_MS,
286-
() => killProcess(proc),
317+
() => terminateProcess(proc),
287318
);
288319

289320
return repairedFile;
@@ -294,7 +325,7 @@ export async function repairContainer(
294325
if (abortCleanup) {
295326
abortSignal?.removeEventListener("abort", abortCleanup);
296327
}
297-
killProcess(proc);
328+
await terminateProcess(proc);
298329
}
299330
}
300331

@@ -329,6 +360,23 @@ function buildExtraOutputFlags(flags: ResilientInputFlags): string[] {
329360
return [];
330361
}
331362

363+
function getProcessTimeoutMs(
364+
durationSeconds: number,
365+
baseTimeoutMs: number,
366+
): number {
367+
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
368+
return baseTimeoutMs;
369+
}
370+
371+
return Math.min(
372+
MAX_PROCESS_TIMEOUT_MS,
373+
Math.max(
374+
baseTimeoutMs,
375+
Math.ceil(durationSeconds * PROCESS_TIMEOUT_PER_SECOND_MS),
376+
),
377+
);
378+
}
379+
332380
export async function processVideo(
333381
inputPath: string,
334382
metadata: VideoMetadata,
@@ -355,6 +403,10 @@ export async function processVideo(
355403
const extraOutputArgs = resilientFlags
356404
? buildExtraOutputFlags(resilientFlags)
357405
: [];
406+
const processTimeoutMs = getProcessTimeoutMs(
407+
metadata.duration,
408+
opts.timeoutMs,
409+
);
358410

359411
const ffmpegArgs: string[] = [
360412
"ffmpeg",
@@ -473,8 +525,8 @@ export async function processVideo(
473525
throw new Error("FFmpeg produced empty output file");
474526
}
475527
})(),
476-
PROCESS_TIMEOUT_MS,
477-
() => killProcess(proc),
528+
processTimeoutMs,
529+
() => terminateProcess(proc),
478530
);
479531

480532
return outputTempFile;
@@ -485,7 +537,7 @@ export async function processVideo(
485537
if (abortCleanup) {
486538
abortSignal?.removeEventListener("abort", abortCleanup);
487539
}
488-
killProcess(proc);
540+
await terminateProcess(proc);
489541
}
490542
}
491543

0 commit comments

Comments
 (0)