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