mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 00:44:07 +01:00
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:
1 parent
a94c167598
commit
3f6ebca2a5
4 files changed
+98
-27
No files matched your search
+4
-4
@@ -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 "<holder token>" .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.<pid>`) 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.<pid>`) 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 <path>` 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 <path>` 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
|
||||
|
||||
|
||||
+30
-15
@@ -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 ;;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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<number | null>((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<number | null>((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<string, string> = { 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;
|
||||
|
||||
Reference in new issue
Block a user