Skip to main content

term_session_server/
session_server.rs

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