Skip to main content

term_session/
lib.rs

1pub mod auto_spawn;
2
3use std::io;
4use std::sync::Arc;
5
6use muxio_tokio_rpc_ipc_client::RpcCallPrebuffered;
7use term_session_muxio_service_definitions::{
8    KillChannel, KillClient, ListChannels, ListChannelsResponse, ShutdownGateway,
9};
10
11// TODO: Rename to TERM_SESSION_CHANNEL
12pub const CHANNEL_ENV_VAR: &str = "TERM_WM_CHANNEL";
13pub const DEFAULT_CHANNEL: &str = "default/main";
14
15/// Resolve the channel from an optional CLI arg, falling back to the env var,
16/// then the default.
17pub fn resolve_channel(cli_channel: Option<String>) -> String {
18    cli_channel
19        .or_else(|| std::env::var(CHANNEL_ENV_VAR).ok())
20        .unwrap_or_else(|| DEFAULT_CHANNEL.to_string())
21}
22
23/// Seconds per minute, used by [`format_unix_relative`].
24const SECS_PER_MIN: u64 = 60;
25/// Seconds per hour, used by [`format_unix_relative`].
26const SECS_PER_HOUR: u64 = 3600;
27/// Seconds per day, used by [`format_unix_relative`].
28const SECS_PER_DAY: u64 = 86400;
29
30/// Format a unix timestamp as a relative human string ("2s ago", "5m ago", …),
31/// always in elapsed units regardless of age ("2d 5h" for ages beyond a day).
32pub fn format_unix_relative(ts: u64) -> String {
33    let now = std::time::SystemTime::now()
34        .duration_since(std::time::UNIX_EPOCH)
35        .map(|d| d.as_secs())
36        .unwrap_or(0);
37    format_unix_relative_at(ts, now)
38}
39
40/// Format a unix timestamp relative to an explicit `now` in unix seconds.
41///
42/// Elapsed durations are always rendered in relative units: seconds, minutes,
43/// hours, then combined days + hours. A zero timestamp renders as `-`.
44/// Timestamps newer than `now` saturate to the seconds tier.
45pub fn format_unix_relative_at(ts: u64, now: u64) -> String {
46    if ts == 0 {
47        return "-".to_string();
48    }
49    let diff = now.saturating_sub(ts);
50    if diff < SECS_PER_MIN {
51        format!("{diff}s")
52    } else if diff < SECS_PER_HOUR {
53        format!("{}m", diff / SECS_PER_MIN)
54    } else if diff < SECS_PER_DAY {
55        format!("{}h", diff / SECS_PER_HOUR)
56    } else {
57        format!(
58            "{}d {}h",
59            diff / SECS_PER_DAY,
60            (diff % SECS_PER_DAY) / SECS_PER_HOUR
61        )
62    }
63}
64
65/// Connect to the gateway daemon and run `op` with a live client. The tokio
66/// runtime that hosts the muxio connection is kept alive for the whole `op`,
67/// so RPCs complete (dropping it early would tear down the connection and
68/// hang the call). `op` receives an owned `Arc` and runs on that runtime.
69pub fn with_gateway<F, Fut, T>(op: F) -> io::Result<T>
70where
71    F: FnOnce(Arc<muxio_tokio_rpc_ipc_client::RpcIpcClient>) -> Fut,
72    Fut: std::future::Future<Output = T>,
73{
74    let gateway = term_session_muxio_service_definitions::gateway_channel_name();
75    let rt =
76        tokio::runtime::Runtime::new().map_err(|e| io::Error::other(format!("runtime: {e}")))?;
77    rt.block_on(async {
78        let client = muxio_tokio_rpc_ipc_client::RpcIpcClient::new(&gateway.to_string())
79            .await
80            .map_err(|e| {
81                io::Error::new(
82                    io::ErrorKind::ConnectionRefused,
83                    format!(
84                        "No gateway daemon is running on '{gateway}'. Start one with `term-session --channel <name>` or `term-session --daemon` first.\n  cause: {e}"
85                    ),
86                )
87            })?;
88        Ok(op(client).await)
89    })
90}
91
92/// List channels from the gateway, including the daemon PID + socket name.
93pub fn list_channels() -> io::Result<ListChannelsResponse> {
94    with_gateway(|client| async move { ListChannels::call(&*client, ()).await })?
95        .map_err(|e| io::Error::other(format!("list: {e}")))
96}
97
98/// Kill a channel's session and detach all its sockets.
99///
100/// The gateway refuses while any participant is attached to the channel unless
101/// `force` is true (see `RPC_ERROR_LIVE_PARTICIPANTS`).
102pub fn kill_channel(channel: &str, force: bool) -> io::Result<()> {
103    with_gateway(|client| async move {
104        KillChannel::call(&*client, (channel.to_string(), force)).await
105    })?
106    .map_err(|e| io::Error::other(format!("kill channel: {e}")))
107}
108
109/// Detach a single client socket from a channel by `conn_id`.
110pub fn kill_client(channel: &str, conn_id: usize) -> io::Result<()> {
111    with_gateway(|client| async move {
112        KillClient::call(&*client, (channel.to_string(), conn_id)).await
113    })?
114    .map_err(|e| io::Error::other(format!("kill client: {e}")))
115}
116
117/// Stop the gateway daemon.
118///
119/// The daemon refuses to shut down while any live session is running unless
120/// `force` is true (see `RPC_ERROR_LIVE_SESSIONS`).
121pub fn stop_gateway(force: bool) -> io::Result<()> {
122    with_gateway(|client| async move { ShutdownGateway::call(&*client, force).await })?
123        .map_err(|e| io::Error::other(format!("shutdown: {e}")))
124}
125
126/// Run the gateway daemon: rename the process, detach from the controlling
127/// terminal, and serve until `ShutdownGateway`. `selfcheck_marker` is a
128/// test-only path written with the platform's detachment proof once bound.
129pub fn run_daemon(selfcheck_marker: Option<std::path::PathBuf>) -> io::Result<()> {
130    tracing_subscriber::fmt::init();
131
132    // Make the daemon recognizable in process managers: every `term-session`
133    // process is the same binary, so rename this one so `ps`/`top`/Task
134    // Manager show `term-session-daemon` instead of generic `term-session`.
135    set_daemon_process_name();
136
137    // Self-detach: a `--daemon` that was not already started detached (e.g.
138    // spawned directly by a test or wrapper, not via
139    // `auto_spawn::connect_or_spawn_server`) detaches itself from the
140    // launching terminal so Ctrl+C / SIGHUP never reach it.
141    //
142    // - Unix: `setsid()` starts a new session and process group and drops the
143    //   controlling terminal. It fails with EPERM if the process is already a
144    //   process-group leader, which is exactly the already-detached case — so
145    //   ignore that error.
146    // - Windows: `FreeConsole()` detaches from the launching console so no
147    //   console control events (Ctrl+C, Ctrl+Close) are ever delivered to the
148    //   daemon. It reports failure when there is no console to detach from,
149    //   which is the already-detached `auto_spawn` case — so ignore that too.
150    #[cfg(unix)]
151    unsafe {
152        libc::setsid();
153    }
154    #[cfg(windows)]
155    unsafe {
156        let _ = windows_sys::Win32::System::Console::FreeConsole();
157    }
158
159    let gateway = term_session_muxio_service_definitions::gateway_channel_name();
160
161    // Test-only: as soon as the gateway socket is reachable, write the
162    // platform's detachment proof to the marker, then exit the probe thread.
163    if let Some(ref marker) = selfcheck_marker {
164        let gw = gateway.clone();
165        let marker = marker.clone();
166        std::thread::Builder::new()
167            .name("daemon-selfcheck".into())
168            .spawn(move || {
169                for _ in 0..200 {
170                    if term_session_muxio_service_definitions::probe_ipc_endpoint(&gw) {
171                        write_selfcheck_marker(&marker);
172                        return;
173                    }
174                    std::thread::sleep(std::time::Duration::from_millis(25));
175                }
176                let _ = std::fs::write(&marker, "bound-timeout");
177            })?;
178    }
179
180    let rt =
181        tokio::runtime::Runtime::new().map_err(|e| io::Error::other(format!("runtime: {e}")))?;
182    rt.block_on(term_session_server::run_gateway(gateway.clone()))
183        .map_err(|e| io::Error::other(format!("gateway error: {e}")))?;
184    Ok(())
185}
186
187/// Rename the running process so process managers can distinguish the gateway
188/// daemon from interactive `term-session` clients. Best-effort and cosmetic:
189/// a failure is ignored and never affects functionality.
190///
191/// Platform behavior (and limitations):
192/// - **Linux:** `PR_SET_NAME` sets the process comm (capped at 15 bytes →
193///   `term-session-d`), so `ps -comm`, `top`, and `htop` show the renamed
194///   value. This is the most complete rename on any platform.
195/// - **macOS:** `pthread_setname_np` sets the **thread** name, not the process
196///   comm — `ps -o comm` and Activity Monitor's process list still show
197///   `term-session`. The renamed value is only visible in Activity Monitor's
198///   per-thread view (and `sample`). This is an OS limitation: macOS has no
199///   portable user-space API to rename the process comm. Daemon disambiguation
200///   on macOS therefore relies primarily on the `--daemon` argv flag and the
201///   `Gateway Daemon PID` header printed by `term-session list`.
202/// - **Windows:** `SetThreadDescription` sets the thread description, which
203///   Process Explorer / Process Hacker show in the **Description** column.
204pub fn set_daemon_process_name() {
205    #[cfg(target_os = "linux")]
206    {
207        use std::ffi::CString;
208        if let Ok(name) = CString::new("term-session-d") {
209            unsafe {
210                libc::prctl(libc::PR_SET_NAME, name.as_ptr() as usize, 0, 0, 0);
211            }
212        }
213    }
214    #[cfg(target_os = "macos")]
215    {
216        use std::ffi::CString;
217        if let Ok(name) = CString::new("term-session-daemon") {
218            unsafe {
219                libc::pthread_setname_np(name.as_ptr());
220            }
221        }
222    }
223    #[cfg(windows)]
224    {
225        use windows_sys::Win32::System::Threading::{GetCurrentThread, SetThreadDescription};
226        let wide: Vec<u16> = "term-session-daemon"
227            .encode_utf16()
228            .chain(std::iter::once(0))
229            .collect();
230        unsafe {
231            SetThreadDescription(GetCurrentThread(), wide.as_ptr());
232        }
233    }
234}
235
236/// Write the platform's detachment proof to the marker (test-only).
237fn write_selfcheck_marker(marker: &std::path::Path) {
238    #[cfg(windows)]
239    let proof = {
240        use windows_sys::Win32::System::Console::{
241            GetConsoleProcessList, GetStdHandle, STD_INPUT_HANDLE,
242        };
243        let mut pids = [0u32; 4];
244        let count = unsafe {
245            let _handle = GetStdHandle(STD_INPUT_HANDLE);
246            GetConsoleProcessList(pids.as_mut_ptr(), pids.len() as u32)
247        };
248        if count == 0 {
249            "windows-no-console"
250        } else {
251            "windows-has-console"
252        }
253    };
254    #[cfg(unix)]
255    let proof = {
256        let sid = unsafe { libc::getsid(0) };
257        let pid = unsafe { libc::getpid() };
258        if sid == pid {
259            "unix-session-leader"
260        } else {
261            "unix-not-leader"
262        }
263    };
264    #[cfg(not(any(unix, windows)))]
265    let proof = "unsupported";
266    let _ = std::fs::write(marker, proof);
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    /// Serializes tests that mutate `TERM_WM_CHANNEL`, which is process-global.
274    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
275        static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
276        LOCK.lock().unwrap_or_else(|e| e.into_inner())
277    }
278
279    #[test]
280    fn cli_channel_takes_precedence_over_env() {
281        let _guard = env_lock();
282        unsafe {
283            std::env::set_var(CHANNEL_ENV_VAR, "other/chan");
284        }
285        assert_eq!(resolve_channel(Some("work/dev".to_string())), "work/dev");
286        unsafe {
287            std::env::remove_var(CHANNEL_ENV_VAR);
288        }
289    }
290
291    #[test]
292    fn falls_back_to_env_channel() {
293        let _guard = env_lock();
294        unsafe {
295            std::env::set_var(CHANNEL_ENV_VAR, "work/dev");
296        }
297        assert_eq!(resolve_channel(None), "work/dev");
298        unsafe {
299            std::env::remove_var(CHANNEL_ENV_VAR);
300        }
301    }
302
303    #[test]
304    fn falls_back_to_default_channel() {
305        let _guard = env_lock();
306        unsafe {
307            std::env::remove_var(CHANNEL_ENV_VAR);
308        }
309        assert_eq!(resolve_channel(None), DEFAULT_CHANNEL);
310    }
311
312    #[test]
313    fn format_zero_timestamp_is_dash() {
314        assert_eq!(format_unix_relative_at(0, SECS_PER_DAY), "-");
315    }
316
317    #[test]
318    fn format_under_a_minute_shows_seconds() {
319        assert_eq!(
320            format_unix_relative_at(SECS_PER_DAY - 42, SECS_PER_DAY),
321            "42s"
322        );
323    }
324
325    #[test]
326    fn format_under_an_hour_shows_minutes() {
327        assert_eq!(
328            format_unix_relative_at(SECS_PER_DAY - 3_300, SECS_PER_DAY),
329            "55m"
330        );
331    }
332
333    #[test]
334    fn format_under_a_day_shows_hours() {
335        assert_eq!(
336            format_unix_relative_at(SECS_PER_DAY - 7_200, SECS_PER_DAY),
337            "2h"
338        );
339    }
340
341    #[test]
342    fn format_older_than_a_day_shows_days_and_hours() {
343        assert_eq!(
344            format_unix_relative_at(10 * SECS_PER_DAY, 11 * SECS_PER_DAY),
345            "1d 0h"
346        );
347        assert_eq!(
348            format_unix_relative_at(10 * SECS_PER_DAY, 11 * SECS_PER_DAY + 3 * SECS_PER_HOUR),
349            "1d 3h"
350        );
351    }
352
353    #[test]
354    fn format_day_boundary_exact() {
355        assert_eq!(
356            format_unix_relative_at(10 * SECS_PER_DAY, 11 * SECS_PER_DAY),
357            "1d 0h"
358        );
359    }
360
361    #[test]
362    fn format_timestamp_newer_than_now_saturates() {
363        assert_eq!(
364            format_unix_relative_at(SECS_PER_DAY + 10, SECS_PER_DAY),
365            "0s"
366        );
367    }
368
369    #[test]
370    fn format_does_not_render_clock_time() {
371        // Regression for the military-time leak: an old timestamp rendered
372        // `ts % 86400` (UTC time-of-day). It must never produce HH:MM:SS.
373        let ts = SECS_PER_DAY * 40 + 18 * SECS_PER_HOUR + 48 * SECS_PER_MIN + 46;
374        let out = format_unix_relative_at(ts, SECS_PER_DAY * 42);
375        assert_eq!(out, "1d 5h");
376        assert!(!out.contains(':'), "clock-time format leaked: {out}");
377    }
378}