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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 01:06:47 +00:00
1 parent a94c167598
commit 3f6ebca2a5
4 files changed
+98 -27

No files matched your search

+30 -15
View File
@@ -109,19 +109,23 @@ state() {
# taken by a live claimant or orphaned by a dead one. A lock is broken only
# when its holder is dead or its pid now belongs to another process, never by
# age, and unlock removes only the caller's own lock. An ownerless lock is
# broken once no claimant lives and it is at least 2 s old, so a taker between
# its mkdir and its owner rename is never taken for a crash, even when its
# claim landed after the claims were read. A symlink .lock left by an older
# broken once no claimant lives and it is at least 2 s old (and unchanged
# while it was checked), so a taker between its mkdir and its owner rename is
# never taken for a crash, even when its claim landed after the claims were
# read; a claim older than 2 minutes is abandoned, since a live taker holds
# one for milliseconds. An owner token whose pid is 0 or not a number counts
# as a dead holder. A symlink .lock left by an older
# gstack names its holder the same way and excludes a directory lock at the
# same path. Known limit: the reverse does not hold, since an older gstack's
# ln -s onto a directory lock links inside it and succeeds; only a process
# that started before an upgrade of this checkout can run that code. A live holder
# makes lock() give up after GSTACK_LEASE_LOCK_TRIES tries 0.1 s apart
# (default 300).
# (default 300) or 30 s, whichever comes first.
LOCK="$DIR/.lock" TOKEN=""
# take PATH — create the lock PATH owned by TOKEN; 1 when it exists.
take() {
printf '%s\n' "$TOKEN" > "$1.claim.$$" || { rm -f "$1.claim.$$"; return 1; }
if [ -e "$1" ] || [ -L "$1" ]; then return 1; fi
{ printf '%s\n' "$TOKEN" > "$1.claim.$$"; } 2>/dev/null || { rm -f "$1.claim.$$"; return 1; }
if mkdir "$1" 2>/dev/null; then
mv -f "$1.claim.$$" "$1/owner" && return 0
rm -rf "$1"
@@ -132,19 +136,27 @@ take() {
# owner_of PATH — the token the lock PATH names, empty when it names none.
owner_of() { if [ -L "$1" ]; then readlink "$1" 2>/dev/null; else head -1 "$1/owner" 2>/dev/null; fi; }
# orphaned PATH — PATH is a lock (not a legacy symlink) that names no owner,
# has no live claimant and is at least 2 s old. Claims are read before the
# owner, so a taker that renames its claim in between is seen as the owner;
# the age is read last, so a lock made since is too new. An empty claim is a
# taker that has not reached its mkdir, so it never made this lock.
# has no live claimant younger than 2 minutes, is at least 2 s old and kept
# its mtime throughout. The clock is read before the first mtime, so a pause
# only makes the lock look younger, and a lock made since (a new generation)
# has a newer mtime. Claims are read before the owner, so a taker that renames
# its claim in between is seen as the owner. An empty claim is a taker that
# has not reached its mkdir, so it never made this lock. An mtime a minute or
# more in the future (clock skew) counts as old.
mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null; }
orphaned() {
_oclock=$(date +%s)
[ -e "$1" ] && [ ! -L "$1" ] || return 1
_om=$(mtime "$1") || return 1
for _c in "$1".claim.*; do
[ -n "$(find "$_c" -mmin -2 2>/dev/null)" ] || continue
_t=$(head -1 "$_c" 2>/dev/null)
[ -n "$_t" ] && ! holder_gone "$_t" && return 1
done
[ -z "$(owner_of "$1")" ] || return 1
_m=$(stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null) || return 1
[ $(( $(date +%s) - _m )) -ge 2 ]
[ "$(mtime "$1")" = "$_om" ] || return 1
_oage=$((_oclock - _om))
[ "$_oage" -ge 2 ] || [ "$_oage" -le -60 ]
}
# drop PATH — remove a lock in one step: a directory is renamed aside first,
# so nobody sees it ownerless on the way out. Once renamed the lock is
@@ -157,26 +169,28 @@ drop() {
}
lock() {
[ -n "$TOKEN" ] || TOKEN="$$ $(identity $$)"
_tries=0 _gone=0
_tries=0 _gone=0 _max=${GSTACK_LEASE_LOCK_TRIES:-300} _start=$SECONDS
case "$_max" in ''|*[!0-9]*) _max=300 ;; esac
until take "$LOCK"; do
LOCK_OWNER=$(owner_of "$LOCK")
if [ ! -e "$LOCK" ] && [ ! -L "$LOCK" ]; then
# Released since take, or take itself cannot work here: never wait out
# the tries for a lock nobody holds.
_gone=$((_gone + 1))
[ "$_gone" -ge 3 ] && return 1
[ "$_gone" -ge 10 ] && return 1
continue
fi
_gone=0 _tries=$((_tries + 1))
if [ -n "$LOCK_OWNER" ]; then holder_gone "$LOCK_OWNER" && break_lock "$LOCK_OWNER" && continue
elif orphaned "$LOCK"; then break_lock "" && continue
fi
[ "$_tries" -ge "${GSTACK_LEASE_LOCK_TRIES:-300}" ] && return 1
[ "$_tries" -ge "$_max" ] && return 1
[ $((SECONDS - _start)) -ge 30 ] && return 1
sleep 0.1
done
}
holder_gone() {
case "${1%% *}" in ''|*[!0-9]*|0|1) return 0 ;; esac
case "${1%% *}" in ''|*[!0-9]*|0) return 0 ;; esac
case "$(state "${1%% *}" "${1#* }" 0)" in dead|stale) return 0 ;; esac; return 1
}
# break_lock OWNER — remove the lock only if it still names OWNER (empty: is
@@ -235,6 +249,7 @@ case "$VERB" in
_tmp="$DIR/.$PID.$$"
identity "$PID" > "$_tmp" && mv -f "$_tmp" "$DIR/$PID" || { rm -f "$_tmp"; unlock; exit 2; }
find "$DIR" -maxdepth 1 -mmin +120 -type f ! -name '.lock*' -exec rm -f {} + 2>/dev/null
find "$DIR" -maxdepth 1 -mmin +2 -type f -name '.lock*.claim.*' -exec rm -f {} + 2>/dev/null
rm -rf "$DIR"/.lock*.gone.* 2>/dev/null
unlock
exit 0 ;;