Symptom
A live iOS preview session went blank with a stuck spinner. The agent (com.previewsmcp.host) died and was never relaunched, so the shell's reconnect timed out (agent UDS connect failed). No agent crash report existed, so the agent was terminated, not crashed.
Root cause (two independent facts combine)
1. App identity is global, so any actor on the shared sim can kill our agent.
- Bundle IDs are fixed constants:
com.previewsmcp.host (HostAppSource/Info.plist:6) and com.previewsmcp.shell (HostAppSource/Shell/Info.plist:6, mirrored at IOSPreviewSession.swift:47-48). A simulator stores exactly one install per bundle ID, regardless of which worktree built it.
start() and _relaunch() unconditionally simctl terminate both bundle IDs (IOSPreviewSession.swift:221-225, 491); terminateAppIfRunning targets purely by bundle ID (SimulatorManager.swift:186-193) and cannot tell "my agent" from another worktree's.
- The default device is the first booted sim (
BuildHelpers.swift:82-99), i.e. the same shared dev sim the test suite boots (SimSpikeSupport.bootSimulator()), and those tests also terminate the same bundle IDs (IOSSimSpikeTests.swift:176,226,424-425).
- The daemon socket is global (
~/.previewsmcp/serve.sock, DaemonPaths.swift:26-28), but each worktree runs its own .build/debug/previewsmcp for tests/run, so the single-daemon guard does not prevent cross-worktree install/terminate on the sim.
Kill chain: worktree B runs a test/run → resolves the same first-booted sim → installs/terminates com.previewsmcp.host → worktree A's live agent dies.
2. Nothing relaunches the agent on unexpected death.
The only relaunch path is memory-pressure _relaunch(), called solely from reload()/handleSourceChange() (IOSPreviewSession.swift:416,449,533-537). launchApp wires no termination handler. So a death outside a source edit is never noticed, and the shell hangs on the spinner forever.
Which lever? (session vs worktree isolation)
- Session isolation alone is NOT sufficient and is not the gap here. The offender was a different process in a different worktree, which never touches this daemon's session table. It also does not add the missing death-watcher.
- Worktree / app-identity isolation is the dominant fix. Distinct per-worktree bundle IDs + a dedicated sim mean another worktree's terminate-by-bundle-ID cannot name our app.
- Neither fixes the silent hang by itself — even fully isolated, an OOM-kill or
simctl shutdown reproduces the blank spinner without a death-watcher.
Recommendation (smallest blast radius first)
- Per-worktree app identity (keystone): derive a bundle-ID suffix from a stable per-worktree token (repo-root hash or
PREVIEWSMCP_INSTANCE env), make hostBundleID/shellBundleID instance properties, and template CFBundleIdentifier in buildHostApp/buildShellApp. Defeats the cross-worktree stomp.
- Per-worktree daemon socket: set
PREVIEWSMCP_SOCKET_DIR per worktree (override already exists, DaemonPaths.swift:17-23) so each worktree has its own daemon/session registry.
- Dedicated sim per session/worktree (robustness follow-on): stop defaulting to first-booted for long-lived sessions (
BuildHelpers.swift:91); clone/boot a dedicated device.
- Respawn-on-death watcher: monitor the agent PID / agent-UDS EOF and
_relaunch() on unexpected death, distinct from the memory path. Without this, any external kill or OOM still yields a permanent silent spinner.
Build 1, 2, 4 first; treat 3 as defense-in-depth. Do not invest in per-session bundle-ID namespacing or daemon serialization — they target an intra-daemon collision that is not the one hit here.
Context
Surfaced while testing the flash-free respawn work on branch ios-option2 (PR #241). The stuck spinner there was this isolation collision, not a flaw in the respawn logic.
🤖 Generated with Claude Code
Symptom
A live iOS preview session went blank with a stuck spinner. The agent (
com.previewsmcp.host) died and was never relaunched, so the shell's reconnect timed out (agent UDS connect failed). No agent crash report existed, so the agent was terminated, not crashed.Root cause (two independent facts combine)
1. App identity is global, so any actor on the shared sim can kill our agent.
com.previewsmcp.host(HostAppSource/Info.plist:6) andcom.previewsmcp.shell(HostAppSource/Shell/Info.plist:6, mirrored atIOSPreviewSession.swift:47-48). A simulator stores exactly one install per bundle ID, regardless of which worktree built it.start()and_relaunch()unconditionallysimctl terminateboth bundle IDs (IOSPreviewSession.swift:221-225, 491);terminateAppIfRunningtargets purely by bundle ID (SimulatorManager.swift:186-193) and cannot tell "my agent" from another worktree's.BuildHelpers.swift:82-99), i.e. the same shared dev sim the test suite boots (SimSpikeSupport.bootSimulator()), and those tests also terminate the same bundle IDs (IOSSimSpikeTests.swift:176,226,424-425).~/.previewsmcp/serve.sock,DaemonPaths.swift:26-28), but each worktree runs its own.build/debug/previewsmcpfor tests/run, so the single-daemon guard does not prevent cross-worktree install/terminate on the sim.Kill chain: worktree B runs a test/
run→ resolves the same first-booted sim → installs/terminatescom.previewsmcp.host→ worktree A's live agent dies.2. Nothing relaunches the agent on unexpected death.
The only relaunch path is memory-pressure
_relaunch(), called solely fromreload()/handleSourceChange()(IOSPreviewSession.swift:416,449,533-537).launchAppwires no termination handler. So a death outside a source edit is never noticed, and the shell hangs on the spinner forever.Which lever? (session vs worktree isolation)
simctl shutdownreproduces the blank spinner without a death-watcher.Recommendation (smallest blast radius first)
PREVIEWSMCP_INSTANCEenv), makehostBundleID/shellBundleIDinstance properties, and templateCFBundleIdentifierinbuildHostApp/buildShellApp. Defeats the cross-worktree stomp.PREVIEWSMCP_SOCKET_DIRper worktree (override already exists,DaemonPaths.swift:17-23) so each worktree has its own daemon/session registry.BuildHelpers.swift:91); clone/boot a dedicated device._relaunch()on unexpected death, distinct from the memory path. Without this, any external kill or OOM still yields a permanent silent spinner.Build 1, 2, 4 first; treat 3 as defense-in-depth. Do not invest in per-session bundle-ID namespacing or daemon serialization — they target an intra-daemon collision that is not the one hit here.
Context
Surfaced while testing the flash-free respawn work on branch
ios-option2(PR #241). The stuck spinner there was this isolation collision, not a flaw in the respawn logic.🤖 Generated with Claude Code