feat(cluster): RFC 010 c8 — exposure registry + fixed-seed type hashing
Nothing local is remotely reachable by default (RFC §4). expose(Name<M>)
marks a name remotely addressable and registers M's decoder under
type_hash::<M>(); expose_type::<M>() registers only the decoder (the
reply-to path). exposed_names() is the auditable remote surface.
D3's watchable fold, resolved against the code as it stands (stated in the
module docs): register() ALREADY stamps every named holder watchable ('no
successfully-registered actor can die unflagged', registry.rs), so an
exposed name's holder needs no extra mark — and re-registration after a
holder's death re-stamps the new holder for free, which a per-tenancy mark
taken at expose time could not do. The cluster's own mark_watchable
set-site is therefore the pid crossing the wire (frame serialization, c10)
— the exact analog of the membrane crossing. c8 adds only the name/type
state neither the registry nor slot bits can carry. No new pid registry;
RFC §4 honored.
One-viable calls, flagged:
- State lives on RuntimeInner (the pg pattern: leaf RawMutex field,
cfg-gated behind cluster, zero-cost-when-off per c1) — c9's inbound
decode consults it per frame; manager-held state would serialize every
remote delivery through one gen_server.
- type_hash = FNV-1a 64 (fixed seed: the offset basis) over TypeId: a
constant of the binary — stable across runs of the same build (the scope
the build-hash handshake reduces the mesh to), deliberately not across
builds. Collisions degrade to decode error / refused channel, never a
misroute (the NoChannel guarantee, RFC §3).
- Decoder = decode-and-deliver-to-pid Arc closure capturing M (the one
typed site): decode_payload then send_dyn. Wire-name → pid resolution
stays OUTSIDE — that is c9's single seam, which calls decode_deliver.
Arc so the call happens with the exposure lock RELEASED: send_dyn takes
the registry lock, a mutual Leaf (the runtime asserts on nesting — caught
live by the first test run).
- expose is a name-level fact, valid for an unregistered name (names
late-bind; c9 resolves per delivery).
tests/cluster_expose.rs 5/0 stable x5, purely local per roadmap:
exposed/unexposed lookup + audit listing; decoder registration and the
delivery contract (happy path into a registered String channel; unknown
hash; corrupt bytes; wrong channel refused — never misrouted); distinct
types distinct hashes; expose/bridge-crossing agreement via the shared
watchable observable (terminal_reason after holder death); hash stability
across runs in the same binary via a c4-harness re-exec. Payload types are
std types — the crate's serde is derive-less by design, user crates bring
their own derive.
All cluster suites regression-clean (envelope 15, handshake 11, transport
11, lifecycle 1, liveness 3, connect 9, two_node 3, membership 4, mesh 2);
clippy --lib green both configs; fmt clean; default build compiles.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
//! RFC 010 c8 — exposure registry + type hashing. Purely local, no network.
|
||||
//!
|
||||
//! Payload types are std types (`String`, `u64`) because the crate's serde is
|
||||
//! deliberately derive-less (`default-features = false`) — user crates bring
|
||||
//! their own derive; the contract here is `DeserializeOwned`.
|
||||
//!
|
||||
//! The hash-stability test re-execs the current binary (the c4 harness): the
|
||||
//! guarantee under test is "stable across runs in the SAME binary" — exactly
|
||||
//! what the build-hash handshake reduces the mesh to — not stability across
|
||||
//! builds, which the scope guard explicitly rejects.
|
||||
#![cfg(feature = "cluster")]
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{maybe_child, spawn_node};
|
||||
use smarm::cluster::envelope::encode_payload;
|
||||
use smarm::cluster::expose::{
|
||||
decode_deliver, decoder_registered, expose, expose_type, exposed_hash, exposed_names,
|
||||
type_hash, DeliverError,
|
||||
};
|
||||
use smarm::monitor::{monitor, terminal_reason, DownReason};
|
||||
use smarm::{channel, register, run, spawn, Name};
|
||||
|
||||
const ROLES: &[(&str, fn())] = &[("hasher", role_hasher)];
|
||||
|
||||
/// Print the hashes this process computes; the parent (a different run of
|
||||
/// the same binary) compares against its own.
|
||||
fn role_hasher() {
|
||||
println!("HASH-STRING {}", type_hash::<String>());
|
||||
println!("HASH-U64 {}", type_hash::<u64>());
|
||||
}
|
||||
|
||||
const GREETER: Name<String> = Name::new("expose-test.greeter");
|
||||
|
||||
/// Exposed and unexposed lookup, the returned hash, and the audit listing.
|
||||
#[test]
|
||||
fn exposed_and_unexposed_lookup() {
|
||||
maybe_child(ROLES);
|
||||
run(|| {
|
||||
let h = expose(GREETER);
|
||||
assert_eq!(h, type_hash::<String>());
|
||||
assert_eq!(exposed_hash("expose-test.greeter"), Some(h));
|
||||
assert_eq!(exposed_hash("never-exposed"), None);
|
||||
assert!(exposed_names().contains(&("expose-test.greeter", h)));
|
||||
});
|
||||
}
|
||||
|
||||
/// Distinct types land on distinct hashes (FNV over distinct TypeIds — a
|
||||
/// smoke assertion; a collision would degrade to a decode error, never a
|
||||
/// misroute, per RFC §3).
|
||||
#[test]
|
||||
fn distinct_types_distinct_hashes() {
|
||||
maybe_child(ROLES);
|
||||
run(|| {
|
||||
assert_ne!(type_hash::<String>(), type_hash::<u64>());
|
||||
assert_ne!(type_hash::<String>(), type_hash::<Vec<u8>>());
|
||||
});
|
||||
}
|
||||
|
||||
/// The decode-and-deliver contract: a registered hash decodes into the
|
||||
/// target's typed channel; an unknown hash, corrupt bytes, and a missing
|
||||
/// channel each fail without delivering — `WrongChannel`, never a misroute.
|
||||
#[test]
|
||||
fn decoder_registration_and_delivery() {
|
||||
maybe_child(ROLES);
|
||||
run(|| {
|
||||
let h_string = expose_type::<String>();
|
||||
let h_u64 = expose_type::<u64>();
|
||||
assert!(decoder_registered(h_string));
|
||||
assert!(!decoder_registered(h_string.wrapping_add(1)));
|
||||
|
||||
// A live actor with a String channel (registered from its own body,
|
||||
// announced via a ready signal — the tests/registry.rs idiom).
|
||||
let (ready_tx, ready_rx) = channel::<()>();
|
||||
let (stop_tx, stop_rx) = channel::<()>();
|
||||
let (msg_tx, msg_rx) = channel::<String>();
|
||||
let pid = spawn(move || {
|
||||
register(Name::<String>::new("expose-test.sink"), msg_tx).unwrap();
|
||||
ready_tx.send(()).unwrap();
|
||||
let _ = stop_rx.recv();
|
||||
})
|
||||
.pid();
|
||||
ready_rx.recv().unwrap();
|
||||
|
||||
// Happy path: decode + deliver through the published channel.
|
||||
let bytes = encode_payload("hello across the seam").unwrap();
|
||||
decode_deliver(h_string, pid, &bytes).unwrap();
|
||||
assert_eq!(msg_rx.recv().unwrap(), "hello across the seam");
|
||||
|
||||
// Unknown hash: nothing was registered under it.
|
||||
assert!(matches!(
|
||||
decode_deliver(h_string.wrapping_add(1), pid, &bytes),
|
||||
Err(DeliverError::UnknownType)
|
||||
));
|
||||
|
||||
// Corrupt bytes: the decoder fails before any send.
|
||||
assert!(matches!(
|
||||
decode_deliver(h_string, pid, &[0xff; 3]),
|
||||
Err(DeliverError::Decode(_))
|
||||
));
|
||||
|
||||
// Right decoder, wrong channel: the actor has no u64 channel, so the
|
||||
// decoded value is refused — the NoChannel guarantee.
|
||||
let u64_bytes = encode_payload(&7u64).unwrap();
|
||||
assert!(matches!(
|
||||
decode_deliver(h_u64, pid, &u64_bytes),
|
||||
Err(DeliverError::WrongChannel)
|
||||
));
|
||||
|
||||
stop_tx.send(()).unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
/// `expose` and the bridge crossing agree on the resulting set: both funnel
|
||||
/// the pid-boundary mark through the watchable machinery, so an exposed
|
||||
/// name's holder dies with a terminal record — the exact observable
|
||||
/// `mark_watchable` guarantees the membrane. (For named holders the mark is
|
||||
/// already stamped by `register` itself; this pins the shared contract.)
|
||||
#[test]
|
||||
fn expose_and_bridge_crossing_agree_on_the_set() {
|
||||
maybe_child(ROLES);
|
||||
run(|| {
|
||||
let (ready_tx, ready_rx) = channel::<()>();
|
||||
let (stop_tx, stop_rx) = channel::<()>();
|
||||
let (msg_tx, _msg_rx) = channel::<String>();
|
||||
let pid = spawn(move || {
|
||||
register(GREETER, msg_tx).unwrap();
|
||||
ready_tx.send(()).unwrap();
|
||||
let _ = stop_rx.recv();
|
||||
})
|
||||
.pid();
|
||||
ready_rx.recv().unwrap();
|
||||
|
||||
expose(GREETER);
|
||||
let m = monitor(pid);
|
||||
stop_tx.send(()).unwrap();
|
||||
assert_eq!(m.rx.recv().unwrap().reason, DownReason::Exit);
|
||||
assert_eq!(terminal_reason(pid), Some(DownReason::Exit));
|
||||
});
|
||||
}
|
||||
|
||||
/// Hash stability across runs in the same binary: a re-exec of this binary
|
||||
/// computes the same hashes this process does.
|
||||
#[test]
|
||||
fn hash_stable_across_runs_in_same_binary() {
|
||||
maybe_child(ROLES);
|
||||
let (mine_string, mine_u64) = {
|
||||
// Computing a TypeId hash needs no runtime, but keep the contract
|
||||
// uniform with real call sites.
|
||||
(type_hash::<String>(), type_hash::<u64>())
|
||||
};
|
||||
let mut child = spawn_node("hasher", &[]);
|
||||
let line = child.wait_line("HASH-STRING", |l| l.starts_with("HASH-STRING "));
|
||||
assert_eq!(
|
||||
line["HASH-STRING ".len()..].parse::<u64>().unwrap(),
|
||||
mine_string
|
||||
);
|
||||
let line = child.wait_line("HASH-U64", |l| l.starts_with("HASH-U64 "));
|
||||
assert_eq!(line["HASH-U64 ".len()..].parse::<u64>().unwrap(), mine_u64);
|
||||
child.wait_exit();
|
||||
}
|
||||
Reference in New Issue
Block a user