fix(python,nodejs): serialize concurrent client startup - #2570
Conversation
Node's CopilotClient.start() had the same race as the Python client: concurrent callers (including auto-start from createSession()/resumeSession()) could each observe a non-connected state and spawn a separate CLI process, with the last one silently overwriting cliProcess and orphaning the rest. Guard start() with a shared in-flight startPromise so concurrent callers join one startup instead of racing; the guard is cleared on completion so a failed start can still be retried. The original start body moves into a private doStart() with no behavior change. Adds regression tests for single-flight behavior and retry-after-failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
SteveSandersonMS
left a comment
There was a problem hiding this comment.
Approving. I reproduced the race with a real subprocess (not just mocks): concurrent start() calls spawned multiple CLI processes before this fix, and exactly one after, in both Python and Node.js.
I've also added the Node.js fix as a second commit, since the same race exists there (same early-return-on-connected-only pattern, same auto-start-from-createSession/resumeSession trigger) and the separate Node PR that would have covered it (#2561) was closed by its author without merging. It reuses that PR's approach (shared in-flight startPromise, doStart() split) since it was already minimal and idiomatic for JS. New tests pass, tsc --noEmit/eslint clean, and manual validation confirms 2→1 real subprocess spawns for both languages.
Thanks for the clear repro and tests on the Python side.
CodeQL flagged 'await second' as a statement with no effect. The await is intentional (it drives the task to raise CancelledError inside pytest.raises), so make the discard explicit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
"should recover marker after cold resume with explicit session id" is
the only raw-constructed CopilotClient test in this file that actually
calls sendAndWait (a real model round-trip). Every other raw
new CopilotClient({...}) test in this file only creates/resumes a
session (no model call), so it never exercises model-call auth.
Without an explicit gitHubToken, CopilotClient defaults
useLoggedInUser to true, so the runtime falls back to auto-detecting
credentials (keychain/gh CLI/ambient env) for the model call instead
of deterministically using the harness's fake token. That fallback
path is unreliable on some hosts (observed failing intermittently on
the Alpine ARM64 CI lane with "No GitHub OAuth token or Copilot HMAC
key provided", pre-dating this PR and unrelated to the startup-lock
fix). Passing gitHubToken explicitly - matching the already-reliable
createClient() harness helper and the passing "should resume a
session using a new client" test - removes the auto-login fallback
entirely.
Verified locally: full nodejs/test/e2e/session.e2e.test.ts suite
(37 passed, 2 pre-existing skips) passes with this change; tsc
--noEmit and eslint clean.
Concurrent callers of
CopilotClient.start()(including auto-start fromcreateSession()/resumeSession()) can both observe a disconnected/connecting client, launch separate runtimes, and overwrite the shared connection. Serialize the state check and complete startup sequence per client so waiting callers reuse the established connection instead of racing.Fixes the race in both Python and Node.js:
start()with a per-clientasyncio.Lock(); the original body moves into a private_start().start()with a shared in-flightstartPromise; the original body moves into a privatedoStart(). Cleared on completion so a failed start can still be retried.Related to #2560 (Node.js report) and #2561 (an earlier, since-closed Node.js attempt at this fix). This PR now fixes both languages and can close #2560.
Regression tests (Python and Node.js) pause startup independently during process creation, connection setup, and protocol verification, reproducing duplicate initialization before the fix. Additional tests cover retry after startup failure and (Python) cancellation of a waiting caller without cancelling the active startup.
Validation:
test_client_start.py,test_jsonrpc.py,test_rpc_timeout.py,test_session.py, andtest_github_token_provider.py.uv run ruff check,uv run ruff format --check ., anduv run ty check copilotpassed.client.test.tssingle-flight and retry-after-failure tests pass;tsc --noEmitandeslintclean.start()calls spawned 2 real OS processes. Post-fix: 1.AI-assisted implementation and validation.