Skip to content

CopilotClient.start() is not single-flight — concurrent auto-start spawns duplicate CLI processes #2560

Description

@DonJayamanne

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions