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}
139
140type SharedState = Arc<ServerState>;
141
142fn rpc_err(message: &str) -> Box<dyn std::error::Error + Send + Sync> {
143    Box::new(std::io::Error::other(message.to_string()))
144}
145
146/// Lift an `io::Error` into the boxed handler error type.
147fn boxed_io(e: std::io::Error) -> Box<dyn std::error::Error + Send + Sync> {
148    Box::new(e)
149}
150
151/// Unix seconds for a connection's `connected_at_unix` wire field.
152fn now_unix() -> u64 {
153    std::time::SystemTime::now()
154        .duration_since(std::time::UNIX_EPOCH)
155        .map(|d| d.as_secs())
156        .unwrap_or(0)
157}
158
159impl ChannelState {
160    fn new(
161        cmd: Vec<String>,
162        input_tx: mpsc::Sender<Vec<u8>>,
163        notify: Arc<Notify>,
164        created_seq: u64,
165    ) -> Self {
166        Self {
167            session: None,
168            clients: HashMap::new(),
169            subscribers: Vec::new(),
170            notify,
171            created_at_unix: now_unix(),
172            created_seq,
173            cmd,
174            input_tx,
175            kill_pending: false,
176            is_reaped: false,
177        }
178    }
179
180    /// Replace the current session and attach the Notify callback so the
181    /// background polling task wakes on PTY output.
182    fn set_session(&mut self, mut session: Session) {
183        let n = self.notify.clone();
184        session.set_status_callback(Some(Box::new(move |status| {
185            if matches!(status, PtyStatus::Wakeup | PtyStatus::Exited) {
186                n.notify_one();
187            }
188        })));
189        self.session = Some(session);
190        // Prime notify to process initial startup output generated before the
191        // callback was registered.
192        self.notify.notify_one();
193    }
194
195    /// Signal the session's process group (non-blocking) and arm the kill
196    /// escalation flag. The caller is responsible for spawning the detached
197    /// escalation task (see `spawn_kill_escalation`). Mechanism only — no
198    /// sleeps, no waits, no `SIGKILL` here.
199    fn request_session_kill(&mut self, signal: i32) {
200        let _ = &signal;
201        if let Some(session) = self.session.as_mut() {
202            #[cfg(unix)]
203            let _ = session.pty.signal_process_group(signal);
204            #[cfg(not(unix))]
205            let _ = session.pty.kill_child();
206        }
207        self.kill_pending = true;
208        self.notify.notify_one();
209    }
210
211    /// Flush remaining PTY buffers and stream completion markers to all active
212    /// subscribers, then drop them. Used by kill paths and on session exit.
213    fn finalize_subscribers(&mut self) {
214        if let Some(session) = self.session.as_mut() {
215            let raw = session.read_output();
216            if !raw.is_empty() {
217                for sub in &self.subscribers {
218                    sub.respond.respond(raw.clone(), false);
219                }
220            }
221        }
222        for sub in &self.subscribers {
223            sub.respond.respond(Vec::new(), true);
224        }
225        self.subscribers.clear();
226    }
227
228    /// Constrain the PTY to the smallest geometry across all connected clients.
229    /// This guarantees the virtual buffer never exceeds any attached monitor.
230    ///
231    /// Geometry is strictly client-driven: if no connected client has reported
232    /// real dimensions yet (all are still `u16::MAX`), nothing is constrained
233    /// and the session keeps its spawn-time size. No hardcoded default size is
234    /// ever imposed.
235    fn recalculate_pty_size(&mut self) {
236        let Some(session) = self.session.as_mut() else {
237            return;
238        };
239        let Some(min_cols) = self
240            .clients
241            .values()
242            .map(|c| c.cols)
243            .filter(|&c| c != u16::MAX)
244            .min()
245        else {
246            return;
247        };
248        let Some(min_rows) = self
249            .clients
250            .values()
251            .map(|c| c.rows)
252            .filter(|&r| r != u16::MAX)
253            .min()
254        else {
255            return;
256        };
257        let size = PtySize {
258            rows: min_rows,
259            cols: min_cols,
260            pixel_width: 0,
261            pixel_height: 0,
262        };
263        let _ = session.pty.resize(size);
264        session.cols = min_cols;
265        session.rows = min_rows;
266    }
267
268    /// Broadcast geometry to all connected clients via detached async tasks.
269    /// Call AFTER releasing the channel lock.
270    fn notify_clients(&self, clients: &[ClientEntry], cols: u16, rows: u16) {
271        for client in clients {
272            let Some(caller) = client.caller.clone() else {
273                continue;
274            };
275            tokio::spawn(async move {
276                if let Err(e) = OnPtyResized::call(&caller, (cols, rows)).await {
277                    tracing::debug!(error = ?e, "Failed to deliver OnPtyResized notification");
278                }
279            });
280        }
281    }
282
283    fn to_info(&self, name: &ChannelName) -> ChannelInfo {
284        let session = self.session.as_ref().map(|s| SessionInfo {
285            id: s.id,
286            cols: s.cols,
287            rows: s.rows,
288            exited: s.exited,
289            exit_code: s.exit_code,
290            title: s.title.clone().unwrap_or_default(),
291        });
292        // Sort by `conn_id`, which muxio assigns monotonically at connection
293        // accept — ascending order = connection order, newest client last.
294        let mut clients: Vec<ClientInfo> = self
295            .clients
296            .iter()
297            .map(|(conn_id, c)| ClientInfo {
298                conn_id: *conn_id,
299                pid: c.pid,
300                hostname: c.hostname.clone(),
301                connected_at_unix: c.connected_at_unix,
302                cols: c.cols,
303                rows: c.rows,
304                user: c.user.clone(),
305                version: c.version.clone(),
306                ssh_ip: c.ssh_ip.clone(),
307            })
308            .collect();
309        clients.sort_by_key(|c| c.conn_id);
310        ChannelInfo {
311            name: name.to_string(),
312            created_at_unix: self.created_at_unix,
313            session,
314            clients,
315        }
316    }
317}
318
319/// Resolve the channel a connection is bound to, or `None` if unattached.
320async fn bound_channel(state: &ServerState, conn_id: usize) -> Option<ChannelName> {
321    let conns = state.conns.read().await;
322    match conns.get(&conn_id)?.state {
323        ConnState::Attached(ref name) => Some(name.clone()),
324        ConnState::Unattached => None,
325    }
326}
327
328/// Fetch an `Arc<ChannelState>` for the channel, resolving then dropping the
329/// routing guard (never holding `conns` while locking the channel).
330async fn resolve_channel(
331    state: &ServerState,
332    name: &ChannelName,
333) -> Option<Arc<Mutex<ChannelState>>> {
334    let channels = state.channels.read().await;
335    channels.get(name).cloned()
336}
337
338/// Create (or fetch, re-verifying under the write lock) a channel.
339/// Safe double-checked locking: a racing Attach that saw a reaped Arc is
340/// redirected to the canonical instance instead of overwriting it.
341async fn get_or_create_channel(
342    state: &SharedState,
343    name: &ChannelName,
344) -> Arc<Mutex<ChannelState>> {
345    {
346        let channels = state.channels.read().await;
347        if let Some(existing) = channels.get(name) {
348            let arc = existing.clone();
349            drop(channels);
350            let is_reaped = arc.lock().await.is_reaped;
351            if !is_reaped {
352                return arc;
353            }
354        }
355    }
356
357    let mut channels = state.channels.write().await;
358    // Re-check under the write lock: a racing thread may have inserted a
359    // non-reaped channel between our read and write acquisition. A reaped
360    // (or absent) entry falls through and is replaced with a fresh channel.
361    if let Some(existing) = channels.get(name) {
362        let arc = existing.clone();
363        let is_reaped = arc.lock().await.is_reaped;
364        if !is_reaped {
365            return arc;
366        }
367    }
368    let (input_tx, input_rx) = mpsc::channel::<Vec<u8>>(INPUT_CHANNEL_CAPACITY);
369    let notify = Arc::new(Notify::new());
370    let created_seq = state.next_channel_seq.fetch_add(1, Ordering::Relaxed);
371    let channel = Arc::new(Mutex::new(ChannelState::new(
372        Vec::new(),
373        input_tx,
374        notify,
375        created_seq,
376    )));
377    let ch = Arc::clone(&channel);
378    tokio::spawn(async move {
379        let mut input_rx = input_rx;
380        while let Some(data) = input_rx.recv().await {
381            let writer = {
382                let guard = ch.lock().await;
383                guard.session.as_ref().map(|s| s.pty.writer_handle())
384            };
385            if let Some(writer) = writer {
386                let _ = tokio::task::spawn_blocking(move || writer.write_bytes(&data)).await;
387            }
388        }
389        // Note: the input task runs for the daemon lifetime; the channel's
390        // `input_rx` is only dropped when the channel's senders all vanish.
391    });
392
393    // Output polling task: drains PTY output and broadcasts to subscribers;
394    // on session exit finalizes subscribers, clears the session, and reaps
395    // the channel if no clients remain.
396    {
397        let st = Arc::clone(state);
398        let ch = Arc::clone(&channel);
399        let notify = {
400            let locked = ch.lock().await;
401            locked.notify.clone()
402        };
403        let name_for_task = name.clone();
404        tokio::spawn(async move {
405            loop {
406                tokio::select! {
407                    _ = notify.notified() => {}
408                    _ = tokio::time::sleep(SESSION_EXIT_POLL_INTERVAL) => {}
409                }
410                let mut guard = ch.lock().await;
411                if guard.is_reaped {
412                    break;
413                }
414                if guard.subscribers.is_empty() {
415                    if let Some(session) = guard.session.as_mut() {
416                        session.sync_screen();
417                        if session.check_exited() {
418                            tracing::info!(channel = %name_for_task, "Session exited");
419                            guard.session = None;
420                            guard.kill_pending = false;
421                        }
422                    }
423                } else {
424                    let (raw, exited, code) = {
425                        let Some(session) = guard.session.as_mut() else {
426                            // No live session: finalize any lingering subscribers.
427                            for sub in &guard.subscribers {
428                                sub.respond.respond(Vec::new(), true);
429                            }
430                            guard.subscribers.clear();
431                            guard.notify.notify_one();
432                            continue;
433                        };
434                        let raw = session.read_output();
435                        let exited = session.check_exited();
436                        let code = session.exit_code;
437                        (raw, exited, code)
438                    };
439                    if !raw.is_empty() {
440                        for sub in &guard.subscribers {
441                            sub.respond.respond(raw.clone(), false);
442                        }
443                    }
444                    if exited {
445                        tracing::info!(channel = %name_for_task, "Session exited with code {:?}", code);
446                        for sub in &guard.subscribers {
447                            sub.respond.respond(Vec::new(), true);
448                        }
449                        guard.subscribers.clear();
450                        guard.session = None;
451                        guard.kill_pending = false;
452                        guard.notify.notify_one();
453                    }
454                }
455                let should_reap = guard.session.is_none() && guard.clients.is_empty();
456                drop(guard);
457
458                if should_reap {
459                    // GC: drop the channel guard before requesting `channels.write`
460                    // (strict ordering, no AB-BA), then re-verify under the write lock.
461                    let mut channels = st.channels.write().await;
462                    if let Some(arc) = channels.get(&name_for_task) {
463                        let mut locked = arc.lock().await;
464                        if locked.session.is_none() && locked.clients.is_empty() {
465                            locked.is_reaped = true;
466                            drop(locked);
467                            channels.remove(&name_for_task);
468                            tracing::info!(channel = %name_for_task, "Reaped idle channel");
469                        }
470                    }
471                }
472                // Note: the daemon deliberately persists until an explicit
473                // `ShutdownGateway` / `term-session stop`. Sessions survive
474                // client disconnects; idle channels are reaped above but the
475                // gateway process itself is never torn down implicitly.
476            }
477        });
478    }
479
480    channels.insert(name.clone(), Arc::clone(&channel));
481    channel
482}
483
484/// Remove a connection from the routing table and prune it from its bound
485/// channel's client/subscriber maps (authoritative teardown on disconnect).
486async fn evict_conn(state: &ServerState, conn_id: usize) {
487    let channel = {
488        let mut conns = state.conns.write().await;
489        let entry = conns.remove(&conn_id);
490        entry.and_then(|e| match e.state {
491            ConnState::Attached(name) => Some(name),
492            ConnState::Unattached => None,
493        })
494    };
495    let Some(channel) = channel else {
496        return;
497    };
498    let Some(ch) = resolve_channel(state, &channel).await else {
499        return;
500    };
501    let mut guard = ch.lock().await;
502    guard.clients.remove(&conn_id);
503    guard.subscribers.retain(|s| s.conn_id != conn_id);
504    guard.recalculate_pty_size();
505    // Broadcast the session's actual (client-driven) geometry to remaining
506    // clients. If no session exists there is nothing to broadcast.
507    let session_size = guard.session.as_ref().map(|s| (s.cols, s.rows));
508    let targets: Vec<ClientEntry> = guard.clients.values().cloned().collect();
509    drop(guard);
510    let Some((ncols, nrows)) = session_size else {
511        return;
512    };
513    // Best-effort geometry broadcast; the channel may be reaped concurrently,
514    // in which case clients already see end-of-stream.
515    if let Some(ch) = resolve_channel(state, &channel).await {
516        let guard = ch.lock().await;
517        guard.notify_clients(&targets, ncols, nrows);
518    }
519}
520
521/// Spawn a detached escalation task for a kill-requested session, returning
522/// its `JoinHandle` so the caller (e.g. shutdown teardown) can await it.
523///
524/// Policy owned by the daemon supervisor (never the pty engine): after an
525/// async `SIGKILL_GRACE` sleep, re-check the session state; if it already
526/// exited during the grace window (reaped by the output-polling task), abort
527/// instantly — no blind `SIGKILL` to a possibly-recycled pgid. Only escalate
528/// if the session is still alive and the kill is still pending.
529async fn spawn_kill_escalation(
530    state: &SharedState,
531    name: &ChannelName,
532) -> tokio::task::JoinHandle<()> {
533    let state = Arc::clone(state);
534    let name = name.clone();
535    tokio::spawn(async move {
536        tokio::time::sleep(SIGKILL_GRACE).await;
537        let Some(ch) = resolve_channel(&state, &name).await else {
538            return;
539        };
540        let mut guard = ch.lock().await;
541        if !guard.kill_pending {
542            return;
543        }
544        // Abort if the session exited during the grace (or is already gone).
545        let alive = guard
546            .session
547            .as_ref()
548            .is_some_and(|s| !s.exited && s.pty.reader_is_alive());
549        guard.kill_pending = false;
550        if !alive {
551            return;
552        }
553        if let Some(session) = guard.session.as_mut() {
554            #[cfg(unix)]
555            let _ = session.pty.signal_process_group(SIGKILL);
556            #[cfg(not(unix))]
557            let _ = session.pty.kill_child();
558        }
559    })
560}
561
562/// Run the gateway daemon. Hosts every channel in one process; returns after
563/// a `ShutdownGateway` (or transport error).
564pub async fn run_gateway(
565    gateway: ChannelName,
566) -> Result<i32, Box<dyn std::error::Error + Send + Sync>> {
567    let socket_name = gateway.to_string();
568    let state: SharedState = Arc::new(ServerState {
569        conns: RwLock::new(HashMap::new()),
570        channels: RwLock::new(HashMap::new()),
571        is_shutting_down: AtomicBool::new(false),
572        next_channel_seq: AtomicU64::new(0),
573    });
574
575    let (event_tx, mut event_rx) = mpsc::unbounded_channel();
576    let server = RpcIpcServer::new(Some(event_tx));
577    let endpoint = server.endpoint();
578
579    // ── Attach ────────────────────────────────────────────────────────
580    let st = Arc::clone(&state);
581    endpoint
582        .register_prebuffered(Attach::METHOD_ID, move |payload, ctx| {
583            let state = Arc::clone(&st);
584            async move {
585                if state.is_shutting_down.load(Ordering::SeqCst) {
586                    return Err(rpc_err(RPC_ERROR_SHUTTING_DOWN));
587                }
588                let req = Attach::decode_request(&payload)?;
589                let name = ChannelName::parse(&req.channel).map_err(|e| rpc_err(&e))?;
590                let _channel = get_or_create_channel(&state, &name).await;
591                let conn_id = ctx.conn_id;
592                let mut conns = state.conns.write().await;
593                // The `ClientConnected` event is processed by a separate async
594                // loop, so it may not have inserted the entry yet when this
595                // handler runs. Ensure the entry exists before binding.
596                let entry = conns.entry(conn_id).or_insert_with(|| ConnEntry {
597                    handle: RpcIpcConnectionContextHandle(ctx.clone()),
598                    state: ConnState::Unattached,
599                    hostname: String::new(),
600                    connected_at_unix: now_unix(),
601                    pid: 0,
602                    user: String::new(),
603                    version: String::new(),
604                    ssh_ip: None,
605                });
606                entry.state = ConnState::Attached(name);
607                entry.hostname = req.hostname;
608                entry.connected_at_unix = now_unix();
609                entry.pid = req.pid;
610                entry.user = req.user;
611                entry.version = req.version;
612                entry.ssh_ip = req.ssh_ip;
613                Attach::encode_response(conn_id).map_err(boxed_io)
614            }
615        })
616        .await
617        .map_err(|e| format!("register Attach: {e:?}"))?;
618
619    // ── Spawn ────────────────────────────────────────────────────────
620    let st = Arc::clone(&state);
621    endpoint
622        .register_prebuffered(Spawn::METHOD_ID, move |payload, ctx| {
623            let state = Arc::clone(&st);
624            async move {
625                if state.is_shutting_down.load(Ordering::SeqCst) {
626                    return Err(rpc_err(RPC_ERROR_SHUTTING_DOWN));
627                }
628                let SpawnRequest {
629                    cmd,
630                    cols,
631                    rows,
632                    cwd,
633                } = Spawn::decode_request(&payload)?;
634                let channel = bound_channel(state.as_ref(), ctx.conn_id).await;
635                let Some(channel) = channel else {
636                    return Err(rpc_err(RPC_ERROR_UNATTACHED));
637                };
638                let ch = get_or_create_channel(&state, &channel).await;
639                // Fetch the connection's entry (caller handle, hostname,
640                // identity) before locking the channel (never hold two locks).
641                let conn_meta = {
642                    let conns = state.conns.read().await;
643                    conns.get(&ctx.conn_id).cloned()
644                };
645                let mut guard = ch.lock().await;
646                let entry = guard
647                    .clients
648                    .entry(ctx.conn_id)
649                    .or_insert_with(|| ClientEntry {
650                        caller: conn_meta.as_ref().map(|c| c.handle.clone()),
651                        hostname: conn_meta
652                            .as_ref()
653                            .map(|c| c.hostname.clone())
654                            .unwrap_or_default(),
655                        connected_at_unix: conn_meta
656                            .as_ref()
657                            .map(|c| c.connected_at_unix)
658                            .unwrap_or(0),
659                        pid: conn_meta.as_ref().map(|c| c.pid).unwrap_or(0),
660                        user: conn_meta
661                            .as_ref()
662                            .map(|c| c.user.clone())
663                            .unwrap_or_default(),
664                        version: conn_meta
665                            .as_ref()
666                            .map(|c| c.version.clone())
667                            .unwrap_or_default(),
668                        ssh_ip: conn_meta.as_ref().and_then(|c| c.ssh_ip.clone()),
669                        cols,
670                        rows,
671                    });
672                entry.cols = cols;
673                entry.rows = rows;
674
675                // If a session already exists and hasn't exited, reuse it.
676                if guard.session.as_ref().is_some_and(|s| !s.exited) {
677                    guard.recalculate_pty_size();
678                    let session = guard.session.as_ref().unwrap();
679                    let (ncols, nrows) = (session.cols, session.rows);
680                    let targets: Vec<ClientEntry> = guard.clients.values().cloned().collect();
681                    let id = session.id;
682                    let cols = session.cols;
683                    let rows = session.rows;
684                    drop(guard);
685                    if let Some(ch) = resolve_channel(state.as_ref(), &channel).await {
686                        let g = ch.lock().await;
687                        g.notify_clients(&targets, ncols, nrows);
688                    }
689                    return Spawn::encode_response(SpawnResponse { id, cols, rows })
690                        .map_err(boxed_io);
691                }
692
693                // Respawn: new non-empty cmd overwrites the stored template;
694                // empty/None falls back to the existing template.
695                let effective_cmd = if let Some(c) = cmd
696                    && !c.is_empty()
697                {
698                    guard.cmd = c.clone();
699                    Some(c)
700                } else if !guard.cmd.is_empty() {
701                    Some(guard.cmd.clone())
702                } else {
703                    None
704                };
705                // Spawn in the client's launch directory when provided (the
706                // caller expects to land where they ran `term-session`), else
707                // fall back to the daemon's cwd for legacy/empty payloads.
708                let effective_cwd = cwd.filter(|c| !c.is_empty());
709                let id = SESSION_ID;
710                let session = Session::spawn(
711                    id,
712                    effective_cmd,
713                    cols,
714                    rows,
715                    Some(&channel),
716                    effective_cwd.as_ref(),
717                )?;
718                guard.set_session(session);
719                guard.recalculate_pty_size();
720                let targets: Vec<ClientEntry> = guard.clients.values().cloned().collect();
721                let session = guard.session.as_ref().unwrap();
722                let (sid, scol, srow) = (session.id, session.cols, session.rows);
723                let (ncols, nrows) = (scol, srow);
724                drop(guard);
725                if let Some(ch) = resolve_channel(state.as_ref(), &channel).await {
726                    let g = ch.lock().await;
727                    g.notify_clients(&targets, ncols, nrows);
728                }
729                Spawn::encode_response(SpawnResponse {
730                    id: sid,
731                    cols: scol,
732                    rows: srow,
733                })
734                .map_err(boxed_io)
735            }
736        })
737        .await
738        .map_err(|e| format!("register Spawn: {e:?}"))?;
739
740    // ── ResizePty ────────────────────────────────────────────────────
741    let st = Arc::clone(&state);
742    endpoint
743        .register_prebuffered(ResizePty::METHOD_ID, move |payload, ctx| {
744            let state = Arc::clone(&st);
745            async move {
746                if state.is_shutting_down.load(Ordering::SeqCst) {
747                    return Err(rpc_err(RPC_ERROR_SHUTTING_DOWN));
748                }
749                let (_id, cols, rows) = ResizePty::decode_request(&payload)?;
750                let channel = bound_channel(state.as_ref(), ctx.conn_id).await;
751                let Some(channel) = channel else {
752                    return Err(rpc_err(RPC_ERROR_UNATTACHED));
753                };
754                let ch = resolve_channel(state.as_ref(), &channel)
755                    .await
756                    .ok_or_else(|| rpc_err("channel not found"))?;
757                let mut guard = ch.lock().await;
758                if let Some(client) = guard.clients.get_mut(&ctx.conn_id) {
759                    client.cols = cols;
760                    client.rows = rows;
761                }
762                guard.recalculate_pty_size();
763                let (ncols, nrows) = guard
764                    .session
765                    .as_ref()
766                    .map(|s| (s.cols, s.rows))
767                    .unwrap_or((cols, rows));
768                let targets: Vec<ClientEntry> = guard.clients.values().cloned().collect();
769                drop(guard);
770                if let Some(ch) = resolve_channel(state.as_ref(), &channel).await {
771                    let g = ch.lock().await;
772                    g.notify_clients(&targets, ncols, nrows);
773                }
774                ResizePty::encode_response((ncols, nrows)).map_err(boxed_io)
775            }
776        })
777        .await
778        .map_err(|e| format!("register ResizePty: {e:?}"))?;
779
780    // ── CloseSession ─────────────────────────────────────────────────
781    let st = Arc::clone(&state);
782    endpoint
783        .register_prebuffered(CloseSession::METHOD_ID, move |payload, ctx| {
784            let state = Arc::clone(&st);
785            async move {
786                if state.is_shutting_down.load(Ordering::SeqCst) {
787                    return Err(rpc_err(RPC_ERROR_SHUTTING_DOWN));
788                }
789                let _id = CloseSession::decode_request(&payload)?;
790                let channel = bound_channel(state.as_ref(), ctx.conn_id).await;
791                let Some(channel) = channel else {
792                    return Err(rpc_err(RPC_ERROR_UNATTACHED));
793                };
794                let ch = resolve_channel(state.as_ref(), &channel)
795                    .await
796                    .ok_or_else(|| rpc_err("channel not found"))?;
797                let mut guard = ch.lock().await;
798                guard.request_session_kill(SIGTERM);
799                guard.finalize_subscribers();
800                drop(guard);
801                // Spawn the exited-checked SIGKILL escalation for stragglers.
802                spawn_kill_escalation(&state, &channel).await;
803                CloseSession::encode_response(()).map_err(boxed_io)
804            }
805        })
806        .await
807        .map_err(|e| format!("register CloseSession: {e:?}"))?;
808
809    // ── WriteInput ───────────────────────────────────────────────────
810    let st = Arc::clone(&state);
811    endpoint
812        .register_prebuffered(WriteInput::METHOD_ID, move |payload, ctx| {
813            let state = Arc::clone(&st);
814            async move {
815                if state.is_shutting_down.load(Ordering::SeqCst) {
816                    return Err(rpc_err(RPC_ERROR_SHUTTING_DOWN));
817                }
818                let (id, data) = WriteInput::decode_request(&payload)?;
819                let channel = bound_channel(state.as_ref(), ctx.conn_id).await;
820                let Some(channel) = channel else {
821                    return Err(rpc_err(RPC_ERROR_UNATTACHED));
822                };
823                let ch = resolve_channel(state.as_ref(), &channel)
824                    .await
825                    .ok_or_else(|| rpc_err("channel not found"))?;
826                let writer = {
827                    let guard = ch.lock().await;
828                    guard
829                        .session
830                        .as_ref()
831                        .filter(|s| s.id == id)
832                        .map(|s| s.pty.writer_handle())
833                };
834                // PTY writes are blocking I/O (kernel input buffer); offload
835                // to the blocking pool so a full buffer never stalls an async
836                // worker or holds the state lock.
837                if let Some(writer) = writer {
838                    let _ = tokio::task::spawn_blocking(move || writer.write_bytes(&data)).await;
839                }
840                WriteInput::encode_response(()).map_err(boxed_io)
841            }
842        })
843        .await
844        .map_err(|e| format!("register WriteInput: {e:?}"))?;
845
846    // ── StreamInput ──────────────────────────────────────────────────
847    let st = Arc::clone(&state);
848    endpoint
849        .register_stream_handler(STREAM_INPUT_METHOD_ID, move |event, _responder, ctx| {
850            if let RpcStreamEvent::PayloadChunk { bytes, .. } = event {
851                let state = Arc::clone(&st);
852                let conn_id = ctx.conn_id;
853                tokio::spawn(async move {
854                    let channel = bound_channel(state.as_ref(), conn_id).await;
855                    let Some(channel) = channel else {
856                        return;
857                    };
858                    let ch = resolve_channel(state.as_ref(), &channel).await;
859                    let Some(ch) = ch else {
860                        return;
861                    };
862                    let tx = {
863                        let guard = ch.lock().await;
864                        guard.input_tx.clone()
865                    };
866                    if let Err(e) = tx.try_send(bytes) {
867                        tracing::warn!(error = %e, "gateway input buffer full; dropping input chunk");
868                    }
869                });
870            }
871            // Intentionally ignore End/Error — the channel stays alive.
872        })
873        .await
874        .map_err(|e| format!("register stream handler STREAM_INPUT: {e:?}"))?;
875
876    // ── SubscribeOutput ──────────────────────────────────────────────
877    let st = Arc::clone(&state);
878    endpoint
879        .register_stream_handler(SUBSCRIBE_OUTPUT_METHOD_ID, move |event, respond, ctx| {
880            let is_new = matches!(&event, RpcStreamEvent::Header { .. });
881            if is_new {
882                let st = Arc::clone(&st);
883                let conn_id = ctx.conn_id;
884                tokio::spawn(async move {
885                    let channel = bound_channel(&st, conn_id).await;
886                    let Some(channel) = channel else {
887                        return;
888                    };
889                    let ch = resolve_channel(&st, &channel).await;
890                    let Some(ch) = ch else {
891                        return;
892                    };
893                    let mut guard = ch.lock().await;
894                    // Drain accumulated PTY output and capture the raw bytes
895                    // so they can be sent to the new subscriber.
896                    let early = guard.session.as_mut().and_then(|s| {
897                        let data = s.read_output();
898                        if data.is_empty() { None } else { Some(data) }
899                    });
900                    let snapshot = guard.session.as_mut().map(|s| s.generate_snapshot());
901                    guard.subscribers.push(SubscriberEntry {
902                        conn_id,
903                        respond: respond.clone(),
904                    });
905                    guard.notify.notify_one();
906                    let is_dead = guard.session.is_none();
907                    drop(guard);
908                    if let Some(data) = snapshot
909                        && !data.is_empty()
910                    {
911                        respond.respond(data, false);
912                    }
913                    if let Some(data) = early {
914                        respond.respond(data, false);
915                    }
916                    if is_dead {
917                        respond.respond(Vec::new(), true);
918                    }
919                });
920            }
921        })
922        .await
923        .map_err(|e| format!("register SubscribeOutput: {e:?}"))?;
924
925    // ── ListChannels ─────────────────────────────────────────────────
926    let st = Arc::clone(&state);
927    let list_socket = socket_name.clone();
928    endpoint
929        .register_prebuffered(ListChannels::METHOD_ID, move |_payload, _ctx| {
930            let state = Arc::clone(&st);
931            let socket = list_socket.clone();
932            async move {
933                let channels = {
934                    let chans = state.channels.read().await;
935                    chans
936                        .iter()
937                        .map(|(k, v)| (k.clone(), v.clone()))
938                        .collect::<Vec<_>>()
939                };
940                // Creation order, newest last (monotonic `created_seq`, not
941                // second-resolution wall clock). The seq is read under the same
942                // per-channel lock used to build `ChannelInfo` below.
943                let mut out: Vec<(u64, ChannelInfo)> = Vec::with_capacity(channels.len());
944                for (name, ch) in channels {
945                    let guard = ch.lock().await;
946                    out.push((guard.created_seq, guard.to_info(&name)));
947                }
948                out.sort_by_key(|(seq, _)| *seq);
949                let out: Vec<ChannelInfo> = out.into_iter().map(|(_, info)| info).collect();
950                ListChannels::encode_response(ListChannelsResponse {
951                    gateway_pid: std::process::id() as u64,
952                    socket,
953                    channels: out,
954                })
955                .map_err(boxed_io)
956            }
957        })
958        .await
959        .map_err(|e| format!("register ListChannels: {e:?}"))?;
960
961    // ── KillChannel ──────────────────────────────────────────────────
962    let st = Arc::clone(&state);
963    endpoint
964        .register_prebuffered(KillChannel::METHOD_ID, move |payload, _ctx| {
965            let state = Arc::clone(&st);
966            async move {
967                if state.is_shutting_down.load(Ordering::SeqCst) {
968                    return Err(rpc_err(RPC_ERROR_SHUTTING_DOWN));
969                }
970                let channel_str = KillChannel::decode_request(&payload)?;
971                let name = ChannelName::parse(&channel_str).map_err(|e| rpc_err(&e))?;
972                // 1) Snapshot the target connections under `conns.write`, then release.
973                let target_conns: Vec<usize> = {
974                    let conns = state.conns.read().await;
975                    conns
976                        .iter()
977                        .filter(|(_, entry)| matches!(entry.state, ConnState::Attached(ref n) if n == &name))
978                        .map(|(conn_id, _)| *conn_id)
979                        .collect()
980                };
981                // 2) Lock the channel: signal the session tree and evict every socket.
982                if let Some(ch) = resolve_channel(state.as_ref(), &name).await {
983                    let mut guard = ch.lock().await;
984                    guard.request_session_kill(SIGTERM);
985                    guard.finalize_subscribers();
986                    for conn_id in &target_conns {
987                        guard.clients.remove(conn_id);
988                        guard.subscribers.retain(|s| s.conn_id != *conn_id);
989                    }
990                    drop(guard);
991                    spawn_kill_escalation(&state, &name).await;
992                }
993                // 3) Evict the ConnEntry records from the routing table.
994                let mut conns = state.conns.write().await;
995                for conn_id in &target_conns {
996                    conns.remove(conn_id);
997                }
998                KillChannel::encode_response(()).map_err(boxed_io)
999            }
1000        })
1001        .await
1002        .map_err(|e| format!("register KillChannel: {e:?}"))?;
1003
1004    // ── KillClient ───────────────────────────────────────────────────
1005    let st = Arc::clone(&state);
1006    endpoint
1007        .register_prebuffered(KillClient::METHOD_ID, move |payload, _ctx| {
1008            let state = Arc::clone(&st);
1009            async move {
1010                if state.is_shutting_down.load(Ordering::SeqCst) {
1011                    return Err(rpc_err(RPC_ERROR_SHUTTING_DOWN));
1012                }
1013                let (channel_str, conn_id) = KillClient::decode_request(&payload)?;
1014                let name = ChannelName::parse(&channel_str).map_err(|e| rpc_err(&e))?;
1015                // Reject if the conn does not exist or is not attached to the
1016                // named channel — kill-client must target a real client.
1017                let bound_channel = {
1018                    let conns = state.conns.read().await;
1019                    conns.get(&conn_id).and_then(|c| match &c.state {
1020                        ConnState::Attached(n) if n == &name => Some(n.clone()),
1021                        _ => None,
1022                    })
1023                };
1024                let Some(bound) = bound_channel else {
1025                    return Err(rpc_err(&format!(
1026                        "client {conn_id} is not attached to channel '{name}'"
1027                    )));
1028                };
1029                // Evict the conn first (conns → channel ordering).
1030                {
1031                    let mut conns = state.conns.write().await;
1032                    conns.remove(&conn_id);
1033                }
1034                if let Some(ch) = resolve_channel(state.as_ref(), &bound).await {
1035                    let mut guard = ch.lock().await;
1036                    // End the evicted subscriber's stream before dropping it.
1037                    let mut evicted: Vec<StreamResponder> = Vec::new();
1038                    let mut keep = Vec::with_capacity(guard.subscribers.len());
1039                    for sub in guard.subscribers.drain(..) {
1040                        if sub.conn_id == conn_id {
1041                            evicted.push(sub.respond);
1042                        } else {
1043                            keep.push(sub);
1044                        }
1045                    }
1046                    guard.subscribers = keep;
1047                    for respond in evicted {
1048                        respond.respond(Vec::new(), true);
1049                    }
1050                    guard.clients.remove(&conn_id);
1051                    guard.recalculate_pty_size();
1052                    let session_size = guard.session.as_ref().map(|s| (s.cols, s.rows));
1053                    let targets: Vec<ClientEntry> = guard.clients.values().cloned().collect();
1054                    drop(guard);
1055                    if let (Some((ncols, nrows)), Some(ch)) =
1056                        (session_size, resolve_channel(state.as_ref(), &bound).await)
1057                    {
1058                        let g = ch.lock().await;
1059                        g.notify_clients(&targets, ncols, nrows);
1060                    }
1061                }
1062                KillClient::encode_response(()).map_err(boxed_io)
1063            }
1064        })
1065        .await
1066        .map_err(|e| format!("register KillClient: {e:?}"))?;
1067
1068    // ── ShutdownGateway ──────────────────────────────────────────────
1069    let st = Arc::clone(&state);
1070    let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
1071    let shutdown_tx = Arc::new(Mutex::new(Some(shutdown_tx)));
1072    endpoint
1073        .register_prebuffered(ShutdownGateway::METHOD_ID, move |payload, _ctx| {
1074            let state = Arc::clone(&st);
1075            let shutdown_tx = Arc::clone(&shutdown_tx);
1076            async move {
1077                // Refuse an accidental shutdown while live sessions are running
1078                // unless the caller explicitly forced it. Checked BEFORE the
1079                // `is_shutting_down` seal so a refused stop leaves the gateway
1080                // fully operational (no half-sealed state, no orphaned teardown).
1081                let force = ShutdownGateway::decode_request(&payload).map_err(boxed_io)?;
1082                if !force {
1083                    let live = {
1084                        let chans = state.channels.read().await;
1085                        let mut n = 0usize;
1086                        for ch in chans.values() {
1087                            let guard = ch.lock().await;
1088                            if guard.session.as_ref().is_some_and(|s| !s.exited) {
1089                                n += 1;
1090                            }
1091                        }
1092                        n
1093                    };
1094                    if live > 0 {
1095                        return Err(rpc_err(&format!(
1096                            "{RPC_ERROR_LIVE_SESSIONS} ({live} live session(s))"
1097                        )));
1098                    }
1099                }
1100                // Atomic seal: reject all further RPCs before teardown starts.
1101                state.is_shutting_down.store(true, Ordering::SeqCst);
1102                // Snapshot the channels, then release the map lock.
1103                let channels: Vec<(ChannelName, Arc<Mutex<ChannelState>>)> = {
1104                    let chans = state.channels.read().await;
1105                    chans.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
1106                };
1107                // Off-lock: signal each session's process group (non-blocking),
1108                // finalize subscribers, and collect the escalation handles so
1109                // the exit signal fires only after every child tree is reaped.
1110                let mut escalations = Vec::new();
1111                for (name, ch) in channels {
1112                    let mut guard = ch.lock().await;
1113                    tracing::info!(channel = %name, "Shutdown: signaling session tree");
1114                    guard.request_session_kill(SIGTERM);
1115                    guard.finalize_subscribers();
1116                    drop(guard);
1117                    escalations.push(spawn_kill_escalation(&state, &name).await);
1118                }
1119                // Deferred exit signal: await the SIGKILL escalation tasks so
1120                // all child process trees are definitively terminated, then
1121                // sleep a grace so the transport flushes the `()` response
1122                // frame, then fire the oneshot that ends run_gateway. Never
1123                // fire it synchronously from the handler.
1124                tokio::spawn(async move {
1125                    for handle in escalations {
1126                        let _ = handle.await;
1127                    }
1128                    tokio::time::sleep(std::time::Duration::from_millis(SHUTDOWN_FLUSH_GRACE_MS))
1129                        .await;
1130                    let mut tx_guard = shutdown_tx.lock().await;
1131                    if let Some(tx) = tx_guard.take() {
1132                        let _ = tx.send(());
1133                    }
1134                });
1135                ShutdownGateway::encode_response(()).map_err(boxed_io)
1136            }
1137        })
1138        .await
1139        .map_err(|e| format!("register ShutdownGateway: {e:?}"))?;
1140
1141    // ── Connection event loop ────────────────────────────────────────
1142    let st = Arc::clone(&state);
1143    tokio::spawn(async move {
1144        while let Some(event) = event_rx.recv().await {
1145            match event {
1146                RpcIpcServerEvent::ClientConnected(handle) => {
1147                    tracing::info!("Client {} connected", handle.0.conn_id);
1148                    let mut conns = st.conns.write().await;
1149                    // A fast client may send Attach before this event is
1150                    // processed; the Attach handler already inserted an
1151                    // `Attached` entry. `insert` would clobber that binding and
1152                    // a subsequent Spawn would see the state reset to
1153                    // Unattached. Only create the entry if it is absent.
1154                    conns.entry(handle.0.conn_id).or_insert_with(|| ConnEntry {
1155                        handle: handle.clone(),
1156                        state: ConnState::Unattached,
1157                        hostname: String::new(),
1158                        connected_at_unix: now_unix(),
1159                        pid: 0,
1160                        user: String::new(),
1161                        version: String::new(),
1162                        ssh_ip: None,
1163                    });
1164                }
1165                RpcIpcServerEvent::ClientDisconnected(conn_id) => {
1166                    tracing::info!("Client {conn_id} disconnected");
1167                    evict_conn(st.as_ref(), conn_id).await;
1168                }
1169            }
1170        }
1171    });
1172
1173    tracing::info!("Gateway listening on channel {gateway}");
1174
1175    // Wait for either the server to finish or a shutdown signal.
1176    let exit_code = tokio::select! {
1177        result = async {
1178            server.serve(&socket_name).await.map_err(|e| format!("serve: {e:?}"))
1179        } => {
1180            result?;
1181            0
1182        }
1183        _ = &mut shutdown_rx => {
1184            // Give the transport time to flush final subscriber frames.
1185            tokio::time::sleep(SESSION_EXIT_FLUSH_GRACE).await;
1186            0
1187        }
1188    };
1189
1190    Ok(exit_code)
1191}