From e5feef2496bffbb3935ffc55714395dc6e3cf5c9 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:32:47 -0800 Subject: [PATCH 01/93] perf(code-index): pin staged segment reads per preparation Refs #4533 --- .../code_index_scheduler/publication_store.rs | 192 +++++++++++++++++- .../tests/publication_store.rs | 129 +++++++++++- 2 files changed, 315 insertions(+), 6 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 1fd7b36eec5..105348511dc 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -574,6 +574,12 @@ struct DaemonCodeIndexPreparationFilesV1 { // prohibit removing an open file. Retention outlives all staging cleanup. segments: StagedGenerationSegmentsV1, evidence: TemporaryEvidencePackV1, + // Only the open preparation owns these handles. Its retention lease + // prevents an authorized sweep from unlinking their sealed paths. + #[cfg(unix)] + pinned_segment_reads: VecDeque, + #[cfg(unix)] + segment_read_open_attempts: u64, directory: tempfile::TempDir, _retention: CodeGenerationStoreLockV1, referenced_segment_bytes: u64, @@ -602,6 +608,15 @@ impl DaemonCodeIndexPreparationV1 { }) } + #[cfg(all(test, unix))] + pub(super) fn staged_segment_read_open_attempts_for_test(&self) -> u64 { + let state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + match &*state { + DaemonCodeIndexPreparationStateV1::Open(files) => files.segment_read_open_attempts, + _ => 0, + } + } + fn invalidate_uncommitted(&self) { let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); if matches!(&*state, DaemonCodeIndexPreparationStateV1::Open(_)) { @@ -627,10 +642,16 @@ impl DaemonCodeIndexPreparationV1 { segments, mut evidence, _retention, + #[cfg(unix)] + pinned_segment_reads, .. } = *files; evidence.rollback_unattached(directory.path())?; drop(evidence); + // Windows cannot unlink an open staging file. Release any read pins + // before staged cleanup, even when the preparation is aborted. + #[cfg(unix)] + drop(pinned_segment_reads); drop(segments); let result = directory .close() @@ -774,10 +795,10 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { let state = Arc::clone(&self.state); let segments_root = self.segments_root.clone(); Arc::new(move |request, buffer| { - let state = state + let mut state = state .lock() .map_err(|_| CodeIndexProductionErrorV1::Publication(Self::closed()))?; - match &*state { + match &mut *state { DaemonCodeIndexPreparationStateV1::Aborted => { Err(CodeIndexProductionErrorV1::Publication(Self::closed())) } @@ -799,8 +820,80 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { ) })?; let canonical = segments_root.join(format!("segment-{suffix}.json")); - let path = files.segments.pending.get(&canonical).unwrap_or(&canonical); - DaemonCodeIndexPublicationStoreV1::read_segment_at(path, request, buffer) + let path = files + .segments + .pending + .get(&canonical) + .cloned() + .unwrap_or(canonical); + #[cfg(not(unix))] + { + DaemonCodeIndexPublicationStoreV1::read_segment_at( + &path, request, buffer, + ) + } + #[cfg(unix)] + { + // Range reads from the same staged segment can be + // interleaved by the sparse successor. Keep at most + // eight handles, in least-recently-used order. + if let Some(position) = files + .pinned_segment_reads + .iter() + .position(|entry| entry.path == path) + { + let entry = &files.pinned_segment_reads[position]; + let unchanged = path + .symlink_metadata() + .ok() + .zip(entry.segment.file.metadata().ok()) + .is_some_and(|(named, pinned)| { + DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity( + &named, &pinned, + ) + }); + let entry = files.pinned_segment_reads.remove(position).ok_or_else( + || { + CodeIndexProductionErrorV1::Contract( + "staged segment read cache lost a handle".to_owned(), + ) + }, + )?; + if unchanged { + files.pinned_segment_reads.push_back(entry); + } + } + if files + .pinned_segment_reads + .back() + .is_none_or(|entry| entry.path != path) + { + let segment = + DaemonCodeIndexPublicationStoreV1::open_pinned_staged_segment( + &path, request, + )?; + files.segment_read_open_attempts += 1; + if files.pinned_segment_reads.len() + == MAX_STAGED_SEGMENT_READ_HANDLES + { + files.pinned_segment_reads.pop_front(); + } + files.pinned_segment_reads.push_back(PinnedPreparationSegmentV1 { + path, + segment, + }); + } + let pinned = files.pinned_segment_reads.back_mut().ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "staged segment read cache has no handle".to_owned(), + ) + })?; + DaemonCodeIndexPublicationStoreV1::read_pinned_partitioned_segment( + &mut pinned.segment, + request, + buffer, + ) + } } } }) @@ -896,6 +989,16 @@ struct PinnedGenerationSegmentV1 { file: File, } +// The file count is bounded independently of the number of staged segments. +#[cfg(unix)] +const MAX_STAGED_SEGMENT_READ_HANDLES: usize = 8; + +#[cfg(unix)] +struct PinnedPreparationSegmentV1 { + path: PathBuf, + segment: PinnedGenerationSegmentV1, +} + impl TemporaryEvidencePackV1 { pub(super) fn create(path: PathBuf) -> Result { let file = std::fs::OpenOptions::new() @@ -1937,6 +2040,83 @@ impl DaemonCodeIndexPublicationStoreV1 { }) } + #[cfg(unix)] + fn same_unix_segment_identity( + named: &std::fs::Metadata, + pinned: &std::fs::Metadata, + ) -> bool { + use std::os::unix::fs::MetadataExt; + + named.file_type().is_file() + && pinned.is_file() + && named.dev() == pinned.dev() + && named.ino() == pinned.ino() + && named.len() == pinned.len() + && named.mtime() == pinned.mtime() + && named.mtime_nsec() == pinned.mtime_nsec() + && named.ctime() == pinned.ctime() + && named.ctime_nsec() == pinned.ctime_nsec() + } + + /// One actual file open per cache miss. Do not reopen via Handle::from_path + /// on macOS: comparing the Unix inode and change-time metadata before and + /// after open proves the same identity without another endpoint scan. + #[cfg(unix)] + fn open_pinned_staged_segment( + path: &Path, + request: SealedGenerationSegmentReadV1<'_>, + ) -> Result { + let (digest, size_bytes, offset, length) = match request { + SealedGenerationSegmentReadV1::Whole { digest, size_bytes } => { + (digest.as_str(), size_bytes, 0, size_bytes) + } + SealedGenerationSegmentReadV1::Range { + digest, + size_bytes, + offset, + length, + } => (digest.as_str(), size_bytes, offset, length), + }; + if offset + .checked_add(length) + .is_none_or(|end| end > size_bytes) + { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed generation segment range exceeds its manifest identity".to_owned(), + )); + } + let named = path.symlink_metadata().map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "staged sealed segment is unavailable: {error}" + )) + })?; + if !named.file_type().is_file() || named.len() != size_bytes { + return Err(CodeIndexProductionErrorV1::Contract( + "staged sealed segment identity does not match its manifest".to_owned(), + )); + } + let file = File::open(path).map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "staged sealed segment cannot be opened: {error}" + )) + })?; + let opened = file.metadata().map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "staged sealed segment metadata cannot be read: {error}" + )) + })?; + if !Self::same_unix_segment_identity(&named, &opened) { + return Err(CodeIndexProductionErrorV1::Contract( + "staged sealed segment was replaced while opening".to_owned(), + )); + } + Ok(PinnedGenerationSegmentV1 { + digest: digest.to_owned(), + size_bytes, + file, + }) + } + fn read_pinned_partitioned_segment( pinned: &mut PinnedGenerationSegmentV1, request: SealedGenerationSegmentReadV1<'_>, @@ -3053,6 +3233,10 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { directory, segments: StagedGenerationSegmentsV1::new(), evidence, + #[cfg(unix)] + pinned_segment_reads: VecDeque::new(), + #[cfg(unix)] + segment_read_open_attempts: 0, _retention: retention, referenced_segment_bytes: 0, manifest_identity: None, diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs index cd6290fbe80..22699e6c807 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs @@ -56,8 +56,9 @@ use tracedecay_code_index::production::{ CodeGraphBuildBoundV1, CodeIndexAtomicPublicationPort, CodeIndexExecutionControlV1, CodeIndexInterruptionV1, CodeIndexPreparationPort, CodeIndexProductionErrorV1, CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, - SEALED_GENERATION_FORMAT_REVISION_V1, SealedGenerationSegmentReadV1, - SharedDecodedContentPoolV1, UninterruptibleCodeIndexControlV1, VerifiedSealedLexicalPageReadV1, + SEALED_GENERATION_FORMAT_REVISION_V1, SealedGenerationSegmentPublicationV1, + SealedGenerationSegmentReadV1, SharedDecodedContentPoolV1, + UninterruptibleCodeIndexControlV1, VerifiedSealedLexicalPageReadV1, }; /// Re-publication stages an already validated decoded generation through the @@ -85,6 +86,130 @@ fn publish_generation( result } +/// The real preparation's segment reader must reuse handles, not merely +/// count an unrelated observer or pin a single noninterleaved page. +#[cfg(unix)] +#[test] +fn staged_segment_range_reads_open_once_per_cached_file_and_revalidate_replacements() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn fixture() -> u32 { 7 }\n")]); + let source_store = TempDir::new().expect("source store"); + let generation = { + let mut source = scheduler( + &fixture, + source_store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(source.reconcile_now().expect("source publication")); + Arc::clone(&source.latest_complete().expect("decode source").generation) + }; + let target_store = TempDir::new().expect("target store"); + let mut target = super::super::DaemonCodeIndexPublicationStoreV1::new( + target_store.path(), + fixture.path(), + SanitizerRevision::new(tracedecay_privacy::CODE_SOURCE_SANITIZER_VERSION_V1) + .expect("sanitizer revision"), + ) + .expect("target publication store"); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let contents = (0..9) + .map(|index| format!("sealed-staged-segment-{index}-bytes").into_bytes()) + .collect::>(); + let digests = contents + .iter() + .map(|bytes| { + ManifestDigest::from_sha256_bytes(&Sha256::digest(bytes)) + .expect("canonical digest") + }) + .collect::>(); + for (digest, bytes) in digests.iter().zip(&contents) { + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { digest, bytes }) + .expect("write exact staged segment"); + } + let reader = preparation.segment_reader(); + let mut read = Vec::new(); + for offset in 0..16 { + reader( + SealedGenerationSegmentReadV1::Range { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + offset, + length: 4, + }, + &mut read, + ) + .expect("read staged segment range"); + assert_eq!(&read, &contents[0][offset as usize..offset as usize + 4]); + } + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + 1, + "16 overlapping production reads must open the first file once", + ); + for (digest, bytes) in digests.iter().zip(&contents).skip(1) { + reader( + SealedGenerationSegmentReadV1::Whole { + digest, + size_bytes: bytes.len() as u64, + }, + &mut read, + ) + .expect("read distinct staged segment"); + assert_eq!(&read, bytes); + } + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + 9, + "nine genuinely distinct segments must each need a real file open", + ); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + }, + &mut read, + ) + .expect("reopen the evicted first segment"); + assert_eq!(&read, &contents[0]); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + 10, + "an eight-handle bound must evict and reopen the ninth distinct file", + ); + + let segments_root = code_generation_segments_root(target_store.path()); + let staging_directory = std::fs::read_dir(segments_root) + .expect("staging root") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| path.is_dir()) + .expect("one active preparation directory"); + let suffix = sha256_hex_suffix(digests[0].as_str()).expect("digest suffix"); + let staged_path = staging_directory.join(format!("segment-{suffix}.json")); + let replacement_path = staging_directory.join("replacement.tmp"); + let replacement = vec![b'Z'; contents[0].len()]; + std::fs::write(&replacement_path, &replacement).expect("write replacement bytes"); + std::fs::rename(replacement_path, staged_path).expect("replace the pinned path"); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + }, + &mut read, + ) + .expect("read current pathname rather than an unlinked handle"); + assert_eq!(read, replacement, "a stale cached inode must never serve"); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + 11, + "path replacement must trigger another real file open", + ); + drop(reader); + preparation.abort().expect("abort and clean staged files"); +} + #[test] fn a_prepared_publication_retries_without_a_construction_worker_reservation() { let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); From cbe966b5411b809856a118e666d86c23e121690f Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:34:26 -0800 Subject: [PATCH 02/93] fix(code-index): scope staged reader test imports to Unix Refs #4533 --- .../src/code_index_scheduler/tests/publication_store.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs index 22699e6c807..18144e44d9a 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs @@ -56,10 +56,11 @@ use tracedecay_code_index::production::{ CodeGraphBuildBoundV1, CodeIndexAtomicPublicationPort, CodeIndexExecutionControlV1, CodeIndexInterruptionV1, CodeIndexPreparationPort, CodeIndexProductionErrorV1, CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, - SEALED_GENERATION_FORMAT_REVISION_V1, SealedGenerationSegmentPublicationV1, - SealedGenerationSegmentReadV1, SharedDecodedContentPoolV1, - UninterruptibleCodeIndexControlV1, VerifiedSealedLexicalPageReadV1, + SEALED_GENERATION_FORMAT_REVISION_V1, SealedGenerationSegmentReadV1, + SharedDecodedContentPoolV1, UninterruptibleCodeIndexControlV1, VerifiedSealedLexicalPageReadV1, }; +#[cfg(unix)] +use tracedecay_code_index::production::SealedGenerationSegmentPublicationV1; /// Re-publication stages an already validated decoded generation through the /// same target writer; failures retain the port's exact typed refusal. From 0d6c177e00b404c7fe34bfa5ad7d93bd33182403 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:36:08 -0800 Subject: [PATCH 03/93] perf(code-index): expose staged open counts in tracing Refs #4533 --- .../code_index_scheduler/publication_store.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 105348511dc..b73f6ee896a 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -852,13 +852,11 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { &named, &pinned, ) }); - let entry = files.pinned_segment_reads.remove(position).ok_or_else( - || { - CodeIndexProductionErrorV1::Contract( - "staged segment read cache lost a handle".to_owned(), - ) - }, - )?; + let entry = files.pinned_segment_reads.remove(position).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "staged segment read cache lost a handle".to_owned(), + ) + })?; if unchanged { files.pinned_segment_reads.push_back(entry); } @@ -873,6 +871,11 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { &path, request, )?; files.segment_read_open_attempts += 1; + tracing::trace!( + target: "tracedecay::code_index", + staged_segment_file_open_count = files.segment_read_open_attempts, + "opened staged segment handle after an LRU miss" + ); if files.pinned_segment_reads.len() == MAX_STAGED_SEGMENT_READ_HANDLES { From b1cfe79ec17694e8d0790e910712abf5488c4751 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:51:02 -0800 Subject: [PATCH 04/93] fix(code-index): refuse replaced pinned staging files --- .../code_index_scheduler/publication_store.rs | 14 +++++- .../tests/publication_store.rs | 47 ++++++++++++++++--- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index b73f6ee896a..95574ec4599 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -648,8 +648,8 @@ impl DaemonCodeIndexPreparationV1 { } = *files; evidence.rollback_unattached(directory.path())?; drop(evidence); - // Windows cannot unlink an open staging file. Release any read pins - // before staged cleanup, even when the preparation is aborted. + // Release pinned descriptors before staged cleanup so a failed + // preparation cannot retain unlinked sealed content. #[cfg(unix)] drop(pinned_segment_reads); drop(segments); @@ -859,6 +859,16 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { })?; if unchanged { files.pinned_segment_reads.push_back(entry); + } else { + // A sealed content-addressed path must not be + // replaced during one preparation. Reopening it + // could return bytes that do not match the + // manifest's digest, even with the same size. + buffer.clear(); + return Err(CodeIndexProductionErrorV1::Contract( + "staged sealed segment identity changed while pinned" + .to_owned(), + )); } } if files diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs index 18144e44d9a..d4bc35c42b3 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs @@ -149,6 +149,24 @@ fn staged_segment_range_reads_open_once_per_cached_file_and_revalidate_replaceme 1, "16 overlapping production reads must open the first file once", ); + let invalid_range = reader( + SealedGenerationSegmentReadV1::Range { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + offset: contents[0].len() as u64 - 1, + length: 2, + }, + &mut read, + ); + assert!( + matches!(invalid_range, Err(CodeIndexProductionErrorV1::Contract(_))), + "a range outside the sealed extent must be refused", + ); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + 1, + "rejecting a bad range must not reopen the pinned file", + ); for (digest, bytes) in digests.iter().zip(&contents).skip(1) { reader( SealedGenerationSegmentReadV1::Whole { @@ -185,7 +203,15 @@ fn staged_segment_range_reads_open_once_per_cached_file_and_revalidate_replaceme .expect("staging root") .filter_map(Result::ok) .map(|entry| entry.path()) - .find(|path| path.is_dir()) + .find(|path| { + path.is_dir() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.starts_with(CODE_INDEX_PREPARATION_DIRECTORY_PREFIX_V1) + }) + }) .expect("one active preparation directory"); let suffix = sha256_hex_suffix(digests[0].as_str()).expect("digest suffix"); let staged_path = staging_directory.join(format!("segment-{suffix}.json")); @@ -193,22 +219,29 @@ fn staged_segment_range_reads_open_once_per_cached_file_and_revalidate_replaceme let replacement = vec![b'Z'; contents[0].len()]; std::fs::write(&replacement_path, &replacement).expect("write replacement bytes"); std::fs::rename(replacement_path, staged_path).expect("replace the pinned path"); - reader( + let replaced = reader( SealedGenerationSegmentReadV1::Whole { digest: &digests[0], size_bytes: contents[0].len() as u64, }, &mut read, - ) - .expect("read current pathname rather than an unlinked handle"); - assert_eq!(read, replacement, "a stale cached inode must never serve"); + ); + assert!( + matches!(replaced, Err(CodeIndexProductionErrorV1::Contract(_))), + "a changed sealed pathname must be refused, not silently reread", + ); + assert!(read.is_empty(), "a refused read must not expose prior bytes"); assert_eq!( preparation.staged_segment_read_open_attempts_for_test(), - 11, - "path replacement must trigger another real file open", + 10, + "refusing a replaced pinned file must not open untrusted bytes", ); drop(reader); preparation.abort().expect("abort and clean staged files"); + assert!( + !staged_path.exists(), + "an aborted preparation must remove a replaced staged segment", + ); } #[test] From ce6566c8dda9d3666e232929673122e86774a1ba Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:57:12 -0800 Subject: [PATCH 05/93] fix(code-index): poison replaced staging reads and count open attempts --- .../code_index_scheduler/publication_store.rs | 75 +++++++++++++------ .../tests/publication_store.rs | 17 +++++ 2 files changed, 68 insertions(+), 24 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 95574ec4599..a4cef39c080 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -580,6 +580,10 @@ struct DaemonCodeIndexPreparationFilesV1 { pinned_segment_reads: VecDeque, #[cfg(unix)] segment_read_open_attempts: u64, + // One changed sealed pathname invalidates the entire transaction. Retrying + // must not silently accept substituted bytes under the same digest name. + #[cfg(unix)] + segment_reads_poisoned: bool, directory: tempfile::TempDir, _retention: CodeGenerationStoreLockV1, referenced_segment_bytes: u64, @@ -810,6 +814,13 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { ) } DaemonCodeIndexPreparationStateV1::Open(files) => { + #[cfg(unix)] + if files.segment_reads_poisoned { + buffer.clear(); + return Err(CodeIndexProductionErrorV1::Contract( + "staged sealed segment identity was invalidated".to_owned(), + )); + } let digest = match request { SealedGenerationSegmentReadV1::Whole { digest, .. } | SealedGenerationSegmentReadV1::Range { digest, .. } => digest, @@ -864,6 +875,7 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { // replaced during one preparation. Reopening it // could return bytes that do not match the // manifest's digest, even with the same size. + files.segment_reads_poisoned = true; buffer.clear(); return Err(CodeIndexProductionErrorV1::Contract( "staged sealed segment identity changed while pinned" @@ -878,14 +890,10 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { { let segment = DaemonCodeIndexPublicationStoreV1::open_pinned_staged_segment( - &path, request, + &path, + request, + &mut files.segment_read_open_attempts, )?; - files.segment_read_open_attempts += 1; - tracing::trace!( - target: "tracedecay::code_index", - staged_segment_file_open_count = files.segment_read_open_attempts, - "opened staged segment handle after an LRU miss" - ); if files.pinned_segment_reads.len() == MAX_STAGED_SEGMENT_READ_HANDLES { @@ -2023,24 +2031,32 @@ impl DaemonCodeIndexPublicationStoreV1 { "sealed generation segment metadata cannot be read: {error}" )) })?; - let file_identity = Handle::from_file(file.try_clone().map_err(|error| { - CodeIndexProductionErrorV1::Contract(format!( - "sealed generation segment handle cannot be cloned: {error}" - )) - })?) - .map_err(|error| { - CodeIndexProductionErrorV1::Contract(format!( - "sealed generation segment handle identity cannot be read: {error}" - )) - })?; - let path_identity = Handle::from_path(&path).map_err(|error| { - CodeIndexProductionErrorV1::Contract(format!( - "sealed generation segment path identity cannot be read: {error}" - )) - })?; + // On Unix the pathname/opened inode and change-time identity can be + // compared without Handle::from_path (another endpoint-scanned open). + #[cfg(unix)] + let same_identity = Self::same_unix_segment_identity(&path_metadata, &file_metadata); + #[cfg(not(unix))] + let same_identity = { + let file_identity = Handle::from_file(file.try_clone().map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation segment handle cannot be cloned: {error}" + )) + })?) + .map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation segment handle identity cannot be read: {error}" + )) + })?; + let path_identity = Handle::from_path(&path).map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation segment path identity cannot be read: {error}" + )) + })?; + file_identity == path_identity + }; if !file_metadata.is_file() || file_metadata.len() != expected_size - || file_identity != path_identity + || !same_identity { return Err(CodeIndexProductionErrorV1::Contract( "sealed generation segment identity changed while it was opened".to_owned(), @@ -2078,6 +2094,7 @@ impl DaemonCodeIndexPublicationStoreV1 { fn open_pinned_staged_segment( path: &Path, request: SealedGenerationSegmentReadV1<'_>, + open_attempts: &mut u64, ) -> Result { let (digest, size_bytes, offset, length) = match request { SealedGenerationSegmentReadV1::Whole { digest, size_bytes } => { @@ -2108,6 +2125,12 @@ impl DaemonCodeIndexPublicationStoreV1 { "staged sealed segment identity does not match its manifest".to_owned(), )); } + *open_attempts = (*open_attempts).saturating_add(1); + tracing::trace!( + target: "tracedecay::code_index", + staged_segment_file_open_count = *open_attempts, + "attempted staged segment pathname open after an LRU miss" + ); let file = File::open(path).map_err(|error| { CodeIndexProductionErrorV1::Contract(format!( "staged sealed segment cannot be opened: {error}" @@ -2135,6 +2158,8 @@ impl DaemonCodeIndexPublicationStoreV1 { request: SealedGenerationSegmentReadV1<'_>, buffer: &mut Vec, ) -> Result<(), CodeIndexProductionErrorV1> { + // Errors must not leave bytes from an earlier successful range visible. + buffer.clear(); let (digest, expected_size, offset, length) = match request { SealedGenerationSegmentReadV1::Whole { digest, size_bytes } => { (digest.as_str(), size_bytes, 0, size_bytes) @@ -2161,13 +2186,13 @@ impl DaemonCodeIndexPublicationStoreV1 { "sealed generation segment range exceeds addressable memory".to_owned(), ) })?; - buffer.clear(); buffer.resize(length, 0); pinned .file .seek(SeekFrom::Start(offset)) .and_then(|_| pinned.file.read_exact(buffer)) .map_err(|error| { + buffer.clear(); CodeIndexProductionErrorV1::Contract(format!( "sealed generation segment read failed: {error}" )) @@ -3250,6 +3275,8 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { pinned_segment_reads: VecDeque::new(), #[cfg(unix)] segment_read_open_attempts: 0, + #[cfg(unix)] + segment_reads_poisoned: false, _retention: retention, referenced_segment_bytes: 0, manifest_identity: None, diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs index d4bc35c42b3..cdd890bbe1e 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs @@ -162,6 +162,7 @@ fn staged_segment_range_reads_open_once_per_cached_file_and_revalidate_replaceme matches!(invalid_range, Err(CodeIndexProductionErrorV1::Contract(_))), "a range outside the sealed extent must be refused", ); + assert!(read.is_empty(), "an invalid range must not expose stale read bytes"); assert_eq!( preparation.staged_segment_read_open_attempts_for_test(), 1, @@ -236,6 +237,22 @@ fn staged_segment_range_reads_open_once_per_cached_file_and_revalidate_replaceme 10, "refusing a replaced pinned file must not open untrusted bytes", ); + // A second request must not turn the first identity refusal into a new + // successful read of substituted content with the same size and name. + let retry = reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + }, + &mut read, + ); + assert!(matches!(retry, Err(CodeIndexProductionErrorV1::Contract(_)))); + assert!(read.is_empty(), "a poisoned preparation must remain closed to reads"); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + 10, + "poisoned staged segment must not be reopened after identity refusal", + ); drop(reader); preparation.abort().expect("abort and clean staged files"); assert!( From 4dfd243c89d53719571d980df779635293c4b5a6 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:58:57 -0800 Subject: [PATCH 06/93] fix(code-index): refuse publication after staged identity failure --- .../src/code_index_scheduler/publication_store.rs | 12 ++++++++++++ .../code_index_scheduler/tests/publication_store.rs | 10 ++++++++++ 2 files changed, 22 insertions(+) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index a4cef39c080..d85a7b334db 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -689,6 +689,12 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { let DaemonCodeIndexPreparationStateV1::Open(files) = &mut *state else { return Err(Self::closed()); }; + #[cfg(unix)] + if files.segment_reads_poisoned { + return Err(DaemonCodeIndexPublicationStoreV1::unavailable( + "staged sealed segment identity was invalidated", + )); + } if files.manifest_identity.is_some() { return Err(DaemonCodeIndexPublicationStoreV1::unavailable( "code-index preparation already entered commit", @@ -3336,6 +3342,12 @@ impl DaemonCodeIndexPublicationStoreV1 { let DaemonCodeIndexPreparationStateV1::Open(files) = &mut *session else { return Err(DaemonCodeIndexPreparationV1::closed()); }; + #[cfg(unix)] + if files.segment_reads_poisoned { + return Err(Self::unavailable( + "staged sealed segment identity was invalidated", + )); + } let manifest_bytes = generation.manifest_bytes(); let generation_size = u64::try_from(manifest_bytes.len()).map_err(Self::unavailable)?; if generation_size > MAX_DURABLE_GENERATION_INDEX_BYTES_V1 { diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs index cdd890bbe1e..3e47318778a 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs @@ -253,6 +253,16 @@ fn staged_segment_range_reads_open_once_per_cached_file_and_revalidate_replaceme 10, "poisoned staged segment must not be reopened after identity refusal", ); + let write_after_poison = preparation.write_segment( + SealedGenerationSegmentPublicationV1::File { + digest: &digests[1], + bytes: &contents[1], + }, + ); + assert!( + matches!(write_after_poison, Err(CodeIndexPublicationStoreErrorV1::Unavailable(_))), + "a poisoned preparation must refuse further writes as well as reads", + ); drop(reader); preparation.abort().expect("abort and clean staged files"); assert!( From 38137c8bf5ae218831287a44a8de1b7cdf472f91 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:00:00 -0800 Subject: [PATCH 07/93] fix(code-index): recheck path identity without another open --- .../code_index_scheduler/publication_store.rs | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index d85a7b334db..a604ca01d92 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -2040,7 +2040,18 @@ impl DaemonCodeIndexPublicationStoreV1 { // On Unix the pathname/opened inode and change-time identity can be // compared without Handle::from_path (another endpoint-scanned open). #[cfg(unix)] - let same_identity = Self::same_unix_segment_identity(&path_metadata, &file_metadata); + let same_identity = { + // Also re-stat the pathname after opening. Comparing just the + // pre-open path and fd would miss a replacement after open, while + // Handle::from_path would require another endpoint-scanned open. + let path_after_open = path.symlink_metadata().map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation segment pathname cannot be rechecked: {error}" + )) + })?; + Self::same_unix_segment_identity(&path_metadata, &file_metadata) + && Self::same_unix_segment_identity(&path_after_open, &file_metadata) + }; #[cfg(not(unix))] let same_identity = { let file_identity = Handle::from_file(file.try_clone().map_err(|error| { @@ -2147,7 +2158,14 @@ impl DaemonCodeIndexPublicationStoreV1 { "staged sealed segment metadata cannot be read: {error}" )) })?; - if !Self::same_unix_segment_identity(&named, &opened) { + let named_after_open = path.symlink_metadata().map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "staged sealed segment pathname cannot be rechecked: {error}" + )) + })?; + if !Self::same_unix_segment_identity(&named, &opened) + || !Self::same_unix_segment_identity(&named_after_open, &opened) + { return Err(CodeIndexProductionErrorV1::Contract( "staged sealed segment was replaced while opening".to_owned(), )); From 21827ea1721727137a5fe5cc9e05d1d227b55acf Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:30:17 -0800 Subject: [PATCH 08/93] style(code-index): format staged segment reader regressions --- .../code_index_scheduler/publication_store.rs | 36 +++++++---------- .../tests/publication_store.rs | 39 ++++++++++++------- 2 files changed, 39 insertions(+), 36 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index a604ca01d92..0cfc799f937 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -845,9 +845,7 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { .unwrap_or(canonical); #[cfg(not(unix))] { - DaemonCodeIndexPublicationStoreV1::read_segment_at( - &path, request, buffer, - ) + DaemonCodeIndexPublicationStoreV1::read_segment_at(&path, request, buffer) } #[cfg(unix)] { @@ -869,11 +867,12 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { &named, &pinned, ) }); - let entry = files.pinned_segment_reads.remove(position).ok_or_else(|| { - CodeIndexProductionErrorV1::Contract( - "staged segment read cache lost a handle".to_owned(), - ) - })?; + let entry = + files.pinned_segment_reads.remove(position).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "staged segment read cache lost a handle".to_owned(), + ) + })?; if unchanged { files.pinned_segment_reads.push_back(entry); } else { @@ -900,15 +899,12 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { request, &mut files.segment_read_open_attempts, )?; - if files.pinned_segment_reads.len() - == MAX_STAGED_SEGMENT_READ_HANDLES - { + if files.pinned_segment_reads.len() == MAX_STAGED_SEGMENT_READ_HANDLES { files.pinned_segment_reads.pop_front(); } - files.pinned_segment_reads.push_back(PinnedPreparationSegmentV1 { - path, - segment, - }); + files + .pinned_segment_reads + .push_back(PinnedPreparationSegmentV1 { path, segment }); } let pinned = files.pinned_segment_reads.back_mut().ok_or_else(|| { CodeIndexProductionErrorV1::Contract( @@ -2071,10 +2067,7 @@ impl DaemonCodeIndexPublicationStoreV1 { })?; file_identity == path_identity }; - if !file_metadata.is_file() - || file_metadata.len() != expected_size - || !same_identity - { + if !file_metadata.is_file() || file_metadata.len() != expected_size || !same_identity { return Err(CodeIndexProductionErrorV1::Contract( "sealed generation segment identity changed while it was opened".to_owned(), )); @@ -2087,10 +2080,7 @@ impl DaemonCodeIndexPublicationStoreV1 { } #[cfg(unix)] - fn same_unix_segment_identity( - named: &std::fs::Metadata, - pinned: &std::fs::Metadata, - ) -> bool { + fn same_unix_segment_identity(named: &std::fs::Metadata, pinned: &std::fs::Metadata) -> bool { use std::os::unix::fs::MetadataExt; named.file_type().is_file() diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs index 3e47318778a..7a4d43e477a 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs @@ -52,6 +52,8 @@ use crate::code_index_scheduler::{ CodeIndexReconcileOutcomeV1, CodeIndexSchedulerErrorV1, CodeIndexSchedulerRegistryV1, CodeIndexWorktreeSchedulerV1, SharedCodeIndexBytePoolV1, scoped_code_index_store_root, }; +#[cfg(unix)] +use tracedecay_code_index::production::SealedGenerationSegmentPublicationV1; use tracedecay_code_index::production::{ CodeGraphBuildBoundV1, CodeIndexAtomicPublicationPort, CodeIndexExecutionControlV1, CodeIndexInterruptionV1, CodeIndexPreparationPort, CodeIndexProductionErrorV1, @@ -59,8 +61,6 @@ use tracedecay_code_index::production::{ SEALED_GENERATION_FORMAT_REVISION_V1, SealedGenerationSegmentReadV1, SharedDecodedContentPoolV1, UninterruptibleCodeIndexControlV1, VerifiedSealedLexicalPageReadV1, }; -#[cfg(unix)] -use tracedecay_code_index::production::SealedGenerationSegmentPublicationV1; /// Re-publication stages an already validated decoded generation through the /// same target writer; failures retain the port's exact typed refusal. @@ -120,8 +120,7 @@ fn staged_segment_range_reads_open_once_per_cached_file_and_revalidate_replaceme let digests = contents .iter() .map(|bytes| { - ManifestDigest::from_sha256_bytes(&Sha256::digest(bytes)) - .expect("canonical digest") + ManifestDigest::from_sha256_bytes(&Sha256::digest(bytes)).expect("canonical digest") }) .collect::>(); for (digest, bytes) in digests.iter().zip(&contents) { @@ -162,7 +161,10 @@ fn staged_segment_range_reads_open_once_per_cached_file_and_revalidate_replaceme matches!(invalid_range, Err(CodeIndexProductionErrorV1::Contract(_))), "a range outside the sealed extent must be refused", ); - assert!(read.is_empty(), "an invalid range must not expose stale read bytes"); + assert!( + read.is_empty(), + "an invalid range must not expose stale read bytes" + ); assert_eq!( preparation.staged_segment_read_open_attempts_for_test(), 1, @@ -231,7 +233,10 @@ fn staged_segment_range_reads_open_once_per_cached_file_and_revalidate_replaceme matches!(replaced, Err(CodeIndexProductionErrorV1::Contract(_))), "a changed sealed pathname must be refused, not silently reread", ); - assert!(read.is_empty(), "a refused read must not expose prior bytes"); + assert!( + read.is_empty(), + "a refused read must not expose prior bytes" + ); assert_eq!( preparation.staged_segment_read_open_attempts_for_test(), 10, @@ -246,21 +251,29 @@ fn staged_segment_range_reads_open_once_per_cached_file_and_revalidate_replaceme }, &mut read, ); - assert!(matches!(retry, Err(CodeIndexProductionErrorV1::Contract(_)))); - assert!(read.is_empty(), "a poisoned preparation must remain closed to reads"); + assert!(matches!( + retry, + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert!( + read.is_empty(), + "a poisoned preparation must remain closed to reads" + ); assert_eq!( preparation.staged_segment_read_open_attempts_for_test(), 10, "poisoned staged segment must not be reopened after identity refusal", ); - let write_after_poison = preparation.write_segment( - SealedGenerationSegmentPublicationV1::File { + let write_after_poison = + preparation.write_segment(SealedGenerationSegmentPublicationV1::File { digest: &digests[1], bytes: &contents[1], - }, - ); + }); assert!( - matches!(write_after_poison, Err(CodeIndexPublicationStoreErrorV1::Unavailable(_))), + matches!( + write_after_poison, + Err(CodeIndexPublicationStoreErrorV1::Unavailable(_)) + ), "a poisoned preparation must refuse further writes as well as reads", ); drop(reader); From 262e3407097899376f63497fad9e370640304cf7 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:43:02 -0800 Subject: [PATCH 09/93] fix(code-index): preserve staged read identity across mutations Keep the concurrent formatting commit in the branch history. Rehome and extend its production-reader regression to cover in-place mutation, transaction poisoning, failed reads, and escaped-reader cleanup. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 123 +++-- .../tests/publication_store.rs | 199 -------- .../tests/staged_segment_reads.rs | 445 ++++++++++++++++++ 3 files changed, 522 insertions(+), 245 deletions(-) create mode 100644 crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 0cfc799f937..8129b8a3d53 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -13,6 +13,8 @@ use std::{ time::{Duration, SystemTime}, }; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; #[cfg(test)] use std::sync::atomic::AtomicUsize; @@ -57,6 +59,10 @@ use tracedecay_code_index::{ use super::{CodeIndexSchedulerErrorV1, PendingHintsV1}; use crate::code_graph_seat::CodeGraphBuildAdmissionV1; +#[cfg(all(test, unix))] +#[path = "tests/staged_segment_reads.rs"] +mod staged_segment_reads; + const MAX_DURABLE_PUBLICATION_POINTER_BYTES: u64 = 512 * 1024; const DURABLE_GENERATION_IO_CHUNK_BYTES_V1: usize = 64 * 1024; @@ -145,7 +151,7 @@ impl SharedCodeIndexBytePoolV1 { pub(super) fn release_dead_entries(&self) { self.bytes .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) + .unwrap_or_else(PoisonError::into_inner) .retain(|_, entry| entry.strong_count() > 0); self.physical_artifacts.release_dead_entries(); } @@ -156,7 +162,7 @@ impl SharedCodeIndexBytePoolV1 { let bytes = self .bytes .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); + .unwrap_or_else(PoisonError::into_inner); CodeIndexBytePoolStatsV1 { parse_chunk_inserted: physical_artifacts.inserted, parse_chunk_reused: physical_artifacts.reused, @@ -214,7 +220,7 @@ struct DecodedGenerationStateV1 { /// The pinned active generation. /// /// Held outside the LRU deque: every unpinned query serves from it, so LRU - /// pressure from pinned or cursor-paged reads of older generations must + /// pressure from pinned or cursor-paged readers of older generations must /// never be able to drop it and force a re-decode on the request path. active: Option>, /// Bumped by every successful publication. A decode that started before a @@ -382,7 +388,7 @@ impl Drop for HeldActiveDecodeV1 { .unwrap_or_else(PoisonError::into_inner); state .in_flight - .retain(|pending| *pending != DecodeSubjectV1::Active); + .retain(|subject| *subject != DecodeSubjectV1::Active); if state.active.is_none() { state.active = self.restore.take(); } @@ -805,6 +811,8 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { let state = Arc::clone(&self.state); let segments_root = self.segments_root.clone(); Arc::new(move |request, buffer| { + // Refusals before a handle is acquired must not expose a prior read. + buffer.clear(); let mut state = state .lock() .map_err(|_| CodeIndexProductionErrorV1::Publication(Self::closed()))?; @@ -822,7 +830,6 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { DaemonCodeIndexPreparationStateV1::Open(files) => { #[cfg(unix)] if files.segment_reads_poisoned { - buffer.clear(); return Err(CodeIndexProductionErrorV1::Contract( "staged sealed segment identity was invalidated".to_owned(), )); @@ -857,31 +864,19 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { .iter() .position(|entry| entry.path == path) { - let entry = &files.pinned_segment_reads[position]; - let unchanged = path - .symlink_metadata() - .ok() - .zip(entry.segment.file.metadata().ok()) - .is_some_and(|(named, pinned)| { - DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity( - &named, &pinned, - ) - }); - let entry = - files.pinned_segment_reads.remove(position).ok_or_else(|| { - CodeIndexProductionErrorV1::Contract( - "staged segment read cache lost a handle".to_owned(), - ) - })?; + let unchanged = files.pinned_segment_reads[position].unchanged(); + let entry = files.pinned_segment_reads.remove(position).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "staged segment read cache lost a handle".to_owned(), + ) + })?; if unchanged { files.pinned_segment_reads.push_back(entry); } else { - // A sealed content-addressed path must not be - // replaced during one preparation. Reopening it - // could return bytes that do not match the - // manifest's digest, even with the same size. + // A sealed pathname and its inode must still + // match the snapshot captured at the first open. files.segment_reads_poisoned = true; - buffer.clear(); + files.pinned_segment_reads.clear(); return Err(CodeIndexProductionErrorV1::Contract( "staged sealed segment identity changed while pinned" .to_owned(), @@ -893,29 +888,41 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { .back() .is_none_or(|entry| entry.path != path) { - let segment = + // Evict before opening so even a cache miss never + // holds a ninth staged read descriptor. + if files.pinned_segment_reads.len() == MAX_STAGED_SEGMENT_READ_HANDLES { + files.pinned_segment_reads.pop_front(); + } + let pinned = DaemonCodeIndexPublicationStoreV1::open_pinned_staged_segment( &path, request, &mut files.segment_read_open_attempts, )?; - if files.pinned_segment_reads.len() == MAX_STAGED_SEGMENT_READ_HANDLES { - files.pinned_segment_reads.pop_front(); - } - files - .pinned_segment_reads - .push_back(PinnedPreparationSegmentV1 { path, segment }); + files.pinned_segment_reads.push_back(pinned); } let pinned = files.pinned_segment_reads.back_mut().ok_or_else(|| { CodeIndexProductionErrorV1::Contract( "staged segment read cache has no handle".to_owned(), ) })?; - DaemonCodeIndexPublicationStoreV1::read_pinned_partitioned_segment( - &mut pinned.segment, - request, - buffer, - ) + let result = + DaemonCodeIndexPublicationStoreV1::read_pinned_partitioned_segment( + &mut pinned.segment, + request, + buffer, + ); + // The read itself may race an in-place write or path + // replacement. Never expose bytes after that change. + if !pinned.unchanged() { + buffer.clear(); + files.segment_reads_poisoned = true; + files.pinned_segment_reads.clear(); + return Err(CodeIndexProductionErrorV1::Contract( + "staged sealed segment identity changed during read".to_owned(), + )); + } + result } } } @@ -1020,6 +1027,26 @@ const MAX_STAGED_SEGMENT_READ_HANDLES: usize = 8; struct PinnedPreparationSegmentV1 { path: PathBuf, segment: PinnedGenerationSegmentV1, + // Both stat and fstat reflect later writes. Keep the original metadata, + // otherwise comparing them to each other accepts same-inode mutations. + identity: std::fs::Metadata, +} + +#[cfg(unix)] +impl PinnedPreparationSegmentV1 { + fn unchanged(&self) -> bool { + self.path + .symlink_metadata() + .ok() + .zip(self.segment.file.metadata().ok()) + .is_some_and(|(named, pinned)| { + DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity(&self.identity, &named) + && DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity( + &self.identity, + &pinned, + ) + }) + } } impl TemporaryEvidencePackV1 { @@ -1920,6 +1947,7 @@ impl DaemonCodeIndexPublicationStoreV1 { request: SealedGenerationSegmentReadV1<'_>, buffer: &mut Vec, ) -> Result<(), CodeIndexProductionErrorV1> { + buffer.clear(); let digest = match request { SealedGenerationSegmentReadV1::Whole { digest, .. } | SealedGenerationSegmentReadV1::Range { digest, .. } => digest, @@ -1936,6 +1964,7 @@ impl DaemonCodeIndexPublicationStoreV1 { request: SealedGenerationSegmentReadV1<'_>, buffer: &mut Vec, ) -> Result<(), CodeIndexProductionErrorV1> { + buffer.clear(); let (_digest, expected_size, offset, length) = match request { SealedGenerationSegmentReadV1::Whole { digest, size_bytes } => { (digest, size_bytes, 0, size_bytes) @@ -1965,7 +1994,6 @@ impl DaemonCodeIndexPublicationStoreV1 { "sealed generation segment identity does not match its manifest".to_owned(), )); } - buffer.clear(); let length = usize::try_from(length).map_err(|_| { CodeIndexProductionErrorV1::Contract( "sealed generation segment range exceeds addressable memory".to_owned(), @@ -1978,6 +2006,7 @@ impl DaemonCodeIndexPublicationStoreV1 { file.read_exact(buffer) }) .map_err(|error| { + buffer.clear(); CodeIndexProductionErrorV1::Contract(format!( "sealed generation segment read failed: {error}" )) @@ -2081,8 +2110,6 @@ impl DaemonCodeIndexPublicationStoreV1 { #[cfg(unix)] fn same_unix_segment_identity(named: &std::fs::Metadata, pinned: &std::fs::Metadata) -> bool { - use std::os::unix::fs::MetadataExt; - named.file_type().is_file() && pinned.is_file() && named.dev() == pinned.dev() @@ -2102,7 +2129,7 @@ impl DaemonCodeIndexPublicationStoreV1 { path: &Path, request: SealedGenerationSegmentReadV1<'_>, open_attempts: &mut u64, - ) -> Result { + ) -> Result { let (digest, size_bytes, offset, length) = match request { SealedGenerationSegmentReadV1::Whole { digest, size_bytes } => { (digest.as_str(), size_bytes, 0, size_bytes) @@ -2160,10 +2187,14 @@ impl DaemonCodeIndexPublicationStoreV1 { "staged sealed segment was replaced while opening".to_owned(), )); } - Ok(PinnedGenerationSegmentV1 { - digest: digest.to_owned(), - size_bytes, - file, + Ok(PinnedPreparationSegmentV1 { + path: path.to_path_buf(), + identity: opened, + segment: PinnedGenerationSegmentV1 { + digest: digest.to_owned(), + size_bytes, + file, + }, }) } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs index 7a4d43e477a..cd6290fbe80 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs @@ -52,8 +52,6 @@ use crate::code_index_scheduler::{ CodeIndexReconcileOutcomeV1, CodeIndexSchedulerErrorV1, CodeIndexSchedulerRegistryV1, CodeIndexWorktreeSchedulerV1, SharedCodeIndexBytePoolV1, scoped_code_index_store_root, }; -#[cfg(unix)] -use tracedecay_code_index::production::SealedGenerationSegmentPublicationV1; use tracedecay_code_index::production::{ CodeGraphBuildBoundV1, CodeIndexAtomicPublicationPort, CodeIndexExecutionControlV1, CodeIndexInterruptionV1, CodeIndexPreparationPort, CodeIndexProductionErrorV1, @@ -87,203 +85,6 @@ fn publish_generation( result } -/// The real preparation's segment reader must reuse handles, not merely -/// count an unrelated observer or pin a single noninterleaved page. -#[cfg(unix)] -#[test] -fn staged_segment_range_reads_open_once_per_cached_file_and_revalidate_replacements() { - let fixture = GitFixture::new(&[("src/lib.rs", "pub fn fixture() -> u32 { 7 }\n")]); - let source_store = TempDir::new().expect("source store"); - let generation = { - let mut source = scheduler( - &fixture, - source_store.path().to_path_buf(), - Arc::new(SharedCodeIndexBytePoolV1::default()), - ); - published(source.reconcile_now().expect("source publication")); - Arc::clone(&source.latest_complete().expect("decode source").generation) - }; - let target_store = TempDir::new().expect("target store"); - let mut target = super::super::DaemonCodeIndexPublicationStoreV1::new( - target_store.path(), - fixture.path(), - SanitizerRevision::new(tracedecay_privacy::CODE_SOURCE_SANITIZER_VERSION_V1) - .expect("sanitizer revision"), - ) - .expect("target publication store"); - let mut preparation = target - .prepare(&generation.sealed_scope(), None) - .expect("prepare target"); - let contents = (0..9) - .map(|index| format!("sealed-staged-segment-{index}-bytes").into_bytes()) - .collect::>(); - let digests = contents - .iter() - .map(|bytes| { - ManifestDigest::from_sha256_bytes(&Sha256::digest(bytes)).expect("canonical digest") - }) - .collect::>(); - for (digest, bytes) in digests.iter().zip(&contents) { - preparation - .write_segment(SealedGenerationSegmentPublicationV1::File { digest, bytes }) - .expect("write exact staged segment"); - } - let reader = preparation.segment_reader(); - let mut read = Vec::new(); - for offset in 0..16 { - reader( - SealedGenerationSegmentReadV1::Range { - digest: &digests[0], - size_bytes: contents[0].len() as u64, - offset, - length: 4, - }, - &mut read, - ) - .expect("read staged segment range"); - assert_eq!(&read, &contents[0][offset as usize..offset as usize + 4]); - } - assert_eq!( - preparation.staged_segment_read_open_attempts_for_test(), - 1, - "16 overlapping production reads must open the first file once", - ); - let invalid_range = reader( - SealedGenerationSegmentReadV1::Range { - digest: &digests[0], - size_bytes: contents[0].len() as u64, - offset: contents[0].len() as u64 - 1, - length: 2, - }, - &mut read, - ); - assert!( - matches!(invalid_range, Err(CodeIndexProductionErrorV1::Contract(_))), - "a range outside the sealed extent must be refused", - ); - assert!( - read.is_empty(), - "an invalid range must not expose stale read bytes" - ); - assert_eq!( - preparation.staged_segment_read_open_attempts_for_test(), - 1, - "rejecting a bad range must not reopen the pinned file", - ); - for (digest, bytes) in digests.iter().zip(&contents).skip(1) { - reader( - SealedGenerationSegmentReadV1::Whole { - digest, - size_bytes: bytes.len() as u64, - }, - &mut read, - ) - .expect("read distinct staged segment"); - assert_eq!(&read, bytes); - } - assert_eq!( - preparation.staged_segment_read_open_attempts_for_test(), - 9, - "nine genuinely distinct segments must each need a real file open", - ); - reader( - SealedGenerationSegmentReadV1::Whole { - digest: &digests[0], - size_bytes: contents[0].len() as u64, - }, - &mut read, - ) - .expect("reopen the evicted first segment"); - assert_eq!(&read, &contents[0]); - assert_eq!( - preparation.staged_segment_read_open_attempts_for_test(), - 10, - "an eight-handle bound must evict and reopen the ninth distinct file", - ); - - let segments_root = code_generation_segments_root(target_store.path()); - let staging_directory = std::fs::read_dir(segments_root) - .expect("staging root") - .filter_map(Result::ok) - .map(|entry| entry.path()) - .find(|path| { - path.is_dir() - && path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| { - name.starts_with(CODE_INDEX_PREPARATION_DIRECTORY_PREFIX_V1) - }) - }) - .expect("one active preparation directory"); - let suffix = sha256_hex_suffix(digests[0].as_str()).expect("digest suffix"); - let staged_path = staging_directory.join(format!("segment-{suffix}.json")); - let replacement_path = staging_directory.join("replacement.tmp"); - let replacement = vec![b'Z'; contents[0].len()]; - std::fs::write(&replacement_path, &replacement).expect("write replacement bytes"); - std::fs::rename(replacement_path, staged_path).expect("replace the pinned path"); - let replaced = reader( - SealedGenerationSegmentReadV1::Whole { - digest: &digests[0], - size_bytes: contents[0].len() as u64, - }, - &mut read, - ); - assert!( - matches!(replaced, Err(CodeIndexProductionErrorV1::Contract(_))), - "a changed sealed pathname must be refused, not silently reread", - ); - assert!( - read.is_empty(), - "a refused read must not expose prior bytes" - ); - assert_eq!( - preparation.staged_segment_read_open_attempts_for_test(), - 10, - "refusing a replaced pinned file must not open untrusted bytes", - ); - // A second request must not turn the first identity refusal into a new - // successful read of substituted content with the same size and name. - let retry = reader( - SealedGenerationSegmentReadV1::Whole { - digest: &digests[0], - size_bytes: contents[0].len() as u64, - }, - &mut read, - ); - assert!(matches!( - retry, - Err(CodeIndexProductionErrorV1::Contract(_)) - )); - assert!( - read.is_empty(), - "a poisoned preparation must remain closed to reads" - ); - assert_eq!( - preparation.staged_segment_read_open_attempts_for_test(), - 10, - "poisoned staged segment must not be reopened after identity refusal", - ); - let write_after_poison = - preparation.write_segment(SealedGenerationSegmentPublicationV1::File { - digest: &digests[1], - bytes: &contents[1], - }); - assert!( - matches!( - write_after_poison, - Err(CodeIndexPublicationStoreErrorV1::Unavailable(_)) - ), - "a poisoned preparation must refuse further writes as well as reads", - ); - drop(reader); - preparation.abort().expect("abort and clean staged files"); - assert!( - !staged_path.exists(), - "an aborted preparation must remove a replaced staged segment", - ); -} - #[test] fn a_prepared_publication_retries_without_a_construction_worker_reservation() { let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs new file mode 100644 index 00000000000..f945e1fea10 --- /dev/null +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -0,0 +1,445 @@ +//! Exercise the production preparation, filesystem writer, and reader together. +use std::{ + fs::File, + io::Write, + os::unix::fs::MetadataExt, + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; + +use sha2::{Digest, Sha256}; +use tempfile::TempDir; +use tracedecay_code_index::production::{ + CodeIndexAtomicPublicationPort, CodeIndexPreparationPort, CodeIndexProductionErrorV1, + CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, + SealedGenerationSegmentPublicationV1, SealedGenerationSegmentReadV1, +}; +use tracedecay_domain::{ManifestDigest, ProjectId, SanitizerRevision, sha256_hex_suffix}; + +use super::{ + DaemonCodeIndexPreparationStateV1, DaemonCodeIndexPreparationV1, + DaemonCodeIndexPublicationStoreV1, MAX_STAGED_SEGMENT_READ_HANDLES, SharedCodeIndexBytePoolV1, +}; +use crate::code_index_scheduler::tests::GitFixture; +use crate::code_index_scheduler::{CodeIndexReconcileOutcomeV1, CodeIndexWorktreeSchedulerV1}; + +fn source_generation( + fixture: &GitFixture, + store_root: &Path, +) -> Arc { + let mut source = CodeIndexWorktreeSchedulerV1::open( + ProjectId::new("project.code-index-tests").expect("project identity"), + fixture.path(), + store_root.to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ) + .expect("source scheduler"); + assert!(matches!( + source.reconcile_now().expect("source publication"), + CodeIndexReconcileOutcomeV1::Published(_) + )); + Arc::clone(&source.latest_complete().expect("decode source").generation) +} + +fn target_store(root: &Path, fixture: &GitFixture) -> DaemonCodeIndexPublicationStoreV1 { + DaemonCodeIndexPublicationStoreV1::new( + root, + fixture.path(), + SanitizerRevision::new(tracedecay_privacy::CODE_SOURCE_SANITIZER_VERSION_V1) + .expect("sanitizer revision"), + ) + .expect("target publication store") +} + +fn segment_digest(bytes: &[u8]) -> ManifestDigest { + ManifestDigest::from_sha256_bytes(&Sha256::digest(bytes)).expect("canonical digest") +} + +fn staged_path(preparation: &DaemonCodeIndexPreparationV1, digest: &ManifestDigest) -> PathBuf { + let state = preparation.state.lock().expect("preparation state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("preparation must still be open"); + }; + let suffix = sha256_hex_suffix(digest.as_str()).expect("digest suffix"); + let canonical = preparation + .segments_root + .join(format!("segment-{suffix}.json")); + files + .segments + .pending + .get(&canonical) + .expect("segment was staged, not published") + .clone() +} + +fn pinned_handle_count(preparation: &DaemonCodeIndexPreparationV1) -> usize { + let state = preparation.state.lock().expect("preparation state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("preparation must still be open"); + }; + files.pinned_segment_reads.len() +} + +#[test] +fn staged_segment_range_reads_open_once_per_cached_file_and_revalidate_replacements() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn fixture() -> u32 { 7 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let contents = (0..9) + .map(|index| format!("sealed-staged-segment-{index}-bytes").into_bytes()) + .collect::>(); + let digests = contents + .iter() + .map(|bytes| segment_digest(bytes)) + .collect::>(); + for (digest, bytes) in digests.iter().zip(&contents) { + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { digest, bytes }) + .expect("write exact staged segment"); + } + let reader = preparation.segment_reader(); + let mut read = Vec::new(); + for offset in 0..16 { + reader( + SealedGenerationSegmentReadV1::Range { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + offset, + length: 4, + }, + &mut read, + ) + .expect("read staged segment range"); + assert_eq!(&read, &contents[0][offset as usize..offset as usize + 4]); + } + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + 1, + "16 overlapping production reads must open the first file once", + ); + let invalid_range = reader( + SealedGenerationSegmentReadV1::Range { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + offset: contents[0].len() as u64 - 1, + length: 2, + }, + &mut read, + ); + assert!(matches!( + invalid_range, + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert!(read.is_empty(), "a bad range must not expose stale bytes"); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 1); + + // Alternating ranges exercise LRU promotion, not just consecutive reads. + for offset in 0..16 { + for index in [0, 1] { + reader( + SealedGenerationSegmentReadV1::Range { + digest: &digests[index], + size_bytes: contents[index].len() as u64, + offset, + length: 4, + }, + &mut read, + ) + .expect("read interleaved staged ranges"); + assert_eq!( + &read, + &contents[index][offset as usize..offset as usize + 4] + ); + } + } + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 2); + for (digest, bytes) in digests.iter().zip(&contents).skip(1) { + reader( + SealedGenerationSegmentReadV1::Whole { + digest, + size_bytes: bytes.len() as u64, + }, + &mut read, + ) + .expect("read distinct staged segment"); + assert_eq!(&read, bytes); + } + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + 9, + "nine distinct segments must each need a real file open", + ); + assert_eq!( + pinned_handle_count(&preparation), + MAX_STAGED_SEGMENT_READ_HANDLES + ); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + }, + &mut read, + ) + .expect("reopen the evicted first segment"); + assert_eq!(&read, &contents[0]); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + 10, + "an eight-handle bound must evict and reopen the ninth distinct file", + ); + + let path = staged_path(&preparation, &digests[0]); + let replacement_path = path.with_extension("replacement.tmp"); + std::fs::write(&replacement_path, vec![b'Z'; contents[0].len()]) + .expect("write replacement bytes"); + std::fs::rename(&replacement_path, &path).expect("replace the pinned path"); + for _ in 0..2 { + read.extend_from_slice(b"stale buffer sentinel"); + let result = reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + }, + &mut read, + ); + assert!(matches!( + result, + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert!(read.is_empty(), "replacement and retry must clear output"); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 10); + } + assert_eq!(pinned_handle_count(&preparation), 0); + assert!(matches!( + preparation.write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &digests[1], + bytes: &contents[1], + }), + Err(CodeIndexPublicationStoreErrorV1::Unavailable(_)) + )); + preparation.abort().expect("abort and clean staged files"); + assert!(!path.exists(), "abort removes replaced staged files"); + // An escaped reader must not keep the preparation or its staged bytes live. + read.extend_from_slice(b"stale after abort"); + assert!(matches!( + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + }, + &mut read, + ), + Err(CodeIndexProductionErrorV1::Publication(_)) + )); + assert!(read.is_empty()); +} + +#[test] +fn staged_segment_in_place_mutation_poison_survives_restoration_and_refuses_commit() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage a real valid publication"); + + // This auxiliary segment is deliberately outside the valid manifest. + // Commit must refuse the poisoned transaction, not just a digest mismatch + // discovered while verifying a segment the manifest happens to reference. + let bytes = b"original-staged-segment"; + let digest = segment_digest(bytes); + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &digest, + bytes, + }) + .expect("write auxiliary staged segment"); + let opens_before = preparation.staged_segment_read_open_attempts_for_test(); + let reader = preparation.segment_reader(); + let mut read = Vec::new(); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digest, + size_bytes: bytes.len() as u64, + }, + &mut read, + ) + .expect("pin original staged bytes"); + assert_eq!(read.as_slice(), bytes); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + opens_before + 1 + ); + let path = staged_path(&preparation, &digest); + let before = path.symlink_metadata().expect("original metadata"); + let mut writer = File::options() + .write(true) + .open(&path) + .expect("same inode writer"); + writer + .write_all(&vec![b'X'; bytes.len()]) + .expect("same-length overwrite"); + // Force a visible timestamp change even on coarse-resolution filesystems. + writer + .set_modified(before.modified().expect("mtime") + Duration::from_secs(2)) + .expect("change mtime"); + drop(writer); + let after = path.symlink_metadata().expect("mutated metadata"); + assert_eq!( + (before.dev(), before.ino(), before.len()), + (after.dev(), after.ino(), after.len()) + ); + assert_ne!( + before.modified().expect("old mtime"), + after.modified().expect("new mtime") + ); + let result = reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digest, + size_bytes: bytes.len() as u64, + }, + &mut read, + ); + assert!(matches!( + result, + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert!(read.is_empty(), "mutated bytes must never escape"); + assert_eq!(pinned_handle_count(&preparation), 0); + + // Restoration cannot unpoison an already-invalidated transaction. + std::fs::write(&path, bytes).expect("restore original bytes"); + read.extend_from_slice(b"stale after mutation"); + assert!(matches!( + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digest, + size_bytes: bytes.len() as u64, + }, + &mut read, + ), + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert!(read.is_empty()); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + opens_before + 1 + ); + assert!(matches!( + preparation.write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &digest, + bytes, + }), + Err(CodeIndexPublicationStoreErrorV1::Unavailable(detail)) + if detail.contains("identity was invalidated") + )); + assert!(matches!( + target.commit(&mut preparation, &publication), + Err(CodeIndexPublicationStoreErrorV1::Unavailable(detail)) + if detail.contains("identity was invalidated") + )); + assert!( + target + .load_active_manifest() + .expect("active manifest probe") + .is_none() + ); + preparation.abort().expect("abort poisoned preparation"); + assert!(!path.exists()); +} + +#[test] +fn staged_segment_cold_refusals_clear_output_without_poisoning_valid_reads() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let first = b"first staged bytes"; + let cold = b"cold staged bytes"; + let first_digest = segment_digest(first); + let cold_digest = segment_digest(cold); + for (digest, bytes) in [ + (&first_digest, first.as_slice()), + (&cold_digest, cold.as_slice()), + ] { + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { digest, bytes }) + .expect("stage segment"); + } + let missing_digest = segment_digest(b"never staged"); + let reader = preparation.segment_reader(); + let mut read = Vec::new(); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &first_digest, + size_bytes: first.len() as u64, + }, + &mut read, + ) + .expect("prime buffer and first handle"); + assert_eq!(read.as_slice(), first); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 1); + for request in [ + SealedGenerationSegmentReadV1::Range { + digest: &cold_digest, + size_bytes: cold.len() as u64, + offset: cold.len() as u64 - 1, + length: 2, + }, + SealedGenerationSegmentReadV1::Whole { + digest: &cold_digest, + size_bytes: cold.len() as u64 + 1, + }, + SealedGenerationSegmentReadV1::Whole { + digest: &missing_digest, + size_bytes: 12, + }, + ] { + read.extend_from_slice(b"stale cold-refusal sentinel"); + assert!(matches!( + reader(request, &mut read), + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert!( + read.is_empty(), + "even a refusal before File::open must clear output" + ); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 1); + } + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &first_digest, + size_bytes: first.len() as u64, + }, + &mut read, + ) + .expect("invalid requests must not poison an unchanged preparation"); + assert_eq!(read.as_slice(), first); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 1); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &cold_digest, + size_bytes: cold.len() as u64, + }, + &mut read, + ) + .expect("valid cold request still opens its file"); + assert_eq!(read.as_slice(), cold); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 2); + preparation.abort().expect("clean up preparation"); +} From b850dcb5d1808f29f3fe9580bb39f93c0b98b07e Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:48:37 -0800 Subject: [PATCH 10/93] fix(code-index): retain sealed identities after handle eviction Keep the first-open file identity for the preparation even after its bounded read cache evicts the descriptor. Refuse a replaced pathname before another open, including across the cache's eviction boundary. Prove the refusal with a real nine-segment regression. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 39 ++++++++++ .../tests/staged_segment_reads.rs | 76 +++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 8129b8a3d53..3c2f71fd6e3 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -584,6 +584,10 @@ struct DaemonCodeIndexPreparationFilesV1 { // prevents an authorized sweep from unlinking their sealed paths. #[cfg(unix)] pinned_segment_reads: VecDeque, + // Eviction limits descriptors but must not discard the identity recorded + // on the first open of any sealed pathname in this preparation. + #[cfg(unix)] + observed_segment_identities: BTreeMap, #[cfg(unix)] segment_read_open_attempts: u64, // One changed sealed pathname invalidates the entire transaction. Retrying @@ -888,6 +892,23 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { .back() .is_none_or(|entry| entry.path != path) { + // An evicted pathname cannot acquire a new + // identity merely because its handle was closed. + let observed = files.observed_segment_identities.get(&path).cloned(); + if let Some(original) = observed.as_ref() + && !path.symlink_metadata().is_ok_and(|current| { + DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity( + original, ¤t, + ) + }) + { + files.segment_reads_poisoned = true; + files.pinned_segment_reads.clear(); + return Err(CodeIndexProductionErrorV1::Contract( + "staged sealed segment identity changed after eviction" + .to_owned(), + )); + } // Evict before opening so even a cache miss never // holds a ninth staged read descriptor. if files.pinned_segment_reads.len() == MAX_STAGED_SEGMENT_READ_HANDLES { @@ -899,6 +920,22 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { request, &mut files.segment_read_open_attempts, )?; + if let Some(original) = observed.as_ref() { + if !DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity( + original, + &pinned.identity, + ) { + files.segment_reads_poisoned = true; + files.pinned_segment_reads.clear(); + return Err(CodeIndexProductionErrorV1::Contract( + "staged sealed segment identity changed while reopening" + .to_owned(), + )); + } + } else { + files.observed_segment_identities + .insert(path.clone(), pinned.identity.clone()); + } files.pinned_segment_reads.push_back(pinned); } let pinned = files.pinned_segment_reads.back_mut().ok_or_else(|| { @@ -3319,6 +3356,8 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { #[cfg(unix)] pinned_segment_reads: VecDeque::new(), #[cfg(unix)] + observed_segment_identities: BTreeMap::new(), + #[cfg(unix)] segment_read_open_attempts: 0, #[cfg(unix)] segment_reads_poisoned: false, diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index f945e1fea10..0df85c26798 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -443,3 +443,79 @@ fn staged_segment_cold_refusals_clear_output_without_poisoning_valid_reads() { assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 2); preparation.abort().expect("clean up preparation"); } + +#[test] +fn staged_segment_evicted_path_replacement_poison_without_reopen() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let contents = (0..9) + .map(|index| format!("eviction-identity-segment-{index}").into_bytes()) + .collect::>(); + let digests = contents + .iter() + .map(|bytes| segment_digest(bytes)) + .collect::>(); + for (digest, bytes) in digests.iter().zip(&contents) { + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { digest, bytes }) + .expect("stage sealed segment"); + } + let first_path = staged_path(&preparation, &digests[0]); + let reader = preparation.segment_reader(); + let mut read = Vec::new(); + for (digest, bytes) in digests.iter().zip(&contents) { + reader( + SealedGenerationSegmentReadV1::Whole { + digest, + size_bytes: bytes.len() as u64, + }, + &mut read, + ) + .expect("pin distinct segment"); + assert_eq!(&read, bytes); + } + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 9); + assert_eq!( + pinned_handle_count(&preparation), + MAX_STAGED_SEGMENT_READ_HANDLES + ); + + let replacement_path = first_path.with_extension("eviction-replacement.tmp"); + std::fs::write(&replacement_path, vec![b'Z'; contents[0].len()]) + .expect("same-length replacement"); + std::fs::rename(&replacement_path, &first_path) + .expect("replace evicted segment pathname"); + read.extend_from_slice(b"old data"); + assert!(matches!( + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + }, + &mut read, + ), + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert!(read.is_empty(), "replacement cannot expose stale or new bytes"); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + 9, + "replaced evicted path must be rejected before another File::open" + ); + assert_eq!(pinned_handle_count(&preparation), 0); + assert!(matches!( + preparation.write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &digests[1], + bytes: &contents[1], + }), + Err(CodeIndexPublicationStoreErrorV1::Unavailable(_)) + )); + preparation.abort().expect("remove poisoned staging"); + assert!(!first_path.exists()); +} From 18269c6740ff134c8d1b436ebd66478397efe366 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:50:56 -0800 Subject: [PATCH 11/93] test(code-index): verify staged reads through durable reload Exercise a real staged generation's whole and repeated range reads, verify the content digest and exact bytes, then publish and cold-decode its durable snapshot without changing the production storage format. Refs #4533 --- .../tests/staged_segment_reads.rs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 0df85c26798..6d642960d7d 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -519,3 +519,82 @@ fn staged_segment_evicted_path_replacement_poison_without_reopen() { preparation.abort().expect("remove poisoned staging"); assert!(!first_path.exists()); } + +#[test] +fn staged_preparation_exact_segment_bytes_commit_and_reload() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage real generation"); + let identities = CodeIndexPublishedGenerationV1::partitioned_segment_identities_from_reader( + publication.manifest_bytes().as_ref(), + ) + .expect("parse staged segment identities") + .expect("current partitioned generation revision"); + let identity = identities + .into_iter() + .find(|identity| identity.size_bytes > 4) + .expect("real generation must have a nonempty segment"); + + let reader = preparation.segment_reader(); + let initial_opens = preparation.staged_segment_read_open_attempts_for_test(); + let mut exact = Vec::new(); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &identity.digest, + size_bytes: identity.size_bytes, + }, + &mut exact, + ) + .expect("read staged sealed whole segment"); + assert_eq!( + segment_digest(&exact), + identity.digest, + "production reader must return the exact addressed segment" + ); + let mut range = Vec::new(); + for _ in 0..16 { + reader( + SealedGenerationSegmentReadV1::Range { + digest: &identity.digest, + size_bytes: identity.size_bytes, + offset: 1, + length: 3, + }, + &mut range, + ) + .expect("read repeated byte-exact range"); + assert_eq!(range.as_slice(), &exact[1..4]); + } + assert!( + preparation.staged_segment_read_open_attempts_for_test() - initial_opens <= 1, + "whole and repeated range reads may open the segment only once" + ); + let committed = target + .commit(&mut preparation, &publication) + .expect("publish the real staged generation"); + assert_eq!( + committed.as_ref(), + publication.manifest_bytes().as_ref(), + "commit must preserve the staged manifest bytes" + ); + drop(reader); + drop(preparation); + let reloaded = target + .load_active_shared() + .expect("decode committed generation") + .expect("active durable generation"); + assert_eq!(reloaded.manifest().generation_id, generation.manifest().generation_id); + assert_eq!( + reloaded.snapshot().content_identity, + generation.snapshot().content_identity, + "a cold durable read must recover the exact published snapshot identity" + ); +} From 103101b6f869eb2c529d5c716893dae0c6a2fbfe Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:23:28 -0800 Subject: [PATCH 12/93] style(code-index): format pinned segment read regression --- .../code_index_scheduler/publication_store.rs | 31 ++++++++++--------- .../tests/staged_segment_reads.rs | 13 +++++--- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 3c2f71fd6e3..fef2d0ca0dc 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -159,10 +159,7 @@ impl SharedCodeIndexBytePoolV1 { #[cfg(test)] pub(super) fn stats(&self) -> CodeIndexBytePoolStatsV1 { let physical_artifacts = self.physical_artifacts.stats(); - let bytes = self - .bytes - .lock() - .unwrap_or_else(PoisonError::into_inner); + let bytes = self.bytes.lock().unwrap_or_else(PoisonError::into_inner); CodeIndexBytePoolStatsV1 { parse_chunk_inserted: physical_artifacts.inserted, parse_chunk_reused: physical_artifacts.reused, @@ -869,11 +866,12 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { .position(|entry| entry.path == path) { let unchanged = files.pinned_segment_reads[position].unchanged(); - let entry = files.pinned_segment_reads.remove(position).ok_or_else(|| { - CodeIndexProductionErrorV1::Contract( - "staged segment read cache lost a handle".to_owned(), - ) - })?; + let entry = + files.pinned_segment_reads.remove(position).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "staged segment read cache lost a handle".to_owned(), + ) + })?; if unchanged { files.pinned_segment_reads.push_back(entry); } else { @@ -933,7 +931,8 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { )); } } else { - files.observed_segment_identities + files + .observed_segment_identities .insert(path.clone(), pinned.identity.clone()); } files.pinned_segment_reads.push_back(pinned); @@ -1077,11 +1076,13 @@ impl PinnedPreparationSegmentV1 { .ok() .zip(self.segment.file.metadata().ok()) .is_some_and(|(named, pinned)| { - DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity(&self.identity, &named) - && DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity( - &self.identity, - &pinned, - ) + DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity( + &self.identity, + &named, + ) && DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity( + &self.identity, + &pinned, + ) }) } } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 6d642960d7d..513da1be151 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -489,8 +489,7 @@ fn staged_segment_evicted_path_replacement_poison_without_reopen() { let replacement_path = first_path.with_extension("eviction-replacement.tmp"); std::fs::write(&replacement_path, vec![b'Z'; contents[0].len()]) .expect("same-length replacement"); - std::fs::rename(&replacement_path, &first_path) - .expect("replace evicted segment pathname"); + std::fs::rename(&replacement_path, &first_path).expect("replace evicted segment pathname"); read.extend_from_slice(b"old data"); assert!(matches!( reader( @@ -502,7 +501,10 @@ fn staged_segment_evicted_path_replacement_poison_without_reopen() { ), Err(CodeIndexProductionErrorV1::Contract(_)) )); - assert!(read.is_empty(), "replacement cannot expose stale or new bytes"); + assert!( + read.is_empty(), + "replacement cannot expose stale or new bytes" + ); assert_eq!( preparation.staged_segment_read_open_attempts_for_test(), 9, @@ -591,7 +593,10 @@ fn staged_preparation_exact_segment_bytes_commit_and_reload() { .load_active_shared() .expect("decode committed generation") .expect("active durable generation"); - assert_eq!(reloaded.manifest().generation_id, generation.manifest().generation_id); + assert_eq!( + reloaded.manifest().generation_id, + generation.manifest().generation_id + ); assert_eq!( reloaded.snapshot().content_identity, generation.snapshot().content_identity, From c76238bd05e9eb356ee4963010f1036f62f88ba4 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:10:58 -0800 Subject: [PATCH 13/93] fix(code-index): resolve Bazel Clippy failures in staged reads Quote the pathname API in documentation and retain exact empty-buffer regressions using equality assertions required by the pinned Clippy policy. Refs #4533 --- .../src/code_index_scheduler/publication_store.rs | 2 +- .../src/code_index_scheduler/tests/staged_segment_reads.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index fef2d0ca0dc..e0567939c3c 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -2159,7 +2159,7 @@ impl DaemonCodeIndexPublicationStoreV1 { && named.ctime_nsec() == pinned.ctime_nsec() } - /// One actual file open per cache miss. Do not reopen via Handle::from_path + /// One actual file open per cache miss. Do not reopen via `Handle::from_path` /// on macOS: comparing the Unix inode and change-time metadata before and /// after open proves the same identity without another endpoint scan. #[cfg(unix)] diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 513da1be151..3de251060c6 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -237,7 +237,7 @@ fn staged_segment_range_reads_open_once_per_cached_file_and_revalidate_replaceme ), Err(CodeIndexProductionErrorV1::Publication(_)) )); - assert!(read.is_empty()); + assert_eq!(read, [] as [u8; 0]); } #[test] @@ -331,7 +331,7 @@ fn staged_segment_in_place_mutation_poison_survives_restoration_and_refuses_comm ), Err(CodeIndexProductionErrorV1::Contract(_)) )); - assert!(read.is_empty()); + assert_eq!(read, [] as [u8; 0]); assert_eq!( preparation.staged_segment_read_open_attempts_for_test(), opens_before + 1 From 32868523be33a85fb091feebc1d2b11c1b346143 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:17:33 -0800 Subject: [PATCH 14/93] perf(storage): flush staged files through bounded writer handles Reuse duplicated writer descriptors for Linux durability windows instead of reopening every segment. Preserve concurrent member fsyncs and refuse missing, replaced, or mutated members, including earlier flushed windows. Add kernel-observed open-count, barrier, ownership, and failure regressions. Refs #4533 --- .../tracedecay-private-fs/src/framed_log.rs | 161 ++++++++--- .../src/framed_log_batch_tests.rs | 271 ++++++++++++++++++ 2 files changed, 394 insertions(+), 38 deletions(-) create mode 100644 crates/tracedecay-private-fs/src/framed_log_batch_tests.rs diff --git a/crates/tracedecay-private-fs/src/framed_log.rs b/crates/tracedecay-private-fs/src/framed_log.rs index 005e20779c2..1e7de32f3a0 100644 --- a/crates/tracedecay-private-fs/src/framed_log.rs +++ b/crates/tracedecay-private-fs/src/framed_log.rs @@ -4,6 +4,8 @@ use std::fs::{self, File, OpenOptions}; use std::io::{self, Read, Seek, SeekFrom, Write}; #[cfg(target_os = "linux")] use std::os::fd::AsRawFd; +#[cfg(target_os = "linux")] +use std::os::unix::fs::MetadataExt; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; #[cfg(windows)] @@ -16,6 +18,10 @@ use windows_sys::Win32::Storage::FileSystem::{ FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, }; +#[cfg(all(test, target_os = "linux"))] +#[path = "framed_log_batch_tests.rs"] +mod batch_tests; + #[cfg(test)] thread_local! { static DIRECTORY_SYNC_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; @@ -67,23 +73,74 @@ pub fn sync_parent_directory(path: &Path, policy: DirectorySyncPolicy) -> io::Re } } -/// New files whose contents become durable together, before any is renamed -/// into place. +/// New, fully written files whose contents become durable before any is renamed. +/// Members must remain immutable until publication. /// -/// On Linux each member's writeback starts as soon as it is written, and -/// [`Self::sync`] then fsyncs the members concurrently. Concurrent fsyncs -/// share a commit on a journaling filesystem and on ZFS's intent log alike, -/// but on ZFS one fsync after another pays one intent-log commit each, so a -/// seal of a hundred segments waited out a hundred commits. The cost stays -/// proportional to the batch's own bytes. `syncfs(2)` also paid one -/// commit, but waited for every dirty inode on the filesystem, so a one-file -/// seal stalled for seconds behind unrelated writers. Other platforms fsync -/// each file as it is written. +/// Linux retains at most one concurrency window of duplicated writer handles. +/// When that window fills, its members are fsynced concurrently before their +/// handles are released. This preserves grouped log commits without reopening +/// every pathname through endpoint security or holding a descriptor per segment. +/// [`Self::sync`] flushes the final window and checks that earlier members still +/// name the same immutable files. It never flushes unrelated filesystem data. +/// Other platforms fsync each file as it is written. #[derive(Default)] pub struct DurableFileBatch { - /// Paths rather than open handles, so a large seal holds no descriptors. #[cfg(target_os = "linux")] - members: Vec, + pending: Vec, + #[cfg(target_os = "linux")] + synced: Vec, +} + +#[cfg(target_os = "linux")] +struct PendingDurableFile { + identity: DurableFileIdentity, + file: File, +} + +#[cfg(target_os = "linux")] +struct DurableFileIdentity { + path: PathBuf, + metadata: fs::Metadata, +} + +#[cfg(target_os = "linux")] +impl DurableFileIdentity { + fn validate_metadata(&self, current: &fs::Metadata) -> io::Result<()> { + let original = &self.metadata; + if !original.file_type().is_file() + || !current.file_type().is_file() + || original.dev() != current.dev() + || original.ino() != current.ino() + || original.len() != current.len() + || original.mtime() != current.mtime() + || original.mtime_nsec() != current.mtime_nsec() + || original.ctime() != current.ctime() + || original.ctime_nsec() != current.ctime_nsec() + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "durable batch member changed after it was written", + )); + } + Ok(()) + } + + fn validate_path(&self) -> io::Result<()> { + self.validate_metadata(&fs::symlink_metadata(&self.path)?) + } +} + +#[cfg(target_os = "linux")] +impl PendingDurableFile { + fn sync(&self) -> io::Result<()> { + self.identity.validate_path()?; + self.identity.validate_metadata(&self.file.metadata()?)?; + sync_owned_file(&self.identity.path, &self.file)?; + // A replacement or in-place write during the barrier must not be + // reported as a successful flush of the originally admitted bytes. + self.identity.validate_metadata(&self.file.metadata()?)?; + self.identity.validate_path() + } } impl DurableFileBatch { @@ -95,14 +152,31 @@ impl DurableFileBatch { pub fn written(&mut self, path: &Path, file: &File) -> io::Result<()> { #[cfg(target_os = "linux")] { - // SAFETY: `file` is an open descriptor for the whole call. + if self.pending.len() == DURABLE_BATCH_CONCURRENT_SYNCS { + // Keep ownership on failure: no unproven member may move to + // the synced set, and no additional descriptor is admitted. + self.sync_pending()?; + self.synced + .extend(self.pending.drain(..).map(|member| member.identity)); + } + let identity = DurableFileIdentity { + path: path.to_path_buf(), + metadata: file.metadata()?, + }; + identity.validate_path()?; + // Duplication retains the writer's inode without a pathname open. + let retained = file.try_clone()?; + // SAFETY: `retained` is an open descriptor for the whole call. let started = unsafe { - libc::sync_file_range(file.as_raw_fd(), 0, 0, libc::SYNC_FILE_RANGE_WRITE) + libc::sync_file_range(retained.as_raw_fd(), 0, 0, libc::SYNC_FILE_RANGE_WRITE) }; if started != 0 { return Err(io::Error::last_os_error()); } - self.members.push(path.to_path_buf()); + self.pending.push(PendingDurableFile { + identity, + file: retained, + }); Ok(()) } #[cfg(not(target_os = "linux"))] @@ -111,7 +185,7 @@ impl DurableFileBatch { } } - /// Make every member file's contents durable. + /// Make every member file's contents durable, refusing changed members. #[tracing::instrument( name = "private_fs.framed_log.sync_file_batch", level = "trace", @@ -120,33 +194,44 @@ impl DurableFileBatch { pub fn sync(&self) -> io::Result<()> { #[cfg(target_os = "linux")] { - let next = std::sync::atomic::AtomicUsize::new(0); - let syncer = || -> io::Result<()> { - while let Some(member) = self.members.get(next.fetch_add(1, Ordering::Relaxed)) { - sync_file_at(member)?; - } - Ok(()) - }; - let syncers = self.members.len().min(DURABLE_BATCH_CONCURRENT_SYNCS); - std::thread::scope(|scope| { - let helpers = (1..syncers) - .map(|_| scope.spawn(syncer)) - .collect::>(); - let mut outcome = syncer(); - for helper in helpers { - let helped = helper - .join() - .unwrap_or_else(|panic| std::panic::resume_unwind(panic)); - outcome = outcome.and(helped); - } - outcome - }) + // Already-flushed windows need no further opens or barriers, but + // missing or substituted names must still refuse publication. + for identity in &self.synced { + identity.validate_path()?; + } + self.sync_pending() } #[cfg(not(target_os = "linux"))] { Ok(()) } } + + #[cfg(target_os = "linux")] + fn sync_pending(&self) -> io::Result<()> { + let next = std::sync::atomic::AtomicUsize::new(0); + let syncer = || -> io::Result<()> { + while let Some(member) = self.pending.get(next.fetch_add(1, Ordering::Relaxed)) { + member.sync()?; + } + Ok(()) + }; + let syncers = self.pending.len().min(DURABLE_BATCH_CONCURRENT_SYNCS); + std::thread::scope(|scope| { + let helpers = (1..syncers) + .map(|_| scope.spawn(syncer)) + .collect::>(); + let mut outcome = syncer(); + for helper in helpers { + let helped = match helper.join() { + Ok(helped) => helped, + Err(panic) => std::panic::resume_unwind(panic), + }; + outcome = outcome.and(helped); + } + outcome + }) + } } /// Barriers one batch keeps in flight together: enough for the filesystem to diff --git a/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs b/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs new file mode 100644 index 00000000000..d1d6eec12b4 --- /dev/null +++ b/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs @@ -0,0 +1,271 @@ +//! Real filesystem evidence for bounded, descriptor-owned durability batches. +use std::ffi::CString; +use std::fs::{self, File}; +use std::io::{ErrorKind, Read, Write}; +use std::mem::size_of; +use std::os::fd::{AsRawFd, FromRawFd}; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +#[cfg(feature = "test-helpers")] +use super::sync_latency; +use super::{DURABLE_BATCH_CONCURRENT_SYNCS, DurableFileBatch}; + +/// Count kernel open events, not calls to an observer unrelated to the opener. +struct OpenEvents(File); + +impl OpenEvents { + fn watch(root: &Path) -> Self { + // SAFETY: inotify_init1 has no pointer arguments. + let descriptor = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) }; + assert!( + descriptor >= 0, + "inotify: {}", + std::io::Error::last_os_error() + ); + // SAFETY: the successful descriptor is owned exactly once by this File. + let file = unsafe { File::from_raw_fd(descriptor) }; + let path = CString::new(root.as_os_str().as_bytes()).expect("fixture path"); + // SAFETY: file is live and path is NUL-terminated for the whole call. + let watch = + unsafe { libc::inotify_add_watch(file.as_raw_fd(), path.as_ptr(), libc::IN_OPEN) }; + assert!(watch >= 0, "watch: {}", std::io::Error::last_os_error()); + Self(file) + } + + fn drain(&mut self) -> usize { + let mut opens = 0; + let mut buffer = [0_u8; 8192]; + loop { + let length = match self.0.read(&mut buffer) { + Ok(length) => length, + Err(error) if error.kind() == ErrorKind::WouldBlock => return opens, + Err(error) if error.kind() == ErrorKind::Interrupted => continue, + Err(error) => panic!("read inotify events: {error}"), + }; + assert!(length > 0, "inotify unexpectedly reached EOF"); + let mut offset = 0; + while offset < length { + assert!(offset + size_of::() <= length); + // SAFETY: the complete header is in bounds; read_unaligned + // handles the byte buffer's alignment. No borrowed name escapes. + let event = unsafe { + buffer + .as_ptr() + .add(offset) + .cast::() + .read_unaligned() + }; + assert_eq!(event.mask & libc::IN_Q_OVERFLOW, 0, "lost open events"); + if event.mask & libc::IN_OPEN != 0 { + opens += 1; + } + offset += size_of::() + + usize::try_from(event.len).expect("event length"); + assert!(offset <= length, "truncated inotify name"); + } + } + } +} + +fn write_member(batch: &mut DurableFileBatch, root: &Path, index: usize) -> PathBuf { + let path = root.join(format!("segment-{index}.tmp")); + let mut file = File::create(&path).expect("member writer"); + file.write_all(format!("sealed segment {index}").as_bytes()) + .expect("member bytes"); + batch.written(&path, &file).expect("record member"); + path +} + +#[test] +fn durable_batch_flushes_multiple_windows_without_pathname_reopens() { + let root = tempfile::tempdir().expect("batch root"); + let mut events = OpenEvents::watch(root.path()); + let probe = root.path().join("open-probe"); + drop(File::create(&probe).expect("real probe open")); + assert_eq!( + events.drain(), + 1, + "the kernel observer must actually see opens" + ); + fs::remove_file(&probe).expect("remove probe"); + + let mut batch = DurableFileBatch::new(); + let members = DURABLE_BATCH_CONCURRENT_SYNCS * 2 + 3; + for index in 0..members { + write_member(&mut batch, root.path(), index); + assert!(batch.pending.len() <= DURABLE_BATCH_CONCURRENT_SYNCS); + } + assert_eq!(batch.synced.len(), DURABLE_BATCH_CONCURRENT_SYNCS * 2); + assert_eq!(batch.pending.len(), 3); + batch.sync().expect("flush final window"); + assert_eq!( + events.drain(), + members, + "only the original writer opens are allowed; flushing must not reopen members" + ); + batch.sync().expect("repeat final barrier"); + assert_eq!(events.drain(), 0, "a repeated sync must not reopen paths"); + drop(batch); + for index in 0..members { + assert_eq!( + fs::read(root.path().join(format!("segment-{index}.tmp"))) + .expect("exact durable readback"), + format!("sealed segment {index}").as_bytes() + ); + } +} + +#[cfg(feature = "test-helpers")] +#[test] +fn durable_batch_flushes_each_window_before_releasing_its_handles() { + let root = tempfile::tempdir().expect("batch root"); + let barriers = sync_latency::inject(root.path(), Duration::ZERO); + let mut batch = DurableFileBatch::new(); + let members = DURABLE_BATCH_CONCURRENT_SYNCS * 2 + 3; + for index in 0..members { + write_member(&mut batch, root.path(), index); + } + assert_eq!( + barriers.syncs(), + u64::try_from(DURABLE_BATCH_CONCURRENT_SYNCS * 2).expect("flushed count"), + "both completed windows must have issued real file barriers" + ); + batch.sync().expect("flush remaining members"); + assert_eq!( + barriers.syncs(), + u64::try_from(members).expect("member count"), + "no member may be omitted or flushed as unrelated filesystem data" + ); +} + +#[test] +fn durable_batch_rejects_a_path_not_owned_by_the_writer() { + let root = tempfile::tempdir().expect("batch root"); + let path = root.path().join("member.tmp"); + let other = root.path().join("other.tmp"); + let mut writer = File::create(&path).expect("writer"); + writer.write_all(b"original").expect("member bytes"); + fs::write(&other, b"original").expect("same bytes in another inode"); + let mut batch = DurableFileBatch::new(); + assert_eq!( + batch + .written(&other, &writer) + .expect_err("wrong inode") + .kind(), + ErrorKind::InvalidData + ); + assert_eq!(batch.pending.len(), 0); + batch.written(&path, &writer).expect("correct writer path"); + drop(writer); + batch.sync().expect("flush caller-closed writer"); + assert_eq!(fs::read(path).expect("read member"), b"original"); +} + +#[test] +fn durable_batch_refuses_pending_and_already_flushed_replacements() { + for members in [1, DURABLE_BATCH_CONCURRENT_SYNCS + 1] { + let root = tempfile::tempdir().expect("batch root"); + let mut batch = DurableFileBatch::new(); + for index in 0..members { + write_member(&mut batch, root.path(), index); + } + let first = root.path().join("segment-0.tmp"); + let replacement = root.path().join("replacement.tmp"); + // Same bytes rule out content mismatch as the reason for refusing. + fs::write(&replacement, b"sealed segment 0").expect("replacement"); + fs::rename(&replacement, &first).expect("replace recorded inode"); + for _ in 0..2 { + assert_eq!( + batch.sync().expect_err("replaced member").kind(), + ErrorKind::InvalidData + ); + } + assert_eq!( + fs::read(first).expect("replacement preserved"), + b"sealed segment 0" + ); + } +} + +#[test] +fn durable_batch_refuses_a_missing_member_from_an_earlier_window() { + let root = tempfile::tempdir().expect("batch root"); + let mut batch = DurableFileBatch::new(); + for index in 0..=DURABLE_BATCH_CONCURRENT_SYNCS { + write_member(&mut batch, root.path(), index); + } + assert_eq!(batch.synced.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + fs::remove_file(root.path().join("segment-0.tmp")).expect("remove flushed member"); + assert_eq!( + batch.sync().expect_err("missing flushed member").kind(), + ErrorKind::NotFound + ); +} + +#[test] +fn durable_batch_refuses_same_inode_mutation_after_early_flush() { + let root = tempfile::tempdir().expect("batch root"); + let mut batch = DurableFileBatch::new(); + for index in 0..=DURABLE_BATCH_CONCURRENT_SYNCS { + write_member(&mut batch, root.path(), index); + } + let path = root.path().join("segment-0.tmp"); + let before = fs::metadata(&path).expect("original metadata"); + let mut writer = File::options() + .write(true) + .open(&path) + .expect("same inode"); + writer + .write_all(b"changed segment!") + .expect("same-length mutation"); + writer + .set_modified(before.modified().expect("mtime") + Duration::from_secs(2)) + .expect("observable mutation on coarse timestamp filesystems"); + drop(writer); + let after = fs::metadata(&path).expect("mutated metadata"); + assert_eq!( + (before.dev(), before.ino(), before.len()), + (after.dev(), after.ino(), after.len()) + ); + assert_ne!( + before.modified().expect("original mtime"), + after.modified().expect("changed mtime") + ); + assert_eq!( + batch.sync().expect_err("mutated early member").kind(), + ErrorKind::InvalidData + ); +} + +#[test] +fn durable_batch_failed_window_keeps_handles_and_does_not_admit_another_member() { + let root = tempfile::tempdir().expect("batch root"); + let mut batch = DurableFileBatch::new(); + for index in 0..DURABLE_BATCH_CONCURRENT_SYNCS { + write_member(&mut batch, root.path(), index); + } + fs::remove_file(root.path().join("segment-0.tmp")).expect("remove pending member"); + let next = root.path().join("next.tmp"); + let mut writer = File::create(&next).expect("next writer"); + writer.write_all(b"next segment").expect("next bytes"); + assert_eq!( + batch + .written(&next, &writer) + .expect_err("failed window") + .kind(), + ErrorKind::NotFound + ); + assert_eq!(batch.pending.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + assert_eq!( + batch.synced.len(), + 0, + "no partial window may claim durability" + ); + assert_eq!( + batch.sync().expect_err("retry remains refused").kind(), + ErrorKind::NotFound + ); +} From 4d0ad18d0d8cba52a24e29c31536dbe73ae51b5e Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:35:27 -0800 Subject: [PATCH 15/93] fix(code-index): preserve cached handles on rejected reads Validate ranges and named extents before changing staged-reader LRU state. Reuse admitted metadata across the open, retaining replacement checks. Add a real full-cache regression for cold refusals and eviction order. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 101 ++++++++------ .../tests/staged_segment_reads.rs | 124 ++++++++++++++++++ 2 files changed, 185 insertions(+), 40 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index e0567939c3c..33388d36076 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -835,10 +835,33 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { "staged sealed segment identity was invalidated".to_owned(), )); } - let digest = match request { - SealedGenerationSegmentReadV1::Whole { digest, .. } - | SealedGenerationSegmentReadV1::Range { digest, .. } => digest, + let (digest, size_bytes, offset, length) = match request { + SealedGenerationSegmentReadV1::Whole { digest, size_bytes } => { + (digest, size_bytes, 0, size_bytes) + } + SealedGenerationSegmentReadV1::Range { + digest, + size_bytes, + offset, + length, + } => (digest, size_bytes, offset, length), }; + // Rejected requests are not cache accesses: they must not + // evict a good handle or change the next eviction victim. + if offset + .checked_add(length) + .is_none_or(|end| end > size_bytes) + { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed generation segment range exceeds its manifest identity" + .to_owned(), + )); + } + usize::try_from(length).map_err(|_| { + CodeIndexProductionErrorV1::Contract( + "sealed generation segment range exceeds addressable memory".to_owned(), + ) + })?; let suffix = sha256_hex_suffix(digest.as_str()).ok_or_else(|| { CodeIndexProductionErrorV1::Contract( "sealed segment digest is not sha256".to_owned(), @@ -865,7 +888,14 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { .iter() .position(|entry| entry.path == path) { - let unchanged = files.pinned_segment_reads[position].unchanged(); + let cached = &files.pinned_segment_reads[position]; + if cached.segment.size_bytes != size_bytes { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed generation segment identity does not match its manifest" + .to_owned(), + )); + } + let unchanged = cached.unchanged(); let entry = files.pinned_segment_reads.remove(position).ok_or_else(|| { CodeIndexProductionErrorV1::Contract( @@ -893,10 +923,11 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { // An evicted pathname cannot acquire a new // identity merely because its handle was closed. let observed = files.observed_segment_identities.get(&path).cloned(); + let named = path.symlink_metadata(); if let Some(original) = observed.as_ref() - && !path.symlink_metadata().is_ok_and(|current| { + && !named.as_ref().is_ok_and(|current| { DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity( - original, ¤t, + original, current, ) }) { @@ -907,8 +938,19 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { .to_owned(), )); } - // Evict before opening so even a cache miss never - // holds a ninth staged read descriptor. + let named = named.map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "staged sealed segment is unavailable: {error}" + )) + })?; + if !named.file_type().is_file() || named.len() != size_bytes { + return Err(CodeIndexProductionErrorV1::Contract( + "staged sealed segment identity does not match its manifest" + .to_owned(), + )); + } + // Admit the request and path before eviction, then + // evict before opening to retain the descriptor bound. if files.pinned_segment_reads.len() == MAX_STAGED_SEGMENT_READ_HANDLES { files.pinned_segment_reads.pop_front(); } @@ -916,6 +958,7 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { DaemonCodeIndexPublicationStoreV1::open_pinned_staged_segment( &path, request, + &named, &mut files.segment_read_open_attempts, )?; if let Some(original) = observed.as_ref() { @@ -2159,44 +2202,22 @@ impl DaemonCodeIndexPublicationStoreV1 { && named.ctime_nsec() == pinned.ctime_nsec() } - /// One actual file open per cache miss. Do not reopen via `Handle::from_path` - /// on macOS: comparing the Unix inode and change-time metadata before and - /// after open proves the same identity without another endpoint scan. + /// Open an admitted staged pathname without another pre-open stat or + /// `Handle::from_path` open. The caller validates the request and metadata + /// before evicting a cached handle; this checks identity across the open. #[cfg(unix)] fn open_pinned_staged_segment( path: &Path, request: SealedGenerationSegmentReadV1<'_>, + named: &std::fs::Metadata, open_attempts: &mut u64, ) -> Result { - let (digest, size_bytes, offset, length) = match request { - SealedGenerationSegmentReadV1::Whole { digest, size_bytes } => { - (digest.as_str(), size_bytes, 0, size_bytes) - } - SealedGenerationSegmentReadV1::Range { - digest, - size_bytes, - offset, - length, - } => (digest.as_str(), size_bytes, offset, length), + let (digest, size_bytes) = match request { + SealedGenerationSegmentReadV1::Whole { digest, size_bytes } + | SealedGenerationSegmentReadV1::Range { + digest, size_bytes, .. + } => (digest.as_str(), size_bytes), }; - if offset - .checked_add(length) - .is_none_or(|end| end > size_bytes) - { - return Err(CodeIndexProductionErrorV1::Contract( - "sealed generation segment range exceeds its manifest identity".to_owned(), - )); - } - let named = path.symlink_metadata().map_err(|error| { - CodeIndexProductionErrorV1::Contract(format!( - "staged sealed segment is unavailable: {error}" - )) - })?; - if !named.file_type().is_file() || named.len() != size_bytes { - return Err(CodeIndexProductionErrorV1::Contract( - "staged sealed segment identity does not match its manifest".to_owned(), - )); - } *open_attempts = (*open_attempts).saturating_add(1); tracing::trace!( target: "tracedecay::code_index", @@ -2218,7 +2239,7 @@ impl DaemonCodeIndexPublicationStoreV1 { "staged sealed segment pathname cannot be rechecked: {error}" )) })?; - if !Self::same_unix_segment_identity(&named, &opened) + if !Self::same_unix_segment_identity(named, &opened) || !Self::same_unix_segment_identity(&named_after_open, &opened) { return Err(CodeIndexProductionErrorV1::Contract( diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 3de251060c6..2f8c1fcaf99 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -603,3 +603,127 @@ fn staged_preparation_exact_segment_bytes_commit_and_reload() { "a cold durable read must recover the exact published snapshot identity" ); } + +#[test] +fn staged_segment_rejected_reads_preserve_full_cache_and_recency() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let contents = (0..9) + .map(|index| format!("admission-staged-segment-{index}-bytes").into_bytes()) + .collect::>(); + let digests = contents + .iter() + .map(|bytes| segment_digest(bytes)) + .collect::>(); + for (digest, bytes) in digests.iter().zip(&contents) { + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { digest, bytes }) + .expect("stage real segment"); + } + let reader = preparation.segment_reader(); + let mut read = Vec::new(); + for (digest, bytes) in digests.iter().zip(&contents).take(8) { + reader( + SealedGenerationSegmentReadV1::Whole { + digest, + size_bytes: bytes.len() as u64, + }, + &mut read, + ) + .expect("fill the handle cache"); + assert_eq!(&read, bytes); + } + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 8); + assert_eq!(pinned_handle_count(&preparation), 8); + let missing_digest = segment_digest(b"never staged admission"); + for request in [ + SealedGenerationSegmentReadV1::Range { + digest: &digests[8], + size_bytes: contents[8].len() as u64, + offset: contents[8].len() as u64 - 1, + length: 2, + }, + SealedGenerationSegmentReadV1::Range { + digest: &digests[8], + size_bytes: contents[8].len() as u64, + offset: u64::MAX, + length: 2, + }, + SealedGenerationSegmentReadV1::Whole { + digest: &digests[8], + size_bytes: contents[8].len() as u64 + 1, + }, + SealedGenerationSegmentReadV1::Whole { + digest: &missing_digest, + size_bytes: b"never staged admission".len() as u64, + }, + SealedGenerationSegmentReadV1::Range { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + offset: contents[0].len() as u64 - 1, + length: 2, + }, + SealedGenerationSegmentReadV1::Whole { + digest: &digests[0], + size_bytes: contents[0].len() as u64 + 1, + }, + ] { + read.extend_from_slice(b"stale rejected-read sentinel"); + assert!(matches!( + reader(request, &mut read), + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert_eq!(read, [] as [u8; 0]); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 8); + assert_eq!( + pinned_handle_count(&preparation), + 8, + "a rejected request must not evict a valid handle" + ); + } + + // The next valid cold read must evict zero, not one: invalid accesses + // to zero above must not make it more recent than the other seven files. + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[8], + size_bytes: contents[8].len() as u64, + }, + &mut read, + ) + .expect("admit valid cold read after refusals"); + assert_eq!(read, contents[8]); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 9); + assert_eq!(pinned_handle_count(&preparation), 8); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[1], + size_bytes: contents[1].len() as u64, + }, + &mut read, + ) + .expect("second-oldest handle must still be cached"); + assert_eq!(read, contents[1]); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + 9, + "invalid cache hits must not change the eviction victim" + ); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + }, + &mut read, + ) + .expect("reopen the actual least-recently-used segment"); + assert_eq!(read, contents[0]); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 10); + preparation.abort().expect("clean up staged cache fixture"); +} From 688f3a64a6ed5cdf7885b1a8ccced6fcd69c99b6 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:11:58 -0800 Subject: [PATCH 16/93] style(storage): satisfy Bazel formatting gate Apply the exact rustfmt change reported by hosted Repository gates to the same-inode durability regression without changing its assertions. Refs #4533 --- crates/tracedecay-private-fs/src/framed_log_batch_tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs b/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs index d1d6eec12b4..158bc1f66e7 100644 --- a/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs +++ b/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs @@ -214,10 +214,7 @@ fn durable_batch_refuses_same_inode_mutation_after_early_flush() { } let path = root.path().join("segment-0.tmp"); let before = fs::metadata(&path).expect("original metadata"); - let mut writer = File::options() - .write(true) - .open(&path) - .expect("same inode"); + let mut writer = File::options().write(true).open(&path).expect("same inode"); writer .write_all(b"changed segment!") .expect("same-length mutation"); From 70188c58e4e8343550a6d8eefbe0cc2f6bc59fb0 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:10:28 -0800 Subject: [PATCH 17/93] fix(code-index): never replace raced sealed segment names Publish missing canonical names with atomic no-replace semantics. If another publisher wins, verify byte identity before deduplicating and preserve any conflicting immutable content. Exercise the production path for conflicting, identical and absent destinations. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 29 ++++++++++- .../tests/staged_segment_reads.rs | 49 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 33388d36076..2ddf494e1ef 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -1046,6 +1046,32 @@ impl StagedGenerationSegmentsV1 { self.pending.contains_key(final_path) } + // A concurrent publisher can create the final name after our missing-path + // probe. Never replace an immutable content-addressed segment in that gap. + fn publish_missing_segment( + temporary: &Path, + final_path: &Path, + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + match tracedecay_private_fs::framed_log::rename_noreplace(temporary, final_path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let metadata = final_path + .symlink_metadata() + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + if !metadata.file_type().is_file() + || !DaemonCodeIndexPublicationStoreV1::files_equal(final_path, temporary)? + { + return Err(DaemonCodeIndexPublicationStoreV1::corruption( + "concurrently published sealed segment does not match its content address", + )); + } + std::fs::remove_file(temporary) + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable) + } + Err(error) => Err(DaemonCodeIndexPublicationStoreV1::unavailable(error)), + } + } + /// Flush every staged segment once, then name each. A retry resumes the /// remaining names without trying to sync paths already renamed. fn publish(&mut self) -> Result<(), CodeIndexPublicationStoreErrorV1> { @@ -1073,8 +1099,7 @@ impl StagedGenerationSegmentsV1 { .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - std::fs::rename(&temporary, &final_path) - .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + Self::publish_missing_segment(&temporary, &final_path)?; } Err(error) => return Err(DaemonCodeIndexPublicationStoreV1::unavailable(error)), } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 2f8c1fcaf99..2c8f8b50e17 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -727,3 +727,52 @@ fn staged_segment_rejected_reads_preserve_full_cache_and_recency() { assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 10); preparation.abort().expect("clean up staged cache fixture"); } + +#[test] +fn staged_segment_publish_never_overwrites_a_concurrent_canonical_name() { + let root = TempDir::new().expect("sealed segment race fixture"); + let canonical = root.path().join("segment-content-address.json"); + let temporary = root.path().join("staged-segment.json"); + let original = b"sealed-original"; + let changed = b"sealed-modified"; + assert_eq!(original.len(), changed.len()); + + // Exercise the actual production helper after the missing-path probe, + // with a conflicting canonical name created in the race window. + std::fs::write(&canonical, original).expect("competing canonical bytes"); + std::fs::write(&temporary, changed).expect("staged bytes"); + assert!( + super::StagedGenerationSegmentsV1::publish_missing_segment(&temporary, &canonical) + .is_err(), + "a race must refuse, not replace a different immutable segment" + ); + assert_eq!( + std::fs::read(&canonical).expect("preserved competing segment"), + original + ); + assert_eq!( + std::fs::read(&temporary).expect("staged bytes retained on refusal"), + changed + ); + + // When the competing publisher installed identical bytes, deduplication + // may finish without another publication or destroying canonical bytes. + std::fs::write(&temporary, original).expect("identical staged bytes"); + super::StagedGenerationSegmentsV1::publish_missing_segment(&temporary, &canonical) + .expect("identical concurrent publication is safe to deduplicate"); + assert!(!temporary.exists()); + assert_eq!( + std::fs::read(&canonical).expect("canonical name retained"), + original + ); + + std::fs::remove_file(&canonical).expect("vacate canonical name"); + std::fs::write(&temporary, changed).expect("new staged segment"); + super::StagedGenerationSegmentsV1::publish_missing_segment(&temporary, &canonical) + .expect("missing canonical name accepts one atomic publication"); + assert!(!temporary.exists()); + assert_eq!( + std::fs::read(&canonical).expect("new published bytes"), + changed + ); +} From 7184d12e3e9c9782ae2e97f80fcc1c50c5346bb3 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:13:07 -0800 Subject: [PATCH 18/93] perf(code-index): avoid directory opens during publication checks On Unix use the directory device/inode identity directly when preparing and validating a publication. Preserve existing Windows handle identity checks and reject substituted directories before commit. Add a real prepared publication regression for changed and restored directory identities. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 67 +++++++++++++++++-- .../tests/staged_segment_reads.rs | 34 ++++++++++ 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 2ddf494e1ef..a2f508be3e7 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -18,6 +18,7 @@ use std::os::unix::fs::MetadataExt; #[cfg(test)] use std::sync::atomic::AtomicUsize; +#[cfg(not(unix))] use same_file::Handle; use sha2::{Digest, Sha256}; use tracedecay_application::code_index::DaemonCodeIndexControlV1; @@ -541,6 +542,31 @@ pub(super) struct UnpublishedCodeIndexPublicationV1 { pub(super) publication: CodeIndexSealedPublicationV1, } +// On Unix, directory dev/inode is the same identity used by the handle +// library, without an endpoint-authorized open for each prepare/commit check. +#[cfg(unix)] +#[derive(Clone, Copy, PartialEq, Eq)] +struct UnixDirectoryIdentityV1 { + device: u64, + inode: u64, +} + +#[cfg(unix)] +impl UnixDirectoryIdentityV1 { + fn at(path: &Path) -> Result { + let metadata = path.metadata().map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + if !metadata.is_dir() { + return Err(DaemonCodeIndexPublicationStoreV1::unavailable( + "code-generation identity path is not a directory", + )); + } + Ok(Self { + device: metadata.dev(), + inode: metadata.ino(), + }) + } +} + /// An unpublished store transaction. No full store handle is retained here: /// the current handle supplies the source fence when commit is retried. pub struct DaemonCodeIndexPreparationV1 { @@ -548,8 +574,17 @@ pub struct DaemonCodeIndexPreparationV1 { expected_active_generation: Option, undecoded_expectation: Option, incumbent_bytes: Option>, + #[cfg(unix)] + store_identity: UnixDirectoryIdentityV1, + #[cfg(not(unix))] store_identity: Handle, + #[cfg(unix)] + segments_identity: UnixDirectoryIdentityV1, + #[cfg(not(unix))] segments_identity: Handle, + #[cfg(unix)] + generations_identity: UnixDirectoryIdentityV1, + #[cfg(not(unix))] generations_identity: Handle, cache: Arc, active_path: PathBuf, @@ -3365,9 +3400,18 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { { return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); } + #[cfg(unix)] + let store_identity = UnixDirectoryIdentityV1::at(store_root)?; + #[cfg(not(unix))] let store_identity = Handle::from_path(store_root).map_err(Self::unavailable)?; + #[cfg(unix)] + let segments_identity = UnixDirectoryIdentityV1::at(&self.segments_root)?; + #[cfg(not(unix))] let segments_identity = Handle::from_path(&self.segments_root).map_err(Self::unavailable)?; + #[cfg(unix)] + let generations_identity = UnixDirectoryIdentityV1::at(&self.generations_root)?; + #[cfg(not(unix))] let generations_identity = Handle::from_path(&self.generations_root).map_err(Self::unavailable)?; self.seal_checkpoint()?; @@ -3767,17 +3811,28 @@ impl DaemonCodeIndexPublicationStoreV1 { .ok_or_else(|| Self::unavailable("active code-generation pointer has no store root"))?; if self.active_path != preparation.active_path || !Arc::ptr_eq(&self.cache, &preparation.cache) - || Handle::from_path(store_root).map_err(Self::unavailable)? - != preparation.store_identity - || Handle::from_path(&self.segments_root).map_err(Self::unavailable)? - != preparation.segments_identity - || Handle::from_path(&self.generations_root).map_err(Self::unavailable)? - != preparation.generations_identity || self.disposition != preparation.disposition || CodeIndexGenerationScopeV1::for_snapshot(generation.snapshot()) != preparation.scope { return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); } + #[cfg(unix)] + let same_directories = UnixDirectoryIdentityV1::at(store_root)? + == preparation.store_identity + && UnixDirectoryIdentityV1::at(&self.segments_root)? + == preparation.segments_identity + && UnixDirectoryIdentityV1::at(&self.generations_root)? + == preparation.generations_identity; + #[cfg(not(unix))] + let same_directories = Handle::from_path(store_root).map_err(Self::unavailable)? + == preparation.store_identity + && Handle::from_path(&self.segments_root).map_err(Self::unavailable)? + == preparation.segments_identity + && Handle::from_path(&self.generations_root).map_err(Self::unavailable)? + == preparation.generations_identity; + if !same_directories { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } Ok(()) } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 2c8f8b50e17..baaf59504ad 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -776,3 +776,37 @@ fn staged_segment_publish_never_overwrites_a_concurrent_canonical_name() { changed ); } + +#[cfg(unix)] +#[test] +fn prepared_generation_rechecks_directory_identity_without_reopening_paths() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage generation"); + + target + .validate_preparation_store(&preparation, &publication) + .expect("unchanged directories must retain preparation identity"); + let canonical = target.segments_root.clone(); + let moved = target_root.path().join("detached-segments"); + std::fs::rename(&canonical, &moved).expect("move original directory inode"); + std::fs::create_dir(&canonical).expect("substitute another directory"); + assert!(matches!( + target.validate_preparation_store(&preparation, &publication), + Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap) + )); + std::fs::remove_dir(&canonical).expect("remove empty substituted directory"); + std::fs::rename(&moved, &canonical).expect("restore owned original inode"); + target + .validate_preparation_store(&preparation, &publication) + .expect("restoring original inode restores the exact identity"); + preparation.abort().expect("release owned preparation"); +} From df3986a4a557d2f2b9a9b61edaa6d80cf879ee52 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:00:17 -0800 Subject: [PATCH 19/93] fix(code-index): preserve immutable manifest publication and reuse fd Refuse late concurrent generation-name collisions without replacing the competing immutable file, while deduplicating identical content. Hash the fsynced manifest using its existing writer handle instead of reopening its pathname, and assert the readback matches serialized bytes. Exercise real named temporary collision, dedupe and successful publication. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 54 ++++++++++++-- .../tests/staged_segment_reads.rs | 72 +++++++++++++++++++ 2 files changed, 119 insertions(+), 7 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index a2f508be3e7..df92154f863 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -1674,10 +1674,16 @@ impl DaemonCodeIndexPublicationStoreV1 { fn state_digest_file(path: &Path) -> Result { let mut file = File::open(path).map_err(Self::unavailable)?; + Self::state_digest_reader(&mut file) + } + + fn state_digest_reader( + reader: &mut impl Read, + ) -> Result { let mut hasher = Sha256::new(); let mut buffer = vec![0_u8; DURABLE_GENERATION_IO_CHUNK_BYTES_V1]; loop { - let read = file.read(&mut buffer).map_err(Self::unavailable)?; + let read = reader.read(&mut buffer).map_err(Self::unavailable)?; if read == 0 { break; } @@ -1686,6 +1692,30 @@ impl DaemonCodeIndexPublicationStoreV1 { Ok(encode_tagged_lowercase_hex("sha256:", &hasher.finalize())) } + // A canonical generation is immutable. Persisting with replacement would + // overwrite a competing publication that wins after the missing-path probe. + fn publish_missing_generation_manifest( + temporary: tempfile::NamedTempFile, + final_path: &Path, + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + match temporary.persist_noclobber(final_path) { + Ok(_) => Ok(()), + Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => { + let temporary = error.file; + let metadata = final_path.symlink_metadata().map_err(Self::unavailable)?; + if !metadata.file_type().is_file() + || !Self::files_equal(final_path, temporary.path())? + { + return Err(Self::corruption( + "concurrently published generation does not match its content address", + )); + } + temporary.close().map_err(Self::unavailable) + } + Err(error) => Err(Self::unavailable(error.error)), + } + } + fn files_equal(left: &Path, right: &Path) -> Result { let left_metadata = left.symlink_metadata().map_err(Self::unavailable)?; let right_metadata = right.symlink_metadata().map_err(Self::unavailable)?; @@ -3668,7 +3698,19 @@ impl DaemonCodeIndexPublicationStoreV1 { let state_digest = { let _span = tracing::trace_span!("code_index.generation.publish.state_digest").entered(); - Self::state_digest_file(temporary.path())? + // Read back the fsynced bytes through the writer's existing handle. + // A second pathname open is unnecessary and expensive under EDR. + temporary + .as_file_mut() + .seek(SeekFrom::Start(0)) + .map_err(Self::unavailable)?; + let digest = Self::state_digest_reader(temporary.as_file_mut())?; + if digest != Self::state_digest(manifest_bytes) { + return Err(Self::corruption( + "staged generation bytes changed after serialization", + )); + } + digest }; let suffix = sha256_hex_suffix(&state_digest) .ok_or_else(|| Self::unavailable("sealed generation digest is not sha256"))?; @@ -3688,11 +3730,9 @@ impl DaemonCodeIndexPublicationStoreV1 { temporary.close().map_err(Self::unavailable)?; } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - // The manifest is immutable once named. Failure cleanup never - // deletes a canonical name another scope may have attached. - temporary - .persist(&generation_path) - .map_err(|error| Self::unavailable(error.error))?; + // A concurrent publisher can win after the missing-path probe. + // Never replace its immutable generation, even transiently. + Self::publish_missing_generation_manifest(temporary, &generation_path)?; } Err(error) => return Err(Self::unavailable(error)), } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index baaf59504ad..6e80b2d7791 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -810,3 +810,75 @@ fn prepared_generation_rechecks_directory_identity_without_reopening_paths() { .expect("restoring original inode restores the exact identity"); preparation.abort().expect("release owned preparation"); } + +#[test] +fn generation_manifest_publication_preserves_concurrent_canonical_bytes() { + let root = TempDir::new().expect("generation publication fixture"); + let good = b"generation-one"; + let other = b"generation-two"; + assert_eq!(good.len(), other.len()); + let suffix = sha256_hex_suffix(segment_digest(good).as_str()).expect("generation suffix"); + let canonical = root.path().join(format!("generation-{suffix}.json")); + let stage = |bytes: &[u8]| { + let mut temporary = tempfile::Builder::new() + .prefix(".generation-publication-") + .tempfile_in(root.path()) + .expect("stage generation"); + temporary.write_all(bytes).expect("write staged manifest"); + temporary.as_file().sync_all().expect("durable staged manifest"); + temporary + }; + + // The competing pathname appears after the publisher's missing-path + // observation. A same-length conflict must not replace it. + std::fs::write(&canonical, good).expect("competing generation"); + assert!( + DaemonCodeIndexPublicationStoreV1::publish_missing_generation_manifest( + stage(other), + &canonical, + ) + .is_err(), + "conflicting concurrent generation must refuse publication" + ); + assert_eq!( + std::fs::read(&canonical).expect("competing generation retained"), + good + ); + assert_eq!( + std::fs::read_dir(root.path()) + .expect("generation directory") + .count(), + 1, + "refused publication must clean its staging file" + ); + + // A second publisher with identical content may safely deduplicate. + DaemonCodeIndexPublicationStoreV1::publish_missing_generation_manifest( + stage(good), + &canonical, + ) + .expect("identical immutable generation deduplicates"); + assert_eq!(std::fs::read(&canonical).expect("original content"), good); + assert_eq!( + std::fs::read_dir(root.path()) + .expect("generation directory") + .count(), + 1, + "deduplicated publication must not retain staging" + ); + + std::fs::remove_file(&canonical).expect("vacate immutable name"); + DaemonCodeIndexPublicationStoreV1::publish_missing_generation_manifest( + stage(good), + &canonical, + ) + .expect("publish new immutable name"); + assert_eq!(std::fs::read(&canonical).expect("new generation bytes"), good); + assert_eq!( + std::fs::read_dir(root.path()) + .expect("generation directory") + .count(), + 1, + "successful publication must consume staging" + ); +} From b3bc9ec658b3cc62528b7f89bb2c37bcee56d9ea Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:02:00 -0800 Subject: [PATCH 20/93] fix(code-index): use atomic immutable publish for manifest names Reuse the canonical no-replace rename for both staged segments and sealed generations. Close the named temporary handle before renaming, avoiding a possible hard-link residue from tempfile no-clobber persistence and keeping the same byte-exact collision/deduplication contract on every platform. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 31 ++++++------------- .../tests/staged_segment_reads.rs | 6 ++-- 2 files changed, 12 insertions(+), 25 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index df92154f863..b7a52b5af69 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -1082,8 +1082,8 @@ impl StagedGenerationSegmentsV1 { } // A concurrent publisher can create the final name after our missing-path - // probe. Never replace an immutable content-addressed segment in that gap. - fn publish_missing_segment( + // probe. Never replace an immutable content-addressed file in that gap. + fn publish_missing_immutable_file( temporary: &Path, final_path: &Path, ) -> Result<(), CodeIndexPublicationStoreErrorV1> { @@ -1097,7 +1097,7 @@ impl StagedGenerationSegmentsV1 { || !DaemonCodeIndexPublicationStoreV1::files_equal(final_path, temporary)? { return Err(DaemonCodeIndexPublicationStoreV1::corruption( - "concurrently published sealed segment does not match its content address", + "concurrently published immutable code-index file does not match its content address", )); } std::fs::remove_file(temporary) @@ -1134,7 +1134,7 @@ impl StagedGenerationSegmentsV1 { .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - Self::publish_missing_segment(&temporary, &final_path)?; + Self::publish_missing_immutable_file(&temporary, &final_path)?; } Err(error) => return Err(DaemonCodeIndexPublicationStoreV1::unavailable(error)), } @@ -1692,28 +1692,15 @@ impl DaemonCodeIndexPublicationStoreV1 { Ok(encode_tagged_lowercase_hex("sha256:", &hasher.finalize())) } - // A canonical generation is immutable. Persisting with replacement would - // overwrite a competing publication that wins after the missing-path probe. + // A canonical generation is immutable. Close the staging descriptor and + // use the same atomic no-replace authority as sealed segments: tempfile's + // no-clobber persist may leave two hard links after an interrupted publish. fn publish_missing_generation_manifest( temporary: tempfile::NamedTempFile, final_path: &Path, ) -> Result<(), CodeIndexPublicationStoreErrorV1> { - match temporary.persist_noclobber(final_path) { - Ok(_) => Ok(()), - Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => { - let temporary = error.file; - let metadata = final_path.symlink_metadata().map_err(Self::unavailable)?; - if !metadata.file_type().is_file() - || !Self::files_equal(final_path, temporary.path())? - { - return Err(Self::corruption( - "concurrently published generation does not match its content address", - )); - } - temporary.close().map_err(Self::unavailable) - } - Err(error) => Err(Self::unavailable(error.error)), - } + let temporary = temporary.into_temp_path(); + StagedGenerationSegmentsV1::publish_missing_immutable_file(temporary.as_ref(), final_path) } fn files_equal(left: &Path, right: &Path) -> Result { diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 6e80b2d7791..9467fc8eadc 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -742,7 +742,7 @@ fn staged_segment_publish_never_overwrites_a_concurrent_canonical_name() { std::fs::write(&canonical, original).expect("competing canonical bytes"); std::fs::write(&temporary, changed).expect("staged bytes"); assert!( - super::StagedGenerationSegmentsV1::publish_missing_segment(&temporary, &canonical) + super::StagedGenerationSegmentsV1::publish_missing_immutable_file(&temporary, &canonical) .is_err(), "a race must refuse, not replace a different immutable segment" ); @@ -758,7 +758,7 @@ fn staged_segment_publish_never_overwrites_a_concurrent_canonical_name() { // When the competing publisher installed identical bytes, deduplication // may finish without another publication or destroying canonical bytes. std::fs::write(&temporary, original).expect("identical staged bytes"); - super::StagedGenerationSegmentsV1::publish_missing_segment(&temporary, &canonical) + super::StagedGenerationSegmentsV1::publish_missing_immutable_file(&temporary, &canonical) .expect("identical concurrent publication is safe to deduplicate"); assert!(!temporary.exists()); assert_eq!( @@ -768,7 +768,7 @@ fn staged_segment_publish_never_overwrites_a_concurrent_canonical_name() { std::fs::remove_file(&canonical).expect("vacate canonical name"); std::fs::write(&temporary, changed).expect("new staged segment"); - super::StagedGenerationSegmentsV1::publish_missing_segment(&temporary, &canonical) + super::StagedGenerationSegmentsV1::publish_missing_immutable_file(&temporary, &canonical) .expect("missing canonical name accepts one atomic publication"); assert!(!temporary.exists()); assert_eq!( From bcb039e65d501b2fcfea8c98059f7ad70a76d8da Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:27:09 -0800 Subject: [PATCH 21/93] style(code-index): align staged segment reads with rustfmt Apply exact hosted Rust 2024 formatting diagnostics to reader identity checks and publication regressions. Preserve all assertions. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 7 +++--- .../tests/staged_segment_reads.rs | 24 +++++++++---------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index b7a52b5af69..b1b4f47b355 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -554,7 +554,9 @@ struct UnixDirectoryIdentityV1 { #[cfg(unix)] impl UnixDirectoryIdentityV1 { fn at(path: &Path) -> Result { - let metadata = path.metadata().map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + let metadata = path + .metadata() + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; if !metadata.is_dir() { return Err(DaemonCodeIndexPublicationStoreV1::unavailable( "code-generation identity path is not a directory", @@ -3846,8 +3848,7 @@ impl DaemonCodeIndexPublicationStoreV1 { #[cfg(unix)] let same_directories = UnixDirectoryIdentityV1::at(store_root)? == preparation.store_identity - && UnixDirectoryIdentityV1::at(&self.segments_root)? - == preparation.segments_identity + && UnixDirectoryIdentityV1::at(&self.segments_root)? == preparation.segments_identity && UnixDirectoryIdentityV1::at(&self.generations_root)? == preparation.generations_identity; #[cfg(not(unix))] diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 9467fc8eadc..273d649fb09 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -825,7 +825,10 @@ fn generation_manifest_publication_preserves_concurrent_canonical_bytes() { .tempfile_in(root.path()) .expect("stage generation"); temporary.write_all(bytes).expect("write staged manifest"); - temporary.as_file().sync_all().expect("durable staged manifest"); + temporary + .as_file() + .sync_all() + .expect("durable staged manifest"); temporary }; @@ -853,11 +856,8 @@ fn generation_manifest_publication_preserves_concurrent_canonical_bytes() { ); // A second publisher with identical content may safely deduplicate. - DaemonCodeIndexPublicationStoreV1::publish_missing_generation_manifest( - stage(good), - &canonical, - ) - .expect("identical immutable generation deduplicates"); + DaemonCodeIndexPublicationStoreV1::publish_missing_generation_manifest(stage(good), &canonical) + .expect("identical immutable generation deduplicates"); assert_eq!(std::fs::read(&canonical).expect("original content"), good); assert_eq!( std::fs::read_dir(root.path()) @@ -868,12 +868,12 @@ fn generation_manifest_publication_preserves_concurrent_canonical_bytes() { ); std::fs::remove_file(&canonical).expect("vacate immutable name"); - DaemonCodeIndexPublicationStoreV1::publish_missing_generation_manifest( - stage(good), - &canonical, - ) - .expect("publish new immutable name"); - assert_eq!(std::fs::read(&canonical).expect("new generation bytes"), good); + DaemonCodeIndexPublicationStoreV1::publish_missing_generation_manifest(stage(good), &canonical) + .expect("publish new immutable name"); + assert_eq!( + std::fs::read(&canonical).expect("new generation bytes"), + good + ); assert_eq!( std::fs::read_dir(root.path()) .expect("generation directory") From 1d021a585a36fa91a6ab1bc56ccba9701e821c97 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:41:34 -0800 Subject: [PATCH 22/93] fix(code-index): refuse retained reads after I/O interruption Clear stale output on pre-I/O refusal and recheck cancellation and request deadlines after endpoint-scanned segment I/O. Assert exact bytes on success and typed pre/post-I/O failures on a real generation. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 20 ++- .../tests/staged_segment_reads.rs | 128 +++++++++++++++++- 2 files changed, 143 insertions(+), 5 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index b1b4f47b355..40b18edce05 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -2043,6 +2043,9 @@ impl DaemonCodeIndexPublicationStoreV1 { buffer: &mut Vec, control: &dyn CodeIndexExecutionControlV1, ) -> Result<(), CodeIndexProductionErrorV1> { + // A rejected or interrupted request must not return bytes left by + // an earlier successful read, even when it never reaches the file. + buffer.clear(); let root = self .active_path .parent() @@ -2077,7 +2080,22 @@ impl DaemonCodeIndexPublicationStoreV1 { CodeIndexInterruptionV1::Cancelled, )); } - self.read_partitioned_segment(request, buffer) + let result = self.read_partitioned_segment(request, buffer); + // Endpoint authorization may block inside open(). A request that was + // live before I/O must not return data after its deadline or cancel. + if control.is_cancelled() { + buffer.clear(); + return Err(CodeIndexProductionErrorV1::Interrupted( + CodeIndexInterruptionV1::Cancelled, + )); + } + if control.is_deadline_exceeded() { + buffer.clear(); + return Err(CodeIndexProductionErrorV1::Interrupted( + CodeIndexInterruptionV1::DeadlineExceeded, + )); + } + result } fn read_partitioned_segment( diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 273d649fb09..6dee0e72e91 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -4,17 +4,22 @@ use std::{ io::Write, os::unix::fs::MetadataExt, path::{Path, PathBuf}, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, time::Duration, }; use sha2::{Digest, Sha256}; use tempfile::TempDir; use tracedecay_code_index::production::{ - CodeIndexAtomicPublicationPort, CodeIndexPreparationPort, CodeIndexProductionErrorV1, - CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, - SealedGenerationSegmentPublicationV1, SealedGenerationSegmentReadV1, + CodeIndexAtomicPublicationPort, CodeIndexExecutionControlV1, CodeIndexInterruptionV1, + CodeIndexPreparationPort, CodeIndexProductionErrorV1, CodeIndexPublicationStoreErrorV1, + CodeIndexPublishedGenerationV1, SealedGenerationSegmentPublicationV1, + SealedGenerationSegmentReadV1, }; +use tracedecay_code_index_retention::code_index_generations::DurableSealedCodeGenerationIdentityV1; use tracedecay_domain::{ManifestDigest, ProjectId, SanitizerRevision, sha256_hex_suffix}; use super::{ @@ -882,3 +887,118 @@ fn generation_manifest_publication_preserves_concurrent_canonical_bytes() { "successful publication must consume staging" ); } + +struct InterruptOnCheckpoint { + interrupt_at: usize, + deadline: bool, + checks: AtomicUsize, +} + +impl InterruptOnCheckpoint { + fn new(interrupt_at: usize, deadline: bool) -> Self { + Self { + interrupt_at, + deadline, + checks: AtomicUsize::new(0), + } + } +} + +impl CodeIndexExecutionControlV1 for InterruptOnCheckpoint { + fn is_cancelled(&self) -> bool { + let check = self.checks.fetch_add(1, Ordering::SeqCst) + 1; + !self.deadline && check >= self.interrupt_at + } + + fn is_deadline_exceeded(&self) -> bool { + let check = self.checks.fetch_add(1, Ordering::SeqCst) + 1; + self.deadline && check >= self.interrupt_at + } +} + +#[test] +fn retained_generation_read_refuses_pre_and_post_io_interruptions_without_leaking_bytes() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage real generation"); + target + .commit(&mut preparation, &publication) + .expect("publish real generation"); + drop(preparation); + + let pointer = target + .read_publication_pointer() + .expect("read durable pointer") + .expect("active generation pointer"); + let active = pointer + .generation_index + .iter() + .find(|entry| entry.generation_id == pointer.generation_id) + .expect("active generation entry"); + let sealed = DurableSealedCodeGenerationIdentityV1 { + locator: active.generation_file.clone(), + digest: ManifestDigest::new(active.state_digest.clone()).expect("canonical digest"), + size_bytes: active.size_bytes, + }; + let segment = CodeIndexPublishedGenerationV1::partitioned_segment_identities_from_reader( + publication.manifest_bytes().as_ref(), + ) + .expect("parse real manifest") + .expect("current generation revision") + .into_iter() + .find(|segment| segment.size_bytes > 0) + .expect("nonempty retained segment"); + let request = SealedGenerationSegmentReadV1::Whole { + digest: &segment.digest, + size_bytes: segment.size_bytes, + }; + let mut expected = Vec::new(); + target + .read_partitioned_segment(request, &mut expected) + .expect("read exact committed segment"); + assert_eq!(segment_digest(&expected), segment.digest); + + // A control refuses after the initial two checks, i.e. after the real + // pointer and segment reads. A stale successful result is not acceptable. + for (checkpoint, deadline) in [(1, false), (2, true), (3, false), (4, true)] { + let control = InterruptOnCheckpoint::new(checkpoint, deadline); + let mut bytes = b"previous successful read must not survive".to_vec(); + let error = target + .read_retained_partitioned_segment(&sealed, request, &mut bytes, &control) + .expect_err("interrupted retained read must refuse"); + if deadline { + assert!(matches!( + error, + CodeIndexProductionErrorV1::Interrupted( + CodeIndexInterruptionV1::DeadlineExceeded + ) + )); + } else { + assert!(matches!( + error, + CodeIndexProductionErrorV1::Interrupted(CodeIndexInterruptionV1::Cancelled) + )); + } + assert_eq!(bytes, [] as [u8; 0], "no earlier or late bytes may escape"); + assert_eq!( + control.checks.load(Ordering::SeqCst), + checkpoint, + "interrupt must occur at the intended pre/post-I/O checkpoint" + ); + } + + let active = InterruptOnCheckpoint::new(usize::MAX, false); + let mut bytes = b"stale".to_vec(); + target + .read_retained_partitioned_segment(&sealed, request, &mut bytes, &active) + .expect("unchanged generation remains readable"); + assert_eq!(bytes, expected); +} From ae10ea50c73f334cdcb7fef050fe14a97547400a Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:40:31 -0800 Subject: [PATCH 23/93] style(code-index): format retained read interruption regression Apply the exact Bazel rustfmt_check_2024 correction reported for the retained-generation deadline regression, preserving all test assertions. Refs #4533 --- .../src/code_index_scheduler/tests/staged_segment_reads.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 6dee0e72e91..f599c369458 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -977,9 +977,7 @@ fn retained_generation_read_refuses_pre_and_post_io_interruptions_without_leakin if deadline { assert!(matches!( error, - CodeIndexProductionErrorV1::Interrupted( - CodeIndexInterruptionV1::DeadlineExceeded - ) + CodeIndexProductionErrorV1::Interrupted(CodeIndexInterruptionV1::DeadlineExceeded) )); } else { assert!(matches!( From 0f9e7f240defcba27eb14999b0fb7948ea563e4a Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:18:11 -0800 Subject: [PATCH 24/93] fix(code-index): authenticate cold whole-segment reads Pin pathname/inode identity across reads without a second open, verify whole-segment SHA-256 before returning bytes, and clear failed output. Prove same-inode, same-length corruption refusal and true cold reload. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 60 ++++++++++---- .../tests/staged_segment_reads.rs | 80 ++++++++++++++++++- 2 files changed, 125 insertions(+), 15 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 40b18edce05..6506cc5f1ea 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -2143,16 +2143,16 @@ impl DaemonCodeIndexPublicationStoreV1 { buffer: &mut Vec, ) -> Result<(), CodeIndexProductionErrorV1> { buffer.clear(); - let (_digest, expected_size, offset, length) = match request { + let (digest, expected_size, offset, length, whole) = match request { SealedGenerationSegmentReadV1::Whole { digest, size_bytes } => { - (digest, size_bytes, 0, size_bytes) + (digest, size_bytes, 0, size_bytes, true) } SealedGenerationSegmentReadV1::Range { digest, size_bytes, offset, length, - } => (digest, size_bytes, offset, length), + } => (digest, size_bytes, offset, length, false), }; if offset .checked_add(length) @@ -2178,17 +2178,49 @@ impl DaemonCodeIndexPublicationStoreV1 { ) })?; buffer.resize(length, 0); - File::open(path) - .and_then(|mut file| { - file.seek(SeekFrom::Start(offset))?; - file.read_exact(buffer) - }) - .map_err(|error| { - buffer.clear(); - CodeIndexProductionErrorV1::Contract(format!( - "sealed generation segment read failed: {error}" - )) - }) + let read = (|| -> std::io::Result<()> { + let mut file = File::open(path)?; + #[cfg(unix)] + { + let opened = file.metadata()?; + if !Self::same_unix_segment_identity(&metadata, &opened) { + return Err(std::io::Error::other( + "sealed segment pathname changed while opening", + )); + } + } + file.seek(SeekFrom::Start(offset))?; + file.read_exact(buffer)?; + #[cfg(unix)] + { + let opened = file.metadata()?; + let named = path.symlink_metadata()?; + if !Self::same_unix_segment_identity(&metadata, &opened) + || !Self::same_unix_segment_identity(&metadata, &named) + { + return Err(std::io::Error::other( + "sealed segment pathname or bytes changed during read", + )); + } + } + Ok(()) + })(); + if let Err(error) = read { + buffer.clear(); + return Err(CodeIndexProductionErrorV1::Contract(format!( + "sealed generation segment read failed: {error}" + ))); + } + // A full segment has its content address available without another + // pathname open. Never return same-length corrupt bytes to a caller. + // Range callers retain their existing per-page digest authority. + if whole && Self::state_digest(buffer) != digest.as_str() { + buffer.clear(); + return Err(CodeIndexProductionErrorV1::Contract( + "sealed generation segment does not match its content address".to_owned(), + )); + } + Ok(()) } fn open_pinned_partitioned_segment( diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index f599c369458..66abadb849e 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -594,7 +594,11 @@ fn staged_preparation_exact_segment_bytes_commit_and_reload() { ); drop(reader); drop(preparation); - let reloaded = target + // A new store has no decoded-generation or active-manifest memo from the + // publisher; this must really reconstruct the snapshot from durable bytes. + drop(target); + let cold_store = target_store(target_root.path(), &fixture); + let reloaded = cold_store .load_active_shared() .expect("decode committed generation") .expect("active durable generation"); @@ -609,6 +613,80 @@ fn staged_preparation_exact_segment_bytes_commit_and_reload() { ); } + +#[test] +fn committed_whole_segment_read_authenticates_bytes_without_another_open() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage real generation"); + target + .commit(&mut preparation, &publication) + .expect("commit real generation"); + drop(preparation); + drop(target); + + let cold = target_store(target_root.path(), &fixture); + let identity = CodeIndexPublishedGenerationV1::partitioned_segment_identities_from_reader( + publication.manifest_bytes().as_ref(), + ) + .expect("parse real manifest") + .expect("partitioned generation") + .into_iter() + .find(|segment| segment.size_bytes > 4) + .expect("nonempty addressed segment"); + let request = SealedGenerationSegmentReadV1::Whole { + digest: &identity.digest, + size_bytes: identity.size_bytes, + }; + let mut bytes = Vec::new(); + cold.read_partitioned_segment(request, &mut bytes) + .expect("cold valid whole-segment read"); + assert_eq!(segment_digest(&bytes), identity.digest); + let mut range = Vec::new(); + cold.read_partitioned_segment( + SealedGenerationSegmentReadV1::Range { + digest: &identity.digest, + size_bytes: identity.size_bytes, + offset: 1, + length: 3, + }, + &mut range, + ) + .expect("valid range still returns exact bytes"); + assert_eq!(range.as_slice(), &bytes[1..4]); + + // Keep the inode and extent: size/path checks alone accept this corruption. + let segment_path = cold.segments_root.join(format!( + "segment-{}.json", + sha256_hex_suffix(identity.digest.as_str()).expect("digest suffix") + )); + let original = segment_path.symlink_metadata().expect("original segment"); + let mut corrupt = vec![b'X'; bytes.len()]; + if corrupt == bytes { + corrupt[0] ^= 1; + } + std::fs::write(&segment_path, &corrupt).expect("mutate same inode"); + let changed = segment_path.symlink_metadata().expect("mutated segment"); + assert_eq!( + (original.dev(), original.ino(), original.len()), + (changed.dev(), changed.ino(), changed.len()) + ); + let mut reused = b"stale output".to_vec(); + assert!(matches!( + cold.read_partitioned_segment(request, &mut reused), + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert_eq!(reused, [] as [u8; 0], "corrupt bytes must not escape"); +} + #[test] fn staged_segment_rejected_reads_preserve_full_cache_and_recency() { let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); From 5a9edde6d6f40d9e5bd14297fdff7d296dc0627a Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:18:49 -0800 Subject: [PATCH 25/93] test(code-index): use valid fixture and canonical regression layout Correct the literal newline in the corruption fixture and avoid a stale test name that implied an unmeasured open count. Refs #4533 --- .../src/code_index_scheduler/tests/staged_segment_reads.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 66abadb849e..d09a6039e69 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -613,10 +613,9 @@ fn staged_preparation_exact_segment_bytes_commit_and_reload() { ); } - #[test] -fn committed_whole_segment_read_authenticates_bytes_without_another_open() { - let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\\n")]); +fn committed_whole_segment_read_refuses_same_length_corruption() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); let source_root = TempDir::new().expect("source store"); let generation = source_generation(&fixture, source_root.path()); let target_root = TempDir::new().expect("target store"); From 1b53e6ddba8af187dd7c77b4728b17c1baf3acea Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:22:45 -0800 Subject: [PATCH 26/93] perf(storage): reject invalid batch members before window fsync Validate incoming file identity before flushing a full Linux durability window. Recheck it after the potentially slow flush before admission. Exercise actual fsync counts and exact bytes through the production batch. Refs #4533 --- .../tracedecay-private-fs/src/framed_log.rs | 16 ++++-- .../src/framed_log_batch_tests.rs | 49 +++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/crates/tracedecay-private-fs/src/framed_log.rs b/crates/tracedecay-private-fs/src/framed_log.rs index 1e7de32f3a0..16fc428b2c6 100644 --- a/crates/tracedecay-private-fs/src/framed_log.rs +++ b/crates/tracedecay-private-fs/src/framed_log.rs @@ -152,18 +152,24 @@ impl DurableFileBatch { pub fn written(&mut self, path: &Path, file: &File) -> io::Result<()> { #[cfg(target_os = "linux")] { + let identity = DurableFileIdentity { + path: path.to_path_buf(), + metadata: file.metadata()?, + }; + // An invalid incoming member must not force a full, otherwise + // healthy window to fsync before its admission is rejected. + identity.validate_path()?; if self.pending.len() == DURABLE_BATCH_CONCURRENT_SYNCS { // Keep ownership on failure: no unproven member may move to // the synced set, and no additional descriptor is admitted. self.sync_pending()?; self.synced .extend(self.pending.drain(..).map(|member| member.identity)); + // A slow filesystem barrier leaves time for the incoming + // path or descriptor to change. Do not admit that change. + identity.validate_metadata(&file.metadata()?)?; + identity.validate_path()?; } - let identity = DurableFileIdentity { - path: path.to_path_buf(), - metadata: file.metadata()?, - }; - identity.validate_path()?; // Duplication retains the writer's inode without a pathname open. let retained = file.try_clone()?; // SAFETY: `retained` is an open descriptor for the whole call. diff --git a/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs b/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs index 158bc1f66e7..ff26883e64d 100644 --- a/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs +++ b/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs @@ -237,6 +237,55 @@ fn durable_batch_refuses_same_inode_mutation_after_early_flush() { ); } +#[cfg(feature = "test-helpers")] +#[test] +fn durable_batch_invalid_next_member_does_not_flush_a_full_window() { + let root = tempfile::tempdir().expect("batch root"); + let mut batch = DurableFileBatch::new(); + for index in 0..DURABLE_BATCH_CONCURRENT_SYNCS { + write_member(&mut batch, root.path(), index); + } + + let next = root.path().join("next.tmp"); + let other = root.path().join("unrelated.tmp"); + let mut writer = File::create(&next).expect("next writer"); + writer.write_all(b"next segment").expect("next bytes"); + fs::write(&other, b"next segment").expect("same bytes, different inode"); + let barriers = sync_latency::inject(root.path(), Duration::ZERO); + + assert_eq!( + batch + .written(&other, &writer) + .expect_err("a foreign pathname must be rejected before flushing") + .kind(), + ErrorKind::InvalidData + ); + assert_eq!( + barriers.syncs(), + 0, + "an invalid new member must not trigger durability work on a full valid window" + ); + assert_eq!(batch.pending.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + assert_eq!(batch.synced.len(), 0); + + batch.written(&next, &writer).expect("admit correct member"); + assert_eq!( + barriers.syncs(), + u64::try_from(DURABLE_BATCH_CONCURRENT_SYNCS).expect("window size"), + "admitting the real next member must flush the previous window" + ); + assert_eq!(batch.pending.len(), 1); + assert_eq!(batch.synced.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + drop(writer); + batch.sync().expect("flush admitted member"); + assert_eq!( + barriers.syncs(), + u64::try_from(DURABLE_BATCH_CONCURRENT_SYNCS + 1).expect("member count"), + "the optimization must not skip a durability barrier" + ); + assert_eq!(fs::read(&next).expect("durable member"), b"next segment"); +} + #[test] fn durable_batch_failed_window_keeps_handles_and_does_not_admit_another_member() { let root = tempfile::tempdir().expect("batch root"); From 5f9c0137f6c9d0e4dbff445a60425952808cbb9d Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:04:57 -0800 Subject: [PATCH 27/93] perf(code-index): reuse authenticated segment reads at commit Retain Unix inode identity from the stage-time content-address verification and avoid reopening unchanged canonical files in the commit pass. Preserve full hashing for segments not staged by this preparation and refuse changed inodes even if their bytes match. Add a real-store positive-control open counter and replacement regression. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 79 ++++++++++++- .../tests/staged_segment_reads.rs | 110 +++++++++++++++++- 2 files changed, 183 insertions(+), 6 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 6506cc5f1ea..701050a67cb 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -18,6 +18,12 @@ use std::os::unix::fs::MetadataExt; #[cfg(test)] use std::sync::atomic::AtomicUsize; +#[cfg(all(test, unix))] +thread_local! { + static PREPARED_SEGMENT_DIGEST_OPENS: std::cell::Cell = + const { std::cell::Cell::new(0) }; +} + #[cfg(not(unix))] use same_file::Handle; use sha2::{Digest, Sha256}; @@ -1067,6 +1073,10 @@ struct StagedGenerationSegmentsV1 { durable: DurableFileBatch, /// Temporary path of every written segment, keyed by its final name. pending: BTreeMap, + // Previously authenticated canonical inodes need only an identity check + // at commit; reopening all unchanged segments repeats endpoint scans. + #[cfg(unix)] + verified_existing: BTreeMap, flushed: bool, } @@ -1075,6 +1085,8 @@ impl StagedGenerationSegmentsV1 { Self { durable: DurableFileBatch::new(), pending: BTreeMap::new(), + #[cfg(unix)] + verified_existing: BTreeMap::new(), flushed: false, } } @@ -1646,12 +1658,28 @@ impl DaemonCodeIndexPublicationStoreV1 { existing_segment_bytes_read.fetch_add(metadata.len(), Ordering::Relaxed); if !metadata.file_type().is_file() || metadata.len() != u64::try_from(bytes.len()).map_err(Self::unavailable)? - || Self::state_digest_file(&final_path)? != expected_digest { return Err(Self::corruption( "existing sealed segment does not match its content address", )); } + #[cfg(unix)] + { + if Self::state_digest_existing_segment(&final_path, &metadata)? + != expected_digest + { + return Err(Self::corruption( + "existing sealed segment does not match its content address", + )); + } + staged.verified_existing.insert(final_path, metadata); + } + #[cfg(not(unix))] + if Self::state_digest_file(&final_path)? != expected_digest { + return Err(Self::corruption( + "existing sealed segment does not match its content address", + )); + } return Ok(()); } Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} @@ -1674,7 +1702,36 @@ impl DaemonCodeIndexPublicationStoreV1 { .map_err(Self::unavailable) } + #[cfg(unix)] + fn state_digest_existing_segment( + path: &Path, + original: &std::fs::Metadata, + ) -> Result { + #[cfg(test)] + PREPARED_SEGMENT_DIGEST_OPENS.with(|count| count.set(count.get().saturating_add(1))); + let mut file = File::open(path).map_err(Self::unavailable)?; + let opened = file.metadata().map_err(Self::unavailable)?; + if !Self::same_unix_segment_identity(original, &opened) { + return Err(Self::corruption( + "existing sealed segment changed before authentication", + )); + } + let digest = Self::state_digest_reader(&mut file)?; + let after = file.metadata().map_err(Self::unavailable)?; + let named = path.symlink_metadata().map_err(Self::unavailable)?; + if !Self::same_unix_segment_identity(original, &after) + || !Self::same_unix_segment_identity(original, &named) + { + return Err(Self::corruption( + "existing sealed segment changed during authentication", + )); + } + Ok(digest) + } + fn state_digest_file(path: &Path) -> Result { + #[cfg(all(test, unix))] + PREPARED_SEGMENT_DIGEST_OPENS.with(|count| count.set(count.get().saturating_add(1))); let mut file = File::open(path).map_err(Self::unavailable)?; Self::state_digest_reader(&mut file) } @@ -3947,10 +4004,22 @@ impl DaemonCodeIndexPublicationStoreV1 { Self::unavailable(error) } })?; - if !metadata.file_type().is_file() - || metadata.len() != identity.size_bytes - || Self::state_digest_file(path)? != identity.digest.as_str() - { + if !metadata.file_type().is_file() || metadata.len() != identity.size_bytes { + return Err(Self::corruption( + "prepared generation segment does not match its manifest identity", + )); + } + #[cfg(unix)] + if let Some(original) = files.segments.verified_existing.get(&canonical) { + if !Self::same_unix_segment_identity(original, &metadata) { + return Err(Self::corruption( + "previously verified sealed segment changed before commit", + )); + } + verified.insert(identity.digest, identity.size_bytes); + continue; + } + if Self::state_digest_file(path)? != identity.digest.as_str() { return Err(Self::corruption( "prepared generation segment does not match its manifest identity", )); diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index d09a6039e69..9693df93ad5 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -24,7 +24,8 @@ use tracedecay_domain::{ManifestDigest, ProjectId, SanitizerRevision, sha256_hex use super::{ DaemonCodeIndexPreparationStateV1, DaemonCodeIndexPreparationV1, - DaemonCodeIndexPublicationStoreV1, MAX_STAGED_SEGMENT_READ_HANDLES, SharedCodeIndexBytePoolV1, + DaemonCodeIndexPublicationStoreV1, MAX_STAGED_SEGMENT_READ_HANDLES, + PREPARED_SEGMENT_DIGEST_OPENS, SharedCodeIndexBytePoolV1, }; use crate::code_index_scheduler::tests::GitFixture; use crate::code_index_scheduler::{CodeIndexReconcileOutcomeV1, CodeIndexWorktreeSchedulerV1}; @@ -1077,3 +1078,110 @@ fn retained_generation_read_refuses_pre_and_post_io_interruptions_without_leakin .expect("unchanged generation remains readable"); assert_eq!(bytes, expected); } + +#[test] +fn staged_existing_segments_reuse_authenticated_inodes_without_reopening() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut first = target + .prepare(&generation.sealed_scope(), None) + .expect("first preparation"); + let publication = generation + .stage_publication(&mut first) + .expect("stage real generation"); + target + .commit(&mut first, &publication) + .expect("publish canonical sealed segments"); + drop(first); + + // A manifest supplied without stage ownership must authenticate every + // canonical file, not trust its digest-shaped filename. + let unproven = target + .prepare( + &generation.sealed_scope(), + Some(&generation.manifest().generation_id), + ) + .expect("unproven preparation"); + let full_before = PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get); + { + let state = unproven.state.lock().expect("unproven state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("unproven preparation must remain open"); + }; + target + .verify_prepared_segments(files, publication.manifest_bytes().as_ref()) + .expect("authenticate independently supplied manifest"); + } + let full_opens = PREPARED_SEGMENT_DIGEST_OPENS + .with(std::cell::Cell::get) + .saturating_sub(full_before); + assert!(full_opens > 0, "the counter must observe canonical file opens"); + unproven.abort().expect("abort unproven preparation"); + + let mut staged = target + .prepare( + &generation.sealed_scope(), + Some(&generation.manifest().generation_id), + ) + .expect("new preparation"); + let staged_publication = generation + .stage_publication(&mut staged) + .expect("restage existing canonical bytes"); + let before = PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get); + let existing_path = { + let state = staged.state.lock().expect("staged state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("staged preparation must remain open"); + }; + let path = files + .segments + .verified_existing + .keys() + .next() + .expect("previously verified canonical segment") + .clone(); + target + .verify_prepared_segments(files, staged_publication.manifest_bytes().as_ref()) + .expect("reuse sealed inode authentication"); + path + }; + let warm_opens = PREPARED_SEGMENT_DIGEST_OPENS + .with(std::cell::Cell::get) + .saturating_sub(before); + assert!( + warm_opens < full_opens, + "staged existing segments must not be opened again during verification" + ); + + // Equal bytes do not authorize silently replacing an already witnessed + // immutable inode between stage and commit. + let replacement = existing_path.with_extension("witness-replacement.tmp"); + std::fs::write( + &replacement, + std::fs::read(&existing_path).expect("canonical bytes"), + ) + .expect("same-content replacement"); + std::fs::rename(&replacement, &existing_path).expect("replace canonical inode"); + let opens_before_refusal = PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get); + { + let state = staged.state.lock().expect("staged state after replacement"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("staged preparation must remain open"); + }; + assert!( + target + .verify_prepared_segments(files, staged_publication.manifest_bytes().as_ref()) + .is_err(), + "a substituted inode must be refused even with identical bytes" + ); + } + assert_eq!( + PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get), + opens_before_refusal, + "identity refusal must not reopen the replaced pathname" + ); + staged.abort().expect("abort changed preparation"); +} From 0d749b023a3d980233a6528ac5fecae5d62a6812 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:23:19 -0800 Subject: [PATCH 28/93] fix(code-index): preserve pending segment identity on dedup Prefer the existing pending segment over a newly named canonical file when repeated staging races another publisher. Authenticate the pending bytes before commit; the publication path verifies canonical content before deduplicating, without replacing its inode. Exercise the real prepare, repeated write, verify, commit, and inode preservation sequence against a concurrent canonical publication. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 10 ++- .../tests/staged_segment_reads.rs | 81 +++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 701050a67cb..69cd4906e3c 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -1653,6 +1653,13 @@ impl DaemonCodeIndexPublicationStoreV1 { )); } let final_path = segments_root.join(format!("segment-{digest_hex}.json")); + // A pending file remains this preparation's verification authority even + // when another publisher installs its canonical name in the meantime. + // Verify its bytes at commit; publish() authenticates the competing + // canonical bytes before deduplicating without replacing them. + if staged.contains(&final_path) { + return Ok(()); + } match final_path.symlink_metadata() { Ok(metadata) => { existing_segment_bytes_read.fetch_add(metadata.len(), Ordering::Relaxed); @@ -1685,9 +1692,6 @@ impl DaemonCodeIndexPublicationStoreV1 { Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(Self::unavailable(error)), } - if staged.contains(&final_path) { - return Ok(()); - } let temporary_path = staging_root.join(format!("segment-{digest_hex}.json")); let mut file = std::fs::OpenOptions::new() .create_new(true) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 9693df93ad5..c0a71188f71 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -860,6 +860,87 @@ fn staged_segment_publish_never_overwrites_a_concurrent_canonical_name() { ); } +#[test] +fn repeated_pending_segment_accepts_identical_concurrent_canonical_publication() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn fixture() -> u32 { 7 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage real generation"); + + let (canonical, temporary) = { + let state = preparation.state.lock().expect("preparation state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("preparation must remain open"); + }; + files + .segments + .pending + .iter() + .next() + .map(|(name, temporary)| (name.clone(), temporary.clone())) + .expect("a real generation must stage at least one segment") + }; + let bytes = std::fs::read(&temporary).expect("staged segment bytes"); + let digest = segment_digest(&bytes); + assert!(!canonical.exists(), "the first stage has not named its segment"); + + // A separate publisher wins the canonical name after this preparation + // writes its own temporary file. It has the same authenticated bytes but + // a different inode. Repeating the write must not confuse their identities. + let mut foreign = std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&canonical) + .expect("concurrent canonical publication"); + foreign.write_all(&bytes).expect("canonical bytes"); + foreign.sync_all().expect("durable competing segment"); + let foreign_identity = foreign.metadata().expect("canonical inode"); + drop(foreign); + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &digest, + bytes: &bytes, + }) + .expect("repeat an already-pending segment after concurrent naming"); + { + let state = preparation.state.lock().expect("preparation state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("preparation must remain open"); + }; + assert_eq!( + files.segments.pending.get(&canonical), + Some(&temporary), + "this preparation still owns its original temporary bytes" + ); + assert!( + !files.segments.verified_existing.contains_key(&canonical), + "the concurrently named inode must not masquerade as our pending inode" + ); + target + .verify_prepared_segments(files, publication.manifest_bytes().as_ref()) + .expect("verification authenticates pending bytes instead of another inode"); + } + + target + .commit(&mut preparation, &publication) + .expect("identical canonical bytes deduplicate at commit"); + assert!(!temporary.exists(), "deduplicated temporary is discharged"); + assert_eq!( + std::fs::read(&canonical).expect("canonical bytes after commit"), + bytes + ); + let after = canonical.metadata().expect("canonical inode after commit"); + assert_eq!(after.dev(), foreign_identity.dev()); + assert_eq!(after.ino(), foreign_identity.ino()); +} + #[cfg(unix)] #[test] fn prepared_generation_rechecks_directory_identity_without_reopening_paths() { From 108d864ef58496c2dbee9bcc6743abbc28a4f786 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:25:00 -0800 Subject: [PATCH 29/93] style(code-index): apply pinned Rust format to staged regression Apply the exact rustfmt_check_2024 correction reported by hosted Repository gates without changing the regression assertions. Refs #4533 --- .../src/code_index_scheduler/tests/staged_segment_reads.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index c0a71188f71..80086e0739f 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -1199,7 +1199,10 @@ fn staged_existing_segments_reuse_authenticated_inodes_without_reopening() { let full_opens = PREPARED_SEGMENT_DIGEST_OPENS .with(std::cell::Cell::get) .saturating_sub(full_before); - assert!(full_opens > 0, "the counter must observe canonical file opens"); + assert!( + full_opens > 0, + "the counter must observe canonical file opens" + ); unproven.abort().expect("abort unproven preparation"); let mut staged = target From 8a2ffc098d3a0dc0c83419362f7895f3552b5620 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:45:03 -0800 Subject: [PATCH 30/93] perf(code-index): reuse canonical segment witness on repeated staging Avoid repeated endpoint-scanned opens for identical canonical segment writes within one preparation. Recheck original inode identity on every reuse and fail closed on same-byte pathname replacement. Prove nonzero first open, sixteen-write reuse and replacement refusal. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 14 +++ .../tests/staged_segment_reads.rs | 87 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 69cd4906e3c..b608dc2c51d 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -1660,6 +1660,20 @@ impl DaemonCodeIndexPublicationStoreV1 { if staged.contains(&final_path) { return Ok(()); } + // A repeated write of an already authenticated canonical segment + // should not pay another endpoint-scanned open and SHA-256 read. + // The original opened-file witness is authoritative only while its + // exact pathname inode and metadata remain unchanged. + #[cfg(unix)] + if let Some(original) = staged.verified_existing.get(&final_path) { + let current = final_path.symlink_metadata().map_err(Self::unavailable)?; + if !Self::same_unix_segment_identity(original, ¤t) { + return Err(Self::corruption( + "previously authenticated sealed segment changed before repeated write", + )); + } + return Ok(()); + } match final_path.symlink_metadata() { Ok(metadata) => { existing_segment_bytes_read.fetch_add(metadata.len(), Ordering::Relaxed); diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 80086e0739f..a94456576b0 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -1269,3 +1269,90 @@ fn staged_existing_segments_reuse_authenticated_inodes_without_reopening() { ); staged.abort().expect("abort changed preparation"); } + +#[test] +fn repeated_canonical_segment_writes_reuse_witness_and_refuse_replacement() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut first = target + .prepare(&generation.sealed_scope(), None) + .expect("first preparation"); + let publication = generation + .stage_publication(&mut first) + .expect("stage canonical generation"); + target + .commit(&mut first, &publication) + .expect("publish canonical segments"); + drop(first); + + let identity = CodeIndexPublishedGenerationV1::partitioned_segment_identities_from_reader( + publication.manifest_bytes().as_ref(), + ) + .expect("read real segment identities") + .expect("current partitioned generation") + .into_iter() + .find(|identity| identity.size_bytes > 0) + .expect("nonempty canonical segment"); + let suffix = sha256_hex_suffix(identity.digest.as_str()).expect("canonical digest suffix"); + let canonical = target + .segments_root + .join(format!("segment-{suffix}.json")); + let bytes = std::fs::read(&canonical).expect("canonical segment"); + assert_eq!(segment_digest(&bytes), identity.digest); + + let mut preparation = target + .prepare( + &generation.sealed_scope(), + Some(&generation.manifest().generation_id), + ) + .expect("repeated-write preparation"); + let before = PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get); + for _ in 0..16 { + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &identity.digest, + bytes: &bytes, + }) + .expect("authenticate or reuse exact existing segment"); + assert_eq!( + PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get) - before, + 1, + "sixteen repeated writes must open and hash the canonical file only once" + ); + } + let reader = preparation.segment_reader(); + let mut read = Vec::new(); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &identity.digest, + size_bytes: identity.size_bytes, + }, + &mut read, + ) + .expect("read the exact staged canonical segment"); + assert_eq!(read, bytes); + + // A byte-identical inode replacement is not the file that was hashed. + // A subsequent write must reject it without another File::open. + let replacement = canonical.with_extension("repeat-witness.tmp"); + std::fs::write(&replacement, &bytes).expect("same-byte replacement"); + std::fs::rename(&replacement, &canonical).expect("replace canonical inode"); + assert!( + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &identity.digest, + bytes: &bytes, + }) + .is_err(), + "a replaced immutable inode must not inherit the prior witness" + ); + assert_eq!( + PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get) - before, + 1, + "identity refusal should not reopen a substituted inode" + ); + preparation.abort().expect("release repeated-write preparation"); +} From 045bb9943543c8c13a2aca6c50f00d655f722781 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:15:55 -0800 Subject: [PATCH 31/93] fix(code-index): reject unflushed segment after staging failure Only enroll staged segments after write and durability-batch admission succeed; remove the failed temporary before returning the error. A real Linux 33-member regression triggers a failed full-window fsync and proves repeated writes cannot claim the unflushed file as successfully staged. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 23 ++++-- .../tests/staged_segment_reads.rs | 81 +++++++++++++++++++ 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index b608dc2c51d..b0a76f8b667 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -1712,12 +1712,23 @@ impl DaemonCodeIndexPublicationStoreV1 { .write(true) .open(&temporary_path) .map_err(Self::unavailable)?; - staged.pending.insert(final_path, temporary_path.clone()); - file.write_all(bytes).map_err(Self::unavailable)?; - staged - .durable - .written(&temporary_path, &file) - .map_err(Self::unavailable) + // The pending index is publication authority. Never enroll a file + // whose write or durability admission failed: a later retry must not + // mistake an unflushed temporary for a successfully staged segment. + let admitted = file + .write_all(bytes) + .and_then(|()| staged.durable.written(&temporary_path, &file)); + drop(file); + if let Err(error) = admitted { + std::fs::remove_file(&temporary_path).map_err(|cleanup| { + Self::unavailable(format!( + "staged segment admission failed: {error}; temporary cleanup failed: {cleanup}" + )) + })?; + return Err(Self::unavailable(error)); + } + staged.pending.insert(final_path, temporary_path); + Ok(()) } #[cfg(unix)] diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index a94456576b0..3008d355686 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -1356,3 +1356,84 @@ fn repeated_canonical_segment_writes_reuse_witness_and_refuse_replacement() { ); preparation.abort().expect("release repeated-write preparation"); } + +#[cfg(target_os = "linux")] +#[test] +fn failed_durability_admission_cannot_leave_a_publishable_segment() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn fixture() -> u32 { 7 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + + // The 33rd admission flushes the first 32 real writer handles on Linux. + // Mutating a member makes that barrier fail after the 33rd temporary was + // created, exposing the old bug that nevertheless enrolled the temporary. + let contents = (0..33) + .map(|index| format!("durability-admission-{index}-bytes").into_bytes()) + .collect::>(); + let digests = contents + .iter() + .map(|bytes| segment_digest(bytes)) + .collect::>(); + for (digest, bytes) in digests.iter().zip(&contents).take(32) { + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { digest, bytes }) + .expect("stage first durability window"); + } + + let first_path = staged_path(&preparation, &digests[0]); + let prior = first_path.symlink_metadata().expect("first member metadata"); + let mut writer = File::options() + .write(true) + .open(&first_path) + .expect("mutate same inode"); + writer + .write_all(&vec![b'X'; contents[0].len()]) + .expect("same-length mutation"); + writer + .set_modified(prior.modified().expect("mtime") + Duration::from_secs(2)) + .expect("force distinct mtime"); + drop(writer); + + let pending_path = { + let state = preparation.state.lock().expect("preparation state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("preparation must be open"); + }; + let suffix = sha256_hex_suffix(digests[32].as_str()).expect("digest suffix"); + files.directory.path().join(format!("segment-{suffix}.json")) + }; + for _ in 0..2 { + let result = preparation.write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &digests[32], + bytes: &contents[32], + }); + assert!(matches!( + result, + Err(CodeIndexPublicationStoreErrorV1::Unavailable(_)) + )); + assert!( + !pending_path.exists(), + "a failed writer must clean its temporary, including on retry" + ); + let state = preparation.state.lock().expect("preparation state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("preparation must remain abortable"); + }; + assert!( + !files.segments.pending.values().any(|path| path == &pending_path), + "failed durable admission must not be treated as staged" + ); + assert_eq!( + files.segments.pending.len(), + 32, + "only the initially admitted members remain owned" + ); + } + preparation.abort().expect("abort failed durability window"); + assert!(!pending_path.exists()); +} From 97814f39b6c6a16f73c92158faa3a4b5a4a75de7 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:20:35 -0800 Subject: [PATCH 32/93] style(code-index): format failed-admission regression Keep the real Linux durability failure regression unchanged while normalizing a writer expression to the pinned rustfmt layout. Refs #4533 --- .../src/code_index_scheduler/tests/staged_segment_reads.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 3008d355686..b17a35a4e16 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -1387,10 +1387,7 @@ fn failed_durability_admission_cannot_leave_a_publishable_segment() { let first_path = staged_path(&preparation, &digests[0]); let prior = first_path.symlink_metadata().expect("first member metadata"); - let mut writer = File::options() - .write(true) - .open(&first_path) - .expect("mutate same inode"); + let mut writer = File::options().write(true).open(&first_path).expect("mutate same inode"); writer .write_all(&vec![b'X'; contents[0].len()]) .expect("same-length mutation"); From 9b34ee70f0c203dff0eda12d91d47b361c8c15fd Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:25:16 -0800 Subject: [PATCH 33/93] style(code-index): format staged segment regressions Apply six exact Rust 2024 rustfmt differences from hosted Bazel Repository gates; preserve every filesystem test and assertion. --- .../tests/staged_segment_reads.rs | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index b17a35a4e16..975bdd15ad9 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -889,7 +889,10 @@ fn repeated_pending_segment_accepts_identical_concurrent_canonical_publication() }; let bytes = std::fs::read(&temporary).expect("staged segment bytes"); let digest = segment_digest(&bytes); - assert!(!canonical.exists(), "the first stage has not named its segment"); + assert!( + !canonical.exists(), + "the first stage has not named its segment" + ); // A separate publisher wins the canonical name after this preparation // writes its own temporary file. It has the same authenticated bytes but @@ -1297,9 +1300,7 @@ fn repeated_canonical_segment_writes_reuse_witness_and_refuse_replacement() { .find(|identity| identity.size_bytes > 0) .expect("nonempty canonical segment"); let suffix = sha256_hex_suffix(identity.digest.as_str()).expect("canonical digest suffix"); - let canonical = target - .segments_root - .join(format!("segment-{suffix}.json")); + let canonical = target.segments_root.join(format!("segment-{suffix}.json")); let bytes = std::fs::read(&canonical).expect("canonical segment"); assert_eq!(segment_digest(&bytes), identity.digest); @@ -1354,7 +1355,9 @@ fn repeated_canonical_segment_writes_reuse_witness_and_refuse_replacement() { 1, "identity refusal should not reopen a substituted inode" ); - preparation.abort().expect("release repeated-write preparation"); + preparation + .abort() + .expect("release repeated-write preparation"); } #[cfg(target_os = "linux")] @@ -1386,8 +1389,13 @@ fn failed_durability_admission_cannot_leave_a_publishable_segment() { } let first_path = staged_path(&preparation, &digests[0]); - let prior = first_path.symlink_metadata().expect("first member metadata"); - let mut writer = File::options().write(true).open(&first_path).expect("mutate same inode"); + let prior = first_path + .symlink_metadata() + .expect("first member metadata"); + let mut writer = File::options() + .write(true) + .open(&first_path) + .expect("mutate same inode"); writer .write_all(&vec![b'X'; contents[0].len()]) .expect("same-length mutation"); @@ -1402,7 +1410,10 @@ fn failed_durability_admission_cannot_leave_a_publishable_segment() { panic!("preparation must be open"); }; let suffix = sha256_hex_suffix(digests[32].as_str()).expect("digest suffix"); - files.directory.path().join(format!("segment-{suffix}.json")) + files + .directory + .path() + .join(format!("segment-{suffix}.json")) }; for _ in 0..2 { let result = preparation.write_segment(SealedGenerationSegmentPublicationV1::File { @@ -1422,7 +1433,11 @@ fn failed_durability_admission_cannot_leave_a_publishable_segment() { panic!("preparation must remain abortable"); }; assert!( - !files.segments.pending.values().any(|path| path == &pending_path), + !files + .segments + .pending + .values() + .any(|path| path == &pending_path), "failed durable admission must not be treated as staged" ); assert_eq!( From a65bea97bd471ea9ee84f46e6a2d7967a8b92780 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:38:25 -0800 Subject: [PATCH 34/93] perf(code-index): authenticate staged bytes without commit opens Use the original read/write staging descriptor to hash new segment bytes. Retain its inode/size/times witness for commit-time verification instead of reopening each staged pathname under endpoint security. An equal-byte replacement must still refuse, with a nonzero real-store regression for open counts, bytes and inode substitution. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 100 +++++++++++++----- .../tests/staged_segment_reads.rs | 77 +++++++++++++- 2 files changed, 150 insertions(+), 27 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index b0a76f8b667..9c20485407e 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -1073,10 +1073,11 @@ struct StagedGenerationSegmentsV1 { durable: DurableFileBatch, /// Temporary path of every written segment, keyed by its final name. pending: BTreeMap, - // Previously authenticated canonical inodes need only an identity check - // at commit; reopening all unchanged segments repeats endpoint scans. + // Canonical files and newly written temporaries are authenticated by + // their original opened handles. Commit rechecks inode identity rather + // than reopening and hashing already authenticated segment pathnames. #[cfg(unix)] - verified_existing: BTreeMap, + authenticated_segments: BTreeMap, flushed: bool, } @@ -1086,7 +1087,7 @@ impl StagedGenerationSegmentsV1 { durable: DurableFileBatch::new(), pending: BTreeMap::new(), #[cfg(unix)] - verified_existing: BTreeMap::new(), + authenticated_segments: BTreeMap::new(), flushed: false, } } @@ -1665,7 +1666,7 @@ impl DaemonCodeIndexPublicationStoreV1 { // The original opened-file witness is authoritative only while its // exact pathname inode and metadata remain unchanged. #[cfg(unix)] - if let Some(original) = staged.verified_existing.get(&final_path) { + if let Some(original) = staged.authenticated_segments.get(&final_path) { let current = final_path.symlink_metadata().map_err(Self::unavailable)?; if !Self::same_unix_segment_identity(original, ¤t) { return Err(Self::corruption( @@ -1693,7 +1694,7 @@ impl DaemonCodeIndexPublicationStoreV1 { "existing sealed segment does not match its content address", )); } - staged.verified_existing.insert(final_path, metadata); + staged.authenticated_segments.insert(final_path, metadata); } #[cfg(not(unix))] if Self::state_digest_file(&final_path)? != expected_digest { @@ -1707,25 +1708,74 @@ impl DaemonCodeIndexPublicationStoreV1 { Err(error) => return Err(Self::unavailable(error)), } let temporary_path = staging_root.join(format!("segment-{digest_hex}.json")); - let mut file = std::fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&temporary_path) - .map_err(Self::unavailable)?; - // The pending index is publication authority. Never enroll a file - // whose write or durability admission failed: a later retry must not - // mistake an unflushed temporary for a successfully staged segment. - let admitted = file - .write_all(bytes) - .and_then(|()| staged.durable.written(&temporary_path, &file)); + let mut options = std::fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + options.read(true); + let mut file = options.open(&temporary_path).map_err(Self::unavailable)?; + // Read the actual staged bytes through their writer descriptor. + // A read and an inode/metadata witness prove the bytes at write time; + // no second endpoint-authorized pathname open is required at commit. + // Non-Unix durability and verification remain unchanged. + let admitted = (|| -> Result, CodeIndexPublicationStoreErrorV1> { + file.write_all(bytes).map_err(Self::unavailable)?; + #[cfg(unix)] + let original = { + let before = file.metadata().map_err(Self::unavailable)?; + file.seek(SeekFrom::Start(0)).map_err(Self::unavailable)?; + if Self::state_digest_reader(&mut file)? != expected_digest { + return Err(Self::corruption( + "newly staged segment differs from its content address", + )); + } + let after = file.metadata().map_err(Self::unavailable)?; + if !Self::same_unix_segment_identity(&before, &after) { + return Err(Self::corruption( + "newly staged segment changed during authentication", + )); + } + after + }; + staged + .durable + .written(&temporary_path, &file) + .map_err(Self::unavailable)?; + #[cfg(unix)] + { + let after = file.metadata().map_err(Self::unavailable)?; + if !Self::same_unix_segment_identity(&original, &after) { + return Err(Self::corruption( + "newly staged segment changed during durability admission", + )); + } + Ok(Some(after)) + } + #[cfg(not(unix))] + { + Ok(None) + } + })(); drop(file); - if let Err(error) = admitted { - std::fs::remove_file(&temporary_path).map_err(|cleanup| { - Self::unavailable(format!( - "staged segment admission failed: {error}; temporary cleanup failed: {cleanup}" - )) - })?; - return Err(Self::unavailable(error)); + // The pending index is publication authority. Never enroll a file + // whose write, content authentication or durability admission failed. + let witness = match admitted { + Ok(witness) => witness, + Err(error) => { + std::fs::remove_file(&temporary_path).map_err(|cleanup| { + Self::unavailable(format!( + "staged segment admission failed: {error}; temporary cleanup failed: {cleanup}" + )) + })?; + return Err(error); + } + }; + if let Some(witness) = witness { + #[cfg(unix)] + staged + .authenticated_segments + .insert(temporary_path.clone(), witness); + #[cfg(not(unix))] + let _ = witness; } staged.pending.insert(final_path, temporary_path); Ok(()) @@ -4039,7 +4089,7 @@ impl DaemonCodeIndexPublicationStoreV1 { )); } #[cfg(unix)] - if let Some(original) = files.segments.verified_existing.get(&canonical) { + if let Some(original) = files.segments.authenticated_segments.get(path) { if !Self::same_unix_segment_identity(original, &metadata) { return Err(Self::corruption( "previously verified sealed segment changed before commit", diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 975bdd15ad9..09e187793d7 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -614,6 +614,79 @@ fn staged_preparation_exact_segment_bytes_commit_and_reload() { ); } +#[test] +fn staged_writer_authentication_avoids_commit_reopens_and_rejects_replaced_inode() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage real generation"); + let identities = CodeIndexPublishedGenerationV1::partitioned_segment_identities_from_reader( + publication.manifest_bytes().as_ref(), + ) + .expect("parse staged segment identities") + .expect("partitioned generation"); + let identity = identities + .into_iter() + .find(|identity| identity.size_bytes > 0) + .expect("nonempty generation segment"); + let path = staged_path(&preparation, &identity.digest); + let bytes = std::fs::read(&path).expect("real staged segment"); + assert!(!bytes.is_empty(), "the test must authenticate real segment bytes"); + assert_eq!(segment_digest(&bytes), identity.digest); + let before = PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get); + { + let state = preparation.state.lock().expect("preparation state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("preparation must remain open"); + }; + assert!(!files.segments.pending.is_empty(), "real files must be staged"); + assert!( + files.segments.authenticated_segments.contains_key(&path), + "new staged bytes must be verified through their original writer" + ); + target + .verify_prepared_segments(files, publication.manifest_bytes().as_ref()) + .expect("authenticate exact pending generation without reopening"); + } + assert_eq!( + PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get), + before, + "commit verification must not reopen authenticated staged segments" + ); + + // Digest equality alone cannot authorize a replacement of the original + // writer's inode, even when the replacement has byte-identical content. + let replacement = path.with_extension("replacement.tmp"); + std::fs::write(&replacement, &bytes).expect("make equal-byte replacement"); + std::fs::rename(&replacement, &path).expect("replace staged inode"); + let before_refusal = PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get); + { + let state = preparation.state.lock().expect("preparation state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("preparation must remain open"); + }; + assert!( + target + .verify_prepared_segments(files, publication.manifest_bytes().as_ref()) + .is_err(), + "replacing a staged inode must fail even if its bytes are identical" + ); + } + assert_eq!( + PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get), + before_refusal, + "identity rejection must not reopen a substituted staged inode" + ); + preparation.abort().expect("abort replaced staged generation"); +} + #[test] fn committed_whole_segment_read_refuses_same_length_corruption() { let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); @@ -923,7 +996,7 @@ fn repeated_pending_segment_accepts_identical_concurrent_canonical_publication() "this preparation still owns its original temporary bytes" ); assert!( - !files.segments.verified_existing.contains_key(&canonical), + !files.segments.authenticated_segments.contains_key(&canonical), "the concurrently named inode must not masquerade as our pending inode" ); target @@ -1225,7 +1298,7 @@ fn staged_existing_segments_reuse_authenticated_inodes_without_reopening() { }; let path = files .segments - .verified_existing + .authenticated_segments .keys() .next() .expect("previously verified canonical segment") From 2200bd0cb12aa089f59634bd3b1c9366830ab4d0 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 07:27:06 -0800 Subject: [PATCH 35/93] style(code-index): match hosted Bazel Rust formatting --- .../code_index_scheduler/publication_store.rs | 73 ++++++++++--------- .../tests/staged_segment_reads.rs | 19 ++++- 2 files changed, 52 insertions(+), 40 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 9c20485407e..ebe4768820f 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -1717,44 +1717,45 @@ impl DaemonCodeIndexPublicationStoreV1 { // A read and an inode/metadata witness prove the bytes at write time; // no second endpoint-authorized pathname open is required at commit. // Non-Unix durability and verification remain unchanged. - let admitted = (|| -> Result, CodeIndexPublicationStoreErrorV1> { - file.write_all(bytes).map_err(Self::unavailable)?; - #[cfg(unix)] - let original = { - let before = file.metadata().map_err(Self::unavailable)?; - file.seek(SeekFrom::Start(0)).map_err(Self::unavailable)?; - if Self::state_digest_reader(&mut file)? != expected_digest { - return Err(Self::corruption( - "newly staged segment differs from its content address", - )); - } - let after = file.metadata().map_err(Self::unavailable)?; - if !Self::same_unix_segment_identity(&before, &after) { - return Err(Self::corruption( - "newly staged segment changed during authentication", - )); + let admitted = + (|| -> Result, CodeIndexPublicationStoreErrorV1> { + file.write_all(bytes).map_err(Self::unavailable)?; + #[cfg(unix)] + let original = { + let before = file.metadata().map_err(Self::unavailable)?; + file.seek(SeekFrom::Start(0)).map_err(Self::unavailable)?; + if Self::state_digest_reader(&mut file)? != expected_digest { + return Err(Self::corruption( + "newly staged segment differs from its content address", + )); + } + let after = file.metadata().map_err(Self::unavailable)?; + if !Self::same_unix_segment_identity(&before, &after) { + return Err(Self::corruption( + "newly staged segment changed during authentication", + )); + } + after + }; + staged + .durable + .written(&temporary_path, &file) + .map_err(Self::unavailable)?; + #[cfg(unix)] + { + let after = file.metadata().map_err(Self::unavailable)?; + if !Self::same_unix_segment_identity(&original, &after) { + return Err(Self::corruption( + "newly staged segment changed during durability admission", + )); + } + Ok(Some(after)) } - after - }; - staged - .durable - .written(&temporary_path, &file) - .map_err(Self::unavailable)?; - #[cfg(unix)] - { - let after = file.metadata().map_err(Self::unavailable)?; - if !Self::same_unix_segment_identity(&original, &after) { - return Err(Self::corruption( - "newly staged segment changed during durability admission", - )); + #[cfg(not(unix))] + { + Ok(None) } - Ok(Some(after)) - } - #[cfg(not(unix))] - { - Ok(None) - } - })(); + })(); drop(file); // The pending index is publication authority. Never enroll a file // whose write, content authentication or durability admission failed. diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 09e187793d7..745889b3352 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -638,7 +638,10 @@ fn staged_writer_authentication_avoids_commit_reopens_and_rejects_replaced_inode .expect("nonempty generation segment"); let path = staged_path(&preparation, &identity.digest); let bytes = std::fs::read(&path).expect("real staged segment"); - assert!(!bytes.is_empty(), "the test must authenticate real segment bytes"); + assert!( + !bytes.is_empty(), + "the test must authenticate real segment bytes" + ); assert_eq!(segment_digest(&bytes), identity.digest); let before = PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get); { @@ -646,7 +649,10 @@ fn staged_writer_authentication_avoids_commit_reopens_and_rejects_replaced_inode let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { panic!("preparation must remain open"); }; - assert!(!files.segments.pending.is_empty(), "real files must be staged"); + assert!( + !files.segments.pending.is_empty(), + "real files must be staged" + ); assert!( files.segments.authenticated_segments.contains_key(&path), "new staged bytes must be verified through their original writer" @@ -684,7 +690,9 @@ fn staged_writer_authentication_avoids_commit_reopens_and_rejects_replaced_inode before_refusal, "identity rejection must not reopen a substituted staged inode" ); - preparation.abort().expect("abort replaced staged generation"); + preparation + .abort() + .expect("abort replaced staged generation"); } #[test] @@ -996,7 +1004,10 @@ fn repeated_pending_segment_accepts_identical_concurrent_canonical_publication() "this preparation still owns its original temporary bytes" ); assert!( - !files.segments.authenticated_segments.contains_key(&canonical), + !files + .segments + .authenticated_segments + .contains_key(&canonical), "the concurrently named inode must not masquerade as our pending inode" ); target From 9c08e11e418ac6bf5b31e7328131eea7384cb202 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 07:41:49 -0800 Subject: [PATCH 36/93] fix(code-index): bind batch admission to authenticated writer identity --- .../code_index_scheduler/publication_store.rs | 13 +++- .../tracedecay-private-fs/src/framed_log.rs | 22 ++++++- .../src/framed_log_batch_tests.rs | 63 +++++++++++++++++++ 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index ebe4768820f..b163a74b2ed 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -1737,11 +1737,20 @@ impl DaemonCodeIndexPublicationStoreV1 { } after }; + #[cfg(not(unix))] + let original = file.metadata().map_err(Self::unavailable)?; staged .durable - .written(&temporary_path, &file) + .written_with_identity(&temporary_path, &file, &original) .map_err(Self::unavailable)?; - #[cfg(unix)] + // On Linux the batch performed its final identity checks + // before enrolling the member. No fallible post-admission + // check may remove its pathname while retaining its handle. + #[cfg(target_os = "linux")] + { + Ok(Some(original)) + } + #[cfg(all(unix, not(target_os = "linux")))] { let after = file.metadata().map_err(Self::unavailable)?; if !Self::same_unix_segment_identity(&original, &after) { diff --git a/crates/tracedecay-private-fs/src/framed_log.rs b/crates/tracedecay-private-fs/src/framed_log.rs index 16fc428b2c6..b9703c5fd64 100644 --- a/crates/tracedecay-private-fs/src/framed_log.rs +++ b/crates/tracedecay-private-fs/src/framed_log.rs @@ -150,12 +150,26 @@ impl DurableFileBatch { /// Record one fully written member file, created at `path`. pub fn written(&mut self, path: &Path, file: &File) -> io::Result<()> { + let observed = file.metadata()?; + self.written_with_identity(path, file, &observed) + } + + /// Record the exact inode and metadata that the caller already authenticated + /// through its writer. A changed member must fail before admission, not + /// leave a stale handle in a later durability window. + pub fn written_with_identity( + &mut self, + path: &Path, + file: &File, + authenticated: &fs::Metadata, + ) -> io::Result<()> { #[cfg(target_os = "linux")] { let identity = DurableFileIdentity { path: path.to_path_buf(), - metadata: file.metadata()?, + metadata: authenticated.clone(), }; + identity.validate_metadata(&file.metadata()?)?; // An invalid incoming member must not force a full, otherwise // healthy window to fsync before its admission is rejected. identity.validate_path()?; @@ -179,6 +193,11 @@ impl DurableFileBatch { if started != 0 { return Err(io::Error::last_os_error()); } + // This is the last fallible check before admission. There must + // be no caller-side validation that could delete a registered + // member after a successful return. + identity.validate_metadata(&retained.metadata()?)?; + identity.validate_path()?; self.pending.push(PendingDurableFile { identity, file: retained, @@ -187,6 +206,7 @@ impl DurableFileBatch { } #[cfg(not(target_os = "linux"))] { + let _ = authenticated; sync_owned_file(path, file) } } diff --git a/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs b/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs index ff26883e64d..db6c058d73e 100644 --- a/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs +++ b/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs @@ -237,6 +237,69 @@ fn durable_batch_refuses_same_inode_mutation_after_early_flush() { ); } +#[test] +fn durable_batch_refuses_a_changed_authenticated_writer_before_admission_and_recovers() { + let root = tempfile::tempdir().expect("batch root"); + let mut batch = DurableFileBatch::new(); + for index in 0..DURABLE_BATCH_CONCURRENT_SYNCS { + write_member(&mut batch, root.path(), index); + } + + let next = root.path().join("next.tmp"); + let mut writer = File::create(&next).expect("candidate writer"); + writer.write_all(b"next segment").expect("candidate bytes"); + let authenticated = writer.metadata().expect("authenticated inode"); + writer + .set_modified(authenticated.modified().expect("mtime") + Duration::from_secs(2)) + .expect("change metadata after authentication"); + #[cfg(feature = "test-helpers")] + let barriers = sync_latency::inject(root.path(), Duration::ZERO); + + assert_eq!( + batch + .written_with_identity(&next, &writer, &authenticated) + .expect_err("stale authentication must refuse admission") + .kind(), + ErrorKind::InvalidData + ); + #[cfg(feature = "test-helpers")] + assert_eq!( + barriers.syncs(), + 0, + "invalid next member must not flush the healthy 32-member window" + ); + assert_eq!(batch.pending.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + assert_eq!(batch.synced.len(), 0); + drop(writer); + fs::remove_file(&next).expect("discard rejected temporary"); + + // A new writer for the same name can be admitted because the rejected + // inode was never registered in the batch. + let mut retry = File::create(&next).expect("retry writer"); + retry.write_all(b"next segment").expect("retry bytes"); + let retry_identity = retry.metadata().expect("retry identity"); + batch + .written_with_identity(&next, &retry, &retry_identity) + .expect("admit the replacement writer"); + #[cfg(feature = "test-helpers")] + assert_eq!( + barriers.syncs(), + u64::try_from(DURABLE_BATCH_CONCURRENT_SYNCS).expect("window size"), + "the 32 accepted members need real barriers before their handles release" + ); + assert_eq!(batch.pending.len(), 1); + assert_eq!(batch.synced.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + drop(retry); + batch.sync().expect("flush the replacement member"); + #[cfg(feature = "test-helpers")] + assert_eq!( + barriers.syncs(), + u64::try_from(DURABLE_BATCH_CONCURRENT_SYNCS + 1).expect("total count"), + "retry must not skip or duplicate durability barriers" + ); + assert_eq!(fs::read(&next).expect("read back retry"), b"next segment"); +} + #[cfg(feature = "test-helpers")] #[test] fn durable_batch_invalid_next_member_does_not_flush_a_full_window() { From badb863b0228bbc6b5752cb279448eb973085f00 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:26:35 -0800 Subject: [PATCH 37/93] perf(code-index): validate active pointer through one open --- .../code_index_scheduler/publication_store.rs | 45 ++++++++++- .../tests/staged_segment_reads.rs | 81 +++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index b163a74b2ed..8f610d074d6 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -1962,6 +1962,27 @@ impl DaemonCodeIndexPublicationStoreV1 { // a fixed-width pointer through another path, and a 1-second mtime // filesystem can leave both unchanged while the bytes move. The memo // is reused only when the file digest matches. + // A cold pointer validation must not authorize a second pathname + // open merely to populate the memo. Keep the original descriptor + // through parsing and authenticate its bytes again before memoizing. + #[cfg(unix)] + let (bytes, mut opened_pointer) = { + let mut file = File::open(&self.active_path).map_err(Self::map_pointer_io)?; + let mut bytes = Vec::new(); + (&mut file) + .take(MAX_DURABLE_PUBLICATION_POINTER_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(Self::map_pointer_io)?; + if u64::try_from(bytes.len()).map_err(Self::unavailable)? + > MAX_DURABLE_PUBLICATION_POINTER_BYTES + { + return Err(Self::corruption( + "durable code-generation index exceeds its byte bound", + )); + } + (bytes, file) + }; + #[cfg(not(unix))] let bytes = std::fs::read(&self.active_path).map_err(Self::map_pointer_io)?; let digest = Self::state_digest(&bytes); { @@ -2092,7 +2113,29 @@ impl DaemonCodeIndexPublicationStoreV1 { .unwrap_or_else(PoisonError::into_inner); // Install only when the file is still the bytes just parsed. A rename // that landed during validation owns the memo. - if std::fs::read(&self.active_path).ok().as_deref() == Some(bytes.as_slice()) { + // On Unix, re-read through the authenticated descriptor rather than + // opening the same endpoint-scanned pathname again. The two inode + // witnesses reject a concurrent rename; the second byte comparison + // rejects an in-place change during parsing, even on coarse clocks. + #[cfg(unix)] + let unchanged = (|| -> std::io::Result { + let opened_before = opened_pointer.metadata()?; + opened_pointer.seek(SeekFrom::Start(0))?; + let mut current = Vec::new(); + (&mut opened_pointer) + .take(MAX_DURABLE_PUBLICATION_POINTER_BYTES + 1) + .read_to_end(&mut current)?; + let opened_after = opened_pointer.metadata()?; + let named_after = std::fs::metadata(&self.active_path)?; + Ok(current == bytes + && Self::same_unix_segment_identity(&metadata, &opened_before) + && Self::same_unix_segment_identity(&opened_before, &opened_after) + && Self::same_unix_segment_identity(&opened_before, &named_after)) + })() + .unwrap_or(false); + #[cfg(not(unix))] + let unchanged = std::fs::read(&self.active_path).ok().as_deref() == Some(bytes.as_slice()); + if unchanged { *memo = Some(PublicationPointerMemoV1 { mtime, size, diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 745889b3352..dfec10d52ea 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -1533,3 +1533,84 @@ fn failed_durability_admission_cannot_leave_a_publishable_segment() { preparation.abort().expect("abort failed durability window"); assert!(!pending_path.exists()); } + +#[cfg(target_os = "linux")] +fn observed_pointer_opens( + events: &std::sync::mpsc::Receiver>, +) -> usize { + let mut opens = 0; + // Inotify delivers access events asynchronously through notify. Drain to + // quiescence rather than observing an arbitrary instant after the call. + while let Ok(event) = events.recv_timeout(Duration::from_millis(250)) { + let event = event.expect("kernel file watch event"); + if matches!( + event.kind, + notify::EventKind::Access(notify::event::AccessKind::Open(_)) + ) { + opens += 1; + } + } + opens +} + +#[cfg(target_os = "linux")] +#[test] +fn cold_publication_pointer_validation_uses_one_kernel_open_and_recovers_snapshot() { + use notify::Watcher; + + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut publisher = target_store(target_root.path(), &fixture); + let mut preparation = publisher + .prepare(&generation.sealed_scope(), None) + .expect("prepare published generation"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage source snapshot"); + publisher + .commit(&mut preparation, &publication) + .expect("commit real generation"); + drop(preparation); + drop(publisher); + + let cold = target_store(target_root.path(), &fixture); + let (sender, receiver) = std::sync::mpsc::channel(); + let mut watcher = notify::recommended_watcher( + move |event: notify::Result| { + let _ = sender.send(event); + }, + ) + .expect("native file observer"); + watcher + .watch(&cold.active_path, notify::RecursiveMode::NonRecursive) + .expect("watch the exact publication pointer"); + drop(File::open(&cold.active_path).expect("nonzero control open")); + assert_eq!( + observed_pointer_opens(&receiver), + 1, + "the observer must report a real kernel pathname open" + ); + + let pointer = cold + .read_publication_pointer() + .expect("cold pointer validation") + .expect("published pointer"); + assert_eq!(pointer.generation_id, generation.manifest().generation_id.as_str()); + assert_eq!( + observed_pointer_opens(&receiver), + 1, + "parse and post-parse byte revalidation must share one file open" + ); + + let reloaded = cold + .load_active_shared() + .expect("read committed snapshot") + .expect("published generation"); + assert_eq!( + reloaded.snapshot().content_identity, + generation.snapshot().content_identity, + "one-open validation must not alter the durable snapshot" + ); +} From e7ab03f7b5055c7246ffc30e51e8e5e5b60bc47a Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:31:50 -0800 Subject: [PATCH 38/93] style(code-index): normalize pointer open regression formatting --- .../code_index_scheduler/tests/staged_segment_reads.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index dfec10d52ea..c264ea0e3ca 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -1577,12 +1577,11 @@ fn cold_publication_pointer_validation_uses_one_kernel_open_and_recovers_snapsho let cold = target_store(target_root.path(), &fixture); let (sender, receiver) = std::sync::mpsc::channel(); - let mut watcher = notify::recommended_watcher( - move |event: notify::Result| { + let mut watcher = + notify::recommended_watcher(move |event: notify::Result| { let _ = sender.send(event); - }, - ) - .expect("native file observer"); + }) + .expect("native file observer"); watcher .watch(&cold.active_path, notify::RecursiveMode::NonRecursive) .expect("watch the exact publication pointer"); From da385bcd89f73fd1859ad6a5ac4448cebc9a7232 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:53:01 -0800 Subject: [PATCH 39/93] perf(code-index): reuse published pointer writer for memo validation --- .../code_index_scheduler/publication_store.rs | 66 ++++++++++---- .../tests/staged_segment_reads.rs | 87 +++++++++++++++++++ 2 files changed, 135 insertions(+), 18 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 8f610d074d6..5660795c65c 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -2146,27 +2146,57 @@ impl DaemonCodeIndexPublicationStoreV1 { Ok(Some(pointer)) } - fn remember_publication_pointer(&self, pointer: &DurablePublicationPointerV1, bytes: &[u8]) { + fn remember_publication_pointer( + &self, + pointer: &DurablePublicationPointerV1, + bytes: &[u8], + persisted: &mut File, + ) { let mut memo = self .pointer_memo .lock() .unwrap_or_else(PoisonError::into_inner); - // The memo and the file it names are one critical section. A publisher - // that observed older bytes must not install them over a newer file. - match std::fs::read(&self.active_path) { - Ok(current) if current == bytes => { - let metadata = std::fs::metadata(&self.active_path).ok(); - *memo = Some(PublicationPointerMemoV1 { - mtime: metadata - .as_ref() - .and_then(|metadata| metadata.modified().ok()), - size: metadata.map_or(0, |metadata| metadata.len()), - digest: Self::state_digest(bytes), - pointer: pointer.clone(), - }); + // The tempfile descriptor survives its atomic rename. Reopen-free + // readback authenticates that exact inode, while pathname metadata + // prevents memoizing a pointer replaced by another writer. + #[cfg(unix)] + let metadata = (|| -> std::io::Result> { + let opened = persisted.metadata()?; + let named_before = self.active_path.symlink_metadata()?; + if !Self::same_unix_segment_identity(&opened, &named_before) { + return Ok(None); } - Ok(_) => {} - Err(_) => *memo = None, + persisted.seek(SeekFrom::Start(0))?; + let mut current = Vec::new(); + (&mut *persisted) + .take(MAX_DURABLE_PUBLICATION_POINTER_BYTES + 1) + .read_to_end(&mut current)?; + let after = persisted.metadata()?; + let named_after = self.active_path.symlink_metadata()?; + Ok((current == bytes + && Self::same_unix_segment_identity(&opened, &after) + && Self::same_unix_segment_identity(&opened, &named_after)) + .then_some(named_after)) + })() + .ok() + .flatten(); + #[cfg(not(unix))] + let metadata = match std::fs::read(&self.active_path) { + Ok(current) if current == bytes => std::fs::metadata(&self.active_path).ok(), + _ => None, + }; + #[cfg(not(unix))] + let _ = persisted; + if let Some(metadata) = metadata { + *memo = Some(PublicationPointerMemoV1 { + mtime: metadata.modified().ok(), + size: metadata.len(), + digest: Self::state_digest(bytes), + pointer: pointer.clone(), + }); + } else { + // Never preserve an older memo after an unverified replacement. + *memo = None; } } @@ -2213,7 +2243,7 @@ impl DaemonCodeIndexPublicationStoreV1 { .map_err(Self::unavailable)?; temporary.write_all(bytes).map_err(Self::unavailable)?; temporary.as_file().sync_all().map_err(Self::unavailable)?; - temporary + let mut persisted = temporary .persist(&self.active_path) .map_err(|error| Self::map_pointer_io(error.error))?; Self::sync_directory( @@ -2221,7 +2251,7 @@ impl DaemonCodeIndexPublicationStoreV1 { .parent() .ok_or_else(|| Self::unavailable("active pointer has no parent directory"))?, )?; - self.remember_publication_pointer(pointer, bytes); + self.remember_publication_pointer(pointer, bytes, &mut persisted); Ok(()) } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index c264ea0e3ca..e8505977420 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -1613,3 +1613,90 @@ fn cold_publication_pointer_validation_uses_one_kernel_open_and_recovers_snapsho "one-open validation must not alter the durable snapshot" ); } + +#[cfg(target_os = "linux")] +fn observed_named_opens( + events: &std::sync::mpsc::Receiver>, + path: &Path, +) -> usize { + let mut opens = 0; + while let Ok(event) = events.recv_timeout(Duration::from_millis(250)) { + let event = event.expect("kernel file watch event"); + if event.paths.iter().any(|candidate| candidate == path) + && matches!( + event.kind, + notify::EventKind::Access(notify::event::AccessKind::Open(_)) + ) + { + opens += 1; + } + } + opens +} + +#[cfg(target_os = "linux")] +#[test] +fn initial_pointer_publication_reuses_persisted_descriptor_without_pathname_open() { + use notify::Watcher; + + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 2 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut publisher = target_store(target_root.path(), &fixture); + let active_path = publisher.active_path.clone(); + + // Watch the parent: the final pathname does not exist until the first + // publication rename, so it cannot be watched directly beforehand. + let (sender, receiver) = std::sync::mpsc::channel(); + let mut watcher = + notify::recommended_watcher(move |event: notify::Result| { + let _ = sender.send(event); + }) + .expect("native file observer"); + watcher + .watch( + active_path.parent().expect("pointer parent"), + notify::RecursiveMode::NonRecursive, + ) + .expect("watch publication directory"); + + let mut preparation = publisher + .prepare(&generation.sealed_scope(), None) + .expect("prepare first generation"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage source snapshot"); + publisher + .commit(&mut preparation, &publication) + .expect("publish first generation"); + + assert!(active_path.is_file(), "the committed pointer must exist"); + assert_eq!( + observed_named_opens(&receiver, &active_path), + 0, + "memo authentication must reuse the persisted writer, not reopen the pointer" + ); + drop(preparation); + drop(publisher); + + // Non-vacuous kernel-observer control: the same directory watch must + // record a real pathname open after the publication. + drop(File::open(&active_path).expect("control open of persisted pointer")); + assert_eq!( + observed_named_opens(&receiver, &active_path), + 1, + "watcher must observe a real open of the committed pointer" + ); + + let cold = target_store(target_root.path(), &fixture); + let reloaded = cold + .load_active_shared() + .expect("load persisted generation") + .expect("published generation"); + assert_eq!( + reloaded.snapshot().content_identity, + generation.snapshot().content_identity, + "open-free memo population must preserve cold snapshot identity" + ); +} From ed1786ba8f578eb403b509b7e841c310ec702c21 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:25:01 -0800 Subject: [PATCH 40/93] style(code-index): align staged publication with rustfmt --- .../code_index_scheduler/publication_store.rs | 2 +- .../tests/staged_segment_reads.rs | 23 ++++++++++--------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 5660795c65c..5d804253bf2 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -2176,7 +2176,7 @@ impl DaemonCodeIndexPublicationStoreV1 { Ok((current == bytes && Self::same_unix_segment_identity(&opened, &after) && Self::same_unix_segment_identity(&opened, &named_after)) - .then_some(named_after)) + .then_some(named_after)) })() .ok() .flatten(); diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index e8505977420..ab3dcf6c9cf 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -1577,11 +1577,10 @@ fn cold_publication_pointer_validation_uses_one_kernel_open_and_recovers_snapsho let cold = target_store(target_root.path(), &fixture); let (sender, receiver) = std::sync::mpsc::channel(); - let mut watcher = - notify::recommended_watcher(move |event: notify::Result| { - let _ = sender.send(event); - }) - .expect("native file observer"); + let mut watcher = notify::recommended_watcher(move |event: notify::Result| { + let _ = sender.send(event); + }) + .expect("native file observer"); watcher .watch(&cold.active_path, notify::RecursiveMode::NonRecursive) .expect("watch the exact publication pointer"); @@ -1596,7 +1595,10 @@ fn cold_publication_pointer_validation_uses_one_kernel_open_and_recovers_snapsho .read_publication_pointer() .expect("cold pointer validation") .expect("published pointer"); - assert_eq!(pointer.generation_id, generation.manifest().generation_id.as_str()); + assert_eq!( + pointer.generation_id, + generation.manifest().generation_id.as_str() + ); assert_eq!( observed_pointer_opens(&receiver), 1, @@ -1649,11 +1651,10 @@ fn initial_pointer_publication_reuses_persisted_descriptor_without_pathname_open // Watch the parent: the final pathname does not exist until the first // publication rename, so it cannot be watched directly beforehand. let (sender, receiver) = std::sync::mpsc::channel(); - let mut watcher = - notify::recommended_watcher(move |event: notify::Result| { - let _ = sender.send(event); - }) - .expect("native file observer"); + let mut watcher = notify::recommended_watcher(move |event: notify::Result| { + let _ = sender.send(event); + }) + .expect("native file observer"); watcher .watch( active_path.parent().expect("pointer parent"), From c85174247f09292505e4b7ace613e86209f3a445 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:00:17 -0800 Subject: [PATCH 41/93] fix(code-index): retain digest for generation collision regression --- .../src/code_index_scheduler/tests/staged_segment_reads.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index ab3dcf6c9cf..a8909525509 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -1068,7 +1068,8 @@ fn generation_manifest_publication_preserves_concurrent_canonical_bytes() { let good = b"generation-one"; let other = b"generation-two"; assert_eq!(good.len(), other.len()); - let suffix = sha256_hex_suffix(segment_digest(good).as_str()).expect("generation suffix"); + let digest = segment_digest(good); + let suffix = sha256_hex_suffix(digest.as_str()).expect("generation suffix"); let canonical = root.path().join(format!("generation-{suffix}.json")); let stage = |bytes: &[u8]| { let mut temporary = tempfile::Builder::new() From 1bcaf00eb2c9797eaef741aa96ba6b22146e31e3 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:45:23 -0800 Subject: [PATCH 42/93] fix(code-index): authenticate evidence packs through writer handles --- .../code_index_scheduler/publication_store.rs | 96 +++++++++++++++++-- 1 file changed, 90 insertions(+), 6 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 5d804253bf2..e420857b71d 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -819,6 +819,10 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { .insert(self.segments_root.join(name), path); files.evidence.attach_to_manifest(); } + #[cfg(unix)] + if let Some((path, identity)) = files.evidence.authenticated.take() { + files.segments.authenticated_segments.insert(path, identity); + } files.referenced_segment_bytes = files .referenced_segment_bytes .saturating_add(segment_size_bytes); @@ -1064,6 +1068,8 @@ pub(super) struct TemporaryEvidencePackV1 { page_count: u32, committed: bool, published_path: Option, + #[cfg(unix)] + authenticated: Option<(PathBuf, std::fs::Metadata)>, } /// One seal's new file segments. Each keeps a temporary name until the whole @@ -1207,9 +1213,11 @@ impl PinnedPreparationSegmentV1 { impl TemporaryEvidencePackV1 { pub(super) fn create(path: PathBuf) -> Result { - let file = std::fs::OpenOptions::new() - .create_new(true) - .write(true) + let mut options = std::fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + options.read(true); + let file = options .open(&path) .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; Ok(Self { @@ -1220,6 +1228,8 @@ impl TemporaryEvidencePackV1 { page_count: 0, committed: false, published_path: None, + #[cfg(unix)] + authenticated: None, }) } @@ -1296,15 +1306,27 @@ impl TemporaryEvidencePackV1 { let final_path = root.join(format!("segment-{digest_hex}.json")); match final_path.symlink_metadata() { Ok(metadata) => { + #[cfg(unix)] + let actual = DaemonCodeIndexPublicationStoreV1::state_digest_existing_segment( + &final_path, + &metadata, + )?; + #[cfg(not(unix))] + let actual = DaemonCodeIndexPublicationStoreV1::state_digest_file(&final_path)?; if !metadata.file_type().is_file() || metadata.len() != segment_size_bytes - || DaemonCodeIndexPublicationStoreV1::state_digest_file(&final_path)? - != segment_digest.as_str() + || actual != segment_digest.as_str() { return Err(DaemonCodeIndexPublicationStoreV1::corruption( "existing sealed evidence pack does not match its content address", )); } + // The same opened inode verified by SHA-256 is authoritative + // for this preparation's subsequent manifest verification. + #[cfg(unix)] + { + self.authenticated = Some((final_path, metadata)); + } drop(self.file.take()); std::fs::remove_file(&self.path) .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; @@ -1323,11 +1345,73 @@ impl TemporaryEvidencePackV1 { })? .sync_all() .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + // On Unix, keep the original writer descriptor through the rename + // and hash the actual published bytes. Opening the pack by pathname + // again would trigger endpoint authorization for no additional proof. + #[cfg(unix)] + let before_rename = self + .file + .as_ref() + .ok_or_else(|| { + DaemonCodeIndexPublicationStoreV1::unavailable( + "sealed evidence pack writer is already closed", + ) + })? + .metadata() + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + #[cfg(not(unix))] drop(self.file.take()); let staged_path = staging_root.join(format!("segment-{digest_hex}.json")); std::fs::rename(&self.path, &staged_path) .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; - self.published_path = Some(staged_path); + self.published_path = Some(staged_path.clone()); + #[cfg(unix)] + { + let file = self.file.as_mut().ok_or_else(|| { + DaemonCodeIndexPublicationStoreV1::unavailable( + "sealed evidence pack writer is already closed", + ) + })?; + let opened = file + .metadata() + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + // Rename may change ctime, but must not change the writer inode. + if !opened.is_file() + || opened.dev() != before_rename.dev() + || opened.ino() != before_rename.ino() + || opened.len() != before_rename.len() + || opened.mtime() != before_rename.mtime() + || opened.mtime_nsec() != before_rename.mtime_nsec() + { + return Err(DaemonCodeIndexPublicationStoreV1::corruption( + "sealed evidence pack writer changed during staging", + )); + } + file.seek(SeekFrom::Start(0)) + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + if DaemonCodeIndexPublicationStoreV1::state_digest_reader(file)? + != segment_digest.as_str() + { + return Err(DaemonCodeIndexPublicationStoreV1::corruption( + "staged evidence pack bytes differ from the authenticated content address", + )); + } + let after = file + .metadata() + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + let named = staged_path + .symlink_metadata() + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + if !DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity(&opened, &after) + || !DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity(&after, &named) + { + return Err(DaemonCodeIndexPublicationStoreV1::corruption( + "sealed evidence pack changed during writer authentication", + )); + } + self.authenticated = Some((staged_path, after)); + } + drop(self.file.take()); DaemonCodeIndexPublicationStoreV1::sync_directory(staging_root)?; Ok(true) } From 402923fdcecb88c40c1729cec7acb2306e1ed433 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:46:10 -0800 Subject: [PATCH 43/93] test(code-index): witness evidence pack writer identity --- .../tests/staged_segment_reads.rs | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index a8909525509..347df922d46 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -1702,3 +1702,88 @@ fn initial_pointer_publication_reuses_persisted_descriptor_without_pathname_open "open-free memo population must preserve cold snapshot identity" ); } + +#[test] +fn staged_evidence_pack_writer_authentication_avoids_extra_opens_and_refuses_replacement() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn evidence() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let bytes = b"nonempty evidence pack data, authenticated after the staging rename"; + let digest = segment_digest(bytes); + preparation + .write_segment(SealedGenerationSegmentPublicationV1::GenerationEvidencePage { + page_ordinal: 0, + page_digest: &digest, + bytes, + }) + .expect("append a real evidence pack page"); + let before = PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get); + preparation + .write_segment(SealedGenerationSegmentPublicationV1::GenerationEvidenceCommit { + segment_digest: &digest, + segment_size_bytes: bytes.len() as u64, + page_count: 1, + }) + .expect("flush and stage evidence pack"); + let (path, witness) = { + let state = preparation.state.lock().expect("preparation state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &*state else { + panic!("evidence preparation must remain open"); + }; + let path = files + .evidence + .published_path + .as_ref() + .expect("nonempty evidence pack was staged") + .clone(); + let witness = files + .segments + .authenticated_segments + .get(&path) + .expect("the evidence pack must retain its writer's authenticated inode") + .clone(); + assert!( + files.segments.pending.values().any(|pending| pending == &path), + "the authenticated file must still be pending publication" + ); + (path, witness) + }; + assert_eq!(std::fs::read(&path).expect("staged evidence bytes"), bytes); + assert_eq!(segment_digest(bytes), digest); + assert_eq!( + PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get), + before, + "staging must authenticate through the writer, not reopen the evidence pack" + ); + + // Prove the instrumented counter would observe a real digest pathname open. + assert_eq!( + DaemonCodeIndexPublicationStoreV1::state_digest_existing_segment( + &path, + &path.symlink_metadata().expect("original staged inode"), + ) + .expect("positive digest-open control"), + digest.as_str() + ); + assert_eq!( + PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get), + before + 1, + "positive control must observe one actual pathname open" + ); + let replacement = path.with_extension("evidence-replacement.tmp"); + std::fs::write(&replacement, bytes).expect("write equal-byte different inode"); + std::fs::rename(&replacement, &path).expect("replace staged inode"); + assert!( + !DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity( + &witness, + &path.symlink_metadata().expect("replacement inode"), + ), + "equal bytes cannot reuse an authenticated inode witness" + ); + preparation.abort().expect("release failed preparation"); +} From 8dda2ad5f36bd2536f04bac8dcf86f398de6efc6 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:47:08 -0800 Subject: [PATCH 44/93] fix(code-index): validate evidence pack file kind before digest --- .../src/code_index_scheduler/publication_store.rs | 12 ++++++++---- .../tests/staged_segment_reads.rs | 5 ++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index e420857b71d..1ac64593d6f 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -1306,6 +1306,13 @@ impl TemporaryEvidencePackV1 { let final_path = root.join(format!("segment-{digest_hex}.json")); match final_path.symlink_metadata() { Ok(metadata) => { + // Reject non-files and mismatched extents before opening the + // canonical name: a FIFO must never block digest verification. + if !metadata.file_type().is_file() || metadata.len() != segment_size_bytes { + return Err(DaemonCodeIndexPublicationStoreV1::corruption( + "existing sealed evidence pack does not match its content address", + )); + } #[cfg(unix)] let actual = DaemonCodeIndexPublicationStoreV1::state_digest_existing_segment( &final_path, @@ -1313,10 +1320,7 @@ impl TemporaryEvidencePackV1 { )?; #[cfg(not(unix))] let actual = DaemonCodeIndexPublicationStoreV1::state_digest_file(&final_path)?; - if !metadata.file_type().is_file() - || metadata.len() != segment_size_bytes - || actual != segment_digest.as_str() - { + if actual != segment_digest.as_str() { return Err(DaemonCodeIndexPublicationStoreV1::corruption( "existing sealed evidence pack does not match its content address", )); diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 347df922d46..01b013c8126 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -1753,7 +1753,10 @@ fn staged_evidence_pack_writer_authentication_avoids_extra_opens_and_refuses_rep ); (path, witness) }; - assert_eq!(std::fs::read(&path).expect("staged evidence bytes"), bytes); + assert_eq!( + std::fs::read(&path).expect("staged evidence bytes").as_slice(), + &bytes[..] + ); assert_eq!(segment_digest(bytes), digest); assert_eq!( PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get), From 96daef59432c7ffca73800f8efd7092f5939de25 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:48:17 -0800 Subject: [PATCH 45/93] test(code-index): exercise publication retries under a shared lease --- .../tests/staged_segment_reads.rs | 40 ++++++++++++------- .../tests/code_index_scheduler/reconcile.rs | 23 +++++------ 2 files changed, 35 insertions(+), 28 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 01b013c8126..00eee98a014 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -1323,9 +1323,9 @@ fn staged_existing_segments_reuse_authenticated_inodes_without_reopening() { let warm_opens = PREPARED_SEGMENT_DIGEST_OPENS .with(std::cell::Cell::get) .saturating_sub(before); - assert!( - warm_opens < full_opens, - "staged existing segments must not be opened again during verification" + assert_eq!( + warm_opens, 0, + "the pack and all other authenticated segments must stay closed during verification" ); // Equal bytes do not authorize silently replacing an already witnessed @@ -1716,19 +1716,23 @@ fn staged_evidence_pack_writer_authentication_avoids_extra_opens_and_refuses_rep let bytes = b"nonempty evidence pack data, authenticated after the staging rename"; let digest = segment_digest(bytes); preparation - .write_segment(SealedGenerationSegmentPublicationV1::GenerationEvidencePage { - page_ordinal: 0, - page_digest: &digest, - bytes, - }) + .write_segment( + SealedGenerationSegmentPublicationV1::GenerationEvidencePage { + page_ordinal: 0, + page_digest: &digest, + bytes, + }, + ) .expect("append a real evidence pack page"); let before = PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get); preparation - .write_segment(SealedGenerationSegmentPublicationV1::GenerationEvidenceCommit { - segment_digest: &digest, - segment_size_bytes: bytes.len() as u64, - page_count: 1, - }) + .write_segment( + SealedGenerationSegmentPublicationV1::GenerationEvidenceCommit { + segment_digest: &digest, + segment_size_bytes: bytes.len() as u64, + page_count: 1, + }, + ) .expect("flush and stage evidence pack"); let (path, witness) = { let state = preparation.state.lock().expect("preparation state"); @@ -1748,13 +1752,19 @@ fn staged_evidence_pack_writer_authentication_avoids_extra_opens_and_refuses_rep .expect("the evidence pack must retain its writer's authenticated inode") .clone(); assert!( - files.segments.pending.values().any(|pending| pending == &path), + files + .segments + .pending + .values() + .any(|pending| pending == &path), "the authenticated file must still be pending publication" ); (path, witness) }; assert_eq!( - std::fs::read(&path).expect("staged evidence bytes").as_slice(), + std::fs::read(&path) + .expect("staged evidence bytes") + .as_slice(), &bytes[..] ); assert_eq!(segment_digest(bytes), digest); diff --git a/crates/tracedecay-code-index-runtime/tests/code_index_scheduler/reconcile.rs b/crates/tracedecay-code-index-runtime/tests/code_index_scheduler/reconcile.rs index 35ca13c8c1d..494ab27a578 100644 --- a/crates/tracedecay-code-index-runtime/tests/code_index_scheduler/reconcile.rs +++ b/crates/tracedecay-code-index-runtime/tests/code_index_scheduler/reconcile.rs @@ -15,7 +15,9 @@ use std::{ use tempfile::TempDir; use tracedecay_application::diagnostics_publication::CodeIndexPublicationIdentityPortV1; use tracedecay_application::lsp_runtime::LspCodeIndexProjectionIdentityPort; -use tracedecay_code_index_retention::code_index_generations::acquire_code_generation_store_lock; +use tracedecay_code_index_retention::code_index_generations::{ + acquire_code_generation_store_lock, try_acquire_code_generation_store_read_lock, +}; use tracedecay_contracts::code_index_freshness::{ CodeGraphServingReadinessV1, CodeIndexBuildBlockedReasonV1, CodeIndexReadinessTargetV1, CodeIndexReadinessV1, CodeIndexReadinessWaitReadV1, CodeIndexStalenessStateV1, @@ -11836,7 +11838,7 @@ async fn graph_off_changed_source_advances_text_authority_without_full_decode() let (scope, privacy_domain) = { let mut scheduler = scheduler( &fixture, - scoped_store, + scoped_store.clone(), Arc::new(SharedCodeIndexBytePoolV1::default()), ); published(scheduler.reconcile_now().expect("seed retained generation")); @@ -11932,16 +11934,11 @@ async fn graph_off_changed_source_advances_text_authority_without_full_decode() progress.owner_epoch }; - let durable_generations_root = { - let mut scheduler = scheduler - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let durable = scheduler.publication.generations_root.clone(); - let blocker = store.path().join("publication-root-blocker"); - std::fs::write(&blocker, b"not a directory").expect("write publication blocker"); - scheduler.publication.generations_root = blocker; - durable - }; + // A reader permits preparation against the real store and holds off + // only the exclusive pointer commit, leaving a valid candidate to retry. + let publication_reader = try_acquire_code_generation_store_read_lock(&scoped_store) + .expect("open store reader") + .expect("generation A has settled"); fixture.edit( "src/file_0000.rs", "pub fn beta_0000() -> usize { 10_000 }\n", @@ -12032,7 +12029,7 @@ async fn graph_off_changed_source_advances_text_authority_without_full_decode() fixture.path(), &["commit", "-qam", "advance to generation C"], ); - scheduler.publication.generations_root = durable_generations_root; + drop(publication_reader); scheduler.request_background_reconcile_for_observed_change(); assert!( scheduler From 650e1ccbe03c813cb6aebb1a9cb1fc9634ebd108 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:46:31 -0800 Subject: [PATCH 46/93] fix(code-index): reject substituted staged packs before naming --- .../code_index_scheduler/publication_store.rs | 22 ++++++++++ .../tests/staged_segment_reads.rs | 40 ++++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 1ac64593d6f..81b503e3ce9 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -1142,6 +1142,28 @@ impl StagedGenerationSegmentsV1 { .first_key_value() .map(|(final_path, temporary)| (final_path.clone(), temporary.clone())) { + // Evidence packs are already fsynced before they enter pending, + // so the ordinary writer batch cannot detect a later inode swap. + // Bind every pending pathname to the bytes authenticated during + // staging before any immutable canonical name is published. + #[cfg(unix)] + { + let original = self.authenticated_segments.get(&temporary).ok_or_else(|| { + DaemonCodeIndexPublicationStoreV1::corruption( + "staged sealed segment lacks its authenticated inode", + ) + })?; + let current = temporary + .symlink_metadata() + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + if !DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity( + original, ¤t, + ) { + return Err(DaemonCodeIndexPublicationStoreV1::corruption( + "staged sealed segment changed before immutable publication", + )); + } + } match final_path.symlink_metadata() { Ok(metadata) => { if !metadata.file_type().is_file() diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 00eee98a014..3e67833b4c9 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -1312,8 +1312,14 @@ fn staged_existing_segments_reuse_authenticated_inodes_without_reopening() { .segments .authenticated_segments .keys() - .next() - .expect("previously verified canonical segment") + .find(|path| { + !files + .segments + .pending + .values() + .any(|pending| pending == *path) + }) + .expect("previously verified canonical segment, not an uncommitted temporary") .clone(); target .verify_prepared_segments(files, staged_publication.manifest_bytes().as_ref()) @@ -1798,5 +1804,35 @@ fn staged_evidence_pack_writer_authentication_avoids_extra_opens_and_refuses_rep ), "equal bytes cannot reuse an authenticated inode witness" ); + // The real publication path must reject the substituted temporary before + // it gains a durable canonical name. The evidence writer is already synced + // and does not enter the ordinary DurableFileBatch, so its batch sync alone + // cannot supply this check. + let canonical = target.segments_root.join(format!( + "segment-{}.json", + sha256_hex_suffix(digest.as_str()).expect("digest suffix") + )); + assert!(!canonical.exists(), "evidence pack has not been published"); + let opens_before_refusal = PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get); + { + let mut state = preparation.state.lock().expect("changed evidence state"); + let DaemonCodeIndexPreparationStateV1::Open(files) = &mut *state else { + panic!("changed evidence preparation must remain open"); + }; + assert!( + files.segments.publish().is_err(), + "a same-byte, different-inode evidence pack must not be published" + ); + assert!( + files.segments.pending.values().any(|pending| pending == &path), + "rejected pack remains under private temporary ownership" + ); + } + assert!(!canonical.exists(), "refused pack must not gain a canonical name"); + assert_eq!( + PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get), + opens_before_refusal, + "inode refusal must not introduce an extra pathname digest open" + ); preparation.abort().expect("release failed preparation"); } From 8a683a3feb6c8dd9828f6d6ed15fdef36964d6f6 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:52:34 -0800 Subject: [PATCH 47/93] test(automation): bind scheduler fixtures to backend identities Repair three hosted automation_runner_test failures by supplying the configured executable identity required by the production prefilter. Use the same identity for the prefilter and retained runner, preserving manual-schedule, exact-ledger, live-lock and lock-race assertions. Add a production-prefilter regression for unconfigured refusal, deduplication and same-occurrence recovery after binding, with zero backend calls and no effectful terminal. Do not relax backend admission, change timeouts, or alter the graph publication changes. Refs #4532 --- .../tests/automation_runner_test/jobs.rs | 124 ++++++++++++++++-- 1 file changed, 110 insertions(+), 14 deletions(-) diff --git a/crates/tracedecay/tests/automation_runner_test/jobs.rs b/crates/tracedecay/tests/automation_runner_test/jobs.rs index 740283d56e8..7342786381b 100644 --- a/crates/tracedecay/tests/automation_runner_test/jobs.rs +++ b/crates/tracedecay/tests/automation_runner_test/jobs.rs @@ -86,6 +86,7 @@ fn enabled_job_config() -> AutomationSettingsV1 { struct ContentBackend { calls: AtomicUsize, content: &'static str, + executable: Option, } impl ContentBackend { @@ -93,9 +94,15 @@ impl ContentBackend { Self { calls: AtomicUsize::new(0), content, + executable: None, } } + fn with_executable(mut self, executable: &Path) -> Self { + self.executable = Some(executable.to_path_buf()); + self + } + fn calls(&self) -> usize { self.calls.load(Ordering::SeqCst) } @@ -126,7 +133,7 @@ impl AgentTaskBackend for ContentBackend { } fn executable(&self) -> Option<&std::path::Path> { - None + self.executable.as_deref() } } @@ -988,6 +995,10 @@ async fn scheduler_trigger_skips_repeat_and_respects_lock_discipline() { let mut job = sample_job("sched-job"); job.schedule = Some("manual".to_string()); let config = enabled_job_config(); + // Reach the schedule gate with a bound identity, not the earlier + // backend-unconfigured refusal. This gate never invokes the backend. + let executable = tempfile::NamedTempFile::new().unwrap(); + let backend = ContentBackend::new("unused").with_executable(executable.path()); // Manual schedule: every scheduler tick of the same occurrence reaches // the prefilter, which records the skip once. @@ -996,7 +1007,7 @@ async fn scheduler_trigger_skips_repeat_and_respects_lock_discipline() { let run = evaluate_and_record_scheduler_skip( &dashboard_root, &config, - None, + backend.executable(), &job, "sched-occurrence", None, @@ -1019,7 +1030,7 @@ async fn scheduler_trigger_skips_repeat_and_respects_lock_discipline() { let repeated = evaluate_and_record_scheduler_skip( &dashboard_root, &config, - None, + backend.executable(), &job, "sched-occurrence", None, @@ -1031,6 +1042,76 @@ async fn scheduler_trigger_skips_repeat_and_respects_lock_discipline() { repeated.ledger_record, records[0], "an immediate repeat skip must return the exact durable prior row" ); + assert_eq!(backend.calls(), 0); +} + +#[tokio::test] +async fn scheduler_prefilter_rejects_unconfigured_backend_and_recovers_without_effect() { + let temp = tempdir().unwrap(); + let dashboard_root = temp.path().join("dashboard"); + let job = sample_job("backend-recovery"); + let config = enabled_job_config(); + let occurrence = "backend-recovery-occurrence"; + let backend = ContentBackend::new("unused"); + + let first = evaluate_and_record_scheduler_skip( + &dashboard_root, + &config, + backend.executable(), + &job, + occurrence, + None, + ) + .await + .unwrap() + .expect("a due job still requires a configured backend"); + assert_eq!(first.report["reason"], json!("backend_unconfigured")); + assert_eq!(first.ledger_record.status, AutomationRunStatus::Skipped); + assert_ne!(first.run_id, occurrence); + let repeated = evaluate_and_record_scheduler_skip( + &dashboard_root, + &config, + backend.executable(), + &job, + occurrence, + None, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(repeated.ledger_record, first.ledger_record); + + let executable = tempfile::NamedTempFile::new().unwrap(); + let backend = backend.with_executable(executable.path()); + assert!( + evaluate_and_record_scheduler_skip( + &dashboard_root, + &config, + backend.executable(), + &job, + occurrence, + None, + ) + .await + .unwrap() + .is_none(), + "binding the backend must admit the same due occurrence without cooldown" + ); + assert_eq!(backend.calls(), 0); + assert_eq!( + load_run_records(&dashboard_root, 10).await.unwrap(), + vec![first.ledger_record], + "advisory admission must not append an effectful terminal" + ); + assert!( + tracedecay_automation_runtime::automation::run_ledger::find_run_record_exact_bounded( + &dashboard_root, + occurrence, + ) + .await + .unwrap() + .is_none() + ); } #[tokio::test] @@ -1206,15 +1287,22 @@ async fn scheduler_prefilter_live_lock_wins_over_not_due_summary() { let config = enabled_job_config(); let occurrence = "locked-prefilter-occurrence"; - let prefilter = - evaluate_and_record_scheduler_skip(&dashboard_root, &config, None, &job, occurrence, None) - .await - .unwrap() - .expect("the live job lock must produce a diagnostic"); + let executable = tempfile::NamedTempFile::new().unwrap(); + let backend = ContentBackend::new("unused").with_executable(executable.path()); + let prefilter = evaluate_and_record_scheduler_skip( + &dashboard_root, + &config, + backend.executable(), + &job, + occurrence, + None, + ) + .await + .unwrap() + .expect("the live job lock must produce a diagnostic"); assert_eq!(prefilter.report["reason"], json!("scheduler_lock_active")); assert_ne!(prefilter.run_id, occurrence); - let backend = ContentBackend::new("unused"); let admitted_job = tracedecay_automation_runtime::automation::jobs::admit_job(&job).unwrap(); let retained = run_user_job_with_backend_for_retained_settlement( &dashboard_root, @@ -1262,12 +1350,21 @@ async fn retained_scheduler_runner_reacquires_after_due_prefilter() { job.schedule = Some("every 1h".to_owned()); let config = enabled_job_config(); let occurrence = "prefilter-gap-occurrence"; + let executable = tempfile::NamedTempFile::new().unwrap(); + let backend = ContentBackend::new("unused").with_executable(executable.path()); assert!( - evaluate_and_record_scheduler_skip(&dashboard_root, &config, None, &job, occurrence, None) - .await - .unwrap() - .is_none(), + evaluate_and_record_scheduler_skip( + &dashboard_root, + &config, + backend.executable(), + &job, + occurrence, + None, + ) + .await + .unwrap() + .is_none(), "a new interval job is advisory-due before effect admission" ); let _raced_in = @@ -1276,7 +1373,6 @@ async fn retained_scheduler_runner_reacquires_after_due_prefilter() { .unwrap() .expect("the advisory prefilter must release its reservation lock"); - let backend = ContentBackend::new("unused"); let admitted_job = tracedecay_automation_runtime::automation::jobs::admit_job(&job).unwrap(); let retained = run_user_job_with_backend_for_retained_settlement( &dashboard_root, From 9e803130aaa4d2d5e9496a3020a317dca575e534 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:19:43 -0800 Subject: [PATCH 48/93] perf(code-index): reuse parsed pointer bytes for publication CAS Avoid reopening the same active pointer after authenticated parsing in preparation and commit. Preserve exact serialized CAS identity and add a Linux kernel-observed one-open snapshot regression. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 33 +++++---- .../tests/staged_segment_reads.rs | 67 +++++++++++++++++++ 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 81b503e3ce9..58c51aa1be3 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -2047,6 +2047,16 @@ impl DaemonCodeIndexPublicationStoreV1 { pub(super) fn read_publication_pointer( &self, ) -> Result, CodeIndexPublicationStoreErrorV1> { + self.read_publication_pointer_with_bytes() + .map(|result| result.map(|(pointer, _)| pointer)) + } + + // The CAS callers need the *exact serialized bytes* of the pointer they + // just authenticated. Return those bytes from the original file open + // rather than making an independent endpoint-scanned pathname open. + fn read_publication_pointer_with_bytes( + &self, + ) -> Result)>, CodeIndexPublicationStoreErrorV1> { let metadata = match std::fs::metadata(&self.active_path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { @@ -2105,7 +2115,7 @@ impl DaemonCodeIndexPublicationStoreV1 { { memo.mtime = mtime; memo.size = size; - return Ok(Some(memo.pointer.clone())); + return Ok(Some((memo.pointer.clone(), bytes))); } } let pointer: DurablePublicationPointerV1 = @@ -2253,7 +2263,7 @@ impl DaemonCodeIndexPublicationStoreV1 { pointer: pointer.clone(), }); } - Ok(Some(pointer)) + Ok(Some((pointer, bytes))) } fn remember_publication_pointer( @@ -3773,13 +3783,10 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { }; let (incumbent_bytes, pointer) = { let _read = self.acquire_generation_read_lock()?; - let pointer = self.read_publication_pointer()?; - let bytes = if pointer.is_some() { - Some(std::fs::read(&self.active_path).map_err(Self::unavailable)?) - } else { - None - }; - (bytes, pointer) + match self.read_publication_pointer_with_bytes()? { + Some((pointer, bytes)) => (Some(bytes), Some(pointer)), + None => (None, None), + } }; match undecoded_expectation.as_ref() { Some(expected) @@ -3941,11 +3948,9 @@ impl DaemonCodeIndexPublicationStoreV1 { .map_err(Self::unavailable)? .ok_or(CodeIndexPublicationStoreErrorV1::StoreLockContended)?; self.validate_preparation_store(preparation, generation)?; - let prior_pointer = self.read_publication_pointer()?; - let prior_bytes = if prior_pointer.is_some() { - Some(std::fs::read(&self.active_path).map_err(Self::unavailable)?) - } else { - None + let (prior_pointer, prior_bytes) = match self.read_publication_pointer_with_bytes()? { + Some((pointer, bytes)) => (Some(pointer), Some(bytes)), + None => (None, None), }; let pointer_already_installed = files.pending_pointer.as_deref().is_some() && files.pending_pointer.as_deref() == prior_bytes.as_deref(); diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 3e67833b4c9..5b3071e7f25 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -1560,6 +1560,73 @@ fn observed_pointer_opens( opens } +#[cfg(target_os = "linux")] +#[test] +fn publication_pointer_cas_reads_exact_bytes_with_one_kernel_open() { + use notify::Watcher; + + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 3 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut publisher = target_store(target_root.path(), &fixture); + let mut preparation = publisher + .prepare(&generation.sealed_scope(), None) + .expect("prepare real generation"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage real snapshot"); + publisher + .commit(&mut preparation, &publication) + .expect("persist actual generation"); + drop(preparation); + drop(publisher); + + let cold = target_store(target_root.path(), &fixture); + let expected = std::fs::read(&cold.active_path).expect("persisted exact pointer bytes"); + assert!(!expected.is_empty(), "CAS must compare an actual pointer"); + + let (sender, receiver) = std::sync::mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |event: notify::Result| { + let _ = sender.send(event); + }) + .expect("native file observer"); + watcher + .watch(&cold.active_path, notify::RecursiveMode::NonRecursive) + .expect("watch exact pointer inode"); + drop(File::open(&cold.active_path).expect("positive pathname-open control")); + assert_eq!( + observed_pointer_opens(&receiver), + 1, + "observer must count a real kernel open" + ); + + let (pointer, bytes) = cold + .read_publication_pointer_with_bytes() + .expect("read one authenticated pointer and its CAS bytes") + .expect("published pointer"); + assert_eq!( + observed_pointer_opens(&receiver), + 1, + "reading the exact CAS bytes must not reopen the pointer" + ); + assert_eq!(bytes, expected, "CAS must retain the exact persisted bytes"); + assert_eq!( + pointer.generation_id, + generation.manifest().generation_id.as_str() + ); + + let reloaded = cold + .load_active_shared() + .expect("cold read published generation") + .expect("published snapshot"); + assert_eq!( + reloaded.snapshot().content_identity, + generation.snapshot().content_identity, + "one-open pointer CAS read must preserve cold snapshot identity" + ); +} + #[cfg(target_os = "linux")] #[test] fn cold_publication_pointer_validation_uses_one_kernel_open_and_recovers_snapshot() { From 08a217724e253c524ce7e653ce4e71c582d05ac6 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:49:10 -0800 Subject: [PATCH 49/93] style(code-index): format publication and staged read changes --- .../src/code_index_scheduler/publication_store.rs | 3 ++- .../tests/staged_segment_reads.rs | 11 +++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 58c51aa1be3..1c99fd30ef4 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -2056,7 +2056,8 @@ impl DaemonCodeIndexPublicationStoreV1 { // rather than making an independent endpoint-scanned pathname open. fn read_publication_pointer_with_bytes( &self, - ) -> Result)>, CodeIndexPublicationStoreErrorV1> { + ) -> Result)>, CodeIndexPublicationStoreErrorV1> + { let metadata = match std::fs::metadata(&self.active_path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 5b3071e7f25..976af5f1902 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -1891,11 +1891,18 @@ fn staged_evidence_pack_writer_authentication_avoids_extra_opens_and_refuses_rep "a same-byte, different-inode evidence pack must not be published" ); assert!( - files.segments.pending.values().any(|pending| pending == &path), + files + .segments + .pending + .values() + .any(|pending| pending == &path), "rejected pack remains under private temporary ownership" ); } - assert!(!canonical.exists(), "refused pack must not gain a canonical name"); + assert!( + !canonical.exists(), + "refused pack must not gain a canonical name" + ); assert_eq!( PREPARED_SEGMENT_DIGEST_OPENS.with(std::cell::Cell::get), opens_before_refusal, From 8ea3d315d413a56ca5661c23d02796764aad94f2 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:00:39 -0800 Subject: [PATCH 50/93] fix(code-index): bind memoized pointer to named inode Reject same-byte symlink replacements of cached or cold publication pointers without extra pathname opens. Add real-store refusal and snapshot recovery coverage, and move native watcher trait imports to module scope. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 26 ++++++- .../tests/staged_segment_reads.rs | 67 ++++++++++++++++++- 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 1c99fd30ef4..3c84681eeea 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -2058,7 +2058,13 @@ impl DaemonCodeIndexPublicationStoreV1 { &self, ) -> Result)>, CodeIndexPublicationStoreErrorV1> { - let metadata = match std::fs::metadata(&self.active_path) { + // The named pointer must itself be a regular file. Following a + // symlink would let an equal-byte replacement inherit the memo. + #[cfg(unix)] + let named = std::fs::symlink_metadata(&self.active_path); + #[cfg(not(unix))] + let named = std::fs::metadata(&self.active_path); + let metadata = match named { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { *self @@ -2114,6 +2120,24 @@ impl DaemonCodeIndexPublicationStoreV1 { if let Some(memo) = memo.as_mut() && memo.digest == digest { + // A matching digest does not authenticate a substituted + // pathname. Preserve the cold path's inode checks without + // another endpoint-scanned open. + #[cfg(unix)] + { + let opened = opened_pointer.metadata().map_err(Self::map_pointer_io)?; + let named = self + .active_path + .symlink_metadata() + .map_err(Self::map_pointer_io)?; + if !Self::same_unix_segment_identity(&metadata, &opened) + || !Self::same_unix_segment_identity(&opened, &named) + { + return Err(Self::corruption( + "active code-generation pointer changed during cached validation", + )); + } + } memo.mtime = mtime; memo.size = size; return Ok(Some((memo.pointer.clone(), bytes))); diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 976af5f1902..b713172d39b 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -11,6 +11,7 @@ use std::{ time::Duration, }; +use notify::Watcher; use sha2::{Digest, Sha256}; use tempfile::TempDir; use tracedecay_code_index::production::{ @@ -1563,7 +1564,6 @@ fn observed_pointer_opens( #[cfg(target_os = "linux")] #[test] fn publication_pointer_cas_reads_exact_bytes_with_one_kernel_open() { - use notify::Watcher; let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 3 }\n")]); let source_root = TempDir::new().expect("source store"); @@ -1627,10 +1627,72 @@ fn publication_pointer_cas_reads_exact_bytes_with_one_kernel_open() { ); } +#[test] +fn cached_publication_pointer_refuses_equal_byte_symlink_and_recovers_snapshot() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut publisher = target_store(target_root.path(), &fixture); + let mut preparation = publisher + .prepare(&generation.sealed_scope(), None) + .expect("prepare real generation"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage generation"); + publisher + .commit(&mut preparation, &publication) + .expect("publish generation"); + drop(preparation); + + let pointer_path = publisher.active_path.clone(); + let exact_bytes = std::fs::read(&pointer_path).expect("committed pointer bytes"); + assert!(!exact_bytes.is_empty()); + let expected_generation = generation.manifest().generation_id.as_str(); + assert_eq!( + publisher + .read_publication_pointer() + .expect("legitimate cached pointer") + .expect("active generation") + .generation_id, + expected_generation + ); + + let original = target_root.path().join("original-active-pointer"); + std::fs::rename(&pointer_path, &original).expect("move disposable pointer"); + std::os::unix::fs::symlink(&original, &pointer_path) + .expect("substitute symlink to the exact same bytes"); + assert_eq!( + std::fs::read(&pointer_path).expect("linked pointer data"), + exact_bytes, + "symlink replacement must preserve the same bytes and digest" + ); + assert!( + publisher.read_publication_pointer().is_err(), + "the warm memo must refuse an equal-byte symlink replacement" + ); + let cold = target_store(target_root.path(), &fixture); + assert!( + cold.read_publication_pointer().is_err(), + "cold pointer validation must also refuse a symlink" + ); + + std::fs::remove_file(&pointer_path).expect("remove test symlink"); + std::fs::rename(&original, &pointer_path).expect("restore original pointer"); + let loaded = cold + .load_active_shared() + .expect("load restored generation") + .expect("published snapshot"); + assert_eq!( + loaded.snapshot().content_identity, + generation.snapshot().content_identity, + "restored exact inode must recover the original published snapshot" + ); +} + #[cfg(target_os = "linux")] #[test] fn cold_publication_pointer_validation_uses_one_kernel_open_and_recovers_snapshot() { - use notify::Watcher; let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); let source_root = TempDir::new().expect("source store"); @@ -1713,7 +1775,6 @@ fn observed_named_opens( #[cfg(target_os = "linux")] #[test] fn initial_pointer_publication_reuses_persisted_descriptor_without_pathname_open() { - use notify::Watcher; let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 2 }\n")]); let source_root = TempDir::new().expect("source store"); From c42535cada7f0785fe1098ba0ff61da7d06f5543 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:01:55 -0800 Subject: [PATCH 51/93] fix(code-index): scope Linux watcher trait and own test identity Keep the native watcher import restricted to Linux, and retain the expected generation ID by value throughout the filesystem regression. Refs #4533 --- .../src/code_index_scheduler/tests/staged_segment_reads.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index b713172d39b..c77c9a973d3 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -11,6 +11,7 @@ use std::{ time::Duration, }; +#[cfg(target_os = "linux")] use notify::Watcher; use sha2::{Digest, Sha256}; use tempfile::TempDir; @@ -1648,7 +1649,7 @@ fn cached_publication_pointer_refuses_equal_byte_symlink_and_recovers_snapshot() let pointer_path = publisher.active_path.clone(); let exact_bytes = std::fs::read(&pointer_path).expect("committed pointer bytes"); assert!(!exact_bytes.is_empty()); - let expected_generation = generation.manifest().generation_id.as_str(); + let expected_generation = generation.manifest().generation_id.clone(); assert_eq!( publisher .read_publication_pointer() From 46c5d97e2ad248d3c5c25bda32ef692922b207bc Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:55:20 -0800 Subject: [PATCH 52/93] style(code-index): satisfy Rustfmt for native pointer regressions Remove three extraneous blank lines flagged by hosted Bazel rustfmt. Preserve the nonzero kernel-open and snapshot assertions unchanged. Refs #4533 --- .../src/code_index_scheduler/tests/staged_segment_reads.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index c77c9a973d3..c3432c8c4f8 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -1565,7 +1565,6 @@ fn observed_pointer_opens( #[cfg(target_os = "linux")] #[test] fn publication_pointer_cas_reads_exact_bytes_with_one_kernel_open() { - let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 3 }\n")]); let source_root = TempDir::new().expect("source store"); let generation = source_generation(&fixture, source_root.path()); @@ -1694,7 +1693,6 @@ fn cached_publication_pointer_refuses_equal_byte_symlink_and_recovers_snapshot() #[cfg(target_os = "linux")] #[test] fn cold_publication_pointer_validation_uses_one_kernel_open_and_recovers_snapshot() { - let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); let source_root = TempDir::new().expect("source store"); let generation = source_generation(&fixture, source_root.path()); @@ -1776,7 +1774,6 @@ fn observed_named_opens( #[cfg(target_os = "linux")] #[test] fn initial_pointer_publication_reuses_persisted_descriptor_without_pathname_open() { - let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 2 }\n")]); let source_root = TempDir::new().expect("source store"); let generation = source_generation(&fixture, source_root.path()); From 5f2e8905dfe73773a49a2968a0728e63b50baee0 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:17:06 -0800 Subject: [PATCH 53/93] fix(code-index): authenticate cold pinned whole reads Match the uncached whole-segment digest contract without another pathname open. Reject same-inode, same-size corruption on a first staged read; assert the real file open and cleared response bytes. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 17 +++-- .../tests/staged_segment_reads.rs | 62 +++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 3c84681eeea..11ea66ac488 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -2757,16 +2757,16 @@ impl DaemonCodeIndexPublicationStoreV1 { ) -> Result<(), CodeIndexProductionErrorV1> { // Errors must not leave bytes from an earlier successful range visible. buffer.clear(); - let (digest, expected_size, offset, length) = match request { + let (digest, expected_size, offset, length, whole) = match request { SealedGenerationSegmentReadV1::Whole { digest, size_bytes } => { - (digest.as_str(), size_bytes, 0, size_bytes) + (digest.as_str(), size_bytes, 0, size_bytes, true) } SealedGenerationSegmentReadV1::Range { digest, size_bytes, offset, length, - } => (digest.as_str(), size_bytes, offset, length), + } => (digest.as_str(), size_bytes, offset, length, false), }; if offset .checked_add(length) @@ -2793,7 +2793,16 @@ impl DaemonCodeIndexPublicationStoreV1 { CodeIndexProductionErrorV1::Contract(format!( "sealed generation segment read failed: {error}" )) - }) + })?; + // A cold pinned read must enforce the same whole-segment digest + // contract as the uncached reader, not just its pathname and extent. + if whole && Self::state_digest(buffer) != digest { + buffer.clear(); + return Err(CodeIndexProductionErrorV1::Contract( + "sealed generation segment does not match its content address".to_owned(), + )); + } + Ok(()) } #[tracing::instrument( diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index c3432c8c4f8..f8fcaa5f345 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -697,6 +697,68 @@ fn staged_writer_authentication_avoids_commit_reopens_and_rejects_replaced_inode .expect("abort replaced staged generation"); } +#[test] +fn cold_staged_whole_segment_read_rejects_same_inode_content_corruption() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage real generation"); + let identity = CodeIndexPublishedGenerationV1::partitioned_segment_identities_from_reader( + publication.manifest_bytes().as_ref(), + ) + .expect("parse staged segment identities") + .expect("partitioned generation") + .into_iter() + .find(|segment| segment.size_bytes > 4) + .expect("nonempty staged segment"); + let path = staged_path(&preparation, &identity.digest); + let original = std::fs::read(&path).expect("staged segment bytes"); + assert_eq!(segment_digest(&original), identity.digest); + let before = path.symlink_metadata().expect("staged inode"); + + // Mutate before the first reader open: metadata checks within the pinned + // reader cannot discover a change to an inode they have never observed. + let mut corrupted = original.clone(); + corrupted[0] ^= 1; + std::fs::write(&path, &corrupted).expect("corrupt staged inode in place"); + assert_ne!(segment_digest(&corrupted), identity.digest); + let after = path.symlink_metadata().expect("changed inode"); + assert_eq!( + (before.dev(), before.ino(), before.len()), + (after.dev(), after.ino(), after.len()), + "the regression must preserve the pathname, inode and extent" + ); + + let reader = preparation.segment_reader(); + let open_count = preparation.staged_segment_read_open_attempts_for_test(); + assert_eq!(open_count, 0, "exercise an actual cold pinned read"); + let mut output = b"stale response".to_vec(); + assert!(matches!( + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &identity.digest, + size_bytes: identity.size_bytes, + }, + &mut output, + ), + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + open_count + 1, + "the digest refusal must follow one real pinned-file open" + ); + assert!(output.is_empty(), "corrupt bytes must not escape"); + preparation.abort().expect("abort corrupted staged generation"); +} + #[test] fn committed_whole_segment_read_refuses_same_length_corruption() { let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); From 95a93a8f16ae15340d21c13decef1898666e0261 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:21:18 -0800 Subject: [PATCH 54/93] style(code-index): satisfy staged corruption test rustfmt Refs #4533 --- .../src/code_index_scheduler/tests/staged_segment_reads.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index f8fcaa5f345..701cecd4cbe 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -756,7 +756,9 @@ fn cold_staged_whole_segment_read_rejects_same_inode_content_corruption() { "the digest refusal must follow one real pinned-file open" ); assert!(output.is_empty(), "corrupt bytes must not escape"); - preparation.abort().expect("abort corrupted staged generation"); + preparation + .abort() + .expect("abort corrupted staged generation"); } #[test] From 262cf1f667c387906eba8e5c76dbc8cda4f37601 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:23:55 -0800 Subject: [PATCH 55/93] test(code-index): compare the canonical pointer generation ID --- .../src/code_index_scheduler/tests/staged_segment_reads.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 701cecd4cbe..b15d42feb27 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -1712,14 +1712,13 @@ fn cached_publication_pointer_refuses_equal_byte_symlink_and_recovers_snapshot() let pointer_path = publisher.active_path.clone(); let exact_bytes = std::fs::read(&pointer_path).expect("committed pointer bytes"); assert!(!exact_bytes.is_empty()); - let expected_generation = generation.manifest().generation_id.clone(); assert_eq!( publisher .read_publication_pointer() .expect("legitimate cached pointer") .expect("active generation") .generation_id, - expected_generation + generation.manifest().generation_id.as_str() ); let original = target_root.path().join("original-active-pointer"); From 5ffbb82e32a7e31ff5825e499d22201f8d96bd00 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:37:46 -0800 Subject: [PATCH 56/93] test(code-index): compare the captured pointer bytes explicitly --- .../src/code_index_scheduler/tests/staged_segment_reads.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index b15d42feb27..0643b7825ee 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -1711,7 +1711,7 @@ fn cached_publication_pointer_refuses_equal_byte_symlink_and_recovers_snapshot() let pointer_path = publisher.active_path.clone(); let exact_bytes = std::fs::read(&pointer_path).expect("committed pointer bytes"); - assert!(!exact_bytes.is_empty()); + assert_ne!(exact_bytes.as_slice(), b""); assert_eq!( publisher .read_publication_pointer() From e9b6e8efd8d7355b10a00739e7dfdd9aa7f45184 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:28:57 -0800 Subject: [PATCH 57/93] fix(storage): recheck flushed windows after final barriers An earlier flushed member can be replaced while the final window fsyncs. Revalidate the earlier identities after the final barriers without new pathname opens. Keep the initial fail-fast checks and failed ownership. Add a deterministic real-filesystem regression that replaces a flushed member at the existing production fsync span. Require positive barrier counts, identical-byte distinct-inode refusal, and retry refusal. Refs #4533 --- .../tracedecay-private-fs/src/framed_log.rs | 13 +- .../src/framed_log_snapshot_tests.rs | 119 ++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 crates/tracedecay-private-fs/src/framed_log_snapshot_tests.rs diff --git a/crates/tracedecay-private-fs/src/framed_log.rs b/crates/tracedecay-private-fs/src/framed_log.rs index b9703c5fd64..be56f6112fa 100644 --- a/crates/tracedecay-private-fs/src/framed_log.rs +++ b/crates/tracedecay-private-fs/src/framed_log.rs @@ -22,6 +22,10 @@ use windows_sys::Win32::Storage::FileSystem::{ #[path = "framed_log_batch_tests.rs"] mod batch_tests; +#[cfg(all(test, target_os = "linux", feature = "test-helpers"))] +#[path = "framed_log_snapshot_tests.rs"] +mod snapshot_tests; + #[cfg(test)] thread_local! { static DIRECTORY_SYNC_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; @@ -225,7 +229,14 @@ impl DurableFileBatch { for identity in &self.synced { identity.validate_path()?; } - self.sync_pending() + self.sync_pending()?; + // The final barriers may block long enough for an earlier window + // to change. Its pre-barrier observation cannot authorize the + // whole batch after those barriers return. Recheck without opens. + for identity in &self.synced { + identity.validate_path()?; + } + Ok(()) } #[cfg(not(target_os = "linux"))] { diff --git a/crates/tracedecay-private-fs/src/framed_log_snapshot_tests.rs b/crates/tracedecay-private-fs/src/framed_log_snapshot_tests.rs new file mode 100644 index 00000000000..2a7fa9620aa --- /dev/null +++ b/crates/tracedecay-private-fs/src/framed_log_snapshot_tests.rs @@ -0,0 +1,119 @@ +//! A final durability barrier must not reuse an earlier window's stale identity. +use std::fs::{self, File}; +use std::io::{ErrorKind, Write}; +use std::os::unix::fs::MetadataExt; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use tracing::level_filters::LevelFilter; +use tracing::span::{Attributes, Id, Record}; +use tracing::{Event, Metadata, Subscriber}; + +use super::{DURABLE_BATCH_CONCURRENT_SYNCS, DurableFileBatch, sync_latency}; + +// Interleave a real rename at the existing production fsync span. The test +// leaves exactly one pending member, so its barrier executes on this thread: +// no sleeps, global subscriber, fake fsync, or production-only test hook. +struct ReplaceEarlierWindowAtBarrier { + destination: PathBuf, + replacement: PathBuf, + entered: Arc, +} + +impl Subscriber for ReplaceEarlierWindowAtBarrier { + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + metadata.is_span() && metadata.name() == "private_fs.framed_log.fsync" + } + + fn max_level_hint(&self) -> Option { + Some(LevelFilter::TRACE) + } + + fn new_span(&self, attributes: &Attributes<'_>) -> Id { + assert_eq!(attributes.metadata().name(), "private_fs.framed_log.fsync"); + Id::from_u64(1) + } + + fn record(&self, _span: &Id, _values: &Record<'_>) {} + + fn record_follows_from(&self, _span: &Id, _follows: &Id) {} + + fn event(&self, _event: &Event<'_>) {} + + fn enter(&self, _span: &Id) { + assert_eq!( + self.entered.fetch_add(1, Ordering::SeqCst), + 0, + "exactly one final member barrier must execute" + ); + fs::rename(&self.replacement, &self.destination) + .expect("replace an earlier window during the final barrier"); + } + + fn exit(&self, _span: &Id) {} +} + +#[test] +fn durable_batch_rechecks_earlier_windows_after_the_final_barrier() { + let root = tempfile::tempdir().expect("isolated durability fixture"); + let barriers = sync_latency::inject(root.path(), Duration::ZERO); + let mut batch = DurableFileBatch::new(); + let bytes = b"immutable batch member"; + let first = root.path().join("member-0.tmp"); + for index in 0..=DURABLE_BATCH_CONCURRENT_SYNCS { + let path = root.path().join(format!("member-{index}.tmp")); + let mut writer = File::create(&path).expect("member writer"); + writer.write_all(bytes).expect("nonempty member bytes"); + batch.written(&path, &writer).expect("admit immutable member"); + } + assert_eq!(batch.synced.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + assert_eq!(batch.pending.len(), 1); + assert_eq!(barriers.syncs(), DURABLE_BATCH_CONCURRENT_SYNCS as u64); + + batch.sync().expect("intact multi-window batch must succeed"); + assert_eq!(barriers.syncs(), DURABLE_BATCH_CONCURRENT_SYNCS as u64 + 1); + assert_eq!(fs::read(&first).expect("exact original bytes"), bytes); + let original = fs::symlink_metadata(&first).expect("original identity"); + let replacement = root.path().join("replacement.tmp"); + fs::write(&replacement, bytes).expect("same-byte distinct inode"); + let replacement_identity = fs::symlink_metadata(&replacement).expect("replacement identity"); + assert_ne!( + (original.dev(), original.ino()), + (replacement_identity.dev(), replacement_identity.ino()) + ); + + let entered = Arc::new(AtomicUsize::new(0)); + let observer = ReplaceEarlierWindowAtBarrier { + destination: first.clone(), + replacement, + entered: Arc::clone(&entered), + }; + let result = tracing::subscriber::with_default(observer, || batch.sync()); + assert_eq!(entered.load(Ordering::SeqCst), 1); + assert_eq!(barriers.syncs(), DURABLE_BATCH_CONCURRENT_SYNCS as u64 + 2); + assert_eq!( + result + .expect_err("the final barrier must not authorize a replaced earlier member") + .kind(), + ErrorKind::InvalidData + ); + assert_eq!(fs::read(&first).expect("replacement readback"), bytes); + let current = fs::symlink_metadata(&first).expect("current identity"); + assert_eq!( + (current.dev(), current.ino()), + (replacement_identity.dev(), replacement_identity.ino()) + ); + assert_eq!(batch.synced.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + assert_eq!(batch.pending.len(), 1); + assert_eq!( + batch.sync().expect_err("retry must still refuse").kind(), + ErrorKind::InvalidData + ); + assert_eq!( + barriers.syncs(), + DURABLE_BATCH_CONCURRENT_SYNCS as u64 + 2, + "a known-invalid earlier window must refuse before another barrier" + ); +} From 76b7556730ce493ed871f172a2c9639f04640163 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:42:19 -0800 Subject: [PATCH 58/93] fix(code-index): poison staged reads after digest failures Refs #4533 --- .../code_index_scheduler/publication_store.rs | 8 ++ .../tests/staged_segment_reads.rs | 98 +++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 11ea66ac488..afd083021f9 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -1048,6 +1048,14 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { "staged sealed segment identity changed during read".to_owned(), )); } + // A failed authenticated read (including a digest mismatch + // without a metadata change) invalidates the preparation. + // A retry must not reauthorize these staged bytes. + if result.is_err() { + buffer.clear(); + files.segment_reads_poisoned = true; + files.pinned_segment_reads.clear(); + } result } } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 0643b7825ee..51ac09faf4e 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -248,6 +248,104 @@ fn staged_segment_range_reads_open_once_per_cached_file_and_revalidate_replaceme assert_eq!(read, [] as [u8; 0]); } +#[test] +fn staged_segment_cold_digest_mismatch_poison_survives_restoration() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage real generation"); + + // An auxiliary segment outside the manifest can still invalidate the + // transaction. Corrupt it *before* the first pinned read, so all inode + // metadata stays unchanged during that read and only SHA-256 catches it. + let original = b"auxiliary sealed segment"; + let digest = segment_digest(original); + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &digest, + bytes: original, + }) + .expect("stage auxiliary segment"); + let path = staged_path(&preparation, &digest); + let corrupted = vec![b'X'; original.len()]; + std::fs::write(&path, &corrupted).expect("corrupt the same staged inode"); + assert_eq!(std::fs::read(&path).expect("corrupt bytes"), corrupted); + + let reader = preparation.segment_reader(); + let opens_before = preparation.staged_segment_read_open_attempts_for_test(); + let request = SealedGenerationSegmentReadV1::Whole { + digest: &digest, + size_bytes: original.len() as u64, + }; + let mut read = b"prior success sentinel".to_vec(); + assert!(matches!( + reader(request, &mut read), + Err(CodeIndexProductionErrorV1::Contract(detail)) + if detail.contains("content address") + )); + assert!(read.is_empty(), "corrupt bytes must not escape"); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + opens_before + 1, + "the checksum refusal must follow a real pathname open" + ); + assert_eq!( + pinned_handle_count(&preparation), + 0, + "a failed digest must invalidate the pinned cache even when metadata is stable" + ); + + // Neither retries nor restoration can make a poisoned transaction valid. + read.extend_from_slice(b"stale retry"); + assert!(matches!( + reader(request, &mut read), + Err(CodeIndexProductionErrorV1::Contract(detail)) + if detail.contains("identity was invalidated") + )); + assert!(read.is_empty()); + std::fs::write(&path, original).expect("restore exact original bytes"); + assert!(matches!( + reader(request, &mut read), + Err(CodeIndexProductionErrorV1::Contract(detail)) + if detail.contains("identity was invalidated") + )); + assert!(read.is_empty()); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + opens_before + 1, + "poisoned reads must not cause another endpoint-scanned open" + ); + assert!(matches!( + preparation.write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &digest, + bytes: original, + }), + Err(CodeIndexPublicationStoreErrorV1::Unavailable(detail)) + if detail.contains("identity was invalidated") + )); + assert!(matches!( + target.commit(&mut preparation, &publication), + Err(CodeIndexPublicationStoreErrorV1::Unavailable(detail)) + if detail.contains("identity was invalidated") + )); + assert!( + target + .load_active_manifest() + .expect("active manifest probe") + .is_none(), + "a poisoned preparation must not publish its valid-looking manifest" + ); + preparation.abort().expect("discard poisoned preparation"); + assert!(!path.exists()); +} + #[test] fn staged_segment_in_place_mutation_poison_survives_restoration_and_refuses_commit() { let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); From ec0c903f9a493ebebdde5e40b3bed10296356401 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:13:49 -0800 Subject: [PATCH 59/93] style(storage): apply hosted Bazel rustfmt to durability regression Match the two exact formatting changes reported by the current hosted repository-gates failure. Preserve all nonzero fsync counts, real inode replacement, rejection, and retry assertions unchanged. Refs #4533 --- .../src/framed_log_snapshot_tests.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay-private-fs/src/framed_log_snapshot_tests.rs b/crates/tracedecay-private-fs/src/framed_log_snapshot_tests.rs index 2a7fa9620aa..623ceeccd24 100644 --- a/crates/tracedecay-private-fs/src/framed_log_snapshot_tests.rs +++ b/crates/tracedecay-private-fs/src/framed_log_snapshot_tests.rs @@ -66,13 +66,17 @@ fn durable_batch_rechecks_earlier_windows_after_the_final_barrier() { let path = root.path().join(format!("member-{index}.tmp")); let mut writer = File::create(&path).expect("member writer"); writer.write_all(bytes).expect("nonempty member bytes"); - batch.written(&path, &writer).expect("admit immutable member"); + batch + .written(&path, &writer) + .expect("admit immutable member"); } assert_eq!(batch.synced.len(), DURABLE_BATCH_CONCURRENT_SYNCS); assert_eq!(batch.pending.len(), 1); assert_eq!(barriers.syncs(), DURABLE_BATCH_CONCURRENT_SYNCS as u64); - batch.sync().expect("intact multi-window batch must succeed"); + batch + .sync() + .expect("intact multi-window batch must succeed"); assert_eq!(barriers.syncs(), DURABLE_BATCH_CONCURRENT_SYNCS as u64 + 1); assert_eq!(fs::read(&first).expect("exact original bytes"), bytes); let original = fs::symlink_metadata(&first).expect("original identity"); From 2e1b2418f20269a2cc5574a4b20307ac000b4eee Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:53:16 -0800 Subject: [PATCH 60/93] fix(code-index): preserve cached handles on failed cold opens A valid cold staged-segment request could pass metadata checks, evict the oldest healthy pinned descriptor, then fail its pathname open. Repeated reads of the evicted segment paid a needless endpoint-security open. Open and authenticate the candidate before evicting the oldest cached handle. The retained LRU stays at eight entries; only the admission candidate briefly exists outside it. Preserve original inode checks. A deterministic real-filesystem regression moves the ninth staged file at the production open event, proves the failed attempt, preserves all eight handles and the original bytes, and requires zero extra opens on re-reading the previous oldest member. The predecessor fails the bound. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 13 +- .../tests/staged_segment_reads.rs | 145 ++++++++++++++++++ 2 files changed, 153 insertions(+), 5 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index afd083021f9..75e39ac4532 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -996,11 +996,11 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { .to_owned(), )); } - // Admit the request and path before eviction, then - // evict before opening to retain the descriptor bound. - if files.pinned_segment_reads.len() == MAX_STAGED_SEGMENT_READ_HANDLES { - files.pinned_segment_reads.pop_front(); - } + // Authenticate the candidate before displacing a + // healthy cached handle. A failed open must not + // cause another endpoint-authorized open later. + // Admission holds at most one transient descriptor + // beyond the eight-entry retained cache. let pinned = DaemonCodeIndexPublicationStoreV1::open_pinned_staged_segment( &path, @@ -1025,6 +1025,9 @@ impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { .observed_segment_identities .insert(path.clone(), pinned.identity.clone()); } + if files.pinned_segment_reads.len() == MAX_STAGED_SEGMENT_READ_HANDLES { + files.pinned_segment_reads.pop_front(); + } files.pinned_segment_reads.push_back(pinned); } let pinned = files.pinned_segment_reads.back_mut().ok_or_else(|| { diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 51ac09faf4e..a710872a6cf 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -1056,6 +1056,151 @@ fn staged_segment_rejected_reads_preserve_full_cache_and_recency() { preparation.abort().expect("clean up staged cache fixture"); } +// A pathname may disappear after its metadata was validated but before the +// first open. Exercise that real syscall boundary without depending on races +// or the runner's permissions. +#[cfg(target_os = "linux")] +struct MoveStagedCandidateBeforeOpen { + source: PathBuf, + destination: PathBuf, + observed: Arc, +} + +#[cfg(target_os = "linux")] +impl tracing::Subscriber for MoveStagedCandidateBeforeOpen { + fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool { + metadata.is_event() + && metadata.target() == "tracedecay::code_index" + && metadata + .fields() + .field("staged_segment_file_open_count") + .is_some() + } + + fn max_level_hint(&self) -> Option { + Some(tracing::level_filters::LevelFilter::TRACE) + } + + fn new_span(&self, _attributes: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + + fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {} + + fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {} + + fn event(&self, _event: &tracing::Event<'_>) { + assert_eq!( + self.observed.fetch_add(1, Ordering::SeqCst), + 0, + "the candidate must attempt one real pathname open" + ); + std::fs::rename(&self.source, &self.destination) + .expect("move the candidate between metadata validation and File::open"); + } + + fn enter(&self, _span: &tracing::span::Id) {} + + fn exit(&self, _span: &tracing::span::Id) {} +} + +#[cfg(target_os = "linux")] +#[test] +fn staged_failed_cold_open_preserves_eight_healthy_handles() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut target = target_store(target_root.path(), &fixture); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let contents = (0..9) + .map(|index| format!("staged-open-race-{index}-bytes").into_bytes()) + .collect::>(); + let digests = contents + .iter() + .map(|bytes| segment_digest(bytes)) + .collect::>(); + for (digest, bytes) in digests.iter().zip(&contents) { + preparation + .write_segment(SealedGenerationSegmentPublicationV1::File { digest, bytes }) + .expect("stage real nonempty segment"); + } + + let reader = preparation.segment_reader(); + let mut read = Vec::new(); + for index in 0..8 { + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[index], + size_bytes: contents[index].len() as u64, + }, + &mut read, + ) + .expect("admit real healthy descriptor"); + assert_eq!(read, contents[index]); + } + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 8); + assert_eq!(pinned_handle_count(&preparation), 8); + + let candidate = staged_path(&preparation, &digests[8]); + let moved = candidate.with_extension("moved-before-open"); + let observed = Arc::new(AtomicUsize::new(0)); + let injector = MoveStagedCandidateBeforeOpen { + source: candidate.clone(), + destination: moved.clone(), + observed: Arc::clone(&observed), + }; + read.extend_from_slice(b"stale before the failed open"); + let refused = tracing::subscriber::with_default(injector, || { + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[8], + size_bytes: contents[8].len() as u64, + }, + &mut read, + ) + }); + assert!(matches!(refused, Err(CodeIndexProductionErrorV1::Contract(_)))); + assert_eq!(observed.load(Ordering::SeqCst), 1); + assert!(read.is_empty(), "a failed first open cannot return stale bytes"); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 9); + assert_eq!( + pinned_handle_count(&preparation), + 8, + "the failed open must not evict the oldest valid descriptor" + ); + + std::fs::rename(&moved, &candidate).expect("restore the same original inode"); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[0], + size_bytes: contents[0].len() as u64, + }, + &mut read, + ) + .expect("previous oldest handle must remain cached"); + assert_eq!(read, contents[0]); + assert_eq!( + preparation.staged_segment_read_open_attempts_for_test(), + 9, + "a failed admission must not cause a second endpoint-scanned open" + ); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &digests[8], + size_bytes: contents[8].len() as u64, + }, + &mut read, + ) + .expect("restored candidate must be readable"); + assert_eq!(read, contents[8]); + assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 10); + assert_eq!(pinned_handle_count(&preparation), 8); + preparation.abort().expect("remove staged files"); +} + #[test] fn staged_segment_publish_never_overwrites_a_concurrent_canonical_name() { let root = TempDir::new().expect("sealed segment race fixture"); From e18d2950aba824b1aaa7433b9ca971f21e8028ec Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:48:11 -0800 Subject: [PATCH 61/93] fix(code-index): reject deduplication across swapped inodes The canonical/pending file equality check compared only opened bytes. A path replaced after open could make the equality result authorize deleting a pending sealed segment while the canonical path named a different inode. Check both opened descriptors against the original named identity before and after streaming; no additional pathname open is needed. Add real nonempty filesystem comparison, equal-byte different-inode replacement during the existing tracing span, typed refusal and exact original-file recovery. This regression requires the named inode check and fails the earlier implementation. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 67 +++++++--- .../tests/immutable_segment_equality.rs | 114 ++++++++++++++++++ 2 files changed, 167 insertions(+), 14 deletions(-) create mode 100644 crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/immutable_segment_equality.rs diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 75e39ac4532..370a6634629 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -70,6 +70,10 @@ use crate::code_graph_seat::CodeGraphBuildAdmissionV1; #[path = "tests/staged_segment_reads.rs"] mod staged_segment_reads; +#[cfg(all(test, unix))] +#[path = "tests/immutable_segment_equality.rs"] +mod immutable_segment_equality; + const MAX_DURABLE_PUBLICATION_POINTER_BYTES: u64 = 512 * 1024; const DURABLE_GENERATION_IO_CHUNK_BYTES_V1: usize = 64 * 1024; @@ -1983,25 +1987,60 @@ impl DaemonCodeIndexPublicationStoreV1 { if left_metadata.len() != right_metadata.len() { return Ok(false); } - let mut left = File::open(left).map_err(Self::unavailable)?; - let mut right = File::open(right).map_err(Self::unavailable)?; + let mut left_file = File::open(left).map_err(Self::unavailable)?; + let mut right_file = File::open(right).map_err(Self::unavailable)?; + #[cfg(unix)] + { + let left_opened = left_file.metadata().map_err(Self::unavailable)?; + let right_opened = right_file.metadata().map_err(Self::unavailable)?; + if !Self::same_unix_segment_identity(&left_metadata, &left_opened) + || !Self::same_unix_segment_identity(&right_metadata, &right_opened) + { + return Err(Self::corruption( + "immutable code-index file changed while opening for comparison", + )); + } + } let mut left_buffer = vec![0_u8; DURABLE_GENERATION_IO_CHUNK_BYTES_V1]; let mut right_buffer = vec![0_u8; DURABLE_GENERATION_IO_CHUNK_BYTES_V1]; - loop { - let left_read = left.read(&mut left_buffer).map_err(Self::unavailable)?; - if left_read == 0 { - return Ok(right - .read(&mut right_buffer[..1]) - .map_err(Self::unavailable)? - == 0); + // Content equality authorizes discarding the pending canonical copy. + // A slow endpoint-scanned read must not authorize a swapped pathname. + let equal = { + let _span = + tracing::trace_span!("code_index.generation.compare_immutable_bytes").entered(); + loop { + let left_read = left_file.read(&mut left_buffer).map_err(Self::unavailable)?; + if left_read == 0 { + break right_file + .read(&mut right_buffer[..1]) + .map_err(Self::unavailable)? + == 0; + } + right_file + .read_exact(&mut right_buffer[..left_read]) + .map_err(Self::unavailable)?; + if left_buffer[..left_read] != right_buffer[..left_read] { + break false; + } } - right - .read_exact(&mut right_buffer[..left_read]) - .map_err(Self::unavailable)?; - if left_buffer[..left_read] != right_buffer[..left_read] { - return Ok(false); + }; + #[cfg(unix)] + { + let left_named = left.symlink_metadata().map_err(Self::unavailable)?; + let right_named = right.symlink_metadata().map_err(Self::unavailable)?; + let left_opened = left_file.metadata().map_err(Self::unavailable)?; + let right_opened = right_file.metadata().map_err(Self::unavailable)?; + if !Self::same_unix_segment_identity(&left_metadata, &left_named) + || !Self::same_unix_segment_identity(&left_metadata, &left_opened) + || !Self::same_unix_segment_identity(&right_metadata, &right_named) + || !Self::same_unix_segment_identity(&right_metadata, &right_opened) + { + return Err(Self::corruption( + "immutable code-index file changed during comparison", + )); } } + Ok(equal) } pub(super) fn state_digest(bytes: &[u8]) -> String { diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/immutable_segment_equality.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/immutable_segment_equality.rs new file mode 100644 index 00000000000..c85edecfc8b --- /dev/null +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/immutable_segment_equality.rs @@ -0,0 +1,114 @@ +//! Immutable deduplication must never accept bytes from a displaced pathname. +use std::fs; +use std::os::unix::fs::MetadataExt; +use std::path::PathBuf; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use tempfile::TempDir; +use tracing::level_filters::LevelFilter; +use tracing::span::{Attributes, Id, Record}; +use tracing::{Event, Metadata, Subscriber}; + +use super::DaemonCodeIndexPublicationStoreV1; + +struct ReplaceCanonicalDuringComparison { + canonical: PathBuf, + displaced: PathBuf, + replacement: PathBuf, + observed: Arc, +} + +impl Subscriber for ReplaceCanonicalDuringComparison { + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + metadata.is_span() && metadata.name() == "code_index.generation.compare_immutable_bytes" + } + + fn max_level_hint(&self) -> Option { + Some(LevelFilter::TRACE) + } + + fn new_span(&self, _attributes: &Attributes<'_>) -> Id { + Id::from_u64(1) + } + + fn record(&self, _span: &Id, _values: &Record<'_>) {} + + fn record_follows_from(&self, _span: &Id, _follows: &Id) {} + + fn event(&self, _event: &Event<'_>) {} + + fn enter(&self, _span: &Id) { + assert_eq!( + self.observed.fetch_add(1, Ordering::SeqCst), + 0, + "the byte comparison must enter exactly once" + ); + // Both real files were already opened; substitute an equal-byte new + // inode so comparing descriptor bytes alone incorrectly returns true. + fs::rename(&self.canonical, &self.displaced).expect("displace original inode"); + fs::rename(&self.replacement, &self.canonical).expect("install different inode"); + } + + fn exit(&self, _span: &Id) {} +} + +#[test] +fn canonical_deduplication_refuses_same_byte_path_replacement_during_read() { + let root = TempDir::new().expect("private immutable comparison fixture"); + let canonical = root.path().join("segment-canonical.json"); + let pending = root.path().join("segment-pending.json"); + let displaced = root.path().join("segment-displaced.json"); + let replacement = root.path().join("segment-replacement.json"); + let bytes = b"nonempty deterministic immutable segment bytes"; + fs::write(&canonical, bytes).expect("canonical content"); + fs::write(&pending, bytes).expect("pending content"); + fs::write(&replacement, bytes).expect("replacement content"); + + let canonical_identity = fs::symlink_metadata(&canonical).expect("canonical identity"); + let replacement_identity = fs::symlink_metadata(&replacement).expect("replacement identity"); + assert_ne!( + (canonical_identity.dev(), canonical_identity.ino()), + (replacement_identity.dev(), replacement_identity.ino()), + "the replacement must be a real, different inode" + ); + assert!( + DaemonCodeIndexPublicationStoreV1::files_equal(&canonical, &pending) + .expect("intact files compare equal"), + "positive control reads two nonempty actual files" + ); + + let observed = Arc::new(AtomicUsize::new(0)); + let swap = ReplaceCanonicalDuringComparison { + canonical: canonical.clone(), + displaced: displaced.clone(), + replacement, + observed: Arc::clone(&observed), + }; + let result = tracing::subscriber::with_default(swap, || { + DaemonCodeIndexPublicationStoreV1::files_equal(&canonical, &pending) + }); + assert_eq!(observed.load(Ordering::SeqCst), 1); + let error = result.expect_err("a changed canonical name cannot authorize deduplication"); + assert!( + error.to_string().contains("changed during comparison"), + "the exact inode change must be the refusal: {error}" + ); + assert_eq!(fs::read(&canonical).expect("replacement bytes"), bytes); + assert_eq!(fs::read(&pending).expect("pending bytes"), bytes); + let installed = fs::symlink_metadata(&canonical).expect("installed identity"); + assert_eq!( + (installed.dev(), installed.ino()), + (replacement_identity.dev(), replacement_identity.ino()) + ); + + fs::remove_file(&canonical).expect("discard substituted fixture"); + fs::rename(&displaced, &canonical).expect("restore original fixture inode"); + assert!( + DaemonCodeIndexPublicationStoreV1::files_equal(&canonical, &pending) + .expect("recovered exact original comparison"), + "the original bytes still deduplicate after recovery" + ); +} From 8e17075f148cd797dc0e84891f95a2a0d246f49c Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:05:16 -0800 Subject: [PATCH 62/93] fix(code-index): retain staged inode authority through deduplication A competing canonical name may appear while a staged segment is pending. The byte comparator re-stats both filenames and can accept a same-byte replacement of the temporary after its earlier authenticated inode check. Validate the original staged inode again after equality, including the no-replace collision path, before deleting the authenticated temporary. This adds metadata checks, not endpoint-authorized pathname opens. Add a real staged-writer regression that changes the temporary inode at the start of canonical comparison, proves the exact bytes and preserved pending ownership, and refuses a stale witness even after inode restore. Measure full comparison pathname-open latency in a production trace span. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 70 +++++++--- .../tests/immutable_segment_equality.rs | 121 +++++++++++++++++- .../tests/staged_segment_reads.rs | 8 +- 3 files changed, 174 insertions(+), 25 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 370a6634629..67752f6013a 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -1117,11 +1117,35 @@ impl StagedGenerationSegmentsV1 { self.pending.contains_key(final_path) } + // A staged inode was authenticated before the collision read. The + // comparator proves two CURRENT pathnames match; it cannot prove that a + // staged pathname still names the original authenticated bytes. + fn validate_authenticated_temporary( + temporary: &Path, + authenticated: Option<&std::fs::Metadata>, + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + #[cfg(unix)] + if let Some(original) = authenticated { + let current = temporary + .symlink_metadata() + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + if !DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity(original, ¤t) { + return Err(DaemonCodeIndexPublicationStoreV1::corruption( + "staged sealed segment changed during canonical deduplication", + )); + } + } + #[cfg(not(unix))] + let _ = (temporary, authenticated); + Ok(()) + } + // A concurrent publisher can create the final name after our missing-path // probe. Never replace an immutable content-addressed file in that gap. fn publish_missing_immutable_file( temporary: &Path, final_path: &Path, + authenticated_temporary: Option<&std::fs::Metadata>, ) -> Result<(), CodeIndexPublicationStoreErrorV1> { match tracedecay_private_fs::framed_log::rename_noreplace(temporary, final_path) { Ok(()) => Ok(()), @@ -1136,6 +1160,7 @@ impl StagedGenerationSegmentsV1 { "concurrently published immutable code-index file does not match its content address", )); } + Self::validate_authenticated_temporary(temporary, authenticated_temporary)?; std::fs::remove_file(temporary) .map_err(DaemonCodeIndexPublicationStoreV1::unavailable) } @@ -1162,23 +1187,14 @@ impl StagedGenerationSegmentsV1 { // Bind every pending pathname to the bytes authenticated during // staging before any immutable canonical name is published. #[cfg(unix)] - { - let original = self.authenticated_segments.get(&temporary).ok_or_else(|| { - DaemonCodeIndexPublicationStoreV1::corruption( - "staged sealed segment lacks its authenticated inode", - ) - })?; - let current = temporary - .symlink_metadata() - .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; - if !DaemonCodeIndexPublicationStoreV1::same_unix_segment_identity( - original, ¤t, - ) { - return Err(DaemonCodeIndexPublicationStoreV1::corruption( - "staged sealed segment changed before immutable publication", - )); - } - } + let authenticated = Some(self.authenticated_segments.get(&temporary).ok_or_else(|| { + DaemonCodeIndexPublicationStoreV1::corruption( + "staged sealed segment lacks its authenticated inode", + ) + })?); + #[cfg(not(unix))] + let authenticated = None; + Self::validate_authenticated_temporary(&temporary, authenticated)?; match final_path.symlink_metadata() { Ok(metadata) => { if !metadata.file_type().is_file() @@ -1188,11 +1204,19 @@ impl StagedGenerationSegmentsV1 { "existing sealed segment does not match its content address", )); } + // The equality read can observe a new, byte-identical + // temporary inode after our pre-comparison witness check. + // Never discard the authenticated staged copy on that basis. + Self::validate_authenticated_temporary(&temporary, authenticated)?; std::fs::remove_file(&temporary) .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - Self::publish_missing_immutable_file(&temporary, &final_path)?; + Self::publish_missing_immutable_file( + &temporary, + &final_path, + authenticated, + )?; } Err(error) => return Err(DaemonCodeIndexPublicationStoreV1::unavailable(error)), } @@ -1973,10 +1997,18 @@ impl DaemonCodeIndexPublicationStoreV1 { final_path: &Path, ) -> Result<(), CodeIndexPublicationStoreErrorV1> { let temporary = temporary.into_temp_path(); - StagedGenerationSegmentsV1::publish_missing_immutable_file(temporary.as_ref(), final_path) + StagedGenerationSegmentsV1::publish_missing_immutable_file( + temporary.as_ref(), + final_path, + None, + ) } fn files_equal(left: &Path, right: &Path) -> Result { + // Measure the endpoint-authorized pathname opens as well as the byte + // loop, which has its own narrower span below. + let _pathname_span = + tracing::trace_span!("code_index.generation.compare_immutable_pathnames").entered(); let left_metadata = left.symlink_metadata().map_err(Self::unavailable)?; let right_metadata = right.symlink_metadata().map_err(Self::unavailable)?; if !left_metadata.file_type().is_file() || !right_metadata.file_type().is_file() { diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/immutable_segment_equality.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/immutable_segment_equality.rs index c85edecfc8b..e6fd9b784f8 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/immutable_segment_equality.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/immutable_segment_equality.rs @@ -4,15 +4,17 @@ use std::os::unix::fs::MetadataExt; use std::path::PathBuf; use std::sync::{ Arc, - atomic::{AtomicUsize, Ordering}, + atomic::{AtomicU64, AtomicUsize, Ordering}, }; +use sha2::{Digest, Sha256}; use tempfile::TempDir; +use tracedecay_domain::{ManifestDigest, sha256_hex_suffix}; use tracing::level_filters::LevelFilter; use tracing::span::{Attributes, Id, Record}; use tracing::{Event, Metadata, Subscriber}; -use super::DaemonCodeIndexPublicationStoreV1; +use super::{DaemonCodeIndexPublicationStoreV1, StagedGenerationSegmentsV1}; struct ReplaceCanonicalDuringComparison { canonical: PathBuf, @@ -112,3 +114,118 @@ fn canonical_deduplication_refuses_same_byte_path_replacement_during_read() { "the original bytes still deduplicate after recovery" ); } + +struct ReplacePendingBeforeComparison { + pending: PathBuf, + displaced: PathBuf, + replacement: PathBuf, + observed: Arc, +} + +impl Subscriber for ReplacePendingBeforeComparison { + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + metadata.is_span() && metadata.name() == "code_index.generation.compare_immutable_pathnames" + } + + fn max_level_hint(&self) -> Option { + Some(LevelFilter::TRACE) + } + + fn new_span(&self, _attributes: &Attributes<'_>) -> Id { + Id::from_u64(1) + } + + fn record(&self, _span: &Id, _values: &Record<'_>) {} + + fn record_follows_from(&self, _span: &Id, _follows: &Id) {} + + fn event(&self, _event: &Event<'_>) {} + + fn enter(&self, _span: &Id) { + assert_eq!( + self.observed.fetch_add(1, Ordering::SeqCst), + 0, + "a single canonical comparison must run" + ); + // The publisher already checked the original staged inode. Substitute + // a second same-byte inode BEFORE the comparator samples metadata. + fs::rename(&self.pending, &self.displaced).expect("retain original staged inode"); + fs::rename(&self.replacement, &self.pending).expect("replace staged pathname"); + } + + fn exit(&self, _span: &Id) {} +} + +#[test] +fn staged_deduplication_refuses_a_swapped_temporary_before_comparison() { + let root = TempDir::new().expect("isolated immutable publication fixture"); + let segments = root.path().join("segments"); + let temporary_root = root.path().join("staging"); + fs::create_dir(&segments).expect("canonical segment directory"); + fs::create_dir(&temporary_root).expect("staging directory"); + let bytes = b"authenticated nonempty segment for canonical deduplication"; + let digest = ManifestDigest::from_sha256_bytes(&Sha256::digest(bytes)) + .expect("content address"); + let suffix = sha256_hex_suffix(digest.as_str()).expect("canonical hex suffix"); + let canonical = segments.join(format!("segment-{suffix}.json")); + let mut staged = StagedGenerationSegmentsV1::new(); + let already_read = AtomicU64::new(0); + DaemonCodeIndexPublicationStoreV1::stage_segment( + &segments, + &temporary_root, + &already_read, + &digest, + bytes, + &mut staged, + ) + .expect("real stage with authenticated writer and durability admission"); + let temporary = staged + .pending + .get(&canonical) + .expect("pending content-addressed segment") + .clone(); + let original = fs::symlink_metadata(&temporary).expect("authenticated inode"); + fs::write(&canonical, bytes).expect("concurrently named canonical bytes"); + let replacement = temporary_root.join("replacement"); + let displaced = temporary_root.join("original"); + fs::write(&replacement, bytes).expect("same bytes in a second inode"); + let changed = fs::symlink_metadata(&replacement).expect("replacement inode"); + assert_ne!((original.dev(), original.ino()), (changed.dev(), changed.ino())); + assert_eq!(already_read.load(Ordering::Relaxed), 0); + + let observed = Arc::new(AtomicUsize::new(0)); + let swap = ReplacePendingBeforeComparison { + pending: temporary.clone(), + displaced: displaced.clone(), + replacement, + observed: Arc::clone(&observed), + }; + let result = tracing::subscriber::with_default(swap, || staged.publish()); + assert_eq!(observed.load(Ordering::SeqCst), 1); + let error = result.expect_err("a new staged inode cannot authorize deduplication"); + assert!( + error + .to_string() + .contains("changed during canonical deduplication"), + "refuse the stale staging witness: {error}" + ); + assert_eq!(fs::read(&canonical).expect("original canonical bytes"), bytes); + assert_eq!(fs::read(&temporary).expect("replacement bytes"), bytes); + assert_eq!(fs::read(&displaced).expect("original staged bytes"), bytes); + assert!(staged.pending.contains_key(&canonical)); + + fs::remove_file(&temporary).expect("remove substituted fixture"); + fs::rename(&displaced, &temporary).expect("restore the authenticated inode"); + // Rename changes ctime even when the original inode is restored. That + // original witness must remain invalid; only a fresh preparation may + // authenticate a new epoch of staged bytes. + let retry = staged.publish().expect_err("a changed witness cannot be reused"); + assert!( + retry + .to_string() + .contains("changed during canonical deduplication"), + "the previous authentication must not become reusable: {retry}" + ); + assert!(staged.pending.contains_key(&canonical)); + assert_eq!(fs::read(&canonical).expect("canonical remains intact"), bytes); +} diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index a710872a6cf..9b1945fa1cf 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -27,7 +27,7 @@ use tracedecay_domain::{ManifestDigest, ProjectId, SanitizerRevision, sha256_hex use super::{ DaemonCodeIndexPreparationStateV1, DaemonCodeIndexPreparationV1, DaemonCodeIndexPublicationStoreV1, MAX_STAGED_SEGMENT_READ_HANDLES, - PREPARED_SEGMENT_DIGEST_OPENS, SharedCodeIndexBytePoolV1, + PREPARED_SEGMENT_DIGEST_OPENS, SharedCodeIndexBytePoolV1, StagedGenerationSegmentsV1, }; use crate::code_index_scheduler::tests::GitFixture; use crate::code_index_scheduler::{CodeIndexReconcileOutcomeV1, CodeIndexWorktreeSchedulerV1}; @@ -1215,7 +1215,7 @@ fn staged_segment_publish_never_overwrites_a_concurrent_canonical_name() { std::fs::write(&canonical, original).expect("competing canonical bytes"); std::fs::write(&temporary, changed).expect("staged bytes"); assert!( - super::StagedGenerationSegmentsV1::publish_missing_immutable_file(&temporary, &canonical) + StagedGenerationSegmentsV1::publish_missing_immutable_file(&temporary, &canonical, None) .is_err(), "a race must refuse, not replace a different immutable segment" ); @@ -1231,7 +1231,7 @@ fn staged_segment_publish_never_overwrites_a_concurrent_canonical_name() { // When the competing publisher installed identical bytes, deduplication // may finish without another publication or destroying canonical bytes. std::fs::write(&temporary, original).expect("identical staged bytes"); - super::StagedGenerationSegmentsV1::publish_missing_immutable_file(&temporary, &canonical) + StagedGenerationSegmentsV1::publish_missing_immutable_file(&temporary, &canonical, None) .expect("identical concurrent publication is safe to deduplicate"); assert!(!temporary.exists()); assert_eq!( @@ -1241,7 +1241,7 @@ fn staged_segment_publish_never_overwrites_a_concurrent_canonical_name() { std::fs::remove_file(&canonical).expect("vacate canonical name"); std::fs::write(&temporary, changed).expect("new staged segment"); - super::StagedGenerationSegmentsV1::publish_missing_immutable_file(&temporary, &canonical) + StagedGenerationSegmentsV1::publish_missing_immutable_file(&temporary, &canonical, None) .expect("missing canonical name accepts one atomic publication"); assert!(!temporary.exists()); assert_eq!( From 52b335d2523cb28fc441a602a39197e59a3b78c4 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:51:45 -0800 Subject: [PATCH 63/93] fix(code-index): honor tracing capture policy in storage regressions Replace unsafe lone scoped subscribers with the canonical callsite-safe capture helper. Move the Linux final-barrier integrity regression into the daemon caller crate to reuse that helper without a filesystem/kernel dependency cycle. Keep its real fsync, nonempty 33-file batch, same-byte inode substitution, byte-exact refusal, and failed retry checks. Apply the exact hosted Bazel rustfmt and empty-assert fixes while preserving existing tests and production behavior. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 25 ++++---- .../tests/durable_batch_snapshot.rs} | 64 +++++++++---------- .../tests/immutable_segment_equality.rs | 34 +++++++--- .../tests/staged_segment_reads.rs | 20 ++++-- .../tracedecay-private-fs/src/framed_log.rs | 4 -- 5 files changed, 82 insertions(+), 65 deletions(-) rename crates/{tracedecay-private-fs/src/framed_log_snapshot_tests.rs => tracedecay-code-index-runtime/src/code_index_scheduler/tests/durable_batch_snapshot.rs} (65%) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 67752f6013a..42cff3514d8 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -74,6 +74,10 @@ mod staged_segment_reads; #[path = "tests/immutable_segment_equality.rs"] mod immutable_segment_equality; +#[cfg(all(test, target_os = "linux"))] +#[path = "tests/durable_batch_snapshot.rs"] +mod durable_batch_snapshot; + const MAX_DURABLE_PUBLICATION_POINTER_BYTES: u64 = 512 * 1024; const DURABLE_GENERATION_IO_CHUNK_BYTES_V1: usize = 64 * 1024; @@ -1187,11 +1191,12 @@ impl StagedGenerationSegmentsV1 { // Bind every pending pathname to the bytes authenticated during // staging before any immutable canonical name is published. #[cfg(unix)] - let authenticated = Some(self.authenticated_segments.get(&temporary).ok_or_else(|| { - DaemonCodeIndexPublicationStoreV1::corruption( - "staged sealed segment lacks its authenticated inode", - ) - })?); + let authenticated = + Some(self.authenticated_segments.get(&temporary).ok_or_else(|| { + DaemonCodeIndexPublicationStoreV1::corruption( + "staged sealed segment lacks its authenticated inode", + ) + })?); #[cfg(not(unix))] let authenticated = None; Self::validate_authenticated_temporary(&temporary, authenticated)?; @@ -1212,11 +1217,7 @@ impl StagedGenerationSegmentsV1 { .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - Self::publish_missing_immutable_file( - &temporary, - &final_path, - authenticated, - )?; + Self::publish_missing_immutable_file(&temporary, &final_path, authenticated)?; } Err(error) => return Err(DaemonCodeIndexPublicationStoreV1::unavailable(error)), } @@ -2041,7 +2042,9 @@ impl DaemonCodeIndexPublicationStoreV1 { let _span = tracing::trace_span!("code_index.generation.compare_immutable_bytes").entered(); loop { - let left_read = left_file.read(&mut left_buffer).map_err(Self::unavailable)?; + let left_read = left_file + .read(&mut left_buffer) + .map_err(Self::unavailable)?; if left_read == 0 { break right_file .read(&mut right_buffer[..1]) diff --git a/crates/tracedecay-private-fs/src/framed_log_snapshot_tests.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/durable_batch_snapshot.rs similarity index 65% rename from crates/tracedecay-private-fs/src/framed_log_snapshot_tests.rs rename to crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/durable_batch_snapshot.rs index 623ceeccd24..4525a1f8d13 100644 --- a/crates/tracedecay-private-fs/src/framed_log_snapshot_tests.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/durable_batch_snapshot.rs @@ -1,21 +1,19 @@ -//! A final durability barrier must not reuse an earlier window's stale identity. +//! Real Linux durability: an earlier flushed file may change during the final fsync. use std::fs::{self, File}; use std::io::{ErrorKind, Write}; use std::os::unix::fs::MetadataExt; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::Duration; +use tracedecay_private_fs::framed_log::DurableFileBatch; +use tracedecay_runtime_core::logging::set_tracing_capture; use tracing::level_filters::LevelFilter; use tracing::span::{Attributes, Id, Record}; use tracing::{Event, Metadata, Subscriber}; -use super::{DURABLE_BATCH_CONCURRENT_SYNCS, DurableFileBatch, sync_latency}; - -// Interleave a real rename at the existing production fsync span. The test -// leaves exactly one pending member, so its barrier executes on this thread: -// no sleeps, global subscriber, fake fsync, or production-only test hook. +// The kernel fsync remains real. The scoped subscriber substitutes a real +// same-byte inode on the final pending member's production fsync span. struct ReplaceEarlierWindowAtBarrier { destination: PathBuf, replacement: PathBuf, @@ -58,27 +56,19 @@ impl Subscriber for ReplaceEarlierWindowAtBarrier { #[test] fn durable_batch_rechecks_earlier_windows_after_the_final_barrier() { let root = tempfile::tempdir().expect("isolated durability fixture"); - let barriers = sync_latency::inject(root.path(), Duration::ZERO); let mut batch = DurableFileBatch::new(); let bytes = b"immutable batch member"; let first = root.path().join("member-0.tmp"); - for index in 0..=DURABLE_BATCH_CONCURRENT_SYNCS { + // The 33rd real writer forces the prior 32-member window to fsync. + for index in 0..33 { let path = root.path().join(format!("member-{index}.tmp")); let mut writer = File::create(&path).expect("member writer"); writer.write_all(bytes).expect("nonempty member bytes"); - batch - .written(&path, &writer) - .expect("admit immutable member"); + batch.written(&path, &writer).expect("admit immutable member"); } - assert_eq!(batch.synced.len(), DURABLE_BATCH_CONCURRENT_SYNCS); - assert_eq!(batch.pending.len(), 1); - assert_eq!(barriers.syncs(), DURABLE_BATCH_CONCURRENT_SYNCS as u64); - - batch - .sync() - .expect("intact multi-window batch must succeed"); - assert_eq!(barriers.syncs(), DURABLE_BATCH_CONCURRENT_SYNCS as u64 + 1); + batch.sync().expect("intact multi-window batch must succeed"); assert_eq!(fs::read(&first).expect("exact original bytes"), bytes); + let original = fs::symlink_metadata(&first).expect("original identity"); let replacement = root.path().join("replacement.tmp"); fs::write(&replacement, bytes).expect("same-byte distinct inode"); @@ -91,15 +81,17 @@ fn durable_batch_rechecks_earlier_windows_after_the_final_barrier() { let entered = Arc::new(AtomicUsize::new(0)); let observer = ReplaceEarlierWindowAtBarrier { destination: first.clone(), - replacement, + replacement: replacement.clone(), entered: Arc::clone(&entered), }; - let result = tracing::subscriber::with_default(observer, || batch.sync()); + let result = { + let _capture = set_tracing_capture(observer); + batch.sync() + }; assert_eq!(entered.load(Ordering::SeqCst), 1); - assert_eq!(barriers.syncs(), DURABLE_BATCH_CONCURRENT_SYNCS as u64 + 2); assert_eq!( result - .expect_err("the final barrier must not authorize a replaced earlier member") + .expect_err("the final fsync must not authorize a replaced earlier member") .kind(), ErrorKind::InvalidData ); @@ -109,15 +101,17 @@ fn durable_batch_rechecks_earlier_windows_after_the_final_barrier() { (current.dev(), current.ino()), (replacement_identity.dev(), replacement_identity.ino()) ); - assert_eq!(batch.synced.len(), DURABLE_BATCH_CONCURRENT_SYNCS); - assert_eq!(batch.pending.len(), 1); - assert_eq!( - batch.sync().expect_err("retry must still refuse").kind(), - ErrorKind::InvalidData - ); - assert_eq!( - barriers.syncs(), - DURABLE_BATCH_CONCURRENT_SYNCS as u64 + 2, - "a known-invalid earlier window must refuse before another barrier" - ); + // The invalidated batch retains its failure; it cannot run another fsync + // or silently discard the previous member on retry. + let observer = ReplaceEarlierWindowAtBarrier { + destination: first, + replacement, + entered: Arc::clone(&entered), + }; + let retry = { + let _capture = set_tracing_capture(observer); + batch.sync() + }; + assert_eq!(retry.expect_err("retry must still refuse").kind(), ErrorKind::InvalidData); + assert_eq!(entered.load(Ordering::SeqCst), 1, "invalid retry must not fsync"); } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/immutable_segment_equality.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/immutable_segment_equality.rs index e6fd9b784f8..960310540b3 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/immutable_segment_equality.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/immutable_segment_equality.rs @@ -10,6 +10,7 @@ use std::sync::{ use sha2::{Digest, Sha256}; use tempfile::TempDir; use tracedecay_domain::{ManifestDigest, sha256_hex_suffix}; +use tracedecay_runtime_core::logging::set_tracing_capture; use tracing::level_filters::LevelFilter; use tracing::span::{Attributes, Id, Record}; use tracing::{Event, Metadata, Subscriber}; @@ -89,9 +90,10 @@ fn canonical_deduplication_refuses_same_byte_path_replacement_during_read() { replacement, observed: Arc::clone(&observed), }; - let result = tracing::subscriber::with_default(swap, || { + let result = { + let _capture = set_tracing_capture(swap); DaemonCodeIndexPublicationStoreV1::files_equal(&canonical, &pending) - }); + }; assert_eq!(observed.load(Ordering::SeqCst), 1); let error = result.expect_err("a changed canonical name cannot authorize deduplication"); assert!( @@ -164,8 +166,8 @@ fn staged_deduplication_refuses_a_swapped_temporary_before_comparison() { fs::create_dir(&segments).expect("canonical segment directory"); fs::create_dir(&temporary_root).expect("staging directory"); let bytes = b"authenticated nonempty segment for canonical deduplication"; - let digest = ManifestDigest::from_sha256_bytes(&Sha256::digest(bytes)) - .expect("content address"); + let digest = + ManifestDigest::from_sha256_bytes(&Sha256::digest(bytes)).expect("content address"); let suffix = sha256_hex_suffix(digest.as_str()).expect("canonical hex suffix"); let canonical = segments.join(format!("segment-{suffix}.json")); let mut staged = StagedGenerationSegmentsV1::new(); @@ -190,7 +192,10 @@ fn staged_deduplication_refuses_a_swapped_temporary_before_comparison() { let displaced = temporary_root.join("original"); fs::write(&replacement, bytes).expect("same bytes in a second inode"); let changed = fs::symlink_metadata(&replacement).expect("replacement inode"); - assert_ne!((original.dev(), original.ino()), (changed.dev(), changed.ino())); + assert_ne!( + (original.dev(), original.ino()), + (changed.dev(), changed.ino()) + ); assert_eq!(already_read.load(Ordering::Relaxed), 0); let observed = Arc::new(AtomicUsize::new(0)); @@ -200,7 +205,10 @@ fn staged_deduplication_refuses_a_swapped_temporary_before_comparison() { replacement, observed: Arc::clone(&observed), }; - let result = tracing::subscriber::with_default(swap, || staged.publish()); + let result = { + let _capture = set_tracing_capture(swap); + staged.publish() + }; assert_eq!(observed.load(Ordering::SeqCst), 1); let error = result.expect_err("a new staged inode cannot authorize deduplication"); assert!( @@ -209,7 +217,10 @@ fn staged_deduplication_refuses_a_swapped_temporary_before_comparison() { .contains("changed during canonical deduplication"), "refuse the stale staging witness: {error}" ); - assert_eq!(fs::read(&canonical).expect("original canonical bytes"), bytes); + assert_eq!( + fs::read(&canonical).expect("original canonical bytes"), + bytes + ); assert_eq!(fs::read(&temporary).expect("replacement bytes"), bytes); assert_eq!(fs::read(&displaced).expect("original staged bytes"), bytes); assert!(staged.pending.contains_key(&canonical)); @@ -219,7 +230,9 @@ fn staged_deduplication_refuses_a_swapped_temporary_before_comparison() { // Rename changes ctime even when the original inode is restored. That // original witness must remain invalid; only a fresh preparation may // authenticate a new epoch of staged bytes. - let retry = staged.publish().expect_err("a changed witness cannot be reused"); + let retry = staged + .publish() + .expect_err("a changed witness cannot be reused"); assert!( retry .to_string() @@ -227,5 +240,8 @@ fn staged_deduplication_refuses_a_swapped_temporary_before_comparison() { "the previous authentication must not become reusable: {retry}" ); assert!(staged.pending.contains_key(&canonical)); - assert_eq!(fs::read(&canonical).expect("canonical remains intact"), bytes); + assert_eq!( + fs::read(&canonical).expect("canonical remains intact"), + bytes + ); } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 9b1945fa1cf..d7eb52af368 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -23,6 +23,7 @@ use tracedecay_code_index::production::{ }; use tracedecay_code_index_retention::code_index_generations::DurableSealedCodeGenerationIdentityV1; use tracedecay_domain::{ManifestDigest, ProjectId, SanitizerRevision, sha256_hex_suffix}; +use tracedecay_runtime_core::logging::set_tracing_capture; use super::{ DaemonCodeIndexPreparationStateV1, DaemonCodeIndexPreparationV1, @@ -309,14 +310,14 @@ fn staged_segment_cold_digest_mismatch_poison_survives_restoration() { Err(CodeIndexProductionErrorV1::Contract(detail)) if detail.contains("identity was invalidated") )); - assert!(read.is_empty()); + assert_eq!(read, Vec::::new()); std::fs::write(&path, original).expect("restore exact original bytes"); assert!(matches!( reader(request, &mut read), Err(CodeIndexProductionErrorV1::Contract(detail)) if detail.contains("identity was invalidated") )); - assert!(read.is_empty()); + assert_eq!(read, Vec::::new()); assert_eq!( preparation.staged_segment_read_open_attempts_for_test(), opens_before + 1, @@ -1153,7 +1154,8 @@ fn staged_failed_cold_open_preserves_eight_healthy_handles() { observed: Arc::clone(&observed), }; read.extend_from_slice(b"stale before the failed open"); - let refused = tracing::subscriber::with_default(injector, || { + let refused = { + let _capture = set_tracing_capture(injector); reader( SealedGenerationSegmentReadV1::Whole { digest: &digests[8], @@ -1161,10 +1163,16 @@ fn staged_failed_cold_open_preserves_eight_healthy_handles() { }, &mut read, ) - }); - assert!(matches!(refused, Err(CodeIndexProductionErrorV1::Contract(_)))); + }; + assert!(matches!( + refused, + Err(CodeIndexProductionErrorV1::Contract(_)) + )); assert_eq!(observed.load(Ordering::SeqCst), 1); - assert!(read.is_empty(), "a failed first open cannot return stale bytes"); + assert!( + read.is_empty(), + "a failed first open cannot return stale bytes" + ); assert_eq!(preparation.staged_segment_read_open_attempts_for_test(), 9); assert_eq!( pinned_handle_count(&preparation), diff --git a/crates/tracedecay-private-fs/src/framed_log.rs b/crates/tracedecay-private-fs/src/framed_log.rs index be56f6112fa..636bdca88f1 100644 --- a/crates/tracedecay-private-fs/src/framed_log.rs +++ b/crates/tracedecay-private-fs/src/framed_log.rs @@ -22,10 +22,6 @@ use windows_sys::Win32::Storage::FileSystem::{ #[path = "framed_log_batch_tests.rs"] mod batch_tests; -#[cfg(all(test, target_os = "linux", feature = "test-helpers"))] -#[path = "framed_log_snapshot_tests.rs"] -mod snapshot_tests; - #[cfg(test)] thread_local! { static DIRECTORY_SYNC_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; From 3d12658a88e2cc5c7715b20945711673c1858eed Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:53:01 -0800 Subject: [PATCH 64/93] style(code-index): format durability retry assertions Match repository rustfmt's multiline assertion style in the relocated real-fsync regression. Keep its same-inode refusal and retry behavior. Refs #4533 --- .../tests/durable_batch_snapshot.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/durable_batch_snapshot.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/durable_batch_snapshot.rs index 4525a1f8d13..bb218f202e9 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/durable_batch_snapshot.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/durable_batch_snapshot.rs @@ -112,6 +112,13 @@ fn durable_batch_rechecks_earlier_windows_after_the_final_barrier() { let _capture = set_tracing_capture(observer); batch.sync() }; - assert_eq!(retry.expect_err("retry must still refuse").kind(), ErrorKind::InvalidData); - assert_eq!(entered.load(Ordering::SeqCst), 1, "invalid retry must not fsync"); + assert_eq!( + retry.expect_err("retry must still refuse").kind(), + ErrorKind::InvalidData + ); + assert_eq!( + entered.load(Ordering::SeqCst), + 1, + "invalid retry must not fsync" + ); } From 33d1b0c8ec815abc75c7d999255059637f0dbea5 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:25:38 -0800 Subject: [PATCH 65/93] style(code-index): apply hosted Bazel rustfmt corrections Apply the two exact rustfmt hunks reported by hosted repository gates in the final durability-barrier regression. Preserve source and behavioral assertions without weakening the nonzero filesystem evidence. Refs #4533 --- .../code_index_scheduler/tests/durable_batch_snapshot.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/durable_batch_snapshot.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/durable_batch_snapshot.rs index bb218f202e9..5b654d3f03e 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/durable_batch_snapshot.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/durable_batch_snapshot.rs @@ -64,9 +64,13 @@ fn durable_batch_rechecks_earlier_windows_after_the_final_barrier() { let path = root.path().join(format!("member-{index}.tmp")); let mut writer = File::create(&path).expect("member writer"); writer.write_all(bytes).expect("nonempty member bytes"); - batch.written(&path, &writer).expect("admit immutable member"); + batch + .written(&path, &writer) + .expect("admit immutable member"); } - batch.sync().expect("intact multi-window batch must succeed"); + batch + .sync() + .expect("intact multi-window batch must succeed"); assert_eq!(fs::read(&first).expect("exact original bytes"), bytes); let original = fs::symlink_metadata(&first).expect("original identity"); From 0384da63eacf27f7973fc891ce3dccc6ea0fcba0 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:32:26 -0800 Subject: [PATCH 66/93] test(private-fs): exercise final fsync identity at owning target Rehome the final-window inode replacement regression into the filesystem owner. Extend its existing fsync test-helper delay authority with an exact pause/resume handshake, not a timed race or mocked barrier. A real 33-file batch must refuse same-byte inode replacement after a genuine final fsync, retain ownership, preserve bytes, and reject retry without further fsync. Remove the now-redundant runtime-only test registration and source. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 4 - .../tests/durable_batch_snapshot.rs | 128 ------------------ .../tracedecay-private-fs/src/framed_log.rs | 77 ++++++++++- .../src/framed_log_batch_tests.rs | 59 ++++++++ 4 files changed, 130 insertions(+), 138 deletions(-) delete mode 100644 crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/durable_batch_snapshot.rs diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 42cff3514d8..443b0b73da4 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -74,10 +74,6 @@ mod staged_segment_reads; #[path = "tests/immutable_segment_equality.rs"] mod immutable_segment_equality; -#[cfg(all(test, target_os = "linux"))] -#[path = "tests/durable_batch_snapshot.rs"] -mod durable_batch_snapshot; - const MAX_DURABLE_PUBLICATION_POINTER_BYTES: u64 = 512 * 1024; const DURABLE_GENERATION_IO_CHUNK_BYTES_V1: usize = 64 * 1024; diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/durable_batch_snapshot.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/durable_batch_snapshot.rs deleted file mode 100644 index 5b654d3f03e..00000000000 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/durable_batch_snapshot.rs +++ /dev/null @@ -1,128 +0,0 @@ -//! Real Linux durability: an earlier flushed file may change during the final fsync. -use std::fs::{self, File}; -use std::io::{ErrorKind, Write}; -use std::os::unix::fs::MetadataExt; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; - -use tracedecay_private_fs::framed_log::DurableFileBatch; -use tracedecay_runtime_core::logging::set_tracing_capture; -use tracing::level_filters::LevelFilter; -use tracing::span::{Attributes, Id, Record}; -use tracing::{Event, Metadata, Subscriber}; - -// The kernel fsync remains real. The scoped subscriber substitutes a real -// same-byte inode on the final pending member's production fsync span. -struct ReplaceEarlierWindowAtBarrier { - destination: PathBuf, - replacement: PathBuf, - entered: Arc, -} - -impl Subscriber for ReplaceEarlierWindowAtBarrier { - fn enabled(&self, metadata: &Metadata<'_>) -> bool { - metadata.is_span() && metadata.name() == "private_fs.framed_log.fsync" - } - - fn max_level_hint(&self) -> Option { - Some(LevelFilter::TRACE) - } - - fn new_span(&self, attributes: &Attributes<'_>) -> Id { - assert_eq!(attributes.metadata().name(), "private_fs.framed_log.fsync"); - Id::from_u64(1) - } - - fn record(&self, _span: &Id, _values: &Record<'_>) {} - - fn record_follows_from(&self, _span: &Id, _follows: &Id) {} - - fn event(&self, _event: &Event<'_>) {} - - fn enter(&self, _span: &Id) { - assert_eq!( - self.entered.fetch_add(1, Ordering::SeqCst), - 0, - "exactly one final member barrier must execute" - ); - fs::rename(&self.replacement, &self.destination) - .expect("replace an earlier window during the final barrier"); - } - - fn exit(&self, _span: &Id) {} -} - -#[test] -fn durable_batch_rechecks_earlier_windows_after_the_final_barrier() { - let root = tempfile::tempdir().expect("isolated durability fixture"); - let mut batch = DurableFileBatch::new(); - let bytes = b"immutable batch member"; - let first = root.path().join("member-0.tmp"); - // The 33rd real writer forces the prior 32-member window to fsync. - for index in 0..33 { - let path = root.path().join(format!("member-{index}.tmp")); - let mut writer = File::create(&path).expect("member writer"); - writer.write_all(bytes).expect("nonempty member bytes"); - batch - .written(&path, &writer) - .expect("admit immutable member"); - } - batch - .sync() - .expect("intact multi-window batch must succeed"); - assert_eq!(fs::read(&first).expect("exact original bytes"), bytes); - - let original = fs::symlink_metadata(&first).expect("original identity"); - let replacement = root.path().join("replacement.tmp"); - fs::write(&replacement, bytes).expect("same-byte distinct inode"); - let replacement_identity = fs::symlink_metadata(&replacement).expect("replacement identity"); - assert_ne!( - (original.dev(), original.ino()), - (replacement_identity.dev(), replacement_identity.ino()) - ); - - let entered = Arc::new(AtomicUsize::new(0)); - let observer = ReplaceEarlierWindowAtBarrier { - destination: first.clone(), - replacement: replacement.clone(), - entered: Arc::clone(&entered), - }; - let result = { - let _capture = set_tracing_capture(observer); - batch.sync() - }; - assert_eq!(entered.load(Ordering::SeqCst), 1); - assert_eq!( - result - .expect_err("the final fsync must not authorize a replaced earlier member") - .kind(), - ErrorKind::InvalidData - ); - assert_eq!(fs::read(&first).expect("replacement readback"), bytes); - let current = fs::symlink_metadata(&first).expect("current identity"); - assert_eq!( - (current.dev(), current.ino()), - (replacement_identity.dev(), replacement_identity.ino()) - ); - // The invalidated batch retains its failure; it cannot run another fsync - // or silently discard the previous member on retry. - let observer = ReplaceEarlierWindowAtBarrier { - destination: first, - replacement, - entered: Arc::clone(&entered), - }; - let retry = { - let _capture = set_tracing_capture(observer); - batch.sync() - }; - assert_eq!( - retry.expect_err("retry must still refuse").kind(), - ErrorKind::InvalidData - ); - assert_eq!( - entered.load(Ordering::SeqCst), - 1, - "invalid retry must not fsync" - ); -} diff --git a/crates/tracedecay-private-fs/src/framed_log.rs b/crates/tracedecay-private-fs/src/framed_log.rs index 636bdca88f1..3d05ecd82a2 100644 --- a/crates/tracedecay-private-fs/src/framed_log.rs +++ b/crates/tracedecay-private-fs/src/framed_log.rs @@ -491,7 +491,7 @@ fn remove_owned_temp(path: &Path) { pub mod sync_latency { use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::{Arc, Mutex, PoisonError}; + use std::sync::{Arc, Mutex, PoisonError, mpsc}; use std::time::Duration; struct Injection { @@ -499,6 +499,29 @@ pub mod sync_latency { root: PathBuf, delay: Duration, syncs: Arc, + pause: Option>, + } + + // A fixture can synchronize exactly at a real fsync, without wall-time + // races, mock barriers, or changes to production file I/O. + struct SyncPauseSignal { + entered: mpsc::Sender<()>, + resume: Mutex>, + } + + pub struct SyncPause { + entered: mpsc::Receiver<()>, + resume: mpsc::Sender<()>, + } + + impl SyncPause { + pub fn wait_for_sync(&self, timeout: Duration) -> bool { + self.entered.recv_timeout(timeout).is_ok() + } + + pub fn resume(self) { + let _ = self.resume.send(()); + } } static INJECTIONS: Mutex> = Mutex::new(Vec::new()); @@ -527,9 +550,11 @@ pub mod sync_latency { } } - /// Delay every fsync of `root` or a path below it by `delay`. - #[must_use] - pub fn inject(root: &Path, delay: Duration) -> SyncLatencyGuard { + fn register( + root: &Path, + delay: Duration, + pause: Option>, + ) -> SyncLatencyGuard { let id = NEXT_ID.fetch_add(1, Ordering::SeqCst); let syncs = Arc::new(AtomicU64::new(0)); INJECTIONS @@ -540,19 +565,59 @@ pub mod sync_latency { root: root.to_path_buf(), delay, syncs: Arc::clone(&syncs), + pause, }); SyncLatencyGuard { id, syncs } } + /// Delay every fsync of root or a path below it by delay. + #[must_use] + pub fn inject(root: &Path, delay: Duration) -> SyncLatencyGuard { + register(root, delay, None) + } + + /// Pause one actual barrier under a private fixture root until released. + #[must_use] + pub fn pause(root: &Path) -> (SyncLatencyGuard, SyncPause) { + let (entered_sender, entered_receiver) = mpsc::channel(); + let (resume_sender, resume_receiver) = mpsc::channel(); + let signal = SyncPauseSignal { + entered: entered_sender, + resume: Mutex::new(resume_receiver), + }; + let guard = register(root, Duration::ZERO, Some(Arc::new(signal))); + ( + guard, + SyncPause { + entered: entered_receiver, + resume: resume_sender, + }, + ) + } + pub(super) fn before_sync(path: &Path) { let matched = INJECTIONS .lock() .unwrap_or_else(PoisonError::into_inner) .iter() .find(|injection| path.starts_with(&injection.root)) - .map(|injection| (injection.delay, Arc::clone(&injection.syncs))); - if let Some((delay, syncs)) = matched { + .map(|injection| { + ( + injection.delay, + Arc::clone(&injection.syncs), + injection.pause.clone(), + ) + }); + if let Some((delay, syncs, pause)) = matched { syncs.fetch_add(1, Ordering::SeqCst); + if let Some(pause) = pause { + let _ = pause.entered.send(()); + let _ = pause + .resume + .lock() + .unwrap_or_else(PoisonError::into_inner) + .recv(); + } std::thread::sleep(delay); } } diff --git a/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs b/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs index db6c058d73e..b23cf86d465 100644 --- a/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs +++ b/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs @@ -378,3 +378,62 @@ fn durable_batch_failed_window_keeps_handles_and_does_not_admit_another_member() ErrorKind::NotFound ); } + +#[cfg(feature = "test-helpers")] +#[test] +fn durable_batch_checks_earlier_windows_after_the_last_barrier() { + let root = tempfile::tempdir().expect("isolated durability root"); + let mut batch = DurableFileBatch::new(); + // 33 real writers: the first window has 32 completed fsyncs and the + // final window still owns its one descriptor. + for index in 0..=DURABLE_BATCH_CONCURRENT_SYNCS { + write_member(&mut batch, root.path(), index); + } + assert_eq!(batch.synced.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + assert_eq!(batch.pending.len(), 1); + batch.sync().expect("original batch is valid"); + + let first = root.path().join("segment-0.tmp"); + let original = fs::symlink_metadata(&first).expect("original inode"); + let replacement = root.path().join("replacement.tmp"); + fs::write(&replacement, b"sealed segment 0").expect("same-byte replacement"); + let other = fs::symlink_metadata(&replacement).expect("replacement inode"); + assert_ne!((original.dev(), original.ino()), (other.dev(), other.ino())); + + // The existing fsync test fixture pauses at the actual barrier, so + // replacement happens after preflight but before fsync returns. + let (barriers, pause) = sync_latency::pause(root.path()); + let result = std::thread::scope(|scope| { + let worker = scope.spawn(|| batch.sync()); + let reached = pause.wait_for_sync(Duration::from_secs(10)); + let replaced = if reached { + fs::rename(&replacement, &first) + } else { + Ok(()) + }; + pause.resume(); + assert!(reached, "one real final-window barrier must be observed"); + replaced.expect("replace earlier inode while final barrier is paused"); + worker.join().expect("fsync worker") + }); + assert_eq!(barriers.syncs(), 1, "one real fsync must have executed"); + assert_eq!( + result + .expect_err("the changed earlier inode must never publish") + .kind(), + ErrorKind::InvalidData + ); + assert_eq!( + fs::read(&first).expect("replacement remains intact"), + b"sealed segment 0" + ); + let installed = fs::symlink_metadata(&first).expect("installed inode"); + assert_eq!((installed.dev(), installed.ino()), (other.dev(), other.ino())); + assert_eq!(batch.synced.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + assert_eq!(batch.pending.len(), 1); + assert_eq!( + batch.sync().expect_err("invalidated batch must keep refusing").kind(), + ErrorKind::InvalidData + ); + assert_eq!(barriers.syncs(), 1, "failed retry must not issue another fsync"); +} From 6c0b52ed3a9070691e0ace5e0be7473fdcb131b8 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:43:11 -0800 Subject: [PATCH 67/93] fix(storage): keep fsync pause crate-test-only Resolve the review finding by limiting the barrier pause API, channel state and registration to the owning crate's test configuration. The existing public latency injection remains unchanged for consumers. Apply the exact three rustfmt corrections confirmed by hosted Bazel. Preserve the 33-member real-fsync corruption regression and assertions. Refs #4533 --- .../tracedecay-private-fs/src/framed_log.rs | 55 +++++++++++++------ .../src/framed_log_batch_tests.rs | 16 +++++- 2 files changed, 52 insertions(+), 19 deletions(-) diff --git a/crates/tracedecay-private-fs/src/framed_log.rs b/crates/tracedecay-private-fs/src/framed_log.rs index 3d05ecd82a2..49bec1e5bda 100644 --- a/crates/tracedecay-private-fs/src/framed_log.rs +++ b/crates/tracedecay-private-fs/src/framed_log.rs @@ -491,7 +491,9 @@ fn remove_owned_temp(path: &Path) { pub mod sync_latency { use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::{Arc, Mutex, PoisonError, mpsc}; + use std::sync::{Arc, Mutex, PoisonError}; + #[cfg(test)] + use std::sync::mpsc; use std::time::Duration; struct Injection { @@ -499,27 +501,31 @@ pub mod sync_latency { root: PathBuf, delay: Duration, syncs: Arc, + #[cfg(test)] pause: Option>, } // A fixture can synchronize exactly at a real fsync, without wall-time // races, mock barriers, or changes to production file I/O. + #[cfg(test)] struct SyncPauseSignal { entered: mpsc::Sender<()>, resume: Mutex>, } - pub struct SyncPause { + #[cfg(test)] + pub(super) struct SyncPause { entered: mpsc::Receiver<()>, resume: mpsc::Sender<()>, } + #[cfg(test)] impl SyncPause { - pub fn wait_for_sync(&self, timeout: Duration) -> bool { + pub(super) fn wait_for_sync(&self, timeout: Duration) -> bool { self.entered.recv_timeout(timeout).is_ok() } - pub fn resume(self) { + pub(super) fn resume(self) { let _ = self.resume.send(()); } } @@ -553,6 +559,7 @@ pub mod sync_latency { fn register( root: &Path, delay: Duration, + #[cfg(test)] pause: Option>, ) -> SyncLatencyGuard { let id = NEXT_ID.fetch_add(1, Ordering::SeqCst); @@ -565,6 +572,7 @@ pub mod sync_latency { root: root.to_path_buf(), delay, syncs: Arc::clone(&syncs), + #[cfg(test)] pause, }); SyncLatencyGuard { id, syncs } @@ -573,12 +581,20 @@ pub mod sync_latency { /// Delay every fsync of root or a path below it by delay. #[must_use] pub fn inject(root: &Path, delay: Duration) -> SyncLatencyGuard { - register(root, delay, None) + #[cfg(test)] + { + register(root, delay, None) + } + #[cfg(not(test))] + { + register(root, delay) + } } /// Pause one actual barrier under a private fixture root until released. + #[cfg(test)] #[must_use] - pub fn pause(root: &Path) -> (SyncLatencyGuard, SyncPause) { + pub(super) fn pause(root: &Path) -> (SyncLatencyGuard, SyncPause) { let (entered_sender, entered_receiver) = mpsc::channel(); let (resume_sender, resume_receiver) = mpsc::channel(); let signal = SyncPauseSignal { @@ -595,22 +611,29 @@ pub mod sync_latency { ) } + struct MatchedInjection { + delay: Duration, + syncs: Arc, + #[cfg(test)] + pause: Option>, + } + pub(super) fn before_sync(path: &Path) { let matched = INJECTIONS .lock() .unwrap_or_else(PoisonError::into_inner) .iter() .find(|injection| path.starts_with(&injection.root)) - .map(|injection| { - ( - injection.delay, - Arc::clone(&injection.syncs), - injection.pause.clone(), - ) + .map(|injection| MatchedInjection { + delay: injection.delay, + syncs: Arc::clone(&injection.syncs), + #[cfg(test)] + pause: injection.pause.clone(), }); - if let Some((delay, syncs, pause)) = matched { - syncs.fetch_add(1, Ordering::SeqCst); - if let Some(pause) = pause { + if let Some(matched) = matched { + matched.syncs.fetch_add(1, Ordering::SeqCst); + #[cfg(test)] + if let Some(pause) = matched.pause { let _ = pause.entered.send(()); let _ = pause .resume @@ -618,7 +641,7 @@ pub mod sync_latency { .unwrap_or_else(PoisonError::into_inner) .recv(); } - std::thread::sleep(delay); + std::thread::sleep(matched.delay); } } } diff --git a/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs b/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs index b23cf86d465..49397878e4c 100644 --- a/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs +++ b/crates/tracedecay-private-fs/src/framed_log_batch_tests.rs @@ -428,12 +428,22 @@ fn durable_batch_checks_earlier_windows_after_the_last_barrier() { b"sealed segment 0" ); let installed = fs::symlink_metadata(&first).expect("installed inode"); - assert_eq!((installed.dev(), installed.ino()), (other.dev(), other.ino())); + assert_eq!( + (installed.dev(), installed.ino()), + (other.dev(), other.ino()) + ); assert_eq!(batch.synced.len(), DURABLE_BATCH_CONCURRENT_SYNCS); assert_eq!(batch.pending.len(), 1); assert_eq!( - batch.sync().expect_err("invalidated batch must keep refusing").kind(), + batch + .sync() + .expect_err("invalidated batch must keep refusing") + .kind(), ErrorKind::InvalidData ); - assert_eq!(barriers.syncs(), 1, "failed retry must not issue another fsync"); + assert_eq!( + barriers.syncs(), + 1, + "failed retry must not issue another fsync" + ); } From ff7015e4176952a770b2198fb41b2e0e0f36d092 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 10:32:41 -0800 Subject: [PATCH 68/93] style(private-fs): apply hosted Bazel rustfmt corrections --- crates/tracedecay-private-fs/src/framed_log.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-private-fs/src/framed_log.rs b/crates/tracedecay-private-fs/src/framed_log.rs index 49bec1e5bda..96ce7e3286a 100644 --- a/crates/tracedecay-private-fs/src/framed_log.rs +++ b/crates/tracedecay-private-fs/src/framed_log.rs @@ -491,9 +491,9 @@ fn remove_owned_temp(path: &Path) { pub mod sync_latency { use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::{Arc, Mutex, PoisonError}; #[cfg(test)] use std::sync::mpsc; + use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; struct Injection { @@ -559,8 +559,7 @@ pub mod sync_latency { fn register( root: &Path, delay: Duration, - #[cfg(test)] - pause: Option>, + #[cfg(test)] pause: Option>, ) -> SyncLatencyGuard { let id = NEXT_ID.fetch_add(1, Ordering::SeqCst); let syncs = Arc::new(AtomicU64::new(0)); From dacc5fc62f626af2334df7d40cfa206e0ab883dd Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 10:51:16 -0800 Subject: [PATCH 69/93] perf(code-index): reuse retained segment descriptors Reuse at most eight verified sealed segment handles for repeated retained generation page reads. Revalidate pathname and descriptor identities on hits and after reads. A Linux filesystem regression counts real opens, checks exact snapshot bytes, and refuses same-byte inode substitution. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 151 +++++++++++++++++- .../tests/staged_segment_reads.rs | 116 ++++++++++++++ 2 files changed, 266 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 443b0b73da4..e671603a38a 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -2555,8 +2555,25 @@ impl DaemonCodeIndexPublicationStoreV1 { /// the store, for a build over the sealed active generation. pub(super) fn segment_reader(&self) -> Arc { let segments_root = self.segments_root.clone(); + // A retained generation can request many pages from the same sealed + // segment. Keep descriptors inside the reader lifetime, not in a + // process-global cache that could survive generation retirement. + #[cfg(unix)] + let pinned_reads = Mutex::new(VecDeque::::new()); Arc::new(move |request, buffer: &mut Vec| { - Self::read_segment_under(&segments_root, request, buffer) + #[cfg(unix)] + { + let mut pinned = pinned_reads.lock().map_err(|_| { + CodeIndexProductionErrorV1::Contract( + "sealed generation reader cache was poisoned".to_owned(), + ) + })?; + Self::read_cached_retained_segment(&segments_root, request, buffer, &mut pinned) + } + #[cfg(not(unix))] + { + Self::read_segment_under(&segments_root, request, buffer) + } }) } @@ -2582,6 +2599,138 @@ impl DaemonCodeIndexPublicationStoreV1 { Self::read_segment_at(&path, request, buffer) } + /// Reuse endpoint-authorized opens for repeated pages of a retained + /// generation. Every hit still checks the named and opened inode: a pinned + /// descriptor cannot authorize reads through a replaced pathname. + #[cfg(unix)] + #[tracing::instrument( + name = "code_index.generation.decode.segment", + level = "trace", + skip_all + )] + fn read_cached_retained_segment( + segments_root: &Path, + request: SealedGenerationSegmentReadV1<'_>, + buffer: &mut Vec, + pinned_reads: &mut VecDeque, + ) -> Result<(), CodeIndexProductionErrorV1> { + buffer.clear(); + let (digest, size_bytes, offset, length) = match request { + SealedGenerationSegmentReadV1::Whole { digest, size_bytes } => { + (digest, size_bytes, 0, size_bytes) + } + SealedGenerationSegmentReadV1::Range { + digest, + size_bytes, + offset, + length, + } => (digest, size_bytes, offset, length), + }; + if offset + .checked_add(length) + .is_none_or(|end| end > size_bytes) + { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed generation segment range exceeds its manifest identity".to_owned(), + )); + } + usize::try_from(length).map_err(|_| { + CodeIndexProductionErrorV1::Contract( + "sealed generation segment range exceeds addressable memory".to_owned(), + ) + })?; + let suffix = sha256_hex_suffix(digest.as_str()).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract("sealed segment digest is not sha256".to_owned()) + })?; + let path = segments_root.join(format!("segment-{suffix}.json")); + if let Some(index) = pinned_reads.iter().position(|entry| entry.path == path) { + if pinned_reads[index].segment.size_bytes != size_bytes { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed generation segment identity does not match its manifest".to_owned(), + )); + } + if !pinned_reads[index].unchanged() { + pinned_reads.clear(); + return Err(CodeIndexProductionErrorV1::Contract( + "retained sealed segment changed while pinned".to_owned(), + )); + } + let entry = pinned_reads.remove(index).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed generation read cache lost a handle".to_owned(), + ) + })?; + pinned_reads.push_back(entry); + } else { + let named = path.symlink_metadata().map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation segment is unavailable: {error}" + )) + })?; + if !named.file_type().is_file() || named.len() != size_bytes { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed generation segment identity does not match its manifest".to_owned(), + )); + } + // Admit the new descriptor before evicting a healthy handle. + // Handle::from_path would trigger an additional authorized open. + let file = File::open(&path).map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation segment cannot be opened: {error}" + )) + })?; + let opened = file.metadata().map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation segment metadata cannot be read: {error}" + )) + })?; + let named_after = path.symlink_metadata().map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation segment pathname cannot be rechecked: {error}" + )) + })?; + if !Self::same_unix_segment_identity(&named, &opened) + || !Self::same_unix_segment_identity(&named_after, &opened) + { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed generation segment identity changed while opening".to_owned(), + )); + } + if pinned_reads.len() == MAX_STAGED_SEGMENT_READ_HANDLES { + pinned_reads.pop_front(); + } + pinned_reads.push_back(PinnedPreparationSegmentV1 { + path, + identity: opened, + segment: PinnedGenerationSegmentV1 { + digest: digest.as_str().to_owned(), + size_bytes, + file, + }, + }); + } + let pinned = pinned_reads.back_mut().ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed generation read cache has no handle".to_owned(), + ) + })?; + let result = Self::read_pinned_partitioned_segment(&mut pinned.segment, request, buffer); + // A failed read or changed inode cannot leave an authoritative cached + // descriptor or bytes visible to the caller. + if !pinned.unchanged() { + buffer.clear(); + pinned_reads.clear(); + return Err(CodeIndexProductionErrorV1::Contract( + "retained sealed segment changed during read".to_owned(), + )); + } + if result.is_err() { + buffer.clear(); + pinned_reads.clear(); + } + result + } + fn read_segment_at( path: &Path, request: SealedGenerationSegmentReadV1<'_>, diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index d7eb52af368..65490707393 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -2085,6 +2085,122 @@ fn observed_named_opens( opens } +#[cfg(target_os = "linux")] +#[test] +fn retained_generation_ranges_reuse_one_open_and_refuse_a_replaced_inode() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn retained() -> u32 { 7 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut publisher = target_store(target_root.path(), &fixture); + let mut preparation = publisher + .prepare(&generation.sealed_scope(), None) + .expect("prepare real generation"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage source snapshot"); + let identities = CodeIndexPublishedGenerationV1::partitioned_segment_identities_from_reader( + publication.manifest_bytes().as_ref(), + ) + .expect("parse sealed segment identities") + .expect("partitioned generation"); + let identity = identities + .into_iter() + .find(|identity| identity.size_bytes > 4) + .expect("real generation has a nonempty segment"); + publisher + .commit(&mut preparation, &publication) + .expect("publish real generation"); + drop(preparation); + drop(publisher); + + let cold = target_store(target_root.path(), &fixture); + let reloaded = cold + .load_active_shared() + .expect("decode retained generation") + .expect("committed generation"); + assert_eq!( + reloaded.snapshot().content_identity, + generation.snapshot().content_identity, + "retained segment caching must preserve the exact sealed snapshot" + ); + let suffix = sha256_hex_suffix(identity.digest.as_str()).expect("digest suffix"); + let path = cold.segments_root.join(format!("segment-{suffix}.json")); + let (sender, receiver) = std::sync::mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |event: notify::Result| { + let _ = sender.send(event); + }) + .expect("native file-open observer"); + watcher + .watch(&cold.segments_root, notify::RecursiveMode::NonRecursive) + .expect("watch sealed segment directory"); + + let reader = cold.segment_reader(); + let mut whole = Vec::new(); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &identity.digest, + size_bytes: identity.size_bytes, + }, + &mut whole, + ) + .expect("read committed content-addressed segment"); + assert_eq!(segment_digest(&whole), identity.digest); + assert!(whole.len() > 4); + let mut range = Vec::new(); + for _ in 0..16 { + reader( + SealedGenerationSegmentReadV1::Range { + digest: &identity.digest, + size_bytes: identity.size_bytes, + offset: 1, + length: 3, + }, + &mut range, + ) + .expect("read retained page without pathname reopen"); + assert_eq!(range, whole[1..4]); + } + assert_eq!( + observed_named_opens(&receiver, &path), + 1, + "one whole and sixteen range reads must cause exactly one kernel open" + ); + // Prove the observer itself records a real open. + drop(File::open(&path).expect("control pathname open")); + assert_eq!(observed_named_opens(&receiver, &path), 1); + + let previous = target_root.path().join("retained-original-inode"); + std::fs::rename(&path, &previous).expect("move original inode"); + std::fs::write(&path, &whole).expect("replace with identical bytes"); + assert_ne!( + std::fs::metadata(&path).expect("replacement metadata").ino(), + std::fs::metadata(&previous).expect("original metadata").ino(), + "replacement must use a distinct inode" + ); + // Drain opens caused by writing the replacement, not by the reader. + let _ = observed_named_opens(&receiver, &path); + let mut refused = vec![0x55, 0xaa]; + assert!(matches!( + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &identity.digest, + size_bytes: identity.size_bytes, + }, + &mut refused, + ), + Err(CodeIndexProductionErrorV1::Contract(_)) + )); + assert!(refused.is_empty(), "refused read must expose no stale bytes"); + assert_eq!( + observed_named_opens(&receiver, &path), + 0, + "an invalidated pinned inode must be refused without a pathname open" + ); + std::fs::remove_file(&path).expect("remove substituted name"); + std::fs::rename(&previous, &path).expect("restore original inode"); +} + #[cfg(target_os = "linux")] #[test] fn initial_pointer_publication_reuses_persisted_descriptor_without_pathname_open() { From b91f2216f0e629b14004ebbd77c00264e3ff33d6 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:44:34 -0800 Subject: [PATCH 70/93] fix(code-index): format retained-read regression for CI Apply the two exact rustfmt hunks from hosted Bazel run 42677767864. No behavior or assertions changed. Refs #4533. --- .../tests/staged_segment_reads.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 65490707393..9161dc09a36 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -2174,8 +2174,12 @@ fn retained_generation_ranges_reuse_one_open_and_refuse_a_replaced_inode() { std::fs::rename(&path, &previous).expect("move original inode"); std::fs::write(&path, &whole).expect("replace with identical bytes"); assert_ne!( - std::fs::metadata(&path).expect("replacement metadata").ino(), - std::fs::metadata(&previous).expect("original metadata").ino(), + std::fs::metadata(&path) + .expect("replacement metadata") + .ino(), + std::fs::metadata(&previous) + .expect("original metadata") + .ino(), "replacement must use a distinct inode" ); // Drain opens caused by writing the replacement, not by the reader. @@ -2191,7 +2195,10 @@ fn retained_generation_ranges_reuse_one_open_and_refuse_a_replaced_inode() { ), Err(CodeIndexProductionErrorV1::Contract(_)) )); - assert!(refused.is_empty(), "refused read must expose no stale bytes"); + assert!( + refused.is_empty(), + "refused read must expose no stale bytes" + ); assert_eq!( observed_named_opens(&receiver, &path), 0, From 3bbff2b7acee5ea3b5986bb4fdc20ba12f390548 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:25:30 -0800 Subject: [PATCH 71/93] perf(retention): reuse manifest descriptor across mark probe Refs #4533. Avoid a second endpoint-authorized pathname open for each current-revision generation manifest in segment mark-and-sweep. Keep the same descriptor for bounded revision validation and full SHA-256 marking. Add a Linux native inotify regression with a positive open control, byte-exact retained evidence, real orphan collection and corruption refusal. Only Bazel-hosted native CI may establish execution of this regression. --- .../src/code_index_generations.rs | 14 +- .../code_index_generations/generation_scan.rs | 11 +- .../src/code_index_generations/tests.rs | 182 ++++++++++++++++++ 3 files changed, 199 insertions(+), 8 deletions(-) diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations.rs b/crates/tracedecay-code-index-retention/src/code_index_generations.rs index 1f9f8ab24b1..54b494ef239 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations.rs @@ -10,7 +10,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs::File; -use std::io::{BufReader, Read}; +use std::io::{BufReader, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -112,7 +112,7 @@ use text_artifacts::{ plan_collectable_text_artifacts_cancellable, stage_collectable_text_artifacts, }; -use generation_scan::{read_generation_format_revision, read_generation_metadata}; +use generation_scan::{read_generation_format_revision_from_file, read_generation_metadata}; #[cfg(test)] use scope_quarantine::ScopeQuarantineAuthority; @@ -1414,13 +1414,19 @@ fn sweep_unreferenced_generation_segments( })? .to_owned() }; - if read_generation_format_revision(&path, is_cancelled)? + // The revision probe and full descriptor mark must observe the + // same opened inode. Reopening here paid a second endpoint-security + // authorization for every current-revision manifest and could read + // different bytes if the pathname changed between the two opens. + let mut file = File::open(&path).map_err(deferred_if_absent)?; + if read_generation_format_revision_from_file(&mut file, &path, is_cancelled)? != SEALED_GENERATION_FORMAT_REVISION_V1 { continue; } + file.seek(SeekFrom::Start(0)).map_err(storage)?; let mut reader = CancellableGenerationManifestReaderV1 { - file: File::open(&path).map_err(deferred_if_absent)?, + file, hasher: Sha256::new(), is_cancelled, cancelled: false, diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/generation_scan.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/generation_scan.rs index 94e144df538..9981ea82fbf 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/generation_scan.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/generation_scan.rs @@ -14,14 +14,14 @@ use super::{ const MAX_FORMAT_REVISION_PREFIX_BYTES: usize = 4 * 1024; const GENERATION_METADATA_READ_CHUNK_BYTES: usize = 4 * 1024; -pub(super) fn read_generation_format_revision( +pub(super) fn read_generation_format_revision_from_file( + file: &mut File, path: &Path, is_cancelled: &dyn Fn() -> bool, ) -> Result { if is_cancelled() { return Err(CodeGenerationRetentionErrorV1::Cancelled); } - let file = File::open(path).map_err(deferred_if_absent)?; let mut prefix = Vec::with_capacity(MAX_FORMAT_REVISION_PREFIX_BYTES); file.take(MAX_FORMAT_REVISION_PREFIX_BYTES as u64) .read_to_end(&mut prefix) @@ -242,7 +242,8 @@ mod tests { use super::{ CodeGenerationRetentionErrorV1, GENERATION_METADATA_READ_CHUNK_BYTES, GenerationDigestVerificationV1, MAX_GENERATION_METADATA_PREFIX_BYTES, parse_json_u32_field, - read_generation_format_revision, read_generation_metadata, read_generation_metadata_prefix, + read_generation_format_revision_from_file, read_generation_metadata, + read_generation_metadata_prefix, }; fn generation_bytes(manifest_padding: usize, payload_bytes: usize) -> Vec { @@ -270,10 +271,12 @@ mod tests { let root = tempfile::tempdir().unwrap(); let bytes = generation_bytes(0, 256 * 1024); let (path, expected_digest) = write_generation(root.path(), &bytes); + let mut revision_file = File::open(&path).unwrap(); assert_eq!( - read_generation_format_revision(&path, &|| false).unwrap(), + read_generation_format_revision_from_file(&mut revision_file, &path, &|| false).unwrap(), SEALED_GENERATION_FORMAT_REVISION_V1 ); + drop(revision_file); let mut file = File::open(&path).unwrap(); let (revision, manifest) = read_generation_metadata_prefix( &mut file, diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs index 7fc9f340678..57dc9ca6c9a 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs @@ -3,6 +3,13 @@ use super::*; use super::{journal, receipt_store}; use tracedecay_domain::sha256_hex_suffix; +#[cfg(target_os = "linux")] +use std::ffi::CString; +#[cfg(target_os = "linux")] +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +#[cfg(target_os = "linux")] +use std::os::unix::ffi::OsStrExt; + mod graph_replay_pool_lock_tests; mod graph_replay_release_tests; @@ -1939,6 +1946,181 @@ fn metadata_only_segment_census_distinguishes_empty_cancelled_and_exhausted() { assert_eq!(remaining_entries, 0); } + +#[cfg(target_os = "linux")] +fn native_manifest_open_events(observer: &OwnedFd) -> usize { + let mut opens = 0; + let mut buffer = [0u8; 4096]; + loop { + // The watch includes close events, preventing inotify from coalescing + // successive opens of the same inode into one unread notification. + let read = unsafe { + libc::read( + observer.as_raw_fd(), + buffer.as_mut_ptr().cast(), + buffer.len(), + ) + }; + if read < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::WouldBlock { + break; + } + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + panic!("cannot read native manifest-open events: {error}"); + } + if read == 0 { + break; + } + let bytes = usize::try_from(read).expect("nonnegative event byte count"); + let header_size = std::mem::size_of::(); + let mut offset = 0; + while offset < bytes { + assert!(offset + header_size <= bytes, "complete native event header"); + let event = unsafe { + std::ptr::read_unaligned( + buffer[offset..].as_ptr().cast::(), + ) + }; + let end = offset + header_size + usize::try_from(event.len).unwrap(); + assert!(end <= bytes, "complete native event payload"); + if event.mask & libc::IN_OPEN != 0 { + opens += 1; + } + offset = end; + } + } + opens +} + +/// A full mark pass must reuse the revision probe's actual descriptor, not +/// reauthorize a pathname for the same manifest. The retained evidence is +/// still marked live, while unrelated content is eligible for collection. +#[cfg(target_os = "linux")] +#[test] +fn segment_mark_reads_a_generation_manifest_with_one_kernel_open() { + let store = tempfile::TempDir::new().expect("retention store"); + let generation_root = store.path().join(GENERATIONS_DIRECTORY); + let segments_root = store.path().join(GENERATION_SEGMENTS_DIRECTORY); + std::fs::create_dir_all(&generation_root).expect("generation directory"); + std::fs::create_dir_all(&segments_root).expect("segment directory"); + + let evidence_bytes = b"nonempty retained segment evidence"; + let evidence_digest = encode_tagged_lowercase_hex("sha256:", &Sha256::digest(evidence_bytes)); + let segment_descriptor = serde_json::json!({ + "segment_digest": evidence_digest, + "segment_size_bytes": evidence_bytes.len(), + "decoded_size_bytes": evidence_bytes.len(), + }); + let generation = serde_json::json!({ + "format_revision": SEALED_GENERATION_FORMAT_REVISION_V1, + "manifest": { + "generation_id": "generation.v1.manifest-open", + "seal": { "sealed_at": 7 } + }, + "snapshot": { "files": [] }, + "file_segments": [], + "file_evidence": [], + "generation_evidence": { + "segment_digest": evidence_digest, + "segment_size_bytes": evidence_bytes.len(), + "pages": [{ + "page_ordinal": 0, + "page_digest": evidence_digest, + "page_size_bytes": evidence_bytes.len() + }] + }, + "code_graph_pages": [], + "resolution_index": { + "definitions": [segment_descriptor.clone()], + "references": [segment_descriptor.clone()], + "import_aliases": segment_descriptor + } + }); + let generation_bytes = serde_json::to_vec(&generation).expect("serialize generation"); + let manifest_bytes = serde_json::to_vec(&serde_json::json!({ + "state_digest": encode_tagged_lowercase_hex("sha256:", &Sha256::digest(&generation_bytes)), + "generation": generation, + })) + .expect("serialize sealed manifest"); + let manifest_digest = encode_lowercase_hex(&Sha256::digest(&manifest_bytes)); + let manifest_path = generation_root.join(format!("generation-{manifest_digest}.json")); + std::fs::write(&manifest_path, &manifest_bytes).expect("write sealed manifest"); + + let referenced_path = segments_root.join(format!( + "segment-{}.json", + sha256_hex_suffix(&evidence_digest).expect("evidence digest") + )); + std::fs::write(&referenced_path, evidence_bytes).expect("write retained evidence"); + let orphan_bytes = b"unreferenced segment"; + let orphan_digest = encode_lowercase_hex(&Sha256::digest(orphan_bytes)); + let orphan_path = segments_root.join(format!("segment-{orphan_digest}.json")); + std::fs::write(&orphan_path, orphan_bytes).expect("write unreferenced segment"); + + let raw_fd = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) }; + assert!(raw_fd >= 0, "native inotify: {}", std::io::Error::last_os_error()); + let observer = unsafe { OwnedFd::from_raw_fd(raw_fd) }; + let native_path = CString::new(manifest_path.as_os_str().as_bytes()) + .expect("manifest path has no NUL"); + let watch = unsafe { + libc::inotify_add_watch( + observer.as_raw_fd(), + native_path.as_ptr(), + libc::IN_OPEN | libc::IN_CLOSE_NOWRITE, + ) + }; + assert!(watch >= 0, "native manifest watch: {}", std::io::Error::last_os_error()); + + drop(File::open(&manifest_path).expect("positive control pathname open")); + assert_eq!( + native_manifest_open_events(&observer), + 1, + "the observer must detect an actual kernel open" + ); + let (found, exhausted) = + sweep_unreferenced_generation_segments(store.path(), None, false, &|| false, None) + .expect("authenticated mark and conservative sweep"); + assert!(found, "the unrelated nonempty segment is collectable"); + assert!(!exhausted); + assert_eq!( + native_manifest_open_events(&observer), + 1, + "revision probe and full segment mark must reuse one opened inode" + ); + + let (found, exhausted) = + sweep_unreferenced_generation_segments(store.path(), None, true, &|| false, None) + .expect("collect only the unreferenced segment"); + assert!(found); + assert!(!exhausted); + assert_eq!( + native_manifest_open_events(&observer), + 1, + "applying the mark must also avoid a second manifest open" + ); + assert_eq!( + std::fs::read(&referenced_path).expect("retained evidence survives"), + evidence_bytes + ); + assert!(!orphan_path.exists(), "unreferenced segment was removed"); + + // Identical byte extent but a different content-addressed file digest is + // never accepted as live evidence on the next mark. + let mut altered = manifest_bytes; + altered.push(b' '); + std::fs::write(&manifest_path, altered).expect("mutate disposable manifest"); + assert!(matches!( + sweep_unreferenced_generation_segments(store.path(), None, false, &|| false, None), + Err(CodeGenerationRetentionErrorV1::UnsafeState(_)) + )); + assert_eq!( + std::fs::read(&referenced_path).expect("corruption cannot erase live evidence"), + evidence_bytes + ); +} + #[test] fn maintenance_preparation_wakes_for_an_unreferenced_final_segment() { let store = tempfile::TempDir::new().expect("create unpublished store"); From b46aeeb962519c8966bdc4ab2cd1b8731c37e046 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:27:59 -0800 Subject: [PATCH 72/93] fix(code-index): preserve healthy handles on failed cold reads Authenticate a retained segment's first read before admitting its handle or evicting a healthy cached descriptor. Keep existing fail-closed cache invalidation for changed or failed admitted handles, and clear caller output before locking the reader cache. Add a real production-writer regression with nine nonempty segments, repeated checksum refusals, exact descriptor and LRU preservation, repair and bounded admission, range readback, and cached corruption. Linux observes actual pathname opens with a positive kernel control. Preserve the concurrent retention change in parent 3bbff2b. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 48 +++- .../tests/retained_segment_admission.rs | 261 ++++++++++++++++++ 2 files changed, 294 insertions(+), 15 deletions(-) create mode 100644 crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/retained_segment_admission.rs diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index e671603a38a..716164ad11e 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -74,6 +74,10 @@ mod staged_segment_reads; #[path = "tests/immutable_segment_equality.rs"] mod immutable_segment_equality; +#[cfg(all(test, unix))] +#[path = "tests/retained_segment_admission.rs"] +mod retained_segment_admission; + const MAX_DURABLE_PUBLICATION_POINTER_BYTES: u64 = 512 * 1024; const DURABLE_GENERATION_IO_CHUNK_BYTES_V1: usize = 64 * 1024; @@ -2561,6 +2565,7 @@ impl DaemonCodeIndexPublicationStoreV1 { #[cfg(unix)] let pinned_reads = Mutex::new(VecDeque::::new()); Arc::new(move |request, buffer: &mut Vec| { + buffer.clear(); #[cfg(unix)] { let mut pinned = pinned_reads.lock().map_err(|_| { @@ -2655,6 +2660,14 @@ impl DaemonCodeIndexPublicationStoreV1 { "retained sealed segment changed while pinned".to_owned(), )); } + if let Err(error) = + Self::read_checked_retained_segment(&mut pinned_reads[index], request, buffer) + { + pinned_reads.clear(); + return Err(error); + } + // Only a successful read changes recency. A failed cache hit + // retains the existing fail-closed invalidation behavior. let entry = pinned_reads.remove(index).ok_or_else(|| { CodeIndexProductionErrorV1::Contract( "sealed generation read cache lost a handle".to_owned(), @@ -2672,7 +2685,8 @@ impl DaemonCodeIndexPublicationStoreV1 { "sealed generation segment identity does not match its manifest".to_owned(), )); } - // Admit the new descriptor before evicting a healthy handle. + // Opening is not admission: the first authenticated read must + // also succeed before a healthy cached handle can be evicted. // Handle::from_path would trigger an additional authorized open. let file = File::open(&path).map_err(|error| { CodeIndexProductionErrorV1::Contract(format!( @@ -2696,10 +2710,7 @@ impl DaemonCodeIndexPublicationStoreV1 { "sealed generation segment identity changed while opening".to_owned(), )); } - if pinned_reads.len() == MAX_STAGED_SEGMENT_READ_HANDLES { - pinned_reads.pop_front(); - } - pinned_reads.push_back(PinnedPreparationSegmentV1 { + let mut candidate = PinnedPreparationSegmentV1 { path, identity: opened, segment: PinnedGenerationSegmentV1 { @@ -2707,26 +2718,33 @@ impl DaemonCodeIndexPublicationStoreV1 { size_bytes, file, }, - }); + }; + // A failed cold candidate owns no cache authority. Drop only + // that candidate; existing handles still validate on every use. + Self::read_checked_retained_segment(&mut candidate, request, buffer)?; + if pinned_reads.len() == MAX_STAGED_SEGMENT_READ_HANDLES { + pinned_reads.pop_front(); + } + pinned_reads.push_back(candidate); } - let pinned = pinned_reads.back_mut().ok_or_else(|| { - CodeIndexProductionErrorV1::Contract( - "sealed generation read cache has no handle".to_owned(), - ) - })?; + Ok(()) + } + + #[cfg(unix)] + fn read_checked_retained_segment( + pinned: &mut PinnedPreparationSegmentV1, + request: SealedGenerationSegmentReadV1<'_>, + buffer: &mut Vec, + ) -> Result<(), CodeIndexProductionErrorV1> { let result = Self::read_pinned_partitioned_segment(&mut pinned.segment, request, buffer); - // A failed read or changed inode cannot leave an authoritative cached - // descriptor or bytes visible to the caller. if !pinned.unchanged() { buffer.clear(); - pinned_reads.clear(); return Err(CodeIndexProductionErrorV1::Contract( "retained sealed segment changed during read".to_owned(), )); } if result.is_err() { buffer.clear(); - pinned_reads.clear(); } result } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/retained_segment_admission.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/retained_segment_admission.rs new file mode 100644 index 00000000000..48cfea1f5bf --- /dev/null +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/retained_segment_admission.rs @@ -0,0 +1,261 @@ +//! Failed first reads must not discard unrelated authenticated segment handles. +use std::collections::VecDeque; +use std::fs; +#[cfg(target_os = "linux")] +use std::fs::File; +use std::os::fd::{AsRawFd, RawFd}; +#[cfg(target_os = "linux")] +use std::path::Path; +use std::path::PathBuf; +use std::sync::atomic::AtomicU64; +#[cfg(target_os = "linux")] +use std::sync::mpsc::{self, Receiver}; +#[cfg(target_os = "linux")] +use std::time::Duration; + +#[cfg(target_os = "linux")] +use notify::{RecommendedWatcher, Watcher}; +use sha2::{Digest, Sha256}; +use tracedecay_code_index::production::{ + CodeIndexProductionErrorV1, SealedGenerationSegmentReadV1, +}; +use tracedecay_domain::{ManifestDigest, sha256_hex_suffix}; + +use super::{ + DaemonCodeIndexPublicationStoreV1, MAX_STAGED_SEGMENT_READ_HANDLES, + PinnedPreparationSegmentV1, StagedGenerationSegmentsV1, +}; + +fn cache_handles(cache: &VecDeque) -> Vec<(PathBuf, RawFd)> { + cache + .iter() + .map(|entry| (entry.path.clone(), entry.segment.file.as_raw_fd())) + .collect() +} + +#[cfg(target_os = "linux")] +struct OpenObserver { + _watcher: RecommendedWatcher, + events: Receiver>, +} + +#[cfg(target_os = "linux")] +impl OpenObserver { + fn new(root: &Path) -> Self { + let (sender, events) = mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |event| { + let _ = sender.send(event); + }) + .expect("native open observer"); + watcher + .watch(root, notify::RecursiveMode::NonRecursive) + .expect("watch the actual segment directory"); + Self { + _watcher: watcher, + events, + } + } + + fn opens(&self) -> usize { + let mut count = 0; + while let Ok(event) = self.events.recv_timeout(Duration::from_millis(250)) { + if matches!( + event.expect("kernel event").kind, + notify::EventKind::Access(notify::event::AccessKind::Open(_)) + ) { + count += 1; + } + } + count + } +} + +#[test] +fn retained_failed_first_read_preserves_healthy_handles_and_recovers() { + let root = tempfile::tempdir().expect("isolated segment store"); + let segments = root.path().join("segments"); + fs::create_dir(&segments).expect("canonical segment directory"); + let staging = tempfile::tempdir_in(root.path()).expect("private staging directory"); + let mut staged = StagedGenerationSegmentsV1::new(); + let bytes: Vec> = (0..=MAX_STAGED_SEGMENT_READ_HANDLES) + .map(|index| format!("nonempty retained segment {index:04}: exact bytes").into_bytes()) + .collect(); + let digests: Vec = bytes + .iter() + .map(|bytes| { + ManifestDigest::from_sha256_bytes(&Sha256::digest(bytes)).expect("segment digest") + }) + .collect(); + let paths: Vec = digests + .iter() + .map(|digest| { + segments.join(format!( + "segment-{}.json", + sha256_hex_suffix(digest.as_str()).expect("canonical digest suffix") + )) + }) + .collect(); + let existing_bytes = AtomicU64::new(0); + for (digest, bytes) in digests.iter().zip(&bytes) { + DaemonCodeIndexPublicationStoreV1::stage_segment( + &segments, + staging.path(), + &existing_bytes, + digest, + bytes, + &mut staged, + ) + .expect("stage through the production writer"); + } + staged + .publish() + .expect("durably publish actual segment files"); + drop(staged); + + let candidate = MAX_STAGED_SEGMENT_READ_HANDLES; + let mut damaged = bytes[candidate].clone(); + damaged[0] ^= 1; + fs::write(&paths[candidate], &damaged).expect("corrupt the disposable cold candidate"); + assert_eq!(damaged.len(), bytes[candidate].len()); + assert_ne!( + DaemonCodeIndexPublicationStoreV1::state_digest(&damaged), + digests[candidate].as_str() + ); + + #[cfg(target_os = "linux")] + let observer = OpenObserver::new(&segments); + #[cfg(target_os = "linux")] + { + let _ = observer.opens(); + drop(File::open(&paths[0]).expect("positive observer control")); + assert_eq!(observer.opens(), 1, "the observer must count a real open"); + } + + let request = |index: usize| SealedGenerationSegmentReadV1::Whole { + digest: &digests[index], + size_bytes: bytes[index].len() as u64, + }; + let mut cache = VecDeque::new(); + let mut output = Vec::new(); + for (index, expected) in bytes.iter().take(candidate).enumerate() { + DaemonCodeIndexPublicationStoreV1::read_cached_retained_segment( + &segments, + request(index), + &mut output, + &mut cache, + ) + .expect("authenticate every healthy segment"); + assert_eq!(&output, expected); + } + assert_eq!(cache.len(), MAX_STAGED_SEGMENT_READ_HANDLES); + #[cfg(target_os = "linux")] + assert_eq!(observer.opens(), MAX_STAGED_SEGMENT_READ_HANDLES); + let healthy = cache_handles(&cache); + + for _ in 0..2 { + output = vec![0x55, 0xaa]; + let error = DaemonCodeIndexPublicationStoreV1::read_cached_retained_segment( + &segments, + request(candidate), + &mut output, + &mut cache, + ) + .expect_err("a corrupt candidate must fail its first authenticated read"); + let CodeIndexProductionErrorV1::Contract(detail) = error else { + panic!("expected an authenticated read refusal, got {error}"); + }; + assert_eq!( + detail, + "sealed generation segment does not match its content address" + ); + assert_eq!( + output, + Vec::::new(), + "refusal clears stale and corrupt bytes" + ); + assert_eq!( + cache_handles(&cache), + healthy, + "failed admission preserves healthy descriptors and their LRU positions" + ); + #[cfg(target_os = "linux")] + assert_eq!(observer.opens(), 1, "the refused read must open its file"); + } + + for (index, expected) in bytes.iter().take(candidate).enumerate() { + DaemonCodeIndexPublicationStoreV1::read_cached_retained_segment( + &segments, + request(index), + &mut output, + &mut cache, + ) + .expect("healthy data remains readable after candidate refusal"); + assert_eq!(&output, expected); + } + assert_eq!(cache_handles(&cache), healthy); + #[cfg(target_os = "linux")] + assert_eq!(observer.opens(), 0, "healthy handles must not be reopened"); + + fs::write(&paths[candidate], &bytes[candidate]).expect("repair disposable candidate"); + #[cfg(target_os = "linux")] + assert_eq!(observer.opens(), 1, "observe the fixture write separately"); + DaemonCodeIndexPublicationStoreV1::read_cached_retained_segment( + &segments, + request(candidate), + &mut output, + &mut cache, + ) + .expect("the repaired cold candidate can pass authentication and enter the cache"); + assert_eq!(output, bytes[candidate]); + assert_eq!(cache.len(), MAX_STAGED_SEGMENT_READ_HANDLES); + assert_eq!( + cache.front().expect("remaining healthy handle").path, + paths[1] + ); + assert_eq!( + cache.back().expect("admitted candidate").path, + paths[candidate] + ); + #[cfg(target_os = "linux")] + assert_eq!(observer.opens(), 1); + + DaemonCodeIndexPublicationStoreV1::read_cached_retained_segment( + &segments, + SealedGenerationSegmentReadV1::Range { + digest: &digests[candidate], + size_bytes: bytes[candidate].len() as u64, + offset: 1, + length: 3, + }, + &mut output, + &mut cache, + ) + .expect("admitted candidate serves an exact range without reopening"); + assert_eq!(output, bytes[candidate][1..4]); + #[cfg(target_os = "linux")] + assert_eq!(observer.opens(), 0); + + // Failed admission is cache-neutral, not permission to trust changed + // cached data. A later mutation must retain the fail-closed hit behavior. + fs::write(&paths[candidate], &damaged).expect("mutate the now-cached disposable file"); + #[cfg(target_os = "linux")] + let _ = observer.opens(); + assert!( + DaemonCodeIndexPublicationStoreV1::read_cached_retained_segment( + &segments, + request(candidate), + &mut output, + &mut cache, + ) + .is_err(), + "an admitted handle still refuses a changed inode or digest" + ); + assert_eq!(output, Vec::::new()); + assert_eq!( + cache.len(), + 0, + "changed cached authority still invalidates handles" + ); + #[cfg(target_os = "linux")] + assert_eq!(observer.opens(), 0); +} From 8d260d5851698f1e3834af1bc4c0dc345dcf601f Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:37:53 -0800 Subject: [PATCH 73/93] fix(retention): preserve cancellation at manifest open boundary Check cancellation before and after a failed endpoint-authorized manifest open. A listed manifest disappearing during cancellation must refuse with Cancelled, not GenerationStoreBusy; segment bytes remain untouched. Add a nonzero real-filesystem withdrawal/cancellation regression in the retention suite, plus pinned-rustfmt fixes from Bazel CI 42702807630. Refs #4533 --- .../src/code_index_generations.rs | 13 ++- .../code_index_generations/generation_scan.rs | 3 +- .../src/code_index_generations/tests.rs | 88 ++++++++++++++++--- .../tests/retained_segment_admission.rs | 4 +- 4 files changed, 93 insertions(+), 15 deletions(-) diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations.rs b/crates/tracedecay-code-index-retention/src/code_index_generations.rs index 54b494ef239..a722b019a60 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations.rs @@ -1418,7 +1418,18 @@ fn sweep_unreferenced_generation_segments( // same opened inode. Reopening here paid a second endpoint-security // authorization for every current-revision manifest and could read // different bytes if the pathname changed between the two opens. - let mut file = File::open(&path).map_err(deferred_if_absent)?; + // Cancellation must win immediately before endpoint-authorized + // open(), including when a listed generation disappeared meanwhile. + if is_cancelled() { + return Err(CodeGenerationRetentionErrorV1::Cancelled); + } + let mut file = File::open(&path).map_err(|error| { + if is_cancelled() { + CodeGenerationRetentionErrorV1::Cancelled + } else { + deferred_if_absent(error) + } + })?; if read_generation_format_revision_from_file(&mut file, &path, is_cancelled)? != SEALED_GENERATION_FORMAT_REVISION_V1 { diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/generation_scan.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/generation_scan.rs index 9981ea82fbf..5d6ef4b7368 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/generation_scan.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/generation_scan.rs @@ -273,7 +273,8 @@ mod tests { let (path, expected_digest) = write_generation(root.path(), &bytes); let mut revision_file = File::open(&path).unwrap(); assert_eq!( - read_generation_format_revision_from_file(&mut revision_file, &path, &|| false).unwrap(), + read_generation_format_revision_from_file(&mut revision_file, &path, &|| false) + .unwrap(), SEALED_GENERATION_FORMAT_REVISION_V1 ); drop(revision_file); diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs index 57dc9ca6c9a..73089979ca1 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs @@ -2,14 +2,13 @@ use super::*; use super::{journal, receipt_store}; -use tracedecay_domain::sha256_hex_suffix; #[cfg(target_os = "linux")] use std::ffi::CString; #[cfg(target_os = "linux")] use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; #[cfg(target_os = "linux")] use std::os::unix::ffi::OsStrExt; - +use tracedecay_domain::sha256_hex_suffix; mod graph_replay_pool_lock_tests; mod graph_replay_release_tests; @@ -1946,7 +1945,6 @@ fn metadata_only_segment_census_distinguishes_empty_cancelled_and_exhausted() { assert_eq!(remaining_entries, 0); } - #[cfg(target_os = "linux")] fn native_manifest_open_events(observer: &OwnedFd) -> usize { let mut opens = 0; @@ -1978,11 +1976,12 @@ fn native_manifest_open_events(observer: &OwnedFd) -> usize { let header_size = std::mem::size_of::(); let mut offset = 0; while offset < bytes { - assert!(offset + header_size <= bytes, "complete native event header"); + assert!( + offset + header_size <= bytes, + "complete native event header" + ); let event = unsafe { - std::ptr::read_unaligned( - buffer[offset..].as_ptr().cast::(), - ) + std::ptr::read_unaligned(buffer[offset..].as_ptr().cast::()) }; let end = offset + header_size + usize::try_from(event.len).unwrap(); assert!(end <= bytes, "complete native event payload"); @@ -2060,10 +2059,14 @@ fn segment_mark_reads_a_generation_manifest_with_one_kernel_open() { std::fs::write(&orphan_path, orphan_bytes).expect("write unreferenced segment"); let raw_fd = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) }; - assert!(raw_fd >= 0, "native inotify: {}", std::io::Error::last_os_error()); + assert!( + raw_fd >= 0, + "native inotify: {}", + std::io::Error::last_os_error() + ); let observer = unsafe { OwnedFd::from_raw_fd(raw_fd) }; - let native_path = CString::new(manifest_path.as_os_str().as_bytes()) - .expect("manifest path has no NUL"); + let native_path = + CString::new(manifest_path.as_os_str().as_bytes()).expect("manifest path has no NUL"); let watch = unsafe { libc::inotify_add_watch( observer.as_raw_fd(), @@ -2071,7 +2074,11 @@ fn segment_mark_reads_a_generation_manifest_with_one_kernel_open() { libc::IN_OPEN | libc::IN_CLOSE_NOWRITE, ) }; - assert!(watch >= 0, "native manifest watch: {}", std::io::Error::last_os_error()); + assert!( + watch >= 0, + "native manifest watch: {}", + std::io::Error::last_os_error() + ); drop(File::open(&manifest_path).expect("positive control pathname open")); assert_eq!( @@ -2121,6 +2128,65 @@ fn segment_mark_reads_a_generation_manifest_with_one_kernel_open() { ); } +/// The marked file may be removed after the directory entry was returned. +/// A cancellation arriving at that exact boundary must not be rewritten as +/// an unrelated busy-store error or trigger any segment collection. +#[test] +fn segment_mark_prefers_cancellation_before_disappeared_manifest_open() { + let store = tempfile::TempDir::new().expect("isolated retention store"); + let generation_root = store.path().join(GENERATIONS_DIRECTORY); + let segments_root = store.path().join(GENERATION_SEGMENTS_DIRECTORY); + std::fs::create_dir_all(&generation_root).expect("generation directory"); + std::fs::create_dir_all(&segments_root).expect("segment directory"); + + let manifest_bytes = b"nonempty manifest that is withdrawn after enumeration"; + let digest = encode_lowercase_hex(&Sha256::digest(manifest_bytes)); + let manifest_path = generation_root.join(format!("generation-{digest}.json")); + std::fs::write(&manifest_path, manifest_bytes).expect("create a real manifest"); + + let segment_bytes = b"nonempty unreferenced segment remains untouched on cancellation"; + let segment_digest = encode_lowercase_hex(&Sha256::digest(segment_bytes)); + let segment_path = segments_root.join(format!("segment-{segment_digest}.json")); + std::fs::write(&segment_path, segment_bytes).expect("create segment to protect"); + + // With this single scope, the first cancellation probe runs when the + // actual manifest directory entry is visited. Simulate the listed file + // disappearing and cancellation becoming active before open(). The next + // probe must be the pre-open cancellation boundary. + let calls = std::cell::Cell::new(0usize); + let cancelled = || { + let next = calls.get() + 1; + calls.set(next); + if next == 1 { + std::fs::remove_file(&manifest_path) + .expect("simulate concurrent withdrawal of enumerated manifest"); + false + } else { + true + } + }; + let error = sweep_unreferenced_generation_segments( + store.path(), + None, + true, + &cancelled, + None, + ) + .expect_err("cancellation after enumeration must prevent segment deletion"); + assert!(matches!(error, CodeGenerationRetentionErrorV1::Cancelled)); + assert_eq!( + calls.get(), + 2, + "the pre-open cancellation check must run after enumerating the file" + ); + assert!(!manifest_path.exists(), "the withdrawal must actually occur"); + assert_eq!( + std::fs::read(&segment_path).expect("protected segment bytes"), + segment_bytes, + "cancellation cannot collect an unmarked segment" + ); +} + #[test] fn maintenance_preparation_wakes_for_an_unreferenced_final_segment() { let store = tempfile::TempDir::new().expect("create unpublished store"); diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/retained_segment_admission.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/retained_segment_admission.rs index 48cfea1f5bf..1a9a2c02f2e 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/retained_segment_admission.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/retained_segment_admission.rs @@ -22,8 +22,8 @@ use tracedecay_code_index::production::{ use tracedecay_domain::{ManifestDigest, sha256_hex_suffix}; use super::{ - DaemonCodeIndexPublicationStoreV1, MAX_STAGED_SEGMENT_READ_HANDLES, - PinnedPreparationSegmentV1, StagedGenerationSegmentsV1, + DaemonCodeIndexPublicationStoreV1, MAX_STAGED_SEGMENT_READ_HANDLES, PinnedPreparationSegmentV1, + StagedGenerationSegmentsV1, }; fn cache_handles(cache: &VecDeque) -> Vec<(PathBuf, RawFd)> { From 598c1ffcf1c44b4fa99555317686f87d4b00c691 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:52:46 -0800 Subject: [PATCH 74/93] perf(code-index): reuse retained segment handles across requests Retained history page reads previously bypassed the per-reader descriptor cache, reopening the segment pathname for every page even on the same store. Share an eight-handle authenticated LRU across store clones, without changing per-request generation identity or deadline checks. Clear pinned descriptors during store reset so retired bytes can be reclaimed. A native Linux inotify regression requires one cold open for 17 authenticated whole/range calls and zero opens from a peer handle; it also verifies exact bytes and refuses a substituted inode. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 32 ++++ .../tests/staged_segment_reads.rs | 154 ++++++++++++++++++ 2 files changed, 186 insertions(+) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 716164ad11e..72eaaf1f663 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -512,6 +512,10 @@ pub struct DaemonCodeIndexPublicationStoreV1 { expected_sanitizer_revision: SanitizerRevision, disposition: CodeIndexPublicationDispositionV1, pointer_memo: Arc>>, + /// One bounded descriptor cache for retained page reads across requests. + /// Cloned stores share its identity checks; retirement discards it. + #[cfg(unix)] + retained_segment_reads: Arc>>, undecoded_active_expectation: Option, /// The canonical source-hint authority plus the exact pre-capture epoch /// used by a retained rebuild. Ordinary publication leaves this absent. @@ -1570,6 +1574,8 @@ impl DaemonCodeIndexPublicationStoreV1 { expected_sanitizer_revision, disposition: CodeIndexPublicationDispositionV1::Active, pointer_memo: Arc::new(Mutex::new(None)), + #[cfg(unix)] + retained_segment_reads: Arc::new(Mutex::new(VecDeque::new())), undecoded_active_expectation: None, reconcile_publication_fence: None, shutdown_signal: None, @@ -2529,6 +2535,24 @@ impl DaemonCodeIndexPublicationStoreV1 { CodeIndexInterruptionV1::Cancelled, )); } + #[cfg(unix)] + let result = self + .retained_segment_reads + .lock() + .map_err(|_| { + CodeIndexProductionErrorV1::Contract( + "retained generation segment reader cache was poisoned".to_owned(), + ) + }) + .and_then(|mut pinned| { + Self::read_cached_retained_segment( + &self.segments_root, + request, + buffer, + &mut pinned, + ) + }); + #[cfg(not(unix))] let result = self.read_partitioned_segment(request, buffer); // Endpoint authorization may block inside open(). A request that was // live before I/O must not return data after its deadline or cancel. @@ -3962,6 +3986,14 @@ impl DaemonCodeIndexPublicationStoreV1 { let store_lock = try_acquire_code_generation_store_lock(store_root) .map_err(Self::unavailable)? .ok_or(CodeIndexPublicationStoreErrorV1::StoreLockContended)?; + // Do not let an old retained descriptor keep deleted segment bytes + // resident after this scope's reset, even when another store clone + // served the last page. + #[cfg(unix)] + self.retained_segment_reads + .lock() + .map_err(|_| Self::unavailable("retained segment read cache was poisoned"))? + .clear(); let receipt = reset_code_index_scope_store(&store_lock).map_err(Self::unavailable)?; std::fs::create_dir_all(&self.generations_root).map_err(Self::unavailable)?; tracedecay_code_index_retention::code_index_generations::record_scope_root( diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 9161dc09a36..a874a2b4bf6 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -2406,3 +2406,157 @@ fn staged_evidence_pack_writer_authentication_avoids_extra_opens_and_refuses_rep ); preparation.abort().expect("release failed preparation"); } + +#[cfg(target_os = "linux")] +#[test] +fn retained_history_reads_across_requests_share_one_segment_open_and_refuse_replacement() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn retained() -> u32 { 9 }\n")]); + let source_root = TempDir::new().expect("source store"); + let generation = source_generation(&fixture, source_root.path()); + let target_root = TempDir::new().expect("target store"); + let mut publisher = target_store(target_root.path(), &fixture); + let mut preparation = publisher + .prepare(&generation.sealed_scope(), None) + .expect("prepare generation"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage production evidence"); + let segment = CodeIndexPublishedGenerationV1::partitioned_segment_identities_from_reader( + publication.manifest_bytes().as_ref(), + ) + .expect("sealed segment identities") + .expect("partitioned generation") + .into_iter() + .find(|identity| identity.size_bytes > 4) + .expect("nonempty real segment"); + publisher + .commit(&mut preparation, &publication) + .expect("publish complete generation"); + drop(preparation); + drop(publisher); + + let cold = target_store(target_root.path(), &fixture); + let pointer = cold + .read_publication_pointer() + .expect("read installed pointer") + .expect("active generation"); + let entry = pointer + .generation_index + .iter() + .find(|entry| entry.generation_id == pointer.generation_id) + .expect("indexed active generation"); + let identity = DurableSealedCodeGenerationIdentityV1 { + locator: pointer.generation_file.clone(), + digest: ManifestDigest::new(pointer.state_digest.clone()) + .expect("generation digest"), + size_bytes: entry.size_bytes, + }; + let suffix = sha256_hex_suffix(segment.digest.as_str()).expect("segment digest"); + let path = cold.segments_root.join(format!("segment-{suffix}.json")); + let (sender, receiver) = std::sync::mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |event: notify::Result| { + let _ = sender.send(event); + }) + .expect("native observer"); + watcher + .watch(&cold.segments_root, notify::RecursiveMode::NonRecursive) + .expect("watch real sealed segment directory"); + + let control = tracedecay_code_index::production::UninterruptibleCodeIndexControlV1; + let mut whole = Vec::new(); + cold.read_retained_partitioned_segment( + &identity, + SealedGenerationSegmentReadV1::Whole { + digest: &segment.digest, + size_bytes: segment.size_bytes, + }, + &mut whole, + &control, + ) + .expect("first authenticated historical segment read"); + assert_eq!(whole.len() as u64, segment.size_bytes); + assert_eq!(segment_digest(&whole), segment.digest); + assert!(whole.len() > 4); + let mut range = Vec::new(); + for _ in 0..16 { + cold.read_retained_partitioned_segment( + &identity, + SealedGenerationSegmentReadV1::Range { + digest: &segment.digest, + size_bytes: segment.size_bytes, + offset: 1, + length: 3, + }, + &mut range, + &control, + ) + .expect("read historical page without reopening segment"); + assert_eq!(range, whole[1..4]); + } + assert_eq!( + observed_named_opens(&receiver, &path), + 1, + "one whole and sixteen historical reads must require one segment open" + ); + drop(File::open(&path).expect("control pathname open")); + assert_eq!( + observed_named_opens(&receiver, &path), + 1, + "kernel observer must count a separate real pathname open" + ); + + let peer = cold.clone(); + peer.read_retained_partitioned_segment( + &identity, + SealedGenerationSegmentReadV1::Range { + digest: &segment.digest, + size_bytes: segment.size_bytes, + offset: 2, + length: 2, + }, + &mut range, + &control, + ) + .expect("a second store handle shares the bounded retained cache"); + assert_eq!(range, whole[2..4]); + assert_eq!( + observed_named_opens(&receiver, &path), + 0, + "a cloned publication store must not repeat the endpoint open" + ); + + let previous = target_root.path().join("retained-history-original-inode"); + std::fs::rename(&path, &previous).expect("move disposable canonical inode"); + std::fs::write(&path, &whole).expect("replace with identical bytes and another inode"); + assert_ne!( + std::fs::metadata(&previous).expect("original metadata").ino(), + std::fs::metadata(&path).expect("replacement metadata").ino(), + "the test must substitute a different inode" + ); + // Fixture replacement opens are not reader opens. + let _ = observed_named_opens(&receiver, &path); + range = vec![0x11, 0x22]; + assert!( + peer.read_retained_partitioned_segment( + &identity, + SealedGenerationSegmentReadV1::Range { + digest: &segment.digest, + size_bytes: segment.size_bytes, + offset: 1, + length: 3, + }, + &mut range, + &control, + ) + .is_err(), + "a replaced pathname must not inherit a cached segment's identity" + ); + assert_eq!(range, Vec::::new(), "failure reveals no previously read bytes"); + assert_eq!( + observed_named_opens(&receiver, &path), + 0, + "inode rejection does not need another endpoint-authorized open" + ); + std::fs::remove_file(&path).expect("remove disposable replacement"); + std::fs::rename(&previous, &path).expect("restore original inode"); +} From 9ea8a396040010a906082172a2c7639749fc51dd Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:21:07 -0800 Subject: [PATCH 75/93] fix(code-index): apply hosted Bazel formatting corrections Apply exactly five rustfmt_check_2024 hunks emitted by native hosted Bazel run 42706670785 across the retention cancellation regression and the retained-history read regression. Preserve all assertions and production semantics. Refs #4533. --- .../src/code_index_generations/tests.rs | 15 ++++++--------- .../tests/staged_segment_reads.rs | 17 ++++++++++++----- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs index 73089979ca1..2f875f354d1 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs @@ -2165,21 +2165,18 @@ fn segment_mark_prefers_cancellation_before_disappeared_manifest_open() { true } }; - let error = sweep_unreferenced_generation_segments( - store.path(), - None, - true, - &cancelled, - None, - ) - .expect_err("cancellation after enumeration must prevent segment deletion"); + let error = sweep_unreferenced_generation_segments(store.path(), None, true, &cancelled, None) + .expect_err("cancellation after enumeration must prevent segment deletion"); assert!(matches!(error, CodeGenerationRetentionErrorV1::Cancelled)); assert_eq!( calls.get(), 2, "the pre-open cancellation check must run after enumerating the file" ); - assert!(!manifest_path.exists(), "the withdrawal must actually occur"); + assert!( + !manifest_path.exists(), + "the withdrawal must actually occur" + ); assert_eq!( std::fs::read(&segment_path).expect("protected segment bytes"), segment_bytes, diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index a874a2b4bf6..7e7b3969552 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -2447,8 +2447,7 @@ fn retained_history_reads_across_requests_share_one_segment_open_and_refuse_repl .expect("indexed active generation"); let identity = DurableSealedCodeGenerationIdentityV1 { locator: pointer.generation_file.clone(), - digest: ManifestDigest::new(pointer.state_digest.clone()) - .expect("generation digest"), + digest: ManifestDigest::new(pointer.state_digest.clone()).expect("generation digest"), size_bytes: entry.size_bytes, }; let suffix = sha256_hex_suffix(segment.digest.as_str()).expect("segment digest"); @@ -2529,8 +2528,12 @@ fn retained_history_reads_across_requests_share_one_segment_open_and_refuse_repl std::fs::rename(&path, &previous).expect("move disposable canonical inode"); std::fs::write(&path, &whole).expect("replace with identical bytes and another inode"); assert_ne!( - std::fs::metadata(&previous).expect("original metadata").ino(), - std::fs::metadata(&path).expect("replacement metadata").ino(), + std::fs::metadata(&previous) + .expect("original metadata") + .ino(), + std::fs::metadata(&path) + .expect("replacement metadata") + .ino(), "the test must substitute a different inode" ); // Fixture replacement opens are not reader opens. @@ -2551,7 +2554,11 @@ fn retained_history_reads_across_requests_share_one_segment_open_and_refuse_repl .is_err(), "a replaced pathname must not inherit a cached segment's identity" ); - assert_eq!(range, Vec::::new(), "failure reveals no previously read bytes"); + assert_eq!( + range, + Vec::::new(), + "failure reveals no previously read bytes" + ); assert_eq!( observed_named_opens(&receiver, &path), 0, From 61394f7f12b5d0a9de82d7e163122377075a0c4d Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:31:07 -0800 Subject: [PATCH 76/93] fix(code-index): retire cached descriptors before generation GC Connect mounted/retiring generation readers to ordinary daemon retention. Under the exclusive store and segment locks, release cached historical descriptors before unlinking segments so deleted inodes do not remain allocated indefinitely. Refuse deletion if the cache is poisoned. Preserve byte-authenticated read locking and skip unrelated dry runs. Add a real two-generation publication/retention regression verifying an old-only cached segment is held, retired, freed from the process FD table, refused after deletion, and the active snapshot remains readable. Migrate all direct retention executor callers to the new callback argument. Refs #4533. Hosted Bazel execution and review remain required. --- .../src/code_index_generations.rs | 20 +++ .../src/code_index_generations/tests.rs | 3 + .../tests/graph_replay_pool_lock_tests.rs | 1 + .../code_index_scheduler/publication_store.rs | 24 ++- .../src/code_index_scheduler/registry.rs | 31 ++++ .../tests/publication_store.rs | 170 ++++++++++++++++++ .../src/store_maintenance/mod.rs | 9 +- 7 files changed, 256 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations.rs b/crates/tracedecay-code-index-retention/src/code_index_generations.rs index a722b019a60..10a07293ac2 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations.rs @@ -435,6 +435,13 @@ pub enum CodeGenerationRetentionErrorV1 { TargetHeld(String), } +/// A daemon-owned generation reader releases its open segment descriptors +/// before the retention executor unlinks any corresponding store entries. +/// The callback runs under the exclusive generation and shared-segment locks; +/// a refusal preserves the existing files and their durable receipts. +pub type RetainedSegmentHandleRetirementV1 = + Arc Result<(), CodeGenerationRetentionErrorV1> + Send + Sync>; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CodeGenerationRetentionModeV1 { DryRun, @@ -1774,6 +1781,7 @@ pub fn execute_code_generation_retention( completed_at, graph_replay_pool_root, &|| false, + None, ) } @@ -1793,6 +1801,7 @@ pub fn execute_code_generation_retention_cancellable( completed_at: UtcMicros, graph_replay_pool_root: Option<&Path>, is_cancelled: &dyn Fn() -> bool, + retire_cached_segments: Option<&RetainedSegmentHandleRetirementV1>, ) -> Result { if is_cancelled() { return Err(CodeGenerationRetentionErrorV1::Cancelled); @@ -1836,6 +1845,16 @@ pub fn execute_code_generation_retention_cancellable( "active generation changed after the retention mark phase".to_owned(), )); } + // Readers take the shared store lock before consulting their descriptor + // cache. Once this exclusive lock is held there can be no in-flight + // retained read; empty the daemon's cache before unlinking any segment. + // Otherwise POSIX keeps deleted segment bytes allocated indefinitely. + if (!plan.collectable_generations.is_empty() + || plan.collectable_generation_segments == GenerationSegmentCensusV1::Present) + && let Some(retire) = retire_cached_segments + { + retire()?; + } let mut generation_segment_batch_exhausted = if plan.collectable_generations.is_empty() && plan.collectable_generation_segments == GenerationSegmentCensusV1::Present { @@ -2112,6 +2131,7 @@ fn run_code_generation_retention_cancellable( completed_at, graph_replay_pool_root, is_cancelled, + None, ) } diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs index 2f875f354d1..3f83567d0ce 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs @@ -1003,6 +1003,7 @@ fn cancellable_artifact_apply_stops_rehash_before_quarantine_and_retries() { UtcMicros(15), None, &|| checks.fetch_add(1, std::sync::atomic::Ordering::SeqCst) >= 6, + None ) .expect_err("cancellation must interrupt the under-lock artifact rehash"); @@ -4055,6 +4056,7 @@ fn abandoned_preparations_are_collected_only_after_shared_project_publication_le UtcMicros(1), None, &|| false, + None ); assert!(matches!( result, @@ -4072,6 +4074,7 @@ fn abandoned_preparations_are_collected_only_after_shared_project_publication_le UtcMicros(1), None, &|| locking::store_lock_park_count() > prior_parks, + None ); assert!(matches!( result, diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/tests/graph_replay_pool_lock_tests.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/tests/graph_replay_pool_lock_tests.rs index 2b2e5785fcd..cf84aa34552 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/tests/graph_replay_pool_lock_tests.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/tests/graph_replay_pool_lock_tests.rs @@ -276,6 +276,7 @@ fn execute_cancels_held_pool_acquire_before_any_exposure() { UtcMicros(121), Some(&pool_root), &|| graph_replay_pool_acquire_observation().0 >= 1, + None ) .expect_err("cancellation during pool wait must abort execute"); diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 72eaaf1f663..525b8461ae8 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -35,7 +35,7 @@ use tracedecay_code_index_retention::code_index_generations::{ DurableSealedCodeGenerationIdentityV1, MAX_DURABLE_GENERATION_INDEX_BYTES_V1, MAX_DURABLE_GENERATION_INDEX_ENTRIES_V1, acquire_generation_segments_publication_lock, code_generation_segments_root, durable_generation_index_digest, reset_code_index_scope_store, - retain_bounded_generation_index, try_acquire_code_generation_store_lock, + RetainedSegmentHandleRetirementV1, retain_bounded_generation_index, try_acquire_code_generation_store_lock, try_acquire_code_generation_store_read_lock, }; use tracedecay_domain::{ @@ -3976,6 +3976,28 @@ impl DaemonCodeIndexPublicationStoreV1 { /// entry shape digests differently, is deleted outright: no copy is kept /// and nothing is migrated. A store another owner holds is a typed busy /// refusal the caller retries; every other failure is reported as is. + pub(super) fn retained_segment_reclaimer(&self) -> RetainedSegmentHandleRetirementV1 { + #[cfg(unix)] + { + let cached = Arc::clone(&self.retained_segment_reads); + Arc::new(move || { + cached + .lock() + .map_err(|_| { + CodeGenerationRetentionErrorV1::UnsafeState( + "retained segment cache is poisoned".to_owned(), + ) + })? + .clear(); + Ok(()) + }) + } + #[cfg(not(unix))] + { + Arc::new(|| Ok(())) + } + } + pub(super) fn reset_corrupt_store( &self, ) -> Result { diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs index 8e4b0a26fdb..23ddfcd9649 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs @@ -30,6 +30,7 @@ use tracedecay_contracts::code_index_freshness::{ CodeGraphServingReadinessV1, CodeIndexBuildBlockedReasonV1, CodeIndexBuildPhaseV1, CodeIndexBuildProgressV1, CodeIndexConvergenceParkedV1, }; +use tracedecay_code_index_retention::code_index_generations::RetainedSegmentHandleRetirementV1; use tracedecay_domain::{ CodeGenerationId, IndexPathPolicyV1, ManifestDigest, ProjectId, RepositoryId, SanitizedCodeFileV1, WorktreeId, forward_slash_path, host_cpu_target, @@ -2386,6 +2387,36 @@ type AttributionSeatsV1 = BTreeMap)>; impl CodeIndexSchedulerRegistryV1 { + /// Coordinate ordinary daemon retention with retained-history readers. + /// The executor invokes the returned owner only while it holds the + /// exclusive store lock; no scheduler mutex or I/O is held here. + pub async fn retained_segment_reclaimer( + &self, + project_root: &Path, + ) -> Option { + let project_root = canonical_existing_identity(project_root).ok()?; + if let Some(retire) = self + .mounted + .lock() + .await + .get(&project_root) + .map(|mounted| { + mounted + .historical_generation_owner + .publication + .retained_segment_reclaimer() + }) + { + return Some(retire); + } + self.retiring.lock().await.get(&project_root).map(|retiring| { + retiring + .historical_generation_owner + .publication + .retained_segment_reclaimer() + }) + } + fn incomplete_text_slice_may_continue(pending_wake: &PendingWakeV1) -> bool { !pending_wake.has_pending_arrival() } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs index cd6290fbe80..e1a0adf4851 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs @@ -1,3 +1,6 @@ +#[cfg(target_os = "linux")] +use std::os::unix::fs::MetadataExt; + use std::{ collections::{BTreeMap, BTreeSet}, fmt::Write as _, @@ -16,6 +19,7 @@ use tracedecay_code_index_retention::code_index_generations::{ CODE_INDEX_PREPARATION_DIRECTORY_PREFIX_V1, CodeGenerationRetentionErrorV1, CodeGenerationRetentionModeV1, DEFAULT_STRANDED_SCOPE_MINIMUM_AGE_SECS, DurableGenerationCardinalityV1, DurableGenerationIndexEntryV1, DurablePublicationPointerV1, + DurableSealedCodeGenerationIdentityV1, RetainedSegmentHandleRetirementV1, MAX_CODE_GENERATION_RETENTION_BATCH_V1, acquire_code_generation_store_lock, code_generation_segments_root, code_text_artifact_staging_root, code_text_artifacts_root, durable_generation_index_digest, execute_code_generation_retention_cancellable, @@ -640,6 +644,7 @@ fn partitioned_reclamation_is_bounded_and_preserves_retained_segments() { UtcMicros(8_000_000), None, &|| orphan_paths.iter().any(|path| !path.exists()), + None ) .expect_err("interrupt segment sweep after its first unlink"); assert!( @@ -1641,6 +1646,171 @@ fn evidence_pack_failure_after_pages_never_publishes_manifest_or_pointer() { ); } + +#[cfg(target_os = "linux")] +#[test] +fn ordinary_retention_closes_cached_historical_segment_before_unlink() { + let fixture = GitFixture::new(&[( + "src/lib.rs", + "pub fn retained_before_collection() -> usize { 1 }\n", + )]); + let store = TempDir::new().expect("isolated generation store"); + let generation_ids = retention_generations(&fixture, store.path(), 2); + assert_eq!(generation_ids.len(), 2); + + let pointer: DurablePublicationPointerV1 = serde_json::from_slice( + &std::fs::read(store.path().join("active-code-generation-v1.json")) + .expect("read the published generation index"), + ) + .expect("decode the published generation index"); + let old = pointer + .generation_index + .iter() + .find(|entry| entry.generation_id == generation_ids[0].as_str()) + .expect("old generation remains indexed"); + let active = pointer + .generation_index + .iter() + .find(|entry| entry.generation_id == generation_ids[1].as_str()) + .expect("successor generation remains indexed"); + let segments_of = |generation_file: &str| { + let manifest = std::fs::read( + store.path().join("code-generations-v1").join(generation_file), + ) + .expect("read exact generation manifest"); + CodeIndexPublishedGenerationV1::partitioned_segment_identities_from_reader( + manifest.as_slice(), + ) + .expect("parse the current sealed format") + .expect("a published generation has segment identities") + }; + let old_segments = segments_of(&old.generation_file); + let active_segments = segments_of(&active.generation_file); + let active_digests = active_segments + .iter() + .map(|identity| identity.digest.as_str()) + .collect::>(); + let retired_segment = old_segments + .iter() + .find(|identity| !active_digests.contains(identity.digest.as_str())) + .expect("the source edit must replace at least one nonempty segment"); + let suffix = sha256_hex_suffix(retired_segment.digest.as_str()) + .expect("segment content-address suffix"); + let path = code_generation_segments_root(store.path()) + .join(format!("segment-{suffix}.json")); + let expected = std::fs::read(&path).expect("read live old segment"); + assert!(!expected.is_empty(), "the retired segment contains actual bytes"); + let inode = std::fs::metadata(&path).expect("original segment inode"); + + let scheduler = scheduler( + &fixture, + store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + let publication = scheduler.publication.clone(); + let identity = DurableSealedCodeGenerationIdentityV1 { + locator: old.generation_file.clone(), + digest: ManifestDigest::new(old.state_digest.clone()).expect("generation digest"), + size_bytes: old.size_bytes, + }; + let mut received = vec![0x55, 0xaa]; + publication + .read_retained_partitioned_segment( + &identity, + SealedGenerationSegmentReadV1::Whole { + digest: &retired_segment.digest, + size_bytes: retired_segment.size_bytes, + }, + &mut received, + &UninterruptibleCodeIndexControlV1, + ) + .expect("serve an actual historical segment through the cached reader"); + assert_eq!(received, expected); + assert_eq!( + publication.retained_segment_reads.lock().expect("retained cache").len(), + 1, + "one actual historical segment file is held open" + ); + let holders = || { + std::fs::read_dir("/proc/self/fd") + .expect("inspect actual process descriptor table") + .filter_map(|entry| entry.ok()) + .filter_map(|entry| std::fs::metadata(entry.path()).ok()) + .filter(|metadata| metadata.dev() == inode.dev() && metadata.ino() == inode.ino()) + .count() + }; + assert!(holders() > 0, "a live descriptor must pin the original inode"); + + let plan = tracedecay_code_index_retention::code_index_generations::plan_code_generation_retention( + store.path(), + &BTreeSet::new(), + ) + .expect("independently verify retention liveness and digests"); + assert!( + plan.collectable_generations + .iter() + .any(|item| item.generation_id == generation_ids[0]), + "the old generation must be collectable" + ); + let release = publication.retained_segment_reclaimer(); + let releases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observed_releases = Arc::clone(&releases); + let guarded: RetainedSegmentHandleRetirementV1 = Arc::new(move || { + observed_releases.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + release() + }); + let report = execute_code_generation_retention_cancellable( + store.path(), + plan, + CodeGenerationRetentionModeV1::Apply, + UtcMicros(100_000_000), + None, + &|| false, + Some(&guarded), + ) + .expect("apply the real normal retention transaction"); + assert!( + report.deleted_generations + .iter() + .any(|item| item.generation_id == generation_ids[0]), + "retention must remove the actual old generation" + ); + assert_eq!(releases.load(std::sync::atomic::Ordering::SeqCst), 1); + assert!( + publication.retained_segment_reads.lock().expect("retained cache").is_empty(), + "retention must clear the shared descriptor cache before unlinking" + ); + assert!(!path.exists(), "the old-only segment pathname is reclaimed"); + assert_eq!( + holders(), + 0, + "normal retention must not leave the deleted inode allocated on an idle daemon" + ); + assert!( + publication + .load_generation(&generation_ids[1]) + .expect("read live generation") + .is_some(), + "the active snapshot must remain readable" + ); + received = vec![0x55, 0xaa]; + assert!( + publication + .read_retained_partitioned_segment( + &identity, + SealedGenerationSegmentReadV1::Whole { + digest: &retired_segment.digest, + size_bytes: retired_segment.size_bytes, + }, + &mut received, + &UninterruptibleCodeIndexControlV1, + ) + .is_err(), + "a collected generation must never be served from a stale cached descriptor" + ); + assert_eq!(received, Vec::::new()); +} + /// Membership of the durable `generation_index` is a referential-integrity /// check, not a liveness mark: a published store keeps the active generation /// and the vector-readable sources, and everything older is collectable while diff --git a/crates/tracedecay-maintenance/src/store_maintenance/mod.rs b/crates/tracedecay-maintenance/src/store_maintenance/mod.rs index 8475df33b86..7b878d8f670 100644 --- a/crates/tracedecay-maintenance/src/store_maintenance/mod.rs +++ b/crates/tracedecay-maintenance/src/store_maintenance/mod.rs @@ -14,7 +14,8 @@ use crate::lease::ProjectStoreMaintenanceLeaseV1; use crate::telemetry::StoreTelemetrySamplingRegistry; use tracedecay_code_index_retention::code_index_generations::{ CodeGenerationRetentionErrorV1, CodeGenerationRetentionModeV1, CodeGenerationRetentionPlanV1, - CodeGenerationRetentionReportV1, CodeIndexScopeV1, code_index_scopes, code_index_store_root, + CodeGenerationRetentionReportV1, CodeIndexScopeV1, RetainedSegmentHandleRetirementV1, + code_index_scopes, code_index_store_root, execute_code_generation_retention_cancellable, }; use tracedecay_code_index_runtime::code_index_scheduler::CodeIndexSchedulerRegistryV1; @@ -220,6 +221,7 @@ async fn execute_collection( scan_owner_root: &Path, observations: &StoreTelemetrySamplingRegistry, cancellation: &CancellationToken, + retire_cached_segments: Option, ) -> Result { // `current_timestamp()` counts seconds; wrapping it in `UtcMicros` stamped // every deletion receipt with a seconds value in a micros-typed field @@ -238,6 +240,7 @@ async fn execute_collection( completed_at, Some(&execution_pool_root), &|| execution_cancellation.is_cancelled(), + retire_cached_segments.as_ref(), )?; scan.lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -467,6 +470,7 @@ pub async fn run_code_generation_retention( log_code_generation_retention_degraded(observations, "retention_cancelled"); return CodeGenerationRetentionOutcomeV1::Failed; } + let retire_cached_segments = schedulers.retained_segment_reclaimer(lease.project_root()).await; let report = match execute_collection( &store_root, plan, @@ -475,6 +479,7 @@ pub async fn run_code_generation_retention( &layout.data_root, observations, cancellation, + retire_cached_segments, ) .await { @@ -645,6 +650,7 @@ pub async fn run_registered_code_generation_retention( } continue; } + let retire_cached_segments = schedulers.retained_segment_reclaimer(&root).await; match execute_collection( &scope.store_root, plan, @@ -653,6 +659,7 @@ pub async fn run_registered_code_generation_retention( &store.data_root, observations, cancellation, + retire_cached_segments, ) .await { From 43ca411f9608a7f6ee3d0f5549101cd29224e45c Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:49:11 -0800 Subject: [PATCH 77/93] fix(code-index): format retention cache release for Bazel Apply all 21 exact Rustfmt hunks emitted by hosted Repository gates on 61394f7 across six changed production/test files. No semantic or assertion changes. Keep the issue PR targeting master, Refs #4533. --- .../src/code_index_generations/tests.rs | 6 +- .../tests/graph_replay_pool_lock_tests.rs | 2 +- .../code_index_scheduler/publication_store.rs | 8 +-- .../src/code_index_scheduler/registry.rs | 26 ++++----- .../tests/publication_store.rs | 55 ++++++++++++------- .../src/store_maintenance/mod.rs | 7 ++- 6 files changed, 60 insertions(+), 44 deletions(-) diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs index 3f83567d0ce..5aee960f68f 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs @@ -1003,7 +1003,7 @@ fn cancellable_artifact_apply_stops_rehash_before_quarantine_and_retries() { UtcMicros(15), None, &|| checks.fetch_add(1, std::sync::atomic::Ordering::SeqCst) >= 6, - None + None, ) .expect_err("cancellation must interrupt the under-lock artifact rehash"); @@ -4056,7 +4056,7 @@ fn abandoned_preparations_are_collected_only_after_shared_project_publication_le UtcMicros(1), None, &|| false, - None + None, ); assert!(matches!( result, @@ -4074,7 +4074,7 @@ fn abandoned_preparations_are_collected_only_after_shared_project_publication_le UtcMicros(1), None, &|| locking::store_lock_park_count() > prior_parks, - None + None, ); assert!(matches!( result, diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/tests/graph_replay_pool_lock_tests.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/tests/graph_replay_pool_lock_tests.rs index cf84aa34552..29df960d515 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/tests/graph_replay_pool_lock_tests.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/tests/graph_replay_pool_lock_tests.rs @@ -276,7 +276,7 @@ fn execute_cancels_held_pool_acquire_before_any_exposure() { UtcMicros(121), Some(&pool_root), &|| graph_replay_pool_acquire_observation().0 >= 1, - None + None, ) .expect_err("cancellation during pool wait must abort execute"); diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 525b8461ae8..efa6c868a84 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -33,10 +33,10 @@ use tracedecay_code_index_retention::code_index_generations::{ CodeGenerationStoreLockV1, CodeIndexScopeStoreResetV1, DurableGenerationCardinalityV1, DurableGenerationIndexEntryV1, DurablePublicationPointerV1, DurableSealedCodeGenerationIdentityV1, MAX_DURABLE_GENERATION_INDEX_BYTES_V1, - MAX_DURABLE_GENERATION_INDEX_ENTRIES_V1, acquire_generation_segments_publication_lock, - code_generation_segments_root, durable_generation_index_digest, reset_code_index_scope_store, - RetainedSegmentHandleRetirementV1, retain_bounded_generation_index, try_acquire_code_generation_store_lock, - try_acquire_code_generation_store_read_lock, + MAX_DURABLE_GENERATION_INDEX_ENTRIES_V1, RetainedSegmentHandleRetirementV1, + acquire_generation_segments_publication_lock, code_generation_segments_root, + durable_generation_index_digest, reset_code_index_scope_store, retain_bounded_generation_index, + try_acquire_code_generation_store_lock, try_acquire_code_generation_store_read_lock, }; use tracedecay_domain::{ CodeGenerationId, ContentDigest, ManifestDigest, ProjectId, SanitizerRevision, WorktreeId, diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs index 23ddfcd9649..c25e041ed50 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs @@ -26,11 +26,11 @@ use tracedecay_code_index::production::{ CodeIndexExecutionControlV1, CodeIndexPublishedGenerationV1, VerifiedSealedTextGenerationMetadataV1, }; +use tracedecay_code_index_retention::code_index_generations::RetainedSegmentHandleRetirementV1; use tracedecay_contracts::code_index_freshness::{ CodeGraphServingReadinessV1, CodeIndexBuildBlockedReasonV1, CodeIndexBuildPhaseV1, CodeIndexBuildProgressV1, CodeIndexConvergenceParkedV1, }; -use tracedecay_code_index_retention::code_index_generations::RetainedSegmentHandleRetirementV1; use tracedecay_domain::{ CodeGenerationId, IndexPathPolicyV1, ManifestDigest, ProjectId, RepositoryId, SanitizedCodeFileV1, WorktreeId, forward_slash_path, host_cpu_target, @@ -2395,26 +2395,24 @@ impl CodeIndexSchedulerRegistryV1 { project_root: &Path, ) -> Option { let project_root = canonical_existing_identity(project_root).ok()?; - if let Some(retire) = self - .mounted + if let Some(retire) = self.mounted.lock().await.get(&project_root).map(|mounted| { + mounted + .historical_generation_owner + .publication + .retained_segment_reclaimer() + }) { + return Some(retire); + } + self.retiring .lock() .await .get(&project_root) - .map(|mounted| { - mounted + .map(|retiring| { + retiring .historical_generation_owner .publication .retained_segment_reclaimer() }) - { - return Some(retire); - } - self.retiring.lock().await.get(&project_root).map(|retiring| { - retiring - .historical_generation_owner - .publication - .retained_segment_reclaimer() - }) } fn incomplete_text_slice_may_continue(pending_wake: &PendingWakeV1) -> bool { diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs index e1a0adf4851..6435f2a8a1d 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs @@ -19,8 +19,8 @@ use tracedecay_code_index_retention::code_index_generations::{ CODE_INDEX_PREPARATION_DIRECTORY_PREFIX_V1, CodeGenerationRetentionErrorV1, CodeGenerationRetentionModeV1, DEFAULT_STRANDED_SCOPE_MINIMUM_AGE_SECS, DurableGenerationCardinalityV1, DurableGenerationIndexEntryV1, DurablePublicationPointerV1, - DurableSealedCodeGenerationIdentityV1, RetainedSegmentHandleRetirementV1, - MAX_CODE_GENERATION_RETENTION_BATCH_V1, acquire_code_generation_store_lock, + DurableSealedCodeGenerationIdentityV1, MAX_CODE_GENERATION_RETENTION_BATCH_V1, + RetainedSegmentHandleRetirementV1, acquire_code_generation_store_lock, code_generation_segments_root, code_text_artifact_staging_root, code_text_artifacts_root, durable_generation_index_digest, execute_code_generation_retention_cancellable, execute_scope_root_retention, plan_scope_root_retention_with_liveness_proof, @@ -644,7 +644,7 @@ fn partitioned_reclamation_is_bounded_and_preserves_retained_segments() { UtcMicros(8_000_000), None, &|| orphan_paths.iter().any(|path| !path.exists()), - None + None, ) .expect_err("interrupt segment sweep after its first unlink"); assert!( @@ -1646,7 +1646,6 @@ fn evidence_pack_failure_after_pages_never_publishes_manifest_or_pointer() { ); } - #[cfg(target_os = "linux")] #[test] fn ordinary_retention_closes_cached_historical_segment_before_unlink() { @@ -1675,7 +1674,10 @@ fn ordinary_retention_closes_cached_historical_segment_before_unlink() { .expect("successor generation remains indexed"); let segments_of = |generation_file: &str| { let manifest = std::fs::read( - store.path().join("code-generations-v1").join(generation_file), + store + .path() + .join("code-generations-v1") + .join(generation_file), ) .expect("read exact generation manifest"); CodeIndexPublishedGenerationV1::partitioned_segment_identities_from_reader( @@ -1694,12 +1696,14 @@ fn ordinary_retention_closes_cached_historical_segment_before_unlink() { .iter() .find(|identity| !active_digests.contains(identity.digest.as_str())) .expect("the source edit must replace at least one nonempty segment"); - let suffix = sha256_hex_suffix(retired_segment.digest.as_str()) - .expect("segment content-address suffix"); - let path = code_generation_segments_root(store.path()) - .join(format!("segment-{suffix}.json")); + let suffix = + sha256_hex_suffix(retired_segment.digest.as_str()).expect("segment content-address suffix"); + let path = code_generation_segments_root(store.path()).join(format!("segment-{suffix}.json")); let expected = std::fs::read(&path).expect("read live old segment"); - assert!(!expected.is_empty(), "the retired segment contains actual bytes"); + assert!( + !expected.is_empty(), + "the retired segment contains actual bytes" + ); let inode = std::fs::metadata(&path).expect("original segment inode"); let scheduler = scheduler( @@ -1727,7 +1731,11 @@ fn ordinary_retention_closes_cached_historical_segment_before_unlink() { .expect("serve an actual historical segment through the cached reader"); assert_eq!(received, expected); assert_eq!( - publication.retained_segment_reads.lock().expect("retained cache").len(), + publication + .retained_segment_reads + .lock() + .expect("retained cache") + .len(), 1, "one actual historical segment file is held open" ); @@ -1739,13 +1747,17 @@ fn ordinary_retention_closes_cached_historical_segment_before_unlink() { .filter(|metadata| metadata.dev() == inode.dev() && metadata.ino() == inode.ino()) .count() }; - assert!(holders() > 0, "a live descriptor must pin the original inode"); + assert!( + holders() > 0, + "a live descriptor must pin the original inode" + ); - let plan = tracedecay_code_index_retention::code_index_generations::plan_code_generation_retention( - store.path(), - &BTreeSet::new(), - ) - .expect("independently verify retention liveness and digests"); + let plan = + tracedecay_code_index_retention::code_index_generations::plan_code_generation_retention( + store.path(), + &BTreeSet::new(), + ) + .expect("independently verify retention liveness and digests"); assert!( plan.collectable_generations .iter() @@ -1770,14 +1782,19 @@ fn ordinary_retention_closes_cached_historical_segment_before_unlink() { ) .expect("apply the real normal retention transaction"); assert!( - report.deleted_generations + report + .deleted_generations .iter() .any(|item| item.generation_id == generation_ids[0]), "retention must remove the actual old generation" ); assert_eq!(releases.load(std::sync::atomic::Ordering::SeqCst), 1); assert!( - publication.retained_segment_reads.lock().expect("retained cache").is_empty(), + publication + .retained_segment_reads + .lock() + .expect("retained cache") + .is_empty(), "retention must clear the shared descriptor cache before unlinking" ); assert!(!path.exists(), "the old-only segment pathname is reclaimed"); diff --git a/crates/tracedecay-maintenance/src/store_maintenance/mod.rs b/crates/tracedecay-maintenance/src/store_maintenance/mod.rs index 7b878d8f670..9c4340341e0 100644 --- a/crates/tracedecay-maintenance/src/store_maintenance/mod.rs +++ b/crates/tracedecay-maintenance/src/store_maintenance/mod.rs @@ -15,8 +15,7 @@ use crate::telemetry::StoreTelemetrySamplingRegistry; use tracedecay_code_index_retention::code_index_generations::{ CodeGenerationRetentionErrorV1, CodeGenerationRetentionModeV1, CodeGenerationRetentionPlanV1, CodeGenerationRetentionReportV1, CodeIndexScopeV1, RetainedSegmentHandleRetirementV1, - code_index_scopes, code_index_store_root, - execute_code_generation_retention_cancellable, + code_index_scopes, code_index_store_root, execute_code_generation_retention_cancellable, }; use tracedecay_code_index_runtime::code_index_scheduler::CodeIndexSchedulerRegistryV1; use tracedecay_domain::CodeGenerationId; @@ -470,7 +469,9 @@ pub async fn run_code_generation_retention( log_code_generation_retention_degraded(observations, "retention_cancelled"); return CodeGenerationRetentionOutcomeV1::Failed; } - let retire_cached_segments = schedulers.retained_segment_reclaimer(lease.project_root()).await; + let retire_cached_segments = schedulers + .retained_segment_reclaimer(lease.project_root()) + .await; let report = match execute_collection( &store_root, plan, From fba00d6b0dba1e8bd25a413f1c2563b32dfb57f5 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:55:33 -0800 Subject: [PATCH 78/93] perf(code-index): reuse authenticated publication pointer descriptor A historical page read still opened the active pointer for every range, even when its segment was cached. Pin the authenticated Unix pointer inode in the existing digest memo, rehash its bytes through that descriptor and verify path/fd identity before and after reads. Atomic publication changes fall back to cold verification; same-inode mutations refuse corruption. A Linux native inotify production-history test requires one cold pointer open and zero more across 17 exact-byte reads and a cloned store, with a positive kernel-open control. Refs #4533; partial, do not merge. --- .../code_index_scheduler/publication_store.rs | 58 +++++++++++++++++++ .../tests/staged_segment_reads.rs | 30 ++++++++++ 2 files changed, 88 insertions(+) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index efa6c868a84..bc80f4bca0b 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -422,6 +422,10 @@ struct PublicationPointerMemoV1 { size: u64, digest: String, pointer: DurablePublicationPointerV1, + // Retain the authenticated inode, not just its digest. An ordinary + // historical range read must not pay another endpoint-authorized open. + #[cfg(unix)] + file: Option, } #[derive(Clone)] @@ -2176,6 +2180,52 @@ impl DaemonCodeIndexPublicationStoreV1 { } let mtime = metadata.modified().ok(); let size = metadata.len(); + // Verify a cached pointer through its existing descriptor. Metadata + // witnesses both sides of the read, including same-byte inode swaps, + // while hashing the current bytes detects in-place changes even on + // filesystems with coarse timestamps. An atomic replacement misses + // this inode and falls through to the ordinary cold validation. + #[cfg(unix)] + { + let mut cached = self + .pointer_memo + .lock() + .unwrap_or_else(PoisonError::into_inner); + if let Some(entry) = cached.as_mut() + && let Some(file) = entry.file.as_mut() + { + let before = file.metadata().map_err(Self::map_pointer_io)?; + if Self::same_unix_segment_identity(&metadata, &before) { + file.seek(SeekFrom::Start(0)) + .map_err(Self::map_pointer_io)?; + let mut current = Vec::new(); + (&mut *file) + .take(MAX_DURABLE_PUBLICATION_POINTER_BYTES + 1) + .read_to_end(&mut current) + .map_err(Self::map_pointer_io)?; + let after = file.metadata().map_err(Self::map_pointer_io)?; + let named_after = self + .active_path + .symlink_metadata() + .map_err(Self::map_pointer_io)?; + if !Self::same_unix_segment_identity(&before, &after) + || !Self::same_unix_segment_identity(&before, &named_after) + { + return Err(Self::corruption( + "active code-generation pointer changed during pinned validation", + )); + } + if Self::state_digest(¤t) != entry.digest { + return Err(Self::corruption( + "active code-generation pointer bytes changed while pinned", + )); + } + entry.mtime = mtime; + entry.size = size; + return Ok(Some((entry.pointer.clone(), current))); + } + } + } // Size and mtime are hints, not identity. Text-artifact attach writes // a fixed-width pointer through another path, and a 1-second mtime // filesystem can leave both unchanged while the bytes move. The memo @@ -2231,6 +2281,10 @@ impl DaemonCodeIndexPublicationStoreV1 { } memo.mtime = mtime; memo.size = size; + #[cfg(unix)] + { + memo.file = Some(opened_pointer); + } return Ok(Some((memo.pointer.clone(), bytes))); } } @@ -2377,6 +2431,8 @@ impl DaemonCodeIndexPublicationStoreV1 { size, digest, pointer: pointer.clone(), + #[cfg(unix)] + file: Some(opened_pointer), }); } Ok(Some((pointer, bytes))) @@ -2429,6 +2485,8 @@ impl DaemonCodeIndexPublicationStoreV1 { size: metadata.len(), digest: Self::state_digest(bytes), pointer: pointer.clone(), + #[cfg(unix)] + file: persisted.try_clone().ok(), }); } else { // Never preserve an older memo after an unverified replacement. diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 7e7b3969552..62b90f65b8c 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -2436,10 +2436,24 @@ fn retained_history_reads_across_requests_share_one_segment_open_and_refuse_repl drop(publisher); let cold = target_store(target_root.path(), &fixture); + let (pointer_sender, pointer_receiver) = std::sync::mpsc::channel(); + let mut pointer_watcher = + notify::recommended_watcher(move |event: notify::Result| { + let _ = pointer_sender.send(event); + }) + .expect("native pointer open observer"); + pointer_watcher + .watch(&cold.active_path, notify::RecursiveMode::NonRecursive) + .expect("watch the authenticated active pointer"); let pointer = cold .read_publication_pointer() .expect("read installed pointer") .expect("active generation"); + assert_eq!( + observed_pointer_opens(&pointer_receiver), + 1, + "cold publication pointer authentication requires one kernel open" + ); let entry = pointer .generation_index .iter() @@ -2497,6 +2511,17 @@ fn retained_history_reads_across_requests_share_one_segment_open_and_refuse_repl 1, "one whole and sixteen historical reads must require one segment open" ); + assert_eq!( + observed_pointer_opens(&pointer_receiver), + 0, + "17 historical reads must reuse the pointer's authenticated descriptor" + ); + drop(File::open(&cold.active_path).expect("positive pointer pathname-open control")); + assert_eq!( + observed_pointer_opens(&pointer_receiver), + 1, + "kernel observer must detect an independently opened pointer" + ); drop(File::open(&path).expect("control pathname open")); assert_eq!( observed_named_opens(&receiver, &path), @@ -2523,6 +2548,11 @@ fn retained_history_reads_across_requests_share_one_segment_open_and_refuse_repl 0, "a cloned publication store must not repeat the endpoint open" ); + assert_eq!( + observed_pointer_opens(&pointer_receiver), + 0, + "a clone must share the pinned publication pointer without another open" + ); let previous = target_root.path().join("retained-history-original-inode"); std::fs::rename(&path, &previous).expect("move disposable canonical inode"); From 0accee86cb71a0433e6ae6acbf1d0f3acb8d8fcb Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:38:47 -0800 Subject: [PATCH 79/93] fix(code-index): close pointer handles before scope reset Clear the shared pointer memo before the destructive reset, retaining its mutex through deletion. Later reset failures must not retain an unlinked pointer descriptor across store clones. Add a real generation publication, contended-reset preservation, post-unlink failure and fresh-store recovery regression. Linux observes actual held inode descriptors before and after reset. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 20 +- .../tests/publication_reset.rs | 186 ++++++++++++++++++ 2 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_reset.rs diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index bc80f4bca0b..9c5f534be53 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -78,6 +78,10 @@ mod immutable_segment_equality; #[path = "tests/retained_segment_admission.rs"] mod retained_segment_admission; +#[cfg(all(test, unix))] +#[path = "tests/publication_reset.rs"] +mod publication_reset; + const MAX_DURABLE_PUBLICATION_POINTER_BYTES: u64 = 512 * 1024; const DURABLE_GENERATION_IO_CHUNK_BYTES_V1: usize = 64 * 1024; @@ -4074,7 +4078,17 @@ impl DaemonCodeIndexPublicationStoreV1 { .lock() .map_err(|_| Self::unavailable("retained segment read cache was poisoned"))? .clear(); - let receipt = reset_code_index_scope_store(&store_lock).map_err(Self::unavailable)?; + let receipt = { + // Release the shared pointer descriptor before any unlink, even + // when later cleanup or reconstruction fails. Keep the memo locked + // through deletion so a reader cannot repopulate it during reset. + let mut memo = self + .pointer_memo + .lock() + .unwrap_or_else(PoisonError::into_inner); + *memo = None; + reset_code_index_scope_store(&store_lock).map_err(Self::unavailable)? + }; std::fs::create_dir_all(&self.generations_root).map_err(Self::unavailable)?; tracedecay_code_index_retention::code_index_generations::record_scope_root( store_root, @@ -4092,10 +4106,6 @@ impl DaemonCodeIndexPublicationStoreV1 { .lock() .unwrap_or_else(PoisonError::into_inner) = None; self.cache.ready.notify_all(); - *self - .pointer_memo - .lock() - .unwrap_or_else(PoisonError::into_inner) = None; *self .active_manifest_memo .lock() diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_reset.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_reset.rs new file mode 100644 index 00000000000..904b9f9a82c --- /dev/null +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_reset.rs @@ -0,0 +1,186 @@ +//! Failed scope resets must release cached descriptors before unlinking files. +use std::fs; +use std::os::unix::fs::MetadataExt; +use std::sync::Arc; + +use tempfile::TempDir; +use tracedecay_code_index::production::{ + CodeIndexAtomicPublicationPort, CodeIndexPublicationStoreErrorV1, +}; +use tracedecay_code_index_retention::code_index_generations::try_acquire_code_generation_store_read_lock; +use tracedecay_domain::{ProjectId, SanitizerRevision}; + +use super::{DaemonCodeIndexPublicationStoreV1, SharedCodeIndexBytePoolV1}; +use crate::code_index_scheduler::tests::GitFixture; +use crate::code_index_scheduler::{CodeIndexReconcileOutcomeV1, CodeIndexWorktreeSchedulerV1}; + +#[cfg(target_os = "linux")] +fn live_inode_handles(identity: &fs::Metadata) -> usize { + let mut count = 0; + for entry in fs::read_dir("/proc/self/fd").expect("observe process descriptors") { + let path = entry.expect("descriptor directory entry").path(); + match fs::metadata(&path) { + Ok(current) if (current.dev(), current.ino()) == (identity.dev(), identity.ino()) => { + count += 1; + } + Ok(_) => {} + // Unrelated test threads may close a descriptor after enumeration. + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!("cannot inspect {}: {error}", path.display()), + } + } + count +} + +#[test] +fn failed_reset_releases_pointer_handles_across_store_clones() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn reset_fixture() -> u32 { 17 }\n")]); + let source_root = TempDir::new().expect("isolated source store"); + let mut source = CodeIndexWorktreeSchedulerV1::open( + ProjectId::new("project.code-index-tests").expect("project identity"), + fixture.path(), + source_root.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ) + .expect("source scheduler"); + assert!(matches!( + source.reconcile_now().expect("publish source generation"), + CodeIndexReconcileOutcomeV1::Published(_) + )); + let generation = Arc::clone(&source.latest_complete().expect("decode source").generation); + drop(source); + + let target_root = TempDir::new().expect("isolated target store"); + let open_target = || { + DaemonCodeIndexPublicationStoreV1::new( + target_root.path(), + fixture.path(), + SanitizerRevision::new(tracedecay_privacy::CODE_SOURCE_SANITIZER_VERSION_V1) + .expect("sanitizer revision"), + ) + .expect("target publication store") + }; + let mut target = open_target(); + let mut preparation = target + .prepare(&generation.sealed_scope(), None) + .expect("prepare actual generation"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage actual generation"); + target + .commit(&mut preparation, &publication) + .expect("publish actual generation"); + drop(preparation); + let clone = target.clone(); + assert_eq!( + clone + .read_publication_pointer() + .expect("authenticate cached pointer") + .expect("published pointer") + .generation_id, + generation.manifest().generation_id.as_str() + ); + let pointer_bytes = fs::read(&target.active_path).expect("exact pointer bytes"); + assert_ne!(pointer_bytes.as_slice(), b""); + let identity = target + .active_path + .symlink_metadata() + .expect("pointer inode"); + { + let memo = target.pointer_memo.lock().expect("shared pointer memo"); + let file = memo + .as_ref() + .expect("memoized publication") + .file + .as_ref() + .expect("memo owns an actual descriptor"); + let opened = file.metadata().expect("cached descriptor metadata"); + assert_eq!( + (opened.dev(), opened.ino()), + (identity.dev(), identity.ino()) + ); + } + #[cfg(target_os = "linux")] + assert_ne!( + live_inode_handles(&identity), + 0, + "positive live-handle control" + ); + + // A rejected reset owns no deletion authority and must preserve its memo. + let reader = try_acquire_code_generation_store_read_lock(target_root.path()) + .expect("acquire production read lock") + .expect("uncontended read lock"); + assert!(matches!( + target.reset_corrupt_store(), + Err(CodeIndexPublicationStoreErrorV1::StoreLockContended) + )); + assert_eq!( + fs::read(&target.active_path).expect("preserved pointer"), + pointer_bytes + ); + assert!(clone.pointer_memo.lock().expect("preserved memo").is_some()); + drop(reader); + + // The existing fault fixture makes cache reset fail after real on-disk + // deletion and directory reconstruction. No filesystem operation is mocked. + target.poison_decoded_cache_for_test(); + let error = target + .reset_corrupt_store() + .expect_err("decoded-cache refusal must propagate after deletion"); + assert!( + matches!( + &error, + CodeIndexPublicationStoreErrorV1::Unavailable(detail) + if detail == "daemon decoded-generation lock is poisoned" + ), + "unexpected reset error: {error}" + ); + assert!( + !target + .active_path + .try_exists() + .expect("inspect reset pointer"), + "the injected failure must occur after the actual pointer unlink" + ); + // Observe before any pointer read: a read of the missing name could itself + // clear the old memo and conceal the reset's descriptor leak. + #[cfg(target_os = "linux")] + assert_eq!( + live_inode_handles(&identity), + 0, + "no deleted pointer inode remains open" + ); + assert!(target.pointer_memo.lock().expect("reset memo").is_none()); + assert!(clone.pointer_memo.lock().expect("cloned reset memo").is_none()); + + // A fresh store must be able to rebuild the reset scope and recover the + // same snapshot, independently of the deliberately poisoned old cache. + let mut recovered = open_target(); + assert!( + recovered + .load_active_shared() + .expect("empty reset store") + .is_none() + ); + let mut preparation = recovered + .prepare(&generation.sealed_scope(), None) + .expect("prepare after failed reset"); + let publication = generation + .stage_publication(&mut preparation) + .expect("restage exact source snapshot"); + recovered + .commit(&mut preparation, &publication) + .expect("publish after failed reset"); + drop(preparation); + drop(recovered); + let cold = open_target(); + let loaded = cold + .load_active_shared() + .expect("cold reload after rebuilding") + .expect("republished generation"); + assert_eq!( + loaded.snapshot().content_identity, + generation.snapshot().content_identity + ); +} From 63a01e7b63c8c155cc4c2bb26d876add521db173 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:12:14 -0800 Subject: [PATCH 80/93] fix(code-index): repair Bazel retention and reset regressions Exercise the production metadata reader for vanished/directory path errors, retain sibling-test visibility of the bounded descriptor cache, and supply the absent optional retirement callback in the isolated text-artifact test. Apply the exact hosted Rustfmt hunk to the failed-reset regression. These repair compilation and formatting without removing assertions or changing storage behavior. Refs #4533 --- .../src/code_index_generations/tests.rs | 17 ++++++++++++----- .../code_index_scheduler/publication_store.rs | 2 +- .../tests/publication_reset.rs | 8 +++++++- .../text_artifact_scan_tests.rs | 1 + 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs index 5aee960f68f..28f6d8fbb07 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs @@ -3943,8 +3943,12 @@ fn recovery_completes_a_committed_rewrite_that_never_reached_the_pointer() { fn vanished_listed_generation_open_defers_instead_of_storage_loss() { let root = tempfile::tempdir().expect("census root"); let missing = root.path().join(format!("generation-{:064x}.json", 1)); - let error = super::generation_scan::read_generation_format_revision(&missing, &|| false) - .expect_err("a vanished listed generation defers the census"); + let error = super::generation_scan::read_generation_metadata( + &missing, + GenerationDigestVerificationV1::MetadataOnly, + &|| false, + ) + .expect_err("a vanished listed generation defers the census"); assert!( matches!(error, CodeGenerationRetentionErrorV1::GenerationStoreBusy), "a missing listed generation is a publisher race, not a storage failure: {error:?}" @@ -3952,9 +3956,12 @@ fn vanished_listed_generation_open_defers_instead_of_storage_loss() { let directory = root.path().join("not-a-generation-file"); std::fs::create_dir(&directory).expect("directory where a file was listed"); - let storage_error = - super::generation_scan::read_generation_format_revision(&directory, &|| false) - .expect_err("a directory is not a vanished file"); + let storage_error = super::generation_scan::read_generation_metadata( + &directory, + GenerationDigestVerificationV1::MetadataOnly, + &|| false, + ) + .expect_err("a directory is not a vanished file"); assert!( matches!(storage_error, CodeGenerationRetentionErrorV1::Storage(_)), "non-NotFound census I/O stays a storage failure: {storage_error:?}" diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 9c5f534be53..1fd1695e493 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -523,7 +523,7 @@ pub struct DaemonCodeIndexPublicationStoreV1 { /// One bounded descriptor cache for retained page reads across requests. /// Cloned stores share its identity checks; retirement discards it. #[cfg(unix)] - retained_segment_reads: Arc>>, + pub(super) retained_segment_reads: Arc>>, undecoded_active_expectation: Option, /// The canonical source-hint authority plus the exact pre-capture epoch /// used by a retained rebuild. Ordinary publication leaves this absent. diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_reset.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_reset.rs index 904b9f9a82c..b31a6563fc6 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_reset.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_reset.rs @@ -152,7 +152,13 @@ fn failed_reset_releases_pointer_handles_across_store_clones() { "no deleted pointer inode remains open" ); assert!(target.pointer_memo.lock().expect("reset memo").is_none()); - assert!(clone.pointer_memo.lock().expect("cloned reset memo").is_none()); + assert!( + clone + .pointer_memo + .lock() + .expect("cloned reset memo") + .is_none() + ); // A fresh store must be able to rebuild the reset scope and recover the // same snapshot, independently of the deliberately poisoned old cache. diff --git a/crates/tracedecay-maintenance/src/store_maintenance/text_artifact_scan_tests.rs b/crates/tracedecay-maintenance/src/store_maintenance/text_artifact_scan_tests.rs index 67c476d421c..8401b0b3f33 100644 --- a/crates/tracedecay-maintenance/src/store_maintenance/text_artifact_scan_tests.rs +++ b/crates/tracedecay-maintenance/src/store_maintenance/text_artifact_scan_tests.rs @@ -58,6 +58,7 @@ async fn maintenance_owner_advances_noop_pages_and_applies_a_later_orphan() { store.path(), &observations, &cancellation, + None, ) .await .unwrap(); From ec15c307092f909e9f19dfbec0d4783877dbb746 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:13:43 -0800 Subject: [PATCH 81/93] fix(code-index): align cached handle type visibility The retained cache's sibling unit test reads its bounded length. Match the new scheduler-private field visibility on its element type to avoid a private-interfaces warning under Bazel Clippy -Dwarnings. Refs #4533 --- .../src/code_index_scheduler/publication_store.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 1fd1695e493..8b0ff1f7642 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -1258,7 +1258,7 @@ struct PinnedGenerationSegmentV1 { const MAX_STAGED_SEGMENT_READ_HANDLES: usize = 8; #[cfg(unix)] -struct PinnedPreparationSegmentV1 { +pub(super) struct PinnedPreparationSegmentV1 { path: PathBuf, segment: PinnedGenerationSegmentV1, // Both stat and fstat reflect later writes. Keep the original metadata, From 46c01415002ed521f48e1042ebddeca2018ad698 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:30:44 -0800 Subject: [PATCH 82/93] fix(code-index): keep native Bazel retention tests compiling Retain the missing-generation and non-file refusal assertions while avoiding expect_err's Debug bound on the metadata success type. Remove a Clippy redundant closure in the live retained-segment descriptor regression. Refs #4533 --- .../src/code_index_generations/tests.rs | 6 ++++-- .../src/code_index_scheduler/tests/publication_store.rs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs index 28f6d8fbb07..37eebb99415 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs @@ -3948,7 +3948,8 @@ fn vanished_listed_generation_open_defers_instead_of_storage_loss() { GenerationDigestVerificationV1::MetadataOnly, &|| false, ) - .expect_err("a vanished listed generation defers the census"); + .err() + .expect("a vanished listed generation defers the census"); assert!( matches!(error, CodeGenerationRetentionErrorV1::GenerationStoreBusy), "a missing listed generation is a publisher race, not a storage failure: {error:?}" @@ -3961,7 +3962,8 @@ fn vanished_listed_generation_open_defers_instead_of_storage_loss() { GenerationDigestVerificationV1::MetadataOnly, &|| false, ) - .expect_err("a directory is not a vanished file"); + .err() + .expect("a directory is not a vanished file"); assert!( matches!(storage_error, CodeGenerationRetentionErrorV1::Storage(_)), "non-NotFound census I/O stays a storage failure: {storage_error:?}" diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs index 6435f2a8a1d..599ecb39523 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs @@ -1742,7 +1742,7 @@ fn ordinary_retention_closes_cached_historical_segment_before_unlink() { let holders = || { std::fs::read_dir("/proc/self/fd") .expect("inspect actual process descriptor table") - .filter_map(|entry| entry.ok()) + .filter_map(Result::ok) .filter_map(|entry| std::fs::metadata(entry.path()).ok()) .filter(|metadata| metadata.dev() == inode.dev() && metadata.ino() == inode.ino()) .count() From a840b5c90936a293dce67d239bdbb4ee101a70bd Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:37:34 -0800 Subject: [PATCH 83/93] perf(code-index): reuse historical generation lock descriptor Keep a per-store, clone-shared Unix lock descriptor while taking and dropping an actual kernel shared lease for each historical page. Authenticate the lock pathname inode before and after acquisition; fail closed on replacement and retry rather than block a contending in-process reader. The Linux production read regression now counts actual lock-file opens across 17 reads and a clone, including an independent positive event-observer control. Refs #4533 --- .../src/code_index_generations.rs | 2 + .../src/code_index_generations/locking.rs | 95 +++++++++++++++++++ .../code_index_scheduler/publication_store.rs | 20 +++- .../tests/staged_segment_reads.rs | 26 +++++ 4 files changed, 140 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations.rs b/crates/tracedecay-code-index-retention/src/code_index_generations.rs index 10a07293ac2..f263ef0e473 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations.rs @@ -56,6 +56,8 @@ pub use graph_replay_release::{ code_generation_graph_replay_release_page, complete_code_generation_graph_replay_release, }; use locking::acquire_generation_segments_lock_checked; +#[cfg(unix)] +pub use locking::CachedCodeGenerationStoreReadLockV1; pub use locking::{ CodeGenerationStoreLockV1, acquire_code_generation_store_lock, acquire_generation_segments_publication_lock, try_acquire_code_generation_store_lock, diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs index e5cf9aec730..b771eb85b9a 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs @@ -2,6 +2,10 @@ use std::cell::Cell; use std::fs::{File, OpenOptions}; use std::path::{Path, PathBuf}; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; +#[cfg(unix)] +use std::sync::{Mutex, MutexGuard, TryLockError}; use std::time::Instant; use tracedecay_private_fs::FileLease; @@ -47,6 +51,97 @@ impl CodeGenerationStoreLockV1 { } } +/// Reuses the same lock-file descriptor for successive bounded historical reads. +/// +/// Every read still obtains a fresh kernel shared lock. The mutex is acquired +/// without blocking so concurrent callers and cancelled requests can retry. +/// The pathname and opened inode are authenticated before and after locking; +/// a replaced lock authority must never silently authorize an old inode. +#[cfg(unix)] +pub struct CachedCodeGenerationStoreReadLockV1 { + store_root: PathBuf, + file: Mutex>, +} + +#[cfg(unix)] +pub struct CachedCodeGenerationStoreReadGuardV1<'a> { + file: MutexGuard<'a, Option>, +} + +#[cfg(unix)] +impl Drop for CachedCodeGenerationStoreReadGuardV1<'_> { + fn drop(&mut self) { + if let Some(file) = self.file.as_ref() { + if let Err(error) = file.unlock() { + tracing::warn!(lease = CODE_GENERATION_LEASE_LABEL, %error, "cached read lock release failed"); + } + } + } +} + +#[cfg(unix)] +impl CachedCodeGenerationStoreReadLockV1 { + pub fn new(store_root: &Path) -> Self { + Self { + store_root: store_root.to_path_buf(), + file: Mutex::new(None), + } + } + + fn verify_named_inode(file: &File, path: &Path) -> Result<(), CodeGenerationRetentionErrorV1> { + let opened = file.metadata().map_err(storage)?; + let named = std::fs::metadata(path).map_err(super::deferred_if_absent)?; + if !named.is_file() || (opened.dev(), opened.ino()) != (named.dev(), named.ino()) { + return Err(CodeGenerationRetentionErrorV1::UnsafeState( + "generation-store lock pathname no longer names the opened inode".to_owned(), + )); + } + Ok(()) + } + + pub fn try_acquire( + &self, + ) -> Result>, CodeGenerationRetentionErrorV1> + { + let mut cached = match self.file.try_lock() { + Ok(guard) => guard, + Err(TryLockError::WouldBlock) => return Ok(None), + Err(TryLockError::Poisoned(_)) => { + return Err(CodeGenerationRetentionErrorV1::UnsafeState( + "cached generation-store read lock is poisoned".to_owned(), + )); + } + }; + let root = canonical_store_root(&self.store_root)?; + let path = root.join(STORE_LOCK_FILE); + if cached.is_none() { + *cached = Some(open_lock_file(&path)?); + } + let Some(file) = cached.as_ref() else { + return Err(CodeGenerationRetentionErrorV1::UnsafeState( + "cached generation-store lock was not initialized".to_owned(), + )); + }; + Self::verify_named_inode(file, &path)?; + match file.try_lock_shared().map_err(std::io::Error::from) { + Ok(()) => { + let lease = CachedCodeGenerationStoreReadGuardV1 { file: cached }; + // Recheck after kernel admission, before any pointer/segment read. + // On an error the guard's Drop explicitly unlocks the descriptor. + let Some(file) = lease.file.as_ref() else { + return Err(CodeGenerationRetentionErrorV1::UnsafeState( + "cached generation-store lock disappeared".to_owned(), + )); + }; + Self::verify_named_inode(file, &path)?; + Ok(Some(lease)) + } + Err(error) if tracedecay_private_fs::is_lock_contended(&error) => Ok(None), + Err(error) => Err(storage(error)), + } + } +} + pub fn acquire_code_generation_store_lock( store_root: &Path, ) -> Result { diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 8b0ff1f7642..6a3c1209834 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -38,6 +38,8 @@ use tracedecay_code_index_retention::code_index_generations::{ durable_generation_index_digest, reset_code_index_scope_store, retain_bounded_generation_index, try_acquire_code_generation_store_lock, try_acquire_code_generation_store_read_lock, }; +#[cfg(unix)] +use tracedecay_code_index_retention::code_index_generations::CachedCodeGenerationStoreReadLockV1; use tracedecay_domain::{ CodeGenerationId, ContentDigest, ManifestDigest, ProjectId, SanitizerRevision, WorktreeId, canonical_text::encode_tagged_lowercase_hex, sha256_hex_suffix, @@ -520,6 +522,9 @@ pub struct DaemonCodeIndexPublicationStoreV1 { expected_sanitizer_revision: SanitizerRevision, disposition: CodeIndexPublicationDispositionV1, pointer_memo: Arc>>, + /// A fresh shared kernel lock per historical read, without a new open(2). + #[cfg(unix)] + retained_generation_lock: Arc, /// One bounded descriptor cache for retained page reads across requests. /// Cloned stores share its identity checks; retirement discards it. #[cfg(unix)] @@ -1583,6 +1588,8 @@ impl DaemonCodeIndexPublicationStoreV1 { disposition: CodeIndexPublicationDispositionV1::Active, pointer_memo: Arc::new(Mutex::new(None)), #[cfg(unix)] + retained_generation_lock: Arc::new(CachedCodeGenerationStoreReadLockV1::new(store_root)), + #[cfg(unix)] retained_segment_reads: Arc::new(Mutex::new(VecDeque::new())), undecoded_active_expectation: None, reconcile_publication_fence: None, @@ -2563,6 +2570,7 @@ impl DaemonCodeIndexPublicationStoreV1 { // A rejected or interrupted request must not return bytes left by // an earlier successful read, even when it never reaches the file. buffer.clear(); + #[cfg(not(unix))] let root = self .active_path .parent() @@ -2578,9 +2586,15 @@ impl DaemonCodeIndexPublicationStoreV1 { CodeIndexInterruptionV1::DeadlineExceeded, )); } - if let Some(lock) = - try_acquire_code_generation_store_read_lock(root).map_err(Self::unavailable)? - { + #[cfg(unix)] + let candidate = self + .retained_generation_lock + .try_acquire() + .map_err(Self::unavailable)?; + #[cfg(not(unix))] + let candidate = + try_acquire_code_generation_store_read_lock(root).map_err(Self::unavailable)?; + if let Some(lock) = candidate { break lock; } std::thread::park_timeout(Duration::from_millis(1)); diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs index 62b90f65b8c..ec427cbaf10 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/staged_segment_reads.rs @@ -2475,6 +2475,16 @@ fn retained_history_reads_across_requests_share_one_segment_open_and_refuse_repl .watch(&cold.segments_root, notify::RecursiveMode::NonRecursive) .expect("watch real sealed segment directory"); + let lock_path = target_root.path().join(".code-generation-retention.lock"); + let (lock_sender, lock_receiver) = std::sync::mpsc::channel(); + let mut lock_watcher = + notify::recommended_watcher(move |event: notify::Result| { + let _ = lock_sender.send(event); + }) + .expect("native generation-store lock open observer"); + lock_watcher + .watch(target_root.path(), notify::RecursiveMode::NonRecursive) + .expect("watch the real generation-store lock directory"); let control = tracedecay_code_index::production::UninterruptibleCodeIndexControlV1; let mut whole = Vec::new(); cold.read_retained_partitioned_segment( @@ -2511,6 +2521,17 @@ fn retained_history_reads_across_requests_share_one_segment_open_and_refuse_repl 1, "one whole and sixteen historical reads must require one segment open" ); + assert_eq!( + observed_named_opens(&lock_receiver, &lock_path), + 1, + "17 real historical reads must perform one cold lock-file open, not 17" + ); + drop(File::open(&lock_path).expect("positive lock pathname-open control")); + assert_eq!( + observed_named_opens(&lock_receiver, &lock_path), + 1, + "kernel observer must count an independent lock-file open" + ); assert_eq!( observed_pointer_opens(&pointer_receiver), 0, @@ -2553,6 +2574,11 @@ fn retained_history_reads_across_requests_share_one_segment_open_and_refuse_repl 0, "a clone must share the pinned publication pointer without another open" ); + assert_eq!( + observed_named_opens(&lock_receiver, &lock_path), + 0, + "a cloned store must reuse the same lock descriptor while reacquiring the kernel lease" + ); let previous = target_root.path().join("retained-history-original-inode"); std::fs::rename(&path, &previous).expect("move disposable canonical inode"); From 30747aaad5715902154d3ba283db1df95d48d058 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:38:40 -0800 Subject: [PATCH 84/93] test(code-index): prove cached reader lock exclusion and inode refusal Exercise real Unix kernel shared and exclusive leases across cached reads, including same-process contention, explicit release, recovery and a replaced lock pathname. A stale cached descriptor must refuse the new inode. Refs #4533 --- .../src/code_index_generations/locking.rs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs index b771eb85b9a..f9482a63f75 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs @@ -498,3 +498,64 @@ fn open_lock_file(path: &Path) -> Result { .open(path) .map_err(super::deferred_if_absent) } + +#[cfg(all(test, unix))] +mod cached_read_lock_tests { + use super::*; + + #[test] + fn cached_reader_keeps_exclusive_fencing_and_refuses_replaced_lock_file() { + let store = tempfile::tempdir().expect("isolated generation-store root"); + let cached = CachedCodeGenerationStoreReadLockV1::new(store.path()); + + let read = cached + .try_acquire() + .expect("first shared kernel lock") + .expect("uncontended shared lease"); + assert!( + try_acquire_code_generation_store_lock(store.path()) + .expect("probe a real exclusive kernel lock") + .is_none(), + "a writer must not enter a protected historical page read" + ); + assert!( + cached.try_acquire().expect("second in-process probe").is_none(), + "the single descriptor must not be concurrently unlocked by another reader" + ); + drop(read); + + let writer = try_acquire_code_generation_store_lock(store.path()) + .expect("exclusive acquisition after reader release") + .expect("read guard must explicitly unlock"); + assert!( + cached.try_acquire().expect("shared probe under writer").is_none(), + "a real exclusive lock must prevent a cached shared lease" + ); + drop(writer); + drop( + cached + .try_acquire() + .expect("reader recovers from write lock") + .expect("shared lease after exclusive release"), + ); + + let path = store.path().join(STORE_LOCK_FILE); + let original = std::fs::metadata(&path).expect("cached lock inode"); + std::fs::rename(&path, store.path().join("retired-lock-inode")) + .expect("replace the lock pathname without closing the original inode"); + std::fs::File::create(&path).expect("different lock file at the same pathname"); + let replacement = std::fs::metadata(&path).expect("new lock inode"); + assert_ne!( + (original.dev(), original.ino()), + (replacement.dev(), replacement.ino()), + "replacement must be a different real inode" + ); + assert!( + matches!( + cached.try_acquire(), + Err(CodeGenerationRetentionErrorV1::UnsafeState(_)) + ), + "a cached descriptor must not lock a retired inode to authorize a new scope" + ); + } +} From 53b765cf90341373be23c805277310326cd82df0 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:38:45 -0800 Subject: [PATCH 85/93] fix(code-index): reject symlink aliases of cached read lock Authenticate the lock-file directory entry with lstat rather than following a symlink to a retained descriptor. A same-inode symlink replacement must not silently authorize historical reads under an old lock authority. Retain the one-open cache and kernel fencing. Extend the real Unix kernel regression with a positive same-inode symlink control, refusal, exact-inode restoration, and writer exclusion. Refs #4533 --- .../src/code_index_generations/locking.rs | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs index f9482a63f75..7987964121f 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs @@ -90,7 +90,9 @@ impl CachedCodeGenerationStoreReadLockV1 { fn verify_named_inode(file: &File, path: &Path) -> Result<(), CodeGenerationRetentionErrorV1> { let opened = file.metadata().map_err(storage)?; - let named = std::fs::metadata(path).map_err(super::deferred_if_absent)?; + // A symlink to the retired inode is not the original lock authority. + // lstat authenticates the directory entry without another open(). + let named = std::fs::symlink_metadata(path).map_err(super::deferred_if_absent)?; if !named.is_file() || (opened.dev(), opened.ino()) != (named.dev(), named.ino()) { return Err(CodeGenerationRetentionErrorV1::UnsafeState( "generation-store lock pathname no longer names the opened inode".to_owned(), @@ -541,7 +543,8 @@ mod cached_read_lock_tests { let path = store.path().join(STORE_LOCK_FILE); let original = std::fs::metadata(&path).expect("cached lock inode"); - std::fs::rename(&path, store.path().join("retired-lock-inode")) + let retired = store.path().join("retired-lock-inode"); + std::fs::rename(&path, &retired) .expect("replace the lock pathname without closing the original inode"); std::fs::File::create(&path).expect("different lock file at the same pathname"); let replacement = std::fs::metadata(&path).expect("new lock inode"); @@ -557,5 +560,48 @@ mod cached_read_lock_tests { ), "a cached descriptor must not lock a retired inode to authorize a new scope" ); + + // A symlink back to the retired inode has the same dev/ino when + // followed. The cached reader must still refuse this replacement: + // verifying metadata(path) would wrongly accept it. + std::fs::remove_file(&path).expect("remove replacement lock inode"); + std::os::unix::fs::symlink(&retired, &path) + .expect("replace the lock with an alias of the retired inode"); + let followed = std::fs::metadata(&path).expect("follow symlink as positive control"); + assert_eq!( + (followed.dev(), followed.ino()), + (original.dev(), original.ino()), + "the symlink must resolve to exactly the still-open cached inode" + ); + assert!( + std::fs::symlink_metadata(&path) + .expect("inspect replacement type") + .file_type() + .is_symlink(), + "the replacement must be a real symlink" + ); + assert!( + matches!( + cached.try_acquire(), + Err(CodeGenerationRetentionErrorV1::UnsafeState(_)) + ), + "a lock-file symlink must not authorize a retired cached inode" + ); + + // Recovery through the original pathname must not require another + // cache entry or skip the real shared kernel-lock acquisition. + std::fs::remove_file(&path).expect("remove lock symlink"); + std::fs::rename(&retired, &path).expect("restore exact original lock inode"); + let read = cached + .try_acquire() + .expect("recheck restored original lock") + .expect("reacquire real shared kernel lock"); + assert!( + try_acquire_code_generation_store_lock(store.path()) + .expect("probe exclusive lock after recovery") + .is_none(), + "restored cached read must still fence exclusive writers" + ); + drop(read); } } From 20c5a3561b423d1ea9443f4f28ffb4206bf8d40c Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:12:37 -0800 Subject: [PATCH 86/93] fix(code-index): refuse symlinked pointer publication slots Reject both existing-target and dangling symlinks before pointer CAS or rename. The cold authenticated pointer recheck also uses lstat to forbid a symlink substitution to the same opened inode, without another open. Exercise real symlink pathname refusal, untouched target/link identity, and exact-byte ordinary publication in the owning runtime tests. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 6 +- .../tests/publication_store.rs | 80 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 6a3c1209834..555b6e41658 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -1729,7 +1729,9 @@ impl DaemonCodeIndexPublicationStoreV1 { } fn require_regular_pointer_slot(&self) -> Result<(), CodeIndexPublicationStoreErrorV1> { - match std::fs::metadata(&self.active_path) { + // A dangling or same-inode symlink is still not a pointer slot. + // Inspect the directory entry without another endpoint-authorized open. + match std::fs::symlink_metadata(&self.active_path) { Ok(metadata) if metadata.file_type().is_file() => Ok(()), Ok(_) => Err(Self::corrupt_non_file_pointer()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), @@ -2427,7 +2429,7 @@ impl DaemonCodeIndexPublicationStoreV1 { .take(MAX_DURABLE_PUBLICATION_POINTER_BYTES + 1) .read_to_end(&mut current)?; let opened_after = opened_pointer.metadata()?; - let named_after = std::fs::metadata(&self.active_path)?; + let named_after = std::fs::symlink_metadata(&self.active_path)?; Ok(current == bytes && Self::same_unix_segment_identity(&metadata, &opened_before) && Self::same_unix_segment_identity(&opened_before, &opened_after) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs index 599ecb39523..22a2bf401ac 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs @@ -3231,6 +3231,86 @@ fn publication_pointer_memo_follows_bytes_when_size_and_mtime_stay_put() { ); } +#[cfg(unix)] +#[test] +fn pointer_commit_refuses_existing_and_dangling_symlink_slots() { + use std::os::unix::fs::symlink; + + for target_exists in [true, false] { + let store = TempDir::new().expect("isolated publication store"); + let project = TempDir::new().expect("isolated project"); + let publication = super::super::DaemonCodeIndexPublicationStoreV1::new( + store.path(), + project.path(), + SanitizerRevision::new(tracedecay_privacy::CODE_SOURCE_SANITIZER_VERSION_V1) + .expect("sanitizer revision"), + ) + .expect("open publication store"); + let pointer_path = store.path().join("active-code-generation-v1.json"); + let foreign_path = store.path().join("foreign-pointer.json"); + let observed = same_length_publication_pointer("generation.symlink-observed", 0x35); + let replacement = same_length_publication_pointer("generation.symlink-replacement", 0x36); + let observed_bytes = serde_json::to_vec(&observed).expect("encode observed pointer"); + let replacement_bytes = + serde_json::to_vec(&replacement).expect("encode replacement pointer"); + if target_exists { + std::fs::write(&foreign_path, &observed_bytes).expect("write foreign pointer"); + } + symlink(&foreign_path, &pointer_path).expect("install symlinked pointer slot"); + let lock = acquire_code_generation_store_lock(store.path()).expect("exclusive store lock"); + let error = publication + .commit_observed_pointer( + &lock, + target_exists.then_some(observed_bytes.as_slice()), + &replacement, + &replacement_bytes, + ) + .expect_err("a symlink must never acquire pointer publication authority"); + assert!( + matches!( + error, + CodeIndexPublicationStoreErrorV1::CorruptionResetRequired(_) + ), + "a symlinked pointer slot must be reset-required corruption: {error}" + ); + assert_eq!( + std::fs::read_link(&pointer_path).expect("link remains in place"), + foreign_path, + "refusal must not replace the symlink" + ); + if target_exists { + assert_eq!( + std::fs::read(&foreign_path).expect("foreign pointer still exists"), + observed_bytes, + "refusal must not change the foreign pointer" + ); + } else { + assert!( + !foreign_path.exists(), + "refusal must not create the dangling symlink target" + ); + } + + // The same exact-byte CAS must still work after the invalid entry + // is replaced by an ordinary file under the existing store lock. + std::fs::remove_file(&pointer_path).expect("remove symlink"); + std::fs::write(&pointer_path, &observed_bytes).expect("restore regular pointer"); + publication + .commit_observed_pointer( + &lock, + Some(observed_bytes.as_slice()), + &replacement, + &replacement_bytes, + ) + .expect("ordinary pointer slot preserves CAS publication"); + assert_eq!( + std::fs::read(&pointer_path).expect("read published pointer"), + replacement_bytes, + "the successful publication must persist exact bytes" + ); + } +} + #[test] fn stale_pointer_commit_does_not_replace_a_changed_active_pointer() { let store = TempDir::new().expect("store root"); From ebb9f1d0023ba0f275faacb2f8dfdaffda30bc6b Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:55:09 -0800 Subject: [PATCH 87/93] fix(code-index): refuse changed cold pointer authorities Fail closed when the final cold publication-pointer witness detects a replacement inode, symlink alias or changed bytes. Previously the reader skipped memo installation but still returned the stale parsed pointer. Exercise a real opened inode against same-byte replacement and symlink alias, followed by valid restoration, without an additional production pathname open. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 60 +++++++++---- .../tests/publication_pointer_validation.rs | 87 +++++++++++++++++++ 2 files changed, 132 insertions(+), 15 deletions(-) create mode 100644 crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_pointer_validation.rs diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 555b6e41658..bcde67f39ea 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -84,6 +84,10 @@ mod retained_segment_admission; #[path = "tests/publication_reset.rs"] mod publication_reset; +#[cfg(all(test, unix))] +#[path = "tests/publication_pointer_validation.rs"] +mod publication_pointer_validation; + const MAX_DURABLE_PUBLICATION_POINTER_BYTES: u64 = 512 * 1024; const DURABLE_GENERATION_IO_CHUNK_BYTES_V1: usize = 64 * 1024; @@ -2421,21 +2425,15 @@ impl DaemonCodeIndexPublicationStoreV1 { // witnesses reject a concurrent rename; the second byte comparison // rejects an in-place change during parsing, even on coarse clocks. #[cfg(unix)] - let unchanged = (|| -> std::io::Result { - let opened_before = opened_pointer.metadata()?; - opened_pointer.seek(SeekFrom::Start(0))?; - let mut current = Vec::new(); - (&mut opened_pointer) - .take(MAX_DURABLE_PUBLICATION_POINTER_BYTES + 1) - .read_to_end(&mut current)?; - let opened_after = opened_pointer.metadata()?; - let named_after = std::fs::symlink_metadata(&self.active_path)?; - Ok(current == bytes - && Self::same_unix_segment_identity(&metadata, &opened_before) - && Self::same_unix_segment_identity(&opened_before, &opened_after) - && Self::same_unix_segment_identity(&opened_before, &named_after)) - })() - .unwrap_or(false); + let unchanged = { + Self::require_cold_pointer_witness( + &self.active_path, + &mut opened_pointer, + &metadata, + &bytes, + )?; + true + }; #[cfg(not(unix))] let unchanged = std::fs::read(&self.active_path).ok().as_deref() == Some(bytes.as_slice()); if unchanged { @@ -2451,6 +2449,38 @@ impl DaemonCodeIndexPublicationStoreV1 { Ok(Some((pointer, bytes))) } + /// Fail closed if a cold pointer's pathname or bytes changed while its + /// original descriptor was parsed. A failed witness is not a valid + /// publication snapshot and must never be returned to a caller. + #[cfg(unix)] + fn require_cold_pointer_witness( + path: &Path, + file: &mut File, + named_before: &std::fs::Metadata, + bytes: &[u8], + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + let opened_before = file.metadata().map_err(Self::map_pointer_io)?; + file.seek(SeekFrom::Start(0)) + .map_err(Self::map_pointer_io)?; + let mut current = Vec::new(); + (&mut *file) + .take(MAX_DURABLE_PUBLICATION_POINTER_BYTES + 1) + .read_to_end(&mut current) + .map_err(Self::map_pointer_io)?; + let opened_after = file.metadata().map_err(Self::map_pointer_io)?; + let named_after = path.symlink_metadata().map_err(Self::map_pointer_io)?; + if current != bytes + || !Self::same_unix_segment_identity(named_before, &opened_before) + || !Self::same_unix_segment_identity(&opened_before, &opened_after) + || !Self::same_unix_segment_identity(&opened_before, &named_after) + { + return Err(Self::corruption( + "active code-generation pointer changed during cold validation", + )); + } + Ok(()) + } + fn remember_publication_pointer( &self, pointer: &DurablePublicationPointerV1, diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_pointer_validation.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_pointer_validation.rs new file mode 100644 index 00000000000..27686ba0986 --- /dev/null +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_pointer_validation.rs @@ -0,0 +1,87 @@ +//! Cold publication authentication must not return a pointer from a retired inode. +use std::fs::{self, File}; +use std::io::Read; +use std::os::unix::fs::{MetadataExt, symlink}; + +use tempfile::TempDir; +use tracedecay_code_index::production::CodeIndexPublicationStoreErrorV1; + +use super::DaemonCodeIndexPublicationStoreV1; + +#[test] +fn cold_pointer_witness_refuses_same_byte_inode_swap_and_symlink_alias() { + let root = TempDir::new().expect("isolated pointer directory"); + let pointer = root.path().join("active-code-generation-v1.json"); + let retired = root.path().join("retired-pointer"); + let replacement = root.path().join("replacement-pointer"); + let bytes = br#"{"published":"real-cold-pointer-witness"}"#; + fs::write(&pointer, bytes).expect("write nonempty original pointer"); + let named_before = pointer.symlink_metadata().expect("original pointer inode"); + let mut opened = File::open(&pointer).expect("open original pointer once"); + let mut readback = Vec::new(); + opened + .read_to_end(&mut readback) + .expect("read the exact original pointer bytes"); + assert_eq!(readback.as_slice(), bytes); + assert!( + DaemonCodeIndexPublicationStoreV1::require_cold_pointer_witness( + &pointer, + &mut opened, + &named_before, + &readback, + ) + .is_ok(), + "unchanged real pointer must authenticate" + ); + + fs::write(&replacement, bytes).expect("different inode with identical bytes"); + fs::rename(&pointer, &retired).expect("retire original pathname"); + fs::rename(&replacement, &pointer).expect("replace pathname with same bytes"); + let replacement_identity = pointer.symlink_metadata().expect("replacement inode"); + assert_ne!( + (named_before.dev(), named_before.ino()), + (replacement_identity.dev(), replacement_identity.ino()), + "a real different inode must be used as the negative control" + ); + assert_eq!(fs::read(&pointer).expect("replacement bytes"), readback); + assert!(matches!( + DaemonCodeIndexPublicationStoreV1::require_cold_pointer_witness( + &pointer, + &mut opened, + &named_before, + &readback, + ), + Err(CodeIndexPublicationStoreErrorV1::CorruptionResetRequired(_)) + )); + + fs::remove_file(&pointer).expect("remove replacement"); + symlink(&retired, &pointer).expect("symlink to the exact opened inode"); + let followed = fs::metadata(&pointer).expect("positive symlink target control"); + assert_eq!( + (named_before.dev(), named_before.ino()), + (followed.dev(), followed.ino()) + ); + assert!(pointer.symlink_metadata().expect("symlink entry").file_type().is_symlink()); + assert!(matches!( + DaemonCodeIndexPublicationStoreV1::require_cold_pointer_witness( + &pointer, + &mut opened, + &named_before, + &readback, + ), + Err(CodeIndexPublicationStoreErrorV1::CorruptionResetRequired(_)) + )); + + fs::remove_file(&pointer).expect("remove symlink"); + fs::rename(&retired, &pointer).expect("restore original inode"); + assert!( + DaemonCodeIndexPublicationStoreV1::require_cold_pointer_witness( + &pointer, + &mut opened, + &named_before, + &readback, + ) + .is_ok(), + "the original inode and exact bytes must recover without another open" + ); +} From fcbe2818bdd9901ffd2da2f11fb7698c922f6d5c Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:57:02 -0800 Subject: [PATCH 88/93] test(code-index): reauthenticate pointer ctime after rename A real same-inode rename updates Unix ctime. Refresh that metadata for the positive restoration control while preserving the same open descriptor; keep the byte-identical inode replacement and symlink refusal assertions. Refs #4533 --- .../tests/publication_pointer_validation.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_pointer_validation.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_pointer_validation.rs index 27686ba0986..4748eb1a1ce 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_pointer_validation.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_pointer_validation.rs @@ -61,7 +61,13 @@ fn cold_pointer_witness_refuses_same_byte_inode_swap_and_symlink_alias() { (named_before.dev(), named_before.ino()), (followed.dev(), followed.ino()) ); - assert!(pointer.symlink_metadata().expect("symlink entry").file_type().is_symlink()); + assert!( + pointer + .symlink_metadata() + .expect("symlink entry") + .file_type() + .is_symlink() + ); assert!(matches!( DaemonCodeIndexPublicationStoreV1::require_cold_pointer_witness( &pointer, @@ -74,11 +80,18 @@ fn cold_pointer_witness_refuses_same_byte_inode_swap_and_symlink_alias() { fs::remove_file(&pointer).expect("remove symlink"); fs::rename(&retired, &pointer).expect("restore original inode"); + // Rename updates ctime on Unix; authenticate the restored pathname's + // current metadata while retaining the original open descriptor. + let restored_identity = pointer.symlink_metadata().expect("restored inode"); + assert_eq!( + (named_before.dev(), named_before.ino()), + (restored_identity.dev(), restored_identity.ino()) + ); assert!( DaemonCodeIndexPublicationStoreV1::require_cold_pointer_witness( &pointer, &mut opened, - &named_before, + &restored_identity, &readback, ) .is_ok(), From e86a4e500b0477ba40c5cbab9cc9c32d24b8bb92 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:58:28 -0800 Subject: [PATCH 89/93] fix(code-index): distinguish pointer replacement from corruption Refuse a cold pointer after a pathname rotation without returning stale bytes. Treat legitimate concurrent replacement or unlink as a retryable CAS conflict, not a destructive corruption reset; retain corruption for symlinks and in-place byte or inode metadata mutation. Exercise missing path, same-byte new inode, symlink alias, and restoration through real descriptor metadata with no additional production open. Refs #4533 --- .../code_index_scheduler/publication_store.rs | 22 ++++++++++++++++++- .../tests/publication_pointer_validation.rs | 11 +++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index bcde67f39ea..804f2eecedf 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -2468,7 +2468,27 @@ impl DaemonCodeIndexPublicationStoreV1 { .read_to_end(&mut current) .map_err(Self::map_pointer_io)?; let opened_after = file.metadata().map_err(Self::map_pointer_io)?; - let named_after = path.symlink_metadata().map_err(Self::map_pointer_io)?; + let named_after = match path.symlink_metadata() { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } + Err(error) => return Err(Self::map_pointer_io(error)), + }; + if !named_after.file_type().is_file() { + return Err(Self::corrupt_non_file_pointer()); + } + // An ordinary concurrent publication replaces the pointer inode. + // Refuse that stale observation without incorrectly resetting a + // healthy store; the caller can retry on the new publication. + if (named_before.dev(), named_before.ino()) != (opened_before.dev(), opened_before.ino()) + || (named_after.dev(), named_after.ino()) + != (opened_before.dev(), opened_before.ino()) + { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } + // Mutation of the same inode or its bytes violates immutable pointer + // identity and requires the existing typed corruption reset. if current != bytes || !Self::same_unix_segment_identity(named_before, &opened_before) || !Self::same_unix_segment_identity(&opened_before, &opened_after) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_pointer_validation.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_pointer_validation.rs index 4748eb1a1ce..f618e467c8d 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_pointer_validation.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_pointer_validation.rs @@ -36,6 +36,15 @@ fn cold_pointer_witness_refuses_same_byte_inode_swap_and_symlink_alias() { fs::write(&replacement, bytes).expect("different inode with identical bytes"); fs::rename(&pointer, &retired).expect("retire original pathname"); + assert!(matches!( + DaemonCodeIndexPublicationStoreV1::require_cold_pointer_witness( + &pointer, + &mut opened, + &named_before, + &readback, + ), + Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap) + )); fs::rename(&replacement, &pointer).expect("replace pathname with same bytes"); let replacement_identity = pointer.symlink_metadata().expect("replacement inode"); assert_ne!( @@ -51,7 +60,7 @@ fn cold_pointer_witness_refuses_same_byte_inode_swap_and_symlink_alias() { &named_before, &readback, ), - Err(CodeIndexPublicationStoreErrorV1::CorruptionResetRequired(_)) + Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap) )); fs::remove_file(&pointer).expect("remove replacement"); From 33df9902a1f263f7618b5983766fad7690dab64d Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:50:00 -0800 Subject: [PATCH 90/93] style(code-index): apply hosted Bazel rustfmt corrections Apply the seven exact formatting hunks emitted by the current-head Rustfmt gate for retention and runtime, preserving all behavior and nonzero assertions. Refs #4533 --- .../src/code_index_generations.rs | 2 +- .../src/code_index_generations/locking.rs | 12 +++++++++--- .../src/code_index_scheduler/publication_store.rs | 11 ++++++----- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations.rs b/crates/tracedecay-code-index-retention/src/code_index_generations.rs index f263ef0e473..6639b00da23 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations.rs @@ -55,9 +55,9 @@ pub use graph_replay_release::{ CodeGenerationGraphReplayReleasePageV1, CodeGenerationGraphReplayReleaseV1, code_generation_graph_replay_release_page, complete_code_generation_graph_replay_release, }; -use locking::acquire_generation_segments_lock_checked; #[cfg(unix)] pub use locking::CachedCodeGenerationStoreReadLockV1; +use locking::acquire_generation_segments_lock_checked; pub use locking::{ CodeGenerationStoreLockV1, acquire_code_generation_store_lock, acquire_generation_segments_publication_lock, try_acquire_code_generation_store_lock, diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs index 7987964121f..683fd75a962 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs @@ -1,9 +1,9 @@ #[cfg(test)] use std::cell::Cell; use std::fs::{File, OpenOptions}; -use std::path::{Path, PathBuf}; #[cfg(unix)] use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; #[cfg(unix)] use std::sync::{Mutex, MutexGuard, TryLockError}; use std::time::Instant; @@ -521,7 +521,10 @@ mod cached_read_lock_tests { "a writer must not enter a protected historical page read" ); assert!( - cached.try_acquire().expect("second in-process probe").is_none(), + cached + .try_acquire() + .expect("second in-process probe") + .is_none(), "the single descriptor must not be concurrently unlocked by another reader" ); drop(read); @@ -530,7 +533,10 @@ mod cached_read_lock_tests { .expect("exclusive acquisition after reader release") .expect("read guard must explicitly unlock"); assert!( - cached.try_acquire().expect("shared probe under writer").is_none(), + cached + .try_acquire() + .expect("shared probe under writer") + .is_none(), "a real exclusive lock must prevent a cached shared lease" ); drop(writer); diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 804f2eecedf..e348f5e1280 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -28,6 +28,8 @@ thread_local! { use same_file::Handle; use sha2::{Digest, Sha256}; use tracedecay_application::code_index::DaemonCodeIndexControlV1; +#[cfg(unix)] +use tracedecay_code_index_retention::code_index_generations::CachedCodeGenerationStoreReadLockV1; use tracedecay_code_index_retention::code_index_generations::{ CODE_INDEX_PREPARATION_DIRECTORY_PREFIX_V1, CodeGenerationRetentionErrorV1, CodeGenerationStoreLockV1, CodeIndexScopeStoreResetV1, DurableGenerationCardinalityV1, @@ -38,8 +40,6 @@ use tracedecay_code_index_retention::code_index_generations::{ durable_generation_index_digest, reset_code_index_scope_store, retain_bounded_generation_index, try_acquire_code_generation_store_lock, try_acquire_code_generation_store_read_lock, }; -#[cfg(unix)] -use tracedecay_code_index_retention::code_index_generations::CachedCodeGenerationStoreReadLockV1; use tracedecay_domain::{ CodeGenerationId, ContentDigest, ManifestDigest, ProjectId, SanitizerRevision, WorktreeId, canonical_text::encode_tagged_lowercase_hex, sha256_hex_suffix, @@ -1592,7 +1592,9 @@ impl DaemonCodeIndexPublicationStoreV1 { disposition: CodeIndexPublicationDispositionV1::Active, pointer_memo: Arc::new(Mutex::new(None)), #[cfg(unix)] - retained_generation_lock: Arc::new(CachedCodeGenerationStoreReadLockV1::new(store_root)), + retained_generation_lock: Arc::new(CachedCodeGenerationStoreReadLockV1::new( + store_root, + )), #[cfg(unix)] retained_segment_reads: Arc::new(Mutex::new(VecDeque::new())), undecoded_active_expectation: None, @@ -2482,8 +2484,7 @@ impl DaemonCodeIndexPublicationStoreV1 { // Refuse that stale observation without incorrectly resetting a // healthy store; the caller can retry on the new publication. if (named_before.dev(), named_before.ino()) != (opened_before.dev(), opened_before.ino()) - || (named_after.dev(), named_after.ino()) - != (opened_before.dev(), opened_before.ino()) + || (named_after.dev(), named_after.ino()) != (opened_before.dev(), opened_before.ino()) { return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); } From b852ca00e4c5971486f9de7efa757e159982a4dc Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:37:08 -0800 Subject: [PATCH 91/93] fix(private-fs): revalidate completed durability windows Recheck every pending member after all fsync workers join, before a window can succeed or release its retained descriptors. Per-member post-barrier checks alone miss changes while another worker finishes. Cover same-byte inode replacement during final sync and same-inode mutation during rollover with real fsyncs and deterministic, test-only completion handshakes. Preserve failed admission and exact recovery. Refs #4533 --- .../tracedecay-private-fs/src/framed_log.rs | 66 ++++++- .../src/framed_log_window_tests.rs | 174 ++++++++++++++++++ 2 files changed, 232 insertions(+), 8 deletions(-) create mode 100644 crates/tracedecay-private-fs/src/framed_log_window_tests.rs diff --git a/crates/tracedecay-private-fs/src/framed_log.rs b/crates/tracedecay-private-fs/src/framed_log.rs index 96ce7e3286a..f6eaea55ca5 100644 --- a/crates/tracedecay-private-fs/src/framed_log.rs +++ b/crates/tracedecay-private-fs/src/framed_log.rs @@ -22,6 +22,10 @@ use windows_sys::Win32::Storage::FileSystem::{ #[path = "framed_log_batch_tests.rs"] mod batch_tests; +#[cfg(all(test, target_os = "linux", feature = "test-helpers"))] +#[path = "framed_log_window_tests.rs"] +mod window_tests; + #[cfg(test)] thread_local! { static DIRECTORY_SYNC_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; @@ -246,6 +250,8 @@ impl DurableFileBatch { let syncer = || -> io::Result<()> { while let Some(member) = self.pending.get(next.fetch_add(1, Ordering::Relaxed)) { member.sync()?; + #[cfg(all(test, feature = "test-helpers"))] + sync_latency::after_batch_member_sync(&member.identity.path); } Ok(()) }; @@ -263,7 +269,16 @@ impl DurableFileBatch { outcome = outcome.and(helped); } outcome - }) + })?; + // An early member may change after its own barrier and validation + // while another worker is still syncing. Authenticate the whole + // window after every worker finishes, before success or handle release. + for member in &self.pending { + let identity = &member.identity; + identity.validate_metadata(&member.file.metadata()?)?; + identity.validate_path()?; + } + Ok(()) } } @@ -511,6 +526,19 @@ pub mod sync_latency { struct SyncPauseSignal { entered: mpsc::Sender<()>, resume: Mutex>, + after_member: bool, + } + + #[cfg(test)] + impl SyncPauseSignal { + fn wait(&self) { + let _ = self.entered.send(()); + let _ = self + .resume + .lock() + .unwrap_or_else(PoisonError::into_inner) + .recv(); + } } #[cfg(test)] @@ -594,11 +622,24 @@ pub mod sync_latency { #[cfg(test)] #[must_use] pub(super) fn pause(root: &Path) -> (SyncLatencyGuard, SyncPause) { + pause_at(root, false) + } + + /// Observe a completed member before the concurrent window joins. + #[cfg(all(test, target_os = "linux"))] + #[must_use] + pub(super) fn pause_after_batch_member(root: &Path) -> (SyncLatencyGuard, SyncPause) { + pause_at(root, true) + } + + #[cfg(test)] + fn pause_at(root: &Path, after_member: bool) -> (SyncLatencyGuard, SyncPause) { let (entered_sender, entered_receiver) = mpsc::channel(); let (resume_sender, resume_receiver) = mpsc::channel(); let signal = SyncPauseSignal { entered: entered_sender, resume: Mutex::new(resume_receiver), + after_member, }; let guard = register(root, Duration::ZERO, Some(Arc::new(signal))); ( @@ -610,6 +651,20 @@ pub mod sync_latency { ) } + #[cfg(all(test, target_os = "linux"))] + pub(super) fn after_batch_member_sync(path: &Path) { + let pause = INJECTIONS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .iter() + .find(|injection| path.starts_with(&injection.root)) + .and_then(|injection| injection.pause.clone()) + .filter(|pause| pause.after_member); + if let Some(pause) = pause { + pause.wait(); + } + } + struct MatchedInjection { delay: Duration, syncs: Arc, @@ -632,13 +687,8 @@ pub mod sync_latency { if let Some(matched) = matched { matched.syncs.fetch_add(1, Ordering::SeqCst); #[cfg(test)] - if let Some(pause) = matched.pause { - let _ = pause.entered.send(()); - let _ = pause - .resume - .lock() - .unwrap_or_else(PoisonError::into_inner) - .recv(); + if let Some(pause) = matched.pause.filter(|pause| !pause.after_member) { + pause.wait(); } std::thread::sleep(matched.delay); } diff --git a/crates/tracedecay-private-fs/src/framed_log_window_tests.rs b/crates/tracedecay-private-fs/src/framed_log_window_tests.rs new file mode 100644 index 00000000000..e10bf791168 --- /dev/null +++ b/crates/tracedecay-private-fs/src/framed_log_window_tests.rs @@ -0,0 +1,174 @@ +//! Changes after one member's completed barrier must invalidate its whole window. +use std::fs::{self, File}; +use std::io::{ErrorKind, Write}; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +use super::{DURABLE_BATCH_CONCURRENT_SYNCS, DurableFileBatch, sync_latency}; + +fn write_members(batch: &mut DurableFileBatch, root: &Path, count: usize) -> Vec { + (0..count) + .map(|index| { + let path = root.join(format!("segment-{index}.tmp")); + let mut writer = File::create(&path).expect("member writer"); + writer + .write_all(format!("sealed segment {index}").as_bytes()) + .expect("nonempty member bytes"); + batch.written(&path, &writer).expect("admit original member"); + path + }) + .collect() +} + +#[test] +fn final_sync_refuses_replacement_after_a_pending_member_completed() { + let root = tempfile::tempdir().expect("isolated batch root"); + let mut batch = DurableFileBatch::new(); + let paths = write_members(&mut batch, root.path(), 2); + let first = &paths[0]; + let original = fs::metadata(first).expect("original member identity"); + + let (barriers, result) = thread::scope(|scope| { + // This handshake happens after the real fsync and both per-member + // identity checks. No sleep or scheduler ordering establishes the race. + // Keep the pause local so any assertion failure disconnects the worker + // before thread::scope joins it. + let (barriers, pause) = sync_latency::pause_after_batch_member(first); + let worker = scope.spawn(|| batch.sync()); + assert!(pause.wait_for_sync(Duration::from_secs(10))); + assert_eq!( + barriers.syncs(), + 1, + "a real member fsync must have completed" + ); + + let replacement = root.path().join("replacement.tmp"); + fs::write(&replacement, b"sealed segment 0").expect("identical replacement bytes"); + fs::rename(&replacement, first).expect("replace the already-checked inode"); + let current = fs::metadata(first).expect("replacement identity"); + assert_ne!( + (original.dev(), original.ino()), + (current.dev(), current.ino()), + "same-byte replacement must actually change the inode" + ); + pause.resume(); + (barriers, worker.join().expect("batch worker")) + }); + + let error = result.expect_err("changed pending window"); + assert_eq!(error.kind(), ErrorKind::InvalidData); + assert_eq!(batch.pending.len(), 2, "failed sync retains both handles"); + assert!(batch.synced.is_empty()); + assert_eq!( + fs::read(first).expect("replacement survives"), + b"sealed segment 0" + ); + let error = batch + .sync() + .expect_err("retry must still refuse the old identity"); + assert_eq!(error.kind(), ErrorKind::InvalidData); + assert_eq!( + barriers.syncs(), + 1, + "retry must not flush the substituted member" + ); + drop(barriers); + drop(batch); + + // A fresh, explicitly authenticated batch can publish the surviving bytes. + let mut recovered = DurableFileBatch::new(); + for path in &paths { + let writer = File::options() + .read(true) + .write(true) + .open(path) + .expect("fresh writer"); + recovered + .written(path, &writer) + .expect("authenticate current inode"); + } + let barriers = sync_latency::inject(root.path(), Duration::ZERO); + recovered.sync().expect("flush freshly authenticated batch"); + assert_eq!(barriers.syncs(), 2); + for (index, path) in paths.iter().enumerate() { + assert_eq!( + fs::read(path).expect("exact durable recovery bytes"), + format!("sealed segment {index}").as_bytes() + ); + } +} + +#[test] +fn window_rollover_refuses_mutation_after_a_pending_member_completed() { + let root = tempfile::tempdir().expect("isolated full-window root"); + let mut batch = DurableFileBatch::new(); + let paths = write_members(&mut batch, root.path(), DURABLE_BATCH_CONCURRENT_SYNCS); + let first = &paths[0]; + let original = fs::metadata(first).expect("original member identity"); + let incoming = root.path().join("incoming.tmp"); + let mut writer = File::create(&incoming).expect("incoming writer"); + writer.write_all(b"incoming segment").expect("incoming bytes"); + + let (barriers, result) = thread::scope(|scope| { + let (barriers, pause) = sync_latency::pause_after_batch_member(first); + let worker = scope.spawn(|| batch.written(&incoming, &writer)); + assert!(pause.wait_for_sync(Duration::from_secs(10))); + assert_eq!( + barriers.syncs(), + 1, + "a real member fsync must have completed" + ); + + let mut changed = File::options() + .write(true) + .open(first) + .expect("same inode writer"); + changed + .write_all(b"changed segment!") + .expect("same-length mutation"); + changed + .set_modified(original.modified().expect("original mtime") + Duration::from_secs(2)) + .expect("observable mutation even on coarse timestamp filesystems"); + drop(changed); + let current = fs::metadata(first).expect("mutated member identity"); + assert_eq!( + (original.dev(), original.ino(), original.len()), + (current.dev(), current.ino(), current.len()), + "the mutation must preserve both inode and byte count" + ); + pause.resume(); + (barriers, worker.join().expect("admission worker")) + }); + + let error = result.expect_err("changed full window"); + assert_eq!(error.kind(), ErrorKind::InvalidData); + assert_eq!(batch.pending.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + assert!(batch.synced.is_empty(), "no unproven member may be promoted"); + assert!( + batch + .pending + .iter() + .all(|member| member.identity.path != incoming) + ); + assert_eq!( + fs::read(first).expect("mutated bytes survive"), + b"changed segment!" + ); + assert_eq!( + fs::read(&incoming).expect("incoming bytes survive"), + b"incoming segment" + ); + let error = batch + .written(&incoming, &writer) + .expect_err("retry preserves failed admission"); + assert_eq!(error.kind(), ErrorKind::InvalidData); + assert_eq!( + barriers.syncs(), + 1, + "changed identity must fail before another fsync" + ); + assert_eq!(batch.pending.len(), DURABLE_BATCH_CONCURRENT_SYNCS); + assert!(batch.synced.is_empty()); +} From 108b39fa4ea2eab2ed72c0db155d3c7eac5753cc Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:27:44 -0800 Subject: [PATCH 92/93] style(private-fs): apply hosted Bazel formatting corrections Apply the three exact rustfmt hunks reported by current-head Bazel repository gates for the durability window regressions. Preserve all production logic, test assertions and deterministic interleavings. Refs #4533 --- .../src/framed_log_window_tests.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-private-fs/src/framed_log_window_tests.rs b/crates/tracedecay-private-fs/src/framed_log_window_tests.rs index e10bf791168..5f706ed487f 100644 --- a/crates/tracedecay-private-fs/src/framed_log_window_tests.rs +++ b/crates/tracedecay-private-fs/src/framed_log_window_tests.rs @@ -16,7 +16,9 @@ fn write_members(batch: &mut DurableFileBatch, root: &Path, count: usize) -> Vec writer .write_all(format!("sealed segment {index}").as_bytes()) .expect("nonempty member bytes"); - batch.written(&path, &writer).expect("admit original member"); + batch + .written(&path, &writer) + .expect("admit original member"); path }) .collect() @@ -109,7 +111,9 @@ fn window_rollover_refuses_mutation_after_a_pending_member_completed() { let original = fs::metadata(first).expect("original member identity"); let incoming = root.path().join("incoming.tmp"); let mut writer = File::create(&incoming).expect("incoming writer"); - writer.write_all(b"incoming segment").expect("incoming bytes"); + writer + .write_all(b"incoming segment") + .expect("incoming bytes"); let (barriers, result) = thread::scope(|scope| { let (barriers, pause) = sync_latency::pause_after_batch_member(first); @@ -145,7 +149,10 @@ fn window_rollover_refuses_mutation_after_a_pending_member_completed() { let error = result.expect_err("changed full window"); assert_eq!(error.kind(), ErrorKind::InvalidData); assert_eq!(batch.pending.len(), DURABLE_BATCH_CONCURRENT_SYNCS); - assert!(batch.synced.is_empty(), "no unproven member may be promoted"); + assert!( + batch.synced.is_empty(), + "no unproven member may be promoted" + ); assert!( batch .pending From 60f5bcdb5ad6cf6f0c985449d124335afc2d07f2 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:47:46 -0800 Subject: [PATCH 93/93] fix(code-index): authenticate retention generation metadata reads Refuse symlinked generation names and inode or metadata changes across full and metadata-only retention scans. Keep the same single pathname open while validating the original descriptor and directory entry. Add owning-crate real-file regressions for an alias and a same-byte foreign-inode rotation during both verification modes, plus a healthy full-hash control. Neither path requires additional open calls. Refs #4533 --- .../code_index_generations/generation_scan.rs | 123 +++++++++++++++++- 1 file changed, 122 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/generation_scan.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/generation_scan.rs index 5d6ef4b7368..e90ac413252 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/generation_scan.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/generation_scan.rs @@ -1,5 +1,7 @@ use std::fs::File; use std::io::Read; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; use std::path::Path; use sha2::{Digest, Sha256}; @@ -47,8 +49,25 @@ pub(super) fn read_generation_metadata( if is_cancelled() { return Err(CodeGenerationRetentionErrorV1::Cancelled); } + // Enumerating a content-addressed name does not authorize following an + // alias to another generation. This metadata witness adds no pathname open. + let named_before = path.symlink_metadata().map_err(deferred_if_absent)?; + if !named_before.file_type().is_file() { + return Err(CodeGenerationRetentionErrorV1::UnsafeState(format!( + "generation file '{}' is not a regular file", + path.display() + ))); + } let mut file = File::open(path).map_err(deferred_if_absent)?; - let size_bytes = file.metadata().map_err(storage)?.len(); + let opened_before = file.metadata().map_err(storage)?; + #[cfg(unix)] + if !same_generation_file_identity(&named_before, &opened_before) { + return Err(CodeGenerationRetentionErrorV1::UnsafeState(format!( + "generation file '{}' changed while opening", + path.display() + ))); + } + let size_bytes = opened_before.len(); let mut hasher = Sha256::new(); let (format_revision, manifest) = read_generation_metadata_prefix(&mut file, path, verification, &mut hasher, is_cancelled)?; @@ -75,9 +94,44 @@ pub(super) fn read_generation_metadata( } GenerationDigestVerificationV1::MetadataOnly => named_state_digest(path)?, }; + // The manifest, digest and reported size must belong to the pathname + // enumerated by retention, not a replaced inode or modified old handle. + // Metadata-only scans cannot fall back to trusting a substituted name. + let named_after = path.symlink_metadata().map_err(deferred_if_absent)?; + let opened_after = file.metadata().map_err(storage)?; + #[cfg(unix)] + let unchanged = same_generation_file_identity(&named_before, &named_after) + && same_generation_file_identity(&named_before, &opened_after); + #[cfg(not(unix))] + let unchanged = named_after.file_type().is_file() + && opened_after.is_file() + && named_after.len() == size_bytes + && opened_after.len() == size_bytes; + if !unchanged { + return Err(CodeGenerationRetentionErrorV1::UnsafeState(format!( + "generation file '{}' changed during metadata read", + path.display() + ))); + } Ok((format_revision, manifest, state_digest, size_bytes)) } +#[cfg(unix)] +fn same_generation_file_identity( + original: &std::fs::Metadata, + current: &std::fs::Metadata, +) -> bool { + original.file_type().is_file() + && current.file_type().is_file() + && original.dev() == current.dev() + && original.ino() == current.ino() + && original.len() == current.len() + && original.mtime() == current.mtime() + && original.mtime_nsec() == current.mtime_nsec() + && original.ctime() == current.ctime() + && original.ctime_nsec() == current.ctime_nsec() +} + /// Read only as far as complete metadata requires, up to the existing bound. /// Geometrically spaced probes keep repeated prefix parsing linear in bytes /// even when a manifest is large. Reads stay small and cancellable between @@ -233,6 +287,8 @@ mod tests { use std::cell::Cell; use std::fs::{self, File, OpenOptions}; use std::io::{Seek, SeekFrom, Write}; + #[cfg(unix)] + use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; use sha2::{Digest, Sha256}; @@ -432,6 +488,71 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn generation_metadata_refuses_symlink_aliases_in_both_modes() { + let root = tempfile::tempdir().unwrap(); + let bytes = generation_bytes(0, 32 * 1024); + let (target, _) = write_generation(root.path(), &bytes); + let alias = root.path().join(format!("generation-{}.json", "a".repeat(64))); + std::os::unix::fs::symlink(&target, &alias).unwrap(); + assert_eq!(fs::read(&alias).unwrap(), bytes, "alias resolves to valid bytes"); + + for mode in [ + GenerationDigestVerificationV1::MetadataOnly, + GenerationDigestVerificationV1::Full, + ] { + assert!(matches!( + read_generation_metadata(&alias, mode, &|| false), + Err(CodeGenerationRetentionErrorV1::UnsafeState(_)) + )); + } + let (_, _, digest, size) = + read_generation_metadata(&target, GenerationDigestVerificationV1::Full, &|| false) + .unwrap(); + assert_eq!(digest, encode_tagged_lowercase_hex("sha256:", &Sha256::digest(&bytes))); + assert_eq!(size, bytes.len() as u64); + } + + #[cfg(unix)] + #[test] + fn generation_metadata_refuses_same_byte_inode_rotation_during_read() { + for mode in [ + GenerationDigestVerificationV1::MetadataOnly, + GenerationDigestVerificationV1::Full, + ] { + let root = tempfile::tempdir().unwrap(); + let bytes = generation_bytes(0, 64 * 1024); + let (path, _) = write_generation(root.path(), &bytes); + let substitute = root.path().join("substitute.tmp"); + fs::write(&substitute, &bytes).unwrap(); + let original = fs::metadata(&path).unwrap(); + let foreign = fs::metadata(&substitute).unwrap(); + assert_ne!( + (original.dev(), original.ino()), + (foreign.dev(), foreign.ino()), + "the replacement must use a different real inode" + ); + let probes = Cell::new(0_usize); + let result = read_generation_metadata(&path, mode, &|| { + let previous = probes.get(); + probes.set(previous + 1); + // The third cancellation checkpoint is after reading the + // first nonempty prefix from the already-open descriptor. + if previous == 2 { + fs::rename(&substitute, &path).unwrap(); + } + false + }); + assert!(probes.get() >= 3, "the real reader must cross the swap"); + assert!(matches!( + result, + Err(CodeGenerationRetentionErrorV1::UnsafeState(_)) + )); + assert_eq!(fs::read(&path).unwrap(), bytes); + } + } + #[test] fn format_revision_requires_a_complete_unsigned_json_integer() { for input in [