From 3f6ebca2a555eb69755cf3ddedb2bf8b4fc708ed Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 26 Oct 2026 01:06:47 +0000 Subject: [PATCH] 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> --- CHANGELOG.md | 8 ++--- bin/gstack-lease | 45 ++++++++++++++++++--------- docs/capy-install.md | 7 +++-- test/gstack-lease.test.ts | 65 ++++++++++++++++++++++++++++++++++++--- 4 files changed, 98 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58142cecf..b76360e2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,19 +5,19 @@ **Skills start at once on Windows again: the workflow lease lock no longer depends on symlinks (#3330).** `bin/gstack-lease` took its lock with `ln -s "" .lock`. In Git for Windows' default symlink mode (`MSYS` unset), `ln -s` cannot link to a target that does not exist, so the lock was never taken. Every skill start then waited out all 300 tries (0.1 s apart, plus process start costs) before `gstack-skill-start` printed anything, and still wrote no lease. One report measured 499 s per skill start. The error also blamed a "live process" with an empty pid. ### Fixed -- **`bin/gstack-lease`**: the lock is now a `.lock` directory taken with `mkdir`, which is atomic on every host gstack runs on, with an `owner` file naming the holder's pid and start identity. A taker writes a claim (`.lock.claim.`) before its `mkdir` and renames it in as the owner after, so a lock left ownerless by a taker killed between the two steps is broken once every claimant is dead and the lock is at least 2 s old (a live taker between its two steps is never mistaken for a crash). A lock whose owner is empty or names no valid pid, or a stray file at `.lock`, counts as ownerless instead of wedging every skill start. A lock is removed by renaming it aside first, so nobody sees it half-removed; once renamed it is released even if the renamed copy cannot be deleted, and `touch` prunes leftover copies. Stale-holder semantics are unchanged: a lock is broken only when its holder is dead or its pid now belongs to another process, breakers still serialize on `.lock.break` and reread before removing, and unlock removes only the caller's own lock. A symlink `.lock` left by an older gstack is still read and broken the same way, and it excludes the new lock at the same path. The reverse does not hold: an older gstack's `ln -s` onto the directory lock links inside it, so a process that started before an upgrade of the same checkout can briefly share the lock (documented as a known limit). +- **`bin/gstack-lease`**: the lock is now a `.lock` directory taken with `mkdir`, which is atomic on every host gstack runs on, with an `owner` file naming the holder's pid and start identity. A taker writes a claim (`.lock.claim.`) before its `mkdir` and renames it in as the owner after, so a lock left ownerless by a taker killed between the two steps is broken once no claimant younger than 2 minutes lives and the lock is at least 2 s old and unchanged while it was checked (a live taker between its two steps is never mistaken for a crash, and a new lock made meanwhile is never removed). An owner that names no valid pid (0 or not a number) counts as a dead holder, and an empty owner, a stray file at `.lock`, or an ownerless lock whose mtime is a minute or more in the future (clock skew) counts as ownerless instead of wedging every skill start. A lock is removed by renaming it aside first, so nobody sees it half-removed; once renamed it is released even if the renamed copy cannot be deleted, and `touch` prunes leftover copies and claims older than 2 minutes. A waiter no longer writes a claim or runs `mkdir` while the lock is visibly held, and a live holder that never lets go bounds the wait at 300 tries or 30 s, whichever comes first (a non-numeric `GSTACK_LEASE_LOCK_TRIES` falls back to 300 instead of looping forever). Stale-holder semantics are unchanged: a lock is broken only when its holder is dead or its pid now belongs to another process, breakers still serialize on `.lock.break` and reread before removing, and unlock removes only the caller's own lock. A symlink `.lock` left by an older gstack is still read and broken the same way, and it excludes the new lock at the same path. The reverse does not hold: an older gstack's `ln -s` onto the directory lock links inside it, so a process that started before an upgrade of the same checkout can briefly share the lock (documented as a known limit). - A dead holder's lock that cannot be renamed aside (a Windows file in use) is removed in place under `.lock.break`, and when that fails too the try is counted, so `touch` ends with exit 3 instead of spinning without sleeping. The message no longer calls a dead holder live. - The 120-minute prune in `touch` no longer reaches into the lock: it removed the `owner` file of a holder that resumed past the window, which then left its own lock behind. -- When the lock cannot be taken although nobody holds it, `touch` now fails after three quick retries with `gstack-lease: cannot take the lease directory lock ` instead of waiting out every try and naming an empty pid. `gstack-skill-start` stays fail-open, as before. +- When the lock cannot be taken although nobody holds it, `touch` now fails after ten quick retries with `gstack-lease: cannot take the lease directory lock ` instead of waiting out every try and naming an empty pid. `gstack-skill-start` stays fail-open, as before. - `docs/capy-install.md` describes the directory lock. ### Tests - `test/gstack-lease-git-bash.test.ts` (new, in the curated windows-free-tests lane, launched through `lib/script-invocation.ts`, so Git Bash on `windows-latest`): `touch` with the default 300 tries takes the lock on its first try (no sleep between tries), writes its lease and leaves no lock. On `windows-latest` the old bin fails this case; a host whose `ln -s` fails like Git Bash's still takes the lock (with a control that the shim really fails `ln -s`); a lock held by a live Git Bash process is kept and named, and a dead holder's lock is broken. The last two fail against the old bin. - `test/gstack-lease.test.ts`: the dead-holder, live-holder and dead-breaker cases run against both the directory lock and the legacy symlink lock. The race cases inject at `mkdir` instead of `ln`. New cases: a lock held by a running gstack-lease excludes a second taker, which names it; 16 parallel touches all land and leave nothing behind; an ownerless lock is kept while a claimant lives and broken once none does; a lock that cannot be taken while nobody holds it fails without sleeping between tries. Seven of these fail against the old bin. -- From /ship's coverage audit and review: the 16 parallel touches now also prove no two are inside the lock at once (a mutant whose `mkdir` always succeeds fails it); the exclusion case holds the lock until the test releases it instead of for a 3 s sleep; a young ownerless lock is kept and malformed locks (empty owner, pid 0, stray file) are broken; the prune keeps a resumed holder's lock; a renamed-aside lock that cannot be deleted never takes a new taker's lock with it; a dead holder's lock that cannot be renamed aside ends `touch` instead of spinning. The last four fail against the bin before their fix. Also covered: a live or ownerless `.lock.break`, a failed owner rename, a lock released between a failed take and its check, and unlock's fallback. The Git Bash case now also checks that the live holder still owns the lock after a blocked `touch`. +- From /ship's coverage audit and review: the 16 parallel touches now also prove no two are inside the lock at once (a mutant whose `mkdir` always succeeds fails it); the exclusion case holds the lock until the test releases it instead of for a 3 s sleep; a young ownerless lock is kept and malformed locks (empty owner, pid 0, a non-numeric owner, a stray file, an mtime an hour ahead) are broken; a claim older than 2 minutes neither keeps an ownerless lock nor survives `touch`; a live holder bounds `touch` by the 30 s deadline with the default and with a non-numeric tries value, without one `mkdir` on the held lock; the prune keeps a resumed holder's lock; a renamed-aside lock that cannot be deleted never takes a new taker's lock with it; a dead holder's lock that cannot be renamed aside ends `touch` instead of spinning. Each case that pins a fix fails against the bin before that fix. Also covered: a live or ownerless `.lock.break`, a failed owner rename, a lock released between a failed take and its check, and unlock's fallback. The Git Bash case now also checks that the live holder still owns the lock after a blocked `touch`. ### Gate integrity -- No threshold, budget, timeout, trial count, case kind or assertion was weakened. Three existing race tests moved their fault injection from `ln` to `mkdir`, because the lock no longer calls `ln`. Their assertions are unchanged. Under /ship review, the exclusion case's wait for the holder's owner file rose from 2 s to 10 s and its fixed 3 s hold became a release file (no assertion changed), and the ownerless-lock cases backdate their planted lock by a minute because an ownerless lock is now broken only once 2 s old (their assertions are unchanged; the new young-lock case pins the guard). +- No threshold, budget, timeout, trial count, case kind or assertion was weakened. Three existing race tests moved their fault injection from `ln` to `mkdir`, because the lock no longer calls `ln`. Their assertions are unchanged. Under /ship review, the exclusion case's wait for the holder's owner file rose from 2 s to 10 s and its fixed 3 s hold became a release file (no assertion changed), the ownerless-lock cases backdate their planted lock by a minute because an ownerless lock is now broken only once 2 s old (their assertions are unchanged; the new young-lock case pins the guard), and the exclusion and parallel cases got an explicit 30 s test timeout instead of Bun's 5 s default. ## [1.91.221.0] - 2026-10-25 diff --git a/bin/gstack-lease b/bin/gstack-lease index cccae420f..f0e07f222 100755 --- a/bin/gstack-lease +++ b/bin/gstack-lease @@ -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 ;; diff --git a/docs/capy-install.md b/docs/capy-install.md index 2d14542fa..2c36c3c3d 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -126,9 +126,10 @@ 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 (or one whose owner is empty or names no valid pid) is broken only once -every claimant is dead and the lock is at least 2 s old, which keeps a taker -between its two steps safe. The lock is broken only when its holder is dead or +lock is broken only once no claimant younger than 2 minutes lives and the +lock is at least 2 s old and unchanged while checked, which keeps a taker +between its two steps safe; an owner that names no valid pid counts as a dead +holder. A live holder that never lets go bounds the wait at 300 tries or 30 s. The lock is broken only when its 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. Two known limits remain. An older gstack's `ln -s` diff --git a/test/gstack-lease.test.ts b/test/gstack-lease.test.ts index a9ba00068..12da26de5 100644 --- a/test/gstack-lease.test.ts +++ b/test/gstack-lease.test.ts @@ -280,7 +280,7 @@ describe('gstack-lease', () => { const [first, second] = [sleeper(), sleeper()]; // The first touch holds the lock across its prune (find runs under the lock) until the test releases it. const release = path.join(dir, 'release'); - const slow = bashEnv(dir, `find() { until [ -e "${release}" ]; do sleep 0.05; done; command find "$@"; }\n`); + const slow = bashEnv(dir, `find() { case "$*" in *'+120'*) until [ -e "${release}" ]; do sleep 0.05; done ;; esac; command find "$@"; }\n`); const holder = spawn('bash', [LEASE, 'touch', '--root', dir, '--pid', String(first)], { env: { ...baseEnv, BASH_ENV: slow }, stdio: 'ignore' }); children.push(holder); const exited = new Promise((resolve) => holder.on('exit', resolve)); @@ -294,7 +294,7 @@ describe('gstack-lease', () => { expect(await exited).toBe(0); expect(run(['touch', '--root', dir, '--pid', String(second)]).status).toBe(0); expect(fs.readdirSync(leases).sort()).toEqual([String(first), String(second)].sort()); - }); + }, 30_000); test('parallel touches all take the lock in turn and leave no lock, claim or temp file behind', async () => { const { dir, leases } = checkout(); @@ -302,7 +302,7 @@ describe('gstack-lease', () => { // The prune runs under the lock: a second process inside it at the same time logs an overlap. const inside = path.join(dir, 'inside'); const overlaps = path.join(dir, 'overlaps.log'); - const probe = bashEnv(dir, `find() { command mkdir "${inside}" 2>/dev/null || echo overlap >> "${overlaps}"; sleep 0.05; command rmdir "${inside}" 2>/dev/null; command find "$@"; }\n`); + const probe = bashEnv(dir, `find() { case "$*" in *'+120'*) command mkdir "${inside}" 2>/dev/null || echo overlap >> "${overlaps}"; sleep 0.05; command rmdir "${inside}" 2>/dev/null ;; esac; command find "$@"; }\n`); const codes = await Promise.all(pids.map((p) => new Promise((resolve) => { const c = spawn('bash', [LEASE, 'touch', '--root', dir, '--pid', String(p)], { env: { ...baseEnv, BASH_ENV: probe }, stdio: 'ignore' }); children.push(c); @@ -311,7 +311,7 @@ describe('gstack-lease', () => { expect(codes).toEqual(pids.map(() => 0)); expect(fs.existsSync(overlaps), 'two touches were inside the lock at once').toBe(false); expect(fs.readdirSync(leases).sort()).toEqual(pids.map(String).sort()); - }); + }, 30_000); // #3330: a taker killed between its mkdir and its owner rename leaves an ownerless lock. test('an ownerless lock is broken once every claimant is gone, and kept while a live claimant may still own it', () => { @@ -355,7 +355,9 @@ describe('gstack-lease', () => { const holder = sleeper(); const lockPath = path.join(leases, '.lock'); fs.mkdirSync(lockPath, { recursive: true }); - const young = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '3' }); + // The clock reads the lock's own mtime, so the lock stays 0 s old however slow the runner is. + const frozen = bashEnv(dir, `date() { case "$1" in +%s) command stat -c %Y "${lockPath}" 2>/dev/null || command stat -f %m "${lockPath}" ;; *) command date "$@" ;; esac; }\n`); + const young = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '3', BASH_ENV: frozen }); expect(young.status, young.stdout + young.stderr).toBe(3); expect(fs.existsSync(lockPath)).toBe(true); expect(fs.existsSync(path.join(leases, String(holder)))).toBe(false); @@ -363,6 +365,8 @@ describe('gstack-lease', () => { const shapes: Array<[string, () => void]> = [ ['an empty owner file', () => plantLock(lockPath, '')], ['owner pid 0', () => plantLock(lockPath, '0 ')], + ['a non-numeric owner', () => plantLock(lockPath, 'garbage x')], + ['an ownerless lock an hour ahead (clock skew)', () => fs.mkdirSync(lockPath)], ['a stray file', () => { fs.rmSync(lockPath, { recursive: true, force: true }); fs.writeFileSync(lockPath, ''); }], ]; for (const [shape, plant] of shapes) { @@ -370,12 +374,63 @@ describe('gstack-lease', () => { fs.rmSync(path.join(leases, String(holder)), { force: true }); plant(); age(lockPath); + if (shape.includes('clock skew')) { + const hourAhead = new Date(Date.now() + 3_600_000); + fs.utimesSync(lockPath, hourAhead, hourAhead); + } const r = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '3' }); expect(r.status, `${shape}: ${r.stdout}${r.stderr}`).toBe(0); expect(fs.readdirSync(leases), shape).toEqual([String(holder)]); } }); + // Value: protects=a claim left by a killed taker neither blocks recovery of an ownerless lock (even when its pid is alive again) nor lingers: + // a claim older than 2 minutes is abandoned, and touch prunes it; + // fails_when=orphaned() honors claims of any age, or touch's prune skips claim files; + // why_new=the ownerless cases plant fresh claims only; nothing ages one or checks the prune for claims; + // seam=none; no_claim=real pid reuse (a live sleeper stands in for the reused pid) + test('a claim older than 2 minutes is abandoned: it neither keeps an ownerless lock nor survives touch', () => { + const { dir, leases } = checkout(); + const [holder, reused] = [sleeper(), sleeper()]; + const lockPath = path.join(leases, '.lock'); + fs.mkdirSync(lockPath, { recursive: true }); + age(lockPath); + const claim = path.join(leases, `.lock.claim.${reused}`); + fs.writeFileSync(claim, `${reused} \n`); + const fiveMinutesAgo = new Date(Date.now() - 300_000); + fs.utimesSync(claim, fiveMinutesAgo, fiveMinutesAgo); + const r = run(['touch', '--root', dir, '--pid', String(holder)], { GSTACK_LEASE_LOCK_TRIES: '3' }); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(fs.readdirSync(leases)).toEqual([String(holder)]); + }); + + // Value: protects=a live holder that never lets go bounds touch at 30 s even with the default 300 tries, a non-numeric + // GSTACK_LEASE_LOCK_TRIES falls back to the default instead of looping forever, and a visibly held lock is never mkdir'd; + // fails_when=lock() loses its SECONDS deadline or its tries validation, or take() stops checking the path first; + // why_new=every live-holder case lowers the tries; none runs the default budget, a bad value or counts mkdir calls; + // seam=none; no_claim=real elapsed time (a sleep shim advances SECONDS 10 s per wait) + test('a live holder bounds touch by a 30 s deadline, and a non-numeric tries value cannot loop forever', () => { + const { dir, leases } = checkout(); + const [holder, owner] = [sleeper(), sleeper()]; + plantLock(path.join(leases, '.lock'), `${owner} `); + const waits = path.join(dir, 'waits.log'); + const mkdirs = path.join(dir, 'mkdirs.log'); + const shim = bashEnv(dir, `sleep() { echo "$*" >> "${waits}"; SECONDS=$((SECONDS + 10)); }\nmkdir() { case "$*" in *"/.lock") echo "$*" >> "${mkdirs}" ;; esac; command mkdir "$@"; }\n`); + for (const tries of [undefined, 'abc']) { + fs.rmSync(waits, { force: true }); + const env: Record = { BASH_ENV: shim }; + if (tries) env.GSTACK_LEASE_LOCK_TRIES = tries; + const r = spawnSync('bash', [LEASE, 'touch', '--root', dir, '--pid', String(holder)], { encoding: 'utf8', env: { ...baseEnv, ...env }, timeout: 20_000 }); + expect(r.error?.message ?? '', `tries=${tries}: touch did not end`).not.toContain('ETIMEDOUT'); + expect(r.status, `tries=${tries}: ${r.stdout}${r.stderr}`).toBe(3); + expect(r.stderr).toContain(`locked by live process ${owner};`); + expect(r.stderr).not.toContain('integer expression'); + expect(fs.readFileSync(waits, 'utf8').trim().split('\n').length, `tries=${tries}`).toBeLessThanOrEqual(3); + } + expect(fs.existsSync(mkdirs), 'take() ran mkdir on a visibly held lock').toBe(false); + expect(ownerOf(path.join(leases, '.lock'))).toBe(String(owner)); + }, 30_000); + // Value: protects=a holder that resumes past the 120-minute prune window keeps its own lock metadata, so unlock still recognizes and removes it; // fails_when=touch's prune recurses into .lock (deleting owner) or matches lock files at the top level; // why_new=the prune test plants only lease files; none ages the lock's owner file before the prune runs;