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}