Skip to content

Commit c7296ec

Browse files
committed
Merge branch 'main' of https://github.com/appium/appium-ios-remotexpc into accessibility-audit-service
2 parents 2546971 + f1bc459 commit c7296ec

8 files changed

Lines changed: 399 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,21 @@
1+
## [5.14.3](https://github.com/appium/appium-ios-remotexpc/compare/v5.14.2...v5.14.3) (2026-08-11)
2+
3+
### Bug Fixes
4+
5+
* **display:** close the video writer when the audio writer's close throws ([#291](https://github.com/appium/appium-ios-remotexpc/issues/291)) ([ee8f381](https://github.com/appium/appium-ios-remotexpc/commit/ee8f381d867dfe703e407462ad7da0184b59a67d))
6+
7+
## [5.14.2](https://github.com/appium/appium-ios-remotexpc/compare/v5.14.1...v5.14.2) (2026-08-11)
8+
9+
### Bug Fixes
10+
11+
* **display:** stop captures when the recording's output file cannot be created ([#290](https://github.com/appium/appium-ios-remotexpc/issues/290)) ([18ad748](https://github.com/appium/appium-ios-remotexpc/commit/18ad7480bf5a82888a7ea9a574e937ce11b5eaba))
12+
13+
## [5.14.1](https://github.com/appium/appium-ios-remotexpc/compare/v5.14.0...v5.14.1) (2026-08-11)
14+
15+
### Bug Fixes
16+
17+
* **display:** surface screen-recording write errors instead of crashing ([#289](https://github.com/appium/appium-ios-remotexpc/issues/289)) ([2ef7852](https://github.com/appium/appium-ios-remotexpc/commit/2ef7852814d01bcc08202e82434ebea74424d8de))
18+
119
## [5.14.0](https://github.com/appium/appium-ios-remotexpc/compare/v5.13.6...v5.14.0) (2026-08-09)
220

321
### Features

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "appium-ios-remotexpc",
3-
"version": "5.14.0",
3+
"version": "5.14.3",
44
"description": "",
55
"keywords": [],
66
"bugs": {

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: 36 additions & 19 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,8 +124,30 @@ export async function recordScreenAndAudioToFiles(
127124
throw error;
128125
}
129126

130-
const videoOut = createWriteStream(videoPath);
131-
const audioOut = await M4aFileWriter.create(audioPath);
127+
const videoOut = new AnnexBFileWriter(videoPath);
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+
}
132151
let framesWritten = 0;
133152
let videoBytes = 0;
134153
let sawKeyFrame = false;
@@ -147,9 +166,7 @@ export async function recordScreenAndAudioToFiles(
147166
sawKeyFrame = true;
148167
}
149168
const chunk = toAnnexB(unit.nals);
150-
if (!videoOut.write(chunk)) {
151-
await once(videoOut, 'drain');
152-
}
169+
await videoOut.write(chunk);
153170
framesWritten += 1;
154171
videoBytes += chunk.length;
155172
}
@@ -179,16 +196,16 @@ export async function recordScreenAndAudioToFiles(
179196
log.debug(`Failed to stop cleanly: ${error instanceof Error ? error.message : String(error)}`);
180197
});
181198
await audioCapture.stop().catch((): void => undefined);
182-
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-
});
199+
// The video writer closes even when the audio writer throws. M4aFileWriter
200+
// finalizes by reopening the finished file to patch mdat's length, so it can
201+
// fail on a full disk long after every frame is safely on disk. Sequencing
202+
// the two closes would leak the video file descriptor for the life of the
203+
// process, and the error its stream is holding would never be read.
204+
try {
205+
audioWritten = await audioOut.close();
206+
} finally {
207+
await videoOut.close();
208+
}
192209
}
193210

194211
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 {
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+
});

0 commit comments

Comments
 (0)