TL;DR
Several concurrent first calls on one CopilotClient can each start a separate
Copilot CLI process. The client only tracks the last process handle
(this.cliProcess), so stop() cannot terminate the earlier ones — a duplicate
cold-start cost plus a real process leak. The fix makes start() single-flight:
all concurrent callers await one in-progress startup. A three-call reproduction
then spawns one CLI (instead of three) and leaves zero orphans after stop().
Impact
- One logical startup can pay for multiple CLI boots, native-addon loads, and
settings reads (CPU/memory/disk contention).
- Orphaned CLI child processes survive
stop(), and temp/session resources they
own can remain in use.
- Triggered without any explicit
start() call — e.g. firing several
createSession() calls with Promise.all() on a fresh client.
Root cause
start() returns early only when the state is already "connected". The
"connecting" state has no stored in-flight promise, so a second concurrent
caller re-enters the whole start body.
createSession() and resumeSession() auto-start when !this.connection, so
the race happens even when the app never calls start() directly.
startCLIServer() assigns this.cliProcess = spawn(...); a second spawn
overwrites it and orphans the first, which stop() never terminates.
Reproduction
const client = new CopilotClient(); // autoStart defaults to true
await Promise.all([
client.createSession({ onPermissionRequest: approveAll }),
client.createSession({ onPermissionRequest: approveAll }),
client.createSession({ onPermissionRequest: approveAll }),
]);
// Expected: 1 CLI process; after stop(), 0 remain.
// Actual: up to 3 CLI processes; after stop(), the extras are orphaned.
Proposed fix
- Add a private
startPromise. In start(): if connected, return; if a start is
in flight, return that same promise; otherwise run the start body once and
clear startPromise in a finally so a failed start can be retried.
- Move the existing start body into a private
doStart(); startup order is
unchanged.
Acceptance criteria
- Concurrent
createSession/resumeSession/start() on a fresh client spawn
exactly one CLI process.
- After
stop(), zero CLI processes remain.
- A failed start can be retried by a later
start().
- Regression tests covering the concurrent-start and retry-after-failure cases.
TL;DR
Several concurrent first calls on one
CopilotClientcan each start a separateCopilot CLI process. The client only tracks the last process handle
(
this.cliProcess), sostop()cannot terminate the earlier ones — a duplicatecold-start cost plus a real process leak. The fix makes
start()single-flight:all concurrent callers await one in-progress startup. A three-call reproduction
then spawns one CLI (instead of three) and leaves zero orphans after
stop().Impact
settings reads (CPU/memory/disk contention).
stop(), and temp/session resources theyown can remain in use.
start()call — e.g. firing severalcreateSession()calls withPromise.all()on a fresh client.Root cause
start()returns early only when the state is already"connected". The"connecting"state has no stored in-flight promise, so a second concurrentcaller re-enters the whole start body.
createSession()andresumeSession()auto-start when!this.connection, sothe race happens even when the app never calls
start()directly.startCLIServer()assignsthis.cliProcess = spawn(...); a second spawnoverwrites it and orphans the first, which
stop()never terminates.Reproduction
Proposed fix
startPromise. Instart(): if connected, return; if a start isin flight, return that same promise; otherwise run the start body once and
clear
startPromisein afinallyso a failed start can be retried.doStart(); startup order isunchanged.
Acceptance criteria
createSession/resumeSession/start()on a fresh client spawnexactly one CLI process.
stop(), zero CLI processes remain.start().