Skip to content

Commit 126f02b

Browse files
wan9chiclaude
andcommitted
fix(fspy): unify shared memory on a sparse temp file across all platforms
Codex CLI's and Claude Code's default sandboxes deny the primitives fspy's Unix shared memory was built on: the macOS Seatbelt profile denies `shm_open` (`ipc-posix-shm-write-create`), and both sandboxes block Unix-domain sockets, which the Linux memfd broker depended on. Plain files in the temp directory are writable under both. Replace all three backends with one file-backed implementation attached by path: a sparse temporary file plus `memmap2`, with the file's absolute path as the identifier. No broker, no tokio requirement, no global object names. Lifetime semantics converge too: dropping the owner makes the name disappear and later opens fail, while existing views stay usable — now on Windows as well, where closing the delete-on-close handle applies the delete disposition even while other processes hold views. Refs #563. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7b44c42 commit 126f02b

18 files changed

Lines changed: 566 additions & 1208 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Changelog
22

3+
- **Fixed** Automatic file-access tracking now works inside coding-agent sandboxes, including the default Codex CLI and Claude Code sandboxes ([#563](https://github.com/voidzero-dev/vite-task/issues/563), [#576](https://github.com/voidzero-dev/vite-task/pull/576)).
34
- **Added** Tasks now run with `VP_RUN=1` set, so tools can tell they are running under `vp run` instead of being invoked directly ([#570](https://github.com/voidzero-dev/vite-task/pull/570)).
45
- **Fixed** The task cache now supports much larger automatically tracked input sets without hitting wincode's default 4 MiB sequence preallocation limit ([#554](https://github.com/voidzero-dev/vite-task/pull/554)).
56
- **Fixed** npm workspace patterns beginning with `./` now discover matching packages correctly ([vite-plus#2201](https://github.com/voidzero-dev/vite-plus/issues/2201), [#547](https://github.com/voidzero-dev/vite-task/pull/547)).

Cargo.lock

Lines changed: 0 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ jsonc-parser = { version = "0.32.0", features = ["serde"] }
8989
libc = "0.2.185"
9090
libtest-mimic = "0.8.2"
9191
memmap2 = "0.9.11"
92-
memfd = "0.6.5"
9392
monostate = "1.0.2"
9493
napi = "3"
9594
napi-build = "2"

crates/fspy_shared/src/ipc/channel/mod.rs

Lines changed: 24 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -2,55 +2,22 @@
22
33
mod shm_io;
44

5-
use std::{env::temp_dir, fs::File, io, mem::MaybeUninit, ops::Deref, path::PathBuf, sync::Arc};
5+
use std::{env::temp_dir, fs::File, io, ops::Deref, path::PathBuf};
66

7-
use fspy_shm::Shm;
7+
use fspy_shm::{Mapping, ShmKeeper};
88
pub use shm_io::FrameMut;
99
use shm_io::{ShmReader, ShmWriter};
1010
use tracing::debug;
1111
use uuid::Uuid;
12-
use wincode::{
13-
SchemaRead, SchemaWrite,
14-
config::Config,
15-
error::{ReadResult, WriteResult},
16-
io::{Reader, Writer},
17-
};
12+
use wincode::{SchemaRead, SchemaWrite};
1813

1914
use super::NativeStr;
2015

21-
/// wincode schema adapter for `Arc<str>`, which is a foreign type with unsized inner.
22-
pub(crate) struct ArcStrSchema;
23-
24-
// SAFETY: Delegates to `str`'s SchemaWrite impl, preserving its size/write invariants.
25-
unsafe impl<C: Config> SchemaWrite<C> for ArcStrSchema {
26-
type Src = Arc<str>;
27-
28-
fn size_of(src: &Self::Src) -> WriteResult<usize> {
29-
<str as SchemaWrite<C>>::size_of(src)
30-
}
31-
32-
fn write(writer: impl Writer, src: &Self::Src) -> WriteResult<()> {
33-
<str as SchemaWrite<C>>::write(writer, src)
34-
}
35-
}
36-
37-
// SAFETY: Delegates to `&str`'s SchemaRead impl; dst is initialized on Ok.
38-
unsafe impl<'de, C: Config> SchemaRead<'de, C> for ArcStrSchema {
39-
type Dst = Arc<str>;
40-
41-
fn read(mut reader: impl Reader<'de>, dst: &mut MaybeUninit<Self::Dst>) -> ReadResult<()> {
42-
let s: &str = <&str as SchemaRead<'de, C>>::get(&mut reader)?;
43-
dst.write(Arc::from(s));
44-
Ok(())
45-
}
46-
}
47-
4816
/// Serializable configuration to create channel senders.
4917
#[derive(SchemaWrite, SchemaRead, Clone, Debug)]
5018
pub struct ChannelConf {
5119
lock_file_path: Box<NativeStr>,
52-
#[wincode(with = "ArcStrSchema")]
53-
shm_id: Arc<str>,
20+
shm_id: Box<NativeStr>,
5421
}
5522

5623
/// Creates a mpsc IPC channel with one receiver and a `ChannelConf` that can be passed around processes and used to create multiple senders
@@ -62,12 +29,15 @@ pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> {
6229
// Initialize the lock file with a unique name.
6330
let lock_file_path = temp_dir().join(format!("fspy_ipc_{}.lock", Uuid::new_v4()));
6431

65-
let shm = fspy_shm::create(capacity)?;
32+
let (keeper, handle) = fspy_shm::create(capacity)?;
33+
let mapping = handle.map()?;
6634

67-
let conf =
68-
ChannelConf { lock_file_path: lock_file_path.as_os_str().into(), shm_id: shm.id().into() };
35+
let conf = ChannelConf {
36+
lock_file_path: lock_file_path.as_os_str().into(),
37+
shm_id: keeper.id().into(),
38+
};
6939

70-
let receiver = Receiver::new(lock_file_path, shm)?;
40+
let receiver = Receiver::new(lock_file_path, keeper, mapping)?;
7141
Ok((conf, receiver))
7242
}
7343

@@ -83,16 +53,17 @@ impl ChannelConf {
8353
let lock_file = File::open(self.lock_file_path.to_cow_os_str())?;
8454
lock_file.try_lock_shared()?;
8555

86-
let shm = fspy_shm::open(&self.shm_id)?;
87-
// SAFETY: `shm` is a freshly opened shared memory region with valid pointer and size.
88-
// Exclusive write access is ensured by the shared file lock held by this sender.
89-
let writer = unsafe { ShmWriter::new(shm) };
56+
let mapping = fspy_shm::open(&self.shm_id.to_cow_os_str())?.map()?;
57+
// SAFETY: `mapping` is a freshly mapped shared memory region with valid
58+
// pointer and size. Exclusive write access is ensured by the shared
59+
// file lock held by this sender.
60+
let writer = unsafe { ShmWriter::new(mapping) };
9061
Ok(Sender { writer, lock_file, lock_file_path: self.lock_file_path.clone() })
9162
}
9263
}
9364

9465
pub struct Sender {
95-
writer: ShmWriter<Shm>,
66+
writer: ShmWriter<Mapping>,
9667
lock_file_path: Box<NativeStr>,
9768
lock_file: File,
9869
}
@@ -106,20 +77,13 @@ impl Drop for Sender {
10677
}
10778

10879
impl Deref for Sender {
109-
type Target = ShmWriter<Shm>;
80+
type Target = ShmWriter<Mapping>;
11081

11182
fn deref(&self) -> &Self::Target {
11283
&self.writer
11384
}
11485
}
11586

116-
#[cfg_attr(
117-
target_os = "windows",
118-
expect(
119-
clippy::non_send_fields_in_send_ty,
120-
reason = "`Sender` holds a shared file lock that ensures there's no reader, so `shm` can be safely written to"
121-
)
122-
)]
12387
/// SAFETY: `Sender` holds a shared file lock that ensures there's no reader, so `shm` can be safely written to.
12488
unsafe impl Send for Sender {}
12589

@@ -131,16 +95,11 @@ unsafe impl Sync for Sender {}
13195
pub struct Receiver {
13296
lock_file_path: PathBuf,
13397
lock_file: File,
134-
shm: Shm,
98+
/// Keeps the backing file's name alive for as long as senders may attach.
99+
_keeper: ShmKeeper,
100+
mapping: Mapping,
135101
}
136102

137-
#[cfg_attr(
138-
target_os = "windows",
139-
expect(
140-
clippy::non_send_fields_in_send_ty,
141-
reason = "Receiver doesn't read or write `shm`. It only passes it to `ReceiverLockGuard` under the lock"
142-
)
143-
)]
144103
/// SAFETY: `Receiver` doesn't read or write `shm`. It only passes it to `ReceiverLockGuard` under the lock.
145104
unsafe impl Send for Receiver {}
146105

@@ -156,9 +115,9 @@ impl Drop for Receiver {
156115
}
157116

158117
impl Receiver {
159-
fn new(lock_file_path: PathBuf, shm: Shm) -> io::Result<Self> {
118+
fn new(lock_file_path: PathBuf, keeper: ShmKeeper, mapping: Mapping) -> io::Result<Self> {
160119
let lock_file = File::create(&lock_file_path)?;
161-
Ok(Self { lock_file_path, lock_file, shm })
120+
Ok(Self { lock_file_path, lock_file, _keeper: keeper, mapping })
162121
}
163122

164123
/// Lock the shared memory for unique read access.
@@ -172,7 +131,7 @@ impl Receiver {
172131
self.lock_file.lock()?;
173132
// SAFETY: The exclusive file lock is held, so no writers can access the shared memory.
174133
// The lock ensures all prior writes are visible to this thread.
175-
let reader = ShmReader::new(unsafe { self.shm.as_slice() });
134+
let reader = ShmReader::new(unsafe { self.mapping.as_slice() });
176135
Ok(ReceiverLockGuard { reader, lock_file: &self.lock_file })
177136
}
178137
}

crates/fspy_shared/src/ipc/channel/shm_io.rs

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use std::{
99
};
1010

1111
use bytemuck::must_cast;
12-
use fspy_shm::Shm;
12+
use fspy_shm::Mapping;
1313
use wincode::{SchemaWrite, Serialize as _, config::DefaultConfig};
1414

1515
// `ShmWriter` writes headers using atomic operations to prevent partial writes due to crashes,
@@ -28,7 +28,7 @@ pub trait AsRawSlice {
2828
fn as_raw_slice(&self) -> *mut [u8];
2929
}
3030

31-
impl AsRawSlice for Shm {
31+
impl AsRawSlice for Mapping {
3232
fn as_raw_slice(&self) -> *mut [u8] {
3333
slice_from_raw_parts_mut(self.as_ptr(), self.len())
3434
}
@@ -672,31 +672,20 @@ mod tests {
672672

673673
const SHM_SIZE: usize = 1024 * 1024;
674674

675-
// On Linux, `fspy_shm::create` spawns the mapping's broker onto the
676-
// ambient tokio runtime, which serves the child processes' opens.
677-
#[cfg(target_os = "linux")]
678-
let runtime = tokio::runtime::Builder::new_multi_thread()
679-
.worker_threads(1)
680-
.enable_io()
681-
.enable_time()
682-
.build()
683-
.unwrap();
684-
#[cfg(target_os = "linux")]
685-
let _guard = runtime.enter();
686-
687-
let shm = fspy_shm::create(SHM_SIZE).unwrap();
688-
let shm_name = shm.id().to_owned();
675+
let (keeper, handle) = fspy_shm::create(SHM_SIZE).unwrap();
676+
let shm_name = keeper.id().to_str().expect("test temp dir is UTF-8").to_owned();
689677

690678
let children: Vec<Child> = (0..CHILD_COUNT)
691679
.map(|child_index| {
692680
let cmd = command_for_fn!(
693681
(shm_name.clone(), child_index),
694682
|(shm_name, child_index): (String, usize)| {
695-
let shm = fspy_shm::open(&shm_name).unwrap();
696-
// SAFETY: `shm` is a freshly opened shared memory region with a valid
697-
// pointer and size. Concurrent write access is safe because `ShmWriter`
698-
// uses atomic operations.
699-
let writer = unsafe { ShmWriter::new(shm) };
683+
let mapping =
684+
fspy_shm::open(std::ffi::OsStr::new(&shm_name)).unwrap().map().unwrap();
685+
// SAFETY: `mapping` is a freshly mapped shared memory region with a
686+
// valid pointer and size. Concurrent write access is safe because
687+
// `ShmWriter` uses atomic operations.
688+
let writer = unsafe { ShmWriter::new(mapping) };
700689
for i in 0..FRAME_COUNT_EACH_CHILD {
701690
let frame_data = std::format!("{child_index} {i}");
702691
assert!(writer.try_write_frame(frame_data.as_bytes()));
@@ -712,9 +701,10 @@ mod tests {
712701
assert!(status.success());
713702
}
714703

704+
let mapping = handle.map().unwrap();
715705
// SAFETY: All child processes have exited (waited above), so no concurrent writers exist.
716706
// The shared memory is valid and fully written.
717-
let shm = unsafe { shm.as_slice() };
707+
let shm = unsafe { mapping.as_slice() };
718708
let reader = ShmReader::new(shm);
719709
let frames = reader.iter_frames().map(BStr::new).collect::<FxHashSet<&BStr>>();
720710
assert_eq!(frames.len(), CHILD_COUNT * FRAME_COUNT_EACH_CHILD);

crates/fspy_shm/Cargo.toml

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,37 +7,21 @@ license.workspace = true
77
publish = false
88
rust-version.workspace = true
99

10-
[target.'cfg(target_os = "linux")'.dependencies]
10+
[dependencies]
1111
memmap2 = { workspace = true }
12-
memfd = { workspace = true }
13-
passfd = { workspace = true, features = ["async"] }
14-
nix = { workspace = true, features = ["fs", "socket", "user"] }
15-
tokio = { workspace = true, features = ["macros", "net", "rt", "time"] }
16-
tokio-util = { workspace = true }
17-
tracing = { workspace = true }
18-
uuid = { workspace = true, features = ["v4"] }
19-
20-
[target.'cfg(target_os = "macos")'.dependencies]
21-
base64 = { workspace = true }
22-
memmap2 = { workspace = true }
23-
nix = { workspace = true, features = ["fs", "mman"] }
2412
uuid = { workspace = true, features = ["v4"] }
2513

2614
[target.'cfg(target_os = "windows")'.dependencies]
27-
uuid = { workspace = true, features = ["v4"] }
2815
windows-sys = { workspace = true, features = [
2916
"Win32_Foundation",
30-
"Win32_Security",
3117
"Win32_Storage_FileSystem",
3218
"Win32_System_IO",
3319
"Win32_System_Ioctl",
34-
"Win32_System_Memory",
3520
] }
3621

3722
[dev-dependencies]
3823
ctor = { workspace = true }
3924
subprocess_test = { workspace = true }
40-
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
4125

4226
[lints]
4327
workspace = true

0 commit comments

Comments
 (0)