dotnet / dotnet/runtime

`Console.CancelKeyPress` race condition causes deadlock

Open
#129,983 4 comments 0 reactions 0 assignees View on GitHub
area-System.Console needs-further-triage
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Description

### Description

On Windows, removing the **last** `Console.CancelKeyPress` handler deadlocks permanently if a `CancelKeyPress`
handler is currently being invoked on another thread.

Unsubscribing the last handler disposes the underlying `PosixSignalRegistration`, whose `Unregister()` calls
`SetConsoleCtrlHandler(handler, Add: false)`. That call is made **while holding `Console`'s internal lock**, and
on Windows it blocks until all in-flight console control handler routines have returned. But the in-flight
handler routine cannot make progress (in the repro it is waiting on the unsubscribing thread; in the real-world
case below it is blocked acquiring the same `Console` lock), so:

* the unsubscribe waits for the in-flight handler to drain, and
* the in-flight handler never completes,

= a permanent deadlock. Worse, because the unsubscribing thread is holding `Console`'s lock the entire time,
**every subsequent console operation in the process hangs too** — there is no way to recover, and a force-abort
via further Ctrl+C cannot help.

### Reproduction Steps

Run this code, press ctrl+c.

```csharp
using System;
using System.Threading;

// Uncomment this to NOT hang: keeping one permanent handler subscribed means the `-=` below never removes
// the *last* handler, so SetConsoleCtrlHandler(.., Add:false) is never called and there is no deadlock.
// Console.CancelKeyPress += static (_, _) => { };

var unsubscribeEvent = new ManualResetEventSlim(false);
var completeEvent = new ManualResetEventSlim(false);
ConsoleCancelEventHandler? handler = null;

// A second thread removes the last handler, but only once a handler is actually in-flight.
var unsubscribe = new Thread(() =>
{
unsubscribeEvent.Wait();
Console.WriteLine("[unsubscribe] calling Console.CancelKeyPress -= handler ...");
Console.CancelKeyPress -= handler!; // removes the LAST handler; hangs here forever on Windows
Console.WriteLine("[unsubscribe] returned"); // reached only if the runtime does NOT deadlock
}) { IsBackground = true, Name = "unsubscribe" };
unsubscribe.Start();

handler = (sender, e) =>
{
e.Cancel = true; // handle it so the process is not terminated
Console.WriteLine("[handler] running on the dispatch thread; asking another thread to unsubscribe...");
unsubscribeEvent.Set();
unsubscribe.Join(); // wait for the unsubscribe to complete (it never does)
Console.WriteLine("[handler] returned");
completeEvent.Set();
};
Console.CancelKeyPress += handler; // the one and only handler

Console.WriteLine($"PID {Environment.ProcessId}: press Ctrl+C once. Bug = hang; correct = '[unsubscribe] returned' then exit.");
completeEvent.Wait();
```

### Expected behavior

`[handler] running ...`, `[unsubscribe] calling ... -=`, `[unsubscribe] returned`, `[handler] returned`, then the process exits.

### Actual behavior

it prints `[handler] running ...` / `[unsubscribe] calling ... -=` and then hangs forever; the process must be killed.

### Regression?

No (repro in net10.0 and net481)

### Known Workarounds

Add a persistent `Console.CancelKeyPress` listener.

### Configuration

.Net 10.0.301
Windows 10
x64

### Other information

> Uncommenting the one-line permanent handler at the top makes the hang disappear — confirming the trigger is
> specifically the removal of the *last* handler.
>
> The cross-thread `Join()` is only there to make the in-flight window deterministic from a single key press.
> In the wild this overlap happens naturally when Ctrl+C is pressed repeatedly (see below) — no artificial wait
> is needed; one handler invocation just needs to still be on the stack when another thread removes the last
> handler.

### Deadlocked call stack

The unsubscribing thread is parked in the runtime, never to return:

```
System.Runtime.InteropServices.PosixSignalRegistration.Unregister() // -> SetConsoleCtrlHandler(.., Add:false)
System.Runtime.InteropServices.PosixSignalRegistration.Dispose()
System.Console.remove_CancelKeyPress(ConsoleCancelEventHandler)

```

### Real-world impact

This is the root cause of [dotnet/BenchmarkDotNet#3181](https://github.com/dotnet/BenchmarkDotNet/issues/3181):
pressing Ctrl+C several times while a benchmark run is starting freezes the process 100% of the time on Windows.
BenchmarkDotNet subscribes/unsubscribes `Console.CancelKeyPress` from several helpers over a run; when a burst of
Ctrl+C events is dispatched, one thread ends up removing the last handler (via `SetConsoleCtrlHandler(remove)`,
holding the `Console` lock) while another control event's dispatch is blocked acquiring that same lock — the exact
deadlock above. We worked around it by keeping one permanent no-op handler subscribed so the count never reaches
zero, but the underlying runtime behavior (a blocking, drain-waiting `SetConsoleCtrlHandler` call made under the
`Console` lock) seems worth fixing.

### Notes / possible direction

The problematic combination is **(1)** holding `Console`'s lock across **(2)** a `SetConsoleCtrlHandler(remove)`
call that synchronously waits for in-flight handler routines. Either not holding the lock across that OS call, or
not having handler dispatch contend on the same lock that unsubscription holds, would break the cycle.

[Edit] I'm unsure if this is windows-specific, that's just where it was observed.

Contributor guide

Open the contributing guide

Research direction

Start by running the Windows reproduction and inspect Console.CancelKeyPress, PosixSignalRegistration.Unregister(), and SetConsoleCtrlHandler(remove) while the last handler is removed. Trace the lock and in-flight handler interaction; done means the unsubscribe returns, the handler completes, and subsequent console operations do not hang.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
operating-systems
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.