ADR-0032: Shared-memory lifetime -- the consumed-ack protocol¶
Status: accepted (shipped 0.4.15) -- records a correctness-critical
protocol that lived only in CLAUDE.md and was referenced but never
owned by ADR-0019 / ADR-0029.
Decision¶
A shared-memory block lives until the reader says it is done with it, not until a timer guesses so. After the parent reads a reply, it sends the worker
{"type": "consumed", "call_id": N}; the worker frees that call's shm blocks and held tensors on receipt. The retention TTL survives only as the crash fallback, for a parent that died before acking.
Mechanics (_persistent_worker.py, parent subprocess.py):
- The worker serializes a reply into shm blocks and hands the metadata to
the parent, then keeps the blocks alive in a per-call keeper
(
TensorKeeper/ShmKeeper, keyed bycall_id). - The parent reconstructs the reply (mapping or copying those blocks),
then sends
consumedwith thatcall_id. The worker drops the keeper entry; the OS frees the block when the last handle closes. - If no ack arrives, a TTL sweep (
TENSOR_KEEPER_TTL, 60 s) frees the block anyway -- the fallback for a crashed or wedged parent, never the primary path. - Materialize-on-receipt is the sibling rule (ADR-0029):
a value the receiver holds but cannot reconstruct (an
OpaquePayload) is copied into receiver-owned memory before the ack, so the receipt no longer references the sender's shm and survives the sender's death.
The ownership rule in one line: the side that produced the block frees it, on a signal from the side that read it -- never on a clock.
Context¶
The predecessor design used the 60 s TTL as the primary keeper: the worker freed a reply's blocks 60 s after creating them, hoping the parent had read them by then. A timer cannot be correct here, because "has the reader finished" is not a function of elapsed time:
- the parent may be slow (a multi-gigabyte deserialize, a suspended laptop, GC pressure);
- the parent may re-enter the worker mid-read (a VRAM-eviction callback, ADR-0025);
- a fat multi-output reply starts every block's clock at creation, so output #1 can expire while output #10 is still being serialized.
The concrete failure this caused was the WinError 2 crash class: a
lib-less host held an OpaquePayload whose frames pointed at worker shm
that the TTL had already freed; forwarding the receipt later hit a dead
shm name. The fix pair -- event-driven release (this ADR) plus
materialize-on-receipt (0029) -- removed timer-based correctness from the
transport entirely.
The protocol is single-in-flight per worker (ADR-0020),
so call_id correlation is unambiguous: the parent acks exactly the reply
it just read, and the worker has at most a handful of un-acked calls
outstanding.
Consequences¶
- Worker memory for a reply is held for microseconds-to-milliseconds (the read + ack round-trip) instead of a fixed 60 s, so peak shm footprint tracks in-flight replies, not wall-clock.
- Correctness no longer depends on the reader being "fast enough"; the TTL is now a pure liveness backstop and can be raised without risk.
- The protocol assumes the parent always acks a reply it consumed. A parent bug that reads-but-forgets-to-ack degrades to the old behavior (freed at TTL) rather than leaking -- a safe failure.
- Double-crash shm leak (known gap): if both parent and worker are
SIGKILLed, no ack and no TTL sweep runs, so the reply's
/dev/shmblocks leak until reboot. The startup sweep (wrap.py) reclaims stale sockets and temp dirs but not orphaned shm blocks; closing that is a small future item, tracked on the roadmap. - Any future move off single-in-flight (the pending map,
ADR-0010 v2 item 2) must preserve
per-
call_idack semantics -- the keeper is keyed on it.