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;