agentmux_cef\commands/orphan_reconcile.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Host-side orphan-instance reconciliation. Spec:
5//! `docs/specs/SPEC_HOST_ORPHAN_RECONCILIATION_2026_05_05.md`.
6//!
7//! When the launcher detects that the last user-visible window
8//! has closed but the host is still alive, it emits
9//! `Event::HostShouldQuit`. The host's handler invokes
10//! `reconcile_and_drain` here, which closes any orphan
11//! `window-pool-*` browsers (promoted out of the warm pool, but
12//! the launcher mirror has dropped them — typically because their
13//! HWND was destroyed without the host's `on_before_close` running).
14//!
15//! Each close funnels back through `client::on_before_close`,
16//! whose Stage 2 hook fires `quit_message_loop()` once
17//! `browser_list` empties — so the reconciler doesn't drive
18//! UI-thread shutdown directly.
19//!
20//! Threading: CEF Browser/BrowserHost methods (`host()`,
21//! `window_handle()`, `close_browser()`) MUST run on the UI
22//! thread per CEF docs. The IPC reader thread that delivers
23//! `HostShouldQuit` does only state-snapshot + classification
24//! work, then `cef::post_task`s the UI-thread closure.
25
26use std::collections::{HashMap, HashSet};
27use std::sync::Arc;
28
29use cef::*;
30
31use crate::state::AppState;
32
33/// HWND liveness state of a candidate browser. Inputs to the
34/// planner; computed in production from real CEF/Win32 calls in
35/// `hwnd_is_dead_or_missing` + the `host()` check, supplied
36/// directly in tests.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub(crate) enum HwndStatus {
39 /// HWND is non-null AND `IsWindow` returns true. Live user window.
40 Live,
41 /// `BrowserHost` is gone (`browser.host()` returned None).
42 Hostless,
43 /// HWND is null OR `IsWindow` returns false. Zombie.
44 Dead,
45}
46
47/// What the planner decided to do with a single label.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub(crate) enum CloseAction {
50 /// Call `host.close_browser(force=1)` — requires live BrowserHost.
51 /// Used for zombies (Dead) and ready warm-pool / unpromoted-pool
52 /// labels in drain mode.
53 CloseBrowser,
54 /// Dispatch `HostCommand::UnregisterBrowser` directly. Used for
55 /// hostless zombies — there's no BrowserHost to call
56 /// `close_browser` on, so we clean `state.browsers` ourselves and
57 /// drive `quit_message_loop` from the orchestrator.
58 UnregisterBrowser,
59}
60
61/// Output of the pure planning step. The orchestrator executes this
62/// against real CEF state. Kept separate so tests can verify
63/// decisions without standing up a CEF runtime.
64#[derive(Debug, Default, Clone, PartialEq, Eq)]
65pub(crate) struct ReconcilePlan {
66 /// Labels to act on, in deterministic order (zombies first, then
67 /// drain-mode pool inventory). Each entry pairs a label with the
68 /// action the orchestrator should take.
69 pub closes: Vec<(String, CloseAction)>,
70 /// Live HWND, not in `pool.queue` — Race B. Diagnostic only;
71 /// these are intentionally NOT in `closes`.
72 pub freshly_promoted: Vec<String>,
73 /// Whether to dispatch `BeginDrain` before executing `closes`.
74 /// Equivalent to `safe_to_drain`.
75 pub begin_drain: bool,
76}
77
78/// Pure planning function. The orchestrator HWND-probes every
79/// non-pane top-level browser in `state.browsers` and feeds that map
80/// here.
81///
82/// `browser_status` keys every non-pane label in `state.browsers` to
83/// its HwndStatus. Includes pool-prefixed labels AND regular
84/// top-level windows (e.g. `main`, `window-N`). Pane labels
85/// (`browser-pane-*`) are NOT in this map — they drain via a
86/// separate cascade and would otherwise pollute classification.
87///
88/// `all_browser_labels` is `state.list_browsers()` keyed; used only
89/// for `live_user_count`.
90pub(crate) fn plan_reconcile(
91 browser_status: &HashMap<String, HwndStatus>,
92 all_browser_labels: &[String],
93 shadow_keys: &HashSet<String>,
94 pool_queue: &HashSet<String>,
95 unpromoted_pool: &HashSet<String>,
96 pending_creation_in_flight: bool,
97) -> ReconcilePlan {
98 // Live user count = labels in shadow, not pane-prefixed. Zombies
99 // are absent from shadow (`apply_hwnd_destroyed` prunes
100 // `state.windows`), so no subtraction needed.
101 let live_user_count = all_browser_labels
102 .iter()
103 .filter(|label| {
104 shadow_keys.contains(label.as_str()) && !label.starts_with("browser-pane-")
105 })
106 .count();
107
108 // Classify each non-pane top-level browser. Pool-prefixed and
109 // regular top-levels share the same shape:
110 // Hostless → UnregisterBrowser, but ONLY when drain
111 // fires (otherwise we'd bypass the pool
112 // reducer's per-destroy bookkeeping).
113 // Dead → CloseBrowser. on_before_close handles the
114 // reducer cleanup.
115 // Live + shadow → promoted user window, leave alone.
116 // Live + pool.queue / pool.unpromoted → drainable pool slot.
117 // Live + (none of above) → freshly opened/promoted, blocks
118 // drain (Race B).
119 let mut dead_zombies: Vec<String> = Vec::new();
120 let mut hostless: Vec<String> = Vec::new();
121 let mut drainable: Vec<String> = Vec::new();
122 let mut freshly_promoted: Vec<String> = Vec::new();
123 for (label, status) in browser_status {
124 match status {
125 HwndStatus::Dead => dead_zombies.push(label.clone()),
126 HwndStatus::Hostless => hostless.push(label.clone()),
127 HwndStatus::Live => {
128 if shadow_keys.contains(label.as_str()) {
129 // promoted user window
130 } else if pool_queue.contains(label) || unpromoted_pool.contains(label) {
131 drainable.push(label.clone());
132 } else {
133 freshly_promoted.push(label.clone());
134 }
135 }
136 }
137 }
138
139 // `pending_creation_in_flight` blocks drain too: a stale
140 // `HostShouldQuit` racing with `open_window_with_kind` (which
141 // enqueues `PendingWindowCreation` BEFORE `post_create_window`
142 // registers the browser) would otherwise close the warm pool
143 // and drive Stage-2 quit before the new window is registered,
144 // dropping it.
145 let safe_to_drain = live_user_count == 0
146 && freshly_promoted.is_empty()
147 && !pending_creation_in_flight;
148
149 // Sort for determinism (HashMap iteration order is unspecified).
150 dead_zombies.sort();
151 hostless.sort();
152 drainable.sort();
153 freshly_promoted.sort();
154
155 let mut closes: Vec<(String, CloseAction)> = Vec::new();
156 // Dead zombies normally close regardless of drain state —
157 // `close_browser(force=1)` triggers the host's own
158 // `on_before_close` cleanup chain. BUT if a window creation is
159 // pending, that cleanup chain itself can race the pending
160 // creation: when the zombie is the last registered browser,
161 // `on_before_close` sees `user_browser_count == 0`, dispatches
162 // `BeginDrain`, and Stage-2 `quit_message_loop` fires before
163 // the new window registers. Defer zombie reap until the
164 // creation completes; the next `HostShouldQuit` will catch it.
165 if !pending_creation_in_flight {
166 for label in dead_zombies {
167 closes.push((label, CloseAction::CloseBrowser));
168 }
169 }
170 if safe_to_drain {
171 // Hostless gets UnregisterBrowser ONLY in drain mode.
172 // Outside drain, dispatching UnregisterBrowser bypasses
173 // `on_pool_window_destroyed` cleanup, leaving pool reducer
174 // state with a stale label that's no longer in `browsers`.
175 // The hostless state is already a CEF lifecycle anomaly;
176 // letting it persist until the next drain is preferable to
177 // creating per-handler-state drift.
178 for label in hostless {
179 closes.push((label, CloseAction::UnregisterBrowser));
180 }
181 for label in drainable {
182 closes.push((label, CloseAction::CloseBrowser));
183 }
184 }
185
186 ReconcilePlan {
187 closes,
188 freshly_promoted,
189 begin_drain: safe_to_drain,
190 }
191}
192
193/// IPC-thread entry point. Posts a UI-thread task that does all
194/// state-snapshot + classification + close work. The IPC thread no
195/// longer pre-classifies candidates — between IPC and UI execution,
196/// labels can move between `pool.unpromoted` / `pool.queue` /
197/// promoted-into-shadow. Re-snapshotting on the UI thread avoids
198/// stale classification.
199pub fn reconcile_and_drain(state: &Arc<AppState>) {
200 // Marshal CEF Browser/BrowserHost calls to the UI thread. The
201 // existing two-stage cascade (`client/mod.rs::on_before_close`)
202 // gets to make these calls inline because it already runs on
203 // the UI thread; we don't, so we hand the work to CEF's task
204 // queue. Three v0.33.491–v0.33.494 attempts at driving UI work
205 // *directly* from the IPC handler all hung CEF — this path
206 // avoids that by using CEF's own scheduler.
207 let mut task = OrphanReconcileTask::new(state.clone());
208 let posted = post_task(ThreadId::UI, Some(&mut task));
209 tracing::debug!(
210 target: "wrr-trace",
211 "[orphan-reconcile] posted UI-thread task posted={}",
212 posted != 0
213 );
214}
215
216wrap_task! {
217 pub struct OrphanReconcileTask {
218 state: Arc<AppState>,
219 }
220
221 impl Task {
222 fn execute(&self) {
223 ui_thread_reconcile(&self.state);
224 }
225 }
226}
227
228/// UI-thread body. CEF Browser methods are safe here.
229///
230/// Re-snapshot browsers (state may have advanced since the IPC
231/// thread classified candidates — labels that have actually closed
232/// are no longer in the map; new candidates may have appeared, but
233/// we'll catch them on the next `HostShouldQuit`). For each
234/// candidate that's still present, probe its HWND: live → skip
235/// (Race B, freshly promoted), dead → close.
236///
237/// Drain mode is set ONLY if no live user browser remains *after*
238/// removing the zombies we're about to close. A stale
239/// `HostShouldQuit` racing with a live user session must NOT flip
240/// `quit_state` to `Draining`, because there's no transition back
241/// to `Running` and `spawn_pool_window` then refuses to refill the
242/// pool — silently degrading the live session.
243fn ui_thread_reconcile(state: &Arc<AppState>) {
244 let browser_pairs = state.list_browsers();
245 let shadow_keys: HashSet<String> = state
246 .shadow_window_meta
247 .lock()
248 .keys()
249 .cloned()
250 .collect();
251 let pool_queue: HashSet<String> = state
252 .host_state
253 .lock()
254 .pool
255 .queue
256 .iter()
257 .cloned()
258 .collect();
259 let unpromoted_pool: HashSet<String> = state.unpromoted_pool_labels_snapshot();
260
261 // Probe HWND status for every non-pane top-level browser. Panes
262 // drain via a separate cascade and would otherwise pollute
263 // classification. Includes pool-prefixed labels AND regular
264 // top-level windows (`main`, `window-N`) — non-pool zombies
265 // need to be reaped just like pool zombies do.
266 let label_to_browser: HashMap<String, Browser> = browser_pairs
267 .iter()
268 .filter(|(l, _)| !l.starts_with("browser-pane-"))
269 .map(|(l, b)| (l.clone(), b.clone()))
270 .collect();
271 let browser_status: HashMap<String, HwndStatus> = label_to_browser
272 .iter()
273 .map(|(label, browser)| (label.clone(), classify_hwnd(browser)))
274 .collect();
275
276 let all_labels: Vec<String> = browser_pairs.iter().map(|(l, _)| l.clone()).collect();
277 // Only USER-window pending creates block drain. Pool spawns
278 // (`window-pool-*` via `spawn_pool_window`) and pane creates
279 // (`browser-pane-*` via the pane creation path) also enqueue
280 // `PendingWindowCreation`, but they're excluded from user-window
281 // counts everywhere else (live_user_count, Stage-2 quit gate)
282 // and gating drain on them would suppress reconciliation
283 // indefinitely with no later `HostShouldQuit` retry.
284 let pending_creation_in_flight = state
285 .host_state
286 .lock()
287 .pending_window_creations
288 .iter()
289 .any(|p| {
290 // Exclusions:
291 // - `window-pool-` / `browser-pane-` per the comment above.
292 // - `floating-` because floating-pane creation (#810) can
293 // leak a pending entry on failure paths (e.g. CEF
294 // `browser_host_create_browser` returning 0); without
295 // this exclusion, one failed creation would permanently
296 // block orphan reconciliation. Floating panes manage
297 // their own lifecycle and don't participate in orphan
298 // reconciliation today. Codex P1 on PR #811.
299 !p.label.starts_with("window-pool-")
300 && !p.label.starts_with("browser-pane-")
301 && !p.label.starts_with("floating-")
302 });
303 let plan = plan_reconcile(
304 &browser_status,
305 &all_labels,
306 &shadow_keys,
307 &pool_queue,
308 &unpromoted_pool,
309 pending_creation_in_flight,
310 );
311
312 if !plan.freshly_promoted.is_empty() {
313 tracing::info!(
314 target: "wrr",
315 "[orphan-reconcile] {} candidate(s) appear freshly-promoted (live HWND, not in pool queue), skipping: {:?}",
316 plan.freshly_promoted.len(),
317 plan.freshly_promoted
318 );
319 }
320
321 if plan.closes.is_empty() {
322 if plan.begin_drain {
323 // Drain requested AND nothing for us to do (state.browsers
324 // is empty). Stage-2 quit may be blocked by stale
325 // `client::browser_list` entries we can't see from here.
326 // Drive quit ourselves. The pending-creation gate is
327 // already enforced by `plan.begin_drain`.
328 tracing::warn!(
329 target: "wrr",
330 "[orphan-reconcile] nothing to close but drain requested — driving quit_message_loop"
331 );
332 quit_message_loop();
333 } else {
334 tracing::info!(
335 target: "wrr",
336 "[orphan-reconcile] nothing to close, drain not requested — host has live work or pending creation"
337 );
338 }
339 return;
340 }
341
342 if plan.begin_drain {
343 state.host_dispatch(crate::reducer::HostCommand::BeginDrain {
344 reason: crate::state::QuitReason::LastWindowClosed,
345 });
346 } else {
347 tracing::info!(
348 target: "wrr",
349 "[orphan-reconcile] skipping BeginDrain — live user windows or freshly-promoted candidates remain"
350 );
351 }
352
353 tracing::warn!(
354 target: "wrr",
355 "[orphan-reconcile] executing {} close action(s): {:?}",
356 plan.closes.len(),
357 plan.closes
358 );
359
360 let mut any_hostless = false;
361 for (i, (label, action)) in plan.closes.iter().enumerate() {
362 match action {
363 CloseAction::CloseBrowser => {
364 if let Some(browser) = label_to_browser.get(label).cloned() {
365 let late_hostless =
366 drive_close_browser(state, i, label, browser, plan.begin_drain);
367 if late_hostless {
368 any_hostless = true;
369 }
370 } else {
371 tracing::warn!(
372 target: "wrr",
373 "[orphan-reconcile][{}] label={} vanished before close",
374 i, label
375 );
376 }
377 }
378 CloseAction::UnregisterBrowser => {
379 drive_unregister(state, i, label);
380 any_hostless = true;
381 }
382 }
383 }
384
385 // Hostless orphans don't get `on_before_close` callbacks. The
386 // host's own Stage-2 quit gates on `client::browser_list.is_empty()`
387 // — but UnregisterBrowser only touches the reducer's `browsers`
388 // map, not CefClient's internal list, so a stale Hostless entry
389 // there permanently blocks Stage 2.
390 //
391 // Drive `quit_message_loop` ourselves whenever drain ran AND any
392 // Hostless cleanup happened. Closables we dispatched alongside
393 // are mid-close at this point; their `on_before_close` may not
394 // run before the loop terminates, but we're shutting down anyway
395 // and OS process exit reclaims their resources. Gated on
396 // `begin_drain` so a stale `HostShouldQuit` racing with a live
397 // session can't terminate it.
398 if any_hostless && plan.begin_drain {
399 tracing::warn!(
400 target: "wrr",
401 "[orphan-reconcile] hostless orphans unregistered in drain mode — driving quit_message_loop"
402 );
403 quit_message_loop();
404 }
405}
406
407/// Map a Browser to its `HwndStatus`. Calls CEF + Win32; only safe
408/// from the UI thread. Used by the orchestrator to build the input
409/// to `plan_reconcile`.
410fn classify_hwnd(browser: &Browser) -> HwndStatus {
411 let mut b = browser.clone();
412 let Some(host) = b.host() else { return HwndStatus::Hostless };
413 #[cfg(target_os = "windows")]
414 unsafe {
415 use windows_sys::Win32::Foundation::HWND;
416 use windows_sys::Win32::UI::WindowsAndMessaging::IsWindow;
417 let wh = host.window_handle();
418 if wh.0.is_null() {
419 return HwndStatus::Dead;
420 }
421 if IsWindow(wh.0 as HWND) == 0 {
422 HwndStatus::Dead
423 } else {
424 HwndStatus::Live
425 }
426 }
427 // On Linux/macOS `cef_window_handle_t` is `u64` (X11 XID / NSView ptr),
428 // not the Win32 HWND tuple-struct, so `wh.0.is_null()` doesn't typecheck.
429 // We don't have an `IsWindow` equivalent here either — treat any host
430 // with a Browser as Live for orphan-reconcile classification. The Win32
431 // path keeps the strict liveness check that landed in #702.
432 #[cfg(not(target_os = "windows"))]
433 {
434 let _ = host;
435 HwndStatus::Live
436 }
437}
438
439/// Execute a `CloseBrowser` action. Already on UI thread.
440/// `host.close_browser(force=1)` works regardless of HWND state and
441/// triggers the host's `on_before_close` callback chain (which
442/// dispatches `UnregisterBrowser` and drives Stage-2 quit naturally).
443/// Returns `true` iff the browser's host had vanished by execute
444/// time and the orchestrator should treat this as a late hostless
445/// transition (relevant for the Stage-2 quit drive — same reasoning
446/// as the planner's Hostless bucket).
447fn drive_close_browser(
448 state: &Arc<AppState>,
449 idx: usize,
450 label: &str,
451 mut browser: Browser,
452 drain_mode: bool,
453) -> bool {
454 if let Some(host) = browser.host() {
455 host.close_browser(1);
456 tracing::debug!(
457 target: "wrr-trace",
458 "[orphan-reconcile][{}] close_browser(force=1) label={}",
459 idx, label
460 );
461 false
462 } else {
463 // Race: HWND status was Live/Dead at planning, but
464 // BrowserHost vanished before we got here. Mirror the
465 // planner's Hostless path — but only fall through to
466 // UnregisterBrowser if drain is active (same gating as the
467 // Hostless bucket — outside drain, dispatching
468 // UnregisterBrowser bypasses on_pool_window_destroyed
469 // bookkeeping and creates pool-reducer drift).
470 if drain_mode {
471 tracing::warn!(
472 target: "wrr",
473 "[orphan-reconcile][{}] browser host=None at execute time label={} — late hostless transition; dispatching UnregisterBrowser",
474 idx, label
475 );
476 state.host_dispatch(crate::reducer::HostCommand::UnregisterBrowser {
477 label: label.to_string(),
478 });
479 true
480 } else {
481 tracing::warn!(
482 target: "wrr",
483 "[orphan-reconcile][{}] browser host=None at execute time label={} — late hostless transition; deferring UnregisterBrowser (drain not active)",
484 idx, label
485 );
486 false
487 }
488 }
489}
490
491/// Execute an `UnregisterBrowser` action. Hostless candidates can't
492/// be `close_browser`'d (no `BrowserHost` to call it on), so we
493/// clean `state.browsers` ourselves. Caller drives `quit_message_loop`
494/// after the loop if any unregister fired.
495fn drive_unregister(state: &Arc<AppState>, idx: usize, label: &str) {
496 tracing::warn!(
497 target: "wrr",
498 "[orphan-reconcile][{}] hostless label={} — dispatching UnregisterBrowser",
499 idx, label
500 );
501 state.host_dispatch(crate::reducer::HostCommand::UnregisterBrowser {
502 label: label.to_string(),
503 });
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 fn set_of(items: &[&str]) -> HashSet<String> {
511 items.iter().map(|s| s.to_string()).collect()
512 }
513
514 fn vec_of(items: &[&str]) -> Vec<String> {
515 items.iter().map(|s| s.to_string()).collect()
516 }
517
518 // ── plan_reconcile integration tests ──────────────────────────
519 //
520 // Cover the state cross-product the orchestrator must handle:
521 //
522 // axes: HwndStatus (Live / Dead / Hostless),
523 // in shadow_window_meta (yes / no),
524 // in pool.queue (yes / no),
525 // label prefix (window-pool-* / browser-pane-* / other),
526 // and the resulting (live_user_count, freshly_promoted)
527 // derivation that drives `safe_to_drain`.
528 //
529 // Each test names the spec scenario (Race A/B/C/D) it exercises.
530
531 fn map_of(items: &[(&str, HwndStatus)]) -> HashMap<String, HwndStatus> {
532 items.iter().map(|(s, st)| (s.to_string(), *st)).collect()
533 }
534
535 #[test]
536 fn plan_no_browsers_is_empty_drain_true() {
537 let plan = plan_reconcile(
538 &map_of(&[]),
539 &vec_of(&[]),
540 &set_of(&[]),
541 &set_of(&[]),
542 &set_of(&[]),
543 false,
544 );
545 assert!(plan.closes.is_empty());
546 assert!(plan.freshly_promoted.is_empty());
547 assert!(plan.begin_drain);
548 }
549
550 #[test]
551 fn plan_single_dead_zombie_drains_and_closes() {
552 let label = "window-pool-zombie";
553 let plan = plan_reconcile(
554 &map_of(&[(label, HwndStatus::Dead)]),
555 &vec_of(&[label]),
556 &set_of(&[]),
557 &set_of(&[]),
558 &set_of(&[]),
559 false,
560 );
561 assert_eq!(plan.closes, vec![(label.to_string(), CloseAction::CloseBrowser)]);
562 assert!(plan.freshly_promoted.is_empty());
563 assert!(plan.begin_drain);
564 }
565
566 #[test]
567 fn plan_hostless_zombie_unregisters_and_drains() {
568 let label = "window-pool-hostless";
569 let plan = plan_reconcile(
570 &map_of(&[(label, HwndStatus::Hostless)]),
571 &vec_of(&[label]),
572 &set_of(&[]),
573 &set_of(&[]),
574 &set_of(&[]),
575 false,
576 );
577 assert_eq!(
578 plan.closes,
579 vec![(label.to_string(), CloseAction::UnregisterBrowser)]
580 );
581 assert!(plan.begin_drain);
582 }
583
584 #[test]
585 fn plan_freshly_promoted_blocks_drain_and_skips_close() {
586 // Race B: live HWND, NOT in pool.queue, NOT in unpromoted,
587 // NOT in shadow. Pre-echo-promotion. Must NOT be closed AND
588 // must block drain.
589 let label = "window-pool-just-promoted";
590 let plan = plan_reconcile(
591 &map_of(&[(label, HwndStatus::Live)]),
592 &vec_of(&[label]),
593 &set_of(&[]),
594 &set_of(&[]),
595 &set_of(&[]),
596 false,
597 );
598 assert!(plan.closes.is_empty(), "freshly promoted must not be closed: {:?}", plan.closes);
599 assert_eq!(plan.freshly_promoted, vec![label.to_string()]);
600 assert!(!plan.begin_drain);
601 }
602
603 #[test]
604 fn plan_ready_warm_pool_drains() {
605 // Race D: live HWND IN pool.queue. Common shutdown path.
606 let label = "window-pool-ready";
607 let plan = plan_reconcile(
608 &map_of(&[(label, HwndStatus::Live)]),
609 &vec_of(&[label]),
610 &set_of(&[]),
611 &set_of(&[label]),
612 &set_of(&[]),
613 false,
614 );
615 assert_eq!(plan.closes, vec![(label.to_string(), CloseAction::CloseBrowser)]);
616 assert!(plan.freshly_promoted.is_empty());
617 assert!(plan.begin_drain);
618 }
619
620 #[test]
621 fn plan_unpromoted_pool_drains() {
622 // Spawning pool slot — live HWND, IN unpromoted_pool.
623 // Drain branch must close it so Stage 2 sees empty browser_list.
624 let label = "window-pool-spawning";
625 let plan = plan_reconcile(
626 &map_of(&[(label, HwndStatus::Live)]),
627 &vec_of(&[label]),
628 &set_of(&[]),
629 &set_of(&[]),
630 &set_of(&[label]),
631 false,
632 );
633 assert_eq!(plan.closes, vec![(label.to_string(), CloseAction::CloseBrowser)]);
634 assert!(plan.begin_drain);
635 }
636
637 #[test]
638 fn plan_live_user_window_blocks_drain() {
639 let user_label = "window-pool-promoted-user";
640 let zombie_label = "window-pool-zombie";
641 let plan = plan_reconcile(
642 &map_of(&[
643 (user_label, HwndStatus::Live),
644 (zombie_label, HwndStatus::Dead),
645 ]),
646 &vec_of(&[user_label, zombie_label]),
647 &set_of(&[user_label]),
648 &set_of(&[]),
649 &set_of(&[]),
650 false,
651 );
652 assert_eq!(
653 plan.closes,
654 vec![(zombie_label.to_string(), CloseAction::CloseBrowser)]
655 );
656 assert!(!plan.begin_drain);
657 }
658
659 #[test]
660 fn plan_zombie_plus_freshly_promoted_skips_drain() {
661 // Zombie always closes; freshly_promoted blocks drain.
662 let zombie = "window-pool-zombie";
663 let promoted = "window-pool-promoted";
664 let plan = plan_reconcile(
665 &map_of(&[
666 (zombie, HwndStatus::Dead),
667 (promoted, HwndStatus::Live),
668 ]),
669 &vec_of(&[zombie, promoted]),
670 &set_of(&[]),
671 &set_of(&[]),
672 &set_of(&[]),
673 false,
674 );
675 assert_eq!(
676 plan.closes,
677 vec![(zombie.to_string(), CloseAction::CloseBrowser)]
678 );
679 assert_eq!(plan.freshly_promoted, vec![promoted.to_string()]);
680 assert!(!plan.begin_drain);
681 }
682
683 #[test]
684 fn plan_zombie_plus_ready_pool_drains_both() {
685 let zombie = "window-pool-zombie";
686 let ready = "window-pool-ready";
687 let plan = plan_reconcile(
688 &map_of(&[
689 (zombie, HwndStatus::Dead),
690 (ready, HwndStatus::Live),
691 ]),
692 &vec_of(&[zombie, ready]),
693 &set_of(&[]),
694 &set_of(&[ready]),
695 &set_of(&[]),
696 false,
697 );
698 let close_labels: Vec<&str> = plan.closes.iter().map(|(l, _)| l.as_str()).collect();
699 assert!(close_labels.contains(&zombie));
700 assert!(close_labels.contains(&ready));
701 assert_eq!(plan.closes.len(), 2);
702 assert!(plan.begin_drain);
703 }
704
705 #[test]
706 fn plan_promoted_pool_window_in_shadow_is_left_alone() {
707 // A `window-pool-*` label that's in shadow is a promoted user
708 // window (kept its prefix). Live HWND. Must NOT be closed
709 // and DOES count toward live_user_count.
710 let promoted = "window-pool-active";
711 let plan = plan_reconcile(
712 &map_of(&[(promoted, HwndStatus::Live)]),
713 &vec_of(&[promoted]),
714 &set_of(&[promoted]),
715 &set_of(&[]),
716 &set_of(&[]),
717 false,
718 );
719 assert!(plan.closes.is_empty());
720 assert!(plan.freshly_promoted.is_empty());
721 assert!(!plan.begin_drain, "promoted pool window must keep host alive");
722 }
723
724 #[test]
725 fn plan_browser_pane_labels_dont_count_toward_live_user() {
726 let pane = "browser-pane-foo";
727 let zombie = "window-pool-zombie";
728 let plan = plan_reconcile(
729 &map_of(&[(zombie, HwndStatus::Dead)]),
730 &vec_of(&[pane, zombie]),
731 &set_of(&[pane]),
732 &set_of(&[]),
733 &set_of(&[]),
734 false,
735 );
736 assert!(plan.begin_drain);
737 let close_labels: Vec<&str> = plan.closes.iter().map(|(l, _)| l.as_str()).collect();
738 assert!(close_labels.contains(&zombie));
739 }
740
741 #[test]
742 fn plan_v0_33_643_reproduction() {
743 let z1 = "window-pool-722b6186bb6e42378b48b7068c0d54b0";
744 let z2 = "window-pool-b4e20337180247bdbd7408ddd7754b78";
745 let plan = plan_reconcile(
746 &map_of(&[(z1, HwndStatus::Dead), (z2, HwndStatus::Dead)]),
747 &vec_of(&[z1, z2]),
748 &set_of(&[]),
749 &set_of(&[]),
750 &set_of(&[]),
751 false,
752 );
753 assert_eq!(plan.closes.len(), 2);
754 for (label, action) in &plan.closes {
755 assert!(label == z1 || label == z2);
756 assert_eq!(*action, CloseAction::CloseBrowser);
757 }
758 assert!(plan.begin_drain);
759 }
760
761 #[test]
762 fn plan_hostless_unpromoted_pool_unregisters_not_closes() {
763 // Hostless takes precedence over pool-state classification:
764 // an unpromoted-pool slot that lost its BrowserHost still
765 // needs UnregisterBrowser, not CloseBrowser.
766 let label = "window-pool-hostless-unpromoted";
767 let plan = plan_reconcile(
768 &map_of(&[(label, HwndStatus::Hostless)]),
769 &vec_of(&[label]),
770 &set_of(&[]),
771 &set_of(&[]),
772 &set_of(&[label]),
773 false,
774 );
775 assert_eq!(
776 plan.closes,
777 vec![(label.to_string(), CloseAction::UnregisterBrowser)]
778 );
779 assert!(plan.begin_drain);
780 }
781
782 #[test]
783 fn plan_mixed_full_state_space() {
784 // Composite touching every bucket: zombie + hostless +
785 // unpromoted pool + ready pool + freshly_promoted + promoted
786 // user window + pane.
787 let zombie = "window-pool-z";
788 let hostless = "window-pool-h";
789 let unpromoted = "window-pool-u";
790 let ready = "window-pool-r";
791 let fresh = "window-pool-f";
792 let promoted = "window-pool-p";
793 let pane = "browser-pane-x";
794 let plan = plan_reconcile(
795 &map_of(&[
796 (zombie, HwndStatus::Dead),
797 (hostless, HwndStatus::Hostless),
798 (unpromoted, HwndStatus::Live),
799 (ready, HwndStatus::Live),
800 (fresh, HwndStatus::Live),
801 (promoted, HwndStatus::Live),
802 // pane not in pool_browser_status — it's not pool-prefixed
803 ]),
804 &vec_of(&[zombie, hostless, unpromoted, ready, fresh, promoted, pane]),
805 &set_of(&[promoted]),
806 &set_of(&[ready]),
807 &set_of(&[unpromoted]),
808 false,
809 );
810 // freshly_promoted blocks drain → only Dead zombies close.
811 // Hostless is gated on safe_to_drain (outside drain,
812 // dispatching UnregisterBrowser bypasses pool-reducer
813 // bookkeeping; it waits for the next reconcile that can
814 // drain).
815 assert_eq!(plan.freshly_promoted, vec![fresh.to_string()]);
816 assert!(!plan.begin_drain);
817 let actions: HashMap<String, CloseAction> = plan
818 .closes
819 .iter()
820 .map(|(l, a)| (l.clone(), a.clone()))
821 .collect();
822 assert_eq!(actions.get(zombie), Some(&CloseAction::CloseBrowser));
823 assert!(!actions.contains_key(hostless), "hostless waits for drain mode");
824 assert!(!actions.contains_key(unpromoted), "unpromoted spared when drain blocked");
825 assert!(!actions.contains_key(ready), "ready spared when drain blocked");
826 assert!(!actions.contains_key(fresh), "freshly_promoted never closes");
827 assert!(!actions.contains_key(promoted), "promoted user window never closes");
828 assert!(!actions.contains_key(pane), "pane never in plan");
829 }
830
831 #[test]
832 fn plan_non_pool_zombie_closes_too() {
833 // A regular `main`/`window-X` top-level can crash. The
834 // launcher's apply_hwnd_destroyed emits HostShouldQuit; the
835 // reconciler must reap that zombie too, not just
836 // `window-pool-*` ones.
837 let main = "main";
838 let plan = plan_reconcile(
839 &map_of(&[(main, HwndStatus::Dead)]),
840 &vec_of(&[main]),
841 &set_of(&[]),
842 &set_of(&[]),
843 &set_of(&[]),
844 false,
845 );
846 assert_eq!(plan.closes, vec![(main.to_string(), CloseAction::CloseBrowser)]);
847 assert!(plan.begin_drain);
848 }
849
850 #[test]
851 fn plan_hostless_skipped_when_drain_blocked() {
852 // Hostless cleanup outside drain mode bypasses
853 // on_pool_window_destroyed and creates pool-reducer drift.
854 // When a live user window is present, the hostless entry
855 // must NOT be unregistered — wait for the next reconcile
856 // that can drain.
857 let user = "main";
858 let hostless = "window-pool-hostless";
859 let plan = plan_reconcile(
860 &map_of(&[
861 (user, HwndStatus::Live),
862 (hostless, HwndStatus::Hostless),
863 ]),
864 &vec_of(&[user, hostless]),
865 &set_of(&[user]),
866 &set_of(&[]),
867 &set_of(&[]),
868 false,
869 );
870 assert!(!plan.begin_drain);
871 assert!(plan.closes.is_empty(), "hostless must not close while drain blocked: {:?}", plan.closes);
872 }
873
874 #[test]
875 fn plan_dead_zombie_closes_even_when_drain_blocked() {
876 // Counterpart to the test above: Dead zombies always close,
877 // because close_browser(force=1) drives the host's own
878 // cleanup chain (on_before_close → on_pool_window_destroyed →
879 // UnregisterBrowser). Drain state is irrelevant.
880 let user = "main";
881 let zombie = "window-pool-zombie";
882 let plan = plan_reconcile(
883 &map_of(&[
884 (user, HwndStatus::Live),
885 (zombie, HwndStatus::Dead),
886 ]),
887 &vec_of(&[user, zombie]),
888 &set_of(&[user]),
889 &set_of(&[]),
890 &set_of(&[]),
891 false,
892 );
893 assert!(!plan.begin_drain);
894 assert_eq!(
895 plan.closes,
896 vec![(zombie.to_string(), CloseAction::CloseBrowser)]
897 );
898 }
899
900 #[test]
901 fn plan_mixed_hostless_and_closable_in_drain() {
902 // When a drain plan contains both Hostless AND CloseBrowser
903 // entries, the orchestrator must still drive
904 // quit_message_loop. The closables' on_before_close would
905 // normally satisfy Stage-2 quit, but the Hostless entries
906 // leave stale references in `client::browser_list`
907 // (UnregisterBrowser doesn't touch that), so Stage 2 never
908 // fires. The reconciler drives quit itself.
909 //
910 // Plan-level assertion: both actions are scheduled, drain
911 // fires. The quit_message_loop drive itself is exercised by
912 // the orchestrator and gated on `begin_drain && any_hostless`.
913 let dead = "window-pool-dead";
914 let hostless = "window-pool-hostless";
915 let plan = plan_reconcile(
916 &map_of(&[
917 (dead, HwndStatus::Dead),
918 (hostless, HwndStatus::Hostless),
919 ]),
920 &vec_of(&[dead, hostless]),
921 &set_of(&[]),
922 &set_of(&[]),
923 &set_of(&[]),
924 false,
925 );
926 assert!(plan.begin_drain);
927 let actions: HashMap<String, CloseAction> = plan
928 .closes
929 .iter()
930 .map(|(l, a)| (l.clone(), a.clone()))
931 .collect();
932 assert_eq!(actions.get(dead), Some(&CloseAction::CloseBrowser));
933 assert_eq!(actions.get(hostless), Some(&CloseAction::UnregisterBrowser));
934 }
935
936 #[test]
937 fn plan_pending_window_creation_blocks_drain() {
938 // A stale HostShouldQuit racing with `open_window_with_kind`
939 // can land in the gap between `PendingWindowCreation`
940 // enqueue and `post_create_window` registering the browser.
941 // In that gap the browser doesn't appear in any state, but
942 // a creation is in flight. Drain MUST be deferred — closing
943 // the warm pool here would let Stage-2 quit fire before the
944 // new browser registers, dropping it.
945 let ready = "window-pool-ready";
946 let plan = plan_reconcile(
947 &map_of(&[(ready, HwndStatus::Live)]),
948 &vec_of(&[ready]),
949 &set_of(&[]),
950 &set_of(&[ready]),
951 &set_of(&[]),
952 true, // pending creation in flight
953 );
954 assert!(!plan.begin_drain, "pending creation must block drain");
955 assert!(plan.closes.is_empty(), "ready warm pool must not close while creation pending: {:?}", plan.closes);
956 }
957
958 #[test]
959 fn plan_pending_creation_blocks_zombie_close() {
960 // Dead zombies normally close regardless of drain state, but
961 // when a creation is in flight, even the zombie's
962 // `on_before_close` cleanup chain can race the pending
963 // creation: if the zombie is the last browser, that chain
964 // dispatches `BeginDrain` and quit before the new window
965 // registers. Defer zombie reap until next `HostShouldQuit`.
966 let zombie = "window-pool-zombie";
967 let plan = plan_reconcile(
968 &map_of(&[(zombie, HwndStatus::Dead)]),
969 &vec_of(&[zombie]),
970 &set_of(&[]),
971 &set_of(&[]),
972 &set_of(&[]),
973 true,
974 );
975 assert!(!plan.begin_drain);
976 assert!(plan.closes.is_empty(), "zombie close must defer when creation pending: {:?}", plan.closes);
977 }
978
979 #[test]
980 fn plan_idempotent_under_repeat() {
981 let label = "window-pool-zombie";
982 let inputs = (
983 map_of(&[(label, HwndStatus::Dead)]),
984 vec_of(&[label]),
985 set_of(&[]),
986 set_of(&[]),
987 set_of(&[]),
988 );
989 let p1 = plan_reconcile(&inputs.0, &inputs.1, &inputs.2, &inputs.3, &inputs.4, false);
990 let p2 = plan_reconcile(&inputs.0, &inputs.1, &inputs.2, &inputs.3, &inputs.4, false);
991 assert_eq!(p1, p2);
992 }
993
994}