Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Fix SDK test completion races and timeout cleanup - #2642

Open
stephentoub wants to merge 13 commits into
mainfrom
stephentoub-sdk-permission-completion-race
Open

Fix SDK test completion races and timeout cleanup#2642
stephentoub wants to merge 13 commits into
mainfrom
stephentoub-sdk-permission-completion-race

Conversation

@stephentoub

@stephentoub stephentoub commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fix completion-subscription races across SDK tests, two Python fixture/cleanup defects, and missing pre-timeout diagnostics for intermittent CI hangs. SDK product sources, runtime pins, and snapshots are unchanged. Windows diagnostics add a CI-only pinned dotnet-stack tool manifest.

Current head: 77127f86dec3990e7bbfe809f177a3d94e63d6d6. SDK run 34787031867 is pending. The preceding run passed every Go, .NET, and Node check; its one Python failure now has a concrete stalled-operation trace and a verified fixture repair below. Full current-head CI is not yet green.

Completion-subscription repair

session.idle is ephemeral: subscribing after send returns can permanently miss completion, and durable history cannot recover idle. Starting a goroutine or scheduling an asyncio task is not itself a subscription fence.

SDK Repair
.NET Use a shared send-and-wait helper, preserving the 120-second default and requiring a current-turn assistant message. Keep tests of SendAsync using pre-armed observation.
Go Pre-arm 25 final-message waits and three abort/recovery waits. Separate live completion from independently completed history; preserve caller contexts.
Node.js Use sendAndWait for ordinary turns and a subscribe-before-trigger helper when testing send or releasing permission handlers. Clean up listeners on every terminal path.
Python Use send_and_wait for ordinary turns; synchronously subscribe before scheduling waiter tasks. Preserve caller timeouts/error policies across abort, pending-work, mode, and todo tests.
Java / Rust Audited completion APIs, subscriptions, abort/recovery, and pending work. No affected live callers identified; no code changes.

Fake-RPC regressions force early terminal events before send replies, with idle omitted from durable history. Preserve prompts, session setup, output assertions, and nonblocking-send coverage. No forced already-idle flags, sleeps, timeout increases, or assertion weakening.

Python repairs

Abandoned test task after pytest-timeout

pytest-timeout can interrupt Runner.run outside the coroutine, leaving the timed-out test task alive and holding a session-disconnect lock. Module teardown then waits on that abandoned task, causing a second timeout.

The harness preserves diagnostics and cancels exactly the interrupted runner's test task. It does not cancel unrelated tasks, force-stop the runtime, or turn the initial timeout into success. Actual-plugin/module-fixture controls prove that old behavior reproduces the lock-only teardown stall, the fix removes it, and a runtime that continues withholding replies still causes teardown failure.

The original session-config test's initial 300-second timeout is not conclusively attributed to this cleanup defect. Its exact stalled await was absent from the original log. Six SDK RPC probes and six proxy probes confirmed the cleanup defect but did not identify that primary await. get_exchanges() uses independent HTTPX requests with inactivity timeouts, not a shared stdout response-future map.

Persisted-session fixture detached before completing its turn

Python Alpine ARM64/inprocess job 103801413979 failed test_should_list_find_and_inspect_persisted_session_state. The retained diagnostics identify session.disconnect() in the test's finally, awaiting a session.detach response for 299.922 seconds. This particular test creates an explicit stdio client even in the inprocess matrix cell; the shared fixture's FFI state is not the blocked connection.

The test enqueued an uncontrolled model turn merely to make the persisted-session listing nonempty, then inspected metadata and detached without observing turn completion. It now reuses the existing synthetic inference-response helpers, awaits send_and_wait with the same prompt and a 60-second completion budget, verifies the synthetic assistant response, and only then saves/lists metadata. All nonempty-list, exact-session, LocalSessionMetadataValue, and is_remote=False discriminator assertions remain. The obsolete persistence polling loop is removed. A nested finally also stops the per-test client if detach raises, without suppressing that detach failure.

