From 29e0704116073832a87e6493c1dd45fa86a8397b Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 13:54:38 +0800 Subject: [PATCH 01/21] refactor(fspy): own Windows shared-memory mapping Co-Authored-By: Claude Fable 5 Co-authored-by: GPT-5 Codex --- Cargo.lock | 2 + Cargo.toml | 1 + .../src/windows/winapi_utils.rs | 5 + crates/fspy_shm/Cargo.toml | 12 +- crates/fspy_shm/src/lib.rs | 20 +- crates/fspy_shm/src/windows/mod.rs | 490 ++++++++++++++++++ crates/fspy_shm/src/windows/sys.rs | 121 +++++ 7 files changed, 640 insertions(+), 11 deletions(-) create mode 100644 crates/fspy_shm/src/windows/mod.rs create mode 100644 crates/fspy_shm/src/windows/sys.rs diff --git a/Cargo.lock b/Cargo.lock index 2205bf2d7..3ef2a73c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1387,6 +1387,7 @@ dependencies = [ name = "fspy_shm" version = "0.0.0" dependencies = [ + "base64", "ctor", "memmap2", "passfd", @@ -1397,6 +1398,7 @@ dependencies = [ "tokio-util", "tracing", "uuid", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b10915784..c031ee5ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -168,6 +168,7 @@ wax = "0.7.0" which = "8.0.0" widestring = "1.2.0" winapi = "0.3.9" +windows-sys = "0.61" winsafe = { version = "0.0.27", features = ["kernel"] } xxhash-rust = { version = "0.8.15", features = ["const_xxh3"] } ntest = "0.9.5" diff --git a/crates/fspy_preload_windows/src/windows/winapi_utils.rs b/crates/fspy_preload_windows/src/windows/winapi_utils.rs index 578efbda9..9e40f802c 100644 --- a/crates/fspy_preload_windows/src/windows/winapi_utils.rs +++ b/crates/fspy_preload_windows/src/windows/winapi_utils.rs @@ -99,8 +99,13 @@ pub const fn access_mask_to_mode(desired_access: ACCESS_MASK) -> AccessMode { } } +#[link(name = "kernel32")] unsafe extern "system" { fn LocalFree(hmem: HLOCAL) -> HLOCAL; +} + +#[link(name = "pathcch")] +unsafe extern "system" { fn PathAllocCombine( pszpathin: PCWSTR, pszmore: PCWSTR, diff --git a/crates/fspy_shm/Cargo.toml b/crates/fspy_shm/Cargo.toml index ece633d86..1c7225b86 100644 --- a/crates/fspy_shm/Cargo.toml +++ b/crates/fspy_shm/Cargo.toml @@ -7,7 +7,7 @@ license.workspace = true publish = false rust-version.workspace = true -[target.'cfg(not(target_os = "linux"))'.dependencies] +[target.'cfg(not(any(target_os = "linux", target_os = "windows")))'.dependencies] shared_memory = { workspace = true, features = ["logging"] } [target.'cfg(target_os = "linux")'.dependencies] @@ -19,6 +19,16 @@ tokio-util = { workspace = true } tracing = { workspace = true } uuid = { workspace = true, features = ["v4"] } +[target.'cfg(target_os = "windows")'.dependencies] +base64 = { workspace = true } +uuid = { workspace = true, features = ["v4"] } +windows-sys = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_Memory", +] } + [dev-dependencies] ctor = { workspace = true } subprocess_test = { workspace = true } diff --git a/crates/fspy_shm/src/lib.rs b/crates/fspy_shm/src/lib.rs index 8656890a4..f2ff97e90 100644 --- a/crates/fspy_shm/src/lib.rs +++ b/crates/fspy_shm/src/lib.rs @@ -1,18 +1,22 @@ #![doc = include_str!("../README.md")] -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "windows")))] use std::io; #[cfg(target_os = "linux")] mod linux; +#[cfg(target_os = "windows")] +mod windows; #[cfg(target_os = "linux")] pub use linux::{Shm, create, open}; -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "windows")))] use shared_memory::{Shmem, ShmemConf}; +#[cfg(target_os = "windows")] +pub use windows::{Shm, create, open}; /// An owned shared-memory mapping. -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "windows")))] pub struct Shm { inner: Shmem, } @@ -26,11 +30,9 @@ pub struct Shm { /// # Errors /// /// Returns an error if the platform cannot create or map the region. -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "windows")))] pub fn create(size: usize) -> io::Result { let conf = ShmemConf::new().size(size); - #[cfg(target_os = "windows")] - let conf = conf.allow_raw(true); let inner = conf.create().map_err(io::Error::other)?; Ok(Shm { inner }) @@ -45,17 +47,15 @@ pub fn create(size: usize) -> io::Result { /// # Errors /// /// Returns an error if the mapping does not exist or cannot be mapped. -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "windows")))] pub fn open(id: &str) -> io::Result { let conf = ShmemConf::new().os_id(id); - #[cfg(target_os = "windows")] - let conf = conf.allow_raw(true); let inner = conf.open().map_err(io::Error::other)?; Ok(Shm { inner }) } -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "windows")))] #[expect(clippy::len_without_is_empty, reason = "shared-memory mappings are always non-empty")] impl Shm { /// Returns this mapping's opaque platform identifier. diff --git a/crates/fspy_shm/src/windows/mod.rs b/crates/fspy_shm/src/windows/mod.rs new file mode 100644 index 000000000..05065e675 --- /dev/null +++ b/crates/fspy_shm/src/windows/mod.rs @@ -0,0 +1,490 @@ +mod sys; + +use std::{ + env::temp_dir, + ffi::{OsStr, OsString}, + fs::{self, File, OpenOptions}, + io, + os::windows::{ + ffi::{OsStrExt, OsStringExt}, + fs::OpenOptionsExt, + io::OwnedHandle, + }, + path::{Path, PathBuf}, + slice, +}; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use sys::{CreatedMapping, MappedView}; +use uuid::Uuid; + +const ID_PREFIX: &str = "fspy-win32-v1"; +const MAPPING_NAME_PREFIX: &str = r"Local\vite-task-fspy-"; +const BACKING_DIR: &str = "vite-task-fspy"; + +/// An owned Windows shared-memory mapping. +pub struct Shm { + id: String, + view: MappedView, + _mapping: OwnedHandle, + backing_file: BackingFile, +} + +/// Creates a file-backed named mapping of `size` bytes and returns its owner. +/// +/// # Errors +/// +/// Returns an error if the backing file or mapping cannot be created. +pub fn create(size: usize) -> io::Result { + create_with(size, || format!("{MAPPING_NAME_PREFIX}{}", Uuid::new_v4())) +} + +fn create_with(size: usize, mut next_name: impl FnMut() -> String) -> io::Result { + let size = valid_size(size)?; + let backing_dir = create_backing_dir()?; + + loop { + let mapping_name = next_name(); + if let Some(shm) = create_named(&backing_dir, &mapping_name, size)? { + return Ok(shm); + } + } +} + +fn create_named(backing_dir: &Path, mapping_name: &str, size: u64) -> io::Result> { + let path = backing_path(backing_dir, mapping_name)?; + let id = encode_id(&path, mapping_name)?; + let backing_file = match BackingFile::create(path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => return Ok(None), + Err(error) => return Err(error), + }; + backing_file.file.set_len(size)?; + + let mapping = match sys::create_file_mapping(&backing_file.file, mapping_name)? { + CreatedMapping::Created(mapping) => mapping, + CreatedMapping::AlreadyExists => return Ok(None), + }; + let len = usize::try_from(size).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "shared-memory size exceeds usize") + })?; + let view = MappedView::new(&mapping, len)?; + + Ok(Some(Shm { id, view, _mapping: mapping, backing_file })) +} + +/// Opens the named mapping identified by `id`. +/// +/// # Errors +/// +/// Returns an error if the identifier is invalid, the owner has torn down the +/// mapping, or its backing file has a different size. +pub fn open(id: &str, size: usize) -> io::Result { + let expected_size = valid_size(size)?; + let DecodedId { backing_path, mapping_name } = decode_id(id)?; + let backing_file = BackingFile::open(backing_path)?; + if backing_file.file.metadata()?.len() != expected_size { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "shared-memory backing file has an unexpected size", + )); + } + + let mapping = sys::open_file_mapping(&mapping_name)?; + let view = MappedView::new(&mapping, size)?; + Ok(Shm { id: id.to_owned(), view, _mapping: mapping, backing_file }) +} + +fn valid_size(size: usize) -> io::Result { + if size == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "shared-memory size must be nonzero", + )); + } + u64::try_from(size) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "shared-memory size exceeds u64")) +} + +fn create_backing_dir() -> io::Result { + let backing_dir = temp_dir().join(BACKING_DIR); + fs::create_dir_all(&backing_dir)?; + backing_dir.canonicalize() +} + +fn backing_path(backing_dir: &Path, mapping_name: &str) -> io::Result { + let suffix = mapping_name_suffix(mapping_name)?; + Ok(backing_dir.join(format!("{suffix}.shm"))) +} + +fn mapping_name_suffix(mapping_name: &str) -> io::Result<&str> { + let suffix = mapping_name.strip_prefix(MAPPING_NAME_PREFIX).ok_or_else(invalid_id)?; + let uuid = Uuid::parse_str(suffix).map_err(|_| invalid_id())?; + if uuid.hyphenated().to_string() != suffix { + return Err(invalid_id()); + } + Ok(suffix) +} + +fn encode_id(backing_path: &Path, mapping_name: &str) -> io::Result { + validate_id_parts(backing_path, mapping_name)?; + let path_bytes: Vec = + backing_path.as_os_str().encode_wide().flat_map(u16::to_le_bytes).collect(); + Ok(format!( + "{ID_PREFIX}.{}.{}", + URL_SAFE_NO_PAD.encode(path_bytes), + URL_SAFE_NO_PAD.encode(mapping_name) + )) +} + +struct DecodedId { + backing_path: PathBuf, + mapping_name: String, +} + +fn decode_id(id: &str) -> io::Result { + let mut parts = id.split('.'); + if parts.next() != Some(ID_PREFIX) { + return Err(invalid_id()); + } + let encoded_path = parts.next().ok_or_else(invalid_id)?; + let encoded_mapping_name = parts.next().ok_or_else(invalid_id)?; + if parts.next().is_some() { + return Err(invalid_id()); + } + + let path_bytes = decode_id_part(encoded_path)?; + if path_bytes.is_empty() || path_bytes.len() % 2 != 0 { + return Err(invalid_id()); + } + let path_units = path_bytes + .chunks_exact(2) + .map(|bytes| u16::from_le_bytes([bytes[0], bytes[1]])) + .collect::>(); + let backing_path = PathBuf::from(OsString::from_wide(&path_units)); + + let mapping_name = + String::from_utf8(decode_id_part(encoded_mapping_name)?).map_err(|_| invalid_id())?; + validate_id_parts(&backing_path, &mapping_name)?; + Ok(DecodedId { backing_path, mapping_name }) +} + +fn decode_id_part(encoded: &str) -> io::Result> { + let decoded = URL_SAFE_NO_PAD.decode(encoded).map_err(|_| invalid_id())?; + if URL_SAFE_NO_PAD.encode(&decoded) != encoded { + return Err(invalid_id()); + } + Ok(decoded) +} + +fn validate_id_parts(backing_path: &Path, mapping_name: &str) -> io::Result<()> { + let suffix = mapping_name_suffix(mapping_name)?; + if !backing_path.is_absolute() + || backing_path.file_name() != Some(OsStr::new(&format!("{suffix}.shm"))) + { + return Err(invalid_id()); + } + Ok(()) +} + +fn invalid_id() -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, "invalid Windows shared-memory identifier") +} + +struct BackingFile { + file: File, + owner_path: Option, +} + +impl BackingFile { + fn create(path: PathBuf) -> io::Result { + let file = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .share_mode(sys::SHARE_ALL) + .attributes(sys::TEMPORARY) + .open(&path)?; + Ok(Self { file, owner_path: Some(path) }) + } + + fn open(path: PathBuf) -> io::Result { + let file = OpenOptions::new() + .read(true) + .write(true) + .share_mode(sys::SHARE_ALL) + .attributes(sys::TEMPORARY) + .open(path)?; + Ok(Self { file, owner_path: None }) + } + + fn unlink(&mut self) { + let Some(path) = self.owner_path.take() else { + return; + }; + + let deletion_file = OpenOptions::new() + .access_mode(sys::DELETE_ACCESS) + .share_mode(sys::SHARE_ALL) + .attributes(sys::DELETE_ON_CLOSE) + .open(&path); + if let Ok(deletion_file) = deletion_file { + let deleted_path = deleted_path(&path); + if fs::rename(&path, deleted_path).is_err() { + let _ = fs::remove_file(&path); + } + drop(deletion_file); + } else { + let _ = fs::remove_file(path); + } + } +} + +impl Drop for BackingFile { + fn drop(&mut self) { + self.unlink(); + } +} + +fn deleted_path(path: &Path) -> PathBuf { + path.with_extension(format!("deleted-{}", Uuid::new_v4())) +} + +impl Drop for Shm { + fn drop(&mut self) { + // Remove the public backing-file name before the mapping handle drops, + // preventing opens that begin after owner teardown. + self.backing_file.unlink(); + } +} + +#[expect(clippy::len_without_is_empty, reason = "shared-memory mappings are always non-empty")] +impl Shm { + /// Returns this mapping's opaque Windows identifier. + #[must_use] + pub fn id(&self) -> &str { + &self.id + } + + /// Returns the mapped length in bytes. + #[must_use] + pub const fn len(&self) -> usize { + self.view.len() + } + + /// Returns a raw pointer to the first mapped byte. + #[must_use] + pub const fn as_ptr(&self) -> *mut u8 { + self.view.as_ptr() + } + + /// Returns the mapped bytes as a shared slice. + /// + /// # Safety + /// + /// The caller must ensure that no process or thread mutates the mapping for + /// the lifetime of the returned slice. + #[must_use] + pub const unsafe fn as_slice(&self) -> &[u8] { + // SAFETY: The view is valid for its exact length, and the caller + // guarantees that it is not mutated while the slice is borrowed. + unsafe { slice::from_raw_parts(self.view.as_ptr(), self.view.len()) } + } +} + +#[cfg(test)] +mod tests { + use std::{ffi::OsString, fs, process::Command}; + + use subprocess_test::command_for_fn; + + use super::*; + + const SIZE: usize = 64 * 1024; + + #[test] + fn identifiers_encode_absolute_paths_and_local_canonical_uuids() { + let owner = create(SIZE).unwrap(); + let decoded = decode_id(owner.id()).unwrap(); + let suffix = mapping_name_suffix(&decoded.mapping_name).unwrap(); + let uuid = Uuid::parse_str(suffix).unwrap(); + + assert!(decoded.backing_path.is_absolute()); + assert!(decoded.backing_path.exists()); + assert_eq!(uuid.hyphenated().to_string(), suffix); + assert_eq!(decoded.backing_path.file_name(), Some(OsStr::new(&format!("{suffix}.shm")))); + } + + #[test] + fn identifiers_round_trip_nontrivial_utf16_paths() { + let mapping_name = format!("{MAPPING_NAME_PREFIX}{}", Uuid::new_v4()); + let suffix = mapping_name_suffix(&mapping_name).unwrap(); + let mut path_units = r"C:\vite-task-fspy-".encode_utf16().collect::>(); + path_units.extend([0x00e9, 0x6c34, 0xd83d, 0xde80, 0xd800, u16::from(b'\\')]); + path_units.extend(format!("{suffix}.shm").encode_utf16()); + let path = PathBuf::from(OsString::from_wide(&path_units)); + + let id = encode_id(&path, &mapping_name).unwrap(); + let decoded = decode_id(&id).unwrap(); + + assert_eq!(decoded.mapping_name, mapping_name); + assert_eq!(decoded.backing_path.as_os_str().encode_wide().collect::>(), path_units); + } + + #[test] + fn malformed_ids_and_size_mismatches_are_rejected() { + let owner = create(SIZE).unwrap(); + let decoded = decode_id(owner.id()).unwrap(); + let encoded_path = URL_SAFE_NO_PAD.encode( + decoded + .backing_path + .as_os_str() + .encode_wide() + .flat_map(u16::to_le_bytes) + .collect::>(), + ); + let encoded_mapping_name = URL_SAFE_NO_PAD.encode(&decoded.mapping_name); + let relative_path = PathBuf::from(format!( + r"relative\{}.shm", + mapping_name_suffix(&decoded.mapping_name).unwrap() + )); + let encoded_relative_path = URL_SAFE_NO_PAD.encode( + relative_path.as_os_str().encode_wide().flat_map(u16::to_le_bytes).collect::>(), + ); + let wrong_path = decoded.backing_path.with_file_name("wrong.shm"); + let encoded_wrong_path = URL_SAFE_NO_PAD.encode( + wrong_path.as_os_str().encode_wide().flat_map(u16::to_le_bytes).collect::>(), + ); + + let malformed = [ + String::new(), + decoded.mapping_name, + ID_PREFIX.to_owned(), + format!("{ID_PREFIX}..{encoded_mapping_name}"), + format!("{ID_PREFIX}.AA.{encoded_mapping_name}"), + format!("{ID_PREFIX}.{encoded_relative_path}.{encoded_mapping_name}"), + format!("{ID_PREFIX}.{encoded_wrong_path}.{encoded_mapping_name}"), + format!("{ID_PREFIX}.{encoded_path}.%"), + format!("{ID_PREFIX}.{encoded_path}.{}", URL_SAFE_NO_PAD.encode([0xff])), + format!( + "{ID_PREFIX}.{encoded_path}.{}", + URL_SAFE_NO_PAD.encode(r"Local\vite-task-fspy-not-a-uuid") + ), + format!("{ID_PREFIX}.{encoded_path}=.{encoded_mapping_name}"), + format!("{}.{encoded_mapping_name}.extra", owner.id()), + ]; + for id in malformed { + assert_eq!(open(&id, SIZE).err().unwrap().kind(), io::ErrorKind::InvalidInput); + } + + assert_eq!(open(owner.id(), 0).err().unwrap().kind(), io::ErrorKind::InvalidInput); + assert_eq!(open(owner.id(), SIZE / 2).err().unwrap().kind(), io::ErrorKind::InvalidData); + assert_eq!(open(owner.id(), SIZE + 1).err().unwrap().kind(), io::ErrorKind::InvalidData); + } + + #[test] + fn mapping_name_collisions_retry_and_clean_up() { + let backing_dir = create_backing_dir().unwrap(); + let collision_name = format!("{MAPPING_NAME_PREFIX}{}", Uuid::new_v4()); + let raw_backing_name = format!("{MAPPING_NAME_PREFIX}{}", Uuid::new_v4()); + let raw_backing_path = backing_path(&backing_dir, &raw_backing_name).unwrap(); + let raw_backing = BackingFile::create(raw_backing_path).unwrap(); + raw_backing.file.set_len(SIZE as u64).unwrap(); + let collision_mapping = + match sys::create_file_mapping(&raw_backing.file, &collision_name).unwrap() { + CreatedMapping::Created(mapping) => mapping, + CreatedMapping::AlreadyExists => panic!("random test mapping name collided"), + }; + + let fresh_name = format!("{MAPPING_NAME_PREFIX}{}", Uuid::new_v4()); + let mut names = [collision_name.clone(), fresh_name.clone()].into_iter(); + let created = + create_with(SIZE, || names.next().expect("creation did not stop after retry")).unwrap(); + let decoded = decode_id(created.id()).unwrap(); + + assert_eq!(decoded.mapping_name, fresh_name); + assert_eq!( + decoded.backing_path, + backing_path(&backing_dir, &decoded.mapping_name).unwrap() + ); + assert!(!backing_path(&backing_dir, &collision_name).unwrap().exists()); + drop(collision_mapping); + drop(raw_backing); + } + + #[test] + fn subprocess_open_ignores_changed_temp_and_working_directory() { + let owner = create(SIZE).unwrap(); + let decoded = decode_id(owner.id()).unwrap(); + let changed_cwd = + decoded.backing_path.parent().unwrap().join(format!("changed-cwd-{}", Uuid::new_v4())); + fs::create_dir(&changed_cwd).unwrap(); + // SAFETY: The child does not access the mapping until this write completes. + unsafe { owner.as_ptr().write(17) }; + + let mut command = command_for_fn!(owner.id().to_owned(), |id: String| { + let opened = open(&id, SIZE).unwrap(); + // SAFETY: The parent waits for this child and does not access the + // mapping concurrently. + unsafe { + assert_eq!(opened.as_ptr().read(), 17); + opened.as_ptr().add(SIZE - 1).write(29); + } + }); + command.cwd = changed_cwd.clone(); + command.envs.insert(OsString::from("TMP"), OsString::from("changed-relative-tmp")); + command.envs.insert(OsString::from("TEMP"), OsString::from("changed-relative-temp")); + let succeeded = Command::from(command).status().unwrap().success(); + fs::remove_dir(changed_cwd).unwrap(); + + assert!(succeeded); + // SAFETY: The child exited before this read. + assert_eq!(unsafe { owner.as_ptr().add(SIZE - 1).read() }, 29); + } + + #[test] + fn owner_cleanup_removes_backing_file_after_existing_views_close() { + let owner = create(SIZE).unwrap(); + let id = owner.id().to_owned(); + let DecodedId { backing_path: path, mapping_name } = decode_id(&id).unwrap(); + let backing_dir = path.parent().unwrap().to_owned(); + let suffix = mapping_name_suffix(&mapping_name).unwrap().to_owned(); + let opened = open(&id, SIZE).unwrap(); + assert!(path.exists()); + + drop(owner); + + assert!(!path.exists()); + assert!(open(&id, SIZE).is_err()); + // SAFETY: The mapping remains live and no other test access is concurrent. + unsafe { opened.as_ptr().write(17) }; + // SAFETY: The preceding write is complete and the mapping remains live. + assert_eq!(unsafe { opened.as_ptr().read() }, 17); + drop(opened); + + assert!( + fs::read_dir(backing_dir).unwrap().all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(&suffix)) + ); + } + + #[cfg(target_pointer_width = "64")] + #[test] + fn four_gib_mapping_supports_endpoint_access() { + const PRODUCTION_SIZE: usize = 4 * 1024 * 1024 * 1024; + + let owner = create(PRODUCTION_SIZE).unwrap(); + let opened = open(owner.id(), PRODUCTION_SIZE).unwrap(); + // SAFETY: Both endpoint indexes are within the exact mapped length and + // accesses are synchronized within this test. + unsafe { + owner.as_ptr().write(17); + owner.as_ptr().add(PRODUCTION_SIZE - 1).write(29); + assert_eq!(opened.as_ptr().read(), 17); + assert_eq!(opened.as_ptr().add(PRODUCTION_SIZE - 1).read(), 29); + } + } +} diff --git a/crates/fspy_shm/src/windows/sys.rs b/crates/fspy_shm/src/windows/sys.rs new file mode 100644 index 000000000..52f8b2ca6 --- /dev/null +++ b/crates/fspy_shm/src/windows/sys.rs @@ -0,0 +1,121 @@ +use std::{ + io, + iter::once, + os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, + ptr::NonNull, +}; + +use windows_sys::Win32::{ + Foundation::{ERROR_ALREADY_EXISTS, GENERIC_READ, GENERIC_WRITE, GetLastError}, + Storage::FileSystem::{ + DELETE, FILE_ATTRIBUTE_TEMPORARY, FILE_FLAG_DELETE_ON_CLOSE, FILE_SHARE_DELETE, + FILE_SHARE_READ, FILE_SHARE_WRITE, + }, + System::Memory::{ + CreateFileMappingW, FILE_MAP_WRITE, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, + OpenFileMappingW, PAGE_READWRITE, UnmapViewOfFile, + }, +}; + +pub(super) const SHARE_ALL: u32 = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE; +pub(super) const TEMPORARY: u32 = FILE_ATTRIBUTE_TEMPORARY; +pub(super) const DELETE_ON_CLOSE: u32 = FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE; +pub(super) const DELETE_ACCESS: u32 = GENERIC_READ | GENERIC_WRITE | DELETE; + +pub(super) enum CreatedMapping { + Created(OwnedHandle), + AlreadyExists, +} + +pub(super) fn create_file_mapping(file: &std::fs::File, name: &str) -> io::Result { + let name = wide_name(name)?; + // SAFETY: `file` supplies a valid handle, the security pointer is null, and + // `name` is a live, NUL-terminated UTF-16 buffer for the duration of the call. + let raw_handle = unsafe { + CreateFileMappingW( + file.as_raw_handle().cast(), + std::ptr::null(), + PAGE_READWRITE, + 0, + 0, + name.as_ptr(), + ) + }; + if raw_handle.is_null() { + return Err(last_error()); + } + + // CreateFileMappingW reports name collisions through the thread's last-error + // value even though it returns a valid handle. + // SAFETY: GetLastError has no preconditions and immediately follows that call. + let error = unsafe { GetLastError() }; + // SAFETY: A non-null CreateFileMappingW result is an owned mapping handle. + let handle = unsafe { OwnedHandle::from_raw_handle(raw_handle.cast()) }; + if error == ERROR_ALREADY_EXISTS { + Ok(CreatedMapping::AlreadyExists) + } else { + Ok(CreatedMapping::Created(handle)) + } +} + +pub(super) fn open_file_mapping(name: &str) -> io::Result { + let name = wide_name(name)?; + // SAFETY: `name` is a live, NUL-terminated UTF-16 buffer and inheritance is disabled. + let raw_handle = unsafe { OpenFileMappingW(FILE_MAP_WRITE, 0, name.as_ptr()) }; + if raw_handle.is_null() { + return Err(last_error()); + } + + // SAFETY: A non-null OpenFileMappingW result is an owned mapping handle. + Ok(unsafe { OwnedHandle::from_raw_handle(raw_handle.cast()) }) +} + +pub(super) struct MappedView { + pointer: NonNull, + len: usize, +} + +impl MappedView { + pub(super) fn new(mapping: &OwnedHandle, len: usize) -> io::Result { + // SAFETY: `mapping` is a valid file-mapping handle. Offset zero and + // `len` describe the exact view retained by the returned guard. + let view = + unsafe { MapViewOfFile(mapping.as_raw_handle().cast(), FILE_MAP_WRITE, 0, 0, len) }; + let pointer = NonNull::new(view.Value.cast::()).ok_or_else(last_error)?; + Ok(Self { pointer, len }) + } + + pub(super) const fn as_ptr(&self) -> *mut u8 { + self.pointer.as_ptr() + } + + pub(super) const fn len(&self) -> usize { + self.len + } +} + +impl Drop for MappedView { + fn drop(&mut self) { + // SAFETY: `pointer` is the base address returned by MapViewOfFile and + // this guard owns that view until this single unmap operation. + let _ = unsafe { + UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { Value: self.pointer.as_ptr().cast() }) + }; + } +} + +fn wide_name(name: &str) -> io::Result> { + if name.contains('\0') { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "shared-memory name contains a NUL", + )); + } + Ok(name.encode_utf16().chain(once(0)).collect()) +} + +fn last_error() -> io::Error { + // SAFETY: GetLastError has no preconditions and is called immediately after + // the failing Win32 operation on the same thread. + io::Error::from_raw_os_error(unsafe { GetLastError() }.cast_signed()) +} From fa158a656649b79d322857616c55b7d6720ff4e2 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 21:19:33 +0800 Subject: [PATCH 02/21] docs(fspy): explain Windows import linkage Co-authored-by: GPT-5 Codex --- crates/fspy_preload_windows/src/windows/winapi_utils.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/fspy_preload_windows/src/windows/winapi_utils.rs b/crates/fspy_preload_windows/src/windows/winapi_utils.rs index 9e40f802c..0e89c11e4 100644 --- a/crates/fspy_preload_windows/src/windows/winapi_utils.rs +++ b/crates/fspy_preload_windows/src/windows/winapi_utils.rs @@ -99,6 +99,9 @@ pub const fn access_mask_to_mode(desired_access: ACCESS_MASK) -> AccessMode { } } +// These APIs use separate import libraries. The Windows `shared_memory` +// dependency used to bring `pathcch` into the final DLL; fspy_shm's native +// backend removes that dependency, so name both libraries here. #[link(name = "kernel32")] unsafe extern "system" { fn LocalFree(hmem: HLOCAL) -> HLOCAL; From 8b27495bc66983655bbec8647587587f8acb954b Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 21:28:56 +0800 Subject: [PATCH 03/21] refactor(fspy): simplify Windows shared memory Co-authored-by: GPT-5 Codex --- Cargo.lock | 2 +- crates/fspy_preload_windows/Cargo.toml | 1 + .../src/windows/winapi_utils.rs | 27 +- crates/fspy_shm/Cargo.toml | 1 - crates/fspy_shm/src/windows/mod.rs | 359 +++--------------- crates/fspy_shm/src/windows/sys.rs | 34 +- 6 files changed, 69 insertions(+), 355 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3ef2a73c9..7efdf6e37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1322,6 +1322,7 @@ dependencies = [ "widestring", "winapi", "wincode", + "windows-sys 0.61.2", "winsafe 0.0.27", ] @@ -1387,7 +1388,6 @@ dependencies = [ name = "fspy_shm" version = "0.0.0" dependencies = [ - "base64", "ctor", "memmap2", "passfd", diff --git a/crates/fspy_preload_windows/Cargo.toml b/crates/fspy_preload_windows/Cargo.toml index b315e1c6b..9dd1c50cc 100644 --- a/crates/fspy_preload_windows/Cargo.toml +++ b/crates/fspy_preload_windows/Cargo.toml @@ -23,6 +23,7 @@ winapi = { workspace = true, features = [ "memoryapi", "std", ] } +windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_UI_Shell"] } winsafe = { workspace = true } [target.'cfg(target_os = "windows")'.dev-dependencies] diff --git a/crates/fspy_preload_windows/src/windows/winapi_utils.rs b/crates/fspy_preload_windows/src/windows/winapi_utils.rs index 0e89c11e4..38eba1c19 100644 --- a/crates/fspy_preload_windows/src/windows/winapi_utils.rs +++ b/crates/fspy_preload_windows/src/windows/winapi_utils.rs @@ -6,8 +6,8 @@ use widestring::{U16CStr, U16Str}; use winapi::{ ctypes::c_long, shared::{ - minwindef::{BOOL, FALSE, HLOCAL, MAX_PATH, ULONG}, - ntdef::{HANDLE, HRESULT, PCWSTR, PWSTR, UNICODE_STRING}, + minwindef::{BOOL, FALSE, MAX_PATH}, + ntdef::{HANDLE, PWSTR, UNICODE_STRING}, winerror::{NO_ERROR, S_OK}, }, um::{ @@ -18,6 +18,10 @@ use winapi::{ }, }, }; +use windows_sys::Win32::{ + Foundation::LocalFree, + UI::Shell::{PATHCCH_ALLOW_LONG_PATHS, PathAllocCombine}, +}; use winsafe::{GetLastError, co}; pub fn ck(b: BOOL) -> winsafe::SysResult<()> { @@ -99,24 +103,6 @@ pub const fn access_mask_to_mode(desired_access: ACCESS_MASK) -> AccessMode { } } -// These APIs use separate import libraries. The Windows `shared_memory` -// dependency used to bring `pathcch` into the final DLL; fspy_shm's native -// backend removes that dependency, so name both libraries here. -#[link(name = "kernel32")] -unsafe extern "system" { - fn LocalFree(hmem: HLOCAL) -> HLOCAL; -} - -#[link(name = "pathcch")] -unsafe extern "system" { - fn PathAllocCombine( - pszpathin: PCWSTR, - pszmore: PCWSTR, - dwflags: ULONG, - ppszpathout: *mut PWSTR, - ) -> HRESULT; -} - pub struct HeapPath(PWSTR); impl HeapPath { #[must_use] @@ -133,7 +119,6 @@ impl Drop for HeapPath { } pub fn combine_paths(path1: &U16CStr, path2: &U16CStr) -> winsafe::SysResult { - const PATHCCH_ALLOW_LONG_PATHS: ULONG = 0x0000_0001; let mut out = std::ptr::null_mut(); // SAFETY: FFI call to PathAllocCombine with valid null-terminated wide string pointers let hr = unsafe { diff --git a/crates/fspy_shm/Cargo.toml b/crates/fspy_shm/Cargo.toml index 1c7225b86..7caf7fc8e 100644 --- a/crates/fspy_shm/Cargo.toml +++ b/crates/fspy_shm/Cargo.toml @@ -20,7 +20,6 @@ tracing = { workspace = true } uuid = { workspace = true, features = ["v4"] } [target.'cfg(target_os = "windows")'.dependencies] -base64 = { workspace = true } uuid = { workspace = true, features = ["v4"] } windows-sys = { workspace = true, features = [ "Win32_Foundation", diff --git a/crates/fspy_shm/src/windows/mod.rs b/crates/fspy_shm/src/windows/mod.rs index 05065e675..f29f5d0fc 100644 --- a/crates/fspy_shm/src/windows/mod.rs +++ b/crates/fspy_shm/src/windows/mod.rs @@ -2,23 +2,16 @@ mod sys; use std::{ env::temp_dir, - ffi::{OsStr, OsString}, fs::{self, File, OpenOptions}, io, - os::windows::{ - ffi::{OsStrExt, OsStringExt}, - fs::OpenOptionsExt, - io::OwnedHandle, - }, - path::{Path, PathBuf}, + os::windows::fs::OpenOptionsExt, + path::PathBuf, slice, }; -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use sys::{CreatedMapping, MappedView}; +use sys::MappedView; use uuid::Uuid; -const ID_PREFIX: &str = "fspy-win32-v1"; const MAPPING_NAME_PREFIX: &str = r"Local\vite-task-fspy-"; const BACKING_DIR: &str = "vite-task-fspy"; @@ -26,73 +19,38 @@ const BACKING_DIR: &str = "vite-task-fspy"; pub struct Shm { id: String, view: MappedView, - _mapping: OwnedHandle, - backing_file: BackingFile, + _backing_file: Option, } -/// Creates a file-backed named mapping of `size` bytes and returns its owner. +/// Creates a temporary file-backed named mapping of `size` bytes and returns +/// its owner. /// /// # Errors /// /// Returns an error if the backing file or mapping cannot be created. pub fn create(size: usize) -> io::Result { - create_with(size, || format!("{MAPPING_NAME_PREFIX}{}", Uuid::new_v4())) -} - -fn create_with(size: usize, mut next_name: impl FnMut() -> String) -> io::Result { - let size = valid_size(size)?; - let backing_dir = create_backing_dir()?; - - loop { - let mapping_name = next_name(); - if let Some(shm) = create_named(&backing_dir, &mapping_name, size)? { - return Ok(shm); - } - } -} + let size_u64 = valid_size(size)?; + let id = Uuid::new_v4().simple().to_string(); + let backing_file = create_backing_file(&id)?; + backing_file.set_len(size_u64)?; + let mapping = sys::create_file_mapping(&backing_file, &mapping_name(&id, size))?; + let view = MappedView::new(&mapping, size)?; -fn create_named(backing_dir: &Path, mapping_name: &str, size: u64) -> io::Result> { - let path = backing_path(backing_dir, mapping_name)?; - let id = encode_id(&path, mapping_name)?; - let backing_file = match BackingFile::create(path) { - Ok(file) => file, - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => return Ok(None), - Err(error) => return Err(error), - }; - backing_file.file.set_len(size)?; - - let mapping = match sys::create_file_mapping(&backing_file.file, mapping_name)? { - CreatedMapping::Created(mapping) => mapping, - CreatedMapping::AlreadyExists => return Ok(None), - }; - let len = usize::try_from(size).map_err(|_| { - io::Error::new(io::ErrorKind::InvalidInput, "shared-memory size exceeds usize") - })?; - let view = MappedView::new(&mapping, len)?; - - Ok(Some(Shm { id, view, _mapping: mapping, backing_file })) + Ok(Shm { id, view, _backing_file: Some(backing_file) }) } /// Opens the named mapping identified by `id`. /// /// # Errors /// -/// Returns an error if the identifier is invalid, the owner has torn down the -/// mapping, or its backing file has a different size. +/// Returns an error if the identifier is invalid or the mapping is unavailable. pub fn open(id: &str, size: usize) -> io::Result { - let expected_size = valid_size(size)?; - let DecodedId { backing_path, mapping_name } = decode_id(id)?; - let backing_file = BackingFile::open(backing_path)?; - if backing_file.file.metadata()?.len() != expected_size { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "shared-memory backing file has an unexpected size", - )); - } - - let mapping = sys::open_file_mapping(&mapping_name)?; + valid_size(size)?; + validate_id(id)?; + let mapping = sys::open_file_mapping(&mapping_name(id, size))?; let view = MappedView::new(&mapping, size)?; - Ok(Shm { id: id.to_owned(), view, _mapping: mapping, backing_file }) + + Ok(Shm { id: id.to_owned(), view, _backing_file: None }) } fn valid_size(size: usize) -> io::Result { @@ -106,82 +64,9 @@ fn valid_size(size: usize) -> io::Result { .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "shared-memory size exceeds u64")) } -fn create_backing_dir() -> io::Result { - let backing_dir = temp_dir().join(BACKING_DIR); - fs::create_dir_all(&backing_dir)?; - backing_dir.canonicalize() -} - -fn backing_path(backing_dir: &Path, mapping_name: &str) -> io::Result { - let suffix = mapping_name_suffix(mapping_name)?; - Ok(backing_dir.join(format!("{suffix}.shm"))) -} - -fn mapping_name_suffix(mapping_name: &str) -> io::Result<&str> { - let suffix = mapping_name.strip_prefix(MAPPING_NAME_PREFIX).ok_or_else(invalid_id)?; - let uuid = Uuid::parse_str(suffix).map_err(|_| invalid_id())?; - if uuid.hyphenated().to_string() != suffix { - return Err(invalid_id()); - } - Ok(suffix) -} - -fn encode_id(backing_path: &Path, mapping_name: &str) -> io::Result { - validate_id_parts(backing_path, mapping_name)?; - let path_bytes: Vec = - backing_path.as_os_str().encode_wide().flat_map(u16::to_le_bytes).collect(); - Ok(format!( - "{ID_PREFIX}.{}.{}", - URL_SAFE_NO_PAD.encode(path_bytes), - URL_SAFE_NO_PAD.encode(mapping_name) - )) -} - -struct DecodedId { - backing_path: PathBuf, - mapping_name: String, -} - -fn decode_id(id: &str) -> io::Result { - let mut parts = id.split('.'); - if parts.next() != Some(ID_PREFIX) { - return Err(invalid_id()); - } - let encoded_path = parts.next().ok_or_else(invalid_id)?; - let encoded_mapping_name = parts.next().ok_or_else(invalid_id)?; - if parts.next().is_some() { - return Err(invalid_id()); - } - - let path_bytes = decode_id_part(encoded_path)?; - if path_bytes.is_empty() || path_bytes.len() % 2 != 0 { - return Err(invalid_id()); - } - let path_units = path_bytes - .chunks_exact(2) - .map(|bytes| u16::from_le_bytes([bytes[0], bytes[1]])) - .collect::>(); - let backing_path = PathBuf::from(OsString::from_wide(&path_units)); - - let mapping_name = - String::from_utf8(decode_id_part(encoded_mapping_name)?).map_err(|_| invalid_id())?; - validate_id_parts(&backing_path, &mapping_name)?; - Ok(DecodedId { backing_path, mapping_name }) -} - -fn decode_id_part(encoded: &str) -> io::Result> { - let decoded = URL_SAFE_NO_PAD.decode(encoded).map_err(|_| invalid_id())?; - if URL_SAFE_NO_PAD.encode(&decoded) != encoded { - return Err(invalid_id()); - } - Ok(decoded) -} - -fn validate_id_parts(backing_path: &Path, mapping_name: &str) -> io::Result<()> { - let suffix = mapping_name_suffix(mapping_name)?; - if !backing_path.is_absolute() - || backing_path.file_name() != Some(OsStr::new(&format!("{suffix}.shm"))) - { +fn validate_id(id: &str) -> io::Result<()> { + let uuid = Uuid::parse_str(id).map_err(|_| invalid_id())?; + if uuid.simple().to_string() != id { return Err(invalid_id()); } Ok(()) @@ -191,71 +76,25 @@ fn invalid_id() -> io::Error { io::Error::new(io::ErrorKind::InvalidInput, "invalid Windows shared-memory identifier") } -struct BackingFile { - file: File, - owner_path: Option, +fn mapping_name(id: &str, size: usize) -> String { + format!("{MAPPING_NAME_PREFIX}{id}-{size}") } -impl BackingFile { - fn create(path: PathBuf) -> io::Result { - let file = OpenOptions::new() - .read(true) - .write(true) - .create_new(true) - .share_mode(sys::SHARE_ALL) - .attributes(sys::TEMPORARY) - .open(&path)?; - Ok(Self { file, owner_path: Some(path) }) - } - - fn open(path: PathBuf) -> io::Result { - let file = OpenOptions::new() - .read(true) - .write(true) - .share_mode(sys::SHARE_ALL) - .attributes(sys::TEMPORARY) - .open(path)?; - Ok(Self { file, owner_path: None }) - } - - fn unlink(&mut self) { - let Some(path) = self.owner_path.take() else { - return; - }; - - let deletion_file = OpenOptions::new() - .access_mode(sys::DELETE_ACCESS) - .share_mode(sys::SHARE_ALL) - .attributes(sys::DELETE_ON_CLOSE) - .open(&path); - if let Ok(deletion_file) = deletion_file { - let deleted_path = deleted_path(&path); - if fs::rename(&path, deleted_path).is_err() { - let _ = fs::remove_file(&path); - } - drop(deletion_file); - } else { - let _ = fs::remove_file(path); - } - } +fn backing_path(id: &str) -> PathBuf { + temp_dir().join(BACKING_DIR).join(format!("{id}.shm")) } -impl Drop for BackingFile { - fn drop(&mut self) { - self.unlink(); - } -} - -fn deleted_path(path: &Path) -> PathBuf { - path.with_extension(format!("deleted-{}", Uuid::new_v4())) -} - -impl Drop for Shm { - fn drop(&mut self) { - // Remove the public backing-file name before the mapping handle drops, - // preventing opens that begin after owner teardown. - self.backing_file.unlink(); - } +fn create_backing_file(id: &str) -> io::Result { + let path = backing_path(id); + fs::create_dir_all(path.parent().unwrap())?; + OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .share_mode(sys::SHARE_ALL) + .attributes(sys::TEMPORARY) + .custom_flags(sys::DELETE_ON_CLOSE) + .open(path) } #[expect(clippy::len_without_is_empty, reason = "shared-memory mappings are always non-empty")] @@ -303,121 +142,31 @@ mod tests { const SIZE: usize = 64 * 1024; #[test] - fn identifiers_encode_absolute_paths_and_local_canonical_uuids() { + fn identifiers_are_canonical_simple_uuids() { let owner = create(SIZE).unwrap(); - let decoded = decode_id(owner.id()).unwrap(); - let suffix = mapping_name_suffix(&decoded.mapping_name).unwrap(); - let uuid = Uuid::parse_str(suffix).unwrap(); - - assert!(decoded.backing_path.is_absolute()); - assert!(decoded.backing_path.exists()); - assert_eq!(uuid.hyphenated().to_string(), suffix); - assert_eq!(decoded.backing_path.file_name(), Some(OsStr::new(&format!("{suffix}.shm")))); - } + let uuid = Uuid::parse_str(owner.id()).unwrap(); - #[test] - fn identifiers_round_trip_nontrivial_utf16_paths() { - let mapping_name = format!("{MAPPING_NAME_PREFIX}{}", Uuid::new_v4()); - let suffix = mapping_name_suffix(&mapping_name).unwrap(); - let mut path_units = r"C:\vite-task-fspy-".encode_utf16().collect::>(); - path_units.extend([0x00e9, 0x6c34, 0xd83d, 0xde80, 0xd800, u16::from(b'\\')]); - path_units.extend(format!("{suffix}.shm").encode_utf16()); - let path = PathBuf::from(OsString::from_wide(&path_units)); - - let id = encode_id(&path, &mapping_name).unwrap(); - let decoded = decode_id(&id).unwrap(); - - assert_eq!(decoded.mapping_name, mapping_name); - assert_eq!(decoded.backing_path.as_os_str().encode_wide().collect::>(), path_units); + assert_eq!(uuid.simple().to_string(), owner.id()); } #[test] fn malformed_ids_and_size_mismatches_are_rejected() { let owner = create(SIZE).unwrap(); - let decoded = decode_id(owner.id()).unwrap(); - let encoded_path = URL_SAFE_NO_PAD.encode( - decoded - .backing_path - .as_os_str() - .encode_wide() - .flat_map(u16::to_le_bytes) - .collect::>(), - ); - let encoded_mapping_name = URL_SAFE_NO_PAD.encode(&decoded.mapping_name); - let relative_path = PathBuf::from(format!( - r"relative\{}.shm", - mapping_name_suffix(&decoded.mapping_name).unwrap() - )); - let encoded_relative_path = URL_SAFE_NO_PAD.encode( - relative_path.as_os_str().encode_wide().flat_map(u16::to_le_bytes).collect::>(), - ); - let wrong_path = decoded.backing_path.with_file_name("wrong.shm"); - let encoded_wrong_path = URL_SAFE_NO_PAD.encode( - wrong_path.as_os_str().encode_wide().flat_map(u16::to_le_bytes).collect::>(), - ); - - let malformed = [ - String::new(), - decoded.mapping_name, - ID_PREFIX.to_owned(), - format!("{ID_PREFIX}..{encoded_mapping_name}"), - format!("{ID_PREFIX}.AA.{encoded_mapping_name}"), - format!("{ID_PREFIX}.{encoded_relative_path}.{encoded_mapping_name}"), - format!("{ID_PREFIX}.{encoded_wrong_path}.{encoded_mapping_name}"), - format!("{ID_PREFIX}.{encoded_path}.%"), - format!("{ID_PREFIX}.{encoded_path}.{}", URL_SAFE_NO_PAD.encode([0xff])), - format!( - "{ID_PREFIX}.{encoded_path}.{}", - URL_SAFE_NO_PAD.encode(r"Local\vite-task-fspy-not-a-uuid") - ), - format!("{ID_PREFIX}.{encoded_path}=.{encoded_mapping_name}"), - format!("{}.{encoded_mapping_name}.extra", owner.id()), - ]; - for id in malformed { + let uuid = Uuid::parse_str(owner.id()).unwrap(); + for id in [String::new(), uuid.hyphenated().to_string(), format!("{}0", owner.id())] { assert_eq!(open(&id, SIZE).err().unwrap().kind(), io::ErrorKind::InvalidInput); } assert_eq!(open(owner.id(), 0).err().unwrap().kind(), io::ErrorKind::InvalidInput); - assert_eq!(open(owner.id(), SIZE / 2).err().unwrap().kind(), io::ErrorKind::InvalidData); - assert_eq!(open(owner.id(), SIZE + 1).err().unwrap().kind(), io::ErrorKind::InvalidData); - } - - #[test] - fn mapping_name_collisions_retry_and_clean_up() { - let backing_dir = create_backing_dir().unwrap(); - let collision_name = format!("{MAPPING_NAME_PREFIX}{}", Uuid::new_v4()); - let raw_backing_name = format!("{MAPPING_NAME_PREFIX}{}", Uuid::new_v4()); - let raw_backing_path = backing_path(&backing_dir, &raw_backing_name).unwrap(); - let raw_backing = BackingFile::create(raw_backing_path).unwrap(); - raw_backing.file.set_len(SIZE as u64).unwrap(); - let collision_mapping = - match sys::create_file_mapping(&raw_backing.file, &collision_name).unwrap() { - CreatedMapping::Created(mapping) => mapping, - CreatedMapping::AlreadyExists => panic!("random test mapping name collided"), - }; - - let fresh_name = format!("{MAPPING_NAME_PREFIX}{}", Uuid::new_v4()); - let mut names = [collision_name.clone(), fresh_name.clone()].into_iter(); - let created = - create_with(SIZE, || names.next().expect("creation did not stop after retry")).unwrap(); - let decoded = decode_id(created.id()).unwrap(); - - assert_eq!(decoded.mapping_name, fresh_name); - assert_eq!( - decoded.backing_path, - backing_path(&backing_dir, &decoded.mapping_name).unwrap() - ); - assert!(!backing_path(&backing_dir, &collision_name).unwrap().exists()); - drop(collision_mapping); - drop(raw_backing); + assert!(open(owner.id(), SIZE / 2).is_err()); + assert!(open(owner.id(), SIZE + 1).is_err()); } #[test] fn subprocess_open_ignores_changed_temp_and_working_directory() { let owner = create(SIZE).unwrap(); - let decoded = decode_id(owner.id()).unwrap(); let changed_cwd = - decoded.backing_path.parent().unwrap().join(format!("changed-cwd-{}", Uuid::new_v4())); + temp_dir().join(BACKING_DIR).join(format!("changed-cwd-{}", Uuid::new_v4())); fs::create_dir(&changed_cwd).unwrap(); // SAFETY: The child does not access the mapping until this write completes. unsafe { owner.as_ptr().write(17) }; @@ -443,32 +192,24 @@ mod tests { } #[test] - fn owner_cleanup_removes_backing_file_after_existing_views_close() { + fn owner_cleanup_deletes_backing_file_and_preserves_existing_views() { let owner = create(SIZE).unwrap(); let id = owner.id().to_owned(); - let DecodedId { backing_path: path, mapping_name } = decode_id(&id).unwrap(); - let backing_dir = path.parent().unwrap().to_owned(); - let suffix = mapping_name_suffix(&mapping_name).unwrap().to_owned(); + let path = backing_path(&id); let opened = open(&id, SIZE).unwrap(); assert!(path.exists()); drop(owner); assert!(!path.exists()); - assert!(open(&id, SIZE).is_err()); // SAFETY: The mapping remains live and no other test access is concurrent. unsafe { opened.as_ptr().write(17) }; // SAFETY: The preceding write is complete and the mapping remains live. assert_eq!(unsafe { opened.as_ptr().read() }, 17); + let reopened = open(&id, SIZE).unwrap(); drop(opened); - - assert!( - fs::read_dir(backing_dir).unwrap().all(|entry| !entry - .unwrap() - .file_name() - .to_string_lossy() - .starts_with(&suffix)) - ); + drop(reopened); + assert!(open(&id, SIZE).is_err()); } #[cfg(target_pointer_width = "64")] diff --git a/crates/fspy_shm/src/windows/sys.rs b/crates/fspy_shm/src/windows/sys.rs index 52f8b2ca6..808b4f67d 100644 --- a/crates/fspy_shm/src/windows/sys.rs +++ b/crates/fspy_shm/src/windows/sys.rs @@ -6,10 +6,10 @@ use std::{ }; use windows_sys::Win32::{ - Foundation::{ERROR_ALREADY_EXISTS, GENERIC_READ, GENERIC_WRITE, GetLastError}, + Foundation::{ERROR_ALREADY_EXISTS, GetLastError}, Storage::FileSystem::{ - DELETE, FILE_ATTRIBUTE_TEMPORARY, FILE_FLAG_DELETE_ON_CLOSE, FILE_SHARE_DELETE, - FILE_SHARE_READ, FILE_SHARE_WRITE, + FILE_ATTRIBUTE_TEMPORARY, FILE_FLAG_DELETE_ON_CLOSE, FILE_SHARE_DELETE, FILE_SHARE_READ, + FILE_SHARE_WRITE, }, System::Memory::{ CreateFileMappingW, FILE_MAP_WRITE, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, @@ -19,16 +19,10 @@ use windows_sys::Win32::{ pub(super) const SHARE_ALL: u32 = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE; pub(super) const TEMPORARY: u32 = FILE_ATTRIBUTE_TEMPORARY; -pub(super) const DELETE_ON_CLOSE: u32 = FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE; -pub(super) const DELETE_ACCESS: u32 = GENERIC_READ | GENERIC_WRITE | DELETE; +pub(super) const DELETE_ON_CLOSE: u32 = FILE_FLAG_DELETE_ON_CLOSE; -pub(super) enum CreatedMapping { - Created(OwnedHandle), - AlreadyExists, -} - -pub(super) fn create_file_mapping(file: &std::fs::File, name: &str) -> io::Result { - let name = wide_name(name)?; +pub(super) fn create_file_mapping(file: &std::fs::File, name: &str) -> io::Result { + let name = wide_name(name); // SAFETY: `file` supplies a valid handle, the security pointer is null, and // `name` is a live, NUL-terminated UTF-16 buffer for the duration of the call. let raw_handle = unsafe { @@ -52,14 +46,14 @@ pub(super) fn create_file_mapping(file: &std::fs::File, name: &str) -> io::Resul // SAFETY: A non-null CreateFileMappingW result is an owned mapping handle. let handle = unsafe { OwnedHandle::from_raw_handle(raw_handle.cast()) }; if error == ERROR_ALREADY_EXISTS { - Ok(CreatedMapping::AlreadyExists) + Err(io::Error::new(io::ErrorKind::AlreadyExists, "shared-memory mapping already exists")) } else { - Ok(CreatedMapping::Created(handle)) + Ok(handle) } } pub(super) fn open_file_mapping(name: &str) -> io::Result { - let name = wide_name(name)?; + let name = wide_name(name); // SAFETY: `name` is a live, NUL-terminated UTF-16 buffer and inheritance is disabled. let raw_handle = unsafe { OpenFileMappingW(FILE_MAP_WRITE, 0, name.as_ptr()) }; if raw_handle.is_null() { @@ -104,14 +98,8 @@ impl Drop for MappedView { } } -fn wide_name(name: &str) -> io::Result> { - if name.contains('\0') { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "shared-memory name contains a NUL", - )); - } - Ok(name.encode_utf16().chain(once(0)).collect()) +fn wide_name(name: &str) -> Vec { + name.encode_utf16().chain(once(0)).collect() } fn last_error() -> io::Error { From 99344abfbd2cad9e6ee78f5bf511d0754f471dda Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 9 Jul 2026 12:36:28 +0800 Subject: [PATCH 04/21] perf(fspy): use sparse Windows shared memory Co-authored-by: GPT-5.6 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + crates/fspy_shm/Cargo.toml | 2 + crates/fspy_shm/src/windows/mod.rs | 69 +++++++++++++++++++++++++++-- crates/fspy_shm/src/windows/sys.rs | 71 ++++++++++++++++++++++++++++-- 4 files changed, 135 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 113574b33..3aed885f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Improved** Windows file-access tracking now uses sparse temporary backing files where supported, avoiding an upfront 4 GiB disk allocation per task. - **Fixed** Automatic file-access tracking on Linux now works in containers and Kubernetes runners with limited `/dev/shm` space ([#353](https://github.com/voidzero-dev/vite-task/issues/353), [#523](https://github.com/voidzero-dev/vite-task/pull/523)). - **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)). - **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)). diff --git a/crates/fspy_shm/Cargo.toml b/crates/fspy_shm/Cargo.toml index 7caf7fc8e..f05f42046 100644 --- a/crates/fspy_shm/Cargo.toml +++ b/crates/fspy_shm/Cargo.toml @@ -25,6 +25,8 @@ windows-sys = { workspace = true, features = [ "Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_Ioctl", "Win32_System_Memory", ] } diff --git a/crates/fspy_shm/src/windows/mod.rs b/crates/fspy_shm/src/windows/mod.rs index f29f5d0fc..8e22e5e99 100644 --- a/crates/fspy_shm/src/windows/mod.rs +++ b/crates/fspy_shm/src/windows/mod.rs @@ -22,8 +22,8 @@ pub struct Shm { _backing_file: Option, } -/// Creates a temporary file-backed named mapping of `size` bytes and returns -/// its owner. +/// Creates a sparse, temporary file-backed named mapping of `size` bytes and +/// returns its owner. /// /// # Errors /// @@ -32,7 +32,7 @@ pub fn create(size: usize) -> io::Result { let size_u64 = valid_size(size)?; let id = Uuid::new_v4().simple().to_string(); let backing_file = create_backing_file(&id)?; - backing_file.set_len(size_u64)?; + initialize_backing_file(&backing_file, size_u64)?; let mapping = sys::create_file_mapping(&backing_file, &mapping_name(&id, size))?; let view = MappedView::new(&mapping, size)?; @@ -97,6 +97,23 @@ fn create_backing_file(id: &str) -> io::Result { .open(path) } +fn initialize_backing_file(file: &File, size: u64) -> io::Result<()> { + initialize_backing_file_with(file, size, sys::set_sparse) +} + +fn initialize_backing_file_with( + file: &File, + size: u64, + set_sparse: impl FnOnce(&File) -> io::Result<()>, +) -> io::Result<()> { + if let Err(error) = set_sparse(file) + && !sys::is_sparse_unsupported(&error) + { + return Err(error); + } + file.set_len(size) +} + #[expect(clippy::len_without_is_empty, reason = "shared-memory mappings are always non-empty")] impl Shm { /// Returns this mapping's opaque Windows identifier. @@ -136,6 +153,9 @@ mod tests { use std::{ffi::OsString, fs, process::Command}; use subprocess_test::command_for_fn; + use windows_sys::Win32::Foundation::{ + ERROR_ACCESS_DENIED, ERROR_INVALID_FUNCTION, ERROR_INVALID_PARAMETER, ERROR_NOT_SUPPORTED, + }; use super::*; @@ -212,12 +232,45 @@ mod tests { assert!(open(&id, SIZE).is_err()); } + #[test] + fn unsupported_sparse_errors_fall_back_to_extending_the_file() { + for code in [ERROR_INVALID_FUNCTION, ERROR_NOT_SUPPORTED] { + let file = test_backing_file(); + initialize_backing_file_with(&file, SIZE as u64, |_| { + Err(io::Error::from_raw_os_error(code.cast_signed())) + }) + .unwrap(); + + assert_eq!(file.metadata().unwrap().len(), SIZE as u64); + } + } + + #[test] + fn other_sparse_errors_do_not_extend_the_file() { + for code in [ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER] { + let file = test_backing_file(); + let error = initialize_backing_file_with(&file, SIZE as u64, |_| { + Err(io::Error::from_raw_os_error(code.cast_signed())) + }) + .unwrap_err(); + + assert_eq!(error.raw_os_error(), Some(code.cast_signed())); + assert_eq!(file.metadata().unwrap().len(), 0); + } + } + #[cfg(target_pointer_width = "64")] #[test] - fn four_gib_mapping_supports_endpoint_access() { + fn four_gib_mapping_is_sparse_and_supports_endpoint_access() { const PRODUCTION_SIZE: usize = 4 * 1024 * 1024 * 1024; + const MAX_ENDPOINT_ALLOCATION: u64 = 16 * 1024 * 1024; let owner = create(PRODUCTION_SIZE).unwrap(); + let backing_file = owner._backing_file.as_ref().unwrap(); + let (logical_size, initial_allocation) = sys::file_sizes(backing_file).unwrap(); + assert_eq!(logical_size, PRODUCTION_SIZE as u64); + assert!(initial_allocation < MAX_ENDPOINT_ALLOCATION); + let opened = open(owner.id(), PRODUCTION_SIZE).unwrap(); // SAFETY: Both endpoint indexes are within the exact mapped length and // accesses are synchronized within this test. @@ -227,5 +280,13 @@ mod tests { assert_eq!(opened.as_ptr().read(), 17); assert_eq!(opened.as_ptr().add(PRODUCTION_SIZE - 1).read(), 29); } + + let (logical_size, endpoint_allocation) = sys::file_sizes(backing_file).unwrap(); + assert_eq!(logical_size, PRODUCTION_SIZE as u64); + assert!(endpoint_allocation < MAX_ENDPOINT_ALLOCATION); + } + + fn test_backing_file() -> File { + create_backing_file(&Uuid::new_v4().simple().to_string()).unwrap() } } diff --git a/crates/fspy_shm/src/windows/sys.rs b/crates/fspy_shm/src/windows/sys.rs index 808b4f67d..0bc4c9d94 100644 --- a/crates/fspy_shm/src/windows/sys.rs +++ b/crates/fspy_shm/src/windows/sys.rs @@ -5,15 +5,23 @@ use std::{ ptr::NonNull, }; +#[cfg(test)] +use windows_sys::Win32::Storage::FileSystem::{ + FILE_STANDARD_INFO, FileStandardInfo, GetFileInformationByHandleEx, +}; use windows_sys::Win32::{ - Foundation::{ERROR_ALREADY_EXISTS, GetLastError}, + Foundation::{ERROR_ALREADY_EXISTS, ERROR_INVALID_FUNCTION, ERROR_NOT_SUPPORTED, GetLastError}, Storage::FileSystem::{ FILE_ATTRIBUTE_TEMPORARY, FILE_FLAG_DELETE_ON_CLOSE, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, }, - System::Memory::{ - CreateFileMappingW, FILE_MAP_WRITE, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, - OpenFileMappingW, PAGE_READWRITE, UnmapViewOfFile, + System::{ + IO::DeviceIoControl, + Ioctl::FSCTL_SET_SPARSE, + Memory::{ + CreateFileMappingW, FILE_MAP_WRITE, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, + OpenFileMappingW, PAGE_READWRITE, UnmapViewOfFile, + }, }, }; @@ -21,6 +29,61 @@ pub(super) const SHARE_ALL: u32 = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHAR pub(super) const TEMPORARY: u32 = FILE_ATTRIBUTE_TEMPORARY; pub(super) const DELETE_ON_CLOSE: u32 = FILE_FLAG_DELETE_ON_CLOSE; +pub(super) fn set_sparse(file: &std::fs::File) -> io::Result<()> { + let mut bytes_returned = 0; + // SAFETY: `file` supplies a valid synchronous file handle. FSCTL_SET_SPARSE + // requires no input or output buffers, and `bytes_returned` is writable for + // the duration of the call. + let result = unsafe { + DeviceIoControl( + file.as_raw_handle().cast(), + FSCTL_SET_SPARSE, + std::ptr::null(), + 0, + std::ptr::null_mut(), + 0, + &raw mut bytes_returned, + std::ptr::null_mut(), + ) + }; + if result == 0 { Err(last_error()) } else { Ok(()) } +} + +pub(super) fn is_sparse_unsupported(error: &io::Error) -> bool { + matches!( + error.raw_os_error(), + Some(code) + if code == ERROR_INVALID_FUNCTION.cast_signed() + || code == ERROR_NOT_SUPPORTED.cast_signed() + ) +} + +#[cfg(test)] +pub(super) fn file_sizes(file: &std::fs::File) -> io::Result<(u64, u64)> { + let mut info = FILE_STANDARD_INFO::default(); + let info_size = u32::try_from(std::mem::size_of::()) + .map_err(|_| io::Error::other("file size information is too large"))?; + // SAFETY: `file` supplies a valid handle and `info` is a writable + // FILE_STANDARD_INFO buffer of exactly `info_size` bytes. + let result = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle().cast(), + FileStandardInfo, + (&raw mut info).cast(), + info_size, + ) + }; + if result == 0 { + return Err(last_error()); + } + + let logical_size = u64::try_from(info.EndOfFile) + .map_err(|_| io::Error::other("file has a negative logical size"))?; + let allocated_size = u64::try_from(info.AllocationSize) + .map_err(|_| io::Error::other("file has a negative allocated size"))?; + Ok((logical_size, allocated_size)) +} + pub(super) fn create_file_mapping(file: &std::fs::File, name: &str) -> io::Result { let name = wide_name(name); // SAFETY: `file` supplies a valid handle, the security pointer is null, and From 7426a275b4f100cc3884a04eec90b12c821bf474 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 21:14:45 +0800 Subject: [PATCH 05/21] docs(fspy): explain Windows shared memory design Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/windows/mod.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/fspy_shm/src/windows/mod.rs b/crates/fspy_shm/src/windows/mod.rs index 8e22e5e99..126c2056f 100644 --- a/crates/fspy_shm/src/windows/mod.rs +++ b/crates/fspy_shm/src/windows/mod.rs @@ -1,3 +1,29 @@ +//! Windows shared-memory backend. +//! +//! fspy's production channel uses a 4 GiB logical mapping. Multiple processes +//! append records through atomics and pointer writes, so each record must not +//! require a system call. +//! +//! Windows page-file-backed named mappings offer two allocation modes. With +//! `SEC_COMMIT`, Windows charges the whole mapping against system commit when +//! a process maps the view. `SEC_RESERVE` avoids that charge, but a writer must +//! call `VirtualAlloc` before it touches a reserved page. Committing large +//! chunks would keep system calls off the normal write path, but it would +//! require a cross-process protocol that commits a chunk before publishing +//! space to writers and recovers if the process doing the commit exits. +//! +//! This backend uses a sparse temporary file. `FSCTL_SET_SPARSE` leaves unused +//! ranges unallocated, while the mapped view gives writers the pointer-based +//! fast path. `FILE_ATTRIBUTE_TEMPORARY` asks Windows to retain dirty data in +//! memory when cache is available, and `FILE_FLAG_DELETE_ON_CLOSE` cleans up +//! the file when its owner drops. +//! +//! Windows may write dirty mapped pages to the file under memory pressure, so +//! this design does not guarantee zero disk I/O. It avoids an upfront 4 GiB +//! physical allocation and per-record system calls with less synchronization +//! machinery than `SEC_RESERVE`. Reconsider this choice if benchmarks under +//! memory pressure show that file-backed writeback affects task performance. + mod sys; use std::{ From ded5b36d4b63add4dd58f0690c50ebabf4b673c1 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 21:34:23 +0800 Subject: [PATCH 06/21] refactor(fspy): simplify sparse Windows backend Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/windows/mod.rs | 35 +++++------------------------- 1 file changed, 5 insertions(+), 30 deletions(-) diff --git a/crates/fspy_shm/src/windows/mod.rs b/crates/fspy_shm/src/windows/mod.rs index 126c2056f..5eaa4265d 100644 --- a/crates/fspy_shm/src/windows/mod.rs +++ b/crates/fspy_shm/src/windows/mod.rs @@ -1,29 +1,3 @@ -//! Windows shared-memory backend. -//! -//! fspy's production channel uses a 4 GiB logical mapping. Multiple processes -//! append records through atomics and pointer writes, so each record must not -//! require a system call. -//! -//! Windows page-file-backed named mappings offer two allocation modes. With -//! `SEC_COMMIT`, Windows charges the whole mapping against system commit when -//! a process maps the view. `SEC_RESERVE` avoids that charge, but a writer must -//! call `VirtualAlloc` before it touches a reserved page. Committing large -//! chunks would keep system calls off the normal write path, but it would -//! require a cross-process protocol that commits a chunk before publishing -//! space to writers and recovers if the process doing the commit exits. -//! -//! This backend uses a sparse temporary file. `FSCTL_SET_SPARSE` leaves unused -//! ranges unallocated, while the mapped view gives writers the pointer-based -//! fast path. `FILE_ATTRIBUTE_TEMPORARY` asks Windows to retain dirty data in -//! memory when cache is available, and `FILE_FLAG_DELETE_ON_CLOSE` cleans up -//! the file when its owner drops. -//! -//! Windows may write dirty mapped pages to the file under memory pressure, so -//! this design does not guarantee zero disk I/O. It avoids an upfront 4 GiB -//! physical allocation and per-record system calls with less synchronization -//! machinery than `SEC_RESERVE`. Reconsider this choice if benchmarks under -//! memory pressure show that file-backed writeback affects task performance. - mod sys; use std::{ @@ -45,7 +19,8 @@ const BACKING_DIR: &str = "vite-task-fspy"; pub struct Shm { id: String, view: MappedView, - _backing_file: Option, + #[cfg_attr(not(test), expect(dead_code, reason = "retained for owner cleanup"))] + backing_file: Option, } /// Creates a sparse, temporary file-backed named mapping of `size` bytes and @@ -62,7 +37,7 @@ pub fn create(size: usize) -> io::Result { let mapping = sys::create_file_mapping(&backing_file, &mapping_name(&id, size))?; let view = MappedView::new(&mapping, size)?; - Ok(Shm { id, view, _backing_file: Some(backing_file) }) + Ok(Shm { id, view, backing_file: Some(backing_file) }) } /// Opens the named mapping identified by `id`. @@ -76,7 +51,7 @@ pub fn open(id: &str, size: usize) -> io::Result { let mapping = sys::open_file_mapping(&mapping_name(id, size))?; let view = MappedView::new(&mapping, size)?; - Ok(Shm { id: id.to_owned(), view, _backing_file: None }) + Ok(Shm { id: id.to_owned(), view, backing_file: None }) } fn valid_size(size: usize) -> io::Result { @@ -292,7 +267,7 @@ mod tests { const MAX_ENDPOINT_ALLOCATION: u64 = 16 * 1024 * 1024; let owner = create(PRODUCTION_SIZE).unwrap(); - let backing_file = owner._backing_file.as_ref().unwrap(); + let backing_file = owner.backing_file.as_ref().unwrap(); let (logical_size, initial_allocation) = sys::file_sizes(backing_file).unwrap(); assert_eq!(logical_size, PRODUCTION_SIZE as u64); assert!(initial_allocation < MAX_ENDPOINT_ALLOCATION); From ea957d624fc03f0a847892b35dcd65db196a83aa Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 21:44:53 +0800 Subject: [PATCH 07/21] refactor(fspy): remove redundant Windows id validation Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/windows/mod.rs | 30 ++---------------------------- crates/fspy_shm/src/windows/sys.rs | 14 ++++++++++---- 2 files changed, 12 insertions(+), 32 deletions(-) diff --git a/crates/fspy_shm/src/windows/mod.rs b/crates/fspy_shm/src/windows/mod.rs index 5eaa4265d..429ad25ba 100644 --- a/crates/fspy_shm/src/windows/mod.rs +++ b/crates/fspy_shm/src/windows/mod.rs @@ -44,10 +44,9 @@ pub fn create(size: usize) -> io::Result { /// /// # Errors /// -/// Returns an error if the identifier is invalid or the mapping is unavailable. +/// Returns an error if the mapping is unavailable. pub fn open(id: &str, size: usize) -> io::Result { valid_size(size)?; - validate_id(id)?; let mapping = sys::open_file_mapping(&mapping_name(id, size))?; let view = MappedView::new(&mapping, size)?; @@ -65,18 +64,6 @@ fn valid_size(size: usize) -> io::Result { .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "shared-memory size exceeds u64")) } -fn validate_id(id: &str) -> io::Result<()> { - let uuid = Uuid::parse_str(id).map_err(|_| invalid_id())?; - if uuid.simple().to_string() != id { - return Err(invalid_id()); - } - Ok(()) -} - -fn invalid_id() -> io::Error { - io::Error::new(io::ErrorKind::InvalidInput, "invalid Windows shared-memory identifier") -} - fn mapping_name(id: &str, size: usize) -> String { format!("{MAPPING_NAME_PREFIX}{id}-{size}") } @@ -163,21 +150,8 @@ mod tests { const SIZE: usize = 64 * 1024; #[test] - fn identifiers_are_canonical_simple_uuids() { - let owner = create(SIZE).unwrap(); - let uuid = Uuid::parse_str(owner.id()).unwrap(); - - assert_eq!(uuid.simple().to_string(), owner.id()); - } - - #[test] - fn malformed_ids_and_size_mismatches_are_rejected() { + fn invalid_sizes_are_rejected() { let owner = create(SIZE).unwrap(); - let uuid = Uuid::parse_str(owner.id()).unwrap(); - for id in [String::new(), uuid.hyphenated().to_string(), format!("{}0", owner.id())] { - assert_eq!(open(&id, SIZE).err().unwrap().kind(), io::ErrorKind::InvalidInput); - } - assert_eq!(open(owner.id(), 0).err().unwrap().kind(), io::ErrorKind::InvalidInput); assert!(open(owner.id(), SIZE / 2).is_err()); assert!(open(owner.id(), SIZE + 1).is_err()); diff --git a/crates/fspy_shm/src/windows/sys.rs b/crates/fspy_shm/src/windows/sys.rs index 0bc4c9d94..0ae8360ac 100644 --- a/crates/fspy_shm/src/windows/sys.rs +++ b/crates/fspy_shm/src/windows/sys.rs @@ -85,7 +85,7 @@ pub(super) fn file_sizes(file: &std::fs::File) -> io::Result<(u64, u64)> { } pub(super) fn create_file_mapping(file: &std::fs::File, name: &str) -> io::Result { - let name = wide_name(name); + let name = wide_name(name)?; // SAFETY: `file` supplies a valid handle, the security pointer is null, and // `name` is a live, NUL-terminated UTF-16 buffer for the duration of the call. let raw_handle = unsafe { @@ -116,7 +116,7 @@ pub(super) fn create_file_mapping(file: &std::fs::File, name: &str) -> io::Resul } pub(super) fn open_file_mapping(name: &str) -> io::Result { - let name = wide_name(name); + let name = wide_name(name)?; // SAFETY: `name` is a live, NUL-terminated UTF-16 buffer and inheritance is disabled. let raw_handle = unsafe { OpenFileMappingW(FILE_MAP_WRITE, 0, name.as_ptr()) }; if raw_handle.is_null() { @@ -161,8 +161,14 @@ impl Drop for MappedView { } } -fn wide_name(name: &str) -> Vec { - name.encode_utf16().chain(once(0)).collect() +fn wide_name(name: &str) -> io::Result> { + if name.contains('\0') { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "shared-memory name contains a NUL", + )); + } + Ok(name.encode_utf16().chain(once(0)).collect()) } fn last_error() -> io::Error { From bffde9716a64c0c7c854b43044bcf9af2f71e074 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 21:48:17 +0800 Subject: [PATCH 08/21] refactor(fspy): inline Windows sparse setup Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/windows/mod.rs | 85 +++++------------------------- crates/fspy_shm/src/windows/sys.rs | 15 ++++-- 2 files changed, 23 insertions(+), 77 deletions(-) diff --git a/crates/fspy_shm/src/windows/mod.rs b/crates/fspy_shm/src/windows/mod.rs index 429ad25ba..e0e65770f 100644 --- a/crates/fspy_shm/src/windows/mod.rs +++ b/crates/fspy_shm/src/windows/mod.rs @@ -5,7 +5,6 @@ use std::{ fs::{self, File, OpenOptions}, io, os::windows::fs::OpenOptionsExt, - path::PathBuf, slice, }; @@ -32,8 +31,18 @@ pub struct Shm { pub fn create(size: usize) -> io::Result { let size_u64 = valid_size(size)?; let id = Uuid::new_v4().simple().to_string(); - let backing_file = create_backing_file(&id)?; - initialize_backing_file(&backing_file, size_u64)?; + let backing_dir = temp_dir().join(BACKING_DIR); + fs::create_dir_all(&backing_dir)?; + let backing_file = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .share_mode(sys::SHARE_ALL) + .attributes(sys::TEMPORARY) + .custom_flags(sys::DELETE_ON_CLOSE) + .open(backing_dir.join(format!("{id}.shm")))?; + sys::set_sparse(&backing_file)?; + backing_file.set_len(size_u64)?; let mapping = sys::create_file_mapping(&backing_file, &mapping_name(&id, size))?; let view = MappedView::new(&mapping, size)?; @@ -68,40 +77,6 @@ fn mapping_name(id: &str, size: usize) -> String { format!("{MAPPING_NAME_PREFIX}{id}-{size}") } -fn backing_path(id: &str) -> PathBuf { - temp_dir().join(BACKING_DIR).join(format!("{id}.shm")) -} - -fn create_backing_file(id: &str) -> io::Result { - let path = backing_path(id); - fs::create_dir_all(path.parent().unwrap())?; - OpenOptions::new() - .read(true) - .write(true) - .create_new(true) - .share_mode(sys::SHARE_ALL) - .attributes(sys::TEMPORARY) - .custom_flags(sys::DELETE_ON_CLOSE) - .open(path) -} - -fn initialize_backing_file(file: &File, size: u64) -> io::Result<()> { - initialize_backing_file_with(file, size, sys::set_sparse) -} - -fn initialize_backing_file_with( - file: &File, - size: u64, - set_sparse: impl FnOnce(&File) -> io::Result<()>, -) -> io::Result<()> { - if let Err(error) = set_sparse(file) - && !sys::is_sparse_unsupported(&error) - { - return Err(error); - } - file.set_len(size) -} - #[expect(clippy::len_without_is_empty, reason = "shared-memory mappings are always non-empty")] impl Shm { /// Returns this mapping's opaque Windows identifier. @@ -141,9 +116,6 @@ mod tests { use std::{ffi::OsString, fs, process::Command}; use subprocess_test::command_for_fn; - use windows_sys::Win32::Foundation::{ - ERROR_ACCESS_DENIED, ERROR_INVALID_FUNCTION, ERROR_INVALID_PARAMETER, ERROR_NOT_SUPPORTED, - }; use super::*; @@ -190,7 +162,7 @@ mod tests { fn owner_cleanup_deletes_backing_file_and_preserves_existing_views() { let owner = create(SIZE).unwrap(); let id = owner.id().to_owned(); - let path = backing_path(&id); + let path = temp_dir().join(BACKING_DIR).join(format!("{id}.shm")); let opened = open(&id, SIZE).unwrap(); assert!(path.exists()); @@ -207,33 +179,6 @@ mod tests { assert!(open(&id, SIZE).is_err()); } - #[test] - fn unsupported_sparse_errors_fall_back_to_extending_the_file() { - for code in [ERROR_INVALID_FUNCTION, ERROR_NOT_SUPPORTED] { - let file = test_backing_file(); - initialize_backing_file_with(&file, SIZE as u64, |_| { - Err(io::Error::from_raw_os_error(code.cast_signed())) - }) - .unwrap(); - - assert_eq!(file.metadata().unwrap().len(), SIZE as u64); - } - } - - #[test] - fn other_sparse_errors_do_not_extend_the_file() { - for code in [ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER] { - let file = test_backing_file(); - let error = initialize_backing_file_with(&file, SIZE as u64, |_| { - Err(io::Error::from_raw_os_error(code.cast_signed())) - }) - .unwrap_err(); - - assert_eq!(error.raw_os_error(), Some(code.cast_signed())); - assert_eq!(file.metadata().unwrap().len(), 0); - } - } - #[cfg(target_pointer_width = "64")] #[test] fn four_gib_mapping_is_sparse_and_supports_endpoint_access() { @@ -260,8 +205,4 @@ mod tests { assert_eq!(logical_size, PRODUCTION_SIZE as u64); assert!(endpoint_allocation < MAX_ENDPOINT_ALLOCATION); } - - fn test_backing_file() -> File { - create_backing_file(&Uuid::new_v4().simple().to_string()).unwrap() - } } diff --git a/crates/fspy_shm/src/windows/sys.rs b/crates/fspy_shm/src/windows/sys.rs index 0ae8360ac..53574aa1b 100644 --- a/crates/fspy_shm/src/windows/sys.rs +++ b/crates/fspy_shm/src/windows/sys.rs @@ -46,16 +46,21 @@ pub(super) fn set_sparse(file: &std::fs::File) -> io::Result<()> { std::ptr::null_mut(), ) }; - if result == 0 { Err(last_error()) } else { Ok(()) } -} + if result != 0 { + return Ok(()); + } -pub(super) fn is_sparse_unsupported(error: &io::Error) -> bool { - matches!( + let error = last_error(); + if matches!( error.raw_os_error(), Some(code) if code == ERROR_INVALID_FUNCTION.cast_signed() || code == ERROR_NOT_SUPPORTED.cast_signed() - ) + ) { + Ok(()) + } else { + Err(error) + } } #[cfg(test)] From 528e9f3f9692492fa166a4a55dcec4a5402c3ab2 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 21:49:16 +0800 Subject: [PATCH 09/21] docs: clarify Windows shared memory improvement Co-authored-by: GPT-5 Codex --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aed885f2..5e3c6485a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Improved** Windows file-access tracking now uses sparse temporary backing files where supported, avoiding an upfront 4 GiB disk allocation per task. +- **Improved** Windows file-access tracking now uses sparse temporary backing files where supported, avoiding upfront allocation of the full backing file on disk ([#524](https://github.com/voidzero-dev/vite-task/pull/524)). - **Fixed** Automatic file-access tracking on Linux now works in containers and Kubernetes runners with limited `/dev/shm` space ([#353](https://github.com/voidzero-dev/vite-task/issues/353), [#523](https://github.com/voidzero-dev/vite-task/pull/523)). - **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)). - **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)). From c0667f4c941fc68fdcceb50fda96ef91b5261819 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 21:52:32 +0800 Subject: [PATCH 10/21] refactor(fspy): remove redundant Windows open validation Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/windows/mod.rs | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/crates/fspy_shm/src/windows/mod.rs b/crates/fspy_shm/src/windows/mod.rs index e0e65770f..4581ce8db 100644 --- a/crates/fspy_shm/src/windows/mod.rs +++ b/crates/fspy_shm/src/windows/mod.rs @@ -29,7 +29,15 @@ pub struct Shm { /// /// Returns an error if the backing file or mapping cannot be created. pub fn create(size: usize) -> io::Result { - let size_u64 = valid_size(size)?; + if size == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "shared-memory size must be nonzero", + )); + } + let size_u64 = u64::try_from(size).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "shared-memory size exceeds u64") + })?; let id = Uuid::new_v4().simple().to_string(); let backing_dir = temp_dir().join(BACKING_DIR); fs::create_dir_all(&backing_dir)?; @@ -55,24 +63,12 @@ pub fn create(size: usize) -> io::Result { /// /// Returns an error if the mapping is unavailable. pub fn open(id: &str, size: usize) -> io::Result { - valid_size(size)?; let mapping = sys::open_file_mapping(&mapping_name(id, size))?; let view = MappedView::new(&mapping, size)?; Ok(Shm { id: id.to_owned(), view, backing_file: None }) } -fn valid_size(size: usize) -> io::Result { - if size == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "shared-memory size must be nonzero", - )); - } - u64::try_from(size) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "shared-memory size exceeds u64")) -} - fn mapping_name(id: &str, size: usize) -> String { format!("{MAPPING_NAME_PREFIX}{id}-{size}") } @@ -122,9 +118,8 @@ mod tests { const SIZE: usize = 64 * 1024; #[test] - fn invalid_sizes_are_rejected() { + fn size_mismatches_are_rejected() { let owner = create(SIZE).unwrap(); - assert_eq!(open(owner.id(), 0).err().unwrap().kind(), io::ErrorKind::InvalidInput); assert!(open(owner.id(), SIZE / 2).is_err()); assert!(open(owner.id(), SIZE + 1).is_err()); } From 59ce6aa6e52cc4eca466dd11f8b14161bbe6cb87 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 21:54:26 +0800 Subject: [PATCH 11/21] refactor(fspy): require sparse Windows backing files Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/windows/sys.rs | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/crates/fspy_shm/src/windows/sys.rs b/crates/fspy_shm/src/windows/sys.rs index 53574aa1b..ce05cb04e 100644 --- a/crates/fspy_shm/src/windows/sys.rs +++ b/crates/fspy_shm/src/windows/sys.rs @@ -10,7 +10,7 @@ use windows_sys::Win32::Storage::FileSystem::{ FILE_STANDARD_INFO, FileStandardInfo, GetFileInformationByHandleEx, }; use windows_sys::Win32::{ - Foundation::{ERROR_ALREADY_EXISTS, ERROR_INVALID_FUNCTION, ERROR_NOT_SUPPORTED, GetLastError}, + Foundation::{ERROR_ALREADY_EXISTS, GetLastError}, Storage::FileSystem::{ FILE_ATTRIBUTE_TEMPORARY, FILE_FLAG_DELETE_ON_CLOSE, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, @@ -46,21 +46,7 @@ pub(super) fn set_sparse(file: &std::fs::File) -> io::Result<()> { std::ptr::null_mut(), ) }; - if result != 0 { - return Ok(()); - } - - let error = last_error(); - if matches!( - error.raw_os_error(), - Some(code) - if code == ERROR_INVALID_FUNCTION.cast_signed() - || code == ERROR_NOT_SUPPORTED.cast_signed() - ) { - Ok(()) - } else { - Err(error) - } + if result == 0 { Err(last_error()) } else { Ok(()) } } #[cfg(test)] From cbb23bc4e3a3f4e46c0d58ab5ad11401b80dd56a Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 22:35:09 +0800 Subject: [PATCH 12/21] docs(fspy): explain Windows shared memory design Co-authored-by: GPT-5 Codex --- crates/fspy_shm/README.md | 3 +- crates/fspy_shm/src/windows/README.md | 101 ++++++++++++++++++++++++++ crates/fspy_shm/src/windows/mod.rs | 2 + 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 crates/fspy_shm/src/windows/README.md diff --git a/crates/fspy_shm/README.md b/crates/fspy_shm/README.md index 9ae93f391..37b3d0489 100644 --- a/crates/fspy_shm/README.md +++ b/crates/fspy_shm/README.md @@ -34,5 +34,6 @@ The channel hides that difference with its lock file. [`ChannelConf::sender`](.. Each platform keeps its implementation rationale beside its source: - [Linux: `memfd` with a descriptor broker](src/linux/README.md) +- [Windows: sparse file-backed named mapping](src/windows/README.md) -At this point in the stack, Windows and macOS still delegate mapping creation and opening to the [`shared_memory`](https://crates.io/crates/shared_memory) crate. +At this point in the stack, macOS still delegates mapping creation and opening to the [`shared_memory`](https://crates.io/crates/shared_memory) crate. diff --git a/crates/fspy_shm/src/windows/README.md b/crates/fspy_shm/src/windows/README.md new file mode 100644 index 000000000..5f480f1f6 --- /dev/null +++ b/crates/fspy_shm/src/windows/README.md @@ -0,0 +1,101 @@ +# Windows backend + +The Windows backend creates a sparse temporary file and exposes it through a +named file-mapping object. The file supplies pageable backing storage. Other +processes discover the mapping by its kernel-object name and never open the +file path. + +## Runtime flow + +`create(size)` performs these steps: + +1. Generate a random identifier and create a unique file below the creator's + temporary directory. +2. Open the file read-write with read, write, and delete sharing, plus + `FILE_ATTRIBUTE_TEMPORARY` and `FILE_FLAG_DELETE_ON_CLOSE`. +3. Mark the file sparse with `FSCTL_SET_SPARSE`. +4. Set its logical length without allocating the full file on disk. +5. Create a read-write mapping named + `Local\vite-task-fspy-{identifier}-{size}`. +6. Map an exact-size view and return it with the backing-file handle. + +`open(id, size)` derives the same object name, calls `OpenFileMappingW`, and +maps an exact-size view. It does not resolve a temporary directory, so a child +can change `TEMP`, `TMP`, or its working directory without losing access. + +The mapping handle can close after `MapViewOfFile` succeeds. Windows keeps an +internal reference to the section for each mapped view. + +## Requirements + +The fspy channel needs a large logical mapping with these properties: + +- creating it must not consume its full size in physical memory, system commit, + or disk allocation, +- processes must open it from an opaque string identifier, +- concurrent writers must use ordinary memory and atomic operations instead + of a syscall for every record, +- opening must not depend on the creator's temporary-directory environment, + and +- existing views must survive owner cleanup. + +## Options considered + +| Option | Decision | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| Paging-file-backed named mapping | Rejected. The default committed section reserves the full mapping against the system commit limit even though physical pages remain demand-paged. | +| Reserved section with incremental `VirtualAlloc(MEM_COMMIT)` | Rejected. It needs a shared committed frontier, chunk-refill coordination, and boundary handling across concurrent writers. | +| Named pipe or Unix-domain socket data path | Rejected. Every record would require a syscall and descriptor-buffer management under concurrent writes. | +| Regular temporary file mapping | Rejected. Extending a normal file cannot guarantee that the full logical size remains unallocated on disk. | +| Sparse temporary file mapping | Selected. It preserves memory-style writes, allocates backing ranges on demand, and supports a native named mapping object. | + +[`FILE_ATTRIBUTE_TEMPORARY`](https://learn.microsoft.com/windows/win32/api/fileapi/ns-fileapi-createfile2_extended_parameters) +tells Windows to retain modified pages in cache when possible because the file +is short-lived. It does not guarantee that dirty pages never reach disk under +memory pressure. [Sparseness](https://learn.microsoft.com/windows/win32/fileio/sparse-files) +prevents allocation for untouched ranges; it does not turn a file mapping into +anonymous memory. + +## Why not `shared_memory` + +The `shared_memory` Windows backend creates and extends its own regular backing +file. Its API does not expose the file before `CreateFileMappingW`, so fspy +cannot mark it sparse before establishing the mapping size. It therefore +cannot guarantee the allocation behavior required by the channel. + +The crate also implements persistence through a backing-file rendezvous. +Fspy already has a serialized lock-file path that gates sender creation, while +the Windows mapping must remain openable when a child has a different +temporary-directory environment. Reusing that persistence layer would add a +second lifecycle protocol without solving sparse allocation. + +`memmap2` is not a replacement for this backend. On Windows it creates an +unnamed mapping from a file handle and does not provide a public API for +opening an existing mapping object by name. + +## Lifetime + +The owner retains the backing-file handle. Dropping it unmaps the owner's view +and closes the delete-on-close file. Views opened by other processes remain +valid and keep the section object alive. + +While any view remains, Windows may still allow `OpenFileMappingW` to open the +section name after the owner has dropped. This does not bypass the fspy channel +lifetime: `ChannelConf::sender` must first open the receiver's lock-file path, +and the receiver removes that path before dropping the mapping owner. + +The backend requires sparse-file support. It returns the `FSCTL_SET_SPARSE` +error instead of silently falling back to a fully allocated file. NTFS and +ReFS support sparse files; a temporary directory on an unsupported filesystem +cannot host this mapping. + +## Verification + +The module tests verify that: + +- another process can open the mapping without sharing the creator's current + directory or temporary-directory environment, +- dropping the owner deletes the backing file without invalidating an opened + view, +- the production-size mapping does not allocate its full logical length, and +- writes at both ends of the mapping are visible through another view. diff --git a/crates/fspy_shm/src/windows/mod.rs b/crates/fspy_shm/src/windows/mod.rs index 4581ce8db..b20d8cd44 100644 --- a/crates/fspy_shm/src/windows/mod.rs +++ b/crates/fspy_shm/src/windows/mod.rs @@ -1,3 +1,5 @@ +#![doc = include_str!("README.md")] + mod sys; use std::{ From 90681f69404ce619748131803dfa4715327fefb3 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 22:38:17 +0800 Subject: [PATCH 13/21] docs(fspy): fix Windows backend rustdoc Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/windows/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fspy_shm/src/windows/README.md b/crates/fspy_shm/src/windows/README.md index 5f480f1f6..bf6067f45 100644 --- a/crates/fspy_shm/src/windows/README.md +++ b/crates/fspy_shm/src/windows/README.md @@ -86,7 +86,7 @@ and the receiver removes that path before dropping the mapping owner. The backend requires sparse-file support. It returns the `FSCTL_SET_SPARSE` error instead of silently falling back to a fully allocated file. NTFS and -ReFS support sparse files; a temporary directory on an unsupported filesystem +`ReFS` support sparse files; a temporary directory on an unsupported filesystem cannot host this mapping. ## Verification From 0c32f108be5016c505ed15370b3d4cf843d62e9e Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 22:44:27 +0800 Subject: [PATCH 14/21] docs(fspy): focus Windows docs on design choices Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/windows/README.md | 110 +++++++++----------------- 1 file changed, 39 insertions(+), 71 deletions(-) diff --git a/crates/fspy_shm/src/windows/README.md b/crates/fspy_shm/src/windows/README.md index bf6067f45..be8fb5308 100644 --- a/crates/fspy_shm/src/windows/README.md +++ b/crates/fspy_shm/src/windows/README.md @@ -1,40 +1,19 @@ # Windows backend -The Windows backend creates a sparse temporary file and exposes it through a -named file-mapping object. The file supplies pageable backing storage. Other -processes discover the mapping by its kernel-object name and never open the +The Windows backend maps a sparse temporary file through a named Windows +section object. The sparse file avoids full disk allocation, while the named +section lets another process open the same bytes without knowing the backing file path. -## Runtime flow - -`create(size)` performs these steps: - -1. Generate a random identifier and create a unique file below the creator's - temporary directory. -2. Open the file read-write with read, write, and delete sharing, plus - `FILE_ATTRIBUTE_TEMPORARY` and `FILE_FLAG_DELETE_ON_CLOSE`. -3. Mark the file sparse with `FSCTL_SET_SPARSE`. -4. Set its logical length without allocating the full file on disk. -5. Create a read-write mapping named - `Local\vite-task-fspy-{identifier}-{size}`. -6. Map an exact-size view and return it with the backing-file handle. - -`open(id, size)` derives the same object name, calls `OpenFileMappingW`, and -maps an exact-size view. It does not resolve a temporary directory, so a child -can change `TEMP`, `TMP`, or its working directory without losing access. - -The mapping handle can close after `MapViewOfFile` succeeds. Windows keeps an -internal reference to the section for each mapped view. - ## Requirements The fspy channel needs a large logical mapping with these properties: -- creating it must not consume its full size in physical memory, system commit, - or disk allocation, -- processes must open it from an opaque string identifier, -- concurrent writers must use ordinary memory and atomic operations instead - of a syscall for every record, +- creation must not consume the full mapping in physical memory, system + commit, or disk allocation, +- another process must be able to open it from a serialized identifier, +- concurrent writers must use memory and atomic operations instead of a + syscall for every record, - opening must not depend on the creator's temporary-directory environment, and - existing views must survive owner cleanup. @@ -45,57 +24,46 @@ The fspy channel needs a large logical mapping with these properties: | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | | Paging-file-backed named mapping | Rejected. The default committed section reserves the full mapping against the system commit limit even though physical pages remain demand-paged. | | Reserved section with incremental `VirtualAlloc(MEM_COMMIT)` | Rejected. It needs a shared committed frontier, chunk-refill coordination, and boundary handling across concurrent writers. | -| Named pipe or Unix-domain socket data path | Rejected. Every record would require a syscall and descriptor-buffer management under concurrent writes. | +| Named pipe or Unix-domain socket data path | Rejected. Every record would require a syscall and buffer management under concurrent writes. | | Regular temporary file mapping | Rejected. Extending a normal file cannot guarantee that the full logical size remains unallocated on disk. | -| Sparse temporary file mapping | Selected. It preserves memory-style writes, allocates backing ranges on demand, and supports a native named mapping object. | +| Sparse temporary file mapping | Selected. It preserves memory-style writes, allocates backing ranges on demand, and supports a native named section. | +The backing file is marked sparse before its logical length is established. [`FILE_ATTRIBUTE_TEMPORARY`](https://learn.microsoft.com/windows/win32/api/fileapi/ns-fileapi-createfile2_extended_parameters) -tells Windows to retain modified pages in cache when possible because the file -is short-lived. It does not guarantee that dirty pages never reach disk under -memory pressure. [Sparseness](https://learn.microsoft.com/windows/win32/fileio/sparse-files) -prevents allocation for untouched ranges; it does not turn a file mapping into -anonymous memory. +asks Windows to retain modified pages in cache when possible. It does not +guarantee that dirty pages never reach disk under memory pressure. +[Sparseness](https://learn.microsoft.com/windows/win32/fileio/sparse-files) +prevents allocation for untouched ranges, but nonzero writes can allocate disk +space. + +Sparse-file support is required. The backend returns the sparse-control error +instead of silently falling back to a fully allocated file. NTFS and `ReFS` +support sparse files; a temporary directory on an unsupported filesystem +cannot host the mapping. ## Why not `shared_memory` The `shared_memory` Windows backend creates and extends its own regular backing -file. Its API does not expose the file before `CreateFileMappingW`, so fspy -cannot mark it sparse before establishing the mapping size. It therefore -cannot guarantee the allocation behavior required by the channel. - -The crate also implements persistence through a backing-file rendezvous. -Fspy already has a serialized lock-file path that gates sender creation, while -the Windows mapping must remain openable when a child has a different -temporary-directory environment. Reusing that persistence layer would add a -second lifecycle protocol without solving sparse allocation. - -`memmap2` is not a replacement for this backend. On Windows it creates an -unnamed mapping from a file handle and does not provide a public API for -opening an existing mapping object by name. - -## Lifetime - -The owner retains the backing-file handle. Dropping it unmaps the owner's view -and closes the delete-on-close file. Views opened by other processes remain -valid and keep the section object alive. +file. Its API does not expose that file before creating the mapping, so fspy +cannot mark it sparse before establishing the logical size. It cannot +guarantee the allocation behavior required by the channel. -While any view remains, Windows may still allow `OpenFileMappingW` to open the -section name after the owner has dropped. This does not bypass the fspy channel -lifetime: `ChannelConf::sender` must first open the receiver's lock-file path, -and the receiver removes that path before dropping the mapping owner. +`shared_memory` also uses the backing-file path as part of its persistence +protocol. Fspy openers may have a different `TEMP`, `TMP`, or working directory +from the creator. The selected backend uses the named section as the only +discovery mechanism and uses the channel's existing lock file as its lifetime +gate. -The backend requires sparse-file support. It returns the `FSCTL_SET_SPARSE` -error instead of silently falling back to a fully allocated file. NTFS and -`ReFS` support sparse files; a temporary directory on an unsupported filesystem -cannot host this mapping. +`memmap2` does not replace the missing behavior. On Windows it creates an +unnamed mapping from a file handle and has no public API for opening an +existing section by name. -## Verification +## Lifetime semantics -The module tests verify that: +Dropping the owner unmaps its view and closes the delete-on-close backing file. +Views opened by other processes remain valid and keep the section alive. -- another process can open the mapping without sharing the creator's current - directory or temporary-directory environment, -- dropping the owner deletes the backing file without invalidating an opened - view, -- the production-size mapping does not allocate its full logical length, and -- writes at both ends of the mapping are visible through another view. +Windows may allow the section name to be opened while one of those views +remains. This does not bypass the channel lifetime. `ChannelConf::sender` must +first open the receiver's serialized lock-file path, and the receiver removes +that path before dropping the mapping owner. From 3bc79881d97171bbc537d6f8e9a97ecd19dce0db Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 22:53:15 +0800 Subject: [PATCH 15/21] docs(fspy): tighten Windows backend decision record Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/windows/README.md | 70 +++++++-------------------- 1 file changed, 18 insertions(+), 52 deletions(-) diff --git a/crates/fspy_shm/src/windows/README.md b/crates/fspy_shm/src/windows/README.md index be8fb5308..b2a5a1fcb 100644 --- a/crates/fspy_shm/src/windows/README.md +++ b/crates/fspy_shm/src/windows/README.md @@ -1,69 +1,35 @@ # Windows backend -The Windows backend maps a sparse temporary file through a named Windows -section object. The sparse file avoids full disk allocation, while the named -section lets another process open the same bytes without knowing the backing -file path. - -## Requirements - -The fspy channel needs a large logical mapping with these properties: - -- creation must not consume the full mapping in physical memory, system - commit, or disk allocation, -- another process must be able to open it from a serialized identifier, -- concurrent writers must use memory and atomic operations instead of a - syscall for every record, -- opening must not depend on the creator's temporary-directory environment, - and -- existing views must survive owner cleanup. +The Windows backend maps a sparse temporary file through a named section. This +provides name-based opens and a large logical mapping without charging its full +size to system commit or disk allocation. ## Options considered -| Option | Decision | -| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| Paging-file-backed named mapping | Rejected. The default committed section reserves the full mapping against the system commit limit even though physical pages remain demand-paged. | -| Reserved section with incremental `VirtualAlloc(MEM_COMMIT)` | Rejected. It needs a shared committed frontier, chunk-refill coordination, and boundary handling across concurrent writers. | -| Named pipe or Unix-domain socket data path | Rejected. Every record would require a syscall and buffer management under concurrent writes. | -| Regular temporary file mapping | Rejected. Extending a normal file cannot guarantee that the full logical size remains unallocated on disk. | -| Sparse temporary file mapping | Selected. It preserves memory-style writes, allocates backing ranges on demand, and supports a native named section. | +| Option | Decision | +| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | +| Paging-file-backed section | Rejected because the section's full size is charged against system commit. | +| Reserved section with incremental `VirtualAlloc(MEM_COMMIT)` | Rejected because concurrent writers would need a shared commit frontier and refill protocol. | +| Sparse temporary file with a named section | Selected. Untouched ranges consume neither disk allocation nor commit, and the section name provides process-independent discovery. | -The backing file is marked sparse before its logical length is established. [`FILE_ATTRIBUTE_TEMPORARY`](https://learn.microsoft.com/windows/win32/api/fileapi/ns-fileapi-createfile2_extended_parameters) -asks Windows to retain modified pages in cache when possible. It does not -guarantee that dirty pages never reach disk under memory pressure. +favors cache retention but does not prevent writeback under memory pressure. [Sparseness](https://learn.microsoft.com/windows/win32/fileio/sparse-files) -prevents allocation for untouched ranges, but nonzero writes can allocate disk -space. - -Sparse-file support is required. The backend returns the sparse-control error -instead of silently falling back to a fully allocated file. NTFS and `ReFS` -support sparse files; a temporary directory on an unsupported filesystem -cannot host the mapping. +avoids allocation only for untouched ranges. Creation fails if the temporary +volume does not support sparse files. ## Why not `shared_memory` -The `shared_memory` Windows backend creates and extends its own regular backing -file. Its API does not expose that file before creating the mapping, so fspy -cannot mark it sparse before establishing the logical size. It cannot -guarantee the allocation behavior required by the channel. +`shared_memory` creates and extends a regular backing file before callers can +mark it sparse, so it cannot provide the required allocation behavior. `shared_memory` also uses the backing-file path as part of its persistence -protocol. Fspy openers may have a different `TEMP`, `TMP`, or working directory -from the creator. The selected backend uses the named section as the only -discovery mechanism and uses the channel's existing lock file as its lifetime -gate. - -`memmap2` does not replace the missing behavior. On Windows it creates an -unnamed mapping from a file handle and has no public API for opening an -existing section by name. +protocol. Fspy uses the section name for discovery so an opener does not need +the creator's temporary-directory path. ## Lifetime semantics Dropping the owner unmaps its view and closes the delete-on-close backing file. -Views opened by other processes remain valid and keep the section alive. - -Windows may allow the section name to be opened while one of those views -remains. This does not bypass the channel lifetime. `ChannelConf::sender` must -first open the receiver's serialized lock-file path, and the receiver removes -that path before dropping the mapping owner. +Existing views keep the section alive. The section name may remain openable +during that period, so `ChannelConf::sender` uses the receiver's lock file as +the authoritative lifetime gate. From ff50a42db0713203e81e7e29564d2efd5c00a2a8 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 22:58:26 +0800 Subject: [PATCH 16/21] docs(fspy): keep Windows rationale prose continuous Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/windows/README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/fspy_shm/src/windows/README.md b/crates/fspy_shm/src/windows/README.md index b2a5a1fcb..44ac55f0e 100644 --- a/crates/fspy_shm/src/windows/README.md +++ b/crates/fspy_shm/src/windows/README.md @@ -12,11 +12,10 @@ size to system commit or disk allocation. | Reserved section with incremental `VirtualAlloc(MEM_COMMIT)` | Rejected because concurrent writers would need a shared commit frontier and refill protocol. | | Sparse temporary file with a named section | Selected. Untouched ranges consume neither disk allocation nor commit, and the section name provides process-independent discovery. | -[`FILE_ATTRIBUTE_TEMPORARY`](https://learn.microsoft.com/windows/win32/api/fileapi/ns-fileapi-createfile2_extended_parameters) -favors cache retention but does not prevent writeback under memory pressure. -[Sparseness](https://learn.microsoft.com/windows/win32/fileio/sparse-files) -avoids allocation only for untouched ranges. Creation fails if the temporary -volume does not support sparse files. +`FILE_ATTRIBUTE_TEMPORARY` favors cache retention but does not prevent +writeback under memory pressure. Sparseness avoids allocation only for +untouched ranges. Creation fails if the temporary volume does not support +sparse files. ## Why not `shared_memory` From 07bc5d113b4efd6d783530f18f279b841233954d Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 23:04:06 +0800 Subject: [PATCH 17/21] fix(fspy): retain Windows mapping handles Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/windows/mod.rs | 4 ++-- crates/fspy_shm/src/windows/sys.rs | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/fspy_shm/src/windows/mod.rs b/crates/fspy_shm/src/windows/mod.rs index b20d8cd44..56a7bda7e 100644 --- a/crates/fspy_shm/src/windows/mod.rs +++ b/crates/fspy_shm/src/windows/mod.rs @@ -54,7 +54,7 @@ pub fn create(size: usize) -> io::Result { sys::set_sparse(&backing_file)?; backing_file.set_len(size_u64)?; let mapping = sys::create_file_mapping(&backing_file, &mapping_name(&id, size))?; - let view = MappedView::new(&mapping, size)?; + let view = MappedView::new(mapping, size)?; Ok(Shm { id, view, backing_file: Some(backing_file) }) } @@ -66,7 +66,7 @@ pub fn create(size: usize) -> io::Result { /// Returns an error if the mapping is unavailable. pub fn open(id: &str, size: usize) -> io::Result { let mapping = sys::open_file_mapping(&mapping_name(id, size))?; - let view = MappedView::new(&mapping, size)?; + let view = MappedView::new(mapping, size)?; Ok(Shm { id: id.to_owned(), view, backing_file: None }) } diff --git a/crates/fspy_shm/src/windows/sys.rs b/crates/fspy_shm/src/windows/sys.rs index ce05cb04e..6019ced2c 100644 --- a/crates/fspy_shm/src/windows/sys.rs +++ b/crates/fspy_shm/src/windows/sys.rs @@ -121,16 +121,17 @@ pub(super) fn open_file_mapping(name: &str) -> io::Result { pub(super) struct MappedView { pointer: NonNull, len: usize, + _mapping: OwnedHandle, } impl MappedView { - pub(super) fn new(mapping: &OwnedHandle, len: usize) -> io::Result { + pub(super) fn new(mapping: OwnedHandle, len: usize) -> io::Result { // SAFETY: `mapping` is a valid file-mapping handle. Offset zero and // `len` describe the exact view retained by the returned guard. let view = unsafe { MapViewOfFile(mapping.as_raw_handle().cast(), FILE_MAP_WRITE, 0, 0, len) }; let pointer = NonNull::new(view.Value.cast::()).ok_or_else(last_error)?; - Ok(Self { pointer, len }) + Ok(Self { pointer, len, _mapping: mapping }) } pub(super) const fn as_ptr(&self) -> *mut u8 { From a1c2c70fa9ef77eee75b9b0864159ed1cfb92d0d Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 10 Jul 2026 23:48:32 +0800 Subject: [PATCH 18/21] docs(fspy): use direct Windows shared memory wording Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/windows/README.md | 39 ++++++++++++++------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/crates/fspy_shm/src/windows/README.md b/crates/fspy_shm/src/windows/README.md index 44ac55f0e..5dbaba029 100644 --- a/crates/fspy_shm/src/windows/README.md +++ b/crates/fspy_shm/src/windows/README.md @@ -1,34 +1,35 @@ # Windows backend -The Windows backend maps a sparse temporary file through a named section. This -provides name-based opens and a large logical mapping without charging its full -size to system commit or disk allocation. +The Windows backend maps a sparse temporary file and gives the mapping a +Windows section name. Another process can open the same bytes using only that +name. Creating the mapping does not reserve its full size in system commit or +disk space. ## Options considered -| Option | Decision | -| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | -| Paging-file-backed section | Rejected because the section's full size is charged against system commit. | -| Reserved section with incremental `VirtualAlloc(MEM_COMMIT)` | Rejected because concurrent writers would need a shared commit frontier and refill protocol. | -| Sparse temporary file with a named section | Selected. Untouched ranges consume neither disk allocation nor commit, and the section name provides process-independent discovery. | +| Option | Decision | +| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | +| Paging-file-backed section | Rejected because the section's full size is charged against system commit. | +| Reserved section with incremental `VirtualAlloc(MEM_COMMIT)` | Rejected because writers would need to coordinate when committing more pages. | +| Sparse temporary file with a named section | Selected. Disk blocks are allocated only for written ranges, and another process can open the mapping using the section name. | -`FILE_ATTRIBUTE_TEMPORARY` favors cache retention but does not prevent -writeback under memory pressure. Sparseness avoids allocation only for -untouched ranges. Creation fails if the temporary volume does not support -sparse files. +`FILE_ATTRIBUTE_TEMPORARY` tells Windows to keep file data in memory when +possible, but Windows may still write dirty pages to disk under memory +pressure. Creation fails if the temporary volume does not support sparse +files. ## Why not `shared_memory` -`shared_memory` creates and extends a regular backing file before callers can -mark it sparse, so it cannot provide the required allocation behavior. +`shared_memory` creates and extends a regular file before fspy can mark it +sparse. It therefore cannot ensure that untouched ranges use no disk blocks. -`shared_memory` also uses the backing-file path as part of its persistence -protocol. Fspy uses the section name for discovery so an opener does not need -the creator's temporary-directory path. +`shared_memory` also uses the file path to identify the mapping. Fspy uses the +section name, so another process does not need the creator's temporary-file +path. ## Lifetime semantics Dropping the owner unmaps its view and closes the delete-on-close backing file. Existing views keep the section alive. The section name may remain openable -during that period, so `ChannelConf::sender` uses the receiver's lock file as -the authoritative lifetime gate. +during that period. `ChannelConf::sender` checks the receiver's lock file first +to prevent new senders after the receiver has shut down. From 03396df4747f875d255de42d1598f170a452e1e8 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 11 Jul 2026 06:18:23 +0800 Subject: [PATCH 19/21] docs(fspy): remove hard wraps from Windows README Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/windows/README.md | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/crates/fspy_shm/src/windows/README.md b/crates/fspy_shm/src/windows/README.md index 5dbaba029..b1f4a57bd 100644 --- a/crates/fspy_shm/src/windows/README.md +++ b/crates/fspy_shm/src/windows/README.md @@ -1,9 +1,6 @@ # Windows backend -The Windows backend maps a sparse temporary file and gives the mapping a -Windows section name. Another process can open the same bytes using only that -name. Creating the mapping does not reserve its full size in system commit or -disk space. +The Windows backend maps a sparse temporary file and gives the mapping a Windows section name. Another process can open the same bytes using only that name. Creating the mapping does not reserve its full size in system commit or disk space. ## Options considered @@ -13,23 +10,14 @@ disk space. | Reserved section with incremental `VirtualAlloc(MEM_COMMIT)` | Rejected because writers would need to coordinate when committing more pages. | | Sparse temporary file with a named section | Selected. Disk blocks are allocated only for written ranges, and another process can open the mapping using the section name. | -`FILE_ATTRIBUTE_TEMPORARY` tells Windows to keep file data in memory when -possible, but Windows may still write dirty pages to disk under memory -pressure. Creation fails if the temporary volume does not support sparse -files. +`FILE_ATTRIBUTE_TEMPORARY` tells Windows to keep file data in memory when possible, but Windows may still write dirty pages to disk under memory pressure. Creation fails if the temporary volume does not support sparse files. ## Why not `shared_memory` -`shared_memory` creates and extends a regular file before fspy can mark it -sparse. It therefore cannot ensure that untouched ranges use no disk blocks. +`shared_memory` creates and extends a regular file before fspy can mark it sparse. It therefore cannot ensure that untouched ranges use no disk blocks. -`shared_memory` also uses the file path to identify the mapping. Fspy uses the -section name, so another process does not need the creator's temporary-file -path. +`shared_memory` also uses the file path to identify the mapping. Fspy uses the section name, so another process does not need the creator's temporary-file path. ## Lifetime semantics -Dropping the owner unmaps its view and closes the delete-on-close backing file. -Existing views keep the section alive. The section name may remain openable -during that period. `ChannelConf::sender` checks the receiver's lock file first -to prevent new senders after the receiver has shut down. +Dropping the owner unmaps its view and closes the delete-on-close backing file. Existing views keep the section alive. The section name may remain openable during that period. `ChannelConf::sender` checks the receiver's lock file first to prevent new senders after the receiver has shut down. From 80dfb07ed3e2920bab31fb23644ac99fdeb9db08 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 11 Jul 2026 06:53:55 +0800 Subject: [PATCH 20/21] refactor(fspy): derive Windows shared memory size Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/windows/README.md | 2 +- crates/fspy_shm/src/windows/mod.rs | 31 ++++++++++-------------- crates/fspy_shm/src/windows/sys.rs | 34 ++++++++++++++++++++++----- 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/crates/fspy_shm/src/windows/README.md b/crates/fspy_shm/src/windows/README.md index b1f4a57bd..0e4dc353c 100644 --- a/crates/fspy_shm/src/windows/README.md +++ b/crates/fspy_shm/src/windows/README.md @@ -1,6 +1,6 @@ # Windows backend -The Windows backend maps a sparse temporary file and gives the mapping a Windows section name. Another process can open the same bytes using only that name. Creating the mapping does not reserve its full size in system commit or disk space. +The Windows backend maps a sparse temporary file and gives the mapping a Windows section name. Another process opens the complete section using only that name and derives its length from the mapped view. Creating the mapping does not reserve its full size in system commit or disk space. ## Options considered diff --git a/crates/fspy_shm/src/windows/mod.rs b/crates/fspy_shm/src/windows/mod.rs index 56a7bda7e..fbbb89bd8 100644 --- a/crates/fspy_shm/src/windows/mod.rs +++ b/crates/fspy_shm/src/windows/mod.rs @@ -53,8 +53,8 @@ pub fn create(size: usize) -> io::Result { .open(backing_dir.join(format!("{id}.shm")))?; sys::set_sparse(&backing_file)?; backing_file.set_len(size_u64)?; - let mapping = sys::create_file_mapping(&backing_file, &mapping_name(&id, size))?; - let view = MappedView::new(mapping, size)?; + let mapping = sys::create_file_mapping(&backing_file, &mapping_name(&id))?; + let view = MappedView::new(mapping)?; Ok(Shm { id, view, backing_file: Some(backing_file) }) } @@ -64,15 +64,15 @@ pub fn create(size: usize) -> io::Result { /// # Errors /// /// Returns an error if the mapping is unavailable. -pub fn open(id: &str, size: usize) -> io::Result { - let mapping = sys::open_file_mapping(&mapping_name(id, size))?; - let view = MappedView::new(mapping, size)?; +pub fn open(id: &str) -> io::Result { + let mapping = sys::open_file_mapping(&mapping_name(id))?; + let view = MappedView::new(mapping)?; Ok(Shm { id: id.to_owned(), view, backing_file: None }) } -fn mapping_name(id: &str, size: usize) -> String { - format!("{MAPPING_NAME_PREFIX}{id}-{size}") +fn mapping_name(id: &str) -> String { + format!("{MAPPING_NAME_PREFIX}{id}") } #[expect(clippy::len_without_is_empty, reason = "shared-memory mappings are always non-empty")] @@ -119,13 +119,6 @@ mod tests { const SIZE: usize = 64 * 1024; - #[test] - fn size_mismatches_are_rejected() { - let owner = create(SIZE).unwrap(); - assert!(open(owner.id(), SIZE / 2).is_err()); - assert!(open(owner.id(), SIZE + 1).is_err()); - } - #[test] fn subprocess_open_ignores_changed_temp_and_working_directory() { let owner = create(SIZE).unwrap(); @@ -136,7 +129,7 @@ mod tests { unsafe { owner.as_ptr().write(17) }; let mut command = command_for_fn!(owner.id().to_owned(), |id: String| { - let opened = open(&id, SIZE).unwrap(); + let opened = open(&id).unwrap(); // SAFETY: The parent waits for this child and does not access the // mapping concurrently. unsafe { @@ -160,7 +153,7 @@ mod tests { let owner = create(SIZE).unwrap(); let id = owner.id().to_owned(); let path = temp_dir().join(BACKING_DIR).join(format!("{id}.shm")); - let opened = open(&id, SIZE).unwrap(); + let opened = open(&id).unwrap(); assert!(path.exists()); drop(owner); @@ -170,10 +163,10 @@ mod tests { unsafe { opened.as_ptr().write(17) }; // SAFETY: The preceding write is complete and the mapping remains live. assert_eq!(unsafe { opened.as_ptr().read() }, 17); - let reopened = open(&id, SIZE).unwrap(); + let reopened = open(&id).unwrap(); drop(opened); drop(reopened); - assert!(open(&id, SIZE).is_err()); + assert!(open(&id).is_err()); } #[cfg(target_pointer_width = "64")] @@ -188,7 +181,7 @@ mod tests { assert_eq!(logical_size, PRODUCTION_SIZE as u64); assert!(initial_allocation < MAX_ENDPOINT_ALLOCATION); - let opened = open(owner.id(), PRODUCTION_SIZE).unwrap(); + let opened = open(owner.id()).unwrap(); // SAFETY: Both endpoint indexes are within the exact mapped length and // accesses are synchronized within this test. unsafe { diff --git a/crates/fspy_shm/src/windows/sys.rs b/crates/fspy_shm/src/windows/sys.rs index 6019ced2c..9786e5bba 100644 --- a/crates/fspy_shm/src/windows/sys.rs +++ b/crates/fspy_shm/src/windows/sys.rs @@ -19,8 +19,9 @@ use windows_sys::Win32::{ IO::DeviceIoControl, Ioctl::FSCTL_SET_SPARSE, Memory::{ - CreateFileMappingW, FILE_MAP_WRITE, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, - OpenFileMappingW, PAGE_READWRITE, UnmapViewOfFile, + CreateFileMappingW, FILE_MAP_WRITE, MEMORY_BASIC_INFORMATION, + MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, OpenFileMappingW, PAGE_READWRITE, + UnmapViewOfFile, VirtualQuery, }, }, }; @@ -125,12 +126,33 @@ pub(super) struct MappedView { } impl MappedView { - pub(super) fn new(mapping: OwnedHandle, len: usize) -> io::Result { - // SAFETY: `mapping` is a valid file-mapping handle. Offset zero and - // `len` describe the exact view retained by the returned guard. + pub(super) fn new(mapping: OwnedHandle) -> io::Result { + // SAFETY: `mapping` is a valid file-mapping handle. Offset and length + // zero map the complete section. let view = - unsafe { MapViewOfFile(mapping.as_raw_handle().cast(), FILE_MAP_WRITE, 0, 0, len) }; + unsafe { MapViewOfFile(mapping.as_raw_handle().cast(), FILE_MAP_WRITE, 0, 0, 0) }; let pointer = NonNull::new(view.Value.cast::()).ok_or_else(last_error)?; + + let mut info = MEMORY_BASIC_INFORMATION::default(); + // SAFETY: `pointer` is inside the mapped view and `info` is writable for + // its exact size. + let result = unsafe { + VirtualQuery( + pointer.as_ptr().cast(), + &raw mut info, + std::mem::size_of::(), + ) + }; + if result == 0 { + let error = last_error(); + // SAFETY: `pointer` is the base address returned by MapViewOfFile. + let _ = unsafe { + UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { Value: pointer.as_ptr().cast() }) + }; + return Err(error); + } + + let len = info.RegionSize; Ok(Self { pointer, len, _mapping: mapping }) } From e476b9c70a3fd808970d5f2f91a7ad0fb7446b57 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sat, 11 Jul 2026 07:03:03 +0800 Subject: [PATCH 21/21] docs(fspy): omit Windows mapping mechanics Co-authored-by: GPT-5 Codex --- crates/fspy_shm/src/windows/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fspy_shm/src/windows/README.md b/crates/fspy_shm/src/windows/README.md index 0e4dc353c..fac5350ae 100644 --- a/crates/fspy_shm/src/windows/README.md +++ b/crates/fspy_shm/src/windows/README.md @@ -1,6 +1,6 @@ # Windows backend -The Windows backend maps a sparse temporary file and gives the mapping a Windows section name. Another process opens the complete section using only that name and derives its length from the mapped view. Creating the mapping does not reserve its full size in system commit or disk space. +The Windows backend maps a sparse temporary file and gives the mapping a Windows section name. Another process opens the same section using only that name. Creating the mapping does not reserve its full size in system commit or disk space. ## Options considered