Ctrl+C exits 1 or 130 depending on whether the command was pollable #263

Open
opened 2026-09-06 17:57:34 +00:00 by stephen · 0 comments
Owner

The same Ctrl+C reports two different exit codes depending on whether the command future happened to be pollable when the signal arrived.

Measured on b2ebe8a (the head of #261), with XDG_CONFIG_HOME redirected to a scratch dir and no live forge:

parked stdin read (backstop path)         exit=130
pollable async wait (graceful path)       exit=1

The first is fj auth login --with-token, parked in a synchronous stdin read that the graceful select cannot cancel, so interrupt::force_exit ends it with 130. The second is the OIDC loopback wait, which is ordinary async, so cli::run's select wins and the interrupt surfaces as an ordinary anyhow error that main maps to 1.

Why this is worth fixing rather than documenting

  • The common path reports the less meaningful code. Most commands are pollable, so most interrupts exit 1, which is indistinguishable from any other failure. 128+SIGINT is the conventional encoding and it is the one a caller can actually act on.
  • Anything that learns to key on 130 will usually be wrong. A script that treats 130 as "the user interrupted this" is correct only for the minority of commands that were wedged in a syscall at the time.
  • The two codes also differ in what ran. The 130 path is std::process::exit, so destructors are skipped. That is the mechanism behind the non-atomic token store write fixed in #261, so the difference is not only cosmetic.

Why it was not fixed in #261

Giving the graceful path the same code changes the contract of every interrupted command, not just the wedged ones, and anything currently keying on 1 would see 130 instead. That is a deliberate decision about the CLI's interface, and #261 is a data-loss fix; it did not seem right to change the exit code of every Ctrl+C while fixing a credential write.

What the fix looks like

Have the graceful interrupt return 130 as well, so the code means "interrupted" regardless of which mechanism ended the process. That is a change in cli::run/main's error mapping rather than in interrupt, since the backstop is already correct.

The same Ctrl+C reports two different exit codes depending on whether the command future happened to be pollable when the signal arrived. Measured on `b2ebe8a` (the head of #261), with `XDG_CONFIG_HOME` redirected to a scratch dir and no live forge: ``` parked stdin read (backstop path) exit=130 pollable async wait (graceful path) exit=1 ``` The first is `fj auth login --with-token`, parked in a synchronous stdin read that the graceful select cannot cancel, so `interrupt::force_exit` ends it with 130. The second is the OIDC loopback wait, which is ordinary async, so `cli::run`'s select wins and the interrupt surfaces as an ordinary `anyhow` error that `main` maps to 1. ## Why this is worth fixing rather than documenting - **The common path reports the less meaningful code.** Most commands are pollable, so most interrupts exit 1, which is indistinguishable from any other failure. 128+SIGINT is the conventional encoding and it is the one a caller can actually act on. - **Anything that learns to key on 130 will usually be wrong.** A script that treats 130 as "the user interrupted this" is correct only for the minority of commands that were wedged in a syscall at the time. - **The two codes also differ in what ran.** The 130 path is `std::process::exit`, so destructors are skipped. That is the mechanism behind the non-atomic token store write fixed in #261, so the difference is not only cosmetic. ## Why it was not fixed in #261 Giving the graceful path the same code changes the contract of every interrupted command, not just the wedged ones, and anything currently keying on 1 would see 130 instead. That is a deliberate decision about the CLI's interface, and #261 is a data-loss fix; it did not seem right to change the exit code of every Ctrl+C while fixing a credential write. ## What the fix looks like Have the graceful interrupt return 130 as well, so the code means "interrupted" regardless of which mechanism ended the process. That is a change in `cli::run`/`main`'s error mapping rather than in `interrupt`, since the backstop is already correct.
Sign in to join this conversation.
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rasterstate/fj#263
No description provided.