fix(desktop): let the launch-owner guard retire the owned Host on quit - #4756
Conversation
28f9bda to
f98e93a
Compare
Astro-Han
left a comment
There was a problem hiding this comment.
Reviewed current head f98e93ace880bd392608dbc3bad7b00fc3d1c4e3 (OPEN, MERGEABLE). No P0–P2; one P3 below. Gate stays NO-GO on red hosted test, which fails on an untouched spec.
P3 — the PR body understates the protocol epoch bump
The body says protocol epoch 109 → 110, but the exact diff is packages/runtime-host/src/protocol/index.ts:104 going 112 → 113. Please correct the description so reviewers checking compatibility against the stated numbers do not look at the wrong range.
Gate note (not attributed to this change)
Hosted test fails on the untouched apps/desktop/e2e/quote-selection.spec.ts:22,79-81; windows_recovery passes. Until that red is re-run or waived by maintainers, the gate cannot close either way.
What was checked on this head
Exit cleanup, launch-owner ownership, host resource reclamation, concurrent/reentrant and failure-recovery paths. What I could not judge: real Windows quit-path behavior with a live owned host was not reproduced locally.
Automated review notice: This comment was posted by an automated review agent operated by Astro-Han. It is not an independent human review and does not replace one.
简体中文
本条结论全部来自 @Luna-Deep-Qronos 的审查。我自己没有读这份 diff;我核的是当前 head 有没有漂移、以及 exact-head 的 CI 状态。当前 head 是 f98e93a,可合并。P3 是正文写的 epoch 数字跟实际 diff 对不上,改一下描述。另外线上 test 红在没动过的用例上,门禁等维护者处理。
f98e93a to
851407f
Compare
|
Rebased onto current main (851407f). The two hosted |
Astro-Han
left a comment
There was a problem hiding this comment.
Re-reviewed at 851407f against main (a5a99a633), +245/−381 over 13 files, CI green at this head. The branch conflicts with main on one file only, packages/runtime-host/src/protocol/index.ts: the epoch constant and the ledger under it. main is at 117 and the head still declares 113, so merging as is would lower the epoch. Rebase, declare 118, move your entry to the top of the ledger; everything else auto-merges.
The fix is at the owner and is a net removal. #close() on main releases the candidate launch barrier unconditionally (runtime-host-desktop-manager.ts:977), which flips the guard to released and closes its lease, so the Desktop's disconnect no longer retires the owned ephemeral Host, which is #4730. Dropping that release lets the guard do its job; the force-quit / retry dialog and forceTerminateOwnedLocalHost were the mechanism that compensated, and they go in the same change with the #3709 discussion linked. I followed guard close → close({ reason: 'retirement' }) → shutdownReason → exit code 0, and the reopen-after-quit election with draining retrying a fresh candidate under the 45s deadline. The previous P3 (body said 109 → 110) is fixed; the earlier red gates were untouched transcript e2e and are green after the rebase.
P3:
- The quit probe reads
#hasUpgradeBlockingActivity()fromhost.diagnostics.query, amode: 'query'call, but the predicate's#activeCommandOperations > 1was calibrated forhost.upgrade.prepare, a command that counts itself. From the query path one in-flight command from another client reads as idle and quit does not ask. Give the predicate aselfCommands: 0 | 1argument and pass 0 from the diagnostics path (host-kernel.ts:696,881). - During a reconnect
target.lifecycle?.currentisundefinedand the probe returnsnot_owned(runtime-host-desktop-manager.ts:773-776), so quit does not ask even if the Host is busy; the oldquiesce()covered that window with a timeout. If that is the accepted trade, say so in the probe's comment. host-status.ts:87says a missing field means unknown, not idle; the only reader treats missing as clear. With the epoch bump a mixed-version handshake cannot happen, so pick one: drop the optionality or fix the comment.forceTerminateRegisteredRuntimeHostand itsWithDependenciesvariant (client/registered-host-termination.ts:85, exported fromclient/index.ts:49) have no caller left outside their own test onceforceTerminateOwnedLocalHostis gone. Delete them with it, or say why they stay.
Evidence boundary: static read; not run on a real Electron quit, so the timing of an owned candidate that is no longer unref'd at release, and the reopen election duration, are unverified.
AI-assisted review: drafted with Maka; I verified the conflicting file, the epoch values and the orphaned export myself.
c346b76 to
eb6b7c2
Compare
|
Rebased and review addressed in
Local: full build; desktop main suite 2304 pass; runtime-host kernel 73 pass; protocol suites pass. |
Astro-Han
left a comment
There was a problem hiding this comment.
Reviewed exact head eb6b7c261a12bf2fb0a24c12b8abfa2399225123.
Result: I found no new P0–P2 correctness, permission, concurrency, protocol, or Host-lifecycle issue in the production path. I verified:
packages/runtime-host/src/server/host-kernel.ts:693-728,879-889keeps the diagnostics self-command count separate from thehost.upgrade.prepareself-command count; the late-client gate and diagnostics report the same blocking fact.packages/runtime-host/src/protocol/host-status.ts:145-218requires booleanupgradeBlockingActivity;packages/runtime-host/src/protocol/index.ts:104-109advances the compatibility epoch from 119 to 120, andpackages/runtime-host/src/__tests__/host-kernel.test.ts:2561-2599verifies that the older epoch is rejected during handshake.apps/desktop/src/main/runtime-host-desktop-manager.ts:945-958no longer releases the launch barrier during quit.packages/runtime-host/src/candidate-entry.ts:105-108lets the launch-owner guard close the owned Host with the retirement reason, while the Host/process lifecycle supplies the shutdown deadline.- The former owned force-termination implementation, exports, and call sites are absent.
Two P3 contract/documentation inconsistencies remain:
apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts:327-337still constructs a fake status withoutupgradeBlockingActivityand treats the result as clear. That bypasses the production decoder, where the field is mandatory. Update the fixture to use a valid wire payload or explicitly test decoder rejection.- The PR description still says that
upgradeBlockingActivityis optional and decoded as unknown when absent. That contradicts the mandatory field and epoch-120 handshake contract implemented by this head.
Hosted test and windows_recovery are successful; the merge-tree and whitespace checks are clean. I could not independently rerun local build/typecheck/dist suites because this worktree has no tsc or vitest, and I did not run a real Electron quit/guard-close-failure smoke test. This is a COMMENT, not an approval.
Automated review notice: This review was generated with AI assistance and cannot replace independent human review.
|
Both P3s addressed in
|
jackwener
left a comment
There was a problem hiding this comment.
Approving at exact head eb6b7c26, at @me2seeks's direction. No P0–P2; two P3s carried forward, one of which should still be corrected before this merges even though it does not block.
I handed this one over mid-review rather than duplicating the deep pass, so this approval rests on that published review plus what I verified myself, and I checked that it covers the two angles I had flagged as unfinished:
- The epoch bump refuses cleanly rather than failing obscurely.
protocol/index.ts:104-109advances 119 → 120,host-status.ts:145-218makesupgradeBlockingActivitymandatory, andhost-kernel.test.ts:2561-2599asserts the older epoch is rejected during the handshake — so a mismatched pair refuses at the door instead of surfacing as something else later. - Removing the force-terminate path does not leave the outcome unbounded.
runtime-host-desktop-manager.ts:945-958no longer releases the launch barrier,candidate-entry.ts:105-108has the guard close the owned Host with a retirement reason, and the Host/process lifecycle supplies the deadline. The former owned force-termination implementation, its exports and its call sites are gone rather than left dormant.
What I verified before handing over still stands: the problem is a third-party report (#4730, opened by someone other than this PR's author) with the root cause confirmed structurally in #3709, and the fix lands on that root cause — #close() deliberately not releasing owned candidates is what keeps the launch-owner guard armed, with #ipcMain.close() then supplying the disconnect the guard acts on.
The two P3s, and why one of them is worth doing anyway
A fixture that bypasses the decoder. runtime-host-desktop-manager.test.ts:327-337 builds a status without upgradeBlockingActivity and treats the result as clear, which the production decoder would not accept now that the field is mandatory. Worth fixing as a contract assertion rather than as a fixture edit — either use a valid wire payload or assert the decoder rejects the invalid one. A fixture standing in for the decoder cannot show that the decoder agrees with it, and that is precisely the class of gap that lets a contract drift unnoticed.
The description contradicts the implementation. It still says upgradeBlockingActivity is optional and decoded as unknown when absent; at this head the field is mandatory and that is what the epoch-120 handshake enforces. This repository squash-merges, so the title and body become the commit record on main — a reader of git log would be told the opposite of what the code does. It is P3 in severity and still worth correcting before merge for that reason alone.
One path nobody exercised
No real Electron quit / guard-close-failure smoke was run — by me or by the deep pass. The reasoning that the guard plus the Host-side deadline bounds the outcome is sound on the code, and the unit coverage is real, but the end-to-end behaviour on a genuinely wedged Host remains argued rather than observed. Recording it so it is a known gap rather than an assumed pass.
简体中文
在 exact head eb6b7c26 上批准,依 @me2seeks 的指示。无 P0–P2;两条 P3 带走,其中一条虽不阻塞、但仍建议在合并前改掉。
这一单我中途交接出去、没有重复做深审,所以这条批准建立在那份已发布的评审加上我自己核过的部分之上;我确认过它覆盖了我先前标为未完成的那两个角度:
- epoch 抬升是干净地拒绝,而不是以别的形式怪异地失败。
protocol/index.ts:104-109把 119 推到 120,host-status.ts:145-218将upgradeBlockingActivity定为必填,而host-kernel.test.ts:2561-2599断言旧 epoch 在握手阶段即被拒绝 —— 版本不匹配的配对在门口就被挡住,而不是稍后以别的样子冒出来。 - 移除强制终止路径并没有让结果失去边界。
runtime-host-desktop-manager.ts:945-958不再释放启动屏障,candidate-entry.ts:105-108由 guard 带着退休原因关闭 owned Host,而 deadline 由 Host/进程生命周期提供。原先 owned 强制终止的实现、导出与调用点是被移除而不是留着不用。
我交接前核过的内容依然成立:问题是第三方报告(#4730 由本 PR 作者以外的人提出),根因在 #3709 中被结构性确认,而修复正落在该根因上 —— #close() 刻意不再释放 owned candidate,才让 launch-owner guard 保持 armed,随后的 #ipcMain.close() 提供 guard 据以动作的那个断开信号。
两条 P3,以及其中一条为何仍值得做
一个绕过解码器的夹具。 runtime-host-desktop-manager.test.ts:327-337 构造了不含 upgradeBlockingActivity 的状态并当作「无阻塞」处理,而在该字段已成必填的今天,生产解码器不会接受它。建议按合同断言来修,而不是改夹具 —— 要么用一份合法的 wire 载荷,要么直接断言解码器会拒绝非法载荷。夹具顶替解码器,证明不了解码器与它意见一致,而这正是让合同悄悄漂移的那一类缺口。
描述与实现相矛盾。 正文仍写着 upgradeBlockingActivity 是可选、缺失时解码为 unknown;而在本 head 上它是必填,epoch-120 的握手正是据此强制的。本仓库采用 squash 合并,所以标题与正文会成为 main 上的提交记录 —— 读 git log 的人会被告知与代码相反的事。就严重度而言它是 P3,但仅凭这一点就值得在合并前改掉。
一条没人练过的路径
没有跑过真实的 Electron quit / guard 关闭失败冒烟 —— 我没跑,深审那一遍也没跑。「guard 加 Host 侧 deadline 兜住结果」这个推理在代码上成立,单元覆盖也是真的,但在一个真正卡死的 Host 上的端到端行为,目前是被论证的、而不是被观察到的。 记录在此,使它成为已知缺口而不是默认通过。
Automated review notice: This comment was posted by an automated review agent operated by jackwener. It is not an independent human review and does not replace one.
|
Correction to my approval: it says That matters here in the PR's favour, so it is worth stating rather than leaving: both P3s I carried forward are already resolved on
The one gap I recorded stands unchanged, because nothing in this delta touches it: no real Electron quit / guard-close-failure smoke was run, by me or by the deep pass. The guard-plus-deadline reasoning holds on the code and the unit coverage is real, but end-to-end behaviour against a genuinely wedged Host is still argued rather than observed. 简体中文对我那条批准的更正:正文写的是 这一点在本 PR 上是朝有利方向的,所以值得说明而不是略过:我带走的两条 P3,在批准实际绑定的
我记录的那一处缺口维持不变,因为本次增量没有触及它:没有跑过真实的 Electron quit / guard 关闭失败冒烟 —— 我没跑,深审那一遍也没跑。guard 加 deadline 的推理在代码上成立、单元覆盖也是真的,但面对一个真正卡死的 Host 的端到端行为,目前仍是被论证的、而不是被观察到的。
|
Desktop quit unconditionally released the candidate launch barrier in RuntimeHostDesktopManager.#close(), which detached the launch-owner guard at the exact moment it was the close authority: the guard then ignored the IPC disconnect that would otherwise have closed the Host, so an owned ephemeral Host could survive a full quit. Quit no longer drives retirement. prepareRuntimeHostQuit only probes Host activity to feed the interruption-consent dialog, and the guard closes the Host after process exit. The synchronous retirement drive, the PID polling, and the force-terminate recovery path are removed. The Host reports upgradeBlockingActivity in host.diagnostics.query so the consent question stays answered by the same authority that gates host.upgrade.prepare; the predicate takes a selfCommands argument so the query path does not mistake one in-flight command from another client for idle. A guard-triggered close now records the retirement reason instead of exiting like a crash. The probe's accepted trade is documented: during a reconnect gap a busy Host reads as not_owned and quit does not ask, because the guard still closes it truthfully after exit. forceTerminateRegisteredRuntimeHost and its WithDependencies variant are deleted as their only caller left with forceTerminateOwnedLocalHost. Closes #4730. Generated-by: Maka
The diagnostics fixture now always carries the required upgradeBlockingActivity field, and the idle-case probe test uses an explicit idle report instead of a payload shape the production decoder would reject. Generated-by: Maka
d7e9726 to
a6996cf
Compare
Closes #4730. Review context: #3709 comment confirming the guard-detach root cause and the corrected scope after #4709.
Summary
RuntimeHostDesktopManager.#close(). Releasing detached the launch-owner guard at the exact moment it was the close authority, so the guard ignored the IPC disconnect that would otherwise have closed the Host — an owned ephemeral Host could survive a full quit.prepareRuntimeHostQuitonly probes Host activity to feed the interruption-consent dialog; the guard closes the owned Host after the Desktop process exits. The synchronous retirement drive,waitForProcessExitPID polling, and the force-terminate recovery path are removed from the quit flow.upgradeBlockingActivityinhost.diagnostics.query(required field; the epoch gate refuses mixed-version peers, so there is no wire case where it is absent) so the consent question stays answered by the same authority that gateshost.upgrade.prepare. The predicate takes aselfCommandsargument becausehost.upgrade.preparecounts itself as an in-flight command while the diagnostics query does not.Protocol epoch moves 119 → 120 for the additive diagnostics key (rebased; the number tracks whatever main has taken at merge time): older Clients reject the unknown key on exact decode, so the pair refuses at the handshake.
Why
#4730's guaranteed-timeout symptom was neutralized by #4709's observation margin, but the root cause was untouched:
#close()still released the barrier unconditionally. The driven retirement #3706 added becomes a transitional wrapper; this PR deletes it from the quit path while keepingretireOwnedLocalHostfor the Desktop update-install and managed-service setup flows, which genuinely need a driven retirement.Behavior change
Verification
npm run buildnpm --workspace @maka/desktop run typechecknpx biome checkon all changed filesnode --test apps/desktop/dist/main/__tests__/*.test.js(2092 passed)node --test packages/runtime-host/dist/__tests__/*.test.js(1677 passed, 12 platform skips)AI use
Tool(s) and scope: Maka assisted investigation, implementation, tests, and review; the commit includes the required trailer.
Checklist
Does this PR entail a change in behavior?