Skip to content
Merged
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
62 changes: 59 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1921,7 +1921,7 @@ resolve the new key and read the previous tenant's bytes.

```
t1 = tenancy.load(acquire); if (t1 != cached_tenancy) -> re-resolve
v1 = publish.load(acquire); k = v1 >> 1; if (k == 0) -> Unset
v1 = publish.load(acquire); k = v1 >> 1; if (k == 0) -> ENOMSG
len = cell[k & 1].value_len.load(relaxed)
if (len > value_capacity) { len = value_capacity } // clamp BEFORE the memcpy
memcpy(out, cell[k & 1].payload, len)
Expand All @@ -1935,10 +1935,38 @@ retry iff v2 - (v1 & ~1ULL) >= 2 * CELLS_PER_KEY - 1

The unsigned retry predicate is exact for both parities of `v1` and
cannot overflow on a corrupt word. Retries are **bounded**: after
`READ_RETRY_BUDGET` attempts the read reports `Busy` rather than
spinning. `Busy` is transient by construction -- it means a writer
`READ_RETRY_BUDGET` attempts the read reports `EAGAIN` rather than
spinning. `EAGAIN` is transient by construction -- it means a writer
outran the reader, never that anything is broken.

### Errors are standard errno

`Writer::write`, `Writer::release`, `Blackboard::wait` and
`ReadOutcome::ec` are `std::error_code` over `std::errc`: every outcome
the blackboard can report already has a standard errno that names it.

| errno | means |
|---|---|
| `ENOENT` | no entry for this key |
| `ENOMSG` | key declared, never written |
| `EMSGSIZE` | read buffer too small, or written value over `max_value_size` |
| `EAGAIN` | retry budget exhausted under a hot writer; transient |
| `EBADMSG` | typed read: the value is not `sizeof(T)` bytes |
| `EBADF` | default-constructed or moved-from handle |
| `EPERM` | caller is a `fork()` child; the claim is the parent's |
| `ENOTRECOVERABLE` | the entry was swept and re-tenanted -- the claim is gone |
| `EBUSY` | the board lock stayed held for the whole bounded wait |
| `ETIMEDOUT` | `wait()` returned without `change_seq` moving |

Ignoring the result is the caller's choice: no board invariant depends
on it.

The Python bindings map the same outcomes onto Python's own standard:
a failing `write()` or `release()` raises `OSError` with `.errno` set,
and `ReadOutcome` exposes `.errno` to compare against the stdlib `errno`
module. `wait()` stays a `bool` there -- a timeout is an ordinary answer,
not a failure.

**Values are always copied.** There is deliberately no zero-copy view: a
writer may overwrite the cell mid-read, so unlike `SampleView`'s
refcount-pinned slot there is nothing safe to point at. Pinning would
Expand All @@ -1954,6 +1982,34 @@ suppression names one frame and leaves `write()` and `read()` themselves
checked. The blackboard stress scenario asserts
that no torn value ever escapes, over hundreds of thousands of reads.

### Listing the board

`Reader` answers "what is under this key". Three calls answer "what keys
are there": `snapshot()`, `keys()` and `read_all()`.

`snapshot()` is the diagnostic one. It reports ownership -- `owner_pid`,
`owner_node`, `owner_alive` -- and pays for it twice: it takes the board
lock, because a takeover rewrites `owner_node` in place with no seqlock
over those bytes, and it probes liveness with one OS call per active key
once the lock is dropped.

`keys()` and `read_all()` walk the entry array **unlocked**, running the
read protocol above over each `Active` entry: `keys()` copies the key,
`read_all()` copies the key and the value. A key overtaken by its writer
mid-read is dropped rather than returned torn. `keys()` lists a
declared-but-never-written key; `read_all()` has no value to return for
one.

The unlocked walk copies key bytes that `claim_free_slot()` may be
writing -- the race `bb_key_equals` already carries for
`Reader::resolve()`. `bb_read_key()` is the `noinline` helper that names
that one frame for `tests/tsan.supp`; the tenancy re-check after the copy
discards a torn key.

Neither call is atomic across the board: a key claimed or written during
the walk may or may not appear. `change_seq()` tells a caller whether the
board moved under it -- read it first, list, then compare.

### Key claim and uniqueness

`declare()` first scans for an `Active` entry holding the key (takeover),
Expand Down
24 changes: 5 additions & 19 deletions examples/hello_blackboard.cc
Original file line number Diff line number Diff line change
Expand Up @@ -61,20 +61,6 @@ namespace
return elapsed_time(nanoseconds{static_cast<int64_t>(updated_at_ns)});
}

char const* status_name(blackboard::Status status)
{
char const* name = "?";
switch (status)
{
case blackboard::Ok: { name = "Ok"; break; }
case blackboard::Missing: { name = "Missing"; break; }
case blackboard::Unset: { name = "Unset"; break; }
case blackboard::Truncated: { name = "Truncated"; break; }
case blackboard::Busy: { name = "Busy"; break; }
case blackboard::SizeMismatch: { name = "SizeMismatch"; break; }
}
return name;
}
}

int main()
Expand Down Expand Up @@ -110,23 +96,23 @@ int main()
ArmState seen{};
auto out = state_view.read(seen);
std::printf("[reader] arm/state -> %s (%s, fault %u, %.1f C) age %.3fs owner_alive=%d\n",
status_name(out.status), lifecycle_name(seen.lifecycle),
out.ec.message().c_str(), lifecycle_name(seen.lifecycle),
seen.fault_code, static_cast<double>(seen.temperature_c),
age_of(out.updated_at_ns).count(),
static_cast<int>(state_view.owner_alive()));

uint32_t mode = 0;
out = mode_view.read(mode);
std::printf("[reader] arm/mode -> %s (%u) age %.3fs\n",
status_name(out.status), mode, age_of(out.updated_at_ns).count());
out.ec.message().c_str(), mode, age_of(out.updated_at_ns).count());
std::printf("[reader] a Subscriber here would have received nothing at all.\n\n");

// --- Two states a topic cannot express --------------------------------
ArmState ignored{};
std::printf("[reader] arm/gripper -> %s (no writer ever declared it)\n",
status_name(hmi_board.observe("arm/gripper").read(ignored).status));
hmi_board.observe("arm/gripper").read(ignored).ec.message().c_str());
std::printf("[reader] arm/calibration -> %s (declared, never written)\n\n",
status_name(hmi_board.observe("arm/calibration").read(ignored).status));
hmi_board.observe("arm/calibration").read(ignored).ec.message().c_str());

// --- Change notification, without polling -----------------------------
// Read the sequence BEFORE acting on the current values, then wait on it:
Expand All @@ -145,7 +131,7 @@ int main()
for (int i = 0; i < 3; ++i)
{
uint64_t seq = hmi_board.change_seq();
if (hmi_board.wait(seq, 250ms))
if (not hmi_board.wait(seq, 250ms))
{
state_view.read(seen);
std::printf("[reader] woke on change: arm/state = %s fault %u\n",
Expand Down
6 changes: 3 additions & 3 deletions examples/python/hello_blackboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,15 @@ def main() -> int:
out = state_view.read()
lifecycle, fault, temperature = struct.unpack("<IIf", out.data)
age = (time.monotonic_ns() - out.updated_at_ns) / 1e9
print(f"[reader] arm/state -> {out.status.name} "
print(f"[reader] arm/state -> {out.error} "
f"({_LIFECYCLE[lifecycle]}, fault {fault}, {temperature:.1f} C) "
f"age {age:.3f}s owner_alive={state_view.owner_alive()}")
print("[reader] a Subscriber here would have received nothing at all.\n")

# --- Two states a topic cannot express ------------------------------
print(f"[reader] arm/gripper -> {hmi_board.observe('arm/gripper').read().status.name}"
print(f"[reader] arm/gripper -> {hmi_board.observe('arm/gripper').read().error}"
" (no writer ever declared it)")
print(f"[reader] arm/calibration -> {hmi_board.observe('arm/calibration').read().status.name}"
print(f"[reader] arm/calibration -> {hmi_board.observe('arm/calibration').read().error}"
" (declared, never written)\n")

# --- Change notification, without polling ---------------------------
Expand Down
Loading
Loading