Two controls invoke this exact E2E scenario with a controlled client: both fail against the original fixture and pass with the repair, proving completion-before-save and cleanup-on-detach-failure. The real pinned-runtime fixture also passed locally. This fixes the test's unobserved inference/cleanup overlap; it does not claim to identify or repair the native reason that the original detach reply was withheld.

Pre-timeout diagnostics

Python captures coroutine/async-generator await chains, pending RPC identifiers/methods, lock/transport state, Python thread stacks, and a bounded macOS native sample before cleanup destroys evidence. RPC payloads and arbitrary frame locals are excluded. An opt-in manual reproduce_timeout dispatch runs two full suites or ten session-config invocations with normal pytest/xdist options, first-failure exit, the existing 20-minute job budget, and per-invocation progress artifacts.

.NET macOS and Windows default/CAPI shard 1 wrap their unchanged test command with a watchdog retaining allowlisted provisioning/build/test/framework/shutdown progress, owned-process metadata, and bounded stacks. It observes descendants retaining output pipes after the root command exits. Windows uses a kill-on-close Job Object and retained process handles, plus a pinned dotnet-stack collector for up to four owned CoreCLR processes. Collectors have their own bounded Job Objects; cleanup never targets unrelated processes by executable name. Existing TRX/blame diagnostics remain enabled.

Go macOS/inprocess reuses this watchdog around the unchanged test.sh, retaining full selection, race detection, and the per-package timeout. Native sampling prioritizes the E2E process. An opt-in TestMain writes startup/completion state and all goroutine stacks one minute before the watchdog deadline, independently of buffered go test output.

The command deadline is the earlier of 15 minutes from launch or 16 minutes after checkout, leaving time within the unchanged 20-minute job limit for diagnostics, owned-process cleanup, and artifact upload. Timeout remains failure; an earlier nonzero result is preserved. Ordinary local Go invocations are unchanged. Artifacts exclude arbitrary console output, RPC payloads, environment dumps, heap/process dumps, and locals.

Limits: dotnet-stack does not capture .NET Framework/native CLI stacks; those are explicitly identified with thread metadata and existing blame/TRX evidence. Thread stacks do not reconstruct every suspended async state machine. Diagnostic validation and a subsequent non-reproduction do not prove a primary hang is repaired. See dotnet/ci/README.md and go/README.md.

