From 4c80270af3c60f12f5a64f885ba791c81888e21d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20R=C3=A9mond?= Date: Sat, 14 Nov 2026 15:51:25 +0100 Subject: [PATCH] fix(ir): preserve receive resumption in after bodies --- CHANGELOG.md | 11 +++++ crates/nera-ir/src/lower/expr.rs | 42 +++++++++--------- tests/README.md | 1 + tests/receive_nested_after.erl | 75 ++++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 20 deletions(-) create mode 100644 tests/receive_nested_after.erl diff --git a/CHANGELOG.md b/CHANGELOG.md index 2553b6c..77e6f20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable milestones for the nera project are documented in this file. +## Receives nested in after bodies resume at their own wait + +- A `receive` directly inside another receive's `after` body now waits for + its own timeout and receives messages sent while it waits, as on BEAM. +- Values evaluated earlier in the `after` body survive suspension. Previously + the enclosing receive could resume inside the inner receive before those + values or the inner timeout had been initialized. +- The `receive_nested_after` regression runs unchanged natively and on BEAM, + covering message and timeout wakes, tail position, deeper nesting and + successive inner receives. + ## Oversized binary copies raise Erlang errors - `binary:copy/2` checks result-size multiplication and the binary header's diff --git a/crates/nera-ir/src/lower/expr.rs b/crates/nera-ir/src/lower/expr.rs index 212f34a..04b1983 100644 --- a/crates/nera-ir/src/lower/expr.rs +++ b/crates/nera-ir/src/lower/expr.rs @@ -849,26 +849,11 @@ impl IrBuilder { let before = self.collect_all_bindings(); let mut exits = Vec::new(); - // --- After body block (shared target for timeout paths) --- - let after_block = if let Some(after_clause) = after { - let ab = self.fresh_block_with_id(); - // Like a clause body, the after body binds in its own scope. - self.push_scope(); - self.start_block(ab); - let after_result = self.lower_body(&after_clause.body, is_tail)?; - if !is_tail { - self.emit(Instruction::Move(result_var, after_result)); - self.set_terminator(Terminator::Jump(join_block, vec![])); - exits.extend(self.branch_exit(&before)); - } else if self.current_terminator.is_none() { - self.set_terminator(Terminator::Return(after_result)); - } - self.close_current_block(); - self.pop_scope(); - Some(ab) - } else { - None - }; + // Allocate the after target now, but emit its body after the wait. + // Codegen associates receive_wait with the last receive_peek in block + // order. A nested receive in this body must not take ownership of the + // enclosing receive's wait (or its saved live variables). + let after_block = after.map(|_| self.fresh_block_with_id()); // --- Empty block: no messages (or all scanned, none matched) --- self.start_block(empty_block); @@ -913,6 +898,23 @@ impl IrBuilder { self.close_current_block(); } + // --- After body block (shared target for timeout paths) --- + if let (Some(after_clause), Some(ab)) = (after, after_block) { + // Like a clause body, the after body binds in its own scope. + self.push_scope(); + self.start_block(ab); + let after_result = self.lower_body(&after_clause.body, is_tail)?; + if !is_tail { + self.emit(Instruction::Move(result_var, after_result)); + self.set_terminator(Terminator::Jump(join_block, vec![])); + exits.extend(self.branch_exit(&before)); + } else if self.current_terminator.is_none() { + self.set_terminator(Terminator::Return(after_result)); + } + self.close_current_block(); + self.pop_scope(); + } + // --- Build match clauses --- // Each body block calls receive_accept() to remove the matched message // before executing the clause body. diff --git a/tests/README.md b/tests/README.md index a411685..da4dc4b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -376,6 +376,7 @@ These are automatically skipped by the runner and show up as SKIP in output. | `selective_receive.erl` | Selective receive (mailbox scan) | ~6 | | `receive_after.erl` | Receive with timeout | ~4 | | `receive_timeout_domain.erl` | `after` values outside `infinity` and 0..4294967295: a matching queued message is received first, including selective receive and inside `try`; with no match, `error:timeout_value` at once, queued messages kept, no wait for a later sender, and as an uncaught exit; an error in the `after` expression or earlier in a `try` body comes first; valid boundaries; also run on BEAM | Table-driven | +| `receive_nested_after.erl` | Receive directly in an after body: delayed messages, own timeout, earlier values, tail position, deeper nesting and successive inner receives; also run on BEAM | 5 scenarios | | `receive_nil_message.erl` | The message `[]` is received like any other: first, middle and last in the mailbox, `[]` patterns and guards, `after 0` and no `after`, selective receive skipping it, and a waiting process woken by it; later messages still arrive; also run on BEAM | ~21 | | `send_order_between_processes.erl` | Messages one process sends another arrive in the order they were sent: four senders each stream 100,000 numbers to their own receiver, and 2,000 bursts of three come back from an echo process in order; also run on BEAM | 3 | | `process_dict.erl` | put/get/erase/get_keys | ~10 | diff --git a/tests/receive_nested_after.erl b/tests/receive_nested_after.erl new file mode 100644 index 0000000..623aaca --- /dev/null +++ b/tests/receive_nested_after.erl @@ -0,0 +1,75 @@ +%% EXPECT: receive_nested_after_ok +%% A receive directly inside an after body must resume itself, not its +%% enclosing receive, and preserve values evaluated before the inner wait. +%% Run this fixture unchanged natively and on BEAM. +-module(receive_nested_after). +-export([test/0]). + +-define(WAIT_MS, 120). + +test() -> + {aft, {late, x}} = delayed_message(), + io:format("delayed message and earlier atom ok~n"), + {{kept, [1, 2], <<3, 4>>}, nolate} = own_timeout(), + io:format("inner timeout and earlier value ok~n"), + {tail, x} = tail_receive(), + io:format("tail receive without timeout ok~n"), + {outer, {middle, {late, x}}} = three_levels(), + io:format("three nested receives ok~n"), + {first, x, second, y} = successive_receives(), + io:format("successive inner receives ok~n"), + io:format("receive_nested_after_ok~n"), + ok. + +delayed_message() -> + Sender = sender(self(), x), + receive never -> unexpected after 10 -> + %% Start the sender only once the enclosing receive has timed out. + Sender ! go, + {aft, receive X -> {late, X} after 1000 -> nolate end} + end. + +own_timeout() -> + receive never -> unexpected after 10 -> + Started = erlang:monotonic_time(millisecond), + Keep = {kept, [1, 2], <<3, 4>>}, + R = {Keep, receive never -> unexpected after ?WAIT_MS -> nolate end}, + Elapsed = erlang:monotonic_time(millisecond) - Started, + %% Allow clock quantization, not an early wake on the outer deadline. + true = Elapsed >= ?WAIT_MS - 2, + R + end. + +tail_receive() -> + Sender = sender(self(), x), + receive never -> unexpected after 0 -> + Sender ! go, + receive X -> {tail, X} end + end. + +three_levels() -> + Sender = sender(self(), x), + receive never -> unexpected after 10 -> + {outer, receive never -> unexpected after 10 -> + Sender ! go, + {middle, receive X -> {late, X} after 1000 -> nolate end} + end} + end. + +successive_receives() -> + Sender1 = sender(self(), x), + Sender2 = sender(self(), y), + receive never -> unexpected after 0 -> + Sender1 ! go, + A = {first, receive X -> X after 1000 -> nolate end}, + Sender2 ! go, + {first, V} = A, + {first, V, second, receive Y -> Y after 1000 -> nolate end} + end. + +sender(To, Value) -> + spawn(fun() -> + receive go -> + receive after 30 -> To ! Value end + end + end).