Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import spawnAsync from '@expo/spawn-async';
`spawnAsync` takes the same arguments as [`child_process.spawn`](https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options). Its options are the same as those of `child_process.spawn` plus:

- `ignoreStdio`: whether to ignore waiting for the child process's stdio streams to close before resolving the result promise. When ignoring stdio, the returned values for `stdout` and `stderr` will be empty strings. The default value of this option is `false`.
- `maxBuffer`: the maximum bytes retained from `stdout` and `stderr` (independently). Output is collected with a sliding window. When set explicitly, exceeding it rejects the promise with an error whose `code` is `ERR_CHILD_PROCESS_STDIO_MAXBUFFER` and whose `stdout`/`stderr` carry the truncated tail. When omitted, the default is `buffer.constants.MAX_STRING_LENGTH` (~512 MiB).

It returns a promise whose result is an object with these properties:

Expand Down Expand Up @@ -64,3 +65,13 @@ Here is an example:
})();

```

## Notes

### `maxBuffer`

`maxBuffer` is a later addition to the API. Set it when child output could exhaust memory and crash the parent process, or when the command or arguments are influenced by untrusted input — an attacker can otherwise force unbounded output to crash the parent.

The default of `buffer.constants.MAX_STRING_LENGTH` (~512 MiB) is a crash-safe floor, not a memory bound: at that size the materialized string itself can still exhaust process memory.

When `maxBuffer` is set explicitly, exceeding it rejects the promise immediately with `ERR_CHILD_PROCESS_STDIO_MAXBUFFER`. When left at the default, exceeding it doesn't reject; the sliding-window tail is still readable, but reading `stdout`/`stderr` throws `ERR_CHILD_PROCESS_STDIO_MAXBUFFER` with the truncated tail attached.
147 changes: 147 additions & 0 deletions src/__tests__/spawnAsync-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,153 @@ it('throws errors with preserved stack traces when processes return non-zero exi
}
});

it(`rejects with ERR_CHILD_PROCESS_STDIO_MAXBUFFER when stdout exceeds maxBuffer`, async () => {
await expect(
spawnAsync(
process.execPath,
['-e', 'process.stdout.write("a".repeat(1000));'],
{ maxBuffer: 100 }
)
).rejects.toMatchObject({
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
message: expect.stringMatching(/exceeded maxBuffer of 100 bytes/),
stdout: 'a'.repeat(100),
stderr: '',
status: 0,
});
});

it(`rejects with ERR_CHILD_PROCESS_STDIO_MAXBUFFER when stderr exceeds maxBuffer`, async () => {
await expect(
spawnAsync(
process.execPath,
['-e', 'process.stderr.write("b".repeat(1000));'],
{ maxBuffer: 50 }
)
).rejects.toMatchObject({
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
stdout: '',
stderr: 'b'.repeat(50),
});
});

it(`preserves the most recent bytes via sliding window when maxBuffer is exceeded`, async () => {
await expect(
spawnAsync(
process.execPath,
['-e', 'process.stdout.write("a".repeat(100), () => process.stdout.write("b".repeat(50)));'],
{ maxBuffer: 100 }
)
).rejects.toMatchObject({
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
stdout: 'a'.repeat(50) + 'b'.repeat(50),
});
});

it(`prefers the exit-code error over the maxBuffer error and exposes the truncated tail`, async () => {
await expect(
spawnAsync(
process.execPath,
['-e', 'process.stdout.write("a".repeat(1000)); process.exit(2);'],
{ maxBuffer: 100 }
)
).rejects.toMatchObject({
message: expect.stringContaining('exited with non-zero code: 2'),
stdout: 'a'.repeat(100),
status: 2,
});
});

it(`allows output up to but not exceeding maxBuffer`, async () => {
const result = await spawnAsync(
process.execPath,
['-e', 'process.stdout.write("x".repeat(100));'],
{ maxBuffer: 100 }
);
expect(result.stdout).toBe('x'.repeat(100));
});

it(`does not enforce maxBuffer when ignoreStdio is true`, async () => {
const result = await spawnAsync(
process.execPath,
['-e', 'process.stdout.write("a".repeat(10000));'],
{ ignoreStdio: true, maxBuffer: 10 }
);
expect(result.status).toBe(0);
expect(result.stdout).toBe('');
});

it(`does not enforce maxBuffer when stdio bypasses pipe capture`, async () => {
const result = await spawnAsync(
process.execPath,
['-e', 'process.stdout.write("a".repeat(10000));'],
{ stdio: 'ignore', maxBuffer: 10 }
);
expect(result.status).toBe(0);
expect(result.stdout).toBe('');
});

it(`listens on 'exit' (not 'close') when stdio is not piped to us`, async () => {
const task = spawnAsync('echo', ['hi'], { stdio: 'ignore' });
expect(task.child.listenerCount('exit')).toBe(1);
expect(task.child.listenerCount('close')).toBe(0);
await task;
});

it(`listens on 'close' (not 'exit') when stdio is piped`, async () => {
const task = spawnAsync('echo', ['hi']);
expect(task.child.listenerCount('close')).toBe(1);
expect(task.child.listenerCount('exit')).toBe(0);
await task;
});

describe(`default-cap (lazy) maxBuffer path`, () => {
// The lazy path only triggers against MAX_STRING_LENGTH (~512 MiB), which is
// impractical to generate. Mock the constant so the same code path activates
// at a testable size.
function spawnAsyncWithCap(cap: number) {
let task: any;
jest.isolateModules(() => {
jest.doMock('buffer', () => {
const actual = jest.requireActual<typeof import('buffer')>('buffer');
return {
...actual,
constants: { ...actual.constants, MAX_STRING_LENGTH: cap },
};
});
const localSpawnAsync = require('../spawnAsync');
task = localSpawnAsync(
process.execPath,
['-e', 'process.stdout.write("a".repeat(100), () => process.stdout.write("b".repeat(50)));']
);
});
return task as Promise<SpawnResult> & { child: any };
}

it(`resolves the promise without rejecting`, async () => {
const result = await spawnAsyncWithCap(100);
expect(result.status).toBe(0);
expect(result.signal).toBe(null);
});

it(`throws ERR_CHILD_PROCESS_STDIO_MAXBUFFER on stdout access with the truncated tail`, async () => {
const result = await spawnAsyncWithCap(100);
let error: any;
try { void result.stdout; } catch (e) { error = e; }
expect(error).toMatchObject({
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
stdout: 'a'.repeat(50) + 'b'.repeat(50),
stderr: '',
});
});

it(`only throws on the overflowed stream; the other reads normally`, async () => {
const result = await spawnAsyncWithCap(100);
expect(result.stderr).toBe('');
expect(() => result.stdout).toThrow();
});
});

it(`exports TypeScript types`, async () => {
let options: SpawnOptions = {};
let promise: SpawnPromise<SpawnResult> = spawnAsync('echo', ['hi'], options);
Expand Down
Loading
Loading