mod capture;
pub mod client;
pub mod worker;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
use running_process_probe::crash::{self, spool::CrashMetadata};
pub use running_process_probe::crash::{spool::SPOOL_DIR_ENV, CrashPolicy};
use running_process_probe::probe_diag::v1::{ProcessKey, Runtime as ProtoRuntime};
pub const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
#[derive(Clone, Debug)]
pub struct AllowPolicy {
pub allow_all_ops: bool,
}
impl Default for AllowPolicy {
fn default() -> Self {
Self {
allow_all_ops: true,
}
}
}
#[derive(Clone, Debug, Default)]
pub struct Disclosure {
pub env_allowlist: Vec<String>,
pub disclose_cwd: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Runtime {
#[default]
Native,
Python,
}
impl Runtime {
fn to_proto(self) -> ProtoRuntime {
match self {
Self::Native => ProtoRuntime::Native,
Self::Python => ProtoRuntime::Python,
}
}
}
#[derive(Clone, Debug)]
pub struct Config {
pub app_class: String,
pub app_name: String,
pub app_version: String,
pub instance: Option<String>,
pub allow_policy: AllowPolicy,
pub disclosure: Disclosure,
pub socket_override: Option<PathBuf>,
pub heartbeat_interval: Duration,
pub runtime: Runtime,
pub crash_policy: CrashPolicy,
pub symbol_manifest_path: Option<PathBuf>,
pub symbol_paths: Vec<PathBuf>,
}
impl Config {
pub fn new(app_class: impl Into<String>) -> Self {
let app_class = app_class.into();
Self {
app_name: app_class.clone(),
app_class,
app_version: env!("CARGO_PKG_VERSION").to_string(),
instance: None,
allow_policy: AllowPolicy::default(),
disclosure: Disclosure::default(),
socket_override: None,
heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
runtime: Runtime::Native,
crash_policy: CrashPolicy::On,
symbol_manifest_path: None,
symbol_paths: Vec::new(),
}
}
pub fn allow_env_value(mut self, name: impl Into<String>) -> Self {
self.disclosure.env_allowlist.push(name.into());
self
}
pub fn with_version(mut self, version: impl Into<String>) -> Self {
self.app_version = version.into();
self
}
pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
self.instance = Some(instance.into());
self
}
pub fn with_runtime(mut self, runtime: Runtime) -> Self {
self.runtime = runtime;
self
}
pub fn crash_policy(mut self, policy: CrashPolicy) -> Self {
self.crash_policy = policy;
self
}
pub fn with_symbol_manifest(mut self, path: impl Into<PathBuf>) -> Self {
self.symbol_manifest_path = Some(path.into());
self
}
pub fn with_symbol_path(mut self, path: impl Into<PathBuf>) -> Self {
self.symbol_paths.push(path.into());
self
}
}
impl Default for Config {
fn default() -> Self {
Self::new("unknown")
}
}
#[derive(Debug, thiserror::Error)]
pub enum InstallError {
#[error("cannot determine current executable: {0}")]
CurrentExe(#[source] std::io::Error),
#[error("cannot spawn probe worker thread: {0}")]
Spawn(#[source] std::io::Error),
#[error("cannot arm crash capture: {0}")]
Crash(#[source] crash::InstallError),
}
pub struct Guard {
stop: Arc<AtomicBool>,
handle: Option<JoinHandle<()>>,
key: Arc<Mutex<Option<ProcessKey>>>,
crash: crash::CrashGuard,
}
impl Guard {
pub fn armed_key(&self) -> Option<ProcessKey> {
self.key.lock().ok().and_then(|k| k.clone())
}
pub fn is_armed(&self) -> bool {
self.armed_key().is_some()
}
pub fn crash_handler_armed(&self) -> bool {
self.crash.is_armed()
}
pub fn crash_sample_ready(&self) -> bool {
self.crash.sample_ready()
}
pub fn crash_sample_thread_count(&self) -> usize {
self.crash.sample_thread_count()
}
}
impl Drop for Guard {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
pub fn install(config: Config) -> Result<Guard, InstallError> {
let creation_time_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0);
let cwd = std::env::current_dir()
.map(|path| path.to_string_lossy().into_owned())
.unwrap_or_default();
let mut request = worker::build_register_request(&config).map_err(InstallError::CurrentExe)?;
if let Some(key) = request.key.as_mut() {
key.start_time = Some(creation_time_ms);
}
let crash = crash::install(
config.crash_policy,
CrashMetadata {
app_class: config.app_class.clone(),
app_name: config.app_name.clone(),
app_version: config.app_version.clone(),
instance_name: config.instance.clone().unwrap_or_default(),
creation_time_ms,
cwd,
},
)
.map_err(InstallError::Crash)?;
let stop = Arc::new(AtomicBool::new(false));
let key = Arc::new(Mutex::new(None));
let handle = worker::spawn(request, config, Arc::clone(&stop), Arc::clone(&key))
.map_err(InstallError::Spawn)?;
Ok(Guard {
stop,
handle: Some(handle),
key,
crash,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_values_are_deny_by_default() {
let cfg = Config::new("app");
assert!(
cfg.disclosure.env_allowlist.is_empty(),
"env values must be deny-by-default"
);
}
#[test]
fn env_values_are_opt_in_by_name() {
let cfg = Config::new("app").allow_env_value("PATH");
assert_eq!(cfg.disclosure.env_allowlist, vec!["PATH".to_string()]);
}
#[test]
fn ops_are_permitted_by_default() {
assert!(Config::new("app").allow_policy.allow_all_ops);
assert_eq!(Config::new("app").crash_policy, CrashPolicy::On);
}
#[test]
fn builder_sets_version_and_instance() {
let cfg = Config::new("app")
.with_version("9.9")
.with_instance("i-1")
.with_symbol_manifest("app.symbols.json")
.with_symbol_path("symbols");
assert_eq!(cfg.app_version, "9.9");
assert_eq!(cfg.instance.as_deref(), Some("i-1"));
assert_eq!(
cfg.symbol_manifest_path.as_deref(),
Some(std::path::Path::new("app.symbols.json"))
);
assert_eq!(cfg.symbol_paths, vec![PathBuf::from("symbols")]);
}
#[test]
fn install_returns_immediately_with_no_daemon_present() {
let mut cfg = Config::new("probe-install-timing");
cfg.socket_override = Some(PathBuf::from(if cfg!(windows) {
r"\\.\pipe\rp-probe-absent-633"
} else {
"/tmp/rp-probe-absent-633.sock"
}));
let start = std::time::Instant::now();
let guard = install(cfg).expect("install must succeed even with no daemon");
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_millis(500),
"install took {elapsed:?}; it must not perform I/O on the calling thread"
);
assert!(!guard.is_armed());
drop(guard);
}
#[test]
fn guard_drop_returns_promptly_without_a_daemon() {
let mut cfg = Config::new("probe-drop-timing");
cfg.socket_override = Some(PathBuf::from(if cfg!(windows) {
r"\\.\pipe\rp-probe-absent-633b"
} else {
"/tmp/rp-probe-absent-633b.sock"
}));
let guard = install(cfg).unwrap();
let start = std::time::Instant::now();
drop(guard);
assert!(
start.elapsed() < Duration::from_secs(5),
"Guard::drop must not block on an absent daemon"
);
}
}