Files
ghostty-rs/crates/ghostty-os/src/systemd.rs
T
ddidderr f8c5ce06d6 feat(os): port Ghostty's Linux OS helpers
Ports ZIG/src/os for Linux into ghostty-os: XDG dirs, home dir, hostname,
env/PATH helpers, passwd lookup (via nix), shell detection, resources
dir discovery, string encodings (printf-q, URL percent), systemd
sd_notify, i18n via gettextrs, xdg-open, desktop/cgroup/kernel info,
temp dirs, URI parsing (port of std.Uri incl. Ghostty's extra options),
and a zbus-based Flatpak HostCommand implementation.

The crate stays free of unsafe. Consequences, all tracked as
TODO(integration) in docs/rewrite/notes/ghostty-os.md:
- locale::ensure_locale is a no-op (setlocale/setenv are unsafe; GTK
  calls setlocale itself); setenv/unsetenv are not exposed.
- passwd's Flatpak branch needs the termio pty and falls back to the
  sandbox entry for now.
- FlatpakHostCommand's D-Bus path is untested (no portal here).
Intentional fix: a leading '@' in NOTIFY_SOCKET is treated as an
abstract socket.

macOS/Windows/WASM modules are out of scope.

Test Plan:
- cargo test -p ghostty-os (88 tests)
- cargo clippy -p ghostty-os --all-targets -- -D warnings
2026-10-11 21:54:11 +02:00

217 lines
7.7 KiB
Rust

use std::io::Read;
/// Kernel `TASK_COMM_LEN`.
const TASK_COMM_LEN: u64 = 16;
/// Returns true if the program was launched as a systemd service.
///
/// On Linux, this returns true if the program was launched as a systemd
/// service. It will return false if Ghostty was launched any other way.
pub fn launched_by_systemd() -> bool {
// On Linux, systemd sets the `INVOCATION_ID` (v232+) and the
// `JOURNAL_STREAM` (v231+) environment variables. If these
// environment variables are not present we were not launched by
// systemd.
if std::env::var_os("INVOCATION_ID").is_none() {
return false;
}
if std::env::var_os("JOURNAL_STREAM").is_none() {
return false;
}
// If `INVOCATION_ID` and `JOURNAL_STREAM` are present, check to make sure
// that our parent process is actually `systemd`, not some other terminal
// emulator that doesn't clean up those environment variables.
let ppid = rustix::process::getppid().map_or(0, rustix::process::Pid::as_raw_pid);
if ppid == 1 {
return true;
}
// If the parent PID is not 1 we need to check to see if we were launched by
// a user systemd daemon. Do that by checking the `/proc/<ppid>/comm`
// to see if it ends with `systemd`.
let comm_path = format!("/proc/{ppid}/comm");
let Ok(comm_file) = std::fs::File::open(&comm_path) else {
log::error!("unable to open '{comm_path}' for reading");
return false;
};
// The maximum length of the command name is defined by
// `TASK_COMM_LEN` in the Linux kernel. This is usually 16
// bytes at the time of writing (Jun 2025) so its set to that.
// Also, since we only care to compare to "systemd", anything
// longer can be assumed to not be systemd.
let mut comm_data = Vec::new();
if comm_file
.take(TASK_COMM_LEN)
.read_to_end(&mut comm_data)
.is_err()
{
log::error!("problems reading from '{comm_path}'");
return false;
}
let end = comm_data
.iter()
.rposition(|&b| b != b'\n')
.map_or(0, |i| i + 1);
&comm_data[..end] == b"systemd"
}
/// systemd notifications. Used by Ghostty to inform systemd of the state of the
/// process. Currently only used to notify systemd that we are ready and that
/// configuration reloading has started.
///
/// See: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html
///
/// These functions are re-implemented (rather than using `libsystemd`) to
/// avoid another external dependency. Linux systems that do not use
/// `systemd` should never start Ghostty with the `NOTIFY_SOCKET` environment
/// variable set and these functions essentially become a no-op.
pub mod notify {
use rustix::net::{
AddressFamily, SendFlags, SocketAddrUnix, SocketFlags, SocketType, connect, send,
socket_with,
};
/// Send the given message to the UNIX socket specified in the NOTIFY_SOCKET
/// environment variable. If there NOTIFY_SOCKET environment variable does
/// not exist then no message is sent.
pub(super) fn send_message(message: &[u8]) {
// Get the socket address that should receive notifications.
let Some(socket_path) = std::env::var_os("NOTIFY_SOCKET") else {
return;
};
let socket_path = socket_path.as_encoded_bytes();
// If the socket address is an empty string return.
if socket_path.is_empty() {
return;
}
// The socket address must be a path or an abstract socket.
if socket_path[0] != b'/' && socket_path[0] != b'@' {
log::warn!(
"only AF_UNIX sockets with path or abstract namespace addresses are supported!"
);
return;
}
// sockaddr_un.path is 108 bytes.
if 108 < socket_path.len() {
log::warn!("NOTIFY_SOCKET path is too long!");
return;
}
// NOTE: the Zig version copies the path verbatim (including a
// leading '@'); the sd_notify protocol says a leading '@' denotes
// an abstract socket, which is what we do here.
let addr = if socket_path[0] == b'@' {
SocketAddrUnix::new_abstract_name(&socket_path[1..])
} else {
SocketAddrUnix::new(socket_path)
};
let addr = match addr {
Ok(addr) => addr,
Err(e) => {
log::warn!("invalid NOTIFY_SOCKET address: {e}");
return;
}
};
let sock = match socket_with(
AddressFamily::UNIX,
SocketType::DGRAM,
SocketFlags::CLOEXEC,
None,
) {
Ok(s) => s,
Err(e) => {
log::warn!("creating socket failed: {e}");
return;
}
};
if let Err(e) = connect(&sock, &addr) {
log::warn!("unable to connect to notify socket: {e}");
return;
}
match send(&sock, message, SendFlags::empty()) {
Ok(written) if written < message.len() => {
log::warn!(
"short write to notify socket: {written} < {}",
message.len()
);
}
Ok(_) => {}
Err(e) => log::warn!("unable to write to notify socket: {e}"),
}
}
/// Tell systemd that we are ready or that we are finished reloading.
/// See: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html#READY=1
pub fn ready() {
send_message(b"READY=1");
}
/// Tell systemd that we have started reloading our configuration.
/// See: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html#RELOADING=1
/// and: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html#MONOTONIC_USEC=%E2%80%A6
pub fn reloading() {
let ts = rustix::time::clock_gettime(rustix::time::ClockId::Monotonic);
let now = ts.tv_sec * 1_000_000 + ts.tv_nsec / 1000;
let message = format!("RELOADING=1\nMONOTONIC_USEC={now}");
send_message(message.as_bytes());
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::net::UnixDatagram;
#[test]
fn not_launched_without_env() {
temp_env::with_vars(
[("INVOCATION_ID", None::<&str>), ("JOURNAL_STREAM", None)],
|| assert!(!launched_by_systemd()),
);
temp_env::with_vars(
[("INVOCATION_ID", Some("x")), ("JOURNAL_STREAM", None)],
|| assert!(!launched_by_systemd()),
);
}
fn recv_with_socket(f: impl FnOnce()) -> String {
let dir = crate::TempDir::init().unwrap();
let path = dir.path().join("notify.sock");
let sock = UnixDatagram::bind(&path).unwrap();
sock.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
temp_env::with_var("NOTIFY_SOCKET", Some(path.to_str().unwrap()), f);
let mut buf = [0u8; 128];
let n = sock.recv(&mut buf).unwrap();
String::from_utf8(buf[..n].to_vec()).unwrap()
}
#[test]
fn notify_ready_sends_message() {
assert_eq!(recv_with_socket(notify::ready), "READY=1");
}
#[test]
fn notify_reloading_sends_message() {
let msg = recv_with_socket(notify::reloading);
let rest = msg.strip_prefix("RELOADING=1\nMONOTONIC_USEC=").unwrap();
assert!(rest.parse::<i64>().unwrap() > 0);
}
#[test]
fn notify_without_socket_is_noop() {
temp_env::with_var("NOTIFY_SOCKET", None::<&str>, notify::ready);
temp_env::with_var("NOTIFY_SOCKET", Some(""), notify::ready);
temp_env::with_var("NOTIFY_SOCKET", Some("relative"), notify::ready);
}
}