Skip to content
Open
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: 0 additions & 1 deletion Cargo.lock

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

1 change: 0 additions & 1 deletion crates/fspy_shared/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ bytemuck = { workspace = true }
winapi = { workspace = true, features = ["std"] }

[dev-dependencies]
assert2 = { workspace = true }
ctor = { workspace = true }
rustc-hash = { workspace = true }
subprocess_test = { workspace = true }
Expand Down
213 changes: 191 additions & 22 deletions crates/fspy_shared/src/ipc/channel/gated.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ use std::{
};

use fspy_shm::ShmOwner;
use wincode::{SchemaWrite, config::DefaultConfig};
use wincode::{SchemaWrite, Serialize as _, config::DefaultConfig};

use super::{
gate::{EnterError, Gate, GateGuard},
shm_io::{self, AsRawSlice, ShmReader, ShmWriter},
};
use self::shm_io::{ShmReader, ShmWriter};
use super::gate::{EnterError, Gate, GateGuard};

mod shm_io;

pub use shm_io::AsRawSlice;

/// The receiver side gates the owner's own view of the shared bytes.
impl AsRawSlice for ShmOwner {
Expand Down Expand Up @@ -133,29 +135,40 @@ impl<M: AsRawSlice> GatedShmWriter<M> {
/// [`ClaimError::Capacity`] when the arena cannot fit the frame.
pub fn claim_frame(&self, size: NonZeroUsize) -> Result<FrameMut<'_>, ClaimError> {
let gate = self.gate().enter()?;
let frame = self.arena.claim_frame(size).ok_or(ClaimError::Capacity)?;
Ok(FrameMut { frame, _gate: gate })
let content = self.arena.claim_frame(size).ok_or(ClaimError::Capacity)?;
Ok(FrameMut { content, _gate: gate })
}

/// Appends an encoded value as a single frame.
///
/// # Errors
///
/// Returns [`ClaimError::Closed`] wrapped in [`WriteEncodedError::Claim`]
/// once the read end has closed the gate, and otherwise whatever the arena
/// reports.
/// once the read end has closed the gate, [`WriteEncodedError::ZeroSizedFrame`]
/// for a value that encodes to nothing, and otherwise the encoder's error.
///
/// # Panics
///
/// Panics if the encoder does not fill exactly the size it asked for, which
/// would mean the schema is inconsistent with itself.
pub fn write_encoded<T: SchemaWrite<DefaultConfig, Src = T>>(
&self,
value: &T,
) -> Result<(), WriteEncodedError> {
// The guard is held across the inner write and released only afterwards,
// so the frame is complete before the gate count drops.
let _gate = self.gate().enter().map_err(ClaimError::from)?;
self.arena.write_encoded(value).map_err(|error| match error {
shm_io::WriteEncodedError::EncodeError(error) => WriteEncodedError::Encode(error),
shm_io::WriteEncodedError::ZeroSizedFrame => WriteEncodedError::ZeroSizedFrame,
shm_io::WriteEncodedError::InsufficientSpace => ClaimError::Capacity.into(),
})
let serialized_size =
usize::try_from(T::serialized_size(value)?).expect("serialized size exceeds usize");
let Some(size) = NonZeroUsize::new(serialized_size) else {
return Err(WriteEncodedError::ZeroSizedFrame);
Comment on lines +158 to +161

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check the closed gate before sizing encoded values

When write_encoded is called after the receiver has closed with a zero-sized value such as () or a schema whose size calculation fails, these early returns produce ZeroSizedFrame or Encode instead of Claim(Closed). The previous implementation entered the gate before calculating the serialized size, and the documented post-close contract requires writes to be reported as Claim(Closed) so callers can silently discard them; perform the gate check first and hold its guard through serialization.

Useful? React with 👍 / 👎.

};

// The frame holds the gate guard, so it is released only after the
// content is written.
let mut frame = self.claim_frame(size)?;
let mut writer: &mut [u8] = &mut frame;
T::serialize_into(&mut writer, value)?;
assert_eq!(writer.len(), 0);

Ok(())
}

fn gate(&self) -> Gate<'_> {
Expand All @@ -166,24 +179,25 @@ impl<M: AsRawSlice> GatedShmWriter<M> {
/// An exclusively owned frame together with the gate guard that keeps the region
/// open while it is written.
///
/// The frame is declared first so that its completion lands before the gate is
/// released.
/// Dropping it releases the gate, which is what publishes the content to a
/// reader; the arena's own header was already written when the frame was
/// claimed.
pub struct FrameMut<'a> {
frame: shm_io::FrameMut<'a>,
content: &'a mut [u8],
_gate: GateGuard<'a>,
}

impl Deref for FrameMut<'_> {
type Target = [u8];

fn deref(&self) -> &Self::Target {
&self.frame
self.content
}
}

impl DerefMut for FrameMut<'_> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.frame
self.content
}
}

Expand Down Expand Up @@ -460,4 +474,159 @@ mod tests {
assert_eq!(seen.len(), WRITERS * PER_WRITER);
assert_eq!(seen[0], "0 0");
}

