Skip to main content

running_process/observer/
process_watch.rs

1//! Process-watch selectors, provenance, bounded results, and cursor delivery.
2
3use std::collections::VecDeque;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Condvar, Mutex};
6use std::time::{Duration, SystemTime};
7
8use running_process_platform_internal::platform::process::{
9    exact_trace_capability, ExactTraceEvent, ExactTraceEventKind, NonInvasiveObservationGrade,
10    TraceOriginArtifact,
11};
12
13const DEFAULT_RETAINED_MATCHES: usize = 256;
14
15fn inferred_command_details(
16    argv: Option<Vec<std::ffi::OsString>>,
17) -> (Option<PathBuf>, Option<Vec<String>>) {
18    let executable = argv
19        .as_ref()
20        .and_then(|arguments| arguments.first())
21        .filter(|value| !value.is_empty())
22        .map(PathBuf::from);
23    let argv = argv.map(|arguments| {
24        arguments
25            .into_iter()
26            .map(|argument| argument.to_string_lossy().into_owned())
27            .collect()
28    });
29    (executable, argv)
30}
31
32#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
33pub enum ObservationPolicy {
34    #[default]
35    NonInvasive,
36    AllowTracing,
37    RequireExact,
38}
39
40impl ObservationPolicy {
41    pub fn as_str(self) -> &'static str {
42        match self {
43            Self::NonInvasive => "non_invasive",
44            Self::AllowTracing => "allow_tracing",
45            Self::RequireExact => "require_exact",
46        }
47    }
48}
49
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub enum ObservationGrade {
52    ExactTrace,
53    ExactEvent,
54    KernelNotification,
55    KernelHintReconciled,
56    SnapshotInferred,
57}
58
59impl ObservationGrade {
60    pub fn as_str(self) -> &'static str {
61        match self {
62            Self::ExactTrace => "exact_trace",
63            Self::ExactEvent => "exact_event",
64            Self::KernelNotification => "kernel_notification",
65            Self::KernelHintReconciled => "kernel_hint_reconciled",
66            Self::SnapshotInferred => "snapshot_inferred",
67        }
68    }
69}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum StackCapture {
73    OriginPreferred,
74    OriginRequired,
75    OwnerAllThreads,
76}
77
78#[derive(Clone, Debug, Eq, PartialEq)]
79pub struct StackDump {
80    pub capture: StackCapture,
81    pub directory: Option<PathBuf>,
82    pub symbolize_immediately: bool,
83}
84
85impl Default for StackDump {
86    fn default() -> Self {
87        Self {
88            capture: StackCapture::OriginPreferred,
89            directory: None,
90            symbolize_immediately: false,
91        }
92    }
93}
94
95#[derive(Clone, Debug, Eq, PartialEq)]
96enum WatchSelector {
97    Spawn,
98    Exec {
99        basename: Option<String>,
100        path: Option<PathBuf>,
101    },
102    Exit {
103        code: Option<i32>,
104        signal: Option<i32>,
105        basename: Option<String>,
106        failure_only: bool,
107    },
108}
109
110#[derive(Clone, Debug, Eq, PartialEq)]
111pub struct ProcessWatch {
112    selector: WatchSelector,
113    pub dump: Option<StackDump>,
114    pub limit: Option<usize>,
115    pub cooldown: Duration,
116    pub label: String,
117}
118
119#[derive(Clone, Debug, Eq, PartialEq)]
120pub struct ProcessWatchConfigurationError(pub String);
121
122impl std::fmt::Display for ProcessWatchConfigurationError {
123    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        formatter.write_str(&self.0)
125    }
126}
127
128impl std::error::Error for ProcessWatchConfigurationError {}
129
130impl ProcessWatch {
131    pub fn on_spawn(
132        dump: Option<StackDump>,
133        limit: Option<usize>,
134        cooldown: Duration,
135        label: impl Into<String>,
136    ) -> Result<Self, ProcessWatchConfigurationError> {
137        Self::new(WatchSelector::Spawn, dump, limit, cooldown, label)
138    }
139
140    pub fn on_exec(
141        basename: Option<String>,
142        path: Option<PathBuf>,
143        dump: Option<StackDump>,
144        limit: Option<usize>,
145        cooldown: Duration,
146        label: impl Into<String>,
147    ) -> Result<Self, ProcessWatchConfigurationError> {
148        if basename.is_some() && path.is_some() {
149            return Err(ProcessWatchConfigurationError(
150                "on_exec accepts basename or path, not both".to_owned(),
151            ));
152        }
153        if basename.as_ref().is_some_and(|name| name.is_empty()) {
154            return Err(ProcessWatchConfigurationError(
155                "basename must not be empty".to_owned(),
156            ));
157        }
158        Self::new(
159            WatchSelector::Exec { basename, path },
160            dump,
161            limit,
162            cooldown,
163            label,
164        )
165    }
166
167    pub fn on_exit(
168        code: Option<i32>,
169        signal: Option<i32>,
170        basename: Option<String>,
171        dump: Option<StackDump>,
172        limit: Option<usize>,
173        cooldown: Duration,
174        label: impl Into<String>,
175    ) -> Result<Self, ProcessWatchConfigurationError> {
176        if code.is_some() && signal.is_some() {
177            return Err(ProcessWatchConfigurationError(
178                "on_exit accepts code or signal, not both".to_owned(),
179            ));
180        }
181        Self::new(
182            WatchSelector::Exit {
183                code,
184                signal,
185                basename,
186                failure_only: false,
187            },
188            dump,
189            limit,
190            cooldown,
191            label,
192        )
193    }
194
195    pub fn on_failure(
196        basename: Option<String>,
197        dump: Option<StackDump>,
198        limit: Option<usize>,
199        cooldown: Duration,
200        label: impl Into<String>,
201    ) -> Result<Self, ProcessWatchConfigurationError> {
202        Self::new(
203            WatchSelector::Exit {
204                code: None,
205                signal: None,
206                basename,
207                failure_only: true,
208            },
209            dump,
210            limit,
211            cooldown,
212            label,
213        )
214    }
215
216    fn new(
217        selector: WatchSelector,
218        dump: Option<StackDump>,
219        limit: Option<usize>,
220        cooldown: Duration,
221        label: impl Into<String>,
222    ) -> Result<Self, ProcessWatchConfigurationError> {
223        let label = label.into();
224        if label.trim().is_empty() {
225            return Err(ProcessWatchConfigurationError(
226                "watch label must not be empty".to_owned(),
227            ));
228        }
229        if limit == Some(0) {
230            return Err(ProcessWatchConfigurationError(
231                "watch limit must be positive or None".to_owned(),
232            ));
233        }
234        if dump
235            .as_ref()
236            .is_some_and(|request| request.symbolize_immediately)
237        {
238            return Err(ProcessWatchConfigurationError(
239                "immediate remote symbolization is not implemented; use deferred artifacts"
240                    .to_owned(),
241            ));
242        }
243        if dump
244            .as_ref()
245            .is_some_and(|request| request.capture == StackCapture::OwnerAllThreads)
246        {
247            return Err(ProcessWatchConfigurationError(
248                "owner all-thread event-time capture is not implemented".to_owned(),
249            ));
250        }
251        Ok(Self {
252            selector,
253            dump,
254            limit,
255            cooldown,
256            label,
257        })
258    }
259
260    fn non_invasive_unsupported_requirement(&self) -> Option<&'static str> {
261        if self
262            .dump
263            .as_ref()
264            .is_some_and(|dump| dump.capture != StackCapture::OriginPreferred)
265        {
266            return Some("the selected stack capture provenance is unavailable non-invasively");
267        }
268        match &self.selector {
269            WatchSelector::Exit {
270                code,
271                signal,
272                basename,
273                failure_only,
274            } if code.is_some() || signal.is_some() || basename.is_some() || *failure_only => Some(
275                "the exit selector needs status or executable fields this backend cannot provide",
276            ),
277            _ => None,
278        }
279    }
280}
281
282#[derive(Clone, Debug, Eq, PartialEq)]
283pub struct ProcessIdentity {
284    pub pid: u32,
285    pub start_key: Option<u64>,
286}
287
288#[derive(Clone, Copy, Debug, Eq, PartialEq)]
289pub enum ProcessEventKind {
290    Spawn,
291    Exec,
292    Exit,
293    Loss,
294}
295
296#[derive(Clone, Debug)]
297pub struct ProcessEvent {
298    pub kind: ProcessEventKind,
299    pub process: ProcessIdentity,
300    pub parent: Option<ProcessIdentity>,
301    pub timestamp: SystemTime,
302    pub executable: Option<PathBuf>,
303    pub argv: Option<Vec<String>>,
304    pub exit_code: Option<i32>,
305    pub signal: Option<i32>,
306    pub raw_exit_status: Option<i64>,
307    pub backend: &'static str,
308    pub observation_grade: ObservationGrade,
309    pub coverage_complete: bool,
310    pub loss_detected: bool,
311}
312
313#[derive(Clone, Copy, Debug, Eq, PartialEq)]
314pub enum CaptureSource {
315    RemoteSpawningThread,
316    ManagedSpawnBoundary,
317    OwnerEventTimeSnapshot,
318    None,
319}
320
321impl CaptureSource {
322    pub fn as_str(self) -> &'static str {
323        match self {
324            Self::RemoteSpawningThread => "remote_spawning_thread",
325            Self::ManagedSpawnBoundary => "managed_spawn_boundary",
326            Self::OwnerEventTimeSnapshot => "owner_event_time_snapshot",
327            Self::None => "none",
328        }
329    }
330}
331
332#[derive(Clone, Debug)]
333pub struct DumpResult {
334    pub capture_source: CaptureSource,
335    pub artifacts: Vec<PathBuf>,
336    pub symbolized: bool,
337    pub error: Option<String>,
338}
339
340#[derive(Clone, Debug)]
341pub struct ProcessWatchMatch {
342    pub sequence: u64,
343    pub watch: ProcessWatch,
344    pub event: ProcessEvent,
345    pub dump: Option<DumpResult>,
346}
347
348#[derive(Clone, Debug, Eq, PartialEq)]
349pub struct ProcessWatchGap {
350    pub first_missing: u64,
351    pub last_missing: u64,
352}
353
354#[derive(Clone, Debug)]
355pub struct ProcessWatchLoss {
356    pub sequence: u64,
357    pub event: ProcessEvent,
358    pub reason: String,
359}
360
361#[derive(Clone, Debug)]
362pub enum ProcessWatchRead {
363    Match(Box<ProcessWatchMatch>),
364    Loss(Box<ProcessWatchLoss>),
365    Gap(ProcessWatchGap),
366    Timeout,
367    Eof,
368}
369
370#[derive(Clone, Debug, Eq, PartialEq)]
371pub struct ProcessObservationCapabilities {
372    pub exact_available: bool,
373    pub exact_backend: &'static str,
374    pub reason: &'static str,
375    pub non_invasive_backend: &'static str,
376    pub non_invasive_grade: ObservationGrade,
377}
378
379impl ProcessObservationCapabilities {
380    pub fn current() -> Self {
381        let capability = exact_trace_capability();
382        Self {
383            exact_available: capability.available,
384            exact_backend: capability.backend,
385            reason: capability.reason,
386            non_invasive_backend: capability.non_invasive_backend,
387            non_invasive_grade: match capability.non_invasive_grade {
388                NonInvasiveObservationGrade::KernelNotification => {
389                    ObservationGrade::KernelNotification
390                }
391                NonInvasiveObservationGrade::KernelHintReconciled => {
392                    ObservationGrade::KernelHintReconciled
393                }
394                NonInvasiveObservationGrade::SnapshotInferred => ObservationGrade::SnapshotInferred,
395            },
396        }
397    }
398}
399
400#[derive(Clone, Debug, Eq, PartialEq)]
401pub struct ProcessObservation {
402    pub backend: &'static str,
403    pub grade: ObservationGrade,
404    pub fallback_reason: Option<String>,
405}
406
407#[derive(Clone, Debug, Eq, PartialEq)]
408pub struct ProcessObservationError(pub String);
409
410impl std::fmt::Display for ProcessObservationError {
411    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412        formatter.write_str(&self.0)
413    }
414}
415
416impl std::error::Error for ProcessObservationError {}
417
418struct WatchRuntime {
419    watch: ProcessWatch,
420    matched: usize,
421    last_match: Option<SystemTime>,
422}
423
424struct LogState {
425    entries: VecDeque<ProcessWatchRecord>,
426    first_sequence: u64,
427    next_sequence: u64,
428    closed: bool,
429    coverage_complete: bool,
430}
431
432#[derive(Clone, Debug)]
433enum ProcessWatchRecord {
434    Match(ProcessWatchMatch),
435    Loss(ProcessWatchLoss),
436}
437
438struct SharedLog {
439    state: Mutex<LogState>,
440    wake: Condvar,
441}
442
443struct PendingMatch {
444    watch: ProcessWatch,
445    event: ProcessEvent,
446    dump_request: Option<StackDump>,
447    native: ExactTraceEvent,
448}
449
450enum PendingDelivery {
451    Match(Box<PendingMatch>),
452    Loss { event: ProcessEvent, reason: String },
453}
454
455struct PendingOverflow {
456    event: ProcessEvent,
457    reason: String,
458    additional_dropped: usize,
459    native_loss_reasons: Vec<String>,
460}
461
462impl PendingDelivery {
463    fn into_overflow(self) -> PendingOverflow {
464        match self {
465            Self::Match(pending) => PendingOverflow {
466                event: pending.event,
467                reason: "process-watch delivery queue overflow".to_owned(),
468                additional_dropped: 0,
469                native_loss_reasons: Vec::new(),
470            },
471            Self::Loss { event, reason } => PendingOverflow {
472                event,
473                reason: "process-watch delivery queue overflow".to_owned(),
474                additional_dropped: 0,
475                native_loss_reasons: vec![reason],
476            },
477        }
478    }
479
480    fn merge_into(self, overflow: &mut PendingOverflow) {
481        overflow.additional_dropped = overflow.additional_dropped.saturating_add(1);
482        if let Self::Loss { reason, .. } = self {
483            if !overflow.native_loss_reasons.contains(&reason) {
484                overflow.native_loss_reasons.push(reason);
485            }
486        }
487    }
488}
489
490pub(crate) struct ProcessWatchEmitter {
491    watches: Mutex<Vec<WatchRuntime>>,
492    log: Arc<SharedLog>,
493    observation: ProcessObservation,
494    descendant_stop:
495        Arc<running_process_platform_internal::platform::process::DescendantMonitorStop>,
496    exact_delivery_active: std::sync::atomic::AtomicBool,
497    delivery_tx: std::sync::mpsc::SyncSender<PendingDelivery>,
498    delivery_overflow: Arc<Mutex<Option<PendingOverflow>>>,
499    delivery_closing: Arc<std::sync::atomic::AtomicBool>,
500}
501
502impl ProcessWatchEmitter {
503    pub(crate) fn new(
504        watches: Vec<ProcessWatch>,
505        policy: ObservationPolicy,
506    ) -> Result<(Arc<Self>, ProcessWatchSubscriber), ProcessObservationError> {
507        let capabilities = ProcessObservationCapabilities::current();
508        if policy == ObservationPolicy::RequireExact && !capabilities.exact_available {
509            return Err(ProcessObservationError(format!(
510                "exact process observation is unavailable: {}",
511                capabilities.reason
512            )));
513        }
514        let exact = policy != ObservationPolicy::NonInvasive && capabilities.exact_available;
515        if !exact {
516            for watch in &watches {
517                if let Some(requirement) = watch.non_invasive_unsupported_requirement() {
518                    return Err(ProcessObservationError(format!(
519                        "process watch '{}': {requirement}; select an exact tracing backend",
520                        watch.label
521                    )));
522                }
523            }
524        }
525        let observation = if exact {
526            ProcessObservation {
527                backend: capabilities.exact_backend,
528                grade: ObservationGrade::ExactTrace,
529                fallback_reason: None,
530            }
531        } else {
532            let backend = capabilities.non_invasive_backend;
533            let grade = capabilities.non_invasive_grade;
534            ProcessObservation {
535                backend,
536                grade,
537                fallback_reason: (policy == ObservationPolicy::AllowTracing
538                    && !capabilities.exact_available)
539                    .then(|| capabilities.reason.to_owned()),
540            }
541        };
542        let log = Arc::new(SharedLog {
543            state: Mutex::new(LogState {
544                entries: VecDeque::new(),
545                first_sequence: 1,
546                next_sequence: 1,
547                closed: false,
548                coverage_complete: true,
549            }),
550            wake: Condvar::new(),
551        });
552        let (delivery_tx, delivery_rx) = std::sync::mpsc::sync_channel(DEFAULT_RETAINED_MATCHES);
553        let delivery_closing = Arc::new(std::sync::atomic::AtomicBool::new(false));
554        let delivery_overflow = Arc::new(Mutex::new(None));
555        let worker_log = Arc::clone(&log);
556        let worker_closing = Arc::clone(&delivery_closing);
557        let worker_overflow = Arc::clone(&delivery_overflow);
558        std::thread::Builder::new()
559            .name("rp-watch-writer".to_owned())
560            .spawn(move || {
561                delivery_loop(delivery_rx, worker_log, worker_closing, worker_overflow);
562            })
563            .map_err(|error| {
564                ProcessObservationError(format!("spawn process-watch artifact writer: {error}"))
565            })?;
566        let emitter = Arc::new(Self {
567            watches: Mutex::new(
568                watches
569                    .into_iter()
570                    .map(|watch| WatchRuntime {
571                        watch,
572                        matched: 0,
573                        last_match: None,
574                    })
575                    .collect(),
576            ),
577            log: Arc::clone(&log),
578            observation: observation.clone(),
579            descendant_stop: Arc::new(
580                running_process_platform_internal::platform::process::DescendantMonitorStop::new(),
581            ),
582            exact_delivery_active: std::sync::atomic::AtomicBool::new(false),
583            delivery_tx,
584            delivery_overflow,
585            delivery_closing,
586        });
587        Ok((emitter, ProcessWatchSubscriber { log, observation }))
588    }
589
590    pub(crate) fn uses_exact_trace(&self) -> bool {
591        self.observation.grade == ObservationGrade::ExactTrace
592    }
593
594    pub(crate) fn emit_exact(&self, native: ExactTraceEvent) {
595        if self
596            .exact_delivery_active
597            .compare_exchange(
598                false,
599                true,
600                std::sync::atomic::Ordering::AcqRel,
601                std::sync::atomic::Ordering::Acquire,
602            )
603            .is_err()
604        {
605            let mut log = self.log.state.lock().unwrap_or_else(|e| e.into_inner());
606            log.coverage_complete = false;
607            return;
608        }
609        struct DeliveryGuard<'a>(&'a std::sync::atomic::AtomicBool);
610        impl Drop for DeliveryGuard<'_> {
611            fn drop(&mut self) {
612                self.0.store(false, std::sync::atomic::Ordering::Release);
613            }
614        }
615        let _delivery_guard = DeliveryGuard(&self.exact_delivery_active);
616        if let ExactTraceEventKind::Loss { reason } = &native.kind {
617            let mut log = self.log.state.lock().unwrap_or_else(|e| e.into_inner());
618            log.coverage_complete = false;
619            drop(log);
620            let event = event_from_exact(&native, self.observation.clone(), false);
621            self.queue_delivery(PendingDelivery::Loss {
622                event,
623                reason: reason.clone(),
624            });
625            return;
626        }
627        let event = event_from_exact(&native, self.observation.clone(), self.coverage_complete());
628        let now = SystemTime::now();
629        let mut watches = self.watches.lock().unwrap_or_else(|e| e.into_inner());
630        for runtime in &mut *watches {
631            if runtime
632                .watch
633                .limit
634                .is_some_and(|limit| runtime.matched >= limit)
635                || !selector_matches(&runtime.watch.selector, &event)
636                || runtime.last_match.is_some_and(|last| {
637                    now.duration_since(last).unwrap_or_default() < runtime.watch.cooldown
638                })
639            {
640                continue;
641            }
642            runtime.matched += 1;
643            runtime.last_match = Some(now);
644            let pending = PendingMatch {
645                watch: runtime.watch.clone(),
646                event: event.clone(),
647                dump_request: runtime.watch.dump.clone(),
648                native: native.clone(),
649            };
650            self.queue_delivery(PendingDelivery::Match(Box::new(pending)));
651        }
652    }
653
654    pub(crate) fn emit_inferred(&self, pid: u32, started: bool) {
655        let (executable, argv) = inferred_command_details(super::read_process_argv(pid).ok());
656        let event = ProcessEvent {
657            kind: if started {
658                ProcessEventKind::Spawn
659            } else {
660                ProcessEventKind::Exit
661            },
662            process: ProcessIdentity {
663                pid,
664                start_key: None,
665            },
666            parent: None,
667            timestamp: SystemTime::now(),
668            executable,
669            argv,
670            exit_code: None,
671            signal: None,
672            raw_exit_status: None,
673            backend: self.observation.backend,
674            observation_grade: self.observation.grade,
675            coverage_complete: false,
676            loss_detected: false,
677        };
678        self.emit_inferred_event(event.clone());
679        if started {
680            self.emit_inferred_event(ProcessEvent {
681                kind: ProcessEventKind::Exec,
682                ..event
683            });
684        }
685    }
686
687    fn emit_inferred_event(&self, event: ProcessEvent) {
688        let mut watches = self.watches.lock().unwrap_or_else(|e| e.into_inner());
689        let now = SystemTime::now();
690        for runtime in &mut *watches {
691            if runtime
692                .watch
693                .limit
694                .is_some_and(|limit| runtime.matched >= limit)
695                || !selector_matches(&runtime.watch.selector, &event)
696                || runtime.last_match.is_some_and(|last| {
697                    now.duration_since(last).unwrap_or_default() < runtime.watch.cooldown
698                })
699            {
700                continue;
701            }
702            runtime.matched += 1;
703            runtime.last_match = Some(now);
704            let dump = runtime.watch.dump.as_ref().map(|request| DumpResult {
705                capture_source: CaptureSource::None,
706                artifacts: Vec::new(),
707                symbolized: false,
708                error: Some(match request.capture {
709                    StackCapture::OwnerAllThreads => {
710                        "owner all-thread capture is unavailable from this backend".to_owned()
711                    }
712                    _ => "origin capture requires an exact trace backend".to_owned(),
713                }),
714            });
715            self.push(runtime.watch.clone(), event.clone(), dump);
716        }
717    }
718
719    pub(crate) fn descendant_stop(
720        &self,
721    ) -> Arc<running_process_platform_internal::platform::process::DescendantMonitorStop> {
722        Arc::clone(&self.descendant_stop)
723    }
724
725    pub(crate) fn close(&self) {
726        self.descendant_stop.stop();
727        self.finish_delivery();
728    }
729
730    pub(crate) fn finish_delivery(&self) {
731        self.delivery_closing
732            .store(true, std::sync::atomic::Ordering::Release);
733        self.log.wake.notify_all();
734    }
735
736    fn coverage_complete(&self) -> bool {
737        self.log
738            .state
739            .lock()
740            .unwrap_or_else(|e| e.into_inner())
741            .coverage_complete
742    }
743
744    fn push(&self, watch: ProcessWatch, event: ProcessEvent, dump: Option<DumpResult>) {
745        push_match(&self.log, watch, event, dump);
746    }
747
748    fn queue_delivery(&self, delivery: PendingDelivery) {
749        // This mutex is an ordering gate, not a blocking delivery path. Once
750        // one bounded-channel overflow occurs, producers aggregate subsequent
751        // drops here until the worker has drained every earlier accepted item
752        // and publishes exactly one ordered loss record.
753        let mut overflow = self
754            .delivery_overflow
755            .lock()
756            .unwrap_or_else(|error| error.into_inner());
757        if let Some(pending) = overflow.as_mut() {
758            delivery.merge_into(pending);
759            self.mark_coverage_incomplete();
760            return;
761        }
762        match self.delivery_tx.try_send(delivery) {
763            Ok(()) => {}
764            Err(std::sync::mpsc::TrySendError::Full(delivery)) => {
765                *overflow = Some(delivery.into_overflow());
766                self.mark_coverage_incomplete();
767            }
768            Err(std::sync::mpsc::TrySendError::Disconnected(delivery)) => {
769                self.mark_coverage_incomplete();
770                let pending = delivery.into_overflow();
771                push_loss(&self.log, pending.event, pending.reason);
772            }
773        }
774    }
775
776    fn mark_coverage_incomplete(&self) {
777        self.log
778            .state
779            .lock()
780            .unwrap_or_else(|error| error.into_inner())
781            .coverage_complete = false;
782    }
783}
784
785fn delivery_loop(
786    receiver: std::sync::mpsc::Receiver<PendingDelivery>,
787    log: Arc<SharedLog>,
788    closing: Arc<std::sync::atomic::AtomicBool>,
789    overflow: Arc<Mutex<Option<PendingOverflow>>>,
790) {
791    loop {
792        match receiver.recv_timeout(Duration::from_millis(10)) {
793            Ok(PendingDelivery::Match(pending)) => {
794                let pending = *pending;
795                let dump = pending
796                    .dump_request
797                    .as_ref()
798                    .map(|request| write_dump(request, &pending.watch.label, &pending.native));
799                push_match(&log, pending.watch, pending.event, dump);
800            }
801            Ok(PendingDelivery::Loss { event, reason }) => {
802                push_loss(&log, event, reason);
803            }
804            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
805                let pending = overflow
806                    .lock()
807                    .unwrap_or_else(|error| error.into_inner())
808                    .take();
809                if let Some(mut pending) = pending {
810                    if pending.additional_dropped != 0 {
811                        pending.reason.push_str(&format!(
812                            "; {} additional deliveries dropped",
813                            pending.additional_dropped
814                        ));
815                    }
816                    for reason in pending.native_loss_reasons {
817                        pending.reason.push_str("; native trace loss: ");
818                        pending.reason.push_str(&reason);
819                    }
820                    push_loss(&log, pending.event, pending.reason);
821                }
822                if closing.load(std::sync::atomic::Ordering::Acquire) {
823                    let mut state = log.state.lock().unwrap_or_else(|e| e.into_inner());
824                    state.closed = true;
825                    log.wake.notify_all();
826                    return;
827                }
828            }
829            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
830                let mut state = log.state.lock().unwrap_or_else(|e| e.into_inner());
831                state.closed = true;
832                log.wake.notify_all();
833                return;
834            }
835        }
836    }
837}
838
839fn push_loss(log: &SharedLog, event: ProcessEvent, reason: String) {
840    let mut state = log.state.lock().unwrap_or_else(|e| e.into_inner());
841    if state.closed {
842        return;
843    }
844    let sequence = state.next_sequence;
845    state.next_sequence += 1;
846    state
847        .entries
848        .push_back(ProcessWatchRecord::Loss(ProcessWatchLoss {
849            sequence,
850            event,
851            reason,
852        }));
853    trim_log(&mut state);
854    log.wake.notify_all();
855}
856
857fn push_match(log: &SharedLog, watch: ProcessWatch, event: ProcessEvent, dump: Option<DumpResult>) {
858    let mut state = log.state.lock().unwrap_or_else(|e| e.into_inner());
859    if state.closed {
860        return;
861    }
862    let sequence = state.next_sequence;
863    state.next_sequence += 1;
864    state
865        .entries
866        .push_back(ProcessWatchRecord::Match(ProcessWatchMatch {
867            sequence,
868            watch,
869            event,
870            dump,
871        }));
872    trim_log(&mut state);
873    log.wake.notify_all();
874}
875
876fn trim_log(log: &mut LogState) {
877    while log.entries.len() > DEFAULT_RETAINED_MATCHES {
878        log.entries.pop_front();
879        log.first_sequence += 1;
880    }
881}
882
883pub struct ProcessWatchSubscriber {
884    log: Arc<SharedLog>,
885    observation: ProcessObservation,
886}
887
888impl ProcessWatchSubscriber {
889    pub fn observation(&self) -> &ProcessObservation {
890        &self.observation
891    }
892
893    pub fn snapshot(&self) -> Vec<ProcessWatchMatch> {
894        self.log
895            .state
896            .lock()
897            .unwrap_or_else(|e| e.into_inner())
898            .entries
899            .iter()
900            .filter_map(|record| match record {
901                ProcessWatchRecord::Match(item) => Some(item.clone()),
902                ProcessWatchRecord::Loss(_) => None,
903            })
904            .collect()
905    }
906
907    pub fn cursor(&self) -> ProcessWatchCursor {
908        let next_sequence = self
909            .log
910            .state
911            .lock()
912            .unwrap_or_else(|e| e.into_inner())
913            .first_sequence;
914        ProcessWatchCursor {
915            log: Arc::clone(&self.log),
916            next_sequence,
917        }
918    }
919}
920
921pub struct ProcessWatchCursor {
922    log: Arc<SharedLog>,
923    next_sequence: u64,
924}
925
926impl ProcessWatchCursor {
927    pub fn read_next(&mut self, timeout: Option<Duration>) -> ProcessWatchRead {
928        let mut state = self.log.state.lock().unwrap_or_else(|e| e.into_inner());
929        let deadline = timeout.map(|duration| std::time::Instant::now() + duration);
930        loop {
931            if self.next_sequence < state.first_sequence {
932                let gap = ProcessWatchGap {
933                    first_missing: self.next_sequence,
934                    last_missing: state.first_sequence - 1,
935                };
936                self.next_sequence = state.first_sequence;
937                return ProcessWatchRead::Gap(gap);
938            }
939            if self.next_sequence < state.next_sequence {
940                let index = (self.next_sequence - state.first_sequence) as usize;
941                let item = state.entries[index].clone();
942                self.next_sequence += 1;
943                return match item {
944                    ProcessWatchRecord::Match(item) => ProcessWatchRead::Match(Box::new(item)),
945                    ProcessWatchRecord::Loss(item) => ProcessWatchRead::Loss(Box::new(item)),
946                };
947            }
948            if state.closed {
949                return ProcessWatchRead::Eof;
950            }
951            state = if let Some(deadline) = deadline {
952                let Some(remaining) = deadline.checked_duration_since(std::time::Instant::now())
953                else {
954                    return ProcessWatchRead::Timeout;
955                };
956                let (state, result) = self
957                    .log
958                    .wake
959                    .wait_timeout(state, remaining)
960                    .unwrap_or_else(|e| e.into_inner());
961                if result.timed_out() {
962                    return ProcessWatchRead::Timeout;
963                }
964                state
965            } else {
966                self.log.wake.wait(state).unwrap_or_else(|e| e.into_inner())
967            };
968        }
969    }
970}
971
972fn event_from_exact(
973    native: &ExactTraceEvent,
974    observation: ProcessObservation,
975    coverage_complete: bool,
976) -> ProcessEvent {
977    let (kind, exit_code, signal, raw_exit_status, loss_detected) = match &native.kind {
978        ExactTraceEventKind::Spawn => (ProcessEventKind::Spawn, None, None, None, false),
979        ExactTraceEventKind::Exec => (ProcessEventKind::Exec, None, None, None, false),
980        ExactTraceEventKind::Exit {
981            exit_code,
982            signal,
983            raw_status,
984        } => (
985            ProcessEventKind::Exit,
986            *exit_code,
987            *signal,
988            Some(*raw_status),
989            false,
990        ),
991        ExactTraceEventKind::Loss { .. } => (ProcessEventKind::Loss, None, None, None, true),
992    };
993    ProcessEvent {
994        kind,
995        process: ProcessIdentity {
996            pid: native.pid,
997            start_key: native.start_key,
998        },
999        parent: native.parent_pid.map(|pid| ProcessIdentity {
1000            pid,
1001            start_key: native.parent_start_key,
1002        }),
1003        timestamp: native.timestamp,
1004        executable: native.executable.clone(),
1005        argv: native.argv.as_ref().map(|args| {
1006            args.iter()
1007                .map(|arg| arg.to_string_lossy().into_owned())
1008                .collect()
1009        }),
1010        exit_code,
1011        signal,
1012        raw_exit_status,
1013        backend: observation.backend,
1014        observation_grade: observation.grade,
1015        coverage_complete,
1016        loss_detected,
1017    }
1018}
1019
1020fn selector_matches(selector: &WatchSelector, event: &ProcessEvent) -> bool {
1021    match selector {
1022        WatchSelector::Spawn => event.kind == ProcessEventKind::Spawn,
1023        WatchSelector::Exec { basename, path } => {
1024            event.kind == ProcessEventKind::Exec
1025                && executable_matches(
1026                    event.executable.as_deref(),
1027                    basename.as_deref(),
1028                    path.as_deref(),
1029                )
1030        }
1031        WatchSelector::Exit {
1032            code,
1033            signal,
1034            basename,
1035            failure_only,
1036        } => {
1037            event.kind == ProcessEventKind::Exit
1038                && executable_matches(event.executable.as_deref(), basename.as_deref(), None)
1039                && code.is_none_or(|wanted| exit_code_matches(wanted, event.exit_code))
1040                && signal.is_none_or(|wanted| event.signal == Some(wanted))
1041                && (!failure_only
1042                    || event.signal.is_some()
1043                    || event.exit_code.is_some_and(|c| c != 0))
1044        }
1045    }
1046}
1047
1048fn executable_matches(
1049    executable: Option<&Path>,
1050    basename: Option<&str>,
1051    exact_path: Option<&Path>,
1052) -> bool {
1053    if let Some(expected) = exact_path {
1054        return executable == Some(expected);
1055    }
1056    if let Some(expected) = basename {
1057        return executable
1058            .and_then(Path::file_name)
1059            .and_then(|name| name.to_str())
1060            == Some(expected);
1061    }
1062    true
1063}
1064
1065fn exit_code_matches(requested: i32, observed: Option<i32>) -> bool {
1066    if requested == -1 {
1067        return observed == Some(255) || observed == Some(-1);
1068    }
1069    observed == Some(requested)
1070}
1071
1072fn write_dump(request: &StackDump, label: &str, event: &ExactTraceEvent) -> DumpResult {
1073    if request.capture == StackCapture::OwnerAllThreads {
1074        return DumpResult {
1075            capture_source: CaptureSource::None,
1076            artifacts: Vec::new(),
1077            symbolized: false,
1078            error: Some("owner all-thread capture is unavailable for this event".to_owned()),
1079        };
1080    }
1081    let Some(origin) = event.origin.as_ref() else {
1082        return DumpResult {
1083            capture_source: CaptureSource::None,
1084            artifacts: Vec::new(),
1085            symbolized: false,
1086            error: Some(match request.capture {
1087                StackCapture::OwnerAllThreads => {
1088                    "owner all-thread capture is unavailable for this event".to_owned()
1089                }
1090                _ => "spawning-thread origin capture is unavailable for this event".to_owned(),
1091            }),
1092        };
1093    };
1094    let directory = request
1095        .directory
1096        .clone()
1097        .unwrap_or_else(|| std::env::temp_dir().join("running-process-watch"));
1098    if let Err(error) = std::fs::create_dir_all(&directory) {
1099        return dump_error(error);
1100    }
1101    let safe_label: String = label
1102        .chars()
1103        .map(|character| {
1104            if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') {
1105                character
1106            } else {
1107                '_'
1108            }
1109        })
1110        .collect();
1111    let path = directory.join(format!(
1112        "{safe_label}-{}-{}.rpstack",
1113        event.pid, event.sequence
1114    ));
1115    let bytes = render_origin(origin);
1116    if let Err(error) = std::fs::write(&path, bytes) {
1117        return dump_error(error);
1118    }
1119    DumpResult {
1120        capture_source: CaptureSource::RemoteSpawningThread,
1121        artifacts: vec![path],
1122        symbolized: false,
1123        error: request.symbolize_immediately.then(|| {
1124            "immediate remote symbolization is unavailable; retained a deferred raw artifact"
1125                .to_owned()
1126        }),
1127    }
1128}
1129
1130fn dump_error(error: std::io::Error) -> DumpResult {
1131    DumpResult {
1132        capture_source: CaptureSource::None,
1133        artifacts: Vec::new(),
1134        symbolized: false,
1135        error: Some(error.to_string()),
1136    }
1137}
1138
1139fn render_origin(origin: &TraceOriginArtifact) -> Vec<u8> {
1140    let mut text = format!(
1141        "format=running-process-origin-v2\norigin_pid={}\nthread_id={}\narchitecture={}\nregister_format={}\norigin_executable={:?}\nstack_pointer={:?}\ninstruction_pointer={:?}\nregister_bytes={}\nstack_bytes={}\nstack_truncated={}\nmodule_map_bytes={}\nmodule_map_truncated={}\nregisters=",
1142        origin.origin_pid,
1143        origin.thread_id,
1144        origin.architecture,
1145        origin.register_format,
1146        origin.executable,
1147        origin.stack_pointer,
1148        origin.instruction_pointer,
1149        origin.registers.len(),
1150        origin.stack.len(),
1151        origin.truncated,
1152        origin.module_map.len(),
1153        origin.module_map_truncated,
1154    );
1155    push_hex(&mut text, &origin.registers);
1156    text.push_str("\nstack=");
1157    push_hex(&mut text, &origin.stack);
1158    text.push_str("\nmodule_map=");
1159    push_hex(&mut text, &origin.module_map);
1160    text.push('\n');
1161    text.into_bytes()
1162}
1163
1164fn push_hex(output: &mut String, bytes: &[u8]) {
1165    use std::fmt::Write;
1166    for byte in bytes {
1167        let _ = write!(output, "{byte:02x}");
1168    }
1169}
1170
1171#[cfg(test)]
1172mod tests {
1173    use super::*;
1174
1175    #[test]
1176    fn minus_one_matches_unix_truncated_status() {
1177        assert!(exit_code_matches(-1, Some(255)));
1178        assert!(exit_code_matches(-1, Some(-1)));
1179        assert!(!exit_code_matches(-1, Some(254)));
1180        assert!(!exit_code_matches(-1, None));
1181    }
1182
1183    #[test]
1184    fn inferred_details_keep_structured_arguments_and_spaceful_executable() {
1185        let (executable, argv) = inferred_command_details(Some(vec![
1186            std::ffi::OsString::from("/tmp/tool with spaces"),
1187            std::ffi::OsString::from("argument with spaces"),
1188            std::ffi::OsString::from(""),
1189            std::ffi::OsString::from(r"back\slash"),
1190        ]));
1191        assert_eq!(executable, Some(PathBuf::from("/tmp/tool with spaces")));
1192        assert_eq!(
1193            argv,
1194            Some(vec![
1195                "/tmp/tool with spaces".to_owned(),
1196                "argument with spaces".to_owned(),
1197                String::new(),
1198                r"back\slash".to_owned(),
1199            ])
1200        );
1201    }
1202
1203    #[test]
1204    fn ambiguous_exec_selector_is_rejected() {
1205        assert!(ProcessWatch::on_exec(
1206            Some("soldr".to_owned()),
1207            Some(PathBuf::from("/usr/bin/soldr")),
1208            None,
1209            Some(1),
1210            Duration::ZERO,
1211            "recursive-soldr",
1212        )
1213        .is_err());
1214    }
1215
1216    #[test]
1217    fn bounded_log_reports_an_explicit_cursor_gap() {
1218        let watch = ProcessWatch::on_spawn(None, None, Duration::ZERO, "all-spawns").unwrap();
1219        let (emitter, subscriber) =
1220            ProcessWatchEmitter::new(vec![watch], ObservationPolicy::NonInvasive).unwrap();
1221        let mut cursor = subscriber.cursor();
1222        for pid in 1..=(DEFAULT_RETAINED_MATCHES as u32 + 7) {
1223            emitter.emit_inferred(pid, true);
1224        }
1225        let ProcessWatchRead::Gap(gap) = cursor.read_next(Some(Duration::ZERO)) else {
1226            panic!("expected an explicit cursor gap");
1227        };
1228        assert_eq!(gap.first_missing, 1);
1229        assert_eq!(gap.last_missing, 7);
1230    }
1231
1232    #[test]
1233    fn limit_and_cooldown_bound_matching() {
1234        let limited = ProcessWatch::on_spawn(None, Some(2), Duration::ZERO, "limited").unwrap();
1235        let cooled = ProcessWatch::on_spawn(None, None, Duration::from_secs(60), "cooled").unwrap();
1236        let (emitter, subscriber) =
1237            ProcessWatchEmitter::new(vec![limited, cooled], ObservationPolicy::NonInvasive)
1238                .unwrap();
1239        for pid in 1..=4 {
1240            emitter.emit_inferred(pid, true);
1241        }
1242        let matches = subscriber.snapshot();
1243        assert_eq!(
1244            matches
1245                .iter()
1246                .filter(|item| item.watch.label == "limited")
1247                .count(),
1248            2
1249        );
1250        assert_eq!(
1251            matches
1252                .iter()
1253                .filter(|item| item.watch.label == "cooled")
1254                .count(),
1255            1
1256        );
1257    }
1258
1259    #[test]
1260    fn native_loss_is_delivered_without_a_matching_selector() {
1261        let watch = ProcessWatch::on_spawn(None, Some(1), Duration::ZERO, "spawn").unwrap();
1262        let (emitter, subscriber) =
1263            ProcessWatchEmitter::new(vec![watch], ObservationPolicy::NonInvasive).unwrap();
1264        let mut cursor = subscriber.cursor();
1265        emitter.emit_exact(ExactTraceEvent {
1266            sequence: 7,
1267            pid: 42,
1268            parent_pid: None,
1269            parent_start_key: None,
1270            start_key: Some(11),
1271            timestamp: SystemTime::now(),
1272            kind: ExactTraceEventKind::Loss {
1273                reason: "resume failed".to_owned(),
1274            },
1275            executable: None,
1276            argv: None,
1277            origin: None,
1278        });
1279        let ProcessWatchRead::Loss(loss) = cursor.read_next(Some(Duration::from_secs(1))) else {
1280            panic!("expected an explicit loss record");
1281        };
1282        assert_eq!(loss.reason, "resume failed");
1283        assert!(loss.event.loss_detected);
1284        assert!(!loss.event.coverage_complete);
1285    }
1286}
1287
1288#[cfg(test)]
1289#[path = "../tests/process_watch_coverage.rs"]
1290mod coverage_tests;