Skip to content

Commit a5f6fae

Browse files
committed
fix: preserve audio timing failure and cancellation semantics
1 parent 5d7d887 commit a5f6fae

5 files changed

Lines changed: 88 additions & 22 deletions

File tree

apps/media-server/src/__tests__/lib/media-routes-real-world.integration.test.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,12 @@ const sourceReads: {
4949
const uploadConditions: (string | null)[] = [];
5050
const multipartCallbacks: { action: string; payload: unknown }[] = [];
5151
let rejectMultipartSigning = false;
52-
let sourceFault: "changed" | "missing" | "corrupt" | undefined;
52+
let sourceFault:
53+
| "changed"
54+
| "missing"
55+
| "corrupt"
56+
| "corrupt-audio"
57+
| undefined;
5358
let corruptRecordingReadback = false;
5459
let transientFixtureFailures = 0;
5560
let permanentFixtureFailures = 0;
@@ -295,7 +300,11 @@ beforeAll(async () => {
295300
ifMatch: request.headers.get("if-match"),
296301
verification: request.headers.get("x-cap-recording-verification"),
297302
});
298-
const affected = url.pathname.endsWith("video-segment.m4s");
303+
const affected = url.pathname.endsWith(
304+
sourceFault === "corrupt-audio"
305+
? "audio-segment.m4s"
306+
: "video-segment.m4s",
307+
);
299308
if (affected && sourceFault === "missing")
300309
return new Response(null, { status: 404 });
301310
if (
@@ -306,7 +315,8 @@ beforeAll(async () => {
306315
return new Response(null, { status: 412 });
307316
return new Response(
308317
Uint8Array.from(
309-
affected && sourceFault === "corrupt"
318+
affected &&
319+
(sourceFault === "corrupt" || sourceFault === "corrupt-audio")
310320
? new Uint8Array(source.byteLength)
311321
: source,
312322
).buffer,
@@ -707,7 +717,7 @@ describe("media routes real-world integration tests", () => {
707717
30_000,
708718
);
709719

710-
test.each(["changed", "missing", "corrupt"] as const)(
720+
test.each(["changed", "missing", "corrupt", "corrupt-audio"] as const)(
711721
"withholds upload and proof after a pinned source is %s",
712722
async (fault) => {
713723
sourceFault = fault;
@@ -721,7 +731,7 @@ describe("media routes real-world integration tests", () => {
721731
const job = await waitForTerminalJob(jobId);
722732
expect(job.phase).toBe("error");
723733
expect(job.errorCode).toBe(
724-
fault === "corrupt" ? "source-invalid" : `source-${fault}`,
734+
fault.startsWith("corrupt") ? "source-invalid" : `source-${fault}`,
725735
);
726736
expect(job.recordingVerification).toBeUndefined();
727737
expect(uploadConditions).toHaveLength(0);

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

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import { join } from "node:path";
1414
import { Readable } from "node:stream";
1515
import { EncodedPacketSink, FilePathSource, Input, MP4 } from "mediabunny";
1616
import { muxMediaTracksToMp4 } from "../../lib/media-video";
17-
import { proveRecordingPackets } from "../../lib/recording-packet-proof";
17+
import {
18+
proveRecordingPackets,
19+
readRecordingAudioTail,
20+
} from "../../lib/recording-packet-proof";
1821
import {
1922
RecordingTimingError,
2023
readRecordingVideoTiming,
@@ -489,6 +492,33 @@ afterAll(async () => {
489492
});
490493

491494
describe("encoded recording preservation", () => {
495+
test("retains the processing deadline reason when muxing is already cancelled", async () => {
496+
const reason = new Error("Recording processing timed out");
497+
await expect(
498+
muxMediaTracksToMp4(
499+
silent,
500+
silent,
501+
join(directory, "cancelled-mux.mp4"),
502+
AbortSignal.abort(reason),
503+
),
504+
).rejects.toBe(reason);
505+
});
506+
test("distinguishes malformed audio from unavailable local timing reads", async () => {
507+
await expect(
508+
readRecordingAudioTail(
509+
join(directory, "absent-audio.mp4"),
510+
AbortSignal.timeout(5000),
511+
),
512+
).rejects.toMatchObject({ retryable: true });
513+
await expect(
514+
readRecordingAudioTail(silent, AbortSignal.abort()),
515+
).rejects.toMatchObject({ retryable: true });
516+
const malformed = join(directory, "malformed-audio.mp4");
517+
await writeFile(malformed, Buffer.alloc(32));
518+
await expect(
519+
readRecordingAudioTail(malformed, AbortSignal.timeout(5000)),
520+
).rejects.toMatchObject({ retryable: false });
521+
});
492522
test("uses decoded source evidence for tied terminal video samples", async () => {
493523
const input = await tiedTimestampSource("packet-tied-terminal.mp4", 2);
494524
const output = join(directory, "packet-tied-terminal-output.mp4");

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2474,7 +2474,7 @@ export async function muxMediaTracksToMp4(
24742474
outputPath: string,
24752475
abortSignal?: AbortSignal,
24762476
): Promise<void> {
2477-
if (abortSignal?.aborted) throw new Error("Recording mux was cancelled");
2477+
abortSignal?.throwIfAborted();
24782478
abortSignal = AbortSignal.any([
24792479
...(abortSignal ? [abortSignal] : []),
24802480
AbortSignal.timeout(PROCESS_TIMEOUT_MS),
@@ -2484,7 +2484,7 @@ export async function muxMediaTracksToMp4(
24842484
abortSignal,
24852485
timeoutMs: PROCESS_TIMEOUT_MS,
24862486
});
2487-
if (abortSignal?.aborted) throw new Error("Recording mux was cancelled");
2487+
abortSignal?.throwIfAborted();
24882488
const lastTimestamp = timing.lastTimestampTicks - timing.firstTimestampTicks;
24892489
// FFmpeg 7 can discard a fragmented MP4's stored final sample duration.
24902490
const videoTimingFilter = `setts=pts=PTS:dts=DTS:duration=if(eq(PTS-STARTPTS\\,${lastTimestamp})\\,${timing.lastDurationTicks}\\,DURATION)`;

apps/media-server/src/lib/recording-packet-proof.ts

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
22
import { createHash } from "node:crypto";
33
import { lstat } from "node:fs/promises";
44
import { isAbsolute } from "node:path";
5+
import { setTimeout as yieldToEvents } from "node:timers/promises";
56
import { EncodedPacketSink, FilePathSource, Input, MP4 } from "mediabunny";
67
import {
78
RecordingTimingError,
@@ -121,12 +122,16 @@ export async function readRecordingAudioTail(
121122
signal: AbortSignal,
122123
countPackets = false,
123124
) {
124-
const input = new Input({ formats: [MP4], source: new FilePathSource(path) });
125-
const dispose = () => input.dispose();
126-
signal.addEventListener("abort", dispose, { once: true });
125+
const source = new FilePathSource(path).ref();
126+
const input = new Input({ formats: [MP4], source });
127+
let formatReady = false;
127128
try {
129+
signal.throwIfAborted();
130+
await input.getFormat();
131+
formatReady = true;
128132
signal.throwIfAborted();
129133
const tracks = await input.getAudioTracks();
134+
signal.throwIfAborted();
130135
if (tracks.length !== 1)
131136
throw new Error("Recording audio tracks are ambiguous");
132137
const sink = new EncodedPacketSink(tracks[0]);
@@ -159,14 +164,19 @@ export async function readRecordingAudioTail(
159164
packet.sequenceNumber < 0
160165
)
161166
throw new Error("Recording audio duration is not exact");
162-
const packetCount = countPackets
163-
? (
164-
await tracks[0].computePacketStats(Number.POSITIVE_INFINITY, {
165-
metadataOnly: true,
166-
skipLiveWait: true,
167-
})
168-
).packetCount
169-
: undefined;
167+
let packetCount: number | undefined;
168+
if (countPackets) {
169+
packetCount = 0;
170+
for await (const _packet of sink.packets(undefined, undefined, {
171+
metadataOnly: true,
172+
skipLiveWait: true,
173+
})) {
174+
signal.throwIfAborted();
175+
packetCount++;
176+
if (packetCount % 1024 === 0)
177+
await yieldToEvents(0, undefined, { signal });
178+
}
179+
}
170180
if (
171181
packetCount !== undefined &&
172182
(!Number.isSafeInteger(packetCount) || packetCount <= 0)
@@ -181,9 +191,25 @@ export async function readRecordingAudioTail(
181191
duration: time(String(ticks), `1/${scale}`),
182192
hash: `SHA256:${createHash("sha256").update(packet.data).digest("hex")}`,
183193
};
194+
} catch (error) {
195+
const retryable =
196+
signal.aborted ||
197+
(typeof error === "object" &&
198+
error !== null &&
199+
"code" in error &&
200+
typeof error.code === "string");
201+
const failure = new RecordingTimingError(
202+
retryable
203+
? "Recording audio timing inspection was interrupted"
204+
: "Recording audio timing is invalid",
205+
retryable,
206+
);
207+
failure.cause = error;
208+
throw failure;
184209
} finally {
185-
signal.removeEventListener("abort", dispose);
186-
dispose();
210+
// Mediabunny 1.45 disposal leaks rejections after failed format detection and can strand pending reads.
211+
if (formatReady) input.dispose();
212+
else source.free();
187213
}
188214
}
189215

apps/media-server/src/routes/video.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1722,7 +1722,7 @@ function classifySourceError(error: unknown): RecordingErrorCode {
17221722
if (/HTTP error 404|Server returned 404/.test(error.message))
17231723
return "source-missing";
17241724
if (
1725-
/Decoded recording|Invalid decoded recording|Recording has no decoded|Recording video packets are missing|Recording timing has no video track|does not match the completed local file|Verified recording size/.test(
1725+
/Decoded recording|Invalid decoded recording|Recording has no decoded|Recording video packets are missing|Recording audio timing is invalid|Recording timing has no video track|does not match the completed local file|Verified recording size/.test(
17261726
error.message,
17271727
)
17281728
)

0 commit comments

Comments
 (0)