-
-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
6 changed files
with
134 additions
and
98 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
import {spawn} from 'node:child_process'; | ||
import {once, on} from 'node:events'; | ||
|
||
export default function picoSpawn(file, second, third) { | ||
const [commandArguments = [], options = {}] = Array.isArray(second) ? [second, third] : [[], second]; | ||
const state = { | ||
stdout: '', | ||
stderr: '', | ||
output: '', | ||
command: [file, ...commandArguments].join(' '), | ||
}; | ||
const nodeChildProcess = spawnSubprocess(file, commandArguments, options, state); | ||
return Object.assign(getResult(nodeChildProcess, state), {nodeChildProcess}); | ||
} | ||
|
||
const spawnSubprocess = async (file, commandArguments, options, state) => { | ||
try { | ||
const instance = spawn(file, commandArguments, options); | ||
bufferOutput(instance.stdout, 'stdout', options, state); | ||
bufferOutput(instance.stderr, 'stderr', options, state); | ||
|
||
// The `error` event is caught by `once(instance, 'spawn')` and `once(instance, 'close')`. | ||
// But it creates an uncaught exception if it happens exactly one tick after 'spawn'. | ||
// This prevents that. | ||
instance.once('error', () => {}); | ||
|
||
await once(instance, 'spawn'); | ||
return instance; | ||
} catch (error) { | ||
throw getSubprocessError(error, {}, state); | ||
} | ||
}; | ||
|
||
const bufferOutput = (stream, streamName, {buffer = true}, state) => { | ||
if (stream) { | ||
stream.setEncoding('utf8'); | ||
if (buffer) { | ||
stream.on('data', chunk => { | ||
state[streamName] += chunk; | ||
state.output += chunk; | ||
}); | ||
} | ||
} | ||
}; | ||
|
||
const getResult = async (nodeChildProcess, state) => { | ||
const instance = await nodeChildProcess; | ||
const onClose = once(instance, 'close'); | ||
|
||
try { | ||
await Promise.race([ | ||
onClose, | ||
...instance.stdio.filter(Boolean).map(stream => onStreamError(stream)), | ||
]); | ||
checkFailure(instance, state); | ||
return state; | ||
} catch (error) { | ||
await Promise.allSettled([onClose]); | ||
throw getSubprocessError(error, instance, state); | ||
} | ||
}; | ||
|
||
const onStreamError = async stream => { | ||
for await (const [error] of on(stream, 'error')) { | ||
// Ignore errors that are due to closing errors when the subprocesses exit normally, or due to piping | ||
if (!['ERR_STREAM_PREMATURE_CLOSE', 'EPIPE'].includes(error?.code)) { | ||
throw error; | ||
} | ||
} | ||
}; | ||
|
||
const checkFailure = ({exitCode, signalCode}, {command}) => { | ||
if (signalCode) { | ||
throw new Error(`Command was terminated with ${signalCode}: ${command}`); | ||
} | ||
|
||
if (exitCode >= 1) { | ||
throw new Error(`Command failed with exit code ${exitCode}: ${command}`); | ||
} | ||
}; | ||
|
||
const getSubprocessError = (error, {exitCode, signalCode}, state) => Object.assign( | ||
error?.message?.startsWith('Command ') | ||
? error | ||
: new Error(`Command failed: ${state.command}`, {cause: error}), | ||
// `exitCode` can be a negative number (`errno`) when the `error` event is emitted on the `instance` | ||
exitCode >= 1 ? {exitCode} : {}, | ||
signalCode ? {signalName: signalCode} : {}, | ||
state, | ||
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,43 +1,16 @@ | ||
import {spawn} from 'node:child_process'; | ||
import {once} from 'node:events'; | ||
import process from 'node:process'; | ||
import {applyForceShell} from './windows.js'; | ||
import {getResultError} from './result.js'; | ||
|
||
export const spawnSubprocess = async (file, commandArguments, options, context) => { | ||
try { | ||
// When running `node`, keep the current Node version and CLI flags. | ||
// Not applied with file paths to `.../node` since those indicate a clear intent to use a specific Node version. | ||
// Does not work with shebangs, but those don't work cross-platform anyway. | ||
[file, commandArguments] = ['node', 'node.exe'].includes(file.toLowerCase()) | ||
? [process.execPath, [...process.execArgv.filter(flag => !flag.startsWith('--inspect')), ...commandArguments]] | ||
: [file, commandArguments]; | ||
export const handleArguments = async (file, commandArguments, options, context) => { | ||
// When running `node`, keep the current Node version and CLI flags. | ||
// Not applied with file paths to `.../node` since those indicate a clear intent to use a specific Node version. | ||
// This also provides a way to opting out, e.g. using `process.execPath` instead of `node` to discard current CLI flags. | ||
// Does not work with shebangs, but those don't work cross-platform anyway. | ||
[file, commandArguments] = ['node', 'node.exe'].includes(file.toLowerCase()) | ||
? [process.execPath, [...process.execArgv.filter(flag => !flag.startsWith('--inspect')), ...commandArguments]] | ||
: [file, commandArguments]; | ||
|
||
const instance = spawn(...await applyForceShell(file, commandArguments, options)); | ||
bufferOutput(instance.stdout, context, 'stdout'); | ||
bufferOutput(instance.stderr, context, 'stderr'); | ||
|
||
// The `error` event is caught by `once(instance, 'spawn')` and `once(instance, 'close')`. | ||
// But it creates an uncaught exception if it happens exactly one tick after 'spawn'. | ||
// This prevents that. | ||
instance.once('error', () => {}); | ||
|
||
await once(instance, 'spawn'); | ||
return instance; | ||
} catch (error) { | ||
throw getResultError(error, {}, context); | ||
} | ||
}; | ||
|
||
const bufferOutput = (stream, {state}, streamName) => { | ||
if (stream) { | ||
stream.setEncoding('utf8'); | ||
if (!state.isIterating) { | ||
state.isIterating = false; | ||
stream.on('data', chunk => { | ||
state[streamName] += chunk; | ||
state.output += chunk; | ||
}); | ||
} | ||
} | ||
[file, commandArguments, options] = await applyForceShell(file, commandArguments, options); | ||
context.isIterating ??= false; | ||
return [file, commandArguments, {...options, buffer: !context.isIterating}]; | ||
}; |