diff --git a/CHANGELOG.md b/CHANGELOG.md index 05973e2be..55fb4e620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** `vp run` no longer crashes after a task succeeds when a traced process died (or was still writing) while its file accesses were being collected; the run completes and is reported as not cached instead ([#544](https://github.com/voidzero-dev/vite-task/issues/544), [#545](https://github.com/voidzero-dev/vite-task/pull/545)). - **Fixed** An issue where Bun tasks on macOS did not rerun when files they read, wrote, or listed changed ([#532](https://github.com/voidzero-dev/vite-task/issues/532), [#542](https://github.com/voidzero-dev/vite-task/pull/542)). - **Improved** Windows file-access tracking now uses sparse temporary backing files where supported, avoiding upfront allocation of the full backing file on disk ([#524](https://github.com/voidzero-dev/vite-task/pull/524)). - **Fixed** Automatic file-access tracking on Linux now works in containers and Kubernetes runners with limited `/dev/shm` space ([#353](https://github.com/voidzero-dev/vite-task/issues/353), [#523](https://github.com/voidzero-dev/vite-task/pull/523)). diff --git a/Cargo.lock b/Cargo.lock index 5c09ae71e..11622646e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4278,6 +4278,9 @@ dependencies = [ "cow-utils", "cp_r", "ctrlc", + "fspy_detours_sys", + "fspy_shared", + "fspy_shared_unix", "jsonc-parser", "libc", "libtest-mimic", @@ -4301,6 +4304,7 @@ dependencies = [ "vite_task", "vite_workspace", "which", + "wincode", ] [[package]] diff --git a/crates/fspy/src/ipc.rs b/crates/fspy/src/ipc.rs index 51d498600..3d58520cd 100644 --- a/crates/fspy/src/ipc.rs +++ b/crates/fspy/src/ipc.rs @@ -1,4 +1,4 @@ -use std::io; +use std::{cell::Cell, io}; use fspy_shared::ipc::{ PathAccess, @@ -15,6 +15,9 @@ pub const SHM_CAPACITY: usize = 4 * 1024 * 1024 * 1024; pub struct OwnedReceiverLockGuard { /// Owns the shared memory receiver: Receiver, + /// Set when `iter_path_accesses` hit a frame that failed to decode and + /// stopped early (see [`OwnedReceiverLockGuard::is_truncated`]). + deserialize_failed: Cell, /// Borrows the shared memory and owns the file lock #[borrows(receiver)] #[covariant] @@ -23,7 +26,7 @@ pub struct OwnedReceiverLockGuard { impl OwnedReceiverLockGuard { pub fn lock(receiver: Receiver) -> io::Result { - Self::try_new(receiver, fspy_shared::ipc::channel::Receiver::lock) + Self::try_new(receiver, Cell::new(false), fspy_shared::ipc::channel::Receiver::lock) } pub async fn lock_async(receiver: Receiver) -> io::Result { @@ -31,8 +34,27 @@ impl OwnedReceiverLockGuard { } pub fn iter_path_accesses(&self) -> impl Iterator> { - self.borrow_lock_guard() - .iter_frames() - .map(|frame| wincode::deserialize_exact(frame).unwrap()) + self.borrow_lock_guard().iter_frames().map_while(|frame| { + wincode::deserialize_exact(frame).map_or_else( + |_| { + // An in-bounds frame with undecodable content is the same + // torn state as a truncated frame tail (issue 544): a + // writer died or raced the reader mid-frame. Stop instead + // of panicking; everything past this point is unreliable. + self.borrow_deserialize_failed().set(true); + None + }, + Some, + ) + }) + } + + /// Whether a previous [`Self::iter_path_accesses`] iteration ended early + /// because the shared memory contents were inconsistent (a torn frame + /// tail or an undecodable frame — see issue 544). When true, the yielded + /// accesses may be missing entries and must not be treated as a complete + /// record. + pub fn is_truncated(&self) -> bool { + self.borrow_deserialize_failed().get() || self.borrow_lock_guard().is_tail_truncated() } } diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index f01f63b5d..5237f8423 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -198,4 +198,19 @@ impl PathAccessIterable { accesses_in_arena } } + + /// Whether a previous [`Self::iter`] iteration returned an incomplete + /// record (torn or undecodable shared-memory frames — see issue 544). + pub fn is_truncated(&self) -> bool { + #[cfg(not(target_env = "musl"))] + { + self.ipc_receiver_lock_guard.is_truncated() + } + // On musl, accesses come only from in-process arenas, which cannot + // be torn. + #[cfg(target_env = "musl")] + { + false + } + } } diff --git a/crates/fspy/src/windows/mod.rs b/crates/fspy/src/windows/mod.rs index 8081e1298..88af44a54 100644 --- a/crates/fspy/src/windows/mod.rs +++ b/crates/fspy/src/windows/mod.rs @@ -37,6 +37,12 @@ impl PathAccessIterable { pub fn iter(&self) -> impl Iterator> { self.ipc_receiver_lock_guard.iter_path_accesses() } + + /// Whether a previous [`Self::iter`] iteration returned an incomplete + /// record (torn or undecodable shared-memory frames — see issue 544). + pub fn is_truncated(&self) -> bool { + self.ipc_receiver_lock_guard.is_truncated() + } } // pub struct TracedProcess { diff --git a/crates/fspy_shared/src/ipc/channel/shm_io.rs b/crates/fspy_shared/src/ipc/channel/shm_io.rs index 62d927cd5..0d34903da 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io.rs @@ -2,6 +2,7 @@ use core::iter::from_fn; use std::{ + cell::Cell, num::NonZeroUsize, ops::{Deref, DerefMut}, ptr::slice_from_raw_parts_mut, @@ -261,28 +262,54 @@ impl ShmWriter { /// Reader of frames in shared memory created by `ShmWriter`. pub struct ShmReader> { mem: M, + /// Set when [`Self::iter_frames`] found a frame whose recorded extent + /// doesn't fit in the used region and stopped early. See + /// [`Self::is_tail_truncated`]. + tail_truncated: Cell, } impl> ShmReader { /// The content of `mem` should be created by `ShmWriter`. - /// Failing to do so may result in panics (mostly out-of-bounds), but won't trigger undefined behavior. + /// Failing to do so may result in missing frames (see [`Self::is_tail_truncated`]), but won't trigger undefined behavior. /// /// The `ShmReader` must be created after all writing to the shared memory is done and visible to the calling thread. /// This is guaranteed by `M: AsRef<[u8]>`, which means the memory region is immutable during the lifetime of `ShmReader`, /// so no need to mark `ShmReader::new` as unsafe, but care must be taken to create a safe `M` from the shared memory. pub fn new(mem: M) -> Self { assert_alignment(mem.as_ref().as_ptr()); - Self { mem } + Self { mem, tail_truncated: Cell::new(false) } + } + + /// Whether a [`Self::iter_frames`] iteration ended early because the + /// remaining buffer contents were inconsistent — a frame size pointing + /// past the used region. + /// + /// That state means a writer broke the quiescence contract (it died or + /// was still writing between claiming a frame and completing it — see + /// issue 544), so every frame from the inconsistency on is unreliable + /// and is not yielded. Callers that need complete data must treat the + /// whole read as untrustworthy when this returns `true`. + pub const fn is_tail_truncated(&self) -> bool { + self.tail_truncated.get() } /// Iterate over all the frames in the shared memory. + /// + /// A frame whose recorded extent doesn't fit in the used region ends the + /// iteration early instead of panicking; [`Self::is_tail_truncated`] + /// reports that afterwards. pub fn iter_frames(&self) -> impl Iterator { let mem = self.mem.as_ref(); let (header, content) = mem .split_first_chunk::<{ size_of::() }>() .expect("mem too small to contain header"); let content_size: usize = must_cast(*header); - let mut remaining_content = &content[..content_size]; + let mut remaining_content = content.get(..content_size).unwrap_or_else(|| { + // The recorded end offset overruns the mapping. No frame can be + // trusted; yield nothing. + self.tail_truncated.set(true); + &[] + }); from_fn(move || { let frame_size = loop { @@ -299,8 +326,18 @@ impl> ShmReader { 1.. => { // Partially written frame - skip it and continue let size = usize::try_from(frame_header).unwrap(); - remaining_content = - &remaining_content[roundup_to_align_frame_header(size)..]; + let Some(rest) = + remaining_content.get(roundup_to_align_frame_header(size)..) + else { + // The skip overruns the used region: these bytes + // were never a frame header (e.g. frame content + // reached by walking over a claimed-but-unwritten + // frame that a dying or racing writer later + // filled — issue 544). Stop; the tail is lost. + self.tail_truncated.set(true); + return None; + }; + remaining_content = rest; } ..0 => { // Fully written frame (negative size indicates completion) @@ -309,8 +346,15 @@ impl> ShmReader { } }; + let padded_size = roundup_to_align_frame_header(frame_size); + if padded_size > remaining_content.len() { + // Same misparse as above, with the garbage bytes read as a + // "complete" frame header. + self.tail_truncated.set(true); + return None; + } let (frame_with_padding, next_remaining_content) = - remaining_content.split_at(roundup_to_align_frame_header(frame_size)); + remaining_content.split_at(padded_size); remaining_content = next_remaining_content; Some(&frame_with_padding[..frame_size]) @@ -381,6 +425,31 @@ mod tests { } } + /// Reproduce the byte-level state from issue 544 through raw writes: bump + /// the end offset to cover a frame extent, leave the frame header zero, + /// and fill in content bytes. This is what a writer that dies — or races + /// the reader — between claiming a frame and completing it leaves behind; + /// the writer API can never produce it on its own (the header is always + /// stored before any content). + fn append_torn_frame(shm: &MockedShm, content: &[u8]) { + let base = shm.as_raw_slice().cast::(); + let extent = roundup_to_align_frame_header(size_of::() + content.len()); + // SAFETY: the mapping starts with the writers' shared `AtomicUsize` + // end offset; the allocation is valid and aligned for `usize`. + let end_offset = unsafe { AtomicUsize::from_ptr(base.cast()) }; + let frame_offset = end_offset.fetch_add(extent, Ordering::Relaxed); + // SAFETY: the extent claimed above is exclusively ours and within the + // allocation (the callers stay far below the capacity). + unsafe { + let frame = base.add(size_of::() + frame_offset); + std::ptr::copy_nonoverlapping( + content.as_ptr(), + frame.add(size_of::()), + content.len(), + ); + } + } + #[test] fn single_thread_basic() { // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. @@ -396,6 +465,47 @@ mod tests { assert_eq!(frames.next().unwrap(), b"world"); assert_eq!(frames.next().unwrap(), b"this is a test"); assert_eq!(frames.next(), None); + assert!(!reader.is_tail_truncated()); + } + + #[test] + fn torn_frame_truncates_tail_as_partial_frame() { + // Walking over the torn frame's zero header lands on its content: + // b"\0/pr" reads as the positive (partially-written) frame header + // 0x7270_2F00, whose skip length overruns the used region. The exact + // state — including the header value — observed in issue 544. + let shm = MockedShm::alloc(1024); + // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. + let writer = unsafe { ShmWriter::new(shm.clone()) }; + assert!(writer.try_write_frame(b"foo")); + append_torn_frame(&shm, b"\0/private/tmp/some/staged/path"); + assert!(writer.try_write_frame(b"bar")); + + let reader = ShmReader::new(shm); + let mut frames = reader.iter_frames(); + assert_eq!(frames.next().unwrap(), b"foo"); + // The torn frame ends the iteration: everything behind it (even the + // valid "bar" frame) is unreachable because the walk lost its frame + // alignment. + assert_eq!(frames.next(), None); + assert!(reader.is_tail_truncated()); + } + + #[test] + fn torn_frame_truncates_tail_as_complete_frame() { + // Content bytes with the high bit set read as a negative ("complete") + // frame header whose extent overruns the used region. + let shm = MockedShm::alloc(1024); + // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. + let writer = unsafe { ShmWriter::new(shm.clone()) }; + assert!(writer.try_write_frame(b"foo")); + append_torn_frame(&shm, b"\xF0\xF1\xF2\xF3 torn frame content"); + + let reader = ShmReader::new(shm); + let mut frames = reader.iter_frames(); + assert_eq!(frames.next().unwrap(), b"foo"); + assert_eq!(frames.next(), None); + assert!(reader.is_tail_truncated()); } #[test] fn single_thread_empty() { diff --git a/crates/vite_task/src/session/event.rs b/crates/vite_task/src/session/event.rs index 030000f54..7d488b41a 100644 --- a/crates/vite_task/src/session/event.rs +++ b/crates/vite_task/src/session/event.rs @@ -87,6 +87,11 @@ pub enum CacheNotUpdatedReason { /// (its `input` config includes auto-inference). Task ran but cannot /// be cached without tracked path accesses. FspyUnsupported, + /// fspy couldn't read the traced path accesses back completely (torn or + /// undecodable shared-memory frames left by a tracked process that died + /// or was still writing — see issue 544). The inferred inputs/outputs + /// may be missing entries, so caching would risk stale hits. + TrackingIncomplete, /// The runner's IPC server failed during execution, so the collected /// reports may be incomplete. Caching such a run would risk stale /// inputs/outputs on the next hit. Carries the underlying error for diff --git a/crates/vite_task/src/session/execute/cache_update.rs b/crates/vite_task/src/session/execute/cache_update.rs index 0cf4df1c5..d63de5a45 100644 --- a/crates/vite_task/src/session/execute/cache_update.rs +++ b/crates/vite_task/src/session/execute/cache_update.rs @@ -34,6 +34,10 @@ struct TrackingOutcome { /// First path that was both read and written during execution, if any. /// A non-empty value means caching this task is unsound. read_write_overlap: Option, + /// True when the traced accesses couldn't be read back completely (torn + /// shared-memory frames — see issue 544). `path_reads`/`path_writes` may + /// then be missing entries, so caching this task is unsound. + tracking_truncated: bool, } type TrackedEnvValues = BTreeMap>; @@ -98,6 +102,14 @@ pub(super) async fn update_cache( workspace_root, ); + if let Some(TrackingOutcome { tracking_truncated: true, .. }) = &fspy_outcome { + // The traced accesses couldn't be read back completely (torn + // shared-memory frames — see issue 544): the inferred inputs and + // outputs may be missing entries, so a cache entry built from them + // could produce stale hits later. + return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::TrackingIncomplete), None); + } + if let Some(TrackingOutcome { read_write_overlap: Some(path), .. }) = &fspy_outcome { // fspy-inferred read-write overlap: the task wrote to a file it also // read, so the prerun input hashes are stale and caching is unsound. @@ -249,6 +261,7 @@ fn observe_fspy( path_reads: filtered_path_reads, path_writes: filtered_path_writes, read_write_overlap, + tracking_truncated: tracked.truncated, } }) } diff --git a/crates/vite_task/src/session/execute/tracked_accesses.rs b/crates/vite_task/src/session/execute/tracked_accesses.rs index 6e3596512..4d1ae6164 100644 --- a/crates/vite_task/src/session/execute/tracked_accesses.rs +++ b/crates/vite_task/src/session/execute/tracked_accesses.rs @@ -22,6 +22,12 @@ pub struct TrackedPathAccesses { /// Tracked path writes pub path_writes: FxHashSet, + + /// True when fspy couldn't read the traced accesses back completely + /// (torn or undecodable shared-memory frames — see issue 544). + /// `path_reads`/`path_writes` may then be missing entries and must not + /// feed a cache entry. + pub truncated: bool, } impl TrackedPathAccesses { @@ -64,6 +70,9 @@ impl TrackedPathAccesses { } } } + // Truncation is discovered lazily while iterating, so this must be + // read after the loop drained the iterator. + accesses.truncated = raw.is_truncated(); accesses } } diff --git a/crates/vite_task/src/session/reporter/summary.rs b/crates/vite_task/src/session/reporter/summary.rs index 76961ad00..ebec35a23 100644 --- a/crates/vite_task/src/session/reporter/summary.rs +++ b/crates/vite_task/src/session/reporter/summary.rs @@ -108,6 +108,11 @@ pub enum SpawnOutcome { /// Task ran successfully but cache was not updated. #[serde(default)] fspy_unsupported: bool, + /// `true` when the traced file accesses couldn't be read back + /// completely (torn shared-memory frames — see issue 544). Task ran + /// successfully but cache was not updated. + #[serde(default)] + tracking_incomplete: bool, /// Rendered message of the IPC server error that caused the cache to /// be skipped, if any. ipc_server_error: Option, @@ -328,6 +333,10 @@ impl TaskResult { cache_update_status, CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::FspyUnsupported) ); + let tracking_incomplete = matches!( + cache_update_status, + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::TrackingIncomplete) + ); let ipc_server_error = match cache_update_status { CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::IpcServerError(err)) => { Some(vite_str::format!("{err}")) @@ -351,6 +360,7 @@ impl TaskResult { saved_error, input_modified_path, fspy_unsupported, + tracking_incomplete, ipc_server_error, tool_disabled_cache, ), @@ -364,6 +374,7 @@ impl TaskResult { saved_error, input_modified_path, fspy_unsupported, + tracking_incomplete, ipc_server_error, tool_disabled_cache, ), @@ -378,6 +389,7 @@ fn spawn_outcome_from_execution( saved_error: Option<&SavedExecutionError>, input_modified_path: Option, fspy_unsupported: bool, + tracking_incomplete: bool, ipc_server_error: Option, tool_disabled_cache: bool, ) -> SpawnOutcome { @@ -389,6 +401,7 @@ fn spawn_outcome_from_execution( infra_error: saved_error.cloned(), input_modified_path, fspy_unsupported, + tracking_incomplete, ipc_server_error, tool_disabled_cache, }, @@ -409,6 +422,7 @@ fn spawn_outcome_from_execution( infra_error: None, input_modified_path: None, fspy_unsupported: false, + tracking_incomplete: false, ipc_server_error: None, tool_disabled_cache: false, }, @@ -558,6 +572,14 @@ impl TaskResult { "→ Not cached: `input` auto-inference isn't supported on this OS. Configure `input` manually to enable caching.", ); } + // Incomplete tracking data — same overrides precedence as above + if let Self::Spawned { + outcome: SpawnOutcome::Success { tracking_incomplete: true, .. }, + .. + } = self + { + return Str::from("→ Not cached: file access tracking data was incomplete"); + } match self { Self::CacheHit { saved_duration_ms } => { diff --git a/crates/vite_task_bin/Cargo.toml b/crates/vite_task_bin/Cargo.toml index 3d179de0d..ddfc7096b 100644 --- a/crates/vite_task_bin/Cargo.toml +++ b/crates/vite_task_bin/Cargo.toml @@ -34,6 +34,17 @@ which = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] nix = { workspace = true, features = ["mount", "sched", "user"] } +# `vtt write-garbage-fspy-frame` connects a sender to the task's fspy channel +# (from the payload fspy hands every traced process) to write an undecodable +# frame; the channel doesn't exist on musl targets. +[target.'cfg(all(unix, not(target_env = "musl")))'.dependencies] +fspy_shared_unix = { workspace = true } + +[target.'cfg(windows)'.dependencies] +fspy_detours_sys = { workspace = true } +fspy_shared = { workspace = true } +wincode = { workspace = true } + [dev-dependencies] cow-utils = { workspace = true } cp_r = { workspace = true } diff --git a/crates/vite_task_bin/src/vtt/main.rs b/crates/vite_task_bin/src/vtt/main.rs index 9e3c7f6fb..7dc902481 100644 --- a/crates/vite_task_bin/src/vtt/main.rs +++ b/crates/vite_task_bin/src/vtt/main.rs @@ -29,13 +29,14 @@ mod stat_file; mod stat_long_filename; mod touch_file; mod write_file; +mod write_garbage_fspy_frame; fn main() { let args: Vec = std::env::args().collect(); if args.len() < 2 { eprintln!("Usage: vtt [args...]"); eprintln!( - "Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, grep-file, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, small_dev_shm, stat-file, stat_long_filename, touch-file, write-file" + "Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, grep-file, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, small_dev_shm, stat-file, stat_long_filename, touch-file, write-file, write-garbage-fspy-frame" ); std::process::exit(1); } @@ -78,6 +79,7 @@ fn main() { "stat_long_filename" => stat_long_filename::run(&args[2..]), "touch-file" => touch_file::run(&args[2..]), "write-file" => write_file::run(&args[2..]), + "write-garbage-fspy-frame" => write_garbage_fspy_frame::run(), other => { eprintln!("Unknown subcommand: {other}"); std::process::exit(1); diff --git a/crates/vite_task_bin/src/vtt/write_garbage_fspy_frame.rs b/crates/vite_task_bin/src/vtt/write_garbage_fspy_frame.rs new file mode 100644 index 000000000..9fe7349fe --- /dev/null +++ b/crates/vite_task_bin/src/vtt/write_garbage_fspy_frame.rs @@ -0,0 +1,66 @@ +//! `vtt write-garbage-fspy-frame` — writes a frame that is not a valid path +//! access into the fspy channel of the current task (issue 544). +//! +//! The frame goes through the channel's own `Sender` API — the same interface +//! every traced process writes through — so the channel-level frame is +//! perfectly well formed; only its content doesn't decode as a `PathAccess`. +//! This reconstructs the in-bounds variant of the frame-stream corruption +//! described in issue 544 (a misparsed frame yielding garbage path-access +//! records): collecting the traced accesses after the task succeeded must not +//! crash the runner, and the incomplete data must not produce a cache entry. + +#[cfg(any(all(unix, not(target_env = "musl")), windows))] +pub fn run() -> Result<(), Box> { + use std::num::NonZeroUsize; + + // Obtain the task's channel configuration from the payload fspy hands + // every traced process, and connect a sender to it like any tracked + // writer would. + #[cfg(all(unix, not(target_env = "musl")))] + let sender = { + let payload = fspy_shared_unix::payload::decode_payload_from_env() + .map_err(|err| format!("failed to decode fspy payload: {err}"))?; + payload.payload.ipc_channel_conf.sender()? + }; + #[cfg(windows)] + let sender = { + let mut payload_len: u32 = 0; + // SAFETY: FFI call; `PAYLOAD_ID` is a static GUID and `payload_len` a + // valid out-pointer. + let payload_ptr = unsafe { + fspy_detours_sys::DetourFindPayloadEx( + &fspy_shared::windows::PAYLOAD_ID, + &raw mut payload_len, + ) + } + .cast::(); + if payload_ptr.is_null() { + return Err("fspy Detours payload not found; is this process traced by fspy?".into()); + } + // SAFETY: `DetourFindPayloadEx` returned a non-null pointer to + // `payload_len` bytes that live for the rest of the process. + let payload_bytes = + unsafe { std::slice::from_raw_parts(payload_ptr, payload_len.try_into()?) }; + let payload: fspy_shared::windows::Payload<'_> = wincode::deserialize_exact(payload_bytes) + .map_err(|err| format!("failed to decode fspy payload: {err}"))?; + payload.channel_conf.sender()? + }; + + // A single-byte frame: the byte decodes as an `AccessMode`, after which + // the path is missing, so `PathAccess` deserialization always fails. + let frame_size = NonZeroUsize::new(1).expect("1 is non-zero"); + let mut frame = sender.claim_frame(frame_size).ok_or("no space left in the fspy channel")?; + frame.copy_from_slice(&[1]); + // Dropping the claimed frame marks it as completely written. + drop(frame); + + println!("wrote a garbage frame to the fspy channel"); + Ok(()) +} + +#[cfg(not(any(all(unix, not(target_env = "musl")), windows)))] +pub fn run() -> Result<(), Box> { + Err("vtt write-garbage-fspy-frame requires the fspy shared-memory channel, \ + which does not exist on musl targets" + .into()) +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/fspy_garbage_frame/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/fspy_garbage_frame/package.json new file mode 100644 index 000000000..63345729e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/fspy_garbage_frame/package.json @@ -0,0 +1,4 @@ +{ + "name": "fspy-garbage-frame", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/fspy_garbage_frame/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/fspy_garbage_frame/snapshots.toml new file mode 100644 index 000000000..42f70c819 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/fspy_garbage_frame/snapshots.toml @@ -0,0 +1,10 @@ +# The fspy shared-memory channel doesn't exist on musl targets (fspy uses +# seccomp-unotify arenas there), so there is no frame stream to corrupt: +# non-musl only. +[[e2e]] +name = "garbage_frame" +comment = """ +Repro for issue 544: the task connects a sender to its own fspy channel — the same writer API every traced process uses — and writes a frame that doesn't decode as a path access, reconstructing the in-bounds variant of the frame-stream corruption a dying or racing writer leaves behind. Collecting the traced accesses after the task succeeded must not crash the runner, and the run must not be cached from the incomplete data (the second run must stay a cache miss). +""" +platform = "non-musl" +steps = [["vt", "run", "-v", "garbage-frame"], ["vt", "run", "-v", "garbage-frame"]] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/fspy_garbage_frame/snapshots/garbage_frame.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/fspy_garbage_frame/snapshots/garbage_frame.md new file mode 100644 index 000000000..eec34661e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/fspy_garbage_frame/snapshots/garbage_frame.md @@ -0,0 +1,45 @@ +# garbage_frame + +Repro for issue 544: the task connects a sender to its own fspy channel — the same writer API every traced process uses — and writes a frame that doesn't decode as a path access, reconstructing the in-bounds variant of the frame-stream corruption a dying or racing writer leaves behind. Collecting the traced accesses after the task succeeded must not crash the runner, and the run must not be cached from the incomplete data (the second run must stay a cache miss). + +## `vt run -v garbage-frame` + +``` +$ vtt write-garbage-fspy-frame +wrote a garbage frame to the fspy channel + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 1 cache misses +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] fspy-garbage-frame#garbage-frame: $ vtt write-garbage-fspy-frame ✓ + → Not cached: file access tracking data was incomplete +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +## `vt run -v garbage-frame` + +``` +$ vtt write-garbage-fspy-frame +wrote a garbage frame to the fspy channel + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 1 cache misses +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] fspy-garbage-frame#garbage-frame: $ vtt write-garbage-fspy-frame ✓ + → Not cached: file access tracking data was incomplete +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/fspy_garbage_frame/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/fspy_garbage_frame/vite-task.json new file mode 100644 index 000000000..b9d2d6000 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/fspy_garbage_frame/vite-task.json @@ -0,0 +1,13 @@ +{ + "tasks": { + "garbage-frame": { + "command": "vtt write-garbage-fspy-frame", + "cache": true, + "input": [ + { + "auto": true + } + ] + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/redact.rs b/crates/vite_task_bin/tests/e2e_snapshots/redact.rs index fea4a0b92..c0dfaa75b 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/redact.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/redact.rs @@ -99,6 +99,11 @@ pub fn redact_e2e_output(mut output: String, workspace_root: &str) -> String { let thread_regex = regex::Regex::new(r"using \d+ threads").unwrap(); output = thread_regex.replace_all(&output, "using threads").into_owned(); + // Redact the process id that panic messages carry ("thread 'main' (123) + // panicked at ..."). + let panic_pid_regex = regex::Regex::new(r"thread '([^']+)' \(\d+\) panicked").unwrap(); + output = panic_pid_regex.replace_all(&output, "thread '$1' () panicked").into_owned(); + // Remove Node.js experimental warnings (e.g., Type Stripping warnings) let node_warning_regex = regex::Regex::new(r"(?m)^\(node:\d+\) ExperimentalWarning:.*\n?").unwrap();