diff --git a/.github/workflows/source-gates.yml b/.github/workflows/source-gates.yml index e13c2eafc..072003eca 100644 --- a/.github/workflows/source-gates.yml +++ b/.github/workflows/source-gates.yml @@ -5,7 +5,7 @@ name: Source Gates # These ran as seven separate workflows. Each paid ~26s compiling the xtask # binary and ~13s compiling its test binary to do a few seconds of file walking, # and all seven shared one cache key — so they raced, and six of every seven -# cache saves were discarded. One job compiles xtask once and runs all eight +# cache saves were discarded. One job compiles xtask once and runs all nine # gates plus the whole fixture suite. # # `check-all-source-gates` runs every gate before reporting, so one job still diff --git a/Cargo.lock b/Cargo.lock index 3dc3d21e0..f6d782ed4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4666,6 +4666,7 @@ dependencies = [ "streamlib-surface-adapter", "streamlib-surface-client", "tatolab-vulkanalia", + "tempfile", "thiserror 2.0.18", "tracing", "tracing-subscriber", @@ -4966,6 +4967,7 @@ version = "0.17.1" dependencies = [ "libc", "serde_json", + "tempfile", ] [[package]] diff --git a/adapters/streamlib-adapter-cuda/Cargo.toml b/adapters/streamlib-adapter-cuda/Cargo.toml index ceca77459..cc0c09d19 100644 --- a/adapters/streamlib-adapter-cuda/Cargo.toml +++ b/adapters/streamlib-adapter-cuda/Cargo.toml @@ -62,6 +62,9 @@ serial_test = "3.2" # The OPAQUE_FD round-trip tests drive the surface-share daemon directly # so they exercise the same wire format helper processes consume. serde_json.workspace = true +# Each test socket gets a directory of its own: the kernel answers uniqueness, +# and the socket is unlinked on drop instead of littering /tmp. +tempfile.workspace = true [[test]] name = "conformance" diff --git a/adapters/streamlib-adapter-cuda/tests/opaque_fd_consumer_rhi_round_trip.rs b/adapters/streamlib-adapter-cuda/tests/opaque_fd_consumer_rhi_round_trip.rs index cb2b0d608..73a6272ba 100644 --- a/adapters/streamlib-adapter-cuda/tests/opaque_fd_consumer_rhi_round_trip.rs +++ b/adapters/streamlib-adapter-cuda/tests/opaque_fd_consumer_rhi_round_trip.rs @@ -61,6 +61,7 @@ use streamlib_surface_adapter::{ use streamlib_surface_client::{ MAX_DMA_BUF_PLANES, connect_to_surface_share_socket, send_request_with_fds, }; +use tempfile::TempDir; const W: u32 = 32; const H: u32 = 32; @@ -68,14 +69,13 @@ const BPP: u32 = 4; const SURFACE_ID: &str = "stage6-opaque-fd-round-trip"; const RUNTIME_ID: &str = "stage6-test-runtime"; -fn tmp_socket_path() -> PathBuf { - let mut p = std::env::temp_dir(); - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - p.push(format!("streamlib-stage6-{nanos}.sock")); - p +/// A socket path in a directory of its own, so uniqueness is the kernel's answer. +/// The returned [`TempDir`] owns that lifetime — dropping it early removes the +/// socket out from under the running daemon. +fn tmp_socket_path() -> (TempDir, PathBuf) { + let dir = TempDir::new().expect("temp dir for surface-share socket"); + let path = dir.path().join("stage6.sock"); + (dir, path) } #[test] @@ -148,7 +148,7 @@ fn opaque_fd_chain_host_export_to_consumer_import_to_adapter_acquire() { // ── Phase 2: stand up surface-share daemon ────────────────────────── let state = SurfaceShareState::new(); - let socket_path = tmp_socket_path(); + let (_socket_dir, socket_path) = tmp_socket_path(); let mut service = UnixSocketSurfaceService::new(state, socket_path.clone()); service.start().expect("surface-share service start"); std::thread::sleep(Duration::from_millis(50)); diff --git a/docs/plan/changes/one-monotonic-clock.md b/docs/plan/changes/one-monotonic-clock.md index bc51967d6..40751dda8 100644 --- a/docs/plan/changes/one-monotonic-clock.md +++ b/docs/plan/changes/one-monotonic-clock.md @@ -125,14 +125,30 @@ Bare patterns — the ship gate greps each line verbatim as a fixed string. ## ADDED -- ADDED: `cargo xtask check-clock-usage` + its workflow — the mechanical guard that keeps +- ADDED: `cargo xtask check-clock-usage` ~~+ its workflow~~ — the mechanical guard that keeps the wall-clock list from growing by accident. It bans wall-clock reads - (`SystemTime::now`, `chrono::Utc::now`, `time.time_ns`, `Date.now`) outside an explicit + (`SystemTime::now`, `chrono::Utc::now`, `time.time_ns`, ~~`Date.now`~~) outside an explicit file allowlist holding exactly the four surfaces above, in the shape of the existing `lint-logging` / `check-no-escalate-in-lifecycle` checks. Recon found a set of `SystemTime` uses that mint *unique names*, not timestamps (`vulkan_graphics_kernel.rs:3119`, `surface_share/unix_socket_service.rs:977`, several test helpers); those either move to a counter/uuid or join the allowlist with a stated reason — final at implementation. + + > Landed 2026-08-12 (#1728). **No workflow** — #1857 consolidated the per-gate workflows, + > so the guard is an entry in `ALL_SOURCE_WALKING_GATES` run by the existing `source-gates` + > job. **No `Date.now` arm** — the Deno SDK is deleted and the engine tree holds no + > `.ts`/`.js` source, so that scan root does not exist; Rust and Python only. + > + > The unique-name uses are **all converted, none allowlisted** — 16 sites across 15 files. + > The allowlist is per-file, so an entry for `iceoryx2/output.rs` or `thread_runner.rs` + > would have licensed a wall-clock read in the exact data-plane files the guard exists to + > protect. `mint_machine_global_unique_name_suffix()` (`core/machine_global_unique_name.rs`) + > is the one primitive for machine-global namespaces, built on the `uuid` the engine already + > links. Every test *socket* path takes a `TempDir` instead — `sun_path` is 108 bytes, and a + > unique name in a shared temp dir spends most of that budget before `TMPDIR` is accounted + > for. The api-server's name seed takes the OS CSPRNG, and the dead + > `apple/time.rs::system_time_to_ns` was deleted. The permitted list holds exactly the four surfaces, as five files + > (`host_ts` has two readers). - ADDED: an epoch-parity test asserting `MediaClock::now()` and the wheel's `monotonic_now_ns` land in the same domain as a directly-read `clock_gettime(CLOCK_MONOTONIC)`. The natural home is the existing cross-process gate @@ -140,6 +156,12 @@ Bare patterns — the ship gate greps each line verbatim as a fixed string. `MediaClock` — but its Python/Deno subprocess arms are doomed with the ripout, so the surviving shape is host + wheel. + > Landed as the anticipated host + wheel pair, not one cross-process test: the Rust half is + > `core/media_clock.rs::now_lands_in_the_kernel_monotonic_domain` (#1725), which brackets + > `MediaClock::now()` between two direct `clock_gettime(CLOCK_MONOTONIC)` reads, and the + > Python half is `tests/test_clock_and_log.py::test_monotonic_now_ns_reads_the_kernel_monotonic_clock`. + > `polyglot_linux_monotonic_clock_parity.rs` went with the ripout. + ## Notes (not tickets) - No test anywhere asserts a frame timestamp is small or near zero, so the epoch flip diff --git a/runtime/streamlib-api-server/processors/api_server.rs b/runtime/streamlib-api-server/processors/api_server.rs index 5046b7f6d..78ca70735 100644 --- a/runtime/streamlib-api-server/processors/api_server.rs +++ b/runtime/streamlib-api-server/processors/api_server.rs @@ -89,16 +89,21 @@ const NOUNS: &[&str] = &[ fn generate_runtime_name() -> String { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; - use std::time::{SystemTime, UNIX_EPOCH}; - // Use time + pid for randomness without adding fastrand dependency - let seed = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos() as u64 - ^ (std::process::id() as u64); + // The OS CSPRNG this crate already links for bearer tokens. + let mut seed_bytes = [0u8; 8]; + if let Err(csprng_unavailable) = getrandom::getrandom(&mut seed_bytes) { + // A display name is not worth failing a runtime over, and the pid still + // separates concurrent runtimes on one host. + tracing::warn!("OS CSPRNG unavailable for runtime naming: {csprng_unavailable}"); + seed_bytes = (std::process::id() as u64).to_ne_bytes(); + } + + // The two indices read disjoint halves, so the seed has to be diffused + // across all 64 bits first: a pid is well under 2^32, which would otherwise + // leave the high half zero and pin every degraded runtime to one noun. let mut hasher = DefaultHasher::new(); - seed.hash(&mut hasher); + seed_bytes.hash(&mut hasher); let hash = hasher.finish(); let adj = ADJECTIVES[(hash as usize) % ADJECTIVES.len()]; diff --git a/runtime/streamlib-engine/benches/output_writer_ffi_hop.rs b/runtime/streamlib-engine/benches/output_writer_ffi_hop.rs index bd34f71c7..dbdf4e331 100644 --- a/runtime/streamlib-engine/benches/output_writer_ffi_hop.rs +++ b/runtime/streamlib-engine/benches/output_writer_ffi_hop.rs @@ -43,6 +43,7 @@ use std::sync::Arc; use criterion::{Criterion, black_box, criterion_group, criterion_main}; use iceoryx2::prelude::*; +use streamlib_engine::core::machine_global_unique_name::mint_machine_global_unique_name_suffix; use streamlib_engine::iceoryx2::{ ChannelEgressConfig, ChannelTrustTier, OutputWriter, OutputWriterInner, TRUSTED_CHANNEL_PAYLOAD_CEILING_BYTES, @@ -52,13 +53,8 @@ use streamlib_engine::iceoryx2::{ /// don't collide on iceoryx2's machine-global `/dev/shm` namespace. fn unique_suffix(tag: &str) -> String { format!( - "bench/output_writer/{}/{}/{}", - tag, - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() + "bench/output_writer/{tag}/{}", + mint_machine_global_unique_name_suffix() ) } diff --git a/runtime/streamlib-engine/src/apple/time.rs b/runtime/streamlib-engine/src/apple/time.rs index 04a2e899e..22238912d 100644 --- a/runtime/streamlib-engine/src/apple/time.rs +++ b/runtime/streamlib-engine/src/apple/time.rs @@ -5,8 +5,6 @@ // Review if these high-precision timing functions are needed or can be removed #![allow(dead_code)] -use std::time::{SystemTime, UNIX_EPOCH}; - #[link(name = "CoreServices", kind = "framework")] extern "C" { fn mach_absolute_time() -> u64; @@ -34,10 +32,6 @@ pub fn mach_now_ns() -> i64 { } } -pub fn system_time_to_ns(time: SystemTime) -> i64 { - time.duration_since(UNIX_EPOCH).unwrap().as_nanos() as i64 -} - #[cfg(test)] mod tests { use super::*; @@ -53,11 +47,4 @@ mod tests { let elapsed = ns2 - ns1; assert!((10_000_000..20_000_000).contains(&elapsed)); } - - #[test] - fn test_system_time_conversion() { - let now = SystemTime::now(); - let ns = system_time_to_ns(now); - assert!(ns > 0); - } } diff --git a/runtime/streamlib-engine/src/core/compiler/compiler_ops/open_iceoryx2_service_op.rs b/runtime/streamlib-engine/src/core/compiler/compiler_ops/open_iceoryx2_service_op.rs index 42388fc1f..d2dd212d4 100644 --- a/runtime/streamlib-engine/src/core/compiler/compiler_ops/open_iceoryx2_service_op.rs +++ b/runtime/streamlib-engine/src/core/compiler/compiler_ops/open_iceoryx2_service_op.rs @@ -750,6 +750,7 @@ mod tests { use super::*; use crate::core::execution::ExecutionConfig; use crate::core::graph::{InputLinkPortRef, OutputLinkPortRef}; + use crate::core::machine_global_unique_name::mint_machine_global_unique_name_suffix; use crate::core::processors::{DynGeneratedProcessor, PROCESSOR_REGISTRY, ProcessorSpec}; use crate::core::{ProcessorDescriptor, RuntimeContextFullAccess, RuntimeContextLimitedAccess}; @@ -984,13 +985,8 @@ mod tests { /// the stale service, not as a clean failure. fn unique_service_name(tag: &str) -> String { format!( - "test/wiring/{}/{}/{}", - tag, - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() + "test/wiring/{tag}/{}", + mint_machine_global_unique_name_suffix() ) } diff --git a/runtime/streamlib-engine/src/core/execution/thread_runner.rs b/runtime/streamlib-engine/src/core/execution/thread_runner.rs index 9bedb8af8..687609a35 100644 --- a/runtime/streamlib-engine/src/core/execution/thread_runner.rs +++ b/runtime/streamlib-engine/src/core/execution/thread_runner.rs @@ -576,18 +576,14 @@ fn dispatch_on_resume( #[cfg(all(test, target_os = "linux"))] mod tests { use super::*; + use crate::core::machine_global_unique_name::mint_machine_global_unique_name_suffix; use iceoryx2::prelude::*; use std::os::fd::{AsRawFd, FromRawFd}; fn unique_suffix(tag: &str) -> String { format!( - "test/runner/{}/{}/{}", - tag, - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() + "test/runner/{tag}/{}", + mint_machine_global_unique_name_suffix() ) } diff --git a/runtime/streamlib-engine/src/core/logging/event.rs b/runtime/streamlib-engine/src/core/logging/event.rs index d5676f8f1..a72652f14 100644 --- a/runtime/streamlib-engine/src/core/logging/event.rs +++ b/runtime/streamlib-engine/src/core/logging/event.rs @@ -79,8 +79,13 @@ pub struct RuntimeLogEvent { /// Schema version of this record. Bumped on breaking changes. pub schema_version: u32, - /// Host monotonic receipt timestamp, nanoseconds since UNIX epoch. + /// Host receipt wall-clock timestamp, nanoseconds since the UNIX epoch. /// Authoritative sort key across the merged stream. + /// + /// Wall clock, not monotonic: a log record's job is correlating StreamLib + /// with the outside world and with other hosts' logs, which monotonic time + /// cannot do. Never compare or subtract this against a media timestamp — + /// they share a unit and are different quantities. pub host_ts: u64, /// Unique runtime identifier (from [`RuntimeUniqueId`]). diff --git a/runtime/streamlib-engine/src/core/machine_global_unique_name.rs b/runtime/streamlib-engine/src/core/machine_global_unique_name.rs new file mode 100644 index 000000000..19d469c2a --- /dev/null +++ b/runtime/streamlib-engine/src/core/machine_global_unique_name.rs @@ -0,0 +1,82 @@ +// Copyright (c) 2025 Jonathan Fontanez +// SPDX-License-Identifier: BUSL-1.1 + +//! Unique-name minting for machine-global namespaces. +//! +//! iceoryx2 service names live in `/dev/shm`, which is machine-global and +//! outlives the process that created an entry. A name that collides with a +//! concurrent process — or with a stale entry an earlier run left behind after +//! the pid was recycled — surfaces as `DoesNotSupportRequestedMinBufferSize` +//! against the wrong service, not as a clean failure. +//! +//! Nothing minted here is a timestamp. A v4 UUID's 122 random bits make a +//! collision between two mints vanishingly improbable — not impossible, but far +//! below the rate at which the namespace itself fails — across processes, runs +//! and reboots alike, so no clock is read and no counter is kept. The version +//! matters: `now_v7` and friends are timestamp-based, and reaching for one would +//! put a wall-clock read back into this module. The pid prefix is diagnostic — +//! it is what lets someone reading a stale `/dev/shm` entry or a leftover `/tmp` +//! file name the process that left it. +//! +//! The suffix carries no separator a path or a file name would reject, so the +//! caller composes it into whatever naming convention its namespace uses. + +use uuid::Uuid; + +/// A `-` suffix that no concurrent process, and no earlier run, +/// will collide with short of exhausting 122 bits of randomness. +pub fn mint_machine_global_unique_name_suffix() -> String { + format!("{}-{}", std::process::id(), Uuid::new_v4()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn successive_mints_never_repeat() { + let minted: HashSet = (0..10_000) + .map(|_| mint_machine_global_unique_name_suffix()) + .collect(); + assert_eq!(minted.len(), 10_000, "two mints collided"); + } + + /// The pid is a fixed prefix within one process, so distinctness can only + /// come from the UUID — this fails if that component is ever dropped or + /// made constant, which a whole-string equality test does not catch. + #[test] + fn the_uuid_component_is_what_differs_between_two_mints() { + let first = mint_machine_global_unique_name_suffix(); + let second = mint_machine_global_unique_name_suffix(); + + let pid_prefix = format!("{}-", std::process::id()); + let first_uuid = first.strip_prefix(&pid_prefix).expect(&first); + let second_uuid = second.strip_prefix(&pid_prefix).expect(&second); + + assert_ne!(first_uuid, second_uuid); + + let parsed = Uuid::parse_str(first_uuid).expect(&first); + assert_eq!( + parsed.get_version_num(), + 4, + "randomness is the whole contract: the timestamp-based versions would put a \ + wall-clock read back into this mint. The `uuid` dependency enabling only `v4` \ + is the first guard and the compiler enforces it; this is the one that survives \ + another feature being switched on for an unrelated reason" + ); + } + + #[test] + fn carries_no_separator_a_path_or_file_name_would_reject() { + let minted = mint_machine_global_unique_name_suffix(); + assert!( + !minted.contains(['/', '\\', '.', ' ']), + "callers compose this into iceoryx2 service paths and into file names: {minted}" + ); + assert!( + minted.starts_with(&format!("{}-", std::process::id())), + "a stale entry names the process that left it: {minted}" + ); + } +} diff --git a/runtime/streamlib-engine/src/core/mod.rs b/runtime/streamlib-engine/src/core/mod.rs index 77db41bed..cc9c1384b 100644 --- a/runtime/streamlib-engine/src/core/mod.rs +++ b/runtime/streamlib-engine/src/core/mod.rs @@ -33,6 +33,7 @@ pub mod execution; pub mod graph; pub mod graph_snapshot; pub mod json_schema; +pub mod machine_global_unique_name; pub mod media_clock; pub mod prelude; pub mod processors; diff --git a/runtime/streamlib-engine/src/core/pubsub/integration_tests.rs b/runtime/streamlib-engine/src/core/pubsub/integration_tests.rs index 49f184c86..21087b4d1 100644 --- a/runtime/streamlib-engine/src/core/pubsub/integration_tests.rs +++ b/runtime/streamlib-engine/src/core/pubsub/integration_tests.rs @@ -30,6 +30,7 @@ use super::events::{ Event, EventListener, KeyCode, KeyState, Modifiers, MouseButton, MouseState, ProcessorEvent, RuntimeEvent, topics, }; +use crate::core::machine_global_unique_name::mint_machine_global_unique_name_suffix; use crate::iceoryx2::{Iceoryx2Node, MAX_EVENT_PAYLOAD_SIZE}; use parking_lot::Mutex; use std::sync::Arc; @@ -181,7 +182,10 @@ fn test_iceoryx2_direct_delivery() { // Bypass PubSub layer entirely — verify iceoryx2 pub/sub works in-process let node = Iceoryx2Node::new().expect("Failed to create iceoryx2 node"); - let service_name = format!("streamlib/diag-{}/events/test", uuid::Uuid::new_v4()); + let service_name = format!( + "streamlib/diag-{}/events/test", + mint_machine_global_unique_name_suffix() + ); // Create subscriber FIRST (must exist before publisher sends) let sub_service = node diff --git a/runtime/streamlib-engine/src/core/runtime/tap.rs b/runtime/streamlib-engine/src/core/runtime/tap.rs index 02baef6ea..db058c9fa 100644 --- a/runtime/streamlib-engine/src/core/runtime/tap.rs +++ b/runtime/streamlib-engine/src/core/runtime/tap.rs @@ -321,19 +321,15 @@ mod tests { use iceoryx2::prelude::*; use streamlib_ipc_types::RESERVED_TAP_SUBSCRIBER_SLOTS_PER_CHANNEL; + use crate::core::machine_global_unique_name::mint_machine_global_unique_name_suffix; use crate::iceoryx2::{FRAME_HEADER_SIZE, Iceoryx2Node, Iceoryx2Service}; const RING_DEPTH: usize = 16; fn unique_channel_name(tag: &str) -> String { format!( - "test/tap/{}/{}/{}", - tag, - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() + "test/tap/{tag}/{}", + mint_machine_global_unique_name_suffix() ) } diff --git a/runtime/streamlib-engine/src/iceoryx2/input.rs b/runtime/streamlib-engine/src/iceoryx2/input.rs index 060b1f450..e329c54db 100644 --- a/runtime/streamlib-engine/src/iceoryx2/input.rs +++ b/runtime/streamlib-engine/src/iceoryx2/input.rs @@ -683,17 +683,13 @@ impl Drop for InputMailboxes { #[cfg(test)] mod tests { use super::*; + use crate::core::machine_global_unique_name::mint_machine_global_unique_name_suffix; use crate::iceoryx2::PortKey; fn unique_suffix(tag: &str) -> String { format!( - "test/input/{}/{}/{}", - tag, - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() + "test/input/{tag}/{}", + mint_machine_global_unique_name_suffix() ) } diff --git a/runtime/streamlib-engine/src/iceoryx2/node.rs b/runtime/streamlib-engine/src/iceoryx2/node.rs index 76ea6db31..6ed0907d5 100644 --- a/runtime/streamlib-engine/src/iceoryx2/node.rs +++ b/runtime/streamlib-engine/src/iceoryx2/node.rs @@ -287,16 +287,12 @@ impl Iceoryx2EventService { #[cfg(test)] mod tests { use super::*; + use crate::core::machine_global_unique_name::mint_machine_global_unique_name_suffix; fn unique_service_name(tag: &str) -> String { format!( - "test/node/{}/{}/{}", - tag, - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() + "test/node/{tag}/{}", + mint_machine_global_unique_name_suffix() ) } diff --git a/runtime/streamlib-engine/src/iceoryx2/output.rs b/runtime/streamlib-engine/src/iceoryx2/output.rs index 8ffe46087..7edf7f3a9 100644 --- a/runtime/streamlib-engine/src/iceoryx2/output.rs +++ b/runtime/streamlib-engine/src/iceoryx2/output.rs @@ -498,18 +498,14 @@ impl Drop for OutputWriter { #[cfg(test)] mod tests { use super::*; + use crate::core::machine_global_unique_name::mint_machine_global_unique_name_suffix; /// Each test gets a unique service-name prefix so parallel invocations /// don't collide on iceoryx2's machine-global `/dev/shm` namespace. fn unique_suffix(tag: &str) -> String { format!( - "test/output/{}/{}/{}", - tag, - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() + "test/output/{tag}/{}", + mint_machine_global_unique_name_suffix() ) } diff --git a/runtime/streamlib-engine/src/linux/surface_share/unix_socket_service.rs b/runtime/streamlib-engine/src/linux/surface_share/unix_socket_service.rs index 85ba7c782..3517c6b78 100644 --- a/runtime/streamlib-engine/src/linux/surface_share/unix_socket_service.rs +++ b/runtime/streamlib-engine/src/linux/surface_share/unix_socket_service.rs @@ -944,6 +944,7 @@ mod tests { use super::*; use std::os::unix::io::FromRawFd; use streamlib_surface_client::{connect_to_surface_share_socket, send_request_with_fds}; + use tempfile::TempDir; fn make_memfd_with(contents: &[u8]) -> RawFd { use std::io::{Seek, SeekFrom, Write}; @@ -972,24 +973,20 @@ mod tests { buf } - fn tmp_socket_path() -> PathBuf { - let mut p = std::env::temp_dir(); - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - p.push(format!( - "streamlib-runtime-surface-share-test-{}-{}.sock", - std::process::id(), - nanos - )); - p + /// A socket path in a directory of its own. `sun_path` is 108 bytes and a + /// unique name in a shared temp dir spends most of that budget before + /// `TMPDIR` is even accounted for; a private dir keeps the path short and + /// unlinks it on drop. The returned [`TempDir`] owns that lifetime. + fn tmp_socket_path() -> (TempDir, PathBuf) { + let dir = TempDir::new().expect("temp dir for test socket"); + let path = dir.path().join("surface-share.sock"); + (dir, path) } #[test] fn check_in_check_out_roundtrip_preserves_fd_content() { let state = SurfaceShareState::new(); - let socket_path = tmp_socket_path(); + let (_socket_dir, socket_path) = tmp_socket_path(); let mut service = UnixSocketSurfaceService::new(state, socket_path.clone()); service.start().expect("service start"); @@ -1058,7 +1055,7 @@ mod tests { #[test] fn check_out_unknown_surface_id_returns_error_no_fd() { let state = SurfaceShareState::new(); - let socket_path = tmp_socket_path(); + let (_socket_dir, socket_path) = tmp_socket_path(); let mut service = UnixSocketSurfaceService::new(state, socket_path.clone()); service.start().expect("service start"); std::thread::sleep(std::time::Duration::from_millis(50)); @@ -1084,7 +1081,7 @@ mod tests { #[test] fn check_in_check_out_multi_fd_roundtrip() { let state = SurfaceShareState::new(); - let socket_path = tmp_socket_path(); + let (_socket_dir, socket_path) = tmp_socket_path(); let mut service = UnixSocketSurfaceService::new(state, socket_path.clone()); service.start().expect("service start"); std::thread::sleep(std::time::Duration::from_millis(50)); @@ -1182,7 +1179,7 @@ mod tests { #[test] fn handle_type_round_trips_explicit_opaque_fd_and_default_dma_buf() { let state = SurfaceShareState::new(); - let socket_path = tmp_socket_path(); + let (_socket_dir, socket_path) = tmp_socket_path(); let mut service = UnixSocketSurfaceService::new(state, socket_path.clone()); service.start().expect("service start"); std::thread::sleep(std::time::Duration::from_millis(50)); @@ -1301,7 +1298,7 @@ mod tests { #[test] fn drm_format_modifier_and_strides_round_trip() { let state = SurfaceShareState::new(); - let socket_path = tmp_socket_path(); + let (_socket_dir, socket_path) = tmp_socket_path(); let mut service = UnixSocketSurfaceService::new(state, socket_path.clone()); service.start().expect("service start"); std::thread::sleep(std::time::Duration::from_millis(50)); @@ -1385,7 +1382,7 @@ mod tests { #[test] fn same_process_disconnect_does_not_trigger_watchdog() { let state = SurfaceShareState::new(); - let socket_path = tmp_socket_path(); + let (_socket_dir, socket_path) = tmp_socket_path(); let mut service = UnixSocketSurfaceService::new(state.clone(), socket_path.clone()); service.start().expect("service start"); std::thread::sleep(std::time::Duration::from_millis(50)); @@ -1516,7 +1513,7 @@ mod tests { #[test] fn oversize_fd_vec_rejected() { let state = SurfaceShareState::new(); - let socket_path = tmp_socket_path(); + let (_socket_dir, socket_path) = tmp_socket_path(); let mut service = UnixSocketSurfaceService::new(state, socket_path.clone()); service.start().expect("service start"); std::thread::sleep(std::time::Duration::from_millis(50)); @@ -1569,7 +1566,7 @@ mod tests { // also work but introduces ordering noise; the unit-level helper // gate is the narrowest test. let state = SurfaceShareState::new(); - let socket_path = tmp_socket_path(); + let (_socket_dir, socket_path) = tmp_socket_path(); let mut service = UnixSocketSurfaceService::new(state, socket_path.clone()); service.start().expect("service start"); std::thread::sleep(std::time::Duration::from_millis(50)); @@ -1630,7 +1627,7 @@ mod tests { #[test] fn vk_image_create_info_round_trip_through_register_lookup() { let state = SurfaceShareState::new(); - let socket_path = tmp_socket_path(); + let (_socket_dir, socket_path) = tmp_socket_path(); let mut service = UnixSocketSurfaceService::new(state, socket_path.clone()); service.start().expect("service start"); std::thread::sleep(std::time::Duration::from_millis(50)); @@ -1743,7 +1740,7 @@ mod tests { #[test] fn vk_image_create_info_absent_fields_surface_documented_defaults() { let state = SurfaceShareState::new(); - let socket_path = tmp_socket_path(); + let (_socket_dir, socket_path) = tmp_socket_path(); let mut service = UnixSocketSurfaceService::new(state, socket_path.clone()); service.start().expect("service start"); std::thread::sleep(std::time::Duration::from_millis(50)); @@ -1849,7 +1846,7 @@ mod tests { fn current_image_layout_round_trip_through_register_lookup_update() { // VK_IMAGE_LAYOUT_GENERAL = 1, SHADER_READ_ONLY_OPTIMAL = 5. let state = SurfaceShareState::new(); - let socket_path = tmp_socket_path(); + let (_socket_dir, socket_path) = tmp_socket_path(); let mut service = UnixSocketSurfaceService::new(state, socket_path.clone()); service.start().expect("service start"); std::thread::sleep(std::time::Duration::from_millis(50)); diff --git a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_compute_kernel.rs b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_compute_kernel.rs index 64f2c7c5e..8dc82230a 100644 --- a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_compute_kernel.rs +++ b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_compute_kernel.rs @@ -34,6 +34,7 @@ use crate::core::{Error, Result}; pub const PIPELINE_CACHE_DIR_ENV: &str = "STREAMLIB_PIPELINE_CACHE_DIR"; use super::HostVulkanDevice; +use crate::core::machine_global_unique_name::mint_machine_global_unique_name_suffix; /// One compute kernel: shader pipeline + descriptor set + per-dispatch primitives. /// @@ -1504,17 +1505,10 @@ fn atomic_write_pipeline_cache(path: &Path, data: &[u8]) -> std::io::Result<()> std::fs::create_dir_all(parent)?; } // Same-directory temp file → POSIX rename is atomic on the same - // filesystem. PID + nanos disambiguates concurrent writers; the loser - // of the race just overwrites the winner, which is fine — both blobs - // are equally valid and the driver re-validates on next load. - let suffix = format!( - "tmp.{}.{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - ); + // filesystem. The loser of a race just overwrites the winner, which is + // fine — both blobs are equally valid and the driver re-validates on + // next load. + let suffix = format!("tmp.{}", mint_machine_global_unique_name_suffix()); let mut tmp = path.to_path_buf(); tmp.set_extension(format!("bin.{suffix}")); std::fs::write(&tmp, data)?; @@ -2005,14 +1999,9 @@ mod tests { use serial_test::serial; fn unique_cache_dir(label: &str) -> PathBuf { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); std::env::temp_dir().join(format!( - "streamlib-pipeline-cache-{label}-{}-{}", - std::process::id(), - nanos + "streamlib-pipeline-cache-{label}-{}", + mint_machine_global_unique_name_suffix() )) } diff --git a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_graphics_kernel.rs b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_graphics_kernel.rs index 8be7b5ea0..7780baa2f 100644 --- a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_graphics_kernel.rs +++ b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_graphics_kernel.rs @@ -55,6 +55,7 @@ use crate::core::rhi::{ use crate::core::{Error, Result}; use super::HostVulkanDevice; +use crate::core::machine_global_unique_name::mint_machine_global_unique_name_suffix; /// Env var that overrides the default pipeline-cache directory. Shared with /// [`super::vulkan_compute_kernel`] so cached pipelines for both kernel @@ -2273,14 +2274,7 @@ fn atomic_write_pipeline_cache(path: &Path, data: &[u8]) -> std::io::Result<()> if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - let suffix = format!( - "tmp.{}.{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - ); + let suffix = format!("tmp.{}", mint_machine_global_unique_name_suffix()); let mut tmp = path.to_path_buf(); tmp.set_extension(format!("gfx.bin.{suffix}")); std::fs::write(&tmp, data)?; diff --git a/runtime/streamlib-engine/tests/surface_share_subprocess_crash.rs b/runtime/streamlib-engine/tests/surface_share_subprocess_crash.rs index 8c110166b..b19e744c2 100644 --- a/runtime/streamlib-engine/tests/surface_share_subprocess_crash.rs +++ b/runtime/streamlib-engine/tests/surface_share_subprocess_crash.rs @@ -21,6 +21,7 @@ use std::time::{Duration, Instant}; use streamlib_engine::linux_surface_share::{SurfaceShareState, UnixSocketSurfaceService}; use streamlib_surface_adapter::testing::{CrashTiming, SubprocessCrashHarness}; use streamlib_surface_client::{connect_to_surface_share_socket, send_request_with_fds}; +use tempfile::TempDir; /// Locate the test helper binary built by `cargo test` under `target//`. fn locate_helper_binary() -> PathBuf { @@ -45,19 +46,14 @@ fn locate_helper_binary() -> PathBuf { panic!("surface_share_crash_helper binary not built"); } -fn tmp_socket_path(label: &str) -> PathBuf { - let mut p = std::env::temp_dir(); - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - p.push(format!( - "streamlib-surface-share-watchdog-{}-{}-{}.sock", - label, - std::process::id(), - nanos - )); - p +/// A socket path in a directory of its own. `sun_path` is 108 bytes and a unique +/// name in a shared temp dir spends most of that budget before `TMPDIR` is even +/// accounted for; a private dir keeps the path short and unlinks it on drop. The +/// returned [`TempDir`] owns that lifetime. +fn tmp_socket_path(label: &str) -> (TempDir, PathBuf) { + let dir = TempDir::new().expect("temp dir for test socket"); + let path = dir.path().join(format!("{label}.sock")); + (dir, path) } /// Live fd count for the current process — `/proc/self/fd` entries. @@ -91,7 +87,7 @@ fn watchdog_cleans_up_surface_after_subprocess_sigkill() { assert!(helper.exists(), "helper binary missing: {:?}", helper); let state = SurfaceShareState::new(); - let socket_path = tmp_socket_path("crash"); + let (_socket_dir, socket_path) = tmp_socket_path("crash"); let mut service = UnixSocketSurfaceService::new(state.clone(), socket_path.clone()); service.start().expect("service start"); // Give the listener thread a tick to bind before any subprocess connects. diff --git a/runtime/streamlib-surface-client/Cargo.toml b/runtime/streamlib-surface-client/Cargo.toml index 1204fc774..b3ab11aea 100644 --- a/runtime/streamlib-surface-client/Cargo.toml +++ b/runtime/streamlib-surface-client/Cargo.toml @@ -18,5 +18,10 @@ path = "src/lib.rs" libc.workspace = true serde_json.workspace = true +[target.'cfg(target_os = "linux")'.dev-dependencies] +# Each test socket gets a directory of its own: the kernel answers uniqueness, +# and the socket is unlinked on drop instead of littering /tmp. +tempfile.workspace = true + [lints] workspace = true diff --git a/runtime/streamlib-surface-client/src/linux.rs b/runtime/streamlib-surface-client/src/linux.rs index ecae25e83..bf5af0410 100644 --- a/runtime/streamlib-surface-client/src/linux.rs +++ b/runtime/streamlib-surface-client/src/linux.rs @@ -311,20 +311,16 @@ mod tests { use std::os::unix::net::{UnixListener, UnixStream}; use std::path::PathBuf; - /// Build a temp socket path unique to this process + monotonic nanos. - fn tmp_socket_path(label: &str) -> PathBuf { - let mut p = std::env::temp_dir(); - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - p.push(format!( - "streamlib-surface-client-test-{}-{}-{}.sock", - label, - std::process::id(), - nanos - )); - p + use tempfile::TempDir; + + /// A socket path in a directory of its own, so uniqueness is the kernel's + /// answer and the socket is unlinked on drop. The returned [`TempDir`] owns + /// that lifetime — dropping it early removes the socket out from under the + /// raw `UnixListener` these tests bind, which has no owner to unlink it. + fn tmp_socket_path(label: &str) -> (TempDir, PathBuf) { + let dir = TempDir::new().expect("temp dir for test socket"); + let path = dir.path().join(format!("{label}.sock")); + (dir, path) } /// Create an anonymous kernel fd (memfd) seeded with `contents`. @@ -359,7 +355,7 @@ mod tests { /// subprocess consumer — the protocol has no version handshake. #[test] fn wire_format_is_big_endian_u32_length_prefix_plus_payload() { - let socket_path = tmp_socket_path("wire-format"); + let (_socket_dir, socket_path) = tmp_socket_path("wire-format"); let listener = UnixListener::bind(&socket_path).expect("bind"); let server = std::thread::spawn(move || { @@ -390,7 +386,7 @@ mod tests { /// protects the regression gate. #[test] fn send_recv_preserves_fd_content_via_scm_rights() { - let socket_path = tmp_socket_path("fd-roundtrip"); + let (_socket_dir, socket_path) = tmp_socket_path("fd-roundtrip"); let listener = UnixListener::bind(&socket_path).expect("bind"); let server = std::thread::spawn(move || { @@ -436,7 +432,7 @@ mod tests { /// consumer has to pair an fd with its `plane_sizes[i]`/`plane_offsets[i]`. #[test] fn send_recv_preserves_multi_fd_order_and_content_via_scm_rights() { - let socket_path = tmp_socket_path("multi-fd-roundtrip"); + let (_socket_dir, socket_path) = tmp_socket_path("multi-fd-roundtrip"); let listener = UnixListener::bind(&socket_path).expect("bind"); let server = std::thread::spawn(move || { @@ -498,7 +494,7 @@ mod tests { /// budget is `MAX_DMA_BUF_PLANES` plane fds + 1 optional sync-fd slot. #[test] fn send_rejects_oversize_fd_vec_without_closing_caller_fds() { - let socket_path = tmp_socket_path("oversize"); + let (_socket_dir, socket_path) = tmp_socket_path("oversize"); let listener = UnixListener::bind(&socket_path).expect("bind"); let client = UnixStream::connect(&socket_path).expect("connect"); let _accepted = listener.accept().expect("accept"); @@ -527,7 +523,7 @@ mod tests { /// see identical serialization + deserialization behavior. #[test] fn send_request_round_trips_json_and_returns_response_fds() { - let socket_path = tmp_socket_path("send-request"); + let (_socket_dir, socket_path) = tmp_socket_path("send-request"); let listener = UnixListener::bind(&socket_path).expect("bind"); let server = std::thread::spawn(move || { @@ -592,7 +588,7 @@ mod tests { /// (buffer overrun on send, `MSG_CTRUNC` error on recv). #[test] fn send_recv_round_trip_at_max_scm_rights_cap() { - let socket_path = tmp_socket_path("cap-edge"); + let (_socket_dir, socket_path) = tmp_socket_path("cap-edge"); let listener = UnixListener::bind(&socket_path).expect("bind"); let server = std::thread::spawn(move || { diff --git a/xtask/src/check_clock_usage.rs b/xtask/src/check_clock_usage.rs new file mode 100644 index 000000000..96323a1aa --- /dev/null +++ b/xtask/src/check_clock_usage.rs @@ -0,0 +1,812 @@ +// Copyright (c) 2025 Jonathan Fontanez +// SPDX-License-Identifier: BUSL-1.1 + +//! Bans wall-clock reads outside the four observability surfaces the plan +//! permits them on (`docs/plan/ARCHITECTURE.md` §Media I/O +//! `[one-monotonic-clock]`; rationale in `docs/decisions/one-monotonic-clock.md`). +//! +//! Monotonic is the only legal clock on the data plane. A wall-clock value and a +//! media timestamp share a unit and are different quantities, so a subtraction +//! across them is always a bug — and it is an easy bug to write, because +//! `SystemTime::now()` is the reflexive spelling for "what time is it". The four +//! surfaces that keep wall clock correlate StreamLib with the outside world and +//! with other hosts' logs, a job monotonic time cannot do. +//! +//! There is no per-line pragma and no opt-out attribute. The file allowlist is +//! the only way past this gate, every entry names one of exactly four +//! [`ObservabilitySurface`] variants, and a fifth surface is a plan change — so +//! widening the list means adding a variant, which no one does by accident. +//! +//! Cheap substring scan, no `syn` and no compile. Whole-line `//` and `#` +//! comments and Python triple-quoted spans are blanked first, so a doc comment +//! or module docstring naming a banned API is not a violation. A *trailing* +//! comment naming one still is: put such a note on its own line. +//! +//! Being a substring scan, it reads one line at a time and takes each banned +//! spelling literally. A call split across lines at the `::`, or a wall clock +//! renamed at the import (`use std::time::SystemTime as Elsewhere`), walks past +//! it. That is the accepted floor: this gate exists to stop the reflexive +//! `SystemTime::now()` and the one-token slip from the engine's own +//! `clock_gettime(CLOCK_MONOTONIC)`, not to beat someone working around it. +//! +//! Discovery is `git ls-files`, not a filesystem walk. The scan roots hold +//! virtualenvs and build trees carrying tens of thousands of third-party +//! sources that are not ours to gate. + +use anyhow::{Context, Result}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Workspace trees whose clock usage this gate owns. +/// +/// `packages/test-fixtures` is in because it is engine-side test infrastructure +/// compiled into the engine's own runs, not a consumer. The rest of `packages/` +/// and all of `examples/` are downstream consumers that lag the engine by +/// design. `packages/escalate` and `packages/core` are engine-side too but hold +/// schemas only — no source this gate reads, and a root that contributes no +/// files fails [`ensure_every_arm_read_source`]. +const SCAN_ROOTS: &[&str] = &[ + "runtime", + "sdk", + "adapters", + "xtask", + "packages/test-fixtures", +]; + +/// Files whose *source text* spells a banned pattern without reading a clock — +/// this gate's own constants and fixtures. Not allowlist entries: the +/// permitted-surface list stays exactly the four the plan names, and nothing +/// here is licensed to read a wall clock. +const SCAN_EXEMPT_FILES: &[&str] = &["xtask/src/check_clock_usage.rs"]; + +/// The four surfaces the plan permits a wall-clock read on. Adding a fifth is a +/// plan change, so it is a variant here before it is a line in the allowlist. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ObservabilitySurface { + LogRecordHostTimestamp, + LogRecordSourceTimestamp, + LogFileName, + ControlPlaneEventTimestamp, +} + +impl ObservabilitySurface { + pub const ALL: &'static [ObservabilitySurface] = &[ + ObservabilitySurface::LogRecordHostTimestamp, + ObservabilitySurface::LogRecordSourceTimestamp, + ObservabilitySurface::LogFileName, + ObservabilitySurface::ControlPlaneEventTimestamp, + ]; + + pub const fn label(self) -> &'static str { + match self { + ObservabilitySurface::LogRecordHostTimestamp => "log record `host_ts`", + ObservabilitySurface::LogRecordSourceTimestamp => "log record `source_ts`", + ObservabilitySurface::LogFileName => "log file naming", + ObservabilitySurface::ControlPlaneEventTimestamp => { + "control-plane pubsub event `timestamp_ns`" + } + } + } +} + +pub struct PermittedWallClockSurface { + pub path: &'static str, + pub surface: ObservabilitySurface, + pub reason: &'static str, +} + +/// Every file permitted to read a wall clock, and why. Correlating with the +/// outside world is the whole justification; nothing on the data plane qualifies. +const PERMITTED_WALL_CLOCK_SURFACES: &[PermittedWallClockSurface] = &[ + PermittedWallClockSurface { + path: "runtime/streamlib-engine/src/core/logging/worker.rs", + surface: ObservabilitySurface::LogRecordHostTimestamp, + reason: "stamps host receipt, the authoritative sort key across a merged log stream", + }, + PermittedWallClockSurface { + path: "runtime/streamlib-engine/src/core/compiler/compiler_ops/subprocess_escalate.rs", + surface: ObservabilitySurface::LogRecordHostTimestamp, + reason: "stamps host receipt for records relayed from a helper process", + }, + PermittedWallClockSurface { + path: "sdk/streamlib-python-wheel/python/streamlib/_helper.py", + surface: ObservabilitySurface::LogRecordSourceTimestamp, + reason: "stamps the record at its Python origin, before the relay hop", + }, + PermittedWallClockSurface { + path: "runtime/streamlib-engine/src/core/logging/init.rs", + surface: ObservabilitySurface::LogFileName, + reason: "mints `started_at_millis`, which humans read off the JSONL file name", + }, + PermittedWallClockSurface { + path: "runtime/streamlib-engine/src/core/pubsub/bus.rs", + surface: ObservabilitySurface::ControlPlaneEventTimestamp, + reason: "stamps control-plane events, which are correlated against outside-world clocks", + }, +]; + +pub struct ClockUsageLanguage { + pub name: &'static str, + pub extension: &'static str, + /// Blanks comments and string prose while preserving line count, so a + /// reported line number still points at the source. Fails on prose this arm + /// cannot delimit, rather than blanking what it could not parse. + pub blank_out_prose: fn(&str) -> Result, + pub banned_wall_clock_reads: &'static [&'static str], +} + +const LANGUAGES: &[ClockUsageLanguage] = &[ + ClockUsageLanguage { + name: "rust", + extension: "rs", + blank_out_prose: blank_out_rust_prose, + banned_wall_clock_reads: &[ + "SystemTime::now", + "Utc::now", + "Local::now", + "UNIX_EPOCH.elapsed", + "CLOCK_REALTIME", + ], + }, + ClockUsageLanguage { + name: "python", + extension: "py", + blank_out_prose: blank_out_python_prose, + banned_wall_clock_reads: &[ + "time.time(", + "time.time_ns(", + "datetime.now(", + "datetime.utcnow(", + "datetime.today(", + "CLOCK_REALTIME", + ], + }, +]; + +/// The monotonic spelling to reach for in each language, named in the failure so +/// the fix does not require finding this file. +const MONOTONIC_REPLACEMENTS: &str = "`MediaClock::now()` in Rust, `monotonic_now_ns()` in Python; for a unique name \ + rather than a timestamp, `mint_machine_global_unique_name_suffix()`"; + +#[derive(Debug, PartialEq, Eq)] +pub struct WallClockReadViolation { + pub path: PathBuf, + pub line: usize, + pub matched_pattern: &'static str, + pub line_text: String, +} + +#[derive(Debug, Default)] +pub struct ClockUsageScanReport { + pub violations: Vec, + pub files_scanned: usize, + pub files_scanned_per_scan_root: Vec<(&'static str, usize)>, + pub files_scanned_per_language: Vec<(&'static str, usize)>, +} + +pub fn run(workspace_root: &Path) -> Result<()> { + let report = scan(workspace_root)?; + + crate::ensure_source_walking_gate_read_source( + "check-clock-usage", + &format!("{SCAN_ROOTS:?}"), + report.files_scanned, + "a wall-clock read onto the data plane", + )?; + ensure_every_arm_read_source(&report)?; + ensure_every_permitted_surface_still_reads_a_wall_clock(workspace_root)?; + + if report.violations.is_empty() { + println!( + "✓ check-clock-usage: {} file(s) scanned across {:?}, no wall-clock read outside \ + the {} permitted observability surface(s)", + report.files_scanned, + SCAN_ROOTS, + ObservabilitySurface::ALL.len(), + ); + return Ok(()); + } + + eprintln!( + "✗ check-clock-usage: {} violation(s)", + report.violations.len() + ); + for violation in &report.violations { + eprintln!( + " {}:{}: `{}`\n {}", + violation.path.display(), + violation.line, + violation.matched_pattern, + violation.line_text.trim(), + ); + } + eprintln!( + "\nWall clock is permitted on exactly {} observability surfaces — {} — and nowhere else. \ + A wall-clock value never enters the data plane and is never compared against, subtracted \ + from, or substituted for a media timestamp: the two share a unit and are different \ + quantities. Use {}. Widening the list is a plan change \ + (`docs/plan/ARCHITECTURE.md` §Media I/O), not a judgement call.", + ObservabilitySurface::ALL.len(), + ObservabilitySurface::ALL + .iter() + .map(|surface| surface.label()) + .collect::>() + .join(", "), + MONOTONIC_REPLACEMENTS, + ); + anyhow::bail!( + "check-clock-usage: {} wall-clock read(s) outside the permitted observability surfaces", + report.violations.len() + ); +} + +pub fn scan(workspace_root: &Path) -> Result { + let tracked = tracked_files_under_scan_roots(workspace_root)?; + scan_files(workspace_root, &tracked) +} + +/// Workspace-relative paths git tracks under the scan roots. +/// +/// A filesystem walk would descend `sdk/streamlib-python-wheel/.venv-pyright` +/// and every other build tree, gating third-party sources the project does not +/// own. `git ls-files` sees exactly what CI checks out. +fn tracked_files_under_scan_roots(workspace_root: &Path) -> Result> { + let output = std::process::Command::new("git") + .args(["ls-files", "-z", "--"]) + .args(SCAN_ROOTS) + .current_dir(workspace_root) + .output() + .context("failed to run `git ls-files` for check-clock-usage")?; + + anyhow::ensure!( + output.status.success(), + "`git ls-files` failed ({}) — check-clock-usage cannot enumerate its scan roots", + output.status + ); + + let listing = + String::from_utf8(output.stdout).context("`git ls-files` emitted a non-UTF-8 path")?; + Ok(listing + .split('\0') + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .collect()) +} + +pub fn scan_files( + workspace_root: &Path, + relative_paths: &[PathBuf], +) -> Result { + let mut report = ClockUsageScanReport { + files_scanned_per_scan_root: SCAN_ROOTS.iter().map(|root| (*root, 0)).collect(), + files_scanned_per_language: LANGUAGES.iter().map(|lang| (lang.name, 0)).collect(), + ..Default::default() + }; + + for relative_path in relative_paths { + if SCAN_EXEMPT_FILES + .iter() + .any(|exempt| relative_path == Path::new(exempt)) + { + continue; + } + let Some(language) = language_of(relative_path) else { + continue; + }; + + let body = fs::read_to_string(workspace_root.join(relative_path)) + .with_context(|| format!("failed to read {}", relative_path.display()))?; + + report.files_scanned += 1; + count_file(&mut report.files_scanned_per_language, language.name); + if let Some(root) = scan_root_of(relative_path) { + count_file(&mut report.files_scanned_per_scan_root, root); + } + + if is_permitted_wall_clock_surface(relative_path) { + continue; + } + let reads = wall_clock_reads(&body, language) + .with_context(|| format!("failed to scan {}", relative_path.display()))?; + for (line, matched_pattern, line_text) in reads { + report.violations.push(WallClockReadViolation { + path: relative_path.clone(), + line, + matched_pattern, + line_text, + }); + } + } + + Ok(report) +} + +fn count_file(counts: &mut [(&'static str, usize)], key: &'static str) { + if let Some(entry) = counts.iter_mut().find(|(name, _)| *name == key) { + entry.1 += 1; + } +} + +fn language_of(relative_path: &Path) -> Option<&'static ClockUsageLanguage> { + let extension = relative_path.extension()?.to_str()?; + LANGUAGES.iter().find(|lang| lang.extension == extension) +} + +fn scan_root_of(relative_path: &Path) -> Option<&'static str> { + SCAN_ROOTS + .iter() + .find(|root| relative_path.starts_with(root)) + .copied() +} + +fn is_permitted_wall_clock_surface(relative_path: &Path) -> bool { + PERMITTED_WALL_CLOCK_SURFACES + .iter() + .any(|permitted| relative_path == Path::new(permitted.path)) +} + +/// Every banned read in `body`, as `(1-based line, pattern, line text)`. +fn wall_clock_reads( + body: &str, + language: &'static ClockUsageLanguage, +) -> Result> { + let code = (language.blank_out_prose)(body)?; + Ok(banned_reads_in(&code, language) + .map(|(line, pattern, text)| (line, pattern, text.to_string())) + .collect()) +} + +/// Answers the allowlist-liveness question without building a violation list. +fn contains_wall_clock_read(body: &str, language: &'static ClockUsageLanguage) -> Result { + let code = (language.blank_out_prose)(body)?; + Ok(banned_reads_in(&code, language).next().is_some()) +} + +fn banned_reads_in<'a>( + code: &'a str, + language: &'static ClockUsageLanguage, +) -> impl Iterator { + code.lines().enumerate().flat_map(move |(index, line)| { + language + .banned_wall_clock_reads + .iter() + .filter_map(move |pattern| { + line.contains(pattern) + .then_some((index + 1, *pattern, line)) + }) + }) +} + +fn blank_out_rust_prose(body: &str) -> Result { + Ok(body + .lines() + .map(|line| { + if line.trim_start().starts_with("//") { + "" + } else { + line + } + }) + .collect::>() + .join("\n")) +} + +/// Blanks `#` comment lines and every triple-quoted span, which is where the +/// wheel's own `clock.py` names the banned APIs to warn readers off them. +/// +/// A span that never closes is refused rather than scanned: it would blank every +/// line after it, and this gate's only failure mode is reading nothing and +/// reporting clean. +fn blank_out_python_prose(body: &str) -> Result { + const TRIPLE_QUOTES: [&str; 2] = ["\"\"\"", "'''"]; + let mut open_quote: Option<&str> = None; + let mut code_lines: Vec = Vec::new(); + + for line in body.lines() { + if open_quote.is_none() && line.trim_start().starts_with('#') { + code_lines.push(String::new()); + continue; + } + + let mut code = String::new(); + let mut rest = line; + loop { + match open_quote { + None => { + let opener = TRIPLE_QUOTES + .iter() + .filter_map(|quote| rest.find(quote).map(|at| (at, *quote))) + .min_by_key(|(at, _)| *at); + match opener { + Some((at, quote)) => { + code.push_str(&rest[..at]); + open_quote = Some(quote); + rest = &rest[at + quote.len()..]; + } + None => { + code.push_str(rest); + break; + } + } + } + Some(quote) => match rest.find(quote) { + Some(at) => { + open_quote = None; + rest = &rest[at + quote.len()..]; + } + None => break, + }, + } + } + code_lines.push(code); + } + + anyhow::ensure!( + open_quote.is_none(), + "unterminated triple-quoted span — every line after it would be hidden from \ + check-clock-usage" + ); + Ok(code_lines.join("\n")) +} + +/// A scan arm that read nothing is indistinguishable from a clean one, so the +/// Python arm losing its tree would silently stop gating the SDK. +fn ensure_every_arm_read_source(report: &ClockUsageScanReport) -> Result<()> { + for (scan_root, files_scanned) in &report.files_scanned_per_scan_root { + anyhow::ensure!( + *files_scanned > 0, + "check-clock-usage scanned 0 files under {scan_root} — that scan root moved out \ + from under the gate" + ); + } + for (language, files_scanned) in &report.files_scanned_per_language { + anyhow::ensure!( + *files_scanned > 0, + "check-clock-usage scanned 0 {language} files — that language arm moved out from \ + under the gate" + ); + } + Ok(()) +} + +/// An allowlist entry whose file stopped reading a wall clock is a licence +/// nobody is using, sitting on a path a future change will reuse. +fn ensure_every_permitted_surface_still_reads_a_wall_clock(workspace_root: &Path) -> Result<()> { + for permitted in PERMITTED_WALL_CLOCK_SURFACES { + let relative_path = Path::new(permitted.path); + let body = fs::read_to_string(workspace_root.join(relative_path)).with_context(|| { + format!( + "check-clock-usage permits {} for {}, but the file is unreadable — a surface \ + that moves leaves the allowlist in the same change", + permitted.path, + permitted.surface.label(), + ) + })?; + + let language = language_of(relative_path).with_context(|| { + format!( + "check-clock-usage permits {}, whose extension no language arm scans", + permitted.path + ) + })?; + + anyhow::ensure!( + contains_wall_clock_read(&body, language) + .with_context(|| format!("failed to scan {}", permitted.path))?, + "check-clock-usage permits {} for {} ({}), but it reads no wall clock — drop the \ + entry so the allowlist stays exactly the permitted set", + permitted.path, + permitted.surface.label(), + permitted.reason, + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + /// The family idiom for the workspace root in a gate's tests: free, and it + /// needs neither cargo on PATH nor the package lock. + fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .to_path_buf() + } + + /// Both language arms and every scan root get a file, so a fixture tree + /// satisfies the same read-source contract the real run asserts. + fn scan_fixture(files: &[(&str, &str)]) -> (TempDir, ClockUsageScanReport) { + let tmp = TempDir::new().unwrap(); + let mut tree: Vec<(&str, &str)> = vec![ + ("runtime/streamlib-engine/src/lib.rs", "pub fn ok() {}\n"), + ( + "sdk/streamlib-python-wheel/python/streamlib/ok.py", + "OK = 1\n", + ), + ( + "adapters/streamlib-adapter-cuda/src/lib.rs", + "pub fn ok() {}\n", + ), + ("xtask/src/ok.rs", "pub fn ok() {}\n"), + ( + "packages/test-fixtures/processors/ok.rs", + "pub fn ok() {}\n", + ), + ]; + tree.extend_from_slice(files); + + let mut relative_paths = Vec::new(); + for (relative_path, body) in &tree { + let path = tmp.path().join(relative_path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, body).unwrap(); + relative_paths.push(PathBuf::from(relative_path)); + } + + let report = scan_files(tmp.path(), &relative_paths).unwrap(); + (tmp, report) + } + + #[test] + fn flags_a_wall_clock_read_in_a_data_plane_file() { + let (_tmp, report) = scan_fixture(&[( + "runtime/streamlib-engine/src/iceoryx2/output.rs", + "fn stamp() -> u64 {\n SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() as u64\n}\n", + )]); + assert_eq!(report.violations.len(), 1, "got {:?}", report.violations); + assert_eq!(report.violations[0].line, 2); + assert_eq!(report.violations[0].matched_pattern, "SystemTime::now"); + } + + /// The engine's own canonical clock read is + /// `libc::clock_gettime(libc::CLOCK_MONOTONIC, ..)`; flipping one token + /// yields a wall-clock read, and the gate's failure message points readers + /// straight at that file. + #[test] + fn flags_the_raw_syscall_a_session_gets_by_copying_media_clock() { + let (_tmp, report) = scan_fixture(&[( + "runtime/streamlib-engine/src/iceoryx2/output.rs", + "unsafe { libc::clock_gettime(libc::CLOCK_REALTIME, &mut timespec) };\n", + )]); + assert_eq!(report.violations.len(), 1, "got {:?}", report.violations); + assert_eq!(report.violations[0].matched_pattern, "CLOCK_REALTIME"); + } + + #[test] + fn flags_the_python_realtime_clock_id() { + let (_tmp, report) = scan_fixture(&[( + "sdk/streamlib-python-wheel/python/streamlib/stamp.py", + "stamp = time.clock_gettime_ns(time.CLOCK_REALTIME)\n", + )]); + assert_eq!(report.violations.len(), 1, "got {:?}", report.violations); + } + + #[test] + fn flags_reading_the_wall_clock_through_the_epoch() { + let (_tmp, report) = scan_fixture(&[( + "runtime/streamlib-engine/src/iceoryx2/output.rs", + "let since_epoch = std::time::UNIX_EPOCH.elapsed().unwrap();\n", + )]); + assert_eq!(report.violations.len(), 1, "got {:?}", report.violations); + } + + #[test] + fn flags_the_chrono_spellings() { + let (_tmp, report) = scan_fixture(&[( + "runtime/streamlib-engine/src/core/runtime/node.rs", + "let a = chrono::Utc::now();\nlet b = Local::now();\n", + )]); + assert_eq!(report.violations.len(), 2, "got {:?}", report.violations); + } + + #[test] + fn flags_a_python_wall_clock_read() { + let (_tmp, report) = scan_fixture(&[( + "sdk/streamlib-python-wheel/python/streamlib/stamp.py", + "import time\n\n\ndef stamp() -> int:\n return time.time_ns()\n", + )]); + assert_eq!(report.violations.len(), 1, "got {:?}", report.violations); + assert_eq!(report.violations[0].line, 5); + assert_eq!(report.violations[0].matched_pattern, "time.time_ns("); + } + + #[test] + fn accepts_every_permitted_observability_surface() { + for permitted in PERMITTED_WALL_CLOCK_SURFACES { + let read = match language_of(Path::new(permitted.path)).unwrap().name { + "python" => "stamp = datetime.now(timezone.utc)\n", + _ => "let stamp = SystemTime::now();\n", + }; + let (_tmp, report) = scan_fixture(&[(permitted.path, read)]); + assert!( + report.violations.is_empty(), + "{} is permitted for {}: {:?}", + permitted.path, + permitted.surface.label(), + report.violations, + ); + } + } + + #[test] + fn skips_a_rust_doc_comment_naming_the_banned_call() { + let (_tmp, report) = scan_fixture(&[( + "runtime/streamlib-engine/src/core/media_clock.rs", + "//! Never `SystemTime::now()` — that is the wall clock.\n\ + /// Superseded by `MediaClock::now()`, not `Utc::now()`.\n\ + pub fn ok() {}\n", + )]); + assert!(report.violations.is_empty(), "got {:?}", report.violations); + } + + #[test] + fn skips_a_python_module_docstring_naming_the_banned_apis() { + let (_tmp, report) = scan_fixture(&[( + "sdk/streamlib-python-wheel/python/streamlib/clock.py", + "\"\"\"Canonical monotonic-clock timestamp source.\n\n\ + Wall-clock APIs (`time.time`, `datetime.now`, `time.time_ns`) are NOT\n\ + comparable across processes.\n\"\"\"\n\n\ + from ._engine import monotonic_now_ns as monotonic_now_ns\n", + )]); + assert!( + report.violations.is_empty(), + "the wheel's own clock.py warns readers off these APIs by naming them: {:?}", + report.violations, + ); + } + + #[test] + fn skips_a_python_comment_and_a_single_quoted_docstring() { + let (_tmp, report) = scan_fixture(&[( + "sdk/streamlib-python-wheel/tests/test_clock.py", + "# time.time() is banned here\n'''Also banned: datetime.now()'''\nOK = 1\n", + )]); + assert!(report.violations.is_empty(), "got {:?}", report.violations); + } + + #[test] + fn flags_code_that_follows_a_closed_docstring_on_one_line() { + let (_tmp, report) = scan_fixture(&[( + "sdk/streamlib-python-wheel/python/streamlib/stamp.py", + "\"\"\"doc\"\"\"\nstamp = time.time()\n", + )]); + assert_eq!(report.violations.len(), 1, "got {:?}", report.violations); + assert_eq!(report.violations[0].line, 2); + } + + #[test] + fn refuses_a_python_file_whose_triple_quoted_span_never_closes() { + let unterminated = + blank_out_python_prose("\"\"\"doc that never closes\nstamp = time.time()\n"); + let err = unterminated.unwrap_err(); + assert!(err.to_string().contains("unterminated"), "got {err}"); + } + + #[test] + fn an_unterminated_span_fails_the_scan_rather_than_hiding_the_file() { + let tmp = TempDir::new().unwrap(); + let relative_path = "sdk/streamlib-python-wheel/python/streamlib/broken.py"; + let path = tmp.path().join(relative_path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "'''never closed\nstamp = time.time_ns()\n").unwrap(); + + let err = scan_files(tmp.path(), &[PathBuf::from(relative_path)]).unwrap_err(); + assert!(err.to_string().contains("broken.py"), "got {err:#}"); + } + + #[test] + fn accepts_the_monotonic_spellings() { + let (_tmp, report) = scan_fixture(&[ + ( + "runtime/streamlib-engine/src/iceoryx2/output.rs", + "let stamp = MediaClock::now().as_nanos() as u64;\nlet t = Instant::now();\n", + ), + ( + "sdk/streamlib-python-wheel/python/streamlib/stamp.py", + "stamp = monotonic_now_ns()\nalso = time.clock_gettime_ns(time.CLOCK_MONOTONIC)\n", + ), + ]); + assert!(report.violations.is_empty(), "got {:?}", report.violations); + } + + /// The gate reads whole-line comments only, so a note sharing a line with + /// code reads as code. Stated as a test so the limit is discoverable. + #[test] + fn flags_a_trailing_comment_naming_a_banned_call() { + let (_tmp, report) = scan_fixture(&[( + "runtime/streamlib-engine/src/iceoryx2/output.rs", + "pub fn ok() {} // never SystemTime::now()\n", + )]); + assert_eq!(report.violations.len(), 1, "got {:?}", report.violations); + } + + #[test] + fn never_scans_its_own_source() { + let (_tmp, report) = + scan_fixture(&[(SCAN_EXEMPT_FILES[0], "let a = SystemTime::now();\n")]); + assert!(report.violations.is_empty(), "got {:?}", report.violations); + } + + #[test] + fn every_permitted_entry_names_one_of_the_four_surfaces() { + for permitted in PERMITTED_WALL_CLOCK_SURFACES { + assert!( + ObservabilitySurface::ALL.contains(&permitted.surface), + "{} names a surface outside the permitted four", + permitted.path, + ); + } + for surface in ObservabilitySurface::ALL { + assert!( + PERMITTED_WALL_CLOCK_SURFACES + .iter() + .any(|permitted| permitted.surface == *surface), + "{} has no file — a permitted surface with no reader is not a surface", + surface.label(), + ); + } + } + + #[test] + fn refuses_a_tree_where_a_scan_root_read_nothing() { + let report = ClockUsageScanReport { + files_scanned_per_scan_root: vec![("runtime", 3), ("sdk", 0)], + files_scanned_per_language: vec![("rust", 3), ("python", 1)], + ..Default::default() + }; + let err = ensure_every_arm_read_source(&report).unwrap_err(); + assert!(err.to_string().contains("sdk"), "got {err}"); + } + + #[test] + fn refuses_a_tree_where_the_python_arm_read_nothing() { + let report = ClockUsageScanReport { + files_scanned_per_scan_root: vec![("runtime", 3)], + files_scanned_per_language: vec![("rust", 3), ("python", 0)], + ..Default::default() + }; + let err = ensure_every_arm_read_source(&report).unwrap_err(); + assert!(err.to_string().contains("python"), "got {err}"); + } + + #[test] + fn refuses_an_allowlist_entry_that_reads_no_wall_clock() { + let tmp = TempDir::new().unwrap(); + for permitted in PERMITTED_WALL_CLOCK_SURFACES { + let path = tmp.path().join(permitted.path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "nothing here reads a clock\n").unwrap(); + } + let err = ensure_every_permitted_surface_still_reads_a_wall_clock(tmp.path()).unwrap_err(); + assert!(err.to_string().contains("reads no wall clock"), "got {err}"); + } + + #[test] + fn the_real_tree_permits_only_files_that_still_read_a_wall_clock() { + ensure_every_permitted_surface_still_reads_a_wall_clock(&workspace_root()).unwrap(); + } + + #[test] + fn discovery_skips_virtualenv_and_build_trees() { + let tracked = tracked_files_under_scan_roots(&workspace_root()).unwrap(); + + assert!( + tracked + .iter() + .any(|path| path + == Path::new("sdk/streamlib-python-wheel/python/streamlib/clock.py")), + "the wheel's Python package is inside the scan roots", + ); + for path in &tracked { + let text = path.to_string_lossy(); + assert!( + !text.contains(".venv") && !text.contains("site-packages"), + "{text} is third-party source this gate does not own", + ); + } + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 703f272be..864a758bd 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -8,6 +8,7 @@ use clap::{Parser, Subcommand}; use std::path::{Path, PathBuf}; pub mod check_boundaries; +pub mod check_clock_usage; pub mod check_device_wait_idle; pub mod check_no_escalate_in_lifecycle; pub mod check_no_in_process_placement; @@ -47,7 +48,7 @@ pub fn ensure_source_walking_gate_read_source( /// Every source-walking gate, paired with the subcommand name that runs it alone. /// /// Each gate reads the tree and reports; none builds the workspace. That is what -/// lets one process run all eight in well under a second, and why CI runs them as +/// lets one process run all nine in well under a second, and why CI runs them as /// a single job rather than one runner per gate. const ALL_SOURCE_WALKING_GATES: &[(&str, fn(&Path) -> Result<()>)] = &[ ("lint-logging", lint_logging::run), @@ -67,6 +68,7 @@ const ALL_SOURCE_WALKING_GATES: &[(&str, fn(&Path) -> Result<()>)] = &[ "check-no-unbounded-cstr-from-ptr", check_no_unbounded_cstr_from_ptr::run, ), + ("check-clock-usage", check_clock_usage::run), ]; /// Run every source-walking gate, reporting all failures rather than the first. @@ -263,6 +265,19 @@ enum Commands { /// external API is not flagged. CheckNoUnboundedCstrFromPtr, + /// CI gate for the wall-clock allowlist. Fails on a wall-clock read + /// (`SystemTime::now`, `Utc::now`, `time.time_ns`, `datetime.now`, …) + /// anywhere under `runtime/ sdk/ adapters/ xtask/ packages/test-fixtures/` + /// outside the four + /// observability surfaces the plan permits it on: log record `host_ts` + /// and `source_ts`, log file naming, and the control-plane pubsub event + /// timestamp. Monotonic is the only legal clock on the data plane — a + /// wall-clock value and a media timestamp share a unit and are different + /// quantities, so subtracting across them is always a bug. There is no + /// per-line pragma: widening the list is a plan change. See + /// `docs/decisions/one-monotonic-clock.md`. + CheckClockUsage, + /// Drift trip-wire for the vendored vulkanalia fork trees /// (`vendor/tatolab-vulkanalia{,-sys,-vma}`): hashes each vendored crate /// dir and fails on any byte change vs. the recorded hash — the guard @@ -308,6 +323,7 @@ fn main() -> Result<()> { Commands::CheckNoUnboundedCstrFromPtr => { check_no_unbounded_cstr_from_ptr::run(&workspace_root()?)? } + Commands::CheckClockUsage => check_clock_usage::run(&workspace_root()?)?, Commands::CheckVendoredVulkanalia => check_vendored_vulkanalia::run(&workspace_root()?)?, Commands::CheckAllSourceGates => run_all_source_walking_gates(&workspace_root()?)?, Commands::RunLocalCiGates => run_local_ci_gates(&workspace_root()?)?,