Skip to content

Fix atomic write, symlink-loop abort, and concurrency=0 hang in adr-bot - #811

Open
judyks wants to merge 7 commits into
mainfrom
atomic-writes-fixes
Open

Fix atomic write, symlink-loop abort, and concurrency=0 hang in adr-bot#811
judyks wants to merge 7 commits into
mainfrom
atomic-writes-fixes

Conversation

@judyks

@judyks judyks commented Jul 27, 2026

Copy link
Copy Markdown

Problem

Four latent bugs in adr-bot, none of which surface in normal use:

  • Data loss. write_files used fs::write, which truncates before writing. merge_content carries through prose written outside the managed markers, so a crash mid-write (SIGKILL, disk full) left CLAUDE.md truncated — and that content is unrecoverable, since the tool never authored it and cannot regenerate it.
  • Stack-overflow abort. signals::pipeline::walk_dir recursed on path.is_dir(), which follows symlinks, with no depth cap or visited set. A symlink cycle in the repo recursed until the process aborted.
  • Ten-minute hang. ConcurrentTailoringConfig::new rejected batch_size == 0 but not concurrency. A hand-edited concurrency: 0 reached Semaphore::new(0), hanging every project for the full per-project timeout (600s default) before reporting a misleading RunnerTimeout.
  • CI flake. Two .spawn() calls in auth.rs were not wrapped in spawn_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 returned ETXTBSY and surfaced the wrong error variant.

Changes

File Change
generation/writer.rs Write to a temp file in the same directory, then rename into place. Probe target writability first (see below).
analysis/signals/pipeline.rs Skip symlinks via entry.file_type(), mirroring the existing guard in rule_resolver.rs.
tailoring/concurrent.rs Reject concurrency == 0 with ConfigError, matching the existing batch_size guard.
cli/commands/auth.rs Route both spawns through spawn_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 concurrency fails immediately with an actionable message instead of hanging for ten minutes.

For the reviewer

  • One deliberate behavior decision. rename(2) requires write permission on the containing directory, not on the target file — so an atomic write would silently replace a CLAUDE.md the user had deliberately made read-only, which the previous fs::write could not do. write_files now probes the target with OpenOptions::write(true).open() (no truncate, so the file is untouched) and refuses. This preserves the prior behavior rather than accepting the rename semantics.
  • The auth.rs change 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.
  • Permission-based tests cannot be verified as root, which bypasses DAC permission checks. Verified under 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 CI
  • New/updated tests: test_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

judyks and others added 7 commits July 27, 2026 15:32
… 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
judyks force-pushed the atomic-writes-fixes branch from a2c0419 to 359871c Compare July 27, 2026 22:32
@judyks judyks added bug Something isn't working rust Pull requests that update rust code and removed bug Something isn't working rust Pull requests that update rust code labels Aug 5, 2026 — with Claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants