Skip to content

Commit 18ad748

Browse files
authored
fix(display): stop captures when the recording's output file cannot be created (#290)
recordAudioToFile and recordScreenAndAudioToFiles start their captures before opening the output file. M4aFileWriter.create awaits the write stream's 'open', so an audio path whose parent directory does not exist rejects once the device is already streaming, and nothing unwinds it: the try/finally that stops the captures does not begin until several statements later. A file descriptor is the least of what leaks: - the device keeps encoding and sending RTP, because stopAllMediaStreams is never called - the UDP receivers stay bound and their RTCP keepalives keep firing, which is exactly what tells the device to keep the session alive past its 20s timeout - UdpMediaReceiver queues datagrams into an unbounded array whenever no consumer is waiting, and after this failure there is no consumer, so inbound RTP accumulates in memory for the lifetime of the process Neither capture escapes its function, so a caller cannot release them either. The leaked receiver also keeps the event loop alive, so a process that hits this never exits on its own. Both functions now release what they have already acquired before rethrowing. A failed stop is logged at debug rather than swallowed silently, since that case means the device is still streaming, which is the leak itself; the redundant second stop and the file close stay quiet. The original error is what propagates in every case. Covered by unit tests that drive the real capture path against a stub service and assert the teardown ran. Reverting either guard fails them with 0 stops instead of 1 and 2, and also hangs the runner on the leaked receiver.
1 parent f5c35bb commit 18ad748

4 files changed

Lines changed: 156 additions & 2 deletions

File tree

