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