Skip to content

sdk: make client startup single-flight - #2585

Draft
DonJayamanne wants to merge 5 commits into
mainfrom
perf/single-flight-client-start
Draft

sdk: make client startup single-flight#2585
DonJayamanne wants to merge 5 commits into
mainfrom
perf/single-flight-client-start

Conversation

@DonJayamanne

@DonJayamanne DonJayamanne commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

  • make Node.js CopilotClient.start() share one in-flight CLI startup
  • serialize Python startup attempts and clean partial transports after failure or cancellation before retrying
  • atomically publish one .NET connection task, clear failed or cancelled attempts before waking waiters, and clean a partially started FFI host
  • add deterministic concurrency, retry, and cancellation regression coverage across the affected SDKs

Why

Concurrent lazy-start callers could pass the initial state check before startup was published, causing duplicate CLI processes or connections. The language-specific guards now establish one startup owner while preserving safe retry behavior after failure.

Validation

  • Node.js focused startup tests: 2 passed
  • Python client unit tests: 152 passed
  • Python startup-error retry E2E: passed
  • Python Ruff check/format and ty check copilot: passed
  • .NET startup regressions: 2 passed
  • .NET ClientSessionLifetimeTests: 125 passed
  • .NET SDK build for net8.0, net10.0, and netstandard2.0: passed locally with NuGet audit disabled because of the upstream advisory below
  • .NET formatter verification scoped to changed files: passed
  • git diff --check: passed

CI note

The current .NET jobs stop during restore, before compiling this diff, because the existing Microsoft.SourceLink.GitHub 10.0.102 dependency now triggers GHSA-23fw-v26w-5fgq. This PR does not update that unrelated dependency or suppress repository CI audits.

Concurrent createSession()/resumeSession() on a fresh client each auto-start
the CLI; without an in-flight guard the second spawn overwrote this.cliProcess
and orphaned the first, which stop() never terminates. Guard start() with a
shared startPromise so concurrent callers join one spawn; clear it on completion
so a failed start can retry.

Adds regression tests (single-flight + retry-after-failure).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

Serialize Python startup attempts and clean partial resources before retrying after failures or cancellation.

Atomically publish one .NET connection task, clear failed attempts before notifying waiters, and cover concurrent, failed, and cancelled startup behavior with regression tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@DonJayamanne DonJayamanne changed the title sdk: make CopilotClient.start() single-flight sdk: make client startup single-flight Sep 9, 2026
@DonJayamanne

Copy link
Copy Markdown
Contributor Author

Addressed the cross-SDK consistency review in cf091b903:

  • Python now serializes startup and cleans partial resources on failure and cancellation before retry.
  • .NET now atomically publishes one shared startup task and clears failed/cancelled attempts before notifying waiters.
  • Added deterministic concurrency, retry, and cancellation regressions; no changes were needed for Go, Java, or Rust.

Comment thread python/test_client.py
await connect_started.wait()
first_start.cancel()
with pytest.raises(asyncio.CancelledError):
await first_start
@github-actions

This comment has been minimized.

Update the CLI startup failure E2E for retryable client startup. A subsequent lazy start now launches a fresh process and reports the same invalid argument instead of interacting with a stale pipe.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@DonJayamanne

Copy link
Copy Markdown
Contributor Author

Fixed the Python matrix regression in a6d7ad37b. All eight jobs had the same single failing E2E: it expected a stale pipe after failed startup, but startup is now intentionally retryable and correctly returns the original invalid-argument stderr again. The updated expectation passes locally, along with 152 Python client tests, Ruff, and ty.

The .NET jobs are a separate pre-compilation restore failure from the existing Microsoft.SourceLink.GitHub 10.0.102 dependency and newly published GHSA-23fw-v26w-5fgq; no unrelated dependency or audit suppression was added here.

@github-actions

This comment has been minimized.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by SDK Consistency Review Agent for #2585 · copilot · sonnet50 · 56.7 AIC · ⌖ 12.4 AIC · ⊞ 8.3K

Comment thread dotnet/src/Client.cs
Comment on lines +408 to +416
catch (Exception ex)
{
// Clear before waking waiters so continuations can immediately retry.
_ = Interlocked.CompareExchange(
ref _connectionTask,
null,
startCompletion.Task);
startCompletion.TrySetException(ex);
}
Comment thread dotnet/src/Client.cs
else if (ffiHost is not null)
{
try { ffiHost.Dispose(); }
catch (Exception cleanupError) { AddCleanupError(null, cleanupError, _logger); }
@github-actions

This comment has been minimized.

@github-actions

Copy link
Copy Markdown
Contributor

Cross-SDK consistency review

This PR fixes a startup race/leak in Node.js, .NET, and Python: start() is made single-flight (concurrent callers share one in-progress startup) and the single-flight guard is cleared after a failed attempt so a later start() call retries instead of replaying the same stale error forever. Each language change is paired with matching new unit tests.

Findings:

  • Java (java/sdk/src/main/java/com/github/copilot/CopilotClient.java) already has the single-flight half of this fix (connectionFuture cached under synchronized), but not the retry-after-failure half — connectionFuture is only cleared in cleanupConnection() (reached via stop()/forceStop()), not automatically after startCoreBody() fails. Left an inline comment with specifics and a suggested fix.
  • Go (go/client.go) already retries correctly: Start() coalesces concurrent callers via startStopMux, and since state isn't cached in a future/promise, a failed attempt simply lets the next Start() call re-attempt from scratch. No change needed.
  • Rust (rust/src/lib.rs) — Client::start() is an associated constructor function (Client::start(options) -> Result<Self>), not a method called on an already-constructed instance, so there's no persistent client object to hold a single-flight guard or a stuck failed-startup future in the first place. This concept doesn't map onto Rust's API shape, so no change is suggested there.

Overall the PR maintains good parity for the languages it touches (Node/.NET/Python), with matching behavior and test coverage across all three. The one actionable gap is the Java retry-after-failure behavior noted above.

Generated by SDK Consistency Review Agent for #2585 · copilot · sonnet50 · 125 AIC · ⌖ 12.4 AIC · ⊞ 8.3K ·

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by SDK Consistency Review Agent for #2585 · copilot · sonnet50 · 125 AIC · ⌖ 12.4 AIC · ⊞ 8.3K

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants