Skip to content
Closed
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
1 change: 1 addition & 0 deletions Cargo.lock

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

27 changes: 20 additions & 7 deletions crates/fspy_shared/src/ipc/channel/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,23 @@ use super::NativeStr;
#[derive(SchemaWrite, SchemaRead, Clone, Debug)]
pub struct ChannelConf {
lock_file_path: Box<NativeStr>,
shm_id: Box<NativeStr>,
shm_path: Box<NativeStr>,
}

/// Creates a mpsc IPC channel with one receiver and a `ChannelConf` that can be passed around processes and used to create multiple senders
#[expect(clippy::missing_errors_doc, reason = "non-vt crate: cannot use vt_str/vt_path types")]
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 id = Uuid::new_v4();
let temp_dir = std::path::absolute(temp_dir())?;
let lock_file_path = temp_dir.join(format!("fspy_ipc_{id}.lock"));
let shm_path = temp_dir.join(format!("fspy_ipc_{id}.shm"));

let (keeper, handle) = fspy_shm::create(capacity)?;
let mapping = handle.map()?;
let (keeper, handle) = shm_result(fspy_shm::create(shm_path.as_os_str(), capacity))?;
let mapping = shm_result(handle.map())?;

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

let receiver = Receiver::new(lock_file_path, keeper, mapping)?;
Expand All @@ -50,7 +52,8 @@ impl ChannelConf {
let lock_file = File::open(self.lock_file_path.to_cow_os_str())?;
lock_file.try_lock_shared()?;

let mapping = fspy_shm::open(&self.shm_id.to_cow_os_str())?.map()?;
let handle = shm_result(fspy_shm::open(&self.shm_path.to_cow_os_str()))?;
let mapping = shm_result(handle.map())?;
// SAFETY: `mapping` is a freshly mapped shared memory region with valid
// pointer and size. Exclusive write access is ensured by the shared
// file lock held by this sender.
Expand All @@ -59,6 +62,16 @@ impl ChannelConf {
}
}

#[cfg(unix)]
fn shm_result<T>(result: fspy_shm::Result<T>) -> io::Result<T> {
result.map_err(|error| io::Error::from_raw_os_error(error.raw_os_error()))
}

#[cfg(windows)]
const fn shm_result<T>(result: fspy_shm::Result<T>) -> io::Result<T> {
result
}

pub struct Sender {
writer: ShmWriter<Mapping>,
lock_file_path: Box<NativeStr>,
Expand Down
7 changes: 5 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,11 @@ 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 = std::path::absolute(std::env::temp_dir())
.unwrap()
.join(format!("fspy_ipc_test_{}.shm", uuid::Uuid::new_v4()));
let (_keeper, handle) = fspy_shm::create(shm_path.as_os_str(), SHM_SIZE).unwrap();
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
7 changes: 5 additions & 2 deletions crates/fspy_shm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ license.workspace = true
publish = false
rust-version.workspace = true

[dependencies]
[target.'cfg(unix)'.dependencies]
sigsafe = { workspace = true }

[target.'cfg(windows)'.dependencies]
memmap2 = { workspace = true }
uuid = { workspace = true, features = ["v4"] }

[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { workspace = true, features = [
Expand All @@ -22,6 +24,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
27 changes: 13 additions & 14 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 and opening additional views of the same bytes.

`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 full absolute path and passes it to both `create` and `open`.

## 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 and returns its `ShmKeeper` and an `ShmHandle`. |
| `open(path)` | Opens an `ShmHandle` on the shared memory at the same path. |
| `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.
`ShmKeeper` owns the name's lifetime: 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.

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 full path supplied by the caller. The fspy channel chooses a unique name directly in the system temporary directory and passes that path to every process. There is no broker, no global object name, and no asynchronous runtime. On Unix, 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.

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:
Unix creates, sizes, opens, maps, and unlinks through `sigsafe`; on Linux, its raw-syscall backend works before libc initialization. Unix path storage is fixed-capacity and inline, so these operations do not allocate. Windows maps through `memmap2`. The remaining platform-specific parts are 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 |
| Descriptor safety | raw `openat` with `O_CLOEXEC` | non-inheritable handles, the Rust standard default |

`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.
The Unix keeper removes the name through `sigsafe`. The Windows keeper first uses `remove_file`. 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.

## Options considered

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

## Lifetime semantics

`create` returns the only keeper. `open` returns `ShmHandle`s.
`create(path, size)` returns the only keeper. `open(path)` returns `ShmHandle`s.

- While the keeper is alive, a process that knows the identifier can open the shared memory.
- While the keeper is alive, a process that knows the path 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.
- An `ShmHandle` and its `Mapping`s stay usable after the keeper is gone. They keep the bytes alive and cannot extend the path'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 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 keeper's process is killed, its `Drop` never runs and the file stays behind. The fspy channel places it in the system temporary directory, where Unix temporary-file reapers or Windows cleanup tools can remove it. The file costs about as much disk as the run wrote into it.
Loading
Loading