Repository navigation
with-device: an EACCES reported as CLAIMED — fix both halves (jess#280, AFD-133) - #282
Merged
Merged
Conversation
…0, AFD-133)
relay filed jess#280 against jess's own interlock with a clean reproduction and a
control, and it is a real defect: a PERMISSION FAILURE was reported as a CLAIM.
THE DEFECT. try_claim ended its open with `.ok()?`, so EVERY open failure collapsed
into None, and None means "held by another" at every call site. On fourpi.local —
the VEHICLE host — the lockdir is pi:pi drwxrwxr-x and the standard account r is not
in group pi. An exclusive flock(2) needs the file WRITABLE, so the open failed EACCES
and every device read busy, permanently. --self-test failed 3 of 5 in a pattern
reading as "locks are never released", and DEVICE BUSY named a holder pid that was
not running.
It failed CLOSED, which is the right direction. But a safety tool rendering absence
as data is the exact failure this repo keeps finding, and the repo's own rule is that
"could not run" is not "answered no". It sent the reporter hunting a phantom holder.
STRUCTURAL CAUSE: Option could not express three outcomes and scan_status carried a
bool. There was nowhere for the third state to go, which is why it was reported as
the second for months.
HALF ONE — the third state exists. Outcome is now Got/Held/Undetermined, and State
is Free/Claimed/Unknown, each carrying WHY. claim_all bails immediately on
Undetermined instead of spinning until --wait expires and then reporting BUSY. New
exit code 4 EXIT_UNDETERMINED, distinct from 3: a caller retrying on 3 would spin
forever on an EACCES, and one treating 3 as "another agent is working" blames a
phantom. --status prints UNKNOWN with the cause and emits an unknown state with a
why field, instead of CLAIMED with an empty holder as the only hint. --self-test
checks writability FIRST and refuses with PRECONDITION FAIL / CANNOT RUN, explicitly
exonerating the locking. try_claim stays as a wrapper that still collapses to None,
so any caller not yet updated keeps failing closed.
HALF TWO — the interlock could never have worked cross-account. Nothing ever set a
mode, so the lockdir and lock files inherited the creating user's umask, and the
first account to claim a device silently owned the interlock for everyone. Now the
/tmp pattern: 1777 on the directory (sticky, so accounts cannot delete each other's
locks) and 0666 on the lock and holder files, because an exclusive flock needs write
access. Safe because THE LOCK FILE CARRIES NO DATA — the lock lives on the fd and the
file is a zero-byte rendezvous point; anyone who can write it could already take the
lock by running the tool. Widening is best-effort and NEVER NARROWS, so running it
cannot lock out an account already using the directory.
VERIFIED: 63 tests (30 lib + 33 behavioural), clippy -D warnings clean, coverage
92.4% against a 90 floor. Six new tests, including the one that makes the others mean
something — a GENUINELY held device must still exit 3 with its holder named. Without
it, exiting 4 on everything would pass the EACCES test; the two must refuse
DIFFERENTLY, which is the entire point.
MUTATION TEST: reverting the fix to return Held on open failure makes the EACCES test
fail with "DEVICE BUSY: 'dev-a' is claimed — {}" — the reported symptom verbatim,
empty holder and all. So the test pins this specific bug rather than something near it.
A TEST OF MINE THAT WAS THE WRONG MODEL. The first EACCES test chmod'd the LOCKDIR to
0500 and failed, because half two legitimately widens a directory the test user owns.
The test was wrong, not the fix: the real scenario is a lock FILE another account
created that we cannot chmod, and a read-only lock file models it exactly without
needing a second user. Recorded because "the fix broke my test" is usually the test
being a bad model, and here it was.
NOT VERIFIED ON fourpi.local, stated plainly: there is no Rust toolchain on any of the
three Pis and no aarch64-linux cross-linker here, so the fixed binary could not be
built for the host where the bug was reported. Confirmed there independently of relay:
the lockdir is pi:pi, r is not in group pi, writing the lock file returns Permission
denied, and fuser shows nothing holding the flock — plus a fact relay's own diagnosis
lacked, that r cannot even EXECUTE /home/pi/bench/with-device (exit 126) because
/home/pi is not traversable by r, which explains their empty find more completely than
the tilde scope alone. The fix is verified on darwin against the identical code path
(EACCES from OpenOptions::open). Cross-building, or a toolchain on a Pi, is the
remaining step before the operational claim can be made on the vehicle host.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017EKh2onUHpWSUiAEqWxfy2
…me bug one level up Two things, both from actually running the fixed binary on the host the bug was reported against instead of reasoning about it. RETRACTION FIRST. AFD-133 and PR #282 both said the fix could NOT be verified on fourpi.local, because "there is no Rust toolchain on any of the three Pis and this Mac has no aarch64-linux cross-linker". The second half was WRONG, and wrong in the way worth recording: it was a claim about a toolchain that was never tested, only assumed. with-device has ZERO dependencies and no build.rs, so it cross-builds statically with no external linker at all — rustup target add aarch64-unknown-linux-musl CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=rust-lld \ cargo build --release --target aarch64-unknown-linux-musl because rust-lld ships inside the toolchain and the musl libc.a ships with rust-std. 487,440 B static aarch64 ELF, sha256 5f3aeb06. An UNTESTED BLOCKER IS A HYPOTHESIS, NOT A FINDING, and writing one into a safety artifact as a limitation is the same error class as a vacuous pass pointing the other way. VERIFIED ON THE REPORTED HOST, AS THE REPORTED ACCOUNT. Run as r on fourpi.local, before repairing anything, with the lockdir still pi:pi drwxrwxr-x: --status all 6 devices UNKNOWN with the Permission denied cause, and json "state":"unknown" with a why. Was: CLAIMED, empty holder. a claim exit 4, CANNOT DETERMINE, "Nothing was run. This is NOT 'the device is busy'". Was: exit 3 BUSY. --self-test PRECONDITION FAIL + "self-test: CANNOT RUN (this says nothing about the locking)". Was: 3 of 5 failures accusing the locking. NEGATIVE CONTROL, same binary, same host, same account, ONE variable (a writable lockdir): status exit 0, claim exit 0, wrapped command ran, and the modes came out drwxrwxrwt / -rw-rw-rw- — half two's modes measured, not asserted. AFTER REPAIR, as r: status all free exit 0; self-test PASS 7/7 including contender-while-held, after-holder-CRASHED and the opposite-order deadlock pair; a real claim with --require-claim SATISFIED inside it; a second agent refused exit 3 with a LIVE holder record {"pid":7628,"purpose":"holder"} — the property the defect had destroyed, BUSY meaning busy again; and the lock file still pi:pi 0666, so pi is not locked out. THE EXECUTION HALF IS FIXED TOO, not just diagnosed. The binary is installed at /usr/local/bin/with-device (0755) on ALL THREE hosts, so the exit-126 failure — r could not traverse /home/pi to even execute the old copy — cannot recur. gale.local and wohl.local both self-test PASS as r. The interlock is live on the whole bench for the standard account, which also unblocks AFD-113's F100 re-run on gale.local. AND THE SECOND DEFECT, WHICH IS THE REASON THIS COMMIT EXISTS. On fourpi the FIXED --status printed UNKNOWN for all six devices and EXITED 0. That is jess#280 again in the exit code, and unlike the original it fails OPEN: a caller that branches on the status, or greps for CLAIMED and finds none, reads "the board is clear" out of "nothing could be read". --status now exits 4 when any device is unreadable and says on stderr "N of M device(s) could not be read — this listing is INCOMPLETE, not a clean board". Exit 0 now means the scan ANSWERED for every device, free or claimed. The help text's exit-code table said "0 ... or success for --status" and is corrected. MUTATION TEST: changing that branch back to exit(0) fails the new test on the EXIT CODE while UNKNOWN still prints — so the test pins the code, not the message. Its CONTROL (--status over a genuinely CLAIMED device must still exit 0) is INSENSITIVE to the same mutation, which is what stops "exit 4 on anything" from passing both. Re-measured on fourpi afterwards: broken lockdir -> 4, writable lockdir -> 0. The pattern, stated because it will recur: the tri-state was added to the RETURN VALUE and the EXIT CODE was left behind. Fixing a could-not-run/answered-no conflation in one channel does not fix it in the others — and only running the fixed tool on the real host surfaced the channel that was missed. 65 tests (30 lib + 35 behavioural), clippy -D warnings clean, fmt clean, coverage 92.56% against the 90 floor, CLI baseline checks mirrored locally. Version 0.4.0 -> 0.5.0, because an exit code is part of the interface. rivet validate PASS (586 warnings). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017EKh2onUHpWSUiAEqWxfy2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #280 — filed by relay against jess's own interlock, with a clean reproduction and a control. It is a real defect: a permission failure was reported as a claim.
The defect
try_claimended its open with.ok()?, so every open failure collapsed intoNone— andNonemeans "held by another" at every call site. On fourpi.local (the vehicle host) the lockdir ispi:pi drwxrwxr-xand the standard accountris not in grouppi. An exclusiveflock(2)needs the file writable, so the open failedEACCESand every device read busy, permanently.--self-testfailed 3 of 5 in a pattern that reads as "locks are never released", andDEVICE BUSYnamed a holder pid that was not running.It failed closed, which is the right direction. But a safety tool rendering absence as data is the exact failure this repo keeps finding, and this repo's own rule is that "could not run" is not "answered no". It sent the reporter hunting a phantom holder.
Structural cause:
Optioncould not express three outcomes, andscan_statuscarried abool. There was nowhere for the third state to go — which is why it was reported as the second.Half one — the third state exists
OutcomeisGot/Held/Undetermined(why);StateisFree/Claimed/Unknown(why). Each carries the cause.claim_allbails immediately onUndeterminedinstead of spinning until--waitexpires and then reporting BUSY.EXIT_UNDETERMINED), distinct from 3. A caller retrying on 3 would spin forever on anEACCES; one treating 3 as "another agent is working" blames a phantom.--statusprintsUNKNOWN — <cause>and emits"state":"unknown"with awhy, instead of CLAIMED with an empty holder as the only hint.--self-testchecks writability first and refuses withPRECONDITION FAIL/CANNOT RUN, explicitly exonerating the locking.try_claimstays as a wrapper that still collapses toNone, so any not-yet-updated caller keeps failing closed.Half two — it could never have worked cross-account
Nothing ever set a mode, so the lockdir and lock files inherited the creating user's umask: the first account to claim a device silently owned the interlock for everyone. Now the
/tmppattern —1777on the directory (sticky, so accounts cannot delete each other's locks) and0666on lock and holder files, because an exclusiveflockneeds write access.Safe because the lock file carries no data: the lock lives on the fd, the file is a zero-byte rendezvous point, and anyone who can write it could already take the lock by running the tool. Widening is best-effort and never narrows, so running this cannot lock out an account already using the directory.
Verification
63 tests (30 lib + 33 behavioural),
clippy -D warningsclean, coverage 92.4% against a 90 floor. Six new tests, including the one that makes the others mean something — a genuinely held device must still exit 3 with its holder named. Without it, exiting 4 on everything would pass the EACCES test; the two must refuse differently, which is the whole point.Mutation test: reverting the fix to
return Outcome::Heldon open failure makes the EACCES test fail withDEVICE BUSY: 'dev-a' is claimed — {}— the reported symptom verbatim, empty holder and all. The test pins this bug, not something near it.A test of mine that was the wrong model. The first EACCES test
chmod'd the lockdir to0500and failed, because half two legitimately widens a directory the test user owns. The test was wrong, not the fix: the real scenario is a lock file another account created that we cannot chmod, and a read-only lock file models it exactly without needing a second user.Verified on fourpi.local — and the first version of this PR was wrong about that
Retraction. This PR originally said the fix could not be verified on fourpi.local because "there is no Rust toolchain on any of the three Pis and this Mac has no aarch64-linux cross-linker." The second half was wrong — a claim about a toolchain that was never tested, only assumed.
with-devicehas zero dependencies and nobuild.rs, so it cross-builds statically with no external linker at all:rust-lldships inside the toolchain and the musllibc.aships withrust-std. 487 KB static aarch64 ELF. An untested blocker is a hypothesis, not a finding — writing one into a safety artifact as a limitation is the same error class as a vacuous pass pointing the other way.The reproduction, on the reported host as the reported account, before repairing anything (lockdir still
pi:pi drwxrwxr-x):--statusUNKNOWN — Permission denied, json"state":"unknown"+whyCLAIMED, empty holderCANNOT DETERMINE, "Nothing was run. This is NOT 'the device is busy'"BUSY--self-testPRECONDITION FAIL+CANNOT RUN (this says nothing about the locking)Negative control — same binary, same host, same account, one variable changed (a writable lockdir): status exit 0, claim exit 0, wrapped command ran, modes came out
drwxrwxrwt/-rw-rw-rw-. Half two's modes measured, not asserted.After repair, as
r: status all free exit 0;--self-testPASS 7/7 including contender-while-held, after-holder-CRASHED and the opposite-order deadlock pair; a real claim with--require-claimsatisfied inside it; a second agent refused exit 3 with a live holder record{"pid":7628,"purpose":"holder"}— the property the defect destroyed, BUSY meaning busy again; lock file stillpi:pi 0666, sopiis not locked out.The execution half is fixed too, not just diagnosed. Installed at
/usr/local/bin/with-device(0755) on all three hosts, so the exit-126 failure —rcould not traverse/home/pito even execute the old copy — cannot recur. gale.local and wohl.local both self-test PASS asr. The interlock is live on the whole bench for the standard account, which also unblocks AFD-113's F100 re-run on gale.A second defect of the same class, found by that verification
On fourpi the fixed
--statusprintedUNKNOWNfor all six devices and exited 0. That is #280 again in the exit code, and unlike the original it fails open: a caller that branches on the status, or greps forCLAIMEDand finds none, reads "the board is clear" out of "nothing could be read".--statusnow exits 4 when any device is unreadable, withN of M device(s) could not be read — this listing is INCOMPLETE, not a clean boardon stderr. Exit 0 now means the scan answered for every device, free or claimed. The help text's exit-code table said "0 … or success for--status" and is corrected.Mutation test: changing that branch back to
exit(0)fails the new test on the exit code whileUNKNOWNstill prints — the test pins the code, not the message. Its control (--statusover a genuinely claimed device must still exit 0) is insensitive to the same mutation, which is what stops "exit 4 on anything" from passing both. Re-measured on fourpi after the change: broken lockdir → 4, writable lockdir → 0.The pattern, stated because it will recur: the tri-state was added to the return value and the exit code was left behind. Fixing a could-not-run/answered-no conflation in one channel does not fix it in the others — and only running the fixed tool on the real host surfaced the one that was missed.
Totals
65 tests (30 lib + 35 behavioural),
clippy -D warningsclean,fmtclean, coverage 92.56% against the 90 floor, CLI baseline checks mirrored locally. Version 0.5.0 — an exit code is part of the interface.rivet validatePASS (586 warnings).Recorded as AFD-133.
🤖 Generated with Claude Code
https://claude.ai/code/session_017EKh2onUHpWSUiAEqWxfy2