Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions packages/process-daemon/src/daemon/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,112 @@ const ACCEPT_BACKOFF: Duration = Duration::from_millis(100);
const MAX_ACCEPTS_PER_PASS: usize = 64;

pub fn run(directory: &Path) -> Result<()> {
// First, before bind starts the sweeper thread: every thread created after
// this inherits the mask, so only the signal thread ever receives them.
#[cfg(unix)]
let signals = signals::block()?;
// Before bind, so a failure here leaves nothing to clean up.
let wake = Wake::new()?;
let (runtime, listener) = platform::Runtime::bind(directory)?;
// Created after bind so bind I/O does not consume the acquire window.
let control = Arc::new(Control::new(wake, Ownership::new(Instant::now())));
#[cfg(unix)]
if let Err(error) = signals::stop_on(signals, &control) {
let _ = runtime.finish();
return Err(error);
}
serve(runtime, listener, control, Settings::default())
}

/// Turns SIGTERM and SIGINT into an orderly stop, so children are killed and
/// the runtime directory is removed instead of the daemon dying with both left
/// behind.
///
/// Children inherit the signal mask across `exec`. portable-pty resets it for
/// PTY children; anything else the daemon spawns must reset it itself.
#[cfg(unix)]
mod signals {
use std::io;
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};

use anyhow::{Context, Result};

use super::Control;

const STOP: [libc::c_int; 2] = [libc::SIGTERM, libc::SIGINT];
/// A second signal this soon after the first is part of the same stop
/// (Ctrl-C plus a wrapper's SIGTERM), not a sign that the stop is stuck.
const REPEAT_GRACE: Duration = Duration::from_secs(1);

pub(super) struct Blocked(libc::sigset_t);

/// Blocks the stop signals on the calling thread, and restores their
/// default action: macOS never delivers an ignored signal to `sigwait`,
/// and a launcher may have left SIGINT ignored.
pub(super) fn block() -> Result<Blocked> {
// SAFETY: plain libc calls on a local, initialized signal set.
unsafe {
let mut set: libc::sigset_t = std::mem::zeroed();
libc::sigemptyset(&mut set);
for signal in STOP {
libc::sigaddset(&mut set, signal);
}
let result = libc::pthread_sigmask(libc::SIG_BLOCK, &set, std::ptr::null_mut());
if result != 0 {
return Err(io::Error::from_raw_os_error(result))
.context("failed to block stop signals");
}
// Only after blocking, so there is no window where the default
// action could kill the daemon.
for signal in STOP {
libc::signal(signal, libc::SIG_DFL);
}
Ok(Blocked(set))
}
}

pub(super) fn stop_on(signals: Blocked, control: &Arc<Control>) -> Result<()> {
let control = Arc::clone(control);
thread::Builder::new()
.name("opencode-pty-signals".into())
.spawn(move || {
wait(&signals);
let stopped = Instant::now();
control.request_stop();
loop {
let signal = wait(&signals);
if stopped.elapsed() >= REPEAT_GRACE {
// The orderly stop is stuck. Exit without running exit
// handlers, which other threads may be blocking.
// SAFETY: `_exit` is always safe to call.
unsafe { libc::_exit(128 + signal) };
}
}
})
.context("failed to start the signal thread")?;
Ok(())
}

fn wait(signals: &Blocked) -> libc::c_int {
loop {
let mut signal = 0;
// SAFETY: valid set and out pointer; `sigwait` returns an error
// number instead of setting errno, and fills `signal` on success.
let result = unsafe { libc::sigwait(&signals.0, &mut signal) };
if result == 0 {
return signal;
}
// EINTR (after SIGSTOP and SIGCONT) just retries. Nothing else is
// expected for valid signals; pause so it can't spin.
if result != libc::EINTR {
thread::sleep(Duration::from_millis(100));
}
}
}
}

struct Settings {
/// Longest the loop sleeps without a wake or deadline.
safety_net: Duration,
Expand Down
39 changes: 39 additions & 0 deletions packages/process-daemon/tests/ownership.rs
Original file line number Diff line number Diff line change
Expand Up @@ -536,3 +536,42 @@ fn shutdown_keeps_a_replaced_socket() {
drop(replacement);
std::fs::remove_file(&daemon.registration.socket).unwrap();
}

#[test]
fn stop_signals_stop_terminals_and_clean_up() {
for signal in [libc::SIGTERM, libc::SIGINT] {
let mut daemon = Daemon::start();
let (_owner, response) = daemon.own(None);
assert!(matches!(response, Response::Owned));
let terminal = daemon.terminal("exec sleep 1000");

// The daemon blocks these signals on its own threads; its children must not
// inherit that mask, so a terminal's process still dies from the signal.
let probe = daemon.terminal("exec sleep 1000");
// SAFETY: the probe PID belongs to this test's live terminal.
assert_eq!(unsafe { libc::kill(probe.pid.unwrap() as i32, signal) }, 0);
let deadline = Instant::now() + Duration::from_secs(3);
loop {
let Response::Terminals { terminals } = daemon.request(Request::List) else {
panic!("list failed");
};
let exited = terminals.iter().any(|value| {
value.id == probe.id
&& !matches!(
value.lifecycle,
opencode_pty::service::TerminalLifecycle::Running
)
});
if exited {
break;
}
assert!(Instant::now() < deadline, "probe ignored signal {signal}");
thread::sleep(Duration::from_millis(10));
}

// SAFETY: the PID belongs to the daemon this test spawned.
assert_eq!(unsafe { libc::kill(daemon.child.id() as i32, signal) }, 0);
daemon.wait();
assert_terminal_stopped(&terminal);
}
}
Loading