Skip to content

fix(install): wait for launchd service to fully unload before bootstrap - #916

Open
edwardtoday wants to merge 1 commit into
kxn:masterfrom
edwardtoday:fix/launchd-stop-wait-fully-unloaded
Open

edwardtoday wants to merge 1 commit into
kxn:masterfrom
edwardtoday:fix/launchd-stop-wait-fully-unloaded

Conversation

@edwardtoday

@edwardtoday edwardtoday commented Sep 16, 2026 •

Copy link
Copy Markdown

Fixes #914.

问题

macOS 上本地升级失败,接着回滚也失败,最终把服务留在未加载状态(launchctl 报 "Could not find service"),也就是 daemon 在人工 bootstrap 之前一直不可用:

upgrade shim: restart rollback service failed after start upgraded service: exit status 5: Bootstrap failed: 5: Input/output error

根因:launchdUserStopAndWait 先 bootout,然后等待 launchdUserIsRunning,而后者只认 state = running。但 bootout 之后服务不会立刻消失,而是先进入 state = SIGTERMed(daemon 正在优雅关闭全部 headless 实例)。该状态被判为「已停止」,于是没有发生任何等待,紧接着就对仍然注册着的 label 执行 bootstrap。macOS 对此返回 Bootstrap failed: 5: Input/output error,而 isLaunchdAlreadyLoadedErr 匹配不到它(该函数只找 "already loaded"),于是升级被判定为失败,回滚又撞上同一个竞态。

直接复现:

launchctl bootout gui/$UID/com.codex-remote.service
launchctl print gui/$UID/com.codex-remote.service | grep "state ="   # state = SIGTERMed
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.codex-remote.service.plist
# Bootstrap failed: 5: Input/output error
# ……等 daemon 进程真正退出(print 报 missing)后,同一条 bootstrap 命令即可成功

另外需要注意:对已经加载的服务重复执行 bootstrap,返回的同样是这个 Input/output error,而不是 "already loaded",所以不能仅凭错误串判断。

修复

新增 launchdUserIsUnloaded:只有 launchctl print 报 missing(服务已从 domain 完全卸载)才视为停止,并让 launchdUserStopAndWait 使用它。原有的超时路径(terminate 后上报失败)保持不变。

测试

TestLaunchdUserStopAndWaitWaitsForFullUnload 通过 mock launchctl,让服务先两次返回 state = SIGTERMed,之后才转为 missing。在 master 上它以 print calls=1(等待立即返回)失败;修复后通过。

已用 go test ./internal/app/install/ 验证;完整的 upgrade-local.sh 流程现在能跑完 prepared → switching → observing → committed,切换后 healthz 返回 200。

备注

CI 里的 scripts/check/no-local-paths.sh 在干净的 master 上同样失败(internal/core/orchestrator/service_target_picker_polluted_workspace_test.go 中的 /home/qagent/... 不在 allowlist 内),与本 PR 无关。

launchdUserStopAndWait decided the service had stopped by looking for
`state = running` only. After bootout, launchd keeps the service in
`state = SIGTERMed` while the daemon gracefully shuts down its headless
instances, so the wait loop returned immediately and bootstrap was issued
against a label that was still registered. macOS reports that as
"Bootstrap failed: 5: Input/output error", which is not matched by
isLaunchdAlreadyLoadedErr (it only looks for "already loaded"). The local
upgrade therefore failed, and the rollback hit the same race, leaving the
service unloaded until it was bootstrapped by hand.

Wait until `launchctl print` reports the service as missing instead.

Fixes kxn#914
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] launchd 本地升级:bootout 后 SIGTERMed 被判为已停止,bootstrap 撞 I/O error 导致升级与回滚双双失败

1 participant