Skip to content

CTRL+C does not complete the test run when there is long running test #5345

Description

@nohwnd

Describe the bug

Running a project with test that takes long time or infinite time will not return control to the user in timely manner when they press Ctrl+C.

MTP does cooperative cancellation, and leaves the details to the test framework, and MSTest (and NUnit) do block the returning on main method, until the currently running test completes.

Steps To Reproduce

Have single test that sleeps for 10s.
Run tests as exe and cancel by pressing Ctrl+C.
Cancelling test session message is shown, but we still wait 10s for the test to finish.

Image

Image

Expected behavior

Run is cancelled / aborted.

Actual behavior

Described above.

Additional context

To solve this we agreed to implement a 2 stage approach. When user presses CTRL+C we will start cancelling the session. This is up to the test framework to handle. MTP will only cooperatively cancel the framework, and if the framework does not return control we cannot do anything.

The framework is free to abandon the test work, and continue to cleanup. To ensure fast cancellation. And cleanup.

We will also output message to Press Ctrl+C again to abort.

When user does that we will kill the process, to ensure timely return of control. But we won't guarantee any cleanup.

Activity

  1. nohwnd commented on Mar 31, 2025

    @nohwnd
    MemberAuthor

    attempts at roughly fixing this in MTP and mstest here #5267 and here #5288

    Work needed is to connect ctrl+c to the test session cancellation for the cancel, and to env failfast for abort.

  2. bart-vmware commented on Dec 15, 2025

    @bart-vmware

    Not sure if this is related, but when I run all tests locally (xUnit v3, 1000+ tests, multi-targeted, many projects) and press Ctrl+C, nothing terminates. I don't know if it helps, but I have to press Ctrl+C like ten times in a row to get back to the command prompt.

    This only happens with MTP. VSTest does not have this problem. I can imagine that cancelling a CI job faces similar problems (we auto-cancel existing jobs for a PR/branch when a new commit is pushed).

    From my point of view, interpreting Ctrl+C as a cancellation request is not intuitive. It is supposed to work as a hard break, similar to pressing Stop in a debugger: kill whatever was running and exit immediately.

  3. nohwnd commented on Dec 16, 2025

    @nohwnd
    MemberAuthor

    Yes, this seems like the same problem. In the description I tried to remedy this with 2 different approaches. But none of them were accepted in the code base.

    Problem of why it hangs is described here: #5288 (comment)

  4. nohwnd commented on Dec 16, 2025

    @nohwnd
    MemberAuthor

    Something somewhere needs to break the cooperative cancellation chain (otherwise we will wait forever for code that is not cooperatively cancelling), and based on the feedback in the PRs this is better done in the test framework, than in MTP. I don't disagree, it is better for the framework to choose the behavior than us forcing it. So this probably needs to flow better into xunit, and there the long running task that holds up the cancellation needs to be abandoned to the background and control returned back to the platform.

  5. bart-vmware commented on Dec 16, 2025

    @bart-vmware

    I'm not sure I fully understand, but waiting for a cancellation token is only reliable when everything fully supports cancellation. That is often not the case.

    We pass TestContext.Current.CancellationToken to all async calls in all tests. But we also have some code-under-test that performs sync-over-async without a token. The reason is that IConfigurationProvider.Load has no async equivalent and doesn't accept a token. So if our configuration provider performs a network call and DNS is slow, it can take up to five seconds to break out (we often experience this on macOS). Or 30 seconds before the HTTP request times out.

    From my point of view, a Ctrl+C should just call something like Environment.FailFast, instead of trying a graceful shutdown.

  6. nohwnd commented on Dec 16, 2025

    @nohwnd
    MemberAuthor

    Yeah, we are saying the same thing. 👍

    My two PRs above implement something similar to stopping ungracefully. But a better place to put that code is in the framework and it's handling of cancellation code that does not cooperate. So the platform does not force a (theoretical) framework that is cooperative cancellation only, to abort threads and exit forcefully.

  7. Evangelink commented on Dec 16, 2025

    @Evangelink
    Member

    I am not against the Ctrl+C to do a fail fast on MTP, my main concern was and is only that there is always someone that wants to cancel fast but still have the trx, coverage or whatever report.

    My point is to say we either cancel and assume everything cooperates or we terminate but we don't do some kind of graceful termination where we try to kill some stuff after some time so we can try to continue (we saw the number of issues related to this design on VSTest).

    I think Jakub Jareš (@nohwnd) has/had one draft PR with first Ctrl +C triggering cancellation and second doing strong abort and this design is fine by me.

  8. nohwnd commented on Dec 18, 2025

    @nohwnd
    MemberAuthor

    Okay I might have been misremembering. The thing we agreed on is actually described in Additional context in the issue above. Let's do that first step then :)

    As I think I detailed in other places:

    In my ideal world the cancellation would have multiple stages, it would notify the framework to start tearing down.

    Framework could ask for additional time to tear down (e.g. when it wants to do additional cleanup via async calls, like playwright, by sending "cancellation ack, ETA 10s" ). This would be communicated to the user, e.g. Playwright is cancelling... After 10s we would see if Playwright asked for more time or not.

    I don't think that is what currently happens in the platform, when cancellation happens, the test starts tearing down, and also the platform starts tearing down, giving no grace period to either the framework or the extensions to clean up.

    Details of this are blurry for me though, and it is realized via cancellation tokens which as not easy to track where they propagate for this kind of flow.

  9. bart-vmware commented on Dec 18, 2025

    @bart-vmware

    Perhaps have two key bindings then: one for hard-stop and one for request-abort?

    In the ReSharper Unit Test Sessions view in VS, there are actually two buttons: one to cancel the testrun, which doesn't always respond immediately. And there's a hard kill button, similar to the debugger-stop button. Usually, the first button is sufficient. But sometimes it never completes, so we need the second one. How it works internally, I don't know. If you're not already in contact with the R# team and want details on how that works, I can reach out to them.

  10. nohwnd commented on Dec 18, 2025

    @nohwnd
    MemberAuthor

    We discussed that as well, but people usually understand ctrl+c as abort, and aborting right away is not great experience here, because cleanups won't run.

    We have similar gestures in vstest as well, cancel and abort (but VS only aborts, because cancellation takes time and makes it look stuck - to me this is not great experience either, and is often source of complaints and confusion)

  11. Evangelink commented on May 25, 2026

    @Evangelink
    Member

    Posting this as a follow-up to my earlier comment and to give #8580 some design context.

    RFC: Phased graceful shutdown for Microsoft.Testing.Platform

    Tracks: #5345 · Related prior attempts: #5267, #5288 · Status: Draft

    Summary

    Replace MTP's single-shot CancellationToken model with a two-phase
    shutdown
    (Draining → Aborting) bounded by a configurable grace ceiling, and
    align the Ctrl+C UX with the convention used by docker compose, kubectl,
    npm, vitest, and dotnet watch: first press = cooperative cancel, second
    press = abort, third press = terminate.

    This RFC describes Design A only. A follow-up RFC will cover an opt-in
    IShutdownParticipant interface for extension-side ETA negotiation
    (Design B). An IPC/server-mode variant (Design C) is deferred.

    Motivation

    Today MTP has exactly one cancellation signal: a CancellationTokenSource
    flipped by Ctrl+C
    (CTRLPlusCCancellationTokenSource).
    Every participant — framework, reporters, dump extensions, retry,
    crash/hang dump — observes the same token at the same time. This causes
    three recurring failure modes that show up across MSTest, xUnit v3, and
    several CI scenarios:

    1. Shutdown hangs forever. When a framework's cancellation is only
      partly cooperative (e.g. xUnit v3 sync-over-async in
      IConfigurationProvider.Load, blocking DNS calls), Ctrl+C does
      nothing and the user has to press it 10× to recover. Reported on
      CTRL+C does not complete the test run when there is long running test #5345 by external users.
    2. Lost reports. Conversely, when shutdown does propagate, every
      participant tears down simultaneously — there is no window to flush
      TRX, coverage, AzureDevOps artifacts, or crash dumps. Users
      experience either-or behaviour and cannot choose.
    3. No observability. The user sees the cursor sit. They cannot tell
      whether the platform is making progress, which extension is delaying
      shutdown, or how long to wait before force-killing.

    The previous attempts at "make it harder/faster" (#5267, #5288)
    correctly identified the trade-off but did not give the test framework
    or extensions a contract to participate in. This RFC proposes that
    contract.

    Goals

    • Ctrl+C reliably exits within a bounded time, even when frameworks
      don't perfectly cooperate.
    • Well-behaved reporters get a chance to flush their output.
    • The user sees what's happening and knows how to escape.
    • CI builds get a deterministic shutdown ceiling they can configure.
    • No breaking change to existing extensions — the new contract is
      additive.

    Non-goals

    • Per-extension ETA negotiation (Design B follow-up).
    • Cross-process / IPC shutdown messaging (Design C follow-up).
    • Replacing CancellationToken semantics with something exotic — we
      layer phases on top of standard tokens.

    Design

    Lifecycle phases

    RUNNING ──Ctrl+C/cancel──▶ DRAINING ──grace elapsed──▶ ABORTING ──abort elapsed──▶ EXITED
                           ▲                            ▲                          ▲
                       2nd Ctrl+C ────────────────────┘                            │
                                                                  3rd Ctrl+C ──────┘
                                                                  (Environment.FailFast)
    
    Phase Meaning Triggered by
    Running Normal test execution. —
    Draining Stop accepting new work; finish in-flight; flush reporters. 1st Ctrl+C, --shutdown-grace-period reached
    Aborting Stop everything cooperative; reporters should bail out quickly. 2nd Ctrl+C, grace period elapsed
    Exited Host shut down. natural exit, or 3rd Ctrl+C / abort elapsed

    Two tokens

    The existing ITestApplicationCancellationTokenSource gains two named
    tokens. The existing CancellationToken property keeps its current
    meaning (aliased to DrainingToken for source compatibility — no
    existing consumer changes behaviour).

    public interface ITestApplicationCancellationTokenSource
    {
        /// <summary>
        /// Existing API. Equivalent to <see cref="DrainingToken"/>.
        /// Signalled when the platform enters the Draining phase.
        /// </summary>
        CancellationToken CancellationToken { get; }
    
        /// <summary>
        /// Signalled when entering Draining. Honour it by stopping the
        /// dispatch of new work and flushing in-flight state. You may take
        /// up to <c>--shutdown-grace-period</c> to finish.
        /// </summary>
        CancellationToken DrainingToken { get; }
    
        /// <summary>
        /// Signalled when entering Aborting. Honour it by bailing out of
        /// long-running work as fast as you can. You may take up to
        /// <c>--shutdown-abort-timeout</c> to finish; after that the host
        /// will terminate.
        /// </summary>
        CancellationToken AbortingToken { get; }
    
        void Cancel();
        void CancelAfter(TimeSpan timeout);
    }

    Internally, DrainingToken is the source of truth for "user wanted to
    stop." AbortingToken is a linked token derived from DrainingToken

    • a delay (shutdownGracePeriod) + a 2nd-Ctrl+C signal — whichever
      trips first.

    CLI options

    Option Default Meaning
    --shutdown-grace-period 30s Max time spent in Draining before auto-escalating to Aborting. Matches HostOptions.ShutdownTimeout.
    --shutdown-abort-timeout 10s Max time spent in Aborting before the host force-terminates.

    Both accept a TimeSpan (e.g. 30s, 2m, 00:01:30). Both have
    config-file equivalents under a new platformOptions:shutdown section.

    Ctrl+C UX

    ^C  Cancelling test session… (press Ctrl+C again to force quit)
    ^C  Aborting test session… (press Ctrl+C again to terminate immediately)
    ^C  Terminated.
    

    The status line for Draining/Aborting is written by the existing
    TerminalOutputDevice; in non-interactive output modes (CI logs,
    JSON-RPC) it becomes a structured log event so consumers can render it
    their way.

    Behavioural rules

    1. Entering Draining is idempotent. Multiple cancel sources
      (Ctrl+C, --treenode-filter early exit, framework error, server
      request) all converge.
    2. Entering Aborting is idempotent and irreversible. Once aborting,
      you cannot return to draining.
    3. The grace timer starts when Draining is entered and is not
      restartable
      in Design A. (Restartable / per-extension extension
      = Design B.)
    4. Environment.FailFast happens only on the 3rd Ctrl+C or if the
      abort timer also elapses. The platform itself never silently
      FailFasts.
    5. Extensions that observe AbortingToken and exit promptly cause no
      FailFast.
    6. Existing code that observes the old CancellationToken is
      automatically treated as observing DrainingToken — no source
      change required.

    Host integration

    • CommonTestHost owns the phase state machine.
    • TestHostBuilder.CommonServices wires the new tokens into the
      service container alongside the existing source.
    • TestSessionLifetimeHandlersContainer.OnTestSessionFinishingAsync is
      invoked with DrainingToken (so reporters can still flush) and a
      parallel AbortingToken is exposed via ITestSessionContext for the
      rare extension that wants to differentiate.
    • TestHostOchestratorHost / TestHostControllersTestHost propagate
      both tokens to controlled hosts via env vars
      (MTP_SHUTDOWN_GRACE_PERIOD_MS, MTP_SHUTDOWN_ABORT_TIMEOUT_MS) so
      child processes share the same budget.

    Compatibility

    • Source-compatible. ITestApplicationCancellationTokenSource
      gains two members. We will mark the new members as
      [EditorBrowsable(Never)] for one release to keep IntelliSense
      clean during migration.
    • Binary-compatible. Interface gains default-implementation
      members; existing implementers (mocks, test infrastructure) are
      unaffected.
    • Behavioural change: the platform now waits up to
      --shutdown-grace-period before bailing. Today it waits forever in
      some cases and ~instantly in others. The new default (30s grace + 10s
      abort) is a strict improvement for both the "waits forever" and
      "kills reports" failure modes, but CI maintainers who rely on
      "Ctrl+C = immediate exit" will need to set
      --shutdown-grace-period 0s.

    Alternatives considered

    1. Single-token + escalation flag. Reuse the current token; add a
      bool IsAborting on the source. Rejected because consumers cannot
      subscribe to the transition with standard CancellationToken
      primitives (Register, WaitHandle, WhenCanceled).
    2. Three tokens (Cancelling / Aborting / Terminating). Considered,
      but Terminating cannot be honoured — at that point the host is
      FailFasting and Register callbacks won't run. Two tokens is the
      right shape.
    3. Negotiated ETA in this RFC. Defers cleaner UX, but adds API
      surface that we'd have to live with forever. Better to ship the
      floor (Design A) and prove the need for Design B with concrete
      extension demand (Playwright bridge, large-trace coverage).

    Prior art

    System Notify Negotiation Hard kill
    Kubernetes preStop → SIGTERM terminationGracePeriodSeconds SIGKILL
    systemd SIGTERM sd_notify(EXTEND_TIMEOUT_USEC) SIGKILL
    AWS ECS SIGTERM stopTimeout 30–120s SIGKILL
    AWS Lambda Extensions SHUTDOWN event + deadlineMs None (fixed 2s) runtime
    macOS AppKit applicationShouldTerminate: NSTerminateLater + reply OS kill
    Windows SCM SERVICE_CONTROL_PRESHUTDOWN dwPreshutdownTimeout process
    Erlang/OTP terminate/2 shutdown strategy brutal_kill
    .NET Generic Host IHostApplicationLifetime.StopAsync HostOptions.ShutdownTimeout (default 30s) dispose
    Vitest SIGINT teardownTimeout (default 10s) process exit
    npm / docker compose / k9s 1st SIGINT cancel, 2nd kill — process exit
    ReSharper Test Sessions Stop button (cooperative) Kill button (separate) process kill

    Design A is the Kubernetes / .NET Generic Host shape: bounded
    ceiling, no per-participant negotiation. Design B is the systemd
    EXTEND_TIMEOUT_USEC
    shape, layered on top.

    Rollout

    1. Land the token + phase machinery behind feature flag
      --internal-experimental-phased-shutdown (off by default).
    2. Wire the existing extensions (TRX, coverage, dump) to differentiate
      Draining vs Aborting where it matters; verify behaviour with
      acceptance tests.
    3. Flip the default on for one preview release.
    4. Remove the flag.

    Open questions

    • Should the grace ceiling be inherited from TestContext-style
      configuration on the framework, or only platform-side? Today, MSTest
      ignores platform timeouts. Probably worth a separate discussion.
    • For server-mode, does the client need a way to initiate the phase
      transition (e.g. IDE "Stop" button)? Probably yes, and that's the
      natural intro point for Design C.
    • Should --shutdown-grace-period 0s short-circuit straight to
      Aborting, or is "0s means no grace" too surprising? Suggest the
      former with a doc note.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/mstestMSTest framework (not analyzers or assertions).area/mtpMicrosoft.Testing.Platform core library.

    Type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions