Skip to content

Commit 2ef7852

Browse files
authored
fix(display): surface screen-recording write errors instead of crashing (#289)
recordScreenToFile and recordScreenAndAudioToFiles created their Annex-B output with createWriteStream and never attached an 'error' listener, so a stream error reached the process as an uncaught exception instead of a rejected promise. Callers cannot defend against that. The event bypasses the promise chain, so wrapping the call in try/catch does not help and the process still dies: try { await recordScreenToFile(service, path); } catch { /* never runs */ } The likely trigger is ordinary rather than exotic: an output path whose parent directory does not exist. A full disk or an unplugged volume mid-recording does the same. Passing end(callback) is not a substitute -- the callback does receive the error, but 'error' is still emitted separately and still terminates the process. The audio half of the same feature already guards against this in M4aFileWriter, which holds the first error and re-throws it from the next call. The video half did not. AnnexBFileWriter gives the video track that same guarantee, so a failed recording surfaces as a rejection the caller can handle. Two details differ from M4aFileWriter deliberately: - It does not await 'open'. Doing so would reject while the capture is already streaming, stranding it with no handle to stop it. An open failure surfaces from the first write instead. - close() reports the held error in preference to ending an already-failed stream, which answers with a generic ERR_STREAM_DESTROYED and buries the cause. That is the common path, since the recorders write nothing until the first keyframe arrives. Behaviour is otherwise unchanged: the same backpressure, the same output bytes, no public API added.
1 parent ca1e8c1 commit 2ef7852

3 files changed

Lines changed: 156 additions & 32 deletions

File tree

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

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
1-
import {once} from 'node:events';
2-
import {createWriteStream} from 'node:fs';
3-
41
import {getLogger} from '../../../../lib/logger.js';
52
import {XPCUUID} from '../../../../lib/remote-xpc/xpc-uuid.js';
63
import {type AacEldFormat, aacEldDurationMs} from '../audio/aac-eld.js';
74
import {AudioStreamCapture, type AudioStreamStats} from '../audio/audio-stream-capture.js';
85
import {M4aFileWriter} from '../audio/m4a-writer.js';
96
import {type DisplayService, type StartVideoStreamOptions} from '../index.js';
107
import {toAnnexB} from '../video/hevc.js';
11-
import {ScreenStreamCapture, type ScreenStreamStats} from '../video/screen-stream-capture.js';
8+
import {AnnexBFileWriter, ScreenStreamCapture, type ScreenStreamStats} from '../video/screen-stream-capture.js';
129
import {type MuxCommand, ffmpegMuxCommandBuilder} from './mux-command.js';
1310

1411
const log = getLogger('AvCapture');
@@ -127,7 +124,7 @@ export async function recordScreenAndAudioToFiles(
127124
throw error;
128125
}
129126

130-
const videoOut = createWriteStream(videoPath);
127+
const videoOut = new AnnexBFileWriter(videoPath);
131128
const audioOut = await M4aFileWriter.create(audioPath);
132129
let framesWritten = 0;
133130
let videoBytes = 0;
@@ -147,9 +144,7 @@ export async function recordScreenAndAudioToFiles(
147144
sawKeyFrame = true;
148145
}
149146
const chunk = toAnnexB(unit.nals);
150-
if (!videoOut.write(chunk)) {
151-
await once(videoOut, 'drain');
152-
}
147+
await videoOut.write(chunk);
153148
framesWritten += 1;
154149
videoBytes += chunk.length;
155150
}
@@ -180,15 +175,7 @@ export async function recordScreenAndAudioToFiles(
180175
});
181176
await audioCapture.stop().catch((): void => undefined);
182177
audioWritten = await audioOut.close();
183-
await new Promise<void>((resolve, reject) => {
184-
videoOut.end((error?: Error | null): void => {
185-
if (error) {
186-
reject(error);
187-
return;
188-
}
189-
resolve();
190-
});
191-
});
178+
await videoOut.close();
192179
}
193180

194181
const audioDurationMs = aacEldDurationMs(audioWritten.sampleCount);

src/services/ios/display/video/screen-stream-capture.ts

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,69 @@ export class ScreenStreamCapture {
191191
}
192192
}
193193

194+
/**
195+
* The Annex-B output file, with the write stream's `'error'` event held instead
196+
* of left to reach the process.
197+
*
198+
* A recording runs unattended for minutes and the stream never escapes the
199+
* function that owns it, so a caller cannot attach a listener of its own.
200+
* Without one here, a mistyped path or a write that fails after the fact (a full
201+
* disk, an unplugged volume) arrives as an uncaught `'error'` event and takes
202+
* the process down — `end(callback)` does not help, since the callback receives
203+
* the error *and* the event is still emitted. Holding the first error and
204+
* re-throwing it from the next `write` or `close` turns that into a rejection
205+
* the caller can see, the guarantee {@link M4aFileWriter} already gives the
206+
* audio track.
207+
*
208+
* Opening is deliberately not awaited, unlike `M4aFileWriter.create`: the
209+
* capture is already streaming by the time the file is created, so a rejection
210+
* before the caller holds the writer would strand it. An open failure surfaces
211+
* from the first {@link write} instead.
212+
*/
213+
export class AnnexBFileWriter {
214+
private readonly stream: ReturnType<typeof createWriteStream>;
215+
/** First stream error seen, re-thrown from the next `write` or `close`. */
216+
private streamError: Error | undefined;
217+
218+
/** @param path Destination file path; truncated if it exists. */
219+
constructor(path: string) {
220+
this.stream = createWriteStream(path);
221+
this.stream.on('error', (error: Error) => {
222+
this.streamError ??= error;
223+
});
224+
}
225+
226+
/**
227+
* Appends one chunk, resolving once the stream has room for more so an
228+
* `await` per chunk applies backpressure.
229+
*/
230+
async write(chunk: Buffer): Promise<void> {
231+
if (this.streamError) {
232+
throw this.streamError;
233+
}
234+
if (!this.stream.write(chunk)) {
235+
await once(this.stream, 'drain');
236+
}
237+
if (this.streamError) {
238+
throw this.streamError;
239+
}
240+
}
241+
242+
/** Flushes and closes the file, re-throwing any error the stream saw. */
243+
async close(): Promise<void> {
244+
// The held error is reported in preference to ending an already-failed
245+
// stream, which answers with a generic `ERR_STREAM_DESTROYED` and buries the
246+
// cause. That is the common path for a bad output path: the open fails
247+
// before the first keyframe arrives, so no `write` ever ran to surface it.
248+
if (this.streamError) {
249+
throw this.streamError;
250+
}
251+
await new Promise<void>((resolve, reject) => {
252+
this.stream.end((error?: Error | null): void => (error ? reject(error) : resolve()));
253+
});
254+
}
255+
}
256+
194257
/** Options for {@link recordScreenToFile}. */
195258
export interface RecordScreenOptions extends ScreenStreamCaptureOptions {
196259
/** How long to record, in milliseconds. Defaults to 5000. */
@@ -239,7 +302,7 @@ export async function recordScreenToFile(
239302
const {durationMs = 5000, maxFrames = Number.POSITIVE_INFINITY, ...captureOptions} = options;
240303

241304
const capture = await ScreenStreamCapture.start(service, captureOptions);
242-
const output = createWriteStream(outputPath);
305+
const output = new AnnexBFileWriter(outputPath);
243306
let framesWritten = 0;
244307
let bytesWritten = 0;
245308
let sawKeyFrame = false;
@@ -262,9 +325,7 @@ export async function recordScreenToFile(
262325
}
263326

264327
const chunk = toAnnexB(unit.nals);
265-
if (!output.write(chunk)) {
266-
await once(output, 'drain');
267-
}
328+
await output.write(chunk);
268329
framesWritten += 1;
269330
bytesWritten += chunk.length;
270331

@@ -277,15 +338,7 @@ export async function recordScreenToFile(
277338
await capture.stop().catch((error: unknown) => {
278339
log.debug(`Failed to stop the media stream cleanly: ${error instanceof Error ? error.message : String(error)}`);
279340
});
280-
await new Promise<void>((resolve, reject) => {
281-
output.end((error?: Error | null): void => {
282-
if (error) {
283-
reject(error);
284-
return;
285-
}
286-
resolve();
287-
});
288-
});
341+
await output.close();
289342
}
290343

291344
return {

test/unit/display/video/screen-stream-capture.spec.ts

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import assert from 'node:assert/strict';
2-
import {describe, it} from 'node:test';
2+
import {mkdtemp, readFile, 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';
36

47
import type {DisplayService, MediaStreamAnswer} from '../../../../src/services/ios/display/index.js';
5-
import {ScreenStreamCapture} from '../../../../src/services/ios/display/video/screen-stream-capture.js';
8+
import {
9+
AnnexBFileWriter,
10+
ScreenStreamCapture,
11+
} from '../../../../src/services/ios/display/video/screen-stream-capture.js';
612

713
/**
814
* Builds a capture wired to a stub service and a stub receiver, bypassing
@@ -107,3 +113,81 @@ describe('ScreenStreamCapture', function () {
107113
});
108114
});
109115
});
116+
117+
describe('AnnexBFileWriter', function () {
118+
let directory: string;
119+
let counter = 0;
120+
121+
before(async function () {
122+
directory = await mkdtemp(join(tmpdir(), 'annexb-writer-'));
123+
});
124+
125+
after(async function () {
126+
await rm(directory, {force: true, recursive: true});
127+
});
128+
129+
const chunk = (byte: number, length: number): Buffer => Buffer.alloc(length, byte);
130+
const missingPath = (): string => join(directory, 'no', 'such', 'dir', 'screen.h265');
131+
132+
it('writes every chunk through to the file', async function () {
133+
const path = join(directory, `stream-${counter++}.h265`);
134+
const chunks = [chunk(0x11, 40), chunk(0x22, 55), chunk(0x33, 48)];
135+
136+
const writer = new AnnexBFileWriter(path);
137+
for (const part of chunks) {
138+
await writer.write(part);
139+
}
140+
await writer.close();
141+
142+
assert.deepStrictEqual(await readFile(path), Buffer.concat(chunks));
143+
});
144+
145+
it('writes a file larger than the stream buffer, so backpressure is exercised', async function () {
146+
const path = join(directory, `stream-${counter++}.h265`);
147+
const frame = chunk(0xab, 256 * 1024);
148+
const frames = 40;
149+
150+
const writer = new AnnexBFileWriter(path);
151+
for (let i = 0; i < frames; i++) {
152+
await writer.write(frame);
153+
}
154+
await writer.close();
155+
156+
const file = await readFile(path);
157+
assert.strictEqual(file.length, frame.length * frames);
158+
assert.ok(file.every((byte) => byte === 0xab));
159+
});
160+
161+
it('reports a bad output path as a rejection rather than an uncaught error', async function () {
162+
// A stream 'error' with no listener is an uncaught exception, which during
163+
// an unattended recording would take the whole process down. The open
164+
// failure lands asynchronously, so it surfaces from whichever call runs
165+
// once it has — a later write, or the close in the recorder's finally.
166+
const writer = new AnnexBFileWriter(missingPath());
167+
168+
await assert.rejects(async () => {
169+
await writer.write(chunk(0x11, 40));
170+
await writer.close();
171+
}, /ENOENT/);
172+
});
173+
174+
it('reports a bad output path from close() when no write ever ran', async function () {
175+
// The recorder writes nothing until the first keyframe arrives, so a
176+
// capture that is torn down early reaches close() having never written.
177+
// close() must still name the real cause, not a generic stream error.
178+
const writer = new AnnexBFileWriter(missingPath());
179+
180+
await assert.rejects(() => writer.close(), /ENOENT/);
181+
});
182+
183+
it('surfaces a write failure through the next call', async function () {
184+
const writer = new AnnexBFileWriter(join(directory, `stream-${counter++}.h265`));
185+
await writer.write(chunk(0x11, 40));
186+
187+
// Stand in for a disk filling up mid-recording.
188+
(writer as unknown as {streamError: Error}).streamError = new Error('ENOSPC');
189+
190+
await assert.rejects(() => writer.write(chunk(0x22, 40)), /ENOSPC/);
191+
await assert.rejects(() => writer.close(), /ENOSPC/);
192+
});
193+
});

0 commit comments

Comments
 (0)