test(transport-ssh): stop fake_ssh racing ETXTBSY against sibling forks - #87
Open
hartsock wants to merge 1 commit into
Open
test(transport-ssh): stop fake_ssh racing ETXTBSY against sibling forks#87hartsock wants to merge 1 commit into
hartsock wants to merge 1 commit into
Conversation
The unix_process tests failed intermittently with ExecutableFileBusy at the spawn sites — 12/20 parallel runs on gnuc (ext4 /tmp), 0/4 serial. Different tests failed on different runs, so this blocked the pre-push gate on any branch, including docs-only ones. WHAT: fake_ssh no longer writes the file it later execs. The script bytes go to a staging path that is never executed, and a child process (/bin/cp) creates the exec target. WHY: Linux tracks write access as i_writecount per open-file-description, not per descriptor. libtest runs the four #[tokio::test]s as threads sharing one descriptor table, and glibc's posix_spawn issues clone3 WITHOUT CLONE_FILES, so a sibling test thread's spawn duplicates the whole table. That duplicate keeps this thread's write-side struct file alive past its own close() — f_count 2->1, so __fput() never runs, so put_write_access() never runs, so i_writecount stays 1 — and the subsequent execve of that same inode is refused by deny_write_access(). O_CLOEXEC does not help: it fires at the forked child's exec, not at fork. Delegating the write removes the precondition instead of retrying around it, and the ordering is a kernel guarantee rather than a probability: status() returns only once the copier has exited, and do_exit() runs exit_files() then exit_task_work() — which flushes the deferred __fput — before exit_notify() releases our wait. No descriptor for the executable ever exists in this process to be inherited. Production is untouched and was never exposed: the only production path is SshTransport::bind -> OpenSshClient::system() -> /usr/bin/ssh, and set_mode(0o7xx) appears exactly once in the workspace, in this test module. Rejected alternatives: a retry at the production spawn site (masks a true alarm — ETXTBSY there means someone holds a write fd on that inode right now — only reduces rather than eliminates, and stalls the transport-wide outbound_connect mutex); sync_all() before spawn (measured 54-176x WIDER window, since it holds the descriptor open across the fsync); rename() into place (no effect — the leaked duplicate is on the inode); a static Mutex or --test-threads=1 (hides it). Measured: BEFORE 12/20 parallel runs failed; AFTER 0/30. Full crate 42 passed, 5/5 runs. Refs #86
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.
Summary
agent-mesh-transport-ssh'sunix_processtests failed intermittently withExecutableFileBusyat the.expect("spawn")sites, blocking the pre-push gate onany branch — this was found on a docs-only diff that touches no Rust.
fake_sshno longer writes the file it laterexecs. The script bytes go to astaging path that is never executed, and a child process (
/bin/cp) creates theexec target.
Production diff is empty. Production was never exposed: the only production
path is
SshTransport::bind→OpenSshClient::system()(transport.rs:164) →/usr/bin/ssh, andset_mode(0o7xx)appears exactly once in the whole workspace —inside
mod tests > mod unix_process.Fixes #86
Root cause
Linux tracks write access as
i_writecountper open-file-description, not perdescriptor.
libtestruns the four#[tokio::test]s as threads in one processsharing one descriptor table, and glibc's
posix_spawnissuesclone3withoutCLONE_FILES— so a sibling test thread's spawn duplicates the whole table.While thread A is inside
fs::write(&executable, ..), that duplicate keeps A'swrite-side
struct filealive past A's ownclose():f_count2→1, so__fput()never runs, so
put_write_access()never runs, soi_writecountstays 1 — and A'ssubsequent
execveof that same inode is refused bydeny_write_access().O_CLOEXECis irrelevant: it fires at the forked child'sexec, not atfork.The interference is not cross-inode. Each test has its own tempdir; the write
and the exec are both on A's own
fake-ssh. Only the fork is cross-test, andthe forking thread's target program is irrelevant.
Delegating the write removes the precondition rather than retrying around it, and
the ordering is a kernel guarantee rather than a probability:
status()returnsonly once the copier has exited, and
do_exit()runsexit_files()thenexit_task_work()— which flushes the deferred__fput— beforeexit_notify()releases our
wait4. No descriptor for the executable ever exists in this processto be inherited.
Rejected alternatives
ETXTBSYat the production spawn siteETXTBSYthere means someone holds a write fd on that inode right now, in a module whose docs stress the child is an unauthenticated carrier. Only reduces (a child pinning the fd past the retry budget defeats it — measured: 600 ms hold → still refused after 10 attempts). Also stalls the transport-wideoutbound_connectmutex held acrossconnect().sync_all()before spawnfs::writealready closes before returning.)rename()into placerenameonly rewrites a directory entry. Measured 54/1600 vs 62/1600 naive.flockhandshakeunsafe(or pushes MSRV 1.75→1.89 viaFile::lock), and a blockingLOCK_EXon an fd whose duplicate leaked into a long-lived child turns a retryable error into a silent hang.Mutex/--test-threads=1Test plan
Measured on gnuc, ext4
/tmp, rustc 1.97.1, 16 cores. The failure rate isfilesystem-dependent — on tmpfs (
/dev/shm) the pristine code did not reproduceat all (write window 5 µs vs 26 µs), so a green run on a memory-backed
/tmpprovesless than it looks.
unix_process, ×20ExecutableFileBusyunix_process,--test-threads=1unix_process(5 tests), ×30cargo fmt --all -- --checkcargo clippy --all-targets -- -D warnings.githooks/pre-push(fulljust check+cov-ci)On the regression test
fake_clients_are_executable_under_concurrent_fork_pressureis probabilistic bynecessity, and its doc comment says so. The race window is the
open/closepair inside
fs::write, so no safe-Rust test can schedule a siblingforkinsideit on demand. What it does is drive the window to be hit with overwhelming
probability — 4 writer threads × 40 scripts against 4 forker threads churning
fork+exec— and assert not one spawn is refused. P(clean run before the fix)≈ 3 × 10⁻⁷ on this box. It is not a proxy: it exercises the exact defect through
the exact helper, and fails hard if
fake_sshregresses to an in-process write.It asserts on
ETXTBSYonly — a saturated machine may legitimately refuse aforkwith
EAGAIN, and failing on that would trade one flake for another.Cost: 0.06 s, self-terminating.
Residual risk
/tmpwith a memory-backed emptyDir.
posix_spawnthere closesCLOEXEC descriptors in-kernel at spawn. The change is inert-but-harmless there.
/bin/cpin the test environment — coreutils, and the suitealready hard-codes
/bin/killand/bin/sleep. Fails loudly, not mysteriously.agent-bridle
agent-bridle-core/src/sandbox.rs:3440, where the test asserts an execis denied, so an
ETXTBSYmasquerades as a pass and silently degrades asecurity proof.
Prior art
newt-agent hit the same lineage (#288 → PR #290 → PR #293) and took a retry
approach. This PR takes the structural fix instead, for the reasons in the table above.
🤖 Generated with Claude Code