From ebd3ff1eb6188cf2ed9cd9548155c5d583ce88ad Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Tue, 29 Sep 2026 15:23:49 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20postbox=20install=20waits?= =?UTF-8?q?=20for=20bootout,=20and=20status=20counts=20the=20daemon's=20op?= =?UTF-8?q?en=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The postbox daemon stopped answering (RESOURCE_BUSY, "Too many open files") because `am` 0.3.36 leaks sqlite handles on its store — 234 of 258 open after five days — and this machine's LaunchAgent predated the 4096 limit, so it still ran on launchd's 256. Nothing said so: status reported the daemon loaded and answering right up to the failure. `status` now prints open handles against the limit the installed plist actually carries, names a plist that predates the limit, and warns past 80%. And rerunning `postbox install` to pick the limit up failed with "Bootstrap failed: 5: Input/output error", leaving nothing loaded: bootout returns before launchd has torn the job down, and the bootstrap landed in that window. It now waits (up to 5 s) for the job to be gone. Reported by the eda-tools session over postbox. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 10 +++++++++- bin/executable_postbox | 17 +++++++++++++++++ tests/postbox.bats | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6e1f3b37b11..0b7cd94879e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -946,7 +946,15 @@ explicitly makes it read `~/.claude/.claude.json`, a file nothing else uses. `connect` therefore addresses the default directory by *unsetting* the variable. And launchd gives an agent 256 open files by default; the daemon exhausted that within an hour and fell back to archive snapshots for -inbox reads, so the plist sets 4096. +inbox reads, so the plist sets 4096. That only buys time: `am` 0.3.36 leaks +sqlite handles on its store (234 of 258 after five days, first seen +2026-09-29), and a plist written before the limit existed keeps launchd's +256 until `postbox install` rewrites it. `status` therefore prints a `files:` +line — open handles against the *installed* plist's limit, naming a stale +plist and warning past 80%. `install` waits for `bootout` to finish before +`bootstrap`: launchd tears the job down after bootout returns, and a +bootstrap in that window fails "5: Input/output error" and leaves nothing +loaded. Commands: `name [--source] [dir]`, `names [root...]`, `install`, `uninstall [--purge]`, `start|stop|restart|status|logs`, `connect|disconnect`, `hook diff --git a/bin/executable_postbox b/bin/executable_postbox index 0b140fc9be6..95ea6b9bdfa 100755 --- a/bin/executable_postbox +++ b/bin/executable_postbox @@ -416,6 +416,12 @@ cmd_install() { mkdir -p "$HOME/Library/LaunchAgents" write_plist launchctl bootout "gui/$(uid)/$LABEL" >/dev/null 2>&1 + # bootout returns before launchd has torn the job down; a bootstrap in + # that window fails "5: Input/output error" and leaves nothing loaded. + i=0 + while launchctl print "gui/$(uid)/$LABEL" >/dev/null 2>&1 && [ $i -lt 50 ]; do + sleep 0.1; i=$((i + 1)) + done launchctl bootstrap "gui/$(uid)" "$(plist_path)" || die "launchctl bootstrap failed (see $(plist_path))" launchctl kickstart -k "gui/$(uid)/$LABEL" >/dev/null 2>&1 printf 'postbox: daemon registered as %s on %s\n' "$LABEL" "$(url)" @@ -887,6 +893,17 @@ cmd_status() { if launchctl print "gui/$(uid)/$LABEL" >/dev/null 2>&1; then pid=$(launchctl print "gui/$(uid)/$LABEL" 2>/dev/null | awk '/^\tpid = / { print $3; exit }') printf 'daemon: %s loaded%s\n' "$LABEL" "${pid:+ (pid $pid)}" + # am leaks sqlite handles (~1 per half hour on 0.3.36), so the + # headroom is worth watching. The limit is what the INSTALLED plist + # says — a plist from before the limit existed runs on launchd's 256. + if [ -n "$pid" ]; then + open=$(lsof -p "$pid" 2>/dev/null | tail -n +2 | wc -l | tr -d ' ') + lim=$(grep -A3 'SoftResourceLimits' "$(plist_path)" 2>/dev/null | sed -n 's:.*\([0-9]*\).*:\1:p') + printf 'files: %s open of %s' "$open" "${lim:-256}" + [ -z "$lim" ] && printf ' (launchd default: the installed plist predates the limit — postbox install)' + [ "$open" -ge $(( ${lim:-256} * 4 / 5 )) ] && printf ' — WARNING: over 80%%; postbox restart' + printf '\n' + fi else printf 'daemon: %s not loaded (postbox install)\n' "$LABEL" fi diff --git a/tests/postbox.bats b/tests/postbox.bats index f12987fe774..6d700c54103 100644 --- a/tests/postbox.bats +++ b/tests/postbox.bats @@ -343,6 +343,48 @@ STUB [[ "$output" == *"NOT answering"* ]] } +@test "install: waits for bootout to finish before bootstrapping again" { + # launchd keeps the old job for a moment after bootout returns; a + # bootstrap in that window fails with error 5 and leaves nothing loaded. + echo 3 > "$BATS_TEST_TMPDIR/still-loaded" + cat > "$BIN/launchctl" <<'STUB' +#!/bin/sh +printf '%s\n' "$*" >> "$LAUNCHCTL_LOG" +f="$BATS_TEST_TMPDIR/still-loaded" +if [ "$1" = print ]; then + n=$(cat "$f"); [ "$n" -gt 0 ] || exit 1 + echo $((n - 1)) > "$f"; exit 0 +fi +exit 0 +STUB + run "$PB" install + [ "$status" -eq 0 ] + # bootout, three prints that still see the job, the one that does not, then bootstrap. + [ "$(grep -o '^[a-z]*' "$LAUNCHCTL_LOG" | tr '\n' ' ')" = "bootout print print print print bootstrap kickstart " ] +} + +@test "status: open files against the installed plist's limit, and a stale plist is named" { + cat > "$BIN/launchctl" <<'STUB' +#!/bin/sh +[ "$1" = print ] && printf '\tpid = 4242\n' +exit 0 +STUB + cat > "$BIN/lsof" <<'STUB' +#!/bin/sh +echo HEADER; i=0; while [ $i -lt "$FAKE_FDS" ]; do echo fd; i=$((i + 1)); done +STUB + chmod +x "$BIN/launchctl" "$BIN/lsof" + "$PB" install >/dev/null + FAKE_FDS=40 run "$PB" status + [[ "$output" == *"files: 40 open of 4096"$'\n'* ]] + FAKE_FDS=3300 run "$PB" status + [[ "$output" == *"files: 3300 open of 4096 — WARNING: over 80%; postbox restart"* ]] + # A plist written before the limit existed runs on launchd's default. + sed -i.bak '/ResourceLimits/,/<\/dict>/d' "$HOME/Library/LaunchAgents/local.postbox.plist" + FAKE_FDS=261 run "$PB" status + [[ "$output" == *"files: 261 open of 256 (launchd default: the installed plist predates the limit — postbox install) — WARNING"* ]] +} + @test "status: an installed version other than the pinned one is a nudge to re-check the vocabulary" { cat > "$BIN/am" <<'STUB' #!/bin/sh