src/services/ios/display/audio/audio-stream-capture.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,25 @@ export async function recordAudioToFile(
242242
const timer = setTimeout(() => controller.abort(), durationMs);
243243
timer.unref?.();
244244

245-
const writer = await M4aFileWriter.create(outputPath);
245+
let writer: M4aFileWriter;
246+
try {
247+
writer = await M4aFileWriter.create(outputPath);
248+
} catch (error) {
249+
// The capture is already streaming and never escapes this function, so a
250+
// caller cannot stop it. Left running, the device keeps sending into a
251+
// receiver whose queue nobody drains.
252+
clearTimeout(timer);
253+
// Swallowed so it cannot mask the error being thrown, but logged: a failed
254+
// stop means the device is still streaming, which is the leak itself.
255+
await capture.stop().catch((stopError: unknown) => {
256+
log.debug(
257+
`Failed to stop the audio stream after the output file could not be created: ${
258+
stopError instanceof Error ? stopError.message : String(stopError)
259+
}`,
260+
);
261+
});
262+
throw error;
263+
}
246264
let written;
247265
try {
248266
for await (const unit of capture.accessUnits(controller.signal)) {

src/services/ios/display/recording/av-capture.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,29 @@ export async function recordScreenAndAudioToFiles(
125125
}
126126

127127
const videoOut = new AnnexBFileWriter(videoPath);
128-
const audioOut = await M4aFileWriter.create(audioPath);
128+
let audioOut: M4aFileWriter;
129+
try {
130+
audioOut = await M4aFileWriter.create(audioPath);
131+
} catch (error) {
132+
// Both captures are already streaming by now, and neither they nor the
133+
// video writer escape this function, so a caller cannot release them. Left
134+
// running, the device keeps encoding into a receiver whose queue nobody
135+
// drains, and its RTCP keepalive keeps the session alive indefinitely.
136+
// Each failure is swallowed so it cannot mask the error being thrown. The
137+
// stop is still logged: if it fails the device is left streaming, which is
138+
// the leak itself. One stop tears down both streams, so the second call and
139+
// the file close are quiet.
140+
await videoCapture.stop().catch((stopError: unknown) => {
141+
log.debug(
142+
`Failed to stop cleanly after the audio file could not be created: ${
143+
stopError instanceof Error ? stopError.message : String(stopError)
144+
}`,
145+
);
146+
});
147+
await audioCapture.stop().catch((): void => undefined);
148+
await videoOut.close().catch((): void => undefined);
149+
throw error;
150+
}
129151
let framesWritten = 0;
130152
let videoBytes = 0;
131153
let sawKeyFrame = false;
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import assert from 'node:assert/strict';
2+
import {mkdtemp, rm} from 'node:fs/promises';
3+
import {tmpdir} from 'node:os';
4+
import {join} from 'node:path';
5+
import {after, before, describe, it} from 'node:test';
6+
7+
import {recordAudioToFile} from '../../../../src/services/ios/display/audio/audio-stream-capture.js';
8+
import type {DisplayService, MediaStreamAnswer} from '../../../../src/services/ios/display/index.js';
9+
10+
/** A stub service that negotiates without a device and counts teardowns. */
11+
function makeStubService(): {service: DisplayService; stopCalls: () => number} {
12+
let stopCalls = 0;
13+
const service = {
14+
getTunnelLocalAddress: async (): Promise<string> => '::1',
15+
getDeviceAddress: async (): Promise<string> => '::1',
16+
startAudioStream: async (): Promise<MediaStreamAnswer> =>
17+
// An empty streamConfig means no RTCP identity, so no keepalive timer is
18+
// started — the negotiation is all this test needs.
19+
({clientSessionId: undefined, streamConfig: {}, connection: {}, raw: {}}) as unknown as MediaStreamAnswer,
20+
stopAllMediaStreams: async (): Promise<number[]> => {
21+
stopCalls += 1;
22+
return [];
23+
},
24+
} as unknown as DisplayService;
25+
return {service, stopCalls: () => stopCalls};
26+
}
27+
28+
describe('recordAudioToFile', function () {
29+
let directory: string;
30+
31+
before(async function () {
32+
directory = await mkdtemp(join(tmpdir(), 'record-audio-'));
33+
});
34+
35+
after(async function () {
36+
await rm(directory, {force: true, recursive: true});
37+
});
38+
39+
it('stops the capture when the output file cannot be created', async function () {
40+
// The capture is started before the writer, so a writer that fails to open
41+
// used to strand a live device stream: nothing stopped it, its UDP receiver
42+
// stayed bound, and its queue grew with every packet nobody read. The
43+
// capture never escapes the function, so no caller could clean it up.
44+
const {service, stopCalls} = makeStubService();
45+
const unwritablePath = join(directory, 'no', 'such', 'dir', 'audio.m4a');
46+
47+
await assert.rejects(() => recordAudioToFile(service, unwritablePath), /ENOENT/);
48+
49+
assert.strictEqual(stopCalls(), 1, 'the capture must be stopped before the error propagates');
50+
});
51+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import assert from 'node:assert/strict';
2+
import {mkdtemp, rm} from 'node:fs/promises';
3+
import {tmpdir} from 'node:os';
4+
import {join} from 'node:path';
5+
import {after, before, describe, it} from 'node:test';
6+
7+
import type {DisplayService, MediaStreamAnswer} from '../../../../src/services/ios/display/index.js';
8+
import {recordScreenAndAudioToFiles} from '../../../../src/services/ios/display/recording/av-capture.js';
9+
10+
/** A stub service that negotiates without a device and counts teardowns. */
11+
function makeStubService(): {service: DisplayService; stopCalls: () => number} {
12+
let stopCalls = 0;
13+
// An empty streamConfig means no RTCP identity, so no keepalive timer is
14+
// started — the negotiation is all this test needs.
15+
const answer = {
16+
clientSessionId: undefined,
17+
streamConfig: {},
18+
connection: {},
19+
raw: {},
20+
} as unknown as MediaStreamAnswer;
21+
const service = {
22+
getTunnelLocalAddress: async (): Promise<string> => '::1',
23+
getDeviceAddress: async (): Promise<string> => '::1',
24+
startVideoStream: async (): Promise<MediaStreamAnswer> => answer,
25+
startAudioStream: async (): Promise<MediaStreamAnswer> => answer,
26+
stopAllMediaStreams: async (): Promise<number[]> => {
27+
stopCalls += 1;
28+
return [];
29+
},
30+
} as unknown as DisplayService;
31+
return {service, stopCalls: () => stopCalls};
32+
}
33+
34+
describe('recordScreenAndAudioToFiles', function () {
35+
let directory: string;
36+
37+
before(async function () {
38+
directory = await mkdtemp(join(tmpdir(), 'record-av-'));
39+
});
40+
41+
after(async function () {
42+
await rm(directory, {force: true, recursive: true});
43+
});
44+
45+
it('stops both captures when the audio file cannot be created', async function () {
46+
// Both captures are streaming before either writer is opened, so a failing
47+
// audio writer used to strand two live device streams plus the video file
48+
// descriptor. None of them escape the function, so no caller could release
49+
// them.
50+
const {service, stopCalls} = makeStubService();
51+
52+
await assert.rejects(
53+
() =>
54+
recordScreenAndAudioToFiles(service, {
55+
videoPath: join(directory, 'screen.h265'),
56+
audioPath: join(directory, 'no', 'such', 'dir', 'audio.m4a'),
57+
}),
58+
/ENOENT/,
59+
);
60+
61+
assert.strictEqual(stopCalls(), 2, 'both captures must be stopped before the error propagates');
62+
});
63+
});

0 commit comments

Comments
 (0)