Skip to content

fix: exit through the normal shutdown path on Ctrl+C [patch] - #117

Merged
matt-edmondson merged 5 commits into
mainfrom
fix/110-ctrl-c-exit-path
Sep 16, 2026
Merged

matt-edmondson merged 5 commits into
mainfrom
fix/110-ctrl-c-exit-path

Conversation

@matt-edmondson

@matt-edmondson matt-edmondson commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #110

The problem

UIApplication.RunAsync() hides the cursor on startup and only restores it in its finally block. Ctrl+C never reached that block: .NET treats it as a process interrupt and terminates the process first, so the user was left with an invisible cursor in their terminal after the process exited — terminal state damage that outlives the program.

As the issue notes, SpectreConsoleProvider.ReadInputAsync() already had a Ctrl+C branch, but it was unreachable: Console.ReadKey never sees Ctrl+C unless TreatControlCAsInput is set. The intent was there; the enabling half was not.

The fix

Two halves, both needed for Ctrl+C to actually exit cleanly:

1. Take the signal. A new internal ConsoleInterruptSource hooks Console.CancelKeyPress and a PosixSignalRegistration for SIGTERM, cancels the runtime's default "terminate now" behaviour, and calls Shutdown(). The existing teardown then restores the cursor. RunAsync registers it for the duration of a run and releases it as the run ends, so a second Ctrl+C arriving during teardown still gets the default behaviour rather than a second shutdown.

Following the triage note, this uses CancelKeyPress rather than TreatControlCAsInput: the event is observed wherever the application is, not only while it happens to be inside a blocking read. SIGTERM is taken for the same reason. Registration is guarded against PlatformNotSupportedException, so a platform without SIGTERM still gets Ctrl+C.

Both delivery mechanisms funnel through one OnSignal(cancelDefaultTermination, onInterrupt). Cancelling comes first and the ordering is asserted: notifying runs application shutdown code, and until the default response is cancelled the runtime is still entitled to kill the process partway through it — which is the failure this PR exists to stop.

2. Let cancellation reach a blocked read. ProcessInputAsync awaited ConsoleProvider.ReadInputAsync() directly. A provider parked in Console.ReadKey does not observe the token, so cancelling only took effect once the user pressed an unrelated key — meaning Shutdown() alone would not have ended the run. The read is now abandoned when the token fires. This fixes the same latency for the CancellationToken callers pass to RunAsync.

The previously-dead branch in ReadInputAsync is kept and commented: it is now correct for a host that has set TreatControlCAsInput itself.

Why an internal seam, not a public contract

IInterruptSource is internal (TUI.Core already has InternalsVisibleTo ktsu.TUI.Test). The real signals cannot be raised from a test without signalling — and very likely terminating — the test host, so the seam exists to make the handling testable. No public API changes, hence [patch].

Tests

TUI.Test/UIApplicationLifecycleTests.cs covers the run lifecycle, using a BlockingConsoleProvider whose read does not complete until the test releases it — standing in for a real provider parked in Console.ReadKey, which is the situation the bug depended on.

Every wait in the class is bounded by a 10s timeout. That is deliberate: the behaviour under test is an application that fails to notice a shutdown request, so an unbounded wait would hang CI on a regression instead of reporting one. (An earlier draft did exactly that.)

Verified the tests fail without the fix. With the two production changes reverted (the new types kept so it still compiles), 4 of the then-7 new tests failed at the timeout and 3 passed:

failed ShutdownEndsARunBlockedOnInput (10s)
failed InterruptSignalEndsTheRunAndRestoresTheCursor (10s)
failed TheInterruptRegistrationIsReleasedWhenTheRunEnds (10s)
failed CancellingTheRunTokenEndsARunBlockedOnInput (10s)
  total: 7   failed: 4   succeeded: 3

The 3 that still passed are the ordinary exit-input path (unchanged by this PR) and the two ConsoleInterruptSource unit tests — correct, since neither depends on the reverted wiring.

Full suite: 129/129 pass. dotnet build TUI.sln is clean across net10.0;net9.0;net8.0.

Real signals, end to end. Unit tests use a fake source, so the actual signal plumbing was checked separately with a throwaway harness built against the real ConsoleInterruptSource. Both SIGINT and SIGTERM reach the callback and the process stays alive rather than being terminated by the runtime:

READY
INTERRUPT CALLBACK FIRED (1)
STILL ALIVE AFTER SIGNAL - process was not terminated by the runtime

