From 75ccbc8ba2f2d7a4c13517614ddbe4e2a60a2071 Mon Sep 17 00:00:00 2001 From: Howie Young Date: Sat, 8 Aug 2026 23:22:09 +0800 Subject: [PATCH] Fix serve-agent reinstall race in schedule:weekly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit installAgent() booted out the old LaunchAgent and immediately bootstrapped the new one. launchctl bootout returns before launchd finishes tearing the job down, so bootstrapping into a domain that still holds the (KeepAlive) label reliably failed with "Bootstrap failed: 5: Input/output error". The installer then exited, and because serve is reinstalled last and had already been booted out, every re-run left the resident receiver DOWN — captures silently 401'd until a manual bootstrap. - bootoutAndWait(): after bootout, poll `launchctl print` until the label is actually gone (bounded ~3s) before bootstrapping. - installAgent(): retry bootstrap a few times on the transient EIO, clearing half-registered state between tries; still fail loudly on a real error. - sleepMs via Atomics.wait (no sleep binary, no async needed here). Verified: `npm run schedule:weekly` now reinstalls all three agents cleanly and idempotently; the receiver stays up (/health 200, /capture gate intact). Co-Authored-By: Claude Opus 4.8 --- scripts/install-weekly.mjs | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/scripts/install-weekly.mjs b/scripts/install-weekly.mjs index 9398a3b..ba53f30 100644 --- a/scripts/install-weekly.mjs +++ b/scripts/install-weekly.mjs @@ -136,16 +136,36 @@ const uid = process.getuid(); const laDir = path.join(home, "Library", "LaunchAgents"); fs.mkdirSync(laDir, { recursive: true }); +// Synchronous sleep (no sleep binary, no async in this install flow) +const sleepMs = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + +// Bootout, then wait until launchd has actually released the label. `bootout` returns before teardown +// finishes, and bootstrapping into a domain that still holds the (KeepAlive) label races and fails with +// "Bootstrap failed: 5: Input/output error", leaving the resident receiver DOWN. Poll until it's gone. +function bootoutAndWait(agentLabel) { + spawnSync("launchctl", ["bootout", `gui/${uid}/${agentLabel}`], { stdio: "ignore" }); // may already be absent; that's fine + for (let i = 0; i < 30; i++) { + const present = spawnSync("launchctl", ["print", `gui/${uid}/${agentLabel}`], { stdio: "ignore" }); + if (present.status !== 0) return; // no longer registered → safe to bootstrap + sleepMs(100); + } +} + function installAgent(agentLabel, xml) { const plistPath = path.join(laDir, `${agentLabel}.plist`); fs.writeFileSync(plistPath, xml); - spawnSync("launchctl", ["bootout", `gui/${uid}/${agentLabel}`], { stdio: "ignore" }); // bootout the old version first; failure is fine - const boot = spawnSync("launchctl", ["bootstrap", `gui/${uid}`, plistPath], { encoding: "utf8" }); - if (boot.status !== 0) { - console.error(`launchctl bootstrap ${agentLabel} failed: ${boot.stderr || boot.stdout}`); - process.exit(1); + bootoutAndWait(agentLabel); + // Even after the label clears, launchd can briefly return a transient EIO; retry a few times before giving up. + let boot; + for (let attempt = 1; attempt <= 5; attempt++) { + boot = spawnSync("launchctl", ["bootstrap", `gui/${uid}`, plistPath], { encoding: "utf8" }); + if (boot.status === 0) return plistPath; + if (!/Input\/output error|Bootstrap failed|already/i.test(boot.stderr || boot.stdout || "")) break; // non-transient → fail now + sleepMs(300); + bootoutAndWait(agentLabel); // clear any half-registered state, then try again } - return plistPath; + console.error(`launchctl bootstrap ${agentLabel} failed: ${boot.stderr || boot.stdout}`); + process.exit(1); } // Agents run through a wrapper that resolves node at runtime (no baked node path), executing