Skip to content

Commit 7047470

Browse files
wan9chiclaude
andcommitted
perf(fspy): use sparse Windows shared memory
Co-authored-by: GPT-5.6 <gpt-5.6@openai.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 079c1af commit 7047470

5 files changed

Lines changed: 180 additions & 98 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
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)).
66
- **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)).
7+
- **Improved** Windows file-access tracking now uses sparse temporary backing files where supported, avoiding an upfront 4 GiB disk allocation per task.
78
- **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)).
89
- **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)).
910
- **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)).

crates/fspy_shm/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ windows-sys = { workspace = true, features = [
2626
"Win32_Foundation",
2727
"Win32_Security",
2828
"Win32_Storage_FileSystem",
29+
"Win32_System_IO",
30+
"Win32_System_Ioctl",
2931
"Win32_System_Memory",
3032
] }
3133

crates/fspy_shm/src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,10 @@ mod tests {
195195
write_byte(&owner, 0, 17);
196196
drop(owner);
197197

198-
// Windows keeps the named object alive while an opened view exists.
198+
// The Linux broker and POSIX shm_unlink reject post-drop opens
199+
// immediately. On Windows, an existing mapped view keeps the named
200+
// kernel object alive; fspy's lock file, rather than this
201+
// low-level mapping API, rejects late senders.
199202
#[cfg(not(target_os = "windows"))]
200203
assert!(open(&id, SIZE).is_err());
201204
assert_eq!(read_byte(&opened, 0), 17);

crates/fspy_shm/src/windows/mod.rs

Lines changed: 103 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,20 @@ const BACKING_DIR: &str = "vite-task-fspy";
2525
/// An owned Windows shared-memory mapping.
2626
pub struct Shm {
2727
id: String,
28+
// Field order is significant: unmap the view, close the mapping, then close
29+
// the delete-on-close backing file.
2830
view: MappedView,
29-
_mapping: OwnedHandle,
30-
backing_file: BackingFile,
31+
_mapping: Option<OwnedHandle>,
32+
// Owner mappings keep this file alive until their view and mapping handle drop.
33+
#[cfg_attr(
34+
not(test),
35+
expect(dead_code, reason = "the file is retained only for RAII cleanup")
36+
)]
37+
backing_file: Option<File>,
3138
}
3239

33-
/// Creates a file-backed named mapping of `size` bytes and returns its owner.
40+
/// Creates a sparse, temporary file-backed named mapping of `size` bytes and
41+
/// returns its owner.
3442
///
3543
/// # Errors
3644
///
@@ -54,14 +62,14 @@ fn create_with(size: usize, mut next_name: impl FnMut() -> String) -> io::Result
5462
fn create_named(backing_dir: &Path, mapping_name: &str, size: u64) -> io::Result<Option<Shm>> {
5563
let path = backing_path(backing_dir, mapping_name)?;
5664
let id = encode_id(&path, mapping_name)?;
57-
let backing_file = match BackingFile::create(path) {
65+
let backing_file = match create_backing_file(&path) {
5866
Ok(file) => file,
5967
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => return Ok(None),
6068
Err(error) => return Err(error),
6169
};
62-
backing_file.file.set_len(size)?;
70+
initialize_backing_file(&backing_file, size)?;
6371

64-
let mapping = match sys::create_file_mapping(&backing_file.file, mapping_name)? {
72+
let mapping = match sys::create_file_mapping(&backing_file, mapping_name)? {
6573
CreatedMapping::Created(mapping) => mapping,
6674
CreatedMapping::AlreadyExists => return Ok(None),
6775
};
@@ -70,29 +78,23 @@ fn create_named(backing_dir: &Path, mapping_name: &str, size: u64) -> io::Result
7078
})?;
7179
let view = MappedView::new(&mapping, len)?;
7280

73-
Ok(Some(Shm { id, view, _mapping: mapping, backing_file }))
81+
Ok(Some(Shm { id, view, _mapping: Some(mapping), backing_file: Some(backing_file) }))
7482
}
7583

7684
/// Opens the named mapping identified by `id`.
7785
///
7886
/// # Errors
7987
///
80-
/// Returns an error if the identifier is invalid, the owner has torn down the
81-
/// mapping, or its backing file has a different size.
88+
/// Returns an error if the identifier is invalid, the mapping is unavailable,
89+
/// or `size` cannot be mapped from it.
8290
pub fn open(id: &str, size: usize) -> io::Result<Shm> {
83-
let expected_size = valid_size(size)?;
84-
let DecodedId { backing_path, mapping_name } = decode_id(id)?;
85-
let backing_file = BackingFile::open(backing_path)?;
86-
if backing_file.file.metadata()?.len() != expected_size {
87-
return Err(io::Error::new(
88-
io::ErrorKind::InvalidData,
89-
"shared-memory backing file has an unexpected size",
90-
));
91-
}
91+
valid_size(size)?;
92+
let DecodedId { mapping_name, .. } = decode_id(id)?;
9293

9394
let mapping = sys::open_file_mapping(&mapping_name)?;
9495
let view = MappedView::new(&mapping, size)?;
95-
Ok(Shm { id: id.to_owned(), view, _mapping: mapping, backing_file })
96+
drop(mapping);
97+
Ok(Shm { id: id.to_owned(), view, _mapping: None, backing_file: None })
9698
}
9799

98100
fn valid_size(size: usize) -> io::Result<u64> {
@@ -138,6 +140,7 @@ fn encode_id(backing_path: &Path, mapping_name: &str) -> io::Result<String> {
138140
}
139141

140142
struct DecodedId {
143+
#[cfg(test)]
141144
backing_path: PathBuf,
142145
mapping_name: String,
143146
}
@@ -166,7 +169,11 @@ fn decode_id(id: &str) -> io::Result<DecodedId> {
166169
let mapping_name =
167170
String::from_utf8(decode_id_part(encoded_mapping_name)?).map_err(|_| invalid_id())?;
168171
validate_id_parts(&backing_path, &mapping_name)?;
169-
Ok(DecodedId { backing_path, mapping_name })
172+
Ok(DecodedId {
173+
#[cfg(test)]
174+
backing_path,
175+
mapping_name,
176+
})
170177
}
171178

172179
fn decode_id_part(encoded: &str) -> io::Result<Vec<u8>> {
@@ -191,71 +198,32 @@ fn invalid_id() -> io::Error {
191198
io::Error::new(io::ErrorKind::InvalidInput, "invalid Windows shared-memory identifier")
192199
}
193200

194-
struct BackingFile {
195-
file: File,
196-
owner_path: Option<PathBuf>,
201+
fn create_backing_file(path: &Path) -> io::Result<File> {
202+
OpenOptions::new()
203+
.read(true)
204+
.write(true)
205+
.create_new(true)
206+
.share_mode(sys::SHARE_ALL)
207+
.attributes(sys::TEMPORARY)
208+
.custom_flags(sys::DELETE_ON_CLOSE)
209+
.open(path)
197210
}
198211

199-
impl BackingFile {
200-
fn create(path: PathBuf) -> io::Result<Self> {
201-
let file = OpenOptions::new()
202-
.read(true)
203-
.write(true)
204-
.create_new(true)
205-
.share_mode(sys::SHARE_ALL)
206-
.attributes(sys::TEMPORARY)
207-
.open(&path)?;
208-
Ok(Self { file, owner_path: Some(path) })
209-
}
210-
211-
fn open(path: PathBuf) -> io::Result<Self> {
212-
let file = OpenOptions::new()
213-
.read(true)
214-
.write(true)
215-
.share_mode(sys::SHARE_ALL)
216-
.attributes(sys::TEMPORARY)
217-
.open(path)?;
218-
Ok(Self { file, owner_path: None })
219-
}
220-
221-
fn unlink(&mut self) {
222-
let Some(path) = self.owner_path.take() else {
223-
return;
224-
};
225-
226-
let deletion_file = OpenOptions::new()
227-
.access_mode(sys::DELETE_ACCESS)
228-
.share_mode(sys::SHARE_ALL)
229-
.attributes(sys::DELETE_ON_CLOSE)
230-
.open(&path);
231-
if let Ok(deletion_file) = deletion_file {
232-
let deleted_path = deleted_path(&path);
233-
if fs::rename(&path, deleted_path).is_err() {
234-
let _ = fs::remove_file(&path);
235-
}
236-
drop(deletion_file);
237-
} else {
238-
let _ = fs::remove_file(path);
239-
}
240-
}
241-
}
242-
243-
impl Drop for BackingFile {
244-
fn drop(&mut self) {
245-
self.unlink();
246-
}
212+
fn initialize_backing_file(file: &File, size: u64) -> io::Result<()> {
213+
initialize_backing_file_with(file, size, sys::set_sparse)
247214
}
248215

249-
fn deleted_path(path: &Path) -> PathBuf {
250-
path.with_extension(format!("deleted-{}", Uuid::new_v4()))
251-
}
252-
253-
impl Drop for Shm {
254-
fn drop(&mut self) {
255-
// Remove the public backing-file name before the mapping handle drops,
256-
// preventing opens that begin after owner teardown.
257-
self.backing_file.unlink();
216+
fn initialize_backing_file_with(
217+
file: &File,
218+
size: u64,
219+
set_sparse: impl FnOnce(&File) -> io::Result<()>,
220+
) -> io::Result<()> {
221+
if let Err(error) = set_sparse(file)
222+
&& !sys::is_sparse_unsupported(&error)
223+
{
224+
return Err(error);
258225
}
226+
file.set_len(size)
259227
}
260228

261229
#[expect(clippy::len_without_is_empty, reason = "shared-memory mappings are always non-empty")]
@@ -297,6 +265,9 @@ mod tests {
297265
use std::{ffi::OsString, fs, process::Command};
298266

299267
use subprocess_test::command_for_fn;
268+
use windows_sys::Win32::Foundation::{
269+
ERROR_ACCESS_DENIED, ERROR_INVALID_FUNCTION, ERROR_INVALID_PARAMETER, ERROR_NOT_SUPPORTED,
270+
};
300271

301272
use super::*;
302273

@@ -332,7 +303,7 @@ mod tests {
332303
}
333304

334305
#[test]
335-
fn malformed_ids_and_size_mismatches_are_rejected() {
306+
fn malformed_ids_and_invalid_sizes_are_rejected() {
336307
let owner = create(SIZE).unwrap();
337308
let decoded = decode_id(owner.id()).unwrap();
338309
let encoded_path = URL_SAFE_NO_PAD.encode(
@@ -378,8 +349,7 @@ mod tests {
378349
}
379350

380351
assert_eq!(open(owner.id(), 0).err().unwrap().kind(), io::ErrorKind::InvalidInput);
381-
assert_eq!(open(owner.id(), SIZE / 2).err().unwrap().kind(), io::ErrorKind::InvalidData);
382-
assert_eq!(open(owner.id(), SIZE + 1).err().unwrap().kind(), io::ErrorKind::InvalidData);
352+
assert!(open(owner.id(), SIZE + 1).is_err());
383353
}
384354

385355
#[test]
@@ -388,10 +358,10 @@ mod tests {
388358
let collision_name = format!("{MAPPING_NAME_PREFIX}{}", Uuid::new_v4());
389359
let raw_backing_name = format!("{MAPPING_NAME_PREFIX}{}", Uuid::new_v4());
390360
let raw_backing_path = backing_path(&backing_dir, &raw_backing_name).unwrap();
391-
let raw_backing = BackingFile::create(raw_backing_path).unwrap();
392-
raw_backing.file.set_len(SIZE as u64).unwrap();
361+
let raw_backing = create_backing_file(&raw_backing_path).unwrap();
362+
initialize_backing_file(&raw_backing, SIZE as u64).unwrap();
393363
let collision_mapping =
394-
match sys::create_file_mapping(&raw_backing.file, &collision_name).unwrap() {
364+
match sys::create_file_mapping(&raw_backing, &collision_name).unwrap() {
395365
CreatedMapping::Created(mapping) => mapping,
396366
CreatedMapping::AlreadyExists => panic!("random test mapping name collided"),
397367
};
@@ -443,7 +413,7 @@ mod tests {
443413
}
444414

445415
#[test]
446-
fn owner_cleanup_removes_backing_file_after_existing_views_close() {
416+
fn owner_cleanup_deletes_backing_file_and_preserves_existing_views() {
447417
let owner = create(SIZE).unwrap();
448418
let id = owner.id().to_owned();
449419
let DecodedId { backing_path: path, mapping_name } = decode_id(&id).unwrap();
@@ -455,12 +425,12 @@ mod tests {
455425
drop(owner);
456426

457427
assert!(!path.exists());
458-
assert!(open(&id, SIZE).is_err());
459428
// SAFETY: The mapping remains live and no other test access is concurrent.
460429
unsafe { opened.as_ptr().write(17) };
461430
// SAFETY: The preceding write is complete and the mapping remains live.
462431
assert_eq!(unsafe { opened.as_ptr().read() }, 17);
463432
drop(opened);
433+
assert!(open(&id, SIZE).is_err());
464434

465435
assert!(
466436
fs::read_dir(backing_dir).unwrap().all(|entry| !entry
@@ -471,12 +441,45 @@ mod tests {
471441
);
472442
}
473443

444+
#[test]
445+
fn unsupported_sparse_errors_fall_back_to_extending_the_file() {
446+
for code in [ERROR_INVALID_FUNCTION, ERROR_NOT_SUPPORTED] {
447+
let file = test_backing_file();
448+
initialize_backing_file_with(&file, SIZE as u64, |_| {
449+
Err(io::Error::from_raw_os_error(code.cast_signed()))
450+
})
451+
.unwrap();
452+
453+
assert_eq!(file.metadata().unwrap().len(), SIZE as u64);
454+
}
455+
}
456+
457+
#[test]
458+
fn other_sparse_errors_do_not_extend_the_file() {
459+
for code in [ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER] {
460+
let file = test_backing_file();
461+
let error = initialize_backing_file_with(&file, SIZE as u64, |_| {
462+
Err(io::Error::from_raw_os_error(code.cast_signed()))
463+
})
464+
.unwrap_err();
465+
466+
assert_eq!(error.raw_os_error(), Some(code.cast_signed()));
467+
assert_eq!(file.metadata().unwrap().len(), 0);
468+
}
469+
}
470+
474471
#[cfg(target_pointer_width = "64")]
475472
#[test]
476-
fn four_gib_mapping_supports_endpoint_access() {
473+
fn four_gib_mapping_is_sparse_and_supports_endpoint_access() {
477474
const PRODUCTION_SIZE: usize = 4 * 1024 * 1024 * 1024;
475+
const MAX_ENDPOINT_ALLOCATION: u64 = 16 * 1024 * 1024;
478476

479477
let owner = create(PRODUCTION_SIZE).unwrap();
478+
let backing_file = owner.backing_file.as_ref().unwrap();
479+
let (logical_size, initial_allocation) = sys::file_sizes(backing_file).unwrap();
480+
assert_eq!(logical_size, PRODUCTION_SIZE as u64);
481+
assert!(initial_allocation < MAX_ENDPOINT_ALLOCATION);
482+
480483
let opened = open(owner.id(), PRODUCTION_SIZE).unwrap();
481484
// SAFETY: Both endpoint indexes are within the exact mapped length and
482485
// accesses are synchronized within this test.
@@ -486,5 +489,16 @@ mod tests {
486489
assert_eq!(opened.as_ptr().read(), 17);
487490
assert_eq!(opened.as_ptr().add(PRODUCTION_SIZE - 1).read(), 29);
488491
}
492+
493+
let (logical_size, endpoint_allocation) = sys::file_sizes(backing_file).unwrap();
494+
assert_eq!(logical_size, PRODUCTION_SIZE as u64);
495+
assert!(endpoint_allocation < MAX_ENDPOINT_ALLOCATION);
496+
}
497+
498+
fn test_backing_file() -> File {
499+
let backing_dir = create_backing_dir().unwrap();
500+
let mapping_name = format!("{MAPPING_NAME_PREFIX}{}", Uuid::new_v4());
501+
let path = backing_path(&backing_dir, &mapping_name).unwrap();
502+
create_backing_file(&path).unwrap()
489503
}
490504
}

0 commit comments

Comments
 (0)