Skip to content

ensemble: fault model, peer liveness, and honest message identity - #61

Open
adiled wants to merge 15 commits into
mainfrom
ensemble-cleanup
Open

adiled wants to merge 15 commits into
mainfrom
ensemble-cleanup

Conversation

@adiled

@adiled adiled commented Sep 27, 2026

Copy link
Copy Markdown
Owner

What this is

Twelve commits taking the ensemble from "looks distributed" to something
whose failure behaviour is specified, tested, and mostly correct. Three
phases: a fault model for the simulator, real peer liveness, and a fix to
the identity that delivery dedup was built on.

The through-line is that most of the bugs here were invisible — code
that could not fail visibly because nothing modelled failure. So the
simulator came first.

1. The simulator can now fail (4e630e6, 4843051, 8a9ea8f)

Loss, duplication, reordering, partition, bounded-buffering overflow,
and lossy heal — each with a LinkCounters oracle, so a test asserts
what the wire actually did rather than what arrived. Two test-only races
fixed along the way: a lossy-heal test that subscribed after healing, and
drift tests that collided on a shared temp directory.

2. Peers are live, dead, or absent — and each is handled (8784e07, 655d9c0, 9fa8fa8, 7ec3b37)

A liveness lease with directional probe, plus a redial supervisor with
exponential backoff. Four real bugs fell out:

  • An unproven peer was immortal. A lease that only counted received
    pings never expired for a peer that never pinged. Silence now means
    Stale; Dead means the transport reported closure.
  • The probe ran the wrong way. It checked our lease of them with their
    seq, not theirs of us.
  • Redial only dialled peers that had been evicted — so a peer that was
    never connected was never retried. Configured-but-absent is now a
    first-class case.
  • Undialable peers reported success. A dial that could not complete no
    longer counts as connected.

kill drops the outbound sender, so a dead peer produces silence rather
than a silently live connection.

3. rid is not a message identity (7c45d2f, 359d8c3, fbaf776, 523aea6)

The parked question — "should we dedup on rid?" — has an answer, and it
is no: chi:"echo" is the ack for an rid, so a request and its
response share one, and a receiver deduping on rid drops every response as
a duplicate of its request. Reference clients mint per-session rids
(p-<sid>) that recur across restarts.

The gossip id was neither. mint_msg_id hashed
(topic, rid, from, payload) — content addressing, which cannot tell
"delivered twice" from "sent twice", and repeats are what gossip is for
(a heartbeat, a standing overload alert, a retry). Worse, publish marked
its own id seen before sending, so a repeat that hashed alike was
dropped at the origin and never left; with a ms-precision rid, two
identical publishes in one millisecond hit it.

Ids are now assigned by the originator, once per publish, as
{origin6}-{ms:x}-{seq:x}, and unicast got the same treatment via an
optional mid — where mid answers which message is this and rid
answers which conversation is it in. A response carries the request's
rid and its own mid.

Also enforced dusk: a tone arriving past its own deadline is dropped
and counted, checked after the liveness stamp (an expired tone still
proves the link is alive) and before gossip re-fan.

These tests were mutation-tested, not just made green

  • dedup on mid disabled → a_retransmitted_mid_is_delivered_once fails
  • dedup on rid instead → a_response_echoing_the_request_rid_still_arrives
    and a_tone_with_no_mid_is_delivered_every_time_it_is_sent fail

The second is the one that matters: rid-dedup silently eats exactly the
sends the sender meant, and nothing else would look wrong.

4. Read the code instead of assuming it (523aea6)

Two claims in my own prior summary turned out to be wrong on inspection:

  • There is no per-tone signature. handshake_message covers only
    chi:"hello"; after that a connection is trusted wholesale, so mid is
    exactly as unprotected as chi and rid. Not uniquely weak — but
    BufReader::lines() has no frame cap, so a mid is genuinely unbounded.
    The seen-set is now bounded in bytes (cap * 32, digested), not just
    entries.
  • humd is a relay, not an originator. It forwards with to/from
    rewritten and everything else untouched, which is the only correct
    behaviour — a relay that minted its own mid would break dedup. The
    in-repo originator is hives/bp7, which mints p-{sid} and now also
    mints a mid. That matters there specifically because bp7 is a DTN
    store-and-forward bridge and re-running a prompt is not idempotent.

