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/// Format a unix timestamp as a relative human string ("2s ago", "5m ago", …),
24/// falling back to an absolute HH:MM:SS for very old timestamps.
25pub fn format_unix_relative(ts: u64) -> String {
26    let now = std::time::SystemTime::now()
27        .duration_since(std::time::UNIX_EPOCH)
28        .map(|d| d.as_secs())
29        .unwrap_or(0);
30    if ts == 0 {
31        return "-".to_string();
32    }
33    let diff = now.saturating_sub(ts);
34    if diff < 60 {
35        format!("{diff}s")
36    } else if diff < 3600 {
37        format!("{}m", diff / 60)
38    } else if diff < 86400 {
39        format!("{}h", diff / 3600)
40    } else {
41        let secs = ts % 86400;
42        let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
43        format!("{h:02}:{m:02}:{s:02}")
44    }
45}
46
47/// Connect to the gateway daemon and run `op` with a live client. The tokio
48/// runtime that hosts the muxio connection is kept alive for the whole `op`,
49/// so RPCs complete (dropping it early would tear down the connection and
50/// hang the call). `op` receives an owned `Arc` and runs on that runtime.
51pub fn with_gateway<F, Fut, T>(op: F) -> io::Result<T>
52where
53    F: FnOnce(Arc<muxio_tokio_rpc_ipc_client::RpcIpcClient>) -> Fut,
54    Fut: std::future::Future<Output = T>,
55{
56    let gateway = term_session_muxio_service_definitions::gateway_channel_name();
57    let rt =
58        tokio::runtime::Runtime::new().map_err(|e| io::Error::other(format!("runtime: {e}")))?;
59    rt.block_on(async {
60        let client = muxio_tokio_rpc_ipc_client::RpcIpcClient::new(&gateway.to_string())
61            .await
62            .map_err(|e| {
63                io::Error::new(
64                    io::ErrorKind::ConnectionRefused,
65                    format!(
66                        "No gateway daemon is running on '{gateway}'. Start one with `term-session --channel <name>` or `term-session --daemon` first.\n  cause: {e}"
67                    ),
68                )
69            })?;
70        Ok(op(client).await)
71    })
72}
73
74/// List channels from the gateway, including the daemon PID + socket name.
75pub fn list_channels() -> io::Result<ListChannelsResponse> {
76    with_gateway(|client| async move { ListChannels::call(&*client, ()).await })?
77        .map_err(|e| io::Error::other(format!("list: {e}")))
78}
79
80/// Kill a channel's session and detach all its sockets.
81pub fn kill_channel(channel: &str) -> io::Result<()> {
82    with_gateway(|client| async move { KillChannel::call(&*client, channel.to_string()).await })?
83        .map_err(|e| io::Error::other(format!("kill channel: {e}")))
84}
85
86/// Detach a single client socket from a channel by `conn_id`.
87pub fn kill_client(channel: &str, conn_id: usize) -> io::Result<()> {
88    with_gateway(|client| async move {
89        KillClient::call(&*client, (channel.to_string(), conn_id)).await
90    })?
91    .map_err(|e| io::Error::other(format!("kill client: {e}")))
92}
93
94/// Stop the gateway daemon.
95///
96/// The daemon refuses to shut down while any live session is running unless
97/// `force` is true (see `RPC_ERROR_LIVE_SESSIONS`).
98pub fn stop_gateway(force: bool) -> io::Result<()> {
99    with_gateway(|client| async move { ShutdownGateway::call(&*client, force).await })?
100        .map_err(|e| io::Error::other(format!("shutdown: {e}")))
101}
102
103/// Run the gateway daemon: rename the process, detach from the controlling
104/// terminal, and serve until `ShutdownGateway`. `selfcheck_marker` is a
105/// test-only path written with the platform's detachment proof once bound.
106pub fn run_daemon(selfcheck_marker: Option<std::path::PathBuf>) -> io::Result<()> {
107    tracing_subscriber::fmt::init();
108
109    // Make the daemon recognizable in process managers: every `term-session`
110    // process is the same binary, so rename this one so `ps`/`top`/Task
111    // Manager show `term-session-daemon` instead of generic `term-session`.
112    set_daemon_process_name();
113
114    // Self-detach: a `--daemon` that was not already started detached (e.g.
115    // spawned directly by a test or wrapper, not via
116    // `auto_spawn::connect_or_spawn_server`) detaches itself from the
117    // launching terminal so Ctrl+C / SIGHUP never reach it.
118    //
119    // - Unix: `setsid()` starts a new session and process group and drops the
120    //   controlling terminal. It fails with EPERM if the process is already a
121    //   process-group leader, which is exactly the already-detached case — so
122    //   ignore that error.
123    // - Windows: `FreeConsole()` detaches from the launching console so no
124    //   console control events (Ctrl+C, Ctrl+Close) are ever delivered to the
125    //   daemon. It reports failure when there is no console to detach from,
126    //   which is the already-detached `auto_spawn` case — so ignore that too.
127    #[cfg(unix)]
128    unsafe {
129        libc::setsid();
130    }
131    #[cfg(windows)]
132    unsafe {
133        let _ = windows_sys::Win32::System::Console::FreeConsole();
134    }
135
136    let gateway = term_session_muxio_service_definitions::gateway_channel_name();
137
138    // Test-only: as soon as the gateway socket is reachable, write the
139    // platform's detachment proof to the marker, then exit the probe thread.
140    if let Some(ref marker) = selfcheck_marker {
141        let gw = gateway.clone();
142        let marker = marker.clone();
143        std::thread::Builder::new()
144            .name("daemon-selfcheck".into())
145            .spawn(move || {
146                for _ in 0..200 {
147                    if term_session_muxio_service_definitions::probe_ipc_endpoint(&gw) {
148                        write_selfcheck_marker(&marker);
149                        return;
150                    }
151                    std::thread::sleep(std::time::Duration::from_millis(25));
152                }
153                let _ = std::fs::write(&marker, "bound-timeout");
154            })?;
155    }
156
157    let rt =
158        tokio::runtime::Runtime::new().map_err(|e| io::Error::other(format!("runtime: {e}")))?;
159    rt.block_on(term_session_server::run_gateway(gateway.clone()))
160        .map_err(|e| io::Error::other(format!("gateway error: {e}")))?;
161    Ok(())
162}
163
164/// Rename the running process so process managers can distinguish the gateway
165/// daemon from interactive `term-session` clients. Best-effort and cosmetic:
166/// a failure is ignored and never affects functionality.
167///
168/// Platform behavior (and limitations):
169/// - **Linux:** `PR_SET_NAME` sets the process comm (capped at 15 bytes →
170///   `term-session-d`), so `ps -comm`, `top`, and `htop` show the renamed
171///   value. This is the most complete rename on any platform.
172/// - **macOS:** `pthread_setname_np` sets the **thread** name, not the process
173///   comm — `ps -o comm` and Activity Monitor's process list still show
174///   `term-session`. The renamed value is only visible in Activity Monitor's
175///   per-thread view (and `sample`). This is an OS limitation: macOS has no
176///   portable user-space API to rename the process comm. Daemon disambiguation
177///   on macOS therefore relies primarily on the `--daemon` argv flag and the
178///   `Gateway Daemon PID` header printed by `term-session list`.
179/// - **Windows:** `SetThreadDescription` sets the thread description, which
180///   Process Explorer / Process Hacker show in the **Description** column.
181pub fn set_daemon_process_name() {
182    #[cfg(target_os = "linux")]
183    {
184        use std::ffi::CString;
185        if let Ok(name) = CString::new("term-session-d") {
186            unsafe {
187                libc::prctl(libc::PR_SET_NAME, name.as_ptr() as usize, 0, 0, 0);
188            }
189        }
190    }
191    #[cfg(target_os = "macos")]
192    {
193        use std::ffi::CString;
194        if let Ok(name) = CString::new("term-session-daemon") {
195            unsafe {
196                libc::pthread_setname_np(name.as_ptr());
197            }
198        }
199    }
200    #[cfg(windows)]
201    {
202        use windows_sys::Win32::System::Threading::{GetCurrentThread, SetThreadDescription};
203        let wide: Vec<u16> = "term-session-daemon"
204            .encode_utf16()
205            .chain(std::iter::once(0))
206            .collect();
207        unsafe {
208            SetThreadDescription(GetCurrentThread(), wide.as_ptr());
209        }
210    }
211}
212
213/// Write the platform's detachment proof to the marker (test-only).
214fn write_selfcheck_marker(marker: &std::path::Path) {
215    #[cfg(windows)]
216    let proof = {
217        use windows_sys::Win32::System::Console::{
218            GetConsoleProcessList, GetStdHandle, STD_INPUT_HANDLE,
219        };
220        let mut pids = [0u32; 4];
221        let count = unsafe {
222            let _handle = GetStdHandle(STD_INPUT_HANDLE);
223            GetConsoleProcessList(pids.as_mut_ptr(), pids.len() as u32)
224        };
225        if count == 0 {
226            "windows-no-console"
227        } else {
228            "windows-has-console"
229        }
230    };
231    #[cfg(unix)]
232    let proof = {
233        let sid = unsafe { libc::getsid(0) };
234        let pid = unsafe { libc::getpid() };
235        if sid == pid {
236            "unix-session-leader"
237        } else {
238            "unix-not-leader"
239        }
240    };
241    #[cfg(not(any(unix, windows)))]
242    let proof = "unsupported";
243    let _ = std::fs::write(marker, proof);
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    /// Serializes tests that mutate `TERM_WM_CHANNEL`, which is process-global.
251    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
252        static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
253        LOCK.lock().unwrap_or_else(|e| e.into_inner())
254    }
255
256    #[test]
257    fn cli_channel_takes_precedence_over_env() {
258        let _guard = env_lock();
259        unsafe {
260            std::env::set_var(CHANNEL_ENV_VAR, "other/chan");
261        }
262        assert_eq!(resolve_channel(Some("work/dev".to_string())), "work/dev");
263        unsafe {
264            std::env::remove_var(CHANNEL_ENV_VAR);
265        }
266    }
267
268    #[test]
269    fn falls_back_to_env_channel() {
270        let _guard = env_lock();
271        unsafe {
272            std::env::set_var(CHANNEL_ENV_VAR, "work/dev");
273        }
274        assert_eq!(resolve_channel(None), "work/dev");
275        unsafe {
276            std::env::remove_var(CHANNEL_ENV_VAR);
277        }
278    }
279
280    #[test]
281    fn falls_back_to_default_channel() {
282        let _guard = env_lock();
283        unsafe {
284            std::env::remove_var(CHANNEL_ENV_VAR);
285        }
286        assert_eq!(resolve_channel(None), DEFAULT_CHANNEL);
287    }
288}