P2 — VFS reap/lock hardening: atomic rename-then-delete reap, ctx-interruptible lock acquisition, orphan lock reclaim
Status: PROPOSED — a hardening pass over the merged W1 backend
(go/internal/vfs/localvolume.go, PR #898, RIG-3278) under the frozen P2
record p2-persistent-session-volume.md
(RIG-2395; its Plan § W1). Authoritative issue scope: RIG-3310 — the three
findings W1’s review deferred (MED-4, MED-5, LOW-2). Every go/internal/*
citation is a symbol in the RigelBuild/compass monorepo at main d3205926.
Ledger impact: none. This record changes no cross-cutting decision: the
VolumeManager interface (go/internal/vfs/vfs.go) is untouched, the
close-stamp invariants (a)/(b)/(c) and P2-GC-c/P2-GC-d are preserved as
written, and every change is internal to the LocalManager backend.
docs/designs/DECISIONS.md is untouched.
Problem / Intent
Section titled “Problem / Intent”W1 shipped a correct volume lifecycle whose reap and lock primitives are
coarse in the failure cases. Three coupled findings, all in
go/internal/vfs/localvolume.go:
- MED-5 — a reap is not atomic at the observer’s view.
reapLockeddestroys an eligible volume with oneos.RemoveAll(root). Go’sremoveAllFrom(os/removeall_at.go) deletes bottom-up and, on a per-entry failure (EACCES on a read-only subdir, EBUSY on a mount point inside the tree), keeps going, records the first error, and finally fails tounlinkatthe root because it is non-empty — so a partial reap leaves the root directory present with a gutted tree beneath it. Every existence check this package has is a stat of the root (Lookup,requireVolumeRoot,eachVolume’s marker stat), so a gutted root reads as a live volume: a concurrentAttachmounts a half-tree, and because the close-stamp lives inside the removed subtree (metaDirName), a root whose stamp was deleted but whose shell survived reads as UNSTAMPED — a live session’s — and is ineligible for re-reaping until the nextReconcileOrphansat Runner restart. - MED-4 — a blocked lock acquisition ignores its context.
AttachandStampcalllockVolume(root, true), which parks insyscall.Flock(fd, LOCK_EX). No context can interrupt that syscall, and the holder it waits behind may beExpirein the middle of a multi-GBos.RemoveAllunder the same lock. Both verbs accept acontext.Contextthat is inert pastLookup’s pre-check, so a Runner shutdown during a reap cannot drain its in-flight launches. - LOW-2 — sibling lock files leak. The per-volume lock file
(
<baseDir>/<sessionID>.compass-vfs.lock) deliberately outlives its volume (the stable-inode argument inlockVolume’s doc), so the base dir accumulates one empty file per session id ever seen on the box.eachVolumeReadDirs the base dir on everyExpireandReconcileOrphanstick, so the reaper’s scan cost grows without bound. W1’s doc names the fix — a maintenance pass that unlinks orphan lock files only while uncontended — and defers it.
This record lands the three together: rename-before-reap makes a reap
atomic at the observer’s view (the RemoveAll stays under the lock, but
it now removes a tree no session can reach); context-interruptible
acquisition bounds every remaining wait; and the orphan-lock reclaim rides
the reap pass, made safe by a lock-inode re-verify that the reclaim’s unlink
would otherwise break.
Approach
Section titled “Approach”Three internal changes to LocalManager, no interface change, one new
reserved name under the base dir. Each is stated with the code it replaces.
Code reality this record grounds on
Section titled “Code reality this record grounds on”All in go/internal/vfs/localvolume.go unless noted.
reapLockedis the sole destroyer: “It removes ONLY the volume root …if err := os.RemoveAll(root); err != nil { return fmt.Errorf("vfs: reaping expired volume %q: %w", root, err) }”. It runs insideExpire’s per-volume closure betweenlockVolume(root, false)andlock.release(), so the wholeRemoveAllexecutes with the per-volume lock held.lockVolume(root string, block bool)opensroot + lockFileSuffixwithO_CREATE|O_RDWRand callssyscall.Flock(int(f.Fd()), how)withhow = LOCK_EX(plusLOCK_NBwhen!block). Contention in non-blocking mode returns(nil, nil); blocking mode parks in the syscall.AttachandStampcall it withblock = trueand carry a now-unreachableif lock == nilguard (“Unreachable with block=true”).ExpireandReconcileOrphanscall it withblock = falseand skip onnil.- Every existence verdict is a stat of the root:
Lookup(os.Stat(root)→ErrVolumeNotFoundon not-exist or non-dir),requireVolumeRoot(the under-lock re-checkAttach,Stamp, andstampOrphanLockedshare), andeachVolume(an entry is a volume iff it is a directory containing themetaDirNamemarker dir). eachVolumeis oneos.ReadDir(m.baseDir)per call, skipping non-directories — which is how the sibling*.compass-vfs.lockfiles are kept out of the volume scan today.volumeRootis the reserved-namespace guard: it rejects empty,.,.., separator- or NUL-bearing ids, and any id ending inlockFileSuffix(“its volume root would be another session’s sibling lock-file path”).NewLocalManagerestablishes the base dir withos.MkdirAll(baseDir, volumeDirMode)and fails construction if it is not a directory.- The close-stamp lives inside the root (
stampPath(root) = <root>/.compass-vfs-meta/close-stamp.json);readStampreturns(nil, nil)for an absent file — the UNSTAMPED (live) signal. - Go’s
os.RemoveAll(os/removeall_at.go,removeAllFrom) recurses bottom-up, counts per-entry failures, keeps the first (recurseErr), and only then triesremovediraton the directory itself — so an EACCES on any leaf leaves every ancestor directory, including the root, in place. - Go’s
os.Rename(os/file_unix.go,rename) refuses to rename onto an existing directory (EEXISTvia its ownLstatcheck) and is otherwise a singlerename(2): atomic on a POSIX filesystem when source and target share it. - The house event-gate for concurrency tests is
testing/synctest(go/internal/runnerhub/router_test.go,provision_dedup_test.go;go/internal/ingest/board_reconcile_test.go) —synctest.Wait()observes “durably blocked”, never a sleep. W1’s race test instead gates on/proc/locks(heldFlockField,waitForBlockedFlock,hasBlockedFlockWaiterinlocalvolume_test.go) because a parked flock has no user-space completion signal. That gate depends on a blocked waiter existing, which the change below removes. time.Sleepis forbidden byforbidigo(go/.golangci.yml); the only sanctioned forms are an irreducible poll-tick or a synctest virtual-clock advance, each with//nolint:forbidigo // <reason>.go.modfloor isgo 1.26.0at the cited main (testing/synctestis stable from 1.25); the package imports only the standard library (vfs.goheader: “This package imports nothing outside the standard library”).
(a) Reap = rename under the lock, then remove the renamed tree
Section titled “(a) Reap = rename under the lock, then remove the renamed tree”reapLocked keeps its contract (the sole destroyer, reachable only with the
per-volume lock held, stamp re-read under that lock) and changes its
destruction step from one os.RemoveAll(root) into:
os.RemoveAll(reapingPath(root))— clear any leftover from an earlier partial reap of this session id, so the rename below cannot fail withEEXIST(Go’sos.Renamerefuses to rename onto an existing directory).ErrNotExistis the common case and is success. Any other failure returns before the rename: the volume is untouched and still eligible (I-2 semantics), and a leftover that never converges therefore pins that session id’s next reap until it is repaired (see OQ-2).os.Rename(root, reapingPath(root))wherereapingPath(root) = root + reapingSuffix,reapingSuffix = ".compass-vfs.reaping". Same parent directory, so the same filesystem, so one atomicrename(2). This is the destruction point. After it,Lookup,requireVolumeRoot, and the base-dir scan all see no root and reportErrVolumeNotFound; before it, the volume is byte-for-byte intact. There is no instant at which an observer can see a partially-deleted tree at the volume’s path.os.RemoveAll(reapingPath(root))— the expensive part, still under the lock (see § Alternatives for why not after release). A partial failure here leaves a.compass-vfs.reapingsibling with whatever survived; the session’s root is already gone, so nothing can mount it.
The renamed tree still contains the metaDirName marker and the stamp, so
the base-dir scan must classify by name before it looks for the marker:
an entry named <sessionID>.compass-vfs.reaping is a reaping leftover, never
a volume, whatever it contains. volumeRoot rejects a session id ending in
reapingSuffix exactly as it rejects one ending in lockFileSuffix, so no
session can be keyed onto another session’s reaping path.
Leftover sweep. Expire gains a second responsibility on the same scan:
for every reaping leftover, take the session’s lock non-blocking, RemoveAll
it, release. Contended → skip (someone is attaching or stamping that session
id; the leftover is inert and the next pass revisits). This keeps P2-GC-c
literal: Expire is still the only path that destroys volume contents, now
including the contents it already renamed out. ReconcileOrphans never
touches a leftover — it is a stamping pass over live volumes only.
(b) Lock acquisition: non-blocking flock in a ctx-aware poll, with an inode re-verify
Section titled “(b) Lock acquisition: non-blocking flock in a ctx-aware poll, with an inode re-verify”lockVolume(root, block bool) splits into two functions with no boolean:
tryLockVolume(ctx, root) (*volumeLock, error)— never parks inflock;(nil, nil)on contention at whichever inode it last observed. It is not literally one attempt: the inode re-verify below can re-open and re-attempt, bounded byctxonly. Used byExpireandReconcileOrphans(today’sblock=false).lockVolume(ctx, root) (*volumeLock, error)— loopstryLockVolume-style attempts on one open fd, waiting between attempts onselect { case <-ctx.Done(): …; case <-timer.C: }with exponential backoff fromlockPollInitial = 1mstolockPollMax = 50ms. Returnsctx.Err()when cancelled, after closing the fd; never(nil, nil). Used byAttachandStamp(today’sblock=true), whose “unreachable with block=true”lock == nilguards are deleted with the reason for them.
Both share one acquisition core, and that core adds the step LOW-2 needs:
after Flock succeeds, f.Stat() and os.Stat(path) are compared with
os.SameFile. A match means the fd holds the lock on the inode currently at
the lock path — the real lock. A mismatch (the path is gone, or now names a
different inode) means the acquirer landed on an inode that a reclaim
unlinked while it was waiting; it closes the fd and re-opens the path, which
converges on the live inode. Re-opening is bounded by ctx. Without this
step, unlinking a held lock file re-creates the two-inode split
lockVolume’s W1 doc describes (a waiter that opened the old inode before
the unlink acquires it after the holder’s release, while a newcomer creates
and acquires a fresh inode — two actors “hold” the lock). With it, the split
is a detected retry, and the reclaim in (c) becomes safe.
The polling trade is deliberate: a blocking flock wakes the instant the
holder releases; a poll wakes up to lockPollMax later. Holders are Attach
/ Stamp (a stat, an unlink or an fsync’d write — milliseconds) and Expire
(the rename plus RemoveAll of one tree — seconds for a large tree). Fifty
milliseconds of added latency on a relaunch that races the reaper is noise
beside a container launch, and during a seconds-long reap the poll costs
twenty syscalls per second. What it buys is that every wait this package
performs is now interruptible by the context every verb already accepts.
One W1 semantic is softened, not lost. W1’s Attach comment says an Attach
racing the reaper “must win the volume, not abandon a launch”: a waiter
parked in a blocking flock is woken at release and normally wins before a
later LOCK_NB attempt. A polling waiter holds nothing between attempts,
so an Expire tryLockVolume that lands in the gap after a holder’s
release — up to lockPollMax wide — can win, judge the stamp, and reap
before the parked Attach retries. That Attach then re-verifies under
the lock, finds no root, and returns ErrVolumeNotFound; the provision
path cold-materializes. Correctness is the W1 race test’s other
interleaving, already asserted; what changes is that a relaunch landing in
the same instant as its own expiry deadline may take the cold path where W1
would usually have won. H1 rewrites the comment to say so.
ReconcileOrphans is unaffected only because of its normative
no-concurrent-Attach precondition, which this record keeps.
(c) Orphan lock reclaim rides the Expire scan
Section titled “(c) Orphan lock reclaim rides the Expire scan”On the same base-dir scan, an entry named <sessionID>.compass-vfs.lock is
a lock file. Expire checks, unlocked, whether the session’s root
or its reaping leftover exists: if either does the lock file is live —
keep it, no lock taken (a live volume costs the pass two stats, not a lock
cycle; a leftover’s lock file is reclaimed by the leftover visitor after its
own RemoveAll succeeds, so the lock visitor never pays a second lock cycle
for it). If both are absent: tryLockVolume; contended → skip; else
re-check under the lock that both the root and the reaping leftover are
absent, and if so os.Remove the lock file — the final act of the
critical section — then release. ErrNotExist on the unlink is success
(another reaper got there first).
Why “final act” is a rule and not a style: after the unlink, a newcomer can create and acquire a fresh inode at the path while this holder still holds the old one. That is harmless only because this holder does nothing further to the session’s state. No future code may mutate a volume after unlinking its lock.
Why the reclaim is in Expire and not ReconcileOrphans: the leak grows
for the Runner’s lifetime, so a startup-only pass bounds nothing; Expire
is the periodic pass and already the only destroyer.
The unlocked fast path is safe in the conservative direction only: a root or leftover present unlocked is kept without proof, and keeping is never wrong; both absent unlocked is re-proven under the lock before the unlink.
The scan: one ReadDir, classified by name
Section titled “The scan: one ReadDir, classified by name”eachVolume today filters ReadDir to “directory with the marker”. With
two reserved suffixes and three passes wanting different entry kinds, the
scan becomes scanBaseDir(ctx, visit func(kind entryKind, root string) error)
over one os.ReadDir(m.baseDir), classifying each entry by name first
(entryLock if the name ends in lockFileSuffix and is not a directory;
entryReaping if it ends in reapingSuffix and is a directory), then by
structure (entryVolume if a directory carrying the metaDirName marker),
else entryForeign (W2’s snapshot store, stray files). root is always the
session’s would-be volume root, <baseDir>/<sessionID>, so a visitor derives
the lock and reaping paths from it with the same two helpers every other
call site uses. eachVolume(ctx, fn) stays as the entryVolume-only filter
ReconcileOrphans uses; Expire visits scanBaseDir directly and switches
on kind. Per-entry errors join, as today; the ctx.Err() check per entry is
kept.
What does not change
Section titled “What does not change”- The
VolumeManagerinterface,Volume,CloseIntent, and every exported error ingo/internal/vfs/vfs.go. - Invariants (a), (b), (c) on the close-stamp; the stamp’s location inside
the root;
writeStamp/clearStampdurability. - The lock file’s placement as a root sibling and the reason for it.
ReconcileOrphans’s startup-only precondition and its stamping logic.CreateVolumetakes no lock, as today; it creates only<root>/<marker>and never touches a reaping leftover or a lock file. It may race H3’s reclaim — a root created after the under-lock “root absent” proof but before the unlink leaves a live volume with no lock file — and that is benign: the lock file is materialized on demand by the next acquisition, no one held the unlinked inode, and the reclaimer touches nothing after the unlink (GC-6).
Alternatives considered
Section titled “Alternatives considered”- Blocking
flockon a helper goroutine,selectonctx.Done(). The helper stays parked in an uninterruptible syscall after the caller returns; when the holder finally releases, the orphaned helper acquires the lock and must release it again, briefly excluding a legitimate acquirer that arrived first — a phantom hold with no owner. It also leaks a goroutine per cancelled wait until then, which-raceand any future goroutine-leak gate see. Rejected: the poll is boring, owner-less holds do not exist, and the latency cost is bounded and small. RemoveAllthe renamed tree after releasing the lock. Shortens the hold to the rename and lets a relaunch racing the reap get itsErrVolumeNotFoundimmediately. Costs: two reapers (a hand-run one beside the service) canRemoveAllthe same leftover concurrently (benign forRemoveAll, which toleratesErrNotExist), but a second reap of a recreated volume for the same session id can rename onto a name whose removal is in flight, so its own removal and the in-flight one interleave and one reports a spuriousENOTEMPTY. With (b) the wait is interruptible, so the remaining benefit is relaunch-at-deadline latency, a rare event. Rejected for now; the rename-first structure makes it a local change if measured to matter (recorded as OQ-1).- Unique per-attempt reaping names (
<sid>.compass-vfs.reaping.<nonce>). Avoids the clear-leftover-first step, and with it the one way a stuck leftover can pin a session id’s next reap. Costs prefix matching in the scan, an unbounded leftover set per session, and a nonce source. Rejected: one deterministic name per session keeps the leftover set at most one and the clear-first step is oneRemoveAllthat isErrNotExistin every non-failure run. Note the coupling: nonce names are also what removes the concurrent-removal interleaving that keepsRemoveAllunder the lock (OQ-1), so a future move to RemoveAll-after-release should adopt both together, not one without the other. - Reclaim lock files in
ReconcileOrphans. Startup-only; the leak grows between restarts. Rejected (see (c)). - One base-dir lock instead of per-volume locks. Serializes every
Attachbehind every reap. Rejected; not what W1 froze. renameat2(RENAME_EXCHANGE)/O_TMPFILEtricks. Linux-only extensions outsideos; the plain rename is atomic on every filesystem this package can run on. Rejected.
Invariants
Section titled “Invariants”Numbered so a task brief and a test name can cite them.
- I-1 (atomic destruction). A volume is destroyed by exactly one
rename(2)of its root. At every instant either the whole tree is at<baseDir>/<sessionID>or none of it is.Lookup/Attach/Stampfor that session returnErrVolumeNotFoundfrom the rename onward, and never return a path whose tree is partially deleted. - I-2 (rename failure is non-destructive). If the clear-leftover step
or the rename fails (
EBUSYon a leaked mount point,EACCESon the base dir, a non-converging stale leftover), the volume is untouched and the error is joined into the pass result; the next pass retries. Nothing is removed from a volume whose rename did not succeed. - I-3 (leftovers are inert and convergent). A reaping leftover is never
a volume: no pass stamps it,
Lookupnever resolves it,CreateVolumenever reuses it. EveryExpirepass attempts to remove every uncontended leftover, so a leftover persists only while its removal keeps failing, and each such pass surfaces the failure. - I-4 (every wait is ctx-bounded). No code path in this package blocks
on the per-volume lock past the caller’s context. A cancelled wait
returns
ctx.Err()(viaerrors.Is), closes its fd, and has mutated nothing on disk. - I-5 (lock identity). A returned
*volumeLockwas verified, at acquisition, to holdLOCK_EXon the inode then at<root><lockFileSuffix>; an acquisition that lands on any other inode is discarded and retried. The verification is a point-in-time fact, not a standing one: inside a reclaim’s unlink-to-release window (I-6) the reclaimer still holds the unlinked inode while a newcomer may already hold the live one, and that overlap is harmless only because the reclaimer mutates nothing after the unlink (GC-6). Outside that window at most one actor holds a verified lock for a session id. - I-6 (reclaim is terminal). A lock file is unlinked only by its holder, only after an under-lock proof that the session has neither a root nor a reaping leftover, and as the last mutation of that critical section.
- I-7 (W1 invariants intact). (a)
Attachclears the stamp under the lock before returning; (b) every reap decision is made under the lock from an under-lock stamp read, andAttach/Stamp/stampOrphanLockedre-verify existence under the lock; (c)IntentSuspendedpins forever. P2-GC-c:Expireis the only destroyer, now including leftovers and orphan lock files. P2-GC-d: the returned path is unchanged.
Failure behavior
Section titled “Failure behavior”| Event | Outcome |
|---|---|
RemoveAll of the renamed tree fails partway (EACCES on a subdir, EBUSY on a leaked mount inside the tree) |
Root absent; Lookup/Attach → ErrVolumeNotFound; leftover at <root>.compass-vfs.reaping; pass returns the joined error; next pass sweeps the leftover (I-3). |
os.Rename fails |
Volume intact, stamp intact, still eligible; joined error; retried next pass (I-2). |
Clear-first RemoveAll fails on a stale leftover of the same session id |
Rename not attempted; volume intact and still eligible (I-2); the joined error names the leftover path, not the root. The recreated session’s volume cannot be reaped until the leftover converges or is repaired (OQ-2). |
Crash between rename and RemoveAll |
Leftover on disk; next Expire sweeps it. ReconcileOrphans ignores it (name classification). |
| Crash between the reclaim unlink and release | The kernel drops the lock with the process; the path is gone; the next acquirer creates a fresh inode. No state to repair. |
ctx cancelled while Attach/Stamp waits |
ctx.Err(); fd closed; no stamp change, no clear, no path returned (I-4). The provision path treats it as any other launch abort. Behavior change from W1: a relaunch racing a multi-GB reap used to block to completion and then cold-materialize on ErrVolumeNotFound; now it aborts at the caller’s deadline and the caller retries. OQ-1 (remove after release) is the lever if that abort rate matters. |
ctx already cancelled on entry |
Lookup’s existing check returns ctx.Err() before the lock namespace is touched; no lock file is created. |
| Lock file unlinked from under a waiter | The waiter acquires the dead inode, fails the SameFile check, re-opens, and contends on the live inode (I-5). |
Attach polling while Expire acquires in the gap after a holder’s release |
Expire wins the lock, reaps on its under-lock stamp read, releases; Attach’s next attempt wins, requireVolumeRoot finds no root, ErrVolumeNotFound; the provision path cold-materializes. Bounded by lockPollMax; W1’s “must win” is softened to “may lose a same-instant race at the deadline” (§ (b)). |
CreateVolume for a session id whose orphan lock is being reclaimed |
Root appears after the under-lock absence proof; the unlink proceeds; the live volume has no lock file until the next Attach/Stamp/Expire materializes one. Benign (§ What does not change). |
| A session is recreated while its leftover is still being removed | Root and leftover coexist under different names; the live root is judged on its own stamp; the leftover is swept on its own. A later reap of the new root clears the leftover first, then renames. |
Leftover removal never converges (an agent chmod 000’d a subdir) |
Surfaced as a joined error on every pass; storage of that subtree leaks until repaired. Not silently absorbed. See OQ-2. |
| Corrupt or unknown-intent stamp | Unchanged from W1: error, no rename, no removal. |
| Two reapers on one base dir (service + hand-run) | Every mutation is under a verified lock (I-5), so they serialize per session; the loser skips on contention and revisits next pass. |
Global Constraints
Section titled “Global Constraints”Inherited: the P2 record’s constraints (P2-GC-a … P2-GC-f) and the parent’s nine, unmodified. Specific to this record:
- GC-1 — no interface change.
go/internal/vfs/vfs.gois not touched.VolumeManager,Volume,CloseIntent, and every exported error keep their shape and doc.LocalManager’s exported surface (NewLocalManager,BaseDir,Stamp,ReadStamp,ReconcileOrphans) keeps its signatures. Interface promotion ofStamp/ReconcileOrphansis RIG-3309, not here. - GC-2 — standard library only. The package header’s “imports nothing
outside the standard library” holds. No
golang.org/x/sys, norenameat2, no third-party lock library. - GC-3 —
Expireis the only destroyer, literally. Volume roots, reaping leftovers, and orphan lock files are removed byExpireand by nothing else.ReconcileOrphans,Attach,Stamp,CreateVolume, andlockVolumenever unlink anything except the close-stamp (clearStamp, as today). - GC-4 — no goroutine in the lock path. Lock acquisition is a loop in
the caller’s goroutine that selects on
ctx.Done(); the helper-goroutine design is rejected (§ Alternatives) and must not be reintroduced. - GC-5 — two reserved suffixes, guarded in two places.
lockFileSuffixandreapingSuffixare the only reserved names under the base dir. Each is rejected byvolumeRootand classified byscanBaseDir. Adding a third reserved name means editing both in the same change, with aTestVolumeRootRejectsTraversalrow. - GC-6 — unlink-last. Inside a critical section that unlinks a lock file, the unlink is the final mutation before release. No stat, stamp, rename, or remove follows it.
- GC-7 — no sleeps, no timing assumptions in tests. Concurrency tests
gate on
testing/synctest(synctest.Wait, virtual-clock advance with the house//nolint:forbidigo // synctest virtual clock …reason) or on a channel/WaitGroup. A wall-clocktime.Sleepis a defect. Tests that make a filesystem operation fail through permissions skip underos.Geteuid() == 0(thego/server/socket_test.goidiom) and restore modes int.Cleanupsot.TempDircleanup succeeds. - GC-8 — docs move with the code. Every doc comment that describes the
replaced mechanism is rewritten in the commit that replaces it: the file
header’s “(b)” paragraph,
metaDirName(“reaps its stamp in one os.RemoveAll”),Attach(“the wait is bounded”),reapLocked,lockVolume(the “Accepted tradeoff … out of P2 scope” paragraph), andeachVolume. No sentence promising a “future maintenance pass” survives. - GC-9 — the gate set. RIG-3310’s acceptance gate:
moon run compass-go:fmt,:vet,:lint(golangci-lint run --config .golangci.yml ./..., 0 issues, additionally with--build-tags microvm),:nilaway(zero baseline; a finding is guarded at the deref, never a file opt-out),:build,:test(go test -race ./...), plusgo test -race -count=20 ./internal/vfs/...for the concurrency tests. A nilaway finding on a deletedlock == nilguard is resolved by keeping the guard with a reason that names nilaway, never by widening the change. - GC-10 — public-repo hygiene. This record and the code comments it
drives cite only
RigelBuild/compasspaths and bare tracker ids.
Three tasks, sequential, one bookmark, one PR (RIG-3310 is one PR-sized
change; each task is its own commit so the review reads three mechanisms,
not one diff). All edits are to go/internal/vfs/localvolume.go and
go/internal/vfs/localvolume_test.go. Every task ships its own tests and
its own doc-comment rewrite (GC-8). Lane hint: implement-go; H1 is the
implement-hard candidate (it rewrites the package’s only race test).
H1 — ctx-interruptible lock acquisition with inode re-verify (MED-4, and the I-5 base for LOW-2)
Section titled “H1 — ctx-interruptible lock acquisition with inode re-verify (MED-4, and the I-5 base for LOW-2)”-
Interfaces (produces):
// lockPollInitial / lockPollMax bound the backoff between non-blocking// acquisition attempts in lockVolume. Every attempt is LOCK_NB; no code// path in this package ever parks in a blocking flock (I-4, GC-4).const (lockPollInitial = 1 * time.MillisecondlockPollMax = 50 * time.Millisecond)// tryLockVolume never parks in flock. (nil, nil) means contended at the// inode it last observed — the caller skips this volume. A successful// flock is verified against the inode currently at the lock path (I-5);// a mismatch re-opens and re-attempts, bounded by ctx only, so this is a// loop that converges, not literally one attempt.// Replaces lockVolume(root, false).func tryLockVolume(ctx context.Context, root string) (*volumeLock, error)// lockVolume acquires the session's sibling lock file, waiting with// exponential backoff between non-blocking attempts and returning// ctx.Err() the moment ctx is done. Never returns (nil, nil).// Replaces lockVolume(root, true).func lockVolume(ctx context.Context, root string) (*volumeLock, error)// lockAttempt is the shared core: open (O_CREATE|O_RDWR, stampFileMode),// flock(LOCK_EX|LOCK_NB), then os.SameFile(f.Stat(), os.Stat(path)). On// EWOULDBLOCK/EAGAIN it returns (nil, nil) and keeps nothing open. A// mismatch is a different inode at path OR ErrNotExist from os.Stat(path)// (the reclaim unlinked it): it closes f, re-opens, and tries again// (ctx-bounded). Any other error — from open, flock, f.Stat, or a// non-ErrNotExist os.Stat — closes f and is returned joined with the// close error; it is never treated as a mismatch, so an EACCES on the// base dir cannot spin the re-open loop.func lockAttempt(ctx context.Context, path string) (*volumeLock, error)lockVolumekeeps one fd across backoff iterations (a repeatflockon the same fd is the cheap path); it re-opens only after aSameFilemismatch. The wait isselect { case <-ctx.Done(): return nil, ctx.Err(); case <-timer.C: }on atime.Timercreated insidelockVolumeitself, in the waiting goroutine — which is what makes the parked state durably-blocked undertesting/synctest(a timer created outside the bubble is not). Write the loop so everyreturn l, nilis dominated byl != nil:l, err := lockAttempt(…); if err != nil { return nil, err }; if l != nil { return l, nil }; select …. That shape is what lets nilaway prove the “never(nil, nil)” contract at theAttach/Stampcall sites; GC-9’s nilaway clause covers a finding elsewhere and is not a license to keep those guards. -
Consumes:
volumeLock,release,lockFileSuffix,stampFileModeunchanged. -
Call-site edits:
AttachandStampcalllockVolume(ctx, resolved.HostRoot)and drop theirif lock == nil { … "volume lock unavailable" }guards (the reason for them, “unreachable with block=true”, no longer exists).ExpireandReconcileOrphanscalltryLockVolume(ctx, root).Attach’s “Block rather than skip … must win the volume … the wait is bounded” comment is rewritten: the wait is ctx-bounded, and a relaunch landing in the same instant as its expiry deadline may lose the race to a reap that acquires between two poll attempts, taking theErrVolumeNotFoundcold path (§ (b), Failure behavior). -
Doc edits:
lockVolume’s doc keeps the placement argument verbatim and replaces the “Accepted tradeoff … out of P2 scope” paragraph with the I-5 re-verify rule and a forward reference to H3’s reclaim. The file header’s (b) paragraph gains one sentence: waits are ctx-bounded. -
Test edits (existing): every
lockVolume(x, false)in the test file becomestryLockVolume(t.Context(), x)(TestReconcileOrphansSkipsA- VolumeBeingAttached,TestExpireSkipsALockedVolume,TestVolumeLockSurvivesAReap,TestVolumeLockFileIsOutsideTheVolumeRoot,TestAttachRacingAReapDoesNotReturnAReapedPath).TestAttachRacingAReapDoesNotReturnAReapedPathMUST be rewritten: its gate waits for a-> FLOCKwaiter row in/proc/locks, and aLOCK_NBattempt never enqueues a waiter, so the W1 gate would time out at 30 s against this change. The rewrite runs insidesynctest.Test: hold the stand-in reaper lock viatryLockVolume,go m.Attach(t.Context(), v),synctest.Wait()(returns when Attach is parked in its backoff select), remove the root, release, receive the result on a bubble channel (the receive lets the virtual clock advance to Attach’s timer). Assertion is unchanged.heldFlockField,waitForBlockedFlock,hasBlockedFlockWaiterand their doc comments are deleted (dead once no test observes a blocked waiter); thestrconvandsyscallimports go with them — on main those three helpers are their only users in the test file. -
Tests (new):
TestAttachReturnsCtxErrWhileLockIsHeld(I-4): in a bubble, hold the lock, age-stamp the volume,ctx, cancel := context.WithCancel(t.Context()),go Attach(ctx, v),synctest.Wait(),cancel(), receive:errors.Is(err, context.Canceled); thenreadStamp(root)still returns the aged stamp (nothing cleared) and the root is intact. The same shape forStampasserts no stamp was written.TestAttachWithCancelledCtxTouchesNoLock: a pre-cancelled ctx returnsctx.Err()and<root><lockFileSuffix>does not exist afterwards.TestLockAcquisitionConvergesOnTheLiveInode(I-5): in a bubble, hold viatryLockVolume(inode A);go lockVolume(ctx, root)— the waiter opens A and parks;synctest.Wait(); now, as the reclaim does under the held lock,os.Removethe lock path; release the holder; receive the waiter’s lock (the receive advances the virtual clock to its timer). Assertos.SameFile(waiter.f.Stat(), os.Stat(path))— the waiter re-opened and holds the inode now at the path — and that a freshtryLockVolumereports contention while the waiter holds it. Without the re-verify the waiter holds unlinked A,os.Stat(path)fails, and the fresh attempt creates B and succeeds — the two-inode split.
-
Depends: nothing.
-
Gate:
go test -race -count=20 ./internal/vfs/...green; lint clean.
H2 — rename-before-reap, name-classified scan, leftover sweep (MED-5)
Section titled “H2 — rename-before-reap, name-classified scan, leftover sweep (MED-5)”-
Interfaces (produces):
// reapingSuffix names the sibling a volume root is renamed to at the// instant it is destroyed: <baseDir>/<sessionID><reapingSuffix>. The// rename is the destruction (I-1); the RemoveAll that follows is cleanup// of a tree no session can reach. Reserved like lockFileSuffix (GC-5).const reapingSuffix = ".compass-vfs.reaping"// reapingPath is the leftover path for a volume root.func reapingPath(root string) string { return root + reapingSuffix }// entryKind classifies one base-dir entry by name first, structure second.type entryKind intconst (entryForeign entryKind = iota // not this package's: W2's store, stray filesentryVolume // directory carrying the metaDirName markerentryLock // <sessionID><lockFileSuffix>, a fileentryReaping // <sessionID><reapingSuffix>, a directory)// scanBaseDir runs one os.ReadDir over the base dir and calls visit for// every non-foreign entry with its kind and the session's volume root// (<baseDir>/<sessionID> — the lock and reaping paths derive from it).// Per-entry errors are joined; ctx is checked per entry, as eachVolume// does today.func (m *LocalManager) scanBaseDir(ctx context.Context, visit func(kind entryKind, root string) error) error// eachVolume is unchanged in signature and contract: scanBaseDir filtered// to entryVolume. ReconcileOrphans keeps using it.func (m *LocalManager) eachVolume(ctx context.Context, fn func(root string) error) errorreapLocked(root, now, olderThan)keeps its signature. Its body after the eligibility check becomes:os.RemoveAll(reapingPath(root))(clear a stale leftover;ErrNotExistis success; any other error is wrapped"vfs: clearing stale reaping leftover %q: %w"and returned before the rename, volume untouched),os.Rename(root, reapingPath(root))(wrapped:"vfs: reaping expired volume %q: %w"; failure returns with the volume untouched, I-2), thenos.RemoveAll(reapingPath(root))(wrapped:"vfs: removing reaped volume %q: %w"; failure returns the error, the leftover stays for the sweep, I-3).Expirebecomes ascanBaseDirvisitor:entryVolume→ today’s lock-reap-release;entryReaping→tryLockVolume, skip on contention,os.RemoveAll(reapingPath(root)), release;entryLock→ H3 (a no-op in this commit).ReconcileOrphansis unchanged (it useseachVolume).volumeRootgainsif strings.HasSuffix(sessionID, reapingSuffix) { return "", fmt.Errorf("%w: %q collides with the volume reaping namespace %q", ErrInvalidSessionID, sessionID, reapingSuffix) }beside thelockFileSuffixcheck. -
Consumes: H1’s
tryLockVolume. -
Doc edits:
reapLocked(rename is the destruction point; RemoveAll is cleanup),metaDirName(“reaps its stamp in one os.RemoveAll” → the stamp travels with the renamed tree and is never read again),eachVolume(identity is name-then-structure),Expire(three entry kinds; leftovers are swept every pass),volumeRoot(two reserved suffixes), the file header’s “reconcile, expire” line. -
Tests (new):
TestReapIsAtomicUnderPartialRemoveFailure(I-1, I-3): skip ifos.Geteuid() == 0. Create a volume, write<root>/pinned/heldandchmod 0o500pinned(cleanup restores0o700on whichever of<root>/pinnedor<reaping>/pinnedexists — the rename moves it); age-stamp;Expirereturns a non-nil error;LookupandAttachboth returnErrVolumeNotFound;exists(reapingPath(root))andexists(filepath.Join(reapingPath(root), "pinned", "held")). Do NOT assert the leftover still carries its stamp or marker:removeAllFromattempts every entry of a directory and only then fails on the directory itself, so the fully-deletable.compass-vfs-metasubtree is gone from the leftover whilepinned/heldsurvives — this leftover is marker-less, and an assertion thatreadStamp(reapingPath(root))still returns the aged stamp fails on correct code (and would prove nothing even if the stamp survived:stampOrphanLockednever overwrites an existing stamp). AfterReconcileOrphansassert!exists(reapingPath(root) + lockFileSuffix)instead — a pass that treated the leftover as a volume would have materialized a lock file beside it. Name-first classification is proven by the two manufactured-leftover tests below, where the marker survives. Restore the mode;Expirereturns nil and the leftover is gone. Against W1 this test fails at the firstLookup(the gutted root is still a directory).TestReapRenameFailureLeavesVolumeIntact(I-2): skip if euid 0. Materialize the lock file (acquire + release once), write a sentinel file, age-stamp,chmod 0o500the base dir (cleanup restores);Expirereturns a non-nil error; root, sentinel, and stamp all still present;Lookupsucceeds.TestExpireSweepsAReapingLeftoverWhateverItsStamp(I-3): manufacture a leftover withos.Rename(root, reapingPath(root))after writing a freshIntentSuspendedstamp (the most protected state);Expirewith a 14-day window removes it. This is the assertion that leftovers are classified by name, not judged as volumes. Then:CreateVolumethe same id beside a fresh manufactured leftover;Expiresweeps the leftover and leaves the live unstamped root; age-stamp the root with a second manufactured leftover present; oneExpireleaves neither (the clear-first step).TestVolumeRootRejectsTraversalgains rows"sess-x" + reapingSuffixandreapingSuffix.TestReconcileOrphansIgnoresAReapingLeftover: a leftover with no stamp (the marker dir alone) is not stamped byReconcileOrphans(readStamp(reapingPath(root))stays nil) — the pass never resurrects a leftover as an orphan.
-
Depends: H1.
-
Gate: as H1.
H3 — orphan lock reclaim (LOW-2)
Section titled “H3 — orphan lock reclaim (LOW-2)”-
Interfaces (produces):
// reclaimLockLocked unlinks the session's sibling lock file if, under the// held lock, the session has neither a volume root nor a reaping// leftover. It MUST be the final mutation of the caller's critical// section (GC-6): after the unlink a newcomer may hold a fresh inode.// ErrNotExist is success. Returns nil (kept) when either path exists.func reclaimLockLocked(root string) errorCall sites, each as the last statement before
lock.release():reapLockedafter a successful finalRemoveAll— the common case leaves no orphan at all;Expire’sentryReapingvisitor after a successfulRemoveAll;Expire’sentryLockvisitor: unlockedos.Stat(root)andos.Stat(reapingPath(root))— either present → return nil without locking (a live volume is the common case; a leftover’s lock is call site 2’s to reclaim); both absent →tryLockVolume, skip on contention,reclaimLockLocked(root), release.
-
Consumes: H2’s
scanBaseDir/entryLock, H1’stryLockVolumeand I-5 (the property that makes an unlink under a polling waiter safe). -
Doc edits:
lockVolume’s doc gets the reclaim rule (unlink-last, under-lock proof, I-5 makes it safe);Expire’s doc names the third entry kind; the “one empty file per session id ever seen on this box” sentence is deleted. -
Tests (new):
TestExpireReclaimsOrphanLockFilesOnlyWhenUncontended(I-6): three sessions, each with a materialized lock file. A: root removed → afterExpirethe lock file is gone. B: live, unstamped → lock file kept, root kept. C: root removed, lock held by the test viatryLockVolume→ kept; release; nextExpire→ gone.TestReapLeavesNoOrphanLockInTheCommonCase: aged volume with a materialized lock file; oneExpire; root, leftover, and lock file all absent.TestExpireReclaimsTheLockAfterSweepingALeftover: manufactured leftover plus its lock file, no root; oneExpire; both absent.TestVolumeLockFileIsOutsideTheVolumeRoot’s tail (which materializes a sibling lock file and assertsExpirestill reaps the eligible volume) is kept; its final assertion gains “and the lock file is now absent”.
-
Depends: H1, H2.
-
Gate: as H1, plus the full RIG-3310 gate set (GC-9) on the stacked result.
Test strategy
Section titled “Test strategy”| Invariant | Test | Fails on |
|---|---|---|
| I-1 atomic destruction | TestReapIsAtomicUnderPartialRemoveFailure |
a reap that removes in place (W1) |
| I-2 rename failure non-destructive | TestReapRenameFailureLeavesVolumeIntact |
a reap that removes before or without renaming |
| I-3 leftovers inert + convergent | TestExpireSweepsAReapingLeftoverWhateverItsStamp, TestReconcileOrphansIgnoresAReapingLeftover, the partial-failure test’s second Expire |
a scan that classifies by marker before name; a sweep that consults the stamp |
| I-4 ctx-bounded waits | TestAttachReturnsCtxErrWhileLockIsHeld (Attach + Stamp) |
a blocking flock; a helper goroutine that mutates after the caller returned |
| I-4 “mutated nothing on disk” | TestAttachWithCancelledCtxTouchesNoLock |
a lockVolume that opens the fd (O_CREATE) before checking ctx. Passes on W1 today (Lookup already checks ctx.Err() first); it is a regression pin, not a W1-red test |
| I-5 lock identity | TestLockAcquisitionConvergesOnTheLiveInode, TestVolumeLockSurvivesAReap (migrated) |
an acquire without the SameFile check |
| I-6 reclaim terminal + uncontended | TestExpireReclaimsOrphanLockFilesOnlyWhenUncontended, TestExpireReclaimsTheLockAfterSweepingALeftover, TestReapLeavesNoOrphanLockInTheCommonCase |
a reclaim that ignores contention; one that unlinks a live volume’s lock |
| I-7 W1 invariants intact | every existing W1 test, unchanged in assertion (only the lock-helper signature migrates); TestAttachRacingAReapDoesNotReturnAReapedPath re-gated on synctest |
any regression of (a)/(b)/(c), P2-GC-c, P2-GC-d |
| GC-5 reserved names | TestVolumeRootRejectsTraversal (+2 rows) |
a session id keyed onto a leftover path |
Mechanics the executor must not rediscover:
testing/synctestis the gate for every wait. ALOCK_NBattempt never enqueues a kernel waiter, so W1’s/proc/locksgate cannot observe the new parked state; the backoffselecton a bubble-created timer is durably blocking, sosynctest.Wait()returns exactly when the waiter is parked.t.Context()insidesynctest.Testis bubble-associated. Two traps: (1) never callsynctest.Wait()to make a parked waiter re-poll — with the waiter parked on its timer and the test goroutine running, virtual time is frozen andWaitreturns immediately with the waiter still parked; only a blocking receive on a bubble channel (<-done) lets the clock advance past the timer. (2) The backoff timer must be created in the waiter’s own goroutine, which was started inside the bubble; a timer made by the test goroutine or at package level makes the select non-durably-blocking.t.Runis not allowed inside a bubble — Attach/Stamp variants are two loop iterations, not subtests. Flock, stat, rename, and remove are ordinary syscalls inside the bubble: not durably blocking, but they complete.- Permission-driven failures skip under root (
os.Geteuid() == 0, thego/server/socket_test.goidiom) and restore the mode int.Cleanupsot.TempDircan clean up. The CI moon lane runs unprivileged onubuntu-latest, so the skip never fires there. - Flock contention within one process is real: two fds on the same
inode contend, which is what lets a test hold the reaper’s lock on the
test goroutine while
Attachpolls on another — the same fact W1’s race test already relies on. - The race trio:
go test -race -count=20 ./internal/vfs/...is run per task and on the stacked result; a flake at count 20 is a design bug in the test (a timing assumption crept in), not a retry.
- H1 —
tryLockVolume/lockVolumesplit, ctx-bounded backoff,SameFileinode re-verify;Attach/Stamp/Expire/ReconcileOrphanscall-site migration;TestAttachRacingAReapDoesNotReturnAReapedPathre-gated on synctest and the/proc/lockshelpers deleted; three new tests (I-4 ×2, I-5). Docs per GC-8. - H2 —
reapingSuffix/reapingPath,volumeRootguard,scanBaseDir+entryKind,reapLocked= clear-leftover → rename → remove,ExpiresweepsentryReaping; five test changes (I-1, I-2, I-3 ×2, GC-5). Docs per GC-8. - H3 —
reclaimLockLockedat its three call sites,Expire’sentryLockvisitor with the unlocked fast path; three new tests plus one extended assertion (I-6). Docs per GC-8. - Full gate set (GC-9) green on the stacked PR; PR body carries
Spec-impact: noneandLedger-impact: none.
Open Questions
Section titled “Open Questions”All non-load-bearing: the record is correct and executable without a ruling on any of them; each is a documented deferral.
- OQ-1 (non-load-bearing) —
RemoveAllafter lock release. The rename-first structure makes “release, then remove the leftover” a local change that would shorten the lock hold from tree-size to one rename. Deferred: with I-4 the hold is interruptible, the remaining benefit is latency on a relaunch that races the reap (rare), and the cost is a concurrent-removal interleaving on a recreated-then-re-reaped session id (§ Alternatives). Revisit if W6’s driver measures reap holds that delay launches. - OQ-2 (non-load-bearing) — surfacing a non-converging leftover. A
leftover whose removal fails on every pass (an agent-
chmod 000’d subtree, a leaked mount) is reported as a joinedExpireerror each tick, leaks its storage until an operator repairs it, and pins that session id’s next reap (the clear-first step fails before the rename, so a recreated-then-expired volume of the same id stays on disk, intact and eligible, until the leftover is cleared). Whether that earns a metric or a log line with the leftover path is W6’s (the expiry driver ownsExpire’s error sink); this record only guarantees it is never silent. - OQ-3 (non-load-bearing) — poll bounds.
lockPollInitial = 1ms,lockPollMax = 50msare chosen for a hold profile of milliseconds (Attach/Stamp) to seconds (a large reap). They are unexported constants; tuning them is a one-line change with no contract impact.
Spec-impact: none (no docs/specs clause names the reap or lock mechanism;
the VolumeManager contract is unchanged).
Ledger-impact: none (the P2 record’s DL-326 row is unchanged; every decision
here is backend-internal and record-level).
Refs RIG-3310 RIG-2395 RIG-3278