cmux NEXT: a stale acpmux never softlocks the chats; a daemon restart shows Reconnecting…

Draft
TE
8d ago
+154-13

HOLD: superseded by Leo's 2026-10-29 P0 (no Reconnecting…/Retrying… or card while the app starts or connects; one line only after a real deadline). The webview part must not land; the Swift stale-daemon stop moves to the old-daemon-removal PR.

After a NEXT nightly update, the chats are no longer held behind a card that a stale acpmux daemon can keep up for minutes, and a daemon's restart shows the small neutral Reconnecting… instead of the red card.

Leo, right after updating NIGHTLY:

  • On the red "acpmux did not answer in time" card: "why is that still there? it's unnecessary"
  • On the small Reconnecting… line: "this is ... kinda interesting?"
  • "ok but all this has taken like 30+ seconds? just for the chat to come back? idk that seems way longer than it should've been after a new update"
  • Then: "there's bad/weird softlocking behaviour :( we shouldn't be blocking like this imo there's frequently nothing wrong with showing the same session but in two different places"

What happened (one Mac's daemon log and the app's error journal, 2026-10-29)

  1. The softlock. Sparkle replaced the app bundle while the daemon from the previous bundle was still running. The relaunched app found that daemon stale and asked it to stop (AcpmuxVersionHandoff.handOffIfStale). The daemon refused, because macOS ran the updated app from its code_sign_clone folder, which acpmux's unstable-path rule treats as "a temporary folder".
    • The failed request still used up the socket's one handoff, so every later handshake skipped the handoff.
    • Every later handshake then failed the server peer check: the daemon's executable file was gone, so it reported "the socket's peer process is unknown".
    • Every chat showed "Another acpmux service is using this socket…" for about four minutes, until that daemon happened to stop.
  2. The 30 s.
    • The new daemon listened 7 s after the old app quit. It answered status 12 s later, then loaded the logs of the 24 running chats one after another (13.4 s), so the chat came back about 33 s after the quit.
    • The app's own start lost the lock race to the other starts ("exited before it was ready"), which put up the red card.
    • The page's retry wait grows to 30 s, so it can see the daemon late.

What changed

App (Swift)

  • A stale daemon is stopped. AcpmuxVersionHandoff.handOffIfStale handles a daemon that runs a replaced bundle's executable and refuses its handoff. That daemon is an earlier install of this app on its own home, so it is stopped by signal, the same way AcpmuxUnresponsiveWatch.stop stops an unresponsive daemon: SIGTERM, then SIGKILL after the grace, sent by audit token so a reused pid is never signalled. Its agent hosts detach on SIGTERM and the bundled daemon adopts them, as on the request. An attached daemon is never stopped. Any other failed handoff frees its key, so the next lookup asks again (AfterRefusal).
  • Restart failures are tagged reconnecting. AgentPaneHostError.isTransient covers timed out, restarting, the Chief host starting, a start that exited (lost the lock race), and an unknown socket peer. The handshake failure tags these details.reconnecting, the neutral state #26115 introduced, which until now covered only the update handoff. A missing binary, a stopped daemon, and another build's daemon (with Restart acpmux) keep the card.

Page (webviews)

  • Neutral for a minute. A reconnecting failure shows Reconnecting… for up to RECONNECTING_GRACE_MS (60 s). A recovery that lasts longer shows the card with its reason.
  • Faster retries. While recovering, the next handshake comes within 2 s, not up to 30 s (nextRetryDelay).
  • Gallery. A new agent-pane.host-error state, timed-out: the card a restart shows when it lasts past the minute.

Daemon side (separate lanes, not here)

  • Answer status and attach right after listen, load the opened chat's log first, and stop the lock race.
  • Accept a code_sign_clone of the signed app by its signature.

Performance

The harness replays the page's handshake retries against the logged timeline: the first failed handshake at t=0; a handshake started before the daemon answers fails after 2.05 s (the 2 s status deadline, as logged); one started after it connects.

metricbeforeafterdeltaharness
page connects after the daemon answers, this update5.61 s3.61 s−2.00 sretry replay of the 07:03Z log timeline
same, worst case for a daemon ready anywhere in 0–60 s32.04 s4.05 s−27.99 ssame replay, swept in 10 ms steps
same, mean over 0–60 s10.52 s1.99 s−8.53 ssame replay
chats held by a stale daemon that refuses its handoff~4 min (until it stopped on its own)the handoff's grace (≤ 10 s SIGTERM grace)—logged 07:07:22Z to 07:11:25Z; after: the code path
Replay script
# Replays the page's handshake retries against the 2026-10-29 07:03Z daemon timeline.
# t=0: the first failed handshake (07:03:43.67Z). A handshake started before the daemon answers
# fails after ATTEMPT s (status deadline 2 s; observed 43.92 -> 45.97); one started after it connects.
import sys
ATTEMPT = 2.05
def connect_at(ready, cap):
    t, delay = 0.0, 0.25
    while True:
        start = t + delay
        if start >= ready: return start
        t = start + ATTEMPT
        delay = min(delay * 2, cap)
def row(ready):
    old, new = connect_at(ready, 30.0), connect_at(ready, 2.0)
    return old - ready, new - ready
lag_old, lag_new = row(10.34)  # daemon answered at 07:03:54.01Z
print(f"07:03Z replay: lag after daemon ready old={lag_old:.2f}s new={lag_new:.2f}s")
worst = [row(r / 100) for r in range(0, 6001)]
print(f"worst lag, daemon ready anywhere in 0-60 s: old={max(w[0] for w in worst):.2f}s new={max(w[1] for w in worst):.2f}s")
print(f"mean lag over 0-60 s: old={sum(w[0] for w in worst)/len(worst):.2f}s new={sum(w[1] for w in worst)/len(worst):.2f}s")

Timers: no new timer. The retry timer already existed; its wait is now capped at 2 s while recovering, and it stops on connect, on unmount, and on a Restart acpmux, as before.

Screenshots

The surfaces are the agent-pane.host-error gallery states. Before is timed-out (what a restart showed until now, and what it still shows after a minute); after is reconnecting, the neutral line #26115 added.

statebeforeafter
lightbefore timed out lightafter reconnecting light
darkbefore timed out darkafter reconnecting dark

Rendered from this head's gallery (agent-pane.host-error, normal width 520 px, Chromium on Linux CI).

Tests

  • AcpmuxTransientFailureTests (new): which failures are transient, and that a replaced daemon that refuses its handoff is stopped while any other refusal is asked again.
  • AcpmuxRefusedRestartTests.onlyADaemonRestartIsAReconnectingHandshake: the restart causes are tagged reconnecting; missing, stopped and another build's daemon are not.
  • hostRetry.test.ts: the minute's grace, and the 2 s versus 30 s waits.
  • These run on the fleet and on Linux CI; this is a draft until they report.

Risk

The new path signals a process: only this home's own daemon, as named by its daemon.pid and known by its start time and audit token, and only when its executable file is gone (an update replaced it). That is the same rule and the same signalling code as the unresponsive-daemon restart. A daemon of a live install, another build's daemon, and an attached daemon are never signalled.

🤖 Generated with Claude Code

Agent session: agent session


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Fixes two problems after a NEXT update: a stale acpmux daemon no longer holds the chats behind a red card for minutes, and a daemon restart shows the neutral Reconnecting… line instead of the red card.

  • A daemon that runs a replaced bundle's executable and refuses its handoff is stopped by signal (SIGTERM, then SIGKILL after the grace), the same way an unresponsive daemon is. Any other failed handoff frees its key so the next lookup asks again.
  • Restart failures (timed out, lost start race, daemon still loading, unknown socket peer) are tagged reconnecting: the page shows Reconnecting… for up to 60 seconds, then shows the card with its reason if it lasts longer.
  • While recovering, retries happen within 2 seconds instead of backing off to 30 seconds, so the page connects about 30 seconds faster when a daemon comes back.