Three separate parts of the system moved over these two days, and they don’t have much to do with each other except that they all needed doing before v0.5 can claim to be a real distributed language. So I’m grouping them: generators, the cluster, and the wire format. (Two of them — the cluster and the transport it rides on — are headed for the same spot anyway; more on that at the end.)
Generators stop being a parser fiction
A generator that yields has to suspend in the middle of its body and resume later with all its locals intact. The interesting locals are the ones that are live across a yield — written before the suspension, read after it. Everything else can stay on the stack and die when the block returns. The ones that cross have to live somewhere that survives the suspension, which means a record.
The pass that lands here computes exactly that set. Standard backward liveness over the body CFG, with a local counted as cross-yield-live when it’s live at the program point immediately after any yield. The union of those per-yield sets is what gets lifted into a synthesised state record:
// Gen$state$<owner>:<id>
// field 0 state tag (which suspension point are we at?)
// field 1 init-mask (which lifted locals are valid here?)
// field 2..N cross-yield-live locals, in body-local id orderField 2 onward is deterministic — ascending local id, stable across rebuilds — so the layout doesn’t shuffle between compiles. At each yield site the pass inserts a store-before-yield for every lifted local, and at the matching resume block a symmetric reload at the top of the block. I’ve been calling these the bookend Moves, because they bracket the suspension on both ends. They go through a new Place::GenState { local, field } projection so the rest of MIR doesn’t need to know a state record exists.
The part I kept putting off is cleanup. A generator that’s run halfway and then dropped — the consumer stops pulling — still owns whatever those lifted locals point at. So the pass synthesises a per-generator __drop_in_state shim that reads the state tag, consults the init-mask to see which locals are actually live at that suspension point, and drops exactly those. The init-mask is why it has to be observable at runtime: at compile time I know the lift set, but only the mask knows how far this particular generator got before someone abandoned it.
Codegen for all of this is still gated. The LLVM backend has fail-closed rejection arms for Place::GenState and Terminator::Yield — if either reaches code emission, the compiler refuses rather than emitting something half-right. The MIR substrate is correct and pinned by tests, but the LLVM state-machine lowering that consumes it lands in a follow-up. I’d rather have the analysis provably right and the emission honestly absent than the reverse. So gen and yield still aren’t usable end to end — the front half is real, the codegen half isn’t there yet, and I won’t pretend otherwise.
(Update, June 2026: the rejection arms are gone — yield lowers through a thread-based generator runtime rather than the LLVM state-machine lowering promised here. gen { } blocks type-check and run in v0.5.0. The actor-stream forms — receive gen fn and for await — are specified but refuse with diagnostics and are slated for v0.6.)
The cluster gets a real failure detector
The SWIM membership substrate had a placeholder I wasn’t proud of: a fixed timeout flipped a peer from ALIVE to SUSPECT. Miss N pings, you’re suspect. That number is a lie on every network it isn’t tuned for — too twitchy on a jittery link, too sluggish on a clean one.
So the fixed threshold is gone, replaced with phi-accrual (Hayashibara et al., 2004 — the same detector Akka and Cassandra use). Each peer keeps a 200-sample sliding window of heartbeat inter-arrival times. On every tick the detector computes phi = -log10(P(silence longer than we’ve actually waited, given the learned distribution)) and trips SUSPECT once phi crosses 8.0. The wall-clock latency to suspicion isn’t a constant anymore — it adapts to the jitter the detector has actually observed. A snappy network reacts fast; a sleepy one tolerates proportional slack without false-positiving.
Two bits of discipline I had to get right. Cold start: until a peer’s window holds at least 10 samples, the old fixed ping-timeout is the fallback, so brand-new and quiet peers stay detectable instead of being invisible to the math. And recovery: when a SUSPECT peer comes back to ALIVE, I record an anchor but don’t fold the recovery gap into the distribution. Otherwise every past silence dulls the next real detection, and the whole point of the detector is to stay sharp.
Here’s the join, from a node’s point of view:
type NodeInfo {
id: i64;
addr: string;
}
fn join_cluster(node: NodeInfo) {
println(f"node {node.id} joining at {node.addr}");
}
fn main() {
join_cluster(NodeInfo { id: 1, addr: "127.0.0.1:7000" });
}The membership event ABI didn’t change — the HEW_MEMBERSHIP_EVENT_NODE_* surface is identical, because phi-accrual changes when an event fires, not what a consumer sees. And the tuning constants (threshold, window size, minimum samples) live at module level, deliberately not on the FFI-stable config struct, so re-tuning later is a one-line edit that doesn’t break the C ABI.
Right after that I pulled the SWIM message handlers — ping, ack, ping-req, gossip, tick — out into a ClusterProtocol trait, with the current behaviour shipping as the SimpleSwim default impl behind a Box<dyn ClusterProtocol>. The phi-accrual detector stays attached to the cluster itself rather than the trait, because it’s per-peer observation state, not protocol logic — they’re orthogonal. The reason for the seam is a v0.6 swap-in: a larger-quorum or Lifeguard-style impl should be a single trait-impl drop, not a rewrite. There’s also a partition-injection point in the same surface so I can test split-brain behaviour without actually severing sockets.
The wire format flips to CBOR
For a long time the runtime had its own hand-built binary wire format — call it HBF — sitting in a 2498-line wire.rs. It worked, but it was a bespoke encoder with its own framing rules and no schema anyone outside the file could point at. The doctrine I’ve settled on for v0.5 is CBOR for the inter-process envelope, with a CDDL schema as the doc-of-truth, so that’s the cutover these two days finished.
The envelope is a definite-length CBOR map with integer keys, and the schema describes it precisely:
envelope-frame = {
version: 1 => wire-version,
frame_type: 2 => 1, ; 1 = envelope
target_actor_id: 3 => actor-id,
source_actor_id: 4 => actor-id,
msg_type: 5 => int,
payload: 6 => bstr, ; serialised message body
request_id: 7 => uint, ; 0 = fire-and-forget
source_node_id: 8 => node-id, ; 0 on reply envelopes
}The field numbers deliberately mirror the old HBF ordering so I can cross-reference both while the dust settles. The version field guards the whole schema — a decoder that doesn’t recognise a version returns an UnknownVersion error rather than guessing its way deeper into bytes it doesn’t understand. And the payload length is not re-encoded inside the envelope; the transport (a QUIC stream or TCP) owns framing, and duplicating the length would just create a second source of truth to disagree with itself.
A user type tags its fields for the wire, and those @N tags are the schema the envelope round-trips against:
#[wire]
type StatusUpdate {
node_id: u32 @1,
count: i64 @2,
message: string @3,
}
fn main() {
let s = StatusUpdate { node_id: 7, count: 3, message: "ok" };
println(s.message);
}The swap itself was the careful part: six live call sites — envelope encode, the reader-loop decode, rejection replies, reply envelopes, the node ask path, and the transport send — all routed off HBF and onto the CBOR codec. Every one of them sends decode failures through a typed error. No site falls back to the old format on a bad parse; a malformed frame is an error, not a silent retry with different rules.
Once a grep across the runtime for the old hew_wire_encode_envelope / hew_wire_decode_envelope / HBF_MAGIC symbols came back empty outside wire.rs itself, the module was dead. So it went — all 2498 lines, plus every hew_wire_* extern export and the symbol-classification entries that referenced them. Deleting that file felt better than anything else these two days. (There’s a specific satisfaction in a commit that’s nothing but -2535.)
And then, two more things
f-string interpolation went from a hardcoded stringify to a real Display trait routed through lang-items. The trait and its fmt method are tagged #[lang_item("display")] / #[lang_item("display_fmt")] in the builtins, the checker harvests those into a registry indexed by name, and f-string lowering looks the dispatch target up there instead of baking in a method name. Built-in primitives get their Display impls, and a user type composes the same way:
type User {
name: string;
points: i64;
}
impl Display for User {
fn fmt(u: User) -> string { f"{u.name} ({u.points})" }
}
fn main() {
let u = User { name: "ada", points: 42 };
// Display is resolved through a lang-item, not a hardcoded method name.
println(f"user {u.name} has {u.points} points");
println(f"{u}");
}Interpolating a value whose type has no Display impl is a check-time error with the offending type named — not an opaque unresolved-symbol failure deeper down, and not an empty-string fallback. Same fail-closed boundary as everywhere else: if the dispatch can’t be resolved, the compiler says so rather than inventing a value.
The last piece — which turned out bigger than I’m billing it — is transport selection. HEW_TRANSPORT=quic-mesh now picks a per-actor-pair QUIC mesh transport, with a full vtable adapter — connect, listen, accept, send, recv, close, destroy — modelled on the existing single-stream QUIC transport. Unknown values for that env var fail-closed through a normalize step rather than falling through to a default. And the handshake got hardened in the same pass:
- peer SPKIs from a runtime-mutable allowlist feed the TLS verifier, and non-allowlisted peers are rejected at handshake time;
- oversize frames stop the stream with a typed diagnostic instead of silently draining;
- both directions of the control-stream open are wrapped in a 5-second timeout that surfaces a real error on expiry.
The cluster work and the transport work want to meet somewhere in the middle — phi-accrual deciding a peer is gone, the mesh tearing down its streams to that peer — and that wiring isn’t done yet. But both halves exist now, which is further than either was two days ago.