agentmux_cef\commands/window_pool.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Tear-off Phase 6 — pre-warmed window pool.
5//
6// Spec §0 makes a 0 ms first-paint flash mandatory. The cold path
7// (open_window_at_position → wait for HWND register → SC_MOVE)
8// has an inherent 150-300 ms gap while CEF spawns the renderer
9// process and paints first frame. Pre-spawning hidden windows
10// eliminates that gap: on tear-off we POP an already-painted
11// window from the pool, reposition it under the cursor, show it,
12// and emit `pool:promote` so the renderer bootstraps the
13// workspace in-place (no reload, no renderer restart).
14//
15// Pool windows live with URL `?pool=1`; the frontend init flow
16// detects this and defers `initHostNewWindow()` until the
17// `pool:promote` event arrives with a workspace ID.
18//
19// Sizing: N=2. One window is "next destination," the other is
20// "buffer while respawn completes." With N=1 a back-to-back
21// tear-off would cold-path. With N>2 the RAM cost outweighs the
22// rare-second-tearoff benefit.
23//
24// Lifecycle:
25// - App startup → spawn N pool windows after primary first-paint.
26// - On tear-off → pop, reposition, show, emit promote, enqueue
27// refill. Refills are serialised (single in-flight) so a burst
28// of tear-offs can't spawn unbounded windows.
29// - App shutdown → pool windows close cleanly with the rest of
30// the process.
31
32use std::sync::Arc;
33use std::sync::Mutex;
34use std::collections::HashMap;
35
36use crate::state::{AppState, WindowKind, WindowMeta};
37
38/// HWND cache for pool windows. Populated at `on_after_created`
39/// (register_pool_window) and consulted at `promote_pool_window` as
40/// the source of truth — `BrowserHost::window_handle()` returns null
41/// once the page loads even though the underlying Win32 window is
42/// alive (verified by `IsWindow` — see
43/// `SPEC_POOL_WINDOW_HWND_NULL_2026_05_06.md` §4.1 diagnostic run).
44///
45/// Entries are removed on pool-window destruction
46/// (`on_pool_window_destroyed`) so the map can't leak across the
47/// process lifetime. The HWND is stored as `usize` so this state is
48/// `Send + Sync` without `unsafe`; callers cast back to `HWND` /
49/// `*mut c_void` at use site.
50#[cfg(target_os = "windows")]
51static POOL_HWND_CACHE: std::sync::OnceLock<Mutex<HashMap<String, usize>>> =
52 std::sync::OnceLock::new();
53
54#[cfg(target_os = "windows")]
55fn pool_hwnd_cache() -> &'static Mutex<HashMap<String, usize>> {
56 POOL_HWND_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
57}
58
59/// Target pool size. See module-level comment for rationale.
60pub const POOL_TARGET_SIZE: usize = 2;
61
62/// Pool windows are spawned at this off-screen position so they
63/// don't appear on the user's desktop while pre-painting. On
64/// promote they're moved to the cursor and shown.
65const POOL_OFFSCREEN_X: i32 = -32000;
66const POOL_OFFSCREEN_Y: i32 = -32000;
67const POOL_WIDTH: i32 = 1200;
68const POOL_HEIGHT: i32 = 800;
69/// Pixels above the cursor where the title bar sits — matches
70/// open_window_at_position so the cursor lands near the top-center
71/// of the title bar after promotion.
72const TITLE_BAR_OFFSET_PX: i32 = 16;
73
74// Tab-anchor placement: PR #730 hardcoded FIRST_TAB_INSET_X /
75// TAB_STRIP_TOP_OFFSET_PX as best-effort window-chrome offsets.
76// Smoke on v0.33.704 showed they were inaccurate (new window's
77// first tab not landing where the dragged tab was). The frontend
78// now measures the source window's chrome dynamically and computes
79// the new window's outer top-left position itself; backend just
80// uses the supplied anchor verbatim. The constants are gone.
81
82/// Spawn a single pool window. Called at startup (N times) and
83/// after each promote (1 refill). Idempotent against the
84/// in-flight semaphore — concurrent calls collapse to one spawn
85/// in flight at a time.
86pub fn spawn_pool_window(state: &Arc<AppState>) {
87 // Phase B.9.3 — if the host has decided to quit (last user-
88 // visible window closed, draining pool), skip refill. Without
89 // this guard, every pool close triggers a refill, keeping
90 // state.browsers non-empty forever and quit_message_loop's
91 // QuitWhenIdle never reaches idle.
92 // PR #5 H.5 — early-out before dispatching to the reducer. The
93 // reducer's PoolWindowSpawnStart arm ALSO checks `quit_state !=
94 // Running` and would no-op, but the early-out keeps the warn-log
95 // shape identical to pre-PR for diagnostic continuity.
96 if state.is_quitting() {
97 tracing::warn!(
98 target: "wrr",
99 "[wrr] spawn_pool_window skipped — quit_state != Running (drain mode)"
100 );
101 return;
102 }
103
104 // PR #6 H.7 — refuse pool refill while any pane is mid-close. Pool
105 // windows are CEF top-levels just like user-visible ones; the v146
106 // deadlock fires regardless of whether the new window is on-screen.
107 // See `commands/window.rs::open_window_with_kind` for rationale.
108 if state.any_browser_pane_closing() {
109 tracing::warn!(
110 target: "wfr:gate",
111 "[wfr:gate] spawn_pool_window deferred — pane is mid-close (H.7 invariant)"
112 );
113 return;
114 }
115
116 // PR #6 codex P1 — capacity check. The semaphore (PoolWindowSpawnStart)
117 // only single-flights; it does not enforce the target size. Without
118 // this guard, the new H.7 always-on-pane-close kick (in
119 // `BrowserPaneManager::close` / `drain_closed_label`) would add a
120 // pool window on every pane close once no spawn is in flight, growing
121 // `pool.queue` indefinitely past `POOL_TARGET_SIZE`. The legacy
122 // callers (`mark_pool_window_renderer_ready`,
123 // `on_pool_window_destroyed`) already gate on the same check; this
124 // moves it inside spawn_pool_window so every entry point is covered.
125 if state.pool_queue_size() >= POOL_TARGET_SIZE {
126 tracing::debug!(
127 target: "dnd:tearoff:pool",
128 current = %state.pool_queue_size(),
129 target = %POOL_TARGET_SIZE,
130 "[pool] spawn skipped — pool already at target size"
131 );
132 return;
133 }
134
135 let window_id = uuid::Uuid::new_v4();
136 // Use the `window-pool-` prefix so existing `is_instance_label`
137 // checks (tear_off_hook.rs, app-init.ts) pass naturally — they
138 // accept anything starting with `window-`. After promotion the
139 // label stays the same; the reducer's `pool.unpromoted` is the
140 // authoritative pool-vs-promoted distinction (cleared on
141 // promote — `pool.queue` is populated only after the
142 // renderer-ready handshake, so it's not reliable as the
143 // distinguisher during the ~100ms spawn → ready gap).
144 let label = format!("window-pool-{}", window_id.simple());
145
146 // PR #5 H.4 — atomic single-flight + label-into-unpromoted via the
147 // reducer. `pool_spawn_proceeding=false` means another spawn was
148 // already in flight (or quit_state != Running) and we should skip
149 // — the in-flight spawn will catch up to TARGET_SIZE.
150 let dispatch = state.host_dispatch(
151 crate::reducer::HostCommand::PoolWindowSpawnStart { label: label.clone() },
152 );
153 if !dispatch.pool_spawn_proceeding {
154 tracing::debug!(
155 target: "dnd:tearoff:pool",
156 "[pool] spawn skipped — respawn already in flight or pool draining"
157 );
158 return;
159 }
160
161 // Phase B.4 follow-up — mirror the pool inventory in the launcher
162 // and check pool drift. We use the pool-only variant
163 // (`report_host_pool_count`) rather than the full
164 // `report_host_counts` because `spawn_pool_window` is invoked
165 // from the refill chain inside `on_pool_window_destroyed`, which
166 // runs during `on_before_close` BEFORE the matching
167 // `ReportWindowClosed` is sent for the closing window. A
168 // full-counts snapshot at this moment would see browsers
169 // shrunk (closing window already removed) but the launcher
170 // mirror still holding it (close not yet reported), producing
171 // transient false windows-drift on every normal
172 // promoted-window close that triggers a refill. Pool count IS
173 // stable at this moment (the new label was just added), so
174 // checking pool alone preserves the "check every transition"
175 // guarantee for the dimension that actually changed. (codex
176 // P2 PR #578 rounds 2 + 3.)
177 crate::launcher_ipc::report_pool_window_added(label.clone());
178 {
179 // Pool inventory (unpromoted ∪ queue), not unpromoted-only:
180 // the launcher's `state.pool` mirror is built from
181 // ReportPoolWindowAdded/Removed/Promoted events; the host's
182 // unpromoted→queue transition emits NO event, so the
183 // launcher retains queued labels in its pool set. Reporting
184 // unpromoted.len() under-counts and triggers spurious pool
185 // drift while a warm slot is queued. Atomic snapshot —
186 // single host_state lock.
187 let pool_count = {
188 let st = state.host_state.lock();
189 (st.pool.unpromoted.len() + st.pool.queue.len()) as u32
190 };
191 crate::launcher_ipc::report_host_pool_count(pool_count);
192 }
193
194 let ipc_port = *state.ipc_port.lock();
195 let ipc_token = &state.ipc_token;
196 let base_url = super::window::resolve_frontend_base_url(ipc_port);
197 let separator = if base_url.contains('?') { "&" } else { "?" };
198 // The `pool=1` flag tells the frontend to skip its standard
199 // workspace init and wait for a `pool:promote` event.
200 let url = format!(
201 "{}{}ipc_port={}&ipc_token={}&windowLabel={}&pool=1",
202 base_url, separator, ipc_port, ipc_token, label
203 );
204
205 // Phase B.5 (window_meta step d) — combined pre-create handoff.
206 // Pool windows graduate to tear-off destinations, which are
207 // FullInstance from the user's perspective.
208 //
209 // Phase F.1 — routed through the host reducer.
210 state.host_dispatch(
211 crate::reducer::HostCommand::EnqueuePendingWindowCreation {
212 entry: crate::state::PendingWindowCreation {
213 label: label.clone(),
214 kind: WindowKind::FullInstance,
215 parent_instance_id: None,
216 },
217 },
218 );
219
220 tracing::info!(
221 target: "dnd:tearoff:pool",
222 label = %label,
223 "[pool] spawning pool window"
224 );
225
226 // Spawn at off-screen coords. The window is technically
227 // visible (frameless) but well outside any monitor bounds, so
228 // the user never sees it; CEF still paints it because Windows
229 // considers it a normal HWND.
230 crate::ui_tasks::post_create_window(
231 state,
232 &url,
233 &label,
234 POOL_OFFSCREEN_X,
235 POOL_OFFSCREEN_Y,
236 POOL_WIDTH,
237 POOL_HEIGHT,
238 true,
239 );
240
241 // The window registers in `state.browsers` via on_after_created
242 // asynchronously. We don't add to `window_pool` here — the
243 // register completion handler does that (see register_pool_window
244 // below) so a window only enters the pool after it's actually
245 // alive.
246}
247
248/// Called from on_after_created when a pool window's browser is
249/// registered. Logs + applies WS_EX_TOOLWINDOW so the off-screen
250/// pool window doesn't show up in the taskbar / Alt+Tab. The
251/// promote path (`promote_pool_window`) clears it again so the
252/// torn-off window IS taskbar-visible.
253///
254/// Queue insertion still waits for `mark_pool_window_renderer_ready`
255/// (frontend-side handshake) — without that gate emit_event_to_window
256/// could race the renderer's listener install and drop the promote
257/// signal.
258pub fn register_pool_window(state: &Arc<AppState>, label: &str) {
259 if !label.starts_with("window-pool-") {
260 return;
261 }
262 #[cfg(target_os = "windows")]
263 {
264 // Look up the HWND under a short browsers lock; release
265 // before any Win32 FFI. The HWND should exist by the time
266 // on_after_created fires; if it doesn't, log and bail —
267 // the pool entry is harmless until promote re-checks.
268 use cef::{ImplBrowser, ImplBrowserHost};
269 // Phase H.2.b — reducer-aware lookup with fallback.
270 let raw_hwnd: Option<*mut std::ffi::c_void> = state
271 .get_browser(label)
272 .and_then(|browser| {
273 browser.host().and_then(|host| {
274 let h = host.window_handle();
275 if h.0.is_null() {
276 None
277 } else {
278 Some(h.0 as *mut std::ffi::c_void)
279 }
280 })
281 });
282 if let Some(hwnd) = raw_hwnd {
283 // Cache the HWND for promote-time use. CEF's
284 // `BrowserHost::window_handle()` returns null after the
285 // page loads (verified diagnostic run 2026-05-06), but
286 // the underlying Win32 window is still alive. The cache
287 // is the only reliable source for the HWND at promote.
288 pool_hwnd_cache()
289 .lock()
290 .unwrap()
291 .insert(label.to_string(), hwnd as usize);
292 set_taskbar_hidden(hwnd, true);
293 } else {
294 tracing::warn!(
295 target: "dnd:tearoff:pool",
296 label = %label,
297 "[pool] HWND null at register time — taskbar hide skipped, cache not populated"
298 );
299 }
300 }
301 tracing::debug!(
302 target: "dnd:tearoff:pool",
303 label = %label,
304 "[pool] browser registered, awaiting renderer-ready signal"
305 );
306}
307
308/// Toggle WS_EX_TOOLWINDOW on a window's extended style so it
309/// appears (or doesn't) in the taskbar / Alt+Tab. We use this so
310/// pre-warmed pool windows stay invisible to the user, then
311/// re-enter the taskbar when promoted to a real torn-off window.
312///
313/// Per Win32 docs, changing the ex-style after creation only
314/// updates the taskbar reliably if the window is hidden + reshown.
315/// We do that hide/show cycle here with SWP_FRAMECHANGED so
316/// the change takes effect even if SW_HIDE was already implicit.
317#[cfg(target_os = "windows")]
318fn set_taskbar_hidden(hwnd: *mut std::ffi::c_void, hidden: bool) {
319 unsafe {
320 use windows_sys::Win32::UI::WindowsAndMessaging::{
321 GetWindowLongPtrW, SetWindowLongPtrW, SetWindowPos, ShowWindow,
322 GWL_EXSTYLE, SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE,
323 SWP_NOZORDER, SW_HIDE, SW_SHOWNA, WS_EX_APPWINDOW, WS_EX_TOOLWINDOW,
324 };
325 let mut ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
326 if hidden {
327 ex |= WS_EX_TOOLWINDOW as isize;
328 ex &= !(WS_EX_APPWINDOW as isize);
329 } else {
330 ex &= !(WS_EX_TOOLWINDOW as isize);
331 ex |= WS_EX_APPWINDOW as isize;
332 }
333 // Hide → write style → show forces the shell to re-evaluate
334 // the taskbar entry. Without the hide/show pair Windows often
335 // keeps the original taskbar state on style change.
336 let _ = ShowWindow(hwnd, SW_HIDE);
337 SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex);
338 let _ = SetWindowPos(
339 hwnd,
340 std::ptr::null_mut(),
341 0, 0, 0, 0,
342 SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED,
343 );
344 // Don't re-show pool windows — they should stay hidden until
345 // promote. Re-show only when transitioning OUT of toolwindow.
346 if !hidden {
347 let _ = ShowWindow(hwnd, SW_SHOWNA);
348 }
349 }
350}
351
352/// Called when a pool window is destroyed before it ever became
353/// renderer-ready (renderer crash mid-init, user closed at OS level,
354/// etc.). Without this clearing path the respawn semaphore would
355/// stay locked forever and the pool would never refill.
356pub fn on_pool_window_destroyed(state: &Arc<AppState>, label: &str) {
357 if !label.starts_with("window-pool-") {
358 return;
359 }
360 // Drop the cached HWND so the map can't grow unbounded across the
361 // process lifetime. Idempotent — fine if the entry isn't present
362 // (e.g. a window destroyed before register_pool_window populated
363 // the cache).
364 #[cfg(target_os = "windows")]
365 {
366 pool_hwnd_cache().lock().unwrap().remove(label);
367 }
368 // PR #5 H.4 — atomic remove-from-{unpromoted,queue} + clear
369 // respawn semaphore via reducer. The dispatch returns:
370 // - `pool_destroyed_was_unpromoted`: distinguishes pre-promote
371 // death (this fn owns the launcher mirror update) from
372 // post-promote close (`on_before_close`'s window-close path
373 // owns it). Without this gate, post-promote closes would
374 // fire `ReportHostCounts` here (browsers already shrunk,
375 // mirror hasn't seen the matching `ReportWindowClosed` yet),
376 // causing a guaranteed transient windows-drift alert on
377 // every normal promoted-window close. (codex P2 PR #578 round-1.)
378 // - `pool_size_after`: queue length after removal — caller
379 // decides refill against POOL_TARGET_SIZE.
380 let dispatch = state.host_dispatch(
381 crate::reducer::HostCommand::PoolWindowDestroyedBeforePromote {
382 label: label.to_string(),
383 },
384 );
385
386 if dispatch.pool_destroyed_was_unpromoted {
387 crate::launcher_ipc::report_pool_window_removed(label.to_string());
388 crate::launcher_ipc::compute_and_report_host_counts(state);
389 }
390
391 let needs_refill = dispatch
392 .pool_size_after
393 .map(|n| n < POOL_TARGET_SIZE)
394 .unwrap_or(false);
395 tracing::warn!(
396 target: "dnd:tearoff:pool",
397 label = %label,
398 "[pool] pool window destroyed before promote — releasing semaphore + refilling"
399 );
400 if needs_refill {
401 spawn_pool_window(state);
402 }
403}
404
405/// Called from the `pool_window_ready` IPC handler — fired by the
406/// frontend's awaitPoolPromote AFTER its `pool:promote` listener
407/// is installed. NOW it's safe to enqueue this window for
408/// promotion.
409pub fn mark_pool_window_renderer_ready(state: &Arc<AppState>, label: &str) {
410 if !label.starts_with("window-pool-") {
411 return;
412 }
413
414 // PR #5 H.4 — atomic move-from-unpromoted-to-queue + clear respawn
415 // semaphore via reducer. Idempotent against duplicate frontend
416 // signals (re-mount, hot reload). `pool_size_after` is the queue
417 // length after the move; caller refills if below target.
418 let dispatch = state.host_dispatch(
419 crate::reducer::HostCommand::PoolWindowReady { label: label.to_string() },
420 );
421 let pool_size = dispatch.pool_size_after.unwrap_or(0);
422
423 tracing::info!(
424 target: "dnd:tearoff:pool",
425 label = %label,
426 pool_size = %pool_size,
427 "[pool] pool window renderer ready, enqueued"
428 );
429
430 if pool_size < POOL_TARGET_SIZE {
431 spawn_pool_window(state);
432 }
433}
434
435/// Initialize the pool after primary-window first paint. Spawns
436/// `POOL_TARGET_SIZE` windows. Called once per app run from
437/// `on_after_created` for the "main" label.
438///
439/// Windows-only: promote_pool_window is a no-op on non-Windows
440/// platforms (Phase 7 will add equivalents). Spawning hidden pool
441/// windows that can never be consumed would just waste renderer
442/// processes, so we skip the whole init off-Win32.
443pub fn init_pool(state: &Arc<AppState>) {
444 #[cfg(not(target_os = "windows"))]
445 {
446 let _ = state;
447 return;
448 }
449 #[cfg(target_os = "windows")]
450 {
451 // PR #5 H.4 — read pool size via reducer-aware helper.
452 let current = state.pool_queue_size();
453 if current >= POOL_TARGET_SIZE {
454 return;
455 }
456 // First spawn — the rest are kicked off chain-style by
457 // register_pool_window when each new pool window registers.
458 // This sequencing keeps spawns serialised (one CEF window at
459 // a time) and avoids spawn pressure spikes at startup.
460 spawn_pool_window(state);
461 }
462}
463
464/// Promote a pool window for tear-off. Pops a label, sends a
465/// move-and-show task to the CEF UI thread, and emits
466/// `pool:promote` to the renderer with the workspace ID. Returns
467/// the promoted window's label so the caller can chain SC_MOVE
468/// against it. Returns None if the pool is empty (caller should
469/// fall back to the cold path).
470///
471/// Called from the IPC handler `tear_off_pool_promote`.
472#[cfg(target_os = "windows")]
473pub fn promote_pool_window(
474 state: &Arc<AppState>,
475 workspace_id: &str,
476 screen_x: i32,
477 screen_y: i32,
478 width: Option<i32>,
479 height: Option<i32>,
480 tab_anchor_x: Option<i32>,
481 tab_anchor_y: Option<i32>,
482) -> Option<String> {
483 // PR #5 H.4 — atomic pop+remove via reducer. The dispatch pops
484 // the front of the pool queue, removes the label from
485 // unpromoted, and clears `is_pool` on the corresponding
486 // BrowserHandle, all under one host_state lock. Returns None if
487 // the queue is empty (cold-path fallback).
488 let dispatch = state.host_dispatch(
489 crate::reducer::HostCommand::PopAndPromoteFrontPoolWindow,
490 );
491 let label = dispatch.promoted_pool_label?;
492
493 // Phase B.4 follow-up — pool inventory shrinks unconditionally on
494 // pop. The user-visible WindowOpened report is deferred until
495 // after HWND validation succeeds (codex P1 PR #577 round-1):
496 // emitting it before validation would record a `WindowOpened`
497 // for a label that may never become a real visible window in
498 // the failure path (HWND lookup returns None, function early-
499 // returns after refill), permanently desyncing the mirror.
500 crate::launcher_ipc::report_pool_window_removed(label.clone());
501
502 tracing::info!(
503 target: "dnd:tearoff:pool",
504 label = %label,
505 workspace_id = %workspace_id,
506 screen_x = %screen_x,
507 screen_y = %screen_y,
508 "[pool] promoting pool window"
509 );
510
511 // Resolve the HWND under a SHORT lock — drop the browsers mutex
512 // before any Win32 call so we don't hold a global state lock
513 // across FFI into the OS UI subsystem.
514 //
515 // Each None-returning step is a state-inconsistency bug, not an
516 // expected failure — log per-step at ERROR so an operator can
517 // tell which invariant broke.
518 use cef::{ImplBrowser, ImplBrowserHost};
519 use windows_sys::Win32::Foundation::HWND;
520 use windows_sys::Win32::UI::WindowsAndMessaging::IsWindow;
521 // Resolve the HWND. CEF's `BrowserHost::window_handle()` returns
522 // null after the page loads on Views-based browsers, even though
523 // the underlying Win32 window is alive (verified 2026-05-06,
524 // SPEC_POOL_WINDOW_HWND_NULL_2026_05_06.md). Use the cache we
525 // populated at `register_pool_window`; the CEF path is kept as a
526 // first-try in case some future CEF version starts returning the
527 // HWND consistently again.
528 let raw_hwnd: Option<*mut std::ffi::c_void> = match state.get_browser(&label) {
529 None => {
530 tracing::error!(
531 target: "dnd:tearoff:pool",
532 label = %label,
533 "[pool] promoted label not in browsers map (state inconsistency)"
534 );
535 None
536 }
537 Some(browser) => match browser.host() {
538 None => {
539 tracing::error!(
540 target: "dnd:tearoff:pool",
541 label = %label,
542 "[pool] browser has no host (state inconsistency)"
543 );
544 None
545 }
546 Some(host) => {
547 let cef_hwnd = host.window_handle().0;
548 if !cef_hwnd.is_null() {
549 Some(cef_hwnd as *mut std::ffi::c_void)
550 } else {
551 // CEF lost the reference — fall back to cache.
552 let cached = pool_hwnd_cache().lock().unwrap().get(&label).copied();
553 match cached {
554 None => {
555 tracing::error!(
556 target: "dnd:tearoff:pool",
557 label = %label,
558 "[pool] CEF HWND null AND no cache entry (state inconsistency)"
559 );
560 None
561 }
562 Some(h) => {
563 // Verify the cached HWND is still a live
564 // OS window. If the OS has reclaimed it,
565 // the slot is genuinely dead; refuse the
566 // promote and fall back to cold-path.
567 let alive = unsafe { IsWindow(h as HWND) } != 0;
568 if alive {
569 tracing::debug!(
570 target: "dnd:tearoff:pool",
571 label = %label,
572 hwnd = format!("0x{:x}", h),
573 "[pool] using cached HWND (CEF returned null)"
574 );
575 Some(h as *mut std::ffi::c_void)
576 } else {
577 tracing::error!(
578 target: "dnd:tearoff:pool",
579 label = %label,
580 hwnd = format!("0x{:x}", h),
581 "[pool] cached HWND no longer a live window"
582 );
583 None
584 }
585 }
586 }
587 }
588 }
589 },
590 };
591
592 // Pool-slot leak guard: if HWND lookup fails after we've already
593 // popped the label, capacity permanently shrinks unless we refill.
594 //
595 // Orphan cleanup (B.5c smoke test caught this): the popped label is
596 // still in `state.browsers` but is no longer in `unpromoted_pool_labels`
597 // (we removed it at the top of this fn) and never became a real
598 // user-visible window (`report_window_opened` is gated on the
599 // post-validation success path). Without explicit cleanup the
600 // host's `compute_and_report_host_counts` filter
601 // (`browsers - panes - unpromoted`) counts the orphan as a window,
602 // producing persistent windows-drift against the launcher mirror
603 // (which correctly never received a `WindowOpened` for it).
604 //
605 // `cleanup_failed_promote_orphan` is responsible for ALL recovery
606 // including pool refill — see its contract. We deliberately do NOT
607 // call `spawn_pool_window` here since the cleanup helper either
608 // (a) issues `close_browser` → `on_before_close` → `on_pool_window_destroyed`
609 // already triggers refill, or (b) does direct cleanup + refill
610 // itself. Calling refill from both paths produces double refill.
611 // (codex P1 PR #582 round-1.)
612 let raw_hwnd = match raw_hwnd {
613 Some(h) => h,
614 None => {
615 cleanup_failed_promote_orphan(state, &label);
616 return None;
617 }
618 };
619
620 // Phase F.5 — explicit promote signal sent BETWEEN the matching
621 // `report_pool_window_removed` (above) and `report_window_opened`
622 // (next). The launcher's pool-respawn saga starts on the
623 // resulting `Event::PoolWindowPromoted` and bracket the
624 // subsequent refill in `SagaStarted`/`SagaCompleted` so the
625 // renderer can buffer "you got a tear-off + the pool is
626 // refilling" atomically. Sent only on the validated-HWND path
627 // (mirrors `report_window_opened`'s contract); pre-promote
628 // destroy paths emit only `report_pool_window_removed` with no
629 // promote signal so the saga doesn't fire on non-promote drains.
630 crate::launcher_ipc::report_pool_window_promoted(label.clone());
631
632 // HWND validated — the label IS becoming a real user-visible
633 // window. NOW report the open to the launcher mirror so a
634 // failure path above can't leave the mirror with a phantom
635 // entry. (codex P1 PR #577 round-1.)
636 crate::launcher_ipc::report_window_opened(
637 label.clone(),
638 agentmux_common::ipc::WindowKind::FullInstance,
639 None,
640 );
641
642 // PR #664 codex P2 — explicit AUTHORITATIVE HWND link for
643 // promoted pool windows. Pool windows skip the explicit
644 // report_hwnd_opened branch in `client.rs::on_after_created`
645 // (gated on `!label.starts_with("window-pool-")`) because their
646 // initial registration happens before promotion. The launcher's
647 // drain-on-WindowOpened fallback (in `handle_report_window_opened`)
648 // would only link a recent pending HWND if one happened to be in
649 // the 2s window — pre-promote pool windows are usually older than
650 // that, leaving the mirror permanently hwnd=None. This explicit
651 // link guarantees the mirror tracks the HWND so WRR
652 // visibility/foreground/orphan-destroy drift detection works for
653 // every torn-off window. The accompanying repair logic in
654 // `apply_hwnd_opened` corrects any wrong drain-pick.
655 crate::launcher_ipc::report_hwnd_opened(
656 raw_hwnd as u64,
657 "Chrome_WidgetWin_1".to_string(),
658 label.clone(),
659 Some(label.clone()),
660 );
661
662 // Phase B.4 follow-up — drift check after the atomic
663 // pool→windows transition.
664 crate::launcher_ipc::compute_and_report_host_counts(state);
665
666 // Compute position outside the unsafe block — these are pure
667 // arithmetic, no FFI needed. Don't clamp with .max(0): Windows'
668 // virtual screen space is signed (secondary monitors to the left
669 // of or above the primary have negative coords), and clamping
670 // would push tear-offs onto the primary monitor when the user
671 // grabbed from a secondary.
672 // Use the source window's dimensions when provided (tear-off
673 // UX: new window matches the frame the user dragged from). Fall
674 // back to the pool default otherwise.
675 //
676 // DPI conversion (codex P2 / reagent P1 PR #727): the frontend
677 // sends `window.outerWidth/Height` in CSS/DIP pixels but Win32
678 // `SetWindowPos` expects PHYSICAL pixels. Use the DESTINATION
679 // monitor's DPI (the one under cursor at the drop point), NOT
680 // the pool HWND's current monitor — pool windows live at
681 // POOL_OFFSCREEN_X/Y which is typically the primary monitor, so
682 // on mixed-DPI multi-monitor the pool HWND's DPI doesn't match
683 // the user's actual drop target.
684 let dpi_scale: f32 = unsafe {
685 use windows_sys::Win32::Foundation::POINT;
686 use windows_sys::Win32::Graphics::Gdi::{
687 MonitorFromPoint, MONITOR_DEFAULTTONEAREST,
688 };
689 use windows_sys::Win32::UI::HiDpi::{GetDpiForMonitor, MDT_EFFECTIVE_DPI};
690 let pt = POINT { x: screen_x, y: screen_y };
691 let monitor = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST);
692 let mut dpi_x: u32 = 0;
693 let mut dpi_y: u32 = 0;
694 let hr = GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, &mut dpi_x, &mut dpi_y);
695 if hr != 0 || dpi_x == 0 { 1.0 } else { dpi_x as f32 / 96.0 }
696 };
697 let to_physical = |dip: i32| -> i32 { (dip as f32 * dpi_scale).round() as i32 };
698 let win_w_dip = width.unwrap_or(POOL_WIDTH);
699 let win_h_dip = height.unwrap_or(POOL_HEIGHT);
700 let win_w = if width.is_some() { to_physical(win_w_dip) } else { win_w_dip };
701 let win_h = if height.is_some() { to_physical(win_h_dip) } else { win_h_dip };
702
703 // Position the new window. With tab anchor (frontend captured the
704 // grab offset within the source tab and converted to a screen
705 // point): place the new window's first tab top-left at the anchor
706 // so the cursor stays on the same visual element across the
707 // handoff. Without anchor: cursor-centered title bar (legacy).
708 //
709 // Position units (codex P1 PR #730 round 1): the anchor and
710 // screen_x/y arrive in the SAME unit as the legacy `screen_x -
711 // win_w / 2` math (the unit CEF reports for `window.screenX`).
712 // The inset constants (`FIRST_TAB_INSET_X`, `TAB_STRIP_TOP_OFFSET_PX`)
713 // are LOGICAL/DIP pixels, but they're SMALL (8 / 16) and the
714 // cold-path drag.rs uses them raw too — converting them here in
715 // ONE path would make the warm and cold paths land at different
716 // offsets on HiDPI. Keep both paths consistent by using the
717 // constants raw. The width/height conversion above is independent
718 // and addresses the SetWindowPos-wants-physical-pixels constraint
719 // for SIZE; that conversion stays.
720 // No `.max(0)` clamp on the anchor branch (codex P2 PR #730 round
721 // 2): on multi-monitor setups where a secondary display is to the
722 // left of or above the primary, screen coords can legitimately be
723 // negative, and clamping to 0 would yank the window back onto the
724 // primary monitor. The legacy fallback also doesn't clamp.
725 //
726 // Anchor semantics (refined post-PR #730 smoke): tab_anchor_{x,y}
727 // is now the OUTER TOP-LEFT of the new window, not the screen
728 // position of the grabbed tab. Frontend computes
729 // anchor = cursor_screen - grab_offset - source_chrome_inset
730 // so its hardcoded chrome inset (was FIRST_TAB_INSET_X /
731 // TAB_STRIP_TOP_OFFSET_PX) is gone — frontend measures the source
732 // window's actual chrome dynamically. Backend just places the
733 // window at anchor with no further offset.
734 let (pos_x, pos_y) = match (tab_anchor_x, tab_anchor_y) {
735 (Some(ax), Some(ay)) => (ax, ay),
736 _ => (
737 screen_x - win_w / 2,
738 screen_y - TITLE_BAR_OFFSET_PX,
739 ),
740 };
741
742 // Take the window out of WS_EX_TOOLWINDOW so the promoted window
743 // appears in the taskbar / Alt+Tab like any other AgentMux
744 // instance. Must run BEFORE the position/show below; otherwise
745 // the taskbar entry won't appear until the next style refresh.
746 set_taskbar_hidden(raw_hwnd, false);
747
748 // Reposition + raise to top + show. SWP_NOZORDER is intentionally
749 // *not* set — for tear-off we need the new window at the top of
750 // the Z-order so the subsequent SC_MOVE handshake routes the
751 // mouse-capture correctly. With SWP_NOZORDER set, HWND_TOP would
752 // be silently ignored.
753 unsafe {
754 use windows_sys::Win32::UI::WindowsAndMessaging::{
755 SetWindowPos, ShowWindow, HWND_TOP, SW_SHOW,
756 };
757 let pos_ok = SetWindowPos(
758 raw_hwnd,
759 HWND_TOP,
760 pos_x,
761 pos_y,
762 win_w,
763 win_h,
764 0, // no flags — apply move + size + Z-order all
765 );
766 if pos_ok == 0 {
767 // Non-fatal: the SC_MOVE handshake will still try to
768 // run, but the user may see a misplaced window. Log
769 // for diagnostics so we can detect a pattern.
770 let err = windows_sys::Win32::Foundation::GetLastError();
771 tracing::error!(
772 target: "dnd:tearoff:pool",
773 label = %label,
774 last_err = %err,
775 "[pool] SetWindowPos failed"
776 );
777 }
778 let _ = ShowWindow(raw_hwnd, SW_SHOW);
779 }
780
781 // Phase B.7.3.3 — the launcher's typed events drive the
782 // InstancePanel atoms via the CEF JS bridge. No sync emit here.
783
784 // Now tell the pool window's renderer to bootstrap the workspace.
785 crate::events::emit_event_to_window(
786 state,
787 &label,
788 "pool:promote",
789 &serde_json::json!({
790 "workspaceId": workspace_id,
791 }),
792 );
793
794 // Refill the pool in the background.
795 spawn_pool_window(state);
796
797 Some(label)
798}
799
800/// Phase B.5c follow-up — clean up an orphan pool window left behind
801/// when `promote_pool_window`'s HWND validation fails. Without this,
802/// the popped label sits in `state.browsers` but is no longer in
803/// `unpromoted_pool_labels` (promote removed it) and never became a
804/// real user window (no `WindowOpened` was reported). Host's
805/// `compute_and_report_host_counts` then counts it as a window, while
806/// the launcher mirror correctly does not — persistent off-by-one
807/// drift. (Caught by B.4b drift detection during B.5c smoke test on
808/// v0.33.461.)
809///
810/// Contract: this fn is responsible for ALL recovery including pool
811/// refill. The caller MUST NOT call `spawn_pool_window` itself —
812/// double refill would overshoot `POOL_TARGET_SIZE` and waste
813/// renderer capacity. (codex P1 PR #582 round-1.)
814///
815/// Two paths:
816///
817/// * **Graceful path** (browser+host alive in CEF): issue
818/// `close_browser(1)` and let CEF's `on_before_close` fire. That
819/// path runs the standard cleanup chain — drops from
820/// `state.browsers` + `window_meta`, calls
821/// `on_pool_window_destroyed` (which itself triggers refill via
822/// `spawn_pool_window` when pool size is below target), and
823/// triggers `compute_and_report_host_counts` from `client.rs`.
824/// * **Direct path** (browser or host already gone): `on_before_close`
825/// won't fire so we do its job inline — drop from browsers +
826/// window_meta, send `report_window_closed` (silent no-op in
827/// launcher reducer for an unknown label), spawn the refill
828/// ourselves, and emit a drift count snapshot so the orphan's
829/// removal is observable on the same tick. (reagent P2 PR #582
830/// round-1 — original direct path skipped the snapshot.)
831#[cfg(target_os = "windows")]
832fn cleanup_failed_promote_orphan(state: &Arc<AppState>, label: &str) {
833 use cef::{ImplBrowser, ImplBrowserHost};
834 // Phase H.2.b — reducer-aware lookup with fallback.
835 let mut browser_clone = state.get_browser(label);
836 if let Some(ref mut browser) = browser_clone {
837 if let Some(host) = browser.host() {
838 // force_close = 1: don't run beforeunload, we know
839 // this window never reached a useful state.
840 host.close_browser(1);
841 tracing::info!(
842 target: "dnd:tearoff:pool",
843 label = %label,
844 "[pool] orphan close_browser issued — on_before_close will run cleanup + refill"
845 );
846 return;
847 }
848 }
849 // Browser or host already gone — do `on_before_close`'s job
850 // inline since CEF won't fire it for this label.
851 // Phase H.2.d — legacy `state.browsers.lock().remove` removed;
852 // reducer's UnregisterBrowser is sole canonical mutation site.
853 state.host_dispatch(
854 crate::reducer::HostCommand::UnregisterBrowser {
855 label: label.to_string(),
856 },
857 );
858 state.window_meta.lock().remove(label);
859 crate::launcher_ipc::report_window_closed(label.to_string());
860 // Refill (graceful path gets this via on_pool_window_destroyed).
861 spawn_pool_window(state);
862 // Emit a count snapshot now so the orphan's removal is
863 // observable in the launcher's drift stream on the same tick.
864 crate::launcher_ipc::compute_and_report_host_counts(state);
865 tracing::warn!(
866 target: "dnd:tearoff:pool",
867 label = %label,
868 "[pool] orphan browser already gone — cleaned host state directly + refilled"
869 );
870}
871
872#[cfg(not(target_os = "windows"))]
873pub fn promote_pool_window(
874 _state: &Arc<AppState>,
875 _workspace_id: &str,
876 _screen_x: i32,
877 _screen_y: i32,
878 _width: Option<i32>,
879 _height: Option<i32>,
880 _tab_anchor_x: Option<i32>,
881 _tab_anchor_y: Option<i32>,
882) -> Option<String> {
883 // Non-Windows: pool isn't built yet (Phase 7). Caller falls
884 // back to the cold path.
885 None
886}