fix: exit through the normal shutdown path on Ctrl+C [patch] - #117
Conversation
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
…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
|
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 What changed. Both handlers now funnel through a single Also added an assertion that taking an interrupt is logged, through a recording Result — new-code line coverage measured per file:
Overall project coverage also rose from 48.0% to 54.8%. Suite is 122/122 passing, What is still uncovered, and why I left it. Four lines in 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 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
|



Fixes #110
The problem
UIApplication.RunAsync()hides the cursor on startup and only restores it in itsfinallyblock. 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 aCtrl+Cbranch, but it was unreachable:Console.ReadKeynever sees Ctrl+C unlessTreatControlCAsInputis 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
ConsoleInterruptSourcehooksConsole.CancelKeyPressand aPosixSignalRegistrationfor SIGTERM, cancels the runtime's default "terminate now" behaviour, and callsShutdown(). The existing teardown then restores the cursor.RunAsyncregisters 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
CancelKeyPressrather thanTreatControlCAsInput: 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 againstPlatformNotSupportedException, 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.
ProcessInputAsyncawaitedConsoleProvider.ReadInputAsync()directly. A provider parked inConsole.ReadKeydoes not observe the token, so cancelling only took effect once the user pressed an unrelated key — meaningShutdown()alone would not have ended the run. The read is now abandoned when the token fires. This fixes the same latency for theCancellationTokencallers pass toRunAsync.The previously-dead branch in
ReadInputAsyncis kept and commented: it is now correct for a host that has setTreatControlCAsInputitself.Why an internal seam, not a public contract
IInterruptSourceis internal (TUI.Corealready hasInternalsVisibleTo 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.cscovers the run lifecycle, using aBlockingConsoleProviderwhose read does not complete until the test releases it — standing in for a real provider parked inConsole.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:
The 3 that still passed are the ordinary exit-input path (unchanged by this PR) and the two
ConsoleInterruptSourceunit tests — correct, since neither depends on the reverted wiring.Full suite: 129/129 pass.
dotnet build TUI.slnis clean acrossnet10.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. BothSIGINTandSIGTERMreach the callback and the process stays alive rather than being terminated by the runtime:This was re-run after the
OnSignalrefactor, 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/mainintersected 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 intoOnSignal. 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.Appunder a pseudo-terminal was inconclusive, becauseSetCursorVisibilityissues 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.csmerged cleanly — #113 rewroteRender's dirty-tracking model, which this branch does not touch.UIApplicationTests.csconflicted 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 inUIApplicationTests.csunchanged and this branch's run-lifecycle tests moved toUIApplicationLifecycleTests.cs— matching how the suite already splitsBorderElementTestsfromBorderElementRenderTests.🤖 Generated with Claude Code
https://claude.ai/code/session_01671tcpA4zkfbgPcJm8cTsB