From 69b3975a422f446f84d954dfad240c70749a095f Mon Sep 17 00:00:00 2001 From: Lucid Programmer Date: Fri, 23 Oct 2026 17:50:09 +0530 Subject: [PATCH 1/2] engine: verify macOS readers by process identity, keep reader holds separate from writer ownership, and let reader waits be cancelled. --- Cargo.lock | 1 + crates/contextdb-engine/Cargo.toml | 1 + crates/contextdb-engine/src/database.rs | 12 + .../authoritative_purge_kernel_tests.rs | 22 +- .../contextdb-engine/src/database/trigger.rs | 4 + .../src/local_transport/address.rs | 71 + .../src/local_transport/runtime.rs | 535 +++++- crates/contextdb-engine/src/persistence.rs | 1481 ++++++++++++++++- .../reader_coordination_wait_tests.rs | 227 +++ .../fixtures/public_api_allowed/Cargo.lock | 1 + .../fixtures/public_api_forbidden/Cargo.lock | 1 + .../local_transport_refusal_taxonomy.rs | 4 +- .../local_transport_runtime.rs | 189 ++- ...persistence_coordination_contract_tests.rs | 70 + tests/acceptance/trigger_same_db_progress.rs | 82 +- 15 files changed, 2548 insertions(+), 153 deletions(-) create mode 100644 crates/contextdb-engine/src/persistence/reader_coordination_wait_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 9f28af5e..61ff206a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -771,6 +771,7 @@ name = "contextdb-engine" version = "1.0.0" dependencies = [ "bao-tree", + "base64", "bincode 2.0.1", "blake3", "bytes", diff --git a/crates/contextdb-engine/Cargo.toml b/crates/contextdb-engine/Cargo.toml index 9a7383e9..b07eb48f 100644 --- a/crates/contextdb-engine/Cargo.toml +++ b/crates/contextdb-engine/Cargo.toml @@ -68,6 +68,7 @@ tempfile.workspace = true fs2 = "0.4" libc = "0.2" blake3 = "1" +base64 = "0.22.1" tokio = { workspace = true, optional = true } thiserror.workspace = true iroh = { version = "1.0.2", optional = true, default-features = false, features = ["tls-ring", "metrics"] } diff --git a/crates/contextdb-engine/src/database.rs b/crates/contextdb-engine/src/database.rs index 1a9c964f..85f5bd5b 100644 --- a/crates/contextdb-engine/src/database.rs +++ b/crates/contextdb-engine/src/database.rs @@ -21228,6 +21228,8 @@ impl Database { self.trigger .wait_observed_count .fetch_add(1, Ordering::SeqCst); + #[cfg(any(test, feature = "test-seams"))] + self.trigger.wait_observed_changed.notify_all(); observed_wait = true; } } @@ -39921,6 +39923,16 @@ impl Database { } } + /// Waits for a real same-database trigger contender to enter the wait gate. + #[doc(hidden)] + #[cfg(any(test, feature = "test-seams"))] + pub fn wait_until_trigger_wait_observed_for_test(&self, minimum: u64) { + let mut guard = self.trigger.wait_lock.lock(); + while self.trigger.wait_observed_count.load(Ordering::SeqCst) < minimum { + self.trigger.wait_observed_changed.wait(&mut guard); + } + } + #[doc(hidden)] #[cfg(any(test, feature = "test-seams"))] pub fn __inject_raw_joined_row_value_for_test( diff --git a/crates/contextdb-engine/src/database/authoritative_purge_kernel_tests.rs b/crates/contextdb-engine/src/database/authoritative_purge_kernel_tests.rs index 9d2d617f..e32e4710 100644 --- a/crates/contextdb-engine/src/database/authoritative_purge_kernel_tests.rs +++ b/crates/contextdb-engine/src/database/authoritative_purge_kernel_tests.rs @@ -4,7 +4,6 @@ use super::*; use std::{ collections::{BTreeMap, BTreeSet, HashMap, HashSet}, path::PathBuf, - time::Duration, }; use uuid::Uuid; @@ -1201,6 +1200,20 @@ fn authoritative_file_purge_kernel_removes_only_the_immutable_selected_lineage() #[test] fn authoritative_file_purge_kernel_fences_stale_transactions_and_exports() { + authoritative_file_purge_kernel_export_fixture(false); +} + +#[test] +fn snapshot_export_caller_panic_releases_the_captured_export() { + let panic = std::panic::catch_unwind(|| authoritative_file_purge_kernel_export_fixture(true)) + .expect_err("snapshot export caller must abort after capture"); + assert_eq!( + panic.downcast_ref::<&str>().copied(), + Some("snapshot export caller aborted after capture") + ); +} + +fn authoritative_file_purge_kernel_export_fixture(force_caller_panic: bool) { let seed = fixture_seed(); let pinned_snapshot = seed.db.snapshot(); let pinned = seed.db.pin_snapshot(pinned_snapshot); @@ -1251,13 +1264,16 @@ fn authoritative_file_purge_kernel_fences_stale_transactions_and_exports() { .expect("stage unrelated survivor write"); let export_path = seed.root.path().join("purged-export.redb"); - let pause = seed.db.pause_after_export_snapshot_capture_for_test(); std::thread::scope(|scope| { + let pause = seed.db.pause_after_export_snapshot_capture_for_test(); let export = scope.spawn(|| seed.db.export_snapshot(&export_path)); assert!( - pause.wait_until_reached(Duration::from_secs(5)), + pause.wait_until_reached_blocking(), "export must capture its snapshot before the private kernel commits" ); + if force_caller_panic { + panic!("snapshot export caller aborted after capture"); + } let note_frontier = seed .db .commit_authoritative_purge_kernel(&fixture.note_selection) diff --git a/crates/contextdb-engine/src/database/trigger.rs b/crates/contextdb-engine/src/database/trigger.rs index fa93d332..6c859bfd 100644 --- a/crates/contextdb-engine/src/database/trigger.rs +++ b/crates/contextdb-engine/src/database/trigger.rs @@ -27,6 +27,8 @@ pub(crate) struct TriggerState { active_trigger_names: Mutex>, pub(super) wait_lock: Mutex<()>, pub(super) waiters: Condvar, + #[cfg(any(test, feature = "test-seams"))] + pub(super) wait_observed_changed: Condvar, pub(super) close_waiter_count: AtomicUsize, callback_active_count: AtomicUsize, pub(super) next_audit_index: AtomicU64, @@ -146,6 +148,8 @@ impl TriggerState { active_trigger_names: Mutex::new(Vec::new()), wait_lock: Mutex::new(()), waiters: Condvar::new(), + #[cfg(any(test, feature = "test-seams"))] + wait_observed_changed: Condvar::new(), close_waiter_count: AtomicUsize::new(0), callback_active_count: AtomicUsize::new(0), next_audit_index: AtomicU64::new(0), diff --git a/crates/contextdb-engine/src/local_transport/address.rs b/crates/contextdb-engine/src/local_transport/address.rs index 28eec2d0..cd6e6c50 100644 --- a/crates/contextdb-engine/src/local_transport/address.rs +++ b/crates/contextdb-engine/src/local_transport/address.rs @@ -2,6 +2,7 @@ use super::validate_socket_path_length; use super::{LocalTransportError, validate_channel_addressability}; use contextdb_core::read_contract::ChannelAddress; +#[cfg(not(target_os = "macos"))] use std::fmt::Write; use std::path::{Path, PathBuf}; @@ -16,6 +17,24 @@ pub fn derive_channel_address(store_path: &Path) -> Result String { + #[cfg(target_os = "macos")] + { + macos_channel_basename(address) + } + #[cfg(not(target_os = "macos"))] + { + hex_channel_basename(address) + } +} + +#[cfg(any(test, target_os = "macos"))] +pub(super) fn macos_channel_basename(address: ChannelAddress) -> String { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(address.0) +} + +#[cfg(not(target_os = "macos"))] +fn hex_channel_basename(address: ChannelAddress) -> String { let mut basename = String::with_capacity(address.0.len() * 2 + 5); for byte in address.0 { write!(&mut basename, "{byte:02x}").expect("writing into a string cannot fail"); @@ -24,6 +43,58 @@ pub fn opaque_channel_basename(address: ChannelAddress) -> String { basename } +#[cfg(test)] +mod macos_channel_basename_tests { + use super::*; + + #[test] + fn macos_basename_encodes_all_digest_bytes_in_43_url_safe_characters() { + assert_eq!( + macos_channel_basename(ChannelAddress([0; 32])), + "A".repeat(43) + ); + let mut low = [0; 32]; + low[31] = 1; + assert_eq!( + macos_channel_basename(ChannelAddress(low)), + format!("{}E", "A".repeat(42)) + ); + let high = [255; 32]; + assert_eq!( + macos_channel_basename(ChannelAddress(high)), + format!("{}8", "_".repeat(42)) + ); + let name = macos_channel_basename(ChannelAddress([0x5a; 32])); + assert_eq!(name.len(), 43); + assert!( + name.bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + ); + } + + #[test] + fn macos_private_root_and_actual_basename_fit_sockaddr_un() { + let root = Path::new("/private/var/folders/ab/012345678901234567890123456789/T"); + let channel = root + .join("c") + .join(macos_channel_basename(ChannelAddress([0; 32]))); + assert!( + channel.as_os_str().as_encoded_bytes().len() <= 103, + "{}", + channel.display() + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_public_basename_stays_hex_with_sock_suffix() { + assert_eq!( + opaque_channel_basename(ChannelAddress([0; 32])), + format!("{}.sock", "0".repeat(64)) + ); + } +} + /// Place one local channel below its validated runtime directory, never below /// the inspected store. pub fn channel_socket_path( diff --git a/crates/contextdb-engine/src/local_transport/runtime.rs b/crates/contextdb-engine/src/local_transport/runtime.rs index 3d88bec1..4c8e11e8 100644 --- a/crates/contextdb-engine/src/local_transport/runtime.rs +++ b/crates/contextdb-engine/src/local_transport/runtime.rs @@ -24,6 +24,115 @@ pub struct RuntimeDirectory { directory: PathBuf, } +#[cfg(all(test, unix))] +mod macos_private_runtime_tests { + use super::*; + + const PRIVATE_ROOT: &str = "/private/var/folders/ab/012345678901234567890123456789/T"; + const USER: LocalUserIdentity = LocalUserIdentity(501); + + struct FakeEnvironment; + + impl RuntimeDirectoryEnvironment for FakeEnvironment { + fn xdg_runtime_directory(&self) -> Option { + None + } + fn effective_user_identity(&self) -> Result { + Ok(USER) + } + fn macos_user_temporary_directory(&self) -> Result { + Ok(PathBuf::from(PRIVATE_ROOT)) + } + } + + struct FakeInspector { + private: RuntimeDirectoryFacts, + } + + fn facts(path: &Path, owner: LocalUserIdentity, mode: u32) -> RuntimeDirectoryFacts { + RuntimeDirectoryFacts { + requested_root: path.to_path_buf(), + resolved_root: path.to_path_buf(), + is_absolute: true, + is_local: true, + is_directory: true, + is_symbolic_link: false, + owner, + mode, + writable: true, + available: true, + } + } + + impl RuntimeDirectoryInspector for FakeInspector { + fn inspect(&self, path: &Path) -> Result { + let canonical_tmp = std::fs::canonicalize("/tmp").expect("system temporary directory"); + if path == Path::new("/tmp") { + let mut alias = facts(path, LocalUserIdentity(0), 0o777); + alias.resolved_root = canonical_tmp.clone(); + return Ok(alias); + } + if path == canonical_tmp { + return Ok(facts(path, LocalUserIdentity(0), 0o777)); + } + if path == Path::new(PRIVATE_ROOT) { + return Ok(self.private.clone()); + } + if path == canonical_tmp.join("cdb00000000000001f5") { + return Ok(facts(path, LocalUserIdentity(502), OWNER_ONLY_MODE)); + } + panic!("unexpected inspection: {}", path.display()); + } + } + + fn resolve( + private: RuntimeDirectoryFacts, + ) -> Result<(RuntimeDirectory, RuntimeDirectorySource), LocalTransportError> { + resolve_macos_runtime_layout( + &RuntimeDirectoryRequest { + explicit_root: None, + current_user: USER, + }, + &FakeInspector { private }, + &FakeEnvironment, + ) + } + + #[test] + fn macos_default_uses_private_native_root_despite_foreign_shared_leaf() { + let (layout, source) = resolve(facts(Path::new(PRIVATE_ROOT), USER, OWNER_ONLY_MODE)) + .expect("valid native private root"); + assert_eq!(source, RuntimeDirectorySource::MacosTemporary); + assert_eq!(layout.root(), Path::new(PRIVATE_ROOT)); + assert_eq!(layout.path(), Path::new(PRIVATE_ROOT).join("c")); + } + + #[test] + fn macos_default_refuses_untrusted_private_native_root() { + let cases = [ + ("wrong owner", RuntimeRootViolation::WrongOwner), + ("insecure mode", RuntimeRootViolation::InsecureMode), + ("nonlocal", RuntimeRootViolation::NonLocal), + ("symlink", RuntimeRootViolation::SymbolicLink), + ]; + for (case, expected) in cases { + let mut private = facts(Path::new(PRIVATE_ROOT), USER, OWNER_ONLY_MODE); + match case { + "wrong owner" => private.owner = LocalUserIdentity(502), + "insecure mode" => private.mode = 0o755, + "nonlocal" => private.is_local = false, + "symlink" => private.is_symbolic_link = true, + _ => unreachable!(), + } + assert_eq!( + resolve(private), + Err(LocalTransportError::RuntimeRoot(expected)), + "{case}" + ); + } + } +} + impl RuntimeDirectory { /// The runtime directory inside a runtime root: always that root's /// `contextdb` child, whatever the root itself is named. This process @@ -378,7 +487,7 @@ pub fn validate_socket_path_length(path: &Path) -> Result<(), LocalTransportErro /// Whether a channel below this directory can be addressed at all. /// /// A Unix socket address is capped at 107 bytes on Linux and 103 on macOS, -/// and the channel basename alone is 69 of them. An operator whose service +/// and the Linux channel basename alone is 69 of them. An operator whose service /// manager hands the owner a normally-named runtime directory -- something /// like `/var/lib//runtime` -- is left with barely thirty characters /// of headroom, and past that the owner could not create a channel and every @@ -455,10 +564,7 @@ fn resolve_runtime_layout_with_environment( } #[cfg(target_os = "macos")] { - ( - environment.macos_user_temporary_directory()?, - RuntimeDirectorySource::MacosTemporary, - ) + return resolve_macos_runtime_layout(request, inspector, environment); } #[cfg(not(any(target_os = "linux", target_os = "macos")))] { @@ -477,14 +583,32 @@ fn resolve_runtime_layout_with_environment( } } let runtime = RuntimeDirectory::within(&facts.resolved_root); - validate_channel_addressability( - &runtime - .path() - .join("0000000000000000000000000000000000000000000000000000000000000000.sock"), - )?; + validate_channel_addressability(&runtime.path().join(super::opaque_channel_basename( + contextdb_core::read_contract::ChannelAddress([0; 32]), + )))?; Ok((runtime, source)) } +/// Keep the production macOS default resolver callable by Unix unit tests. +#[cfg(any(target_os = "macos", all(test, unix)))] +fn resolve_macos_runtime_layout( + request: &RuntimeDirectoryRequest, + inspector: &dyn RuntimeDirectoryInspector, + environment: &dyn RuntimeDirectoryEnvironment, +) -> Result<(RuntimeDirectory, RuntimeDirectorySource), LocalTransportError> { + let root = environment.macos_user_temporary_directory()?; + let facts = inspector.inspect(&root)?; + validate_runtime_root(&facts, request.current_user)?; + let runtime = RuntimeDirectory { + root: facts.resolved_root.clone(), + directory: facts.resolved_root.join("c"), + }; + validate_socket_path_length(&runtime.path().join(super::address::macos_channel_basename( + contextdb_core::read_contract::ChannelAddress([0; 32]), + )))?; + Ok((runtime, RuntimeDirectorySource::MacosTemporary)) +} + /// The ONE runtime directory a process uses for this store's local CHANNEL: /// where a writer binds it and a reader dials it. /// @@ -492,7 +616,8 @@ fn resolve_runtime_layout_with_environment( /// have to land on the same answer. A directory an operator supplied IS the /// directory -- a container or packaged service has that exact path created /// and owned for it -- so it is used as given; with nothing supplied, the -/// platform base's owner-only `contextdb` child is prepared and used. Callers +/// platform base's owner-only child is prepared and used (`contextdb` on +/// Linux, `c` inside the per-user Darwin temporary directory on macOS). Callers /// carry the resolved value; re-deriving a directory from the root inside it /// is how a writer and the readers dialling it end up in different places. /// @@ -517,8 +642,8 @@ pub fn runtime_directory_for_store( } /// Resolve and validate the platform-native base, then create and validate -/// its owner-only `contextdb` child. Callers cannot supply any emergency or -/// temporary fallback path through this API. +/// its owner-only child (`contextdb` on Linux, `c` on macOS). Callers cannot +/// supply any emergency or temporary fallback path through this API. pub fn prepare_runtime_directory( request: &RuntimeDirectoryRequest, inspector: &dyn RuntimeDirectoryInspector, @@ -564,6 +689,11 @@ fn prepare_runtime_layout_with_environment( ) -> Result<(RuntimeDirectory, RuntimeDirectorySource), LocalTransportError> { let (runtime, source) = resolve_runtime_layout_with_environment(request, inspector, environment)?; + #[cfg(target_os = "macos")] + if source == RuntimeDirectorySource::MacosTemporary { + prepare_macos_default_runtime_directory(runtime.path(), request.current_user)?; + return Ok((runtime, source)); + } match inspector.inspect(runtime.path()) { Ok(facts) if facts.available => validate_runtime_root(&facts, request.current_user)?, Ok(_) | Err(LocalTransportError::RuntimeRoot(RuntimeRootViolation::Unavailable)) => { @@ -576,6 +706,137 @@ fn prepare_runtime_layout_with_environment( Ok((runtime, source)) } +#[cfg(target_os = "macos")] +fn prepare_macos_default_runtime_directory( + path: &Path, + expected_user: LocalUserIdentity, +) -> Result<(), LocalTransportError> { + use nix::fcntl::{OFlag, open, openat}; + use nix::sys::stat::{Mode, SFlag, fstat, mkdirat}; + use nix::sys::statfs::fstatfs; + + let parent = path.parent().ok_or_else(|| { + LocalTransportError::FilesystemInspection("runtime path has no parent directory".to_owned()) + })?; + let leaf_name = path.file_name().ok_or_else(|| { + LocalTransportError::FilesystemInspection("runtime path has no leaf name".to_owned()) + })?; + + let parent_fd = open( + parent, + OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC | OFlag::O_NOFOLLOW, + Mode::empty(), + ) + .map_err(|error| match error { + nix::errno::Errno::ENOENT => { + LocalTransportError::RuntimeRoot(RuntimeRootViolation::Unavailable) + } + nix::errno::Errno::ELOOP => { + LocalTransportError::RuntimeRoot(RuntimeRootViolation::SymbolicLink) + } + nix::errno::Errno::ENOTDIR => { + LocalTransportError::RuntimeRoot(RuntimeRootViolation::NotDirectory) + } + nix::errno::Errno::EACCES => { + LocalTransportError::RuntimeRoot(RuntimeRootViolation::InsecureMode) + } + other => LocalTransportError::FilesystemInspection(other.to_string()), + })?; + + let parent_stat = fstat(&parent_fd) + .map_err(|error| LocalTransportError::FilesystemInspection(error.to_string()))?; + + let parent_file_type = SFlag::from_bits_truncate(parent_stat.st_mode) & SFlag::S_IFMT; + if parent_file_type != SFlag::S_IFDIR { + return Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::NotDirectory, + )); + } + + if LocalUserIdentity(parent_stat.st_uid as u64) != expected_user { + return Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::WrongOwner, + )); + } + + if (parent_stat.st_mode as u32 & 0o777) != OWNER_ONLY_MODE { + return Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::InsecureMode, + )); + } + + let parent_fs = fstatfs(&parent_fd) + .map_err(|error| LocalTransportError::FilesystemInspection(error.to_string()))?; + if !parent_fs.flags().contains(nix::mount::MntFlags::MNT_LOCAL) { + return Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::NonLocal, + )); + } + + let mode = Mode::S_IRWXU; + match mkdirat(&parent_fd, leaf_name, mode) { + Ok(()) => {} + Err(nix::errno::Errno::EEXIST) => {} + Err(error) => { + return Err(LocalTransportError::FilesystemInspection(error.to_string())); + } + } + + let child_fd = openat( + &parent_fd, + leaf_name, + OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC | OFlag::O_NOFOLLOW, + Mode::empty(), + ) + .map_err(|error| match error { + nix::errno::Errno::ENOENT => { + LocalTransportError::RuntimeRoot(RuntimeRootViolation::Unavailable) + } + nix::errno::Errno::ELOOP => { + LocalTransportError::RuntimeRoot(RuntimeRootViolation::SymbolicLink) + } + nix::errno::Errno::ENOTDIR => { + LocalTransportError::RuntimeRoot(RuntimeRootViolation::NotDirectory) + } + nix::errno::Errno::EACCES => { + LocalTransportError::RuntimeRoot(RuntimeRootViolation::InsecureMode) + } + other => LocalTransportError::FilesystemInspection(other.to_string()), + })?; + + let stat = fstat(&child_fd) + .map_err(|error| LocalTransportError::FilesystemInspection(error.to_string()))?; + + let file_type = SFlag::from_bits_truncate(stat.st_mode) & SFlag::S_IFMT; + if file_type != SFlag::S_IFDIR { + return Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::NotDirectory, + )); + } + + if LocalUserIdentity(stat.st_uid as u64) != expected_user { + return Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::WrongOwner, + )); + } + + if (stat.st_mode as u32 & 0o777) != OWNER_ONLY_MODE { + return Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::InsecureMode, + )); + } + + let fs = fstatfs(&child_fd) + .map_err(|error| LocalTransportError::FilesystemInspection(error.to_string()))?; + if !fs.flags().contains(nix::mount::MntFlags::MNT_LOCAL) { + return Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::NonLocal, + )); + } + + Ok(()) +} + fn create_owner_only_runtime_child(path: &Path) -> Result<(), LocalTransportError> { #[cfg(unix)] { @@ -734,3 +995,251 @@ fn filesystem_is_local(path: &Path) -> Result { fn filesystem_is_local(_path: &Path) -> Result { Err(LocalTransportError::PlatformUnsupported) } + +#[cfg(all(test, target_os = "macos"))] +mod macos_locator_tests { + use super::*; + + #[test] + fn macos_private_temporary_container_validates_shape_mode_and_owner() { + let parent = PathBuf::from("/private/var/folders/ab/012345678901234567890123456789/T"); + let valid_facts = RuntimeDirectoryFacts { + requested_root: parent.clone(), + resolved_root: parent, + is_absolute: true, + is_local: true, + is_directory: true, + is_symbolic_link: false, + owner: LocalUserIdentity(501), + mode: OWNER_ONLY_MODE, + writable: true, + available: true, + }; + let current_user = LocalUserIdentity(501); + + assert!(validate_runtime_root(&valid_facts, current_user).is_ok()); + + // Unavailable + let mut f = valid_facts.clone(); + f.available = false; + assert_eq!( + validate_runtime_root(&f, current_user), + Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::Unavailable + )) + ); + + // Relative + let mut f = valid_facts.clone(); + f.is_absolute = false; + assert_eq!( + validate_runtime_root(&f, current_user), + Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::Relative + )) + ); + + // NonLocal + let mut f = valid_facts.clone(); + f.is_local = false; + assert_eq!( + validate_runtime_root(&f, current_user), + Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::NonLocal + )) + ); + + // SymbolicLink + let mut f = valid_facts.clone(); + f.is_symbolic_link = true; + assert_eq!( + validate_runtime_root(&f, current_user), + Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::SymbolicLink + )) + ); + + // NotDirectory + let mut f = valid_facts.clone(); + f.is_directory = false; + assert_eq!( + validate_runtime_root(&f, current_user), + Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::NotDirectory + )) + ); + + // WrongOwner + let mut f = valid_facts.clone(); + f.owner = LocalUserIdentity(502); + assert_eq!( + validate_runtime_root(&f, current_user), + Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::WrongOwner + )) + ); + + // NotWritable + let mut f = valid_facts.clone(); + f.writable = false; + assert_eq!( + validate_runtime_root(&f, current_user), + Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::NotWritable + )) + ); + + // InsecureMode (private root requires exact owner-only mode) + let mut f = valid_facts.clone(); + f.mode = 0o777; + assert_eq!( + validate_runtime_root(&f, current_user), + Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::InsecureMode + )) + ); + f.mode = 0o755; + assert_eq!( + validate_runtime_root(&f, current_user), + Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::InsecureMode + )) + ); + } + + #[test] + fn macos_default_descriptor_preparer_creates_and_cleans_exclusive_owned_nonce_leaf() { + use std::os::unix::fs::MetadataExt; + + let native_root = ProcessRuntimeDirectoryEnvironment + .macos_user_temporary_directory() + .expect("native private temporary root"); + let test_parent = tempfile::tempdir_in(native_root).expect("private test parent"); + let canonical_tmp = test_parent.path(); + let current_uid = LocalUserIdentity(nix::unistd::geteuid().as_raw() as u64); + let pid = std::process::id(); + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let nonce_leaf = format!("cdb_test_{pid}_{nonce:016x}"); + let test_leaf_path = canonical_tmp.join(&nonce_leaf); + + // Ensure we never delete a pre-existing path + assert!( + !test_leaf_path.exists(), + "test nonce collision: path already exists" + ); + + // Positive case: prepare descriptor-relative runtime child + let prepared = prepare_macos_default_runtime_directory(&test_leaf_path, current_uid); + assert!( + prepared.is_ok(), + "prepare descriptor on owned nonce leaf must succeed" + ); + + // Identity check: verify created leaf metadata dev and ino + let metadata = std::fs::symlink_metadata(&test_leaf_path).expect("leaf metadata"); + assert_eq!(metadata.uid(), current_uid.0 as u32); + assert_eq!(metadata.mode() & 0o777, 0o700); + assert!(metadata.is_dir()); + assert!(!metadata.file_type().is_symlink()); + + let created_dev = metadata.dev(); + let created_ino = metadata.ino(); + + // Idempotent re-run: EEXIST tolerated, reopened via descriptor + let prepared_again = prepare_macos_default_runtime_directory(&test_leaf_path, current_uid); + assert!(prepared_again.is_ok(), "idempotent re-prepare must succeed"); + + // Verify inode identity has not changed before cleanup + let pre_cleanup_meta = std::fs::symlink_metadata(&test_leaf_path) + .expect("re-read leaf metadata before cleanup"); + assert_eq!( + pre_cleanup_meta.dev(), + created_dev, + "device changed before cleanup" + ); + assert_eq!( + pre_cleanup_meta.ino(), + created_ino, + "inode changed before cleanup" + ); + + // Cleanup only our test inode + std::fs::remove_dir(&test_leaf_path).expect("exclusive test leaf cleanup must succeed"); + assert!(!test_leaf_path.exists(), "test leaf must be removed"); + } + + #[test] + fn macos_default_descriptor_preparer_refuses_symlink_regular_file_and_insecure_mode() { + let native_root = ProcessRuntimeDirectoryEnvironment + .macos_user_temporary_directory() + .expect("native private temporary root"); + let test_parent = tempfile::tempdir_in(native_root).expect("private test parent"); + let canonical_tmp = test_parent.path(); + let current_uid = LocalUserIdentity(nix::unistd::geteuid().as_raw() as u64); + let pid = std::process::id(); + let base_nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + + // Negative 1: Symlink leaf refused via O_NOFOLLOW + let symlink_name = format!("cdb_test_symlink_{pid}_{base_nonce:016x}"); + let symlink_path = canonical_tmp.join(&symlink_name); + assert!(!symlink_path.exists()); + std::os::unix::fs::symlink(canonical_tmp, &symlink_path).expect("create test symlink leaf"); + let symlink_res = prepare_macos_default_runtime_directory(&symlink_path, current_uid); + // Darwin openat(O_NOFOLLOW | O_DIRECTORY) on a symlink returns ELOOP (mapped to SymbolicLink) + // or ENOTDIR (mapped to NotDirectory) if O_DIRECTORY is checked first. + assert!( + matches!( + symlink_res, + Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::SymbolicLink + )) | Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::NotDirectory + )) + ), + "symlink leaf must be refused via O_NOFOLLOW with SymbolicLink or NotDirectory: got {:?}", + symlink_res + ); + std::fs::remove_file(&symlink_path).expect("cleanup test symlink"); + + // Negative 2: Regular file leaf refused + let file_name = format!("cdb_test_file_{pid}_{base_nonce:016x}"); + let file_path = canonical_tmp.join(&file_name); + assert!(!file_path.exists()); + std::fs::write(&file_path, b"not-a-directory").expect("write test regular file"); + let file_res = prepare_macos_default_runtime_directory(&file_path, current_uid); + assert_eq!( + file_res, + Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::NotDirectory + )), + "regular file leaf must be refused with NotDirectory" + ); + std::fs::remove_file(&file_path).expect("cleanup test file"); + + // Negative 3: Insecure mode 0755 leaf refused + let insecure_name = format!("cdb_test_insecure_{pid}_{base_nonce:016x}"); + let insecure_path = canonical_tmp.join(&insecure_name); + assert!(!insecure_path.exists()); + let mut builder = std::fs::DirBuilder::new(); + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o755); + builder + .create(&insecure_path) + .expect("create test 0755 directory"); + let insecure_res = prepare_macos_default_runtime_directory(&insecure_path, current_uid); + assert_eq!( + insecure_res, + Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::InsecureMode + )), + "insecurely moded 0755 leaf must be refused with InsecureMode" + ); + std::fs::remove_dir(&insecure_path).expect("cleanup test 0755 directory"); + } +} diff --git a/crates/contextdb-engine/src/persistence.rs b/crates/contextdb-engine/src/persistence.rs index 587a0cf5..5fcded7f 100644 --- a/crates/contextdb-engine/src/persistence.rs +++ b/crates/contextdb-engine/src/persistence.rs @@ -27,10 +27,12 @@ use crate::database::trigger::TriggerPersistenceCommit; use crate::direct_file_reader::DirectTypedImageDigest; use crate::local_transport::{RuntimeDirectory, derive_channel_address}; use crate::sync_types::DdlChange; +#[cfg(target_os = "linux")] +use contextdb_core::read_contract::ReaderProcessIdentity; use contextdb_core::read_contract::{ ChannelAddress, DatabaseIdentity, HeldByReadersDetail, HeldByWriterDetail, LocalUserIdentity, OwnerReadCancellation, OwnerReadStatus, OwnerServingReason, OwnerServingState, ReadFailure, - ReadFailureDetail, ReadFailureKind, ReaderBreadcrumb, ReaderProcessIdentity, WriterRunNumber, + ReadFailureDetail, ReadFailureKind, ReaderBreadcrumb, WriterRunNumber, }; use contextdb_core::{ AdjEntry, ColumnType, DirectedValue, Error, ForeignKeyReference, IndexDecl, IndexKey, @@ -945,7 +947,7 @@ pub(crate) fn recorded_held_owner_status(database_path: &Path) -> Option Option Option, @@ -1324,8 +1328,9 @@ struct CoordinationWatch { #[cfg(unix)] impl CoordinationWatch { - /// Arm the watch BEFORE asking for the byte, so a release that happens - /// between the ask and the sleep is already queued rather than missed. + /// Arm BEFORE asking for the byte so cancellation can queue a wake before + /// sleep begins. Linux also queues companion notifications; macOS rechecks + /// the hold at its private interval. fn arm(companion: &Path, stop: Option<&OwnerReadCancellation>) -> std::io::Result { let wake = Arc::new(WakePipe::new()?); #[cfg(any(test, feature = "test-seams"))] @@ -1350,11 +1355,8 @@ impl CoordinationWatch { std::task::Waker::from(Arc::new(PipeWaker(Arc::clone(&self.wake)))) } - /// Sleep until something about this companion happens, or until somebody - /// pokes the wake pipe. On a platform with no notification channel the - /// caller's own thread takes the blocking acquisition instead: the answer - /// is the same and nothing is left running either way, but a stop is - /// noticed at the next wake rather than at once. + /// Sleep until a companion notification, a wake-pipe poke, or the supplied + /// timeout, then let the caller recheck its cancellation and trusted lock. #[cfg(target_os = "linux")] fn sleep( &self, @@ -1398,7 +1400,45 @@ impl CoordinationWatch { Ok(()) } - #[cfg(not(target_os = "linux"))] + /// Wait for cancellation or the next lock recheck without acquiring a hold. + #[cfg(target_os = "macos")] + fn sleep( + &self, + _held: &File, + _offset: u64, + timeout: Option, + ) -> std::io::Result<()> { + use std::os::fd::AsRawFd as _; + + // A private scheduling bound, not a caller deadline or retry policy. + // Only the outer trusted nonblocking probe can establish release. + const LOCK_RECHECK_QUANTUM: std::time::Duration = std::time::Duration::from_millis(10); + let timeout = timeout + .unwrap_or(LOCK_RECHECK_QUANTUM) + .min(LOCK_RECHECK_QUANTUM); + // Round down so even a sub-millisecond remaining allowance is never + // extended. An interruption returns to the caller instead of renewing it. + let timeout_ms = timeout.as_millis() as libc::c_int; + let mut watched = libc::pollfd { + fd: self.wake.reader.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // Arm precedes the outer probe. Do not drain here: cancellation or a + // deadline wake queued between that probe and this poll must be seen. + let ready = unsafe { libc::poll(&mut watched, 1, timeout_ms) }; + if ready < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::Interrupted { + return Err(error); + } + return Ok(()); + } + self.wake.drain(); + Ok(()) + } + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] fn sleep( &self, held: &File, @@ -1553,12 +1593,11 @@ pub(crate) fn observe_unsettled_claim(database_path: &Path) -> Option Option { /// out: taking the shared side of the same lock a writer takes exclusively /// answers it and is released immediately. fn writer_holds_companion(file: &File) -> Option { - match fs2::FileExt::try_lock_shared(file) { + match companion_ownership_lock(file, CompanionOwnershipLock::Shared) { Ok(()) => { unlock_file(file); Some(false) @@ -2720,7 +2759,11 @@ fn current_reader_breadcrumb() -> std::io::Result { "the current process name could not be verified", )) } - #[cfg(not(target_os = "linux"))] + #[cfg(target_os = "macos")] + { + macos_reader_breadcrumb(process_id) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] { let _ = process_id; Err(std::io::Error::new( @@ -2741,7 +2784,21 @@ fn current_reader_registration_process() -> std::io::Result { process_start: linux_process_start(std::process::id())?, }) } - #[cfg(not(target_os = "linux"))] + #[cfg(target_os = "macos")] + { + let process_name = std::env::current_exe()? + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| safe_reader_process_display_name(name)) + .unwrap_or("contextdb-reader") + .to_owned(); + Ok(ReaderBreadcrumb { + process_id: std::process::id(), + process_name, + process_start: macos_process_start(std::process::id())?, + }) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] { let process_name = std::env::current_exe()? .file_name() @@ -2785,6 +2842,250 @@ fn linux_process_start( Ok(contextdb_core::read_contract::ProcessStartIdentity(value)) } +#[cfg(target_os = "macos")] +fn macos_process_start( + process_id: u32, +) -> std::io::Result { + macos_process_info(process_id).map(|(_, start)| start) +} + +#[cfg(all(test, target_os = "macos"))] +mod macos_process_info_observation { + use std::cell::RefCell; + use std::collections::VecDeque; + + /// One raw native result, not a validated identity. Ordinals start at one + /// within each scope; every call must match both its ordinal and its PID. + pub(super) struct Observation { + pub(super) ordinal: usize, + pub(super) pid: libc::c_int, + pub(super) info: libc::proc_bsdinfo, + pub(super) written: libc::c_int, + pub(super) errno: libc::c_int, + } + + struct State { + pending: VecDeque, + calls: usize, + consumed: usize, + rejected: bool, + } + + thread_local! { + static STATE: RefCell> = const { RefCell::new(None) }; + } + + struct Restore { + previous: Option, + errno: libc::c_int, + } + + impl Drop for Restore { + fn drop(&mut self) { + let finished = STATE.with(|slot| slot.replace(self.previous.take())); + // SAFETY: Darwin's __error returns this thread's live errno slot. + unsafe { *libc::__error() = self.errno }; + // Restore first, even if a queue assertion fails. During unwinding, + // preserve the original panic instead of causing a double panic. + if !std::thread::panicking() { + let finished = finished.expect("native observation scope is absent"); + assert!( + !finished.rejected, + "native observation sequence was rejected" + ); + assert!( + finished.pending.is_empty(), + "native observation scope has {} unused results ({} calls, {} consumed)", + finished.pending.len(), + finished.calls, + finished.consumed, + ); + } + } + } + + /// Runs on this thread only. Nested scopes suspend the outer queue and + /// counters; leaving either scope restores its predecessor and native errno. + /// A caught sequence panic still rejects the scope on normal exit. + pub(super) fn with_observations( + observations: Vec, + run: impl FnOnce() -> R, + ) -> R { + // SAFETY: Darwin's __error returns this thread's live errno slot. + let errno = unsafe { *libc::__error() }; + let previous = STATE.with(|slot| { + slot.replace(Some(State { + pending: observations.into(), + calls: 0, + consumed: 0, + rejected: false, + })) + }); + let _restore = Restore { previous, errno }; + run() + } + + /// (raw calls attempted, queued results consumed) in the active scope. + pub(super) fn counts() -> (usize, usize) { + STATE.with(|slot| { + let state = slot.borrow(); + let state = state.as_ref().expect("native observation scope is absent"); + (state.calls, state.consumed) + }) + } + + /// None selects the unchanged libc call. An active empty/misordered queue + /// panics rather than falling through to a real process observation. + pub(super) fn observe(pid: libc::c_int, info: &mut libc::proc_bsdinfo) -> Option { + STATE.with(|slot| { + let mut slot = slot.borrow_mut(); + let state = slot.as_mut()?; + state.calls += 1; + state.rejected |= state.pending.front().is_none_or(|observation| { + observation.ordinal != state.calls || observation.pid != pid + }); + assert!( + !state.rejected, + "native observation mismatch or exhausted queue at call {} for PID {}", + state.calls, pid, + ); + let observation = state.pending.pop_front().expect("checked pending result"); + *info = observation.info; + state.consumed += 1; + if observation.written <= 0 { + // SAFETY: Darwin's __error returns this thread's live errno slot. + unsafe { *libc::__error() = observation.errno }; + } + Some(observation.written) + }) + } +} + +#[cfg(target_os = "macos")] +fn macos_process_info( + process_id: u32, +) -> std::io::Result<( + libc::proc_bsdinfo, + contextdb_core::read_contract::ProcessStartIdentity, +)> { + let pid = libc::c_int::try_from(process_id) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?; + if pid == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "a process id of 0 has no reader start identity", + )); + } + // SAFETY: `proc_bsdinfo` is a plain integer record. All-zero is a valid value. + let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; + let size = libc::c_int::try_from(std::mem::size_of::()) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?; + const _: () = assert!(std::mem::size_of::() == 136); + #[cfg(all(test, target_os = "macos"))] + let written = macos_process_info_observation::observe(pid, &mut info).unwrap_or_else(|| { + // SAFETY: same native record, size, flavor and argument as the non-test call below. + unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDTBSDINFO, + 0, + std::ptr::addr_of_mut!(info).cast(), + size, + ) + } + }); + // SAFETY: `info` is a live `proc_bsdinfo` and `size` is its byte length. + // `PROC_PIDTBSDINFO` with arg 0 returns that record and does not select a zombie. + #[cfg(not(test))] + let written = unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDTBSDINFO, + 0, + std::ptr::addr_of_mut!(info).cast(), + size, + ) + }; + if written <= 0 { + return Err(std::io::Error::last_os_error()); + } + if written != size { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "macOS process information has an unexpected size", + )); + } + if info.pbi_pid != process_id || info.pbi_start_tvusec >= 1_000_000 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "macOS process start identity was rejected", + )); + } + let packed = info + .pbi_start_tvsec + .checked_mul(1_000_000) + .and_then(|seconds| seconds.checked_add(info.pbi_start_tvusec)) + .filter(|packed| *packed != 0) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "macOS process start identity was rejected", + ) + })?; + Ok(( + info, + contextdb_core::read_contract::ProcessStartIdentity(packed), + )) +} + +#[cfg(target_os = "macos")] +fn macos_reader_process_display_name(info: &libc::proc_bsdinfo) -> Option { + // Inspect only the native fixed-size buffers, never an unbounded CStr or + // caller-supplied argv. Either kernel-observed name can identify a reader. + for field in [&info.pbi_name[..], &info.pbi_comm[..]] { + let Some(end) = field.iter().position(|byte| *byte == 0) else { + continue; + }; + let bytes = field[..end] + .iter() + .map(|byte| *byte as u8) + .collect::>(); + if let Ok(name) = std::str::from_utf8(&bytes) + && safe_reader_process_display_name(name) + { + return Some(name.to_owned()); + } + } + None +} + +#[cfg(target_os = "macos")] +fn macos_reader_breadcrumb(process_id: u32) -> std::io::Result { + let (before, process_start) = macos_process_info(process_id)?; + let process_name = macos_reader_process_display_name(&before).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "macOS reader has no safe operating-system process name", + ) + })?; + // A PID may have been recycled while its name was inspected. Require the + // same nonzero microsecond start identity and display name on both sides. + let (after, after_start) = macos_process_info(process_id)?; + if after_start != process_start + || macos_reader_process_display_name(&after).as_deref() != Some(process_name.as_str()) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "macOS reader process identity changed during inspection", + )); + } + Ok(ReaderBreadcrumb { + process_id, + process_name, + process_start, + }) +} + fn next_reader_breadcrumb_path( database_path: &Path, runtime_root: &Path, @@ -4133,7 +4434,15 @@ fn verify_locked_reader_breadcrumb(breadcrumb: ReaderBreadcrumb) -> Option bool .is_ok_and(|metadata| (metadata.dev(), metadata.ino()) == snapshot.physical_identity) } -#[cfg(not(target_os = "linux"))] +#[cfg(target_os = "macos")] +fn reader_snapshot_owns_descriptor(snapshot: &RegisteredReaderSnapshot) -> bool { + let stored = snapshot.process.process_start; + if stored == contextdb_core::read_contract::ProcessStartIdentity(0) { + return false; + } + if macos_process_start(snapshot.process.process_id).ok() != Some(stored) { + return false; + } + if macos_descriptor_identity(snapshot.process.process_id, snapshot.descriptor_number).ok() + != Some(snapshot.physical_identity) + { + return false; + } + macos_process_start(snapshot.process.process_id).ok() == Some(stored) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] fn reader_snapshot_owns_descriptor(_snapshot: &RegisteredReaderSnapshot) -> bool { false } +#[cfg(target_os = "macos")] +fn macos_descriptor_identity(process_id: u32, descriptor: u32) -> std::io::Result<(u64, u64)> { + // libc 0.2.182 declares `vnode_info` and not `vnode_fdinfo`. Flavor 1 is + // `proc_fileinfo` plus `vnode_info`. Flavor 2 also applies a path check. + #[repr(C)] + struct ProcFileInfo { + fi_openflags: u32, + fi_status: u32, + fi_offset: libc::off_t, + fi_type: i32, + fi_guardflags: u32, + } + #[repr(C)] + struct VnodeFdInfo { + pfi: ProcFileInfo, + pvi: libc::vnode_info, + } + const PROC_PIDFDVNODEINFO: libc::c_int = 1; + // `enum vtype`: `VNON` is 0 and `VREG` is 1. + const VREG: libc::c_int = 1; + const _: () = assert!(std::mem::size_of::() == 24); + const _: () = assert!(std::mem::size_of::() == 136); + const _: () = assert!(std::mem::size_of::() == 152); + const _: () = assert!(std::mem::size_of::() == 176); + + let pid = libc::c_int::try_from(process_id) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?; + if pid == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "a process id of 0 has no reader descriptor", + )); + } + let fd = libc::c_int::try_from(descriptor) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?; + // SAFETY: the record is plain integers. All-zero is a valid value. + let mut info: VnodeFdInfo = unsafe { std::mem::zeroed() }; + let size = libc::c_int::try_from(std::mem::size_of::()) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?; + // SAFETY: `info` is a live flavor-1 record and `size` is its byte length. + let written = unsafe { + libc::proc_pidfdinfo( + pid, + fd, + PROC_PIDFDVNODEINFO, + std::ptr::addr_of_mut!(info).cast(), + size, + ) + }; + if written != size { + return Err(std::io::Error::last_os_error()); + } + if info.pfi.fi_type != libc::PROX_FDTYPE_VNODE + || info.pvi.vi_type != VREG + || (info.pvi.vi_stat.vst_mode & libc::S_IFMT) != libc::S_IFREG + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "reader descriptor is not the registered regular file", + )); + } + // `MetadataExt::dev` casts Apple `dev_t` (`i32`) with `as u64`, which + // sign-extends. `vst_dev` holds those same bits in a `u32`. + let device = (info.pvi.vi_stat.vst_dev as i32) as u64; + Ok((device, info.pvi.vi_stat.vst_ino)) +} + #[cfg(unix)] fn capture_reader_snapshot_registry( identity: (u64, u64), @@ -4812,20 +5205,18 @@ pub enum ReaderReleaseWait { /// says nothing about whether the store is free; and a store nobody is holding /// is released at once, whatever can or cannot be read about readers. /// -/// It is EVENT-DRIVEN, and deliberately has no timer anywhere in it. The -/// blocking acquisition puts this thread to sleep in the kernel until the last -/// holder lets go -- or dies, which the kernel treats as the same event. The -/// wake IS the release; nothing polls, and no interval decides how quickly a -/// waiter learns. Nothing in the store folder is opened, read, or locked. +/// Linux waits on companion notifications; macOS waits on the cancellation +/// pipe with a private lock-recheck interval of at most 10 ms. Each wake returns +/// to the trusted nonblocking companion probe: only taking the hold establishes +/// release. Nothing in the store folder is opened, read, or locked. /// /// Known runtime directories can name an unobservable hold. The wait verdict /// itself comes from the companion hold. /// -/// `stop` ends the wait immediately: the caller is told the moment the token -/// is cancelled, through the token's own listener, so an interrupt is as -/// prompt as a release. A wait ended that way answers `Stopped` while the -/// kernel wait it started finishes on its own; that helper releases nothing -/// and holds nothing once it does. +/// On Linux and macOS, `stop` wakes the caller's own waiting thread through +/// the token's listener and answers `Stopped`; no helper or queued lock remains. +/// Other Unix targets retain their blocking fallback and observe cancellation +/// only when that acquisition returns. /// The same wait, told the runtime ROOT an operator supplied. /// /// The root selects the breadcrumbs used to explain an unobservable hold. @@ -4835,9 +5226,8 @@ pub enum ReaderReleaseWait { /// A root that cannot be read does not turn a released companion hold into /// `Unobservable`; open validation reports the root's configuration refusal. /// -/// Every other part of the contract is [`wait_for_reader_release`]'s -- the -/// kernel wake IS the event, there is no timer anywhere in it, and `stop` is -/// answered as promptly as a release. +/// Every other part of the contract, including platform-specific wake behavior, +/// is [`wait_for_reader_release`]'s. pub fn wait_for_reader_release_in_runtime_dir( path: &Path, runtime_root: Option<&Path>, @@ -4862,9 +5252,9 @@ pub fn wait_for_reader_release( Ok(file) => file, Err(error) => return unobservable_reader_hold(path, runtime, error), }; - // Armed BEFORE the first ask, so a reader that lets go in the gap between - // the ask and the sleep has already left a notification queued rather than - // slipping past unseen. + // Arm BEFORE the first ask so cancellation cannot slip between the probe + // and sleep. Linux also queues release notifications; macOS bounds the next + // trusted release probe by its private recheck interval. let watch = match CoordinationWatch::arm(&companion, Some(stop)) { Ok(watch) => watch, Err(error) => return unobservable_reader_hold(path, runtime, error), @@ -4885,15 +5275,10 @@ pub fn wait_for_reader_release( read_persistence_test_scaffold::note_reader_release_block_for_test( read_persistence_test_scaffold::ReaderReleaseBlockForTest::UntilHolderReleased, ); - // The sleep IS the wait: this thread wakes when the kernel says - // something happened to the companion -- a holder closing its - // descriptor, which is what letting go looks like and what the kernel - // does for a holder that dies -- or when this caller asks to stop. No - // deadline of this wait's own is supplied, because a deadline is a poll - // interval and a poll interval is how long a caller keeps a free store - // hostage after it is free. And the sleeping is done HERE, on the - // caller's own thread, so a caller that stops waiting leaves nothing - // anywhere still queued for a hold nobody wants. + // The caller owns its deadline through `stop`; this wait adds none. + // Linux wakes on companion events or cancellation. macOS watches the + // cancellation pipe and schedules a trusted lock recheck within 10 ms. + // Both sleep here on the caller's thread, without a queued acquisition. if let Err(error) = watch.sleep(&held, COMPANION_READER_HOLD_LOCK_OFFSET, None) { return unobservable_reader_hold(path, runtime, error); } @@ -34478,8 +34863,46 @@ impl Drop for RedbPersistence { } } +/// Every companion ownership operation addresses the same lock, including +/// the short shared probe and error cleanup. macOS whole-file locks overlap +/// the reader and claim-window bytes, so ownership uses a separate OFD byte. +/// An older whole-file owner still overlaps that byte and excludes a writer. +#[derive(Clone, Copy)] +enum CompanionOwnershipLock { + Shared, + Exclusive, + Unlock, +} + +#[cfg(target_os = "macos")] +fn companion_ownership_lock(file: &File, operation: CompanionOwnershipLock) -> std::io::Result<()> { + let lock_type = match operation { + CompanionOwnershipLock::Shared => RangeLockMode::Shared.lock_type(), + CompanionOwnershipLock::Exclusive => RangeLockMode::Exclusive.lock_type(), + CompanionOwnershipLock::Unlock => libc::F_UNLCK as libc::c_short, + }; + match companion_range_lock( + file, + COMPANION_WRITER_OWNERSHIP_LOCK_OFFSET, + lock_type, + false, + )? { + RangeLockOutcome::Taken => Ok(()), + RangeLockOutcome::HeldByAnother => Err(std::io::ErrorKind::WouldBlock.into()), + } +} + +#[cfg(not(target_os = "macos"))] +fn companion_ownership_lock(file: &File, operation: CompanionOwnershipLock) -> std::io::Result<()> { + match operation { + CompanionOwnershipLock::Shared => fs2::FileExt::try_lock_shared(file), + CompanionOwnershipLock::Exclusive => fs2::FileExt::try_lock_exclusive(file), + CompanionOwnershipLock::Unlock => fs2::FileExt::unlock(file), + } +} + fn try_lock_exclusive(file: &File) -> Result { - match fs2::FileExt::try_lock_exclusive(file) { + match companion_ownership_lock(file, CompanionOwnershipLock::Exclusive) { Ok(()) => Ok(true), Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => Ok(false), Err(err) => Err(Error::Other(format!("companion lock error: {err}"))), @@ -34487,9 +34910,13 @@ fn try_lock_exclusive(file: &File) -> Result { } fn unlock_file(file: &File) { - let _ = fs2::FileExt::unlock(file); + let _ = companion_ownership_lock(file, CompanionOwnershipLock::Unlock); } +#[cfg(all(test, any(target_os = "linux", target_os = "macos")))] +#[path = "persistence/reader_coordination_wait_tests.rs"] +mod reader_coordination_wait_tests; + #[cfg(test)] mod reader_breadcrumb_location_proofs { use super::*; @@ -34547,6 +34974,834 @@ mod reader_breadcrumb_location_proofs { assert_eq!(verified.len(), 1); drop(guard); assert!(root.is_dir(), "reader teardown preserves the supplied root"); + + // A named holder must match the complete OS identity, not just a PID. + // These are claimed-marker mutations, not simulated OS observations. + let runtime = RuntimeDirectory::supplied(&root); + assert_ne!(breadcrumb.process_start.0, 0); + for (label, claimed) in [ + ( + "zero PID", + ReaderBreadcrumb { + process_id: 0, + ..breadcrumb.clone() + }, + ), + ( + "invalid PID", + ReaderBreadcrumb { + process_id: u32::MAX, + ..breadcrumb.clone() + }, + ), + ( + "zero start", + ReaderBreadcrumb { + process_start: contextdb_core::read_contract::ProcessStartIdentity(0), + ..breadcrumb.clone() + }, + ), + ( + "changed start", + ReaderBreadcrumb { + process_start: contextdb_core::read_contract::ProcessStartIdentity( + breadcrumb.process_start.0 ^ 1, + ), + ..breadcrumb.clone() + }, + ), + ( + "empty name", + ReaderBreadcrumb { + process_name: String::new(), + ..breadcrumb.clone() + }, + ), + ( + "control name", + ReaderBreadcrumb { + process_name: "reader\x1b[2J".into(), + ..breadcrumb.clone() + }, + ), + ( + "invented name", + ReaderBreadcrumb { + process_name: format!("unobserved-{}", breadcrumb.process_name), + ..breadcrumb.clone() + }, + ), + ] { + let guard = ReaderBreadcrumbGuard::create(&store, &root, Some(&claimed)) + .expect("publish a trusted locked negative control"); + let (count, named) = observed_reader_holders(&store, &runtime) + .expect("inspect the real locked file and OS identity"); + assert_eq!(count, 1, "{label}: an unverified holder still counts"); + assert!( + named.is_empty(), + "{label}: must disclose no identity: {named:?}" + ); + drop(guard); + } + let anonymous = ReaderBreadcrumbGuard::create(&store, &root, None) + .expect("publish the production anonymous marker"); + assert_eq!( + observed_reader_holders(&store, &runtime).unwrap(), + (1, vec![]) + ); + drop(anonymous); + + #[cfg(unix)] + { + use std::os::unix::fs::{PermissionsExt as _, symlink}; + let guard = ReaderBreadcrumbGuard::create(&store, &root, Some(&breadcrumb)) + .expect("publish the trusted-file positive control"); + let path = &guard.path; + assert!(open_trusted_reader_breadcrumb(path).is_ok()); + for mode in [0o400, 0o640, 0o660, 0o644, 0o1600] { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap(); + assert!( + open_trusted_reader_breadcrumb(path).is_err(), + "mode {mode:o}" + ); + assert!( + observed_reader_holders(&store, &runtime) + .unwrap() + .1 + .is_empty() + ); + } + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let alias = directory.path().join("extra-link"); + std::fs::hard_link(path, &alias).unwrap(); + assert!(open_trusted_reader_breadcrumb(path).is_err()); + assert!( + observed_reader_holders(&store, &runtime) + .unwrap() + .1 + .is_empty() + ); + std::fs::remove_file(&alias).unwrap(); + + let displaced = directory.path().join("original-marker"); + std::fs::rename(path, &displaced).unwrap(); + symlink(&displaced, path).unwrap(); + assert!(open_trusted_reader_breadcrumb(path).is_err()); + assert!( + observed_reader_holders(&store, &runtime) + .unwrap() + .1 + .is_empty() + ); + std::fs::remove_file(path).unwrap(); + std::fs::rename(&displaced, path).unwrap(); + assert_eq!( + observed_reader_holders(&store, &runtime).unwrap(), + (1, vec![breadcrumb.clone()]), + "restoring the same trusted locked inode restores the verified identity" + ); + drop(guard); + } + assert_eq!( + observed_reader_holders(&store, &runtime).unwrap(), + (0, vec![]) + ); + + #[cfg(target_os = "macos")] + { + // Decode actual libc fields; do not reproduce the Darwin ABI. + // None bytes means a buffer with no in-bounds NUL terminator. + let fields: &[(&str, Option<&[u8]>, Option<&str>)] = &[ + ("safe", Some(b"native-reader"), Some("native-reader")), + ("empty", Some(b""), None), + ("non UTF-8", Some(b"\xff"), None), + ("non ASCII UTF-8", Some("é".as_bytes()), None), + ("escape", Some(b"r\x1b[2J"), None), + ("newline", Some(b"r\n"), None), + ("DEL", Some(b"r\x7f"), None), + ("slash", Some(b"r/x"), None), + ("backslash", Some(b"r\\x"), None), + ("unterminated", None, None), + ( + "different safe", + Some(b"other-reader"), + Some("other-reader"), + ), + ]; + let fill = |field: &mut [libc::c_char], bytes: Option<&[u8]>| { + field.fill(b'X' as libc::c_char); + if let Some(bytes) = bytes { + assert!(bytes.len() < field.len()); + for (dst, src) in field.iter_mut().zip(bytes) { + *dst = *src as libc::c_char; + } + field[bytes.len()] = 0; + } + }; + for &(name_label, name_bytes, name_expected) in fields { + for &(comm_label, comm_bytes, comm_expected) in fields { + // SAFETY: libc's record consists solely of integer fields. + let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; + fill(&mut info.pbi_name, name_bytes); + fill(&mut info.pbi_comm, comm_bytes); + assert_eq!( + macos_reader_process_display_name(&info).as_deref(), + name_expected.or(comm_expected), + "name={name_label}, comm={comm_label}" + ); + } + } + for pid in [0, i32::MAX as u32 + 1, u32::MAX] { + let error = macos_process_info(pid) + .err() + .expect("invalid PID must fail"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + } + } + #[cfg(target_os = "macos")] + native_observation_rows::run(&store, &root); + } + + // Rows of the existing writer/reader identity proof, using only the approved + // raw-call seam. This module is absent from Linux and production builds. + #[cfg(target_os = "macos")] + mod native_observation_rows { + use super::super::macos_process_info_observation::{ + Observation, counts, with_observations, + }; + use super::*; + use contextdb_core::read_contract::ProcessStartIdentity; + use std::os::unix::fs::MetadataExt as _; + + #[derive(Clone)] + struct Raw { + pid: u32, + sec: u64, + usec: u64, + name: Option>, + comm: Option>, + written: libc::c_int, + errno: libc::c_int, + } + + fn stable(pid: u32) -> Raw { + Raw { + pid, + sec: 1, + usec: 2, + name: Some(b"native-reader".to_vec()), + comm: Some(b"native-reader".to_vec()), + written: libc::c_int::try_from(std::mem::size_of::()).unwrap(), + errno: 0, + } + } + + fn fill(field: &mut [libc::c_char], value: &Option>) { + field.fill(b'X' as libc::c_char); + if let Some(bytes) = value { + assert!(bytes.len() < field.len()); + for (dst, src) in field.iter_mut().zip(bytes) { + *dst = *src as libc::c_char; + } + field[bytes.len()] = 0; + } + } + + fn queue(requested: u32, raws: &[Raw]) -> Vec { + raws.iter() + .enumerate() + .map(|(index, raw)| { + // SAFETY: the real libc record contains only integer fields. + let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; + info.pbi_pid = raw.pid; + info.pbi_start_tvsec = raw.sec; + info.pbi_start_tvusec = raw.usec; + fill(&mut info.pbi_name, &raw.name); + fill(&mut info.pbi_comm, &raw.comm); + Observation { + ordinal: index + 1, + pid: libc::c_int::try_from(requested).unwrap(), + info, + written: raw.written, + errno: raw.errno, + } + }) + .collect() + } + + fn expected(pid: u32, packed: u64, name: &str) -> ReaderBreadcrumb { + ReaderBreadcrumb { + process_id: pid, + process_start: ProcessStartIdentity(packed), + process_name: name.into(), + } + } + + fn descriptor(guard: &ReaderBreadcrumbGuard) -> (u64, u64) { + let held = guard.file.as_ref().unwrap().metadata().unwrap(); + let (opened, path) = open_trusted_reader_breadcrumb(&guard.path).unwrap(); + let opened = opened.metadata().unwrap(); + assert_eq!((opened.dev(), opened.ino()), (held.dev(), held.ino())); + assert_eq!((path.dev(), path.ino()), (held.dev(), held.ino())); + assert_eq!(held.nlink(), 1); + assert_eq!(held.mode() & 0o7777, 0o600); + (held.dev(), held.ino()) + } + + fn positive( + store: &Path, + root: &Path, + guard: &ReaderBreadcrumbGuard, + raws: &[Raw], + want: &ReaderBreadcrumb, + inode: (u64, u64), + ) { + assert_eq!(descriptor(guard), inode); + with_observations(queue(want.process_id, raws), || { + assert_eq!(current_reader_breadcrumb().unwrap(), *want); + assert_eq!(counts(), (2, 2)); + }); + with_observations(queue(want.process_id, raws), || { + assert_eq!( + observed_reader_holders(store, &RuntimeDirectory::supplied(root)).unwrap(), + (1, vec![want.clone()]) + ); + assert_eq!(counts(), (2, 2)); + }); + assert_eq!(descriptor(guard), inode); + } + + // Every failure is driven through BOTH the creator and the real locked + // verifier. The restored control uses the SAME held file descriptor. + fn reject( + store: &Path, + root: &Path, + label: &str, + requested: u32, + raws: &[Raw], + errno: Option, + invalid_input: bool, + ) { + let pid = std::process::id(); + let want = expected(pid, 1_000_002, "native-reader"); + let claim = ReaderBreadcrumb { + process_id: requested, + ..want.clone() + }; + let mut guard = ReaderBreadcrumbGuard::create(store, root, Some(&claim)).unwrap(); + let inode = descriptor(&guard); + let observations = || { + if invalid_input { + vec![] + } else { + queue(requested, raws) + } + }; + with_observations(observations(), || { + let error = if requested == std::process::id() { + current_reader_breadcrumb() + } else { + macos_reader_breadcrumb(requested) + } + .unwrap_err(); + if let Some(errno) = errno { + assert_eq!(error.raw_os_error(), Some(errno), "{label}"); + } else { + assert_eq!( + error.kind(), + if invalid_input { + std::io::ErrorKind::InvalidInput + } else { + std::io::ErrorKind::InvalidData + }, + "{label}" + ); + } + assert_eq!(counts(), (raws.len(), raws.len()), "{label}: creator"); + }); + with_observations(observations(), || { + assert_eq!( + observed_reader_holders(store, &RuntimeDirectory::supplied(root)).unwrap(), + (1, vec![]), + "{label}: rejected identity must remain anonymous" + ); + assert_eq!(counts(), (raws.len(), raws.len()), "{label}: verifier"); + }); + assert_eq!(descriptor(&guard), inode); + // Invalid caller controls have a different claimed PID. Replace only + // the bytes on the held inode, using the production encoder. + if requested != pid { + let file = guard.file.as_mut().unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + file.set_len(0).unwrap(); + file.write_all(&encode_reader_breadcrumb(&want).unwrap()) + .unwrap(); + file.sync_data().unwrap(); + } + let good = stable(pid); + positive(store, root, &guard, &[good.clone(), good], &want, inode); + drop(guard); + assert_eq!( + observed_reader_holders(store, &RuntimeDirectory::supplied(root)).unwrap(), + (0, vec![]), + "{label}: release" + ); + } + + fn both_reads(store: &Path, root: &Path, label: &str, bad: Raw, errno: Option) { + let good = stable(std::process::id()); + // Validate raw-record failures directly as well: otherwise a later + // changed-tuple rejection could mask a broken arithmetic guard. + if !label.starts_with("invalid-name/") { + with_observations(queue(good.pid, &[bad.clone()]), || { + let error = macos_process_info(good.pid) + .err() + .expect("raw record must be rejected"); + if let Some(errno) = errno { + assert_eq!(error.raw_os_error(), Some(errno), "{label}"); + } else { + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData, "{label}"); + } + assert_eq!(counts(), (1, 1)); + }); + } + // Second-read cases precede first-read cases so accepting an invalid + // selected name fails an outcome assertion before queue exhaustion. + reject( + store, + root, + &format!("{label}/second"), + good.pid, + &[good.clone(), bad.clone()], + errno, + false, + ); + reject( + store, + root, + &format!("{label}/first"), + good.pid, + &[bad.clone()], + errno, + false, + ); + } + + pub(super) fn run(store: &Path, root: &Path) { + let pid = std::process::id(); + assert!(pid > 1 && pid < i32::MAX as u32); + let good = stable(pid); + // Exact stable positives run before negatives; a skipped recheck or + // invented constant name must fail an identity/count assertion. + for name in ["native-reader", "other-reader"] { + let raw = Raw { + name: Some(name.as_bytes().to_vec()), + comm: Some(name.as_bytes().to_vec()), + ..good.clone() + }; + let want = expected(pid, 1_000_002, name); + let guard = ReaderBreadcrumbGuard::create(store, root, Some(&want)).unwrap(); + positive( + store, + root, + &guard, + &[raw.clone(), raw], + &want, + descriptor(&guard), + ); + } + // Raw failure is the Cartesian product, not representative samples. + for written in [-1, 0] { + for errno in [libc::EACCES, libc::ESRCH] { + let bad = Raw { + written, + errno, + ..good.clone() + }; + both_reads( + store, + root, + &format!("raw/{written}/errno/{errno}"), + bad, + Some(errno), + ); + } + } + let size = good.written; + for written in (1..size).chain(std::iter::once(size + 1)) { + both_reads( + store, + root, + &format!("length/{written}"), + Raw { + written, + ..good.clone() + }, + None, + ); + } + for returned in [0, pid - 1, pid + 1] { + both_reads( + store, + root, + &format!("returned-pid/{returned}"), + Raw { + pid: returned, + ..good.clone() + }, + None, + ); + } + for requested in [0, i32::MAX as u32 + 1, u32::MAX] { + // Leave a result ready for the specific forbidden truncated call. + // The valid case makes zero production calls; drain it only + // after asserting that, so queue exhaustion cannot mask a failure. + let mut pending = queue( + pid, + &[Raw { + pid: requested, + ..good.clone() + }], + ); + pending[0].pid = requested as libc::c_int; + with_observations(pending, || { + let error = macos_process_info(requested) + .err() + .expect("caller PID must be rejected"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!(counts(), (0, 0), "production must reject before the FFI"); + let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; + assert!( + super::super::macos_process_info_observation::observe( + requested as libc::c_int, + &mut info + ) + .is_some() + ); + assert_eq!( + counts(), + (1, 1), + "only the explicit test drain consumed the result" + ); + }); + reject( + store, + root, + &format!("caller-pid/{requested}"), + requested, + &[], + None, + true, + ); + } + for (label, sec, usec) in [ + ("zero-start", 0, 0), + ("microsecond-limit", 1, 1_000_000), + ("microsecond-max", 1, u64::MAX), + ("multiply-overflow", u64::MAX / 1_000_000 + 1, 0), + ( + "add-overflow", + u64::MAX / 1_000_000, + u64::MAX % 1_000_000 + 1, + ), + // Wrapping exactly at MAX+1 becomes zero and is also rejected + // by the nonzero guard. MAX+2 isolates checked-add itself. + ( + "add-overflow-nonzero", + u64::MAX / 1_000_000, + u64::MAX % 1_000_000 + 2, + ), + ] { + both_reads( + store, + root, + label, + Raw { + sec, + usec, + ..good.clone() + }, + None, + ); + } + for (sec, usec, packed) in [ + (0, 1, 1), + (1, 0, 1_000_000), + (1, 999_999, 1_999_999), + (u64::MAX / 1_000_000, u64::MAX % 1_000_000, u64::MAX), + ] { + let raw = Raw { + sec, + usec, + ..good.clone() + }; + let want = expected(pid, packed, "native-reader"); + let guard = ReaderBreadcrumbGuard::create(store, root, Some(&want)).unwrap(); + positive( + store, + root, + &guard, + &[raw.clone(), raw], + &want, + descriptor(&guard), + ); + } + for (label, after) in [ + ( + "changed-start", + Raw { + usec: 3, + ..good.clone() + }, + ), + ( + "changed-pid", + Raw { + pid: pid + 1, + ..good.clone() + }, + ), + ( + "changed-selected-name", + Raw { + name: Some(b"other-reader".to_vec()), + ..good.clone() + }, + ), + ] { + reject(store, root, label, pid, &[good.clone(), after], None, false); + } + // All nine invalid fixtures from the independent name-field table. + let invalid: &[(&str, Option<&[u8]>)] = &[ + ("empty", Some(b"")), + ("non-utf8", Some(b"\xff")), + ("non-ascii", Some("é".as_bytes())), + ("escape", Some(b"r\x1b[2J")), + ("newline", Some(b"r\n")), + ("del", Some(b"r\x7f")), + ("slash", Some(b"r/x")), + ("backslash", Some(b"r\\x")), + ("unterminated", None), + ]; + for &(label, bytes) in invalid { + let value = bytes.map(<[u8]>::to_vec); + both_reads( + store, + root, + &format!("invalid-name/{label}"), + Raw { + name: value.clone(), + comm: value, + ..good.clone() + }, + None, + ); + let recovered = Raw { + name: bytes.map(<[u8]>::to_vec), + ..good.clone() + }; + let want = expected(pid, 1_000_002, "native-reader"); + let guard = ReaderBreadcrumbGuard::create(store, root, Some(&want)).unwrap(); + let inode = descriptor(&guard); + positive( + store, + root, + &guard, + &[recovered.clone(), recovered.clone()], + &want, + inode, + ); + // Selected name is stable even when its source buffer changes. + positive( + store, + root, + &guard, + &[good.clone(), recovered.clone()], + &want, + inode, + ); + positive( + store, + root, + &guard, + &[recovered, good.clone()], + &want, + inode, + ); + } + // A safe primary wins over a different safe comm, observed exactly. + let primary = Raw { + comm: Some(b"other-reader".to_vec()), + ..good.clone() + }; + let want = expected(pid, 1_000_002, "native-reader"); + let guard = ReaderBreadcrumbGuard::create(store, root, Some(&want)).unwrap(); + positive( + store, + root, + &guard, + &[primary.clone(), primary], + &want, + descriptor(&guard), + ); + drop(guard); + for (label, claim) in [ + ("claimed-start", expected(pid, 1_000_003, "native-reader")), + ("claimed-zero-start", expected(pid, 0, "native-reader")), + ("claimed-name", expected(pid, 1_000_002, "forged-reader")), + ] { + let mut guard = ReaderBreadcrumbGuard::create(store, root, Some(&claim)).unwrap(); + let inode = descriptor(&guard); + with_observations(queue(pid, &[good.clone(), good.clone()]), || { + // Creator succeeds with the observed tuple, not the claim. + assert_eq!(macos_reader_breadcrumb(pid).unwrap(), want, "{label}"); + assert_eq!(counts(), (2, 2)); + }); + with_observations(queue(pid, &[good.clone(), good.clone()]), || { + assert_eq!( + observed_reader_holders(store, &RuntimeDirectory::supplied(root)).unwrap(), + (1, vec![]), + "{label}" + ); + assert_eq!(counts(), (2, 2)); + }); + let file = guard.file.as_mut().unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + file.set_len(0).unwrap(); + file.write_all(&encode_reader_breadcrumb(&want).unwrap()) + .unwrap(); + file.sync_data().unwrap(); + positive( + store, + root, + &guard, + &[good.clone(), good.clone()], + &want, + inode, + ); + } + // Claimed PID is also the verifier's query PID; an observed different + // PID is rejected on the first native record, before tuple equality. + reject( + store, + root, + "claimed-pid", + pid + 1, + &[good.clone()], + None, + false, + ); + let guard = ReaderBreadcrumbGuard::create(store, root, None).unwrap(); + with_observations(vec![], || { + assert_eq!( + observed_reader_holders(store, &RuntimeDirectory::supplied(root)).unwrap(), + (1, vec![]) + ); + assert_eq!(counts(), (0, 0)); + }); + drop(guard); + scope_controls(pid, &good); + } + + fn panic_text(panic: Box) -> String { + if let Some(text) = panic.downcast_ref::() { + text.clone() + } else if let Some(text) = panic.downcast_ref::<&str>() { + (*text).into() + } else { + panic!("unexpected panic payload") + } + } + + fn scope_controls(pid: u32, good: &Raw) { + use std::panic::{AssertUnwindSafe, catch_unwind}; + // SAFETY: Darwin's accessor returns this thread's live errno slot. + let errno = unsafe { *libc::__error() }; + with_observations(queue(pid, &[good.clone(), good.clone()]), || { + macos_process_info(pid).unwrap(); + assert_eq!(counts(), (1, 1)); + let before = unsafe { *libc::__error() }; + let bad = Raw { + written: -1, + errno: libc::EACCES, + ..good.clone() + }; + with_observations(queue(pid, &[bad]), || { + assert_eq!( + macos_process_info(pid) + .err() + .expect("raw failure") + .raw_os_error(), + Some(libc::EACCES) + ); + assert_eq!(counts(), (1, 1)); + }); + assert_eq!(unsafe { *libc::__error() }, before); + assert_eq!(counts(), (1, 1)); + let panic = catch_unwind(AssertUnwindSafe(|| { + with_observations(queue(pid, &[good.clone()]), || { + unsafe { *libc::__error() = libc::ESRCH }; + panic!("native scope original panic"); + }); + })) + .unwrap_err(); + assert_eq!(panic_text(panic), "native scope original panic"); + assert_eq!(unsafe { *libc::__error() }, before); + assert_eq!(counts(), (1, 1)); + macos_process_info(pid).unwrap(); + assert_eq!(counts(), (2, 2)); + }); + assert_eq!(unsafe { *libc::__error() }, errno); + for mode in ["unused", "ordinal", "pid", "exhausted"] { + let mut observations = queue(pid, &[good.clone()]); + match mode { + "ordinal" => observations[0].ordinal = 2, + "pid" => observations[0].pid += 1, + "exhausted" => observations.clear(), + _ => {} + } + let panic = catch_unwind(AssertUnwindSafe(|| { + with_observations(observations, || { + if mode != "unused" { + let rejection = catch_unwind(AssertUnwindSafe(|| { + let _ = macos_process_info(pid); + })); + assert!( + panic_text(rejection.unwrap_err()) + .contains("native observation mismatch") + ); + assert_eq!(counts(), (1, 0)); + // Catching the call panic cannot make scope exit pass. + } + }); + })) + .unwrap_err(); + let message = panic_text(panic); + assert!( + message.contains(if mode == "unused" { + "unused results" + } else { + "sequence was rejected" + }), + "{mode}: {message}" + ); + assert_eq!(unsafe { *libc::__error() }, errno); + // A failed scope must leave no stale override behind. Calling + // observe directly avoids executing a real FFI call here. + let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; + assert!( + super::super::macos_process_info_observation::observe( + pid as libc::c_int, + &mut info + ) + .is_none() + ); + with_observations(queue(pid, &[good.clone()]), || { + macos_process_info(pid).unwrap(); + assert_eq!(counts(), (1, 1)); + }); + } + } } /// A real store nobody is holding, released at once. @@ -34700,6 +35955,110 @@ mod recorded_owner_state_proofs { /// open file the record was read from. #[test] fn a_record_nobody_is_holding_refuses_nobody() { + #[cfg(unix)] + { + // A reader hold and a writer claim are independent facts. Exercise + // their actual primitives before the recorded-owner control below. + let directory = tempfile::TempDir::new().expect("task-scoped lock directory"); + let store = directory.path().join("independent-locks.db"); + std::fs::write(&store, b"").expect("the store exists"); + let initial = CompanionGuard::acquire(&store, CompanionAdmission::NewStore) + .expect("create the trusted companion"); + initial.publish_writer_run(&store, true).unwrap(); + drop(initial); + + let path = companion_path(&store); + let reader = open_trusted_companion(&path).expect("separate reader description"); + let contender = open_trusted_companion_for_exclusive_hold(&path) + .expect("separate competing writer description"); + assert_eq!( + take_companion_range_lock_shared(&reader, COMPANION_READER_HOLD_LOCK_OFFSET) + .unwrap(), + RangeLockOutcome::Taken + ); + let writer = CompanionGuard::acquire(&store, CompanionAdmission::Existing) + .unwrap_or_else(|error| { + panic!("a reader byte hold must coexist with a writer claim: {error}") + }); + writer.publish_writer_run(&store, false).unwrap(); + + let assert_writer_still_owns = || { + assert!( + !try_lock_exclusive(&contender).unwrap(), + "a separate open in this process must not steal writer ownership" + ); + assert!(matches!( + CompanionGuard::acquire(&store, CompanionAdmission::Existing), + Err(Error::ReadFailure(failure)) + if failure.kind() == ReadFailureKind::HeldByWriter + )); + }; + assert_writer_still_owns(); + assert_eq!( + take_companion_range_lock_shared(&contender, COMPANION_CLAIM_WINDOW_LOCK_OFFSET) + .unwrap(), + RangeLockOutcome::HeldByAnother + ); + assert_eq!( + take_companion_range_lock_exclusive(&contender, COMPANION_READER_HOLD_LOCK_OFFSET) + .unwrap(), + RangeLockOutcome::HeldByAnother, + "same-process opens must also contend for the reader byte" + ); + + drop(open_trusted_companion(&path).expect("unrelated open and close")); + assert_writer_still_owns(); + assert_eq!( + take_companion_range_lock_exclusive(&contender, COMPANION_READER_HOLD_LOCK_OFFSET) + .unwrap(), + RangeLockOutcome::HeldByAnother, + "an unrelated descriptor close must not release the reader hold" + ); + + writer.close_claim_window(); + assert_writer_still_owns(); + assert_eq!( + take_companion_range_lock_exclusive(&contender, COMPANION_CLAIM_WINDOW_LOCK_OFFSET) + .unwrap(), + RangeLockOutcome::Taken + ); + release_companion_range_lock(&contender, COMPANION_CLAIM_WINDOW_LOCK_OFFSET); + assert_writer_still_owns(); + { + let state = writer.lock_state().unwrap(); + assert_eq!( + take_companion_range_lock_shared( + &state.file, + COMPANION_READER_HOLD_LOCK_OFFSET + ) + .unwrap(), + RangeLockOutcome::Taken + ); + release_companion_range_lock(&state.file, COMPANION_READER_HOLD_LOCK_OFFSET); + } + assert_writer_still_owns(); + assert_eq!( + take_companion_range_lock_exclusive(&contender, COMPANION_READER_HOLD_LOCK_OFFSET) + .unwrap(), + RangeLockOutcome::HeldByAnother, + "precise unlock on the writer must preserve the other reader's hold" + ); + + release_companion_range_lock(&reader, COMPANION_READER_HOLD_LOCK_OFFSET); + assert_writer_still_owns(); + assert_eq!( + take_companion_range_lock_exclusive(&contender, COMPANION_READER_HOLD_LOCK_OFFSET) + .unwrap(), + RangeLockOutcome::Taken, + "the reader byte becomes free while the writer still owns the store" + ); + release_companion_range_lock(&contender, COMPANION_READER_HOLD_LOCK_OFFSET); + assert_writer_still_owns(); + drop(writer); + assert!(try_lock_exclusive(&contender).unwrap()); + unlock_file(&contender); + } + let directory = tempfile::TempDir::new().expect("task-scoped store directory"); let store = directory.path().join("released.db"); std::fs::write(&store, b"").expect("the store exists"); diff --git a/crates/contextdb-engine/src/persistence/reader_coordination_wait_tests.rs b/crates/contextdb-engine/src/persistence/reader_coordination_wait_tests.rs new file mode 100644 index 00000000..fdec494d --- /dev/null +++ b/crates/contextdb-engine/src/persistence/reader_coordination_wait_tests.rs @@ -0,0 +1,227 @@ +//! A stopped caller returns while the reader's real OFD byte is still held. +//! The private row fixes the schedule after arming/check/probe and before sleep; +//! public rows exercise the callee without claiming to observe its wait entry. + +use super::*; +use std::sync::mpsc; +use std::time::Duration; + +// Recovery guard, not a latency oracle. A missing answer is saved as state; +// the holder is closed and the waiter joined before any verdict is asserted. +const CLEANUP_GUARD: Duration = Duration::from_secs(30); + +#[derive(Clone, Copy)] +enum Row { + QueuedCancel, + PublicCancel, + PublicRelease, +} + +fn held_reader_row(row: Row) { + let directory = tempfile::TempDir::new().expect("task-scoped coordination fixture"); + let root = directory.path().join("runtime"); + std::fs::create_dir(&root).unwrap(); + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); + let store = directory.path().join("reader.db"); + drop(RedbPersistence::create(&store).expect("seed and release a real store")); + let companion = companion_path(&store); + let reader = open_trusted_companion(&companion).expect("trusted reader description"); + let observer = open_trusted_companion_for_exclusive_hold(&companion).unwrap(); + let waiting = open_trusted_companion_for_exclusive_hold(&companion).unwrap(); + assert_eq!( + take_companion_range_lock_shared(&reader, COMPANION_READER_HOLD_LOCK_OFFSET).unwrap(), + RangeLockOutcome::Taken + ); + assert_eq!( + take_companion_range_lock_exclusive(&observer, COMPANION_READER_HOLD_LOCK_OFFSET).unwrap(), + RangeLockOutcome::HeldByAnother, + "a separately opened OFD must observe the reader's live hold" + ); + let identity = current_reader_breadcrumb().expect("authenticate the actual reader process"); + let breadcrumb = ReaderBreadcrumbGuard::create(&store, &root, Some(&identity)).unwrap(); + assert_eq!( + live_reader_holders(&store, Some(&root)), + (1, vec![identity.clone()]) + ); + + // Reader presence and writer ownership are different bytes. Keep this + // independent control beside the cancellation and release rows. + let writer = CompanionGuard::acquire(&store, CompanionAdmission::Existing) + .expect("a reader byte does not prevent a real writer claim"); + let rival = open_trusted_companion_for_exclusive_hold(&companion).unwrap(); + assert!( + !try_lock_exclusive(&rival).unwrap(), + "a second writer cannot steal ownership" + ); + + let stop = OwnerReadCancellation::new(); + let waiting_stop = stop.clone(); + let wait_path = store.clone(); + let wait_root = root.clone(); + let (prepared, preparation) = mpsc::channel(); + let (proceed, permission) = mpsc::channel(); + let (tell, heard) = mpsc::channel(); + let asking = std::thread::spawn(move || { + let watch = matches!(row, Row::QueuedCancel) + .then(|| CoordinationWatch::arm(&companion, Some(&waiting_stop)).unwrap()); + assert!( + !waiting_stop.is_cancelled(), + "check precedes the queued cancellation" + ); + assert_eq!( + take_companion_range_lock_exclusive(&waiting, COMPANION_READER_HOLD_LOCK_OFFSET) + .unwrap(), + RangeLockOutcome::HeldByAnother + ); + prepared + .send(watch.as_ref().map(|watch| Arc::downgrade(&watch.wake))) + .unwrap(); + permission.recv().unwrap(); + let answer = match row { + Row::QueuedCancel => { + // The token was cancelled after the check and failed probe, + // while this armed listener owned the real wake pipe. No + // fabricated wake, pre-cancelled arm, or production seam. + assert!(waiting_stop.is_cancelled()); + let watch = watch.as_ref().unwrap(); + use std::os::fd::AsRawFd as _; + let mut queued = libc::pollfd { + fd: watch.wake.reader.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + assert_eq!(unsafe { libc::poll(&mut queued, 1, 0) }, 1); + assert_ne!( + queued.revents & libc::POLLIN, + 0, + "real token listener queued a wake" + ); + watch + .sleep(&waiting, COMPANION_READER_HOLD_LOCK_OFFSET, None) + .map(|()| ReaderReleaseWait::Stopped) + } + Row::PublicCancel | Row::PublicRelease => { + let runtime = RuntimeDirectory::supplied(&wait_root); + Ok(wait_for_reader_release( + &wait_path, + Some(&runtime), + &waiting_stop, + )) + } + }; + drop(watch); + drop(waiting); + let _ = tell.send(answer); + }); + + // Save setup failure instead of panicking with a blocked holder/waiter. + let wake_lifetime = preparation.recv_timeout(CLEANUP_GUARD); + let prepared_ok = wake_lifetime.is_ok(); + if matches!(row, Row::QueuedCancel) && prepared_ok { + stop.cancel(); + } + let _ = proceed.send(()); + if matches!(row, Row::PublicCancel) { + // This is public API coverage, not the deterministic boundary proof: + // cancellation can precede its first check on this schedule. + stop.cancel(); + } + + let mut while_held = None; + let mut still_held = None; + let mut still_identified = None; + if prepared_ok && !matches!(row, Row::PublicRelease) { + while_held = Some(heard.recv_timeout(CLEANUP_GUARD)); + still_held = Some( + take_companion_range_lock_exclusive(&observer, COMPANION_READER_HOLD_LOCK_OFFSET) + .unwrap(), + ); + still_identified = Some(live_reader_holders(&store, Some(&root))); + } + + // Cleanup comes BEFORE the decisive assertion, including on the old + // Darwin blocking acquisition. Closing this exact OFD wakes its queue. + drop(reader); + drop(breadcrumb); + let final_answer = if while_held.as_ref().is_some_and(|answer| answer.is_ok()) { + None + } else { + Some(heard.recv_timeout(CLEANUP_GUARD)) + }; + // If public release keeps waiting after release, cancellation + // gives it the ordinary escape path before joining the owned thread. + stop.cancel(); + let joined = asking.join(); + let wake_dropped = wake_lifetime + .as_ref() + .is_ok_and(|wake| wake.as_ref().is_none_or(|wake| wake.upgrade().is_none())); + let reacquired = + take_companion_range_lock_exclusive(&observer, COMPANION_READER_HOLD_LOCK_OFFSET).unwrap(); + release_companion_range_lock(&observer, COMPANION_READER_HOLD_LOCK_OFFSET); + let no_readers = live_reader_holders(&store, Some(&root)); + let writer_still_owns = !try_lock_exclusive(&rival).unwrap(); + drop(writer); + let next_writer = CompanionGuard::acquire(&store, CompanionAdmission::Existing); + + assert!( + prepared_ok, + "the waiter observed a real held reader byte before proceeding" + ); + assert!( + joined.is_ok(), + "join the owned waiter after releasing its holder" + ); + assert!( + wake_dropped, + "the directly observed watch and listener release their wake pipe" + ); + assert_eq!( + reacquired, + RangeLockOutcome::Taken, + "no abandoned exclusive hold remains" + ); + assert_eq!( + no_readers, + (0, vec![]), + "reader identity and lifetime marker are cleaned up" + ); + assert!( + writer_still_owns, + "cancelling or releasing a reader wait cannot release a writer" + ); + assert!( + next_writer.is_ok(), + "a distinct writer can reacquire after the real writer closes" + ); + drop(next_writer); + + if matches!(row, Row::PublicRelease) { + assert!( + matches!(final_answer, Some(Ok(Ok(ReaderReleaseWait::Released)))), + "the actual public callee reports Released without cancellation" + ); + } else { + assert_eq!(still_held, Some(RangeLockOutcome::HeldByAnother)); + assert_eq!(still_identified, Some((1, vec![identity]))); + assert!( + matches!(while_held, Some(Ok(Ok(ReaderReleaseWait::Stopped)))), + "cancellation must answer while the authenticated reader byte is still held" + ); + } +} + +#[test] +fn queued_cancellation_after_check_and_arm_returns_before_reader_release() { + held_reader_row(Row::QueuedCancel); +} + +#[test] +fn public_reader_wait_cancellation_preserves_reader_and_writer_ownership() { + held_reader_row(Row::PublicCancel); +} + +#[test] +fn public_reader_wait_release_without_cancellation_cleans_up_and_reacquires() { + held_reader_row(Row::PublicRelease); +} diff --git a/crates/contextdb-engine/tests/fixtures/public_api_allowed/Cargo.lock b/crates/contextdb-engine/tests/fixtures/public_api_allowed/Cargo.lock index 6f019200..6041f20c 100644 --- a/crates/contextdb-engine/tests/fixtures/public_api_allowed/Cargo.lock +++ b/crates/contextdb-engine/tests/fixtures/public_api_allowed/Cargo.lock @@ -485,6 +485,7 @@ name = "contextdb-engine" version = "1.0.0" dependencies = [ "bao-tree", + "base64", "bincode 2.0.1", "blake3", "bytes", diff --git a/crates/contextdb-engine/tests/fixtures/public_api_forbidden/Cargo.lock b/crates/contextdb-engine/tests/fixtures/public_api_forbidden/Cargo.lock index 346f60a7..524c2c41 100644 --- a/crates/contextdb-engine/tests/fixtures/public_api_forbidden/Cargo.lock +++ b/crates/contextdb-engine/tests/fixtures/public_api_forbidden/Cargo.lock @@ -549,6 +549,7 @@ name = "contextdb-engine" version = "1.0.0" dependencies = [ "bao-tree", + "base64", "bincode 2.0.1", "blake3", "bytes", diff --git a/crates/contextdb-engine/tests/local_transport/local_transport_refusal_taxonomy.rs b/crates/contextdb-engine/tests/local_transport/local_transport_refusal_taxonomy.rs index 621c674e..252e453c 100644 --- a/crates/contextdb-engine/tests/local_transport/local_transport_refusal_taxonomy.rs +++ b/crates/contextdb-engine/tests/local_transport/local_transport_refusal_taxonomy.rs @@ -13,11 +13,13 @@ use contextdb_core::read_contract::{ DeadlineClock, ReadFailure, ReadFailureDetail, ReadFailureKind, ReadLimits, }; +#[cfg(target_os = "linux")] +use contextdb_engine::local_transport::linux_filesystem_type_is_local; use contextdb_engine::local_transport::{ FrameBufferAllocator, FrameReader, FrameViolation, LocalInboundKind, LocalProtocolBoundary, LocalRequestEnvelope, LocalResponseExpectation, LocalTransportError, MAX_FRAME_BYTES, ManualDeadlineClock, OrdinaryResultReceiver, decode_message_exact, handshake_with_deadline, - linux_filesystem_type_is_local, receive_framed_ordinary_result, + receive_framed_ordinary_result, }; use std::future::Future; use std::pin::Pin; diff --git a/crates/contextdb-engine/tests/local_transport/local_transport_runtime.rs b/crates/contextdb-engine/tests/local_transport/local_transport_runtime.rs index cef49187..2d65c1d2 100644 --- a/crates/contextdb-engine/tests/local_transport/local_transport_runtime.rs +++ b/crates/contextdb-engine/tests/local_transport/local_transport_runtime.rs @@ -1,14 +1,18 @@ use contextdb_core::read_contract::{ChannelAddress, LocalUserIdentity}; +#[cfg(target_os = "linux")] +use contextdb_engine::local_transport::ProcessRuntimeDirectoryEnvironment; #[cfg(unix)] use contextdb_engine::local_transport::UnixLocalCarrier; #[cfg(target_os = "linux")] use contextdb_engine::local_transport::linux_filesystem_type_is_local; +#[cfg(target_os = "macos")] +use contextdb_engine::local_transport::resolve_runtime_directory; use contextdb_engine::local_transport::{ ChannelPathFacts, ChannelPathViolation, LocalTransportError, OWNER_ONLY_MODE, - ProcessRuntimeDirectoryEnvironment, ResolvedRuntimeDirectory, RuntimeDirectoryEnvironment, - RuntimeDirectoryFacts, RuntimeDirectoryInspector, RuntimeDirectoryRequest, - RuntimeDirectorySource, RuntimeRootViolation, SystemRuntimeDirectoryInspector, - channel_socket_path, inspect_channel_path, opaque_channel_basename, prepare_runtime_directory, + ResolvedRuntimeDirectory, RuntimeDirectoryEnvironment, RuntimeDirectoryFacts, + RuntimeDirectoryInspector, RuntimeDirectoryRequest, RuntimeDirectorySource, + RuntimeRootViolation, SystemRuntimeDirectoryInspector, channel_socket_path, + inspect_channel_path, opaque_channel_basename, prepare_runtime_directory, prepare_runtime_directory_with_environment, resolve_runtime_directory_with_environment, unix_socket_path_limit, validate_channel_path, validate_runtime_root, }; @@ -302,32 +306,134 @@ fn linux_filesystem_locality_is_an_allowlist_and_unknown_types_are_refused() { assert!(!linux_filesystem_type_is_local(0x1234_5678)); } +#[cfg(target_os = "macos")] +fn native_private_root() -> PathBuf { + PathBuf::from("/private/var/folders/ab/012345678901234567890123456789/T") +} + #[test] #[cfg(target_os = "macos")] -fn local_channel_derives_the_macos_confstr_temporary_root() { - let owner = LocalUserIdentity(7_102); - let temporary_root = PathBuf::from("/var/folders/contextdb-user-temp"); - let request = RuntimeDirectoryRequest { - explicit_root: None, - current_user: owner, - }; - let inspector = FactsInspector::default().with(facts(temporary_root.clone(), owner)); +fn local_channel_derives_the_macos_private_default_channel() { + let owner = LocalUserIdentity(7_106); + let root = native_private_root(); + let inspector = FactsInspector::default().with(facts(root.clone(), owner)); + let resolved = resolve_runtime_directory_with_environment( + &RuntimeDirectoryRequest { + explicit_root: None, + current_user: owner, + }, + &inspector, + &RuntimeEnvironment { + xdg: Some(PathBuf::from("/ignored-on-macos")), + effective_user: owner, + macos_temporary: Some(root.clone()), + }, + ) + .expect("macOS private default runtime resolution"); + let expected_dir = root.join("c"); + assert_eq!( + resolved, + ResolvedRuntimeDirectory { + path: expected_dir.clone(), + source: RuntimeDirectorySource::MacosTemporary, + } + ); + let socket = channel_socket_path(&expected_dir, ChannelAddress([0; 32])) + .expect("native private socket address fits"); + assert!(socket.as_os_str().as_encoded_bytes().len() <= 103); + assert_eq!(socket.file_name().unwrap().as_encoded_bytes().len(), 43); +} + +#[test] +#[cfg(target_os = "macos")] +fn macos_default_channel_full_u64_uid_boundaries() { + let root = native_private_root(); + for owner in [LocalUserIdentity(0), LocalUserIdentity(u64::MAX)] { + let inspector = FactsInspector::default().with(facts(root.clone(), owner)); + let resolved = resolve_runtime_directory_with_environment( + &RuntimeDirectoryRequest { + explicit_root: None, + current_user: owner, + }, + &inspector, + &RuntimeEnvironment { + xdg: None, + effective_user: owner, + macos_temporary: Some(root.clone()), + }, + ) + .expect("private root resolution for UID boundary"); + assert_eq!(resolved.path, root.join("c")); + } +} + +#[test] +#[cfg(target_os = "macos")] +fn macos_default_channel_path_length_bound_b57_accept_b58_refuse() { + let address = ChannelAddress([0x33; 32]); + // Parent + /c/ + 43-byte basename fits exactly at 103 when parent is 57 bytes. + let parent_b57 = PathBuf::from(format!("/{}", "p".repeat(56))); + assert_eq!(parent_b57.as_os_str().as_encoded_bytes().len(), 57); + let socket = + channel_socket_path(&parent_b57.join("c"), address).expect("103-byte socket address fits"); + assert_eq!(socket.as_os_str().as_encoded_bytes().len(), 103); + let parent_b58 = PathBuf::from(format!("/{}", "p".repeat(57))); + assert_eq!( + channel_socket_path(&parent_b58.join("c"), address), + Err(LocalTransportError::ChannelPath( + ChannelPathViolation::PathTooLong + )) + ); +} +#[test] +#[cfg(target_os = "macos")] +fn macos_default_channel_refuses_native_and_explicit_symlinks() { + let owner = LocalUserIdentity(7_102); + let alias = PathBuf::from("/private/var/folders/alias/T"); + let root = native_private_root(); + let mut alias_facts = facts(alias.clone(), owner); + alias_facts.resolved_root = root; + alias_facts.is_symbolic_link = true; + let inspector = FactsInspector::default().with(alias_facts); assert_eq!( resolve_runtime_directory_with_environment( - &request, + &RuntimeDirectoryRequest { + explicit_root: None, + current_user: owner + }, &inspector, &RuntimeEnvironment { - xdg: Some(PathBuf::from("/ignored-on-macos")), + xdg: None, effective_user: owner, - macos_temporary: Some(temporary_root.clone()), + macos_temporary: Some(alias) }, - ) - .expect("macOS temporary runtime resolution"), - ResolvedRuntimeDirectory { - path: temporary_root.join("contextdb"), - source: RuntimeDirectorySource::MacosTemporary, - } + ), + Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::SymbolicLink + )) + ); + + let explicit_symlink = PathBuf::from("/service/explicit-symlink"); + let mut explicit_facts = facts(explicit_symlink.clone(), owner); + explicit_facts.is_symbolic_link = true; + let explicit_inspector = FactsInspector::default().with(explicit_facts); + assert_eq!( + resolve_runtime_directory_with_environment( + &RuntimeDirectoryRequest { + explicit_root: Some(explicit_symlink), + current_user: owner + }, + &explicit_inspector, + &RuntimeEnvironment { + xdg: None, + effective_user: owner, + macos_temporary: None + }, + ), + Err(LocalTransportError::RuntimeRoot( + RuntimeRootViolation::SymbolicLink + )) ); } @@ -414,11 +520,9 @@ fn local_channel_path_is_an_owner_only_socket_below_the_validated_runtime_direct } } -/// A Unix socket pathname is capped at 107 bytes, and the channel basename -/// alone is 69, so a runtime base created under a long ambient temporary -/// directory cannot host a socket at all. These journeys therefore build -/// their bases under a deliberately short root instead of inheriting the -/// environment's temporary directory. +/// A Unix socket pathname has a small platform limit, so a runtime base +/// created under a long ambient temporary directory may not host a socket. +/// These journeys use a deliberately short root. #[cfg(unix)] fn short_runtime_base() -> tempfile::TempDir { tempfile::Builder::new() @@ -559,24 +663,24 @@ fn production_preparation_refuses_every_hostile_preexisting_runtime_child() { #[test] #[cfg(target_os = "macos")] -fn process_environment_uses_the_native_macos_user_temporary_directory() { - let environment = ProcessRuntimeDirectoryEnvironment; - let temporary = environment - .macos_user_temporary_directory() - .expect("read _CS_DARWIN_USER_TEMP_DIR through confstr"); - assert!(temporary.is_absolute()); +fn process_environment_resolves_the_native_macos_private_default_channel() { let current = LocalUserIdentity(nix::unistd::geteuid().as_raw() as u64); + let resolved = resolve_runtime_directory( + &RuntimeDirectoryRequest { + explicit_root: None, + current_user: current, + }, + &SystemRuntimeDirectoryInspector, + ) + .expect("resolve native macOS default runtime directory"); + let native_root = contextdb_engine::local_transport::ProcessRuntimeDirectoryEnvironment + .macos_user_temporary_directory() + .expect("native private temporary directory"); + let canonical_root = fs::canonicalize(native_root).expect("canonical native private root"); assert_eq!( - resolve_runtime_directory( - &RuntimeDirectoryRequest { - explicit_root: None, - current_user: current, - }, - &SystemRuntimeDirectoryInspector, - ) - .expect("resolve through the process environment"), + resolved, ResolvedRuntimeDirectory { - path: temporary.join("contextdb"), + path: canonical_root.join("c"), source: RuntimeDirectorySource::MacosTemporary, } ); @@ -649,7 +753,10 @@ fn channel_names_are_distinct_opaque_and_survive_a_deep_runtime_directory() { first_path.file_name().and_then(|name| name.to_str()), Some(first_basename.as_str()) ); + #[cfg(target_os = "linux")] assert_eq!(first_basename.len(), 69); + #[cfg(target_os = "macos")] + assert_eq!(first_basename.len(), 43); let limit = unix_socket_path_limit().expect("Unix socket pathname limit"); let too_long_runtime = PathBuf::from("/").join("r".repeat(limit)); diff --git a/crates/contextdb-engine/tests/read/read_persistence_coordination_contract_tests.rs b/crates/contextdb-engine/tests/read/read_persistence_coordination_contract_tests.rs index e3e22332..e57de2cc 100644 --- a/crates/contextdb-engine/tests/read/read_persistence_coordination_contract_tests.rs +++ b/crates/contextdb-engine/tests/read/read_persistence_coordination_contract_tests.rs @@ -2481,6 +2481,57 @@ fn production_read_image_hydration_reports_live_readers_ignores_recycled_identit assert_breadcrumb_matches_live_linux_process(&first_breadcrumb); assert_breadcrumb_matches_live_linux_process(&second_breadcrumb); } + #[cfg(target_os = "macos")] + for claimed in [&first_breadcrumb, &second_breadcrumb] { + // Independently inspect the real paused child; the marker is no oracle. + // Reuse libc's native record, never a test-side Darwin ABI. + let pid = libc::c_int::try_from(claimed.process_id).unwrap(); + // SAFETY: the native record consists only of integer fields. + let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; + let size = libc::c_int::try_from(std::mem::size_of_val(&info)).unwrap(); + // SAFETY: info is writable and the supplied size matches its allocation. + let written = unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDTBSDINFO, + 0, + std::ptr::addr_of_mut!(info).cast(), + size, + ) + }; + assert_eq!( + written, size, + "the live child must be independently inspectable" + ); + assert_eq!(info.pbi_pid, claimed.process_id); + assert!(info.pbi_start_tvusec < 1_000_000); + let start = info + .pbi_start_tvsec + .checked_mul(1_000_000) + .and_then(|seconds| seconds.checked_add(info.pbi_start_tvusec)) + .expect("the child's native start must fit the identity representation"); + assert_ne!(start, 0); + assert_eq!(claimed.process_start, ProcessStartIdentity(start)); + let name = claimed.process_name.as_bytes(); + assert!(!name.is_empty()); + assert!( + name.iter() + .all(|byte| (0x20..=0x7e).contains(byte) && *byte != b'/' && *byte != b'\\') + ); + assert!( + [&info.pbi_name[..], &info.pbi_comm[..]] + .iter() + .any(|field| { + field.len() > name.len() + && field[name.len()] == 0 + && field[..name.len()] + .iter() + .zip(name) + .all(|(native, expected)| *native as u8 == *expected) + }), + "the reported name must equal a complete independently observed BSD name" + ); + } let stale_breadcrumb = ReaderBreadcrumb { process_id: u32::MAX - 1, process_name: "stale-unlocked-reader".to_owned(), @@ -4495,11 +4546,30 @@ fn assert_production_readers_are_named_where_the_writer_scans(runtime_directory: first .release() .expect("the first production read session finishes and releases its read lock"); + match probe_writer(&path, runtime_directory) { + WriterProbeResult::HeldByReaders { + attempts, + observed_direct_readers, + verified_readers, + message, + } => { + assert_eq!(attempts, 1); + assert_eq!(observed_direct_readers, 1); + assert_eq!(verified_readers, vec![second_breadcrumb.clone()]); + assert!(message.contains(&second_breadcrumb.process_name)); + assert!(message.contains(&format!("process {}", second_breadcrumb.process_id))); + } + other => panic!("the remaining production reader must still refuse the writer: {other:?}"), + } second .release() .expect("the second production read session finishes and releases its read lock"); assert!(!first_breadcrumb_path.exists()); assert!(!second_breadcrumb_path.exists()); + assert!(matches!( + probe_writer(&path, runtime_directory), + WriterProbeResult::Acquired { attempts: 1 } + )); } /// A runtime root short enough that a local channel pathname below it still diff --git a/tests/acceptance/trigger_same_db_progress.rs b/tests/acceptance/trigger_same_db_progress.rs index 569411dd..50112d78 100644 --- a/tests/acceptance/trigger_same_db_progress.rs +++ b/tests/acceptance/trigger_same_db_progress.rs @@ -757,6 +757,20 @@ fn t30_04_no_trigger_callback_baseline_throughput_n32_writers() { #[serial] fn t30_05_sustained_writer_flood_n32_with_no_op_trigger_all_commit() { run_with_timeout(|| { + struct CallbackReleaseGuard(Option>); + impl CallbackReleaseGuard { + fn release(&mut self) { + if let Some(sender) = self.0.take() { + let _ = sender.try_send(()); + } + } + } + impl Drop for CallbackReleaseGuard { + fn drop(&mut self) { + self.release(); + } + } + let db = Arc::new(Database::open_memory()); db.execute( "CREATE TABLE t (id UUID PRIMARY KEY, writer INTEGER, seq INTEGER)", @@ -766,18 +780,19 @@ fn t30_05_sustained_writer_flood_n32_with_no_op_trigger_all_commit() { db.execute("CREATE TRIGGER tr ON t WHEN INSERT", &empty()) .unwrap(); let first_callback_seen = Arc::new(AtomicBool::new(false)); - let first_callback_entered = Arc::new(AtomicBool::new(false)); - let release_first_callback = Arc::new(AtomicBool::new(false)); - let _release_guard = AtomicReleaseGuard(release_first_callback.clone()); + let (first_callback_entered_tx, first_callback_entered_rx) = mpsc::sync_channel(1); + let (release_first_callback_tx, release_first_callback_rx) = mpsc::sync_channel(1); + let release_first_callback_rx = Mutex::new(release_first_callback_rx); + let mut release_guard = CallbackReleaseGuard(Some(release_first_callback_tx)); let first_callback_seen_cb = first_callback_seen.clone(); - let first_callback_entered_cb = first_callback_entered.clone(); - let release_first_callback_cb = release_first_callback.clone(); db.register_trigger_callback("tr", move |_db, _ctx| { if !first_callback_seen_cb.swap(true, AtomicOrdering::SeqCst) { - first_callback_entered_cb.store(true, AtomicOrdering::SeqCst); - while !release_first_callback_cb.load(AtomicOrdering::SeqCst) { - thread::sleep(Duration::from_millis(1)); - } + first_callback_entered_tx.send(()).expect("callback entry receiver"); + release_first_callback_rx + .lock() + .unwrap() + .recv() + .expect("callback release sender"); } Ok(()) }) @@ -789,14 +804,24 @@ fn t30_05_sustained_writer_flood_n32_with_no_op_trigger_all_commit() { let writer_barrier = Arc::new(Barrier::new(n_writers + 1)); let committed = Arc::new(AtomicUsize::new(0)); let typed_err_same_db = Arc::new(AtomicUsize::new(0)); + let (contender_start_tx, contender_start_rx) = mpsc::sync_channel(1); + let mut contender_start_rx = Some(contender_start_rx); let mut handles = Vec::with_capacity(n_writers); for w in 0..n_writers { let db = db.clone(); let writer_barrier = writer_barrier.clone(); let committed = committed.clone(); let typed_err_same_db = typed_err_same_db.clone(); + let contender_start = if w == 0 { + contender_start_rx.take() + } else { + None + }; handles.push(thread::spawn(move || { writer_barrier.wait(); + if let Some(start) = contender_start { + start.recv().expect("designated contender start"); + } for s in 0..commits_per_writer { let tx = match db.begin() { Ok(tx) => tx, @@ -827,30 +852,19 @@ fn t30_05_sustained_writer_flood_n32_with_no_op_trigger_all_commit() { })); } writer_barrier.wait(); - let entered_deadline = Instant::now() + Duration::from_secs(2); - while !first_callback_entered.load(AtomicOrdering::SeqCst) { - if Instant::now() >= entered_deadline { - release_first_callback.store(true, AtomicOrdering::SeqCst); - panic!("first trigger callback did not enter; contention setup failed"); - } - thread::sleep(Duration::from_millis(1)); - } - let overlap_deadline = Instant::now() + Duration::from_secs(1); - while typed_err_same_db.load(AtomicOrdering::SeqCst) == 0 - && db - .trigger_progress_telemetry_snapshot_for_test() - .wait_observed - == 0 - && Instant::now() < overlap_deadline - { - thread::sleep(Duration::from_millis(2)); - } - let overlap_observed = typed_err_same_db.load(AtomicOrdering::SeqCst) > 0 - || db - .trigger_progress_telemetry_snapshot_for_test() - .wait_observed - > 0; - release_first_callback.store(true, AtomicOrdering::SeqCst); + first_callback_entered_rx + .recv() + .expect("first trigger callback did not enter"); + contender_start_tx + .send(()) + .expect("designated contender did not start"); + db.wait_until_trigger_wait_observed_for_test(1); + let overlap_observed = db + .trigger_progress_telemetry_snapshot_for_test() + .wait_observed + > 0; + assert!(overlap_observed, "a writer must reach the contention gate before release"); + release_guard.release(); for h in handles { h.join().unwrap(); } @@ -870,7 +884,7 @@ fn t30_05_sustained_writer_flood_n32_with_no_op_trigger_all_commit() { ); let snap = db.trigger_progress_telemetry_snapshot_for_test(); - let _ = snap.wait_observed; // informational for no-op callbacks; deterministic parks are asserted elsewhere. + assert!(snap.wait_observed > 0, "contention observation must persist after release"); assert_eq!( snap.typed_err_observed_same_db, 0, "engine must report 0 same-DB typed-Err observations; snap={snap:?}" From b02c85afeede687ee119bc171dcffa1d15220e98 Mon Sep 17 00:00:00 2001 From: Lucid Programmer Date: Fri, 23 Oct 2026 18:49:35 +0530 Subject: [PATCH 2/2] ci, tests: add macOS linting, regroup test bundles, and isolate concurrent test observations. --- .github/CI.md | 34 +- .github/workflows/ci.yml | 76 ++++- crates/contextdb-cli/Cargo.toml | 2 +- crates/contextdb-cli/src/repl.rs | 118 ++++++- .../tests/sync_pull_liveness_tests.rs | 35 ++- .../src/owner_read/service.rs | 24 ++ crates/contextdb-engine/src/persistence.rs | 83 ++--- .../declared_memory_limit_bounds_a_writer.rs | 297 ++++++++++++++++++ .../tests/fixtures/isolated_process.rs | 33 ++ .../tests/history_request_memory.rs | 131 +++++++- .../owner_read/owner_read_service_contract.rs | 69 +++- ...n_drop_cycles_return_threads_and_memory.rs | 45 +++ .../authoritative_purge_reader_publication.rs | 1 + .../read/direct_file_reader_contract_tests.rs | 57 ++++ ..._reader_release_wait_never_arms_a_timer.rs | 67 +--- scripts/ci-acceptance-partition-tests.py | 19 +- scripts/ci-cache-post-cleanup-tests.py | 6 +- scripts/ci-install-zig-aarch64.sh | 29 ++ scripts/ci-integration-targets.json | 106 +++---- scripts/ci-integration-tests.py | 31 +- scripts/ci-integration.py | 19 +- scripts/ci-inventory.py | 2 +- scripts/ci-required-tests.py | 12 +- scripts/ci-required.py | 7 +- tests/acceptance/db_lock_visibility.rs | 41 ++- tests/integration/persistence_tests.rs | 4 +- 26 files changed, 1090 insertions(+), 258 deletions(-) create mode 100644 crates/contextdb-engine/tests/fixtures/isolated_process.rs create mode 100755 scripts/ci-install-zig-aarch64.sh diff --git a/.github/CI.md b/.github/CI.md index ade0a483..8c053215 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -3,8 +3,8 @@ The normal gate publishes one source-bound inventory containing 29 unchanged logical PR command/feature tuples in 26 physical rows and 22 certified integration rows (26 on the fail-closed certificate fallback). It dispatches the resulting 49 or 53 physical -rows in 18 reviewed execution bundles after `impact`. Each bundle contains at most -three rows and attempts every constituent even after an earlier failure. Every +rows in 16 reviewed execution bundles after `impact`. Each bundle contains at most +four rows and attempts every constituent even after an earlier failure. Every failed, cancelled, skipped or missing bundle still fails the validator and stable `PR checks` aggregate. When integration is classified inactive, the same mapping retains all 26 physical PR rows and omits only empty integration-only bundles. @@ -16,14 +16,16 @@ retains `target/redb`; features and profiles are never combined. A failed consti is retained while later constituents still run; missing receipts also fail it. Receipt upload includes every constituent directory, including evidence from a failed bundle. Single-shard local commands are unchanged. The impact job publishes the source-bound plan and all -18 bundles once; the integration validator checks their disjoint canonical union, +16 bundles once; the integration validator checks their disjoint canonical union, exact inner payloads, and successful bundle matrix result. Each constituent disables automatic job-ID suffixing and carries the historical `-test` suffix in its per-row cache key, preserving each old cache namespace; feature sets and target directories stay unchanged. The cache action uses its baseline behavior for failed jobs. -The compile roots form `lint` → `features` → `build` → `cross-compile`; each -successor runs after predecessor failure unless the workflow is cancelled, so the -aggregate records every root error. `installed-smoke` remains an independent root. +The compile roots form `lint` → `build`; `features` also needs only `lint` +and runs as a sibling of `build`. Each successor runs after predecessor failure unless the +workflow is cancelled, so the aggregate records every root error. `installed-smoke`, +`lint-macos` and `cross-compile` (which consumes none of `build`'s outputs and has its own +cache) remain independent roots, so the aarch64 build is never queued behind `build`. Setup savings are a forecast; hosted whole-gate timing remains unproven. > Normal correctness blocks merge; original scale/corpus qualification @@ -136,9 +138,10 @@ Superseded PR runs are cancelled; push runs are not. |---|---|---| | `impact` | `python3 scripts/ci-inventory.py`, then `python3 scripts/ci-impact.py` (CI only) | Two steps in this order. The inventory step runs locally too: every workspace package and the redb fork is covered by a test shard with the right feature closure (fails closed otherwise). The impact step then classifies changed paths for integration (see below); it needs the GitHub Actions event file (`GITHUB_EVENT_PATH`), the `GITHUB_OUTPUT` file and a full-history checkout, so it is not a standalone local command. | | `lint` | `bash scripts/verify pr lint` | The job installs `ripgrep` before the offline controls. `python3 -B scripts/ci-build-tests.py`, `python3 -B scripts/ci-required-tests.py` and `python3 -B scripts/ci-inventory-tests.py` run in the lint job before `scripts/verify pr lint`: offline delivery, aggregate, target-inventory and timing-receipt regression controls, then `cargo fmt` and `clippy -D warnings` for the workspace **and** the separately-built redb fork. | -| `test / bundle / ` — 18 bundles | `CI_CRATE=… CI_SHARD=… CI_FEATURES=… bash scripts/verify pr shard` | The bundles expand to the original 29 logical PR command/feature tuples plus every active integration row. Acceptance runs as separate remaining-module, long-running, and replication targets; vector decoding and delivery, many-context search, and layer search are separate work units. Their physical rows reuse the original logical parent's cache key and preserve its profile and feature closure. The normal plan has 49 physical rows when the source-bound dedup certificate applies and 53 in the conservative fallback. Default builds use parallel libtest execution; `test-seams` builds use one test thread. The engine's `test-seams,iroh` coverage is partitioned into seventeen physical rows by [`ci-shard-targets.txt`](../scripts/ci-shard-targets.txt). The inventory guard rejects missing or repeated targets and changed parent bindings. The lib shard alone covers examples and doctests within the split group. The redb fork retains its own target directory. | +| `test / bundle / ` — 16 bundles | `CI_CRATE=… CI_SHARD=… CI_FEATURES=… bash scripts/verify pr shard` | The bundles expand to the original 29 logical PR command/feature tuples plus every active integration row. Acceptance runs as separate remaining-module, long-running, and replication targets; vector decoding and delivery, many-context search, and layer search are separate work units. Their physical rows reuse the original logical parent's cache key and preserve its profile and feature closure. The normal plan has 49 physical rows when the source-bound dedup certificate applies and 53 in the conservative fallback. Default builds use parallel libtest execution; `test-seams` builds use one test thread. The engine's `test-seams,iroh` coverage is partitioned into seventeen physical rows by [`ci-shard-targets.txt`](../scripts/ci-shard-targets.txt). The inventory guard rejects missing or repeated targets and changed parent bindings. The lib shard alone covers examples and doctests within the split group. The redb fork retains its own target directory. | | `build` | `bash scripts/verify pr build` | Release build of the workspace, then [`verify-packaged-engine.sh`](../scripts/verify-packaged-engine.sh) builds the engine only from packaged crate archives. | | `features` | `bash scripts/verify pr features` | Supported standalone feature shapes: engine default, engine `mdns`, server `mdns,production-smoke-driver`, plus `cargo doc` for the workspace. | +| `lint-macos` | `bash scripts/verify pr lint` on `macos-latest` | The same `cargo fmt` and `clippy -D warnings` lint entry as `lint`, run natively on macOS in parallel so macOS-only build and lint breaks fail the PR. | | `installed-smoke` | `bash scripts/verify pr installed` | Installs release CLI and server binaries into a temporary root and runs [`installed-release-durable-sync-smoke.sh`](../scripts/installed-release-durable-sync-smoke.sh), the installed-binary durability and sync smoke over real files and ticketed Iroh. | | `cross-compile ()` | `CI_TARGET= bash scripts/verify pr cross` | Release build of CLI and server for `aarch64-unknown-linux-gnu`. The macOS targets are set aside until CDB-153 (the engine uses Linux-only `pipe2`/`SOCK_CLOEXEC`) is fixed. | | `integration / engine / test-profile / acceptance` and named families | see Extended integration | Nightly-only fixtures when the change affects runtime or integration paths; otherwise the job still runs and records why it was not required. | @@ -297,7 +300,7 @@ active matrix. Failure, cancellation, a missing plan or an unexpected skip fails the reusable workflow and therefore the unchanged **PR checks** aggregate. Inactive integration emits the classified impact reason in logs and summary; only that explicit inactive mode admits the matrix's expected skip. The original -PR checks still run. Normal PR lint/test/build/features/installed-smoke/cross jobs have 30-minute runaway +PR checks still run. Normal PR lint/lint-macos/test/build/features/installed-smoke/cross jobs have 30-minute runaway caps; helper jobs keep five minutes. Integration slices use 30 minutes in normal mode and the existing 330 minutes in full qualification. These per-job caps do not bound the whole normal DAG, setup or queue; they do not establish end-to-end acceptance. The hard thirty-minute normal end-to-end @@ -397,7 +400,8 @@ rule. - The installed smoke needs Bash and GNU `timeout` (`gtimeout` via `brew install coreutils` on macOS). - Cross builds need the target via `rustup target add` and, for aarch64 Linux, - `gcc-aarch64-linux-gnu`. + `gcc-aarch64-linux-gnu` locally; hosted CI uses no apt: `scripts/ci-install-zig-aarch64.sh` + downloads a version-pinned zig, verifies its SHA-256, and uses it as the aarch64 `cc`/`ar`/linker. - Disk: the full suite builds the workspace and the redb fork into separate target directories (`target/` and `target/redb/`); hosted Linux jobs free preinstalled tooling first with [`ci-free-disk.sh`](../scripts/ci-free-disk.sh). @@ -526,12 +530,12 @@ measurement including cold setup and disclosed queue behavior. Policy adoption r pending owner approval. Normal integration alone partitions the three unchanged long-running acceptance cases: -transfer runs exactly f25 and f26 in bundle02 slot3; incremental pull runs exactly -f27 in bundle12 slot3. Source fingerprints and receipt case names/counts fail +transfer runs exactly f25 and f26 in bundle14 slot1; incremental pull runs exactly +f27 in bundle08 slot1. Source fingerprints and receipt case names/counts fail closed on inventory drift. Both retain the old long-running test cache binding. -Server internal checks run in bundle07 slot3; internal and default core stay -adjacent in bundle09. Full qualification, release and PR acceptance commands -remain unchanged, and all three functional oracles remain required. The 18 bundles -still use at most three slots. The observed 1,555 seconds before f27 is partial +Server internal checks run in bundle06 slot2; core internal checks run in bundle06 +slot1 and default core runs in bundle15 slot1. Full qualification, release and PR acceptance commands +remain unchanged, and all three functional oracles remain required. The 16 bundles +still use at most four slots. The observed 1,555 seconds before f27 is partial cost evidence, not a completion bound; a complete hosted pass within 30 minutes remains unproved. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0d56ae3..d0b0e28d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,6 +115,20 @@ jobs: key: lint-debug0-jobs2 - run: bash scripts/verify pr lint + lint-macos: + runs-on: macos-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 + with: + cache: false + rustflags: "" + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + key: lint-macos-debug0-jobs2 + - run: bash scripts/verify pr lint + test-bundles: name: test / ${{ matrix.name }} needs: impact @@ -276,6 +290,53 @@ jobs: path: target/ci-integration-out/ if-no-files-found: error retention-days: 14 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + if: always() && !cancelled() && matrix.slot4 != null + with: + key: ${{ matrix.slot4.cache_key }} + add-job-id-key: false + workspaces: | + . -> target + crates/contextdb-redb -> ../../target/redb + - name: Install test tools for constituent 4 + if: always() && !cancelled() && matrix.slot4.kind == 'pr' + run: sudo apt-get update && sudo apt-get install -y ripgrep + - id: slot4_pr + name: Run constituent 4 / ${{ matrix.slot4.name }} + if: always() && !cancelled() && matrix.slot4.kind == 'pr' + continue-on-error: true + env: + CI_BUNDLE: ${{ matrix.slot4.pr.constituents && matrix.slot4.pr.shard || '' }} + CI_CRATE: ${{ matrix.slot4.pr.crate }} + CI_FEATURES: ${{ matrix.slot4.pr.features }} + CI_SHARD: ${{ matrix.slot4.pr.shard }} + run: bash scripts/verify pr shard + - id: slot4_integration + name: Run constituent 4 / ${{ matrix.slot4.name }} + if: always() && !cancelled() && matrix.slot4.kind == 'integration' + continue-on-error: true + env: + CI_INTEGRATION_ENABLED: ${{ needs.impact.outputs.enabled }} + CI_INTEGRATION_DEFAULT: "false" + CI_INTEGRATION_SLICE: ${{ matrix.slot4.integration.id }} + CI_INTEGRATION_PLAN: ${{ toJSON(matrix.slot4.integration) }} + run: bash scripts/verify integration "${CI_INTEGRATION_SLICE}" + - name: Upload constituent 4 shard receipt + if: always() && !cancelled() && matrix.slot4.kind == 'pr' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: test-${{ matrix.slot4.pr.shard }}-${{ github.run_attempt }} + path: ${{ env.CARGO_TARGET_DIR || 'target' }}/ci-shard-out/ + retention-days: 14 + if-no-files-found: error + - name: Upload constituent 4 integration receipt + if: always() && !cancelled() && matrix.slot4.kind == 'integration' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: extended-integration-${{ matrix.slot4.integration.id }}-${{ github.run_id }}-${{ github.run_attempt }} + path: target/ci-integration-out/ + if-no-files-found: error + retention-days: 14 - name: Require every bundled constituent to succeed if: always() env: @@ -288,6 +349,9 @@ jobs: SLOT3_KIND: ${{ matrix.slot3.kind }} SLOT3_PR: ${{ steps.slot3_pr.outcome }} SLOT3_INTEGRATION: ${{ steps.slot3_integration.outcome }} + SLOT4_KIND: ${{ matrix.slot4.kind }} + SLOT4_PR: ${{ steps.slot4_pr.outcome }} + SLOT4_INTEGRATION: ${{ steps.slot4_integration.outcome }} run: | status=0 check_slot() { @@ -305,12 +369,13 @@ jobs: check_slot "$SLOT1_KIND" "$SLOT1_PR" "$SLOT1_INTEGRATION" check_slot "$SLOT2_KIND" "$SLOT2_PR" "$SLOT2_INTEGRATION" check_slot "$SLOT3_KIND" "$SLOT3_PR" "$SLOT3_INTEGRATION" + check_slot "$SLOT4_KIND" "$SLOT4_PR" "$SLOT4_INTEGRATION" exit "$status" - name: Remove disposable cache test scratch if: always() && !cancelled() run: bash scripts/ci-cache-post-cleanup.sh build: - needs: features + needs: lint if: always() && !cancelled() runs-on: ubuntu-latest timeout-minutes: 30 @@ -365,8 +430,6 @@ jobs: run: bash scripts/verify pr installed cross-compile: - needs: build - if: always() && !cancelled() strategy: matrix: include: @@ -389,10 +452,7 @@ jobs: key: cross-${{ matrix.target }}-debug0-jobs2 - name: Install cross-compilation tools if: matrix.target == 'aarch64-unknown-linux-gnu' - run: | - sudo apt-get update - sudo apt-get install -y gcc-aarch64-linux-gnu - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV" + run: bash scripts/ci-install-zig-aarch64.sh "$RUNNER_TEMP/zig-aarch64" - run: bash scripts/verify pr cross env: CI_TARGET: ${{ matrix.target }} @@ -400,7 +460,7 @@ jobs: required: name: PR checks if: always() - needs: [lint, test-bundles, build, features, installed-smoke, cross-compile, impact, integration] + needs: [lint, lint-macos, test-bundles, build, features, installed-smoke, cross-compile, impact, integration] runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/crates/contextdb-cli/Cargo.toml b/crates/contextdb-cli/Cargo.toml index 2f7f9bba..45d5c8b3 100644 --- a/crates/contextdb-cli/Cargo.toml +++ b/crates/contextdb-cli/Cargo.toml @@ -26,7 +26,7 @@ path = "src/main.rs" # self-dependency below turns it on for this crate's OWN test targets, which is # what lets the seam tests run in the default `cargo test -p contextdb-cli` gate # without the module existing for anyone else. -test-seams = [] +test-seams = ["contextdb-engine/test-seams"] [dependencies] contextdb-engine = { path = "../contextdb-engine", version = "1.0" } diff --git a/crates/contextdb-cli/src/repl.rs b/crates/contextdb-cli/src/repl.rs index 1df3d1dc..09c3889c 100644 --- a/crates/contextdb-cli/src/repl.rs +++ b/crates/contextdb-cli/src/repl.rs @@ -2342,6 +2342,80 @@ fn interactive_pull_progress_line(pages_read: u64, last_reported: u64) -> Option } } +/// Drives the ordinary 200 ms progress cadence from authenticated pull-page +/// responses in the CLI's own feature-gated integration tests. +#[cfg(feature = "test-seams")] +struct PullProgressClockForTest<'a> { + client: &'a SyncClient, + pause: Option, + ticks_left: u8, + rearm_on_next_wait: bool, + runtime: tokio::runtime::Runtime, +} + +#[cfg(feature = "test-seams")] +impl<'a> PullProgressClockForTest<'a> { + fn new_for_test(client: &'a SyncClient) -> Self { + Self { + client, + pause: Some(client.pause_after_pull_response_for_test()), + ticks_left: 0, + rearm_on_next_wait: false, + runtime: tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test pull progress clock runtime"), + } + } + + fn wait_tick_for_test( + &mut self, + stop: &std::sync::atomic::AtomicBool, + stop_notice: &tokio::sync::Notify, + ) -> bool { + if self.ticks_left == 0 { + // Arm the next real-page fence before letting the current page + // continue. The engine can then never outrun the next report. + if self.rearm_on_next_wait { + let previous = self.pause.take().expect("previous pull-page fence"); + self.pause = Some(self.client.pause_after_pull_response_for_test()); + drop(previous); + } + let pause = self.pause.as_ref().expect("armed pull-page fence"); + if stop.load(std::sync::atomic::Ordering::SeqCst) { + return false; + } + let reached = self.runtime.block_on(async { + tokio::select! { + () = pause.wait_until_reached() => true, + () = stop_notice.notified() => false, + } + }); + if !reached || stop.load(std::sync::atomic::Ordering::SeqCst) { + return false; + } + self.rearm_on_next_wait = true; + self.ticks_left = 10; + } + self.ticks_left -= 1; + true + } +} + +#[cfg(feature = "test-seams")] +struct PullProgressStopForTest<'a> { + stop: &'a std::sync::atomic::AtomicBool, + notice: &'a tokio::sync::Notify, +} + +#[cfg(feature = "test-seams")] +impl Drop for PullProgressStopForTest<'_> { + fn drop(&mut self) { + self.stop.store(true, std::sync::atomic::Ordering::SeqCst); + self.notice.notify_one(); + } +} + /// Runs `.sync pull`, streaming periodic liveness signals from the SAME /// per-call page counter (`SyncClient::pull_pages_read_this_pull`) while it /// runs, so a caller can tell a long multi-page pull is alive, not stuck. @@ -2368,6 +2442,16 @@ fn pull_with_progress( let interactive_terminal = std::io::stdin().is_terminal(); let stream_progress = json || interactive_terminal; let progress_stop = std::sync::atomic::AtomicBool::new(false); + #[cfg(feature = "test-seams")] + let progress_stop_notice = tokio::sync::Notify::new(); + #[cfg(feature = "test-seams")] + let test_clock = (stream_progress + && std::env::var("CONTEXTDB_CLI_TEST_PULL_PROGRESS_CLOCK").as_deref() == Ok("pages")) + .then(|| PullProgressClockForTest::new_for_test(client)); + #[cfg(feature = "test-seams")] + let test_clock_enabled = test_clock.is_some(); + #[cfg(feature = "test-seams")] + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); std::thread::scope(|scope| { if stream_progress { scope.spawn(|| { @@ -2375,8 +2459,28 @@ fn pull_with_progress( let mut waited = std::time::Duration::ZERO; let tick = std::time::Duration::from_millis(200); let report_every = std::time::Duration::from_secs(2); + #[cfg(feature = "test-seams")] + let mut test_clock = test_clock; + #[cfg(feature = "test-seams")] + if test_clock.is_some() { + ready_tx.send(()).expect("pull progress start receiver"); + } while !progress_stop.load(std::sync::atomic::Ordering::SeqCst) { - std::thread::sleep(tick); + #[cfg(feature = "test-seams")] + let sleep_for_tick = match test_clock.as_mut() { + Some(clock) => { + if !clock.wait_tick_for_test(&progress_stop, &progress_stop_notice) { + break; + } + false + } + None => true, + }; + #[cfg(not(feature = "test-seams"))] + let sleep_for_tick = true; + if sleep_for_tick { + std::thread::sleep(tick); + } waited += tick; if waited < report_every { continue; @@ -2397,7 +2501,19 @@ fn pull_with_progress( } }); } + #[cfg(feature = "test-seams")] + if test_clock_enabled && stream_progress { + ready_rx.recv().expect("pull progress clock initialized"); + } + #[cfg(feature = "test-seams")] + let stop_on_drop = PullProgressStopForTest { + stop: &progress_stop, + notice: &progress_stop_notice, + }; let result = rt.block_on(client.pull_default()); + #[cfg(feature = "test-seams")] + drop(stop_on_drop); + #[cfg(not(feature = "test-seams"))] progress_stop.store(true, std::sync::atomic::Ordering::SeqCst); result }) diff --git a/crates/contextdb-cli/tests/sync_pull_liveness_tests.rs b/crates/contextdb-cli/tests/sync_pull_liveness_tests.rs index 60796ec5..43c39c25 100644 --- a/crates/contextdb-cli/tests/sync_pull_liveness_tests.rs +++ b/crates/contextdb-cli/tests/sync_pull_liveness_tests.rs @@ -1,17 +1,4 @@ -//! CLI pull liveness (owner-folded correction, 2026-08-06) — the ruled D1 -//! promise ("you can tell a long `.sync pull` is alive, not stuck") is -//! structurally unmet as shipped: the interactive-only progress printer -//! (`pull_with_interactive_progress`, `repl.rs`) checks -//! `std::io::stdin().is_terminal()` before it ever prints a line, so a -//! SCRIPTED session (stdin piped, exactly what these tests and every -//! non-interactive caller look like) gets NO liveness signal at all during a -//! long pull — only the cumulative `pull_pages_read` counter once the whole -//! pull has already finished. The `repl.rs:1134`-area doc comment claiming an -//! agent can poll `.sync status --json` twice mid-pull is false: `.sync pull` -//! blocks the one CLI session for its whole duration, and no second process -//! can open the same store while it runs. -//! -//! Folded contract: +//! CLI pull liveness contracts: //! (a) a long-running `.sync pull` STREAMS machine-readable progress — //! periodic JSON Lines notices on stderr, shape //! `{"sync_pull_progress":{"pages_read":N}}` — emitted even when stdout @@ -22,8 +9,8 @@ //! "this pull did nothing" from "this pull did a lot" without having to //! diff two cumulative reads itself. //! -//! Discipline: no sleeps, no elapsed-time assertions — every assertion here -//! is on stderr/JSON content and counts, never on how long anything took. +//! The scripted progress case drives the existing cadence from real pull-page +//! events. Assertions cover stderr/JSON content and counts, not elapsed time. use contextdb_core::{TenantId, Value}; use contextdb_engine::Database; @@ -166,7 +153,9 @@ async fn a_scripted_multi_page_pull_streams_progress_notices_on_stderr() { // This process's stdin/stdout/stderr are ALL piped (Stdio::piped()) — // exactly the "stdout is not a terminal" case the promise must still // cover. - let (code, stdout, stderr) = run(cli(&edge_path, &hub, "acme"), ".sync pull\n"); + let mut command = cli(&edge_path, &hub, "acme"); + command.env("CONTEXTDB_CLI_TEST_PULL_PROGRESS_CLOCK", "pages"); + let (code, stdout, stderr) = run(command, ".sync pull\n"); assert_eq!(code, Some(0), "stdout:\n{stdout}\nstderr:\n{stderr}"); let progress_notices: Vec = stderr @@ -186,6 +175,18 @@ async fn a_scripted_multi_page_pull_streams_progress_notices_on_stderr() { progress_notices.windows(2).all(|pair| pair[0] <= pair[1]), "streamed pages_read values must be non-decreasing: {progress_notices:?}" ); + let pull_pages = parse_lines(&stdout) + .into_iter() + .find_map(|doc| { + doc.get("sync_pull")? + .get("pull_pages_read_this_pull")? + .as_u64() + }) + .expect("pull result must report this pull's real page count"); + assert!( + pull_pages >= 3, + "the pull must fetch at least 3 real pages: {stdout}" + ); hub.stop().await; } diff --git a/crates/contextdb-engine/src/owner_read/service.rs b/crates/contextdb-engine/src/owner_read/service.rs index fcd3dbf2..5907bd49 100644 --- a/crates/contextdb-engine/src/owner_read/service.rs +++ b/crates/contextdb-engine/src/owner_read/service.rs @@ -796,6 +796,8 @@ struct OwnerServiceWorkerState { struct OwnerServiceWorkers { wake_writer: std::os::unix::net::UnixStream, state: Mutex, + #[cfg(feature = "test-seams")] + carrier_requests_changed: std::sync::Condvar, } #[cfg(unix)] @@ -804,6 +806,8 @@ impl OwnerServiceWorkers { Self { wake_writer, state: Mutex::new(OwnerServiceWorkerState::default()), + #[cfg(feature = "test-seams")] + carrier_requests_changed: std::sync::Condvar::new(), } } @@ -988,6 +992,8 @@ impl OwnerServiceWorkers { .active_carrier_requests .checked_sub(1) .expect("owner carrier-request count cannot underflow"); + #[cfg(feature = "test-seams")] + self.carrier_requests_changed.notify_all(); } fn active_carrier_requests(&self) -> usize { @@ -2059,6 +2065,24 @@ impl OwnerReadService { } } + /// Wait for the carrier to finish requests whose terminal frames a test + /// has already received, before advancing its shared manual clock. + #[cfg(all(unix, feature = "test-seams"))] + #[doc(hidden)] + pub fn wait_for_carrier_requests_for_test(&self, on_wait: impl FnOnce()) { + let mut state = self.workers.state.lock().expect("owner service workers"); + if state.active_carrier_requests != 0 { + on_wait(); + } + while state.active_carrier_requests != 0 { + state = self + .workers + .carrier_requests_changed + .wait(state) + .expect("owner carrier completion wait"); + } + } + pub fn cancellation_signal_count(&self) -> u64 { self.cancellation_signals.load(Ordering::SeqCst) } diff --git a/crates/contextdb-engine/src/persistence.rs b/crates/contextdb-engine/src/persistence.rs index 5fcded7f..cd307528 100644 --- a/crates/contextdb-engine/src/persistence.rs +++ b/crates/contextdb-engine/src/persistence.rs @@ -15406,37 +15406,51 @@ pub mod read_persistence_test_scaffold { UntilDeadline { after_ms: u64 }, } - fn reader_release_blocks() -> &'static Mutex> { - static BLOCKS: OnceLock>> = OnceLock::new(); - BLOCKS.get_or_init(|| Mutex::new(Vec::new())) + thread_local! { + static READER_RELEASE_BLOCKS: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; } - /// Start recording what reader-release waits block in, discarding - /// anything recorded before. Recording is process-wide, so a proof - /// that uses it owns its test binary. + /// Start recording this thread's reader-release blocks, discarding its + /// previous observations. The wait blocks on its caller's thread. pub fn reset_reader_release_blocks_for_test() { - reader_release_blocks() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clear(); + READER_RELEASE_BLOCKS.with(|blocks| blocks.borrow_mut().clear()); } - /// Every block reader-release waits have performed since the reset, in - /// order. + /// Every block this thread's reader-release waits performed since reset, + /// in order. Other threads cannot reset or contribute to this recording. pub fn reader_release_blocks_for_test() -> Vec { - reader_release_blocks() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone() + READER_RELEASE_BLOCKS.with(|blocks| blocks.borrow().clone()) } /// Production reader-release waits call this once for every block they /// perform, whatever mechanism they block on. pub(super) fn note_reader_release_block_for_test(block: ReaderReleaseBlockForTest) { - reader_release_blocks() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .push(block); + READER_RELEASE_BLOCKS.with(|blocks| blocks.borrow_mut().push(block)); + } + + #[cfg(test)] + #[test] + fn reader_release_recording_cannot_be_reset_or_extended_by_another_thread() { + use std::sync::{Arc, Barrier}; + + let own = ReaderReleaseBlockForTest::UntilHolderReleased; + let sibling = ReaderReleaseBlockForTest::UntilDeadline { after_ms: 1 }; + reset_reader_release_blocks_for_test(); + note_reader_release_block_for_test(own); + let recorded = Arc::new(Barrier::new(2)); + let other_recorded = Arc::clone(&recorded); + let other = std::thread::spawn(move || { + reset_reader_release_blocks_for_test(); + note_reader_release_block_for_test(sibling); + other_recorded.wait(); + reader_release_blocks_for_test() + }); + recorded.wait(); + let blocks = reader_release_blocks_for_test(); + let other_blocks = other.join().expect("join the sibling recorder"); + assert_eq!(blocks, vec![own]); + assert_eq!(other_blocks, vec![sibling]); } /// What answering reads has cost this thread in operating-system objects. @@ -35152,9 +35166,7 @@ mod reader_breadcrumb_location_proofs { } } for pid in [0, i32::MAX as u32 + 1, u32::MAX] { - let error = macos_process_info(pid) - .err() - .expect("invalid PID must fail"); + let error = macos_process_info(pid).expect_err("invalid PID must fail"); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); } } @@ -35353,10 +35365,9 @@ mod reader_breadcrumb_location_proofs { // Validate raw-record failures directly as well: otherwise a later // changed-tuple rejection could mask a broken arithmetic guard. if !label.starts_with("invalid-name/") { - with_observations(queue(good.pid, &[bad.clone()]), || { - let error = macos_process_info(good.pid) - .err() - .expect("raw record must be rejected"); + with_observations(queue(good.pid, std::slice::from_ref(&bad)), || { + let error = + macos_process_info(good.pid).expect_err("raw record must be rejected"); if let Some(errno) = errno { assert_eq!(error.raw_os_error(), Some(errno), "{label}"); } else { @@ -35381,7 +35392,7 @@ mod reader_breadcrumb_location_proofs { root, &format!("{label}/first"), good.pid, - &[bad.clone()], + std::slice::from_ref(&bad), errno, false, ); @@ -35465,9 +35476,8 @@ mod reader_breadcrumb_location_proofs { ); pending[0].pid = requested as libc::c_int; with_observations(pending, || { - let error = macos_process_info(requested) - .err() - .expect("caller PID must be rejected"); + let error = + macos_process_info(requested).expect_err("caller PID must be rejected"); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); assert_eq!(counts(), (0, 0), "production must reject before the FFI"); let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; @@ -35687,7 +35697,7 @@ mod reader_breadcrumb_location_proofs { root, "claimed-pid", pid + 1, - &[good.clone()], + std::slice::from_ref(&good), None, false, ); @@ -35729,8 +35739,7 @@ mod reader_breadcrumb_location_proofs { with_observations(queue(pid, &[bad]), || { assert_eq!( macos_process_info(pid) - .err() - .expect("raw failure") + .expect_err("raw failure") .raw_os_error(), Some(libc::EACCES) ); @@ -35739,7 +35748,7 @@ mod reader_breadcrumb_location_proofs { assert_eq!(unsafe { *libc::__error() }, before); assert_eq!(counts(), (1, 1)); let panic = catch_unwind(AssertUnwindSafe(|| { - with_observations(queue(pid, &[good.clone()]), || { + with_observations(queue(pid, std::slice::from_ref(good)), || { unsafe { *libc::__error() = libc::ESRCH }; panic!("native scope original panic"); }); @@ -35753,7 +35762,7 @@ mod reader_breadcrumb_location_proofs { }); assert_eq!(unsafe { *libc::__error() }, errno); for mode in ["unused", "ordinal", "pid", "exhausted"] { - let mut observations = queue(pid, &[good.clone()]); + let mut observations = queue(pid, std::slice::from_ref(good)); match mode { "ordinal" => observations[0].ordinal = 2, "pid" => observations[0].pid += 1, @@ -35796,7 +35805,7 @@ mod reader_breadcrumb_location_proofs { ) .is_none() ); - with_observations(queue(pid, &[good.clone()]), || { + with_observations(queue(pid, std::slice::from_ref(good)), || { macos_process_info(pid).unwrap(); assert_eq!(counts(), (1, 1)); }); diff --git a/crates/contextdb-engine/tests/declared_memory_limit_bounds_a_writer.rs b/crates/contextdb-engine/tests/declared_memory_limit_bounds_a_writer.rs index a5ad1777..66230cb0 100644 --- a/crates/contextdb-engine/tests/declared_memory_limit_bounds_a_writer.rs +++ b/crates/contextdb-engine/tests/declared_memory_limit_bounds_a_writer.rs @@ -17,6 +17,9 @@ //! mapped, so resident bytes over-state what is live, and allocator slack is //! not something a charge model can be asked to predict. +#[path = "fixtures/isolated_process.rs"] +mod isolated_process; + use contextdb_core::Error; use contextdb_core::Value; use contextdb_engine::database::ReceivedSchemaApplyObserverForTest; @@ -143,6 +146,12 @@ fn measure_thread_peak(action: impl FnOnce() -> T) -> (T, i64) { /// rows, so a whole-metadata clone cannot hide behind the table-copy cost. #[test] fn a_received_table_alter_does_not_clone_unrelated_event_metadata_before_admission() { + if isolated_process::run_in_own_process( + module_path!(), + "a_received_table_alter_does_not_clone_unrelated_event_metadata_before_admission", + ) { + return; + } const ROUTES: usize = 8; const FILTER_VALUES: usize = 512; const VALUE_BYTES: usize = 320; @@ -536,6 +545,12 @@ fn charged_for_rows(db: &Database, accountant: &MemoryAccountant) -> usize { /// write, and still has every committed row after a close and reopen. #[test] fn a_declared_memory_limit_refuses_before_the_writer_retains_more_than_it() { + if isolated_process::run_in_own_process( + module_path!(), + "a_declared_memory_limit_refuses_before_the_writer_retains_more_than_it", + ) { + return; + } // Size the budget from what an event actually costs the accountant, so // the limit is one an operator could plausibly have set: room for about // PROOF_ROWS events on top of the schema. @@ -715,6 +730,12 @@ fn a_declared_memory_limit_refuses_before_the_writer_retains_more_than_it() { /// grows with the schema. #[test] fn what_one_more_index_costs_the_writer_per_row() { + if isolated_process::run_in_own_process( + module_path!(), + "what_one_more_index_costs_the_writer_per_row", + ) { + return; + } const ROWS: usize = 400; eprintln!( "{:<21} {:>7} {:>11} {:>11} {:>13} {:>11}", @@ -767,6 +788,12 @@ fn what_one_more_index_costs_the_writer_per_row() { /// either. #[test] fn every_charge_a_row_takes_comes_back_when_the_row_does() { + if isolated_process::run_in_own_process( + module_path!(), + "every_charge_a_row_takes_comes_back_when_the_row_does", + ) { + return; + } const ROWS: usize = 120; const DDL: &str = "CREATE TABLE notes (id UUID PRIMARY KEY, context_id UUID NOT NULL, author TEXT NOT NULL, kind TEXT NOT NULL, seq INTEGER NOT NULL, body TEXT NOT NULL, UNIQUE (context_id, seq)) SYNC OFF"; const INSERT: &str = "INSERT INTO notes (id, context_id, author, kind, seq, body) VALUES ($id, $context_id, $author, $kind, $seq, $body)"; @@ -1077,6 +1104,12 @@ fn charged_for_an_empty_table_after_with_staging( /// refusing work the store has room for. #[test] fn changing_the_index_set_over_existing_rows_settles_what_its_rows_now_cost() { + if isolated_process::run_in_own_process( + module_path!(), + "changing_the_index_set_over_existing_rows_settles_what_its_rows_now_cost", + ) { + return; + } const ROWS: usize = 60; let mut drifted = Vec::new(); for door in [ @@ -1230,6 +1263,12 @@ fn charged_before_and_after(door: ReleaseDoor, rows: usize) -> (usize, usize) { /// `fallback_retained_row_bytes` in `database.rs`, and its unit test there. #[test] fn every_door_that_gives_a_row_back_returns_what_storing_it_took() { + if isolated_process::run_in_own_process( + module_path!(), + "every_door_that_gives_a_row_back_returns_what_storing_it_took", + ) { + return; + } const ROWS: usize = 40; let mut stranded = Vec::new(); for door in [ @@ -1266,6 +1305,12 @@ fn every_door_that_gives_a_row_back_returns_what_storing_it_took() { /// for one per declaration, and not for four while a schema commits. #[test] fn a_table_is_charged_for_the_exact_probe_filters_it_actually_holds() { + if isolated_process::run_in_own_process( + module_path!(), + "a_table_is_charged_for_the_exact_probe_filters_it_actually_holds", + ) { + return; + } const DDL: &str = "CREATE TABLE holders (id UUID PRIMARY KEY, owner UUID NOT NULL UNIQUE, \ note TEXT NOT NULL) SYNC OFF"; let root = tempfile::tempdir().expect("task-scoped fixture root"); @@ -1313,6 +1358,12 @@ fn a_table_is_charged_for_the_exact_probe_filters_it_actually_holds() { /// against a declared limit, and refuses work the machine has room for. #[test] fn a_schema_installed_in_one_transaction_reserves_what_it_keeps() { + if isolated_process::run_in_own_process( + module_path!(), + "a_schema_installed_in_one_transaction_reserves_what_it_keeps", + ) { + return; + } const TABLES: usize = 6; /// Room for the genuine transient staging of a six-table schema commit, and /// not room for three more copies of every filter in it. @@ -1393,6 +1444,12 @@ fn a_schema_installed_in_one_transaction_reserves_what_it_keeps() { /// linear growth, and one that falls is something with a ceiling. #[test] fn what_the_durable_writer_holds_per_event_as_an_import_grows() { + if isolated_process::run_in_own_process( + module_path!(), + "what_the_durable_writer_holds_per_event_as_an_import_grows", + ) { + return; + } const SCALES: [usize; 3] = [300, 900, 2_700]; eprintln!( "{:<8} {:>12} {:>12} {:>14} {:>16}", @@ -1450,6 +1507,12 @@ fn what_the_durable_writer_holds_per_event_as_an_import_grows() { /// store open -- Redb's own page cache, which Redb caps and evicts at 8 MiB. #[test] fn a_durable_writer_is_refused_before_the_process_holds_more_than_its_limit() { + if isolated_process::run_in_own_process( + module_path!(), + "a_durable_writer_is_refused_before_the_process_holds_more_than_its_limit", + ) { + return; + } /// A store costs something to open whatever it then stores. This is that /// one-time cost, and it is the only fixed allowance this test gives: /// everything else it permits has to fall per event as the import grows. @@ -1558,6 +1621,12 @@ fn a_durable_writer_is_refused_before_the_process_holds_more_than_its_limit() { /// mid-transaction, one too few after a DROP INDEX. #[test] fn a_transaction_open_across_an_index_set_change_settles_the_rows_it_staged() { + if isolated_process::run_in_own_process( + module_path!(), + "a_transaction_open_across_an_index_set_change_settles_the_rows_it_staged", + ) { + return; + } const COMMITTED: usize = 40; const STAGED: usize = 20; let mut drifted = Vec::new(); @@ -1595,6 +1664,12 @@ fn a_transaction_open_across_an_index_set_change_settles_the_rows_it_staged() { /// group. The whole group must be admitted before any part becomes durable. #[test] fn a_received_schema_group_refused_for_memory_installs_none_of_itself() { + if isolated_process::run_in_own_process( + module_path!(), + "a_received_schema_group_refused_for_memory_installs_none_of_itself", + ) { + return; + } // Enough rows that what the table retains dwarfs the store's fixed open // cost, so a limit with room for two of the three arriving indexes and not // one index over them is a figure the accountant can tell apart from the @@ -1725,6 +1800,12 @@ fn wide_params(row: usize) -> HashMap { /// bigger than that is refused, and installs none of itself. #[test] fn a_received_unit_priced_far_too_low_is_refused_not_installed_past_the_limit() { + if isolated_process::run_in_own_process( + module_path!(), + "a_received_unit_priced_far_too_low_is_refused_not_installed_past_the_limit", + ) { + return; + } // Enough wide rows that the index the unit brings is under-priced by more // than the bound -- asserted below rather than assumed. const ROWS: usize = 400; @@ -1827,6 +1908,12 @@ fn a_received_unit_priced_far_too_low_is_refused_not_installed_past_the_limit() /// written to it at all. #[test] fn a_received_alter_priced_far_too_low_leaves_the_old_shape_the_log_and_the_restart() { + if isolated_process::run_in_own_process( + module_path!(), + "a_received_alter_priced_far_too_low_leaves_the_old_shape_the_log_and_the_restart", + ) { + return; + } const ROWS: usize = 400; let root = tempfile::tempdir().expect("task-scoped fixture root"); @@ -1961,6 +2048,12 @@ fn a_received_alter_priced_far_too_low_leaves_the_old_shape_the_log_and_the_rest /// covers. #[test] fn a_tiny_declared_limit_still_opens_an_empty_store_that_answers_and_can_raise_itself() { + if isolated_process::run_in_own_process( + module_path!(), + "a_tiny_declared_limit_still_opens_an_empty_store_that_answers_and_can_raise_itself", + ) { + return; + } let root = tempfile::tempdir().expect("task-scoped fixture root"); let path = root.path().join("tiny-limit.db"); @@ -2014,6 +2107,12 @@ fn a_tiny_declared_limit_still_opens_an_empty_store_that_answers_and_can_raise_i /// kind, and refused ahead of all of them. #[test] fn a_mixed_kind_received_unit_refused_for_memory_installs_none_of_itself() { + if isolated_process::run_in_own_process( + module_path!(), + "a_mixed_kind_received_unit_refused_for_memory_installs_none_of_itself", + ) { + return; + } const ROWS: usize = 150; let root = tempfile::tempdir().expect("task-scoped fixture root"); let accountant = Arc::new(MemoryAccountant::no_limit()); @@ -2147,6 +2246,12 @@ fn label_params(row: usize) -> HashMap { /// its automatic primary key, so the whole 64 KiB lands on the arriving index. #[test] fn a_received_index_the_declared_limit_can_hold_is_installed_rather_than_parked() { + if isolated_process::run_in_own_process( + module_path!(), + "a_received_index_the_declared_limit_can_hold_is_installed_rather_than_parked", + ) { + return; + } const ROWS: usize = 60; let root = tempfile::tempdir().expect("task-scoped fixture root"); let tenant = contextdb_core::TenantId::from(HELD_TENANT); @@ -2289,6 +2394,12 @@ fn a_received_index_the_declared_limit_can_hold_is_installed_rather_than_parked( /// and a hold left behind is cleared by the next delivery for that table. #[test] fn a_received_unit_that_installed_is_reported_applied_even_when_its_hold_will_not_clear() { + if isolated_process::run_in_own_process( + module_path!(), + "a_received_unit_that_installed_is_reported_applied_even_when_its_hold_will_not_clear", + ) { + return; + } const ROWS: usize = 150; let root = tempfile::tempdir().expect("task-scoped fixture root"); let tenant = contextdb_core::TenantId::from(HELD_TENANT); @@ -2586,6 +2697,12 @@ fn holdback_refusal_operation(refusal: &Error) -> &str { /// what that unit needed -- and once the held unit lands, the later one applies. #[test] fn a_later_unit_for_a_held_table_waits_until_the_held_one_lands() { + if isolated_process::run_in_own_process( + module_path!(), + "a_later_unit_for_a_held_table_waits_until_the_held_one_lands", + ) { + return; + } let root = tempfile::tempdir().expect("task-scoped fixture root"); let held = a_receiver_holding_a_table_back_for_memory(root.path()); let tenant = contextdb_core::TenantId::from(HELD_TENANT); @@ -2671,6 +2788,12 @@ fn a_later_unit_for_a_held_table_waits_until_the_held_one_lands() { /// hold exists to prevent -- and nothing would report the table as waiting. #[test] fn a_memory_hold_on_a_table_survives_the_receiver_closing_and_reopening() { + if isolated_process::run_in_own_process( + module_path!(), + "a_memory_hold_on_a_table_survives_the_receiver_closing_and_reopening", + ) { + return; + } let root = tempfile::tempdir().expect("task-scoped fixture root"); let held = a_receiver_holding_a_table_back_for_memory(root.path()); let tenant = contextdb_core::TenantId::from(HELD_TENANT); @@ -2729,6 +2852,12 @@ const HELD_INCARNATION: contextdb_core::Incarnation = contextdb_core::Incarnatio /// record, and the author is never told the unit landed. #[test] fn a_received_unit_held_for_memory_blocks_only_its_table_and_is_retried() { + if isolated_process::run_in_own_process( + module_path!(), + "a_received_unit_held_for_memory_blocks_only_its_table_and_is_retried", + ) { + return; + } const ROWS: usize = 150; let root = tempfile::tempdir().expect("task-scoped fixture root"); let tenant = contextdb_core::TenantId::from(HELD_TENANT); @@ -2965,6 +3094,12 @@ fn a_received_unit_held_for_memory_blocks_only_its_table_and_is_retried() { /// because for a moment both copies of those rows exist. #[test] fn taking_delivery_of_a_received_unit_is_charged_to_the_receivers_declared_limit() { + if isolated_process::run_in_own_process( + module_path!(), + "taking_delivery_of_a_received_unit_is_charged_to_the_receivers_declared_limit", + ) { + return; + } const ROWS: usize = 400; let root = tempfile::tempdir().expect("task-scoped fixture root"); let tenant = contextdb_core::TenantId::from(HELD_TENANT); @@ -3065,6 +3200,12 @@ fn taking_delivery_of_a_received_unit_is_charged_to_the_receivers_declared_limit /// an accurate bill either way; only the peak tells the two apart. #[test] fn an_oversized_received_units_row_copy_is_refused_before_it_is_allocated() { + if isolated_process::run_in_own_process( + module_path!(), + "an_oversized_received_units_row_copy_is_refused_before_it_is_allocated", + ) { + return; + } const ROWS: usize = 4_000; let root = tempfile::tempdir().expect("task-scoped fixture root"); let tenant = contextdb_core::TenantId::from(HELD_TENANT); @@ -3199,6 +3340,12 @@ fn an_oversized_received_units_row_copy_is_refused_before_it_is_allocated() { /// instead of absorbed. #[test] fn preparations_own_charges_draw_the_units_reservation_and_its_bounded_excess() { + if isolated_process::run_in_own_process( + module_path!(), + "preparations_own_charges_draw_the_units_reservation_and_its_bounded_excess", + ) { + return; + } const ROWS: usize = 40; let root = tempfile::tempdir().expect("task-scoped fixture root"); // Comfortably inside `ADMITTED_SCHEMA_UNIT_EXCESS_BYTES`: a receiver's @@ -3329,6 +3476,12 @@ fn preparations_own_charges_draw_the_units_reservation_and_its_bounded_excess() /// large has somewhere to come from and shows up as a number below the start. #[test] fn a_refused_delivery_leaves_the_receivers_memory_bill_exactly_as_it_was() { + if isolated_process::run_in_own_process( + module_path!(), + "a_refused_delivery_leaves_the_receivers_memory_bill_exactly_as_it_was", + ) { + return; + } const ROWS: usize = 250; let root = tempfile::tempdir().expect("task-scoped fixture root"); let tenant = contextdb_core::TenantId::from(HELD_TENANT); @@ -3482,6 +3635,12 @@ fn a_refused_delivery_leaves_the_receivers_memory_bill_exactly_as_it_was() { /// without publishing a partial unit. #[test] fn a_panic_between_staging_and_the_durable_commit_leaves_no_stage_behind() { + if isolated_process::run_in_own_process( + module_path!(), + "a_panic_between_staging_and_the_durable_commit_leaves_no_stage_behind", + ) { + return; + } const ROWS: usize = 120; let root = tempfile::tempdir().expect("task-scoped fixture root"); let tenant = contextdb_core::TenantId::from(HELD_TENANT); @@ -3593,6 +3752,12 @@ fn a_panic_between_staging_and_the_durable_commit_leaves_no_stage_behind() { /// trace of the arriving unit anywhere. #[test] fn a_panic_immediately_before_the_durable_commit_leaves_no_stage_behind() { + if isolated_process::run_in_own_process( + module_path!(), + "a_panic_immediately_before_the_durable_commit_leaves_no_stage_behind", + ) { + return; + } const ROWS: usize = 120; let root = tempfile::tempdir().expect("task-scoped fixture root"); let tenant = contextdb_core::TenantId::from(HELD_TENANT); @@ -3737,6 +3902,12 @@ fn assert_refuses_service_after_interrupted_publication(db: &Database) { /// always re-derives from regardless of what the live process was doing. #[test] fn a_panic_after_the_durable_commit_before_publication_is_recovered_by_restart() { + if isolated_process::run_in_own_process( + module_path!(), + "a_panic_after_the_durable_commit_before_publication_is_recovered_by_restart", + ) { + return; + } const ROWS: usize = 30; let root = tempfile::tempdir().expect("task-scoped fixture root"); let tenant = contextdb_core::TenantId::from(HELD_TENANT); @@ -3833,6 +4004,12 @@ fn a_panic_after_the_durable_commit_before_publication_is_recovered_by_restart() /// components. #[test] fn a_panic_between_publication_components_is_recovered_by_restart() { + if isolated_process::run_in_own_process( + module_path!(), + "a_panic_between_publication_components_is_recovered_by_restart", + ) { + return; + } const ROWS: usize = 30; let root = tempfile::tempdir().expect("task-scoped fixture root"); let tenant = contextdb_core::TenantId::from(HELD_TENANT); @@ -4069,6 +4246,12 @@ fn spawn_received_schema_crash_child(fault_point: &str) -> std::path::PathBuf { /// RESTART OF A DIFFERENT PROCESS. #[test] fn a_process_crash_immediately_before_the_durable_commit_leaves_the_old_complete_state() { + if isolated_process::run_in_own_process( + module_path!(), + "a_process_crash_immediately_before_the_durable_commit_leaves_the_old_complete_state", + ) { + return; + } let receiver_path = spawn_received_schema_crash_child("BeforeDurableCommit"); let restarted = Database::open(&receiver_path).expect("reopen the crashed receiver"); assert!( @@ -4093,6 +4276,12 @@ fn a_process_crash_immediately_before_the_durable_commit_leaves_the_old_complete /// PROCESS NEVER RAN. #[test] fn a_process_crash_after_the_durable_commit_before_publication_is_recovered_by_restart() { + if isolated_process::run_in_own_process( + module_path!(), + "a_process_crash_after_the_durable_commit_before_publication_is_recovered_by_restart", + ) { + return; + } let receiver_path = spawn_received_schema_crash_child("AfterDurableCommitBeforePublish"); let restarted = Database::open(&receiver_path).expect("reopen the crashed receiver"); assert!( @@ -4119,6 +4308,12 @@ fn a_process_crash_after_the_durable_commit_before_publication_is_recovered_by_r /// OWN COMPONENTS IS ALSO RECOVERED BY A REAL RESTART OF A DIFFERENT PROCESS. #[test] fn a_process_crash_between_publication_components_is_recovered_by_restart() { + if isolated_process::run_in_own_process( + module_path!(), + "a_process_crash_between_publication_components_is_recovered_by_restart", + ) { + return; + } let receiver_path = spawn_received_schema_crash_child("BetweenPublicationComponents"); let restarted = Database::open(&receiver_path).expect("reopen the crashed receiver"); assert!( @@ -4188,6 +4383,12 @@ fn author_add_then_drop_column(path: &std::path::Path) -> Database { #[cfg(unix)] #[test] fn add_then_drop_column_history_answer_keeps_the_authored_order_after_a_change_log_gap() { + if isolated_process::run_in_own_process( + module_path!(), + "add_then_drop_column_history_answer_keeps_the_authored_order_after_a_change_log_gap", + ) { + return; + } use std::io::{Read, Write}; use std::os::unix::net::UnixListener; @@ -4231,6 +4432,12 @@ fn add_then_drop_column_history_answer_keeps_the_authored_order_after_a_change_l #[test] fn received_add_then_drop_column_installs_the_authors_final_schema() { + if isolated_process::run_in_own_process( + module_path!(), + "received_add_then_drop_column_installs_the_authors_final_schema", + ) { + return; + } let root = tempfile::tempdir().expect("task-scoped schema fixture"); let author = author_add_then_drop_column(&root.path().join("author.db")); let unit = author.changes_since(contextdb_core::Lsn(0)); @@ -4273,6 +4480,12 @@ fn received_add_then_drop_column_installs_the_authors_final_schema() { #[test] fn a_received_drop_column_and_rename_column_install_whole_and_survive_restart() { + if isolated_process::run_in_own_process( + module_path!(), + "a_received_drop_column_and_rename_column_install_whole_and_survive_restart", + ) { + return; + } let root = tempfile::tempdir().expect("task-scoped fixture root"); let tenant = contextdb_core::TenantId::from(HELD_TENANT); @@ -4596,6 +4809,12 @@ fn rename_evidence_journey( #[test] fn a_receiver_renames_a_column_only_when_the_authors_signed_change_says_so() { + if isolated_process::run_in_own_process( + module_path!(), + "a_receiver_renames_a_column_only_when_the_authors_signed_change_says_so", + ) { + return; + } // A divergent peer: the author never had `old`, and sends only a policy // change restating its own full shape. Nothing was renamed: partial // alterations preserve the columns they omit, whether or not the @@ -4651,6 +4870,12 @@ fn a_receiver_renames_a_column_only_when_the_authors_signed_change_says_so() { /// that already landed. #[test] fn a_duplicate_delivery_after_restart_replays_the_stored_result_and_applies_nothing_twice() { + if isolated_process::run_in_own_process( + module_path!(), + "a_duplicate_delivery_after_restart_replays_the_stored_result_and_applies_nothing_twice", + ) { + return; + } let root = tempfile::tempdir().expect("task-scoped fixture root"); let tenant = contextdb_core::TenantId::from(HELD_TENANT); @@ -4741,6 +4966,12 @@ fn a_duplicate_delivery_after_restart_replays_the_stored_result_and_applies_noth /// is prepared off to one side and a refusal discards the stage. #[test] fn a_unit_with_no_provenance_installs_whole_or_not_at_all() { + if isolated_process::run_in_own_process( + module_path!(), + "a_unit_with_no_provenance_installs_whole_or_not_at_all", + ) { + return; + } const ROWS: usize = 300; let root = tempfile::tempdir().expect("task-scoped fixture root"); @@ -5073,11 +5304,23 @@ fn a_unit_refused_while_staging_holds_its_table(door: ReceivedDoor) { #[test] fn an_authenticated_unit_refused_while_staging_holds_its_table() { + if isolated_process::run_in_own_process( + module_path!(), + "an_authenticated_unit_refused_while_staging_holds_its_table", + ) { + return; + } a_unit_refused_while_staging_holds_its_table(ReceivedDoor::Authenticated); } #[test] fn a_unit_with_no_provenance_refused_while_staging_holds_its_table() { + if isolated_process::run_in_own_process( + module_path!(), + "a_unit_with_no_provenance_refused_while_staging_holds_its_table", + ) { + return; + } a_unit_refused_while_staging_holds_its_table(ReceivedDoor::Unattested); } @@ -5314,11 +5557,23 @@ fn a_refused_index_rebuild_returns_the_rows_charge(resident: bool) { #[test] fn a_refused_index_rebuild_returns_the_rows_charge_in_memory() { + if isolated_process::run_in_own_process( + module_path!(), + "a_refused_index_rebuild_returns_the_rows_charge_in_memory", + ) { + return; + } a_refused_index_rebuild_returns_the_rows_charge(true); } #[test] fn a_refused_index_rebuild_returns_the_rows_charge_on_disk() { + if isolated_process::run_in_own_process( + module_path!(), + "a_refused_index_rebuild_returns_the_rows_charge_on_disk", + ) { + return; + } a_refused_index_rebuild_returns_the_rows_charge(false); } @@ -5551,16 +5806,34 @@ fn a_vector_copy_is_admitted_before_it_is_allocated(resident: bool, saved: bool) #[test] fn a_vector_copy_is_admitted_before_it_is_allocated_in_memory() { + if isolated_process::run_in_own_process( + module_path!(), + "a_vector_copy_is_admitted_before_it_is_allocated_in_memory", + ) { + return; + } a_vector_copy_is_admitted_before_it_is_allocated(true, false); } #[test] fn a_vector_copy_is_admitted_before_it_is_allocated_on_disk() { + if isolated_process::run_in_own_process( + module_path!(), + "a_vector_copy_is_admitted_before_it_is_allocated_on_disk", + ) { + return; + } a_vector_copy_is_admitted_before_it_is_allocated(false, false); } #[test] fn a_saved_vector_copy_is_admitted_before_it_is_loaded() { + if isolated_process::run_in_own_process( + module_path!(), + "a_saved_vector_copy_is_admitted_before_it_is_loaded", + ) { + return; + } a_vector_copy_is_admitted_before_it_is_allocated(false, true); } @@ -5580,6 +5853,12 @@ fn a_saved_vector_copy_is_admitted_before_it_is_loaded() { /// delivery with room installs. #[test] fn a_file_backed_receiver_obtains_memory_before_loading_the_table_it_copies() { + if isolated_process::run_in_own_process( + module_path!(), + "a_file_backed_receiver_obtains_memory_before_loading_the_table_it_copies", + ) { + return; + } const ROWS: usize = 4_000; let root = tempfile::tempdir().expect("task-scoped fixture root"); let tenant = contextdb_core::TenantId::from(HELD_TENANT); @@ -5818,6 +6097,12 @@ fn authored_schema_unit(author: &SchemaPeer, statement: &str) -> ChangeSet { #[test] fn an_edge_refuses_schema_a_hub_forwards_under_another_authors_name() { + if isolated_process::run_in_own_process( + module_path!(), + "an_edge_refuses_schema_a_hub_forwards_under_another_authors_name", + ) { + return; + } let root = tempfile::tempdir().expect("task-scoped fixture root"); let author = SchemaPeer::open(root.path(), "author"); let hub = SchemaPeer::open(root.path(), "hub"); @@ -5869,6 +6154,12 @@ fn an_edge_refuses_schema_a_hub_forwards_under_another_authors_name() { #[test] fn an_edge_refuses_a_statement_that_was_swapped_in_flight() { + if isolated_process::run_in_own_process( + module_path!(), + "an_edge_refuses_a_statement_that_was_swapped_in_flight", + ) { + return; + } let root = tempfile::tempdir().expect("task-scoped fixture root"); let author = SchemaPeer::open(root.path(), "swap-author"); let edge = SchemaPeer::open(root.path(), "swap-edge"); @@ -5919,6 +6210,12 @@ fn an_edge_refuses_a_statement_that_was_swapped_in_flight() { #[test] fn an_authors_signature_travels_two_hops_a_restart_and_a_third_peer() { + if isolated_process::run_in_own_process( + module_path!(), + "an_authors_signature_travels_two_hops_a_restart_and_a_third_peer", + ) { + return; + } let root = tempfile::tempdir().expect("task-scoped fixture root"); let author = SchemaPeer::open(root.path(), "journey-author"); let hub = SchemaPeer::open(root.path(), "journey-hub"); diff --git a/crates/contextdb-engine/tests/fixtures/isolated_process.rs b/crates/contextdb-engine/tests/fixtures/isolated_process.rs new file mode 100644 index 00000000..41c5611b --- /dev/null +++ b/crates/contextdb-engine/tests/fixtures/isolated_process.rs @@ -0,0 +1,33 @@ +//! Process-wide observations need a process owned by their one journey. + +/// Return true in the parent after the exact test has passed in its own +/// process, or false in that child so it runs the original journey. +/// Sibling tests still run concurrently, each with independent OS resources, +/// allocator state and instrumentation. No retry or serial harness is used. +pub fn run_in_own_process(module: &str, test: &str) -> bool { + const CHILD: &str = "CONTEXTDB_ISOLATED_OBSERVATION_TEST"; + let module = module.split_once("::").map(|(_, rest)| rest).unwrap_or(""); + let filter = if module.is_empty() { + test.to_owned() + } else { + format!("{module}::{test}") + }; + if std::env::var_os(CHILD).as_deref() == Some(std::ffi::OsStr::new(&filter)) { + return false; + } + let output = std::process::Command::new( + std::env::current_exe().expect("locate this journey's test binary"), + ) + .args(["--exact", &filter, "--nocapture"]) + .env(CHILD, &filter) + .output() + .expect("run the journey in its own process"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success() && stdout.contains("test result: ok. 1 passed;"), + "isolated journey {filter} failed ({:?}):\n{stdout}\n{stderr}", + output.status, + ); + true +} diff --git a/crates/contextdb-engine/tests/history_request_memory.rs b/crates/contextdb-engine/tests/history_request_memory.rs index 688e16c1..3f0b6ed9 100644 --- a/crates/contextdb-engine/tests/history_request_memory.rs +++ b/crates/contextdb-engine/tests/history_request_memory.rs @@ -12,7 +12,10 @@ //! //! Measured with a counting global allocator, so what is compared is what the //! request actually allocated, not what it said it would. Run one test at a -//! time: the allocator counts every thread. +//! time in its own process: the allocator counts every thread. + +#[path = "fixtures/isolated_process.rs"] +mod isolated_process; use contextdb_core::read_contract::{DeadlineClock, DeadlineWait, ReadLimits}; use contextdb_engine::Database; @@ -257,6 +260,12 @@ fn assert_result_bytes_refusal(error: &DirectFileReaderError, ceiling: u64) { #[test] fn an_oversized_history_record_is_refused_before_it_is_copied_and_leaves_nothing_charged() { + if isolated_process::run_in_own_process( + module_path!(), + "an_oversized_history_record_is_refused_before_it_is_copied_and_leaves_nothing_charged", + ) { + return; + } let root = tempfile::tempdir().expect("history memory directory"); let path = root.path().join("history.db"); seed(&path); @@ -487,6 +496,12 @@ fn history_load_threshold( /// own charged. #[test] fn a_roomy_reader_with_a_tight_result_ceiling_refuses_before_the_answer_is_encoded() { + if isolated_process::run_in_own_process( + module_path!(), + "a_roomy_reader_with_a_tight_result_ceiling_refuses_before_the_answer_is_encoded", + ) { + return; + } let root = tempfile::tempdir().expect("history memory directory"); let path = root.path().join("history.db"); seed(&path); @@ -527,6 +542,12 @@ fn a_roomy_reader_with_a_tight_result_ceiling_refuses_before_the_answer_is_encod /// refused, typed, one byte below it, without copying the history either time. #[test] fn a_small_sync_answer_over_a_large_history_is_limited_by_its_own_size() { + if isolated_process::run_in_own_process( + module_path!(), + "a_small_sync_answer_over_a_large_history_is_limited_by_its_own_size", + ) { + return; + } let root = tempfile::tempdir().expect("history memory directory"); let path = root.path().join("history.db"); seed(&path); @@ -591,6 +612,12 @@ fn a_small_sync_answer_over_a_large_history_is_limited_by_its_own_size() { /// `encode_metadata_body`'s result finally gets checked. #[test] fn a_reader_with_room_for_history_but_not_its_answer_is_refused_typed_and_clean() { + if isolated_process::run_in_own_process( + module_path!(), + "a_reader_with_room_for_history_but_not_its_answer_is_refused_typed_and_clean", + ) { + return; + } let root = tempfile::tempdir().expect("history memory directory"); let path = root.path().join("history.db"); seed(&path); @@ -638,6 +665,12 @@ fn a_reader_with_room_for_history_but_not_its_answer_is_refused_typed_and_clean( /// be skipped. #[test] fn repeated_requests_after_history_is_cached_still_answer_and_stay_within_room() { + if isolated_process::run_in_own_process( + module_path!(), + "repeated_requests_after_history_is_cached_still_answer_and_stay_within_room", + ) { + return; + } let root = tempfile::tempdir().expect("history memory directory"); let path = root.path().join("history.db"); seed(&path); @@ -661,6 +694,12 @@ fn repeated_requests_after_history_is_cached_still_answer_and_stay_within_room() /// cancelled, even though nothing is left to walk. #[test] fn a_cached_historys_second_request_still_honours_cancellation() { + if isolated_process::run_in_own_process( + module_path!(), + "a_cached_historys_second_request_still_honours_cancellation", + ) { + return; + } use contextdb_engine::direct_file_reader::test_seams::{ DirectCancellation, metadata_with_cancellation_for_test, }; @@ -711,6 +750,12 @@ fn a_cached_historys_second_request_still_honours_cancellation() { /// than the first left behind. #[test] fn many_row_changes_admit_their_aux_indexes_before_building_them_and_clean_up_on_refusal() { + if isolated_process::run_in_own_process( + module_path!(), + "many_row_changes_admit_their_aux_indexes_before_building_them_and_clean_up_on_refusal", + ) { + return; + } let root = tempfile::tempdir().expect("history memory directory"); let path = root.path().join("history.db"); seed_many_rows(&path); @@ -775,6 +820,12 @@ fn many_row_changes_admit_their_aux_indexes_before_building_them_and_clean_up_on /// caches already warm, occupies no more than it is charged while held. #[test] fn a_history_answer_fits_its_admission_while_built_and_stays_charged_while_held() { + if isolated_process::run_in_own_process( + module_path!(), + "a_history_answer_fits_its_admission_while_built_and_stays_charged_while_held", + ) { + return; + } let root = tempfile::tempdir().expect("history memory directory"); let path = root.path().join("history.db"); seed_rows(&path, ANSWER_ROWS); @@ -875,6 +926,12 @@ fn one_answer_budget(path: &std::path::Path) -> OneAnswerBudget { /// while the first is held, and answers once the first is dropped. #[test] fn a_second_answer_is_refused_while_the_first_is_held() { + if isolated_process::run_in_own_process( + module_path!(), + "a_second_answer_is_refused_while_the_first_is_held", + ) { + return; + } let root = tempfile::tempdir().expect("history memory directory"); let path = root.path().join("history.db"); seed_rows(&path, ANSWER_ROWS); @@ -898,6 +955,12 @@ fn a_second_answer_is_refused_while_the_first_is_held() { /// hold one, a refusal is typed, and everything is given back afterwards. #[test] fn racing_requests_never_hold_two_answers_past_the_limit() { + if isolated_process::run_in_own_process( + module_path!(), + "racing_requests_never_hold_two_answers_past_the_limit", + ) { + return; + } let root = tempfile::tempdir().expect("history memory directory"); let path = root.path().join("history.db"); seed_rows(&path, ANSWER_ROWS); @@ -1023,6 +1086,12 @@ fn cached_answer_under( /// lies strictly between an early stop and the whole answer. #[test] fn cancellation_stops_a_cached_answer_mid_projection() { + if isolated_process::run_in_own_process( + module_path!(), + "cancellation_stops_a_cached_answer_mid_projection", + ) { + return; + } let (complete, steps, complete_peak) = cached_answer_under(None, u64::MAX / 4); complete.expect("an uncancelled request answers"); let (cancelled, cancelled_steps, cancelled_peak) = @@ -1050,6 +1119,12 @@ fn cancellation_stops_a_cached_answer_mid_projection() { /// request stops there with the active-time refusal. #[test] fn active_time_stops_a_cached_answer_mid_projection() { + if isolated_process::run_in_own_process( + module_path!(), + "active_time_stops_a_cached_answer_mid_projection", + ) { + return; + } use contextdb_core::read_contract::{ReadFailureDetail, ReadFailureLimit}; let (complete, steps, complete_peak) = cached_answer_under(None, u64::MAX / 4); complete.expect("a request with time to spare answers"); @@ -1082,6 +1157,12 @@ fn active_time_stops_a_cached_answer_mid_projection() { /// than answered. #[test] fn cancellation_during_encoding_refuses_the_answer() { + if isolated_process::run_in_own_process( + module_path!(), + "cancellation_during_encoding_refuses_the_answer", + ) { + return; + } use contextdb_engine::direct_file_reader::test_seams::while_encoding_answers_for_test; let root = tempfile::tempdir().expect("history memory directory"); let path = root.path().join("history.db"); @@ -1112,6 +1193,12 @@ fn cancellation_during_encoding_refuses_the_answer() { /// kept structures never occupy more than they were charged. #[test] fn history_indexes_are_charged_for_long_table_names_and_list_growth() { + if isolated_process::run_in_own_process( + module_path!(), + "history_indexes_are_charged_for_long_table_names_and_list_growth", + ) { + return; + } use contextdb_engine::database::ChangeLogAuxIndexFixtureForTest; for (tables, name_len, rows_per_table) in [(2_000, 4_096, 1), (64, 16, 1_500), (500, 1, 3)] { let mut fixture = ChangeLogAuxIndexFixtureForTest::new(tables, name_len, rows_per_table); @@ -1155,6 +1242,12 @@ fn fallback_reader( /// and a returned answer stays charged while the caller holds it. #[test] fn a_history_answer_built_from_persisted_state_is_admitted_as_it_is_built() { + if isolated_process::run_in_own_process( + module_path!(), + "a_history_answer_built_from_persisted_state_is_admitted_as_it_is_built", + ) { + return; + } use contextdb_engine::direct_file_reader::test_seams::held_answer_bytes_for_test; let root = tempfile::tempdir().expect("history memory directory"); let path = root.path().join("history.db"); @@ -1379,6 +1472,12 @@ fn answer_rows(response: &DirectMetadataResponse) -> (usize, usize, usize) { /// retained-proof accounting charges until the statement itself ends. #[test] fn pending_deletions_complete_the_history_answer_on_both_paths() { + if isolated_process::run_in_own_process( + module_path!(), + "pending_deletions_complete_the_history_answer_on_both_paths", + ) { + return; + } use contextdb_engine::direct_file_reader::test_seams::{ held_answer_bytes_for_test, statement_own_charge_for_test, }; @@ -1430,6 +1529,12 @@ fn pending_deletions_complete_the_history_answer_on_both_paths() { /// the refusal gives back everything it took. #[test] fn many_pending_deletions_under_a_tight_limit_are_refused_before_they_are_built() { + if isolated_process::run_in_own_process( + module_path!(), + "many_pending_deletions_under_a_tight_limit_are_refused_before_they_are_built", + ) { + return; + } use contextdb_engine::direct_file_reader::test_seams::held_answer_bytes_for_test; let root = tempfile::tempdir().expect("history memory directory"); let path = root.path().join("history.db"); @@ -1494,6 +1599,12 @@ fn many_pending_deletions_under_a_tight_limit_are_refused_before_they_are_built( /// A request cancelled while it reads the pending deletions stops there. #[test] fn cancellation_stops_the_pending_deletion_read() { + if isolated_process::run_in_own_process( + module_path!(), + "cancellation_stops_the_pending_deletion_read", + ) { + return; + } use contextdb_engine::direct_file_reader::test_seams::force_history_log_gap_for_test; let root = tempfile::tempdir().expect("history memory directory"); let path = root.path().join("history.db"); @@ -1555,6 +1666,12 @@ fn cancellation_stops_the_pending_deletion_read() { /// history request.) #[test] fn an_unreadable_pending_deletion_record_refuses_the_history() { + if isolated_process::run_in_own_process( + module_path!(), + "an_unreadable_pending_deletion_record_refuses_the_history", + ) { + return; + } use contextdb_engine::database::fail_next_pending_deletion_read_for_test; use contextdb_engine::direct_file_reader::test_seams::DirectStoreDiagnostic; let root = tempfile::tempdir().expect("history memory directory"); @@ -1649,6 +1766,12 @@ fn schema_history_reader( /// and stops where it is when cancelled during the reconstruction. #[test] fn a_long_schema_history_is_reconstructed_inside_the_limit() { + if isolated_process::run_in_own_process( + module_path!(), + "a_long_schema_history_is_reconstructed_inside_the_limit", + ) { + return; + } use contextdb_engine::direct_file_reader::test_seams::held_answer_bytes_for_test; let root = tempfile::tempdir().expect("history memory directory"); let path = root.path().join("history.db"); @@ -1717,6 +1840,12 @@ fn a_long_schema_history_is_reconstructed_inside_the_limit() { /// stops there. #[test] fn cancellation_stops_a_schema_history_reconstruction() { + if isolated_process::run_in_own_process( + module_path!(), + "cancellation_stops_a_schema_history_reconstruction", + ) { + return; + } use contextdb_engine::direct_file_reader::test_seams::force_change_log_gap_for_test; let root = tempfile::tempdir().expect("history memory directory"); let path = root.path().join("history.db"); diff --git a/crates/contextdb-engine/tests/owner_read/owner_read_service_contract.rs b/crates/contextdb-engine/tests/owner_read/owner_read_service_contract.rs index e0890499..9989e634 100644 --- a/crates/contextdb-engine/tests/owner_read/owner_read_service_contract.rs +++ b/crates/contextdb-engine/tests/owner_read/owner_read_service_contract.rs @@ -499,6 +499,15 @@ impl Harness { ) } + fn advance_clock(&self, now_ms: u64) { + self.advance_clock_observed(now_ms, || {}); + } + + fn advance_clock_observed(&self, now_ms: u64, on_wait: impl FnOnce()) { + self.service.wait_for_carrier_requests_for_test(on_wait); + self.clock.advance_to(now_ms); + } + fn request_with_handshake( &self, presented: LocalHandshake, @@ -2245,6 +2254,56 @@ fn custom_namespace_against_a_handler_less_owner_answers_a_named_owner_route_uns ); } +#[test] +fn manual_clock_advance_waits_for_carrier_completion() { + let handler = Arc::new(GatedHandler::default()); + let handler_seam: Arc = handler.clone(); + let harness = Harness::new(limits(), 4, Some(handler_seam)); + let client = harness.client(60); + let clock = harness.clock.clone(); + let (events, observed) = std::sync::mpsc::channel(); + let (first_event, clock_before_release, answer) = std::thread::scope(|scope| { + let request = + scope.spawn(move || { + block_on(client.lock().expect("held-request client").request( + LocalRequestEnvelope { + limits: limits(), + request: custom("proof.hold", b"clock boundary"), + }, + )) + }); + handler.wait_for_entered(1); + let release = scope.spawn(move || { + let first_event = observed.recv().expect("observe wait or advancement"); + let clock_before_release = clock.now_ms(); + handler.release(); + if first_event == "waiting" { + assert_eq!( + observed.recv().expect("clock advancement event"), + "advanced" + ); + } + (first_event, clock_before_release) + }); + harness.advance_clock_observed(101, || { + events.send("waiting").expect("report carrier wait entry"); + }); + events.send("advanced").expect("report clock advancement"); + let (first_event, clock_before_release) = release.join().expect("request release joins"); + let answer = request.join().expect("held request joins"); + (first_event, clock_before_release, answer) + }); + assert_eq!(first_event, "waiting"); + assert_eq!(clock_before_release, 100); + assert_eq!(harness.clock.now_ms(), 101); + assert_eq!( + answer.expect("held request finishes before the clock advances"), + vec![LocalResponse::Custom { + payload: b"clock boundary".to_vec(), + }], + ); +} + #[test] fn cursor_page_rows_bytes_identity_exhaustion_close_and_expiry_follow_the_frozen_contract() { let mut owner_limits = limits(); @@ -2711,7 +2770,7 @@ fn cursor_page_rows_bytes_identity_exhaustion_close_and_expiry_follow_the_frozen ) .expect("open idle-boundary cursor"), ); - harness.clock.advance_to(110); + harness.advance_clock(110); harness .request( 34, @@ -2722,7 +2781,7 @@ fn cursor_page_rows_bytes_identity_exhaustion_close_and_expiry_follow_the_frozen }, ) .expect("exact idle boundary succeeds"); - harness.clock.advance_to(121); + harness.advance_clock(121); let expired = refusal(harness.request( 34, owner_limits, @@ -2757,7 +2816,7 @@ fn cursor_page_rows_bytes_identity_exhaustion_close_and_expiry_follow_the_frozen .expect("open lifetime-boundary cursor"), ); for now_ms in (130..=220).step_by(9) { - harness.clock.advance_to(now_ms); + harness.advance_clock(now_ms); harness .request( 35, @@ -2769,7 +2828,7 @@ fn cursor_page_rows_bytes_identity_exhaustion_close_and_expiry_follow_the_frozen ) .expect("periodic fetch keeps idle time below its boundary"); } - harness.clock.advance_to(221); + harness.advance_clock(221); harness .request( 35, @@ -2780,7 +2839,7 @@ fn cursor_page_rows_bytes_identity_exhaustion_close_and_expiry_follow_the_frozen }, ) .expect("exact total lifetime boundary succeeds"); - harness.clock.advance_to(222); + harness.advance_clock(222); let expired = refusal(harness.request( 35, owner_limits, diff --git a/crates/contextdb-engine/tests/owner_read/writable_open_drop_cycles_return_threads_and_memory.rs b/crates/contextdb-engine/tests/owner_read/writable_open_drop_cycles_return_threads_and_memory.rs index 67053ed4..4e0530db 100644 --- a/crates/contextdb-engine/tests/owner_read/writable_open_drop_cycles_return_threads_and_memory.rs +++ b/crates/contextdb-engine/tests/owner_read/writable_open_drop_cycles_return_threads_and_memory.rs @@ -7,6 +7,9 @@ //! process's actual thread names and resident set, then waits for this //! fixture's actual accept-thread and final-service events before measuring. +#[path = "../fixtures/isolated_process.rs"] +mod isolated_process; + use contextdb_engine::local_transport::MonotonicDeadlineClock; use contextdb_engine::{ Database, DatabaseOpenOptions, OwnerReadConfig, OwnerReadTestHooks, ReadRoute, ReadSession, @@ -92,6 +95,12 @@ fn owner_thread_count() -> usize { #[test] fn census_counts_a_live_named_owner_thread() { + if isolated_process::run_in_own_process( + module_path!(), + "census_counts_a_live_named_owner_thread", + ) { + return; + } let baseline = owner_thread_count(); let (ready, started) = mpsc::channel(); let (release, wait) = mpsc::channel::<()>(); @@ -113,6 +122,12 @@ fn census_counts_a_live_named_owner_thread() { #[test] fn census_distinguishes_kernel_exit_and_rejects_malformed_flags() { + if isolated_process::run_in_own_process( + module_path!(), + "census_distinguishes_kernel_exit_and_rejects_malformed_flags", + ) { + return; + } let tasks = tempfile::tempdir().expect("task-scoped synthetic proc directory"); let task = tasks.path().join("1"); std::fs::create_dir(&task).unwrap(); @@ -139,6 +154,12 @@ fn census_distinguishes_kernel_exit_and_rejects_malformed_flags() { #[test] fn census_allows_disappeared_tasks_but_rejects_other_io_errors() { + if isolated_process::run_in_own_process( + module_path!(), + "census_allows_disappeared_tasks_but_rejects_other_io_errors", + ) { + return; + } let tasks = tempfile::tempdir().expect("task-scoped synthetic proc directory"); let task = tasks.path().join("1"); std::fs::create_dir(&task).unwrap(); @@ -154,6 +175,12 @@ fn census_allows_disappeared_tasks_but_rejects_other_io_errors() { #[test] fn census_accepts_enoent_and_esrch_but_panics_on_other_read_errors() { + if isolated_process::run_in_own_process( + module_path!(), + "census_accepts_enoent_and_esrch_but_panics_on_other_read_errors", + ) { + return; + } let path = Path::new("/proc/self/task/vanished/comm"); assert_eq!( task_file_result(Ok("contextdb-owner\n".to_owned()), path), @@ -323,16 +350,34 @@ fn run_cycles(arm: &str, close_before_drop: bool, retain_authenticated_idle_read #[test] fn writable_open_drop_cycles_return_threads_and_memory() { + if isolated_process::run_in_own_process( + module_path!(), + "writable_open_drop_cycles_return_threads_and_memory", + ) { + return; + } run_cycles("drop", false, false); run_cycles("close", true, false); } #[test] fn writable_drop_reaps_an_authenticated_idle_owner_reader() { + if isolated_process::run_in_own_process( + module_path!(), + "writable_drop_reaps_an_authenticated_idle_owner_reader", + ) { + return; + } run_cycles("connected-idle-drop", false, true); } #[test] fn writable_close_reaps_an_authenticated_idle_owner_reader() { + if isolated_process::run_in_own_process( + module_path!(), + "writable_close_reaps_an_authenticated_idle_owner_reader", + ) { + return; + } run_cycles("connected-idle-close", true, true); } diff --git a/crates/contextdb-engine/tests/read/authoritative_purge_reader_publication.rs b/crates/contextdb-engine/tests/read/authoritative_purge_reader_publication.rs index c72892d6..21e97639 100644 --- a/crates/contextdb-engine/tests/read/authoritative_purge_reader_publication.rs +++ b/crates/contextdb-engine/tests/read/authoritative_purge_reader_publication.rs @@ -25,6 +25,7 @@ const CRASH_PATH: &str = "CONTEXTDB_PURGE_PUBLICATION_CRASH_PATH"; const PAUSE_ARMED: &str = "CONTEXTDB_PURGE_PUBLICATION_PAUSE_ARMED"; const CONTROL_SOCKET: &str = "CONTEXTDB_PURGE_PUBLICATION_CONTROL_SOCKET"; const MIXED_ROOT_CHILD: &str = "CONTEXTDB_PURGE_MIXED_ROOT_CHILD"; +#[cfg(target_os = "linux")] const NO_PLATFORM_STAGE: &str = "CONTEXTDB_PURGE_NO_PLATFORM_STAGE"; #[cfg(target_os = "linux")] diff --git a/crates/contextdb-engine/tests/read/direct_file_reader_contract_tests.rs b/crates/contextdb-engine/tests/read/direct_file_reader_contract_tests.rs index 29fe5542..f372fbd9 100644 --- a/crates/contextdb-engine/tests/read/direct_file_reader_contract_tests.rs +++ b/crates/contextdb-engine/tests/read/direct_file_reader_contract_tests.rs @@ -2,6 +2,9 @@ use contextdb_core::read_contract::{ CursorExpiryKind, DeadlineClock, DeadlineWait, ReadFailureDetail, ReadFailureKind, ReadFailureLimit, ReadLimits, }; +#[path = "../fixtures/isolated_process.rs"] +mod isolated_process; + use contextdb_core::{ Direction, Error, HistoryPolicy, IndexKind, Lsn, PropagationRule, RetainUnit, ScopeLabelKind, SortDirection, SyncDirection, TableMeta, TxId, Value, VectorQuantization, @@ -1812,6 +1815,12 @@ fn direct_read_keeps_recursive_bytes_modes_links_and_companions_identical() { #[test] fn caller_driven_writer_policy_is_forgotten_by_the_fresh_direct_session() { + if isolated_process::run_in_own_process( + module_path!(), + "caller_driven_writer_policy_is_forgotten_by_the_fresh_direct_session", + ) { + return; + } let _serial = serialise_direct_file_test(); let root = tempfile::tempdir().expect("scratch directory"); let fixture = seed_fixture_after_caller_driven_writer(root.path()); @@ -2849,6 +2858,12 @@ fn assert_no_writable_subsystems(counters: &DirectReaderCounters) { #[test] fn direct_module_uses_only_the_sanctioned_read_dependencies_and_real_probes() { + if isolated_process::run_in_own_process( + module_path!(), + "direct_module_uses_only_the_sanctioned_read_dependencies_and_real_probes", + ) { + return; + } // Inspecting custody data does not open a constructor or writer escape. for forbidden in [ &["crate", "DeliveryOutcome", "new"][..], @@ -3797,6 +3812,12 @@ fn assert_closed_cursor_aftermath( #[test] fn pre_cancelled_calls_stop_execute_open_and_fetch_before_bounded_source_work() { + if isolated_process::run_in_own_process( + module_path!(), + "pre_cancelled_calls_stop_execute_open_and_fetch_before_bounded_source_work", + ) { + return; + } let _serial = serialise_direct_file_test(); let root = tempfile::tempdir().expect("scratch directory"); let fixture = seed_fixture(root.path(), "caller-cancellation-before-source-work"); @@ -3882,6 +3903,12 @@ fn pre_cancelled_calls_stop_execute_open_and_fetch_before_bounded_source_work() #[test] fn convenience_calls_create_three_fresh_independent_cancellation_tokens() { + if isolated_process::run_in_own_process( + module_path!(), + "convenience_calls_create_three_fresh_independent_cancellation_tokens", + ) { + return; + } let _serial = serialise_direct_file_test(); let root = tempfile::tempdir().expect("scratch directory"); let fixture = seed_fixture(root.path(), "independent-convenience-tokens"); @@ -3931,6 +3958,12 @@ fn convenience_calls_create_three_fresh_independent_cancellation_tokens() { #[test] fn terminal_page_immediately_releases_image_memory_and_cursor_state() { + if isolated_process::run_in_own_process( + module_path!(), + "terminal_page_immediately_releases_image_memory_and_cursor_state", + ) { + return; + } let _serial = serialise_direct_file_test(); let root = tempfile::tempdir().expect("scratch directory"); let fixture = seed_fixture(root.path(), "cursor-terminal-release"); @@ -3992,6 +4025,12 @@ fn terminal_page_immediately_releases_image_memory_and_cursor_state() { #[test] fn explicit_close_before_exhaustion_releases_and_later_fetch_is_closed() { + if isolated_process::run_in_own_process( + module_path!(), + "explicit_close_before_exhaustion_releases_and_later_fetch_is_closed", + ) { + return; + } let _serial = serialise_direct_file_test(); let root = tempfile::tempdir().expect("scratch directory"); let fixture = seed_fixture(root.path(), "cursor-explicit-close-release"); @@ -4024,6 +4063,12 @@ fn explicit_close_before_exhaustion_releases_and_later_fetch_is_closed() { #[test] fn cursor_page_byte_refusal_is_terminal_and_releases_the_cursor_charge() { + if isolated_process::run_in_own_process( + module_path!(), + "cursor_page_byte_refusal_is_terminal_and_releases_the_cursor_charge", + ) { + return; + } let _serial = serialise_direct_file_test(); let root = tempfile::tempdir().expect("scratch directory"); let fixture = seed_fixture(root.path(), "cursor-page-byte-terminal-refusal"); @@ -4087,6 +4132,12 @@ fn cursor_page_byte_refusal_is_terminal_and_releases_the_cursor_charge() { #[test] fn manual_clock_idle_expiry_is_terminal_and_releases_owned_resources() { + if isolated_process::run_in_own_process( + module_path!(), + "manual_clock_idle_expiry_is_terminal_and_releases_owned_resources", + ) { + return; + } let _serial = serialise_direct_file_test(); let root = tempfile::tempdir().expect("scratch directory"); let fixture = seed_fixture(root.path(), "cursor-manual-idle-expiry"); @@ -4130,6 +4181,12 @@ fn manual_clock_idle_expiry_is_terminal_and_releases_owned_resources() { #[test] fn manual_clock_lifetime_expiry_survives_activity_then_releases_owned_resources() { + if isolated_process::run_in_own_process( + module_path!(), + "manual_clock_lifetime_expiry_survives_activity_then_releases_owned_resources", + ) { + return; + } let _serial = serialise_direct_file_test(); let root = tempfile::tempdir().expect("scratch directory"); let fixture = seed_fixture(root.path(), "cursor-manual-lifetime-expiry"); diff --git a/crates/contextdb-engine/tests/reader_release/the_reader_release_wait_never_arms_a_timer.rs b/crates/contextdb-engine/tests/reader_release/the_reader_release_wait_never_arms_a_timer.rs index 11553983..552d0a8b 100644 --- a/crates/contextdb-engine/tests/reader_release/the_reader_release_wait_never_arms_a_timer.rs +++ b/crates/contextdb-engine/tests/reader_release/the_reader_release_wait_never_arms_a_timer.rs @@ -15,49 +15,9 @@ //! and the count also settles the other half: a wait that blocks a bounded //! number of times per real hold is not spinning. //! -//! This journey owns its test binary. The recorder below is process-wide, -//! because the wait does its blocking on threads it spawns itself, and a second -//! wait running beside this one would mix its blocks in. -//! -//! ------------------------------------------------------------------------- -//! SEAM THIS JOURNEY NEEDS, VERBATIM -//! -//! It does not exist yet, so this file is compile-RED until it lands. In -//! `crates/contextdb-engine/src/persistence.rs`, inside -//! `pub mod read_persistence_test_scaffold`: -//! -//! ```ignore -//! /// One blocking wait a reader-release wait actually performed. -//! /// -//! /// A wait that sleeps until the kernel reports a holder let go blocks -//! /// once per hold and supplies no deadline of its own. A wait that polls -//! /// supplies a deadline every time, and that deadline IS the poll -//! /// interval. Recording the shape of each block separates the two -//! /// without measuring how long anything took. -//! #[derive(Debug, Clone, Copy, PartialEq, Eq)] -//! pub enum ReaderReleaseBlockForTest { -//! /// Blocked until the kernel reported that a real holder of the -//! /// store let go or died. Nothing but that event decides when this -//! /// wakes. -//! UntilHolderReleased, -//! /// Blocked with a deadline of the wait's own choosing, in -//! /// milliseconds. -//! UntilDeadline { after_ms: u64 }, -//! } -//! -//! /// Start recording what reader-release waits block in, discarding -//! /// anything recorded before. Recording is process-wide, so a proof -//! /// that uses it owns its test binary. -//! pub fn reset_reader_release_blocks_for_test() { .. } -//! -//! /// Every block reader-release waits have performed since the reset, in -//! /// order. -//! pub fn reader_release_blocks_for_test() -> Vec { .. } -//! ``` -//! -//! Production `wait_for_reader_release` records one entry for every block it -//! performs, whatever mechanism it blocks on. -//! ------------------------------------------------------------------------- +//! The recorder belongs to the thread calling the wait. The waiting thread +//! resets it and returns its own observations with its answer, so sibling +//! tests cannot add blocks or clear this journey's recording. use contextdb_core::Value; use contextdb_core::read_contract::{OwnerReadCancellation, ReadRoute}; @@ -164,13 +124,18 @@ fn park_a_reader(path: &Path, runtime: &Path) -> (Arc, std::threa (Arc::new(ParkingReader { pause, ticket }), holding) } -fn wait_in_thread(path: &Path, runtime: &Path) -> mpsc::Receiver { +fn wait_in_thread( + path: &Path, + runtime: &Path, +) -> mpsc::Receiver<(ReaderReleaseWait, Vec)> { let (tell, heard) = mpsc::channel(); let path = path.to_path_buf(); let runtime = RuntimeDirectory::supplied(runtime); std::thread::spawn(move || { let stop = OwnerReadCancellation::new(); - let _ = tell.send(wait_for_reader_release(&path, Some(&runtime), &stop)); + reset_reader_release_blocks_for_test(); + let answer = wait_for_reader_release(&path, Some(&runtime), &stop); + let _ = tell.send((answer, reader_release_blocks_for_test())); }); heard } @@ -185,7 +150,6 @@ fn waiting_out_two_readers_arms_no_deadline_and_blocks_once_per_hold() { let (first, first_thread) = park_a_reader(&path, &runtime); let (second, second_thread) = park_a_reader(&path, &runtime); - reset_reader_release_blocks_for_test(); let heard = wait_in_thread(&path, &runtime); assert!( heard.recv_timeout(PARKED).is_err(), @@ -201,17 +165,14 @@ fn waiting_out_two_readers_arms_no_deadline_and_blocks_once_per_hold() { second.let_go(); second_thread.join().expect("the second reader finishes"); + let (answer, blocks) = heard + .recv_timeout(WATCHDOG) + .expect("the wait answers once the last reader lets go"); assert!( - matches!( - heard - .recv_timeout(WATCHDOG) - .expect("the wait answers once the last reader lets go"), - ReaderReleaseWait::Released - ), + matches!(answer, ReaderReleaseWait::Released), "the last hold ending ends the wait" ); - let blocks = reader_release_blocks_for_test(); assert!( !blocks.is_empty(), "the wait answered without ever blocking, so it spun while two readers held the store", diff --git a/scripts/ci-acceptance-partition-tests.py b/scripts/ci-acceptance-partition-tests.py index f448a509..86a0eee6 100644 --- a/scripts/ci-acceptance-partition-tests.py +++ b/scripts/ci-acceptance-partition-tests.py @@ -9,7 +9,7 @@ from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] -BASELINE_MODULE_SHA256 = "797bbab6c2f5fb0ad39cb44887813322e36c714aff3a40e407f2d324072c6348" +BASELINE_MODULE_SHA256 = "8e63a05c17941066eb4ac8a59f678ac3a24c35f55f8d9ed3247b580b50bb6149" BASELINE_HELPERS_SHA256 = "9905e54b79db77270853f14542c5d525b5da1b33a37ca9889d0a28e3941eafbf" BASELINE_HELPER_TESTS_SHA256 = "9883a4313ce70e8a444b3a7237e587d618fbdc163d2eac86d9acafe40a84d269" ACCEPTANCE_TARGETS = { @@ -99,8 +99,8 @@ def reject(call): combined = integration.combined_matrix(rows, True, decision) bundles = integration.bundle_matrix(combined, rows, True, decision) assert len(combined["include"]) == physical - assert len(bundles["include"]) == 18 - assert all(sum(bundle[f"slot{slot}"] is not None for slot in range(1, 4)) <= 3 + assert len(bundles["include"]) == 16 + assert all(sum(bundle[f"slot{slot}"] is not None for slot in range(1, 5)) <= 4 for bundle in bundles["include"]) read_text = Path.read_text @@ -138,12 +138,15 @@ def reject(call): selected = integration.active_rows(rows, False, "normal", decision) combined = integration.combined_matrix(rows, True, decision) bundles = integration.bundle_matrix(combined, rows, True, decision)["include"] - assert bundles[6]["slot3"]["id"] == "pr-contextdb-server-test-seams" - assert [bundles[8][f"slot{slot}"]["id"] for slot in (1, 2)] == ["pr-contextdb-core-test-seams", "pr-contextdb-core"] - for row_id, bundle_number in (("acceptance-long-running-transfer-test", 2), ("acceptance-long-running-test", 12)): + placed = [bundle[f"slot{slot}"] for bundle in bundles for slot in range(1, 5) + if bundle[f"slot{slot}"] is not None] + for row_id in ("pr-contextdb-server-test-seams", "pr-contextdb-core-test-seams", "pr-contextdb-core"): + assert sum(slot["id"] == row_id for slot in placed) == 1 + for row_id in ("acceptance-long-running-transfer-test", "acceptance-long-running-test"): row = next(row for row in selected if row["id"] == row_id) - slot = bundles[bundle_number - 1]["slot3"] - assert slot["id"] == "integration-" + row_id + matches = [slot for slot in placed if slot["id"] == "integration-" + row_id] + assert len(matches) == 1 + slot = matches[0] assert slot["cache_key"] == "integration-acceptance-long-running-test-debug0-jobs2-test" command = integration.commands(row, mode="normal")[0] assert command[:10] == ["cargo", "test", "--locked", "-p", "contextdb-engine", "--features", integration.FEATURES, "--test", "acceptance_long_running", "--"] diff --git a/scripts/ci-cache-post-cleanup-tests.py b/scripts/ci-cache-post-cleanup-tests.py index ef44ae89..c7abf77f 100644 --- a/scripts/ci-cache-post-cleanup-tests.py +++ b/scripts/ci-cache-post-cleanup-tests.py @@ -123,9 +123,9 @@ def test_workflow_order_condition_and_pin(self): self.assertIn("run: bash scripts/ci-cache-post-cleanup.sh", block) self.assertNotIn("continue-on-error", block) test_job = workflow[workflow.index(" test-bundles:"):build] - self.assertEqual(test_job.count("Swatinem/rust-cache@" + PIN), 3) - self.assertEqual(test_job.count(". -> target"), 3) - self.assertEqual(test_job.count("crates/contextdb-redb -> ../../target/redb"), 3) + self.assertEqual(test_job.count("Swatinem/rust-cache@" + PIN), 4) + self.assertEqual(test_job.count(". -> target"), 4) + self.assertEqual(test_job.count("crates/contextdb-redb -> ../../target/redb"), 4) if __name__ == "__main__": diff --git a/scripts/ci-install-zig-aarch64.sh b/scripts/ci-install-zig-aarch64.sh new file mode 100755 index 00000000..b986840b --- /dev/null +++ b/scripts/ci-install-zig-aarch64.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Provide the aarch64-linux-gnu C compiler/linker for the hosted cross-compile job without apt: +# a checksum-verified, version-pinned zig release acts as cc/ar. Writes env to $GITHUB_ENV. +set -euo pipefail +zig_version=0.17.0 +zig_sha256=1cbe9df9f27e6b78d14ccbca43b6703a404ef79ef1c463de901d7f088d4e2026 +root=${1:?usage: ci-install-zig-aarch64.sh } +mkdir -p "$root" +curl --fail --silent --show-error --location --retry 3 \ + --output "$root/zig.tar.xz" "https://ziglang.org/download/$zig_version/zig-x86_64-linux-$zig_version.tar.xz" +printf '%s %s\n' "$zig_sha256" "$root/zig.tar.xz" | sha256sum --check --strict - +tar -xJf "$root/zig.tar.xz" -C "$root" +zig="$root/zig-x86_64-linux-$zig_version/zig" +"$zig" version | grep -Fx "$zig_version" +# cc-rs passes --target=, which zig cannot parse; zig is already given -target. +cat > "$root/aarch64-cc" < "$root/aarch64-ar" +chmod +x "$root/aarch64-cc" "$root/aarch64-ar" +if [ -n "${GITHUB_ENV:-}" ]; then + { + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=$root/aarch64-cc" + echo "CC_aarch64_unknown_linux_gnu=$root/aarch64-cc" + echo "AR_aarch64_unknown_linux_gnu=$root/aarch64-ar" + } >> "$GITHUB_ENV" +fi diff --git a/scripts/ci-integration-targets.json b/scripts/ci-integration-targets.json index f4709ba8..4e25e63b 100644 --- a/scripts/ci-integration-targets.json +++ b/scripts/ci-integration-targets.json @@ -4,96 +4,92 @@ "schema_version": 1, "bundles": [ [ - "integration-acceptance-test", - "pr-bundle-transactions-relational", - "integration-engine-family-12" - ], - [ - "pr-contextdb-engine-test-seams-4", - "integration-held-out-vector-native-reference", - "integration-acceptance-long-running-transfer-test" + "pr-contextdb-engine-test-seams-13", + "pr-contextdb-engine-test-seams-14", + "pr-contextdb-engine-test-seams-17", + "pr-contextdb-engine-test-seams-5" ], [ - "pr-contextdb-engine-test-seams-2", - "integration-examples", - "pr-contextdb-engine-test-seams-14" + "pr-contextdb-cli", + "integration-engine-family-4", + "integration-engine-family-8" ], [ - "pr-contextdb-engine-test-seams-6", - "pr-bundle-parser-planner-redb", - "integration-acceptance-sync-test" + "pr-contextdb-engine-test-seams-1", + "pr-contextdb-engine-test-seams-8" ], [ - "integration-engine-family-1", - "integration-engine-family-9", - "integration-doctests" + "pr-contextdb-server", + "integration-engine-family-9" ], [ - "pr-contextdb-engine-test-seams-8", - "pr-contextdb-engine-test-seams-9", - "integration-acceptance-sync-release" + "pr-contextdb-engine-test-seams-10", + "pr-contextdb-engine-test-seams-11", + "pr-contextdb-engine-test-seams-16", + "pr-contextdb-engine-test-seams-4" ], [ - "pr-contextdb-engine-test-seams-11", - "pr-contextdb-engine-test-seams-15", + "pr-contextdb-core-test-seams", "pr-contextdb-server-test-seams" ], [ - "integration-engine-family-6", - "integration-indexed-search-work-latency" + "pr-contextdb-engine-test-seams-12", + "pr-contextdb-engine-test-seams-15", + "pr-contextdb-engine-test-seams-7", + "pr-contextdb-engine" ], [ - "pr-contextdb-core-test-seams", - "pr-contextdb-core" + "integration-acceptance-long-running-test", + "integration-acceptance-test", + "integration-engine-family-12", + "integration-engine-family-7" ], [ - "integration-acceptance-release", - "integration-acceptance-long-running-release" + "pr-contextdb-engine-test-seams-6", + "pr-bundle-vector-index-graph" ], [ - "pr-contextdb-engine-test-seams-3", - "integration-sync-schema-transactions", - "pr-contextdb-engine-test-seams-13" + "integration-engine-family-1", + "integration-examples" ], [ - "pr-contextdb-engine-test-seams-10", - "pr-contextdb-engine", - "integration-acceptance-long-running-test" + "integration-acceptance-long-running-release", + "integration-acceptance-release", + "integration-acceptance-sync-release" ], [ - "pr-contextdb-server", - "integration-engine-family-4", - "integration-engine-family-5" + "integration-engine-family-6" ], [ - "pr-contextdb-engine-test-seams-7", - "pr-contextdb-engine-test-seams-16" + "pr-contextdb-engine-test-seams-2", + "pr-contextdb-engine-test-seams-3", + "pr-contextdb-engine-test-seams-9", + "pr-bundle-parser-planner-redb" ], [ - "pr-contextdb-engine-test-seams-1", - "pr-contextdb-engine-test-seams-17" + "integration-acceptance-long-running-transfer-test", + "integration-acceptance-sync-test", + "integration-sync-schema-transactions", + "integration-vector-graph-recall-profile" ], [ - "integration-engine-family-2", + "pr-contextdb-core", "integration-engine-family-3", - "pr-bundle-vector-index-graph" - ], - [ - "pr-contextdb-cli", - "integration-engine-family-8", - "integration-engine-family-7" + "integration-held-out-vector-native-reference", + "integration-indexed-search-work-latency" ], [ - "pr-contextdb-engine-test-seams-12", - "integration-vector-graph-recall-profile", - "pr-contextdb-engine-test-seams-5" + "integration-doctests", + "integration-engine-family-2", + "integration-engine-family-5", + "pr-bundle-transactions-relational" ] ], "fallback_additions": { - "integration-engine-family-10": 8, - "integration-engine-family-11": 10, - "integration-engine-family-13": 14, - "integration-engine-family-14": 15 + "integration-engine-family-10": 12, + "integration-engine-family-11": 12, + "integration-engine-family-13": 4, + "integration-engine-family-14": 4 } }, "pr_jobs": [ diff --git a/scripts/ci-integration-tests.py b/scripts/ci-integration-tests.py index 8834a5c7..a2f02168 100644 --- a/scripts/ci-integration-tests.py +++ b/scripts/ci-integration-tests.py @@ -35,12 +35,12 @@ def test_receipt_uploads_are_cancel_safe_and_fail_on_missing_files(self): def validate(source): upload_blocks = re.findall( - r'(?m)^ - name: Upload constituent [123] (?:shard|integration) receipt\n' + r'(?m)^ - name: Upload constituent [1234] (?:shard|integration) receipt\n' r'(?P.*?)(?=^ - name:|\Z)', source, re.S) - if len(upload_blocks) != 6: - raise ValueError('expected six constituent receipt uploads') + if len(upload_blocks) != 8: + raise ValueError('expected eight constituent receipt uploads') for block in upload_blocks: - if not re.search(r'(?m)^ if: always\(\) && !cancelled\(\) && matrix\.slot[123]\.kind == \'(?:pr|integration)\'$\n', block): + if not re.search(r'(?m)^ if: always\(\) && !cancelled\(\) && matrix\.slot[1234]\.kind == \'(?:pr|integration)\'$\n', block): raise ValueError('receipt upload condition is not cancel-safe') if not re.search(r'(?m)^ if-no-files-found: error$', block): raise ValueError('receipt upload must fail when files are missing') @@ -268,14 +268,14 @@ def test_workflow_routing_and_negative_controls(self): self.assertNotIn('max-parallel', bundle_job['strategy']) caches = [step for step in bundle_job['steps'] if step.get('uses', '').startswith('Swatinem/rust-cache@')] - self.assertEqual(len(caches), 3) + self.assertEqual(len(caches), 4) self.assertEqual([step['with']['key'] for step in caches], ['${{ matrix.slot1.cache_key }}', '${{ matrix.slot2.cache_key }}', - '${{ matrix.slot3.cache_key }}']) - self.assertEqual([step['with']['add-job-id-key'] for step in caches], [False] * 3) + '${{ matrix.slot3.cache_key }}', '${{ matrix.slot4.cache_key }}']) + self.assertEqual([step['with']['add-job-id-key'] for step in caches], [False] * 4) self.assertTrue(all('cache-on-failure' not in step['with'] for step in caches)) runs = {step.get('id'): step for step in bundle_job['steps'] if step.get('id')} - self.assertEqual(set(runs), {f'slot{slot}_{kind}' for slot in range(1, 4) + self.assertEqual(set(runs), {f'slot{slot}_{kind}' for slot in range(1, 5) for kind in ('pr', 'integration')}) self.assertTrue(all(step['continue-on-error'] is True for step in runs.values())) self.assertTrue(all(step['if'].startswith('always() && !cancelled()') @@ -294,7 +294,8 @@ def test_workflow_routing_and_negative_controls(self): outcomes = dict(os.environ, SLOT1_KIND='pr', SLOT1_PR='failure', SLOT1_INTEGRATION='skipped', SLOT2_KIND='integration', SLOT2_PR='skipped', SLOT2_INTEGRATION='success', - SLOT3_KIND='', SLOT3_PR='skipped', SLOT3_INTEGRATION='skipped') + SLOT3_KIND='', SLOT3_PR='skipped', SLOT3_INTEGRATION='skipped', + SLOT4_KIND='', SLOT4_PR='skipped', SLOT4_INTEGRATION='skipped') self.assertNotEqual(subprocess.run(['bash', '-c', final], env=outcomes).returncode, 0) self.assertEqual(len(i.pr_inventory()), 26) @@ -321,9 +322,9 @@ def test_mixed_normal_matrix_is_exact_and_fail_closed(self): self.assertEqual(len(logical_tuples), 29) i.validate_combined(value, self.rows, True, decision) bundles = i.bundle_matrix(value, self.rows, True, decision) - self.assertEqual(len(bundles['include']), 18) + self.assertEqual(len(bundles['include']), 16) flattened = [bundle[f'slot{slot}'] for bundle in bundles['include'] - for slot in range(1, 4) if bundle[f'slot{slot}'] is not None] + for slot in range(1, 5) if bundle[f'slot{slot}'] is not None] self.assertEqual(len(flattened), len(value['include'])) self.assertEqual({row['id'] for row in flattened}, {row['id'] for row in value['include']}) @@ -339,12 +340,12 @@ def test_mixed_normal_matrix_is_exact_and_fail_closed(self): changed_bundles['include'][0]['slot1']['cache_key'] += '-changed' elif mutation == 'source': slot = next(slot for bundle in changed_bundles['include'] - for slot in (bundle['slot1'], bundle['slot2'], bundle['slot3']) + for slot in (bundle['slot1'], bundle['slot2'], bundle['slot3'], bundle['slot4']) if slot is not None and slot['kind'] == 'integration') slot['integration']['source_sha'] = '0' * 40 else: slot = next(slot for bundle in changed_bundles['include'] - for slot in (bundle['slot1'], bundle['slot2'], bundle['slot3']) + for slot in (bundle['slot1'], bundle['slot2'], bundle['slot3'], bundle['slot4']) if slot is not None and slot['kind'] == 'pr') slot['pr']['features'] = 'mixed-profile' with self.subTest(bundle_payload=mutation), self.assertRaises(ValueError): @@ -385,9 +386,9 @@ def test_mixed_normal_matrix_is_exact_and_fail_closed(self): self.assertEqual(len(disabled['include']), 26) self.assertTrue(all(row['kind'] == 'pr' for row in disabled['include'])) disabled_bundles = i.bundle_matrix(disabled, self.rows, False, decision) - self.assertEqual(len(disabled_bundles['include']), 15) + self.assertEqual(len(disabled_bundles['include']), 11) self.assertEqual(sum(bundle[f'slot{slot}'] is not None - for bundle in disabled_bundles['include'] for slot in range(1, 4)), 26) + for bundle in disabled_bundles['include'] for slot in range(1, 5)), 26) # A disabled partition is empty, not an exemption from binding # the published source and certificate decision. for field, value in [('source_sha', '0' * 40), diff --git a/scripts/ci-integration.py b/scripts/ci-integration.py index bd3b12d5..b8c9366a 100644 --- a/scripts/ci-integration.py +++ b/scripts/ci-integration.py @@ -673,7 +673,7 @@ def combined_matrix(rows, enabled, decision, root=ROOT): return {'include': envelopes} -NORMAL_BUNDLES_SHA256 = '54cc4a9062ccb737819faaf7da923793005a8d0dcb55bf28b78e459d953e5c86' +NORMAL_BUNDLES_SHA256 = '14e6e7c3e0958b6e0ab5c435879830fa09fac2eec1de48560304aeb3d5a7584e' SLOT_FIELDS = ENVELOPE_FIELDS | {'cache_key'} @@ -691,19 +691,21 @@ def _bundle_slots(value, enabled, decision, root=ROOT): raise ValueError('normal bundle manifest differs from reviewed design') bundles = definition['bundles'] additions = definition['fallback_additions'] - if (not isinstance(bundles, list) or len(bundles) != 18 - or any(not isinstance(bundle, list) or not 1 <= len(bundle) <= 3 for bundle in bundles) + if (not isinstance(bundles, list) or len(bundles) != 16 + or any(not isinstance(bundle, list) or not 1 <= len(bundle) <= 4 for bundle in bundles) or any(not isinstance(row_id, str) for bundle in bundles for row_id in bundle) or not isinstance(additions, dict) - or additions != {'integration-engine-family-10': 8, - 'integration-engine-family-11': 10, - 'integration-engine-family-13': 14, - 'integration-engine-family-14': 15}): + or additions != {'integration-engine-family-10': 12, + 'integration-engine-family-11': 12, + 'integration-engine-family-13': 4, + 'integration-engine-family-14': 4}): raise ValueError('invalid reviewed normal bundle layout') planned = [list(bundle) for bundle in bundles] if enabled and not decision['dedup_sha']: for row_id, one_based_bundle in additions.items(): planned[one_based_bundle - 1].append(row_id) + if any(len(bundle) > 4 for bundle in planned): + raise ValueError('normal bundle exceeds the four reviewed constituent slots') flattened = [row_id for bundle in planned for row_id in bundle] if len(flattened) != len(set(flattened)): raise ValueError('normal bundles contain duplicate canonical rows') @@ -733,7 +735,8 @@ def bundle_matrix(value, rows, enabled, decision, root=ROOT): slot['name'].removeprefix('normal correctness / ') for slot in slots) result.append(dict(id=f'normal-bundle-{index:02d}', name=name, slot1=slots[0], slot2=slots[1] if len(slots) > 1 else None, - slot3=slots[2] if len(slots) > 2 else None)) + slot3=slots[2] if len(slots) > 2 else None, + slot4=slots[3] if len(slots) > 3 else None)) return {'include': result} diff --git a/scripts/ci-inventory.py b/scripts/ci-inventory.py index f643f3a6..714d184b 100644 --- a/scripts/ci-inventory.py +++ b/scripts/ci-inventory.py @@ -220,7 +220,7 @@ def redb_target_contract(root): for command in commands: if text.count(command) != 1: raise ValueError(f"redb publication target mismatch: {workflow}: {command}") - for workflow, expected in [('ci.yml', 3), ('integration.yml', 1)]: + for workflow, expected in [('ci.yml', 4), ('integration.yml', 1)]: text = (root / '.github/workflows' / workflow).read_text() if text.count('crates/contextdb-redb -> ../../target/redb') != expected: raise ValueError(f"redb cache target mismatch: {workflow}") diff --git a/scripts/ci-required-tests.py b/scripts/ci-required-tests.py index 459c253b..549a2ba9 100644 --- a/scripts/ci-required-tests.py +++ b/scripts/ci-required-tests.py @@ -17,7 +17,7 @@ spec = importlib.util.spec_from_file_location('required', Path(__file__).with_name('ci-required.py')) r = importlib.util.module_from_spec(spec) spec.loader.exec_module(r) -REQUIRED = {'lint', 'test-bundles', 'build', 'features', +REQUIRED = {'lint', 'lint-macos', 'test-bundles', 'build', 'features', 'installed-smoke', 'cross-compile', 'impact', 'integration'} WRITE = {'contents': 'write', 'actions': 'read'} CALLER = {'needs': ['required'], @@ -60,7 +60,7 @@ def test_original_required_inventory_and_release_compile_job_unchanged(self): doc = current() self.assertEqual(r.REQUIRED, REQUIRED) self.assertEqual(doc['jobs']['required']['needs'], - ['lint', 'test-bundles', 'build', 'features', + ['lint', 'lint-macos', 'test-bundles', 'build', 'features', 'installed-smoke', 'cross-compile', 'impact', 'integration']) release = load(RELEASE_JOB)['build'] self.assertEqual(doc['jobs']['build']['steps'], release['steps']) @@ -159,8 +159,12 @@ def test_root_chain_runs_after_failure_and_smoke_is_independent(self): doc = current()['jobs'] self.assertNotIn('needs', doc['lint']) self.assertNotIn('needs', doc['installed-smoke']) - for name, predecessor in [('features', 'lint'), ('build', 'features'), - ('cross-compile', 'build')]: + self.assertNotIn('needs', doc['lint-macos']) + self.assertEqual(doc['lint-macos']['runs-on'], 'macos-latest') + self.assertEqual(doc['lint-macos']['steps'][-1]['run'], 'bash scripts/verify pr lint') + self.assertNotIn('needs', doc['cross-compile']) + self.assertNotIn('if', doc['cross-compile']) + for name, predecessor in [('features', 'lint'), ('build', 'lint')]: self.assertEqual(doc[name]['needs'], predecessor) self.assertEqual(doc[name]['if'], 'always() && !cancelled()') for name, result in [('features', 'failure'), ('build', 'cancelled'), diff --git a/scripts/ci-required.py b/scripts/ci-required.py index 284028f0..136a2714 100644 --- a/scripts/ci-required.py +++ b/scripts/ci-required.py @@ -7,7 +7,7 @@ import re import sys -REQUIRED = {"lint", "test-bundles", "build", "features", +REQUIRED = {"lint", "lint-macos", "test-bundles", "build", "features", "installed-smoke", "cross-compile", "impact", "integration"} @@ -94,8 +94,9 @@ def integration_caller(workflow): def root_chain(workflow): - expected = {'lint': None, 'features': 'lint', 'build': 'features', - 'cross-compile': 'build', 'installed-smoke': None} + expected = {'lint': None, 'features': 'lint', 'build': 'lint', + 'cross-compile': None, 'installed-smoke': None, + 'lint-macos': None} for name, predecessor in expected.items(): block = re.search(r"^ " + re.escape(name) + r":\n(.*?)(?=^ [\w-]+:|\Z)", workflow, re.M | re.S) diff --git a/tests/acceptance/db_lock_visibility.rs b/tests/acceptance/db_lock_visibility.rs index b7543a85..f9721c81 100644 --- a/tests/acceptance/db_lock_visibility.rs +++ b/tests/acceptance/db_lock_visibility.rs @@ -237,34 +237,31 @@ fn t19_05_database_locked_carries_path_and_pid() { } /// REGRESSION GUARD — t19_06: stale PID lock from a crashed prior process is reclaimable. +#[cfg(target_os = "linux")] #[test] fn t19_06_crashed_holder_pid_lock_reclaimable() { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("stale.redb"); // Write a .lock file pointing to a known-dead PID. We pick u32::MAX because - // /proc/4294967295 will not exist on Linux. (Test is skipped on non-Linux - // since acquire_pid_lock's /proc check is Linux-specific; cfg-gate - // accordingly.) - #[cfg(target_os = "linux")] - { - let lock_path = path.with_extension("lock"); - std::fs::write(&lock_path, format!("{}", u32::MAX)).unwrap(); - - let h = Database::open(&path).expect("stale PID lock must be reclaimed"); - // Sanity: write a row, read it back. - h.execute( - "CREATE TABLE t (id UUID PRIMARY KEY)", - &std::collections::HashMap::new(), - ) - .unwrap(); - let scan = h.scan("t", h.snapshot()).unwrap(); - assert_eq!( - scan.len(), - 0, - "fresh DB after stale-lock reclaim should be empty" - ); - } + // /proc/4294967295 will not exist on Linux. (The test is Linux-only + // since acquire_pid_lock's /proc check is Linux-specific.) + let lock_path = path.with_extension("lock"); + std::fs::write(&lock_path, format!("{}", u32::MAX)).unwrap(); + + let h = Database::open(&path).expect("stale PID lock must be reclaimed"); + // Sanity: write a row, read it back. + h.execute( + "CREATE TABLE t (id UUID PRIMARY KEY)", + &std::collections::HashMap::new(), + ) + .unwrap(); + let scan = h.scan("t", h.snapshot()).unwrap(); + assert_eq!( + scan.len(), + 0, + "fresh DB after stale-lock reclaim should be empty" + ); } /// RED — t19_07: cross-variant open rejection — all open APIs share the lock registry. diff --git a/tests/integration/persistence_tests.rs b/tests/integration/persistence_tests.rs index b92a5e81..9a734a16 100644 --- a/tests/integration/persistence_tests.rs +++ b/tests/integration/persistence_tests.rs @@ -14,7 +14,9 @@ use std::process::{Child, Command}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; -use std::time::{Duration, Instant}; +use std::time::Duration; +#[cfg(target_os = "linux")] +use std::time::Instant; use tempfile::TempDir; use uuid::Uuid;