/// Writers that run out of room must fail cleanly rather than corrupt the
/// arena for the ones that fitted.
#[test]
fn concurrent_writers_exceeding_the_arena() {
let (writer, receiver) = endpoints(1024);

thread::scope(|scope| {
for _ in 0..4 {
scope.spawn(|| {
for _ in 0..10 {
let _ = write_frame(&writer, b"hello");
let _ = write_frame(&writer, b"foo");
let _ = write_frame(&writer, b"this is a test");
}
});
}
});

let reader = receiver.close().unwrap();
let mut count = 0;
for frame in reader.iter_frames() {
count += 1;
assert!(
frame == b"hello" || frame == b"foo" || frame == b"this is a test",
"unexpected frame {:?}",
from_utf8(frame)
);
}
assert!(count > 20, "expected most writes to fit, got {count}");
}

/// Several threads racing for the last few bytes must all agree on who got
/// them, and the reader must walk whatever survived without panicking.
#[test]
fn concurrent_writers_racing_for_the_last_bytes() {
let (writer, receiver) = endpoints(GATE_REGION_SIZE + 200);

thread::scope(|scope| {
for _ in 0..10 {
scope.spawn(|| {
let _ = write_frame(
&writer,
b"this_is_a_moderately_long_frame_that_might_cause_races",
);
});
}
});

let reader = receiver.close().unwrap();
let count = reader.iter_frames().count();
// Some but not all of them fit into 200 bytes of arena.
assert!(count > 0);
assert!(count < 10);
}

/// A frame larger than the size header can describe is refused, and the
/// arena stays usable afterwards.
#[test]
fn a_frame_too_large_for_the_header_is_refused() {
let (writer, receiver) = endpoints(1024);

let oversized = NonZeroUsize::new(usize::try_from(u32::MAX).unwrap() + 1).unwrap();
assert!(matches!(writer.claim_frame(oversized), Err(ClaimError::Capacity)));

write_frame(&writer, b"test").unwrap();
let reader = receiver.close().unwrap();
assert_eq!(reader.iter_frames().collect::<Vec<_>>(), vec![b"test".as_slice()]);
}

#[test]
fn a_misaligned_region_is_rejected() {
struct Misaligned(MockRegion);

impl AsRawSlice for Misaligned {
fn as_raw_slice(&self) -> *mut [u8] {
let raw_slice = self.0.as_raw_slice();
slice_from_raw_parts_mut(
// SAFETY: shifting by one byte deliberately misaligns the
// region and stays inside the over-allocated backing buffer.
unsafe { raw_slice.cast::<u8>().add(1) },
raw_slice.len() - 1,
)
}
}

let misaligned = Misaligned(MockRegion::alloc(1024));
assert_ne!(misaligned.as_raw_slice().cast::<u8>() as usize % align_of::<u64>(), 0);

let result = std::panic::catch_unwind(|| {
// SAFETY: the region is deliberately misaligned to check that the
// constructor catches it; the assertion fires before any access.
unsafe { GatedShmWriter::new(misaligned) };
});

assert!(result.is_err(), "a misaligned region must be rejected");
}

/// The same protocol over a real shared mapping, written by several
/// processes at once.
#[test]
#[cfg(not(miri))]
fn real_shm_across_processes() {
use std::process::{Child, Command};

use rustc_hash::FxHashSet;
use subprocess_test::command_for_fn;

const CHILD_COUNT: usize = 12;
const FRAME_COUNT_EACH_CHILD: usize = 100;
const SHM_SIZE: usize = 1024 * 1024;

let shm = fspy_shm::create(SHM_SIZE).unwrap();
let shm_id = shm.id().to_str().expect("test temp dir is UTF-8").to_owned();
// SAFETY: the mapping was just created, so it is valid, stable,
// page-aligned and zero-initialized, and only this protocol reaches it.
let receiver = unsafe { GatedShmReceiver::new(shm) };

let children: Vec<Child> = (0..CHILD_COUNT)
.map(|child_index| {
let cmd = command_for_fn!((shm_id.clone(), child_index), |(
shm_id,
child_index,
): (
String,
usize
)| {
let shm = fspy_shm::open(std::ffi::OsStr::new(&shm_id)).unwrap();
// SAFETY: the mapping was created under the same
// contract and only this protocol reaches it.
let writer = unsafe { GatedShmWriter::new(shm) };
for i in 0..FRAME_COUNT_EACH_CHILD {
let frame_data = format!("{child_index} {i}");
let size = NonZeroUsize::new(frame_data.len()).unwrap();
writer.claim_frame(size).unwrap().copy_from_slice(frame_data.as_bytes());
}
});
Command::from(cmd).spawn().unwrap()
})
.collect();

for mut child in children {
assert!(child.wait().unwrap().success());
}

let reader = receiver.close().unwrap();
let frames: FxHashSet<&str> =
reader.iter_frames().map(|frame| from_utf8(frame).unwrap()).collect();
assert_eq!(frames.len(), CHILD_COUNT * FRAME_COUNT_EACH_CHILD);
for child_index in 0..CHILD_COUNT {
for i in 0..FRAME_COUNT_EACH_CHILD {
assert!(frames.contains(format!("{child_index} {i}").as_str()));
}
}
}
}
Loading
Loading