Skip to content

Commit 331bd96

Browse files
committed
fix: terminate stalled audio timing inspection at its deadline
1 parent a5f6fae commit 331bd96

3 files changed

Lines changed: 253 additions & 99 deletions

File tree

apps/media-server/src/__tests__/lib/recording-verification.integration.test.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,55 @@ afterAll(async () => {
492492
});
493493

494494
describe("encoded recording preservation", () => {
495+
test.skipIf(process.platform === "win32")(
496+
"kills and joins a stalled audio inspector on cancellation and timeout",
497+
async () => {
498+
for (const mode of ["cancel", "timeout"]) {
499+
const path = join(directory, `stalled-audio-${mode}`);
500+
await run(["mkfifo", path]);
501+
const controller = new AbortController();
502+
const pending = readRecordingAudioTail(path, controller.signal);
503+
pending.catch(() => {});
504+
let pids: number[] = [];
505+
let timer: ReturnType<typeof setTimeout> | undefined;
506+
try {
507+
for (let attempt = 0; attempt < 100 && !pids.length; attempt++) {
508+
pids = await decoderPids(path);
509+
if (!pids.length) await Bun.sleep(10);
510+
}
511+
expect(pids).toHaveLength(1);
512+
await Bun.sleep(100);
513+
const started = performance.now();
514+
if (mode === "timeout")
515+
timer = setTimeout(
516+
() =>
517+
controller.abort(new DOMException("Timed out", "TimeoutError")),
518+
50,
519+
);
520+
else controller.abort();
521+
await expect(
522+
Promise.race([
523+
pending,
524+
Bun.sleep(2000).then(() => {
525+
throw new Error("Audio inspection ignored cancellation");
526+
}),
527+
]),
528+
).rejects.toMatchObject({ retryable: true });
529+
expect(performance.now() - started).toBeLessThan(2000);
530+
expect(await decoderPids(path)).toEqual([]);
531+
} finally {
532+
if (timer) clearTimeout(timer);
533+
controller.abort();
534+
for (const pid of pids) {
535+
try {
536+
process.kill(pid, "SIGKILL");
537+
} catch {}
538+
}
539+
await pending.catch(() => {});
540+
}
541+
}
542+
},
543+
);
495544
test("retains the processing deadline reason when muxing is already cancelled", async () => {
496545
const reason = new Error("Recording processing timed out");
497546
await expect(
@@ -1859,7 +1908,10 @@ async function decoderPids(input: string): Promise<number[]> {
18591908
processes.map(async (pid) => {
18601909
try {
18611910
const command = await readFile(`/proc/${pid}/cmdline`, "utf8");
1862-
return command.includes("ffmpeg") && command.includes(input)
1911+
return (command.includes("ffmpeg") ||
1912+
command.includes("ffprobe") ||
1913+
command.includes("recording-audio-timing.ts")) &&
1914+
command.includes(input)
18631915
? Number(pid)
18641916
: null;
18651917
} catch (error) {
@@ -1878,7 +1930,13 @@ async function decoderPids(input: string): Promise<number[]> {
18781930
const output = await run(["ps", "-axo", "pid=,command="]);
18791931
return output
18801932
.split("\n")
1881-
.filter((line) => line.includes("ffmpeg") && line.includes(input))
1933+
.filter(
1934+
(line) =>
1935+
(line.includes("ffmpeg") ||
1936+
line.includes("ffprobe") ||
1937+
line.includes("recording-audio-timing.ts")) &&
1938+
line.includes(input),
1939+
)
18821940
.map((line) => Number.parseInt(line.trim(), 10));
18831941
}
18841942

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { createHash } from "node:crypto";
2+
import { EncodedPacketSink, FilePathSource, Input, MP4 } from "mediabunny";
3+
import { RecordingTimingError } from "./recording-timing";
4+
5+
async function inspectAudioTail(path: string, countPackets = false) {
6+
const source = new FilePathSource(path).ref();
7+
const input = new Input({ formats: [MP4], source });
8+
let formatReady = false;
9+
try {
10+
await input.getFormat();
11+
formatReady = true;
12+
const tracks = await input.getAudioTracks();
13+
if (tracks.length !== 1)
14+
throw new Error("Recording audio tracks are ambiguous");
15+
const sink = new EncodedPacketSink(tracks[0]);
16+
const packet = await sink.getPacket(Number.POSITIVE_INFINITY, {
17+
skipLiveWait: true,
18+
});
19+
if (
20+
!packet ||
21+
(await sink.getNextPacket(packet, {
22+
metadataOnly: true,
23+
skipLiveWait: true,
24+
}))
25+
)
26+
throw new Error("Recording audio tail is ambiguous");
27+
const scale = await tracks[0].getTimeResolution();
28+
const scaled = packet.duration * scale;
29+
const ticks = Math.round(scaled);
30+
const tolerance = Math.max(
31+
0.0000001,
32+
Math.abs(scaled) * Number.EPSILON * 4,
33+
);
34+
if (
35+
!Number.isSafeInteger(scale) ||
36+
scale <= 0 ||
37+
!Number.isSafeInteger(ticks) ||
38+
ticks <= 0 ||
39+
tolerance >= 0.25 ||
40+
Math.abs(scaled - ticks) > tolerance ||
41+
!Number.isSafeInteger(packet.sequenceNumber) ||
42+
packet.sequenceNumber < 0
43+
)
44+
throw new Error("Recording audio duration is not exact");
45+
let packetCount: number | undefined;
46+
if (countPackets) {
47+
packetCount = 0;
48+
for await (const _packet of sink.packets(undefined, undefined, {
49+
metadataOnly: true,
50+
skipLiveWait: true,
51+
})) {
52+
packetCount++;
53+
}
54+
}
55+
if (
56+
packetCount !== undefined &&
57+
(!Number.isSafeInteger(packetCount) || packetCount <= 0)
58+
)
59+
throw new Error("Recording audio packet count is invalid");
60+
return {
61+
packetCount,
62+
durationTicks: ticks,
63+
timeScale: scale,
64+
size: packet.byteLength,
65+
hash: `SHA256:${createHash("sha256").update(packet.data).digest("hex")}`,
66+
};
67+
} catch (error) {
68+
const retryable =
69+
typeof error === "object" &&
70+
error !== null &&
71+
"code" in error &&
72+
typeof error.code === "string";
73+
const failure = new RecordingTimingError(
74+
retryable
75+
? "Recording audio timing inspection was interrupted"
76+
: "Recording audio timing is invalid",
77+
retryable,
78+
);
79+
failure.cause = error;
80+
throw failure;
81+
} finally {
82+
// Mediabunny 1.45 disposal leaks rejections after failed format detection and can strand pending reads.
83+
if (formatReady) input.dispose();
84+
else source.free();
85+
}
86+
}
87+
88+
if (import.meta.main) {
89+
try {
90+
const path = process.argv[2];
91+
if (!path) throw new Error("Missing audio timing path");
92+
console.log(
93+
JSON.stringify(await inspectAudioTail(path, process.argv[3] === "count")),
94+
);
95+
} catch (error) {
96+
console.log(
97+
JSON.stringify({
98+
error:
99+
error instanceof RecordingTimingError && !error.retryable
100+
? "invalid"
101+
: "unavailable",
102+
}),
103+
);
104+
process.exitCode = 1;
105+
}
106+
}

0 commit comments

Comments
 (0)