This was re-run after the OnSignal refactor, since that commit changed exactly the code the unit tests cannot reach.

Coverage

The first push failed SonarCloud's gate at 69.7% coverage on new code (required ≥ 80%). Measuring locally — git diff origin/main intersected with a cobertura report, the same thing the gate computes — showed the gap was concentrated in the two near-identical signal-handler bodies, which is what motivated consolidating them into OnSignal. Now 90.3% coverage on new code, 0% duplication, 0 new issues, 0 security hotspots.

Four lines remain uncovered: the SIGTERM callback body and the catch (PlatformNotSupportedException) guard. Covering them needs either a real signal delivered to the test host — which risks killing the test run — or a platform that rejects SIGTERM. Making the registration injectable would fake both, but that is a second seam existing only to move a metric, and the guard is genuine defensive code, so I left it.

One thing that is not verified here: driving TUI.App under a pseudo-terminal was inconclusive, because SetCursorVisibility issues a cursor-position query (ESC[6n) that nothing in a headless container answers, so the app never gets past hiding the cursor. That is an environment limitation, unrelated to this change — the acceptance criterion is covered by the lifecycle tests plus the signal harness above.

Merge with main

#113 (for #109) and #118 (for #114) landed while this was open, so main was merged in (a8c808e). UIApplication.cs merged cleanly — #113 rewrote Render's dirty-tracking model, which this branch does not touch.

UIApplicationTests.cs conflicted because both branches filled what had been an empty file, with two unrelated sets of tests. Neither side is redundant, so main's render-loop tests stay in UIApplicationTests.cs unchanged and this branch's run-lifecycle tests moved to UIApplicationLifecycleTests.cs — matching how the suite already splits BorderElementTests from BorderElementRenderTests.

🤖 Generated with Claude Code

https://claude.ai/code/session_01671tcpA4zkfbgPcJm8cTsB

UIApplication hid the cursor on startup and only restored it in RunAsync's
finally block. Ctrl+C never reached that block: the runtime treats it as a
process interrupt and terminated the process first, so the user was left with
an invisible cursor in their terminal after the process exited.

Take Ctrl+C and SIGTERM for the duration of a run through a new
ConsoleInterruptSource, which cancels the runtime's default "terminate now"
behaviour and calls Shutdown() instead, letting the existing teardown restore
the terminal. The registration is released as the run ends, so a second Ctrl+C
during teardown still gets the default behaviour.

Cancellation also had to reach a blocked read. ProcessInputAsync awaited the
provider directly, and a provider parked in Console.ReadKey does not observe
the token, so a shutdown request only took effect once the user pressed an
unrelated key. The read is now abandoned when the token fires, which fixes the
same latency for the CancellationToken passed to RunAsync.

The interrupt source is an internal seam rather than a public contract: the
real signals cannot be raised from a test without signalling the test host.
UIApplicationTests was an empty file and now covers the run lifecycle -- every
wait in it is bounded, because the behaviour under test is an application that
fails to notice a shutdown request and an unbounded wait would hang CI on a
regression instead of reporting one.

Fixes #110

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671tcpA4zkfbgPcJm8cTsB
Comment thread TUI.Test/BlockingConsoleProvider.cs Fixed
…tch]

Volatile.Read/Write over a plain field was more ceremony than the situation
needs, and it left the field looking mutable-for-no-reason to analysers. A
volatile bool gives the same cross-thread visibility in one declaration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671tcpA4zkfbgPcJm8cTsB
SonarCloud's quality gate failed the PR at 69.7% coverage on new code
against a required 80%. Measuring locally rather than guessing showed the
gap was concentrated in ConsoleInterruptSource: two near-identical signal
handler bodies that no test could reach, because neither
ConsoleCancelEventArgs nor PosixSignalContext can be constructed by a test.

Both handlers now funnel through one OnSignal method, so the response to a
signal is stated once and is assertable separately from the delivery of one.
The test asserts the ordering rather than just the calls: notifying runs
application shutdown code, and until the default response is cancelled the
runtime is still entitled to kill the process partway through it, so
cancelling has to come first.

Also asserts the interrupt is logged, via a recording ILogger. An operator
reading logs should be able to tell why an application exited.

New-code line coverage is now 86%. The four lines still uncovered are the
SIGTERM callback and the PlatformNotSupportedException guard, which need a
real signal delivered to the test host or a platform that rejects SIGTERM.
Overall coverage rose from 48.0% to 54.8%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671tcpA4zkfbgPcJm8cTsB

Copy link
Copy Markdown
Contributor Author

The SonarCloud quality gate failed this PR on 69.7% coverage on new code (required ≥ 80%). That's this PR's to fix, so: addressed in 380f527.

I measured locally rather than guessing, by intersecting git diff origin/main with a cobertura report — the same thing the gate computes. The gap was concentrated in one place: ConsoleInterruptSource had two near-identical signal-handler bodies that no test could reach, because neither ConsoleCancelEventArgs nor PosixSignalContext can be constructed by a test.

What changed. Both handlers now funnel through a single OnSignal(cancelDefaultTermination, onInterrupt), so the response to a signal is stated once and is assertable separately from the delivery of one. The new test asserts the ordering, not just that both happen — notifying runs application shutdown code, and until the default response is cancelled the runtime is still entitled to kill the process partway through it, so cancelling has to come first. That's the actual invariant this PR rests on.

Also added an assertion that taking an interrupt is logged, through a recording ILogger: an operator reading logs should be able to tell why the app exited.

Result — new-code line coverage measured per file:

File New executable lines covered
TUI.Core/Services/UIApplication.cs 16/16
TUI.Core/Services/ConsoleInterruptSource.cs 27/31
TUI.Core/Services/SpectreConsoleProvider.cs 0/3
Total 43/50 = 86.0%

Overall project coverage also rose from 48.0% to 54.8%. Suite is 122/122 passing, dotnet build TUI.sln clean across net10.0;net9.0;net8.0.

What is still uncovered, and why I left it. Four lines in ConsoleInterruptSource: the SIGTERM callback body and the catch (PlatformNotSupportedException) guard. Covering them needs either a real signal delivered to the test host — which risks killing the test run, so I'm not doing that in CI — or a platform that rejects SIGTERM. I could make the registration itself injectable to fake both, but that's a second seam existing only to move a metric, and the guard is genuine defensive code I don't think should be deleted to satisfy one. The other 3 are comment lines inside ReadInputAsync, which the coverage tool attributes to the lambda's range; that method calls Console.ReadKey and can't run with stdin redirected.

Separately, the real signal path is the part unit tests can't reach, and this commit refactored exactly that code — so I re-ran the throwaway harness against the real ConsoleInterruptSource afterwards. Both signals still behave:

=== SIGINT ===   INTERRUPT CALLBACK FIRED (1) / STILL ALIVE AFTER SIGNAL
=== SIGTERM ===  INTERRUPT CALLBACK FIRED (1) / STILL ALIVE AFTER SIGNAL

Generated by Claude Code

SonarCloud reported seven new issues, all MSTest analyzer suggestions on the
lifecycle tests added by this branch:

- MSTEST0049 (x5): pass the test's cancellation token to RunAsync, so a run
  started by a test ends when the test run itself is cancelled
- MSTEST0068: Assert.AreSequenceEqual over CollectionAssert.AreEqual
- MSTEST0037: Assert.Contains over Assert.IsTrue with a predicate

The assertion rewrites were checked by breaking the expectations on purpose
and confirming both tests still fail: swapping assertion APIs is exactly
where a test can quietly stop asserting anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671tcpA4zkfbgPcJm8cTsB
#113 (for #109) and #118 (for #114) landed on main while this was open.
UIApplication.cs merged cleanly: #113 rewrote Render's dirty-tracking model,
which this branch does not touch, and this branch's changes are confined to
RunAsync's interrupt registration and ProcessInputAsync's read.

UIApplicationTests.cs conflicted because both branches filled what had been an
empty file, with two unrelated sets of tests. Neither side is redundant, so
main's render-loop tests stay in UIApplicationTests.cs unchanged and this
branch's run-lifecycle tests move to UIApplicationLifecycleTests.cs. That
matches how the suite already splits BorderElementTests from
BorderElementRenderTests, and keeps either class readable on its own.

129/129 tests pass and the solution builds clean across net10.0;net9.0;net8.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671tcpA4zkfbgPcJm8cTsB
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 13726f4 into main Sep 16, 2026
13 checks passed
@matt-edmondson
matt-edmondson deleted the fix/110-ctrl-c-exit-path branch September 16, 2026 01:02
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.

Ctrl+C bypasses the app's exit path, leaving the terminal cursor hidden after the process terminates

2 participants