Skip to content

fix(config): bind cross-process file locks to their owning host - #3609

Closed
twoimo wants to merge 1 commit into
Yeachan-Heo:devfrom
twoimo:feat/file-lock-cross-host-identity
Closed

fix(config): bind cross-process file locks to their owning host#3609
twoimo wants to merge 1 commit into
Yeachan-Heo:devfrom
twoimo:feat/file-lock-cross-host-identity

Conversation

@twoimo

@twoimo twoimo commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Hello, and thank you for maintaining this project.

While assessing the Telegram topic-authority slice I traced its shared-volume
concern down to a defect that is entirely independent of it, in
packages/coding-agent/src/config/file-lock.ts. I have separated that fix here
so it can be judged on its own.

The defect

withFileLock decides whether an existing lock is stale by probing the owner's
liveness against the local process table:

if (ownerIsAlive(info, startTimeCache)) return { stale: false };
if (ownerLiveness(info.pid) === "dead" || Date.now() - info.timestamp > staleMs) {
    return { stale: true, owner: info };   // reclaimed
}

That is sound for a lock this host wrote. But the state directory may live on a
shared volume — packages/natives/CHANGELOG.md explicitly supports "a session
store on an NFS home directory" — in which case several hosts contend for the
same lock path. A remote owner's pid is almost never a live local pid, so
ownerLiveness answers dead and the branch reclaims the lock immediately,
with no staleMs grace period at all.

The consequence is that two hosts can hold the same lock and run the same
critical section concurrently. withFileLock is the serialization primitive
behind settings writes, GC stores, and the notification daemon's state, so this
is a silent mutual-exclusion failure rather than a cosmetic one.

A second, narrower hole: removeFileLockDirForGc compares owner equality on
{pid, start_time?, timestamp} only. Two hosts can coincide on pid and
timestamp, in which case one host's guarded removal can delete the other's lock.

The change

  • Stamp the owning host into the lock record (host_id).
  • A foreign-host owner is no longer eligible for the dead fast path. It is
    reclaimable only after the staleMs elapsed-time heuristic — precisely the
    grace period a local owner of indeterminate liveness already receives, so no
    new liveness policy is introduced.
  • Include host identity in owner equality for the guarded lock-dir removal.
  • Records written before this change carry no host_id and keep the previous
    local-pid semantics exactly, so existing on-disk locks are unaffected.

I deliberately did not add any new native primitive, filesystem capability,
or loader requirement. This is a behavioural fix to existing TypeScript, and it
does not touch crates/pi-natives/src/path_identity.rs,
packages/natives/native/index.d.ts, or the daemon generation manifest — I
understand #3596 owns those.

Verification

New suite packages/coding-agent/test/file-lock-cross-host.test.ts (6 tests). I
confirmed it is not a tautology by reverting the source and re-running: three
tests fail on current dev, including the headline one.

# with the fix
6 pass / 0 fail

# source reverted to dev
3 pass / 3 fail
  (fail) refuses to steal another host's freshly-taken lock
  (fail) guarded removal refuses a token whose host differs
  (fail) a lock this process takes records its own host identity

Every existing suite that exercises withFileLock — including the #606 TOCTOU
guard — stays green:

bun test test/file-lock-gc-toctou.test.ts test/file-lock-cross-host.test.ts \
  test/settings-manager.test.ts test/settings-retry-fallback-migration.test.ts \
  test/gc-stores.test.ts test/gc-redteam.test.ts test/notifications-config.test.ts
→ 147 pass / 0 fail

tsc -p packages/coding-agent/tsconfig.json --noEmit and biome check are both
clean. Rebased onto dev at 9547ca6ae.

A note on coverage

The reclaim path is exercised through the public withFileLock API using a
synthetic foreign-host record, not by a genuine two-host NFS test — I have no
shared-volume host pair available. I want to be straightforward that the
multi-host scenario itself is reasoned from the code path rather than reproduced
on real hardware, even though the guard it installs is directly tested.

Thank you for your time reviewing this.

Owner liveness is probed against the local process table, but a state
directory on a shared volume (NFS home directories are supported) is
contended by several hosts. A remote owner's pid is almost never a live
local pid, so a foreign host's freshly-taken lock read as `dead` and was
reclaimed immediately with no stale grace period, letting two hosts run
the same critical section concurrently.

Stamp the owning host into the lock record. A foreign-host owner is now
reclaimable only after the `staleMs` elapsed-time heuristic - the same
grace a local owner of indeterminate liveness receives - and host
identity is part of owner equality for the guarded lock-dir removal, so
one host cannot delete another's lock on a pid/timestamp coincidence.
Records without a host id keep the previous local-pid semantics.

@Yeachan-Heo Yeachan-Heo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES — exact-head lock authority review

Head: 80ccfbd703d2e12191439e437eca7687b2e85b89
Base/current intake dev: 9547ca6aea41efcfa653156c19f6a91d52ec849c
Verdict: P0 = 0, P1 = 3

  1. Foreign locks are reclaimed solely from elapsed contender wall time. A live remote critical section longer than staleMs, or ordinary clock skew, permits concurrent ownership without remote liveness/fencing proof.
  2. hostname / unknown is not collision-resistant node/boot identity, and hostless legacy records are treated as local. Cloned/container hosts and rolling upgrades can therefore reclaim a live foreign owner via local PID probing.
  3. file-lock-gc.ts bypasses the foreign-host decision and can delete a live remote lock immediately after local ESRCH.

This also directly overlaps active #3596 ownership of file-lock.ts; no owner disposition clears that contract. Use one unified fail-closed rule across acquisition and GC: retain foreign, host-colliding, legacy, or unverifiable records unless a distributed liveness/fencing authority proves retirement. Cover same-hostname clones, boot identity, rolling upgrade, skew, long-lived remote holders, and GC pruning.

Exact GitHub Actions runs 30580152139 and 30580152094 are green, but CI does not override these authority blockers.

[repo owner's gaebal-gajae (clawdbot) 🦞]

@Yeachan-Heo

Copy link
Copy Markdown
Owner

Closed unmerged at exact reviewed head 80ccfbd703d2e12191439e437eca7687b2e85b89 after signed REQUEST_CHANGES review 4823193562. The contributor branch was not mutated. A corrected successor may return only after #3596 ownership is reconciled and cross-host acquisition plus GC share collision-resistant node/boot identity, remote liveness/fencing, clock-skew, long-holder, legacy-record, and same-hostname tests with terminal exact-head CI.

[repo owner's gaebal-gajae (clawdbot) 🦞]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants