Skip to main content

term_session_server/
session_server.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use muxio_core::rpc::rpc_internals::RpcStreamEvent;
5use muxio_rpc_service::prebuffered::RpcMethodPrebuffered;
6use muxio_rpc_service_caller::prebuffered::RpcCallPrebuffered;
7use muxio_rpc_service_endpoint::{RpcServiceEndpointInterface, StreamResponder};
8use muxio_tokio_rpc_ipc_server::{RpcIpcConnectionContextHandle, RpcIpcServer, RpcIpcServerEvent};
9use portable_pty::PtySize;
10use tokio::sync::{Mutex, Notify, mpsc, oneshot};
11
12use term_session_muxio_service_definitions::{
13    ChannelName, CloseSession, ListSessions, OnPtyResized, ResizePty, STREAM_INPUT_METHOD_ID,
14    SUBSCRIBE_OUTPUT_METHOD_ID, Spawn, WriteInput,
15};
16use term_wm_pty_engine::PtyStatus;
17
18use crate::session::Session;
19
20/// Default terminal columns when no client constrains the PTY size.
21const FALLBACK_COLS: u16 = 80;
22/// Default terminal rows when no client constrains the PTY size.
23const FALLBACK_ROWS: u16 = 24;
24/// Hardcoded singleton session ID (this server manages one 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 server 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
37pub struct SessionServerConfig {
38    pub channel: ChannelName,
39    pub cmd: Vec<String>,
40    pub cols: u16,
41    pub rows: u16,
42}
43
44#[derive(Clone)]
45struct ClientEntry {
46    caller: Option<RpcIpcConnectionContextHandle>,
47    cols: u16,
48    rows: u16,
49}
50
51struct SubscriberEntry {
52    conn_id: usize,
53    respond: StreamResponder,
54}
55
56struct ServerState {
57    session: Option<Session>,
58    clients: HashMap<usize, ClientEntry>,
59    subscribers: Vec<SubscriberEntry>,
60    notify: Arc<Notify>,
61}
62
63impl ServerState {
64    fn new(notify: Arc<Notify>) -> Self {
65        Self {
66            session: None,
67            clients: HashMap::new(),
68            subscribers: Vec::new(),
69            notify,
70        }
71    }
72
73    /// Replace the current session and attach the Notify callback
74    /// so the background polling task is woken on PTY output.
75    fn set_session(&mut self, mut session: Session) {
76        let n = self.notify.clone();
77        session.set_status_callback(Some(Box::new(move |status| {
78            if matches!(status, PtyStatus::Wakeup | PtyStatus::Exited) {
79                n.notify_one();
80            }
81        })));
82        self.session = Some(session);
83        // Prime notify to process initial startup output generated
84        // before the callback was registered.
85        self.notify.notify_one();
86    }
87
88    /// Terminate and clear the active session, flushing remaining PTY buffers
89    /// and stream completion markers to all active subscribers.
90    fn clear_session(&mut self) {
91        if let Some(mut session) = self.session.take() {
92            let _ = session.pty.kill_child();
93            let raw = session.read_output();
94            if !raw.is_empty() {
95                for sub in &self.subscribers {
96                    sub.respond.respond(raw.clone(), false);
97                }
98            }
99        }
100        for sub in &self.subscribers {
101            sub.respond.respond(Vec::new(), true);
102        }
103        self.subscribers.clear();
104        self.notify.notify_one();
105    }
106
107    /// Constrain the PTY to the smallest geometry across all connected clients.
108    /// This guarantees the virtual buffer never exceeds any attached monitor.
109    fn recalculate_pty_size(&mut self) {
110        let Some(session) = self.session.as_mut() else {
111            return;
112        };
113        if self.clients.is_empty() {
114            return;
115        }
116        let min_cols = self
117            .clients
118            .values()
119            .map(|c| c.cols)
120            .filter(|&c| c != u16::MAX)
121            .min()
122            .unwrap_or(FALLBACK_COLS);
123        let min_rows = self
124            .clients
125            .values()
126            .map(|c| c.rows)
127            .filter(|&r| r != u16::MAX)
128            .min()
129            .unwrap_or(FALLBACK_ROWS);
130        let size = PtySize {
131            rows: min_rows,
132            cols: min_cols,
133            pixel_width: 0,
134            pixel_height: 0,
135        };
136        let _ = session.pty.resize(size);
137        session.cols = min_cols;
138        session.rows = min_rows;
139    }
140
141    /// Broadcast geometry to all clients via detached async tasks.
142    /// Call AFTER releasing the ServerState lock.
143    fn notify_clients(clients: &[ClientEntry], cols: u16, rows: u16) {
144        for client in clients {
145            let Some(caller) = client.caller.clone() else {
146                continue;
147            };
148            tokio::spawn(async move {
149                if let Err(e) = OnPtyResized::call(&caller, (cols, rows)).await {
150                    tracing::debug!(error = ?e, "Failed to deliver OnPtyResized notification");
151                }
152            });
153        }
154    }
155}
156
157type SharedState = Arc<Mutex<ServerState>>;
158
159/// Run the session server. Returns the PTY child's exit code on success.
160pub async fn run_server(
161    config: SessionServerConfig,
162) -> Result<i32, Box<dyn std::error::Error + Send + Sync>> {
163    let socket_name = config.channel.to_string();
164    let notify = Arc::new(Notify::new());
165    let state: SharedState = Arc::new(Mutex::new(ServerState::new(notify.clone())));
166
167    {
168        let mut st = state.lock().await;
169        let cmd = if config.cmd.is_empty() {
170            None
171        } else {
172            Some(config.cmd.clone())
173        };
174        let session = Session::spawn(
175            SESSION_ID,
176            cmd,
177            config.cols,
178            config.rows,
179            Some(&config.channel),
180        )?;
181        st.set_session(session);
182    }
183
184    let channel_id = config.channel.clone();
185
186    let (event_tx, mut event_rx) = mpsc::unbounded_channel();
187    let server = RpcIpcServer::new(Some(event_tx));
188    let endpoint = server.endpoint();
189
190    // Register Spawn
191    let st = Arc::clone(&state);
192    let ch = channel_id.clone();
193    endpoint
194        .register_prebuffered(Spawn::METHOD_ID, move |payload, ctx| {
195            let state = Arc::clone(&st);
196            let ch = ch.clone();
197            async move {
198                let mut guard = state.lock().await;
199                let (cmd, cols, rows) = Spawn::decode_request(&payload)
200                    .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
201                let entry = guard
202                    .clients
203                    .entry(ctx.conn_id)
204                    .or_insert_with(|| ClientEntry {
205                        caller: None,
206                        cols,
207                        rows,
208                    });
209                entry.cols = cols;
210                entry.rows = rows;
211
212                // If a session already exists and hasn't exited, reuse it.
213                if guard.session.as_ref().is_some_and(|s| !s.exited) {
214                    guard.recalculate_pty_size();
215                    let (ncols, nrows) = guard
216                        .session
217                        .as_ref()
218                        .map(|s| (s.cols, s.rows))
219                        .unwrap_or((FALLBACK_COLS, FALLBACK_ROWS));
220                    let targets: Vec<ClientEntry> = guard.clients.values().cloned().collect();
221                    let id = guard.session.as_ref().map(|s| s.id).unwrap_or(SESSION_ID);
222                    let cols = guard.session.as_ref().map(|s| s.cols).unwrap_or(cols);
223                    let rows = guard.session.as_ref().map(|s| s.rows).unwrap_or(rows);
224                    drop(guard);
225                    ServerState::notify_clients(&targets, ncols, nrows);
226                    return Spawn::encode_response((id, cols, rows))
227                        .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>);
228                }
229                let id = SESSION_ID;
230                let session = Session::spawn(id, cmd, cols, rows, Some(&ch))?;
231                guard.set_session(session);
232                // Enforce global geometric constraints on the newly instantiated PTY
233                guard.recalculate_pty_size();
234                let (ncols, nrows) = guard
235                    .session
236                    .as_ref()
237                    .map(|s| (s.cols, s.rows))
238                    .unwrap_or((FALLBACK_COLS, FALLBACK_ROWS));
239                let targets: Vec<ClientEntry> = guard.clients.values().cloned().collect();
240                let session = guard.session.as_ref().unwrap();
241                let (sid, scol, srow) = (session.id, session.cols, session.rows);
242                drop(guard);
243                ServerState::notify_clients(&targets, ncols, nrows);
244                Spawn::encode_response((sid, scol, srow))
245                    .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
246            }
247        })
248        .await
249        .map_err(|e| format!("register Spawn: {e:?}"))?;
250
251    // Register ResizePty
252    let st = Arc::clone(&state);
253    endpoint
254        .register_prebuffered(ResizePty::METHOD_ID, move |payload, ctx| {
255            let state = Arc::clone(&st);
256            async move {
257                let (_id, cols, rows) = ResizePty::decode_request(&payload)
258                    .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
259                let mut guard = state.lock().await;
260                if let Some(client) = guard.clients.get_mut(&ctx.conn_id) {
261                    client.cols = cols;
262                    client.rows = rows;
263                }
264                guard.recalculate_pty_size();
265                let (ncols, nrows) = guard
266                    .session
267                    .as_ref()
268                    .map(|s| (s.cols, s.rows))
269                    .unwrap_or((cols, rows));
270                let targets: Vec<ClientEntry> = guard.clients.values().cloned().collect();
271                drop(guard);
272                ServerState::notify_clients(&targets, ncols, nrows);
273                ResizePty::encode_response((ncols, nrows))
274                    .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
275            }
276        })
277        .await
278        .map_err(|e| format!("register ResizePty: {e:?}"))?;
279
280    // Register CloseSession
281    let st = Arc::clone(&state);
282    endpoint
283        .register_prebuffered(CloseSession::METHOD_ID, move |payload, _ctx| {
284            let state = Arc::clone(&st);
285            async move {
286                let _id = CloseSession::decode_request(&payload)
287                    .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
288                let mut guard = state.lock().await;
289                guard.clear_session();
290                CloseSession::encode_response(())
291                    .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
292            }
293        })
294        .await
295        .map_err(|e| format!("register CloseSession: {e:?}"))?;
296
297    // Register ListSessions
298    let st = Arc::clone(&state);
299    endpoint
300        .register_prebuffered(ListSessions::METHOD_ID, move |_payload, _ctx| {
301            let state = Arc::clone(&st);
302            async move {
303                let guard = state.lock().await;
304                let sessions = match &guard.session {
305                    Some(s) => vec![(s.id, String::new(), s.exited)],
306                    None => vec![],
307                };
308                ListSessions::encode_response(sessions)
309                    .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
310            }
311        })
312        .await
313        .map_err(|e| format!("register ListSessions: {e:?}"))?;
314
315    // Register WriteInput
316    let st = Arc::clone(&state);
317    endpoint
318        .register_prebuffered(WriteInput::METHOD_ID, move |payload, _ctx| {
319            let state = Arc::clone(&st);
320            async move {
321                let (id, data) = WriteInput::decode_request(&payload)
322                    .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
323                let writer = {
324                    let guard = state.lock().await;
325                    guard
326                        .session
327                        .as_ref()
328                        .filter(|s| s.id == id)
329                        .map(|s| s.pty.writer_handle())
330                };
331                // PTY writes are blocking I/O (kernel input buffer); offload
332                // to the blocking pool so a full buffer never stalls an async
333                // worker or holds the state lock.
334                if let Some(writer) = writer {
335                    let _ = tokio::task::spawn_blocking(move || writer.write_bytes(&data)).await;
336                }
337                WriteInput::encode_response(())
338                    .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
339            }
340        })
341        .await
342        .map_err(|e| format!("register WriteInput: {e:?}"))?;
343
344    // Register StreamInput (streaming handler for PTY input)
345    // The channel persists across client disconnects so reconnecting
346    // clients can still send input — we drop it only when the server
347    // shuts down.
348    // Bounded to 128 items + try_send provides memory safety when the
349    // PTY write task falls behind under extreme input bursts.  Dropped
350    // chunks may fragment the PTY byte stream (multi-byte sequences);
351    // client-side coalescing (in term-session-client) prevents most
352    // over-production, but the bound is the last line of defense.
353    let (input_tx, mut input_rx) = mpsc::channel::<Vec<u8>>(INPUT_CHANNEL_CAPACITY);
354    endpoint
355        .register_stream_handler(STREAM_INPUT_METHOD_ID, move |event, _responder, _ctx| {
356            if let RpcStreamEvent::PayloadChunk { bytes, .. } = event
357                && let Err(e) = input_tx.try_send(bytes)
358            {
359                tracing::warn!(error = %e, "server input buffer full; dropping input chunk");
360            }
361            // Intentionally ignore End/Error — the channel stays alive.
362        })
363        .await
364        .map_err(|e| format!("register stream handler STREAM_INPUT: {e:?}"))?;
365
366    // Background task: write received input bytes to the PTY session
367    let input_st = Arc::clone(&state);
368    tokio::spawn(async move {
369        while let Some(data) = input_rx.recv().await {
370            let writer = {
371                let guard = input_st.lock().await;
372                guard.session.as_ref().map(|s| s.pty.writer_handle())
373            };
374            // PTY writes are blocking I/O (kernel input buffer); offload to
375            // the blocking pool so a full buffer never stalls an async worker
376            // or holds the state lock. Awaiting per chunk preserves order.
377            if let Some(writer) = writer {
378                let _ = tokio::task::spawn_blocking(move || writer.write_bytes(&data)).await;
379            }
380        }
381    });
382
383    // Register SubscribeOutput
384    let st = Arc::clone(&state);
385    endpoint
386        .register_stream_handler(SUBSCRIBE_OUTPUT_METHOD_ID, move |event, respond, ctx| {
387            let is_new = matches!(&event, RpcStreamEvent::Header { .. });
388            if is_new {
389                let st = Arc::clone(&st);
390                tokio::spawn(async move {
391                    let mut guard = st.lock().await;
392
393                    // Drain accumulated PTY output and capture the raw bytes
394                    // so they can be sent to the new subscriber (not just the snapshot).
395                    let early = guard.session.as_mut().and_then(|s| {
396                        let data = s.read_output();
397                        if data.is_empty() { None } else { Some(data) }
398                    });
399                    let snapshot = guard.session.as_mut().map(|s| s.generate_snapshot());
400                    guard.subscribers.push(SubscriberEntry {
401                        conn_id: ctx.conn_id,
402                        respond: respond.clone(),
403                    });
404
405                    // Wake the polling loop — the session may have pending
406                    // output or exit state that needs processing.
407                    guard.notify.notify_one();
408                    let is_dead = guard.session.is_none();
409                    drop(guard);
410                    if let Some(data) = snapshot
411                        && !data.is_empty()
412                    {
413                        respond.respond(data, false);
414                    }
415                    if let Some(data) = early {
416                        respond.respond(data, false);
417                    }
418                    if is_dead {
419                        respond.respond(Vec::new(), true);
420                    }
421                });
422            }
423        })
424        .await
425        .map_err(|e| format!("register SubscribeOutput: {e:?}"))?;
426
427    // Connection event handler
428    let st = Arc::clone(&state);
429    tokio::spawn(async move {
430        while let Some(event) = event_rx.recv().await {
431            match event {
432                RpcIpcServerEvent::ClientConnected(handle) => {
433                    tracing::info!("Client {} connected", handle.0.conn_id);
434                    let mut guard = st.lock().await;
435                    let handle_clone = handle.clone();
436                    guard.clients.insert(
437                        handle.0.conn_id,
438                        ClientEntry {
439                            caller: Some(handle_clone),
440                            cols: u16::MAX,
441                            rows: u16::MAX,
442                        },
443                    );
444                }
445                RpcIpcServerEvent::ClientDisconnected(conn_id) => {
446                    tracing::info!("Client {conn_id} disconnected");
447                    let mut guard = st.lock().await;
448                    guard.clients.remove(&conn_id);
449                    guard.subscribers.retain(|s| s.conn_id != conn_id);
450                    guard.recalculate_pty_size();
451                    let (ncols, nrows) = guard
452                        .session
453                        .as_ref()
454                        .map(|s| (s.cols, s.rows))
455                        .unwrap_or((FALLBACK_COLS, FALLBACK_ROWS));
456                    let targets: Vec<ClientEntry> = guard.clients.values().cloned().collect();
457                    drop(guard);
458                    ServerState::notify_clients(&targets, ncols, nrows);
459                }
460            }
461        }
462    });
463
464    // Output polling via Notify — blocks until PTY produces output.
465    // When the session exits, the exit code is sent back through this
466    // channel so run_server can return it.
467    //
468    // A periodic timer also wakes the loop so a session exit is detected even
469    // when the reader thread's EOF/Exited notification is missed or raced.
470    let (exit_tx, mut exit_rx) = oneshot::channel::<i32>();
471    let st = Arc::clone(&state);
472    tokio::spawn(async move {
473        loop {
474            tokio::select! {
475                _ = notify.notified() => {}
476                _ = tokio::time::sleep(SESSION_EXIT_POLL_INTERVAL) => {}
477            }
478            let mut guard = st.lock().await;
479            if guard.subscribers.is_empty() {
480                let mut exited = false;
481                if let Some(session) = guard.session.as_mut() {
482                    session.sync_screen();
483                    exited = session.check_exited();
484                }
485                if exited {
486                    tracing::info!("Session exited, tearing down");
487                    guard.session = None;
488                }
489                continue;
490            }
491            let (raw, exited, code) = {
492                let Some(session) = guard.session.as_mut() else {
493                    let _ = exit_tx.send(0);
494                    break;
495                };
496                let raw = session.read_output();
497                let exited = session.check_exited();
498                let code = session.exit_code;
499                (raw, exited, code)
500            };
501            if raw.is_empty() && !guard.subscribers.is_empty() {
502                tracing::debug!(
503                    "PTY output empty with {} subscribers",
504                    guard.subscribers.len()
505                );
506            }
507
508            // Push raw PTY output to all subscribers
509            if !raw.is_empty() {
510                for sub in &guard.subscribers {
511                    sub.respond.respond(raw.clone(), false);
512                }
513            }
514
515            // On exit: finalize all streams and clean up
516            if exited {
517                for sub in &guard.subscribers {
518                    sub.respond.respond(Vec::new(), true);
519                }
520                guard.subscribers.clear();
521                let _ = exit_tx.send(code.unwrap_or(0));
522                tracing::info!("Session exited with code {:?}", code);
523                break;
524            }
525        }
526    });
527
528    tracing::info!("Session server listening on channel {}", config.channel);
529
530    // Wait for either the server to finish or the session to exit.
531    let exit_code = tokio::select! {
532        result = async {
533            server
534                .serve(&socket_name)
535                .await
536                .map_err(|e| format!("serve: {e:?}"))
537        } => {
538            result?;
539            0
540        }
541        code = &mut exit_rx => {
542            // The polling task just queued the end-of-stream frames to each
543            // subscriber. The transport flushes them asynchronously, so give
544            // it a short grace period before the process exits — otherwise the
545            // frames are dropped with the runtime and the client never learns
546            // the session ended (it hangs waiting for output).
547            tokio::time::sleep(SESSION_EXIT_FLUSH_GRACE).await;
548            code.unwrap_or(0)
549        }
550    };
551
552    Ok(exit_code)
553}