logicaffeine_compile/concurrency/net_inbox.rs
1//! The shared peer-messaging inbox — the relay handle, this node's inbox topic, the received-message
2//! buffer, the wire schema caches, and the receive primitives (drain / enqueue / take). Lifted out of
3//! the tree-walker `Interpreter` so the bytecode VM's task driver owns the SAME inbox and networking
4//! runs byte-identically on both tiers (no tier silently differs). This is a zero-cost relocation:
5//! the holders embed a `NetInbox` by value and delegate, so every access monomorphises to the same
6//! field access it was before — no dyn dispatch, no extra allocation, the relay I/O path untouched.
7//!
8//! The async *suspension* model differs per tier and stays with each holder: the tree-walker loops on
9//! `poll_tick().await`; the VM blocks its task and the scheduler resumes it. Both call the same
10//! non-blocking primitives here (`drain` + `try_take_message` / `try_take_stream`).
11
12use std::cell::RefCell;
13use std::collections::{HashMap, VecDeque};
14use std::rc::Rc;
15
16use crate::concurrency::marshal::{self, WireSchemaCache, WireTypeRegistry};
17use crate::interpreter::{ListRepr, RuntimeValue};
18
19/// A buffered inbound message. A self-describing record list is kept as raw frame bytes so its decode
20/// can be deferred to `Await` (where the `view` knob decides lazy-vs-eager); every other shape is
21/// decoded in arrival order at drain (preserving the schema cache's keyframe ordering).
22pub(crate) enum RecvSlot {
23 /// Already decoded (scalars, structs, maps, `Send cached`/`compressed` bodies — order-sensitive).
24 Decoded(RuntimeValue),
25 /// A deferrable, self-describing record-list frame — decoded (lazily or eagerly) at `Await`.
26 RawRecordList(Rc<Vec<u8>>),
27 /// A batch STREAM frame (a `Stream … to` send) — deframed into a list by `Await stream`.
28 Stream(Rc<Vec<u8>>),
29}
30
31/// The peer-messaging state shared by the tree-walker and the VM task. Fields are `pub(crate)` so a
32/// holder's networking-statement handlers can read/establish them directly (e.g. set `net` on
33/// `Connect`, `inbox` on `Listen`) while the receive primitives below own the buffer mechanics.
34pub(crate) struct NetInbox {
35 /// The live relay connection, established by `Connect`/`Listen`. `None` until the program connects.
36 pub net: Option<logicaffeine_system::net::Net>,
37 /// This node's inbox topic — its identity on the relay, set by `Listen`. `None` until then.
38 pub inbox: Option<Rc<String>>,
39 /// Messages delivered to `inbox` and not yet consumed by an `Await`, kept `(sender, slot)` so an
40 /// `Await … from <peer>` matches by sender while leaving others queued for their own `Await`.
41 pub received: VecDeque<(String, RecvSlot)>,
42 /// `Send cached` schema dictionaries — one per destination peer. Content-addressed, so safe.
43 pub send_schema: HashMap<String, WireSchemaCache>,
44 /// The receive-side schema dictionary. Decoding ALWAYS goes through it (so `Send cached` resolves).
45 pub recv_schema: WireSchemaCache,
46 /// Monotonic id stamped on each `Send redundant` message so its FEC shards can be regrouped.
47 pub send_msg_id: u64,
48 /// Receive-side buffer of incoming FEC shards, keyed by message id, until K arrive to reconstruct.
49 pub recv_shards: HashMap<u64, Vec<Vec<u8>>>,
50 /// THIS node's published acceptance surface — advertised in our handshake AND the budget the
51 /// decode path enforces. One declaration, both advertised and enforced (it cannot drift).
52 pub my_profile: marshal::PeerProfile,
53 /// Each peer's advertised profile, learned from its handshake. A peer not yet heard from is treated
54 /// as the conservative default, so we never over-assume another node's capabilities.
55 pub peer_profiles: HashMap<String, marshal::PeerProfile>,
56 /// Peers we have already advertised our own profile to — so the handshake is sent once per peer, on
57 /// first contact, not on every message.
58 pub handshaked: std::collections::HashSet<String>,
59 /// THIS program's wire type registry (struct/enum schemas → small ids), set once at startup from the
60 /// analysis type table — IDENTICALLY on both tiers. Used to elide type NAMES from the wire when a
61 /// peer advertised a MATCHING registry epoch. Empty (epoch 0) until set → never elide.
62 pub my_registry: marshal::WireTypeRegistry,
63 /// δ-CRDT `Sync`: the causal version we have already shipped to each topic, so the next sync
64 /// publishes only the DELTA since then (not the whole set/sequence). Empty until first sync.
65 pub sync_versions: HashMap<String, logicaffeine_data::crdt::VClock>,
66 /// OFFLINE loopback outbox — with no relay (the VM/wasm-AOT determinism oracles), `publish` queues
67 /// the framed bytes HERE instead of dropping them, and `drain` feeds them back through the normal
68 /// decode path (topic-filtered to our own inbox). This makes a single-node `Send … to <self>` then
69 /// `Await … from <self>` deterministic — the oracle output is transport-independent, so the local
70 /// round-trip through the real wire codec is byte-faithful to what a relay would deliver.
71 pub offline_loopback: VecDeque<(String, Vec<u8>)>,
72}
73
74/// The dedicated topic a peer's handshake travels on — a deterministic transform of its DATA topic, so
75/// both peers derive the same one. Handshakes ride HERE, never the data topic, so they never interleave
76/// with data: a raw / non-Logos peer listening only on the data topic is entirely unaffected.
77pub fn handshake_topic_for(data_topic: &str) -> String {
78 format!("{data_topic}#hs")
79}
80
81thread_local! {
82 /// OFFLINE (single-node) networking mode. When set, `Connect` is a LOCAL NO-OP: the deterministic
83 /// engines (the tree-walker / VM oracles, driven on `futures::block_on` with NO relay transport
84 /// reachable) have nothing to dial, so `net` stays `None` and the following `Listen`/`Send`/`Sync`
85 /// run in their existing offline mode. A real deployment (the browser relay client, or a
86 /// relay-connected driver) leaves this unset and dials for real. This is the networking analogue of
87 /// [`crate::semantics::temporal::set_fixed_clock`] — a deterministic-execution knob the oracles set
88 /// so a `Connect` program has a byte-identical outcome across tree-walker, VM, and the WASM AOT.
89 static NET_OFFLINE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
90}
91
92/// Enter (or leave) OFFLINE networking mode — see [`NET_OFFLINE`]. Set by the deterministic
93/// tree-walker / VM oracles; unset in the browser/relay paths.
94pub fn set_net_offline(offline: bool) {
95 NET_OFFLINE.with(|c| c.set(offline));
96}
97
98/// Whether `Connect` should skip the real dial and run as a single-node local no-op.
99pub fn net_is_offline() -> bool {
100 NET_OFFLINE.with(|c| c.get())
101}
102
103impl NetInbox {
104 pub fn new() -> Self {
105 NetInbox {
106 net: None,
107 inbox: None,
108 received: VecDeque::new(),
109 send_schema: HashMap::new(),
110 recv_schema: WireSchemaCache::content_addressed(),
111 send_msg_id: 0,
112 recv_shards: HashMap::new(),
113 my_profile: marshal::PeerProfile::default(),
114 peer_profiles: HashMap::new(),
115 handshaked: std::collections::HashSet::new(),
116 my_registry: WireTypeRegistry::new(Vec::new()),
117 sync_versions: HashMap::new(),
118 offline_loopback: VecDeque::new(),
119 }
120 }
121
122 /// Install this program's wire type registry (once, at startup) and derive our advertised registry
123 /// epoch from it. Two same-program peers compute the same epoch → they may elide type names.
124 pub fn set_registry(&mut self, registry: marshal::WireTypeRegistry) {
125 self.my_profile.registry_epoch = registry.epoch();
126 self.my_registry = registry;
127 }
128
129 /// The handshake to publish (to `peer`'s HANDSHAKE topic) the FIRST time we contact `peer`, and
130 /// never again — so each peer learns our acceptance surface exactly once, before any data. `None`
131 /// thereafter. The CALLER publishes the returned bytes to `handshake_topic_for(<peer data topic>)`.
132 pub fn first_contact_handshake(&mut self, peer: &str) -> Option<Vec<u8>> {
133 if self.handshaked.insert(peer.to_string()) {
134 Some(self.my_handshake())
135 } else {
136 None
137 }
138 }
139
140 /// This node's handshake frame, advertising `my_profile` under our inbox identity (empty until
141 /// `Listen` sets it). Published to a peer right after `Connect`/`Listen` so it learns our surface.
142 pub fn my_handshake(&self) -> Vec<u8> {
143 let from = self.inbox.as_ref().map(|s| s.as_str()).unwrap_or("");
144 marshal::make_handshake_frame(from, &self.my_profile)
145 }
146
147 /// The profile `peer` advertised — assumed CONSERVATIVE (self-describing, no compression/type-id)
148 /// until we have absorbed its handshake, so we never send a form an unannounced peer can't decode.
149 pub fn peer_profile(&self, peer: &str) -> marshal::PeerProfile {
150 self.peer_profiles.get(peer).copied().unwrap_or_else(marshal::PeerProfile::conservative)
151 }
152
153 /// How to encode a message TO `peer`: my surface ∩ the peer's advertised surface. The `Send` path
154 /// consults this so it automatically stays within exactly what the receiver exposed.
155 pub fn negotiation_for(&self, peer: &str) -> marshal::Negotiated {
156 marshal::negotiate(&self.my_profile, &self.peer_profile(peer))
157 }
158
159 /// Encode `value` for `dest` using the negotiated MAXIMAL CRUSH — the auto-tuner's full dial search
160 /// within the peer's advertised compression surface, type-id name-elision when epochs matched, the
161 /// computed-send gate. BOTH the tree-walker and the VM net path call this for a plain send, so the
162 /// two tiers produce byte-identical wire by construction (the cross-tier lock holds). Self-
163 /// describing, so the receiver decodes it with no hint.
164 pub fn encode_negotiated(
165 &self,
166 from: &str,
167 value: &RuntimeValue,
168 dest: &str,
169 registry: marshal::WireTypeRegistry,
170 ) -> Result<Vec<u8>, String> {
171 marshal::message_to_wire_negotiated(from, value, &self.negotiation_for(dest), registry)
172 }
173
174 /// The next `Send redundant` message id (post-increment), so its FEC shards regroup on receipt.
175 pub fn next_msg_id(&mut self) -> u64 {
176 let id = self.send_msg_id;
177 self.send_msg_id = self.send_msg_id.wrapping_add(1);
178 id
179 }
180
181 /// Publish raw wire bytes to a peer topic. LOCAL/OFFLINE mode (no relay `Connect`ed — the
182 /// playground/test path): a single node has no peer to reach, so a `Send` is a fire-and-forget
183 /// no-op, deterministically. A relay-connected node publishes to the transport. Either way `Send`
184 /// never ERRORS, so a networked program runs identically on tree-walker, VM, and AOT.
185 pub fn publish(&mut self, topic: &str, bytes: Vec<u8>) -> Result<(), String> {
186 match self.net.as_ref() {
187 Some(net) => net.publish(topic, bytes),
188 // OFFLINE: loop the framed message back into our own outbox (single-node determinism), so a
189 // following `Await` on this topic reads it — rather than dropping it.
190 None => {
191 self.offline_loopback.push_back((topic.to_string(), bytes));
192 Ok(())
193 }
194 }
195 }
196
197 /// Drain the relay into `received`, keeping only messages addressed to our own inbox. Non-blocking.
198 /// `registry` (the program's struct/enum type table) is passed in so a `Send shared` type-id
199 /// message resolves; transparent to ordinary self-describing messages.
200 pub fn drain(&mut self, registry: WireTypeRegistry) {
201 let Some(inbox) = self.inbox.clone() else { return };
202 // Own the drained batch so the `net` borrow ends before we push into `received`. With a relay,
203 // this is the relay's delivery; OFFLINE it is our own loopback outbox (the messages we've sent
204 // to ourselves) — fed through the SAME filter + decode path a relay message would take.
205 let drained: Vec<(String, Vec<u8>)> = match self.net.as_mut() {
206 Some(net) => net.drain(),
207 None => self.offline_loopback.drain(..).collect(),
208 };
209 let my_handshake_topic = handshake_topic_for(&inbox);
210 marshal::with_type_registry(registry, || {
211 for (topic, data) in drained {
212 // A peer's handshake arrives on our dedicated handshake topic: ABSORB its advertised
213 // profile (so a later send to that peer negotiates against its real surface) and never
214 // deliver it as a data message.
215 if topic.as_str() == my_handshake_topic.as_str() {
216 if let Some((from, profile)) = marshal::parse_handshake_frame(&data) {
217 self.peer_profiles.insert(from, profile);
218 }
219 continue;
220 }
221 if topic.as_str() != inbox.as_str() {
222 continue;
223 }
224 // A `Send redundant` FEC shard: buffer by message id and reconstruct the exact
225 // message once K shards of that id have arrived, then decode normally.
226 if let Some((msg_id, k, _n)) = crate::concurrency::fec::shard_header(&data) {
227 let buf = self.recv_shards.entry(msg_id).or_default();
228 buf.push(data);
229 let recovered = if buf.len() >= k {
230 crate::concurrency::fec::reconstruct_redundant(buf)
231 } else {
232 None
233 };
234 if let Some((_, payload)) = recovered {
235 self.recv_shards.remove(&msg_id);
236 self.enqueue_received(payload);
237 }
238 } else {
239 self.enqueue_received(data);
240 }
241 }
242 });
243 }
244
245 /// Buffer one inbound frame. A self-describing record list is held UNDECODED so `Await view` can
246 /// wrap it zero-copy; every other shape decodes eagerly through the receive-side schema cache.
247 fn enqueue_received(&mut self, data: Vec<u8>) {
248 // Open the crypto envelope under the active suite (pass-through when none is engaged);
249 // a tampered/foreign frame opens to `None` and is dropped here, never decoded.
250 let Some(data) = crate::concurrency::channel::open_active(data) else { return };
251 // A capability handshake is ABSORBED (the peer's advertised profile is stored), never delivered
252 // as a data message — so a subsequent `Send` to that peer negotiates against its real surface.
253 if let Some((from, profile)) = marshal::parse_handshake_frame(&data) {
254 self.peer_profiles.insert(from, profile);
255 return;
256 }
257 if let Some(sender) = marshal::peek_stream_sender(&data) {
258 self.received.push_back((sender, RecvSlot::Stream(Rc::new(data))));
259 } else if let Some(sender) = marshal::peek_deferrable_sender(&data) {
260 self.received.push_back((sender, RecvSlot::RawRecordList(Rc::new(data))));
261 } else if let Some((from, value)) =
262 marshal::message_from_wire_cached(&data, &mut self.recv_schema)
263 {
264 self.received.push_back((from, RecvSlot::Decoded(value)));
265 }
266 }
267
268 /// Non-blocking: take the first buffered (non-stream) message from `want`, if any. A plain `Await`
269 /// ignores STREAM slots (those belong to `Await stream`).
270 pub fn try_take_message(&mut self, want: &str, view: bool) -> Option<RuntimeValue> {
271 let pos = self
272 .received
273 .iter()
274 .position(|(from, slot)| from == want && !matches!(slot, RecvSlot::Stream(_)))?;
275 Some(self.take_received(pos, view))
276 }
277
278 /// Non-blocking: take the first buffered STREAM batch from `want`, deframed into a list, if any.
279 pub fn try_take_stream(&mut self, want: &str) -> Option<RuntimeValue> {
280 let pos = self
281 .received
282 .iter()
283 .position(|(from, slot)| from == want && matches!(slot, RecvSlot::Stream(_)))?;
284 Some(self.take_stream(pos))
285 }
286
287 /// Pop the buffered message at `pos` and realize its payload. A deferred record list is wrapped
288 /// LAZILY under `view` (zero-copy) or fully decoded; an already-decoded slot is returned as-is.
289 fn take_received(&mut self, pos: usize, view: bool) -> RuntimeValue {
290 let (_, slot) = self.received.remove(pos).unwrap();
291 match slot {
292 RecvSlot::Decoded(value) => value,
293 RecvSlot::RawRecordList(bytes) => {
294 if view {
295 ListRepr::from_received_view(bytes)
296 .map(|l| RuntimeValue::List(Rc::new(RefCell::new(l))))
297 .unwrap_or(RuntimeValue::Nothing)
298 } else {
299 marshal::message_from_wire(&bytes)
300 .map(|(_, v)| v)
301 .unwrap_or(RuntimeValue::Nothing)
302 }
303 }
304 // Unreachable on the plain `Await` path (it skips stream slots); deframe defensively.
305 RecvSlot::Stream(bytes) => RuntimeValue::List(Rc::new(RefCell::new(ListRepr::from_values(
306 marshal::deframe_stream_message(&bytes).unwrap_or_default(),
307 )))),
308 }
309 }
310
311 /// Pop the stream slot at `pos` and deframe it into a `List` of its values (malformed → empty).
312 fn take_stream(&mut self, pos: usize) -> RuntimeValue {
313 let (_, slot) = self.received.remove(pos).unwrap();
314 let values = match slot {
315 RecvSlot::Stream(bytes) => marshal::deframe_stream_message(&bytes).unwrap_or_default(),
316 _ => Vec::new(),
317 };
318 RuntimeValue::List(Rc::new(RefCell::new(ListRepr::from_values(values))))
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use crate::concurrency::marshal::{
326 make_handshake_frame, PeerProfile, ReceiveLimits, WireCompression, FEAT_COMPUTED, FEAT_LZ4,
327 FEAT_TYPE_ID, FEAT_ZSTD,
328 };
329
330 #[test]
331 fn absorbs_a_peer_handshake_and_negotiates_to_its_exposed_surface() {
332 let mut inbox = NetInbox::new();
333 // I speak zstd+lz4+type-id+computed and carry type-registry epoch 5.
334 inbox.my_profile.registry_epoch = 5;
335 inbox.my_profile.features = FEAT_ZSTD | FEAT_LZ4 | FEAT_TYPE_ID | FEAT_COMPUTED;
336
337 // Before any handshake, a peer is treated CONSERVATIVELY (no optional capabilities).
338 assert_eq!(inbox.peer_profile("bob"), PeerProfile::conservative());
339 // …so a send to an unheard peer negotiates to a plain, uncompressed, self-describing form.
340 let cold = inbox.negotiation_for("bob");
341 assert!(!cold.use_type_id && !cold.may_send_computed);
342 assert_eq!(cold.compression, WireCompression::None);
343
344 // Bob advertises a RESTRICTIVE surface: lz4 only (no zstd), type-id, same epoch, declines code,
345 // small byte budget.
346 let bob = PeerProfile {
347 limits: ReceiveLimits { max_bytes: 2048, accept_computed: false, ..Default::default() },
348 registry_epoch: 5,
349 features: FEAT_LZ4 | FEAT_TYPE_ID,
350 };
351 inbox.enqueue_received(make_handshake_frame("bob", &bob));
352
353 // The handshake was absorbed — stored, NOT queued as a data message.
354 assert!(inbox.received.is_empty(), "a handshake is absorbed, never delivered as data");
355 assert_eq!(inbox.peer_profile("bob"), bob);
356
357 // Sending to bob now negotiates to exactly his exposed surface.
358 let n = inbox.negotiation_for("bob");
359 assert!(n.use_type_id, "matching epochs + both type-id → names elided");
360 assert!(!n.may_send_computed, "bob declined computed → never ship code to him");
361 assert_eq!(n.compression, WireCompression::Lz4, "lz4 is the strongest compression both speak");
362 assert_eq!(n.peer_max_bytes, 2048, "stay under bob's byte budget");
363 }
364
365 #[test]
366 fn null_suite_seal_engages_through_enqueue_and_off_is_byte_identical() {
367 use crate::concurrency::channel::{seal_active, with_suite, SUITE_NULL};
368 use crate::concurrency::marshal::{message_to_wire_with, WireCodec, WireIntegrity};
369
370 let blob =
371 message_to_wire_with("bob", &RuntimeValue::Int(7), WireCodec::Native, WireIntegrity::Checked)
372 .expect("encode");
373
374 // OFF: no active suite → the wire is the raw blob and the receive path is unchanged.
375 let mut off = NetInbox::new();
376 let wire_off = seal_active(blob.clone());
377 assert_eq!(wire_off, blob, "no suite → byte-identical wire");
378 off.enqueue_received(wire_off);
379 assert_eq!(off.received.len(), 1, "message delivered with the channel disengaged");
380
381 // ON (null suite): the wire is the framed envelope and the receiver opens it back to the
382 // same message — the seam engages through the real receive path.
383 with_suite(Some(SUITE_NULL), || {
384 let mut on = NetInbox::new();
385 let wire_on = seal_active(blob.clone());
386 assert_ne!(wire_on, blob, "active suite → framed envelope on the wire");
387 on.enqueue_received(wire_on);
388 assert_eq!(on.received.len(), 1, "sealed message opened + delivered under the null suite");
389
390 // A body byte flipped after sealing: open() succeeds (null framing carries no tag of its
391 // own) but the inner FNV checksum fails, so the message is dropped — the transitional
392 // tamper-detection the plan relies on until an AEAD tag lands.
393 let mut tampered = seal_active(blob.clone());
394 let last = tampered.len() - 1;
395 tampered[last] ^= 0xFF;
396 on.enqueue_received(tampered);
397 assert_eq!(on.received.len(), 1, "tampered frame dropped, not delivered");
398 });
399 }
400
401 #[test]
402 fn my_handshake_advertises_my_inbox_identity_and_profile() {
403 let mut inbox = NetInbox::new();
404 inbox.inbox = Some(Rc::new("alice".to_string()));
405 inbox.my_profile.registry_epoch = 9;
406 let frame = inbox.my_handshake();
407 let (from, prof) = marshal::parse_handshake_frame(&frame).expect("my handshake parses");
408 assert_eq!(from, "alice", "advertises our inbox identity");
409 assert_eq!(prof, inbox.my_profile, "advertises our exact profile");
410 }
411}