Two doc defects found by looking rather than reading:

  • seen_set_dedups_within_capacity in gossip never approached capacity —
    three lookups, with a name promising a bound. Renamed honestly, and
    given a real eviction test.
  • Ten rows of WIRE.md's chi tables contradicted
    thrum-core/src/views.rs
    , which calls itself the source of truth and is
    locked by a golden-bytes test. permission-ask and release-permit were
    both documented with a permitId neither body has; session-ready had
    claudeSessionId against a real nestId; pulse had cellId against a
    real pid. Corrected, and the tables now note sid is an envelope field,
    which it listed as a body field throughout.

Two latent test races fixed: humd::peers and humd::identity were
setting and removing process-global XDG_CONFIG_HOME/XDG_STATE_HOME,
which under parallel tests repointed every other test in the process at
their own fixture. Split out load_from / load_or_mint_key_at; the
production entry points are unchanged.

Deliberately not done

  • Unicast mid is enforced but nothing mints one except bp7. The
    nest-side originators are the TS/Go/Python clients, outside this repo.
  • No default gossip TTL. A library TTL silently drops announcements on
    a congested mesh, and how stale an alert may be is the publisher's call.
    publish_with_dusk makes it reachable; the default stays None.
  • No second seen-set at the local socket edge. It is a single Unix
    stream with no duplication source, so one set per receiving edge is the
    correct topology.

Verification

cargo test --workspace — 51/51 test binaries, three consecutive runs.
cargo clippy --workspace --all-targets — 0 errors (45 pre-existing
collapsible_if warnings at baseline, unchanged). hives/bp7 is excluded
from the workspace and was built standalone.

Salvage checkpoint before restoring sim link-control wrappers.
Adds impair/impair_dir/link_counters/buffered/heal_link_faults so a
scenario can name a fault, drive known tones across a broken link, and
assert the receiver saw what the link says it sent.

Two ordering defects surfaced while writing them:

- nestler_send injects on a detached task, so consecutive sends have no
  wire order. A fault script names a tone by position, so it needs
  nestler_send_ordered, which injects in the caller's task.
- a broadcast receiver only sees tones published after it exists, so a
  test that subscribes inside a spawned task races its own first send.
  humd_peer_sub takes the handle before the scenario sends.

Link setup is traffic the link really carried, so scenarios measure
LinkCounters::since a post-handshake baseline rather than a total.

heal flushes the partition buffer through the link's own fault verdict,
so a lossy heal loses the head of the buffer. Test asserts that.

9 scenarios: intact, loss, dup, reorder, partition, lossy heal, recovery,
directional impairment, and inbox-level drop accounting.
A peer is live while traffic has arrived within ttl. The drainer
stamps a per-peer lease on every inbound tone, so a busy peer needs no
probing; a wedged link stops stamping and goes stale.

The drainer previously exited on a closed transport leaving the
registry entry orphaned, and nothing anywhere removed a peer except the
peer-remove CLI. The exit now records TransportClosed, which a sweep
can reap.

chi:peer-ping / chi:peer-pong as a literal pair, matching how
gossip-publish and kad-find-node avoid pulling the chi enum into this
crate. Probes are answered in the drainer and never reach subscribers.

Dead outranks stale: the transport's word about its own link is a fact,
whereas staleness is inferred from a quiet clock.
Boot dialed every peer once and never looked back, so a peer that died
stayed in the registry forever: routing kept addressing a dead link,
and a peer that restarted was unreachable because nobody dialed it
again. Recovery required restarting the daemon.

The supervisor probes, then sweeps. A peer that stops answering goes
stale; after ttl it is evicted and redialled. Backoff is per-peer with
full jitter, so one dead peer doesn't slow the others and a mesh
restarting together doesn't synchronise onto the same retry instant.

tick() is public and returns a SweepReport so a scenario can drive the
supervisor deterministically instead of waiting on a timer.

