Skip to content

Deadline-aware cancellation (prototype) - #10018

Draft
Jakub Jareš (nohwnd) wants to merge 26 commits into
mainfrom
nohwnd-deadline-aware-cancellation
Draft

Deadline-aware cancellation (prototype)#10018
Jakub Jareš (nohwnd) wants to merge 26 commits into
mainfrom
nohwnd-deadline-aware-cancellation

Conversation

@nohwnd

Copy link
Copy Markdown
Member

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:

  • At deadline - stopMargin an 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.
  • At deadline - dumpMargin the 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 --hangdump is on.

stopMargin > dumpMargin on 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 + timeout and export it. Rough sketches:

GitHub Actions (job has timeout-minutes: 60):

- name: Compute deadline
  shell: pwsh
  run: echo "TESTINGPLATFORM_DEADLINE=$((Get-Date).ToUniversalTime().AddMinutes(55).ToString('o'))" >> $env:GITHUB_ENV

Azure DevOps (job has timeoutInMinutes: 60):

- pwsh: echo "##vso[task.setvariable variable=TESTINGPLATFORM_DEADLINE]$((Get-Date).ToUniversalTime().AddMinutes(55).ToString('o'))"
  displayName: Compute deadline

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 -pack passes 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. Full HangDumpTests still 30/30.

This is a prototype, so LMK what you think about the shape before I polish it. Open questions:

  • Names for the env vars and the margins.
  • Whether the dump-at-deadline message should say "deadline" instead of reusing the inactivity-timeout text.

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>
Copilot AI balanced review requested due to automatic review settings July 16, 2026 15:29

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.

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 AbortAtDeadlineExtension that schedules a timer to request IGracefulStopTestExecutionCapability.
  • 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.

Comment thread src/Platform/Microsoft.Testing.Platform/Extensions/AbortAtDeadlineExtension.cs Outdated
Comment thread src/Platform/Microsoft.Testing.Platform/Helpers/DeadlineHelper.cs Outdated
Jakub Jareš (nohwnd) and others added 2 commits July 16, 2026 17:51
…-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>
Copilot AI review requested due to automatic review settings July 16, 2026 16:03

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.

Pull request overview

Copilot reviewed 28 out of 28 changed files in this pull request and generated 4 comments.

Comment thread src/Platform/Microsoft.Testing.Platform/Extensions/AbortAtDeadlineExtension.cs Outdated
Jakub Jareš (nohwnd) and others added 2 commits July 20, 2026 13:22
…-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>
Copilot AI review requested due to automatic review settings July 20, 2026 11:45

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.

Pull request overview

Copilot reviewed 28 out of 28 changed files in this pull request and generated 7 comments.

Comment thread src/Platform/Microsoft.Testing.Platform/Extensions/AbortAtDeadlineExtension.cs Outdated
Comment thread src/Platform/Microsoft.Testing.Platform/Extensions/AbortAtDeadlineExtension.cs Outdated
Comment thread src/Platform/Microsoft.Testing.Platform/Extensions/AbortAtDeadlineExtension.cs Outdated
Comment thread src/Platform/Microsoft.Testing.Platform/Extensions/AbortAtDeadlineExtension.cs Outdated
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>
Copilot AI review requested due to automatic review settings July 22, 2026 06:41

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.

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.RequestReplyAsync waits 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);

Comment thread src/Platform/Microsoft.Testing.Platform/Extensions/AbortAtDeadlineExtension.cs Outdated
Comment thread src/Platform/Microsoft.Testing.Platform/Extensions/AbortAtDeadlineExtension.cs Outdated
Comment thread src/Platform/Microsoft.Testing.Platform/Helpers/DeadlineHelper.cs

@Evangelink Amaury Levé (Evangelink) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Comment thread src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.Framework.cs Outdated
Comment thread src/Platform/Microsoft.Testing.Platform/Helpers/DeadlineHelper.cs
Comment thread src/Platform/Microsoft.Testing.Platform/Helpers/DeadlineHelper.cs
Comment thread src/Platform/Microsoft.Testing.Platform/Extensions/AbortAtDeadlineExtension.cs Outdated
Comment thread src/Platform/Microsoft.Testing.Platform/Helpers/DeadlineHelper.cs
// 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"),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

🤖

Jakub Jareš (nohwnd) and others added 3 commits August 4, 2026 13:24
…-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
Copilot AI review requested due to automatic review settings August 4, 2026 12:23

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.

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 reach WaitForExitAsync/OnTestHostProcessExitedAsync to 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);

Comment thread src/Platform/Microsoft.Testing.Platform/Extensions/AbortAtDeadlineExtension.cs Outdated
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
Copilot AI review requested due to automatic review settings August 4, 2026 13:56

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.

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 TakeDumpAsync is 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/finally that 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-guaranteeing finally.
        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,

Comment thread src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.Framework.cs Outdated
Jakub Jareš (nohwnd) and others added 7 commits August 18, 2026 07:56
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>
Copilot AI review requested due to automatic review settings August 18, 2026 12:22

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.

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 sets IsTestExecutionCompleted, 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-67 requires charset = utf-8-bom for 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.TryParse accepts 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_MentionsRelevantOption covers 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>
Copilot AI review requested due to automatic review settings August 18, 2026 12:53

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.

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 call NotifyTestExecutionCompleted() before this line sets IsDeadlineTriggered; 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 while StopTestExecutionAsync may 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>

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.

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 awaits OnTestHostProcessStartedAsync before it starts WaitForExitAsync/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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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>
Copilot AI review requested due to automatic review settings August 18, 2026 14:18
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>

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.

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 to finally, 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
Comment on lines +382 to +393
// 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);
Comment on lines +541 to +545
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.
}
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