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;