Skip to content

Commit b5602be

Browse files
wan9chiclaude
andcommitted
refactor(fspy): simplify shm_io to a plain frame arena
The gated API reads shared memory only when the gate is closed and nothing was in flight, which proves every claimed frame was fully written and published. The three-state frame header (`0` / `+size` / `-size`), the atomic header stores and the release fences existed so a reader could tolerate crashed or in-flight writers — states that are unreachable through the gate. Delete them. A frame is now `| size: u32 | content | padding |`, written once at claim time with a plain store, and the arena keeps exactly one atomic: its end offset. Crash exclusion lives entirely in the gate, where a leaked count fails closed — the run is not cached — instead of being parsed around. The arena also stops being a surface of its own and becomes an internal of the gated module. No public API change: `GatedShmWriter`, `GatedShmReceiver`, `GatedShmReader`, `FrameMut`, `ClaimError` and `StillWriting` keep their signatures, so the channel layer, the fspy crate and both preload clients are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4f9db9f commit b5602be

8 files changed

Lines changed: 423 additions & 767 deletions

File tree

Cargo.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/fspy_shared/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ bytemuck = { workspace = true }
2121
winapi = { workspace = true, features = ["std"] }
2222

2323
[dev-dependencies]
24-
assert2 = { workspace = true }
2524
ctor = { workspace = true }
2625
rustc-hash = { workspace = true }
2726
subprocess_test = { workspace = true }

crates/fspy_shared/src/ipc/channel/gated.rs

Lines changed: 211 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,14 @@ use std::{
2727
sync::{Arc, atomic::AtomicU64},
2828
};
2929

30-
use wincode::{SchemaWrite, config::DefaultConfig};
30+
use wincode::{SchemaWrite, Serialize as _, config::DefaultConfig};
3131

32-
use super::{
33-
gate::{EnterError, Gate, GateGuard},
34-
shm_io::{self, AsRawSlice, ShmReader, ShmWriter},
35-
};
32+
use self::shm_io::{ShmReader, ShmWriter};
33+
use super::gate::{EnterError, Gate, GateGuard};
34+
35+
mod shm_io;
36+
37+
pub use shm_io::AsRawSlice;
3638

3739
/// Bytes reserved in front of the arena for the gate word.
3840
const GATE_REGION_SIZE: usize = 64;
@@ -125,29 +127,45 @@ impl<M: AsRawSlice> GatedShmWriter<M> {
125127
/// [`ClaimError::Capacity`] when the arena cannot fit the frame.
126128
pub fn claim_frame(&self, size: NonZeroUsize) -> Result<FrameMut<'_>, ClaimError> {
127129
let gate = self.gate().enter()?;
128-
let frame = self.arena.claim_frame(size).ok_or(ClaimError::Capacity)?;
129-
Ok(FrameMut { frame, _gate: gate })
130+
let content = self.arena.claim_frame(size).ok_or(ClaimError::Capacity)?;
131+
Ok(FrameMut { content, _gate: gate })
130132
}
131133

132134
/// Appends an encoded value as a single frame.
133135
///
134136
/// # Errors
135137
///
136138
/// Returns [`ClaimError::Closed`] wrapped in [`WriteEncodedError::Claim`]
137-
/// once the read end has closed the gate, and otherwise whatever the arena
138-
/// reports.
139+
/// once the read end has closed the gate, [`WriteEncodedError::ZeroSizedFrame`]
140+
/// for a value that encodes to nothing, and otherwise the encoder's error.
141+
///
142+
/// # Panics
143+
///
144+
/// Panics if the encoder does not fill exactly the size it asked for, which
145+
/// would mean the schema is inconsistent with itself.
139146
pub fn write_encoded<T: SchemaWrite<DefaultConfig, Src = T>>(
140147
&self,
141148
value: &T,
142149
) -> Result<(), WriteEncodedError> {
143-
// The guard is held across the inner write and released only afterwards,
144-
// so the frame is complete before the gate count drops.
145-
let _gate = self.gate().enter().map_err(ClaimError::from)?;
146-
self.arena.write_encoded(value).map_err(|error| match error {
147-
shm_io::WriteEncodedError::EncodeError(error) => WriteEncodedError::Encode(error),
148-
shm_io::WriteEncodedError::ZeroSizedFrame => WriteEncodedError::ZeroSizedFrame,
149-
shm_io::WriteEncodedError::InsufficientSpace => ClaimError::Capacity.into(),
150-
})
150+
// Enter the gate before touching the value, so a post-close call
151+
// reports `Claim(Closed)` no matter what the value encodes to.
152+
let gate = self.gate().enter().map_err(ClaimError::from)?;
153+
154+
let serialized_size =
155+
usize::try_from(T::serialized_size(value)?).expect("serialized size exceeds usize");
156+
let Some(size) = NonZeroUsize::new(serialized_size) else {
157+
return Err(WriteEncodedError::ZeroSizedFrame);
158+
};
159+
160+
let content = self.arena.claim_frame(size).ok_or(ClaimError::Capacity)?;
161+
// The frame holds the gate guard, so it is released only after the
162+
// content is written.
163+
let mut frame = FrameMut { content, _gate: gate };
164+
let mut writer: &mut [u8] = &mut frame;
165+
T::serialize_into(&mut writer, value)?;
166+
assert_eq!(writer.len(), 0);
167+
168+
Ok(())
151169
}
152170

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

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

171190
fn deref(&self) -> &Self::Target {
172-
&self.frame
191+
self.content
173192
}
174193
}
175194

176195
impl DerefMut for FrameMut<'_> {
177196
fn deref_mut(&mut self) -> &mut Self::Target {
178-
&mut self.frame
197+
self.content
179198
}
180199
}
181200

@@ -407,6 +426,18 @@ mod tests {
407426
assert!(reader.iter_frames().all(|frame| frame == b"filler"));
408427
}
409428

429+
#[test]
430+
fn write_encoded_after_close_reports_closed_for_any_value() {
431+
let (writer, receiver) = endpoints(1024);
432+
let _frames = receiver.close().unwrap();
433+
434+
// A zero-sized value must still report the close, not its own size.
435+
assert!(matches!(
436+
writer.write_encoded(&()),
437+
Err(WriteEncodedError::Claim(ClaimError::Closed))
438+
));
439+
}
440+
410441
#[test]
411442
fn write_encoded_roundtrips_through_the_gate() {
412443
let (writer, receiver) = endpoints(1024);
@@ -452,4 +483,162 @@ mod tests {
452483
assert_eq!(seen.len(), WRITERS * PER_WRITER);
453484
assert_eq!(seen[0], "0 0");
454485
}
486+
487+
/// Writers that run out of room must fail cleanly rather than corrupt the
488+
/// arena for the ones that fitted.
489+
#[test]
490+
fn concurrent_writers_exceeding_the_arena() {
491+
let (writer, receiver) = endpoints(1024);
492+
493+
thread::scope(|scope| {
494+
for _ in 0..4 {
495+
scope.spawn(|| {
496+
for _ in 0..10 {
497+
let _ = write_frame(&writer, b"hello");
498+
let _ = write_frame(&writer, b"foo");
499+
let _ = write_frame(&writer, b"this is a test");
500+
}
501+
});
502+
}
503+
});
504+
505+
let reader = receiver.close().unwrap();
506+
let mut count = 0;
507+
for frame in reader.iter_frames() {
508+
count += 1;
509+
assert!(
510+
frame == b"hello" || frame == b"foo" || frame == b"this is a test",
511+
"unexpected frame {:?}",
512+
from_utf8(frame)
513+
);
514+
}
515+
assert!(count > 20, "expected most writes to fit, got {count}");
516+
}
517+
518+
/// Several threads racing for the last few bytes must all agree on who got
519+
/// them, and the reader must walk whatever survived without panicking.
520+
#[test]
521+
fn concurrent_writers_racing_for_the_last_bytes() {
522+
let (writer, receiver) = endpoints(GATE_REGION_SIZE + 200);
523+
524+
thread::scope(|scope| {
525+
for _ in 0..10 {
526+
scope.spawn(|| {
527+
let _ = write_frame(
528+
&writer,
529+
b"this_is_a_moderately_long_frame_that_might_cause_races",
530+
);
531+
});
532+
}
533+
});
534+
535+
let reader = receiver.close().unwrap();
536+
let count = reader.iter_frames().count();
537+
// Some but not all of them fit into 200 bytes of arena.
538+
assert!(count > 0);
539+
assert!(count < 10);
540+
}
541+
542+
/// A frame larger than the size header can describe is refused, and the
543+
/// arena stays usable afterwards.
544+
#[test]
545+
fn a_frame_too_large_for_the_header_is_refused() {
546+
let (writer, receiver) = endpoints(1024);
547+
548+
let oversized = NonZeroUsize::new(usize::try_from(u32::MAX).unwrap() + 1).unwrap();
549+
assert!(matches!(writer.claim_frame(oversized), Err(ClaimError::Capacity)));
550+
551+
write_frame(&writer, b"test").unwrap();
552+
let reader = receiver.close().unwrap();
553+
assert_eq!(reader.iter_frames().collect::<Vec<_>>(), vec![b"test".as_slice()]);
554+
}
555+
556+
#[test]
557+
fn a_misaligned_region_is_rejected() {
558+
struct Misaligned(MockRegion);
559+
560+
impl AsRawSlice for Misaligned {
561+
fn as_raw_slice(&self) -> *mut [u8] {
562+
let raw_slice = self.0.as_raw_slice();
563+
slice_from_raw_parts_mut(
564+
// SAFETY: shifting by one byte deliberately misaligns the
565+
// region and stays inside the over-allocated backing buffer.
566+
unsafe { raw_slice.cast::<u8>().add(1) },
567+
raw_slice.len() - 1,
568+
)
569+
}
570+
}
571+
572+
let misaligned = Misaligned(MockRegion::alloc(1024));
573+
assert_ne!(misaligned.as_raw_slice().cast::<u8>() as usize % align_of::<u64>(), 0);
574+
575+
let result = std::panic::catch_unwind(|| {
576+
// SAFETY: the region is deliberately misaligned to check that the
577+
// constructor catches it; the assertion fires before any access.
578+
unsafe { GatedShmWriter::new(misaligned) };
579+
});
580+
581+
assert!(result.is_err(), "a misaligned region must be rejected");
582+
}
583+
584+
/// The same protocol over a real shared mapping, written by several
585+
/// processes at once.
586+
#[test]
587+
#[cfg(not(miri))]
588+
fn real_shm_across_processes() {
589+
use std::process::{Child, Command};
590+
591+
use rustc_hash::FxHashSet;
592+
use subprocess_test::command_for_fn;
593+
594+
const CHILD_COUNT: usize = 12;
595+
const FRAME_COUNT_EACH_CHILD: usize = 100;
596+
const SHM_SIZE: usize = 1024 * 1024;
597+
598+
let (keeper, handle) = fspy_shm::create(SHM_SIZE).unwrap();
599+
let shm_id = keeper.id().to_str().expect("test temp dir is UTF-8").to_owned();
600+
let mapping = handle.map().unwrap();
601+
// SAFETY: the backing file was just created zero-initialized, the
602+
// mapping stays valid while the receiver holds it, and only this
603+
// protocol reaches it.
604+
let receiver = unsafe { GatedShmReceiver::new(mapping) };
605+
606+
let children: Vec<Child> = (0..CHILD_COUNT)
607+
.map(|child_index| {
608+
let cmd = command_for_fn!((shm_id.clone(), child_index), |(
609+
shm_id,
610+
child_index,
611+
): (
612+
String,
613+
usize
614+
)| {
615+
let mapping =
616+
fspy_shm::open(std::ffi::OsStr::new(&shm_id)).unwrap().map().unwrap();
617+
// SAFETY: the mapping was created under the same
618+
// contract and only this protocol reaches it.
619+
let writer = unsafe { GatedShmWriter::new(mapping) };
620+
for i in 0..FRAME_COUNT_EACH_CHILD {
621+
let frame_data = format!("{child_index} {i}");
622+
let size = NonZeroUsize::new(frame_data.len()).unwrap();
623+
writer.claim_frame(size).unwrap().copy_from_slice(frame_data.as_bytes());
624+
}
625+
});
626+
Command::from(cmd).spawn().unwrap()
627+
})
628+
.collect();
629+
630+
for mut child in children {
631+
assert!(child.wait().unwrap().success());
632+
}
633+
634+
let reader = receiver.close().unwrap();
635+
let frames: FxHashSet<&str> =
636+
reader.iter_frames().map(|frame| from_utf8(frame).unwrap()).collect();
637+
assert_eq!(frames.len(), CHILD_COUNT * FRAME_COUNT_EACH_CHILD);
638+
for child_index in 0..CHILD_COUNT {
639+
for i in 0..FRAME_COUNT_EACH_CHILD {
640+
assert!(frames.contains(format!("{child_index} {i}").as_str()));
641+
}
642+
}
643+
}
455644
}

0 commit comments

Comments
 (0)