Skip to content

fix(#5978,#5999): unique temp names for link-pass atomic writes; cancel heavy batch children by process group - #6020

Merged
cajasmota merged 2 commits into
mainfrom
worktree-agent-a9bdc183a890ef30d
Jul 27, 2026
Merged

fix(#5978,#5999): unique temp names for link-pass atomic writes; cancel heavy batch children by process group#6020
cajasmota merged 2 commits into
mainfrom
worktree-agent-a9bdc183a890ef30d

Conversation

@cajasmota

Copy link
Copy Markdown
Owner

Two independent fixes to the daemon's heavy-batch path, both found to be worse than originally filed.

#5978 — the link pass's atomic writes collide

internal/links/candidates.go and string_pass.go built temp files as path + ".tmp": deterministic per destination. Two concurrent writers therefore O_TRUNC and 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 pass reads back as truth.

Fixed with writeFileAtomic (os.CreateTemp in the destination directory → write → chmod → rename, remove-on-error).

Behaviour change, stated deliberately: os.WriteFile(tmp, b, 0o644) was umask-masked by open(2); os.CreateTemp + os.Chmod is not. Under umask 077 these files widen from 0600 to 0644. Restoring masking would mean reading syscall.Umask, which is process-global and racy; a mode that does not depend on whether the daemon, the CLI, or a child ran the pass is the better property for per-user state under the grafel home. Documented on the helper and here rather than left to be discovered.

Note on the sinks, since an earlier draft of this message got it wrong: writeDoc — the actual cross-repo links output — always returned its error, so a dropped write there was already reported. string_pass.go's scan cache still discards its error deliberately (best-effort, keyed on mtime+size; a miss costs a rescan). What neither could tolerate was the torn entry.

#5999 — the heavy batch children were cancelled by pid, not by process group

exec.CommandContext's default Cancel is Process.Kill() — a single-pid kill. The child's own children survived. applyGroupAlgoNice now sets Setpgid: true and wires cmd.Cancel to syscall.Kill(-pid, SIGKILL).

The two halves are load-bearing together, and the doc comment explaining why previously stated the wrong mechanism. Corrected: kill(-pid) addresses the group whose PGID equals that pid — not the caller's group. Without Setpgid that group normally does not exist, so the kill returns ESRCH and the fallback quietly papers over it, meaning the group kill never binds at all. The real residual hazard is pid reuse aliasing onto an unrelated group's PGID.

Verification

Both fixes were confirmed to bind, by mutation rather than inspection:

mutation result
restore deterministic path + ".tmp" both tests fail on iteration 1, torn destinations
drop applyProcessGroupCancel 10/10 deterministic failures
drop only Setpgid (keep the group kill) fails 3/3 — ESRCH, falls through safely
delete os.Chmod(tmp, perm) passed clean until this PR added a mode assertion

That last row is the interesting one. The mode was unbound, so a future edit could have silently dropped these files to 0600 with the suite green. TestWriteFileAtomicAppliesRequestedMode now asserts os.Stat().Mode().Perm() for 0644 and 0600 through the helper and 0644 through the real writeDoc caller; the mutation is dead.

RunSubprocessIndex was checked on three axes before extending the group cancel to it: SIGTERM/SIGINT still reach the child, the grafel extract children write nothing to disk (they stream on stdout), and both callers are daemon-side.

Local gate: go build ./..., go vet, gofmt -l clean; GOOS=windows build/vet of internal/daemon/sched clean; 1686 tests across 22 packages; internal/links -race -count=2 (724) and CancelTerminatesChild -race -count=10 (10/10) green.

Not done here

The codebase-wide sweep. This is the third independent occurrence of the .tmp class — internal/statusfile/statusfile.go:434-441 (citing review #5734) and internal/daemon/watchreg/watchreg.go:255-261 each diagnosed and fixed it separately, and internal/daemon/sched/subprocess_runner.go:775 already names it a known hazard and argues its own path is safe via a scheduler invariant. 48 deterministic .tmp sites remain across 40 files. Tracked as #6018, deliberately out of scope here.

One unrelated flake observed and not touched: TestIndexErrorRecorded (internal/daemon/sched/scheduler_test.go:282) failed once under -race -count=4. It asserts after a fixed time.Sleep(150ms) and injects an in-process Index func, so it never reaches the subprocess path; it passed 20/20 in isolation and passed the identical run before these edits.

Independently adversarially reviewed.

Closes #5978
Closes #5999

cajasmota and others added 2 commits July 28, 2026 07:56
Both of the package's temp+rename sinks built their temp path by
concatenating ".tmp" onto the destination, so every writer aiming at one
destination shared a single temp file. Two link passes over the same
group therefore interleaved their bytes inside that file — the
destination could be left holding a torn mix of two passes' output — and
the writer that renamed second failed with ENOENT.

The two sinks fail differently, and the difference matters. writeDoc
(candidates.go), which writes the cross-repo links themselves, always
returned that ENOENT to its caller, so a lost link write was at least
reported. The scan cache in string_pass.go discards its rename error
outright and still does: it is a best-effort per-file cache keyed on
mtime+size, and a miss only costs a rescan. What was NOT tolerable there
is the torn write — a half-written cache entry the next pass would read
back as truth.

Both sinks now go through one writeFileAtomic helper that creates a
unique temp file with os.CreateTemp in the destination's own directory
(same filesystem, so the rename stays atomic), sets the requested mode,
and removes the temp file on every error path.

BEHAVIOUR CHANGE — permission bits are no longer umask-masked. os.WriteFile
passed perm through open(2), so a restrictive umask narrowed these files;
os.CreateTemp makes the file 0600 and the explicit Chmod now sets exactly
0644. Under umask 077 these destinations therefore widen from 0600 to
0644. Deliberate (they are per-user state under the grafel home, and a
mode that does not depend on which process ran the pass is easier to
reason about), documented on the helper, and pinned by a test — deleting
the Chmod leaves the mode at 0600 and fails.

The concurrency test drives eight concurrent writers at one destination
and asserts every write succeeds and the surviving file is one COMPLETE
payload. Against the old deterministic name it fails on the first
iteration with a torn destination.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111KEDUVWEWm9G5RRnJqXft
The batch children are spawned with Setpgid (applyGroupAlgoNice), so each
one already leads its own process group — but no spawn site overrode
cmd.Cancel, so cancellation fell through to exec.CommandContext's default:
a single-pid SIGKILL. Anything the child had forked survived, kept the
inherited stdout/stderr pipes open, and so kept the supervising runner
blocked in its drain-to-EOF loop with the daemon's exclusive heavy-stage
token still held. TestRunSubprocessLinks_CancelTerminatesChild was failing
2-4 runs in 10 under -race for exactly this reason.

applyGroupAlgoNice now also wires cmd.Cancel to SIGKILL the negative pid
(the whole group), with a fallback to the single-pid kill if the group
signal fails. The two are set together in one hook so no spawn site can
take the process group without the matching cancellation.

Also applied to the INDEX child, which previously had neither. It is the
one that really fans out — the extract coordinator forks a `grafel extract`
subprocess per batch — so a cancelled reindex left those grandchildren
running. Both of its call sites are inside the daemon, with no controlling
terminal, so giving it its own process group costs no signal delivery
anything relies on. Windows keeps the os/exec default (no setpgid, no
signals; a tree kill there needs a Job object) and nice_windows.go now says
so instead of leaving it implied.

The new test forks a stand-in child that backgrounds a `sleep`, records its
pid and blocks. Without the override it fails deterministically 10/10 for
both the links and the group-algo runner: the runner is still blocked 5s
after cancel and the grandchild is still alive. Several doc comments that
asserted "cancellation is cmd.Process.Kill" (and one that described it as
SIGTERM, which was never true) are corrected to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111KEDUVWEWm9G5RRnJqXft
@cajasmota
cajasmota merged commit e8979cf into main Jul 27, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant