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