From 3237402c2948654d342b134348485fbb8cb7589d Mon Sep 17 00:00:00 2001 From: Petrus Pennanen Date: Sat, 3 Oct 2026 23:18:49 +0200 Subject: [PATCH 1/2] fix: a live responder owner is evicted when pstart carries stray whitespace session-bootstrap.sh reads pstart verbatim out of the lock file while proc_lstart() trims the ps output it compares against: proc_lstart: ps -p "$1" -o lstart= | sed 's/^ *//;s/ *$//' read_lock: sed -n 's/^pstart=//p' "$LOCK_FILE" | head -1 So a lock whose pstart keeps ps's trailing padding compares unequal in owner_alive(), the LIVE owner reads as dead, and the next session removes the lock and claims it. responder-lock.mjs already trims both sides (readResponderLock does m[1].trim(), procStart does .trim()), so the two halves disagreed about the same file: the MCP path refused the post because it saw a valid lock held by another live session, while the bootstrap kept handing that lock to whoever started next. Observed today as a session being evicted 16 s and then 12 s after claiming, which looked like two sessions fighting and was actually one malformed pstart. Trims LOCK_PSTART the same way proc_lstart trims, so the bash and JS halves agree on what a live owner looks like. Test fails before the change ("a live owner must not be evicted over whitespace") and passes after. Full suite 815/815. Co-Authored-By: Claude Opus 5 --- scripts/session-bootstrap.sh | 7 ++++++- test/session-bootstrap.test.mjs | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/scripts/session-bootstrap.sh b/scripts/session-bootstrap.sh index 1514145..a1584ce 100755 --- a/scripts/session-bootstrap.sh +++ b/scripts/session-bootstrap.sh @@ -134,7 +134,12 @@ proc_lstart() { 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) + # Trim exactly as proc_lstart does: a lock written by any other tool with + # stray padding must still compare equal, or owner_alive() reads a LIVE + # owner as dead and the next session evicts it. responder-lock.mjs already + # trims both sides, so without this the enforcing half and the claiming + # half disagree about the same lock file. + LOCK_PSTART=$(sed -n 's/^pstart=//p' "$LOCK_FILE" 2>/dev/null | head -1 | sed 's/^ *//;s/ *$//') } owner_alive() { diff --git a/test/session-bootstrap.test.mjs b/test/session-bootstrap.test.mjs index 22a14f1..b02ec2d 100644 --- a/test/session-bootstrap.test.mjs +++ b/test/session-bootstrap.test.mjs @@ -243,6 +243,28 @@ describe('single-responder lock', () => { assert.match(readFileSync(env.IAK_RESPONDER_LOCK, 'utf8'), /^sid=sess-f$/m); }); + it('keeps PASSIVE when the live owner\'s pstart carries stray whitespace', () => { + // read_lock takes pstart verbatim from the file while proc_lstart trims the + // ps output, so a lock written by any other tool with trailing spaces made a + // LIVE owner compare unequal and get evicted. The JS half (responder-lock.mjs) + // trims both sides, so enforcement and claiming disagreed: room_post refused + // the post while the bootstrap handed the lock to the next session. + const dir = tempDir(); + const env = lockEnv(dir); + const owner = startSleeper(); + const realStart = execFileSync('ps', ['-p', String(owner), '-o', 'lstart='], { encoding: 'utf8' }) + .replace(/^ +/, '').replace(/\n$/, ''); // leading trimmed, TRAILING KEPT + assert.match(realStart, / $/, 'this fixture needs ps to emit trailing padding'); + writeFileSync(env.IAK_RESPONDER_LOCK, `pid=${owner}\nsid=sess-owner\npstart=${realStart}\n`); + const payload = runHook({ + input: JSON.stringify({ source: 'startup', session_id: 'sess-newcomer' }), + env, + }); + const ctx = payload.hookSpecificOutput.additionalContext; + assert.match(ctx, /PASSIVE/, 'a live owner must not be evicted over whitespace'); + assert.match(readFileSync(env.IAK_RESPONDER_LOCK, 'utf8'), /^sid=sess-owner$/m); + }); + it('elects exactly one ACTIVE responder among concurrent fresh claims', async () => { const dir = tempDir(); const env = lockEnv(dir); From e289d58714a822f887ccdc64a197f01cf51ee4f1 Mon Sep 17 00:00:00 2001 From: Petrus Pennanen Date: Sat, 3 Oct 2026 23:27:05 +0200 Subject: [PATCH 2/2] test: pad pstart explicitly so the fixture is not a macOS ps quirk The fixture built the padded pstart from ps output and asserted ps had emitted trailing spaces. macOS pads lstart and Linux does not, so CI on ubuntu failed the fixture guard itself: 'this fixture needs ps to emit trailing padding'. The guard was right to refuse - it will not pass while testing nothing - but it was guarding the wrong premise. The defect is that read_lock takes pstart verbatim from the FILE, so what must be tolerated is padding in the file, whichever tool wrote it. Padding both ends explicitly tests that on every platform. Still fails without the trim and passes with it; full suite 815/815. Co-Authored-By: Claude Opus 5 --- test/session-bootstrap.test.mjs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/session-bootstrap.test.mjs b/test/session-bootstrap.test.mjs index b02ec2d..4af5958 100644 --- a/test/session-bootstrap.test.mjs +++ b/test/session-bootstrap.test.mjs @@ -252,10 +252,14 @@ describe('single-responder lock', () => { const dir = tempDir(); const env = lockEnv(dir); const owner = startSleeper(); - const realStart = execFileSync('ps', ['-p', String(owner), '-o', 'lstart='], { encoding: 'utf8' }) - .replace(/^ +/, '').replace(/\n$/, ''); // leading trimmed, TRAILING KEPT - assert.match(realStart, / $/, 'this fixture needs ps to emit trailing padding'); - writeFileSync(env.IAK_RESPONDER_LOCK, `pid=${owner}\nsid=sess-owner\npstart=${realStart}\n`); + // Pad explicitly rather than relying on ps: macOS emits trailing spaces in + // lstart and Linux does not, so a fixture built from ps output tests the bug + // on one platform and nothing on the other. The defect is that read_lock + // takes pstart verbatim, so any padding in the FILE is what must be tolerated. + const realStart = execFileSync('ps', ['-p', String(owner), '-o', 'lstart='], { encoding: 'utf8' }).trim(); + const padded = ` ${realStart} `; + assert.notEqual(padded, realStart, 'the fixture must differ from the normalised form'); + writeFileSync(env.IAK_RESPONDER_LOCK, `pid=${owner}\nsid=sess-owner\npstart=${padded}\n`); const payload = runHook({ input: JSON.stringify({ source: 'startup', session_id: 'sess-newcomer' }), env,