Fix atomic write, symlink-loop abort, and concurrency=0 hang in adr-bot - #811
Open
judyks wants to merge 7 commits into
Open
Fix atomic write, symlink-loop abort, and concurrency=0 hang in adr-bot#811judyks wants to merge 7 commits into
judyks wants to merge 7 commits into
Conversation
… crash fs::write truncates the target before writing, so a crash mid-write (SIGKILL, disk full) left CLAUDE.md/AGENTS.md truncated — permanently losing any hand-written prose outside the managed markers, since this tool never authored that content and can't regenerate it. Write to a temp file in the same directory and rename into place instead.
…alysis walk_dir recursed on path.is_dir(), which follows symlinks, with no is_symlink() guard — a symlink cycle in the repo caused unbounded recursion and a stack-overflow abort (uncatchable, takes down the whole run). Mirrors the guard rule_resolver.rs::walk_dir already uses.
Semaphore::new(0) never grants a permit, so a hand-edited `concurrency: 0` in config.yaml hung every project for the full per-project timeout (10 minutes default) and then reported RunnerTimeout, which points the user at their LLM runner instead of their config. Validate concurrency the same way batch_size already is.
Changing the read-only-target test to expect success (atomic rename bypasses target permissions) orphaned the write-failure branch in write_files — nothing exercised it anymore. Add a test where the temp file itself can't be created (read-only parent dir). Similarly, the symlink-skip guard in walk_dir now intercepts the broken symlink the old test used to reach the "neither dir nor file" fallthrough, before it gets there. Add a Unix-socket entry (not a symlink) to exercise that branch directly.
Recursive removal of an empty directory only needs write permission on its parent, not on the directory itself — verified empirically (rm -rf succeeds through an empty chmod 0555 dir). TempDir cleans up fine without restoring the "locked" subdir's permissions first.
Both spawn sites in check_auth_async and check_auth_async_no_json called Command::spawn() directly, unlike every other spawn in the codebase (probe.rs, subprocess.rs, cursor_cli.rs), which all go through runner::util::spawn_with_etxtbsy_retry. A freshly written executable can transiently return ETXTBSY when a concurrent fork in another thread still holds a writable fd across the fork -> exec window. The auth tests hit this: create_fake_binary writes a shell script, chmods it, then immediately execs it. When the race fires, the bare spawn maps the error to RunnerFailed, so test_run_auth_invalid_json sees RunnerFailed where it asserts RunnerOutputParse and the suite fails with a single flaky failure out of ~3000. Route both spawns through the existing retry helper. Also give the assertion a message that prints the actual error, matching the style used by its siblings at the no-json spawn/timeout tests; the bare assert!(matches!(..)) is why CI never showed which error was returned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZVHeWURrDsXpyo9gMLLXd
The switch to atomic writes changed behaviour that no test was guarding: rename(2) needs write permission only on the containing directory, not on the target file, so write_files would silently replace a CLAUDE.md the user had deliberately chmod'd read-only. The plain fs::write it replaced could not do that. The read-only test was updated to expect success, which documented the regression rather than preventing it. Probe the target for writability before persisting the temp file. Opening with write(true) and no truncate leaves the file untouched and reproduces fs::write's own decision, so ACLs, non-owner cases, and read-only mounts all resolve the way they did before. Flip the test back to asserting Failed, and additionally assert the target is left byte-for-byte intact. Verified as an unprivileged user (root bypasses DAC permission checks, so this class of test cannot be exercised as uid 0): 3014 passed, 1 failed — the single failure being a git "dubious ownership" error from running as a non-owner, unrelated to these changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZVHeWURrDsXpyo9gMLLXd
judyks
force-pushed
the
atomic-writes-fixes
branch
from
July 27, 2026 22:32
a2c0419 to
359871c
Compare
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.
Problem
Four latent bugs in
adr-bot, none of which surface in normal use:write_filesusedfs::write, which truncates before writing.merge_contentcarries through prose written outside the managed markers, so a crash mid-write (SIGKILL, disk full) leftCLAUDE.mdtruncated — and that content is unrecoverable, since the tool never authored it and cannot regenerate it.signals::pipeline::walk_dirrecursed onpath.is_dir(), which follows symlinks, with no depth cap or visited set. A symlink cycle in the repo recursed until the process aborted.ConcurrentTailoringConfig::newrejectedbatch_size == 0but notconcurrency. A hand-editedconcurrency: 0reachedSemaphore::new(0), hanging every project for the full per-project timeout (600s default) before reporting a misleadingRunnerTimeout..spawn()calls inauth.rswere not wrapped inspawn_with_etxtbsy_retry, unlike the five other spawn sites. Test fixtures write a script and immediately exec it, so a concurrent fork holding a write fd across fork→exec returnedETXTBSYand surfaced the wrong error variant.Changes
generation/writer.rsanalysis/signals/pipeline.rsentry.file_type(), mirroring the existing guard inrule_resolver.rs.tailoring/concurrent.rsconcurrency == 0withConfigError, matching the existingbatch_sizeguard.cli/commands/auth.rsspawn_with_etxtbsy_retry.Impact
No change to the normal path. Writes are now crash-safe, a symlinked repo no longer aborts analysis, and an invalid
concurrencyfails immediately with an actionable message instead of hanging for ten minutes.For the reviewer
rename(2)requires write permission on the containing directory, not on the target file — so an atomic write would silently replace aCLAUDE.mdthe user had deliberately made read-only, which the previousfs::writecould not do.write_filesnow probes the target withOpenOptions::write(true).open()(no truncate, so the file is untouched) and refuses. This preserves the prior behavior rather than accepting the rename semantics.auth.rschange is unrelated to the rest of this PR. It fixes a pre-existing flake that was failing CI on this branch, not a regression introduced here. Straightforward to split out if preferred.setpriv --reuid=65534: 3014 passed, 1 failed — that one failure being git's "dubious ownership" error from running as a non-owner of the checkout, not a code issue.Test plan
cargo test --lib,cargo clippy --lib --all-features,cargo fmt --check— all green in CItest_write_to_readonly_file_returns_failed,test_write_fails_when_temp_file_cannot_be_created,collect_source_files_skips_symlink_cycle,walk_dir_skips_entries_that_are_neither_dir_nor_file,test_config_new_zero_concurrency_returns_config_error