Skip to content

Repository files navigation

mutex-pi

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.

What it does

For each guarded mutation:

  1. Planning and reads take no lock. Reads record a SHA-256 fingerprint of the exact bytes the built-in read tool observed.
  2. Immediately before the write commit — inside the built-in tool's operations.writeFile hook so the resolved absolute path is what we guard — acquire a cross-process per-canonical-path lock (atomic mkdir with owner metadata, PID liveness, per-lock reclaim mutex, quarantine-based atomic reclaim).
  3. Inside the lock, re-fingerprint the current file. If it differs from what the operation planned against, reject with an explicit stale error.
  4. Otherwise commit and release immediately.
  5. 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.

Scope

  • 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: atomic mkdir is 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, pidIsAlive returns 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 to os.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.

Liveness rules

  • 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 staleMs old (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.

Install

bun install
bun run check    # biome + tsc + bun test

Load in Pi:

pi --extension ./src/extension.ts

Or drop it into .pi/extensions/mutex-pi (project-local) or ~/.pi/agent/extensions/mutex-pi (global) and Pi auto-discovers it.

Configuration

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.sh

Environment overrides:

  • PI_BIN — pi binary path (default: pi on 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.

Usage as a library

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();
}

Benchmark

bun run bench

Compares:

  • 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 reports rejectedAttempts.

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.

Tests

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 test

License

MIT

About

Cross-process file lock + optimistic stale-read guard for Pi's read/edit/write tools

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages