Cross-process file lock + optimistic stale-read guard for Pi's
built-in read / edit / write tools. Ships as a Pi extension you can load with
pi --extension ./src/extension.ts or drop into .pi/extensions/.
Pi already serializes file mutations inside one process via an in-memory queue. When two Pi processes (subagents, terminals, sidecars) both edit the same file, that queue does not coordinate them. mutex-pi fills that narrow gap.
For each guarded mutation:
- Planning and reads take no lock. Reads record a SHA-256 fingerprint of the exact bytes the built-in read tool observed.
- Immediately before the write commit — inside the built-in tool's
operations.writeFilehook so the resolved absolute path is what we guard — acquire a cross-process per-canonical-path lock (atomicmkdirwith owner metadata, PID liveness, per-lock reclaim mutex, quarantine-based atomic reclaim). - Inside the lock, re-fingerprint the current file. If it differs from what the operation planned against, reject with an explicit stale error.
- Otherwise commit and release immediately.
- Multi-path acquisitions sort canonically to avoid deadlock and roll back on partial failure.
Image reads preserve built-in image handling: the extension implements
magic-byte MIME detection for JPEG / PNG / GIF / WEBP / BMP so the built-in
still returns an image content part.
- Cooperative, same-user, single local host. All non-cooperating writers
(
bash, external editors, other daemons) bypass the primitive. - Foreign-host metadata is fail-closed: the primitive never reclaims a
lock whose owner metadata reports a different
hostname(). Do not use it over NFS / SMB / SSHFS or other network filesystems: atomicmkdiris not guaranteed and PID liveness refers to the wrong host. - Same-user PID reuse. If a holder dies and its PID is reused by an
unrelated same-user process before the initialization-grace window
elapses,
pidIsAlivereturns true and the lock stays held until either the reused PID also dies or the missing-metadata grace expires. Bounded stall, not silent data loss. - Lock root permissions. The default lock root is per-user under
$HOME/.cache/mutex-pi/locks(falling back toos.tmpdir()/mutex-pi-locks-<uid>) created chmod 0700. On multi-user hosts sharing a writable temp path, any user with mkdir permission in the same root can DoS other users' locks; that scenario is out of scope. - Not distributed. No cross-machine primitives. No cross-file atomicity beyond the sorted-lock deadlock-free multi-path acquisition helper.
- Not a sandbox. External writers can and will race with guarded tools; the primitive documents which writers cooperate, nothing more.
- A live same-host PID always owns the lock, even if its heartbeat has gone stale. Heartbeats are informational; they are never a reason to reclaim from a live holder.
- Missing or invalid metadata inside a lockDir may only be reclaimed after
the lockDir mtime is at least
staleMsold (initialization grace). - Reclaim uses an atomic
rename(lockDir, quarantine/<rand>)under a sibling per-lock reclaim mutex, then recursively removes the quarantine. Concurrent reclaimers cannot both win the rename, and no reclaimer can act on a replacement lock until it re-acquires the mutex and re-reads metadata.
bun install
bun run check # biome + tsc + bun testLoad in Pi:
pi --extension ./src/extension.tsOr drop it into .pi/extensions/mutex-pi (project-local) or
~/.pi/agent/extensions/mutex-pi (global) and Pi auto-discovers it.
Environment variables read at session_start:
MUTEX_PI_STRICT_WRITE=0— allow existing-file writes without a prior read snapshot. Default: strict.MUTEX_PI_TIMEOUT_MS=<n>— lock acquisition timeout in ms. Default 5000.
Smoke script:
scripts/smoke-pi.shEnvironment overrides:
PI_BIN— pi binary path (default:pion PATH).PI_PROVIDER— provider host (required; set to your Pi provider).PI_MODEL— model id (default:claude-sonnet-5).PI_SMOKE_KEEP— preserve the scratch directory for post-mortem.
The smoke script exits non-zero if pi is missing, if the invocation fails,
or if the on-disk file contents don't match the expected hello mutex
result.
The lock primitive is exported from src/lock.ts and is safe to use on its
own:
import { acquire, acquireAll, withLock } from 'mutex-pi/lock';
// Simple withLock
await withLock('/tmp/some/file', async () => {
// critical section
});
// Multiple paths, deadlock-free
const leases = await acquireAll(['/a', '/b'], { timeoutMs: 3000 });
try {
// ...
} finally {
for (let i = leases.length - 1; i >= 0; i--) await leases[i].release();
}bun run benchCompares:
unguarded— read-modify-write with no coordination.lock-only— read outside the lock, commit inside; still allows silent lost updates when two workers captured the same base value.stale-only— fingerprint check outside a lock; exposes the check/write TOCTOU window.lock+stale— read outside; under the lock, re-fingerprint and reject on drift. Rejections are explicit.lock+stale+retry— lock+stale with bounded outside-read retry; folds rejections back into completions and reportsrejectedAttempts.
All strategies model the planning read outside the commit-time critical
section, which is what the extension actually does. The output is JSON with
invariantViolations and structuralWarnings arrays that trip a non-zero
exit code so the bench can be used as a CI signal. Do not read a single
delta as proof of superiority.
Real filesystem, real child processes, no mocked locks. Covers:
- Same-path serialization and different-path concurrency.
- Symlink alias convergence.
- Live same-host holder survives past staleMs with heartbeat off.
- Missing metadata is protected by an initialization grace.
- Concurrent reclaimers: no two acquirers hold the lock simultaneously.
- Killed same-host holder is reclaimed after PID probe reports dead.
- Foreign-host metadata is never reclaimed.
- Timeout, abort signal, token-safe release.
- Extension-level: blind-write reject, create allow, stale-write reject, stale-edit reject, lock cleanup after error.
~/,file://, and absolute paths agree on the same canonical key so the write guard cannot be bypassed by choosing a non-normalized path form.- Image reads (PNG / GIF) preserve
image/*MIME detection. - Benchmark internal consistency invariants (small-scale).
bun testMIT