Each transport gains dial_one, so a redial failure is attributable to
one peer rather than a whole sweep. A transport that isn't configured
is skipped rather than failed.
InMemoryEndpoint::kill drops the outbound *sender*, which is what
closes the peer's receiver and ends its drainer. close() dropped only
our own receiver, so it stopped us reading while leaving the peer with
a link that looked fine — a kill built on it detected nothing.

kill is distinct from partition on purpose: a partitioned link stays
nominally up and its peer keeps renewing its lease, so a partition
heals itself and a dead peer needs a redial. A liveness sweep that
reaped partitions would churn the peer set on every network blip.

The sender moved behind a lock so kill can drop it. Sends go through
emit/try_emit, which take the sender out for the duration rather than
holding a parking_lot guard across the await — holding it would block
kill, and kill would deadlock against an in-flight send.
tmp() keyed the directory on pid + now_ms(). Tests run in parallel,
so two starting in the same millisecond shared a directory; whichever
finished first called remove_dir_all and the other found it empty.
Seen as flag_persists_through_wilt failing in a full workspace run and
passing alone. Add a per-call counter to the name.
Three defects, all found by driving the real supervisor and sim rather
than the units underneath them.

An unproven peer never expired. Lease::state returned Live whenever
last_seen was None, so a link that swallowed every probe stayed Live
forever — "unproven" had quietly become "immortal", and the black-hole
case was the one case that could not be detected at all. A lease is a
deadline: count silence from install, not from the first reply.

Sim::probe(a, b) looked up b and probed b's peers, so it ignored a
entirely and reported on the reverse direction. It happened to agree
with the fault scripts written alongside it, which is how the bug
survived. Add Ensemble::probe_one so a probe says something about the
direction it travels; clone the conn out from under the registry lock
while we are here, since holding that read lock across a send blocks
every add_peer/remove_peer for the length of the send.

The redial set was "just evicted", which misses every peer that failed
its *first* dial: those were never installed, so no sweep can name
them, and a peer down at boot stayed down permanently. It is now
"configured but absent". Related, dial() returned !tried, so a peer
with no usable hint counted as a successful redial, cleared its own
backoff, and would be re-dialled forever.

Correction to 4e630e6: I claimed a sweep that reaped partitions would
churn the peer set. It does reap them, and that is right. A partition
is not distinguishable from a death by observation — both are silence —
so sweeping on silence is honest, and backoff plus redial is what
prevents churn. What a partition is not is Dead, which stays reserved
for a transport that reported closure: a fact rather than an inference,
and the only case that earns an immediate redial.
link_recovers_after_lossy_heal subscribed to b's inbox after healing,
but a subscription only sees tones delivered after it exists and the
healed buffer reaches the inbox asynchronously. When a partition drops
fewer than three, the window is short enough for a late arrival to
displace an "ok", so the assertion failed under load and passed alone.

Subscribe first. That makes the arrival order fixed, and lets the
assertion cover the lossy heal as well as the recovery: two buffered
tones survive, heal's own wane-sync sits between them and the new
marks, and the three "ok" marks follow in order.
`rid` is not a message identity, so the answer to the open "should we
dedup on rid" question is no, and the reason is in WIRE.md: `chi:"echo"`
is the ack *for* an rid, so a request and its response share one, and a
receiver deduping on rid would drop every response as a duplicate of
its request. rids are also explicitly per-originator and format-
agnostic, and reference clients mint per-session values (`p-<sid>`)
that recur across restarts.

The gossip id was therefore neither. mint_msg_id hashed
(topic, rid, from, payload) — content addressing, which cannot tell
"delivered twice" from "sent twice", and repeats are ordinary for the
tones gossip exists to carry: a heartbeat, a standing overload alert, a
retry. Worse, publish marks its own id seen before sending, so a repeat
that hashed alike was dropped at the origin and never left. Since the
rid was `gossip-{topic}-{now_ms()}`, the reachable case was two
identical publishes inside one millisecond.

