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;