HandoffBuffer class

Shared memory for a value where only the newest copy matters - a rendered frame - handed between one writing isolate and one reading isolate with neither ever blocking the other.

Why not a RingBuffer, and why not a TripleBuffer

A RingBuffer keeps every record in order. That is right for commands, where dropping the third of five is a bug. It is wrong for frames: the instant a newer frame exists the older one is garbage, so a queue faithfully preserves exactly what nobody wants - and being bounded, it forces the reader to drain every tick whether it intends to draw or not, or the writer starts failing.

A TripleBuffer is closer, but its writer cycles slots blindly: 0, 1, 2, 0, ... with no idea where the reader is. That gives the reader two publishes of grace and then reuses its slot underneath it. Safe only while the reader is reliably faster than two of the writer's frames, which is an assumption about scheduling rather than a guarantee - and when it breaks, it breaks silently, as wrong pixels.

The handoff

Two shared words remove the guessing. Each is written by exactly one side and read by the other, so neither needs more than an aligned word store:

  • ready - "this slot is complete, you may read it". Written by the writer, read by the reader.
  • reading - "I am holding this slot right now". Written by the reader, read by the writer.

The writer takes any slot that is neither ready nor reading. That is the whole rule, and both halves matter: not reading, or it would scribble under the reader; not ready, or it would overwrite the frame the reader is entitled to pick up at any moment.

Why the reader says what it holds, not what it has released

An earlier version had the reader publish free - "you may write here" - which sounds equivalent and is not. free only advances when the reader reads, so the writer produced exactly one frame per read and then idled. The frame waiting was therefore the one produced just after the previous read, and by the time the reader came back it was a whole read-interval stale. That is the opposite of what a renderer wants, and it defeated the point of moving off the ring.

Saying what it holds instead lets the writer keep going: it always has a slot that is neither being read nor waiting to be read, so it replaces its own unread frame with a fresher one and the reader always collects the newest.

Three slots, and why two will not do

The reader holds one and the newest complete one is another, so the writer needs a third to work in. With two, "neither ready nor reading" can be the empty set and the writer has nowhere to go - which is exactly the stalling behaviour above. Three is the minimum that lets the writer run freely while a reader holds a slot for as long as it likes.

Claim, then confirm

The reader writes reading and then re-reads ready. The writer only ever enters a slot after publishing a different one, so if ready has not moved since the claim, the writer cannot be inside the slot just claimed. Without that second read there is a window: the reader picks up ready, the writer publishes elsewhere and then steps into the slot the reader was about to claim.

Memory ordering

Every control word is a naturally-aligned 32-bit slot in one block, so each store and load is atomic on every architecture Dart targets, and no compare-and-swap is needed (stable dart:ffi exposes none anyway). What is not here is an explicit fence: publish writes the payload, then the used length, then ready last, and correctness relies on those retiring in order as seen from the other isolate. That holds on the strongly-ordered targets this engine runs on (x64/ARM64). Flagged rather than assumed away - revisit with a real release-store if a weak-memory target ever matters.

Constructors

HandoffBuffer(int slotBytes)
HandoffBuffer.fromAddresses({required int slotBytes, required int controlAddress, required List<int> slotAddresses})
Rebuilds a view over a buffer the other isolate allocated. The reconstructing side gets the same native memory at the same addresses - see Game's notes on what survives Isolate.spawn.

Properties

controlAddress int
no setter
hashCode int
The hash code for this object.
no setterinherited
hasPublished bool
Whether anything has ever been published - for a reader that wants to distinguish "no frame yet" from "no new frame".
no setter
readUsedBytes int
How many bytes of the slot from the last beginRead are real.
no setter
runtimeType Type
A representation of the runtime type of the object.
no setterinherited
slotAddresses List<int>
no setter
slotBytes int
Capacity of one slot. A frame that would exceed it is the caller's problem to bound - see GameRenderer2D.maxSpritesPerTick.
final

Methods

beginRead() Pointer<Uint8>?
Takes the newest complete slot and holds it, or null when nothing new has been published since the last call.
beginWrite() Pointer<Uint8>?
The slot to fill: any that is neither the newest complete one nor the one a reader is holding.
dispose() → void
noSuchMethod(Invocation invocation) → dynamic
Invoked when a nonexistent method or property is accessed.
inherited
publish(int usedBytes) → void
Marks the slot from the last beginWrite complete and readable.
toString() String
A string representation of this object.
inherited

Operators

operator ==(Object other) bool
The equality operator.
inherited

Constants

slotCount → const int
Three: one being read, one complete and waiting, one to write into. See the class doc on why two stalls the writer.