The id is now assigned by the originator, once per publish, as
`{origin6}-{ms:x}-{seq:x}`. A per-process counter makes every publish
distinct whatever the clock or the content does, and the origin prefix
keeps two humds from colliding. The wire field name is unchanged, and
the existing canary test — which builds one tone and sends it twice —
still dedups, because the dedup happens on the id in the tone, not on
how it was minted.

Also enforce `dusk`, the other half. A tone arriving past its own dusk
is dropped rather than delivered or re-fanned, and counted in
`expired_dusk()` so a scenario can assert on it rather than infer from
absence. Checked after the liveness stamp, because an expired tone
still proves the link is alive. No dusk means no expiry — the field is
optional. This also bounds what the seen-set must remember: an id only
has to outlive the window in which a duplicate could still be in
flight.

Unicast `route()` is deliberately left without dedup. There is no sound
key for it — the only candidate, rid, repeats by design — and inventing
one would drop correlated responses.

sim/tests/delivery.rs pins the line the old id could not draw: an
identical payload published twice arrives twice, and one publish
duplicated by the link arrives once. The same bytes; only the sender
knows which it meant.
peers and identity both set XDG_CONFIG_HOME / XDG_STATE_HOME and removed
them on the way out. Both variables are process-global and the humd test
binary runs its tests in parallel threads, so a test setting one pointed
every other test in the process at its own fixture, and the remove_var
left the rest reading whatever came next. peers.rs failed this way on a
full workspace run and passed alone.

Split load_from(path) and load_or_mint_key_at(path) out of the
env-derived defaults and have the tests name their own files. The
production entry points are unchanged. hum-identity already serializes
its own env-touching tests behind a lock and shares a binary with no
other path-reading test, so it stays as it is.
Closes the last open item from the delivery work. `route()` had no
dedup because no sound key existed for it: rid, the only candidate,
repeats by design. The key already existed — the originator-assigned
id used for gossip — it just had no unicast spelling. `mid` is that
spelling, an optional envelope field, and the resolution follows from
the same distinction the gossip fix turned on: mid answers *which
message is this*, rid answers *which conversation is it in*.

A response therefore carries the request's rid and its own mid. Same
rid, different mid, both delivered. A retry carries same rid, same mid,
delivered once. The prior state is that sending the same body twice
means two messages, and that stays true: a tone with no mid makes no
at-most-once claim and is delivered every time it is sent. Absence is
meaningful, and a receiver must not substitute rid for a missing mid.

That substitution is the failure this is built to prevent, so it is now
a test. a_response_echoing_the_request_rid_still_arrives sends a
request and a response under one rid and requires both; mutated to
dedup on rid it fails, and so does the no-mid test, because rid-dedup
swallows exactly the sends the sender meant. Mutated the other way —
mid check disabled — a_retransmitted_mid_is_delivered_once fails. Both
directions of the mistake are caught rather than assumed away.

Dusk moves into the new ensemble::delivery module alongside the mid
seen-set, since identity and lifetime are the same question asked
twice: may this tone be delivered, and has it already been. Dusk is
checked first so a dead tone cannot occupy seen-set capacity and evict
a live id. The two seen-sets stay separate on purpose — a mid records
that this node already delivered a tone, a gossip msg_id also governs
whether it is re-fanned, and sharing one set would let each evict the
other's entries early.

mid is a first-class Envelope field rather than left in the tone body,
so the existing "envelope fields win" rule applies and a body mid
cannot shadow it. The generated clients pick the field up from codegen.

8 delivery scenarios green; workspace 51/51 across three runs; clippy
clean.
Follows up the delivery work by reading the code instead of assuming it.
Six of the open questions had answers, and two of them contradicted what
I had written down.

The seen-set was bounded in entries but not in bytes. `mid` is
attacker-controlled and the TCP transport reads NDJSON with
`BufReader::lines()`, which has no length cap, so 4096 entries of
unbounded string is not a bound — a peer could put a megabyte in one
field. `mid_key` now digests to 32 bytes, making the footprint
`cap * 32` whatever arrives, with dedup still exact. `mid_prefix` keeps
logs to a char-safe 12 chars so a 4 MB mid cannot land in a log line.

On signing: there is none, per tone. `handshake_message` covers only
`chi:"hello"`, and after that a connection is trusted wholesale, so
`mid` is exactly as (un)protected as `chi`, `rid`, `to` and the payload.
Nothing about mid is uniquely weak, but a mid being unbounded is real
because a connected peer controls every field.

`DeliveryState::with_cap` exists so capacity is reachable, and
the_seen_set_evicts_at_its_cap now proves eviction at a cap of 2. While
adding it I found `seen_set_dedups_within_capacity` in gossip never came
close to GOSSIP_SEEN_CAP — it asserted three lookups and the name
promised a bound. Renamed to `seen_set_dedups_repeats`, and gave the
real thing its own `seen_set_evicts_at_its_cap`.

`mint_msg_id` sliced `to_hex()[..12]` as bytes. Hid renders hex today
so it cannot panic, but the slice is now char-wise with a fallback,
since a panic in a publisher is not a good failure mode for a cosmetic
prefix.

The originator question resolves against humd, and the answer is that
humd is a relay. Worker replies are forwarded with `to`/`from` rewritten
and everything else — including any `mid` — carried through untouched,
which is the only correct behaviour: a relay that minted its own mid
would break dedup, because two relays of one message would disagree.
The originator is the nest side, and the one in this repo is
`hives/bp7`, which mints `p-{sid}` — the per-session rid that recurs
across restarts, the exact shape that deduping on rid would break. It
now also mints a `mid`, which is load-bearing there specifically because
bp7 is a DTN store-and-forward bridge and re-running a prompt is not
idempotent. bp7 is excluded from the workspace, so it builds
standalone.

Gossip could not set its own `dusk`, and still does not. A library TTL
would silently drop announcements on a congested mesh, and how stale an
alert may be is the publisher's call — a heartbeat wants seconds, a
"worker moved" notice wants minutes. So `publish_with_dusk` makes the
mechanism reachable and the default stays `None`. The new test also
pins where expiry lands: at the FIRST hop, not the last, so a dead tone
dies on entry to the mesh instead of being carried to every subscriber
and dropped N times. My first draft asserted on the far hop and was
wrong.

WIRE.md's chi tables were contradicting `thrum-core/src/views.rs`, which
calls itself the single source of truth for body shapes and is locked by
a golden-bytes test. Ten rows named fields that do not exist:
`permission-ask` and `release-permit` were described with a `permitId`
neither body has (both key on `callId`), `chunk` had a `part` and
`index`, `session-ready` had a `claudeSessionId` against a real
`nestId`, `pulse` had a `cellId` against a real `pid`, `tendril-reach`
had four fields against a real `task`/`tools`. All corrected against
views.rs, and the tables now say that `body fields` excludes `sid`,
which is an envelope field and was listed as a body field throughout.

The claude-cli `HOME` tests turned out to be fine. Every test in both
files holds a per-file lock for its whole body, and the two files are
separate binaries, so they cannot race each other — the same discipline
as hum-identity. Left alone on the evidence.

Workspace 51/51 across three runs; clippy clean.
`cargo clippy -- -D warnings` aborts in codegen/hum-paths before it ever
lints ensemble, so it cannot see its own mistakes here. Linting the
packages directly with --no-deps found two, both mine.

`a_dead_mid_does_not_evict_a_live_one` had no `#[test]`. An earlier edit
left its attribute attached to the function that followed it, so the
test compiled as dead code and the suite stayed green without ever
running it. The orphaned `#[test]` and a duplicated doc comment are gone
and the test runs — and passes, which is the less interesting part. The
interesting part is that "51/51 green" was reporting on a test that was
not in the suite.

Also collapsed a nested if in `Supervisor::dial`, plus a needless borrow
in the delivery tests.

Everything else under `-D warnings` in these crates is pre-existing:
the collapsible ifs in the gossip/KAD dispatch blocks, the penny/drone/
drift dead code, `humd/src/lib.rs` broadly. The remaining hard failures
are all in codegen/src/protocol.rs and hum-paths, untouched here.

Workspace 51/51 across three runs. Note the count was 51 before this
commit too — a non-running test does not change the number of green test
binaries, which is exactly why it went unnoticed.
@adiled

