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;