agentmux_launcher/ipc/server.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Named-pipe IPC server — accept loop + per-connection handler.
5//
6// Phase B.3: every Command goes through the pure reducer
7// (`crate::reducer::update`) which mutates the shared State and
8// returns a Vec<Event>. The handler then writes those events back
9// over the connection. State is held inside Arc<Mutex<State>> and
10// the mutex is acquired only for the duration of the reducer call —
11// never across an await.
12//
13// What this commit does NOT do:
14// * Per-subscriber broadcast routing (B.4 splits replies vs broadcasts;
15// today every event goes back over the originating connection).
16// * Server-initiated events (no spontaneous emissions yet — only
17// reducer outputs).
18// * Persisted client_id (still per-launcher-run).
19//
20// Connection lifecycle: each accepted pipe instance handles one
21// client connection end-to-end. When the client drops, the per-
22// connection task ends and the accept loop continues with a fresh
23// instance.
24
25use std::sync::Arc;
26
27use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
28use tokio::sync::Mutex;
29
30#[cfg(target_os = "windows")]
31use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions};
32
33use agentmux_common::ipc::{ClientKind, Command, ErrorCode, Event};
34
35use crate::host_pipe::HostPipe;
36use crate::reducer;
37use crate::state::State;
38
39/// State the IPC server shares across connections. Carries the
40/// launcher's identity (for patching into Registered events the
41/// reducer emits with sentinel values) plus the canonical State.
42#[derive(Debug)]
43pub struct ServerCtx {
44 pub launcher_pid: u32,
45 pub launcher_version: String,
46 /// Canonical state owned by the server. Mutex held only during
47 /// reducer dispatch — sub-millisecond.
48 ///
49 /// Phase E.1a — moved from `Mutex<State>` to `Arc<Mutex<State>>`
50 /// so the saga coordinator can share access. The coordinator
51 /// needs `bump_version` when emitting saga lifecycle events and
52 /// will (in E.5) inspect state during saga decisions. Sharing
53 /// via `Arc` keeps the existing single-writer-mutex discipline.
54 pub state: std::sync::Arc<Mutex<State>>,
55 /// Phase B.8 — broadcast bus for reducer-emitted events. Every
56 /// reducer event from `reducer::update` is published here; each
57 /// connection subscribes and writes received events to its own
58 /// pipe. Lets observability clients (`--diag wrr`, future Tools)
59 /// see cross-process activity, not just replies to their own
60 /// commands. Per-connection direct sends (Error replies for
61 /// parse failures, register-first violations) bypass the bus —
62 /// they're response-to-this-client-only by intent. (codex P1
63 /// PR #605.)
64 pub events_tx: tokio::sync::broadcast::Sender<Event>,
65 /// Phase D.2 — event log: in-memory ring of recent reducer
66 /// events (replay source for D.3's `GetEvents`) + disk
67 /// persistence stream for crash forensics. Server appends to
68 /// the in-memory ring synchronously after each reducer
69 /// dispatch; the disk writer is a separate task spawned in
70 /// main.rs that subscribes to the broadcast bus.
71 pub event_log: std::sync::Arc<crate::event_log::EventLog>,
72 /// CPD-2 — launcher → host pipe wrapper. The per-connection
73 /// handler hands the host's writer half to `HostPipe::set_writer`
74 /// once the connecting client registers as `ClientKind::Host`,
75 /// and clears it on disconnect. The host's per-connection event
76 /// fanout task routes events through `HostPipe::send_event`
77 /// instead of `send_event` direct (so frames carry the
78 /// `HostFrame` envelope and traverse the pending-buffer path
79 /// when the host reconnects).
80 pub host_pipe: std::sync::Arc<HostPipe>,
81 /// Startup-stage telemetry sink — `ReportStartupStageBegin`/`End`
82 /// commands from the host are forwarded here (bypassing the
83 /// reducer entirely, see the match arm in `handle_connection`) so
84 /// they flow into the same splash-panel display as the launcher's
85 /// own internal stages. `None` when the splash is disabled or on
86 /// platforms/paths that don't yet wire a sink through (mirrors the
87 /// existing `Option<StartupEventSink>` plumbing in unix.rs/windows.rs).
88 pub startup_sink: Option<crate::startup_events::StartupEventSink>,
89}
90
91/// Bind the first named-pipe instance synchronously.
92///
93/// Phase B.6: the bind is the single-instance signal. Splitting it
94/// out of `run_ipc_server` lets the caller (main.rs) detect a
95/// collision BEFORE spawning srv/host and surface a user-visible
96/// error ("AgentMux is already running"). `ServerOptions::create`
97/// requires a Tokio runtime context for IOCP registration, so this
98/// must be called from inside `#[tokio::main]` (or any task on the
99/// runtime) — not from a plain sync entrypoint.
100///
101/// On Windows, a second launcher hitting the same pipe gets
102/// `ERROR_ACCESS_DENIED` (raw OS error 5); other errors mean the
103/// pipe namespace itself is misconfigured.
104#[cfg(target_os = "windows")]
105pub fn bind_first_pipe_instance(pipe_name: &str) -> std::io::Result<NamedPipeServer> {
106 ServerOptions::new()
107 .first_pipe_instance(true)
108 .create(pipe_name)
109}
110
111/// Run the named-pipe IPC server until cancelled (or task panics).
112///
113/// Returns a JoinHandle the caller (main.rs) holds for the life of
114/// the launcher. The server keeps accepting until the launcher's
115/// Tokio runtime shuts down.
116///
117/// The first pipe instance is passed in pre-bound by the caller (see
118/// `bind_first_pipe_instance`) so a collision can be surfaced
119/// synchronously before any children are spawned (Phase B.6).
120///
121/// Each accepted connection becomes a new tokio task running
122/// `handle_connection`. The accept loop creates a fresh
123/// `NamedPipeServer` instance for the next client BEFORE spawning
124/// the handler — without this, a slow handler could starve the next
125/// connect. Standard Win32 named-pipe pattern.
126#[cfg(target_os = "windows")]
127pub fn run_ipc_server(
128 pipe_name: String,
129 first: NamedPipeServer,
130 ctx: ServerCtx,
131) -> tokio::task::JoinHandle<()> {
132 tokio::spawn(async move {
133 let ctx = Arc::new(ctx);
134 crate::log(&format!("[ipc] server starting on {}", pipe_name));
135
136 let mut current = first;
137
138 loop {
139 // Wait for a client to connect to the existing instance.
140 // On error: log + recreate the instance + retry. Without
141 // the explicit `continue`, the failed (un-connected) pipe
142 // instance below would be moved into `accepted` and
143 // spawned in a handler that immediately fails to read,
144 // wasting a per-connection task slot. (reagent P1 + codex
145 // P1 PR #573 round-1.)
146 if let Err(e) = current.connect().await {
147 crate::log(&format!("[ipc] connect failed: {} — recreating instance", e));
148 current = match ServerOptions::new().create(&pipe_name) {
149 Ok(s) => s,
150 Err(create_err) => {
151 crate::log(&format!(
152 "[ipc] FATAL: failed to recreate pipe after connect error: {} (server stopping)",
153 create_err
154 ));
155 return;
156 }
157 };
158 continue;
159 }
160
161 // Hand the accepted instance to a handler task, then
162 // create the NEXT server instance so the next client
163 // doesn't have to wait for the handler to finish.
164 let accepted = current;
165 current = match ServerOptions::new().create(&pipe_name) {
166 Ok(s) => s,
167 Err(e) => {
168 crate::log(&format!(
169 "[ipc] FATAL: failed to create next pipe instance: {} (server stopping)",
170 e
171 ));
172 // Drain the accepted client, then bail.
173 tokio::spawn(handle_connection(accepted, Arc::clone(&ctx)));
174 return;
175 }
176 };
177
178 tokio::spawn(handle_connection(accepted, Arc::clone(&ctx)));
179 }
180 })
181}
182
183/// Unix counterpart of `bind_first_pipe_instance`: bind a Unix
184/// domain socket. The bind is the single-instance signal — a second
185/// launcher pointing at the same socket path gets `EADDRINUSE`. Caller
186/// is responsible for unlinking a stale socket file first (see the
187/// `connect → ECONNREFUSED → unlink → bind` pattern in `main.rs::run_unix`).
188///
189/// A1.1 of SPEC_LAUNCHER_LINUX_PACKAGED_AND_SPLASH_2026_06_05.
190#[cfg(unix)]
191pub fn bind_first_unix_socket(socket_path: &str) -> std::io::Result<tokio::net::UnixListener> {
192 tokio::net::UnixListener::bind(socket_path)
193}
194
195/// Run the Unix-domain-socket IPC server until cancelled (or task panics).
196///
197/// Mirrors the Windows accept loop above. The protocol on the wire is
198/// identical (newline-delimited JSON `Command` / `Event`) so
199/// `handle_connection` is shared between platforms via generics.
200///
201/// The listener is passed in pre-bound by the caller so a collision
202/// (a second launcher pointing at the same data dir) can be surfaced
203/// synchronously before any children spawn. (Same Phase B.6 contract
204/// as the Windows path.)
205#[cfg(unix)]
206pub fn run_ipc_server(
207 socket_path: String,
208 listener: tokio::net::UnixListener,
209 ctx: ServerCtx,
210) -> tokio::task::JoinHandle<()> {
211 tokio::spawn(async move {
212 let ctx = Arc::new(ctx);
213 crate::log(&format!("[ipc] server starting on {}", socket_path));
214
215 // Backoff state — guard against a persistent accept error
216 // (e.g. EMFILE/ENFILE under fd exhaustion, EBADF if the
217 // listener fd is closed out from under us) spinning a hot
218 // loop and flooding the launcher log. Reagent P2 on PR #1288.
219 const MAX_CONSECUTIVE_ACCEPT_ERRORS: u32 = 32;
220 let mut consecutive_errors: u32 = 0;
221
222 loop {
223 match listener.accept().await {
224 Ok((stream, _addr)) => {
225 consecutive_errors = 0;
226 tokio::spawn(handle_connection(stream, Arc::clone(&ctx)));
227 }
228 Err(e) => {
229 consecutive_errors = consecutive_errors.saturating_add(1);
230 if consecutive_errors >= MAX_CONSECUTIVE_ACCEPT_ERRORS {
231 crate::log(&format!(
232 "[ipc] FATAL: {} consecutive accept errors (last: {}); listener appears permanently broken — stopping IPC server",
233 consecutive_errors, e
234 ));
235 return;
236 }
237 // Exponential backoff capped at 1s: 1ms → 2ms → 4ms
238 // → … → 1024ms → 1024ms. Keeps EMFILE/ENFILE
239 // recovery responsive (most descriptor pressure
240 // clears in microseconds) without hot-spinning a
241 // CPU core on a truly broken listener.
242 let backoff_ms = 1u64 << consecutive_errors.min(10);
243 let backoff = std::time::Duration::from_millis(backoff_ms.min(1024));
244 crate::log(&format!(
245 "[ipc] accept error #{}: {} — backing off {:?}",
246 consecutive_errors, e, backoff
247 ));
248 tokio::time::sleep(backoff).await;
249 }
250 }
251 }
252 })
253}
254
255/// Drive one connection: read newline-delimited JSON Commands,
256/// write back JSON Events. First message MUST be `Register`.
257///
258/// Phase B.3: every Command goes through `reducer::update`. The
259/// reducer is sync; we hold the state mutex only while it runs.
260/// Events come back from the reducer as Vec<Event>; we patch sentinel
261/// fields (Registered.launcher_pid / launcher_version — the reducer
262/// can't read those) and publish them on the broadcast bus.
263///
264/// Phase B.8 — events from the reducer flow through the broadcast
265/// bus (`ctx.events_tx`); a per-connection fanout task subscribes
266/// and writes events to this connection's pipe. Per-connection
267/// direct writes are reserved for "response-to-this-client-only"
268/// errors (parse failure, register-first violation). (codex P1
269/// PR #605.)
270// (gate removed — platform-neutral body, accessible from cfg(unix) too — A1.1)
271//
272// Generic over the duplex stream type so the same body serves Windows
273// `NamedPipeServer` and Unix `tokio::net::UnixStream` without code
274// duplication. Bounds are what `tokio::io::split` + `Box<dyn AsyncWrite>`
275// need: AsyncRead + AsyncWrite + Unpin + Send + 'static.
276async fn handle_connection<S>(stream: S, ctx: Arc<ServerCtx>)
277where
278 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
279{
280 let (read_half, write_half) = tokio::io::split(stream);
281 // CPD-2 — wrap the writer in the HostPipe-compatible
282 // `Arc<Mutex<Box<dyn AsyncWrite + Unpin + Send>>>` shape so the
283 // SAME writer is reachable by:
284 // 1. The per-connection main loop (connection-private error
285 // replies via `send_event_shared`).
286 // 2. The per-connection fanout task (broadcast-bus events).
287 // 3. (For the host connection only) `HostPipe::send_command` /
288 // `HostPipe::send_event` after the host registers.
289 // The Mutex serializes writes, so frames can't interleave.
290 let boxed: crate::host_pipe::BoxedWriter = Box::new(write_half);
291 let writer: crate::host_pipe::SharedWriter = crate::host_pipe::make_shared_writer(boxed);
292 let reader = BufReader::new(read_half);
293 let mut lines = reader.lines();
294
295 // Per-connection state the server (not reducer) tracks: have we
296 // seen a Register yet? Reducer-level dedup is keyed by PID across
297 // all connections; this is the per-connection enforcement so a
298 // single connection can't send Ping before Register.
299 let mut registered_kind: Option<ClientKind> = None;
300 let mut registered_pid: Option<u32> = None;
301 // Connection ID is server-allocated (not state-allocated) and
302 // exists only for log correlation; the reducer-allocated
303 // client_id (returned in Registered) is the wire-visible one.
304 let conn_id = NEXT_CONN_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
305
306 // Phase B.8 — fanout task: subscribe to the server-wide event
307 // bus and write each event to THIS connection's pipe. Started
308 // before any commands are processed so a registered client can
309 // start receiving events from concurrent activity immediately.
310 // Aborted when the connection's read loop returns (below).
311 //
312 // CPD-2 design (round 4): events are written DIRECTLY to this
313 // connection's per-connection writer — NOT routed through
314 // `HostPipe`. HostPipe exists for *commands* (saga-issued
315 // launcher → host actions, which CPD-3 will wire). Events stay
316 // on the existing direct-write path because:
317 // 1. Wire format compat — host's parser expects raw `Event`
318 // JSON, not `HostFrame::Event` envelopes (codex P1 round 3).
319 // CPD-3 will update host's parser AND swap the fanout to
320 // HostFrame envelopes together.
321 // 2. No need for HostPipe's pending-buffer / 30s-timeout
322 // semantics on events: events are broadcast-driven, every
323 // subscriber gets them, and stale events post-reconnect
324 // would be wrong anyway.
325 // 3. Each per-connection fanout writes to its OWN writer —
326 // no global-writer race / stale-fanout issue (which is
327 // what `host_session_id` would have guarded against, but
328 // isn't needed when fanouts are connection-local).
329 let fanout_handle = {
330 let writer = Arc::clone(&writer);
331 let ctx = Arc::clone(&ctx);
332 let mut events_rx = ctx.events_tx.subscribe();
333 tokio::spawn(async move {
334 loop {
335 match events_rx.recv().await {
336 Ok(event) => {
337 // Identity is patched at the publisher (before
338 // log + bus). No need to re-patch here.
339 // Errors here mean the pipe is closed; the
340 // read loop will detect EOF on the next
341 // iteration. Swallow + continue to drain
342 // any remaining buffered events so we don't
343 // accidentally hold onto channel slots.
344 if send_event_shared(&writer, event).await.is_err() {
345 return;
346 }
347 }
348 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
349 // Slow client missed `n` events. Phase D's
350 // GetSnapshot resync covers this case
351 // properly; for now the client has to
352 // reconnect to recover. Log so operators
353 // can see when this happens.
354 crate::log(&format!(
355 "[ipc] conn_id={} lagged event bus, missed {} events",
356 conn_id, n
357 ));
358 }
359 Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
360 }
361 }
362 })
363 };
364
365 loop {
366 let line = match lines.next_line().await {
367 Ok(Some(l)) => l,
368 Ok(None) => {
369 crate::log(&format!(
370 "[ipc] connection conn_id={} closed (kind={:?}, pid={:?})",
371 conn_id, registered_kind, registered_pid
372 ));
373 // CPD-2 — clear HostPipe writer if this was the host
374 // connection so subsequent saga commands buffer
375 // (up to 64) until the host reconnects (or fail at
376 // the 30s timeout). Idempotent if not the host.
377 if matches!(registered_kind, Some(ClientKind::Host)) {
378 ctx.host_pipe.clear_writer().await;
379 }
380 // Phase E.1b — synthetic Goodbye on ungraceful
381 // disconnect so the reducer marks the PID Exited;
382 // otherwise reconnect-from-same-PID hits
383 // AlreadyRegistered. (codex P1 #610.)
384 dispatch_synthetic_goodbye(&ctx, conn_id, registered_pid).await;
385 fanout_handle.abort();
386 return;
387 }
388 Err(e) => {
389 crate::log(&format!(
390 "[ipc] read error conn_id={}: {}",
391 conn_id, e
392 ));
393 if matches!(registered_kind, Some(ClientKind::Host)) {
394 ctx.host_pipe.clear_writer().await;
395 }
396 dispatch_synthetic_goodbye(&ctx, conn_id, registered_pid).await;
397 fanout_handle.abort();
398 return;
399 }
400 };
401
402 if line.trim().is_empty() {
403 continue;
404 }
405
406 let cmd = match serde_json::from_str::<Command>(&line) {
407 Ok(c) => c,
408 Err(e) => {
409 // Phase E.1b — parse errors are connection-private
410 // (sent only to the offender, not broadcast, not
411 // appended to the event log). Don't bump the global
412 // event_version: other subscribers would see version
413 // gaps and treat them as missed events. Use 0 as a
414 // sentinel for "not part of the ordered stream."
415 // (codex P2 #610.)
416 let _ = send_event_shared(
417 &writer,
418 Event::Error {
419 code: ErrorCode::InvalidCommand,
420 message: format!("parse failed: {}", e),
421 fatal: false,
422 version: 0,
423 },
424 )
425 .await;
426 continue;
427 }
428 };
429
430 // Per-connection invariants: enforce here so reducer doesn't
431 // need to know about connection identity. Honor the `fatal`
432 // bit — Ping-before-Register is non-fatal (clients can
433 // recover by sending Register next), Goodbye-before-Register
434 // is fatal (can't recover from a closed-by-them connection).
435 // (reagent P1 + codex P1 PR #574 round-1.)
436 if let Some(reply) = enforce_register_first(&cmd, ®istered_kind).await {
437 let close = matches!(&reply, Event::Error { fatal: true, .. });
438 let _ = send_event_shared(&writer, reply).await;
439 if close {
440 fanout_handle.abort();
441 return;
442 }
443 continue;
444 }
445 if let Command::Register { .. } = &cmd {
446 if registered_kind.is_some() {
447 // Phase E.1b — connection-private error; same
448 // version-sentinel rationale as parse-error path
449 // above. (codex P2 #610.)
450 let _ = send_event_shared(
451 &writer,
452 Event::Error {
453 code: ErrorCode::AlreadyRegistered,
454 message: "Register sent twice on the same connection".into(),
455 fatal: false,
456 version: 0,
457 },
458 )
459 .await;
460 continue;
461 }
462 }
463
464 // Track local registration before dispatch so we can update
465 // our per-connection state if the reducer accepts it. The
466 // reducer's PID-uniqueness check might reject the Register;
467 // we re-check the events for that case below.
468 let pre_register = if let Command::Register { kind, pid, .. } = &cmd {
469 Some((*kind, *pid))
470 } else {
471 None
472 };
473
474 // Phase D.3 — `GetEvents { since }` is handled here, not in
475 // the reducer. The reducer is pure (no I/O); querying the
476 // event log is a non-mutating read against the in-memory
477 // ring + disk fallback.
478 //
479 // Phase E.1b — the reply (`Event::EventList`) is sent
480 // DIRECTLY to the requesting connection, NOT broadcast on
481 // the shared bus. EventList is request/response, not a
482 // state transition; broadcasting it would force every
483 // subscriber to process foreign replay payloads
484 // (potentially treating them as their own catch-up data,
485 // duplicating state application). (codex P1 #610.)
486 if let Command::GetEvents { since } = &cmd {
487 // Phase E.1b — read the current version WITHOUT bumping
488 // (codex P2 #610). EventList is connection-private; the
489 // version it carries is the "as-of" point for the
490 // requester's next resync, not a new state-transition
491 // marker. Bumping would create a global gap that other
492 // subscribers see as missed events.
493 let v = {
494 let state = ctx.state.lock().await;
495 state.event_version
496 };
497 let replay = ctx.event_log.events_since(*since);
498 // Don't log truncation as an error — it's a valid
499 // "subscriber missed events that have already been
500 // evicted" signal; the subscriber's resync logic
501 // handles it (re-fetch a fresh snapshot).
502 if ctx.event_log.replay_truncated(*since) {
503 crate::log(&format!(
504 "[ipc] conn_id={} GetEvents since={} truncated (oldest retained event > since+1)",
505 conn_id, since
506 ));
507 }
508 let _ = send_event_shared(
509 &writer,
510 Event::EventList {
511 events: replay,
512 version: v,
513 },
514 )
515 .await;
516 continue;
517 }
518
519 // Startup-stage telemetry — forwarded directly into the
520 // launcher's StartupEventSink (same short-circuit-before-the-
521 // reducer pattern as GetEvents above). Not mirrored State, not
522 // broadcast: this is a side-channel purely for the splash
523 // panel, ephemeral for the life of the process. No-op if
524 // startup_sink is None (splash disabled, or a code path that
525 // doesn't wire a sink through).
526 match &cmd {
527 Command::ReportStartupStageBegin { stage, label } => {
528 if let Some(sink) = &ctx.startup_sink {
529 // StartupEvent::StageBegin's fields are `&'static str` —
530 // every existing caller passes a compile-time constant.
531 // The host's stage/label strings are runtime JSON
532 // values, so leak them to get a 'static lifetime rather
533 // than widening the shared StartupEvent type (which
534 // Windows' splash.rs and Linux's splash_linux/ also
535 // consume, and neither can be built/verified here).
536 // Bounded: a handful of small strings per process
537 // lifetime (one launch's worth of stages), not a loop.
538 let stage: &'static str = Box::leak(stage.clone().into_boxed_str());
539 let label: &'static str = Box::leak(label.clone().into_boxed_str());
540 sink.stage_begin(stage, label);
541 }
542 continue;
543 }
544 Command::ReportStartupStageEnd { stage, duration_ms, status, detail } => {
545 if let Some(sink) = &ctx.startup_sink {
546 let stage: &'static str = Box::leak(stage.clone().into_boxed_str());
547 let status = match status.as_str() {
548 "warn" => crate::startup_events::StartupStatus::Warn,
549 "error" => crate::startup_events::StartupStatus::Error,
550 _ => crate::startup_events::StartupStatus::Ok,
551 };
552 sink.stage_end(stage, *duration_ms, status, detail.clone());
553 }
554 continue;
555 }
556 _ => {}
557 }
558
559 // Dispatch through the reducer. Mutex held briefly — compute
560 // the timestamp BEFORE acquiring so syscalls + string
561 // formatting don't show up in lock-hold time.
562 let now_rfc3339 = chrono::Utc::now().to_rfc3339();
563 // Phase B.9.1 — monotonic ms since launcher start. Used by
564 // the WRR arm for per-window observability ages.
565 // `LAUNCHER_START_INSTANT` is a once-init `Instant`; the
566 // first request seeds it, subsequent ones read its delta.
567 let now_ms = launcher_start_ms();
568 // SPEC_LAUNCHER_TEARDOWN_BACKSTOP Phase 1 — record UI-liveness
569 // telemetry at the transport layer, before the reducer (whose arm
570 // for this variant is a deliberate no-op: this is host-thread
571 // telemetry, not domain state). Latency logged only for a
572 // nonce-matched reply; any reply updates `last_alive`.
573 if let Command::ReportUiThreadAlive { nonce } = &cmd {
574 match crate::ui_liveness::record_alive(*nonce) {
575 Some(rtt) => crate::logging::log(&format!(
576 "[ui-liveness] UI thread alive — probe nonce={} rtt={}ms",
577 nonce,
578 rtt.as_millis()
579 )),
580 None => crate::logging::log(&format!(
581 "[ui-liveness] UI thread alive — unmatched/late reply nonce={}",
582 nonce
583 )),
584 }
585 }
586 let events = {
587 let mut state = ctx.state.lock().await;
588 let rctx = reducer::Ctx {
589 now_rfc3339,
590 conn_id,
591 registered_pid,
592 now_ms,
593 };
594 reducer::update(&mut state, cmd.clone(), &rctx)
595 };
596
597 // SPEC_LAUNCHER_TEARDOWN_BACKSTOP Phase 2 — arm/disarm the J0
598 // teardown state machine from the VALIDATED reducer output (same
599 // layering as the Phase-1 `ReportUiThreadAlive` intercept above:
600 // process-supervision state about the host, not domain state the
601 // reducer owns — hooking the emitted events keeps the reducer pure
602 // while still only reacting to reports the reducer accepted).
603 //
604 // Arm: `PoolDrained` (host's own "last user window closed, drain
605 // begins" report) or `OrphanInstance` drift (mirror saw the last
606 // user window close with the host still Running). Disarm: any
607 // `WindowOpened` (covers crash-reproject re-opening windows within
608 // the grace, and ordinary re-opens). The supervisor additionally
609 // disarms on every host exit (the crash-restart-gap guard).
610 for ev in &events {
611 match ev {
612 Event::PoolDrained { label, .. } => {
613 if crate::teardown_backstop::arm() {
614 crate::logging::log(&format!(
615 "[teardown-backstop] ARMED (PoolDrained, label={}) — grace {}s, then ≥{} unanswered probes ⇒ teardown",
616 label,
617 crate::teardown_backstop::TEARDOWN_GRACE.as_secs(),
618 crate::teardown_backstop::TEARDOWN_REQUIRED_MISSES,
619 ));
620 }
621 }
622 Event::HwndDriftDetected {
623 kind: agentmux_common::ipc::HwndDriftKind::OrphanInstance,
624 ..
625 } => {
626 if crate::teardown_backstop::arm() {
627 crate::logging::log(
628 "[teardown-backstop] ARMED (OrphanInstance drift — last user window closed, host still alive)",
629 );
630 }
631 }
632 Event::WindowOpened { label, .. } => {
633 if crate::teardown_backstop::disarm() {
634 crate::logging::log(&format!(
635 "[teardown-backstop] disarmed — user window opened (label={})",
636 label
637 ));
638 }
639 }
640 _ => {}
641 }
642 }
643
644 // If the reducer accepted the Register (no AlreadyRegistered
645 // error in the output), commit the local connection state.
646 if let Some((kind, pid)) = pre_register {
647 let rejected = events
648 .iter()
649 .any(|e| matches!(e, Event::Error { code: ErrorCode::AlreadyRegistered, .. }));
650 if !rejected {
651 registered_kind = Some(kind);
652 registered_pid = Some(pid);
653 // CPD-2 — host registration: install this connection's
654 // writer into the launcher's HostPipe wrapper and flip
655 // the fanout task to route through it. Subsequent
656 // events for this connection traverse
657 // `HostPipe::send_event` (HostFrame::Event envelope +
658 // pending-buffer-on-disconnect semantics) instead of
659 // the legacy direct write. Drains any frames buffered
660 // since the prior host disconnect (FIFO).
661 if kind == ClientKind::Host {
662 // Install the host's writer half into HostPipe so
663 // saga-issued commands (CPD-3+) can be transmitted
664 // and so any pending Command frames buffered during
665 // a prior host disconnect drain in FIFO order.
666 // Returns a session_id we don't need today (events
667 // bypass HostPipe — see fanout task above), but the
668 // counter is in place for future code that does.
669 let session = ctx
670 .host_pipe
671 .set_writer(std::sync::Arc::clone(&writer))
672 .await;
673 crate::log(&format!(
674 "[ipc] conn_id={} host registered (session={}) — HostPipe writer installed",
675 conn_id, session
676 ));
677 }
678 }
679 }
680
681 // Phase B.8 — publish reducer events on the broadcast bus
682 // instead of writing them directly to this connection. The
683 // per-connection fanout task (spawned above) subscribes and
684 // writes them to its own pipe — including this connection,
685 // which sees its own events back. Drift events still log at
686 // the launcher level so operators see them regardless of
687 // subscriber wiring. (codex P1 PR #605.)
688 let goodbye = matches!(cmd, Command::Goodbye);
689 for event in events {
690 if let Event::DriftDetected {
691 kind,
692 host_count,
693 mirror_count,
694 ..
695 } = &event
696 {
697 crate::log(&format!(
698 "[ipc] DRIFT {:?}: host={} mirror={} (conn_id={})",
699 kind, host_count, mirror_count, conn_id
700 ));
701 }
702 if let Event::HwndDriftDetected {
703 kind,
704 label,
705 hwnd,
706 detail,
707 severity,
708 ..
709 } = &event
710 {
711 crate::log(&format!(
712 "[ipc] WRR-DRIFT [{:?}] {:?} label={:?} hwnd={:?}: {} (conn_id={})",
713 severity, kind, label, hwnd, detail, conn_id
714 ));
715 }
716 // Phase E.1a (codex P2 #608) — patch sentinel identity
717 // BEFORE appending to the log. Reducer emits
718 // `Event::Registered { launcher_pid: 0, launcher_version: "" }`
719 // because it doesn't know the launcher's identity; the
720 // server fills it in. Pre-fix, the patch happened only at
721 // per-connection write, so `GetEvents` replay returned
722 // stored sentinels, inconsistent with live broadcast.
723 let event = patch_launcher_identity(event, &ctx);
724
725 // Phase D.2 — append to the in-memory ring BEFORE
726 // broadcasting so a connection's GetEvents query that
727 // races a just-published event sees consistent
728 // results. Disk persistence (separate task) is best-
729 // effort and may lag. Snapshot / EventList variants
730 // are NOT appended — they're meta-events about the
731 // event stream itself; including them would create
732 // recursive replay (an EventList containing EventLists
733 // is meaningless). Errors are also skipped — they're
734 // per-client diagnostics, not state transitions.
735 if !matches!(event, Event::Snapshot { .. } | Event::EventList { .. } | Event::Error { .. }) {
736 ctx.event_log.append(event.clone());
737 }
738 // Send may fail when no receivers exist (e.g., during
739 // shutdown). That's fine — events are advisory in that
740 // window and the per-connection fanout tasks own retry
741 // semantics via subscribe().
742 let _ = ctx.events_tx.send(event);
743 }
744 if goodbye {
745 crate::log(&format!(
746 "[ipc] goodbye from conn_id={} kind={:?} pid={:?}",
747 conn_id, registered_kind, registered_pid
748 ));
749 // CPD-2 — clear HostPipe writer on graceful goodbye too
750 // so a host that re-registers via a fresh connection can
751 // re-install cleanly. Idempotent if not host.
752 if matches!(registered_kind, Some(ClientKind::Host)) {
753 ctx.host_pipe.clear_writer().await;
754 }
755 fanout_handle.abort();
756 return;
757 }
758 }
759}
760
761/// Per-connection counter for log-correlation IDs (NOT the wire
762/// client_id — that comes from the reducer). Allocated even for
763/// pre-Register failures so log lines can be correlated.
764static NEXT_CONN_ID: std::sync::atomic::AtomicU64 =
765 std::sync::atomic::AtomicU64::new(1);
766
767/// Phase E.1b — synthetic Goodbye dispatch for ungraceful disconnects
768/// (EOF / read error before the client sent an explicit Goodbye).
769/// Without this, the reducer's process record stays Running and a
770/// reconnect from the same live PID hits AlreadyRegistered.
771/// (codex P1 #610.)
772// (gate removed — platform-neutral body, accessible from cfg(unix) too — A1.1)
773async fn dispatch_synthetic_goodbye(
774 ctx: &Arc<ServerCtx>,
775 conn_id: u64,
776 registered_pid: Option<u32>,
777) {
778 let Some(pid) = registered_pid else {
779 return;
780 };
781 let now_rfc3339 = chrono::Utc::now().to_rfc3339();
782 let now_ms = launcher_start_ms();
783 let events = {
784 let mut state = ctx.state.lock().await;
785 let rctx = reducer::Ctx {
786 now_rfc3339,
787 conn_id,
788 registered_pid: Some(pid),
789 now_ms,
790 };
791 reducer::update(&mut state, Command::Goodbye, &rctx)
792 };
793 for event in events {
794 let event = patch_launcher_identity(event, ctx);
795 if !matches!(
796 event,
797 Event::Snapshot { .. } | Event::EventList { .. } | Event::Error { .. }
798 ) {
799 ctx.event_log.append(event.clone());
800 }
801 let _ = ctx.events_tx.send(event);
802 }
803}
804
805/// Phase B.9.1 — milliseconds since the launcher's IPC server
806/// started (first call seeds the epoch). Used as the monotonic
807/// clock for WRR observability timestamps in `reducer::Ctx::now_ms`.
808/// Distinct from `chrono::Utc::now()` because the WRR arm wants
809/// elapsed time, not wall clock — and we don't want clock-skew
810/// jitter (NTP adjustment, DST) showing up as drift.
811fn launcher_start_ms() -> u64 {
812 static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
813 let start = START.get_or_init(std::time::Instant::now);
814 start.elapsed().as_millis() as u64
815}
816
817/// Enforce the "first message must be Register" invariant. Returns
818/// `Some(Event::Error)` if the command violates the contract; the
819/// caller sends it and closes the connection.
820// (gate removed — platform-neutral body, accessible from cfg(unix) too — A1.1)
821async fn enforce_register_first(
822 cmd: &Command,
823 registered_kind: &Option<ClientKind>,
824) -> Option<Event> {
825 // F.7 cleanup audit: prior signature accepted `ctx: &Arc<ServerCtx>`
826 // for symmetry with neighboring helpers, but no body site read it.
827 // Dropped to silence the unused-variable warning without an allow.
828 if registered_kind.is_some() {
829 return None;
830 }
831 let (msg, fatal) = match cmd {
832 Command::Register { .. } => return None,
833 Command::Ping { .. } => ("Ping before Register".to_string(), false),
834 // Liveness telemetry arriving pre-Register is harmless timing skew
835 // (host answered a probe before its Register round-trip completed);
836 // non-fatal, same treatment as Ping.
837 Command::ReportUiThreadAlive { .. } => {
838 ("ReportUiThreadAlive before Register".to_string(), false)
839 }
840 Command::ProbeUiThread { .. } => {
841 ("ProbeUiThread before Register (wrong direction)".to_string(), false)
842 }
843 Command::NotifySrvLatency { .. } => {
844 ("NotifySrvLatency before Register (wrong direction)".to_string(), false)
845 }
846 Command::Goodbye => ("Goodbye before Register".to_string(), true),
847 Command::ReportWindowOpened { .. } => {
848 ("ReportWindowOpened before Register".to_string(), true)
849 }
850 Command::ReportBackgroundServiceEnabled { .. } => {
851 ("ReportBackgroundServiceEnabled before Register".to_string(), true)
852 }
853 Command::ReportWindowClosed { .. } => {
854 ("ReportWindowClosed before Register".to_string(), true)
855 }
856 Command::ReportPoolWindowAdded { .. } => {
857 ("ReportPoolWindowAdded before Register".to_string(), true)
858 }
859 Command::ReportPoolWindowRemoved { .. } => {
860 ("ReportPoolWindowRemoved before Register".to_string(), true)
861 }
862 // Phase F.5 — host-only report; gate matches the other pool-
863 // mirror reports above.
864 Command::ReportPoolWindowPromoted { .. } => {
865 ("ReportPoolWindowPromoted before Register".to_string(), true)
866 }
867 // Phase F.5 — `SpawnPoolWindow` is a launcher→host direction
868 // command. Sent to the launcher pipe before Register: same
869 // soft-error treatment as the srv-pipe misroutes (the client
870 // can recover by registering or routing correctly).
871 Command::SpawnPoolWindow { .. } => {
872 ("SpawnPoolWindow is a launcher→host command; sent to launcher pipe by mistake".to_string(), false)
873 }
874 // Phase F.6 — host-only reports; same fatal-before-Register
875 // treatment as the other window-mirror reports above.
876 Command::ReportPanesReaped { .. } => {
877 ("ReportPanesReaped before Register".to_string(), true)
878 }
879 Command::ReportPoolDrainDecision { .. } => {
880 ("ReportPoolDrainDecision before Register".to_string(), true)
881 }
882 // Phase F.6 — launcher→host direction commands. Same
883 // soft-error treatment as `SpawnPoolWindow` above.
884 Command::ReapPanes { .. } => {
885 ("ReapPanes is a launcher→host command; sent to launcher pipe by mistake".to_string(), false)
886 }
887 Command::DrainPoolIfLast { .. } => {
888 ("DrainPoolIfLast is a launcher→host command; sent to launcher pipe by mistake".to_string(), false)
889 }
890 // Phase CPD-1 — host-only saga-action-failed report. Same
891 // fatal-before-Register treatment as the other Report*
892 // commands above.
893 Command::ReportSagaActionFailed { .. } => {
894 ("ReportSagaActionFailed before Register".to_string(), true)
895 }
896 // Startup-stage telemetry — host-only reports, same
897 // fatal-before-Register treatment as the other Report*
898 // commands. Unreachable in practice: connect_to_launcher's
899 // handshake always sends Register first.
900 Command::ReportStartupStageBegin { .. } => {
901 ("ReportStartupStageBegin before Register".to_string(), true)
902 }
903 Command::ReportStartupStageEnd { .. } => {
904 ("ReportStartupStageEnd before Register".to_string(), true)
905 }
906 Command::ReportHostCounts { .. } => {
907 ("ReportHostCounts before Register".to_string(), true)
908 }
909 Command::ReportHostPoolCount { .. } => {
910 ("ReportHostPoolCount before Register".to_string(), true)
911 }
912 Command::ReportBackendWindowIdRegistered { .. } => {
913 (
914 "ReportBackendWindowIdRegistered before Register".to_string(),
915 true,
916 )
917 }
918 Command::ReportBackendWindowIdUnregistered { .. } => {
919 (
920 "ReportBackendWindowIdUnregistered before Register".to_string(),
921 true,
922 )
923 }
924 // Phase B.9.1 (WRR) — host-only Win32 reality reports.
925 Command::ReportHwndOpened { .. } => {
926 ("ReportHwndOpened before Register".to_string(), true)
927 }
928 Command::ReportHwndDestroyed { .. } => {
929 ("ReportHwndDestroyed before Register".to_string(), true)
930 }
931 Command::ReportHwndVisibilityChanged { .. } => {
932 (
933 "ReportHwndVisibilityChanged before Register".to_string(),
934 true,
935 )
936 }
937 Command::ReportHwndForegroundChanged { .. } => {
938 (
939 "ReportHwndForegroundChanged before Register".to_string(),
940 true,
941 )
942 }
943 Command::ReportHwndIconicChanged { .. } => {
944 ("ReportHwndIconicChanged before Register".to_string(), true)
945 }
946 Command::ReportHwndPositionChanged { .. } => {
947 (
948 "ReportHwndPositionChanged before Register".to_string(),
949 true,
950 )
951 }
952 Command::ReportMonitorTopologyChanged { .. } => {
953 (
954 "ReportMonitorTopologyChanged before Register".to_string(),
955 true,
956 )
957 }
958 // Phase D.1 — GetSnapshot before Register is non-fatal: any
959 // sane diagnostic client can fix it by retrying with Register
960 // first. (Same Ping-before-Register precedent — soft error.)
961 Command::GetSnapshot => ("GetSnapshot before Register".to_string(), false),
962 // Phase D.3 — GetEvents before Register: same non-fatal
963 // semantics as GetSnapshot.
964 Command::GetEvents { .. } => ("GetEvents before Register".to_string(), false),
965 // Phase E.1b — GetSrvSnapshot is a srv-pipe command; if a
966 // client sends it to the launcher pipe by mistake, soft
967 // error: not the launcher's command.
968 Command::GetSrvSnapshot => (
969 "GetSrvSnapshot is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
970 false,
971 ),
972 // Phase E.2 — srv-pipe commands sent to launcher pipe by
973 // mistake. Soft error — clients can recover by routing to
974 // the right pipe.
975 Command::CreateWorkspace { .. } => (
976 "CreateWorkspace is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
977 false,
978 ),
979 Command::DeleteWorkspace { .. } => (
980 "DeleteWorkspace is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
981 false,
982 ),
983 // Phase E.2b — Tab arms are also srv-pipe commands.
984 Command::CreateTab { .. } => (
985 "CreateTab is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
986 false,
987 ),
988 Command::DeleteTab { .. } => (
989 "DeleteTab is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
990 false,
991 ),
992 Command::SetActiveTab { .. } => (
993 "SetActiveTab is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
994 false,
995 ),
996 Command::ReorderTab { .. } => (
997 "ReorderTab is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
998 false,
999 ),
1000 // Phase E.5 — window↔workspace mapping commands are srv-pipe.
1001 Command::CreateWindow { .. } => (
1002 "CreateWindow is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1003 false,
1004 ),
1005 Command::CloseWindowInternal { .. } => (
1006 "CloseWindowInternal is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1007 false,
1008 ),
1009 Command::SwitchWorkspace { .. } => (
1010 "SwitchWorkspace is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1011 false,
1012 ),
1013 // Phase E.5.3 — atomic single-step domain commands are srv-pipe.
1014 Command::ReorderTabsBulk { .. } => (
1015 "ReorderTabsBulk is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1016 false,
1017 ),
1018 Command::RenameWorkspace { .. } => (
1019 "RenameWorkspace is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1020 false,
1021 ),
1022 Command::RenameTab { .. } => (
1023 "RenameTab is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1024 false,
1025 ),
1026 Command::UpdateWorkspaceMeta { .. } => (
1027 "UpdateWorkspaceMeta is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1028 false,
1029 ),
1030 Command::UpdateTabMeta { .. } => (
1031 "UpdateTabMeta is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1032 false,
1033 ),
1034 Command::UpdateBlockMeta { .. } => (
1035 "UpdateBlockMeta is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1036 false,
1037 ),
1038 // Phase E.3 — Block arms are also srv-pipe commands.
1039 Command::CreateBlock { .. } => (
1040 "CreateBlock is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1041 false,
1042 ),
1043 Command::DeleteBlock { .. } => (
1044 "DeleteBlock is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1045 false,
1046 ),
1047 // Phase E.5.5 — saga-driven move commands are srv-pipe.
1048 Command::MoveTab { .. } => (
1049 "MoveTab is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1050 false,
1051 ),
1052 Command::MoveBlock { .. } => (
1053 "MoveBlock is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1054 false,
1055 ),
1056 // Phase E.4 (Option A) — layout focused/magnified setters are srv-pipe.
1057 Command::SetFocusedNode { .. } => (
1058 "SetFocusedNode is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1059 false,
1060 ),
1061 Command::SetMagnifiedNode { .. } => (
1062 "SetMagnifiedNode is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1063 false,
1064 ),
1065 // Phase E.4.B — all layout-tree commands are srv-pipe only.
1066 Command::LayoutInsertNode { .. }
1067 | Command::LayoutInsertNodeAtIndex { .. }
1068 | Command::LayoutDeleteNode { .. }
1069 | Command::LayoutDeleteNodeByBlock { .. }
1070 | Command::LayoutStackPush { .. }
1071 | Command::LayoutStackActivate { .. }
1072 | Command::CreateBlockInStack { .. }
1073 | Command::LayoutStackMove { .. }
1074 | Command::LayoutQueueBackendActions { .. }
1075 | Command::LayoutMoveNode { .. }
1076 | Command::LayoutSwapNodes { .. }
1077 | Command::LayoutResizeNodes { .. }
1078 | Command::LayoutReplaceNode { .. }
1079 | Command::LayoutSplitHorizontal { .. }
1080 | Command::LayoutSplitVertical { .. }
1081 | Command::LayoutClear { .. }
1082 | Command::LayoutSetTree { .. } => (
1083 "Layout command is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1084 false,
1085 ),
1086 Command::UpdateWindowMeta { .. } => (
1087 "UpdateWindowMeta is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1088 false,
1089 ),
1090 };
1091 // Phase E.1b — connection-private error; sentinel version=0
1092 // (codex P2 #610). See parse-error path for rationale.
1093 let v = 0;
1094 Some(Event::Error {
1095 code: ErrorCode::NotRegistered,
1096 message: msg,
1097 fatal,
1098 version: v,
1099 })
1100}
1101
1102/// Patch `launcher_pid` + `launcher_version` into `Event::Registered`.
1103/// The reducer leaves these as sentinels (it can't read env without
1104/// breaking determinism) — the server fills them in here, just
1105/// before serializing to the wire.
1106fn patch_launcher_identity(event: Event, ctx: &Arc<ServerCtx>) -> Event {
1107 if let Event::Registered {
1108 client_id, version, ..
1109 } = event
1110 {
1111 Event::Registered {
1112 client_id,
1113 launcher_pid: ctx.launcher_pid,
1114 launcher_version: ctx.launcher_version.clone(),
1115 version,
1116 }
1117 } else {
1118 event
1119 }
1120}
1121
1122/// Serialize an Event as one JSON line + `\n` and write atomically
1123/// (under the per-connection writer mutex). Returns Err if the
1124/// connection died mid-write.
1125///
1126/// CPD-2 — generalized from `Arc<Mutex<WriteHalf<NamedPipeServer>>>`
1127/// to `crate::host_pipe::SharedWriter` so the launcher's IPC server
1128/// + the host_pipe wrapper share one writer-handle representation.
1129/// Wire shape is unchanged: a non-host client sees a raw `Event` JSON
1130/// line (legacy schema), a host client sees a `HostFrame::Event`
1131/// envelope only when the frame goes through `HostPipe::send_event`.
1132/// Connection-private error replies in this file still emit raw
1133/// `Event` JSON to preserve backwards compat with existing host
1134/// versions that haven't adopted the envelope yet — CPD-1 lands the
1135/// host-side schema migration.
1136// (gate removed — platform-neutral body, accessible from cfg(unix) too — A1.1)
1137async fn send_event_shared(
1138 writer: &crate::host_pipe::SharedWriter,
1139 event: Event,
1140) -> std::io::Result<()> {
1141 let mut buf = serde_json::to_vec(&event).map_err(|e| {
1142 std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
1143 })?;
1144 buf.push(b'\n');
1145 let mut w = writer.lock().await;
1146 w.write_all(&buf).await?;
1147 w.flush().await?;
1148 Ok(())
1149}