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