Make Ctrl+C work when the command future is not pollable #261
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/sigint-backstop"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Follow-up to #258. That PR fixed the one
spawn_blockingin the tree; thisone fixes the wider shape it belonged to.
cli::runraces the command againsttokio::signal::ctrl_c(). That selectonly runs while the command future is still pollable, and several paths park
the
block_onthread inside a synchronous syscall for an unbounded time.While the thread sits there the select never gets polled again, and tokio has
already replaced SIGINT's default disposition, so the signal does not
terminate the process either. Ctrl+C is not slow in these states, it is inert.
Confirmed, by stack-sampling a hung process
Client::connect->client::resolve::resolve->auth::load_token->SecKeychainFindGenericPassword, on the path of nearlyevery authenticated command. Blocks forever whenever macOS wants an
authorization dialog it cannot present: over ssh, in CI, from a git hook, or
from an ad-hoc signed dev build. This is what deadlocks the pre-push hook.
--body -,--input -,--with-token, and the interactive Fjord Account prompts. Sampled stack:main thread in
read(2), every tokio worker idle, 8s after Ctrl+C.The fix
crate::interruptspawns an async task (notspawn_blocking, which is whatdropping the runtime waits on, so the backstop can never itself become a
reason the process won't exit). It watches for SIGINT, gives the graceful
select 250 ms to unwind, and force-exits 130 if the process is still there.
The graceful path keeps winning wherever it can, so destructors and the pager
restore are untouched.
A child holding the terminal's foreground process group takes the same SIGINT
directly and is entitled to decide what it means:
lessabsorbs itdeliberately.
ForegroundChildmarks those windows and the backstop standsdown while one is open. It wraps the
$EDITORspawn, the browser opener,extension dispatch, setup-git's
git config, theCommand::status()sites ingit, and the pager's wait.Mid-command the pager is not yet the user's to scroll, so a force-exit there
restores stdout and SIGTERMs the pager. SIGTERM rather than SIGKILL because
that is what lets
lessput back the terminal modes it changed; verifiedseparately.
Verification
On a pty, writing 0x03 to the master so the tty driver decides whether that
becomes a signal or a byte, exactly as a terminal does:
--body -)dialoguerpromptqexits 0$EDITORcargo fmt,cargo clippy --all-targets --all-features -- -D warningsclean.cargo test768 passed / 0 failed / 2 ignored.Not claimed
client::resolve::testswere skipped locally, not run: they are thekeychain tests, and this machine is in exactly the state that makes the
keychain block. CI runs them.
that under load, an interrupt reports exit 130 instead of exit 1.
pager::abandon; the backstop itself is portable butonly the Unix pager path has been exercised.
Gate findings from #261
The token store is now replaced, not truncated.
write_file_storeopenedthe file with
truncate(true)and wrote the contents back on the next line, sobetween those two syscalls the store was zero bytes. The backstop force-exits
from a runtime worker while the main thread keeps running, and the path that
reaches this write is the one the backstop exists for: a keychain call that
hangs, its synchronous error path, and no
.awaitalong the way for thegraceful select to regain control on. An exit landing in that window destroyed
every host's token, not only the one being changed.
It now writes a temporary file in the same directory, fsyncs it and renames it
over the target. Rename is atomic, so the window stops existing rather than
being made narrower, which is why this is the fix rather than a longer grace.
It is also correct independently of interrupts, since a crash or a power loss
truncated the store the same way.
Same shape elsewhere, reported not fixed.
fs::writeisFile::createpluswrite_all, so it truncates too. Six sites hold persistent user state:config/hosts.rs:115,cli/config.rs:84,cli/alias.rs:79,cli/work_session.rs:75,cli/stack_state.rs:208, and the token store fixedhere.
config/hosts.rsis the one to do next, becausefj auth loginwrites iton the same interrupted path; it is not batched in here because its file has no
explicit mode today and picking one is a permissions decision, not a bug fix.
The other four are reachable only from commands that do not park. The rest of
the
fs::writecalls insrc/are test fixtures, downloaded artifacts, or manpage output.
Coverage: the dangerous function is now tested, not excluded.
tests/interrupt_backstop.rsdrives the built binary throughenv!("CARGO_BIN_EXE_fj"), the waytests/version.rsalready does: it parks acommand in a synchronous stdin read and requires a real SIGINT to end it, SIGINTs
a logout across ten delays and requires the store never to be left empty, and
checks the backstop adds no latency to an ordinary command. Locally that takes
src/interrupt.rsfrom essentially uncovered to 88.16% of lines with 100% ofits functions executed,
armandforce_exitincluded. NoCOV_IGNOREentryand no
coverage(off)was added; an exclusion would have declared the intentwithout testing the one function in the diff that can destroy user data.
What each test does and does not pin. The subprocess test is not the
regression pin for the atomic write. The real truncate window is microseconds
wide and no test can reliably land a signal in it; run against the pre-fix write
it passes. The pin is the in-process test in
src/auth/mod.rs, which opens aread handle before the rewrite and requires it to still see the old file
afterwards. Replacement by rename leaves that handle on the old inode;
truncate-in-place does not, so it fails deterministically on the old write.
Checked both ways, and checked as an A/B with the window artificially widened to
400 ms: the old write loses the store, the new write does not.
Exit 1 versus exit 130. Not acceptable as a permanent state, and not changed
here. The graceful path returns 1 only because the interrupt surfaces as an
ordinary error, so the same Ctrl+C reports two different codes and the common
path reports the less meaningful one. Anything that learns to key on 130 will be
wrong most of the time. The fix is to give the graceful path the same code,
which changes the contract of every interrupted command and is a separate
decision from the backstop, so it belongs in its own change rather than riding
along in a data-loss fix.
CLAUDE.mdnarrowed. "Never reintroducespawn_blocking" banned a generaltokio API on the strength of one misuse. It now says the actual rule: nothing on
the shutdown path may run on the blocking pool.
Closing the coverage gate, and why not the two obvious ways. After the
backstop tests the gate was still 0.10 points short of its 61 floor. Covering
the pager cannot help:
output/pager.rsis inCOV_IGNOREand does not appearin the gate's table at all, so none of its 43 added lines are measured.
Excluding the one-line
ForegroundChild::enter()wrappers is about seven lines,worth roughly 0.02, and it would exclude the half of the diff that had never
been tested rather than the half that cannot be. So the guard is tested instead:
fj auth setup-gitspawnsgit config --globalinside the same wrapper used atthe
$EDITOR, browser andgitsites, offline against a scratchHOME, with a--dry-runpair showing the spawn is genuinely skipped there. NoCOV_IGNOREentry and no
coverage(off)was added, andCOV_MINis untouched. CI linecoverage 60.90% to 61.08%.
Testing
cargo test779 unit + 5 interrupt + 1 version, all passing.cargo fmt --all -- --checkandcargo clippy --locked --all-targetsclean.APPROVE
Head reviewed:
b2ebe8a238Head confirmed against the API. Base
5c6d099d, mergeable, CI 3/3 green including the coverage jobthat was red last round. Everything the prior verdict established is left alone; this covers only
the delta.
Constructed against a build of this head with
XDG_CONFIG_HOMEredirected to a scratch directoryand
FJ_TOKEN,FJ_SESSIONandBW_SESSIONstripped from every child. No live forge, no realcredentials.
The headline: I reverted the atomic write, and the result is split
Both halves, stated whichever way they went:
The unit test fails deterministically, on the assertion it was written for:
The 231-line subprocess test passes against the reverted write. It does not pin the atomic write.
So the finding is closed, and not by the file that looks like it closes it. The pin is the
in-process test, which opens a read handle before the rewrite and requires it to still see the old
inode afterwards. That is a real discriminator: rename leaves the handle on the old inode,
truncate-in-place does not, and it needs no signal timing to work.
What makes this an approve rather than a finding is that the PR says so first. The body states,
unprompted: "The subprocess test is not the regression pin for the atomic write. The real truncate
window is microseconds wide and no test can reliably land a signal in it; run against the pre-fix
write it passes. The pin is the in-process test." I tested it before reading that paragraph closely
and got the same two results. An author who characterises the weaker half of their own test suite
correctly is doing the thing this gate exists to check for.
Binary identity, because the prior gate lost a batch of runs to exactly this. Mutant
99ae178f9749474092cd8804640c5f01, real head7555b2c4c0cf42929a439ba756d1fde4, checked at thepoint of use, and corroborated at source level each time: mutant
truncate(true)=1 NamedTempFile=0,head
truncate(true)=0 NamedTempFile=1. On the restored head both tests pass, the unit test and allfive in
interrupt_backstop.An incidental confirmation the fix is a replacement and not an addition: the mutant would not
compile until I restored
OpenOptionsandOpenOptionsExtto the imports. The head removed them,so the truncate machinery is gone from the file rather than left sitting beside the new path.
1. The write is atomic, on all four counts
tempfile::NamedTempFile::new_in(parent)whereparentis the target's ownparent, defaulting to
.when there is none, with the reason in a comment: rename is only atomicwithin one filesystem. Not a temp dir.
set_permissions(0o600)on the temp file before any content is written, sothe window where a world-readable file holds tokens does not exist.
persistcarries the modeacross, and
set_file_store_permissionsafter the rename is belt-and-braces.sync_all()on the temp file precedespersist, commented as"so a crash after it cannot leave the new name pointing at unflushed content".
NamedTempFiledeletes on drop, so every?in the function removesthe temp file. The rename failure path is
persist(...).map_err(|e| e.error), which drops theNamedTempFilecarried in thePersistErrorand so also cleans up, with context naming thetarget. No partial token store is left in the config directory.
One gap, non-blocking. The parent directory is not fsynced after the rename, so the directory
entry is not itself crash-durable. That does not affect the property this PR is about, since rename
is atomic for any observer regardless, and it is a different failure mode (power loss, not
interrupt). Worth knowing rather than fixing here.
2. The other sites: claude-8's answer is accurate
I grepped independently before reading its claim. Every
fs::write,File::createandtruncate(true)insrc/, minus test fixtures, downloaded artifacts and man-page output, leavesthese persistent-state writers on the non-atomic shape:
The body names exactly those five plus the token store it fixed, says
config/hosts.rsis nextbecause
fj auth loginwrites it on the same interrupted path, and gives a real reason for notbatching it: that file has no explicit mode today, so picking one is a permissions decision rather
than a bug fix. That matches my grep with nothing missing and nothing invented. The pattern is
confirmed, the scope choice is defensible, and it is recorded rather than absorbed.
3. The coverage tests: covered, with one soft spot
None of the seven added tests is pure padding. The two I expected to be were not:
a_guarded_child_spawn_completes_and_writes_only_inside_the_scratch_homeasserts the credentialhelper was actually installed for the host asked for, and
setup_git_dry_run_spawns_nothing_and_ writes_nothingasserts.gitconfigdoes not exist. Together they show the guarded spawn is reachedin one case and skipped in the other, which is what stops the guard being decorative. Both assert
behaviour someone would care about breaking.
The soft spot is
arming_the_backstop_does_not_delay_an_ordinary_command. It boundsfj auth statusat 15 seconds. Measured on this head, three runs: 0.021s, 0.012s, 0.018s. That isroughly a 700x margin, so the test pins "the backstop does not hang an ordinary command" and cannot
detect anything short of a near-hang; a regression adding five seconds would pass it. The assertion
is real and worth having, the name over-claims what it holds. Not blocking, and worth one word in
the name or a tighter bound.
4. The rest
The AI trailer is gone. Zero matches for
Generated with,claude.ai/code,Co-Authored-By,and zero em-dashes or en-dashes in the body. The prior round's second blocking finding is closed.
CLAUDE.mdis narrowed correctly and belongs here. The rule now reads "Nothing on the shutdownpath may run on the blocking pool: dropping the runtime waits for
spawn_blockingtasks, and thatwait is the original hang (fj#257).
spawn_blockingis still the right tool elsewhere." That istrue, it names the mechanism rather than banning an API, and the last sentence forecloses the
over-reading the prior verdict predicted. It belongs in this PR because the invariant it documents,
wrapping any stdio-inheriting
Command::status()inForegroundChild::enter(), is created by thisfix and would otherwise be discovered by breaking it.
Exit 1 versus exit 130 is answered, not deflected. The prior verdict's objection was that the
body read it as cosmetic. It now says "Not acceptable as a permanent state", explains that the
graceful path returns 1 only because the interrupt surfaces as an ordinary error, agrees anything
keying on 130 will be wrong most of the time, and defers it on the grounds that fixing it changes
the contract of every interrupted command. That is the right disposition and the right reason.
What I did not verify
I did not re-derive the prior gate's timing work, the 250 ms grace, the spawn-site audit, or the
backstop-cannot-block-shutdown result. The brief ruled those settled and I took them as settled.
I did not measure coverage myself. CI's coverage job is green on this head, and the prior round's
red is therefore resolved by whatever the added tests moved; I checked whether the tests assert
things, which is the part a number cannot tell you.
Do not merge; that is the operator's.
stephen referenced this pull request2026-09-06 18:18:29 +00:00