adiled commented Sep 27, 2026

Copy link
Copy Markdown
Owner Author

Pushed one more commit (303b5de).

cargo clippy -- -D warnings aborts in codegen/hum-paths before it ever lints ensemble, so it can't see mistakes in the crates this PR touches. Linting the packages directly with --no-deps found two, both mine:

  • a_dead_mid_does_not_evict_a_live_one had no #[test]. An earlier edit left its attribute on the following function, so it compiled as dead code. The suite reported 51/51 green without ever running it — and a non-running test does not change the count of green test binaries, which is precisely why it went unnoticed. It runs and passes now.
  • A nested if in Supervisor::dial and a needless borrow in the delivery tests.

Everything else under -D warnings in these crates is pre-existing (the collapsible ifs in the gossip/KAD dispatch blocks, the penny/drone/drift dead code). Re-verified: 51/51 across three runs, clippy 0 errors.

…mesh

A peer that stops reading doesn't close its socket. The buffer fills,
the write stops completing, and the connection keeps looking healthy to
a liveness lease — which renews on unrelated traffic, so silence never
trips it. Meanwhile the sender is parked inside write_all, and every
fan-out that walks peers sequentially is now parked with it. The only
symptom was a Lagged(n) on a broadcast receiver: a count, and no cause.

Every send is now bounded by SEND_TIMEOUT (5s). On timeout the
connection is closed rather than retried, so the lease can mark the
peer dead and the supervisor redial it, and the event lands in
send_timeouts() where it can be alerted on. Applied to all seven send
sites: hello, both probes, publish, re-fan, KAD replies, and route.

The probes matter most, and not just for the wedge: a probe that blocks
forever is a probe that never reports the stall it exists to find.

sim: InMemoryEndpoint::stall() models a peer whose buffer has filled —
the write hangs rather than erroring, because an error would let a fix
that only checks the return value pass without touching the deadline.

The five tests in sim/tests/stalled_peer.rs were each checked against a
reverted fix. The two fan-out tests fail without the deadline (the
publish itself hangs, so the test bounds it and fails with a message
instead of eating the CI timeout). Note the honest limits: route()
targets one peer, so two separate routes don't serialise — the fan-out
tests target publish(), which is the real single loop. A test asserting
"a healthy peer still gets through" on the route path would have passed
either way.
Each of these was green while asserting nothing, or claiming a coverage
it did not have. All three are now checked against a deliberate break.

iroh_integration: try_bind() turned every failure into `None` and the
test returned, so a broken transport and a working one looked identical.
The skip was hiding an actual bug — see below. Skips are now classified
against a short list of sandbox signatures, anything unrecognised is a
failure, HUM_REQUIRE_TRANSPORT_TESTS=1 turns even a forgiven skip into
a panic so the job with real UDP can't quietly lose the coverage, and
the classifier has its own tests so it can't rot into "ignore
everything".

Underneath was a genuine defect. The test built dial hints from
bound_sockets(), which reports the addresses we *bound* — 0.0.0.0 and
[::]. Neither is a destination, so the dial hung for the full 30s
timeout and the swallowed error called it a skip. The test had never
once completed a handshake. IrohTransport::dial_hints maps the
unspecified address to loopback and leaves a concrete address alone
(rewriting a real LAN address to 127.0.0.1 would break T1). Runtime
went from a 31s hang to 0.9s, and the test asserts its hints are
dialable so the shape can't come back.

partition_and_heal: the header claimed it caught "the wire silently
keeps delivering during partition". It did not, and could not — nothing
is sent until the heal, so the tips stay divergent whether the link is
sealed or wide open. The test now pushes a real wane-sync into the
outage and asserts it is refused. Verified: un-partitioning the link
now fails two tests, where it failed none before. Also added a negative
control (an unhealed outage must never reconcile) and a rewind check —
a min-merge satisfies "both sides are equal" and would pass a naive
convergence assertion while silently rewinding a Lamport clock.

sim::wane_sync is split out of heal() so a test can emit one during an
outage.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant