use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::time::Duration;
pub const STOP_GRACE: Duration = Duration::from_secs(5);
#[cfg(unix)]
const KILL_WAIT: Duration = Duration::from_secs(2);
const OPENCODE_NAMES: &[&str] = &["opencode", "opencode-cli", "ai.opencode.desktop"];
pub const SERVICE_BINARY: &str = "opencode-cli";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProcId {
pub pid: u32,
pub start: String,
pub exe: PathBuf,
}
impl ProcId {
#[must_use]
pub fn same_process(&self, other: &ProcId) -> bool {
self.pid == other.pid && self.start == other.start && same_path(&self.exe, &other.exe)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Attached {
Yes,
No,
Unknown,
}
impl Attached {
#[must_use]
pub fn blocks_replacement(self) -> bool {
!matches!(self, Attached::No)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StopOutcome {
Stopped,
Gone,
Changed,
Unreadable,
Failed(String),
}
impl StopOutcome {
#[must_use]
pub fn is_gone(&self) -> bool {
matches!(self, StopOutcome::Stopped | StopOutcome::Gone)
}
}
pub trait ProcessTable: Send + Sync {
fn read(&self, pid: u32) -> Option<ProcId>;
fn exists(&self, pid: u32) -> bool;
fn opencode_processes(&self) -> Result<Vec<(u32, PathBuf)>, String>;
fn stop(&self, expected: &ProcId, grace: Duration) -> StopOutcome;
fn spawn(&self, cli: &Path, env: &ServiceEnv) -> std::io::Result<ProcId>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct SystemTable;
impl ProcessTable for SystemTable {
fn read(&self, pid: u32) -> Option<ProcId> {
read(pid)
}
fn exists(&self, pid: u32) -> bool {
pid != 0 && sys::exists(pid)
}
fn opencode_processes(&self) -> Result<Vec<(u32, PathBuf)>, String> {
sys::processes().map(|all| {
all.into_iter()
.filter(|(_, exe)| is_opencode_exe(exe))
.collect()
})
}
fn stop(&self, expected: &ProcId, grace: Duration) -> StopOutcome {
stop(expected, grace)
}
fn spawn(&self, cli: &Path, env: &ServiceEnv) -> std::io::Result<ProcId> {
spawn(cli, env)
}
}
#[must_use]
pub fn read(pid: u32) -> Option<ProcId> {
if pid == 0 {
return None;
}
sys::read(pid)
}
pub fn attached(table: &dyn ProcessTable, except: &[u32]) -> Attached {
match table.opencode_processes() {
Err(e) => {
tracing::debug!(error = %e, "OpenCode process table unreadable; counting as attached");
Attached::Unknown
}
Ok(found) => {
if found.iter().any(|(pid, _)| !except.contains(pid)) {
Attached::Yes
} else {
Attached::No
}
}
}
}
fn base_name(exe: &Path) -> Option<String> {
let name = exe.file_name()?.to_string_lossy().to_ascii_lowercase();
Some(
name.strip_suffix(".exe")
.map(str::to_string)
.unwrap_or(name),
)
}
#[must_use]
pub fn is_opencode_exe(exe: &Path) -> bool {
base_name(exe).is_some_and(|n| OPENCODE_NAMES.contains(&n.as_str()))
}
#[must_use]
pub fn is_service_binary(exe: &Path) -> bool {
base_name(exe).is_some_and(|n| n == SERVICE_BINARY)
}
#[must_use]
pub fn same_path(a: &Path, b: &Path) -> bool {
if crate::core::path_compat::dedup_key(a) == crate::core::path_compat::dedup_key(b) {
return true;
}
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(a), Ok(b)) => {
crate::core::path_compat::dedup_key(&a) == crate::core::path_compat::dedup_key(&b)
}
_ => false,
}
}
#[must_use]
pub fn running_elevated() -> bool {
sys::running_elevated()
}
pub const RELAY_KEYS: [&str; 4] = [
"HTTPS_PROXY",
"HTTP_PROXY",
"NO_PROXY",
"NODE_EXTRA_CA_CERTS",
];
const CREDENTIAL_VARS: &[&str] = &[
"HTTPS_PROXY",
"HTTP_PROXY",
"ALL_PROXY",
"NO_PROXY",
"NODE_EXTRA_CA_CERTS",
];
const OPENLATCH_PREFIX: &str = "OPENLATCH_";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServiceEnv {
pub remove: Vec<OsString>,
pub add: Vec<(&'static str, String)>,
}
impl ServiceEnv {
pub fn new<I>(inherited: I, keys: &crate::hooks::proxy_env_file::EnvJson) -> Self
where
I: IntoIterator<Item = (OsString, OsString)>,
{
let remove = inherited
.into_iter()
.map(|(k, _)| k)
.filter(|k| strips(k))
.collect();
let add = vec![
(RELAY_KEYS[0], keys.https_proxy.clone()),
(RELAY_KEYS[1], keys.http_proxy.clone()),
(RELAY_KEYS[2], keys.no_proxy.clone()),
(RELAY_KEYS[3], keys.node_extra_ca_certs.clone()),
];
Self { remove, add }
}
pub fn apply(&self, cmd: &mut std::process::Command) {
for k in &self.remove {
cmd.env_remove(k);
}
for (k, v) in &self.add {
cmd.env(k, v);
}
}
}
fn strips(name: &OsStr) -> bool {
let upper = name.to_string_lossy().to_ascii_uppercase();
upper.starts_with(OPENLATCH_PREFIX) || CREDENTIAL_VARS.contains(&upper.as_str())
}
pub fn spawn(cli: &Path, env: &ServiceEnv) -> std::io::Result<ProcId> {
spawn_in(cli, env, dirs::home_dir().as_deref())
}
fn spawn_in(cli: &Path, env: &ServiceEnv, cwd: Option<&Path>) -> std::io::Result<ProcId> {
let mut cmd = std::process::Command::new(cli);
cmd.arg("serve")
.arg("--service")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
if let Some(cwd) = cwd {
cmd.current_dir(cwd);
}
env.apply(&mut cmd);
let mut child = sys::spawn_detached(&mut cmd)?;
let pid = child.id();
let Some(id) = read(pid) else {
let _ = child.kill();
let _ = child.wait();
return Err(std::io::Error::other(format!(
"the identity of the spawned service (pid {pid}) could not be read; stopped it"
)));
};
let child = std::sync::Arc::new(std::sync::Mutex::new(child));
let reaped = std::sync::Arc::clone(&child);
if let Err(e) = std::thread::Builder::new()
.name("opencode-service-reaper".into())
.spawn(move || {
let _ = reaped.lock().unwrap_or_else(|e| e.into_inner()).wait();
})
{
let mut child = child.lock().unwrap_or_else(|e| e.into_inner());
let _ = child.kill();
let _ = child.wait();
return Err(std::io::Error::other(format!(
"the reaper for the spawned service (pid {pid}) could not start: {e}; stopped it"
)));
}
Ok(id)
}
pub fn stop(expected: &ProcId, grace: Duration) -> StopOutcome {
if expected.pid == 0 {
return StopOutcome::Failed("refusing to signal pid 0".into());
}
sys::stop(expected, grace)
}
#[cfg(unix)]
fn verify(expected: &ProcId) -> Verified {
match read(expected.pid) {
Some(now) if now.same_process(expected) => Verified::Same,
Some(_) => Verified::Changed,
None if sys::exists(expected.pid) => Verified::Unreadable,
None => Verified::Gone,
}
}
#[cfg(unix)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Verified {
Same,
Changed,
Unreadable,
Gone,
}
#[cfg(any(target_os = "linux", test))]
fn parse_stat_start(stat: &str) -> Option<u64> {
let (_, rest) = stat.rsplit_once(')')?;
rest.split_whitespace().nth(19)?.parse().ok()
}
#[cfg(unix)]
mod unix_common {
use std::time::{Duration, Instant};
pub(super) fn to_pid(pid: u32) -> Option<libc::pid_t> {
libc::pid_t::try_from(pid).ok().filter(|p| *p > 0)
}
pub(super) fn kill(pid: u32, sig: libc::c_int) -> Result<bool, String> {
let p = to_pid(pid).ok_or_else(|| format!("refusing to signal pid {pid}"))?;
if unsafe { libc::kill(p, sig) } == 0 {
return Ok(true);
}
let err = std::io::Error::last_os_error();
if err.raw_os_error() == Some(libc::ESRCH) {
return Ok(false);
}
Err(format!("signalling pid {pid}: {err}"))
}
pub(super) fn exists(pid: u32) -> bool {
let Some(p) = to_pid(pid) else { return false };
let rc = unsafe { libc::kill(p, 0) };
rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
pub(super) fn running_elevated() -> bool {
unsafe { libc::geteuid() == 0 }
}
pub(super) fn spawn_detached(
cmd: &mut std::process::Command,
) -> std::io::Result<std::process::Child> {
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
cmd.spawn()
}
pub(super) fn stop_by_reread(expected: &super::ProcId, grace: Duration) -> super::StopOutcome {
use super::{verify, StopOutcome, Verified};
match verify(expected) {
Verified::Same => {}
Verified::Gone => return StopOutcome::Gone,
Verified::Changed => return StopOutcome::Changed,
Verified::Unreadable => return StopOutcome::Unreadable,
}
match kill(expected.pid, libc::SIGTERM) {
Ok(true) => {}
Ok(false) => return StopOutcome::Gone,
Err(e) => return StopOutcome::Failed(e),
}
if wait_gone(expected, grace) {
return StopOutcome::Stopped;
}
match verify(expected) {
Verified::Same => {}
Verified::Gone => return StopOutcome::Stopped,
Verified::Changed | Verified::Unreadable => return StopOutcome::Stopped,
}
match kill(expected.pid, libc::SIGKILL) {
Ok(true) => {}
Ok(false) => return StopOutcome::Stopped,
Err(e) => return StopOutcome::Failed(e),
}
if wait_gone(expected, super::KILL_WAIT) {
StopOutcome::Stopped
} else {
StopOutcome::Failed(format!("pid {} survived SIGKILL", expected.pid))
}
}
fn wait_gone(expected: &super::ProcId, within: Duration) -> bool {
let deadline = Instant::now() + within;
loop {
if super::verify(expected) != super::Verified::Same {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(50));
}
}
}
#[cfg(target_os = "linux")]
mod sys {
use super::unix_common;
use super::{ProcId, StopOutcome};
use std::path::PathBuf;
use std::time::Duration;
pub(super) use unix_common::{exists, running_elevated, spawn_detached};
fn proc_dir(pid: u32) -> PathBuf {
PathBuf::from("/proc").join(pid.to_string())
}
pub(super) fn read(pid: u32) -> Option<ProcId> {
let dir = proc_dir(pid);
let stat = std::fs::read_to_string(dir.join("stat")).ok()?;
let start = super::parse_stat_start(&stat)?;
let exe = std::fs::read_link(dir.join("exe")).ok()?;
Some(ProcId {
pid,
start: start.to_string(),
exe,
})
}
pub(super) fn processes() -> Result<Vec<(u32, PathBuf)>, String> {
let entries = std::fs::read_dir("/proc").map_err(|e| format!("reading /proc: {e}"))?;
let mut out = Vec::new();
for entry in entries.flatten() {
let Some(pid) = entry
.file_name()
.to_str()
.and_then(|s| s.parse::<u32>().ok())
else {
continue;
};
match std::fs::read_link(entry.path().join("exe")) {
Ok(exe) => out.push((pid, exe)),
Err(_) => {
if let Ok(comm) = std::fs::read_to_string(entry.path().join("comm")) {
out.push((pid, PathBuf::from(comm.trim())));
}
}
}
}
Ok(out)
}
fn pidfd_open(pid: u32) -> std::io::Result<libc::c_int> {
let p = unix_common::to_pid(pid)
.ok_or_else(|| std::io::Error::other(format!("refusing pid {pid}")))?;
let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, p, 0 as libc::c_uint) };
if fd < 0 {
return Err(std::io::Error::last_os_error());
}
libc::c_int::try_from(fd).map_err(|_| std::io::Error::other("pidfd out of range"))
}
struct Fd(libc::c_int);
impl Drop for Fd {
fn drop(&mut self) {
unsafe { libc::close(self.0) };
}
}
fn pidfd_signal(fd: &Fd, sig: libc::c_int) -> std::io::Result<bool> {
let rc = unsafe {
libc::syscall(
libc::SYS_pidfd_send_signal,
fd.0,
sig,
std::ptr::null::<libc::siginfo_t>(),
0 as libc::c_uint,
)
};
if rc == 0 {
return Ok(true);
}
let err = std::io::Error::last_os_error();
if err.raw_os_error() == Some(libc::ESRCH) {
return Ok(false);
}
Err(err)
}
fn pidfd_wait(fd: &Fd, within: Duration) -> bool {
let mut pfd = libc::pollfd {
fd: fd.0,
events: libc::POLLIN,
revents: 0,
};
let ms = libc::c_int::try_from(within.as_millis()).unwrap_or(libc::c_int::MAX);
let rc = unsafe { libc::poll(&mut pfd, 1, ms) };
rc > 0
}
pub(super) fn stop(expected: &ProcId, grace: Duration) -> StopOutcome {
use super::{verify, Verified};
let fd = match pidfd_open(expected.pid) {
Ok(fd) => Fd(fd),
Err(e) if e.raw_os_error() == Some(libc::ESRCH) => return StopOutcome::Gone,
Err(e) if e.raw_os_error() == Some(libc::ENOSYS) => {
return unix_common::stop_by_reread(expected, grace);
}
Err(e) => return StopOutcome::Failed(format!("pidfd_open({}): {e}", expected.pid)),
};
match verify(expected) {
Verified::Same => {}
Verified::Gone => return StopOutcome::Gone,
Verified::Changed => return StopOutcome::Changed,
Verified::Unreadable => return StopOutcome::Unreadable,
}
match pidfd_signal(&fd, libc::SIGTERM) {
Ok(true) => {}
Ok(false) => return StopOutcome::Gone,
Err(e) => return StopOutcome::Failed(format!("SIGTERM to {}: {e}", expected.pid)),
}
if pidfd_wait(&fd, grace) {
return StopOutcome::Stopped;
}
match pidfd_signal(&fd, libc::SIGKILL) {
Ok(true) => {}
Ok(false) => return StopOutcome::Stopped,
Err(e) => return StopOutcome::Failed(format!("SIGKILL to {}: {e}", expected.pid)),
}
if pidfd_wait(&fd, super::KILL_WAIT) {
StopOutcome::Stopped
} else {
StopOutcome::Failed(format!("pid {} survived SIGKILL", expected.pid))
}
}
}
#[cfg(target_os = "macos")]
mod sys {
use super::unix_common;
use super::{ProcId, StopOutcome};
use std::path::PathBuf;
use std::time::Duration;
pub(super) use unix_common::{exists, running_elevated, spawn_detached};
fn start_us(pid: libc::c_int) -> Option<u64> {
let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
let size = libc::c_int::try_from(std::mem::size_of::<libc::proc_bsdinfo>()).ok()?;
let rc = unsafe {
libc::proc_pidinfo(
pid,
libc::PROC_PIDTBSDINFO,
0,
(&mut info as *mut libc::proc_bsdinfo).cast(),
size,
)
};
if rc != size {
return None;
}
info.pbi_start_tvsec
.checked_mul(1_000_000)
.and_then(|s| s.checked_add(info.pbi_start_tvusec))
}
fn pidpath(pid: libc::c_int) -> Option<PathBuf> {
use std::os::unix::ffi::OsStrExt;
let mut buf = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
let size = u32::try_from(buf.len()).ok()?;
let len = unsafe { libc::proc_pidpath(pid, buf.as_mut_ptr().cast(), size) };
let len = usize::try_from(len).ok().filter(|l| *l > 0)?;
Some(PathBuf::from(std::ffi::OsStr::from_bytes(&buf[..len])))
}
pub(super) fn read(pid: u32) -> Option<ProcId> {
let p = unix_common::to_pid(pid)?;
let start = start_us(p)?;
let exe = pidpath(p)?;
(start_us(p)? == start).then(|| ProcId {
pid,
start: start.to_string(),
exe,
})
}
pub(super) fn processes() -> Result<Vec<(u32, PathBuf)>, String> {
let n = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) };
if n <= 0 {
return Err("proc_listallpids failed".into());
}
let mut pids = vec![0 as libc::c_int; usize::try_from(n).unwrap_or(0) + 64];
let bytes = libc::c_int::try_from(pids.len() * std::mem::size_of::<libc::c_int>())
.map_err(|_| "proc_listallpids: too many pids".to_string())?;
let got = unsafe { libc::proc_listallpids(pids.as_mut_ptr().cast(), bytes) };
if got <= 0 {
return Err("proc_listallpids failed".into());
}
pids.truncate(usize::try_from(got).unwrap_or(0).min(pids.len()));
let mut out = Vec::new();
for pid in pids.into_iter().filter(|p| *p > 0) {
if let Some(exe) = pidpath(pid) {
out.push((u32::try_from(pid).unwrap_or(0), exe));
} else if let Some(name) = proc_name(pid) {
out.push((u32::try_from(pid).unwrap_or(0), PathBuf::from(name)));
}
}
Ok(out)
}
fn proc_name(pid: libc::c_int) -> Option<String> {
let mut buf = vec![0u8; 256];
let size = u32::try_from(buf.len()).ok()?;
let len = unsafe { libc::proc_name(pid, buf.as_mut_ptr().cast(), size) };
let len = usize::try_from(len).ok().filter(|l| *l > 0)?;
Some(String::from_utf8_lossy(&buf[..len]).into_owned())
}
pub(super) fn stop(expected: &ProcId, grace: Duration) -> StopOutcome {
unix_common::stop_by_reread(expected, grace)
}
}
#[cfg(windows)]
mod sys {
use super::{ProcId, StopOutcome};
use std::path::PathBuf;
use std::time::Duration;
use windows_sys::Win32::Foundation::{
CloseHandle, ERROR_ACCESS_DENIED, FILETIME, HANDLE, INVALID_HANDLE_VALUE, WAIT_OBJECT_0,
};
use windows_sys::Win32::System::Threading::{
GetCurrentProcess, GetExitCodeProcess, GetProcessTimes, OpenProcess, OpenProcessToken,
QueryFullProcessImageNameW, TerminateProcess, WaitForSingleObject, PROCESS_NAME_WIN32,
PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE,
};
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000;
const STILL_ACTIVE: u32 = 259;
struct Handle(HANDLE);
impl Drop for Handle {
fn drop(&mut self) {
unsafe { CloseHandle(self.0) };
}
}
fn open(pid: u32, access: u32) -> Option<Handle> {
let h = unsafe { OpenProcess(access, 0, pid) };
(!h.is_null()).then_some(Handle(h))
}
fn creation_time(h: &Handle) -> Option<u64> {
let zero = FILETIME {
dwLowDateTime: 0,
dwHighDateTime: 0,
};
let (mut created, mut exited, mut kernel, mut user) = (zero, zero, zero, zero);
let ok = unsafe { GetProcessTimes(h.0, &mut created, &mut exited, &mut kernel, &mut user) };
if ok == 0 {
return None;
}
Some((u64::from(created.dwHighDateTime) << 32) | u64::from(created.dwLowDateTime))
}
fn image(h: &Handle) -> Option<PathBuf> {
use std::os::windows::ffi::OsStringExt;
let mut buf = vec![0u16; 32_768];
let mut len = u32::try_from(buf.len()).ok()?;
let ok = unsafe {
QueryFullProcessImageNameW(h.0, PROCESS_NAME_WIN32, buf.as_mut_ptr(), &mut len)
};
if ok == 0 {
return None;
}
buf.truncate(usize::try_from(len).ok()?);
Some(PathBuf::from(std::ffi::OsString::from_wide(&buf)))
}
fn still_running(h: &Handle) -> bool {
let mut code = 0u32;
let ok = unsafe { GetExitCodeProcess(h.0, &mut code) };
ok != 0 && code == STILL_ACTIVE
}
fn identity(pid: u32, h: &Handle) -> Option<ProcId> {
if !still_running(h) {
return None;
}
Some(ProcId {
pid,
start: creation_time(h)?.to_string(),
exe: image(h)?,
})
}
pub(super) fn read(pid: u32) -> Option<ProcId> {
let h = open(pid, PROCESS_QUERY_LIMITED_INFORMATION)?;
identity(pid, &h)
}
pub(super) fn exists(pid: u32) -> bool {
open(pid, PROCESS_QUERY_LIMITED_INFORMATION).is_some_and(|h| still_running(&h))
}
pub(super) fn processes() -> Result<Vec<(u32, PathBuf)>, String> {
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
TH32CS_SNAPPROCESS,
};
let snap = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
if snap == INVALID_HANDLE_VALUE || snap.is_null() {
return Err(format!(
"CreateToolhelp32Snapshot: {}",
std::io::Error::last_os_error()
));
}
let snap = Handle(snap);
let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
entry.dwSize = u32::try_from(std::mem::size_of::<PROCESSENTRY32W>()).unwrap_or(0);
let mut out = Vec::new();
let mut more = unsafe { Process32FirstW(snap.0, &mut entry) } != 0;
if !more {
return Err(format!(
"Process32FirstW: {}",
std::io::Error::last_os_error()
));
}
while more {
let len = entry
.szExeFile
.iter()
.position(|c| *c == 0)
.unwrap_or(entry.szExeFile.len());
let name = String::from_utf16_lossy(&entry.szExeFile[..len]);
out.push((entry.th32ProcessID, PathBuf::from(name)));
more = unsafe { Process32NextW(snap.0, &mut entry) } != 0;
}
Ok(out)
}
pub(super) fn running_elevated() -> bool {
use windows_sys::Win32::Security::{
GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY,
};
let mut token: HANDLE = std::ptr::null_mut();
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
return true;
}
let token = Handle(token);
let mut elevation = TOKEN_ELEVATION { TokenIsElevated: 0 };
let size = u32::try_from(std::mem::size_of::<TOKEN_ELEVATION>()).unwrap_or(0);
let mut got = 0u32;
let ok = unsafe {
GetTokenInformation(
token.0,
TokenElevation,
(&mut elevation as *mut TOKEN_ELEVATION).cast(),
size,
&mut got,
)
};
ok == 0 || elevation.TokenIsElevated != 0
}
pub(super) fn spawn_detached(
cmd: &mut std::process::Command,
) -> std::io::Result<std::process::Child> {
use std::os::windows::process::CommandExt;
crate::core::process_compat::no_console_window(cmd);
cmd.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB);
match cmd.spawn() {
Err(e) if e.raw_os_error() == i32::try_from(ERROR_ACCESS_DENIED).ok() => {
cmd.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP);
cmd.spawn()
}
other => other,
}
}
pub(super) fn stop(expected: &ProcId, grace: Duration) -> StopOutcome {
let Some(h) = open(
expected.pid,
PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_TERMINATE | PROCESS_SYNCHRONIZE,
) else {
return if exists(expected.pid) {
StopOutcome::Unreadable
} else {
StopOutcome::Gone
};
};
if !still_running(&h) {
return StopOutcome::Gone;
}
match identity(expected.pid, &h) {
Some(now) if now.same_process(expected) => {}
Some(_) => return StopOutcome::Changed,
None => return StopOutcome::Unreadable,
}
if unsafe { TerminateProcess(h.0, 1) } == 0 {
return StopOutcome::Failed(format!(
"TerminateProcess({}): {}",
expected.pid,
std::io::Error::last_os_error()
));
}
let ms = u32::try_from(grace.as_millis()).unwrap_or(u32::MAX);
if unsafe { WaitForSingleObject(h.0, ms) } == WAIT_OBJECT_0 {
StopOutcome::Stopped
} else {
StopOutcome::Failed(format!(
"pid {} did not exit after termination",
expected.pid
))
}
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
mod sys {
use super::unix_common;
use super::{ProcId, StopOutcome};
use std::path::PathBuf;
use std::time::Duration;
pub(super) use unix_common::{exists, running_elevated, spawn_detached};
pub(super) fn read(_pid: u32) -> Option<ProcId> {
None
}
pub(super) fn processes() -> Result<Vec<(u32, PathBuf)>, String> {
Err("no process table reader on this OS".into())
}
pub(super) fn stop(_expected: &ProcId, _grace: Duration) -> StopOutcome {
StopOutcome::Unreadable
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hooks::proxy_env_file::EnvJson;
fn keys() -> EnvJson {
EnvJson {
https_proxy: "http://127.0.0.1:47811".into(),
http_proxy: "http://127.0.0.1:47811".into(),
no_proxy: "localhost,127.0.0.1,::1,[::1]".into(),
node_extra_ca_certs: "/ol/model-relay/ca/ca.pem".into(),
instance_id: "0123456789abcdef0123456789abcdef".into(),
}
}
fn os(k: &str, v: &str) -> (OsString, OsString) {
(OsString::from(k), OsString::from(v))
}
#[test]
fn opencode_names_match_case_insensitively_with_exe_stripped() {
for name in [
"opencode",
"OpenCode",
"opencode-cli",
"opencode-cli.exe",
"OpenCode.exe",
"ai.opencode.desktop",
] {
assert!(is_opencode_exe(Path::new(name)), "{name}");
}
for name in ["opencoder", "node", "opencode-cli-dev", "bash", "cline"] {
assert!(!is_opencode_exe(Path::new(name)), "{name}");
}
assert!(is_service_binary(Path::new("opencode-cli.EXE")));
assert!(!is_service_binary(Path::new("opencode")));
}
#[test]
fn the_service_environment_strips_openlatch_credentials_and_proxies_and_adds_four_keys() {
let inherited = vec![
os("PATH", "/usr/bin"),
os("HOME", "/home/dev"),
os("SystemRoot", "C:\\Windows"),
os("OPENLATCH_API_KEY", "secret-a"),
os("OPENLATCH_TOKEN", "secret-b"),
os("openlatch_proxy_auth", "secret-c"),
os("ALL_PROXY", "socks5://corp:1080"),
os("all_proxy", "socks5://corp:1080"),
os("https_proxy", "http://user:pw@corp:8080"),
os("HTTP_PROXY", "http://user:pw@corp:8080"),
os("no_proxy", "corp.internal"),
os("NODE_EXTRA_CA_CERTS", "/etc/corp.pem"),
os("ANTHROPIC_API_KEY", "provider-key-kept"),
];
let env = ServiceEnv::new(inherited, &keys());
let mut cmd = std::process::Command::new("opencode-cli");
env.apply(&mut cmd);
let envs: Vec<(String, Option<String>)> = cmd
.get_envs()
.map(|(k, v)| {
(
k.to_string_lossy().into_owned(),
v.map(|v| v.to_string_lossy().into_owned()),
)
})
.collect();
let norm = |k: &str| {
if cfg!(windows) {
k.to_ascii_uppercase()
} else {
k.to_string()
}
};
let set: Vec<&(String, Option<String>)> =
envs.iter().filter(|(_, v)| v.is_some()).collect();
let mut set_names: Vec<String> = set.iter().map(|(k, _)| norm(k)).collect();
set_names.sort();
let mut want: Vec<String> = RELAY_KEYS.iter().map(|k| norm(k)).collect();
want.sort();
assert_eq!(set_names, want, "exactly the four relay keys are added");
let value = |k: &str| {
envs.iter()
.find(|(n, _)| norm(n) == norm(k))
.and_then(|(_, v)| v.clone())
};
assert_eq!(
value("HTTPS_PROXY").as_deref(),
Some("http://127.0.0.1:47811")
);
assert_eq!(
value("NODE_EXTRA_CA_CERTS").as_deref(),
Some("/ol/model-relay/ca/ca.pem")
);
let removed: Vec<String> = envs
.iter()
.filter(|(_, v)| v.is_none())
.map(|(k, _)| k.to_ascii_uppercase())
.collect();
for gone in [
"OPENLATCH_API_KEY",
"OPENLATCH_TOKEN",
"OPENLATCH_PROXY_AUTH",
"ALL_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
] {
let overridden = cfg!(windows) && RELAY_KEYS.contains(&gone) && value(gone).is_some();
assert!(
removed.iter().any(|k| k == gone) || overridden,
"{gone} is removed"
);
}
for kept in ["PATH", "HOME", "SYSTEMROOT", "ANTHROPIC_API_KEY"] {
assert!(
!envs.iter().any(|(k, _)| k.eq_ignore_ascii_case(kept)),
"{kept} is inherited untouched"
);
}
}
#[test]
fn stat_start_counts_after_the_last_paren() {
let stat =
"4242 (a) b) c) S 1 4242 4242 0 -1 4194560 100 0 0 0 1 2 0 0 20 0 1 0 987654 1000 10";
assert_eq!(parse_stat_start(stat), Some(987_654));
assert_eq!(parse_stat_start("garbage"), None);
}
#[test]
fn pid_zero_is_never_read_or_signalled() {
assert!(read(0).is_none());
let id = ProcId {
pid: 0,
start: "1".into(),
exe: PathBuf::from("opencode-cli"),
};
assert!(matches!(
stop(&id, Duration::from_millis(10)),
StopOutcome::Failed(_)
));
}
#[test]
fn our_own_process_reads_with_a_stable_identity() {
let me = std::process::id();
let a = read(me).expect("own identity readable");
let b = read(me).expect("own identity readable twice");
assert!(a.same_process(&b));
assert!(!a.start.is_empty());
}
#[test]
fn an_unreadable_table_counts_as_attached() {
struct Broken;
impl ProcessTable for Broken {
fn read(&self, _: u32) -> Option<ProcId> {
None
}
fn exists(&self, _: u32) -> bool {
true
}
fn opencode_processes(&self) -> Result<Vec<(u32, PathBuf)>, String> {
Err("denied".into())
}
fn stop(&self, _: &ProcId, _: Duration) -> StopOutcome {
StopOutcome::Unreadable
}
fn spawn(&self, _: &Path, _: &ServiceEnv) -> std::io::Result<ProcId> {
Err(std::io::Error::other("no"))
}
}
assert_eq!(attached(&Broken, &[]), Attached::Unknown);
assert!(Attached::Unknown.blocks_replacement());
assert!(Attached::Yes.blocks_replacement());
assert!(!Attached::No.blocks_replacement());
}
#[cfg(unix)]
mod real {
use super::*;
use std::time::Instant;
fn stub(dir: &Path) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = dir.join("opencode-cli");
std::fs::write(
&path,
"#!/bin/sh\nenv > \"$(dirname \"$0\")/env.out.tmp\"\nmv \"$(dirname \"$0\")/env.out.tmp\" \"$(dirname \"$0\")/env.out\"\nexec sleep 30\n",
)
.unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
fn spawn_retrying(cli: &Path, env: &ServiceEnv) -> ProcId {
for _ in 0..50 {
match spawn_in(cli, env, cli.parent()) {
Ok(id) => return id,
Err(e) if e.raw_os_error() == Some(libc::ETXTBSY) => {
std::thread::sleep(Duration::from_millis(20));
}
Err(e) => panic!("spawn failed: {e}"),
}
}
panic!("spawn kept failing with ETXTBSY");
}
fn wait_for(path: &Path) -> String {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if let Ok(s) = std::fs::read_to_string(path) {
return s;
}
std::thread::sleep(Duration::from_millis(20));
}
panic!("{} never appeared", path.display());
}
#[test]
fn a_spawned_service_leads_its_own_session_with_exactly_the_relay_keys() {
let dir = tempfile::tempdir().unwrap();
let cli = stub(dir.path());
let env = ServiceEnv::new(std::env::vars_os(), &keys());
let id = spawn_retrying(&cli, &env);
let sid = unsafe { libc::getsid(libc::pid_t::try_from(id.pid).unwrap()) };
assert_eq!(
sid,
libc::pid_t::try_from(id.pid).unwrap(),
"setsid made it a session leader"
);
let dumped = wait_for(&dir.path().join("env.out"));
let lines: Vec<&str> = dumped.lines().collect();
for (k, v) in &env.add {
assert!(
lines.contains(&format!("{k}={v}").as_str()),
"{k} set in the child"
);
}
assert!(
!lines.iter().any(|l| l.starts_with("OPENLATCH_")),
"no OPENLATCH_ variable reaches the service"
);
assert!(
!lines
.iter()
.any(|l| l.to_ascii_uppercase().starts_with("ALL_PROXY=")),
"no ALL_PROXY reaches the service"
);
let now = read(id.pid).expect("child readable");
assert!(stop(&now, Duration::from_secs(5)).is_gone());
}
#[test]
fn stop_signals_only_a_process_whose_identity_still_matches() {
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("sleep spawns");
let pid = child.id();
let id = read(pid).expect("child readable");
let reused = ProcId {
start: format!("{}9", id.start),
..id.clone()
};
assert_eq!(
stop(&reused, Duration::from_millis(200)),
StopOutcome::Changed
);
let other_exe = ProcId {
exe: PathBuf::from("/nonexistent/opencode-cli"),
..id.clone()
};
assert_eq!(
stop(&other_exe, Duration::from_millis(200)),
StopOutcome::Changed
);
assert!(
read(pid).is_some_and(|p| p.same_process(&id)),
"still running"
);
let reaper = std::thread::spawn(move || child.wait());
assert_eq!(stop(&id, Duration::from_secs(5)), StopOutcome::Stopped);
reaper.join().unwrap().unwrap();
assert_eq!(stop(&id, Duration::from_millis(100)), StopOutcome::Gone);
}
#[test]
fn a_process_ignoring_sigterm_is_killed_after_the_grace() {
let mut child = std::process::Command::new("sh")
.arg("-c")
.arg("trap '' TERM; while :; do sleep 1; done")
.spawn()
.expect("sh spawns");
let pid = child.id();
std::thread::sleep(Duration::from_millis(300));
let id = read(pid).expect("child readable");
let reaper = std::thread::spawn(move || child.wait());
let began = Instant::now();
assert_eq!(stop(&id, Duration::from_millis(300)), StopOutcome::Stopped);
assert!(began.elapsed() >= Duration::from_millis(300));
reaper.join().unwrap().unwrap();
}
}
#[cfg(windows)]
mod real_windows {
use super::*;
use std::time::Instant;
fn stub(dir: &Path) -> PathBuf {
let path = dir.join("opencode-cli.cmd");
std::fs::write(
&path,
"@echo off\r\nset > \"%~dp0env.tmp\"\r\nmove /y \"%~dp0env.tmp\" \"%~dp0env.out\" > nul\r\nping -n 30 127.0.0.1 > nul\r\n",
)
.unwrap();
path
}
fn wait_for(path: &Path) -> String {
let deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < deadline {
if let Ok(s) = std::fs::read_to_string(path) {
return s;
}
std::thread::sleep(Duration::from_millis(50));
}
panic!("{} never appeared", path.display());
}
#[test]
fn a_spawned_service_gets_exactly_the_relay_keys_and_keeps_system_root() {
let dir = tempfile::tempdir().unwrap();
let cli = stub(dir.path());
let env = ServiceEnv::new(std::env::vars_os(), &keys());
let id = spawn_in(&cli, &env, Some(dir.path())).expect("the service spawns");
let dumped = wait_for(&dir.path().join("env.out"));
let lines: Vec<String> = dumped.lines().map(str::to_ascii_uppercase).collect();
for (k, v) in &env.add {
let want = format!("{k}={v}").to_ascii_uppercase();
assert!(lines.contains(&want), "{k} set in the child");
}
assert!(
!lines.iter().any(|l| l.starts_with("OPENLATCH_")),
"no OPENLATCH_ variable reaches the service"
);
assert!(
!lines.iter().any(|l| l.starts_with("ALL_PROXY=")),
"no ALL_PROXY reaches the service"
);
assert!(
lines.iter().any(|l| l.starts_with("SYSTEMROOT=")),
"SystemRoot is inherited: the environment is never cleared"
);
let now = read(id.pid).expect("child readable");
assert!(now.same_process(&id));
assert!(stop(&now, Duration::from_secs(5)).is_gone());
}
#[test]
fn termination_needs_the_identity_on_the_pinned_handle_to_match() {
let mut child = std::process::Command::new("ping")
.args(["-n", "30", "127.0.0.1"])
.stdout(std::process::Stdio::null())
.spawn()
.expect("ping spawns");
let pid = child.id();
let id = read(pid).expect("child readable");
let reused = ProcId {
start: format!("{}9", id.start),
..id.clone()
};
assert_eq!(
stop(&reused, Duration::from_millis(200)),
StopOutcome::Changed
);
let other_exe = ProcId {
exe: PathBuf::from(r"C:\nonexistent\opencode-cli.exe"),
..id.clone()
};
assert_eq!(
stop(&other_exe, Duration::from_millis(200)),
StopOutcome::Changed
);
assert!(
read(pid).is_some_and(|p| p.same_process(&id)),
"still running"
);
assert_eq!(stop(&id, Duration::from_secs(5)), StopOutcome::Stopped);
child.wait().expect("reaped");
assert_eq!(stop(&id, Duration::from_millis(100)), StopOutcome::Gone);
}
}
}