Skip to content

fspy: shm frame reader panics parsing traced accesses (shm_io.rs:328) #544

Description

@wan9chi

fspy: shm frame reader panics parsing traced accesses (shm_io.rs:328)

vt run crashes with a slice-bounds panic while collecting fspy path accesses
after the task itself completed successfully. The shared-memory frame
iterator reads file-path bytes as a frame header and tries to skip ~1.9 GB
forward.

Pre-existing bug: reproduced at branch HEAD (a8920e66,
agent/real-package-read-write-overlap-tests) with no local changes. Not
caused by, and not fixed by, the ignore-report removal work.

Panic

thread 'main' panicked at crates/fspy_shared/src/ipc/channel/shm_io.rs:328:47:
range start index 1919954688 out of range for slice of length 102756

The panic site is the 1.. ("partially written frame — skip it") arm of
ShmReader::iter_frames:

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)..]; // <- panics
}

Trimmed backtrace (debug build, macOS arm64):

10: core::slice::index::slice_index_fail
12: fspy_shared::ipc::channel::shm_io::ShmReader<&[u8]>::iter_frames::{closure}
14: fspy::ipc::OwnedReceiverLockGuard::iter_path_accesses::{closure}
17: <Chain<...> as Iterator>::next            (PathAccessIterable::iter)
18: vite_task::session::execute::tracked_accesses::TrackedPathAccesses::from_raw
21: vite_task::session::execute::cache_update::observe_fspy
22: vite_task::session::execute::cache_update::update_cache
25: vite_task::session::execute::execute_spawn

So the child ran to completion; the crash happens in the post-run cache-update
phase, on the receiver side, while iterating shm frames.

Decoding the numbers

  • 1919954688 = 0x72702F00. As the little-endian i32 frame header, the four
    bytes at the header position are 00 2F 70 72 = "\0/pr" — a NUL byte
    followed by the beginning of an absolute path (/private/tmp/..., the
    staging directory). The reader is interpreting frame content (a path
    string) as a frame header
    , meaning reader position and writer layout have
    diverged at some earlier point in the buffer.
  • The slice length (102740 / 102756 / 104368 across observed panics) is
    the claimed end offset read from the shm header — roughly 100 KiB used.
    SHM_CAPACITY is 4 GiB sparse (crates/fspy/src/ipc.rs:12), so this is not
    capacity exhaustion.
  • Every observed panic had the same bogus header value — when the failure
    hits, the buffer layout is identical run to run (same workload, same paths).

Reproduction

Environment: macOS Darwin 25.5.0 (arm64), debug build, upstream vite 8.1.3
from packages/tools (vite-task-tools>vite override), Node via
packages/tools/node_modules/.bin.

# 1) Stage the vite fixture under a LONG absolute path (~95+ chars).
#    Short harness tmpdirs do not trip it; the long path changes frame layout.
STAGE=/private/tmp/<something-deep-enough>/vitestage
mkdir -p "$STAGE"
cp -r crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache "$STAGE/ws"
rm -rf "$STAGE"/ws/{portable_clone,portable_origin,snapshots,snapshots.toml}
ln -s "$PWD/packages/tools/node_modules" "$STAGE/node_modules"

# 2) Freshly relink vt — this is the reliable trigger knob (see below).
touch crates/vite_task_bin/src/main.rs
cargo build -p vite_task_bin --bin vt

# 3) Run the task once from the staged workspace.
cd "$STAGE/ws"
PATH="<repo>/target-or-cache/debug:<repo>/packages/tools/node_modules/.bin:$PATH" \
  vt run --cache -v build

Observed frequency:

Condition Result
First run(s) right after a fresh cargo build of vt panic 4/4 (incl. once at clean branch HEAD)
Same staging, warm binary, idle machine ok 14/14
Same staging, warm binary, 10 CPU spinners ok 8/8

The fresh-relink correlation is reliable: touch main.rs && cargo build
followed immediately by a run panicked on the first attempt; the same command
repeated with a warm binary did not. A freshly linked (re-signed) binary
changes first-run timing on macOS (code-signature validation, cold page-ins),
which points at a timing-sensitive window rather than a purely deterministic
arithmetic bug — while the constant bogus header value shows the buffer
content itself is stable.

