Deadline-aware cancellation (prototype) - #10018
Conversation
CI systems (AzDO, GitHub Actions) hard-cancel a job at a fixed wall-clock time. When that happens the runner kills the test process, so we lose the TRX/HTML/AzDO reports and get no dump for a hanging test. This teaches MTP about that deadline through an environment variable and lets it react a bit early, while it still controls its own shutdown: - TESTINGPLATFORM_DEADLINE is an absolute instant (ISO 8601, parsed to UTC). - At deadline minus stop margin (default 60s) an in-process extension asks the framework to gracefully stop scheduling new tests, so the session ends normally and every reporter finalizes. - At deadline minus dump margin (default 30s) the out-of-process HangDump controller takes a dump of the process tree and kills the host, for the case where the host is wedged and never reaches the graceful stop. The deadline comes from the environment, so there is no hardcoded timeout in MTP. The margins are MTP side policy and are env overridable. Wiring the real deadline from the CI timeout is a small bit of YAML, left for a follow-up. It is opt-in: with no deadline set both timers stay unarmed and there is no behavior change. The graceful stop also degrades to a no-op when the framework does not expose IGracefulStopTestExecutionCapability. Verified: build.cmd -pack passes 0/0, and the new acceptance tests pass (AbortAtDeadlineTests 5/5, HangDumpTests 30/30 including the deadline dump). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a prototype “deadline-aware cancellation” mechanism to Microsoft.Testing.Platform (MTP) so CI can provide an absolute wall-clock deadline and MTP can proactively (a) request a graceful stop before the runner hard-kills the process, and (b) trigger HangDump as a fallback before the deadline.
Changes:
- Introduces
DeadlineHelper+ new env vars (TESTINGPLATFORM_DEADLINE,*_STOP_MARGIN,*_DUMP_MARGIN) for parsing an absolute UTC deadline and margins. - Registers a new in-proc
AbortAtDeadlineExtensionthat schedules a timer to requestIGracefulStopTestExecutionCapability. - Extends HangDump to arm an additional one-shot timer for the absolute deadline and adds acceptance coverage for both graceful stop and deadline-driven dump.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HangDumpTests.cs | Adds acceptance coverage ensuring HangDump can be triggered via absolute deadline env var. |
| test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/AbortAtDeadlineTests.cs | New acceptance suite validating the graceful-stop behavior around deadlines/margins and missing capability. |
| src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt | Tracks newly added internal APIs/constants for PublicAPIAnalyzers. |
| src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.Framework.cs | Registers AbortAtDeadlineExtension into the message bus when enabled. |
| src/Platform/Microsoft.Testing.Platform/Helpers/EnvironmentVariableConstants.cs | Adds constants for the new deadline-related environment variables. |
| src/Platform/Microsoft.Testing.Platform/Helpers/DeadlineHelper.cs | New helper to read/parse deadline + margins from environment. |
| src/Platform/Microsoft.Testing.Platform/Extensions/AbortAtDeadlineExtension.cs | New extension that arms a timer to request graceful stop before the deadline. |
| src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt | Tracks newly added env-var constants due to shared-source inclusion. |
| src/Platform/Microsoft.Testing.Extensions.Retry/InternalAPI/InternalAPI.Unshipped.txt | Tracks newly added env-var constants due to shared-source inclusion. |
| src/Platform/Microsoft.Testing.Extensions.MSBuild/InternalAPI.Unshipped.txt | Tracks newly added env-var constants due to shared-source inclusion. |
| src/Platform/Microsoft.Testing.Extensions.HotReload/InternalAPI/InternalAPI.Unshipped.txt | Tracks newly added env-var constants due to shared-source inclusion. |
| src/Platform/Microsoft.Testing.Extensions.HangDump/Microsoft.Testing.Extensions.HangDump.csproj | Links DeadlineHelper.cs into HangDump extension for shared deadline parsing. |
| src/Platform/Microsoft.Testing.Extensions.HangDump/InternalAPI/InternalAPI.Unshipped.txt | Tracks DeadlineHelper + env-var constants for this assembly. |
| src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs | Arms a new deadline-driven dump timer and prevents double dump via _dumpTaken. |
…-cancellation # Conflicts: # src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt
Fixes from the review of #10018: - HangDump: the deadline timer and the inactivity timer both wrote _activityIndicatorTask, so the losing one could overwrite the winner's real dump task with a completed no-op, and Dispose would stop waiting for the dump mid-flight. Move the one-shot guard into a TriggerDumpOnce trampoline so only the winning timer assigns _activityIndicatorTask. - HangDump: the deadline path logged "Hang dump timeout expired", which is misleading because no inactivity timeout expired. Give the deadline case its own reason and its own output message (new HangDumpDeadlineReached resource + regenerated xlf). - AbortAtDeadlineExtension: compute a local non-null stopAt instead of dereferencing _stopAt.Value, so there is no nullable deref. - Add the UTF-8 BOM to the three new source files to satisfy the charset=utf-8-bom editorconfig rule. Verified: build 0/0, AbortAtDeadline 5/5, full HangDump suite 30/30. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…-cancellation # Conflicts: # src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt
More fixes from the review of #10018: - HangDump: the absolute deadline timer was armed only after the pipe handshake in OnTestHostProcessStartedAsync. A test host that wedges during startup never connects back over the pipe, so those waits blocked past the deadline and the deadline dump/kill was never armed, which is exactly the case the deadline is for. Arm the deadline timer right after we have the test host process info (before the handshake). The dump path only needs the PID; the in-progress-test list needs the consumer pipe, so I make it best-effort and skip it when the pipe never connected. - HangDump: the winning timer published _activityIndicatorTask without any ordering against disposal. Disposal could read the field as null, release, and tear the pipes down while a dump the timer just started was still running. Guard the "take the dump once" gate and the task publish under one lock, and have Dispose/DisposeAsync take that lock, claim the gate so no new dump can start, and capture the in-flight task to wait on outside the lock. The lock is a System.Threading.Lock on net9.0 and an object below it, so it still compiles on netstandard2.0. - AbortAtDeadline and HangDump: deadline - margin on DateTimeOffset throws for a very old (but valid) deadline or a large margin. Add a shared DeadlineHelper.SubtractSaturating that clamps at DateTimeOffset.MinValue, so underflow means "already in the past" -> act immediately. Verified: build 0/0, AbortAtDeadline 5/5, full HangDump suite 30/30. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The automated re-review flagged seven spots after the last push. All are in the two deadline timers and the hang dump resource text. AbortAtDeadlineExtension: - DataTypesConsumed is now empty. The extension only implements IDataConsumer so the message bus keeps a live reference to it (which keeps its timer alive). Returning [TestNodeUpdateMessage] made the bus route every test result to a no-op ConsumeAsync, which is O(test-count) for nothing. - The timer callback calls HandleDeadlineAsync directly instead of Task.Run. On single-threaded runtimes (browser/WASI) Task.Run can queue work that never runs; the method is already async and yields at the first await. - Arming the timer clamps a far-future due time to the Timer maximum (~49.7 days) instead of throwing. The run is disposed long before that, so the timer never fires early in practice. - The graceful stop now runs in its own try/catch, separate from the best-effort diagnostics. A logging or output-device failure can no longer skip the stop, and the diagnostics failure log is itself swallowed so it cannot re-throw and skip the stop either. HangDumpProcessLifetimeHandler: - Same far-future clamp on the deadline dump timer. - The in-progress-test query before dumping is wrapped in try/catch. A non-null pipe client is not necessarily connected (it is created when the host sends its pipe name but connected later), so a deadline dump firing in that window could hit an unconnected pipe. Any failure is logged and swallowed so it cannot block taking the dump and killing the tree. - Renamed the resource HangDumpDeadlineReached to HangDumpDeadlineApproaching and reworded the text and log reason to "approaching". The dump fires at deadline minus the dump margin, so the deadline has not been reached yet. Regenerated the xlf files. Local Debug pack is 0/0. AbortAtDeadline acceptance tests 5/5 and the full HangDump acceptance suite 30/30. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs:459
- This request is not actually best-effort when the host is wedged.
NamedPipeClient.RequestReplyAsyncwaits for a response until the supplied run token is canceled (NamedPipeClient.cs:103-105), so a connected host whose control-pipe callback no longer runs can block here indefinitely and prevent both dump creation and process-tree termination. Skip this query for deadline-triggered dumps or bound it to a short deadline-specific budget.
GetInProgressTestsResponse tests = await _namedPipeClient.RequestReplyAsync<GetInProgressTestsRequest, GetInProgressTestsResponse>(new GetInProgressTestsRequest(), cancellationToken).ConfigureAwait(false);
Amaury Levé (Evangelink)
left a comment
There was a problem hiding this comment.
Review — deadline-aware cancellation
I ran this through several review passes (expert MTP reviewer + a bug-focused diff reviewer) and then validated every finding against the code on 14d6b88. Skipping everything the earlier Copilot round already covered and you already fixed.
The shape is good and the concurrency work after the last round (the _dumpLock gate + TriggerDumpOnce trampoline, SubtractSaturating, the timer clamp) reads correctly to me. What follows is what survived validation, roughly in priority order. The first three are the ones I'd want settled before this stops being a prototype.
Things I checked and found clean, so you don't have to re-litigate them: the empty DataTypesConsumed is legal (AsynchronousMessageBus.InitAsync just creates no processor, and the consumer is still disposed via CommonTestHost line 401's messageBus.DataConsumerServices loop); the InternalAPI.Unshipped.txt entries are exact across all five projects that link-compile EnvironmentVariableConstants.cs; the .xlf files are genuinely generated (state="new", source==target, alphabetical); [Embedded] on DeadlineHelper matches every neighbour in that folder; and no new test hard-codes a \(\d+ms\) duration pattern.
| // Stop margin 0 means the graceful stop is scheduled for the deadline itself, a few | ||
| // seconds out. The framework blocks until the stop is requested, so this proves the | ||
| // timer fires on schedule (not only when the deadline is already past). | ||
| ["TESTINGPLATFORM_DEADLINE"] = DateTimeOffset.UtcNow.AddSeconds(6).ToString("o"), |
There was a problem hiding this comment.
Minor — this test can't fail for the reason it exists.
UtcNow.AddSeconds(6) is evaluated in the test process, but the timer is armed in the child after process launch + runtime startup + the MTP builder runs. On a loaded CI agent that easily exceeds 6s, at which point dueTime clamps to zero and the run takes the "already past" path — which is what WhenDeadlineIsInThePast_GracefullyStopsImmediately already covers. The assertions still pass, so the test silently stops testing "the timer fires on schedule".
Same applies to WhenStopMarginIsSubtracted (60s out, 60s margin → stop instant ≈ now).
If you want this to actually pin the timer down, have the test asset report when the stop arrived (e.g. print elapsed-since-start from StopTestExecutionAsync) and assert it's non-trivially greater than zero.
Also: await GracefulStop.Instance.TCS.Task in the asset has no timeout, so a regression in the extension turns these into hangs rather than failures — the acceptance harness timeout is what would eventually catch it. A WaitAsync/Task.WhenAny with a generous cap would fail fast with a readable message instead.
There was a problem hiding this comment.
Half fixed in e7786b2, and I want to be honest about the other half. The unbounded wait is gone: the asset now waits on Task.WhenAny(tcs.Task, Task.Delay(2min)), so a broken stop path fails the assertions instead of hanging until the harness kills it. I did not add an elapsed-time assertion. The tests already assert the new exit code (15) and the stop message, which is what catches a stop that never happened; a wall-clock timing assertion on top of that is the kind of check this repo warns is flaky, so for the prototype I left it out. Happy to add a generous lower bound if you would rather have it.
🤖
…-cancellation # Conflicts: # src/Platform/Microsoft.Testing.Extensions.HangDump/InternalAPI/InternalAPI.Unshipped.txt # src/Platform/Microsoft.Testing.Extensions.MSBuild/InternalAPI.Unshipped.txt # src/Platform/Microsoft.Testing.Extensions.Retry/InternalAPI/InternalAPI.Unshipped.txt
…ocalization, disposal) This is the review round on the deadline-aware cancellation prototype. The big behavioural change is the exit code: a run that the deadline cuts short no longer reports success. - A deadline-truncated run now returns ExitCode.TestExecutionStoppedAtDeadline (15) instead of 0. The stop policy service gained IsDeadlineTriggered and a deadline callback, mirroring the max-failed-tests path, and the extension sets the flag before it asks for the graceful stop so the exit code cannot be raced by session finalization. Without this a suite that never finished would go green on CI, which is the opposite of what the feature is for. - The in-proc extension is only registered for a console run. Server mode builds the framework per request, so it would re-arm the timer against the same absolute instant on every request and fire immediately once the deadline passed. Discovery requests are skipped too. - The operator-facing description and console message moved into PlatformResources and are regenerated into the 13 locales, matching the HangDump side. - The graceful-stop logging is now wrapped the same way as the rest of the handler so a throwing logger cannot escape the timer callback and FailFast the process, and the handler task is drained with a bounded wait on disposal so it is not still touching the logger and output device after teardown. - DeadlineHelper now logs the resolved deadline and margins, warns when the deadline is set but malformed, warns when the framework has no graceful-stop capability, and warns when the dump margin is not smaller than the stop margin. The offset-less footgun is at least visible in the log now. I removed the dead non-negative margin guard. - HangDump disposes both timers on every teardown path (not only the clean exit), takes the dump outside the dump lock (claim the gate under the lock, yield before the work), and lets the deadline dump be published through the normal exited path by returning from the failed handshake when a dump is already in progress. - Added DeadlineHelperTests covering parsing, timezone handling, margin fallback, and the saturating subtraction, which were only exercised indirectly before. Capped the acceptance asset's wait so a broken stop path fails the assertions fast instead of hanging until the harness times out. Local Debug pack is 0/0. DeadlineHelper unit tests 28/28 and AbortAtDeadline acceptance tests 5/5. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b0147c6f-0cfb-4dd6-b420-582b3c7988de
…-cancellation # Conflicts: # src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/InternalAPI/InternalAPI.Unshipped.txt # src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PACKAGE.md
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 70 out of 70 changed files in this pull request and generated 2 comments.
Suppressed comments (2)
test/UnitTests/Microsoft.Testing.Platform.UnitTests/Helpers/DeadlineHelperTests.cs:1
- This new C# file is missing the UTF-8 BOM required by
.editorconfig:66-67. Add the BOM so repository encoding checks and future rewrites preserve the required format.
src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs:246 - For the startup-wedge case described above, this still blocks until the five-minute default handshake timeout. Killing a host that never connected does not complete the server's
WaitForConnectionAsync, and the controller cannot reachWaitForExitAsync/OnTestHostProcessExitedAsyncto publish the dump before the CI hard deadline. Race or cancel the handshake when the deadline dump wins, then await the dump task before continuing.
await _logger.LogDebugAsync($"Wait for test host connection to the server pipe '{_singleConnectionNamedPipeServer.PipeName.Name}'").ConfigureAwait(false);
await _waitConnectionTask.TimeoutAfterAsync(TimeoutHelper.DefaultHangTimeSpanTimeout).ConfigureAwait(false);
using CancellationTokenSource timeout = new(TimeoutHelper.DefaultHangTimeSpanTimeout);
using var linkedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
_waitConsumerPipeName.Wait(linkedCancellationToken.Token);
The timer callback could pass the _disposed check, then Dispose could run to completion and read _handleDeadlineTask while it was still null (draining nothing), and only afterwards would the callback publish the task and run the handler against torn-down services. Publish the task and set/read _disposed under a shared lock so the two cannot interleave: either the callback publishes before Dispose captures it (Dispose then drains it), or Dispose sets _disposed first and the callback observes it and never starts the handler. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b0147c6f-0cfb-4dd6-b420-582b3c7988de
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 70 out of 70 changed files in this pull request and generated 1 comment.
Suppressed comments (5)
src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs:494
- This five-second timeout is applied once per process because
TakeDumpAsyncis called sequentially for every node in the process tree. For a wedged host, each request times out, so six processes consume the entire default 30-second dump margin before dump creation; CI can hard-kill the job without any dump. Query/cache the in-progress tests once for the whole tree, or disable further queries after the first failure, so the timeout is a total bound rather than a per-process bound.
using var queryCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
queryCts.CancelAfter(InProgressTestsQueryTimeout);
GetInProgressTestsResponse tests = await _namedPipeClient.RequestReplyAsync<GetInProgressTestsRequest, GetInProgressTestsResponse>(new GetInProgressTestsRequest(), queryCts.Token).ConfigureAwait(false);
src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs:371
- The deadline dump can fault here before entering the
try/finallythat kills the process tree. If logging or output display throws, the fire-and-forget timer task ends and the wedged host remains alive until CI hard-cancels it, producing no dump—the exact fallback this path is intended to guarantee. Make diagnostics best-effort and place all deadline work after root-process acquisition under a kill-guaranteeingfinally.
await _logger.LogInformationAsync($"{dumpReason}. Taking hang dump.").ConfigureAwait(false);
await _outputDisplay.DisplayAsync(
new ErrorMessageOutputDeviceData(triggeredByDeadline
? ExtensionResources.HangDumpDeadlineApproaching
: string.Format(CultureInfo.InvariantCulture, ExtensionResources.HangDumpTimeoutExpired, _activityTimerValue)),
cancellationToken).ConfigureAwait(false);
test/UnitTests/Microsoft.Testing.Platform.UnitTests/Helpers/DeadlineHelperTests.cs:1
- This new C# file is missing the UTF-8 BOM required for all C# files by
.editorconfig:65-68; the other new C# files include it. Add the BOM so repository encoding checks do not fail.
src/Platform/Microsoft.Testing.Platform/Helpers/ExitCodes.cs:87 - Every other exit-code member has an XML summary, but the newly introduced code has none. Document that code 15 represents execution stopped early for an approaching deadline so generated/internal API documentation remains complete.
src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExitCode.cs:63 - The new exit-code reason is not covered by
GitHubActionsExitCodeTests, even though that suite explicitly verifies the neighboring known mappings for codes 9, 13, and 14. Add code 15 to the known-code assertions so a missing resource/mapping cannot silently fall back to the generic reason.
(int)ExitCode.TestExecutionStoppedAtDeadline => GitHubActionsResources.ExitCodeReasonTestExecutionStoppedAtDeadline,
Four fixes to the deadline-aware cancellation prototype: Deadline callback registration could lose a callback. The registering thread could read IsDeadlineTriggered as false, the trigger could then commit and snapshot a still-empty queue, and only afterwards would the callback be enqueued -- where a one-shot deadline never reaches it. Guard the transition and the callback list with one lock, so each callback runs exactly once. The deadline verdict is committed before the graceful stop is requested (it has to be, or the framework can finalize the session before the flag lands). If StopTestExecutionAsync then threw, the verdict stayed set and a run that executed every test reported TestExecutionStoppedAtDeadline. Revert it when the stop request fails. The extension disarmed the deadline from ITestSessionLifetimeHandler, which it claimed ran before the reporters. It does not: the extension is an IDataConsumer, and NotifyTestSessionEndAsync drains the bus, runs the non-consumer handlers, and only then runs the consumer handlers -- this one appended after the reporters. A deadline reached during that window still marked a fully-executed run as truncated. Signal completion from the host instead, right after the test framework invoker returns. Document ExitCode.TestExecutionStoppedAtDeadline, which was the only member without a summary. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…dump The deadline handler checked "test execution completed" only before it started, then yielded and logged before committing the verdict, so the invoker could return in that window and a run in which every test had finished was still reported as deadline-truncated. Decide it under the same lock the completion transition uses, immediately before the commit. The hang dump asked the test host for the in-progress tests once per process in the tree, so a wedged pipe cost five seconds per process and a six-process tree ate the whole 30s dump margin. Ask once per dump. Also save DeadlineHelperTests.cs as UTF-8 with BOM, as .editorconfig requires for C# files. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
GetReason only had assertions for exit codes 9, 13 and 14, so replacing the new deadline arm with the unknown fallback would have gone unnoticed. Assert the deadline-specific text for 15 and pin it against the fallback. The "query the in-progress tests once per dump operation, not once per process" rule was only exercised by an acceptance test with a single process and a responsive pipe, which passes either way. Move the query and the per-process fan-out into QueryOnceAndDumpTreeAsync and test it with a six-process tree and a stalled query; TakeDumpOfTreeAsync itself dumps and then kills every process it walks, so it cannot be driven against a real tree. Moving the query back into the loop now fails the test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
RevertDeadlineTrigger cleared the flag that also recorded whether the deadline callbacks had run, so reverting the verdict re-armed them: a later trigger could run every callback a second time, and a registration arriving after the revert was queued into a list nothing drains any more. Track callback execution separately and clear only the verdict. Let a dump interrupt the pipe handshake. Killing the test host does not complete this process's own WaitConnectionAsync, so a host that wedged before connecting kept OnTestHostProcessStartedAsync blocked for the full five-minute hang timeout, well past the CI deadline, and OnTestHostProcessExitedAsync never ran to publish the dump that had been taken. Move the in-progress-test query bound into QueryInProgressTestsWithTimeoutAsync so a test can prove the product gives up on a reply that never arrives. The previous test supplied a fake that timed out on its own, so it passed whether or not the product bounded the wait. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Claiming the run closes the door on NotifyTestExecutionCompleted, so a completion arriving after the claim can no longer disarm the deadline. The user-facing message sat between the claim and the commit, so a run that finished while that message was being written was ignored and still reported as stopped at the deadline. Commit the verdict immediately after the claim and write the message afterwards. The new test fails against the previous order. Construct the HangDump consumer pipe client with exitProcessOnConnectionLoss: false. It only carries the best-effort in-progress-test query, and its peer is a test host we are usually about to dump and kill, so a disconnect during the query called IEnvironment.Exit on the controller that still had to take and publish the dump. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Swallowing faults does not cover a logger or output device that wedges: it hands back a task that never completes. The user-facing deadline message is awaited after the verdict is committed and before the graceful stop is requested, so a wedged output device left the run recorded as stopped at the deadline while the stop was never asked for. Bound each best-effort diagnostic with TimeoutAfterAsync, which abandons the task and keeps observing it. In HangDump, the delegate that reports a failed in-progress-test query is a logger call and logger providers can fail. That throw escaped QueryInProgressTestsWithTimeoutAsync and took the dump with it, even though the query is explicitly best-effort. Guard it and always return the empty list. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The deadline was disarmed from OnTestSessionFinishingAsync, which runs at the end of the session-end notification: AbortAtDeadlineExtension is an IDataConsumer, and consumer lifetime handlers are invoked last, after the initial message-bus drain, every non-consumer handler and another drain. That left the whole reporting window unprotected, so a deadline elapsing while TRX, HTML and coverage artifacts were being written marked a fully completed run as deadline-truncated and forced exit code 15. Record completion on IStopPoliciesService the moment the invoker returns, before any reporting or draining starts, and gate the timer on that. The lifetime handler still sets its local flag as a backstop. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 73 out of 73 changed files in this pull request and generated 1 comment.
Suppressed comments (6)
src/Platform/Microsoft.Testing.Platform/Extensions/AbortAtDeadlineExtension.cs:280
Task.Yield()opens a window after the timer has passed both completion checks. If the framework invoker returns during that yield, the host setsIsTestExecutionCompleted, but this handler resumes and still marks the run deadline-triggered and requests a stop, so a fully completed run can incorrectly exit with code 15. Re-checking alone still leaves a check/mark race; coordinate completion and deadline admission atomically in the policy service before emitting diagnostics or invoking the capability.
test/UnitTests/Microsoft.Testing.Platform.UnitTests/Helpers/DeadlineHelperTests.cs:1- This new C# file is UTF-8 without the required BOM;
.editorconfig:66-67requirescharset = utf-8-bomfor all C# files. Re-save it as UTF-8 with BOM so repository encoding checks remain consistent.
src/Platform/Microsoft.Testing.Platform/Helpers/DeadlineHelper.cs:35 DateTimeOffset.TryParseaccepts many invariant-culture formats that are not ISO 8601, such as slash-separated dates. That contradicts the environment-variable contract and can silently schedule a deadline for a malformed CI value instead of logging the configuration warning. Validate against the explicitly supported ISO 8601 forms before accepting the instant.
src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs:494- This five-second query runs inside
TakeDumpAsync, which is called sequentially for every process in the tree. A wedged host therefore costs up to five seconds per process before each dump starts; with the default 30-second margin, six processes can consume the entire margin in repeated requests for the same test list. Query once per dump operation (or skip this optional metadata on the deadline path) so the timeout is bounded for the whole tree, not per process.
using var queryCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
queryCts.CancelAfter(InProgressTestsQueryTimeout);
GetInProgressTestsResponse tests = await _namedPipeClient.RequestReplyAsync<GetInProgressTestsRequest, GetInProgressTestsResponse>(new GetInProgressTestsRequest(), queryCts.Token).ConfigureAwait(false);
src/Platform/Microsoft.Testing.Platform/Helpers/ExitCodes.cs:87
- Every other exit-code member documents its externally observable meaning, but the new code 15 has no XML summary. Add the corresponding summary so generated/internal API documentation explains this outcome consistently.
src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExitCode.cs:63 - The existing
GitHubActionsExitCodeTests.GetReason_ForKnownCode_MentionsRelevantOptioncovers the neighboring known-code mappings, but no test exercises the new code 15 branch. Add an assertion that code 15 returns the deadline-specific reason so a missing resource mapping cannot silently fall back to the generic message.
(int)ExitCode.TestExecutionStoppedAtDeadline => GitHubActionsResources.ExitCodeReasonTestExecutionStoppedAtDeadline,
The deadline outcome has to be recorded before StopTestExecutionAsync is called, because the stop can let the framework finalize the session and compute the exit code straight away. When that request was then rejected, the flag stayed set, so a run that carried on and executed every test still exited with TestExecutionStoppedAtDeadline (15). Commit the outcome only once the stop is accepted, and revert it otherwise. A run hard-killed at the real deadline never reports an exit code at all, so reverting cannot mask a genuine truncation. Also adds the UTF-8 BOM to DeadlineHelperTests.cs, which .editorconfig requires for .cs files. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 73 out of 73 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/Platform/Microsoft.Testing.Platform/Extensions/AbortAtDeadlineExtension.cs:323
- The completion gate and deadline outcome are not committed atomically. The timer can pass both checks in
OnDeadlineReached, yield here, and then the host can callNotifyTestExecutionCompleted()before this line setsIsDeadlineTriggered; a framework whose stop method succeeds as a no-op will make an already-completed run exit with code 15. The flag is also externally visible whileStopTestExecutionAsyncmay still fault and roll it back, so exit-code computation can observe the transient value. Use a synchronized/atomic policy-state transition that arbitrates completion versus deadline claiming, and prevent exit-code finalization from observing an unaccepted stop.
The bounded in-progress-test query sat inside TakeDumpAsync, which the tree loop awaits for every process. The query goes to the single consumer pipe of the test host, so it returns the same list whichever process is about to be dumped, but a connected-but-wedged host that never replies cost the full 5s timeout per process. Six processes exhausted the default 30s dump margin before the first dump was started. Collect the list once before the loop and pass it to each dump. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 73 out of 73 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs:257
- When the host wedges before connecting, the deadline task can dump and kill it, but these handshake waits are not canceled by that task and can remain blocked for
DefaultHangTimeSpanTimeout(five minutes). The controller lifecycle awaitsOnTestHostProcessStartedAsyncbefore it startsWaitForExitAsync/OnTestHostProcessExitedAsync(TestHostControllersTestHost.ProcessLifecycle.cs:114-123), so the dump artifact is not published before the CI deadline. Cancel/race the handshake against the deadline dump task so this method returns as soon as that path wins.
await _logger.LogDebugAsync($"Wait for test host connection to the server pipe '{_singleConnectionNamedPipeServer.PipeName.Name}'").ConfigureAwait(false);
await _waitConnectionTask.TimeoutAfterAsync(TimeoutHelper.DefaultHangTimeSpanTimeout).ConfigureAwait(false);
using CancellationTokenSource timeout = new(TimeoutHelper.DefaultHangTimeSpanTimeout);
using var linkedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
_waitConsumerPipeName.Wait(linkedCancellationToken.Token);
| // Deadline-aware graceful stop only makes sense for a console execution run. Skip it for | ||
| // server mode (BuildTestFrameworkAsync runs per request, which would re-arm the timer against | ||
| // an already-past deadline and fire on every request) and for discovery-only requests. | ||
| if (pushOnlyProtocol?.IsServerMode != true && !testFrameworkBuilderData.IsForDiscoveryRequest) |
There was a problem hiding this comment.
You are right about the gap, and I checked it rather than taking it on trust: DotnetTestConnection.IsServerMode is _transportClient?.IsConnected == true, and the SDK launches the app with --server dotnettestcli, so on a dotnet test run this condition is true and AbortAtDeadlineExtension is never registered. The feature is inert on exactly the path that matters most.
The skip itself is not accidental — BuildTestFrameworkAsync runs per request in server mode, so registering there re-arms the timer against an already-past deadline and fires on every subsequent request. That is why it is written this way, and the comment says so.
So the two halves are both true: the current placement is wrong for dotnet test, and it cannot simply be deleted. Owning the deadline at server/application scope and applying it to the active run request is the right shape, and it is a design change to the prototype rather than a patch to this line, so I want to do it deliberately and with tests for the per-request case. Leaving this thread open as the marker for that.
Announcing the dump and walking the process tree both ran before the try/finally that kills the tree. Loggers and output devices propagate exceptions, so a failure in either one faulted the dump task and left the wedged test host running with no dump at all -- the situation the handler exists to resolve. Announce best-effort, and fall back to the root test host process when the tree cannot be enumerated, so the dump and the kill always happen. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Preserve the target branch's completion and rejected-stop behavior while adding the atomic deadline claim, one-shot callback handling, bounded diagnostics, and one-query-per-dump coverage from the follow-up commits.\n\n🤖\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keep the new best-effort dump announcement and process-tree fallback together with the one-query-per-dump helpers. 🤖 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 75 out of 75 changed files in this pull request and generated 2 comments.
Suppressed comments (2)
src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs:479
- This diagnostic is still on the path before
QueryOnceAndDumpTreeAsync. If the logger provider throws, control jumps directly tofinally, killing the process tree without creating any dump. Make all diagnostics in this block best-effort so they cannot bypass the dump operation.
await _logger.LogInformationAsync($"{dumpReason}.").ConfigureAwait(false);
src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs:711
- This recovery path logs through the same potentially failing logger without guarding it. If annotation writing failed because a provider is broken, this call can throw and skip the actual dump below, despite the comment promising metadata cannot block dump creation. Use the existing best-effort logger helper here.
await _logger.LogDebugAsync($"Could not write the in-progress tests next to the dump of process {process.Id}. Continuing with the dump. {ex}").ConfigureAwait(false);
| Microsoft.Testing.Platform.Services.StopPoliciesService.IsDeadlineTriggered.get -> bool | ||
| Microsoft.Testing.Platform.Services.StopPoliciesService.RegisterOnDeadlineCallbackAsync(System.Func<System.Threading.Tasks.Task!>! callback) -> System.Threading.Tasks.Task! | ||
| Microsoft.Testing.Platform.Services.StopPoliciesService.ExecuteDeadlineCallbacksAsync() -> System.Threading.Tasks.Task! | ||
| Microsoft.Testing.Platform.Services.StopPoliciesService.RevertDeadlineTrigger() -> void |
| // Only now tell the user. This sits after the commit rather than before it so that the claim is | ||
| // not held across this await, and it is still reached only when the deadline actually won the | ||
| // race, so the message is never printed for a run that finished on its own. | ||
| await TryReportAsync( | ||
| () => _outputDevice.DisplayAsync( | ||
| this, | ||
| new FormattedTextOutputDeviceData(PlatformResources.AbortAtDeadlineMessage), | ||
| _cancellationTokenSource.CancellationToken), | ||
| "Failed to report the approaching deadline.").ConfigureAwait(false); | ||
|
|
||
| await capability.StopTestExecutionAsync(_cancellationTokenSource.CancellationToken).ConfigureAwait(false); | ||
| stopAccepted = true; |
| // deadline dump would be taken but never surfaced as an artifact. | ||
| try | ||
| { | ||
| await _logger.LogDebugAsync($"Wait for test host connection to the server pipe '{_singleConnectionNamedPipeServer.PipeName.Name}'").ConfigureAwait(false); |
| catch | ||
| { | ||
| // The logger is the thing that failed, so there is nothing left to report to. Swallowing is | ||
| // the whole point: the caller must continue to the dump and the process tree kill. | ||
| } |
CI hard-cancels a job at a fixed wall-clock time. When it fires the runner kills the test process, and we lose the TRX/HTML/AzDO reports and get no dump for a hanging test. This is a prototype that tells MTP when that deadline is, so it can react a little early while it still owns its shutdown.
What it does
The deadline arrives as an environment variable, an absolute instant:
TESTINGPLATFORM_DEADLINE: ISO 8601, parsed to UTC.TESTINGPLATFORM_DEADLINE_STOP_MARGIN: lead time for the graceful stop. Default 60s.TESTINGPLATFORM_DEADLINE_DUMP_MARGIN: lead time for the hang dump. Default 30s.Two reactions, armed off the same instant:
deadline - stopMarginan in-process extension asks the framework to gracefully stop scheduling new tests (IGracefulStopTestExecutionCapability). In-flight tests finish, the session ends normally, and every reporter gets to finalize.deadline - dumpMarginthe out-of-process HangDump controller dumps the process tree and kills the host. This is the fallback for a wedged host that never reaches the graceful stop. It reuses the controller that HangDump already runs, so it costs nothing extra when--hangdumpis on.stopMargin > dumpMarginon purpose: try the clean stop first, dump only if that did not happen in time.It is opt-in. No deadline set, both timers stay unarmed, no behavior change. The graceful stop also degrades to a no-op if the framework does not expose the capability.
Where the deadline comes from
Out of scope here, that is the "solve timing later" part. MTP has no hardcoded timeout, it only reacts to whatever instant the environment gives it. The CI side is a few lines of YAML that compute
job start + timeoutand export it. Rough sketches:GitHub Actions (job has
timeout-minutes: 60):Azure DevOps (job has
timeoutInMinutes: 60):Both approximate "now" as the job start, which is close enough. The nicer version is Arcade computing the real deadline once and exporting it for everyone, so nobody has to copy YAML around. Left for a follow-up.
Verified
build.cmd -packpasses 0/0. New acceptance tests:AbortAtDeadlineTests(5): past deadline stops immediately, future deadline stops when the timer fires, the stop margin is subtracted from the deadline, no deadline stays silent, missing capability is a no-op.HangDumpTests.HangDump_AbsoluteDeadline_CreateDump(3 tfms): 30 minute inactivity timeout so only the deadline path can fire, and it produces a dump. FullHangDumpTestsstill 30/30.This is a prototype, so LMK what you think about the shape before I polish it. Open questions: