`Console.CancelKeyPress` race condition causes deadlock
- 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
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