Skip to content

Commit 0a80d2b

Browse files
wan9chiclaude
andcommitted
docs(fspy-shm): add a protocol README to shm_io
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d91b2fb commit 0a80d2b

2 files changed

Lines changed: 152 additions & 0 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# shm_io: the fspy frame channel
2+
3+
One shared-memory region. Many writer processes append records; one
4+
receiver reads them once, at the end of a run. The writers are traced build
5+
processes reporting file accesses; the receiver is the runner deciding
6+
whether the run can be cached.
7+
8+
Three requirements shaped everything here:
9+
10+
1. **A writer may die at any instruction** — SIGKILL included. This must
11+
never corrupt the channel or lose another writer's records.
12+
2. **A writer may outlive the run** (a daemon). The receiver must never
13+
wait for writers; closing is immediate.
14+
3. **The result must be safe to cache from.** Either the trace holds every
15+
record the run reported, or the run is declared uncacheable. Never a
16+
silently short trace.
17+
18+
Earlier designs failed these. A file lock proved "no writers left", but a
19+
process that closes file descriptors it does not recognize releases the
20+
lock while its memory mapping keeps writing — the reader then parsed
21+
half-written bytes (issue #544). Waiting for writers hangs forever on
22+
daemons. Counting active writers breaks because a killed process never
23+
decrements the count.
24+
25+
## The region
26+
27+
The channel is a sparse file in the temp directory, mapped into every
28+
participating process. It is address space, not memory: only pages that
29+
are actually written get backed.
30+
31+
```text
32+
| header (64 B) | descriptor table (1/8 of the region) | payloads (the rest, grow up) |
33+
```
34+
35+
The header holds three words, and every one of them only ever counts up:
36+
37+
- the **claim counter** — how many frames were ever claimed. Bit 63 is the
38+
CLOSED gate.
39+
- the **incomplete flag** — nonzero once any live writer lost a record.
40+
- the **payload counter** — how many payload bytes were ever reserved,
41+
including by failed claims. Only writers read it.
42+
43+
The table has one 8-byte slot per frame. For the production 4 GiB region
44+
that is ~67 million slots; the ~3.5 GiB payload region fits ~15–20 million
45+
typical records, so payload space runs out first. The split is fixed, not a
46+
movable frontier, because fixed bounds are what make claiming wait-free:
47+
each counter is checked against its own constant limit using the value
48+
`fetch_add` returned, and overshooting a limit is harmless because nothing
49+
ever locates data through a counter — descriptors are self-describing.
50+
51+
## Writing a frame
52+
53+
Three steps:
54+
55+
1. **Claim.** Two `fetch_add`s — one reserves payload bytes, one reserves a
56+
slot. No retry loop, no lock. A claim that does not fit fails after the
57+
fact; the wasted counter space does not matter (see above).
58+
2. **Fill.** The writer serializes into its payload span. The span is
59+
exclusively its own; nobody else knows it exists yet.
60+
3. **Commit.** One compare-and-swap flips the frame's slot from zero to a
61+
descriptor holding the payload's offset and length. The CAS is the
62+
publication point: before it, the frame does not exist; after it, the
63+
payload is immutable.
64+
65+
Committing is explicit (`FrameMut::finish`). What happens when it never
66+
runs is the heart of the design:
67+
68+
- **The process died** — mid-claim, mid-fill, anywhere. The slot stays
69+
zero. The receiver ignores it. Nothing else is affected, and no cleanup
70+
code ever runs or is needed.
71+
- **The process is alive but abandoned the frame** (dropped it without
72+
finishing). The drop sets the incomplete flag, because the process will
73+
go on to perform the file operation it just failed to record — the trace
74+
now under-reports, so the run must not be cached.
75+
76+
The rule that makes ignoring dead writers safe: **a record is committed
77+
before the recorded operation is performed.** A dead writer's missing
78+
record is an operation that never happened. A record refused after close
79+
belongs to an operation performed after the run's boundary.
80+
81+
## Closing and reading
82+
83+
The receiver closes once, at the end of the run:
84+
85+
1. **Snapshot** the claim counter with a plain load. This is the boundary:
86+
claims at or before it are in the run, later ones are not.
87+
2. **Gate** further claims by setting the CLOSED bit, so stragglers stop.
88+
3. **Freeze** every slot in the snapshot: a compare-and-swap flips zero to
89+
ABORTED. If the slot was already committed, the swap fails and the frame
90+
is kept. Exactly one side wins each slot; both outcomes are terminal.
91+
4. **Validate** each committed descriptor's bounds. A descriptor no correct
92+
writer could produce fails the whole trace — never a panic, never an
93+
out-of-bounds read.
94+
95+
The result, `Frames`, owns the mapping and lends out one `&[u8]` per
96+
committed span, straight from shared memory — no copy. The borrows are
97+
sound because a committed span is never written again and is disjoint from
98+
everything a live straggler may still touch. The mapping is released when
99+
`Frames` is dropped.
100+
101+
```text
102+
writer's commit CAS wins
103+
+------------------------------> COMMITTED (readable)
104+
CLAIMED (slot 0) ---+
105+
+------------------------------> ABORTED (ignored)
106+
receiver's freeze CAS wins
107+
```
108+
109+
## Why this is sound, in one list
110+
111+
- Frame traversal never reads payload bytes; every slot has a fixed place.
112+
The #544 failure class (payload parsed as metadata) is structurally gone.
113+
- A payload is reachable only through its committed descriptor. The commit
114+
is a `Release` write and the receiver's failed freeze is an `Acquire`
115+
read, so an observed descriptor implies fully visible payload bytes.
116+
- Committed and aborted are terminal. No code path changes a terminal slot.
117+
- Counters only grow. The receiver clamps them to the fixed capacities, so
118+
an inflated counter degrades into extra aborted slots, not corruption.
119+
- The bounds checks on descriptors are what justify the `unsafe` borrow
120+
construction: the receiver's memory safety never depends on another
121+
process being correct.
122+
- Byte _integrity_ does trust protocol compliance — a process scribbling
123+
random memory is outside the model. That trust buys the borrow-in-place
124+
reader and the absence of checksums.
125+
126+
## Performance notes
127+
128+
- Claiming is two atomic adds; committing is one CAS. Nothing retries.
129+
- Closing costs one pass over the claimed slots. No payload is copied.
130+
- On Linux, the first touch of the sparse file can cost milliseconds on
131+
journalling filesystems (it is the fault path, not block allocation —
132+
`fallocate` does not help). Channel creation therefore pre-touches the
133+
header page on a background thread, concurrently with process startup.
134+
Windows and macOS fault cheaply and skip this.
135+
136+
## Files
137+
138+
| File | Role |
139+
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
140+
| `mod.rs` | Public surface (`ShmWriter`, `FrameMut`, `close`, `Frames`), the protocol overview docs, and the integration tests — they run against a mocked region and are miri-clean (`cargo miri test -p fspy_shared shm_io`). |
141+
| `layout.rs` | Pure geometry: header offsets, the table/payload split, span validation. Plain integer math, no pointers, no atomics. |
142+
| `slot.rs` | The descriptor codec: pack and unpack one slot value, classify it as unfinished, aborted, committed, or corrupt. Pure. |
143+
| `state.rs` | The only module that touches shared memory. All atomics, every unsafe pointer derivation, and the three-rule memory-ordering contract live here. |
144+
| `writer.rs` | Claim, fill, finish. Owns the argument for why a claimed payload span is exclusively the writer's. |
145+
| `reader.rs` | Close (snapshot, gate, freeze, validate) and `Frames`. Owns the argument for why borrowing committed spans is sound. |
146+
147+
Each file carries one self-contained argument, so the protocol can be
148+
reviewed module by module: the pure math first, then the atomics, then the
149+
two aliasing arguments built on top of them.

crates/fspy_shared/src/ipc/channel/shm_io/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
//! waiting for any writer. A process may die at any instruction — mid-claim,
66
//! mid-write, pre-commit — and only its own unfinished frame is lost.
77
//!
8+
//! `README.md` in this directory tells the whole story in plain words and
9+
//! indexes the modules.
10+
//!
811
//! # Region layout
912
//!
1013
//! ```text

0 commit comments

Comments
 (0)