Skip to content

One-shot RunAsync(args, ct) never cancels: no signal source outside the interactive session #79

Description

@carldebilly

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

  1. 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.
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions