-
-
Notifications
You must be signed in to change notification settings - Fork 6
Description
Problem
When running a script with oxnode, the parent process can exit before the spawned child process
completes. This causes downstream commands chained with && or --sequential to start too early,
seeing incomplete output from the previous step.
Root cause
In execute(), the child process is spawned with exec() (non-blocking), but the method returns
immediately without awaiting the child's completion:
async execute() {
// ...
const cp = exec(`node --enable-source-maps --import ${register} ${args}`, {
env: process.env,
cwd: process.cwd(),
});
// ... pipe setup ...
cp.addListener(`exit`, (code) => {
process.exit(code ?? 0);
});
// ← execute() returns here, the async promise resolves with undefined
}Clipanion's runExit awaits execute():
async runExit(input, context) {
process.exitCode = await this.run(input, context);
}Since execute() resolves immediately, runExit sets process.exitCode = undefined (defaults to 0).
The event loop then drains and the parent process exits — potentially before the child process has
finished its work.
The exit listener calling process.exit(code) is meant to catch this, but it's a race: if the event
loop drains before the child emits exit, the parent is already gone.
Reproduction
In rolldown, the build pipeline runs:
pnpm run build-node && pnpm run build-types-check
build-node uses oxnode to run build.ts, which deletes dist/ then rebuilds it (including .d.ts
files). When the parent exits early, build-types-check starts while dist/ is empty or incomplete,
causing:
error TS18003: No inputs were found in config file 'tsconfig.check.json'.
Specified 'include' paths were '["dist/**/*.d.*ts"]' and 'exclude' paths were '[]'.
This is intermittent — it depends on whether the child finishes before the parent's event loop drains.
Suggested fix
execute() should return a Promise that resolves only when the child process exits:
async execute() {
// ... help/args setup unchanged ...
return new Promise<void>((resolve) => {
const cp = exec(
`node --enable-source-maps --import ${register} ${args}`,
{
env: process.env,
cwd: process.cwd(),
},
);
cp.addListener(`error`, (error) => {
console.error(error);
});
if (cp.stdin) {
this.context.stdin.pipe(cp.stdin);
}
if (cp.stdout) {
cp.stdout.pipe(this.context.stdout);
}
if (cp.stderr) {
cp.stderr.pipe(this.context.stderr);
}
cp.addListener(`exit`, (code) => {
process.exitCode = code ?? 1;
resolve();
});
});
}This keeps runExit's await pending until the child actually exits, and uses process.exitCode +
resolve() instead of process.exit() for graceful shutdown.