Skip to main content

running_process/observer/
mod.rs

1//! Phase 1 of #221: the process-observation capability model and the
2//! portable process-lifecycle baseline.
3//!
4//! This module defines the stable observation types — [`ObserverConfig`],
5//! [`ObserverCapabilities`], [`ObserverEvent`], and the
6//! [`ObserverSubscriber`] handle — plus the always-available lifecycle
7//! backend that emits [`started`](ObserverEventKind::Started) and
8//! [`exited`](ObserverEventKind::Exited) events for child processes spawned
9//! by this crate.
10//!
11//! ## TraceScope dimension (#539)
12//!
13//! The capability matrix is negotiated for a [`TraceScope`]:
14//!
15//! - [`TraceScope::SystemWide`] — the historical default, names admin-gated
16//!   system tracers (ETW kernel providers, eBPF, EndpointSecurity). All
17//!   syscall categories report [`Unavailable`](CapabilitySupport::Unavailable)
18//!   until the Phase 3 backends from #469 land.
19//! - [`TraceScope::LaunchedProcessTree`] — the no-admin tier added by #539.
20//!   Names per-OS primitives that operate purely on the spawn boundary this
21//!   crate already owns (Windows Job Object IOCP, Linux subreaper+pidfd,
22//!   macOS kqueue EVFILT_PROC). Currently every syscall category reports
23//!   `Unavailable`; each #539 slice flips one cell to `Supported`/`Partial`
24//!   with no shape change.
25//!
26//! Lifecycle is `Supported` in every scope because owning the spawn boundary
27//! is sufficient for `started`/`exited` on all three platforms.
28//!
29//! `ObserverCapabilities::negotiate()` preserves the pre-#539 contract and
30//! returns the `SystemWide` matrix; new callers should use
31//! [`negotiate_for_scope`](ObserverCapabilities::negotiate_for_scope).
32//!
33//! ## Off by default
34//!
35//! Observation is entirely opt-in. A [`NativeProcess`](crate::NativeProcess)
36//! emits no events unless an [`ObserverConfig`] is attached via
37//! [`NativeProcess::with_observer`](crate::NativeProcess::with_observer) (or
38//! the equivalent builder seam). With no observer configured the lifecycle
39//! hooks are inert: no channel, no allocation, no events.
40//!
41//! The handle is a plain `std::sync::mpsc` receiver so the lifecycle
42//! baseline stays free of the daemon runtime (tokio/IPC). Phase 2 layers the
43//! daemon-owned subscriber model on top of these same event types.
44
45use std::sync::mpsc::{Receiver, Sender};
46use std::sync::Arc;
47use std::time::{Duration, SystemTime, UNIX_EPOCH};
48
49mod cmdline;
50pub use cmdline::read_process_cmdline;
51
52mod file_handles;
53pub use file_handles::read_process_file_handles;
54
55mod process_watch;
56pub(crate) use process_watch::ProcessWatchEmitter;
57pub use process_watch::{
58    CaptureSource, DumpResult, ObservationGrade, ObservationPolicy, ProcessEvent, ProcessEventKind,
59    ProcessIdentity, ProcessObservation, ProcessObservationCapabilities, ProcessObservationError,
60    ProcessWatch, ProcessWatchConfigurationError, ProcessWatchCursor, ProcessWatchGap,
61    ProcessWatchLoss, ProcessWatchMatch, ProcessWatchRead, ProcessWatchSubscriber, StackCapture,
62    StackDump,
63};
64
65pub(crate) type DescendantPumpStop =
66    running_process_platform_internal::platform::process::DescendantMonitorStop;
67
68/// Scope at which observation is negotiated.
69///
70/// `running-process` exposes two distinct observation tiers because the
71/// underlying OS primitives diverge sharply by privilege:
72///
73/// - [`LaunchedProcessTree`](Self::LaunchedProcessTree) — observe the process
74///   tree that this crate spawned and any descendants reparented under it.
75///   No admin / no entitlements / no kernel driver required. The crate owns
76///   the spawn boundary on every platform (Job Object on Windows, subreaper
77///   on Linux, kqueue child registration on macOS), so per-platform
78///   no-admin primitives are sufficient. This is the scope #539 wires up.
79/// - [`SystemWide`](Self::SystemWide) — observe every process on the host.
80///   Requires ETW kernel providers on Windows, eBPF/CAP_BPF on Linux,
81///   Endpoint Security entitlement on macOS. All of these need admin or
82///   signed entitlements and a separate operational story (#469).
83///
84/// The two scopes can coexist; backends for each are detected and reported
85/// independently. Marked `#[non_exhaustive]` per #431 so future scopes
86/// (e.g. cgroup-scoped, container-scoped) can land without a major bump.
87#[non_exhaustive]
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
89pub enum TraceScope {
90    /// Observation limited to the process tree this crate spawned.
91    /// Backends for this scope must operate without admin privileges.
92    LaunchedProcessTree,
93    /// Observation of every process on the host. Backends typically
94    /// require admin / entitlements / kernel drivers.
95    SystemWide,
96}
97
98impl TraceScope {
99    /// All scopes in stable order.
100    pub const ALL: [TraceScope; 2] = [TraceScope::LaunchedProcessTree, TraceScope::SystemWide];
101
102    /// Stable lowercase name for serialization / matrix rendering.
103    pub fn as_str(self) -> &'static str {
104        match self {
105            TraceScope::LaunchedProcessTree => "launched-process-tree",
106            TraceScope::SystemWide => "system-wide",
107        }
108    }
109}
110
111/// Category of observable process activity.
112///
113/// Phase 1 only implements [`Lifecycle`](Self::Lifecycle). The remaining
114/// categories exist so capability negotiation can report them as
115/// `unavailable` with an honest reason until their Phase 3 platform backends
116/// land.
117///
118/// Marked `#[non_exhaustive]` per #431: Phase 3 will refine these categories
119/// (and possibly add sub-categories) without forcing every consumer to bump
120/// to a new major version of the crate. Out-of-crate matchers must include a
121/// wildcard arm.
122#[non_exhaustive]
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
124pub enum EventCategory {
125    /// Process start and exit for children spawned by this crate.
126    Lifecycle,
127    /// Filesystem activity (open/read/write/unlink). Requires a Phase 3
128    /// platform backend.
129    File,
130    /// Network activity (connect/accept/send/recv). Requires a Phase 3
131    /// platform backend.
132    Network,
133    /// Descendant process creation outside the crate's own spawn path.
134    /// Requires a Phase 3 platform backend.
135    Process,
136}
137
138impl EventCategory {
139    /// All categories the capability matrix reports on, in a stable order.
140    pub const ALL: [EventCategory; 4] = [
141        EventCategory::Lifecycle,
142        EventCategory::File,
143        EventCategory::Network,
144        EventCategory::Process,
145    ];
146
147    /// Return the stable lowercase category name.
148    pub fn as_str(self) -> &'static str {
149        match self {
150            EventCategory::Lifecycle => "lifecycle",
151            EventCategory::File => "file",
152            EventCategory::Network => "network",
153            EventCategory::Process => "process",
154        }
155    }
156}
157
158/// Negotiated support level for a single [`EventCategory`].
159///
160/// Marked `#[non_exhaustive]` per #431: later phases may introduce richer
161/// support gradations (e.g. a `Degraded` variant distinct from `Partial`)
162/// without breaking out-of-crate matchers.
163#[non_exhaustive]
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum CapabilitySupport {
166    /// The category is fully observable on this platform.
167    Supported,
168    /// The category is observable but with documented gaps or caveats.
169    Partial,
170    /// The category cannot be observed by the active backend set.
171    Unavailable,
172}
173
174impl CapabilitySupport {
175    /// Return the stable lowercase support-level name.
176    pub fn as_str(self) -> &'static str {
177        match self {
178            CapabilitySupport::Supported => "supported",
179            CapabilitySupport::Partial => "partial",
180            CapabilitySupport::Unavailable => "unavailable",
181        }
182    }
183}
184
185/// Capability report for one [`EventCategory`]: the negotiated support
186/// level, the backend that would serve it, and a human-readable reason.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct CategoryCapability {
189    /// Which category this entry describes.
190    pub category: EventCategory,
191    /// Negotiated support level.
192    pub support: CapabilitySupport,
193    /// Name of the backend serving (or that would serve) this category.
194    pub backend: &'static str,
195    /// Human-readable explanation, especially for `Partial`/`Unavailable`.
196    pub reason: &'static str,
197}
198
199/// The full capability matrix produced by [`ObserverCapabilities::negotiate`]
200/// or [`ObserverCapabilities::negotiate_for_scope`].
201///
202/// Each [`EventCategory`] appears exactly once for the negotiated
203/// [`TraceScope`]. Phase 1 reports [`Lifecycle`](EventCategory::Lifecycle) as
204/// [`Supported`](CapabilitySupport::Supported) in every scope (the spawn/reap
205/// path is scope-independent); the rest start out as
206/// [`Unavailable`](CapabilitySupport::Unavailable) and flip to
207/// `Supported`/`Partial` as per-OS backends land (#539 for
208/// [`LaunchedProcessTree`](TraceScope::LaunchedProcessTree), #469 for
209/// [`SystemWide`](TraceScope::SystemWide)).
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct ObserverCapabilities {
212    scope: TraceScope,
213    categories: Vec<CategoryCapability>,
214}
215
216/// Detect the backend that would serve [`EventCategory::File`] on this
217/// platform for the requested [`TraceScope`].
218///
219/// Returns `(support, backend, reason)`. Today every branch returns
220/// `Unavailable`. As individual backends land, flip the matching branch to
221/// `Supported`/`Partial` with no shape change.
222///
223/// Scope split:
224///
225/// - [`TraceScope::SystemWide`] — names the admin-gated system tracer that
226///   would have to land (ETW kernel provider, eBPF, EndpointSecurity).
227///   Tracked by #469.
228/// - [`TraceScope::LaunchedProcessTree`] — names the no-admin per-OS
229///   primitive that observes only this crate's spawned tree
230///   (NT handle snapshot, `/proc/<pid>/fd/*`, `proc_pidinfo`). Tracked by
231///   #539. Lands incrementally per slice.
232fn detect_file_backend(scope: TraceScope) -> (CapabilitySupport, &'static str, &'static str) {
233    detect_backend(
234        scope,
235        running_process_platform_internal::platform::process::ObserverCategory::File,
236    )
237}
238
239fn detect_network_backend(scope: TraceScope) -> (CapabilitySupport, &'static str, &'static str) {
240    detect_backend(
241        scope,
242        running_process_platform_internal::platform::process::ObserverCategory::Network,
243    )
244}
245
246fn detect_process_backend(scope: TraceScope) -> (CapabilitySupport, &'static str, &'static str) {
247    detect_backend(
248        scope,
249        running_process_platform_internal::platform::process::ObserverCategory::Process,
250    )
251}
252
253fn detect_backend(
254    scope: TraceScope,
255    category: running_process_platform_internal::platform::process::ObserverCategory,
256) -> (CapabilitySupport, &'static str, &'static str) {
257    use running_process_platform_internal::platform::process::{
258        observer_backend, ObserverScope, ObserverSupport,
259    };
260    let scope = match scope {
261        TraceScope::SystemWide => ObserverScope::SystemWide,
262        TraceScope::LaunchedProcessTree => ObserverScope::LaunchedProcessTree,
263    };
264    let backend = observer_backend(scope, category);
265    let support = match backend.support {
266        ObserverSupport::Supported => CapabilitySupport::Supported,
267        ObserverSupport::Partial => CapabilitySupport::Partial,
268        ObserverSupport::Unavailable => CapabilitySupport::Unavailable,
269    };
270    (support, backend.backend, backend.reason)
271}
272impl ObserverCapabilities {
273    /// Negotiate the capability matrix for the current platform under the
274    /// historical default scope ([`TraceScope::SystemWide`]).
275    ///
276    /// Preserved for backwards compatibility with pre-#539 callers. New
277    /// callers that know which tier they want should use
278    /// [`negotiate_for_scope`](Self::negotiate_for_scope) — the
279    /// `LaunchedProcessTree` scope advertises different per-OS backends
280    /// (no-admin: NT handle snapshot, `/proc/<pid>/fd/*`, `proc_pidinfo`)
281    /// than the `SystemWide` scope (admin-gated: ETW, eBPF, EndpointSecurity).
282    pub fn negotiate() -> Self {
283        Self::negotiate_for_scope(TraceScope::SystemWide)
284    }
285
286    /// Negotiate the capability matrix for the current platform at the
287    /// requested [`TraceScope`].
288    ///
289    /// Lifecycle is `Supported` in every scope (the spawn/reap path is
290    /// scope-independent and runs in-process with no admin requirement).
291    /// File/Network/Process start out `Unavailable` and flip to
292    /// `Supported`/`Partial` as per-OS backends land — the scope × OS
293    /// dispatch lives in the crate-private `detect_*_backend` helpers.
294    pub fn negotiate_for_scope(scope: TraceScope) -> Self {
295        let categories = EventCategory::ALL
296            .iter()
297            .map(|&category| match category {
298                EventCategory::Lifecycle => CategoryCapability {
299                    category,
300                    support: CapabilitySupport::Supported,
301                    backend: "portable-lifecycle",
302                    reason: "started/exited emitted from the crate spawn and reap path",
303                },
304                EventCategory::File => {
305                    let (support, backend, reason) = detect_file_backend(scope);
306                    CategoryCapability {
307                        category,
308                        support,
309                        backend,
310                        reason,
311                    }
312                }
313                EventCategory::Network => {
314                    let (support, backend, reason) = detect_network_backend(scope);
315                    CategoryCapability {
316                        category,
317                        support,
318                        backend,
319                        reason,
320                    }
321                }
322                EventCategory::Process => {
323                    let (support, backend, reason) = detect_process_backend(scope);
324                    CategoryCapability {
325                        category,
326                        support,
327                        backend,
328                        reason,
329                    }
330                }
331            })
332            .collect();
333        Self { scope, categories }
334    }
335
336    /// The [`TraceScope`] this matrix was negotiated for.
337    pub fn scope(&self) -> TraceScope {
338        self.scope
339    }
340
341    /// Return the capability entries in stable [`EventCategory::ALL`] order.
342    pub fn categories(&self) -> &[CategoryCapability] {
343        &self.categories
344    }
345
346    /// Look up the capability entry for one category.
347    pub fn category(&self, category: EventCategory) -> &CategoryCapability {
348        self.categories
349            .iter()
350            .find(|entry| entry.category == category)
351            .expect("ObserverCapabilities always contains every EventCategory")
352    }
353
354    /// Return the negotiated support level for one category.
355    pub fn support(&self, category: EventCategory) -> CapabilitySupport {
356        self.category(category).support
357    }
358
359    /// Return whether a category is fully [`Supported`](CapabilitySupport::Supported).
360    pub fn is_supported(&self, category: EventCategory) -> bool {
361        self.support(category) == CapabilitySupport::Supported
362    }
363
364    /// Return the capability matrix as four fixed-width rows suitable for
365    /// downstream UX (e.g. a clud CLI flag — see Phase 4 of #221 / #431).
366    ///
367    /// Each row is `[category, support, backend, reason]`. Row order matches
368    /// [`EventCategory::ALL`], so consumers can rely on a stable layout. The
369    /// strings are owned so callers can paint colors / pad columns without
370    /// borrowing from `self`.
371    pub fn to_table_rows(&self) -> Vec<[String; 4]> {
372        self.categories
373            .iter()
374            .map(|entry| {
375                [
376                    entry.category.as_str().to_string(),
377                    entry.support.as_str().to_string(),
378                    entry.backend.to_string(),
379                    entry.reason.to_string(),
380                ]
381            })
382            .collect()
383    }
384
385    /// Render the capability matrix as a single human-readable string.
386    ///
387    /// The output is deterministic per scope+category set so a UI can
388    /// snapshot or diff it. The first line names the negotiated
389    /// [`TraceScope`] so a diff between scopes is obvious. Layout:
390    ///
391    /// ```text
392    /// observer capabilities (scope=system-wide):
393    ///   lifecycle    supported    portable-lifecycle  started/exited emitted from the crate spawn and reap path
394    ///   file         unavailable  etw                 Phase 3: Windows ETW file backend not yet implemented
395    ///   network      unavailable  etw                 Phase 3: Windows ETW network backend not yet implemented
396    ///   process      unavailable  etw                 Phase 3: Windows ETW process backend not yet implemented
397    /// ```
398    ///
399    /// Phase 4 (#431) consumers like the clud CLI use this to show the
400    /// actually negotiated matrix rather than claiming syscall coverage the
401    /// active backends do not provide.
402    pub fn render_summary(&self) -> String {
403        // Compute column widths from the longest entry per column so the
404        // output stays aligned as future categories / backends land.
405        let rows = self.to_table_rows();
406        let mut widths = [0usize; 3];
407        for row in &rows {
408            for (i, cell) in row[..3].iter().enumerate() {
409                widths[i] = widths[i].max(cell.len());
410            }
411        }
412        let mut out = format!("observer capabilities (scope={}):\n", self.scope.as_str());
413        for row in &rows {
414            out.push_str(&format!(
415                "  {cat:<cw$}  {sup:<sw$}  {bk:<bw$}  {reason}\n",
416                cat = row[0],
417                sup = row[1],
418                bk = row[2],
419                reason = row[3],
420                cw = widths[0],
421                sw = widths[1],
422                bw = widths[2],
423            ));
424        }
425        out
426    }
427}
428
429/// What happened to an observed process.
430///
431/// Marked `#[non_exhaustive]` per #431: Phase 3 will add variants for File,
432/// Network, and Process events. Out-of-crate matchers must include a
433/// wildcard arm to remain forward-compatible across minor releases.
434#[non_exhaustive]
435#[derive(Debug, Clone, PartialEq, Eq)]
436pub enum ObserverEventKind {
437    /// The child process was spawned. Carries no extra payload.
438    Started,
439    /// The child process exited. Carries the OS exit code (Unix signal
440    /// exits are negative signal numbers, matching the rest of the crate).
441    Exited {
442        /// Exit code of the child.
443        exit_code: i32,
444    },
445    /// A descendant of the spawned process (i.e. a child of a child) was
446    /// created. Emitted on the [`EventCategory::Process`] category by
447    /// per-OS LaunchedProcessTree backends (#539). The descendant PID is
448    /// carried by [`ObserverEvent::pid`].
449    ///
450    /// Unlike [`Started`](Self::Started), this carries no exit code on the
451    /// pair event because the no-admin descendant-lifecycle primitives
452    /// (Windows Job Object IOCP, Linux pidfd reap, macOS `EVFILT_PROC`)
453    /// surface PID-only notifications.
454    DescendantStarted,
455    /// A descendant process exited. Emitted on the
456    /// [`EventCategory::Process`] category by per-OS LaunchedProcessTree
457    /// backends (#539). The descendant PID is carried by
458    /// [`ObserverEvent::pid`]; the exit code is not surfaced — see
459    /// [`DescendantStarted`](Self::DescendantStarted) for rationale.
460    DescendantExited,
461    /// A file was opened by the observed process. Emitted on the
462    /// [`EventCategory::File`] category by the **hook tier** of the
463    /// observer (the sidecar interposer in `running-process-probe`,
464    /// tracked by #551). The pid in [`ObserverEvent::pid`] is the
465    /// process that performed the call. `flags` is the platform-native
466    /// open flags (POSIX `O_*` on Unix; Windows `dwDesiredAccess` |
467    /// `(dwShareMode << 16)` encoded best-effort).
468    FileOpen {
469        /// Filesystem path the consumer opened. On Linux/macOS this is
470        /// the POSIX path passed to `open(2)` / `openat(2)`; on Windows
471        /// it's the resolved Win32 path (DOS-form when available, NT
472        /// path otherwise — matches the slice-4 #550 convention).
473        path: std::path::PathBuf,
474        /// Platform-native open flags. Best-effort encoded.
475        flags: u32,
476    },
477    /// A file was written to by the observed process. `byte_count` is
478    /// the byte count returned by the syscall on success (may be
479    /// shorter than the request on short writes). Emitted on the
480    /// [`EventCategory::File`] category by the hook tier (#551).
481    FileWrite {
482        /// Resolved path of the file the write targeted.
483        path: std::path::PathBuf,
484        /// Number of bytes the syscall reported it actually wrote.
485        byte_count: u64,
486    },
487    /// A file descriptor / handle was closed. Emitted on the
488    /// [`EventCategory::File`] category by the hook tier (#551). The
489    /// path is resolved at hook-fire time from the fd / handle, so it
490    /// matches the path the corresponding [`FileOpen`](Self::FileOpen)
491    /// event reported.
492    FileClose {
493        /// Resolved path of the file that was closed.
494        path: std::path::PathBuf,
495    },
496    /// A file was unlinked / deleted. Emitted on the
497    /// [`EventCategory::File`] category by the hook tier (#551).
498    FileUnlink {
499        /// Path of the file that was unlinked.
500        path: std::path::PathBuf,
501    },
502    /// A file was renamed. Emitted on the [`EventCategory::File`]
503    /// category by the hook tier (#551).
504    FileRename {
505        /// Path the file was renamed from.
506        from: std::path::PathBuf,
507        /// Path the file was renamed to.
508        to: std::path::PathBuf,
509    },
510}
511
512impl ObserverEventKind {
513    /// Return the stable lowercase event-kind name.
514    pub fn as_str(&self) -> &'static str {
515        match self {
516            ObserverEventKind::Started => "started",
517            ObserverEventKind::Exited { .. } => "exited",
518            ObserverEventKind::DescendantStarted => "descendant-started",
519            ObserverEventKind::DescendantExited => "descendant-exited",
520            ObserverEventKind::FileOpen { .. } => "file-open",
521            ObserverEventKind::FileWrite { .. } => "file-write",
522            ObserverEventKind::FileClose { .. } => "file-close",
523            ObserverEventKind::FileUnlink { .. } => "file-unlink",
524            ObserverEventKind::FileRename { .. } => "file-rename",
525        }
526    }
527}
528
529/// A single observation emitted by the lifecycle baseline.
530#[derive(Debug, Clone, PartialEq, Eq)]
531pub struct ObserverEvent {
532    /// Which category produced the event. Always
533    /// [`EventCategory::Lifecycle`] in Phase 1.
534    pub category: EventCategory,
535    /// What happened.
536    pub kind: ObserverEventKind,
537    /// OS process id of the observed child.
538    pub pid: u32,
539    /// Immediate parent of `pid`, when the producing backend knows it.
540    ///
541    /// Populated for [`ObserverEventKind::DescendantStarted`] on Linux
542    /// (the `/proc` children walk names the parent) and macOS (the
543    /// process snapshot carries it); `None` on Windows, whose job-object
544    /// notification is PID-only, and for every non-descendant event,
545    /// where the parent is the observed root itself.
546    pub ppid: Option<u32>,
547    /// Milliseconds since the Unix epoch when the event was recorded.
548    pub timestamp_ms: u128,
549}
550
551impl ObserverEvent {
552    /// Construct an event, stamping it with the current wall-clock time.
553    fn now(category: EventCategory, kind: ObserverEventKind, pid: u32) -> Self {
554        Self::now_with_parent(category, kind, pid, None)
555    }
556
557    fn now_with_parent(
558        category: EventCategory,
559        kind: ObserverEventKind,
560        pid: u32,
561        ppid: Option<u32>,
562    ) -> Self {
563        let timestamp_ms = SystemTime::now()
564            .duration_since(UNIX_EPOCH)
565            .map(|d| d.as_millis())
566            .unwrap_or(0);
567        Self {
568            category,
569            kind,
570            pid,
571            ppid,
572            timestamp_ms,
573        }
574    }
575
576    /// Construct an event stamped with the current wall-clock time.
577    ///
578    /// Crate-public sibling of the private `now` constructor for the daemon's
579    /// per-session observer registry (#221 Phase 2 / #429), which emits
580    /// lifecycle events directly without going through the crate-private
581    /// `ObserverEmitter`.
582    pub fn new_now(category: EventCategory, kind: ObserverEventKind, pid: u32) -> Self {
583        Self::now(category, kind, pid)
584    }
585
586    /// Construct an event carrying the descendant's parent pid, stamping
587    /// it with the current wall-clock time.
588    pub fn new_now_with_parent(
589        category: EventCategory,
590        kind: ObserverEventKind,
591        pid: u32,
592        ppid: Option<u32>,
593    ) -> Self {
594        Self::now_with_parent(category, kind, pid, ppid)
595    }
596}
597
598/// Opt-in configuration that turns process observation on for a single
599/// [`NativeProcess`](crate::NativeProcess).
600///
601/// Constructing a config does not by itself observe anything; it is attached
602/// to a process via
603/// [`NativeProcess::with_observer`](crate::NativeProcess::with_observer).
604/// With no config attached, the process emits no events (off by default).
605#[derive(Debug, Clone)]
606pub struct ObserverConfig {
607    categories: Vec<EventCategory>,
608}
609
610impl ObserverConfig {
611    /// Create a config that observes only the Phase 1 lifecycle baseline.
612    ///
613    /// This is the recommended Phase 1 constructor: it requests exactly the
614    /// category that is actually `Supported`.
615    pub fn lifecycle() -> Self {
616        Self {
617            categories: vec![EventCategory::Lifecycle],
618        }
619    }
620
621    /// Create a config requesting an explicit set of categories.
622    ///
623    /// Categories that are not `Supported` on this platform simply never
624    /// produce events in Phase 1; callers should consult
625    /// [`ObserverCapabilities::negotiate`] to learn which ones are honored.
626    pub fn with_categories(categories: impl IntoIterator<Item = EventCategory>) -> Self {
627        Self {
628            categories: categories.into_iter().collect(),
629        }
630    }
631
632    /// Return whether this config requested observation of `category`.
633    pub fn observes(&self, category: EventCategory) -> bool {
634        self.categories.contains(&category)
635    }
636
637    /// The categories this config requested, in insertion order.
638    pub fn categories(&self) -> &[EventCategory] {
639        &self.categories
640    }
641}
642
643/// Observe the launched process tree of an already-running process.
644///
645/// Attaches the per-OS descendant monitor to `root_pid` without owning its
646/// spawn: callers that manage their own child (custom pipe plumbing, an
647/// adopted pid) get the same `DescendantStarted` / `DescendantExited`
648/// stream that [`NativeProcess::with_observer`](crate::NativeProcess::with_observer)
649/// wires at spawn time. The monitor ends when the subscriber is dropped or
650/// [`ObserverSubscriber::stop`] is called, or when the root's tree fully
651/// drains — the channel then simply closes and `recv` returns `None`.
652///
653/// The `config` must observe [`EventCategory::Process`] for any events to
654/// flow; direct-child `Started`/`Exited` lifecycle events are the spawn
655/// owner's to report and are never synthesized here.
656///
657/// Platform note: Windows discovers descendants through the Job Object
658/// IOCP attached at spawn, so this post-hoc attach observes nothing there
659/// today; Linux (subreaper + `/proc` children walk) and macOS (process
660/// snapshots + kqueue hints) work for any live pid.
661pub fn observe_launched_tree(root_pid: u32, config: ObserverConfig) -> ObserverSubscriber {
662    let (emitter, subscriber) = ObserverEmitter::new(config);
663    crate::descendant_monitor::start(root_pid, Some(&emitter), None);
664    subscriber
665}
666
667/// Receiver handle for observation events.
668///
669/// Returned by
670/// [`NativeProcess::with_observer`](crate::NativeProcess::with_observer).
671/// Dropping the subscriber detaches it; the emitter tolerates a closed
672/// channel and never blocks on a slow or absent consumer.
673pub struct ObserverSubscriber {
674    rx: Receiver<ObserverEvent>,
675    descendant_stop: Arc<DescendantPumpStop>,
676}
677
678impl ObserverSubscriber {
679    /// Wrap an existing channel receiver. Used by the daemon client helpers
680    /// in `client::observer` to hand the caller a subscriber whose channel
681    /// is later fed by an IPC streaming pump.
682    pub(crate) fn from_receiver(rx: Receiver<ObserverEvent>) -> Self {
683        Self {
684            rx,
685            descendant_stop: Arc::new(DescendantPumpStop::new()),
686        }
687    }
688
689    /// Receive the next event, blocking until one arrives or the emitter is
690    /// dropped. Returns `None` once no more events can arrive.
691    pub fn recv(&self) -> Option<ObserverEvent> {
692        self.rx.recv().ok()
693    }
694
695    /// Receive the next event, waiting for at most `timeout`.
696    ///
697    /// Returns [`std::sync::mpsc::RecvTimeoutError::Timeout`] when the bound
698    /// expires and [`std::sync::mpsc::RecvTimeoutError::Disconnected`] once no
699    /// more events can arrive.
700    pub fn recv_timeout(
701        &self,
702        timeout: Duration,
703    ) -> Result<ObserverEvent, std::sync::mpsc::RecvTimeoutError> {
704        self.rx.recv_timeout(timeout)
705    }
706
707    /// Try to receive an event without blocking.
708    pub fn try_recv(&self) -> Option<ObserverEvent> {
709        self.rx.try_recv().ok()
710    }
711
712    /// Drain all currently-queued events without blocking.
713    pub fn drain(&self) -> Vec<ObserverEvent> {
714        let mut events = Vec::new();
715        while let Ok(event) = self.rx.try_recv() {
716            events.push(event);
717        }
718        events
719    }
720
721    /// Borrow the underlying receiver for advanced use (e.g. `iter`/`select`).
722    pub fn receiver(&self) -> &Receiver<ObserverEvent> {
723        &self.rx
724    }
725
726    /// Stop any Linux/macOS descendant pump associated with this subscriber.
727    ///
728    /// This is idempotent and wakes a sleeping pump immediately. Lifecycle
729    /// events already queued on the subscriber remain available.
730    pub fn stop(&self) {
731        self.descendant_stop.stop();
732    }
733}
734
735impl Drop for ObserverSubscriber {
736    fn drop(&mut self) {
737        self.stop();
738    }
739}
740
741/// Internal emitter held by a [`NativeProcess`](crate::NativeProcess) when an
742/// [`ObserverConfig`] is attached.
743///
744/// `None` on a process means observation is off, so the lifecycle hooks are
745/// inert. This keeps the off-by-default path allocation-free.
746pub(crate) struct ObserverEmitter {
747    config: ObserverConfig,
748    tx: Sender<ObserverEvent>,
749    #[allow(dead_code)]
750    descendant_stop: Arc<DescendantPumpStop>,
751}
752
753impl ObserverEmitter {
754    /// Build an emitter from a config and hand back the paired subscriber.
755    pub(crate) fn new(config: ObserverConfig) -> (Self, ObserverSubscriber) {
756        let (tx, rx) = std::sync::mpsc::channel();
757        let descendant_stop = Arc::new(DescendantPumpStop::new());
758        (
759            Self {
760                config,
761                tx,
762                descendant_stop: Arc::clone(&descendant_stop),
763            },
764            ObserverSubscriber {
765                rx,
766                descendant_stop,
767            },
768        )
769    }
770
771    /// Emit a `started` event for `pid` if the config observes lifecycle.
772    pub(crate) fn emit_started(&self, pid: u32) {
773        if !self.config.observes(EventCategory::Lifecycle) {
774            return;
775        }
776        // Ignore send errors: a dropped subscriber must never break the
777        // process spawn/reap path.
778        let _ = self.tx.send(ObserverEvent::now(
779            EventCategory::Lifecycle,
780            ObserverEventKind::Started,
781            pid,
782        ));
783    }
784
785    /// Emit an `exited` event for `pid` if the config observes lifecycle.
786    pub(crate) fn emit_exited(&self, pid: u32, exit_code: i32) {
787        if !self.config.observes(EventCategory::Lifecycle) {
788            return;
789        }
790        let _ = self.tx.send(ObserverEvent::now(
791            EventCategory::Lifecycle,
792            ObserverEventKind::Exited { exit_code },
793            pid,
794        ));
795    }
796
797    /// Return a cloned sender for descendant lifecycle events if the config
798    /// observes [`EventCategory::Process`]; otherwise `None`.
799    ///
800    /// Per-OS LaunchedProcessTree backends (#539) take this `Sender` and run
801    /// a background pump (Windows Job Object IOCP, Linux pidfd reap, macOS
802    /// `EVFILT_PROC`) that fires
803    /// [`DescendantStarted`](ObserverEventKind::DescendantStarted) /
804    /// [`DescendantExited`](ObserverEventKind::DescendantExited) on this
805    /// channel. Returning `None` when Process isn't requested keeps the
806    /// off-by-default path allocation-free.
807    //
808    // `dead_code`-allowed because only the Windows backend (slice 2)
809    // currently consumes this; the Linux subreaper-pidfd backend (slice 5)
810    // and macOS kqueue-evfilt-proc backend (slice 7) will plug in next.
811    #[allow(dead_code)]
812    pub(crate) fn descendant_sink(&self) -> Option<Sender<ObserverEvent>> {
813        if self.config.observes(EventCategory::Process) {
814            Some(self.tx.clone())
815        } else {
816            None
817        }
818    }
819
820    #[allow(dead_code)]
821    pub(crate) fn descendant_pump(
822        &self,
823    ) -> Option<(Sender<ObserverEvent>, Arc<DescendantPumpStop>)> {
824        self.descendant_sink()
825            .map(|sink| (sink, Arc::clone(&self.descendant_stop)))
826    }
827}
828
829#[cfg(test)]
830mod tests;