Validation and preserved failure history

  • Latest Python fixture fix: both shared-scenario controls failed against the original code and passed after the change. The real Windows stdio E2E passed in 14.08 seconds against pinned CLI 1.0.84-5. Ruff checks/formatting passed; no snapshot edits or full local suite run.
  • Run 34785968413 at c9aee7d9: 49/50 SDK test jobs passed, including all Go/.NET/Node gates. Python Alpine reported 933 passed, nine existing skips, and the single 300-second detach timeout above. Diagnostic artifacts confirm Go macOS natural exit 0 in 127.533 seconds; Windows .NET passed 555 net8.0 plus 423 net472 tests with six existing skips and natural exit 0 in 473.532 seconds. No watchdog deadline intervention was needed.
  • Diagnostic controls: 21 Node watchdog controls passed locally, with four existing POSIX-only controls inapplicable on Windows. These cover real managed waiting stacks, orphaned pipe holders, unrelated-process survival, supervisor termination, original failures, safe Go progress filtering, deadlines, and native-target prioritization. Windows helper build had zero warnings/errors; formatting and workflow checks passed. Go's seven focused test functions and go vet passed; an empty-selection E2E run verified TestMain integration, not full E2E behavior. Local cgo/race tooling was unavailable; real macOS CI subsequently passed the Go diagnostic controls with race detection enabled.
  • Prior macOS .NET 103753104713 passed 555 tests with two existing skips and unchanged 452 method identities/case multiplicities. Natural exit 0 in 207.214 seconds; all 11 then-existing watchdog controls passed on macOS.
  • Python timeout cleanup: 19 focused tests, Ruff, and SDK typechecking passed; all three controls passed real POSIX SIGALRM on macOS and Linux. Earlier diagnostic work passed 25 focused tests.
  • Targeted reproduction 34765982698: all ten session-config invocations completed. Pre-cleanup stress 34764284304: four full suites passed 925 tests each; the original five-invocation configuration exhausted the job budget at the fifth start. The bounded scopes above correct that diagnostic-budget defect. These are non-reproduction evidence, not proof of a primary Python timeout repair.
  • Run 34763599690 passed completely. Run 34765985408 passed 49/50 test jobs; macOS .NET shard 1 hit its 20-minute limit without retained evidence, motivating the original watchdog.
  • Run 34768280584 ended with 48/50 test jobs passing. Two Node jobs initially failed before tests on a pinned CLI asset HTTP 500. After the identical URL recovered (HEAD 200, range GET 206), each received exactly one targeted retry: build/validation passed in attempt 2, Ubuntu/default in attempt 3, and the Node gate passed. Original Go/.NET hang results were carried forward, not rerun.
  • That run's Go macOS/inprocess 103753104455 hit the annotated 20-minute job limit without a test/goroutine trace; the unchanged per-package 20-minute timer lost to the enclosing job deadline. Identical inputs/CLI/Go/runner passed 103746971240 in 158.794 seconds, and seven other Go configurations passed. The primary cause remains unattributed.
  • Windows .NET shard 1 103753104714 also hit an annotated 20-minute limit. Metadata spans 25m01s, with the extra time unexplained. Logs return BlobNotFound, the archive lacks that shard, and no Windows artifact existed. Healthy 103746971275 passed 978 tests/six existing skips with unchanged inputs. Its primary cause likewise remains unattributed. Required aggregate failures correctly reflected these cancelled dependencies, not separate defects.
  • Run 34761514877 passed 49/50 test jobs. Python macOS/inprocess reported 913 passed, one failed, nine existing skips, and one teardown error: system-message resume timed out at 300 seconds; module teardown timed out after the available-tools body passed. The original log lacked async/native diagnostics; identical Python code passed previously.
  • First expanded run 34760106004 exposed five new Node fake-RPC fixture deadlocks. df3457bd added the existing sendStarted fence before completing the fake send; all Node configurations then passed. An earlier Go Windows/default timeout later passed unchanged without establishing attribution.

No test-failure reruns-to-green, manual CI cancellations, newly skipped existing SDK tests, or weakened checks/assertions were used. Only the two confirmed external download failures received targeted retries. Full-suite validation uses CI as requested; genuine pushes use existing PR concurrency behavior.

Origin

This follows github/copilot-agent-runtime#20254 and its original optional CAPI C# SDK failure, using #2635's abort-recovery fix as prior art. The original CI incident cannot conclusively be attributed to the reproduced subscription race because runtime logs were not retained.

Historical validation at fa8fa0d4: both new ordering cases failed with old code, then all 22 focused cases and all 16 permission E2Es passed; net472 build, formatting, and the .NET CI matrix passed. Local E2E used an isolated verified copy of the supplied runtime build, not the exact failing CI binary.

Generated by Copilot

Subscribe before sending the permission E2E prompt so an ephemeral session.idle cannot be lost. Exercise the shared scenario against fake RPC with idle before and after the send reply, reusing the abort regression event fence and preserving the 120-second E2E timeout.

Co-authored-by: Copilot App <[email protected]>
Copilot AI balanced review requested due to automatic review settings September 13, 2026 12:09
@stephentoub
stephentoub requested a review from a team as a code owner September 13, 2026 12:09

Copilot AI 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.

Copilot review overview

🟢 Approval recommended

The test-only changes correctly eliminate the subscription race and cover both event orderings.

Review tier: Balanced
Findings: None

What changed in this PR

Fixes a race in the .NET approve-all permission E2E test by subscribing before sending.

Changes:

  • Uses SendAndWaitAsync for race-free completion.
  • Adds deterministic early/late idle regression coverage.
  • Extracts a reusable fake-server event-delivery fence.
File Description
dotnet/​test/​E2E/​PermissionE2ETests.cs Updates and extracts the send/wait scenario.
dotnet/​test/​Unit/​ClientSessionLifetimeTests.cs Adds regression cases and centralizes event fencing.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@github-actions

This comment has been minimized.

stephentoub and others added 4 commits September 13, 2026 09:15
Replace post-send history-backfill waits with a send-and-wait helper that preserves the 120-second budget and requires a current-turn assistant message. Keep SendAsync under test with pre-armed completion observation. Exercise the shared helper with early events and reject missing or previous-turn-only answers.

Co-authored-by: Copilot App <[email protected]>
Replace unsafe post-send waits with sendAndWait or a trigger-based helper that subscribes before work starts. Remove ephemeral-idle history fallback, retain send behavior assertions and test budgets, and add deterministic early-event and cleanup regressions.

Co-authored-by: Copilot App <[email protected]>
Separate live event waiters from reads of already-completed history. Install listeners synchronously before sends, handler release, and abort/recovery operations; preserve caller contexts and required assistant output. Add deterministic fake-RPC regressions with ephemeral idle omitted from history.

Co-authored-by: Copilot App <[email protected]>
Use send_and_wait for ordinary turns and synchronously subscribe before sends, aborts, and pending-work operations. Remove ephemeral-idle backfill, retain per-caller timeouts and error policies, and cover early RPC completion plus cancellation cleanup without initializing E2E runtime from unit tests.

Co-authored-by: Copilot App <[email protected]>
@stephentoub stephentoub changed the title Fix approve-all permission test event subscription race Fix completion-subscription races across SDK tests Sep 13, 2026
Comment thread python/e2e/test_session_e2e.py
Comment thread python/e2e/test_session_e2e.py
Comment thread python/e2e/test_session_e2e.py
Comment thread python/e2e/test_session_e2e.py
Comment thread python/e2e/test_session_e2e.py
Comment thread python/test_session.py
Comment thread python/test_session.py
Comment thread python/test_session.py
Comment thread python/test_session.py
Comment thread python/test_session.py
@github-actions

This comment has been minimized.

CI exposed five regression fixtures completing a fake send before trace-context setup reached the RPC handler. Await the existing sendStarted fence so the RPC resolver is installed before delivering its response, without sleeps or timeout changes.

Co-authored-by: Copilot App <[email protected]>
@github-actions

This comment has been minimized.

Capture suspended await chains, pending RPC metadata, session and transport state, and Python thread stacks at the original pytest-timeout signal. Sample native threads for macOS in-process failures and preserve evidence in xdist reports and CI artifacts without changing timeout or failure semantics.

Add deterministic diagnostic regressions and subprocess coverage for xdist reporting, including real POSIX signal timeouts during test calls and fixture teardown.

Generated by Copilot

Co-authored-by: Copilot App <[email protected]>
Comment thread python/e2e/conftest.py
@github-actions

This comment has been minimized.

Keep ordinary PR and reusable checks unchanged. The manual reproduce_timeout input selects a single macOS/inprocess job, runs up to five complete pytest/xdist suites, and exits on the first failure with its original status and existing diagnostic artifacts. Preserve the 20-minute job budget.

Validated extracted shell syntax and injected failures on invocations 1, 3, and 5, plus the five-success path.

Generated by Copilot

Co-authored-by: Copilot App <[email protected]>
@github-actions

This comment has been minimized.

pytest-timeout's signal interrupts run_until_complete without cancelling the test coroutine. Its held session disconnect lock can then block module cleanup. Identify exactly that task from the interrupted runner's traceback and schedule cancellation after preserving diagnostics; do not cancel unrelated tasks, force-stop the runtime, or hide the first failure.

Add actual-plugin/module-fixture regressions for lock-only cleanup recovery and a still-unresponsive runtime. Both retain the original failed test; the latter still reports teardown failure. POSIX uses the real signal timer and Windows invokes the same plugin handler at the event-loop boundary.

Generated by Copilot

Co-authored-by: Copilot App <[email protected]>
@stephentoub stephentoub changed the title Fix completion-subscription races across SDK tests Fix SDK test completion races and timeout cleanup Sep 13, 2026
@github-actions

This comment has been minimized.

Limit the full-suite diagnostic scope to two invocations and add an allowlisted session-config scope with ten invocations. Preserve pytest/xdist options and stop at the first nonzero status. Record started/completed invocations, only mark complete after all pass, and attempt diagnostic artifact upload even after cancellation. Ordinary PR/reusable tests and the 20-minute budgets are unchanged.

Validated extracted shell syntax, both count limits, first/middle/last failures, exit 130, process interruption, and rejection of unsupported targets.

Generated by Copilot

Co-authored-by: Copilot App <[email protected]>
@github-actions

This comment has been minimized.

Keep the existing dotnet test command and its selection unchanged. Record allowlisted build/runtime/test/shutdown progress and owned process metadata, then collect bounded native stack samples and terminate only the owned process group before the job deadline. Retain artifacts on failure, cancellation, and successful diagnostic controls. Add focused watchdog regressions including macOS sampling and POSIX pipe-retention cleanup.

Co-authored-by: Copilot App <[email protected]>
@github-actions

This comment has been minimized.

Retain goroutine and bounded managed/native stack evidence before the outer job timeout. Reuse the watchdog with owned Windows Job Object cleanup and preserve test selection, assertions, timeouts, and original failures.

Co-authored-by: Copilot App <[email protected]>
@github-actions

This comment has been minimized.

Use the existing synthetic inference response helpers and observe the completed turn before saving and listing session metadata. Preserve nonempty discriminator assertions and always stop the per-test client when detach fails. Add shared-scenario ordering and cleanup controls.

Co-authored-by: Copilot App <[email protected]>
@github-actions

Copy link
Copy Markdown
Contributor

SDK Consistency Review — PR #2642

I reviewed the full file list (get_files) and diff (get_diff) for this PR.

Scope of changes: All modified files are test/CI infrastructure — E2E test harnesses, test-watchdog* CI scripts, and new async-timeout diagnostics tooling (go/internal/testdiagnostics, python/e2e/timeout_diagnostics.py, dotnet/ci/WindowsWatchdog.cs, plus corresponding harness/test updates) for the Node.js, Python, Go, and .NET SDKs. No changes touch public SDK client/API source under nodejs/src/, python/copilot/, go/*.go (non-test), dotnet/src/, java/sdk/src/main/java/, or rust/src/.

Consistency assessment: Since this PR only adds internal test-hang diagnostics and CI watchdog tooling — not public API surface — it is out of scope for cross-language API parity by definition (per review guidelines, public APIs take priority over internal implementation/test details). No public method/signature/behavior inconsistencies were introduced.

Observation (non-blocking): The new timeout-diagnostics tooling (capturing stack/task state on stalled async test runs) is added for Node.js, Python, Go, and .NET, but not for Java or Rust test suites. This is purely internal test infrastructure, so it doesn't violate SDK API consistency, but if Java/Rust E2E tests are similarly prone to silent hangs, a future follow-up could extend equivalent diagnostics there for parity in test reliability tooling.

No inline review comments are needed — this PR maintains SDK API consistency.

Generated by SDK Consistency Review Agent for #2642 · copilot · sonnet50 · 24.7 AIC · ⌖ 12.1 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.

3 participants