v1.91.228.0 fix(gstack-lease): take the lease lock with mkdir, not ln -s, so Git Bash on Windows stops waiting out 300 tries (#3330) (#3424)

* fix(gstack-lease): take the lease lock with mkdir, not ln -s, so Git Bash on Windows stops waiting out 300 tries (#3330)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(gstack-lease): count lock retries instead of timing them

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): a dead lock that cannot be renamed aside no longer spins lock()

break_lock ignored drop()'s failure and returned 0, so lock() continued past
the tries limit forever when a dead holder's .lock could not be renamed aside
(a Windows file in use). It now removes the lock in place under .lock.break,
and returns 1 when that also fails so the try is counted. touch no longer calls
a dead holder 'live' in its exit-3 message.

Adds the /ship coverage-audit tests for the lock's failure paths.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): pre-landing review fixes

- drop() reports a lock released once it is renamed aside, so a failed
  delete of the renamed copy never makes unlock or break_lock rm -rf a new
  taker's lock at .lock.
- An ownerless lock is broken only once it is 2 s old, so a live taker whose
  claim landed after a breaker read the claims keeps the lock it is about to
  publish. An empty owner, an invalid pid or a stray file counts as ownerless.
- touch's prune stays out of the lock (top-level lease files only) and clears
  leftover renamed-aside locks; a failed claim write leaves no claim.
- Docs and CHANGELOG state the mixed-version and dead-break-lock limits
  instead of claiming exclusion that does not hold.

Tests pin each fix (each fails against the previous bin), make the exclusion
case wait on a release file, prove the parallel case is mutually exclusive,
and check the Git Bash holder keeps its lock.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): second /ship review pass

- orphaned() reads the clock before the lock's mtime and requires the mtime
  unchanged at the end, so a lock made meanwhile is never removed; an mtime
  a minute or more ahead (clock skew) counts as old.
- Claims older than 2 minutes are abandoned and pruned by touch.
- A waiter skips the claim and mkdir while the lock is visibly held; the
  wait is bounded at 300 tries or 30 s; a non-numeric tries value falls back
  to 300; ten quick retries (not three) before a lock nobody holds is
  reported as untakeable; pid 1 is a valid holder again.
- Tests: deterministic young-lock clock, skew and non-numeric owner shapes,
  abandoned claims, the deadline, explicit 30 s timeouts.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): third /ship review pass

- A claim is honored while its claimant lives; only a claim with no start
  identity expires after 2 minutes, so a stalled live taker keeps its lock on
  hosts that record identities (the remaining case is a documented limit).
- A late taker never overwrites an owner already in its directory, and does
  not remove a directory that has one.
- A lock whose owner file exists but cannot be read is never ownerless.
- Zero-padded owner pids count as dead; --help prints the whole usage;
  touch prunes only claims whose claimant is gone or recorded no identity.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(lease): scope 'never by age' to locks that name a holder

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-26 04:46:42 +00:00
1 parent 9dc1dfb729
commit e622075a18
8 files changed
+662 -61

No files matched your search

+24 -9
View File
@@ -120,15 +120,30 @@ lease …`). Stale needs proof: two well-formed identities from the same backend
that differ (`ps` is read under `LC_ALL=C TZ=UTC0`). An identity that cannot be
read, a malformed record or a backend change keeps the lease. `touch` (publish
by rename, prune) and reclaim (reread, then delete only the record judged
stale) run under one lock in the lease directory, a symlink naming its holder's
pid and start identity, so a renewal is never deleted and a live lease never
disappears from a concurrent scan. The lock is broken only when its holder is
dead or its pid was reused, never by age, and breakers serialize on a second
symlink and reread the lock before removing it. One killed gstack-lease process
is always safe; two killed in a row, one holding the lock and the next while
breaking it, can still let a reclaim delete a renewal. While a live process holds it,
`touch` exits 3 without writing (`gstack-skill-start` reports that the
session holds no lease) and a reclaim keeps the lease. `bin/gstack-lease list` shows `alive|dead|stale`, and
stale) run under one lock in the lease directory, a `.lock` directory whose
`owner` file names its holder's pid and start identity, so a renewal is never
deleted and a live lease never disappears from a concurrent scan. The lock is a
directory because `mkdir` is atomic on every host, while Git Bash on Windows
cannot `ln -s` to a target that does not exist (#3330). A taker publishes a
claim before its `mkdir` and renames it in as the owner after, so an ownerless
lock is broken only once no claimant lives and the lock is at least 2 s old
and unchanged while checked, which keeps a taker between its two steps safe.
A claim that records no start identity is abandoned after 2 minutes, so a
reused pid cannot keep an orphan forever; an owner that names no valid pid
counts as a dead holder, and an owner file that cannot be read keeps the
lock. A live holder that never lets go bounds the wait at 300 tries or 30 s. A lock that names a holder is broken only when that holder is dead or
its pid was reused, never by age, and breakers serialize on a second lock and
reread the lock before removing it. A symlink `.lock` left by an older gstack
is honored the same way. Three known limits remain. An older gstack's `ln -s`
onto a directory lock links inside it and succeeds, so a process that started
before an upgrade of this checkout can share the lock with a newer one. And a
breaker killed while holding the second lock, followed by two live breakers
clearing it at once, can let a reclaim delete a renewal. On a host without start
identities, a taker stalled over 2 minutes between its `mkdir` and its owner
rename can be broken and then publish into its successor's lock. While a live process
holds the lock, or it cannot be taken at all, `touch` exits 3 without writing
(`gstack-skill-start` reports that the session holds no lease) and a reclaim
keeps the lease. `bin/gstack-lease list` shows `alive|dead|stale`, and
`GSTACK_UPGRADE_FORCE=1` overrides.
## What the installer does