Day 017 把 Jupiter 的 bundle 從「一個 executable file」擴成完整的 byte closure:manifest、entry artifact 與每個 resource 都進入 admission、identity 和 immutable staging。
但 package 被安全地安裝,只回答了生命週期的前半段。
當 plugin 已經啟用、正在處理 call、甚至擁有 host 代管的 process 時,另一個更難的問題才出現:
Operator 按下 Stop 或 Uninstall 後,什麼時候才可以說這個 activation 真的結束了?
今天 Jupiter 的 main 有多條進展:M1 MCP host 已收斂、A2 agent sessions first form 已合入、U1 建立 UI token pipeline;H1a HTTP capability 則仍停在 branch 與 working draft。Day 018 不打算把它們全部寫成 commit 流水帳,而是選擇 A2 真正暴露、F1 正面修掉的一個 lifecycle defect:
一個 activation 已被撤下,但最後一個 in-flight call 尚未結束時,誰負責在 call 結束後再推進一次 teardown?
F1 經 de31e37、6ba9749 實作,0f8554d 完成 parent review,最後由 78b4ff0 合入本次盤點的本機 main@fb862cc。
這個 repository 沒有 remote/upstream;今天也沒有重新執行 Jupiter tests。文中的測試數、load loop 與 gate 都是 f1-receipt.json 的 historical evidence,不是本次 fresh run。
F1 的問題最早由 A2 agent sessions 的測試放大。
A2 的 adapter plugin 可以透過 Station 的 process capability 啟動一個 agent process。這個 process 不是 plugin 自己任意 spawn 後丟著不管,而是 host-owned resource:activation 結束時,capability 的 release(activation) 應該回收它。
測試原本希望這件事成立:
host.uninstall(&adapter).unwrap();
assert!(host.processes().is_empty());
但在 agent stdout 剛好有一行正在送進 adapter 時,uninstall() 可能先回 Ok,process 卻仍存在。
表面上像 process cleanup 太慢,真正缺口在 framework reconcile:
uninstall records intent
-> reconcile withdraws activation
-> one call is still in flight
-> activation stays Deactivating
-> reconcile converges and returns
-> uninstall answers Ok
-> call later finishes
-> nobody schedules another reconcile
每一步單獨看都合理:
問題是最後一個 call 結束本身,不會自動再觸發一次 reconcile。
所以這不是「uninstall 回傳 error」的顯性失敗,而是更危險的半完成狀態:command 成功、publication 已撤下,activation 與它擁有的 resources 卻可能一直留在 Deactivating。
Jupiter 的 lifecycle 核心仍是 desired state reconciliation。
當 installation 正在 Active,但出現以下任一條件:
desired == Stopped;remove == true;apply_stops 會先:
unpublish activation from registry
-> live.withdraw()
-> published = false
-> state = Deactivating
這個順序保證新 work 不再選到它,但舊 work 仍可完成。
下一個 reconcile pass 看見 Deactivating 時,會查 runtime adapter 回報的 in_flight():
if in_flight == 0 {
tear_down(...);
}
如果仍是 1,這一 pass 不做任何 mutation。當其他步驟也沒有變化,reconcile 便達成它自己的正確結論:目前 observed state 已經盡可能靠近 desired state。
這裡要區分兩種「完成」:
reconcile pass completed
!= lifecycle transition completed
Reconcile 是一次狀態推進,不是永遠存活的 scheduler。
而 Snapshot 只讀 state,不會偷偷 reconcile。最後一個 call 即使在一秒後結束,若沒有另一個 command、event path 或 host shutdown 再呼叫 reconcile(),舊 activation 就沒有第二次被檢查的機會。
F1 修的不是 in_flight 計數,而是完成 drain 的觸發責任。
最直覺的修法,是讓所有 lifecycle operations 都等到舊 activation 消失。
但這會破壞 hot replacement。
假設 A 正在處理一個長 call,同時安裝 B:
A serves old call
B prepares and activates
B becomes current for new calls
A drains old call
A is disposed
install(B) 必須在 B 成功 publication 後盡快回答。它不能因為 A 的舊 call 卡住五秒、三十秒甚至更久,否則「candidate 先準備、成功後切換、incumbent 只 drain 已接 work」的價值就消失了。
相反地,直接呼叫:
uninstall(A)
stop(A)
通常帶有更強的同步期待:command 回覆時,operator 希望 A 已不再執行,而且 host-owned resources 已釋放。
因此 F1 沒有用一個全域 awaitDrain flag,而是分成兩層:
| Caller | 回覆承諾 | Drain 如何完成 |
|---|---|---|
uninstall |
在 bound 內等待 dispose;超時仍回覆 | admin wait,剩餘部分交 watcher |
set_desired(Stopped) |
在 bound 內等待 stop;超時仍回覆 | admin wait,剩餘部分交 watcher |
install/install_as |
不等待被替換的舊 revision | watcher |
set_desired(Running) |
沒有 drain,不等待 | ordinary reconcile |
shutdown(grace) |
保留原有 grace loop | shutdown 自己負責 |
這個切分保留了兩種不同的產品語意:管理命令要 bounded confirmation,replacement 要 non-blocking handoff。
uninstall 與 set_desired(..., Stopped) 在第一次 reconcile() 後,會進入共用的 wait_for_drain(bundle)。
Policy 新增:
drain_wait_ms: 5_000
這個 default 是 reasoned,不是精密量測得出的 SLA:
host_io_deadline_ms;Wait loop 每次只做三件事:
Deactivating 的 installations;in_flight == 0,跑一次 reconcile;它不在兩次 poll 之間持 framework lock,也不在 call 還沒 ready 時每 20ms 重跑完整 reconcile。
後者很重要。每一個 reconcile pass 都會詢問 retention index、更新 registry facts、routing 與 events;等待中的 call 沒有變化時,重做整套工作只是在 busy polling。
若 drain 在五秒內結束:
uninstall 回覆時 installation 已 retire,runtime 已 drop,capabilities 已 release;stop 回覆時 installation 留在 Installed,activation 與 resources 已釋放,可再切回 Running。這才恢復 A2 測試原本想表達的語意:
host.uninstall(&adapter).unwrap();
assert!(host.processes().is_empty());
不是「通常過一下就會空」,而是 normal bound 內 command 回覆時就已經空。
如果 call 超過 drain_wait_ms,F1 不會把它 kill,也不會把 stop intention rollback。
管理命令仍回 Ok,但 installation 保持:
state: Deactivating
in_flight: N
diagnostic: Draining: N call(s) still in flight after M ms;
disposal follows the last one
Framework 還會再跑一個 pass,讓帶有 diagnostic 的 installation event 在 command 回覆前已進入 event log。
這裡的 Ok 精確表示:
Stop/uninstall intention 已接受並記錄,新的 eligible work 已撤下;同步等待額度已用完,dispose 尚未完成。
它不表示「process 一定已消失」。
這比 timeout error 更符合目前 contract,因為 operation 不會放棄 stop;但 caller 若需要區分 synchronous completion,必須看 snapshot/event 的 Deactivating 與 diagnostic,而不能只看 command success。
當 drain 最後完成,tear_down 會把 diagnostic 清掉。Diagnostic 描述的是當時狀態,不應像永久錯誤黏在已停止的 installation 上。
Bounded wait 只解決管理命令的體驗,無法處理兩種情況:
F1 因此加入 Framework::watch_drains()。
它不直接常駐一個強引用 thread,而是先把 Weak<Framework> 綁進 framework。每次 reconcile 結束時,ensure_drain_watcher 檢查:
not shutting down
and at least one installation is Deactivating
and no drain watcher is already running
and watch_drains was bound
條件成立才啟動唯一的 plugin-drain thread。
Watcher 每一 tick:
Arc;用 Weak 的原因不是微小最佳化,而是 ownership:
負責清理 framework 的 watcher,不能反過來成為 framework 永遠無法被 drop 的最後一個 owner。
Self-review 曾發現初版在整個 tick、包括 sleep 期間持有 upgraded Arc;正式 commit 前已改成每次只在檢查或 reconcile 時短暫持有。
當沒有任何 Deactivating installation,或 framework 進入 shutdown,watcher 會在 lock 內清除 drain_watcher_running 並結束。若之後又產生新 drain,下一次 reconcile 可以安全啟動新的 watcher。
Watcher 的目的不是建立第二個 reconciliation scheduler。
Call 還在執行時,完整 reconcile 不可能完成 teardown;它只會重讀同樣的 in_flight > 0。所以 watcher 的 idle path只檢查 live handles 的計數,不跑 pass。
Parent review 專門增加一個 pinning test:把 retention index 包成 counting probe。Stop wait 與 watcher 同時觀察 held call,跨過十五個以上 poll intervals,holders() 問詢數不應增加;直到 call release、真正 disposing pass 執行,count 才再變。
這守住的是 cost model:
poll readiness cheaply
reconcile once when progress is possible
而不是:
reconcile repeatedly and hope state eventually changes
Negative control 把 watcher 改成每 tick 都 reconcile 時,原本五個功能測試仍會通過;只有這個 counting test 會失敗。它證明 performance/architecture rule 若沒有直接 evidence,很容易被「結果還是對的」掩蓋。
只提供 watch_drains() API 還不夠。如果 Station 記得呼叫,LinkedHost 忘記,兩種 Host 的 lifecycle semantics 又會分岔。
Day 016 寫過的 PluginHost<R> 在這裡再次成為關鍵邊界。
兩種 Host 都透過:
PluginHost::restore(...)
把自己建立的 Arc<Framework<R>> 交給 shared composition。F1 在這個入口先呼叫:
framework.watch_drains();
所以:
NoRuntime host 也走同一條路;Plugin-host suite 還直接驗證 open 後 drains_watched() == true。
一個直接使用裸 Framework、從未呼叫 watch_drains() 的 caller,仍保留舊行為:最後一個 call 結束後,必須等下一次顯式 reconcile() 才 dispose。這是 API boundary,不應誤寫成所有 framework instance 都有 background completion。
Watcher 發現 in_flight == 0 後,只觸發既有 reconcile;真正的 disposal order 沒有被 F1 另寫一套。
tear_down 會依序:
withdraw();deactivate hook;release(activation);Installed 或 Retired。A2 agent process 就是在第八步由 process capability 回收。
這也解釋為什麼不能在 call 還 in-flight 時直接跳到 cap.release:該 call 可能正在使用 process、store、message pin 或其他由 activation 擁有的 capability state。強制釋放會把「bounded administrative response」變成「破壞執行中 work」。
F1 不取消 call。它保證的是:只要最後一個 call 確實結束,cleanup 不再依賴下一個無關 command。
如果 call 永遠不結束,watcher也不會越權 dispose;in-flight cancellation 仍是另一個需要獨立 contract 的問題。
F1 特別保留一條既有 test:A 正處理 held call 時安裝 B。
正確結果是:
install(B) returns quickly
B is current and serves new calls
A remains Deactivating
held call completes on A
watcher disposes A
不是:
install(B) waits up to drain_wait_ms for A
Parent review 甚至植入一個 negative control,讓 install 也等待。舊 replacement assertion 因為等五秒後仍看見相同狀態,未必能直接抓到 latency regression;F1 的新 test 因此明確要求 install 在遠低於 drain_wait_ms 的時間內回覆。
這提醒一件事:state assertions 相同,不代表 temporal contract 相同。
Plugin system 的 correctness 不只包含最後有哪些 installations,也包含:
F1 worker 先記錄:
GATE PASSED;Parent review 又找到兩個未被原測試 pin 住的行為:
Draining: 或舊 unready: diagnostic。加入兩個 tests 後,merge follow-up 記錄:
framework: 125
workspace: 485 passed, 0 failed, 1 ignored
更有辨識力的是 load loop:
這證明 watcher alone 雖能 eventual cleanup,卻不能滿足「uninstall/stop 回覆時 process 已釋放」的同步 contract。
Negative controls 也分別移除:
它們不是本次重跑結果,而是 committed receipt 的 historical evidence。今天只能說 current main 包含這些 code 與 receipts,不能說我重新驗證了 485 tests 或 live window。
F1 關閉的是 framework lifecycle gap,但 receipt 明確留下 host persistence gap。
PluginHost::uninstall 的順序是:
framework.uninstall(bundle)
-> save_state()
若 drain 在 drain_wait_ms 內完成,framework 已移除 installation,save 會把 record 一起刪掉。
但若 timeout:
Ok 時,installation 仍在 Deactivating;PluginHost::save_state() 仍把它視為 live record;state.json;所以 F1 不能宣稱「timeout 後 framework 與 persisted install records 已原子收斂」。
可能的後續需要 host-side subscriber,或在 framework call 前先記錄 removal intent;但兩者都會改 persistence ordering,必須有自己的 tests,不能順手塞進 F1。
另一個 review boundary 是 Weak ownership:code 明確不在 sleep 期間持 Arc,既有 drop tests 也通過;但沒有直接 test「framework 正在 drain 且 watcher 正在 sleep 時,所有外部 Arcs 被 drop」。這部分仍是 source review 加鄰近 evidence,不應誇大為完整 proof。
同一個問題在 Unity tooling 很常見。
想像一個 Editor extension 正在:
此時 operator disable 或 replace extension。
安全的 lifecycle 不是立刻 destroy everything:
withdraw from new command routing
-> mark Deactivating
-> let admitted callbacks finish
-> release editor hooks, processes and temp resources
-> mark Stopped or Retired
但只把狀態設成 Stopping 也不夠。最後一個 callback 結束後,必須有人再推進 cleanup。
可以沿用 F1 的雙層模型:
Editor command path
bounded wait for ordinary stop/uninstall
Lifecycle watcher
observes in-flight count
runs one cleanup pass when progress becomes possible
Replacement 則不能跟 stop command 共用相同等待語意。新版 importer 或 build plugin 應在 ready 後接新 work,舊版只完成自己已接的 callback;安裝新版不應被舊 callback 的 drain deadline 阻塞。
Unity 端也要避免 watcher 自己變成 leak:若 EditorApplication.update callback、static event 或 background task 持有 extension 的最後一個 strong reference,原本為了清理而建立的 watcher,反而會讓 domain/assembly 永遠無法卸載。使用 weak ownership、明確 unsubscribe 與 single-watcher guard,都是同一個問題的不同語言版本。
最後,timeout success 必須保持誠實。UI 可以顯示「Stop accepted, 1 callback draining」,不能只把按鈕變成綠色 Done;persisted project state 也不能在 runtime cleanup 尚未完成時假裝已完全移除。
Day 018 的 F1 沒有發明新的 plugin state machine,也沒有強制取消執行中的 work。
它補上的是 lifecycle 最容易遺失的一個觸發點:第一次 reconcile 已經做完能做的事,而最後一個 call 稍後才結束時,framework 仍會在正確時機再跑一次,而且只跑需要的那一次。
Stop 是 intention,Deactivating 是承諾,而 Dispose 必須有一條不依賴下一個偶然 command 的完成路徑。