Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/fspy_shared/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ vt_path = { workspace = true }

[target.'cfg(target_os = "windows")'.dependencies]
bytemuck = { workspace = true }
omnipath = { workspace = true }
winapi = { workspace = true, features = ["std"] }

[dev-dependencies]
Expand Down
96 changes: 91 additions & 5 deletions crates/fspy_shared/src/ipc/channel/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ mod shm_io;

use std::{env::temp_dir, fs::File, io, ops::Deref, path::PathBuf};

use fspy_shm::{Mapping, ShmKeeper};
use fspy_shm::Mapping;
pub use shm_io::FrameMut;
use shm_io::{ShmReader, ShmWriter};
use tracing::debug;
Expand All @@ -13,6 +13,14 @@ use wincode::{SchemaRead, SchemaWrite};

use super::IpcStr;

/// Prefix of shared-memory backing file names inside the system temporary
/// directory.
///
/// The files sit directly in the temporary directory. A shared subdirectory
/// would belong to whichever user created it first and block everyone else;
/// uniquely named `0o600` files in the sticky-bit temp directory avoid that.
const SHM_BACKING_PREFIX: &str = "vite-task-fspy-";

/// Serializable configuration to create channel senders.
#[derive(SchemaWrite, SchemaRead, Clone, Debug)]
pub struct ChannelConf {
Expand All @@ -26,18 +34,68 @@ pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> {
// Initialize the lock file with a unique name.
let lock_file_path = temp_dir().join(format!("fspy_ipc_{}.lock", Uuid::new_v4()));

let (keeper, handle) = fspy_shm::create(capacity)?;
let shm_path = shm_backing_path()?;
let handle = fspy_shm::create(shm_path.as_os_str(), capacity)?;
// The keeper exists from here on, so every error path below cleans up.
let keeper = ShmKeeper { path: shm_path };
let mapping = handle.map()?;

let conf = ChannelConf {
lock_file_path: lock_file_path.as_os_str().into(),
shm_id: keeper.id().into(),
shm_id: keeper.path.as_os_str().into(),
};

let receiver = Receiver::new(lock_file_path, keeper, mapping)?;
Ok((conf, receiver))
}

/// Returns a fresh absolute path for a shared-memory backing file.
fn shm_backing_path() -> io::Result<PathBuf> {
// `temp_dir` reflects `TMPDIR` verbatim, which may be relative. The path
// travels to processes with other working directories, so resolve it
// against the creator's current directory first.
let path = std::path::absolute(temp_dir())?
.join(format!("{SHM_BACKING_PREFIX}{}.shm", Uuid::new_v4().simple()));
#[cfg(windows)]
let path = to_verbatim_if_long(path)?;
Ok(path)
}

/// Converts long paths to verbatim (`\\?\`) form up front, so every later use
/// of the path — creation here, opening in any process, removal — stays clear
/// of the legacy `MAX_PATH` limit without relying on the system's long-path
/// opt-in, which the arbitrary processes opening shared memory could not
/// count on anyway.
#[cfg(windows)]
fn to_verbatim_if_long(path: PathBuf) -> io::Result<PathBuf> {
use std::os::windows::ffi::OsStrExt as _;

use omnipath::windows::WinPathExt as _;

// The length at which std's own Windows path conversion switches to a
// verbatim path.
const VERBATIM_THRESHOLD: usize = 248;

if path.as_os_str().encode_wide().count() >= VERBATIM_THRESHOLD {
return path.to_verbatim();
}
Ok(path)
}

/// Keeps the shared memory's backing path alive and removes it on drop.
///
/// Removal is cleanup, not a stop signal: later opens fail, but existing
/// handles and mappings keep reading and writing; see [`fspy_shm::remove`].
struct ShmKeeper {
path: PathBuf,
}

impl Drop for ShmKeeper {
fn drop(&mut self) {
let _ = fspy_shm::remove(self.path.as_os_str());
}
}

impl ChannelConf {
/// Creates a sender.
///
Expand Down Expand Up @@ -93,7 +151,8 @@ unsafe impl Sync for Sender {}
pub struct Receiver {
lock_file_path: PathBuf,
lock_file: File,
/// Keeps the backing file's name alive for as long as senders may attach.
/// Keeps the shared memory's backing file alive for as long as senders
/// may attach.
_keeper: ShmKeeper,
mapping: Mapping,
}
Expand Down Expand Up @@ -156,13 +215,40 @@ impl<'a> Deref for ReceiverLockGuard<'a> {

#[cfg(test)]
mod tests {
use std::{num::NonZeroUsize, str::from_utf8};
use std::{ffi::OsString, fs, num::NonZeroUsize, str::from_utf8};

use bstr::B;
use subprocess_test::command_for_fn;

use super::*;

/// The shared-memory path is generated absolute, so a sender in a process
/// with a different working directory and a relative temporary directory
/// must still attach.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn sender_ignores_changed_temp_and_working_directory() {
let (conf, receiver) = channel(100).unwrap();
let changed_cwd = temp_dir().join(format!("fspy-ipc-changed-cwd-{}", Uuid::new_v4()));
fs::create_dir(&changed_cwd).unwrap();

let mut command = command_for_fn!(conf, |conf: ChannelConf| {
let sender = conf.sender().unwrap();
let frame_size = NonZeroUsize::new(2).unwrap();
let mut frame = sender.claim_frame(frame_size).unwrap();
frame.copy_from_slice(&[4, 2]);
});
command.cwd = changed_cwd.clone();
for name in ["TMPDIR", "TMP", "TEMP"] {
command.envs.insert(OsString::from(name), OsString::from("changed-relative-tmp"));
}
let succeeded = std::process::Command::from(command).status().unwrap().success();
fs::remove_dir(changed_cwd).unwrap();
assert!(succeeded);

let lock = receiver.lock().unwrap();
assert_eq!(lock.iter_frames().next().unwrap(), &[4, 2]);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn smoke() {
let (conf, receiver) = channel(100).unwrap();
Expand Down
6 changes: 4 additions & 2 deletions crates/fspy_shared/src/ipc/channel/shm_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -672,8 +672,10 @@ mod tests {

const SHM_SIZE: usize = 1024 * 1024;

let (keeper, handle) = fspy_shm::create(SHM_SIZE).unwrap();
let shm_name = keeper.id().to_str().expect("test temp dir is UTF-8").to_owned();
let shm_path = crate::ipc::channel::shm_backing_path().unwrap();
let handle = fspy_shm::create(shm_path.as_os_str(), SHM_SIZE).unwrap();
let _keeper = crate::ipc::channel::ShmKeeper { path: shm_path.clone() };
let shm_name = shm_path.to_str().expect("test temp dir is UTF-8").to_owned();
// Map before the children run. Windows keeps views coherent while they
// exist at the same time; a view created after every writer exited can
// observe the file before the writers' dirty pages reach it.
Expand Down
5 changes: 1 addition & 4 deletions crates/fspy_shm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,10 @@ license.workspace = true
publish = false
rust-version.workspace = true

[dependencies]
uuid = { workspace = true, features = ["v4"] }

[target.'cfg(any(unix, windows))'.dependencies]
fspy_nostd = { workspace = true }

[target.'cfg(target_os = "windows")'.dependencies]
omnipath = { workspace = true }
windows-sys = { workspace = true, features = [
"Win32_Foundation",
"Win32_Storage_FileSystem",
Expand All @@ -25,6 +21,7 @@ windows-sys = { workspace = true, features = [
[dev-dependencies]
ctor = { workspace = true }
subprocess_test = { workspace = true }
uuid = { workspace = true, features = ["v4"] }

[lints]
workspace = true
Expand Down
41 changes: 19 additions & 22 deletions crates/fspy_shm/README.md
Original file line number Diff line number Diff line change
@@ -1,45 +1,44 @@
# `fspy_shm`

`fspy_shm` is the private shared-memory layer used by fspy IPC channels. It gives the channel one API for creating a mapping, passing its identifier to another process, and opening additional views of the same bytes.
`fspy_shm` is the private shared-memory layer used by fspy IPC channels. It gives the channel one API for creating a mapping at a caller-chosen path, opening additional views of the same bytes from any process that knows the path, and removing the backing file.

`fspy_shm` exposes only the operations used by fspy. Treat an identifier as an opaque `OsStr`; do not depend on how it is built.
`fspy_shm` exposes only the operations used by fspy. The caller owns the path: it decides where the backing file lives, passes the same path to every process that opens the shared memory, and removes it when the shared memory is no longer needed. The fspy channel generates an absolute uniquely-named path in the system temporary directory and holds it in a keeper that removes it on drop.

## API

The public API is defined in [`src/lib.rs`](src/lib.rs).

| API | Contract |
| --------------------- | --------------------------------------------------------------------------------------- |
| `create(size)` | Creates a zero-initialized backing file and returns its `ShmKeeper` and an `ShmHandle`. |
| `open(id)` | Opens an `ShmHandle` on the shared memory identified by `id`. |
| `ShmKeeper::id()` | Returns the identifier another process passes to `open`. |
| `create(path, size)` | Creates a zero-initialized backing file at `path` and returns an opened `ShmHandle`. |
| `open(path)` | Opens an `ShmHandle` on the shared memory backed by the file at `path`. |
| `remove(path)` | Removes the backing file. Later opens fail; existing handles and mappings keep working. |
| `ShmHandle::map()` | Maps the shared bytes. Callable more than once. |
| `Mapping::len()` | Returns the mapped size. |
| `Mapping::as_ptr()` | Returns a mutable raw pointer to the first byte. |
| `Mapping::as_slice()` | Returns the bytes as a shared slice. The caller must prevent mutation for its lifetime. |

`ShmKeeper` is the name: while it lives, `open` succeeds, and dropping it removes the backing file. `ShmHandle` is the opened file: `create` returns one so the creator never looks its own file up by name, and `open` returns one to everybody else. `Mapping` is the bytes: it keeps them alive until dropped and can do nothing else. None of the three synchronizes memory access. The fspy channel adds that on top with atomic frame headers and a lock file: senders hold a shared file lock while writing, and the receiver takes the exclusive lock before reading, which waits for existing senders and rejects new ones.
`ShmHandle` is the opened file: `create` returns one so the creator never looks its own file up by path, and `open` returns one to everybody else. `Mapping` is the bytes: it keeps them alive until dropped and can do nothing else. Neither synchronizes memory access. The fspy channel adds that on top with atomic frame headers and a lock file: senders hold a shared file lock while writing, and the receiver takes the exclusive lock before reading, which waits for existing senders and rejects new ones.

Every byte in a mapping returned by `create` is initially zero. `open` exposes the mapping's current contents and does not reinitialize them.

## Implementation

One implementation serves every platform: a sparse file named `vite-task-fspy-<uuid>.shm` directly in the system temporary directory. The identifier is the file's absolute path, so another process opens the mapping by opening that path. There is no broker, no global object name, and no asynchronous runtime. The files sit in the temporary directory itself rather than a shared subdirectory: a subdirectory would belong to whichever user created it first and block everyone else, while uniquely named `0o600` files in a sticky-bit directory work for all users.
One implementation serves every platform: a sparse file at the caller's path. Another process opens the mapping by opening that path. There is no broker, no global object name, and no asynchronous runtime.

Only written pages ever occupy memory or disk. The multi-gigabyte capacity fspy asks for therefore costs about as much as the data a run actually records.

Mapping goes through `memmap2` on every platform. The remaining platform-specific parts are three short passages:
Every operation goes through [`fspy_nostd`](../fspy_nostd) wrappers or direct Win32 calls. The platform-specific parts are three short passages:

| Concern | Unix | Windows |
| ----------------- | ---------------------------------------- | --------------------------------------------------------------------------- |
| Same-user access | `mode(0o600)` on the backing file | the per-user `%TEMP%` ACL |
| Sparseness | file holes, produced by setting a length | `FSCTL_SET_SPARSE` before setting a length, or NTFS allocates every cluster |
| Keeper cleanup | unlink the path | unlink the path; see the fallback below |
| Descriptor safety | `O_CLOEXEC`, the Rust standard default | non-inheritable handles, the Rust standard default |
| Concern | Unix | Windows |
| ---------------- | ---------------------------------------- | --------------------------------------------------------------------------- |
| Same-user access | `mode(0o600)` on the backing file | the per-user `%TEMP%` ACL of the caller's chosen directory |
| Sparseness | file holes, produced by setting a length | `FSCTL_SET_SPARSE` before setting a length, or NTFS allocates every cluster |
| Removal | unlink the path | POSIX delete via `FileDispositionInfoEx`; see below |

`FILE_ATTRIBUTE_TEMPORARY` asks Windows to keep the data in memory when it can. Creation fails on a volume without sparse-file support.

The keeper removes the name with `remove_file` on every platform. Modern Windows deletes with POSIX semantics: the name goes away at once, while [existing handles keep working](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/ns-ntddk-_file_disposition_information_ex) and [mapped views keep the data alive](https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-createfilemappingw) until the last one goes away. The first page also reserves the right to fail the delete while a mapped view exists, and Windows versions without POSIX delete do fail it. The keeper then falls back to reopening the file with `FILE_FLAG_DELETE_ON_CLOSE` and closing it, which deletes the file once every handle to it is closed.
`remove` unlinks the path on Unix. On Windows it relies on [POSIX delete semantics](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/ns-ntddk-_file_disposition_information_ex), which requires NTFS on Windows 10 1607 or newer: the name goes away at once, while existing handles keep working and [mapped views keep the data alive](https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-createfilemappingw) until the last one goes away.

## Options considered

Expand All @@ -61,12 +60,10 @@ Earlier revisions rejected temporary files because dirty pages can reach disk. O

## Lifetime semantics

`create` returns the only keeper. `open` returns `ShmHandle`s.
- While the backing file exists, a process that knows the path can open the shared memory.
- `remove` deletes the backing file's name, so later opens fail. This is cleanup, not a stop signal: processes that already opened the shared memory keep reading and writing. The fspy channel stops writers with the close gate it stores in the shared bytes.
- An `ShmHandle` and its `Mapping`s stay usable after the backing file is removed. They keep the bytes alive and cannot restore the path.

- While the keeper is alive, a process that knows the identifier can open the shared memory.
- Dropping the keeper removes the backing file's name, so later opens fail. This is cleanup, not a stop signal: processes that already opened the shared memory keep reading and writing. The fspy channel stops writers with the close gate it stores in the shared bytes.
- An `ShmHandle` and its `Mapping`s stay usable after the keeper is gone. They keep the bytes alive and cannot extend the identifier's validity.
The channel guards the same window from its own side: [`ChannelConf::sender`](../fspy_shared/src/ipc/channel/mod.rs) opens and locks the receiver's exact lock-file path before it calls `fspy_shm::open`, and the receiver removes that path before removing the backing file, so a sender that starts later fails before opening shared memory.

The channel guards the same window from its own side: [`ChannelConf::sender`](../fspy_shared/src/ipc/channel/mod.rs) opens and locks the receiver's exact lock-file path before it calls `fspy_shm::open`, and the receiver removes that path before dropping the keeper, so a sender that starts later fails before opening shared memory.

If the keeper's process is killed, its `Drop` never runs and the file stays behind: on Unix for the system's temporary-file reaper, on Windows until a cleanup tool runs. The file costs about as much disk as the run wrote into it.
If the process that owns the path is killed before it calls `remove`, the file stays behind: on Unix for the system's temporary-file reaper, on Windows until a cleanup tool runs. The file costs about as much disk as the run wrote into it.
Loading
Loading