From 271ed035e56924d8a3ea2faefd899a47b838c6d7 Mon Sep 17 00:00:00 2001 From: ThinkOff Date: Thu, 16 Jul 2026 11:43:00 +0300 Subject: [PATCH 1/2] fix(bootstrap): single room-responder lock per notification file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every fresh Claude Code session on a machine boots the same SessionStart bootstrap and becomes a room responder — so a stray interactive session (e.g. `claude rc` on 2026-07-16) answers the room as a duplicate of the same handle, racing the real agent with conflicting posts. session-bootstrap.sh now claims .responder.lock for the first session; later sessions receive PASSIVE instructions (serve the user directly, never answer the room, explicit takeover recipe). The lock stores owner pid + session id: reclaimed by the same session across resume/compact, stolen automatically when the owner process is dead, and any lock-mechanics failure fails open to ACTIVE. Opt out or relocate via IAK_RESPONDER_LOCK. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 ++ scripts/session-bootstrap.sh | 72 ++++++++++++++++++- test/session-bootstrap.test.mjs | 120 +++++++++++++++++++++++++++++++- 3 files changed, 194 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3687d27..9bf379e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +### Fixed +- **Duplicate room-responder voices**: `session-bootstrap.sh` now enforces a single room responder per notification file via a lock file (`.responder.lock`). The first session claims the lock and gets the full bootstrap; any later session on the same machine gets PASSIVE instructions (serve the user directly, never answer the room) instead of booting a second copy of the same handle. The lock stores the owner's pid + session id, so the owner reclaims it across resume/compact, and a lock whose owner process died is stolen automatically. Disable with `IAK_RESPONDER_LOCK=off`, relocate with `IAK_RESPONDER_LOCK=`. Motivation: on 2026-07-16 a stray `claude rc` session on the Mac mini booted the same bootstrap and posted to the room as a second @claudemm, producing conflicting answers. + ## 0.9.0 (2026-06-10) ### Added diff --git a/scripts/session-bootstrap.sh b/scripts/session-bootstrap.sh index ad36b73..6ca4763 100755 --- a/scripts/session-bootstrap.sh +++ b/scripts/session-bootstrap.sh @@ -12,11 +12,25 @@ # with suppressOutput, so the instructions are injected as session context. # It never blocks session start: any failure degrades to no output + exit 0. # +# Single-responder guard: only ONE session per notification file may act as +# the room responder. Without it, every fresh session on the machine (a +# second interactive `claude`, a remote-control shell, a stray automation +# launch) boots the same instructions and answers the room as the same +# handle — duplicate, conflicting posts (bit the fleet on 2026-07-16). The +# first session claims a lock file; later sessions get PASSIVE instructions +# (serve the user directly, stay out of the room). The lock carries the +# owner's pid + session id: it is reclaimed by the same session on +# resume/compact and stolen automatically once the owner process is dead. +# Any failure in the lock mechanics degrades to ACTIVE (status quo) — a +# rare duplicate voice beats a machine with no room responder at all. +# # Configuration (first match wins): # notification file : $IAK_NEW_FILE, else config poller.notification_file, # else /tmp/iak-new-messages.txt (poller default) # agent handle : $IAK_HANDLE, else config poller.handle (cosmetic) # fallback interval : $IAK_BOOTSTRAP_FALLBACK_SEC (default 1500 seconds) +# responder lock : $IAK_RESPONDER_LOCK (path, or "off" to disable), +# else .responder.lock # config file : $IAK_CONFIG_JSON, else ide-agent-kit.json next to the # repo (scripts/..), else two levels up — which is the # project root when `ide-agent-kit init` installed this @@ -89,17 +103,71 @@ INPUT=$(cat 2>/dev/null || true) SOURCE=$(printf '%s' "$INPUT" | python3 -c "import json,sys try: print(json.load(sys.stdin).get('source','')) except Exception: print('')" 2>/dev/null || true) +SESSION_ID=$(printf '%s' "$INPUT" | python3 -c "import json,sys +try: print(json.load(sys.stdin).get('session_id','')) +except Exception: print('')" 2>/dev/null || true) + +# --- single-responder lock ------------------------------------------------- +# ROLE ends up ACTIVE (full bootstrap) or PASSIVE (stand down); see header. +LOCK_FILE="${IAK_RESPONDER_LOCK:-${NEWMSG_FILE}.responder.lock}" +ROLE="active" +LOCK_PID="" +LOCK_SID="" +LOCK_HELD="" + +pid_alive() { + [ -n "$1" ] || return 1 + case "$1" in (*[!0-9]*|'') return 1;; esac + kill -0 "$1" 2>/dev/null +} + +write_lock() { + # Best-effort atomic replace; failure leaves ROLE=active (fail open). + local tmp="${LOCK_FILE}.$$" + { + printf 'pid=%s\n' "$PPID" + printf 'sid=%s\n' "$SESSION_ID" + printf 'claimed_at=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || true)" + } > "$tmp" 2>/dev/null && mv -f "$tmp" "$LOCK_FILE" 2>/dev/null || rm -f "$tmp" 2>/dev/null +} + +if [ "$LOCK_FILE" != "off" ]; then + if [ -f "$LOCK_FILE" ]; then + LOCK_PID=$(sed -n 's/^pid=//p' "$LOCK_FILE" 2>/dev/null | head -1) + LOCK_SID=$(sed -n 's/^sid=//p' "$LOCK_FILE" 2>/dev/null | head -1) + if [ -n "$SESSION_ID" ] && [ "$LOCK_SID" = "$SESSION_ID" ]; then + write_lock # same session resuming: refresh pid + elif [ "$LOCK_PID" = "$PPID" ]; then + write_lock # same process (e.g. compact): keep ownership + elif pid_alive "$LOCK_PID"; then + ROLE="passive" + else + write_lock # owner died without cleanup: steal the stale lock + fi + else + write_lock + fi + [ "$ROLE" = "active" ] && LOCK_HELD="$LOCK_FILE" +fi BACKLOG="" if [ -s "$NEWMSG_FILE" ]; then BACKLOG=$(wc -l < "$NEWMSG_FILE" | tr -d ' ') fi -CTX="IAK room-agent bootstrap${HANDLE:+ for ${HANDLE}} (injected by SessionStart hook — do this without being asked): +if [ "$ROLE" = "passive" ]; then + CTX="IAK room-agent bootstrap${HANDLE:+ for ${HANDLE}} — PASSIVE (injected by SessionStart hook): another live session already holds this machine's room-responder role for ${NEWMSG_FILE} (owner pid ${LOCK_PID}${LOCK_SID:+, session ${LOCK_SID}}; lock ${LOCK_FILE}). +1. Do NOT arm a Monitor on ${NEWMSG_FILE}, do NOT run the room loop, and do NOT answer room messages${HANDLE:+ as ${HANDLE}} — two sessions answering as one handle produces duplicate, conflicting posts. +2. Serve the user's direct requests in this session normally; reading the room for context is fine, responding to it is not. +3. Take over ONLY if the user explicitly asks this session to become the room responder: delete ${LOCK_FILE}, then follow the standard bootstrap (Monitor + backlog + '/loop check rooms')." +else + CTX="IAK room-agent bootstrap${HANDLE:+ for ${HANDLE}} (injected by SessionStart hook — do this without being asked): 1. If TaskList shows no running Monitor watching ${NEWMSG_FILE}, arm one now: Monitor(command: \"tail -F -n 0 ${NEWMSG_FILE} 2>/dev/null\", persistent: true). Never arm a duplicate. 2. Backlog: ${BACKLOG:-0} unconsumed line(s) in ${NEWMSG_FILE}. If >0: read the messages (prefer the IAK MCP room_list_new tool), act on them, then ack with the IAK MCP room_ack tool - it removes ONLY what you read, so a message the poller appends mid-ack survives. Only if the IAK MCP tools are unavailable this session, fall back to reading the file directly and clearing it (: > ${NEWMSG_FILE}), re-checking immediately after for lines that arrived during the clear. 3. Operate as the self-paced room loop ('/loop check rooms' semantics): after handling room work, keep a fallback ScheduleWakeup armed (~${FALLBACK_SEC}s) with prompt '/loop check rooms'. The Monitor is the primary wake signal. -4. Session source: ${SOURCE:-unknown}. On 'compact' the Monitor usually survived — verify via TaskList instead of re-arming blindly." +4. Session source: ${SOURCE:-unknown}. On 'compact' the Monitor usually survived — verify via TaskList instead of re-arming blindly.${LOCK_HELD:+ +5. You hold the room-responder lock (${LOCK_HELD}): this session is the machine's ONLY room voice. The lock goes stale automatically when this session's process exits — never delete it while active.}" +fi python3 - "$CTX" <<'PYEOF' 2>/dev/null import json, sys diff --git a/test/session-bootstrap.test.mjs b/test/session-bootstrap.test.mjs index 9daf5b6..893a1f0 100644 --- a/test/session-bootstrap.test.mjs +++ b/test/session-bootstrap.test.mjs @@ -2,7 +2,7 @@ import { describe, it, afterEach } from 'node:test'; import { strict as assert } from 'node:assert'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawn, spawnSync } from 'node:child_process'; import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, realpathSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { tmpdir } from 'node:os'; @@ -42,6 +42,7 @@ function runHook({ input = '', env = {} } = {}) { ...process.env, IAK_CONFIG_JSON: '/tmp/iak-nonexistent-config.json', IAK_NEW_FILE: '/tmp/iak-nonexistent-new-messages.txt', + IAK_RESPONDER_LOCK: 'off', ...env, }, }); @@ -106,6 +107,19 @@ describe('session-bootstrap.sh', () => { assert.match(ctx, /for @testbot/); }); + it('is disabled entirely with IAK_RESPONDER_LOCK=off (no lock file, no lock text)', () => { + const dir = tempDir(); + const newFile = join(dir, 'new.txt'); + const payload = runHook({ + input: JSON.stringify({ source: 'startup', session_id: 'sess-off' }), + env: { IAK_NEW_FILE: newFile }, + }); + const ctx = payload.hookSpecificOutput.additionalContext; + assert.match(ctx, /arm one now/); + assert.ok(!ctx.includes('room-responder lock')); + assert.equal(existsSync(`${newFile}.responder.lock`), false); + }); + it('env overrides beat the config values', () => { const dir = tempDir(); const configPath = join(dir, 'ide-agent-kit.json'); @@ -124,6 +138,110 @@ describe('session-bootstrap.sh', () => { }); }); +describe('single-responder lock', () => { + let sleeper = null; + const startSleeper = () => { + sleeper = spawn('sleep', ['60'], { stdio: 'ignore' }); + sleeper.unref(); + return sleeper.pid; + }; + afterEach(() => { + if (sleeper) { + try { sleeper.kill('SIGKILL'); } catch { /* already gone */ } + sleeper = null; + } + }); + + const lockEnv = (dir) => { + // Undo runHook's 'off' default: use the real derived-path behavior. + return { IAK_NEW_FILE: join(dir, 'new.txt'), IAK_RESPONDER_LOCK: `${join(dir, 'new.txt')}.responder.lock` }; + }; + + it('first session claims the lock and gets the ACTIVE bootstrap', () => { + const dir = tempDir(); + const env = lockEnv(dir); + const payload = runHook({ + input: JSON.stringify({ source: 'startup', session_id: 'sess-a' }), + env, + }); + const ctx = payload.hookSpecificOutput.additionalContext; + assert.match(ctx, /arm one now/); + assert.match(ctx, /You hold the room-responder lock/); + const lock = readFileSync(env.IAK_RESPONDER_LOCK, 'utf8'); + assert.match(lock, /^pid=\d+$/m); + assert.match(lock, /^sid=sess-a$/m); + }); + + it('derives the lock path from the notification file when not set explicitly', () => { + const dir = tempDir(); + const newFile = join(dir, 'new.txt'); + const env = { ...process.env, IAK_CONFIG_JSON: '/tmp/iak-nonexistent-config.json', IAK_NEW_FILE: newFile }; + delete env.IAK_RESPONDER_LOCK; + execFileSync('bash', [bootstrapScript], { input: '{"session_id":"sess-d"}', encoding: 'utf8', env }); + assert.equal(existsSync(`${newFile}.responder.lock`), true); + }); + + it('second session goes PASSIVE while the owner process is alive', () => { + const dir = tempDir(); + const env = lockEnv(dir); + const ownerPid = startSleeper(); + writeFileSync(env.IAK_RESPONDER_LOCK, `pid=${ownerPid}\nsid=sess-owner\n`); + const payload = runHook({ + input: JSON.stringify({ source: 'startup', session_id: 'sess-b' }), + env, + }); + const ctx = payload.hookSpecificOutput.additionalContext; + assert.match(ctx, /PASSIVE/); + assert.match(ctx, /Do NOT arm a Monitor/); + assert.ok(!ctx.includes('arm one now')); + // lock untouched: still owned by the live owner + assert.match(readFileSync(env.IAK_RESPONDER_LOCK, 'utf8'), new RegExp(`^pid=${ownerPid}$`, 'm')); + }); + + it('steals a stale lock whose owner process is dead', () => { + const dir = tempDir(); + const env = lockEnv(dir); + const dead = spawnSync('true'); + writeFileSync(env.IAK_RESPONDER_LOCK, `pid=${dead.pid}\nsid=sess-dead\n`); + const payload = runHook({ + input: JSON.stringify({ source: 'startup', session_id: 'sess-c' }), + env, + }); + const ctx = payload.hookSpecificOutput.additionalContext; + assert.match(ctx, /arm one now/); + assert.match(ctx, /You hold the room-responder lock/); + assert.match(readFileSync(env.IAK_RESPONDER_LOCK, 'utf8'), /^sid=sess-c$/m); + }); + + it('the same session id reclaims its lock on resume even under a live foreign pid', () => { + const dir = tempDir(); + const env = lockEnv(dir); + const ownerPid = startSleeper(); + writeFileSync(env.IAK_RESPONDER_LOCK, `pid=${ownerPid}\nsid=sess-same\n`); + const payload = runHook({ + input: JSON.stringify({ source: 'resume', session_id: 'sess-same' }), + env, + }); + const ctx = payload.hookSpecificOutput.additionalContext; + assert.match(ctx, /arm one now/); + const lock = readFileSync(env.IAK_RESPONDER_LOCK, 'utf8'); + assert.match(lock, /^sid=sess-same$/m); + assert.ok(!new RegExp(`^pid=${ownerPid}$`, 'm').test(lock), 'pid refreshed to the resuming session'); + }); + + it('a corrupt lock file with a live-pid line still fails safe to PASSIVE, not a crash', () => { + const dir = tempDir(); + const env = lockEnv(dir); + const ownerPid = startSleeper(); + writeFileSync(env.IAK_RESPONDER_LOCK, `garbage line\npid=${ownerPid}\n%%%\n`); + const payload = runHook({ + input: JSON.stringify({ source: 'startup', session_id: 'sess-e' }), + env, + }); + assert.match(payload.hookSpecificOutput.additionalContext, /PASSIVE/); + }); +}); + describe('claude-settings hook merge', () => { it('adds the hook while preserving unrelated hooks and settings', () => { const settings = { From 231a37968021e6220ad6dd8f3b57f7c99b474a0c Mon Sep 17 00:00:00 2001 From: ThinkOff Date: Thu, 16 Jul 2026 11:50:38 +0300 Subject: [PATCH 2/2] fix(bootstrap): harden responder lock per adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings from @codexmb on #40, addressed: 1. Atomic acquisition: initial claim and stale-steal now use an O_CREAT|O_EXCL (noclobber) create, and every ACTIVE winner re-reads the lock after a settle window and demotes itself to PASSIVE if the file names someone else — concurrent session starts elect exactly one responder (covered by a 6-way concurrent election test, and the test caught a real hole: the bare pid==PPID reclaim fallback let sibling processes sharing a parent all reclaim; it now applies only when the harness passes no session id). 2. PID reuse: the lock records the owner's process start time; a recycled pid fails the identity check and the lock is stolen instead of producing a false PASSIVE. 3. EPERM: aliveness uses ps (visible cross-user) instead of kill -0, so an unsignalable-but-live owner is never treated as dead. Finding 4 (hard enforcement in the room-send path, beyond injected instructions) is an architecture change tracked separately. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- scripts/session-bootstrap.sh | 106 +++++++++++++++++++++++++------- test/session-bootstrap.test.mjs | 37 ++++++++++- 3 files changed, 120 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bf379e..de48ddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased ### Fixed -- **Duplicate room-responder voices**: `session-bootstrap.sh` now enforces a single room responder per notification file via a lock file (`.responder.lock`). The first session claims the lock and gets the full bootstrap; any later session on the same machine gets PASSIVE instructions (serve the user directly, never answer the room) instead of booting a second copy of the same handle. The lock stores the owner's pid + session id, so the owner reclaims it across resume/compact, and a lock whose owner process died is stolen automatically. Disable with `IAK_RESPONDER_LOCK=off`, relocate with `IAK_RESPONDER_LOCK=`. Motivation: on 2026-07-16 a stray `claude rc` session on the Mac mini booted the same bootstrap and posted to the room as a second @claudemm, producing conflicting answers. +- **Duplicate room-responder voices**: `session-bootstrap.sh` now enforces a single room responder per notification file via a lock file (`.responder.lock`). The first session claims the lock and gets the full bootstrap; any later session on the same machine gets PASSIVE instructions (serve the user directly, never answer the room) instead of booting a second copy of the same handle. The lock stores the owner's pid + session id + process start time, so the owner reclaims it across resume/compact, a lock whose owner process died is stolen automatically, and a recycled pid never masquerades as the owner (adversarial review by @codexmb: initial claim is `O_EXCL`-atomic, aliveness uses `ps` so a cross-user EPERM never reads as dead, and every winner re-verifies ownership after a settle window so concurrent starts elect exactly one responder). Disable with `IAK_RESPONDER_LOCK=off`, relocate with `IAK_RESPONDER_LOCK=`. Motivation: on 2026-07-16 a stray `claude rc` session on the Mac mini booted the same bootstrap and posted to the room as a second @claudemm, producing conflicting answers. ## 0.9.0 (2026-06-10) diff --git a/scripts/session-bootstrap.sh b/scripts/session-bootstrap.sh index 6ca4763..1514145 100755 --- a/scripts/session-bootstrap.sh +++ b/scripts/session-bootstrap.sh @@ -19,10 +19,14 @@ # handle — duplicate, conflicting posts (bit the fleet on 2026-07-16). The # first session claims a lock file; later sessions get PASSIVE instructions # (serve the user directly, stay out of the room). The lock carries the -# owner's pid + session id: it is reclaimed by the same session on -# resume/compact and stolen automatically once the owner process is dead. -# Any failure in the lock mechanics degrades to ACTIVE (status quo) — a -# rare duplicate voice beats a machine with no room responder at all. +# owner's pid + session id + process start time: it is reclaimed by the +# same session on resume/compact, and stolen automatically once the owner +# process is dead — start time is the identity check, so a recycled pid +# never masquerades as the owner. Initial claim is O_EXCL-atomic and every +# winner re-verifies after a short settle, so concurrent starts elect +# exactly one responder. Any failure in the lock mechanics degrades to +# ACTIVE (status quo) — a rare duplicate voice beats a machine with no +# room responder at all. # # Configuration (first match wins): # notification file : $IAK_NEW_FILE, else config poller.notification_file, @@ -113,41 +117,97 @@ LOCK_FILE="${IAK_RESPONDER_LOCK:-${NEWMSG_FILE}.responder.lock}" ROLE="active" LOCK_PID="" LOCK_SID="" +LOCK_PSTART="" LOCK_HELD="" +SETTLE_SEC="${IAK_RESPONDER_SETTLE_SEC:-0.3}" -pid_alive() { - [ -n "$1" ] || return 1 +# Process start time doubles as an identity check: a recycled pid has a +# different start time, so a dead owner is never mistaken for alive. Using +# ps (not kill -0) also keeps a cross-user owner visible — kill -0 reports +# EPERM there, which must not read as "dead". +proc_lstart() { + [ -n "${1:-}" ] || return 1 case "$1" in (*[!0-9]*|'') return 1;; esac - kill -0 "$1" 2>/dev/null + ps -p "$1" -o lstart= 2>/dev/null | sed 's/^ *//;s/ *$//' | head -1 } -write_lock() { - # Best-effort atomic replace; failure leaves ROLE=active (fail open). +read_lock() { + LOCK_PID=$(sed -n 's/^pid=//p' "$LOCK_FILE" 2>/dev/null | head -1) + LOCK_SID=$(sed -n 's/^sid=//p' "$LOCK_FILE" 2>/dev/null | head -1) + LOCK_PSTART=$(sed -n 's/^pstart=//p' "$LOCK_FILE" 2>/dev/null | head -1) +} + +owner_alive() { + local now + now=$(proc_lstart "$LOCK_PID") || return 1 + [ -n "$now" ] || return 1 + # Locks written before pstart existed carry no identity beyond the pid. + [ -z "$LOCK_PSTART" ] || [ "$now" = "$LOCK_PSTART" ] +} + +lock_content() { + printf 'pid=%s\nsid=%s\npstart=%s\nclaimed_at=%s\n' \ + "$PPID" "$SESSION_ID" "$(proc_lstart "$PPID" || true)" \ + "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || true)" +} + +claim_atomic() { + # O_CREAT|O_EXCL via noclobber: exactly one concurrent claimer wins. + ( set -o noclobber; lock_content > "$LOCK_FILE" ) 2>/dev/null +} + +claim_overwrite() { + # Only for refreshing a lock this session already owns. local tmp="${LOCK_FILE}.$$" - { - printf 'pid=%s\n' "$PPID" - printf 'sid=%s\n' "$SESSION_ID" - printf 'claimed_at=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || true)" - } > "$tmp" 2>/dev/null && mv -f "$tmp" "$LOCK_FILE" 2>/dev/null || rm -f "$tmp" 2>/dev/null + { lock_content > "$tmp" && mv -f "$tmp" "$LOCK_FILE"; } 2>/dev/null \ + || { rm -f "$tmp" 2>/dev/null; return 1; } +} + +lost_claim_race() { + # A failed claim only demotes us if somebody else really holds the + # file; an unwritable directory is a mechanics failure -> fail open. + [ -f "$LOCK_FILE" ] && ROLE="passive" } if [ "$LOCK_FILE" != "off" ]; then if [ -f "$LOCK_FILE" ]; then - LOCK_PID=$(sed -n 's/^pid=//p' "$LOCK_FILE" 2>/dev/null | head -1) - LOCK_SID=$(sed -n 's/^sid=//p' "$LOCK_FILE" 2>/dev/null | head -1) + read_lock if [ -n "$SESSION_ID" ] && [ "$LOCK_SID" = "$SESSION_ID" ]; then - write_lock # same session resuming: refresh pid - elif [ "$LOCK_PID" = "$PPID" ]; then - write_lock # same process (e.g. compact): keep ownership - elif pid_alive "$LOCK_PID"; then + claim_overwrite || true # same session resuming: refresh pid + elif [ -z "$SESSION_ID" ] && [ "$LOCK_PID" = "$PPID" ]; then + # pid fallback ONLY when the harness passes no session id + # (e.g. compact re-runs in the same process). With a session + # id present the sid line is the sole reclaim identity — + # sibling hook processes can legitimately share a parent pid. + claim_overwrite || true + elif owner_alive; then + ROLE="passive" + else + rm -f "$LOCK_FILE" 2>/dev/null # owner gone (or pid recycled) + claim_atomic || lost_claim_race + fi + else + claim_atomic || lost_claim_race + fi + + if [ "$ROLE" = "active" ]; then + # Post-claim verify: near-simultaneous steals can overwrite each + # other; after a short settle whoever the lock names is the single + # winner, everyone else demotes to PASSIVE (the machine still has + # exactly one responder). An unreadable lock here is a mechanics + # failure and stays ACTIVE. + sleep "$SETTLE_SEC" 2>/dev/null || true + read_lock + if [ -n "$LOCK_PID" ] && [ "$LOCK_PID" != "$PPID" ]; then + ROLE="passive" + elif [ -n "$SESSION_ID" ] && [ -n "$LOCK_SID" ] && [ "$LOCK_SID" != "$SESSION_ID" ]; then ROLE="passive" else - write_lock # owner died without cleanup: steal the stale lock + LOCK_HELD="$LOCK_FILE" fi else - write_lock + read_lock fi - [ "$ROLE" = "active" ] && LOCK_HELD="$LOCK_FILE" fi BACKLOG="" diff --git a/test/session-bootstrap.test.mjs b/test/session-bootstrap.test.mjs index 893a1f0..22a14f1 100644 --- a/test/session-bootstrap.test.mjs +++ b/test/session-bootstrap.test.mjs @@ -2,7 +2,7 @@ import { describe, it, afterEach } from 'node:test'; import { strict as assert } from 'node:assert'; -import { execFileSync, spawn, spawnSync } from 'node:child_process'; +import { execFile, execFileSync, spawn, spawnSync } from 'node:child_process'; import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, realpathSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { tmpdir } from 'node:os'; @@ -229,6 +229,41 @@ describe('single-responder lock', () => { assert.ok(!new RegExp(`^pid=${ownerPid}$`, 'm').test(lock), 'pid refreshed to the resuming session'); }); + it('steals a lock whose pid was recycled by an unrelated process (pstart mismatch)', () => { + const dir = tempDir(); + const env = lockEnv(dir); + const recycled = startSleeper(); // alive, but not the recorded owner: + writeFileSync(env.IAK_RESPONDER_LOCK, `pid=${recycled}\nsid=sess-old\npstart=Thu Jan 1 00:00:00 1970\n`); + const payload = runHook({ + input: JSON.stringify({ source: 'startup', session_id: 'sess-f' }), + env, + }); + const ctx = payload.hookSpecificOutput.additionalContext; + assert.match(ctx, /You hold the room-responder lock/); + assert.match(readFileSync(env.IAK_RESPONDER_LOCK, 'utf8'), /^sid=sess-f$/m); + }); + + it('elects exactly one ACTIVE responder among concurrent fresh claims', async () => { + const dir = tempDir(); + const env = lockEnv(dir); + const runs = await Promise.all(Array.from({ length: 6 }, (_, i) => + new Promise((resolvePromise, reject) => { + execFile('bash', [bootstrapScript], { + encoding: 'utf8', + env: { + ...process.env, + IAK_CONFIG_JSON: '/tmp/iak-nonexistent-config.json', + ...env, + }, + }, (err, stdout) => err ? reject(err) : resolvePromise(stdout)) + .stdin.end(JSON.stringify({ source: 'startup', session_id: `sess-par-${i}` })); + }) + )); + const actives = runs.filter((out) => + JSON.parse(out).hookSpecificOutput.additionalContext.includes('You hold the room-responder lock')); + assert.equal(actives.length, 1, `expected exactly 1 ACTIVE, got ${actives.length}`); + }); + it('a corrupt lock file with a live-pid line still fails safe to PASSIVE, not a crash', () => { const dir = tempDir(); const env = lockEnv(dir);