Both knobs matter: the e2e harness also runs freshly built binaries but stages
fixtures under short tmpdir paths and has never hit this (195/195 green), so
frame layout (path lengths) must also line up for the bad read to land on a
path byte.

Protocol background

Writer (ShmWriter::claim_frame, shm_io.rs ~line 148):

  1. fetch_update on the leading AtomicUsize bumps the end offset by
    roundup4(4 + size) — this is the only cross-writer coordination.
  2. Store +size into the frame's AtomicI32 header (Relaxed), fence(Release).
  3. Write content bytes.
  4. On completion (FrameMut finish): fence(Release), store -size (Relaxed).

Reader (ShmReader::iter_frames, ~line 278), which runs in vt after the
main child exits and OwnedReceiverLockGuard::lock_async returns:

  • Reads the end offset once, then walks frames with plain (non-atomic)
    reads
    ("ShmReader reads headers by simple pointer dereferences").
  • Header 0: "claimed but never written" — advance 4 bytes and keep scanning.
  • Header > 0: "partially written" — skip roundup4(size).
  • Header < 0: complete frame — yield size bytes.

The reader/writer advance arithmetic is mutually consistent (all offsets stay
4-aligned, roundup4(P + 4 + size) = P + 4 + roundup4(size)), so a divergence
requires either a not-yet-visible/in-flight write or a frame state the scan
rules don't actually cover.

Hypotheses (unconfirmed)

  1. Reader overlaps a still-active writer. The design assumes quiescence
    ("The ShmReader must be created after all writing … is done and visible").
    If any descendant of the task (e.g. an esbuild service or worker spawned by
    vite that dies asynchronously after the main child exits) can still execute
    claim_frame/content writes after the receiver lock is acquired, the 0
    arm becomes unsound: the reader sees a zero header for a just-claimed frame,
    4-steps into its extent, the writer's content lands behind it, and the
    next 4-byte read hits path bytes ("\0/pr") — exactly what the panic shows.
    Worth auditing OwnedReceiverLockGuard: does every process that can write
    hold the shared flock for the entire lifetime of its mapping (exec'd
    descendants, forked children, threads still running during process
    teardown)?
  2. Insufficient memory ordering for a non-quiescent reader. Headers are
    written with Relaxed atomics + Release fences, but the reader uses plain
    dereferences with no Acquire pairing. On arm64 this is only correct if true
    quiescence is guaranteed; combined with (1), the reader can observe a
    completion header without the content, or vice versa.
  3. Writer-side accounting edge under specific cumulative frame sizes.
    Considered less likely (the arithmetic reviews clean), but not excluded —
    the constant panic offset would also be consistent with a size-dependent
    boundary bug that only certain path-length mixes reach.

Impact

  • User-visible crash of vp run after the task already succeeded (exit via
    panic, no summary, no cache update).
  • The scarier variant is the one that doesn't panic: a misparsed frame that
    stays in bounds silently yields garbage PathAccess records or swallows real
    ones. That corrupts automatic input/output inference — wrong cache
    fingerprints or missing outputs — with no diagnostic at all. The 0 and 1..
    scan arms can both skip over valid data if the layout assumption is broken.

Suggested next steps

  1. On this panic path, dump the shm region to a file plus the offsets of the
    last few successfully parsed frames — one hexdump around the divergence
    point should immediately distinguish hypothesis (1)/(2) from (3).
  2. Instrument claim_frame (behind an env flag) to log (pid, offset, size)
    per claim; compare against the reader's walk.
  3. Audit receiver-lock coverage for late writers (exec/fork descendants,
    at-exit writes). A cheap experiment: have the traced child spawn a
    grandchild that keeps writing accesses for 500 ms after the parent exits,
    and see if the panic becomes reliable.
  4. Regardless of root cause, make the reader defensive: treat an
    out-of-bounds skip (size > remaining) as "truncated tail — stop iterating
    and warn" instead of panicking, and consider Acquire loads for headers. The
    cache-update path already tolerates missing accesses gracefully
    (FspyUnsupported-style bail) — a truncated read should degrade the same
    way, not abort the run.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions