Skip to main content

byteflow/
samples.rs

1//! Built-in demo chunks assembled with [`crate::ChunkBuilder`].
2//!
3//! Use these as runnable specs of the messaging contract (and as regression
4//! tests). Prefer copying a sample over inventing hop register layouts from
5//! scratch.
6//!
7//! | Sample | Shows |
8//! |--------|--------|
9//! | [`add_forty_two`] | Scalar VM path (no natives) |
10//! | [`ping_pong`] | Cap spawn + Atomic Hop round-trip |
11//! | [`atomic_request_reply`] | Tagged REQ/REP + `print` |
12//! | [`selective_receive`] | `ReceiveMatch` FIFO skip |
13//! | [`ask_reply`] | `Ask` RPC hop |
14//! | [`forged_sender_send`] / [`forged_sender_ask`] | S1: forged `make_msg` sender dies |
15//! | [`boom`] | Immediate trap (supervisor demos) |
16//!
17//! Hop samples require [`crate::std_native_table`].
18
19use crate::{emit_native1_from, emit_native_n, Chunk, ChunkBuilder, Opcode};
20
21/// Native indices (must match [`crate::std_native_map`]).
22///
23/// Hard-coded here so the sample stays self-contained without looking up
24/// the map at assembly time ÔÇö the stability test in `natives` guards drift.
25const N_PRINT: u32 = 0;
26const N_MAKE_MSG: u32 = 2;
27const N_MSG_SENDER: u32 = 3;
28const N_MSG_REQUEST_ID: u32 = 4;
29const N_MSG_TAG: u32 = 5;
30const N_MSG_PAYLOAD: u32 = 6;
31const N_MSG_REPLY_CAP: u32 = 7;
32
33/// Protocol tags for Atomic Hop samples.
34///
35/// Opaque to the VM; only this sample (and its clients) interpret them.
36pub const TAG_REQ: i32 = 1;
37pub const TAG_REP: i32 = 2;
38pub const TAG_PING: i32 = 10;
39pub const TAG_PONG: i32 = 11;
40/// Decoy hop for [`selective_receive`] ÔÇö must be skipped by `ReceiveMatch`.
41pub const TAG_JUNK: i32 = 99;
42
43/// `r0 = 41 + 1; return r0` ÔÇö the 60-second sanity chunk.
44pub fn add_forty_two() -> Chunk {
45    let mut b = ChunkBuilder::new("add-forty-two");
46    b.begin_function("main", 0, 2);
47    b.emit_load_imm(0, 41);
48    b.emit_load_imm(1, 1);
49    b.emit_binop(Opcode::Add, 0, 0, 1);
50    b.emit_return(0);
51    b.finish()
52}
53
54/// Two flows, one **Atomic Hop** round-trip: `main` sends a `Message` to
55/// `pong`, `pong` replies with payload+1 via `msg_reply_cap`, `main` returns
56/// that payload (`2`).
57///
58/// Requires [`crate::std_native_table`] (`make_msg` / `msg_*`).
59///
60/// Every `Send` carries exactly one [`crate::Value::Message`] and targets a
61/// [`crate::Value::Cap`] ÔÇö bare ints / pids trap (Atomic Hop + FlowCap).
62pub fn ping_pong() -> Chunk {
63    let mut b = ChunkBuilder::new("ping-pong");
64
65    // pong: receive Message, reply payload+1 via reply_cap
66    // r0 = request Message
67    // r1 = reply Cap
68    // r2 = request_id
69    // r3 = payload (+1), then make_msg result
70    // r4..r7 = make_msg arg window
71    let pong = b.begin_function("pong", 0, 8);
72    b.emit_receive(0);
73    emit_native1_from!(b, 1, 0, N_MSG_REPLY_CAP);
74    emit_native1_from!(b, 2, 0, N_MSG_REQUEST_ID);
75    emit_native1_from!(b, 3, 0, N_MSG_PAYLOAD);
76    b.emit_load_imm(7, 1);
77    b.emit_binop(Opcode::Add, 3, 3, 7);
78    b.emit_self_pid(4);
79    b.emit_move(5, 2);
80    b.emit_load_imm(6, TAG_PONG);
81    b.emit_move(7, 3);
82    emit_native_n!(b, 4, N_MAKE_MSG, 4);
83    b.emit_send(1, 4);
84    b.emit_exit(4);
85
86    // main: spawn pong (Cap), hop Message{tag=PING, payload=1}, return reply payload
87    b.begin_function("main", 0, 8);
88    b.emit_self_pid(1);
89    b.emit_spawn(0, pong, 0);
90    b.emit_move(2, 1);
91    b.emit_load_imm(3, 1);
92    b.emit_load_imm(4, TAG_PING);
93    b.emit_load_imm(5, 1);
94    emit_native_n!(b, 2, N_MAKE_MSG, 4);
95    b.emit_send(0, 2);
96    b.emit_receive(6);
97    emit_native1_from!(b, 4, 6, N_MSG_PAYLOAD);
98    b.emit_return(4);
99
100    b.finish()
101}
102
103/// Atomic request-reply with [`crate::Value::Message`] (one envelope per hop).
104///
105/// Requires [`crate::std_native_table`] (`make_msg` / `msg_*` / `print`).
106///
107/// # Why "Atomic Hop"
108///
109/// Correlation (`request_id`) and reply routing (`sender`) travel in a
110/// **single** mailbox value. Classic actor runtimes often allow any scalar
111/// on `Send`; Byteflow rejects that ÔÇö every hop is a typed envelope.
112///
113/// # Protocol
114///
115/// 1. `main` spawns `server`, builds
116///    `Message { id=1, tag=REQ, payload=41 }` (sender placeholder ignored)
117/// 2. `server` receives, logs via `print`, replies
118///    `tag=REP, payload=42` via `msg_reply_cap` (echoing `request_id`)
119/// 3. `main` returns the reply payload `42`
120///
121/// # Register discipline (`CallNative` clobbers `r[a]`)
122///
123/// `CallNative ra, , nc` reads args from `r[a..a+nc]` and writes the
124/// result into `r[a]`. Keeping the original `Message` in `r0` therefore
125/// means every unpack is `Move ri, r0` then `CallNative ri, msg_*, 1`.
126/// `make_msg` needs four **contiguous** arg registers; the server rearranges
127/// into `r4..r7` before the call.
128pub fn atomic_request_reply() -> Chunk {
129    let mut b = ChunkBuilder::new("atomic-request-reply");
130
131    // --- server -----------------------------------------------------------
132    // r0  = request Message (never overwritten until Exit)
133    // r1  = reply Cap (from msg_reply_cap)
134    // r2  = request_id
135    // r3  = tag
136    // r4  = payload, then make_msg result (reply Message)
137    // r5  = self Cap, then make_msg arg slot
138    // r6  = TAG_REP for make_msg
139    // r7  = scratch (print copy, +1 imm, payload for make_msg)
140    let server = b.begin_function("server", 0, 8);
141    b.emit_receive(0);
142    emit_native1_from!(b, 7, 0, N_PRINT);
143    emit_native1_from!(b, 1, 0, N_MSG_REPLY_CAP);
144    emit_native1_from!(b, 2, 0, N_MSG_REQUEST_ID);
145    emit_native1_from!(b, 3, 0, N_MSG_TAG);
146    emit_native1_from!(b, 4, 0, N_MSG_PAYLOAD);
147    b.emit_load_imm(7, 1);
148    b.emit_binop(Opcode::Add, 4, 4, 7);
149    b.emit_self_pid(5);
150    b.emit_move(7, 4);
151    b.emit_move(4, 5);
152    b.emit_move(5, 2);
153    b.emit_load_imm(6, TAG_REP);
154    emit_native_n!(b, 4, N_MAKE_MSG, 4);
155    emit_native1_from!(b, 7, 4, N_PRINT);
156    b.emit_send(1, 4);
157    b.emit_exit(4);
158
159    // --- main -------------------------------------------------------------
160    // r0 = server Cap
161    // r1 = self Cap
162    // r2..r5 = make_msg(self, 1, TAG_REQ, 41)  r2 becomes the request Message
163    // r6 = reply Message
164    // r7 = scratch for print / unpack
165    b.begin_function("main", 0, 8);
166    b.emit_self_pid(1);
167    b.emit_spawn(0, server, 0);
168    b.emit_move(2, 1);
169    b.emit_load_imm(3, 1);
170    b.emit_load_imm(4, TAG_REQ);
171    b.emit_load_imm(5, 41);
172    emit_native_n!(b, 2, N_MAKE_MSG, 4);
173    emit_native1_from!(b, 7, 2, N_PRINT);
174    b.emit_send(0, 2);
175    b.emit_receive(6);
176    emit_native1_from!(b, 7, 6, N_PRINT);
177    emit_native1_from!(b, 4, 6, N_MSG_PAYLOAD);
178    b.emit_return(4);
179
180    b.finish()
181}
182
183/// Selective Atomic Hop: server waits for `TAG_REQ` while a `TAG_JUNK` hop
184/// sits ahead in the mailbox (FIFO skip, not drop).
185///
186/// Requires [`crate::std_native_table`].
187///
188/// 1. `main` sends junk (`tag=TAG_JUNK`), then request (`tag=TAG_REQ`, payload=41)
189/// 2. `server` does `ReceiveMatchImm TAG_REQ` ÔÇö must see payload 41, not junk
190/// 3. replies `TAG_REP` / 42; then classic `Receive` drains the leftover junk
191/// 4. `main` returns reply payload `42`
192pub fn selective_receive() -> Chunk {
193    let mut b = ChunkBuilder::new("selective-receive");
194
195    // server: match TAG_REQ, reply 42 via reply_cap, then drain junk
196    let server = b.begin_function("server", 0, 8);
197    b.emit_receive_match_imm(0, TAG_REQ as u16);
198    emit_native1_from!(b, 1, 0, N_MSG_REPLY_CAP);
199    emit_native1_from!(b, 2, 0, N_MSG_REQUEST_ID);
200    emit_native1_from!(b, 4, 0, N_MSG_PAYLOAD);
201    b.emit_load_imm(7, 1);
202    b.emit_binop(Opcode::Add, 4, 4, 7);
203    b.emit_self_pid(5);
204    b.emit_move(7, 4);
205    b.emit_move(4, 5);
206    b.emit_move(5, 2);
207    b.emit_load_imm(6, TAG_REP);
208    emit_native_n!(b, 4, N_MAKE_MSG, 4);
209    b.emit_send(1, 4);
210    // leftover TAG_JUNK must still be waiting
211    b.emit_receive(0);
212    emit_native1_from!(b, 3, 0, N_MSG_TAG);
213    b.emit_load_imm(7, TAG_JUNK);
214    b.emit_binop(Opcode::Eq, 3, 3, 7);
215    // Branch jumps when falsy: not-equal  trap
216    let trap_lbl = b.new_label();
217    b.emit_branch(3, trap_lbl);
218    b.emit_exit(4);
219    b.bind_label(trap_lbl);
220    b.emit_trap(2);
221
222    b.begin_function("main", 0, 8);
223    b.emit_self_pid(1);
224    b.emit_spawn(0, server, 0);
225    // junk hop first
226    b.emit_move(2, 1);
227    b.emit_load_imm(3, 1);
228    b.emit_load_imm(4, TAG_JUNK);
229    b.emit_load_imm(5, 0);
230    emit_native_n!(b, 2, N_MAKE_MSG, 4);
231    b.emit_send(0, 2);
232    // real request
233    b.emit_move(2, 1);
234    b.emit_load_imm(3, 1);
235    b.emit_load_imm(4, TAG_REQ);
236    b.emit_load_imm(5, 41);
237    emit_native_n!(b, 2, N_MAKE_MSG, 4);
238    b.emit_send(0, 2);
239    b.emit_receive_match_imm(6, TAG_REP as u16);
240    emit_native1_from!(b, 4, 6, N_MSG_PAYLOAD);
241    b.emit_return(4);
242
243    b.finish()
244}
245
246/// Atomic request/reply via [`Opcode::Ask`] (RPC hop).
247///
248/// Requires [`crate::std_native_table`].
249///
250/// 1. `main` builds `Message { id=1, tag=REQ, payload=41 }` and `Ask`s the server Cap
251/// 2. `server` `ReceiveMatchImm TAG_REQ`, replies via `msg_reply_cap` with
252///    `TAG_REP` / payload 42 and the same `request_id`
253/// 3. `Ask` resumes with the reply; `main` returns payload `42`
254pub fn ask_reply() -> Chunk {
255    let mut b = ChunkBuilder::new("ask-reply");
256
257    let server = b.begin_function("server", 0, 8);
258    b.emit_receive_match_imm(0, TAG_REQ as u16);
259    emit_native1_from!(b, 1, 0, N_MSG_REPLY_CAP);
260    emit_native1_from!(b, 2, 0, N_MSG_REQUEST_ID);
261    emit_native1_from!(b, 4, 0, N_MSG_PAYLOAD);
262    b.emit_load_imm(7, 1);
263    b.emit_binop(Opcode::Add, 4, 4, 7);
264    b.emit_self_pid(5);
265    b.emit_move(7, 4);
266    b.emit_move(4, 5);
267    b.emit_move(5, 2);
268    b.emit_load_imm(6, TAG_REP);
269    emit_native_n!(b, 4, N_MAKE_MSG, 4);
270    b.emit_send(1, 4);
271    b.emit_exit(4);
272
273    // main: Ask r6, r0 (server Cap), r2 (request Message)
274    b.begin_function("main", 0, 8);
275    b.emit_self_pid(1);
276    b.emit_spawn(0, server, 0);
277    b.emit_move(2, 1);
278    b.emit_load_imm(3, 1);
279    b.emit_load_imm(4, TAG_REQ);
280    b.emit_load_imm(5, 41);
281    emit_native_n!(b, 2, N_MAKE_MSG, 4);
282    b.emit_ask(6, 0, 2);
283    emit_native1_from!(b, 4, 6, N_MSG_PAYLOAD);
284    b.emit_return(4);
285
286    b.finish()
287}
288
289/// Security regression sample: forged `make_msg` sender must not survive `Send`.
290///
291/// # What this proves
292///
293/// Invariant **S1** from `docs/security.md`: structural Atomic Hop typing
294/// alone cannot stop a module from writing `sender = 999` into a
295/// [`crate::Message`]. The scheduler overwrites that field on bytecode
296/// `Send`, so the serverÔÇÖs `msg_sender` / echoed payload reflects the **real**
297/// client flow id.
298///
299/// # Protocol
300///
301/// 1. `main` builds a request with forged sender `999` and `Send`s it.
302/// 2. `server` reads the delivered hop, puts authenticated `msg_sender` into
303///    the reply `payload`, and answers.
304/// 3. `main` returns that payload as `Int`.
305///
306/// Unit tests assert the returned id is not `999` (and is a plausible live
307/// flow id). Requires [`crate::std_native_table`].
308pub fn forged_sender_send() -> Chunk {
309    let mut b = ChunkBuilder::new("forged-sender-send");
310
311    let server = b.begin_function("server", 0, 8);
312    b.emit_receive(0);
313    emit_native1_from!(b, 1, 0, N_MSG_REPLY_CAP);
314    emit_native1_from!(b, 2, 0, N_MSG_REQUEST_ID);
315    emit_native1_from!(b, 3, 0, N_MSG_SENDER);
316    // reply: payload = authenticated sender FlowId
317    b.emit_self_pid(4);
318    b.emit_move(5, 2);
319    b.emit_load_imm(6, TAG_REP);
320    b.emit_move(7, 3);
321    emit_native_n!(b, 4, N_MAKE_MSG, 4);
322    b.emit_send(1, 4);
323    b.emit_exit(4);
324
325    b.begin_function("main", 0, 8);
326    b.emit_self_pid(1);
327    b.emit_spawn(0, server, 0);
328    // Deliberately forge sender = 999
329    b.emit_load_imm(2, 999);
330    b.emit_load_imm(3, 1);
331    b.emit_load_imm(4, TAG_REQ);
332    b.emit_load_imm(5, 0);
333    emit_native_n!(b, 2, N_MAKE_MSG, 4);
334    b.emit_send(0, 2);
335    b.emit_receive(6);
336    emit_native1_from!(b, 4, 6, N_MSG_PAYLOAD);
337    b.emit_return(4);
338
339    b.finish()
340}
341
342/// Same security property as [`forged_sender_send`], via [`Opcode::Ask`].
343///
344/// Covers the request half of **S1** on the RPC path: a forged
345/// `make_msg.sender` is stamped away before the server observes the hop.
346/// Reply authenticity (**S2**, `sender == target`) is covered separately by
347/// mailbox unit tests (`ask_requires_reply_from_target`).
348pub fn forged_sender_ask() -> Chunk {
349    let mut b = ChunkBuilder::new("forged-sender-ask");
350
351    let server = b.begin_function("server", 0, 8);
352    b.emit_receive_match_imm(0, TAG_REQ as u16);
353    emit_native1_from!(b, 1, 0, N_MSG_REPLY_CAP);
354    emit_native1_from!(b, 2, 0, N_MSG_REQUEST_ID);
355    emit_native1_from!(b, 3, 0, N_MSG_SENDER);
356    b.emit_self_pid(4);
357    b.emit_move(5, 2);
358    b.emit_load_imm(6, TAG_REP);
359    b.emit_move(7, 3);
360    emit_native_n!(b, 4, N_MAKE_MSG, 4);
361    b.emit_send(1, 4);
362    b.emit_exit(4);
363
364    b.begin_function("main", 0, 8);
365    b.emit_self_pid(1);
366    b.emit_spawn(0, server, 0);
367    b.emit_load_imm(2, 999);
368    b.emit_load_imm(3, 1);
369    b.emit_load_imm(4, TAG_REQ);
370    b.emit_load_imm(5, 0);
371    emit_native_n!(b, 2, N_MAKE_MSG, 4);
372    b.emit_ask(6, 0, 2);
373    emit_native1_from!(b, 4, 6, N_MSG_PAYLOAD);
374    b.emit_return(4);
375
376    b.finish()
377}
378
379/// Immediate `Trap` ÔÇö used to show [`crate::Supervisor`] restart.
380pub fn boom() -> Chunk {
381    let mut b = ChunkBuilder::new("boom");
382    b.begin_function("boom", 0, 1);
383    b.emit_trap(1);
384    b.finish()
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::{
391        decode, encode, std_native_table, verify, FlowOutcome, Runtime, RuntimeConfig, Value,
392    };
393
394    fn tiny(chunk: Chunk) -> Result<Runtime, crate::SpawnError> {
395        Runtime::with_config(
396            chunk,
397            RuntimeConfig {
398                workers: 1,
399                quantum: 10_000,
400                mailbox: crate::MailboxConfig::DEFAULT,
401            },
402        )
403    }
404
405    fn tiny_natives(chunk: Chunk) -> Result<Runtime, crate::SpawnError> {
406        Runtime::with_natives_and_config(
407            chunk,
408            std_native_table(),
409            RuntimeConfig {
410                workers: 1,
411                quantum: 10_000,
412                mailbox: crate::MailboxConfig::DEFAULT,
413            },
414        )
415    }
416
417    #[test]
418    fn add_forty_two_joins_42() -> Result<(), Box<dyn std::error::Error>> {
419        let rt = tiny(add_forty_two())?;
420        let idx = rt.function_index("main").ok_or("main")?;
421        let outcome = rt.spawn(idx, &[])?.join();
422        rt.shutdown();
423        assert!(matches!(outcome, FlowOutcome::Completed(Value::Int(42))));
424        Ok(())
425    }
426
427    #[test]
428    fn ping_pong_joins_2() -> Result<(), Box<dyn std::error::Error>> {
429        let chunk = ping_pong();
430        assert!(verify(&chunk).is_ok());
431        let bytes = encode(&chunk);
432        let chunk = decode(&bytes)?;
433        let rt = tiny_natives(chunk)?;
434        let idx = rt.function_index("main").ok_or("main")?;
435        let outcome = rt.spawn(idx, &[])?.join();
436        let sent = rt.metrics().messages_sent;
437        rt.shutdown();
438        assert!(matches!(outcome, FlowOutcome::Completed(Value::Int(2))));
439        assert!(sent >= 2);
440        Ok(())
441    }
442
443    #[test]
444    fn atomic_request_reply_joins_42() -> Result<(), Box<dyn std::error::Error>> {
445        let chunk = atomic_request_reply();
446        assert!(verify(&chunk).is_ok());
447        let bytes = encode(&chunk);
448        let chunk = decode(&bytes)?;
449        let rt = tiny_natives(chunk)?;
450        let idx = rt.function_index("main").ok_or("main")?;
451        let outcome = rt.spawn(idx, &[])?.join();
452        let sent = rt.metrics().messages_sent;
453        rt.shutdown();
454        assert!(
455            matches!(outcome, FlowOutcome::Completed(Value::Int(42))),
456            "got {outcome:?}"
457        );
458        assert!(sent >= 1);
459        Ok(())
460    }
461
462    #[test]
463    fn selective_receive_skips_junk_tag() -> Result<(), Box<dyn std::error::Error>> {
464        let chunk = selective_receive();
465        assert!(verify(&chunk).is_ok());
466        let bytes = encode(&chunk);
467        let chunk = decode(&bytes)?;
468        let rt = tiny_natives(chunk)?;
469        let idx = rt.function_index("main").ok_or("main")?;
470        let outcome = rt.spawn(idx, &[])?.join();
471        rt.shutdown();
472        assert!(
473            matches!(outcome, FlowOutcome::Completed(Value::Int(42))),
474            "got {outcome:?}"
475        );
476        Ok(())
477    }
478
479    #[test]
480    fn ask_reply_joins_42() -> Result<(), Box<dyn std::error::Error>> {
481        let chunk = ask_reply();
482        assert!(verify(&chunk).is_ok());
483        let bytes = encode(&chunk);
484        let chunk = decode(&bytes)?;
485        let rt = tiny_natives(chunk)?;
486        let idx = rt.function_index("main").ok_or("main")?;
487        let outcome = rt.spawn(idx, &[])?.join();
488        let sent = rt.metrics().messages_sent;
489        rt.shutdown();
490        assert!(
491            matches!(outcome, FlowOutcome::Completed(Value::Int(42))),
492            "got {outcome:?}"
493        );
494        // request + reply
495        assert!(sent >= 2);
496        Ok(())
497    }
498
499    #[test]
500    fn send_overwrites_forged_sender() -> Result<(), Box<dyn std::error::Error>> {
501        // S1: make_msg(sender=999, …) + Send → receiver must not see 999.
502        let chunk = forged_sender_send();
503        assert!(verify(&chunk).is_ok());
504        let rt = tiny_natives(chunk)?;
505        let idx = rt.function_index("main").ok_or("main")?;
506        let outcome = rt.spawn(idx, &[])?.join();
507        rt.shutdown();
508        match outcome {
509            FlowOutcome::Completed(Value::Int(n)) => {
510                assert_ne!(n, 999, "forged make_msg sender must not survive Send");
511                assert!(n >= 1, "authenticated sender must be a live flow id");
512                Ok(())
513            }
514            other => Err(format!("expected Completed(Int), got {other:?}").into()),
515        }
516    }
517
518    #[test]
519    fn ask_overwrites_forged_request_sender() -> Result<(), Box<dyn std::error::Error>> {
520        // S1 on the Ask request path (same forge, RPC hop).
521        let chunk = forged_sender_ask();
522        assert!(verify(&chunk).is_ok());
523        let rt = tiny_natives(chunk)?;
524        let idx = rt.function_index("main").ok_or("main")?;
525        let outcome = rt.spawn(idx, &[])?.join();
526        rt.shutdown();
527        match outcome {
528            FlowOutcome::Completed(Value::Int(n)) => {
529                assert_ne!(n, 999, "forged make_msg sender must not survive Ask");
530                assert!(n >= 1, "authenticated sender must be a live flow id");
531                Ok(())
532            }
533            other => Err(format!("expected Completed(Int), got {other:?}").into()),
534        }
535    }
536
537    #[test]
538    fn send_scalar_target_traps() -> Result<(), Box<dyn std::error::Error>> {
539        let mut b = ChunkBuilder::new("bad-cap-target");
540        b.begin_function("main", 0, 6);
541        b.emit_load_imm(0, 99); // Int — not Cap
542        b.emit_load_imm(1, 0);
543        b.emit_load_imm(2, 1);
544        b.emit_load_imm(3, TAG_PING);
545        b.emit_load_imm(4, 1);
546        emit_native_n!(b, 1, N_MAKE_MSG, 4);
547        b.emit_send(0, 1);
548        b.emit_return(1);
549        let rt = tiny_natives(b.finish())?;
550        let outcome = rt.spawn(0, &[])?.join();
551        rt.shutdown();
552        assert!(
553            matches!(outcome, FlowOutcome::Failed(_)),
554            "non-Cap Send target must fail, got {outcome:?}"
555        );
556        Ok(())
557    }
558
559    #[test]
560    fn send_scalar_is_not_an_atomic_hop() -> Result<(), Box<dyn std::error::Error>> {
561        let mut b = ChunkBuilder::new("bad-hop");
562        b.begin_function("main", 0, 2);
563        b.emit_self_pid(0);
564        b.emit_load_imm(1, 99);
565        b.emit_send(0, 1);
566        b.emit_return(1);
567        let rt = tiny(b.finish())?;
568        let outcome = rt.spawn(0, &[])?.join();
569        rt.shutdown();
570        assert!(
571            matches!(outcome, FlowOutcome::Failed(_)),
572            "scalar Send must trap, got {outcome:?}"
573        );
574        Ok(())
575    }
576}