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