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):
fetch_update on the leading AtomicUsize bumps the end offset by
roundup4(4 + size) — this is the only cross-writer coordination.
- Store
+size into the frame's AtomicI32 header (Relaxed), fence(Release).
- Write content bytes.
- 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)
- 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)?
- 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.
- 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
- 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).
- Instrument
claim_frame (behind an env flag) to log (pid, offset, size)
per claim; compare against the reader's walk.
- 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.
- 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.
fspy: shm frame reader panics parsing traced accesses (
shm_io.rs:328)vt runcrashes with a slice-bounds panic while collecting fspy path accessesafter 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. Notcaused by, and not fixed by, the ignore-report removal work.
Panic
The panic site is the
1..("partially written frame — skip it") arm ofShmReader::iter_frames:Trimmed backtrace (debug build, macOS arm64):
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-endiani32frame header, the fourbytes at the header position are
00 2F 70 72="\0/pr"— a NUL bytefollowed by the beginning of an absolute path (
/private/tmp/..., thestaging 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.
102740/102756/104368across observed panics) isthe claimed end offset read from the shm header — roughly 100 KiB used.
SHM_CAPACITYis 4 GiB sparse (crates/fspy/src/ipc.rs:12), so this is notcapacity exhaustion.
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>viteoverride), Node viapackages/tools/node_modules/.bin.Observed frequency:
cargo buildofvtThe fresh-relink correlation is reliable:
touch main.rs && cargo buildfollowed 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):fetch_updateon the leadingAtomicUsizebumps the end offset byroundup4(4 + size)— this is the only cross-writer coordination.+sizeinto the frame'sAtomicI32header (Relaxed),fence(Release).FrameMutfinish):fence(Release), store-size(Relaxed).Reader (
ShmReader::iter_frames, ~line 278), which runs invtafter themain child exits and
OwnedReceiverLockGuard::lock_asyncreturns:reads ("
ShmReaderreads headers by simple pointer dereferences").0: "claimed but never written" — advance 4 bytes and keep scanning.> 0: "partially written" — skiproundup4(size).< 0: complete frame — yieldsizebytes.The reader/writer advance arithmetic is mutually consistent (all offsets stay
4-aligned,
roundup4(P + 4 + size) = P + 4 + roundup4(size)), so a divergencerequires either a not-yet-visible/in-flight write or a frame state the scan
rules don't actually cover.
Hypotheses (unconfirmed)
("The
ShmReadermust 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, the0arm 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 writehold the shared flock for the entire lifetime of its mapping (exec'd
descendants, forked children, threads still running during process
teardown)?
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.
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
vp runafter the task already succeeded (exit viapanic, no summary, no cache update).
stays in bounds silently yields garbage
PathAccessrecords or swallows realones. That corrupts automatic input/output inference — wrong cache
fingerprints or missing outputs — with no diagnostic at all. The
0and1..scan arms can both skip over valid data if the layout assumption is broken.
Suggested next steps
last few successfully parsed frames — one hexdump around the divergence
point should immediately distinguish hypothesis (1)/(2) from (3).
claim_frame(behind an env flag) to log(pid, offset, size)per claim; compare against the reader's walk.
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.
out-of-bounds skip (
size > remaining) as "truncated tail — stop iteratingand 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 sameway, not abort the run.