Skip to main content

term_session_server/
session_server.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
4
5use muxio_core::rpc::rpc_internals::RpcStreamEvent;
6use muxio_rpc_service::prebuffered::RpcMethodPrebuffered;
7use muxio_rpc_service_caller::prebuffered::RpcCallPrebuffered;
8use muxio_rpc_service_endpoint::{RpcServiceEndpointInterface, StreamResponder};
9use muxio_tokio_rpc_ipc_server::{RpcIpcConnectionContextHandle, RpcIpcServer, RpcIpcServerEvent};
10use portable_pty::PtySize;
11use tokio::sync::{Mutex, Notify, RwLock, mpsc, oneshot};
12
13use term_session_muxio_service_definitions::{
14    Attach, ChannelInfo, ChannelName, ClientInfo, CloseSession, KillChannel, KillClient,
15    ListChannels, ListChannelsResponse, OnPtyResized, RPC_ERROR_LIVE_SESSIONS,
16    RPC_ERROR_SHUTTING_DOWN, RPC_ERROR_UNATTACHED, ResizePty, STREAM_INPUT_METHOD_ID,
17    SUBSCRIBE_OUTPUT_METHOD_ID, SessionInfo, ShutdownGateway, Spawn, SpawnRequest, SpawnResponse,
18    WriteInput,
19};
20use term_wm_pty_engine::PtyStatus;
21
22use crate::session::Session;
23
24/// Session id per channel (each channel hosts a single PTY at a time).
25const SESSION_ID: u64 = 1;
26/// Bounded input channel capacity — memory safety against extreme input bursts.
27const INPUT_CHANNEL_CAPACITY: usize = 128;
28
29/// Grace period to let the transport flush end-of-stream frames after the
30/// session exits, before the gateway process terminates.
31const SESSION_EXIT_FLUSH_GRACE: std::time::Duration = std::time::Duration::from_millis(100);
32
33/// How often the output polling task wakes to re-check the session's exit
34/// status, as a fallback for a missed or raced PTY EOF notification.
35const SESSION_EXIT_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100);
36
37/// Grace between SIGTERM and SIGKILL when terminating a session's process tree.
38const SIGKILL_GRACE: std::time::Duration = std::time::Duration::from_millis(500);
39
40/// SIGTERM for cooperative process-group termination. On non-Unix platforms
41/// the value is unused (kill paths fall back to `kill_child`); it is kept a
42/// named `const` so the call sites read identically.
43#[cfg(unix)]
44const SIGTERM: i32 = libc::SIGTERM;
45#[cfg(not(unix))]
46const SIGTERM: i32 = 15;
47/// SIGKILL for straggler escalation after the grace window.
48#[cfg(unix)]
49const SIGKILL: i32 = libc::SIGKILL;
50#[cfg(not(unix))]
51#[allow(dead_code)]
52const SIGKILL: i32 = 9;
53
54/// Grace before the gateway exits after the last ShutdownGateway response is
55/// flushed: the RPC handler returns immediately and a detached task sleeps
56/// this long so the muxio transport drains the `()` frame before the socket
57/// is torn down.
58const SHUTDOWN_FLUSH_GRACE_MS: u64 = 50;
59
60/// A connection's bind state. Identity is server-assigned (`conn_id`); a
61/// connection must `Attach` before it may spawn/resize/write.
62#[derive(Clone)]
63enum ConnState {
64    Unattached,
65    Attached(ChannelName),
66}
67
68#[derive(Clone)]
69struct ConnEntry {
70    handle: RpcIpcConnectionContextHandle,
71    state: ConnState,
72    hostname: String,
73    connected_at_unix: u64,
74    /// Client process OS PID (reported at Attach).
75    pid: u64,
76    /// OS user running the client process (reported at Attach).
77    user: String,
78    /// Client binary version (reported at Attach).
79    version: String,
80    /// Remote peer IP for SSH attaches; `None` for local (reported at Attach).
81    ssh_ip: Option<String>,
82}
83
84#[derive(Clone)]
85struct ClientEntry {
86    caller: Option<RpcIpcConnectionContextHandle>,
87    hostname: String,
88    connected_at_unix: u64,
89    pid: u64,
90    user: String,
91    version: String,
92    ssh_ip: Option<String>,
93    cols: u16,
94    rows: u16,
95}
96
97struct SubscriberEntry {
98    conn_id: usize,
99    respond: StreamResponder,
100}
101
102/// Per-channel state. One gateway process hosts many channels; each channel
103/// owns its own session, connected clients, subscribers, and input channel.
104struct ChannelState {
105    session: Option<Session>,
106    clients: HashMap<usize, ClientEntry>,
107    subscribers: Vec<SubscriberEntry>,
108    notify: Arc<Notify>,
109    /// Unix seconds when the channel was first created on the gateway.
110    created_at_unix: u64,
111    /// Monotonic creation sequence (across the whole gateway process). Sort key
112    /// for `ListChannels`: creation order, newest last. `created_at_unix` is
113    /// only second-resolution wall clock and cannot disambiguate same-second
114    /// creations, so this monotonic counter is authoritative.
115    created_seq: u64,
116    /// Command template used to respawn the session after it exits.
117    cmd: Vec<String>,
118    input_tx: mpsc::Sender<Vec<u8>>,
119    /// True between a SIGTERM request and the process group's actual exit (or
120    /// the SIGKILL escalation). Cleared when the session is observed exited.
121    kill_pending: bool,
122    /// Tombstone set by GC just before removal; any Attach that read a stale
123    /// Arc re-checks this under the write lock (safe double-checked locking).
124    is_reaped: bool,
125}
126
127/// Gateway coordination. Two tiers:
128/// - `conns` is a read-mostly routing table (RwLock).
129/// - `channels` maps channel names to independently-mutexed channel states.
130///
131/// The gateway never holds more than one lock at a time (resolve-and-drop).
132struct ServerState {
133    conns: RwLock<HashMap<usize, ConnEntry>>,
134    channels: RwLock<HashMap<ChannelName, Arc<Mutex<ChannelState>>>>,
135    is_shutting_down: AtomicBool,
136    /// Monotonic source of `ChannelState::created_seq` (creation order).
137    next_channel_seq: AtomicU64,
138    /// Per-connection ordered input forwarders. The synchronous `StreamInput`
139    /// handler forwards each chunk into this queue in wire order (the handler
140    /// is invoked sequentially per stream by muxio); a single consumer task
141    /// per connection drains it FIFO into the channel's `input_tx`, so bursty
142    /// input (e.g. IME voice typing) is never reordered by racing tasks.
143    input_forwarders: std::sync::Mutex<HashMap<usize, mpsc::Sender<Vec<u8>>>>,
144}
145
146type SharedState = Arc<ServerState>;
147
148fn rpc_err(message: &str) -> Box<dyn std::error::Error + Send + Sync> {
149    Box::new(std::io::Error::other(message.to_string()))
150}
151
152/// Lift an `io::Error` into the boxed handler error type.
153fn boxed_io(e: std::io::Error) -> Box<dyn std::error::Error + Send + Sync> {
154    Box::new(e)
155}
156
157/// Unix seconds for a connection's `connected_at_unix` wire field.
158fn now_unix() -> u64 {
159    std::time::SystemTime::now()
160        .duration_since(std::time::UNIX_EPOCH)
161        .map(|d| d.as_secs())
162        .unwrap_or(0)
163}
164
165impl ChannelState {
166    fn new(
167        cmd: Vec<String>,
168        input_tx: mpsc::Sender<Vec<u8>>,
169        notify: Arc<Notify>,
170        created_seq: u64,
171    ) -> Self {
172        Self {
173            session: None,
174            clients: HashMap::new(),
175            subscribers: Vec::new(),
176            notify,
177            created_at_unix: now_unix(),
178            created_seq,
179            cmd,
180            input_tx,
181            kill_pending: false,
182            is_reaped: false,
183        }
184    }
185
186    /// Replace the current session and attach the Notify callback so the
187    /// background polling task wakes on PTY output.
188    fn set_session(&mut self, mut session: Session) {
189        let n = self.notify.clone();
190        session.set_status_callback(Some(Box::new(move |status| {
191            if matches!(status, PtyStatus::Wakeup | PtyStatus::Exited) {
192                n.notify_one();
193            }
194        })));
195        self.session = Some(session);
196        // Prime notify to process initial startup output generated before the
197        // callback was registered.
198        self.notify.notify_one();
199    }
200
201    /// Signal the session's process group (non-blocking) and arm the kill
202    /// escalation flag. The caller is responsible for spawning the detached
203    /// escalation task (see `spawn_kill_escalation`). Mechanism only — no
204    /// sleeps, no waits, no `SIGKILL` here.
205    fn request_session_kill(&mut self, signal: i32) {
206        let _ = &signal;
207        if let Some(session) = self.session.as_mut() {
208            #[cfg(unix)]
209            let _ = session.pty.signal_process_group(signal);
210            #[cfg(not(unix))]
211            let _ = session.pty.kill_child();
212        }
213        self.kill_pending = true;
214        self.notify.notify_one();
215    }
216
217    /// Flush remaining PTY buffers and stream completion markers to all active
218    /// subscribers, then drop them. Used by kill paths and on session exit.
219    fn finalize_subscribers(&mut self) {
220        if let Some(session) = self.session.as_mut() {
221            let raw = session.read_output();
222            if !raw.is_empty() {
223                for sub in &self.subscribers {
224                    sub.respond.respond(raw.clone(), false);
225                }
226            }
227        }
228        for sub in &self.subscribers {
229            sub.respond.respond(Vec::new(), true);
230        }
231        self.subscribers.clear();
232    }
233
234    /// Constrain the PTY to the smallest geometry across all connected clients.
235    /// This guarantees the virtual buffer never exceeds any attached monitor.
236    ///
237    /// Geometry is strictly client-driven: if no connected client has reported
238    /// real dimensions yet (all are still `u16::MAX`), nothing is constrained
239    /// and the session keeps its spawn-time size. No hardcoded default size is
240    /// ever imposed.
241    fn recalculate_pty_size(&mut self) {
242        let Some(session) = self.session.as_mut() else {
243            return;
244        };
245        let Some(min_cols) = self
246            .clients
247            .values()
248            .map(|c| c.cols)
249            .filter(|&c| c != u16::MAX)
250            .min()
251        else {
252            return;
253        };
254        let Some(min_rows) = self
255            .clients
256            .values()
257            .map(|c| c.rows)
258            .filter(|&r| r != u16::MAX)
259            .min()
260        else {
261            return;
262        };
263        let size = PtySize {
264            rows: min_rows,
265            cols: min_cols,
266            pixel_width: 0,
267            pixel_height: 0,
268        };
269        let _ = session.pty.resize(size);
270        session.cols = min_cols;
271        session.rows = min_rows;
272    }
273
274    /// Broadcast geometry to all connected clients via detached async tasks.
275    /// Call AFTER releasing the channel lock.
276    fn notify_clients(&self, clients: &[ClientEntry], cols: u16, rows: u16) {
277        for client in clients {
278            let Some(caller) = client.caller.clone() else {
279                continue;
280            };
281            tokio::spawn(async move {
282                if let Err(e) = OnPtyResized::call(&caller, (cols, rows)).await {
283                    tracing::debug!(error = ?e, "Failed to deliver OnPtyResized notification");
284                }
285            });
286        }
287    }
288
289    fn to_info(&self, name: &ChannelName) -> ChannelInfo {
290        let session = self.session.as_ref().map(|s| SessionInfo {
291            id: s.id,
292            cols: s.cols,
293            rows: s.rows,
294            exited: s.exited,
295            exit_code: s.exit_code,
296            title: s.title.clone().unwrap_or_default(),
297        });
298        // Sort by `conn_id`, which muxio assigns monotonically at connection
299        // accept — ascending order = connection order, newest client last.
300        let mut clients: Vec<ClientInfo> = self
301            .clients
302            .iter()
303            .map(|(conn_id, c)| ClientInfo {
304                conn_id: *conn_id,
305                pid: c.pid,
306                hostname: c.hostname.clone(),
307                connected_at_unix: c.connected_at_unix,
308                cols: c.cols,
309                rows: c.rows,
310                user: c.user.clone(),
311                version: c.version.clone(),
312                ssh_ip: c.ssh_ip.clone(),
313            })
314            .collect();
315        clients.sort_by_key(|c| c.conn_id);
316        ChannelInfo {
317            name: name.to_string(),
318            created_at_unix: self.created_at_unix,
319            session,
320            clients,
321        }
322    }
323}
324
325/// Resolve the channel a connection is bound to, or `None` if unattached.
326async fn bound_channel(state: &ServerState, conn_id: usize) -> Option<ChannelName> {
327    let conns = state.conns.read().await;
328    match conns.get(&conn_id)?.state {
329        ConnState::Attached(ref name) => Some(name.clone()),
330        ConnState::Unattached => None,
331    }
332}
333
334/// Fetch an `Arc<ChannelState>` for the channel, resolving then dropping the
335/// routing guard (never holding `conns` while locking the channel).
336async fn resolve_channel(
337    state: &ServerState,
338    name: &ChannelName,
339) -> Option<Arc<Mutex<ChannelState>>> {
340    let channels = state.channels.read().await;
341    channels.get(name).cloned()
342}
343
344/// Drain one connection's ordered input queue, forwarding chunks to the
345/// bound channel's `input_tx` in exact arrival order. Exits when the forwarder
346/// sender is dropped (connection End/Error, eviction, or Attach re-bind) or
347/// the channel's input receiver closes. Exactly one of these tasks exists per
348/// connection, so chunk ordering is preserved end-to-end even under bursts.
349///
350/// Chunks queued during bursts (e.g. mouse drags or IME voice typing) are
351/// coalesced via non-blocking `try_recv` before forwarding, and `input_tx` is
352/// cached across chunks to eliminate per-chunk routing lookup overhead.
353async fn drain_input_forwarder(
354    state: SharedState,
355    conn_id: usize,
356    mut rx: mpsc::Receiver<Vec<u8>>,
357) {
358    let mut cached_tx: Option<mpsc::Sender<Vec<u8>>> = None;
359
360    while let Some(mut bytes) = rx.recv().await {
361        // Coalesce any additional chunks currently queued in the forwarder channel.
362        while let Ok(mut next) = rx.try_recv() {
363            bytes.append(&mut next);
364        }
365
366        // Re-resolve the target channel's `input_tx` if not cached or closed.
367        if cached_tx.as_ref().is_none_or(|tx| tx.is_closed()) {
368            cached_tx = None;
369            if let Some(channel) = bound_channel(state.as_ref(), conn_id).await
370                && let Some(ch) = resolve_channel(state.as_ref(), &channel).await
371            {
372                let guard = ch.lock().await;
373                if !guard.is_reaped {
374                    cached_tx = Some(guard.input_tx.clone());
375                }
376            }
377        }
378
379        if let Some(ref tx) = cached_tx {
380            // Backpressure: `input_tx` is bounded (INPUT_CHANNEL_CAPACITY); a full
381            // buffer parks this consumer instead of silently dropping the chunk.
382            if tx.send(bytes).await.is_err() {
383                cached_tx = None;
384            }
385        }
386    }
387}
388
389/// Create (or fetch, re-verifying under the write lock) a channel.
390/// Safe double-checked locking: a racing Attach that saw a reaped Arc is
391/// redirected to the canonical instance instead of overwriting it.
392async fn get_or_create_channel(
393    state: &SharedState,
394    name: &ChannelName,
395) -> Arc<Mutex<ChannelState>> {
396    {
397        let channels = state.channels.read().await;
398        if let Some(existing) = channels.get(name) {
399            let arc = existing.clone();
400            drop(channels);
401            let is_reaped = arc.lock().await.is_reaped;
402            if !is_reaped {
403                return arc;
404            }
405        }
406    }
407
408    let mut channels = state.channels.write().await;
409    // Re-check under the write lock: a racing thread may have inserted a
410    // non-reaped channel between our read and write acquisition. A reaped
411    // (or absent) entry falls through and is replaced with a fresh channel.
412    if let Some(existing) = channels.get(name) {
413        let arc = existing.clone();
414        let is_reaped = arc.lock().await.is_reaped;
415        if !is_reaped {
416            return arc;
417        }
418    }
419    let (input_tx, input_rx) = mpsc::channel::<Vec<u8>>(INPUT_CHANNEL_CAPACITY);
420    let notify = Arc::new(Notify::new());
421    let created_seq = state.next_channel_seq.fetch_add(1, Ordering::Relaxed);
422    let channel = Arc::new(Mutex::new(ChannelState::new(
423        Vec::new(),
424        input_tx,
425        notify,
426        created_seq,
427    )));
428    let ch = Arc::clone(&channel);
429    tokio::spawn(async move {
430        let mut input_rx = input_rx;
431        while let Some(mut data) = input_rx.recv().await {
432            // Coalesce any additional chunks currently queued in input_rx so a
433            // burst of tiny chunks (e.g. mouse drags) is written to the PTY in
434            // a single blocking call instead of one spawn_blocking per chunk.
435            while let Ok(mut next) = input_rx.try_recv() {
436                data.append(&mut next);
437            }
438
439            let writer = {
440                let guard = ch.lock().await;
441                guard.session.as_ref().map(|s| s.pty.writer_handle())
442            };
443            if let Some(writer) = writer {
444                let _ = tokio::task::spawn_blocking(move || writer.write_bytes(&data)).await;
445            }
446        }
447        // Note: the input task runs for the daemon lifetime; the channel's
448        // `input_rx` is only dropped when the channel's senders all vanish.
449    });
450
451    // Output polling task: drains PTY output and broadcasts to subscribers;
452    // on session exit finalizes subscribers, clears the session, and reaps
453    // the channel if no clients remain.
454    {
455        let st = Arc::clone(state);
456        let ch = Arc::clone(&channel);
457        let notify = {
458            let locked = ch.lock().await;
459            locked.notify.clone()
460        };
461        let name_for_task = name.clone();
462        tokio::spawn(async move {
463            loop {
464                tokio::select! {
465                    _ = notify.notified() => {}
466                    _ = tokio::time::sleep(SESSION_EXIT_POLL_INTERVAL) => {}
467                }
468                let mut guard = ch.lock().await;
469                if guard.is_reaped {
470                    break;
471                }
472                if guard.subscribers.is_empty() {
473                    if let Some(session) = guard.session.as_mut() {
474                        session.sync_screen();
475                        if session.check_exited() {
476                            tracing::info!(channel = %name_for_task, "Session exited");
477                            guard.session = None;
478                            guard.kill_pending = false;
479                        }
480                    }
481                } else {
482                    let (raw, exited, code) = {
483                        let Some(session) = guard.session.as_mut() else {
484                            // No live session: finalize any lingering subscribers.
485                            for sub in &guard.subscribers {
486                                sub.respond.respond(Vec::new(), true);
487                            }
488                            guard.subscribers.clear();
489                            guard.notify.notify_one();
490                            continue;
491                        };
492                        let raw = session.read_output();
493                        let exited = session.check_exited();
494                        let code = session.exit_code;
495                        (raw, exited, code)
496                    };
497                    if !raw.is_empty() {
498                        for sub in &guard.subscribers {
499                            sub.respond.respond(raw.clone(), false);
500                        }
501                    }
502                    if exited {
503                        tracing::info!(channel = %name_for_task, "Session exited with code {:?}", code);
504                        for sub in &guard.subscribers {
505                            sub.respond.respond(Vec::new(), true);
506                        }
507                        guard.subscribers.clear();
508                        guard.session = None;
509                        guard.kill_pending = false;
510                        guard.notify.notify_one();
511                    }
512                }
513                let should_reap = guard.session.is_none() && guard.clients.is_empty();
514                drop(guard);
515
516                if should_reap {
517                    // GC: drop the channel guard before requesting `channels.write`
518                    // (strict ordering, no AB-BA), then re-verify under the write lock.
519                    let mut channels = st.channels.write().await;
520                    if let Some(arc) = channels.get(&name_for_task) {
521                        let mut locked = arc.lock().await;
522                        if locked.session.is_none() && locked.clients.is_empty() {
523                            locked.is_reaped = true;
524                            drop(locked);
525                            channels.remove(&name_for_task);
526                            tracing::info!(channel = %name_for_task, "Reaped idle channel");
527                        }
528                    }
529                }
530                // Note: the daemon deliberately persists until an explicit
531                // `ShutdownGateway` / `term-session stop`. Sessions survive
532                // client disconnects; idle channels are reaped above but the
533                // gateway process itself is never torn down implicitly.
534            }
535        });
536    }
537
538    channels.insert(name.clone(), Arc::clone(&channel));
539    channel
540}
541
542/// Drop a connection's input forwarder. Called on disconnect (evict_conn) and
543/// on Attach re-bind so an abrupt drop or a channel re-attach cannot leak the
544/// drain task or route `cached_tx` to a stale channel's `input_tx`.
545fn purge_input_forwarder(state: &ServerState, conn_id: usize) {
546    if let Ok(mut fwd) = state.input_forwarders.lock() {
547        fwd.remove(&conn_id);
548    }
549}
550
551/// Remove a connection from the routing table and prune it from its bound
552/// channel's client/subscriber maps (authoritative teardown on disconnect).
553async fn evict_conn(state: &ServerState, conn_id: usize) {
554    purge_input_forwarder(state, conn_id);
555    let channel = {
556        let mut conns = state.conns.write().await;
557        let entry = conns.remove(&conn_id);
558        entry.and_then(|e| match e.state {
559            ConnState::Attached(name) => Some(name),
560            ConnState::Unattached => None,
561        })
562    };
563    let Some(channel) = channel else {
564        return;
565    };
566    let Some(ch) = resolve_channel(state, &channel).await else {
567        return;
568    };
569    let mut guard = ch.lock().await;
570    guard.clients.remove(&conn_id);
571    guard.subscribers.retain(|s| s.conn_id != conn_id);
572    guard.recalculate_pty_size();
573    // Broadcast the session's actual (client-driven) geometry to remaining
574    // clients. If no session exists there is nothing to broadcast.
575    let session_size = guard.session.as_ref().map(|s| (s.cols, s.rows));
576    let targets: Vec<ClientEntry> = guard.clients.values().cloned().collect();
577    drop(guard);
578    let Some((ncols, nrows)) = session_size else {
579        return;
580    };
581    // Best-effort geometry broadcast; the channel may be reaped concurrently,
582    // in which case clients already see end-of-stream.
583    if let Some(ch) = resolve_channel(state, &channel).await {
584        let guard = ch.lock().await;
585        guard.notify_clients(&targets, ncols, nrows);
586    }
587}
588
589/// Spawn a detached escalation task for a kill-requested session, returning
590/// its `JoinHandle` so the caller (e.g. shutdown teardown) can await it.
591///
592/// Policy owned by the daemon supervisor (never the pty engine): after an
593/// async `SIGKILL_GRACE` sleep, re-check the session state; if it already
594/// exited during the grace window (reaped by the output-polling task), abort
595/// instantly — no blind `SIGKILL` to a possibly-recycled pgid. Only escalate
596/// if the session is still alive and the kill is still pending.
597async fn spawn_kill_escalation(
598    state: &SharedState,
599    name: &ChannelName,
600) -> tokio::task::JoinHandle<()> {
601    let state = Arc::clone(state);
602    let name = name.clone();
603    tokio::spawn(async move {
604        tokio::time::sleep(SIGKILL_GRACE).await;
605        let Some(ch) = resolve_channel(&state, &name).await else {
606            return;
607        };
608        let mut guard = ch.lock().await;
609        if !guard.kill_pending {
610            return;
611        }
612        // Abort if the session exited during the grace (or is already gone).
613        let alive = guard
614            .session
615            .as_ref()
616            .is_some_and(|s| !s.exited && s.pty.reader_is_alive());
617        guard.kill_pending = false;
618        if !alive {
619            return;
620        }
621        if let Some(session) = guard.session.as_mut() {
622            #[cfg(unix)]
623            let _ = session.pty.signal_process_group(SIGKILL);
624            #[cfg(not(unix))]
625            let _ = session.pty.kill_child();
626        }
627    })
628}
629
630/// Run the gateway daemon. Hosts every channel in one process; returns after
631/// a `ShutdownGateway` (or transport error).
632pub async fn run_gateway(
633    gateway: ChannelName,
634) -> Result<i32, Box<dyn std::error::Error + Send + Sync>> {
635    let socket_name = gateway.to_string();
636    let state: SharedState = Arc::new(ServerState {
637        conns: RwLock::new(HashMap::new()),
638        channels: RwLock::new(HashMap::new()),
639        is_shutting_down: AtomicBool::new(false),
640        next_channel_seq: AtomicU64::new(0),
641        input_forwarders: std::sync::Mutex::new(HashMap::new()),
642    });
643
644    let (event_tx, mut event_rx) = mpsc::unbounded_channel();
645    let server = RpcIpcServer::new(Some(event_tx));
646    let endpoint = server.endpoint();
647
648    // ── Attach ────────────────────────────────────────────────────────
649    let st = Arc::clone(&state);
650    endpoint
651        .register_prebuffered(Attach::METHOD_ID, move |payload, ctx| {
652            let state = Arc::clone(&st);
653            async move {
654                if state.is_shutting_down.load(Ordering::SeqCst) {
655                    return Err(rpc_err(RPC_ERROR_SHUTTING_DOWN));
656                }
657                let req = Attach::decode_request(&payload)?;
658                let name = ChannelName::parse(&req.channel).map_err(|e| rpc_err(&e))?;
659                let _channel = get_or_create_channel(&state, &name).await;
660                let conn_id = ctx.conn_id;
661                // Purge any existing input forwarder for this connection so a
662                // re-attach to a different channel invalidates `cached_tx` and
663                // forces fresh target resolution.
664                purge_input_forwarder(&state, conn_id);
665                let mut conns = state.conns.write().await;
666                // The `ClientConnected` event is processed by a separate async
667                // loop, so it may not have inserted the entry yet when this
668                // handler runs. Ensure the entry exists before binding.
669                let entry = conns.entry(conn_id).or_insert_with(|| ConnEntry {
670                    handle: RpcIpcConnectionContextHandle(ctx.clone()),
671                    state: ConnState::Unattached,
672                    hostname: String::new(),
673                    connected_at_unix: now_unix(),
674                    pid: 0,
675                    user: String::new(),
676                    version: String::new(),
677                    ssh_ip: None,
678                });
679                entry.state = ConnState::Attached(name);
680                entry.hostname = req.hostname;
681                entry.connected_at_unix = now_unix();
682                entry.pid = req.pid;
683                entry.user = req.user;
684                entry.version = req.version;
685                entry.ssh_ip = req.ssh_ip;
686                Attach::encode_response(conn_id).map_err(boxed_io)
687            }
688        })
689        .await
690        .map_err(|e| format!("register Attach: {e:?}"))?;
691
692    // ── Spawn ────────────────────────────────────────────────────────
693    let st = Arc::clone(&state);
694    endpoint
695        .register_prebuffered(Spawn::METHOD_ID, move |payload, ctx| {
696            let state = Arc::clone(&st);
697            async move {
698                if state.is_shutting_down.load(Ordering::SeqCst) {
699                    return Err(rpc_err(RPC_ERROR_SHUTTING_DOWN));
700                }
701                let SpawnRequest {
702                    cmd,
703                    cols,
704                    rows,
705                    cwd,
706                } = Spawn::decode_request(&payload)?;
707                let channel = bound_channel(state.as_ref(), ctx.conn_id).await;
708                let Some(channel) = channel else {
709                    return Err(rpc_err(RPC_ERROR_UNATTACHED));
710                };
711                let ch = get_or_create_channel(&state, &channel).await;
712                // Fetch the connection's entry (caller handle, hostname,
713                // identity) before locking the channel (never hold two locks).
714                let conn_meta = {
715                    let conns = state.conns.read().await;
716                    conns.get(&ctx.conn_id).cloned()
717                };
718                let mut guard = ch.lock().await;
719                let entry = guard
720                    .clients
721                    .entry(ctx.conn_id)
722                    .or_insert_with(|| ClientEntry {
723                        caller: conn_meta.as_ref().map(|c| c.handle.clone()),
724                        hostname: conn_meta
725                            .as_ref()
726                            .map(|c| c.hostname.clone())
727                            .unwrap_or_default(),
728                        connected_at_unix: conn_meta
729                            .as_ref()
730                            .map(|c| c.connected_at_unix)
731                            .unwrap_or(0),
732                        pid: conn_meta.as_ref().map(|c| c.pid).unwrap_or(0),
733                        user: conn_meta
734                            .as_ref()
735                            .map(|c| c.user.clone())
736                            .unwrap_or_default(),
737                        version: conn_meta
738                            .as_ref()
739                            .map(|c| c.version.clone())
740                            .unwrap_or_default(),
741                        ssh_ip: conn_meta.as_ref().and_then(|c| c.ssh_ip.clone()),
742                        cols,
743                        rows,
744                    });
745                entry.cols = cols;
746                entry.rows = rows;
747
748                // If a session already exists and hasn't exited, reuse it.
749                if guard.session.as_ref().is_some_and(|s| !s.exited) {
750                    guard.recalculate_pty_size();
751                    let session = guard.session.as_ref().unwrap();
752                    let (ncols, nrows) = (session.cols, session.rows);
753                    let targets: Vec<ClientEntry> = guard.clients.values().cloned().collect();
754                    let id = session.id;
755                    let cols = session.cols;
756                    let rows = session.rows;
757                    drop(guard);
758                    if let Some(ch) = resolve_channel(state.as_ref(), &channel).await {
759                        let g = ch.lock().await;
760                        g.notify_clients(&targets, ncols, nrows);
761                    }
762                    return Spawn::encode_response(SpawnResponse { id, cols, rows })
763                        .map_err(boxed_io);
764                }
765
766                // Respawn: new non-empty cmd overwrites the stored template;
767                // empty/None falls back to the existing template.
768                let effective_cmd = if let Some(c) = cmd
769                    && !c.is_empty()
770                {
771                    guard.cmd = c.clone();
772                    Some(c)
773                } else if !guard.cmd.is_empty() {
774                    Some(guard.cmd.clone())
775                } else {
776                    None
777                };
778                // Spawn in the client's launch directory when provided (the
779                // caller expects to land where they ran `term-session`), else
780                // fall back to the daemon's cwd for legacy/empty payloads.
781                let effective_cwd = cwd.filter(|c| !c.is_empty());
782                let id = SESSION_ID;
783                let session = Session::spawn(
784                    id,
785                    effective_cmd,
786                    cols,
787                    rows,
788                    Some(&channel),
789                    effective_cwd.as_ref(),
790                )?;
791                guard.set_session(session);
792                guard.recalculate_pty_size();
793                let targets: Vec<ClientEntry> = guard.clients.values().cloned().collect();
794                let session = guard.session.as_ref().unwrap();
795                let (sid, scol, srow) = (session.id, session.cols, session.rows);
796                let (ncols, nrows) = (scol, srow);
797                drop(guard);
798                if let Some(ch) = resolve_channel(state.as_ref(), &channel).await {
799                    let g = ch.lock().await;
800                    g.notify_clients(&targets, ncols, nrows);
801                }
802                Spawn::encode_response(SpawnResponse {
803                    id: sid,
804                    cols: scol,
805                    rows: srow,
806                })
807                .map_err(boxed_io)
808            }
809        })
810        .await
811        .map_err(|e| format!("register Spawn: {e:?}"))?;
812
813    // ── ResizePty ────────────────────────────────────────────────────
814    let st = Arc::clone(&state);
815    endpoint
816        .register_prebuffered(ResizePty::METHOD_ID, move |payload, ctx| {
817            let state = Arc::clone(&st);
818            async move {
819                if state.is_shutting_down.load(Ordering::SeqCst) {
820                    return Err(rpc_err(RPC_ERROR_SHUTTING_DOWN));
821                }
822                let (_id, cols, rows) = ResizePty::decode_request(&payload)?;
823                let channel = bound_channel(state.as_ref(), ctx.conn_id).await;
824                let Some(channel) = channel else {
825                    return Err(rpc_err(RPC_ERROR_UNATTACHED));
826                };
827                let ch = resolve_channel(state.as_ref(), &channel)
828                    .await
829                    .ok_or_else(|| rpc_err("channel not found"))?;
830                let mut guard = ch.lock().await;
831                if let Some(client) = guard.clients.get_mut(&ctx.conn_id) {
832                    client.cols = cols;
833                    client.rows = rows;
834                }
835                guard.recalculate_pty_size();
836                let (ncols, nrows) = guard
837                    .session
838                    .as_ref()
839                    .map(|s| (s.cols, s.rows))
840                    .unwrap_or((cols, rows));
841                let targets: Vec<ClientEntry> = guard.clients.values().cloned().collect();
842                drop(guard);
843                if let Some(ch) = resolve_channel(state.as_ref(), &channel).await {
844                    let g = ch.lock().await;
845                    g.notify_clients(&targets, ncols, nrows);
846                }
847                ResizePty::encode_response((ncols, nrows)).map_err(boxed_io)
848            }
849        })
850        .await
851        .map_err(|e| format!("register ResizePty: {e:?}"))?;
852
853    // ── CloseSession ─────────────────────────────────────────────────
854    let st = Arc::clone(&state);
855    endpoint
856        .register_prebuffered(CloseSession::METHOD_ID, move |payload, ctx| {
857            let state = Arc::clone(&st);
858            async move {
859                if state.is_shutting_down.load(Ordering::SeqCst) {
860                    return Err(rpc_err(RPC_ERROR_SHUTTING_DOWN));
861                }
862                let _id = CloseSession::decode_request(&payload)?;
863                let channel = bound_channel(state.as_ref(), ctx.conn_id).await;
864                let Some(channel) = channel else {
865                    return Err(rpc_err(RPC_ERROR_UNATTACHED));
866                };
867                let ch = resolve_channel(state.as_ref(), &channel)
868                    .await
869                    .ok_or_else(|| rpc_err("channel not found"))?;
870                let mut guard = ch.lock().await;
871                guard.request_session_kill(SIGTERM);
872                guard.finalize_subscribers();
873                drop(guard);
874                // Spawn the exited-checked SIGKILL escalation for stragglers.
875                spawn_kill_escalation(&state, &channel).await;
876                CloseSession::encode_response(()).map_err(boxed_io)
877            }
878        })
879        .await
880        .map_err(|e| format!("register CloseSession: {e:?}"))?;
881
882    // ── WriteInput ───────────────────────────────────────────────────
883    let st = Arc::clone(&state);
884    endpoint
885        .register_prebuffered(WriteInput::METHOD_ID, move |payload, ctx| {
886            let state = Arc::clone(&st);
887            async move {
888                if state.is_shutting_down.load(Ordering::SeqCst) {
889                    return Err(rpc_err(RPC_ERROR_SHUTTING_DOWN));
890                }
891                let (id, data) = WriteInput::decode_request(&payload)?;
892                let channel = bound_channel(state.as_ref(), ctx.conn_id).await;
893                let Some(channel) = channel else {
894                    return Err(rpc_err(RPC_ERROR_UNATTACHED));
895                };
896                let ch = resolve_channel(state.as_ref(), &channel)
897                    .await
898                    .ok_or_else(|| rpc_err("channel not found"))?;
899                let writer = {
900                    let guard = ch.lock().await;
901                    guard
902                        .session
903                        .as_ref()
904                        .filter(|s| s.id == id)
905                        .map(|s| s.pty.writer_handle())
906                };
907                // PTY writes are blocking I/O (kernel input buffer); offload
908                // to the blocking pool so a full buffer never stalls an async
909                // worker or holds the state lock.
910                if let Some(writer) = writer {
911                    let _ = tokio::task::spawn_blocking(move || writer.write_bytes(&data)).await;
912                }
913                WriteInput::encode_response(()).map_err(boxed_io)
914            }
915        })
916        .await
917        .map_err(|e| format!("register WriteInput: {e:?}"))?;
918
919    // ── StreamInput ──────────────────────────────────────────────────
920    let st = Arc::clone(&state);
921    endpoint
922        .register_stream_handler(STREAM_INPUT_METHOD_ID, move |event, _responder, ctx| {
923            let state = Arc::clone(&st);
924            let conn_id = ctx.conn_id;
925            match event {
926                RpcStreamEvent::PayloadChunk { bytes, .. } => {
927                    // Forward the chunk into the connection's ordered queue
928                    // synchronously — the handler is invoked sequentially in
929                    // wire order per stream, so `try_send` here preserves chunk
930                    // ordering. A single consumer task per connection drains
931                    // FIFO into the channel's `input_tx`, so bursty input
932                    // (e.g. IME voice typing) is never reordered by racing
933                    // spawned tasks.
934                    let forwarder = {
935                        let mut fwd = state
936                            .input_forwarders
937                            .lock()
938                            .unwrap_or_else(|e| e.into_inner());
939                        if let Some(tx) = fwd.get(&conn_id) {
940                            tx.clone()
941                        } else {
942                            let (tx, rx) = mpsc::channel(INPUT_CHANNEL_CAPACITY);
943                            fwd.insert(conn_id, tx.clone());
944                            let fwd_state = Arc::clone(&state);
945                            tokio::spawn(async move {
946                                drain_input_forwarder(fwd_state, conn_id, rx).await;
947                            });
948                            tx
949                        }
950                    };
951                    if let Err(e) = forwarder.try_send(bytes) {
952                        tracing::warn!(error = %e, "gateway input buffer full; dropping input chunk");
953                    }
954                }
955                RpcStreamEvent::End { .. } | RpcStreamEvent::Error { .. } => {
956                    // Close the forwarder: dropping the sender makes the
957                    // consumer task's `recv()` return `None` so it exits after
958                    // draining. The channel's `input_tx` persists, so the
959                    // session survives the client disconnect.
960                    if let Ok(mut fwd) = state.input_forwarders.lock() {
961                        fwd.remove(&conn_id);
962                    }
963                }
964                _ => {}
965            }
966        })
967        .await
968        .map_err(|e| format!("register stream handler STREAM_INPUT: {e:?}"))?;
969
970    // ── SubscribeOutput ──────────────────────────────────────────────
971    let st = Arc::clone(&state);
972    endpoint
973        .register_stream_handler(SUBSCRIBE_OUTPUT_METHOD_ID, move |event, respond, ctx| {
974            let is_new = matches!(&event, RpcStreamEvent::Header { .. });
975            if is_new {
976                let st = Arc::clone(&st);
977                let conn_id = ctx.conn_id;
978                tokio::spawn(async move {
979                    let channel = bound_channel(&st, conn_id).await;
980                    let Some(channel) = channel else {
981                        return;
982                    };
983                    let ch = resolve_channel(&st, &channel).await;
984                    let Some(ch) = ch else {
985                        return;
986                    };
987                    let mut guard = ch.lock().await;
988                    // Drain accumulated PTY output and capture the raw bytes
989                    // so they can be sent to the new subscriber.
990                    let early = guard.session.as_mut().and_then(|s| {
991                        let data = s.read_output();
992                        if data.is_empty() { None } else { Some(data) }
993                    });
994                    let snapshot = guard.session.as_mut().map(|s| s.generate_snapshot());
995                    guard.subscribers.push(SubscriberEntry {
996                        conn_id,
997                        respond: respond.clone(),
998                    });
999                    guard.notify.notify_one();
1000                    let is_dead = guard.session.is_none();
1001                    drop(guard);
1002                    if let Some(data) = snapshot
1003                        && !data.is_empty()
1004                    {
1005                        respond.respond(data, false);
1006                    }
1007                    if let Some(data) = early {
1008                        respond.respond(data, false);
1009                    }
1010                    if is_dead {
1011                        respond.respond(Vec::new(), true);
1012                    }
1013                });
1014            }
1015        })
1016        .await
1017        .map_err(|e| format!("register SubscribeOutput: {e:?}"))?;
1018
1019    // ── ListChannels ─────────────────────────────────────────────────
1020    let st = Arc::clone(&state);
1021    let list_socket = socket_name.clone();
1022    endpoint
1023        .register_prebuffered(ListChannels::METHOD_ID, move |_payload, _ctx| {
1024            let state = Arc::clone(&st);
1025            let socket = list_socket.clone();
1026            async move {
1027                let channels = {
1028                    let chans = state.channels.read().await;
1029                    chans
1030                        .iter()
1031                        .map(|(k, v)| (k.clone(), v.clone()))
1032                        .collect::<Vec<_>>()
1033                };
1034                // Creation order, newest last (monotonic `created_seq`, not
1035                // second-resolution wall clock). The seq is read under the same
1036                // per-channel lock used to build `ChannelInfo` below.
1037                let mut out: Vec<(u64, ChannelInfo)> = Vec::with_capacity(channels.len());
1038                for (name, ch) in channels {
1039                    let guard = ch.lock().await;
1040                    out.push((guard.created_seq, guard.to_info(&name)));
1041                }
1042                out.sort_by_key(|(seq, _)| *seq);
1043                let out: Vec<ChannelInfo> = out.into_iter().map(|(_, info)| info).collect();
1044                ListChannels::encode_response(ListChannelsResponse {
1045                    gateway_pid: std::process::id() as u64,
1046                    socket,
1047                    channels: out,
1048                })
1049                .map_err(boxed_io)
1050            }
1051        })
1052        .await
1053        .map_err(|e| format!("register ListChannels: {e:?}"))?;
1054
1055    // ── KillChannel ──────────────────────────────────────────────────
1056    let st = Arc::clone(&state);
1057    endpoint
1058        .register_prebuffered(KillChannel::METHOD_ID, move |payload, _ctx| {
1059            let state = Arc::clone(&st);
1060            async move {
1061                if state.is_shutting_down.load(Ordering::SeqCst) {
1062                    return Err(rpc_err(RPC_ERROR_SHUTTING_DOWN));
1063                }
1064                let channel_str = KillChannel::decode_request(&payload)?;
1065                let name = ChannelName::parse(&channel_str).map_err(|e| rpc_err(&e))?;
1066                // 1) Snapshot the target connections under `conns.write`, then release.
1067                let target_conns: Vec<usize> = {
1068                    let conns = state.conns.read().await;
1069                    conns
1070                        .iter()
1071                        .filter(|(_, entry)| matches!(entry.state, ConnState::Attached(ref n) if n == &name))
1072                        .map(|(conn_id, _)| *conn_id)
1073                        .collect()
1074                };
1075                // 2) Lock the channel: signal the session tree and evict every socket.
1076                if let Some(ch) = resolve_channel(state.as_ref(), &name).await {
1077                    let mut guard = ch.lock().await;
1078                    guard.request_session_kill(SIGTERM);
1079                    guard.finalize_subscribers();
1080                    for conn_id in &target_conns {
1081                        guard.clients.remove(conn_id);
1082                        guard.subscribers.retain(|s| s.conn_id != *conn_id);
1083                    }
1084                    drop(guard);
1085                    spawn_kill_escalation(&state, &name).await;
1086                }
1087                // 3) Evict the ConnEntry records from the routing table.
1088                let mut conns = state.conns.write().await;
1089                for conn_id in &target_conns {
1090                    conns.remove(conn_id);
1091                }
1092                KillChannel::encode_response(()).map_err(boxed_io)
1093            }
1094        })
1095        .await
1096        .map_err(|e| format!("register KillChannel: {e:?}"))?;
1097
1098    // ── KillClient ───────────────────────────────────────────────────
1099    let st = Arc::clone(&state);
1100    endpoint
1101        .register_prebuffered(KillClient::METHOD_ID, move |payload, _ctx| {
1102            let state = Arc::clone(&st);
1103            async move {
1104                if state.is_shutting_down.load(Ordering::SeqCst) {
1105                    return Err(rpc_err(RPC_ERROR_SHUTTING_DOWN));
1106                }
1107                let (channel_str, conn_id) = KillClient::decode_request(&payload)?;
1108                let name = ChannelName::parse(&channel_str).map_err(|e| rpc_err(&e))?;
1109                // Reject if the conn does not exist or is not attached to the
1110                // named channel — kill-client must target a real client.
1111                let bound_channel = {
1112                    let conns = state.conns.read().await;
1113                    conns.get(&conn_id).and_then(|c| match &c.state {
1114                        ConnState::Attached(n) if n == &name => Some(n.clone()),
1115                        _ => None,
1116                    })
1117                };
1118                let Some(bound) = bound_channel else {
1119                    return Err(rpc_err(&format!(
1120                        "client {conn_id} is not attached to channel '{name}'"
1121                    )));
1122                };
1123                // Evict the conn first (conns → channel ordering).
1124                {
1125                    let mut conns = state.conns.write().await;
1126                    conns.remove(&conn_id);
1127                }
1128                if let Some(ch) = resolve_channel(state.as_ref(), &bound).await {
1129                    let mut guard = ch.lock().await;
1130                    // End the evicted subscriber's stream before dropping it.
1131                    let mut evicted: Vec<StreamResponder> = Vec::new();
1132                    let mut keep = Vec::with_capacity(guard.subscribers.len());
1133                    for sub in guard.subscribers.drain(..) {
1134                        if sub.conn_id == conn_id {
1135                            evicted.push(sub.respond);
1136                        } else {
1137                            keep.push(sub);
1138                        }
1139                    }
1140                    guard.subscribers = keep;
1141                    for respond in evicted {
1142                        respond.respond(Vec::new(), true);
1143                    }
1144                    guard.clients.remove(&conn_id);
1145                    guard.recalculate_pty_size();
1146                    let session_size = guard.session.as_ref().map(|s| (s.cols, s.rows));
1147                    let targets: Vec<ClientEntry> = guard.clients.values().cloned().collect();
1148                    drop(guard);
1149                    if let (Some((ncols, nrows)), Some(ch)) =
1150                        (session_size, resolve_channel(state.as_ref(), &bound).await)
1151                    {
1152                        let g = ch.lock().await;
1153                        g.notify_clients(&targets, ncols, nrows);
1154                    }
1155                }
1156                KillClient::encode_response(()).map_err(boxed_io)
1157            }
1158        })
1159        .await
1160        .map_err(|e| format!("register KillClient: {e:?}"))?;
1161
1162    // ── ShutdownGateway ──────────────────────────────────────────────
1163    let st = Arc::clone(&state);
1164    let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
1165    let shutdown_tx = Arc::new(Mutex::new(Some(shutdown_tx)));
1166    endpoint
1167        .register_prebuffered(ShutdownGateway::METHOD_ID, move |payload, _ctx| {
1168            let state = Arc::clone(&st);
1169            let shutdown_tx = Arc::clone(&shutdown_tx);
1170            async move {
1171                // Refuse an accidental shutdown while live sessions are running
1172                // unless the caller explicitly forced it. Checked BEFORE the
1173                // `is_shutting_down` seal so a refused stop leaves the gateway
1174                // fully operational (no half-sealed state, no orphaned teardown).
1175                let force = ShutdownGateway::decode_request(&payload).map_err(boxed_io)?;
1176                if !force {
1177                    let live = {
1178                        let chans = state.channels.read().await;
1179                        let mut n = 0usize;
1180                        for ch in chans.values() {
1181                            let guard = ch.lock().await;
1182                            if guard.session.as_ref().is_some_and(|s| !s.exited) {
1183                                n += 1;
1184                            }
1185                        }
1186                        n
1187                    };
1188                    if live > 0 {
1189                        return Err(rpc_err(&format!(
1190                            "{RPC_ERROR_LIVE_SESSIONS} ({live} live session(s))"
1191                        )));
1192                    }
1193                }
1194                // Atomic seal: reject all further RPCs before teardown starts.
1195                state.is_shutting_down.store(true, Ordering::SeqCst);
1196                // Snapshot the channels, then release the map lock.
1197                let channels: Vec<(ChannelName, Arc<Mutex<ChannelState>>)> = {
1198                    let chans = state.channels.read().await;
1199                    chans.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
1200                };
1201                // Off-lock: signal each session's process group (non-blocking),
1202                // finalize subscribers, and collect the escalation handles so
1203                // the exit signal fires only after every child tree is reaped.
1204                let mut escalations = Vec::new();
1205                for (name, ch) in channels {
1206                    let mut guard = ch.lock().await;
1207                    tracing::info!(channel = %name, "Shutdown: signaling session tree");
1208                    guard.request_session_kill(SIGTERM);
1209                    guard.finalize_subscribers();
1210                    drop(guard);
1211                    escalations.push(spawn_kill_escalation(&state, &name).await);
1212                }
1213                // Deferred exit signal: await the SIGKILL escalation tasks so
1214                // all child process trees are definitively terminated, then
1215                // sleep a grace so the transport flushes the `()` response
1216                // frame, then fire the oneshot that ends run_gateway. Never
1217                // fire it synchronously from the handler.
1218                tokio::spawn(async move {
1219                    for handle in escalations {
1220                        let _ = handle.await;
1221                    }
1222                    tokio::time::sleep(std::time::Duration::from_millis(SHUTDOWN_FLUSH_GRACE_MS))
1223                        .await;
1224                    let mut tx_guard = shutdown_tx.lock().await;
1225                    if let Some(tx) = tx_guard.take() {
1226                        let _ = tx.send(());
1227                    }
1228                });
1229                ShutdownGateway::encode_response(()).map_err(boxed_io)
1230            }
1231        })
1232        .await
1233        .map_err(|e| format!("register ShutdownGateway: {e:?}"))?;
1234
1235    // ── Connection event loop ────────────────────────────────────────
1236    let st = Arc::clone(&state);
1237    tokio::spawn(async move {
1238        while let Some(event) = event_rx.recv().await {
1239            match event {
1240                RpcIpcServerEvent::ClientConnected(handle) => {
1241                    tracing::info!("Client {} connected", handle.0.conn_id);
1242                    let mut conns = st.conns.write().await;
1243                    // A fast client may send Attach before this event is
1244                    // processed; the Attach handler already inserted an
1245                    // `Attached` entry. `insert` would clobber that binding and
1246                    // a subsequent Spawn would see the state reset to
1247                    // Unattached. Only create the entry if it is absent.
1248                    conns.entry(handle.0.conn_id).or_insert_with(|| ConnEntry {
1249                        handle: handle.clone(),
1250                        state: ConnState::Unattached,
1251                        hostname: String::new(),
1252                        connected_at_unix: now_unix(),
1253                        pid: 0,
1254                        user: String::new(),
1255                        version: String::new(),
1256                        ssh_ip: None,
1257                    });
1258                }
1259                RpcIpcServerEvent::ClientDisconnected(conn_id) => {
1260                    tracing::info!("Client {conn_id} disconnected");
1261                    evict_conn(st.as_ref(), conn_id).await;
1262                }
1263            }
1264        }
1265    });
1266
1267    tracing::info!("Gateway listening on channel {gateway}");
1268
1269    // Wait for either the server to finish or a shutdown signal.
1270    let exit_code = tokio::select! {
1271        result = async {
1272            server.serve(&socket_name).await.map_err(|e| format!("serve: {e:?}"))
1273        } => {
1274            result?;
1275            0
1276        }
1277        _ = &mut shutdown_rx => {
1278            // Give the transport time to flush final subscriber frames.
1279            tokio::time::sleep(SESSION_EXIT_FLUSH_GRACE).await;
1280            0
1281        }
1282    };
1283
1284    Ok(exit_code)
1285}
1286
1287#[cfg(test)]
1288mod tests {
1289    use super::*;
1290    use muxio_core::rpc::RpcDispatcher;
1291    use muxio_tokio_rpc_ipc_server::RpcIpcConnectionContext;
1292
1293    /// Build a ServerState where `conn_id = 1` is attached to `test/coalesce`,
1294    /// whose ChannelState carries the given `input_tx` (observed via the paired
1295    /// receiver in the test).
1296    fn state_with_input(input_tx: mpsc::Sender<Vec<u8>>) -> SharedState {
1297        let name = ChannelName::parse("test/coalesce").expect("parse channel");
1298        let channel = Arc::new(Mutex::new(ChannelState::new(
1299            Vec::new(),
1300            input_tx,
1301            Arc::new(Notify::new()),
1302            1,
1303        )));
1304        let mut channels = HashMap::new();
1305        channels.insert(name.clone(), channel);
1306        let (write_tx, _write_rx) = mpsc::unbounded_channel();
1307        let conn = ConnEntry {
1308            handle: RpcIpcConnectionContextHandle(Arc::new(RpcIpcConnectionContext {
1309                write_tx,
1310                conn_id: 1,
1311                is_connected: Arc::new(AtomicBool::new(true)),
1312                dispatcher: Arc::new(Mutex::new(RpcDispatcher::new())),
1313            })),
1314            state: ConnState::Attached(name),
1315            hostname: String::new(),
1316            connected_at_unix: 0,
1317            pid: 0,
1318            user: String::new(),
1319            version: String::new(),
1320            ssh_ip: None,
1321        };
1322        let mut conns = HashMap::new();
1323        conns.insert(1, conn);
1324        Arc::new(ServerState {
1325            conns: RwLock::new(conns),
1326            channels: RwLock::new(channels),
1327            is_shutting_down: AtomicBool::new(false),
1328            next_channel_seq: AtomicU64::new(0),
1329            input_forwarders: std::sync::Mutex::new(HashMap::new()),
1330        })
1331    }
1332
1333    #[tokio::test]
1334    async fn drain_input_forwarder_coalesces_queued_chunks() {
1335        let (input_tx, mut input_rx) = mpsc::channel(128);
1336        let state = state_with_input(input_tx);
1337
1338        // Pre-fill the forwarder channel so coalescing is deterministic.
1339        let (fwd_tx, fwd_rx) = mpsc::channel(128);
1340        fwd_tx.send(b"chunk1".to_vec()).await.unwrap();
1341        fwd_tx.send(b"chunk2".to_vec()).await.unwrap();
1342        fwd_tx.send(b"chunk3".to_vec()).await.unwrap();
1343        drop(fwd_tx); // so the worker task exits after draining
1344
1345        tokio::spawn(drain_input_forwarder(state, 1, fwd_rx));
1346
1347        // The production worker must coalesce all three into one send.
1348        let received = input_rx.recv().await.expect("coalesced input");
1349        assert_eq!(received, b"chunk1chunk2chunk3");
1350    }
1351
1352    #[tokio::test]
1353    async fn drain_input_forwarder_forwards_isolated_chunk_unchanged() {
1354        let (input_tx, mut input_rx) = mpsc::channel(128);
1355        let state = state_with_input(input_tx);
1356
1357        let (fwd_tx, fwd_rx) = mpsc::channel(128);
1358        fwd_tx.send(b"only".to_vec()).await.unwrap();
1359        drop(fwd_tx);
1360
1361        tokio::spawn(drain_input_forwarder(state, 1, fwd_rx));
1362
1363        let received = input_rx.recv().await.expect("forwarded input");
1364        assert_eq!(received, b"only");
1365    }
1366
1367    #[tokio::test]
1368    async fn evict_conn_purges_input_forwarder() {
1369        let (input_tx, _input_rx) = mpsc::channel(128);
1370        let state = state_with_input(input_tx);
1371        let (fwd_tx, _fwd_rx) = mpsc::channel(128);
1372        state.input_forwarders.lock().unwrap().insert(1, fwd_tx);
1373
1374        evict_conn(&state, 1).await;
1375
1376        assert!(!state.input_forwarders.lock().unwrap().contains_key(&1));
1377    }
1378
1379    #[test]
1380    fn reattach_purges_existing_forwarder() {
1381        // Exercises the SAME production `purge_input_forwarder` that both
1382        // `evict_conn` and the `Attach::METHOD_ID` handler call — not an
1383        // inlined `fwd.remove`. A socket re-attach to a different channel
1384        // must drop the old forwarder so `cached_tx` cannot route input to the
1385        // previous channel's `input_tx`.
1386        let (input_tx, _input_rx) = mpsc::channel(128);
1387        let state = state_with_input(input_tx);
1388        let (fwd_tx, _fwd_rx) = mpsc::channel(128);
1389        state.input_forwarders.lock().unwrap().insert(1, fwd_tx);
1390
1391        purge_input_forwarder(&state, 1);
1392
1393        assert!(!state.input_forwarders.lock().unwrap().contains_key(&1));
1394    }
1395}