Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)).
Expand Down
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 27 additions & 5 deletions crates/fspy/src/ipc.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::io;
use std::{cell::Cell, io};

use fspy_shared::ipc::{
PathAccess,
Expand All @@ -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<bool>,
/// Borrows the shared memory and owns the file lock
#[borrows(receiver)]
#[covariant]
Expand All @@ -23,16 +26,35 @@ pub struct OwnedReceiverLockGuard {

impl OwnedReceiverLockGuard {
pub fn lock(receiver: Receiver) -> io::Result<Self> {
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<Self> {
spawn_blocking(move || Self::lock(receiver)).await.expect("lock task panicked")
}

pub fn iter_path_accesses(&self) -> impl Iterator<Item = PathAccess<'_>> {
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()
}
}
15 changes: 15 additions & 0 deletions crates/fspy/src/unix/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
6 changes: 6 additions & 0 deletions crates/fspy/src/windows/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ impl PathAccessIterable {
pub fn iter(&self) -> impl Iterator<Item = PathAccess<'_>> {
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 {
Expand Down
122 changes: 116 additions & 6 deletions crates/fspy_shared/src/ipc/channel/shm_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use core::iter::from_fn;
use std::{
cell::Cell,
num::NonZeroUsize,
ops::{Deref, DerefMut},
ptr::slice_from_raw_parts_mut,
Expand Down Expand Up @@ -261,28 +262,54 @@ impl<M: AsRawSlice> ShmWriter<M> {
/// Reader of frames in shared memory created by `ShmWriter`.
pub struct ShmReader<M: AsRef<[u8]>> {
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<bool>,
}

impl<M: AsRef<[u8]>> ShmReader<M> {
/// 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<Item = &[u8]> {
let mem = self.mem.as_ref();
let (header, content) = mem
.split_first_chunk::<{ size_of::<usize>() }>()
.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 {
Expand All @@ -299,8 +326,18 @@ impl<M: AsRef<[u8]>> ShmReader<M> {
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)
Expand All @@ -309,8 +346,15 @@ impl<M: AsRef<[u8]>> ShmReader<M> {
}
};

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])
Expand Down Expand Up @@ -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::<u8>();
let extent = roundup_to_align_frame_header(size_of::<i32>() + 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::<usize>() + frame_offset);
std::ptr::copy_nonoverlapping(
content.as_ptr(),
frame.add(size_of::<i32>()),
content.len(),
);
}
}

#[test]
fn single_thread_basic() {
// SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation.
Expand All @@ -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() {
Expand Down
5 changes: 5 additions & 0 deletions crates/vite_task/src/session/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions crates/vite_task/src/session/execute/cache_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RelativePathBuf>,
/// 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<Str, Option<EnvValueHash>>;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -249,6 +261,7 @@ fn observe_fspy(
path_reads: filtered_path_reads,
path_writes: filtered_path_writes,
read_write_overlap,
tracking_truncated: tracked.truncated,
}
})
}
Expand Down
9 changes: 9 additions & 0 deletions crates/vite_task/src/session/execute/tracked_accesses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ pub struct TrackedPathAccesses {

/// Tracked path writes
pub path_writes: FxHashSet<RelativePathBuf>,

/// 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 {
Expand Down Expand Up @@ -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
}
}
Expand Down
Loading
Loading