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
103/// Why an `Await` fails in a program that never `Listen`ed: there is no inbox to await a message in. Every
104/// tier reports this one sentence.
105pub const AWAIT_WITHOUT_LISTEN: &str = "Await requires a prior Listen to establish an inbox";
106
107/// The relay topic a `Send … to <dest>` / `Await … from <src>` operand names: a `PeerAgent`'s own topic, an
108/// address string's canonical topic; any other value is not an address, and the operation fails.
109pub fn peer_topic(value: &RuntimeValue) -> Result<String, String> {
110    match value {
111        RuntimeValue::Peer(topic) => Ok((**topic).clone()),
112        RuntimeValue::Text(s) => Ok(logicaffeine_system::addr::canonical_topic(s)),
113        other => Err(format!(
114            "Send/Await expects a PeerAgent or address string, got {}",
115            other.type_name()
116        )),
117    }
118}
119
120/// The relay URL `Connect to <raw>` dials — a `ws://` URL as written, or a libp2p multiaddr as its `ws://`
121/// form — or why `raw` is neither, which fails the `Connect`.
122pub fn relay_url(raw: &str) -> Result<String, String> {
123    logicaffeine_system::addr::multiaddr_to_ws_url(raw).map_err(|e| {
124        format!("Connect address '{raw}' is not a ws:// URL or supported multiaddr: {e}")
125    })
126}
127
128impl NetInbox {
129    pub fn new() -> Self {
130        NetInbox {
131            net: None,
132            inbox: None,
133            received: VecDeque::new(),
134            send_schema: HashMap::new(),
135            recv_schema: WireSchemaCache::content_addressed(),
136            send_msg_id: 0,
137            recv_shards: HashMap::new(),
138            my_profile: marshal::PeerProfile::default(),
139            peer_profiles: HashMap::new(),
140            handshaked: std::collections::HashSet::new(),
141            my_registry: WireTypeRegistry::new(Vec::new()),
142            sync_versions: HashMap::new(),
143            offline_loopback: VecDeque::new(),
144        }
145    }
146
147    /// Install this program's wire type registry (once, at startup) and derive our advertised registry
148    /// epoch from it. Two same-program peers compute the same epoch → they may elide type names.
149    pub fn set_registry(&mut self, registry: marshal::WireTypeRegistry) {
150        self.my_profile.registry_epoch = registry.epoch();
151        self.my_registry = registry;
152    }
153
154    /// The handshake to publish (to `peer`'s HANDSHAKE topic) the FIRST time we contact `peer`, and
155    /// never again — so each peer learns our acceptance surface exactly once, before any data. `None`
156    /// thereafter. The CALLER publishes the returned bytes to `handshake_topic_for(<peer data topic>)`.
157    pub fn first_contact_handshake(&mut self, peer: &str) -> Option<Vec<u8>> {
158        if self.handshaked.insert(peer.to_string()) {
159            Some(self.my_handshake())
160        } else {
161            None
162        }
163    }
164
165    /// This node's handshake frame, advertising `my_profile` under our inbox identity (empty until
166    /// `Listen` sets it). Published to a peer right after `Connect`/`Listen` so it learns our surface.
167    pub fn my_handshake(&self) -> Vec<u8> {
168        let from = self.inbox.as_ref().map(|s| s.as_str()).unwrap_or("");
169        marshal::make_handshake_frame(from, &self.my_profile)
170    }
171
172    /// The profile `peer` advertised — assumed CONSERVATIVE (self-describing, no compression/type-id)
173    /// until we have absorbed its handshake, so we never send a form an unannounced peer can't decode.
174    pub fn peer_profile(&self, peer: &str) -> marshal::PeerProfile {
175        self.peer_profiles
176            .get(peer)
177            .copied()
178            .unwrap_or_else(marshal::PeerProfile::conservative)
179    }
180
181    /// How to encode a message TO `peer`: my surface ∩ the peer's advertised surface. The `Send` path
182    /// consults this so it automatically stays within exactly what the receiver exposed.
183    pub fn negotiation_for(&self, peer: &str) -> marshal::Negotiated {
184        marshal::negotiate(&self.my_profile, &self.peer_profile(peer))
185    }
186
187    /// Encode `value` for `dest` using the negotiated MAXIMAL CRUSH — the auto-tuner's full dial search
188    /// within the peer's advertised compression surface, type-id name-elision when epochs matched, the
189    /// computed-send gate. BOTH the tree-walker and the VM net path call this for a plain send, so the
190    /// two tiers produce byte-identical wire by construction (the cross-tier lock holds). Self-
191    /// describing, so the receiver decodes it with no hint.
192    pub fn encode_negotiated(
193        &self,
194        from: &str,
195        value: &RuntimeValue,
196        dest: &str,
197        registry: marshal::WireTypeRegistry,
198    ) -> Result<Vec<u8>, String> {
199        marshal::message_to_wire_negotiated(from, value, &self.negotiation_for(dest), registry)
200    }
201
202    /// The next `Send redundant` message id (post-increment), so its FEC shards regroup on receipt.
203    pub fn next_msg_id(&mut self) -> u64 {
204        let id = self.send_msg_id;
205        self.send_msg_id = self.send_msg_id.wrapping_add(1);
206        id
207    }
208
209    /// Publish raw wire bytes to a peer topic. LOCAL/OFFLINE mode (no relay `Connect`ed — the
210    /// playground/test path): a single node has no peer to reach, so a `Send` is a fire-and-forget
211    /// no-op, deterministically. A relay-connected node publishes to the transport. Either way `Send`
212    /// never ERRORS, so a networked program runs identically on tree-walker, VM, and AOT.
213    pub fn publish(&mut self, topic: &str, bytes: Vec<u8>) -> Result<(), String> {
214        match self.net.as_ref() {
215            Some(net) => net.publish(topic, bytes),
216            // OFFLINE: loop the framed message back into our own outbox (single-node determinism), so a
217            // following `Await` on this topic reads it — rather than dropping it.
218            None => {
219                self.offline_loopback.push_back((topic.to_string(), bytes));
220                Ok(())
221            }
222        }
223    }
224
225    /// Drain the relay into `received`, keeping only messages addressed to our own inbox. Non-blocking.
226    /// `registry` (the program's struct/enum type table) is passed in so a `Send shared` type-id
227    /// message resolves; transparent to ordinary self-describing messages.
228    pub fn drain(&mut self, registry: WireTypeRegistry) {
229        let Some(inbox) = self.inbox.clone() else {
230            return;
231        };
232        // Own the drained batch so the `net` borrow ends before we push into `received`. With a relay,
233        // this is the relay's delivery; OFFLINE it is our own loopback outbox (the messages we've sent
234        // to ourselves) — fed through the SAME filter + decode path a relay message would take.
235        let drained: Vec<(String, Vec<u8>)> = match self.net.as_mut() {
236            Some(net) => net.drain(),
237            None => self.offline_loopback.drain(..).collect(),
238        };
239        let my_handshake_topic = handshake_topic_for(&inbox);
240        marshal::with_type_registry(registry, || {
241            for (topic, data) in drained {
242                // A peer's handshake arrives on our dedicated handshake topic: ABSORB its advertised
243                // profile (so a later send to that peer negotiates against its real surface) and never
244                // deliver it as a data message.
245                if topic.as_str() == my_handshake_topic.as_str() {
246                    if let Some((from, profile)) = marshal::parse_handshake_frame(&data) {
247                        self.peer_profiles.insert(from, profile);
248                    }
249                    continue;
250                }
251                if topic.as_str() != inbox.as_str() {
252                    continue;
253                }
254                // A `Send redundant` FEC shard: buffer by message id and reconstruct the exact
255                // message once K shards of that id have arrived, then decode normally.
256                if let Some((msg_id, k, _n)) = crate::concurrency::fec::shard_header(&data) {
257                    let buf = self.recv_shards.entry(msg_id).or_default();
258                    buf.push(data);
259                    let recovered = if buf.len() >= k {
260                        crate::concurrency::fec::reconstruct_redundant(buf)
261                    } else {
262                        None
263                    };
264                    if let Some((_, payload)) = recovered {
265                        self.recv_shards.remove(&msg_id);
266                        self.enqueue_received(payload);
267                    }
268                } else {
269                    self.enqueue_received(data);
270                }
271            }
272        });
273    }
274
275    /// Buffer one inbound frame. A self-describing record list is held UNDECODED so `Await view` can
276    /// wrap it zero-copy; every other shape decodes eagerly through the receive-side schema cache.
277    fn enqueue_received(&mut self, data: Vec<u8>) {
278        // Open the crypto envelope under the active suite (pass-through when none is engaged);
279        // a tampered/foreign frame opens to `None` and is dropped here, never decoded.
280        let Some(data) = crate::concurrency::channel::open_active(data) else {
281            return;
282        };
283        // A capability handshake is ABSORBED (the peer's advertised profile is stored), never delivered
284        // as a data message — so a subsequent `Send` to that peer negotiates against its real surface.
285        if let Some((from, profile)) = marshal::parse_handshake_frame(&data) {
286            self.peer_profiles.insert(from, profile);
287            return;
288        }
289        if let Some(sender) = marshal::peek_stream_sender(&data) {
290            self.received
291                .push_back((sender, RecvSlot::Stream(Rc::new(data))));
292        } else if let Some(sender) = marshal::peek_deferrable_sender(&data) {
293            self.received
294                .push_back((sender, RecvSlot::RawRecordList(Rc::new(data))));
295        } else if let Some((from, value)) =
296            marshal::message_from_wire_cached(&data, &mut self.recv_schema)
297        {
298            self.received.push_back((from, RecvSlot::Decoded(value)));
299        }
300    }
301
302    /// Non-blocking: take the first buffered (non-stream) message from `want`, if any. A plain `Await`
303    /// ignores STREAM slots (those belong to `Await stream`).
304    pub fn try_take_message(&mut self, want: &str, view: bool) -> Option<RuntimeValue> {
305        let pos = self
306            .received
307            .iter()
308            .position(|(from, slot)| from == want && !matches!(slot, RecvSlot::Stream(_)))?;
309        Some(self.take_received(pos, view))
310    }
311
312    /// Non-blocking: take the first buffered STREAM batch from `want`, deframed into a list, if any.
313    pub fn try_take_stream(&mut self, want: &str) -> Option<RuntimeValue> {
314        let pos = self
315            .received
316            .iter()
317            .position(|(from, slot)| from == want && matches!(slot, RecvSlot::Stream(_)))?;
318        Some(self.take_stream(pos))
319    }
320
321    /// Pop the buffered message at `pos` and realize its payload. A deferred record list is wrapped
322    /// LAZILY under `view` (zero-copy) or fully decoded; an already-decoded slot is returned as-is.
323    fn take_received(&mut self, pos: usize, view: bool) -> RuntimeValue {
324        let (_, slot) = self.received.remove(pos).unwrap();
325        match slot {
326            RecvSlot::Decoded(value) => value,
327            RecvSlot::RawRecordList(bytes) => {
328                if view {
329                    ListRepr::from_received_view(bytes)
330                        .map(|l| RuntimeValue::List(Rc::new(RefCell::new(l))))
331                        .unwrap_or(RuntimeValue::Nothing)
332                } else {
333                    marshal::message_from_wire(&bytes)
334                        .map(|(_, v)| v)
335                        .unwrap_or(RuntimeValue::Nothing)
336                }
337            }
338            // Unreachable on the plain `Await` path (it skips stream slots); deframe defensively.
339            RecvSlot::Stream(bytes) => RuntimeValue::List(Rc::new(RefCell::new(
340                ListRepr::from_values(marshal::deframe_stream_message(&bytes).unwrap_or_default()),
341            ))),
342        }
343    }
344
345    /// Pop the stream slot at `pos` and deframe it into a `List` of its values (malformed → empty).
346    fn take_stream(&mut self, pos: usize) -> RuntimeValue {
347        let (_, slot) = self.received.remove(pos).unwrap();
348        let values = match slot {
349            RecvSlot::Stream(bytes) => marshal::deframe_stream_message(&bytes).unwrap_or_default(),
350            _ => Vec::new(),
351        };
352        RuntimeValue::List(Rc::new(RefCell::new(ListRepr::from_values(values))))
353    }
354}
355
356#[cfg(test)]
357#[path = "../../../logicaffeine_tests/unit/logicaffeine_compile/concurrency/net_inbox/tests.rs"]
358mod tests;