fix(#6018): one atomicfile helper, 16 converted sites, and a guard on the common spellings - #6026
Merged
Merged
Conversation
… the common spellings The tree wrote files atomically by hand, ~48 times, as `tmp := path + ".tmp"` / write / rename. The temp name is DETERMINISTIC PER DESTINATION, so it is shared by every writer aiming at that destination: two concurrent writers both O_TRUNC and interleave into one temp inode, and each renames a torn file into place. The damage is not the lost write, it is the garbled one that the next reader treats as truth — and where the rename error is discarded (several best-effort caches do that deliberately) the collision is invisible at runtime. This had been independently diagnosed and point-fixed three times without anyone generalising: internal/statusfile (review #5734), internal/daemon/watchreg, and internal/links (#5978). internal/daemon/sched/subprocess_runner.go names it a "known hazard" and argues its own path is safe via the scheduler's exclusive heavy-stage token — correct, but a containment argument resting on an unrelated invariant holding forever. internal/atomicfile ------------------- WriteFile(path, b, perm) creates a UNIQUE temp via os.CreateTemp in path's own directory (same filesystem, so the rename stays atomic), writes, chmods to perm, renames, and removes the temp on every error path. Modelled on #5978's writeFileAtomic; this is now the shared one. Two traps from #5978 are handled once, here, instead of 48 times: - os.CreateTemp always creates 0600, so the Chmod is load-bearing and easy to delete without breaking anything visible (deleting it left the whole of #5978's package green). TestWriteFile_AppliesPerm asserts os.Stat().Mode().Perm() for 0600/0644/0755; deleting the Chmod fails 6 of the package's tests. - MODE / BEHAVIOUR CHANGE: perm is NOT umask-masked. os.WriteFile passed perm through open(2); Chmod does not. Under umask 077 a converted destination widens from 0600 to 0644. Same decision as #5978 (reading syscall.Umask is process-global and racy, and there is no read-only form), documented on the helper and pinned by TestWriteFile_PermNotUmaskMasked so it is not re-litigated per call site. Three further ways this differs from os.WriteFile are documented on the package and pinned by tests. All follow from rename-over-destination, so the code being replaced already behaved this way — but the call sites still LOOK like os.WriteFile, so they are written down: the destination directory must already exist (no MkdirAll; call sites keep their own), a SYMLINK destination is replaced rather than written through, and a 0444 destination is overwritten where os.WriteFile fails EACCES. ORPHANED TEMPS — a new, accepted cost. A deterministic path+".tmp" was self-healing across crashes: the next write truncated the orphan. Unique names give that up; a process killed between CreateTemp and Rename leaves a distinct `.<name>.tmp-<random>`, and nothing sweeps `*.tmp-*` in these directories. The leak is bounded by crash frequency and the files are small, so it is accepted rather than fixed — but #5978 made this same trade silently and it is now written down on the package. Converted (16 sites, 13 files) ------------------------------ internal/registry/registry.go saveTo, SaveGroupConfig — highest value. Written by BOTH the CLI and the daemon with no cross-process lock; a torn registry is a broken install, not a recoverable cache miss. internal/graph/genpath.go WriteCurrentPointer, WriteCurrentPointerRaw (now sharing flipCurrentPointer) and WriteGenGraph. The `current` pointer is written by the indexer and read cross-process by the daemon and MCP to LOCATE the active graph; both writers built the same fixed "current.tmp" in the same shared dir, so a tear renames a garbled gen name into place and readers then resolve to a nonexistent or wrong generation. internal/mcp/docstate.go SaveDocgenState — parallel agent sessions. internal/daemon/mode/mode.go SaveConfig — CLI + daemon, no lock. internal/daemon/pins.go save — s.mu serialises only ONE process. internal/daemon/dead_letters.go recordDeadLetter — concurrent workers. internal/daemon/root_manifest.go WriteRootManifest. internal/daemon/sched/rss.go Record — errors stay DISCARDED (best-effort budget calibration); only the tearing is fixed. internal/daemon/worktree/worktree.go save. internal/daemon/algo/cache.go cache write. internal/daemon/watch/quarantine.go writeQuarantineFile AND persistLocked — these two aimed at the same destination from different goroutines. internal/docgen/llm_cache.go WriteCache. internal/graph/graph.go writeJSONAtomic (the stats sidecar). Error handling is preserved per site, including the deliberate discards. Five incidental consequences worth flagging for review: - Errors that were previously reported as separate "write tmp" / "rename" strings are now one message naming the destination (docstate, algo cache, llm_cache, mode, genpath, sidecar). No caller or test matches on those strings — verified by grep. - quarantine.persistLocked previously audited a staging-write failure but silently dropped a rename failure; both now audit as "persist-error". - worktree.save's #5675 retry-once now covers the rename as well as the staging write, which the split form did not. Same transient class. Its test could no longer seed `path+".tmp"` to force the first attempt to fail (that only worked BECAUSE the name was deterministic), so save() now goes through a `writeStoreFile` var seam and the test injects a one-shot failure. A second test pins that the retry is exactly one retry. The seam is an unsynchronised package var read from reconcile goroutines; that is documented on it, and the swapping tests are non-parallel. - genpath's pointer flip likewise now retries the write as well as the rename. A hard failure (missing/unwritable dir) therefore takes ~200ms (40 x 5ms) to surface instead of failing immediately — irrelevant next to a publish that cannot complete at all. Documented on flipCurrentPointer. - graph.go's writeJSONAtomic used json.NewEncoder against the open temp, but encodes ONE small in-memory sidecar value, so it became json.Marshal + the helper. json.Encoder.Encode appends a trailing newline and json.Marshal does not; the newline is added back explicitly. NO existing test covered those bytes, so sidecar_bytes_6018_test.go now pins the trailing newline, the pretty/minified forms byte-for-byte against a live json.Encoder, and the mode. NOT converted, and why ---------------------- The ordering rule is EXPOSURE, not convenience. internal/graph/graph.go WriteAtomic — encodes the WHOLE graph Document into the open file. Converting means marshalling to []byte first, doubling peak memory on the largest object in the process. That is a MEMORY argument, not a "it streams" one; it needs a writer-callback variant of the helper. internal/graph/fbwriter/* — genuinely streaming: the flatbuffer is built incrementally against an open handle and never exists as one []byte. internal/install/*, internal/cli/register.go, internal/agentpatterns/* — install-time / interactive single-writer paths. internal/enrichment/*, internal/dashboard/*, internal/embed/store.go, internal/indexer/diff/diff.go, internal/agents/inject_map.go, internal/graph/manifest.go, internal/cli/pendingtools.go — plausible follow-ups, left out to keep this reviewable. None is written by more than one process today. Already safe, left alone: internal/statusfile, internal/daemon/watchreg, internal/links (all three already use os.CreateTemp), and the pid-suffixed flows temp in subprocess_runner.go. The guard, and what it does NOT cover ------------------------------------- internal/atomicfile/guard_6018_test.go walks every non-test .go file and fails on any of THREE spellings outside notYetConverted: `<expr> + ".tmp"`, `<expr> + tmpSuffix` (file-local string const), and `fmt.Sprintf("%s.tmp", ...)`. The scan is AST-based, not grep, so a ".tmp" in a comment, a map key (daemon/watch/skip.go) or a strings.HasSuffix argument does not trip it. It ends ONE FAMILY OF SPELLINGS, not the class. It does NOT catch a different suffix (`path + ".partial"`), a fixed name in a shared directory (`filepath.Join(filepath.Dir(path), "state.tmp")` — the same bug with no concatenation at all, and the likeliest way this re-enters), a suffix const from another file of the package, or a suffix reaching the concat through a variable. A scan found zero live instances of any of these, so nothing is exploited today. All four are listed in the file doc AND carried as `want: 0` rows in TestGuardDetectsTheShape, so the table states the real reach instead of only showing what passes. notYetConverted is an explicit 24-file debt ledger that may only SHRINK — the test ALSO fails when an entry no longer offends, because a stale entry would leave the guard permanently blind to that file. Mutation results (every new test defect-checked before commit) ------------------------------------------------------------- 1. delete the Chmod from WriteFile -> 6 FAIL 2. os.CreateTemp -> os.Create(path+".tmp") -> ConcurrentWriters FAIL, iteration 0 3. drop the remove-temp-on-error defer -> RemovesTempOnError FAIL 4. revert registry.saveTo to the old shape -> guard FAIL at registry.go:305 5. add a converted file to notYetConverted -> guard FAIL as stale 6. blind the detector (token.ADD -> token.SUB) -> guard FAIL 5x, including the "scan found nothing at all" assertion — the vacuously-green failure mode is itself covered 7. remove worktree.save's retry -> both persist_retry tests FAIL 8. revert genpath's pointer flip -> guard FAIL at genpath.go:419 9. blind the fmt.Sprintf detector branch -> GuardDetectsTheShape FAIL 10. blind the const-suffix detector branch -> GuardDetectsTheShape FAIL 11. drop the sidecar trailing newline -> 6 FAIL (this one initially passed unnoticed, which is why sidecar_bytes_6018_test.go exists) 12. drop MarshalIndent in pretty mode -> ByteIdenticalToEncoder/pretty FAIL TestDeterministicTempName_FailsConcurrently is a negative control: it runs the same 8-writer harness against the OLD idiom and FAILS THE BUILD if that ever stops tearing, so the positive concurrency test cannot go quietly vacuous. Verification ------------ go build / go vet / gofmt -l clean. `go test ./... -count=1` exits 1: 229 packages, ONE failing test, TestComputeCentrality_ByteIdenticalOnSampledPath. That failure is PRE-EXISTING and unrelated (tracked as #6024) — confirmed here by restoring internal/graph to its 591a407 sources and reproducing the identical `centrality key count: legacy=4000 new=852`. This commit introduces no failures. -race clean on every touched package (atomicfile, graph apart from #6024, daemon/worktree, registry, mcp, daemon, daemon/watch, daemon/algo, daemon/sched, daemon/mode, docgen). NOTE ON THE TOOLING: the repo's `rtk` wrapper truncates `go test ./...` output at 10 MiB, drops the FAIL lines, and prints a green "72 packages" summary over a run that exits 1 with 229 packages. The numbers above come from `rtk proxy go test ./... -count=1` and its real exit code. Full-suite claims on this repo must not come from the wrapper summary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0111KEDUVWEWm9G5RRnJqXft
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.
The class
Code across the tree wrote files atomically as
tmp := path + ".tmp"→ write →os.Rename. The temp name is deterministic per destination, so two concurrent writersO_TRUNCand interleave into the same tmp inode, then each renames a torn file into place. The failure is not a lost write — it is a garbled one that a later read treats as truth.This had been independently diagnosed and fixed three times without anyone generalising it:
internal/statusfile/statusfile.go(citing review #5734),internal/daemon/watchreg/watchreg.go, andinternal/links(#5978). A fourth site,internal/daemon/sched/subprocess_runner.go:775, names it a "known hazard" and argues its own path is safe via a scheduler invariant — correct local reasoning, but a containment argument that depends on an unrelated invariant holding forever.What this adds
internal/atomicfile.WriteFile(path, b, perm)—os.CreateTempin the destination directory (so rename stays on one filesystem), write,Chmod, rename, remove-temp on every error path including a failed rename.Both traps from #5978 are handled once, in the package, rather than 48 times at call sites:
Chmodis load-bearing (os.CreateTempcreates 0600) and is pinned by a test assertingos.Stat().Mode().Perm()across 0600/0644/0755. In concurrent link passes share a deterministic .tmp filename → rename ENOENT drops a pass's links #5978 deleting the chmod left the whole package green until an assertion was added; that trap is now closed at the source.permis deliberately not umask-masked, unlikeos.WriteFilewhich open(2) masks. Underumask 077files widen 0600 → 0644. Restoring masking would mean readingsyscall.Umask, which is process-global, write-only and racy; a mode that does not depend on whether the daemon, the CLI, or a child ran the write is the better property for per-user state. Documented and pinned.Two further divergences from
os.WriteFileare documented and pinned by tests: a symlink destination is replaced rather than written through, and a 0444 destination is overwritten whereos.WriteFilerefuses. Neither is a regression — the code being replaced was already rename-based.It deliberately does not
MkdirAll:os.CreateTempfails on a missing directory exactly whereos.WriteFilewould, so every call site keeps its existing setup unchanged. Pinned, so nobody assumes otherwise.16 sites converted
registry/registry.go(x2),mcp/docstate.go,graph/genpath.go(x3),graph/graph.go,daemon/mode/mode.go,daemon/pins.go,daemon/dead_letters.go,daemon/root_manifest.go,daemon/sched/rss.go,daemon/worktree/worktree.go,daemon/algo/cache.go,daemon/watch/quarantine.go(x2),docgen/llm_cache.go.The two sites #6018 named as reachable today are both handled:
registry.go(written by CLI processes and the daemon with no cross-process lock — a torn registry is a user-visible broken install) andmcp/docstate.go(parallel agent sessions, the workload this project is being optimised for).sched/rss.gokeeps its discarded errors verbatim. That is a deliberate best-effort design and not this sweep's to change; only the tearing is fixed.Risk ordering, corrected on review
The first cut excluded all of
internal/graph/*as "streams into an open file handle rather than writing one[]byte". That rationale was wrong for the most exposed writer in the tree.genpath.go'sWriteCurrentPointer/WriteCurrentPointerRawwrite a single small[]byte— a one-line drop-in — and they publish the graph generation pointer, written by the indexer and read cross-process by the daemon and MCP to locate the current graph. Both built the same fixed temp path in the same shared directory; a torn write there sends readers to a wrong or nonexistent generation. That is a strictly worse blast radius than the two best-effort caches the first cut converted while admitting their concurrency was unproven.Reviewing that file turned up a third site,
WriteGenGraphat:527, which publishes the graph file the pointer points at.genpath.gonow leaves the ledger entirely.graph.go:580writeJSONAtomicis also converted — it usedjson.NewEncoder(f)but encodes one in-memory value. The blanket rationale is replaced with per-file reasoning, andWriteAtomic's exclusion is restated as what it actually is: marshalling the whole Document to[]bytewould double peak memory on the largest object in the process. A memory argument, not a streaming one.One defect caught by mutation, not by review
json.Encoder.Encodeappends a trailing newline;json.Marshaldoes not. The newline was restored — and mutation-testing showed that deleting it kept the entiregraphpackage green. No test covered those bytes. That is precisely the #5978 deleted-chmodshape, one package over, and it would have shipped.sidecar_bytes_6018_test.gonow pins the newline, both pretty and minified forms byte-for-byte against a livejson.Encoder, and the mode. Independently verified: removing the newline failsTestWriteSidecar_TrailingNewlineandTestWriteSidecar_ByteIdenticalToEncoder.The guard — what it does and does not catch
An AST-based test walks every non-test
.gofile in the repo (cmd/,tools/,site/included) and fails on the deterministic-temp shape outside an explicit ledger. The ledger only shrinks: the test also fails when an entry stops offending, so a stale entry cannot silently blind it.Caught:
path + ".tmp", raw-string form,filepath.Join(dir, name+".tmp"),fmt.Sprintf("%s.tmp", path), and file-local const suffixes.Not caught, and recorded as
want: 0rows in the test table rather than omitted: a different suffix (.partial), a fixed name in a shared directory (the identical hazard with no concatenation at all), a cross-file const, and a variable-borne suffix. A scan found zero live instances of any of these today.The subject line no longer claims to "end the class" — it says a guard on the common spellings, because that is what it is. The file doc states that a green run means no new instance of the covered spellings, not a clean tree.
Verification
go build ./... && go vet ./... && gofmt -l .clean.-raceclean on all touched packages. Full suite exit 0, 229 packages, zero failures — taken fromrtk proxy go testand the raw exit code, not thertksummary line, which truncates above 10 MiB and prints a green summary over a red run. An earlier revision of this branch reported the suite green on that basis when it was not; the wrapper's unreliability is noted in the commit message.12 mutations, all confirmed failing — including deleting the
Chmod, revertingos.CreateTemptoos.Create(path+".tmp")(fails the concurrency harness on iteration 0), dropping the remove-on-error defer, reverting a converted site, adding a non-offending ledger entry, and blinding the detector (which trips a "scan found nothing at all" assertion, covering the guard-that-does-not-bind case explicitly).TestDeterministicTempName_FailsConcurrentlyis a negative control running the same 8-writer harness against the old idiom, failing the build if it ever stops tearing.Changes beyond temp-naming, all deliberate
TestStoreSave_RetriesTransientTmpFailureforced a transient failure by pre-creatingpath+".tmp"as a directory — which worked only because the name was deterministic. AwriteStoreFilevar indaemon/worktree/worktree.golets the test inject a one-shot failure instead. Reviewed and accepted: no non-racy alternative exists short of an error-injecting filesystem, and the rewritten test is stronger — it assertscalls == 2, the first direct proof the retry happens and is exactly one, plus a new test covering the both-attempts-fail path that had no coverage before. Carries a note that it must not be swapped concurrently.quarantine.persistLockednow audits rename failures it previously dropped — the function has no return value, so no caller can newly fail.worktree.save's retry now covers the rename, the same transient class Monorepo + git worktrees:missing .gitfalse positive on setup, then the daemon melts down re-indexing every worktree (andMAX_WORKTREES_PER_REPO=0won't disable it) #5675 targeted.Left for follow-up
~32 sites remain, on the ledger with per-file reasoning:
graph/fbwriter/*(genuinely streaming — they need a different helper shape, and their destinations are unique per generation, though that gen-allocation exclusivity is argued rather than verified),install/*,cli/register.go,agentpatterns/*, and the dashboard/enrichment group.Already safe and untouched:
statusfile,daemon/watchreg,links(all onos.CreateTempalready), and the pid-suffixed flows temp insubprocess_runner.go.One new accepted cost: with deterministic names, a temp orphaned by a crash was overwritten by the next write — self-healing. Unique names mean each crash leaves a distinct
.X.tmp-<rand>and no sweeper exists for*.tmp-*. Bounded by crash frequency, documented on the package. #5978 made the same trade silently.Independently adversarially reviewed.
Closes #6018