Fix SDK test completion races and timeout cleanup - #2642
Conversation
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]>
There was a problem hiding this comment.
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
SendAndWaitAsyncfor 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.
This comment has been minimized.
This comment has been minimized.
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]>
This comment has been minimized.
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]>
This comment has been minimized.
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]>
This comment has been minimized.
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]>
This comment has been minimized.
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]>
This comment has been minimized.
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]>
This comment has been minimized.
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]>
This comment has been minimized.
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]>
This comment has been minimized.
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]>
SDK Consistency Review — PR #2642I reviewed the full file list ( Scope of changes: All modified files are test/CI infrastructure — E2E test harnesses, 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.
|
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-stacktool 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.idleis ephemeral: subscribing aftersendreturns can permanently miss completion, and durable history cannot recover idle. Starting a goroutine or scheduling an asyncio task is not itself a subscription fence.SendAsyncusing pre-armed observation.sendAndWaitfor ordinary turns and a subscribe-before-trigger helper when testingsendor releasing permission handlers. Clean up listeners on every terminal path.send_and_waitfor ordinary turns; synchronously subscribe before scheduling waiter tasks. Preserve caller timeouts/error policies across abort, pending-work, mode, and todo tests.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-timeoutcan interruptRunner.runoutside 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 identifysession.disconnect()in the test'sfinally, awaiting asession.detachresponse 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_waitwith 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, andis_remote=Falsediscriminator assertions remain. The obsolete persistence polling loop is removed. A nestedfinallyalso 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_timeoutdispatch 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-stackcollector 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-inTestMainwrites startup/completion state and all goroutine stacks one minute before the watchdog deadline, independently of bufferedgo testoutput.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-stackdoes 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. Seedotnet/ci/README.mdandgo/README.md.Validation and preserved failure history
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.go vetpassed; an empty-selection E2E run verifiedTestMainintegration, not full E2E behavior. Local cgo/race tooling was unavailable; real macOS CI subsequently passed the Go diagnostic controls with race detection enabled.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.df3457bdadded the existingsendStartedfence 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