Skip to main content

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}