diff --git a/CHANGELOG.md b/CHANGELOG.md index 113574b3..5e3c6485 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 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)). diff --git a/Cargo.lock b/Cargo.lock index 2205bf2d..7efdf6e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1322,6 +1322,7 @@ dependencies = [ "widestring", "winapi", "wincode", + "windows-sys 0.61.2", "winsafe 0.0.27", ] @@ -1397,6 +1398,7 @@ dependencies = [ "tokio-util", "tracing", "uuid", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b1091578..c031ee5e 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/Cargo.toml b/crates/fspy_preload_windows/Cargo.toml index b315e1c6..9dd1c50c 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 578efbda..38eba1c1 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,16 +103,6 @@ pub const fn access_mask_to_mode(desired_access: ACCESS_MASK) -> AccessMode { } } -unsafe extern "system" { - fn LocalFree(hmem: HLOCAL) -> HLOCAL; - fn PathAllocCombine( - pszpathin: PCWSTR, - pszmore: PCWSTR, - dwflags: ULONG, - ppszpathout: *mut PWSTR, - ) -> HRESULT; -} - pub struct HeapPath(PWSTR); impl HeapPath { #[must_use] @@ -125,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 ece633d8..f05f4204 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,17 @@ tokio-util = { workspace = true } tracing = { workspace = true } uuid = { workspace = true, features = ["v4"] } +[target.'cfg(target_os = "windows")'.dependencies] +uuid = { workspace = true, features = ["v4"] } +windows-sys = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_Ioctl", + "Win32_System_Memory", +] } + [dev-dependencies] ctor = { workspace = true } subprocess_test = { workspace = true } diff --git a/crates/fspy_shm/README.md b/crates/fspy_shm/README.md index 9ae93f39..37b3d048 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/lib.rs b/crates/fspy_shm/src/lib.rs index 8656890a..f2ff97e9 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/README.md b/crates/fspy_shm/src/windows/README.md new file mode 100644 index 00000000..fac5350a --- /dev/null +++ b/crates/fspy_shm/src/windows/README.md @@ -0,0 +1,23 @@ +# Windows backend + +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 + +| 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` 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` 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. diff --git a/crates/fspy_shm/src/windows/mod.rs b/crates/fspy_shm/src/windows/mod.rs new file mode 100644 index 00000000..fbbb89bd --- /dev/null +++ b/crates/fspy_shm/src/windows/mod.rs @@ -0,0 +1,198 @@ +#![doc = include_str!("README.md")] + +mod sys; + +use std::{ + env::temp_dir, + fs::{self, File, OpenOptions}, + io, + os::windows::fs::OpenOptionsExt, + slice, +}; + +use sys::MappedView; +use uuid::Uuid; + +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, + #[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 +/// returns its owner. +/// +/// # Errors +/// +/// Returns an error if the backing file or mapping cannot be created. +pub fn create(size: usize) -> io::Result { + 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)?; + 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))?; + let view = MappedView::new(mapping)?; + + Ok(Shm { id, view, backing_file: Some(backing_file) }) +} + +/// Opens the named mapping identified by `id`. +/// +/// # Errors +/// +/// Returns an error if the mapping is unavailable. +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) -> String { + format!("{MAPPING_NAME_PREFIX}{id}") +} + +#[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 subprocess_open_ignores_changed_temp_and_working_directory() { + let owner = create(SIZE).unwrap(); + let changed_cwd = + 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) }; + + let mut command = command_for_fn!(owner.id().to_owned(), |id: String| { + let opened = open(&id).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_deletes_backing_file_and_preserves_existing_views() { + 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).unwrap(); + assert!(path.exists()); + + drop(owner); + + assert!(!path.exists()); + // 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).unwrap(); + drop(opened); + drop(reopened); + assert!(open(&id).is_err()); + } + + #[cfg(target_pointer_width = "64")] + #[test] + 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()).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); + } + + 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); + } +} diff --git a/crates/fspy_shm/src/windows/sys.rs b/crates/fspy_shm/src/windows/sys.rs new file mode 100644 index 00000000..9786e5bb --- /dev/null +++ b/crates/fspy_shm/src/windows/sys.rs @@ -0,0 +1,192 @@ +use std::{ + io, + iter::once, + os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, + ptr::NonNull, +}; + +#[cfg(test)] +use windows_sys::Win32::Storage::FileSystem::{ + FILE_STANDARD_INFO, FileStandardInfo, GetFileInformationByHandleEx, +}; +use windows_sys::Win32::{ + Foundation::{ERROR_ALREADY_EXISTS, GetLastError}, + Storage::FileSystem::{ + FILE_ATTRIBUTE_TEMPORARY, FILE_FLAG_DELETE_ON_CLOSE, FILE_SHARE_DELETE, FILE_SHARE_READ, + FILE_SHARE_WRITE, + }, + System::{ + IO::DeviceIoControl, + Ioctl::FSCTL_SET_SPARSE, + Memory::{ + CreateFileMappingW, FILE_MAP_WRITE, MEMORY_BASIC_INFORMATION, + MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, OpenFileMappingW, PAGE_READWRITE, + UnmapViewOfFile, VirtualQuery, + }, + }, +}; + +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_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(()) } +} + +#[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 + // `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 { + Err(io::Error::new(io::ErrorKind::AlreadyExists, "shared-memory mapping already exists")) + } else { + Ok(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, + _mapping: OwnedHandle, +} + +impl MappedView { + 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, 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 }) + } + + 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()) +}