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