Skip to content

Unhandled rejection when child process fails to spawn (ENOENT) before initialize() settles #121

@zahalzel

Description

@zahalzel

Summary

When a caller wires ClientSideConnection up to a child process's stdio (via Readable.toWeb / Writable.toWeb) and the spawn fails with ENOENT, Node fires unhandledRejection for the pending-response promise before the caller's await attaches a handler. initialize() does reject correctly, so the caller's control flow is fine — but the cosmetic unhandled rejection still crashes strict test runners (vitest, Node --unhandled-rejections=strict) and pollutes logs.

Telltale warning:

(node:NNN) PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: N)

Related but distinct from #102 (stream read failures during #receive). The control scenarios below show #102's fix holds — the remaining leak is in sendRequest's listener-attachment window, not in #receive.

Environment

  • @agentclientprotocol/sdk 0.19.0
  • Node.js v22.16.0
  • Windows 11 (also reproduces on Linux per downstream reports)

Minimal repro

// repro.mjs — node repro.mjs
import { spawn } from 'node:child_process';
import { Readable, Writable } from 'node:stream';
import { ClientSideConnection, ndJsonStream } from '@agentclientprotocol/sdk';

process.on('unhandledRejection', (err) => {
    console.log('UNHANDLED:', err?.message ?? String(err));
    process.exitCode = 1;
});

const child = spawn('definitely-not-a-real-binary-xyz', [], {
    stdio: ['pipe', 'pipe', 'pipe'],
});
child.on('error', () => {}); // absorb spawn error

const stream = ndJsonStream(
    Writable.toWeb(child.stdin),
    Readable.toWeb(child.stdout),
);

const connection = new ClientSideConnection(
    () => ({
        async writeTextFile() { return {}; },
        async readTextFile() { return { content: '' }; },
        async requestPermission() {
            return { outcome: { outcome: 'selected', optionId: 'allow' } };
        },
        async sessionUpdate() {},
    }),
    stream,
);

try {
    await connection.initialize({
        protocolVersion: 1,
        clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
    });
} catch (err) {
    console.log('initialize rejected (expected):', err.message);
}

Expected: clean exit, single "initialize rejected" line.
Actual: UNHANDLED: ACP connection closed fires first, then the caught rejection, then PromiseRejectionHandledWarning.

Control scenarios (both clean, both exercise #102's fix path)

  • A — synthetic ReadableStream that calls controller.close() immediately: clean.
  • B — real child process that exits 0 before sending any bytes: clean.
  • C — spawn ENOENT (above): LEAKS.

So the trigger is specifically "streams wired up but never opened," not "streams that close cleanly after opening."

Diagnosis

In Connection.sendRequest (dist/acp.js ~line 1127):

async sendRequest(method, params) {
    this.#throwIfClosed();
    const id = this.#nextRequestId++;
    const responsePromise = new Promise((resolve, reject) => {
        this.#pendingResponses.set(id, { resolve, reject });
    });
    await this.#sendMessage({ jsonrpc: "2.0", id, method, params });
    return responsePromise;
}

Race:

  1. responsePromise is created; {resolve, reject} stored in #pendingResponses.
  2. await this.#sendMessage(...) yields.
  3. #receive() observes the broken transport → calls #close(err) → iterates #pendingResponses and rejects responsePromise.
  4. #sendMessage swallows its own write error via .catch(err => this.#close(err)), so the outer await resolves normally.
  5. return responsePromise hands back an already-rejected promise. By the time the caller's await attaches a handler, Node has already flagged unhandledRejection. The later handler attach produces PromiseRejectionHandledWarning.

Suggested fix

Attach a no-op handler inside sendRequest so the rejection is marked "observed" during the #sendMessage await window. Semantics unchanged — the caller's await still sees the rejection:

async sendRequest(method, params) {
    this.#throwIfClosed();
    const id = this.#nextRequestId++;
    const responsePromise = new Promise((resolve, reject) => {
        this.#pendingResponses.set(id, { resolve, reject });
    });
    responsePromise.catch(() => {}); // suppress unhandledRejection during send-await window
    await this.#sendMessage({ jsonrpc: "2.0", id, method, params });
    return responsePromise;
}

Happy to open a PR with this fix and a regression test (spawn ENOENT → initialize() should reject with no unhandledRejection).

Impact on downstream

  • Vitest flags the rejection and fails tests that exercise the missing-binary path.
  • Downstream workaround in agent-loom PR #62: removed the spawn-based missing-binary test case. Will re-enable when this lands.

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