Skip to content

[bug] acquireLock can hand one lock to two live processes #760

Description

@SaulMoro

acquireLock (src/update.ts) is the lock behind pull/push (the sync lock that keeps another pull from racing the report), migrate, bootstrap, the update check and, since #758, the one-time usage discard. Under contention it hands the same lock to two live processes.

Evidence

16 processes, each calling acquireLock / releaseLock on one path in a loop. While a process holds the lock it creates a marker file with O_EXCL; EEXIST means a second holder at the same time.

Run Attempts Acquired Two holders at once
1 16 × 2000 791 9
2 16 × 2000 1048 6
3 16 × 2000 955 12

About 1% of acquisitions overlap another holder.

Cause

The reclaim branch treats a lock that has just vanished as stale, then renames over whatever is there by then:

A holds the lock, B contends
B  exclusiveCreate(lock)           EEXIST
B  isLockStale(lock)               …
A  releaseLock → remove(lock)
B    readFile fails → "vanished"   → stale = true
B  acquireReclaimSentinel          ok
C  exclusiveCreate(lock)           ok: C holds the lock
B  exclusiveCreate(lock)           EEXIST (C's lock)
B  isLockStale(lock)               may read C's file mid-write (empty → "unparseable" → stale),
                                   or a lock that vanished again
B  rename(tmp, lock)               overwrites C's lock: B and C both hold it

Confirmed by counting, at the rename in that branch, targets owned by a different live pid: 100 and 122 in two runs of 16 × 1000, against 22 and 24 overlaps inside the critical section.

A second, smaller hole, found in review of #758: exclusiveCreate uses writeFile(…, { flag: 'wx' }), so the lock file exists before its content, and a reader in between sees an empty file and calls it stale. Closing only this one (payload written to a temp file, then linked into place) does not remove the overlaps: 7 in 16 × 500 with it closed.

Proposed fix

 // update.ts
-async function isLockStale(resolved): Promise<boolean>
+async function lockState(resolved): Promise<'live' | 'stale' | 'missing'>

 // acquireLock, reclaim branch (sentinel held)
-  if (!(await isLockStale(resolved))) return false;
-  rename(tmp, resolved)
+  loop: missing → exclusiveCreate again · live → return false · stale → rename(tmp, resolved)

 // exclusiveCreate
-  writeFile(target, payload, { flag: 'wx' })
+  writeFile(tmp, payload); link(tmp, target)   // the name never exists without its content

With the sentinel held, a lock that is present and stale cannot be replaced by anyone else, so renaming over it stays safe; only the missing case needs the retry. A regression test can run the stress loop above with a few workers (child processes) and assert no overlap.

Impact

Several sessions starting at once each run a session-start pull, so two pulls can hold the sync lock together and report the same usage twice or truncate events the other just read. For the usage discard in #758, the worst case is losing one telemetry event.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions