Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
1081 lines
39 KiB
Rust
1081 lines
39 KiB
Rust
//! Self-daemonization and single-instance locking for the workspace-server.
|
|
//!
|
|
//! The server is launched fire-and-forget by the sandbox orchestrator, which
|
|
//! only ever holds a handle to the originally-spawned PID / process group.
|
|
//! After a double-fork + `setsid()` the surviving daemon lives in a new
|
|
//! session and process group, so a later process-group kill on the original
|
|
//! pgid cannot reach it.
|
|
//!
|
|
//! The double-fork MUST run before the tokio runtime — or `tracing_subscriber`
|
|
//! / the rustls provider — start any threads: forking a multi-threaded process
|
|
//! leaves every lock held by a non-forking thread permanently locked in the
|
|
//! child, which can deadlock it.
|
|
|
|
use std::fs::{self, File, OpenOptions};
|
|
use std::io::{self, Write};
|
|
#[cfg(target_os = "linux")]
|
|
use std::os::fd::{FromRawFd as _, OwnedFd};
|
|
use std::path::Path;
|
|
use std::time::{Duration, Instant};
|
|
use std::{process, thread};
|
|
#[cfg(windows)]
|
|
use windows::Win32::Foundation::HANDLE;
|
|
|
|
use fs2::FileExt;
|
|
|
|
use crate::util::is_lock_contended;
|
|
|
|
#[cfg(unix)]
|
|
use std::os::unix::io::{AsRawFd, RawFd};
|
|
|
|
/// stdout + stderr redirect target when no `--log-file` is given.
|
|
#[cfg(unix)]
|
|
pub const DEFAULT_LOG_PATH: &str = "/tmp/workspace-server.log";
|
|
#[cfg(windows)]
|
|
pub const DEFAULT_LOG_PATH: &str = "C:\\Windows\\Temp\\workspace-server.log";
|
|
|
|
/// Single-instance lock file used when no `--pid-file` is given.
|
|
#[cfg(unix)]
|
|
pub const DEFAULT_PIDFILE_PATH: &str = "/tmp/workspace-server.pid";
|
|
#[cfg(windows)]
|
|
pub const DEFAULT_PIDFILE_PATH: &str = "C:\\Windows\\Temp\\workspace-server.pid";
|
|
|
|
/// Readiness marker written once the server connection is established; the control
|
|
/// plane polls it and may override the path with `--ready-file`.
|
|
#[cfg(unix)]
|
|
pub const DEFAULT_READY_PATH: &str = "/tmp/workspace-server.ready";
|
|
#[cfg(windows)]
|
|
pub const DEFAULT_READY_PATH: &str = "C:\\Windows\\Temp\\workspace-server.ready";
|
|
|
|
/// How long a takeover waits for the gracefully-terminated predecessor to
|
|
/// release the pidfile lock before escalating to a forceful kill.
|
|
///
|
|
/// Intentionally far below the server's own SIGTERM drain budget
|
|
/// (`KIGI_WORKSPACE_TERMINATION_GRACE_MS`, default 45s): a takeover only
|
|
/// happens when the orchestrator has already declared the incumbent stale,
|
|
/// so a bounded ready time for the replacement outranks completing the
|
|
/// predecessor's drain.
|
|
pub const TAKEOVER_GRACE: Duration = Duration::from_secs(2);
|
|
|
|
/// How long a takeover waits for the lock after the forceful kill (process
|
|
/// death releases the flock) before declining.
|
|
const TAKEOVER_KILL_GRACE: Duration = Duration::from_secs(1);
|
|
|
|
/// Poll interval while waiting for the predecessor to release the lock.
|
|
const TAKEOVER_POLL: Duration = Duration::from_millis(50);
|
|
|
|
/// Invocation fragment identifying a pidfile holder as a workspace-server.
|
|
const WORKSPACE_SERVER_NAME_FRAGMENT: &str = "workspace-server";
|
|
|
|
/// Double-fork + `setsid()` into a new session, `chdir("/")`, and redirect
|
|
/// stdio (stdin ← `/dev/null`, stdout+stderr appended to `log_path`).
|
|
///
|
|
/// Must be called before any runtime/tracing/TLS threads start (see module docs).
|
|
#[cfg(unix)]
|
|
pub fn daemonize(log_path: &Path) -> io::Result<()> {
|
|
// First fork: the launcher-tracked parent exits, orphaning the child.
|
|
fork_and_exit_parent()?;
|
|
|
|
// New session/process group, detaching the controlling terminal. Must
|
|
// follow a fork — a process-group leader cannot call setsid().
|
|
// SAFETY: `setsid()` takes no pointers; it only changes session membership.
|
|
if unsafe { libc::setsid() } == -1 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
|
|
// Second fork: a non-session-leader can never reacquire a controlling tty.
|
|
fork_and_exit_parent()?;
|
|
|
|
// Detach from the launch directory (callers capture cwd beforehand).
|
|
// SAFETY: `c"/"` is a 'static, NUL-terminated string valid for the call.
|
|
if unsafe { libc::chdir(c"/".as_ptr()) } == -1 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
|
|
redirect_stdio(log_path)
|
|
}
|
|
|
|
/// Windows daemonization: no fork/setsid (the launcher already backgrounds the
|
|
/// server) — only redirect stdout+stderr to the log file. Must run before any
|
|
/// stdout/stderr use (Rust caches the std handles on first access). The
|
|
/// single-instance lock is taken separately via [`PidFile`].
|
|
#[cfg(windows)]
|
|
pub fn daemonize(log_path: &Path) -> io::Result<()> {
|
|
use std::os::windows::io::AsRawHandle;
|
|
|
|
use windows::Win32::Foundation::HANDLE;
|
|
use windows::Win32::System::Console::{STD_ERROR_HANDLE, STD_OUTPUT_HANDLE, SetStdHandle};
|
|
|
|
if let Some(parent) = log_path.parent() {
|
|
let _ = fs::create_dir_all(parent);
|
|
}
|
|
let log = daemon_file_options()
|
|
.create(true)
|
|
.append(true)
|
|
.open(log_path)?;
|
|
|
|
let handle = HANDLE(log.as_raw_handle());
|
|
// SAFETY: `handle` is a live file handle owned by `log`; SetStdHandle only
|
|
// records it as the process stdout/stderr. `forget(log)` keeps it open for
|
|
// the process lifetime (the std streams reference it now).
|
|
unsafe {
|
|
SetStdHandle(STD_OUTPUT_HANDLE, handle).map_err(io::Error::other)?;
|
|
SetStdHandle(STD_ERROR_HANDLE, handle).map_err(io::Error::other)?;
|
|
}
|
|
std::mem::forget(log);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(any(unix, windows)))]
|
|
pub fn daemonize(_log_path: &Path) -> io::Result<()> {
|
|
Err(io::Error::new(
|
|
io::ErrorKind::Unsupported,
|
|
"daemonize is only supported on Unix and Windows",
|
|
))
|
|
}
|
|
|
|
/// `fork()`; the parent exits 0, the child returns `Ok(())` to continue.
|
|
#[cfg(unix)]
|
|
fn fork_and_exit_parent() -> io::Result<()> {
|
|
// SAFETY: only called pre-runtime while single-threaded, so the fork
|
|
// cannot strand another thread's lock in the child.
|
|
match unsafe { libc::fork() } {
|
|
-1 => Err(io::Error::last_os_error()),
|
|
0 => Ok(()),
|
|
_ => process::exit(0),
|
|
}
|
|
}
|
|
|
|
/// `OpenOptions` for a daemon-owned file (log or pidfile). On Unix it adds
|
|
/// `O_NOFOLLOW` + mode `0600` as symlink/permission defense-in-depth; the
|
|
/// per-tenant sandbox namespace is the primary control. Shared with the
|
|
/// preview-proxy log (`preview_supervisor`) so both daemon-owned files get the
|
|
/// same posture.
|
|
#[cfg(unix)]
|
|
pub(crate) fn daemon_file_options() -> OpenOptions {
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
let mut opts = OpenOptions::new();
|
|
opts.custom_flags(libc::O_NOFOLLOW).mode(0o600);
|
|
opts
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
pub(crate) fn daemon_file_options() -> OpenOptions {
|
|
OpenOptions::new()
|
|
}
|
|
|
|
/// Open `/dev/null` (read) for stdin and `log_path` (created, append) for
|
|
/// stdout + stderr.
|
|
#[cfg(unix)]
|
|
fn open_stdio_targets(log_path: &Path) -> io::Result<(File, File)> {
|
|
if let Some(parent) = log_path.parent() {
|
|
let _ = fs::create_dir_all(parent);
|
|
}
|
|
let stdin_src = OpenOptions::new().read(true).open("/dev/null")?;
|
|
let log = daemon_file_options()
|
|
.create(true)
|
|
.append(true)
|
|
.open(log_path)?;
|
|
Ok((stdin_src, log))
|
|
}
|
|
|
|
/// `dup2(source, target)`, mapping the `-1` sentinel to an `io::Error`.
|
|
#[cfg(unix)]
|
|
fn redirect_fd(target: RawFd, source: &File) -> io::Result<()> {
|
|
// SAFETY: `source` is an open File and `target` a standard descriptor —
|
|
// both valid for `dup2`.
|
|
if unsafe { libc::dup2(source.as_raw_fd(), target) } == -1 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn redirect_stdio(log_path: &Path) -> io::Result<()> {
|
|
let (stdin_src, log) = open_stdio_targets(log_path)?;
|
|
redirect_fd(libc::STDIN_FILENO, &stdin_src)?;
|
|
redirect_fd(libc::STDOUT_FILENO, &log)?;
|
|
redirect_fd(libc::STDERR_FILENO, &log)?;
|
|
// `stdin_src` / `log` close here; fds 0/1/2 keep their dup'd copies.
|
|
Ok(())
|
|
}
|
|
|
|
/// Single-instance lock backed by an advisory `flock` on a pidfile, held for
|
|
/// the daemon's lifetime. Dropping it closes the file, releasing the lock; the
|
|
/// pidfile itself is left on disk for diagnostics.
|
|
#[derive(Debug)]
|
|
pub struct PidFile {
|
|
_file: File,
|
|
}
|
|
|
|
impl PidFile {
|
|
/// Take the exclusive lock and record the current PID.
|
|
///
|
|
/// - `Ok(Some(_))` — lock acquired; hold the returned guard.
|
|
/// - `Ok(None)` — another live process holds the lock (caller should
|
|
/// no-op and exit cleanly).
|
|
/// - `Err(_)` — an I/O error opening or locking the file.
|
|
pub fn acquire(path: &Path) -> io::Result<Option<Self>> {
|
|
if let Some(parent) = path.parent() {
|
|
let _ = fs::create_dir_all(parent);
|
|
}
|
|
let mut file = daemon_file_options()
|
|
.read(true)
|
|
.write(true)
|
|
.create(true)
|
|
.truncate(false)
|
|
.open(path)?;
|
|
|
|
match file.try_lock_exclusive() {
|
|
Ok(()) => {}
|
|
Err(e) if is_lock_contended(&e) => return Ok(None),
|
|
Err(e) => return Err(e),
|
|
}
|
|
|
|
// PID contents are advisory diagnostics; the flock provides exclusion.
|
|
// `set_len(0)` clears any stale (possibly longer) value first.
|
|
file.set_len(0)?;
|
|
file.write_all(process::id().to_string().as_bytes())?;
|
|
file.flush()?;
|
|
|
|
Ok(Some(Self { _file: file }))
|
|
}
|
|
|
|
/// Acquire the lock, taking over from a live predecessor workspace-server
|
|
/// if one holds it: graceful termination (its normal drain runs), `grace`
|
|
/// to release the lock, then a forceful kill (process death releases the
|
|
/// flock). The lock is never bypassed — a guard is returned only with the
|
|
/// flock held.
|
|
///
|
|
/// `Ok(None)` means the caller should exit quietly: the holder is not an
|
|
/// identifiable workspace-server, or the lock could not be won after the
|
|
/// escalation (e.g. a concurrent newer spawn took it).
|
|
pub fn acquire_or_take_over(path: &Path, grace: Duration) -> io::Result<Option<Self>> {
|
|
Self::acquire_or_take_over_matching(path, grace, WORKSPACE_SERVER_NAME_FRAGMENT)
|
|
}
|
|
|
|
/// [`Self::acquire_or_take_over`] with an injectable name fragment so
|
|
/// tests can match their own predecessor processes.
|
|
fn acquire_or_take_over_matching(
|
|
path: &Path,
|
|
grace: Duration,
|
|
name_fragment: &str,
|
|
) -> io::Result<Option<Self>> {
|
|
if let Some(guard) = Self::acquire(path)? {
|
|
return Ok(Some(guard));
|
|
}
|
|
|
|
let Some(pid) = read_pidfile_pid(path) else {
|
|
return Ok(None);
|
|
};
|
|
if pid == process::id() {
|
|
return Ok(None);
|
|
}
|
|
let Some(predecessor) = PredecessorTarget::open(pid, name_fragment) else {
|
|
return Ok(None);
|
|
};
|
|
|
|
// tracing is not initialized this early; in daemonized mode stderr is
|
|
// already redirected to the log file, so eprintln! is the log channel.
|
|
eprintln!("taking over from predecessor workspace-server (pid {pid})");
|
|
if let Err(e) = predecessor.signal(false) {
|
|
eprintln!("failed to signal predecessor (pid {pid}): {e}");
|
|
}
|
|
if let Some(guard) = Self::poll_acquire(path, grace)? {
|
|
return Ok(Some(guard));
|
|
}
|
|
|
|
eprintln!("predecessor (pid {pid}) did not release the pidfile lock in time; killing it");
|
|
if let Err(e) = predecessor.signal(true) {
|
|
eprintln!("failed to kill predecessor (pid {pid}): {e}");
|
|
}
|
|
if let Some(guard) = Self::poll_acquire(path, TAKEOVER_KILL_GRACE)? {
|
|
return Ok(Some(guard));
|
|
}
|
|
|
|
// The holder we signaled is dead yet the lock is still owned — a
|
|
// concurrent newer spawn won it. Decline rather than double-run.
|
|
eprintln!("pidfile lock is still held after killing pid {pid}; exiting");
|
|
Ok(None)
|
|
}
|
|
|
|
/// Retry [`Self::acquire`] until it succeeds or `budget` elapses.
|
|
fn poll_acquire(path: &Path, budget: Duration) -> io::Result<Option<Self>> {
|
|
let deadline = Instant::now() + budget;
|
|
loop {
|
|
if let Some(guard) = Self::acquire(path)? {
|
|
return Ok(Some(guard));
|
|
}
|
|
if Instant::now() >= deadline {
|
|
return Ok(None);
|
|
}
|
|
thread::sleep(TAKEOVER_POLL);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Advisory pid recorded in the pidfile by its holder; `None` if unreadable
|
|
/// or not a positive integer.
|
|
fn read_pidfile_pid(path: &Path) -> Option<u32> {
|
|
fs::read_to_string(path)
|
|
.ok()?
|
|
.trim()
|
|
.parse::<u32>()
|
|
.ok()
|
|
.filter(|&pid| pid > 0)
|
|
}
|
|
|
|
/// True if the basename of `name` (path separators `/` and `\` both count)
|
|
/// contains `fragment`. Matching the basename rather than the whole path
|
|
/// keeps a directory component like `/home/workspace-server-data/foo` from
|
|
/// satisfying the kill gate.
|
|
#[cfg(any(test, target_os = "linux", windows))]
|
|
fn basename_contains(name: &str, fragment: &str) -> bool {
|
|
name.rsplit(['/', '\\']).next().is_some_and(|base| {
|
|
base.to_ascii_lowercase()
|
|
.contains(&fragment.to_ascii_lowercase())
|
|
})
|
|
}
|
|
|
|
/// True if `pid`'s argv0 basename (from `/proc/<pid>/cmdline`) matches
|
|
/// `fragment`.
|
|
#[cfg(target_os = "linux")]
|
|
fn process_name_matches(pid: u32, fragment: &str) -> bool {
|
|
match fs::read(format!("/proc/{pid}/cmdline")) {
|
|
Ok(cmdline) => cmdline
|
|
.split(|&b| b == 0)
|
|
.next()
|
|
.is_some_and(|argv0| basename_contains(&String::from_utf8_lossy(argv0), fragment)),
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
|
|
/// A pinned, verified handle to the predecessor process: `pidfd_open(2)` on
|
|
/// Linux, an `OpenProcess` handle on Windows.
|
|
///
|
|
/// Pinning happens **before** verification and every signal is delivered
|
|
/// through the pin, closing the check-then-kill pid-reuse race by
|
|
/// construction: a recycled pid is unreachable — at worst a signal lands on
|
|
/// the already-dead pinned instance and is a no-op.
|
|
#[cfg(target_os = "linux")]
|
|
struct PredecessorTarget {
|
|
pid: u32,
|
|
/// `None` = pidfd unsupported on this kernel; plain-`kill` fallback mode
|
|
/// (retains only the historical residual race).
|
|
pidfd: Option<OwnedFd>,
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
impl PredecessorTarget {
|
|
/// Pin `pid` and verify its executable basename matches `fragment`.
|
|
/// `None` if the process is gone, inaccessible, or not a match.
|
|
fn open(pid: u32, fragment: &str) -> Option<Self> {
|
|
// SAFETY: pidfd_open takes value arguments only; the returned fd is
|
|
// fresh and exclusively owned here.
|
|
let ret = unsafe { libc::syscall(libc::SYS_pidfd_open, pid as libc::pid_t, 0u32) };
|
|
let pidfd = if ret >= 0 {
|
|
// SAFETY: `ret` is a freshly returned, unowned fd.
|
|
Some(unsafe { OwnedFd::from_raw_fd(ret as RawFd) })
|
|
} else {
|
|
let e = io::Error::last_os_error();
|
|
if e.raw_os_error() == Some(libc::ESRCH) {
|
|
return None;
|
|
}
|
|
// ENOSYS or seccomp-filtered: degrade to unpinned kill().
|
|
None
|
|
};
|
|
|
|
// Verify after pinning: a pid recycled before the pin fails the name
|
|
// match; recycled after, the pin targets the dead predecessor.
|
|
if !process_name_matches(pid, fragment) {
|
|
return None;
|
|
}
|
|
Some(Self { pid, pidfd })
|
|
}
|
|
|
|
/// Deliver graceful (SIGTERM) or forceful (SIGKILL) termination to the
|
|
/// pinned instance. Already-dead is `Ok`.
|
|
fn signal(&self, forceful: bool) -> io::Result<()> {
|
|
let signal = if forceful {
|
|
libc::SIGKILL
|
|
} else {
|
|
libc::SIGTERM
|
|
};
|
|
let ret = match &self.pidfd {
|
|
// SAFETY: the pidfd is owned and open; the siginfo pointer is
|
|
// documented-null (kernel builds a default), flags are zero.
|
|
Some(fd) => unsafe {
|
|
libc::syscall(
|
|
libc::SYS_pidfd_send_signal,
|
|
fd.as_raw_fd(),
|
|
signal,
|
|
std::ptr::null::<libc::siginfo_t>(),
|
|
0u32,
|
|
)
|
|
},
|
|
// SAFETY: kill() takes no pointers.
|
|
None => unsafe { libc::kill(self.pid as libc::pid_t, signal) }.into(),
|
|
};
|
|
if ret == 0 {
|
|
return Ok(());
|
|
}
|
|
match io::Error::last_os_error() {
|
|
e if e.raw_os_error() == Some(libc::ESRCH) => Ok(()),
|
|
e => Err(e),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
struct PredecessorTarget {
|
|
handle: HANDLE,
|
|
}
|
|
|
|
// SAFETY: the HANDLE is an owned kernel object reference; it is not tied to
|
|
// the creating thread and is only used behind &self.
|
|
#[cfg(windows)]
|
|
unsafe impl Send for PredecessorTarget {}
|
|
|
|
#[cfg(windows)]
|
|
impl PredecessorTarget {
|
|
/// Pin `pid` with query + terminate rights and verify the image basename
|
|
/// matches `fragment` on the pinned handle.
|
|
fn open(pid: u32, fragment: &str) -> Option<Self> {
|
|
use windows::Win32::System::Threading::{
|
|
OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE,
|
|
QueryFullProcessImageNameW,
|
|
};
|
|
use windows::core::PWSTR;
|
|
|
|
// SAFETY: OpenProcess is FFI with value args; windows-rs returns Err
|
|
// on absence/permission failure.
|
|
let handle = unsafe {
|
|
OpenProcess(
|
|
PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_TERMINATE,
|
|
false,
|
|
pid,
|
|
)
|
|
}
|
|
.ok()?;
|
|
let target = Self { handle };
|
|
|
|
// QueryFullProcessImageNameW writes a NUL-terminated UTF-16 path into
|
|
// the buffer; `size` is updated to the chars written (excluding NUL).
|
|
let mut buf: Vec<u16> = vec![0; 1024];
|
|
let mut size: u32 = buf.len() as u32;
|
|
// SAFETY: the handle is pinned by `target`; buf outlives the call;
|
|
// size is in/out.
|
|
let result = unsafe {
|
|
QueryFullProcessImageNameW(
|
|
target.handle,
|
|
PROCESS_NAME_WIN32,
|
|
PWSTR(buf.as_mut_ptr()),
|
|
&mut size,
|
|
)
|
|
};
|
|
if result.is_err() {
|
|
return None;
|
|
}
|
|
basename_contains(&String::from_utf16_lossy(&buf[..size as usize]), fragment)
|
|
.then_some(target)
|
|
}
|
|
|
|
/// `TerminateProcess` on the pinned handle (Windows has no graceful
|
|
/// signal for a detached process). Already-dead is `Ok`.
|
|
fn signal(&self, _forceful: bool) -> io::Result<()> {
|
|
use windows::Win32::System::Threading::TerminateProcess;
|
|
|
|
// SAFETY: the handle is the pinned kernel object owned by self.
|
|
match unsafe { TerminateProcess(self.handle, 0) } {
|
|
Ok(()) => Ok(()),
|
|
// Already exited: terminating a dead (but pinned) process fails
|
|
// with access-style errors; the takeover treats that as done.
|
|
Err(e) => Err(io::Error::other(format!("TerminateProcess: {e}"))),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
impl Drop for PredecessorTarget {
|
|
fn drop(&mut self) {
|
|
use windows::Win32::Foundation::CloseHandle;
|
|
// SAFETY: the handle is owned by self and closed exactly once.
|
|
let _ = unsafe { CloseHandle(self.handle) };
|
|
}
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "linux", windows)))]
|
|
struct PredecessorTarget;
|
|
|
|
#[cfg(not(any(target_os = "linux", windows)))]
|
|
impl PredecessorTarget {
|
|
/// Unsupported platform: never identify a predecessor (takeover declines
|
|
/// rather than kill blind).
|
|
fn open(_pid: u32, _fragment: &str) -> Option<Self> {
|
|
None
|
|
}
|
|
|
|
fn signal(&self, _forceful: bool) -> io::Result<()> {
|
|
Err(io::Error::new(
|
|
io::ErrorKind::Unsupported,
|
|
"process termination is only supported on Linux and Windows",
|
|
))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
// Used only by the linux-gated predecessor-takeover tests below.
|
|
#[cfg(target_os = "linux")]
|
|
use std::process::{Child, Command, Stdio};
|
|
|
|
use tempfile::TempDir;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn pidfile_acquire_is_exclusive() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("ws.pid");
|
|
|
|
let first = PidFile::acquire(&path).unwrap();
|
|
assert!(first.is_some(), "first acquire should win the lock");
|
|
|
|
// A second open of the same path conflicts on the advisory flock,
|
|
// even within the same process (flock is per open file description).
|
|
let second = PidFile::acquire(&path).unwrap();
|
|
assert!(second.is_none(), "contended acquire must report None");
|
|
|
|
drop(first);
|
|
|
|
// Dropping the guard closes the fd and releases the flock. Retry briefly:
|
|
// under the parallel test runner a concurrent `fork`/`Command::spawn` can
|
|
// transiently duplicate this flock'd fd, holding the lock until the child
|
|
// `execve`s (the fd is `O_CLOEXEC`). That window is microseconds, so a
|
|
// short bounded retry makes the release deterministic without weakening
|
|
// the held-exclusion assertion above.
|
|
let deadline = Instant::now() + Duration::from_secs(2);
|
|
let third = loop {
|
|
match PidFile::acquire(&path).unwrap() {
|
|
Some(guard) => break Some(guard),
|
|
None if Instant::now() < deadline => {
|
|
thread::sleep(Duration::from_millis(5));
|
|
}
|
|
none => break none,
|
|
}
|
|
};
|
|
assert!(third.is_some(), "acquire should succeed after release");
|
|
}
|
|
|
|
#[test]
|
|
fn pidfile_records_current_pid() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("ws.pid");
|
|
|
|
let guard = PidFile::acquire(&path).unwrap().unwrap();
|
|
let contents = fs::read_to_string(&path).unwrap();
|
|
assert_eq!(contents.trim().parse::<u32>().unwrap(), process::id());
|
|
drop(guard);
|
|
}
|
|
|
|
#[test]
|
|
fn pidfile_persists_on_disk_after_drop() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("ws.pid");
|
|
|
|
let guard = PidFile::acquire(&path).unwrap().unwrap();
|
|
assert!(path.exists());
|
|
drop(guard);
|
|
// The file is intentionally left behind for diagnostics; only the
|
|
// lock is released (re-acquirable, covered by the exclusivity test).
|
|
assert!(path.exists(), "pidfile should remain on disk after drop");
|
|
}
|
|
|
|
#[test]
|
|
fn pidfile_acquire_creates_parent_dir() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("nested/sub/ws.pid");
|
|
|
|
let guard = PidFile::acquire(&path).unwrap();
|
|
assert!(guard.is_some());
|
|
assert!(path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn pidfile_acquire_truncates_stale_longer_content() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("ws.pid");
|
|
// A leftover value longer than our PID would leave trailing bytes if
|
|
// `set_len(0)` were missing.
|
|
fs::write(&path, "999999999999 stale junk\n").unwrap();
|
|
|
|
let guard = PidFile::acquire(&path).unwrap().unwrap();
|
|
let contents = fs::read_to_string(&path).unwrap();
|
|
assert_eq!(
|
|
contents,
|
|
process::id().to_string(),
|
|
"stale content must be fully truncated, no trailing bytes"
|
|
);
|
|
drop(guard);
|
|
}
|
|
|
|
#[test]
|
|
fn contended_acquire_does_not_modify_pidfile() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("ws.pid");
|
|
|
|
let holder = PidFile::acquire(&path).unwrap().unwrap();
|
|
let before = fs::read_to_string(&path).unwrap();
|
|
|
|
let contended = PidFile::acquire(&path).unwrap();
|
|
assert!(contended.is_none());
|
|
|
|
let after = fs::read_to_string(&path).unwrap();
|
|
assert_eq!(before, after, "contended acquire must not rewrite the file");
|
|
drop(holder);
|
|
}
|
|
|
|
#[test]
|
|
fn pidfile_acquire_errors_on_directory() {
|
|
let dir = TempDir::new().unwrap();
|
|
let as_dir = dir.path().join("a_dir");
|
|
fs::create_dir(&as_dir).unwrap();
|
|
|
|
// Opening a directory for writing yields EISDIR — a real error that
|
|
// must surface as `Err`, never be swallowed into `Ok(None)`.
|
|
assert!(
|
|
PidFile::acquire(&as_dir).is_err(),
|
|
"acquiring a directory path must error, not report Ok(None)"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn open_stdio_targets_opens_devnull_and_log() {
|
|
use std::io::{Read, Write};
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
let log_path = dir.path().join("logs/ws.log");
|
|
|
|
let (mut stdin_src, mut log) = open_stdio_targets(&log_path).unwrap();
|
|
assert!(log_path.exists(), "log file should be created");
|
|
|
|
log.write_all(b"hello").unwrap();
|
|
log.flush().unwrap();
|
|
assert_eq!(fs::read_to_string(&log_path).unwrap(), "hello");
|
|
|
|
// The stdin source is /dev/null: reads yield EOF immediately.
|
|
let mut buf = [0u8; 4];
|
|
assert_eq!(stdin_src.read(&mut buf).unwrap(), 0);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn open_stdio_targets_appends_to_existing_log() {
|
|
use std::io::Write;
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
let log_path = dir.path().join("ws.log");
|
|
fs::write(&log_path, "prior\n").unwrap();
|
|
|
|
let (_stdin_src, mut log) = open_stdio_targets(&log_path).unwrap();
|
|
log.write_all(b"more\n").unwrap();
|
|
log.flush().unwrap();
|
|
|
|
assert_eq!(fs::read_to_string(&log_path).unwrap(), "prior\nmore\n");
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn open_stdio_targets_errors_when_parent_is_a_file() {
|
|
let dir = TempDir::new().unwrap();
|
|
let parent_file = dir.path().join("not_a_dir");
|
|
fs::write(&parent_file, "x").unwrap();
|
|
// `not_a_dir` is a regular file, so a log path under it is ENOTDIR.
|
|
let log_path = parent_file.join("ws.log");
|
|
|
|
assert!(
|
|
open_stdio_targets(&log_path).is_err(),
|
|
"a log path whose parent is a file must error"
|
|
);
|
|
}
|
|
|
|
// O_NOFOLLOW makes a symlinked final component fail with ELOOP rather than
|
|
// being followed — deterministic and uid-independent (no chmod, root-safe).
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn open_stdio_targets_rejects_symlinked_log() {
|
|
let dir = TempDir::new().unwrap();
|
|
let target = dir.path().join("real.log");
|
|
fs::write(&target, "").unwrap();
|
|
let link = dir.path().join("link.log");
|
|
std::os::unix::fs::symlink(&target, &link).unwrap();
|
|
|
|
let err = open_stdio_targets(&link).unwrap_err();
|
|
assert_eq!(err.raw_os_error(), Some(libc::ELOOP));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn pidfile_acquire_rejects_symlinked_path() {
|
|
let dir = TempDir::new().unwrap();
|
|
let target = dir.path().join("real.pid");
|
|
let link = dir.path().join("link.pid");
|
|
std::os::unix::fs::symlink(&target, &link).unwrap();
|
|
|
|
let err = PidFile::acquire(&link).unwrap_err();
|
|
assert_eq!(err.raw_os_error(), Some(libc::ELOOP));
|
|
// The truncate-through-symlink primitive is blocked: O_CREAT did not
|
|
// follow the link to create (and truncate) its target.
|
|
assert!(!target.exists());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn pidfile_created_mode_is_owner_only() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("ws.pid");
|
|
|
|
let _guard = PidFile::acquire(&path).unwrap().unwrap();
|
|
let mode = fs::metadata(&path).unwrap().permissions().mode();
|
|
// No group/other bits, regardless of umask (0600 & ~umask keeps them 0).
|
|
assert_eq!(
|
|
mode & 0o077,
|
|
0,
|
|
"pidfile must not be group/other-accessible"
|
|
);
|
|
}
|
|
|
|
#[cfg(not(any(unix, windows)))]
|
|
#[test]
|
|
fn daemonize_unsupported_off_unix_and_windows() {
|
|
let err = daemonize(Path::new("ignored")).unwrap_err();
|
|
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
|
|
}
|
|
|
|
#[test]
|
|
fn take_over_uncontended_acquires_normally() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("ws.pid");
|
|
|
|
let guard = PidFile::acquire_or_take_over(&path, Duration::from_millis(100)).unwrap();
|
|
assert!(guard.is_some());
|
|
assert_eq!(
|
|
fs::read_to_string(&path).unwrap(),
|
|
process::id().to_string()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn take_over_declines_unreadable_pidfile() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("ws.pid");
|
|
|
|
let _holder = PidFile::acquire(&path).unwrap().unwrap();
|
|
fs::write(&path, "not a pid").unwrap();
|
|
|
|
let taken =
|
|
PidFile::acquire_or_take_over_matching(&path, Duration::from_millis(100), "sleep")
|
|
.unwrap();
|
|
assert!(taken.is_none(), "an unidentifiable holder must be declined");
|
|
}
|
|
|
|
#[test]
|
|
fn take_over_declines_own_pid() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("ws.pid");
|
|
|
|
// The in-process holder wrote our own pid; a takeover must not
|
|
// signal ourselves.
|
|
let _holder = PidFile::acquire(&path).unwrap().unwrap();
|
|
let taken =
|
|
PidFile::acquire_or_take_over_matching(&path, Duration::from_millis(100), "").unwrap();
|
|
assert!(taken.is_none());
|
|
}
|
|
|
|
/// Spawn a long-sleeping child to stand in for a predecessor process.
|
|
#[cfg(target_os = "linux")]
|
|
fn spawn_predecessor() -> Child {
|
|
Command::new("sleep")
|
|
.arg("300")
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("spawn sleep")
|
|
}
|
|
|
|
/// Wait (bounded) for a child to exit; returns true if it did.
|
|
#[cfg(target_os = "linux")]
|
|
fn wait_for_exit(child: &mut Child, budget: Duration) -> bool {
|
|
let deadline = Instant::now() + budget;
|
|
while Instant::now() < deadline {
|
|
if child.try_wait().expect("try_wait").is_some() {
|
|
return true;
|
|
}
|
|
thread::sleep(Duration::from_millis(10));
|
|
}
|
|
false
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn take_over_declines_non_matching_holder() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("ws.pid");
|
|
|
|
let _holder = PidFile::acquire(&path).unwrap().unwrap();
|
|
let mut child = spawn_predecessor();
|
|
fs::write(&path, child.id().to_string()).unwrap();
|
|
|
|
let taken = PidFile::acquire_or_take_over_matching(
|
|
&path,
|
|
Duration::from_millis(100),
|
|
"definitely-not-this-process",
|
|
)
|
|
.unwrap();
|
|
assert!(taken.is_none(), "a foreign holder must not be taken over");
|
|
assert!(
|
|
child.try_wait().expect("try_wait").is_none(),
|
|
"a foreign holder must not be killed"
|
|
);
|
|
|
|
child.kill().expect("cleanup kill");
|
|
let _ = child.wait();
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn take_over_declines_when_lock_is_never_released() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("ws.pid");
|
|
|
|
// The flock is held in-process for the whole test — after the child
|
|
// named in the pidfile is dead, the lock is still owned by "someone
|
|
// else" (a concurrent-spawn stand-in), so the takeover must decline
|
|
// rather than run without single-instance protection.
|
|
let _holder = PidFile::acquire(&path).unwrap().unwrap();
|
|
let mut child = spawn_predecessor();
|
|
let child_pid = child.id();
|
|
fs::write(&path, child_pid.to_string()).unwrap();
|
|
|
|
let taken =
|
|
PidFile::acquire_or_take_over_matching(&path, Duration::from_millis(300), "sleep")
|
|
.unwrap();
|
|
|
|
assert!(
|
|
wait_for_exit(&mut child, Duration::from_secs(2)),
|
|
"the predecessor must be terminated"
|
|
);
|
|
assert!(
|
|
taken.is_none(),
|
|
"a takeover that cannot win the lock must decline, never proceed lockless"
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(&path).unwrap(),
|
|
child_pid.to_string(),
|
|
"a declined takeover must not rewrite the pidfile"
|
|
);
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn take_over_escalates_to_sigkill_for_stuck_predecessor() {
|
|
use std::os::unix::process::ExitStatusExt as _;
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("ws.pid");
|
|
|
|
// A predecessor that ignores the graceful signal: only the SIGKILL
|
|
// escalation can end it. It touches a marker once the trap is
|
|
// installed so the test cannot signal it during bash startup.
|
|
let trap_ready = dir.path().join("trap-ready");
|
|
let mut child = Command::new("bash")
|
|
.arg("-c")
|
|
.arg(format!(
|
|
"trap '' TERM; touch {}; while true; do sleep 1; done",
|
|
trap_ready.display()
|
|
))
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("spawn stubborn child");
|
|
let trap_deadline = Instant::now() + Duration::from_secs(5);
|
|
while !trap_ready.exists() {
|
|
assert!(Instant::now() < trap_deadline, "child never set its trap");
|
|
thread::sleep(Duration::from_millis(10));
|
|
}
|
|
|
|
// Stand in for the stuck predecessor's flock: released only after the
|
|
// graceful grace has expired, inside the post-kill window.
|
|
let holder = PidFile::acquire(&path).unwrap().unwrap();
|
|
fs::write(&path, child.id().to_string()).unwrap();
|
|
let release = thread::spawn(move || {
|
|
thread::sleep(Duration::from_millis(600));
|
|
drop(holder);
|
|
});
|
|
|
|
let taken =
|
|
PidFile::acquire_or_take_over_matching(&path, Duration::from_millis(300), "bash")
|
|
.unwrap();
|
|
release.join().expect("release thread");
|
|
|
|
let status = child.wait().expect("child wait");
|
|
assert_eq!(
|
|
status.signal(),
|
|
Some(libc::SIGKILL),
|
|
"a SIGTERM-immune predecessor must be ended by the SIGKILL escalation"
|
|
);
|
|
assert!(taken.is_some(), "the lock freed within the kill window");
|
|
assert_eq!(
|
|
fs::read_to_string(&path).unwrap(),
|
|
process::id().to_string()
|
|
);
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn take_over_acquires_cleanly_when_predecessor_releases() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("ws.pid");
|
|
|
|
let holder = PidFile::acquire(&path).unwrap().unwrap();
|
|
let mut child = spawn_predecessor();
|
|
fs::write(&path, child.id().to_string()).unwrap();
|
|
|
|
// Release the lock shortly after the takeover starts waiting,
|
|
// simulating the predecessor finishing its drain within grace.
|
|
let release = thread::spawn(move || {
|
|
thread::sleep(Duration::from_millis(100));
|
|
drop(holder);
|
|
});
|
|
|
|
let taken =
|
|
PidFile::acquire_or_take_over_matching(&path, Duration::from_secs(5), "sleep").unwrap();
|
|
release.join().expect("release thread");
|
|
|
|
assert!(
|
|
wait_for_exit(&mut child, Duration::from_secs(2)),
|
|
"the predecessor must be terminated"
|
|
);
|
|
assert!(taken.is_some());
|
|
assert_eq!(
|
|
fs::read_to_string(&path).unwrap(),
|
|
process::id().to_string()
|
|
);
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn process_name_matches_own_argv0() {
|
|
let pid = process::id();
|
|
// Derive the fragment from this process's real argv0 basename rather
|
|
// than hardcoding a name: different test runners name the binary
|
|
// differently (e.g. Cargo uses `kigi_workspace-<hash>`), so a
|
|
// hardcoded fragment matches under one runner but not another.
|
|
let cmdline = fs::read(format!("/proc/{pid}/cmdline")).expect("read own cmdline");
|
|
let argv0 = cmdline.split(|&b| b == 0).next().expect("argv0 present");
|
|
let basename = String::from_utf8_lossy(argv0)
|
|
.rsplit(['/', '\\'])
|
|
.next()
|
|
.expect("basename")
|
|
.to_owned();
|
|
assert!(!basename.is_empty(), "argv0 basename must not be empty");
|
|
assert!(process_name_matches(pid, &basename));
|
|
assert!(!process_name_matches(pid, "definitely-not-this-process"));
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn predecessor_target_pins_verifies_and_signals() {
|
|
let mut child = spawn_predecessor();
|
|
|
|
assert!(
|
|
PredecessorTarget::open(child.id(), "not-a-match").is_none(),
|
|
"a non-matching name must not produce a target"
|
|
);
|
|
|
|
// /proc/<pid>/cmdline can lag briefly after spawn under remote CI
|
|
// executors; derive the fragment from the live cmdline (handles
|
|
// busybox-as-sleep) and retry pin open instead of a one-shot expect.
|
|
let fragment = {
|
|
let deadline = Instant::now() + Duration::from_secs(2);
|
|
loop {
|
|
if let Ok(cmdline) = fs::read(format!("/proc/{}/cmdline", child.id())) {
|
|
let argv0 = cmdline.split(|&b| b == 0).next().unwrap_or_default();
|
|
let basename = String::from_utf8_lossy(argv0)
|
|
.rsplit(['/', '\\'])
|
|
.next()
|
|
.unwrap_or("")
|
|
.to_owned();
|
|
if !basename.is_empty() {
|
|
break basename;
|
|
}
|
|
}
|
|
if Instant::now() >= deadline {
|
|
panic!("child cmdline never became readable");
|
|
}
|
|
thread::sleep(Duration::from_millis(10));
|
|
}
|
|
};
|
|
|
|
let target = {
|
|
let deadline = Instant::now() + Duration::from_secs(2);
|
|
loop {
|
|
if let Some(t) = PredecessorTarget::open(child.id(), &fragment) {
|
|
break t;
|
|
}
|
|
if Instant::now() >= deadline {
|
|
panic!("pin child (fragment={fragment:?})");
|
|
}
|
|
thread::sleep(Duration::from_millis(10));
|
|
}
|
|
};
|
|
target.signal(false).expect("graceful signal");
|
|
assert!(
|
|
wait_for_exit(&mut child, Duration::from_secs(2)),
|
|
"the pinned child must receive the signal"
|
|
);
|
|
|
|
// Signalling the dead pinned instance is a no-op, never a stray kill.
|
|
target
|
|
.signal(true)
|
|
.expect("signal to dead pinned instance is Ok");
|
|
}
|
|
|
|
#[test]
|
|
fn basename_contains_ignores_directory_components() {
|
|
assert!(basename_contains(
|
|
"/usr/local/bin/kigi-workspace-server",
|
|
"workspace-server"
|
|
));
|
|
assert!(basename_contains(
|
|
"C:\\Program Files\\XAI-Workspace-Server.exe",
|
|
"workspace-server"
|
|
));
|
|
assert!(
|
|
!basename_contains("/home/workspace-server-data/unrelated", "workspace-server"),
|
|
"a matching directory component must not satisfy the kill gate"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn read_pidfile_pid_parses_and_rejects() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("ws.pid");
|
|
|
|
fs::write(&path, "1234\n").unwrap();
|
|
assert_eq!(read_pidfile_pid(&path), Some(1234));
|
|
|
|
fs::write(&path, "0").unwrap();
|
|
assert_eq!(read_pidfile_pid(&path), None, "pid 0 is not a process");
|
|
|
|
fs::write(&path, "garbage").unwrap();
|
|
assert_eq!(read_pidfile_pid(&path), None);
|
|
|
|
assert_eq!(read_pidfile_pid(&dir.path().join("missing")), None);
|
|
}
|
|
}
|