Skip to content

Commit ee18bff

Browse files
wan9chiclaude
andcommitted
fix(fspy): use memfd for Linux shared memory
Replace the Linux /dev/shm-backed adapter with a sealed memfd mapping. The memfd is handed out to injected senders by a broker task that is an internal implementation detail of the owner mapping: create() spawns it onto the ambient tokio runtime, and dropping the owner stops it via a cancellation drop-guard, with a per-request cancellation check making post-drop opens fail deterministically. Client requests carry send/recv timeouts so an unavailable broker fails senders instead of stalling them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 23ada60 commit ee18bff

11 files changed

Lines changed: 794 additions & 102 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
- **Fixed** Windows automatic input tracking now records executable image reads when process creation performs the lookup inside `NtCreateUserProcess` ([#518](https://github.com/voidzero-dev/vite-task/pull/518)).
44
- **Fixed** Failures while waiting for a started task process to exit no longer incorrectly say the process failed to spawn ([#515](https://github.com/voidzero-dev/vite-task/pull/515)).
55
- **Fixed** Missing env vars requested through `@voidzero-dev/vite-task-client` now return `undefined` instead of `null`, preserving Vite production `NODE_ENV` semantics when builds run through `vp run` ([#508](https://github.com/voidzero-dev/vite-task/pull/508)).
6+
- **Fixed** Linux file-access tracking no longer consumes the `/dev/shm` mount used by containers and Kubernetes runners ([#353](https://github.com/voidzero-dev/vite-task/issues/353)).
67
- **Fixed** Windows builds no longer hang on CI when a `node_modules/.bin` `.cmd` shim is routed through PowerShell: the npm/pnpm/yarn `.ps1` wrappers read stdin and block forever on a non-TTY pipe, so the PowerShell rewrite is now skipped when stdin is not an interactive terminal, falling back to the `.cmd` (which never reads stdin) ([#491](https://github.com/voidzero-dev/vite-task/pull/491)).
78
- **Added** First-party support for caching `vite build` with zero cache config, giving Vite projects correct cache hits out of the box ([vitejs/vite#22453](https://github.com/vitejs/vite/pull/22453)).
89
- **Added** Support for specifying tasks from dependency packages in `dependsOn`, such as `dependsOn: [{ "task": "build", "from": "dependencies" }]` ([#479](https://github.com/voidzero-dev/vite-task/pull/479)).

Cargo.lock

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

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ ref-cast = "1.0.24"
116116
regex = "1.11.3"
117117
rusqlite = "0.39.0"
118118
rustc-hash = "2.1.1"
119+
rustix = "1.1"
119120
# SeccompAction::UserNotif (SECCOMP_RET_USER_NOTIF) was added after the latest published release (v0.5.0)
120121
seccompiler = { git = "https://github.com/rust-vmm/seccompiler", rev = "08587106340b8e3cb361c7561411510039436857" }
121122
serde = "1.0.219"

crates/fspy_shared/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ ctor = { workspace = true }
2828
rustc-hash = { workspace = true }
2929
subprocess_test = { workspace = true }
3030

31+
[target.'cfg(target_os = "linux")'.dev-dependencies]
32+
tokio = { workspace = true, features = ["net", "rt-multi-thread", "time"] }
33+
3134
[lints]
3235
workspace = true
3336

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

Lines changed: 86 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,12 @@ impl Deref for Sender {
117117
}
118118
}
119119

120-
#[expect(
121-
clippy::non_send_fields_in_send_ty,
122-
reason = "`Sender` holds a shared file lock that ensures there's no reader, so `shm` can be safely written to"
120+
#[cfg_attr(
121+
not(target_os = "linux"),
122+
expect(
123+
clippy::non_send_fields_in_send_ty,
124+
reason = "`Sender` holds a shared file lock that ensures there's no reader, so `shm` can be safely written to"
125+
)
123126
)]
124127
/// SAFETY: `Sender` holds a shared file lock that ensures there's no reader, so `shm` can be safely written to.
125128
unsafe impl Send for Sender {}
@@ -135,9 +138,12 @@ pub struct Receiver {
135138
shm: Shm,
136139
}
137140

138-
#[expect(
139-
clippy::non_send_fields_in_send_ty,
140-
reason = "Receiver doesn't read or write `shm`. It only pass it to `ReceiverLockGuard` under the lock"
141+
#[cfg_attr(
142+
not(target_os = "linux"),
143+
expect(
144+
clippy::non_send_fields_in_send_ty,
145+
reason = "Receiver doesn't read or write `shm`. It only passes it to `ReceiverLockGuard` under the lock"
146+
)
141147
)]
142148
/// SAFETY: `Receiver` doesn't read or write `shm`. It only passes it to `ReceiverLockGuard` under the lock.
143149
unsafe impl Send for Receiver {}
@@ -204,78 +210,102 @@ mod tests {
204210

205211
use super::*;
206212

213+
/// Runs `test` with an ambient tokio runtime on Linux, where `channel`
214+
/// spawns the shared-memory broker onto it. The test body runs on the
215+
/// test thread, so it may block without starving the broker.
216+
fn in_service_runtime<T>(test: impl FnOnce() -> T) -> T {
217+
#[cfg(target_os = "linux")]
218+
let runtime = tokio::runtime::Builder::new_multi_thread()
219+
.worker_threads(1)
220+
.enable_io()
221+
.enable_time()
222+
.build()
223+
.unwrap();
224+
#[cfg(target_os = "linux")]
225+
let _guard = runtime.enter();
226+
test()
227+
}
228+
207229
#[test]
208230
fn smoke() {
209-
let (conf, receiver) = channel(100).unwrap();
210-
let cmd = command_for_fn!(conf, |conf: ChannelConf| {
211-
let sender = conf.sender().unwrap();
212-
let frame_size = NonZeroUsize::new(2).unwrap();
213-
let mut frame = sender.claim_frame(frame_size).unwrap();
214-
frame.copy_from_slice(&[4, 2]);
215-
});
216-
assert!(std::process::Command::from(cmd).status().unwrap().success());
231+
in_service_runtime(|| {
232+
let (conf, receiver) = channel(100).unwrap();
233+
let cmd = command_for_fn!(conf, |conf: ChannelConf| {
234+
let sender = conf.sender().unwrap();
235+
let frame_size = NonZeroUsize::new(2).unwrap();
236+
let mut frame = sender.claim_frame(frame_size).unwrap();
237+
frame.copy_from_slice(&[4, 2]);
238+
});
239+
assert!(std::process::Command::from(cmd).status().unwrap().success());
217240

218-
let lock = receiver.lock().unwrap();
219-
let mut frames = lock.iter_frames();
241+
let lock = receiver.lock().unwrap();
242+
let mut frames = lock.iter_frames();
220243

221-
let received_frame = frames.next().unwrap();
222-
assert_eq!(received_frame, &[4, 2]);
244+
let received_frame = frames.next().unwrap();
245+
assert_eq!(received_frame, &[4, 2]);
223246

224-
assert!(frames.next().is_none());
247+
assert!(frames.next().is_none());
248+
});
225249
}
226250

227251
#[test]
228252
#[expect(clippy::print_stdout, reason = "test diagnostics")]
229253
fn forbid_new_senders_after_locked() {
230-
let (conf, receiver) = channel(42).unwrap();
231-
let _lock = receiver.lock().unwrap();
254+
in_service_runtime(|| {
255+
let (conf, receiver) = channel(42).unwrap();
256+
let _lock = receiver.lock().unwrap();
232257

233-
let cmd = command_for_fn!(conf, |conf: ChannelConf| {
234-
print!("{}", conf.sender().is_ok());
258+
let cmd = command_for_fn!(conf, |conf: ChannelConf| {
259+
print!("{}", conf.sender().is_ok());
260+
});
261+
let output = std::process::Command::from(cmd).output().unwrap();
262+
assert_eq!(B(&output.stdout), B("false"));
235263
});
236-
let output = std::process::Command::from(cmd).output().unwrap();
237-
assert_eq!(B(&output.stdout), B("false"));
238264
}
239265

240266
#[test]
241267
#[expect(clippy::print_stdout, reason = "test diagnostics")]
242268
fn forbid_new_senders_after_receiver_dropped() {
243-
let (conf, receiver) = channel(42).unwrap();
244-
drop(receiver);
269+
in_service_runtime(|| {
270+
let (conf, receiver) = channel(42).unwrap();
271+
drop(receiver);
245272

246-
let cmd = command_for_fn!(conf, |conf: ChannelConf| {
247-
print!("{}", conf.sender().is_ok());
273+
let cmd = command_for_fn!(conf, |conf: ChannelConf| {
274+
print!("{}", conf.sender().is_ok());
275+
});
276+
let output = std::process::Command::from(cmd).output().unwrap();
277+
assert_eq!(B(&output.stdout), B("false"));
248278
});
249-
let output = std::process::Command::from(cmd).output().unwrap();
250-
assert_eq!(B(&output.stdout), B("false"));
251279
}
252280

253281
#[test]
254282
fn concurrent_senders() {
255-
let (conf, receiver) = channel(8192).unwrap();
256-
for i in 0u16..200 {
257-
let cmd = command_for_fn!((conf.clone(), i), |(conf, i): (ChannelConf, u16)| {
258-
let sender = conf.sender().unwrap();
259-
let data_to_send = i.to_string();
260-
sender
261-
.claim_frame(NonZeroUsize::new(data_to_send.len()).unwrap())
262-
.unwrap()
263-
.copy_from_slice(data_to_send.as_bytes());
264-
});
265-
let output = std::process::Command::from(cmd).output().unwrap();
266-
assert!(
267-
output.status.success(),
268-
"Failed to send in iteration {}: {:?}",
269-
i,
270-
B(&output.stderr)
271-
);
272-
}
273-
let lock = receiver.lock().unwrap();
274-
let mut received_values: Vec<u16> = lock
275-
.iter_frames()
276-
.map(|frame| from_utf8(frame).unwrap().parse::<u16>().unwrap())
277-
.collect();
278-
received_values.sort_unstable();
279-
assert_eq!(received_values, (0u16..200).collect::<Vec<u16>>());
283+
in_service_runtime(|| {
284+
let (conf, receiver) = channel(8192).unwrap();
285+
for i in 0u16..200 {
286+
let cmd = command_for_fn!((conf.clone(), i), |(conf, i): (ChannelConf, u16)| {
287+
let sender = conf.sender().unwrap();
288+
let data_to_send = i.to_string();
289+
sender
290+
.claim_frame(NonZeroUsize::new(data_to_send.len()).unwrap())
291+
.unwrap()
292+
.copy_from_slice(data_to_send.as_bytes());
293+
});
294+
let output = std::process::Command::from(cmd).output().unwrap();
295+
assert!(
296+
output.status.success(),
297+
"Failed to send in iteration {}: {:?}",
298+
i,
299+
B(&output.stderr)
300+
);
301+
}
302+
let lock = receiver.lock().unwrap();
303+
let mut received_values: Vec<u16> = lock
304+
.iter_frames()
305+
.map(|frame| from_utf8(frame).unwrap().parse::<u16>().unwrap())
306+
.collect();
307+
received_values.sort_unstable();
308+
assert_eq!(received_values, (0u16..200).collect::<Vec<u16>>());
309+
});
280310
}
281311
}

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,18 @@ 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+
675687
let shm = fspy_shm::create(SHM_SIZE).unwrap();
676688
let shm_name = shm.id().to_owned();
677689

crates/fspy_shm/Cargo.toml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,25 @@ license.workspace = true
77
publish = false
88
rust-version.workspace = true
99

10-
[dependencies]
10+
[target.'cfg(not(target_os = "linux"))'.dependencies]
1111
shared_memory = { workspace = true, features = ["logging"] }
1212

13+
[target.'cfg(target_os = "linux")'.dependencies]
14+
base64 = { workspace = true }
15+
memmap2 = { workspace = true }
16+
rustix = { workspace = true, features = ["fs", "net"] }
17+
tokio = { workspace = true, features = ["macros", "net", "rt", "time"] }
18+
tokio-util = { workspace = true }
19+
tracing = { workspace = true }
20+
uuid = { workspace = true, features = ["v4"] }
21+
1322
[dev-dependencies]
1423
ctor = { workspace = true }
1524
subprocess_test = { workspace = true }
1625

26+
[target.'cfg(target_os = "linux")'.dev-dependencies]
27+
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
28+
1729
[lints]
1830
workspace = true
1931

0 commit comments

Comments
 (0)