Skip to content

oxnode parent process can exit before child process finishes #493

@h-a-n-a

Description

@h-a-n-a

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions