Summary
A one-shot CLI built on Repl.Defaults has no way to observe a termination signal. RunAsync(string[], CancellationToken) takes a token, which reads as "the framework owns the lifetime and will cancel this for me", but nothing in the shipped assemblies ever cancels it: the only console-signal hook is Console.CancelKeyPress, and it is reached on the interactive path. On net10.0 the runtime no longer installs a default termination handler either, so the OS default applies and the process dies without unwinding.
For a tool that does real work in a handler — writes files, stages a directory, holds a temporary tree it deletes in a finally — this means a SIGTERM skips every finally on the way out. That is the shape CI uses to cancel a job.
What we observed
Consumer: a headless build tool, one command, spawned by a parent process, one invocation per process. Pinned Repl 0.12.0-dev.11.
Symbol presence in the shipped assemblies (lib/net10.0):
| symbol |
Repl.Core.dll |
Repl.Defaults.dll |
PosixSignal / PosixSignalRegistration |
absent |
absent |
ConsoleLifetime |
absent |
absent |
IHostLifetime |
absent |
absent |
IHostApplicationLifetime |
absent |
absent |
Console.CancelKeyPress |
present |
absent |
Repl.Defaults declares a dependency on Microsoft.Extensions.Hosting and its API can consume an IHost (Run(args, IHost, …)), but it does not build one — no HostApplicationBuilder, CreateApplicationBuilder, CreateDefaultBuilder, HostBuilder or UseConsoleLifetime symbol is present. So the component that would normally install SIGTERM/SIGINT handling in a generic host is not in the picture, even though the dependency that provides it is.
The practical consequence in our case: our command's cleanup finally did not run on cancellation, leaving a staged copy of a checkout in the temp directory, and the exit code our contract reserves for "cancelled" became unreachable.
What we did
Registered the handlers ourselves in Program.cs and passed the token in:
using var cancellation = new CancellationTokenSource();
using var sigterm = PosixSignalRegistration.Create(
PosixSignal.SIGTERM, ctx => { ctx.Cancel = true; cancellation.Cancel(); });
using var sigint = PosixSignalRegistration.Create(
PosixSignal.SIGINT, ctx => { ctx.Cancel = true; cancellation.Cancel(); });
return await app.RunAsync(args, cancellation.Token);
That works, and we are content to keep it if it is the intended contract. Two notes on why it still feels like a gap rather than a division of labour:
ctx.Cancel = true is what suppresses the runtime's default termination, so a consumer who only calls cancellation.Cancel() — the obvious first attempt — still gets killed mid-unwind.
- Nothing in the API surface signals that the token is the caller's to feed. An overload that accepts a
CancellationToken and a profile named for one-shot CLI execution both point the other way.
Suggestions, in order of how much they'd change
- Document the contract. If the caller owns signal handling for one-shot execution, saying so on
RunAsync's token parameter and in the CLI profile's summary would be enough. Cheapest fix, and it removes the wrong inference.
- Install handlers in the one-shot profile.
UseCliProfile() already means "defaults suited for CLI one-shot execution"; registering SIGTERM/SIGINT (with ctx.Cancel = true) and cancelling the run token is arguably part of that. Opt-out for hosts that manage their own lifetime.
- Use
ConsoleLifetime when an IHost is in play. The dependency is already declared; letting the generic host's own lifetime do this would make the hosted and non-hosted paths agree.
Happy to put together a minimal reproduction if that would help — say the word and we'll push a small repo that spawns a one-shot app, sends it SIGTERM, and shows the finally being skipped.
Secondary, possibly a separate issue
The routing-refusal exit code is 1, hard-coded, and indistinguishable from a handler failure.
An invocation the framework cannot route — unknown option, option missing its value, no verb, an unrecognised help spelling — exits 1. So does an unhandled exception escaping a handler. A tool whose exit codes are a published contract has to tell "you typed it wrong" from "it broke", and cannot: there is no configuration point, and the two cases arrive as the same number.
We worked around it by making our handler total (every path, exceptions included, returns an explicit code) and then translating 1 at the entry point, which is sound only because of that totality. A configurable refusal code — or simply a distinct one — would remove the need.
Measured on 0.12.0-dev.11, one command mapped as a verb:
| invocation |
exit code |
| no arguments |
0 (prints the full help) |
<verb> with a required option missing |
our own code, as returned by the handler |
| unknown option |
1 |
| option missing its value |
1 |
| no verb |
1 |
--help |
0 |
-h |
1 |
Two smaller things visible in that table:
- No arguments exits 0. Printing help is right; the success code means a CI step that ran the tool with an empty argument list goes green having done nothing.
git exits 1 on a bare invocation, for what it's worth — we did not survey further, and the convention is not uniform, so treat this as "non-zero would be safer" rather than a claim about a standard. (InteractivePolicy.Prevent is what gets you the help instead of an interactive prompt — worth noting that UseCliProfile() alone did not change this for us.)
-h is not an alias for --help. ReplOptionAttribute has Aliases, but the built-in help option is not ours to annotate, so there is no way to add the conventional short form.
Summary
A one-shot CLI built on
Repl.Defaultshas no way to observe a termination signal.RunAsync(string[], CancellationToken)takes a token, which reads as "the framework owns the lifetime and will cancel this for me", but nothing in the shipped assemblies ever cancels it: the only console-signal hook isConsole.CancelKeyPress, and it is reached on the interactive path. Onnet10.0the runtime no longer installs a default termination handler either, so the OS default applies and the process dies without unwinding.For a tool that does real work in a handler — writes files, stages a directory, holds a temporary tree it deletes in a
finally— this means aSIGTERMskips everyfinallyon the way out. That is the shape CI uses to cancel a job.What we observed
Consumer: a headless build tool, one command, spawned by a parent process, one invocation per process. Pinned
Repl 0.12.0-dev.11.Symbol presence in the shipped assemblies (
lib/net10.0):Repl.Core.dllRepl.Defaults.dllPosixSignal/PosixSignalRegistrationConsoleLifetimeIHostLifetimeIHostApplicationLifetimeConsole.CancelKeyPressRepl.Defaultsdeclares a dependency onMicrosoft.Extensions.Hostingand its API can consume anIHost(Run(args, IHost, …)), but it does not build one — noHostApplicationBuilder,CreateApplicationBuilder,CreateDefaultBuilder,HostBuilderorUseConsoleLifetimesymbol is present. So the component that would normally installSIGTERM/SIGINThandling in a generic host is not in the picture, even though the dependency that provides it is.The practical consequence in our case: our command's cleanup
finallydid not run on cancellation, leaving a staged copy of a checkout in the temp directory, and the exit code our contract reserves for "cancelled" became unreachable.What we did
Registered the handlers ourselves in
Program.csand passed the token in:That works, and we are content to keep it if it is the intended contract. Two notes on why it still feels like a gap rather than a division of labour:
ctx.Cancel = trueis what suppresses the runtime's default termination, so a consumer who only callscancellation.Cancel()— the obvious first attempt — still gets killed mid-unwind.CancellationTokenand a profile named for one-shot CLI execution both point the other way.Suggestions, in order of how much they'd change
RunAsync's token parameter and in the CLI profile's summary would be enough. Cheapest fix, and it removes the wrong inference.UseCliProfile()already means "defaults suited for CLI one-shot execution"; registeringSIGTERM/SIGINT(withctx.Cancel = true) and cancelling the run token is arguably part of that. Opt-out for hosts that manage their own lifetime.ConsoleLifetimewhen anIHostis in play. The dependency is already declared; letting the generic host's own lifetime do this would make the hosted and non-hosted paths agree.Happy to put together a minimal reproduction if that would help — say the word and we'll push a small repo that spawns a one-shot app, sends it
SIGTERM, and shows thefinallybeing skipped.Secondary, possibly a separate issue
The routing-refusal exit code is
1, hard-coded, and indistinguishable from a handler failure.An invocation the framework cannot route — unknown option, option missing its value, no verb, an unrecognised help spelling — exits
1. So does an unhandled exception escaping a handler. A tool whose exit codes are a published contract has to tell "you typed it wrong" from "it broke", and cannot: there is no configuration point, and the two cases arrive as the same number.We worked around it by making our handler total (every path, exceptions included, returns an explicit code) and then translating
1at the entry point, which is sound only because of that totality. A configurable refusal code — or simply a distinct one — would remove the need.Measured on
0.12.0-dev.11, one command mapped as a verb:<verb>with a required option missing--help-hTwo smaller things visible in that table:
gitexits 1 on a bare invocation, for what it's worth — we did not survey further, and the convention is not uniform, so treat this as "non-zero would be safer" rather than a claim about a standard. (InteractivePolicy.Preventis what gets you the help instead of an interactive prompt — worth noting thatUseCliProfile()alone did not change this for us.)-his not an alias for--help.ReplOptionAttributehasAliases, but the built-in help option is not ours to annotate, so there is no way to add the conventional short form.