Skip to main content

term_session_server/
session_server.rs

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