Skip to content

Commit 9547e51

Browse files
committed
Fix maxBuffer being ignored or wrongly applied
1 parent 6adb3c3 commit 9547e51

11 files changed

Lines changed: 241 additions & 36 deletions

File tree

‎docs/api.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,6 +1027,8 @@ Largest amount of data allowed on [`stdout`](#resultstdout), [`stderr`](#results
10271027

10281028
By default, this applies to both `stdout` and `stderr`, but [different values can also be passed](output.md#stdoutstderr-specific-options).
10291029

1030+
With [synchronous methods](execution.md#synchronous-execution), this cannot be enforced per file descriptor while the subprocess runs, since `spawnSync()` only accepts a single value, which limits the total output of all file descriptors. The `maxBuffer.stdout` value is used for it. The other file descriptors' own values are enforced from the output, once the subprocess ended, and cannot be higher than `maxBuffer.stdout`.
1031+
10301032
When reached, [`error.isMaxBuffer`](#errorismaxbuffer) becomes `true`.
10311033

10321034
[More info.](output.md#big-output)

‎docs/execution.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ Synchronous execution is generally discouraged as it holds the CPU and prevents
161161
- [IPC](ipc.md): [`sendMessage()`](api.md#sendmessagemessage-sendmessageoptions), [`getOneMessage()`](api.md#getonemessagegetonemessageoptions), [`getEachMessage()`](api.md#geteachmessagegeteachmessageoptions), [`result.ipcOutput`](output.md#any-output-type), [`ipc`](api.md#optionsipc) option, [`serialization`](api.md#optionsserialization) option, [`ipcInput`](input.md#any-input-type) option.
162162
- [`result.all`](api.md#resultall) is not interleaved.
163163
- [`detached`](api.md#optionsdetached) option.
164-
- The [`maxBuffer`](api.md#optionsmaxbuffer) option is always measured in bytes, not in characters, [lines](api.md#optionslines) nor [objects](transform.md#object-mode). Also, it ignores transforms and the [`encoding`](api.md#optionsencoding) option.
164+
- The [`maxBuffer`](api.md#optionsmaxbuffer) option is always measured in bytes, not in characters, [lines](api.md#optionslines) nor [objects](transform.md#object-mode). Also, it ignores transforms and the [`encoding`](api.md#optionsencoding) option, and the `maxBuffer.stdout` value limits the total output of all file descriptors.
165165

166166
<hr>
167167

‎lib/io/max-buffer.js‎

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,10 @@ const getMaxBufferUnit = (readableObjectMode, lines, encoding) => {
3535
return 'characters';
3636
};
3737

38-
// Check the `maxBuffer` option with `result.ipcOutput`
39-
export const checkIpcMaxBuffer = (subprocess, ipcOutput, maxBuffer) => {
40-
if (ipcOutput.length !== maxBuffer) {
38+
// Check the `maxBuffer` option with `result.ipcOutput`, before buffering one more message.
39+
// Like the stream-based `maxBuffer`, the threshold is hit when the buffered amount would exceed it, which also applies to non-integer values.
40+
export const checkIpcMaxBuffer = (ipcOutput, maxBuffer) => {
41+
if (ipcOutput.length + 1 <= maxBuffer) {
4142
return;
4243
}
4344

@@ -57,33 +58,53 @@ const getMaxBufferInfo = (error, maxBuffer) => {
5758
return {streamName: 'output', threshold: maxBuffer[1], unit: 'bytes'};
5859
}
5960

60-
const {maxBufferInfo: {fdNumber, unit}} = error;
61+
const {maxBufferInfo: {fdNumber, unit, threshold = getFdSpecificValue(maxBuffer, fdNumber)}} = error;
6162
delete error.maxBufferInfo;
6263

63-
const threshold = getFdSpecificValue(maxBuffer, fdNumber);
6464
if (fdNumber === 'ipc') {
6565
return {streamName: 'IPC output', threshold, unit: 'messages'};
6666
}
6767

6868
return {streamName: getStreamName(fdNumber), threshold, unit};
6969
};
7070

71-
// The only way to apply `maxBuffer` with `spawnSync()` is to use the native `maxBuffer` option Node.js provides.
72-
// However, this has multiple limitations, and cannot behave the exact same way as the async behavior.
73-
// When the `maxBuffer` is hit, a `ENOBUFS` error is thrown.
74-
export const isMaxBufferSync = (resultError, output, maxBuffer) => resultError?.code === 'ENOBUFS'
75-
&& output !== null
76-
&& output.some(result => result !== null && result.length > getMaxBufferSync(maxBuffer));
77-
78-
// When `maxBuffer` is hit, ensure the result is truncated
79-
export const truncateMaxBufferSync = (result, isMaxBuffer, maxBuffer) => {
80-
if (!isMaxBuffer) {
81-
return result;
71+
// The native `maxBuffer` option passed to `spawnSync()` does not allow differentiating values per file descriptor, so we use `stdout`'s.
72+
// Since each other file descriptor's own limit is capped by `stdout`'s, this is also the highest one.
73+
// Node.js applies it to the total output of all piped file descriptors, not to each of them. When it is hit, the subprocess is killed and a `ENOBUFS` error is thrown.
74+
export const getMaxBufferSync = ([, stdoutMaxBuffer]) => stdoutMaxBuffer;
75+
76+
// File descriptors with a limit lower than `spawnSync()`'s native `maxBuffer` are enforced from the output, after the subprocess exits.
77+
// Since no native error was thrown, we create the `ENOBUFS` error ourselves.
78+
// Like with asynchronous methods, file descriptors which are not buffered are not limited by their own value. However, when they are piped, e.g. to a file, they still count towards the native limit, which is `stdout`'s value.
79+
export const handleMaxBufferSync = ({resultError, output, maxBuffer, buffer}) => {
80+
const fdNumber = findMaxBufferSync(output, maxBuffer, buffer);
81+
if (fdNumber === undefined) {
82+
// The native limit can be hit by the total output without any single file descriptor being over its own limit
83+
return {resultError, isMaxBuffer: resultError?.code === 'ENOBUFS'};
8284
}
8385

84-
const maxBufferValue = getMaxBufferSync(maxBuffer);
85-
return result.length > maxBufferValue ? result.slice(0, maxBufferValue) : result;
86+
const threshold = getMaxBufferValueSync(maxBuffer, fdNumber);
87+
const error = resultError ?? Object.assign(new MaxBufferError(), {code: 'ENOBUFS'});
88+
error.maxBufferInfo = {fdNumber, unit: 'bytes', threshold};
89+
return {resultError: error, isMaxBuffer: true};
8690
};
8791

88-
// `spawnSync()` does not allow differentiating `maxBuffer` per file descriptor, so we always use `stdout`
89-
export const getMaxBufferSync = ([, stdoutMaxBuffer]) => stdoutMaxBuffer;
92+
const findMaxBufferSync = (output, maxBuffer, buffer) => {
93+
if (output === null) {
94+
return;
95+
}
96+
97+
const fdNumber = output.findIndex((result, index) => result !== null
98+
&& buffer[index]
99+
&& result.length > getMaxBufferValueSync(maxBuffer, index));
100+
return fdNumber === -1 ? undefined : fdNumber;
101+
};
102+
103+
// `spawnSync()` uses `maxBuffer.stdout` as its native `maxBuffer`, so other file descriptors' limits can only be lower than that
104+
const getMaxBufferValueSync = (maxBuffer, fdNumber) => Math.min(maxBuffer[1], maxBuffer[fdNumber]);
105+
106+
// When `maxBuffer` is hit, ensure each buffered file descriptor's result is truncated at its own limit
107+
export const truncateMaxBufferSync = (result, fdNumber, {maxBuffer, buffer}) => {
108+
const maxBufferValue = getMaxBufferValueSync(maxBuffer, fdNumber);
109+
return buffer[fdNumber] && result.length > maxBufferValue ? result.slice(0, maxBufferValue) : result;
110+
};

‎lib/io/output-sync.js‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {FILE_TYPES} from '../stdio/type.js';
77
import {truncateMaxBufferSync} from './max-buffer.js';
88

99
// Apply `stdout`/`stderr` options, after spawning, in sync mode
10-
export const transformOutputSync = ({fileDescriptors, syncResult: {output}, options, isMaxBuffer, verboseInfo}) => {
10+
export const transformOutputSync = ({fileDescriptors, syncResult: {output}, options, verboseInfo}) => {
1111
if (output === null) {
1212
return {output: Array.from({length: 3})};
1313
}
@@ -21,21 +21,20 @@ export const transformOutputSync = ({fileDescriptors, syncResult: {output}, opti
2121
fdNumber,
2222
state,
2323
outputFiles,
24-
isMaxBuffer,
2524
verboseInfo,
2625
}, options));
2726
return {output: transformedOutput, ...state};
2827
};
2928

3029
const transformOutputResultSync = (
31-
{result, fileDescriptors, fdNumber, state, outputFiles, isMaxBuffer, verboseInfo},
30+
{result, fileDescriptors, fdNumber, state, outputFiles, verboseInfo},
3231
{buffer, encoding, lines, stripFinalNewline, maxBuffer},
3332
) => {
3433
if (result === null) {
3534
return;
3635
}
3736

38-
const truncatedResult = truncateMaxBufferSync(result, isMaxBuffer, maxBuffer);
37+
const truncatedResult = truncateMaxBufferSync(result, fdNumber, {maxBuffer, buffer});
3938
const uint8ArrayResult = bufferToUint8Array(truncatedResult);
4039
const {stdioItems, objectMode} = fileDescriptors[fdNumber];
4140
const chunks = runOutputGeneratorsSync([uint8ArrayResult], stdioItems, encoding, state);

‎lib/ipc/buffer-messages.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const waitForIpcOutput = async ({
2929
reference: true,
3030
})) {
3131
if (buffer) {
32-
checkIpcMaxBuffer(subprocess, ipcOutput, maxBuffer);
32+
checkIpcMaxBuffer(ipcOutput, maxBuffer);
3333
ipcOutput.push(message);
3434
}
3535

‎lib/methods/main-sync.js‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,6 @@ const spawnSubprocessSync = ({file, commandArguments, options, command, escapedC
9696
fileDescriptors,
9797
syncResult,
9898
options,
99-
isMaxBuffer,
10099
verboseInfo,
101100
});
102101
const stdio = output.map((stdioOutput, fdNumber) => stripNewline(stdioOutput, options, fdNumber));

‎lib/resolve/exit-sync.js‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
import {DiscardedError} from '../return/final-error.js';
2-
import {isMaxBufferSync} from '../io/max-buffer.js';
2+
import {handleMaxBufferSync} from '../io/max-buffer.js';
33
import {isFailedExit} from './exit-async.js';
44

55
// Retrieve exit code, signal name and error information, with synchronous methods
6-
export const getExitResultSync = ({error, status: exitCode, signal, output}, {maxBuffer}) => {
7-
const resultError = getResultError(error, exitCode, signal);
8-
const isTimedOut = resultError?.code === 'ETIMEDOUT';
9-
const isMaxBuffer = isMaxBufferSync(resultError, output, maxBuffer);
6+
export const getExitResultSync = ({error, status: exitCode, signal, output}, {maxBuffer, buffer}) => {
7+
const initialError = getResultError(error, exitCode, signal);
8+
const isTimedOut = initialError?.code === 'ETIMEDOUT';
9+
// A file descriptor over its `maxBuffer` does not stop the subprocess, so the timeout that did is reported instead. That file descriptor is still truncated.
10+
const {resultError, isMaxBuffer} = isTimedOut
11+
? {resultError: initialError, isMaxBuffer: false}
12+
: handleMaxBufferSync({
13+
resultError: initialError,
14+
output,
15+
maxBuffer,
16+
buffer,
17+
});
1018
return {
1119
resultError,
1220
exitCode,
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/usr/bin/env node
2+
import process from 'node:process';
3+
import {getWriteStream} from '../helpers/fs.js';
4+
5+
const fdNumber = Number(process.argv[2]);
6+
const bytes = '.'.repeat(Number(process.argv[3]));
7+
getWriteStream(fdNumber).write(bytes);
8+
setTimeout(() => {}, 1e8);

‎test/helpers/max-buffer.js‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,8 @@ import {execa, execaSync} from '../../index.js';
33
export const maxBuffer = 10;
44

55
export const assertErrorMessage = (t, shortMessage, {execaMethod = execa, length = maxBuffer, fdNumber = 1, unit = 'characters'} = {}) => {
6-
const [expectedStreamName, expectedUnit] = execaMethod === execaSync
7-
? ['output', 'bytes']
8-
: [STREAM_NAMES[fdNumber], unit];
9-
t.true(shortMessage.includes(`${expectedStreamName} was larger than ${length} ${expectedUnit}`));
6+
const expectedUnit = execaMethod === execaSync ? 'bytes' : unit;
7+
t.true(shortMessage.includes(`${STREAM_NAMES[fdNumber]} was larger than ${length} ${expectedUnit}`));
108
};
119

1210
const STREAM_NAMES = ['stdin', 'stdout', 'stderr', 'stdio[3]'];

0 commit comments

Comments
 (0)