From 639d2643f3b01eeb6e2cf8823c49f65c87c627b3 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Wed, 21 Oct 2026 19:20:07 -0300 Subject: [PATCH 01/50] A call through the empty closure is a named panic at the call (#3091) A closure slot given `null` holds the empty closure, `{ .fn = 0 }`. A call through it, `let c: fn = null` then `c()`, a field or parameter given `null`, an array element, or a `fn?` holding `null` (a present value, so `match`'s `some` arm and `!` take it), jumped to address 0: an access violation with no message. Every closure call is emitted through `_aether_closure_fn(f.fn, where)`, a static inline in the prelude that panics with `where` when the function is missing. `where` is a literal built at the call: the category `empty_closure_call`, then the call's file, line and column. The erased dispatch (#2847) needs nothing of its own, since no branch matches a null function and the plain call under it is the checked one. The reference says what `null` in a `fn?` is, a present empty closure, and that `none` is the absence of any closure, and lists the new category under Panic categories. Not covered: a typed C function pointer (`fn(int) -> int`, a `cfn`) is no closure, and a call through a null one still jumps to 0. Test: tests/regression/test_issue3091_empty_closure_call.ae covers a bare `fn`, a field, a parameter called directly, through `call` and as a closure type with a keep mode, an array element, a `fn?` some arm and unwrap, each caught with its position, and `none` taking the none arm. It crashes before (0xC0000005 on both C paths) and passes after. tests/integration/panic_categories pins the category token. Every regression test that uses a closure (337 files) builds and runs on both C paths as before; the three that fail (test_fs_stat_kind_socket_fifo, test_os_run_full, test_run_capture_status) fail the same way before, as POSIX-only checks on Windows. --- compiler/codegen/codegen.c | 9 ++ compiler/codegen/codegen_expr.c | 17 +- docs/language-reference.md | 8 +- ...44Z-fixed-3091-empty-closure-call-panic.md | 6 + .../test_closure_args_borrowed.sh | 2 +- .../test_closure_args_foreign.sh | 2 +- .../panic_categories/empty_closure.ae | 8 + .../panic_categories/test_panic_categories.sh | 1 + .../test_issue3091_empty_closure_call.ae | 151 ++++++++++++++++++ 9 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 new_changelogs/20261021T220544Z-fixed-3091-empty-closure-call-panic.md create mode 100644 tests/integration/panic_categories/empty_closure.ae create mode 100644 tests/regression/test_issue3091_empty_closure_call.ae diff --git a/compiler/codegen/codegen.c b/compiler/codegen/codegen.c index b130fe363..d398a7d39 100644 --- a/compiler/codegen/codegen.c +++ b/compiler/codegen/codegen.c @@ -9131,6 +9131,15 @@ void generate_program(CodeGenerator* gen, ASTNode* program) { print_line(gen, "#endif"); // Closure support: generic closure struct (function pointer + captured environment) print_line(gen, "typedef struct { void (*fn)(void); void* env; } _AeClosure;"); + /* #3091: the function a call through a closure jumps to. The empty + * closure (`null` in a closure slot, also when a `fn?` holds it) has + * none, and the call panics with `where`, its category and source + * position, instead of jumping to address 0. */ + print_line(gen, "typedef void (*_AeFnPtr)(void);"); + print_line(gen, "static inline _AeFnPtr _aether_closure_fn(_AeFnPtr fn, const char* where) {"); + print_line(gen, " if (!fn) aether_panic(where);"); + print_line(gen, " return fn;"); + print_line(gen, "}"); /* The head every closure env starts with: its destructor (#1398) and its * reference count (#2494). An env is held by the closure value's owner * and by every env that captured the closure; each holder releases diff --git a/compiler/codegen/codegen_expr.c b/compiler/codegen/codegen_expr.c index cee660f5b..fe11f4b22 100644 --- a/compiler/codegen/codegen_expr.c +++ b/compiler/codegen/codegen_expr.c @@ -6010,10 +6010,11 @@ static const char* slice_elem_c_type(const Type* t) { } /* The source path a bounds-check failure names, as a C string literal - * (#2305). A path is written into generated C, so it is escaped like any - * string: a Windows path's backslashes were emitted raw, and each `\G`, - * `\s`, `\d` became an unknown-escape warning per access (a `\a` a BEL in - * the message). Control characters are written as octal escapes. */ + * (#2305), and the one a call through the empty closure names (#3091). + * A path is written into generated C, so it is escaped like any string: a + * Windows path's backslashes were emitted raw, and each `\G`, `\s`, `\d` + * became an unknown-escape warning per access (a `\a` a BEL in the + * message). Control characters are written as octal escapes. */ static void emit_slice_diag_file(CodeGenerator* gen, const ASTNode* n) { const char* path = (n && n->source_file) ? n->source_file : "?"; fputc('"', gen->output); @@ -10386,9 +10387,13 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { fprintf(gen->output, ", %s", atype); sig_pi++; } - fprintf(gen->output, "))"); + /* #3091: through `_aether_closure_fn`, which + * panics at this call when the closure is empty. */ + fprintf(gen->output, "))_aether_closure_fn("); generate_expression(gen, closure_arg); - fprintf(gen->output, ".fn)("); + fprintf(gen->output, ".fn, \"empty_closure_call: call through an empty closure at \" "); + emit_slice_diag_file(gen, expr); + fprintf(gen->output, " \":%d:%d\"))(", expr->line, expr->column); generate_expression(gen, closure_arg); fprintf(gen->output, ".env"); int arg_pi = 0; diff --git a/docs/language-reference.md b/docs/language-reference.md index 4f19cb48f..cd140730c 100644 --- a/docs/language-reference.md +++ b/docs/language-reference.md @@ -2033,6 +2033,7 @@ downstream triage match on it, so it does not change with the prose after it. | `precondition_violation` | a `requires` clause that evaluated false | | `postcondition_violation` | an `ensures` clause that evaluated false | | `forced_unwrap_none` | `x!` on an optional holding `none` | +| `empty_closure_call` | a call through the empty closure (a closure slot or a `fn?` holding `null`) | A failure that forwards an existing error value (`expr!` on a fallible) prints that error's own message rather than a category, because the message came from @@ -3904,7 +3905,12 @@ wherever a closure goes: a binding, a field, an argument, a result, an element of an array literal (`[f, null]`, `[null, f]`), a part of a tuple literal, a module-level `var`, the default of `??`. A `fn?` given `null` holds the empty closure, as a `ptr?` given `null` holds a null pointer: it -is not `none`, and `o == null` is true. A closure compares only with +is a present value, not `none`, so `o == null` is true, a `match` runs its +`some` arm and `o!` gives the empty closure; `none` is the absence of any +closure. A call through the empty closure, however it was reached, panics +at the call with the category `empty_closure_call` and the call's source +position ([Panic categories](#panic-categories)), which a `catch` +receives. A closure compares only with `null`: `f == g` between two closures, or a closure and a pointer, is a compile error, and so is one between two `fn?` or a `fn?` and a closure (Go's rule for function values). The same diff --git a/new_changelogs/20261021T220544Z-fixed-3091-empty-closure-call-panic.md b/new_changelogs/20261021T220544Z-fixed-3091-empty-closure-call-panic.md new file mode 100644 index 000000000..cc6eadd0f --- /dev/null +++ b/new_changelogs/20261021T220544Z-fixed-3091-empty-closure-call-panic.md @@ -0,0 +1,6 @@ +- **A call through the empty closure is a named panic.** `let c: fn = + null` then `c()`, a closure field or parameter given `null`, and a `fn?` + holding `null` (a present empty closure, which `match`'s `some` arm and + `!` take) crashed with an access violation and no message. The call now + panics with the category `empty_closure_call` and its source position, + which a `catch` receives (#3091). diff --git a/tests/integration/closure_args_borrowed/test_closure_args_borrowed.sh b/tests/integration/closure_args_borrowed/test_closure_args_borrowed.sh index 254930294..47a80c742 100644 --- a/tests/integration/closure_args_borrowed/test_closure_args_borrowed.sh +++ b/tests/integration/closure_args_borrowed/test_closure_args_borrowed.sh @@ -96,7 +96,7 @@ h!+ i!" grep -q '_ad_[0-9]* = (char\*)(mk(((const char\*)&_ae_slit_[0-9]*))), aether_unwind_track_str_place((const void\*)\&_ad_[0-9]*, NULL), apply(look, _ad_[0-9]*), aether_unwind_forget((const void\*)\&_ad_[0-9]*), aether_heap_str_free' "$tmp/borrowed.c" \ || fail "the argument to apply() is not freed" "$tmp/borrowed.c" -n="$(grep -c '_ad_[0-9]* = (char\*)(mk(a)), .*f\.fn)(f\.env, _ad_[0-9]*), aether_unwind_forget((const void\*)\&_ad_[0-9]*), aether_heap_str_free' "$tmp/borrowed.c")" +n="$(grep -c '_ad_[0-9]* = (char\*)(mk(a)), .*_aether_closure_fn(f\.fn, "empty_closure_call: .*"))(f\.env, _ad_[0-9]*), aether_unwind_forget((const void\*)\&_ad_[0-9]*), aether_heap_str_free' "$tmp/borrowed.c")" [ "$n" = "3" ] || fail "an argument to a call through an fn parameter is not freed ($n of 3)" "$tmp/borrowed.c" # ---- a closure keeps its `string` parameter: the store takes its own diff --git a/tests/integration/closure_args_foreign/test_closure_args_foreign.sh b/tests/integration/closure_args_foreign/test_closure_args_foreign.sh index d78b2c4be..c0e6739bc 100644 --- a/tests/integration/closure_args_foreign/test_closure_args_foreign.sh +++ b/tests/integration/closure_args_foreign/test_closure_args_foreign.sh @@ -54,7 +54,7 @@ AE } freed() { - grep -q '_ad_[0-9]* = (char\*)(mk(a)), .*f\.fn)(f\.env, _ad_[0-9]*), aether_unwind_forget((const void\*)\&_ad_[0-9]*), aether_heap_str_free' "$tmp/$1.c" + grep -q '_ad_[0-9]* = (char\*)(mk(a)), .*_aether_closure_fn(f\.fn, "empty_closure_call: .*"))(f\.env, _ad_[0-9]*), aether_unwind_forget((const void\*)\&_ad_[0-9]*), aether_heap_str_free' "$tmp/$1.c" } gen control "" "" diff --git a/tests/integration/panic_categories/empty_closure.ae b/tests/integration/panic_categories/empty_closure.ae new file mode 100644 index 000000000..ada8315b4 --- /dev/null +++ b/tests/integration/panic_categories/empty_closure.ae @@ -0,0 +1,8 @@ +// A call through the empty closure must print its own category token. +import std.io + +main() { + let c: fn = null + c() + println("after") +} diff --git a/tests/integration/panic_categories/test_panic_categories.sh b/tests/integration/panic_categories/test_panic_categories.sh index 842160f06..9b9313adc 100755 --- a/tests/integration/panic_categories/test_panic_categories.sh +++ b/tests/integration/panic_categories/test_panic_categories.sh @@ -45,6 +45,7 @@ expect_category() { expect_category precondition precondition_violation expect_category forced_none forced_unwrap_none +expect_category empty_closure empty_closure_call if [ "$fail" -eq 0 ]; then echo " [PASS] panic_categories: failures lead with their stable category token" diff --git a/tests/regression/test_issue3091_empty_closure_call.ae b/tests/regression/test_issue3091_empty_closure_call.ae new file mode 100644 index 000000000..6f8dbf4db --- /dev/null +++ b/tests/regression/test_issue3091_empty_closure_call.ae @@ -0,0 +1,151 @@ +// Regression (#3091): a call through the empty closure is a named panic at +// the call, not a jump to address 0. +// +// `let c: fn = null` then `c()`, a struct field left `null`, a parameter +// given `null` (called directly, through `call`, or as a closure type that +// says what it keeps), an array element, and a `fn?` holding `null` (a +// present empty closure, so its `some` arm runs and `!` takes it) each +// crashed the program with an access violation and no message. Each call +// now panics with the category `empty_closure_call` and the call's source +// position, which a `catch` receives. `none` in a `fn?` is the absence of a +// closure: no call is made. + +import std.string + +extern exit(code: int) + +struct Holder { cb: fn } + +fn check(cond: bool, msg: string) { + if !cond { + println("FAIL #3091: ${msg}") + exit(1) + } +} + +fn check_panic(e: string, line: string, what: string) { + check(string.starts_with(e, "empty_closure_call: "), "${what}: category, got '${e}'") + check(string.contains(e, "test_issue3091_empty_closure_call.ae:${line}:"), + "${what}: position ${line}, got '${e}'") +} + +direct(cb: fn) { + cb() +} + +through_call(cb: fn, x: int) { + call(cb, x) +} + +keeping(el: @keep ptr, tick: fn(@keep ptr, float)) { + tick(el, 0.5) +} + +bare() -> string { + let c: fn = null + try { + c() + } catch e { + return e + } + return "no panic" +} + +field() -> string { + h = Holder { cb: null } + try { + h.cb() + } catch e { + return e + } + return "no panic" +} + +param() -> string { + try { + direct(null) + } catch e { + return e + } + return "no panic" +} + +param_call() -> string { + try { + through_call(null, 3) + } catch e { + return e + } + return "no panic" +} + +param_keeping() -> string { + try { + keeping(null, null) + } catch e { + return e + } + return "no panic" +} + +element() -> string { + let fs: fn[2] = [null, null] + try { + g = fs[1] + g() + } catch e { + return e + } + return "no panic" +} + +opt_some() -> string { + let o: fn? = null + try { + match o { + some(f) -> { f() } + none -> { return "none arm" } + } + } catch e { + return e + } + return "no panic" +} + +opt_unwrap() -> string { + let o: fn? = null + try { + g = o! + g() + } catch e { + return e + } + return "no panic" +} + +opt_none() -> string { + let o: fn? = none + match o { + some(f) -> { f() } + none -> { return "none arm" } + } + return "some arm" +} + +main() { + check_panic(bare(), "47", "bare fn") + check_panic(field(), "57", "struct field") + check_panic(param(), "33", "parameter") + check_panic(param_call(), "37", "parameter through call") + check_panic(param_keeping(), "41", "closure type with a keep mode") + check_panic(element(), "95", "array element") + check_panic(opt_some(), "106", "fn? some arm") + check_panic(opt_unwrap(), "119", "fn? unwrap") + check(opt_none() == "none arm", "none in a fn? is the absence") + // A closure that holds a function is called as before. + n = 0 + h = Holder { cb: || { n = n + 1 } } + h.cb() + check(n == 1, "a full closure is called") + println("ok") +} From 22bea4f455625199f3d13cdc926232cac1c10def Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Wed, 21 Oct 2026 18:29:19 -0300 Subject: [PATCH 02/50] An actor moves only with no message of it in flight, before it runs, so order holds and a ring gathers on one core (#3090, #3093) Two scheduler defects in moving actors between cores, and one fix for both. #3093, ordering. A message one actor sends another goes either straight into the target's mailbox (a local send) or through a core's queues (a from_queue, or a sender's overflow buffer on its way to one). When the target was moved while a message of the second kind was on its way, the old core forwarded that message after it, and the sender's next message, sent to the new core directly, could be stepped first. A message re-queued because the mailbox was full went to the back of a queue and could be overtaken the same way. A test with eight senders streaming numbered messages to four targets saw 14,400 to 21,924 of 3,200,000 out of order on the runtime before (three runs). #3090, rings never gathered. A cross-core send set migrate_to on its target, and the target's core moved it after running the message, onto the core the sender had just left. So each actor in a chain took the place of the one before it, and a thread ring only shifted around the cores: 3.03 M msg/s, on 5.6 s of CPU. The in-flight protocol: - Every message routed through a core's queues is counted in its target's new `inflight` (ActorBase, AETHER_ACTOR_BASE_FIELDS) before the sender reads assigned_core, and uncounted when it reaches the mailbox. That covers send_through_core, the batch flush (one count per actor in the batch), send_buffer_flush, the I/O poller's events, re-queues on a full mailbox and the overflow paths. A sender that finds the AETHER_INFLIGHT_MOVING mark waits for the move to end, then reads the new core. - Every move between cores is made in move_actor, by the actor's own core, while it steps nothing, under both table locks and the step lock, and only after a CAS of `inflight` from 0 to MOVING. No message for the actor is then anywhere but its mailbox, which moves with it. Work stealing goes through it too: the idle core asks for an actor by migrate_to, and the busy core moves it. - Message-driven migration moves the actor before it runs the message that brought the request, so a chain's next actor follows the message. A busy actor is asked again after its batch. - Delivery takes a run: the messages of one actor that follow each other in a batch go into its mailbox in order and are uncounted with one decrement. A full mailbox is made room in by stepping the actor there, its owner (make_room), so no message gives up its place. A main-thread actor waits for main-thread mode to let it go first. - A local send to an actor with messages still in the queues goes behind them (send_local, send_through_core). overflow_flush delivers its own core's entries directly only while that core's own queue is empty. A message re-queued onto its own core goes behind that core's overflow (requeue_own). Measured, Windows i7-13700K, 1,000,000 messages, 1 warmup and 10 interleaved runs, M msg/s, median (range), CV; CPU s: base (#3087) this thread ring 3.03 (2.92-3.14) 2.1% 5.555 24.91 (24.77-25.06) 0.4% 0.078 fork-join 30.23 (25.78-33.01) 6.2% 0.289 39.69 (37.97-42.08) 3.4% 0.281 ping-pong 12.36 (12.08-12.56) 1.4% 12.56 (11.82-12.70) 2.1% counting 42.84 (42.28-43.07) 0.6% 42.90 (37.73-43.01) 3.8% skynet 1.31 (0.66-1.68) 37.4% 1.69 (1.11-2.14) 16.3% The ring gathers on one core in its first lap: 8.2x the rate on 1/71 of the CPU time. Fork-join pays nothing for the counting. A first version counted and uncounted each message alone and cost it 8% (28.25 against 30.83). Delivered as runs, with one decrement per run, a worker's batch is now stepped together, and fork-join is 31% faster. Ping-pong and counting are unchanged; counting's range is one slow run. The C test actors stepped more than one message per call. docs/c- embedding.md already says a step takes one: the scheduler counts every call as one message processed. Those actors passed only while every step found one message waiting. Delivered a run at a time, a mailbox holds several, a drained batch counted as one, and scheduler_wait never balanced: "Scheduler bidirectional ping-pong" hung in its shutdown with all 50 pings and pongs through. They take one message per call now: counter, order, pingpong, echo and burst in test_scheduler.c, the three in test_scheduler_stress.c (whose stress step also took the 101st message out of the mailbox without counting it), wsteal_step, and the bench_scheduler and bench_zerocopy actors. benchmarks/http's servers drain too, but never wait for quiescence. Test: tests/regression/test_actor_pair_fifo.ae, eight senders over the cores and four targets, 3,200,000 numbered messages, each target checking every sender's sequence. Before: 21,924, 14,400 and 15,485 out of order. After: 0 in three runs. A variant with a slow handler (4000 rounds, handler loop of 400), so mailboxes fill and make_room runs: 0 in three. Checked: make test (593) passes three times. The 35 actor, ask, scheduler and cooperative tests in tests/regression and tests/syntax pass. The ownership grid's actor cells (P11, P12K, P12D) are 155 OK, none worse than the baseline. The POSIX side compiles with -Wall -Wextra and no warning (MSYS2 gcc -fsyntax-only). --- docs/actor-concurrency.md | 19 +- docs/c-embedding.md | 2 + ...857Z-fixed-3090-3093-migration-in-order.md | 20 + runtime/actors/aether_send_buffer.c | 10 +- runtime/scheduler/multicore_scheduler.c | 567 +++++++++++------- runtime/scheduler/multicore_scheduler.h | 34 +- tests/regression/test_actor_pair_fifo.ae | 111 ++++ tests/runtime/bench_scheduler.c | 21 +- tests/runtime/bench_zerocopy.c | 2 +- tests/runtime/test_scheduler.c | 63 +- tests/runtime/test_scheduler_stress.c | 10 +- tests/runtime/test_worksteal_race.c | 2 +- 12 files changed, 609 insertions(+), 252 deletions(-) create mode 100644 new_changelogs/20261021T222857Z-fixed-3090-3093-migration-in-order.md create mode 100644 tests/regression/test_actor_pair_fifo.ae diff --git a/docs/actor-concurrency.md b/docs/actor-concurrency.md index 133b4aea4..51ad88b86 100644 --- a/docs/actor-concurrency.md +++ b/docs/actor-concurrency.md @@ -14,12 +14,13 @@ The actor runtime includes several performance optimizations applied automatical - Work stealing activated when cores become idle - Non-blocking work stealing using try-lock with ascending core-id lock ordering - NUMA-aware allocation when available (Linux and Windows; on macOS `aether_numa_alloc` is a plain `malloc`) -- Message-driven actor migration co-locates communicating actors on the sender's core +- Message-driven actor migration co-locates communicating actors on the sender's core: an actor a message from another core reaches moves to that core before it runs it, so a chain of sends (a ring, a pipeline) gathers on one core in its first round, where its sends are local +- An actor moves only by its own core, and only while no message for it is on its way through a core's queues (each such message is counted in the actor's `inflight`), so the messages one actor sends another arrive in order across a move (#3090, #3093) **Work Stealing:** - Triggered after extended idle scheduler cycles - Selects the core with the most pending work -- Steals entire actors (not individual messages) to preserve cache locality +- Takes entire actors (not individual messages) to preserve cache locality: the idle core asks the busy one for an actor, and the busy core moves it, as every move is made - Uses ascending core-id lock ordering consistent with migration to prevent deadlock **Message Delivery:** @@ -83,7 +84,7 @@ typedef struct { } CounterActor; ``` -The fields, in order: `active`, `id`, `mailbox`, `step`, `thread`, `auto_process`, `assigned_core`, `migrate_to`, `main_thread_only`, `spsc_queue`, `reply_slot`, `step_lock`, `timeout_ns`, `last_activity_ns`, `dead`, `alloc_size`, `destroy_state`, `scheduler_owned`, `inbox`, `thread_parked`. The header comments each one. +The fields, in order: `active`, `id`, `mailbox`, `step`, `thread`, `auto_process`, `assigned_core`, `migrate_to`, `main_thread_only`, `spsc_queue`, `reply_slot`, `step_lock`, `timeout_ns`, `last_activity_ns`, `dead`, `alloc_size`, `destroy_state`, `scheduler_owned`, `inbox`, `thread_parked`, `inflight`. The header comments each one. ## Message Passing @@ -329,13 +330,17 @@ int main() { Actors are placed on the caller's core at spawn time. Actors spawned from the main thread default to core 0, keeping top-level actor groups co-located. Actors spawned from within actor handlers inherit the parent's core. -During operation, cross-core message senders set a `migrate_to` hint on the target actor, requesting migration to the sender's core. The owning scheduler thread checks these hints after processing messages in the coalesce buffer and migrates the actor accordingly. This co-locates communicating actors regardless of their initial placement. Migration is non-blocking: if the destination lock cannot be acquired, migration is deferred to the next iteration. +During operation, a sender on another core sets a `migrate_to` hint on the target actor, asking for it on the sender's core. The actor's own core moves it, before it runs the message that brought the request, so a chain of sends (a thread ring, a pipeline) gathers on one core in its first round and its sends turn local. A busy actor whose messages run first is asked again after that batch. A move is non-blocking: if a lock cannot be taken, it waits for the next iteration. + +An actor moves only while no message for it is on its way through a core's queues. A sender counts its message in the actor's `inflight` before it reads the actor's core, and the core that delivers the message to the mailbox uncounts it, a whole run of one actor's messages at once. The move turns `inflight` from 0 to a moving mark, so a message cannot be left behind on the core the actor leaves and then overtaken by the sender's next one (#3093). A message that finds the mailbox full keeps its place: the actor's core steps it to make room, instead of putting the message at the back of a queue. Work stealing goes through the same path: the idle core asks for an actor, and the busy core moves it. + +Measured on Windows (i7-13700K, 1,000,000 messages, 10 runs, M msg/s, medians) against the runtime before (#3090): thread ring 24.91 from 3.03, on 0.078 s of CPU from 5.56 s, its 100 actors on one core; fork-join 39.69 from 30.23, each worker's batch delivered as one run; ping-pong (12.56 and 12.36) and counting (42.90 and 42.84) unchanged; skynet 1.69 from 1.31, with a narrower spread. ### Message Routing -- **Same-core messages:** Direct mailbox or SPSC queue write (verified via `current_core_id`) -- **Cross-core messages:** Enqueued to target core's lock-free incoming queue -- **Migrated actor messages:** Forwarded to the actor's current core with spin-retry +- **Same-core messages:** Direct mailbox or SPSC queue write (verified via `current_core_id`), behind any message of the actor still in the queues +- **Cross-core messages:** Counted in the target's `inflight`, then enqueued to the target core's lock-free incoming queue +- **A message on a core the actor has left:** Only one routed by a stale read or by the runtime itself, since a counted message holds the actor where it was routed; forwarded to the actor's current core, still counted ## Memory Management diff --git a/docs/c-embedding.md b/docs/c-embedding.md index 758555b8e..2f74c5b73 100644 --- a/docs/c-embedding.md +++ b/docs/c-embedding.md @@ -296,6 +296,8 @@ The `ActorBase` contains: - `alloc_size`, `destroy_state`, `scheduler_owned`: How the scheduler frees it - `inbox`, `thread_parked`: For an actor with its own thread (`auto_process`), where every sender puts its messages and whether the thread sleeps idle +- `inflight`: Messages for the actor on their way through a core's queues; + it moves to another core only while this is 0 Embedding `ActorBase` as the first member, as above, keeps the layout right whatever fields it gains. The full list, in order, with a comment for each, is diff --git a/new_changelogs/20261021T222857Z-fixed-3090-3093-migration-in-order.md b/new_changelogs/20261021T222857Z-fixed-3090-3093-migration-in-order.md new file mode 100644 index 000000000..557a3f0a0 --- /dev/null +++ b/new_changelogs/20261021T222857Z-fixed-3090-3093-migration-in-order.md @@ -0,0 +1,20 @@ +- **Messages from one actor to another arrive in order while the target + moves between cores, and a chain of actors gathers on one core.** When an + actor moved to the core that sent to it, a message still queued on its old + core was forwarded after it, and the sender's next message, sent to the + new core directly, could be stepped first. A test with eight senders and + four moving targets saw 14,400 to 21,924 of 3,200,000 messages out of + order (#3093). A message re-queued because the target's mailbox was full + went to the back of a queue and could be overtaken the same way. + + Now every message routed through a core's queues is counted on its target + until it reaches the mailbox, and an actor moves only by its own core with + none in flight. A full mailbox is made room in by stepping the actor, so + the message keeps its place. + + A moved actor used to take the place its sender had just left, so a thread + ring only shifted around the cores. Now it moves before it runs the + message, so the ring gathers on one core in its first lap: 24.91 M msg/s + from 3.03, on 1/71 of the CPU time (#3090). A worker's batch is delivered + as one run: fork-join 39.69 M msg/s from 30.23. Ping-pong and counting are + unchanged. diff --git a/runtime/actors/aether_send_buffer.c b/runtime/actors/aether_send_buffer.c index 4fce47c39..e7baaa449 100644 --- a/runtime/actors/aether_send_buffer.c +++ b/runtime/actors/aether_send_buffer.c @@ -60,7 +60,11 @@ void send_buffer_flush(void) { } // Slow path: cross-core batch enqueue (KEY OPTIMIZATION) - // Batch all messages to same actor in ONE atomic operation + // Batch all messages to same actor in ONE atomic operation. Counted in + // flight before the actor's core is read again (#3090, #3093), so it + // stays on the core they are routed to until they reach its mailbox. + inflight_enter(actor, g_send_buffer.count); + target_core = atomic_load_explicit(&actor->assigned_core, memory_order_acquire); void* actors[SEND_BUFFER_SIZE]; for (int i = 0; i < g_send_buffer.count; i++) { actors[i] = actor; // All messages to same actor @@ -75,7 +79,9 @@ void send_buffer_flush(void) { atomic_fetch_add(&schedulers[target_core].work_count, g_send_buffer.count); g_send_buffer.count = 0; } else { - // Queue full - fall back to individual sends with backoff + // Queue full - fall back to individual sends with backoff, which + // count each message themselves + inflight_leave(actor, g_send_buffer.count); for (int i = 0; i < g_send_buffer.count; i++) { scheduler_send_remote(actor, g_send_buffer.buffer[i], g_send_buffer.core_id); } diff --git a/runtime/scheduler/multicore_scheduler.c b/runtime/scheduler/multicore_scheduler.c index dfb7eb8a6..1f2361dbb 100644 --- a/runtime/scheduler/multicore_scheduler.c +++ b/runtime/scheduler/multicore_scheduler.c @@ -447,6 +447,40 @@ static inline void sched_wake(Scheduler* target) { } } +// --------------------------------------------------------------------------- +// Messages in flight, and moving an actor between cores (#3090, #3093) +// +// A message one actor sends another goes either straight into the target's +// mailbox, by the target's own core (a local send), or through a core's +// queues: a from_queue, or a sender's overflow buffer on its way to one. +// Moving the target to another core while a message of the second kind was +// on its way left that message behind: the old core forwarded it, while the +// sender's next message went to the new core directly and could be stepped +// first (#3093, 31k of 3.2M messages out of order in a test). +// +// So every message routed through the queues is counted in the target's +// `inflight` before the sender reads assigned_core (inflight_enter), and +// uncounted when it reaches the mailbox (inflight_leave). An actor moves +// only by its own core, at a point of that core's loop where it is stepping +// nothing (so no local send to it is under way), and only after +// move_begin() has turned `inflight` from 0 to AETHER_INFLIGHT_MOVING: no +// message for it is anywhere but its mailbox, which moves with it. A sender +// that counts its message during the move waits for move_end() before it +// reads assigned_core, and then reads the new core. +// --------------------------------------------------------------------------- + +static inline int move_begin(ActorBase* actor) { + int none = 0; + return atomic_compare_exchange_strong_explicit(&actor->inflight, &none, + AETHER_INFLIGHT_MOVING, + memory_order_acq_rel, + memory_order_relaxed); +} + +static inline void move_end(ActorBase* actor) { + atomic_fetch_and_explicit(&actor->inflight, ~AETHER_INFLIGHT_MOVING, memory_order_release); +} + // --------------------------------------------------------------------------- // Idle park of actor threads (#2592) // @@ -634,6 +668,18 @@ static void overflow_append(int target, ActorBase* actor, Message msg) { atomic_fetch_add_explicit(&g_overflow_total, 1, memory_order_relaxed); } +// Queue a counted message for one of this core's own actors on this core's +// own queue, behind whatever this core's own overflow buffer holds: an entry +// there overflowed when the queue was full, so it is older, and a message +// put in the queue past it was stepped first (#3093). send_through_core +// keeps the same order for another core's queue. +static void requeue_own(int core, ActorBase* actor, Message msg) { + if (tls_overflow[core].count > 0 || + !queue_enqueue(&schedulers[core].from_queues[core], actor, msg)) { + overflow_append(core, actor, msg); + } +} + // Try to flush deferred sends into their target from_queues. // Called at the top of each scheduler_thread iteration. // @@ -647,6 +693,8 @@ static void overflow_append(int target, ActorBase* actor, Message msg) { // Cross-core: Always uses head-pointer advancement instead of memmove. // This makes the cost O(drained) instead of O(total), eliminating the // multi-MB memmoves that caused phase-2 stalls with large overflow. +static void scheduler_drop_dead_message(Scheduler* sched, Message* msg); + static void overflow_flush(int from_core) { if (!tls_overflow_any) return; int from_idx = (from_core >= 0 && from_core < MAX_CORES) ? from_core : MAX_CORES; @@ -660,8 +708,13 @@ static void overflow_flush(int from_core) { // ── OWN-CORE (small overflow): direct mailbox + step() ───────── // Bypass the from_queue entirely for low-contention fast path. // When overflow is large (tree-spawn cascade), fall through to the - // uniform queue-based path below to avoid amplification. - if (t == from_core && from_core >= 0 && b->count <= 4096) { + // uniform queue-based path below to avoid amplification. Not while + // this core's own queue holds messages: an entry overflowed because + // that queue was full, so the queue's are older, and delivered past + // them it was stepped first (#3093: 511 of a sender's messages + // overtaken by its next 128). + if (t == from_core && from_core >= 0 && b->count <= 4096 && + queue_size(&schedulers[from_core].from_queues[from_core]) == 0) { int start = b->head; int orig_count = b->count; int limit = orig_count < 128 ? orig_count : 128; @@ -670,9 +723,18 @@ static void overflow_flush(int from_core) { ActorBase* actor = b->actors[start + i]; Message msg = b->msgs[start + i]; int ac = atomic_load_explicit(&actor->assigned_core, memory_order_relaxed); - if (ac == from_core && !actor->auto_process && + if (unlikely(atomic_load_explicit(&actor->dead, memory_order_acquire))) { + // Released since it was sent: dropped and credited, as the + // core loop drops one (#1301), and uncounted (#3090). Put in + // the mailbox of a released actor, it was never processed, + // and scheduler_wait() waited for it. + if (!actor->auto_process) inflight_leave(actor, 1); + scheduler_drop_dead_message(&schedulers[from_core], &msg); + flushed++; + } else if (ac == from_core && !actor->auto_process && !atomic_load_explicit(&actor->main_thread_only, memory_order_relaxed)) { if (mailbox_send(&actor->mailbox, msg)) { + inflight_leave(actor, 1); atomic_store_explicit(&actor->active, 1, memory_order_relaxed); flushed++; // Immediately process if step_lock available @@ -1223,12 +1285,21 @@ static void tombstone_keep(ActorBase* actor) { aether_numa_free_aligned(actor, actor->alloc_size); } -// The most recently kept block of this size, or NULL. +// The most recently kept block of this size that no message is on its way +// to, or NULL. A message sent to the released actor before its release can +// still be in a core's queue, counted in the block's `inflight` (#3090); the +// core that dequeues it drops it, the block still marked released, and +// uncounts it. Handed to a new actor first, the block would have taken that +// message as the new actor's, and its uncount from the new actor's count. static ActorBase* tombstone_take(size_t size) { ActorBase* actor = NULL; tombstone_lock(); TombstoneBucket* b = tombstone_bucket_locked(size, 0); - if (b && b->count > 0) actor = b->blocks[--b->count]; + for (int k = b ? b->count - 1 : -1; k >= 0 && !actor; k--) { + if (atomic_load_explicit(&b->blocks[k]->inflight, memory_order_acquire) != 0) continue; + actor = b->blocks[k]; + b->blocks[k] = b->blocks[--b->count]; + } tombstone_unlock(); return actor; } @@ -1522,6 +1593,69 @@ static void scheduler_drop_dead_message(Scheduler* sched, Message* msg) { atomic_fetch_add_explicit(&sched->messages_processed, 1, memory_order_relaxed); } +// Move `actor` from `sched`, its core, to core `dst`, on `sched`'s own thread +// while it steps nothing: every move of an actor between cores is made here +// (#3090, #3093). Takes both cores' table locks without waiting, in +// ascending core order as every two-lock path does, then the actor's step +// lock, then move_begin: no message for it may be on its way through any +// core. Returns 1 when moved; the new core is woken to step whatever is in +// the mailbox that moved with it. Returns 0, the actor where it was, when a +// lock is taken or a message is in flight. +static int move_actor(Scheduler* sched, ActorBase* actor, int dst, int slot_hint) { + if (dst < 0 || dst >= num_cores || dst == sched->core_id || actor->auto_process || + atomic_load_explicit(&actor->main_thread_only, memory_order_acquire) || + atomic_load_explicit(&actor->dead, memory_order_acquire)) + return 0; + Scheduler* to = &schedulers[dst]; + OptimizedSpinlock* first = sched->core_id < dst ? &sched->actor_lock : &to->actor_lock; + OptimizedSpinlock* second = sched->core_id < dst ? &to->actor_lock : &sched->actor_lock; + if (atomic_flag_test_and_set_explicit(&first->lock, memory_order_acquire)) return 0; + if (atomic_flag_test_and_set_explicit(&second->lock, memory_order_acquire)) { + atomic_flag_clear_explicit(&first->lock, memory_order_release); + return 0; + } + int moved = 0; + int at = actor_table_has_room_locked(to) ? actor_table_find_locked(sched, actor, slot_hint) : -1; + if (at >= 0 && !atomic_flag_test_and_set_explicit(&actor->step_lock, memory_order_acquire)) { + if (move_begin(actor)) { + actor_table_remove_locked(sched, at); + atomic_store_explicit(&actor->assigned_core, dst, memory_order_release); + atomic_store_explicit(&actor->migrate_to, -1, memory_order_relaxed); + actor_table_push_locked(to, actor); + move_end(actor); + moved = 1; + } + atomic_flag_clear_explicit(&actor->step_lock, memory_order_release); + } + atomic_flag_clear_explicit(&second->lock, memory_order_release); + atomic_flag_clear_explicit(&first->lock, memory_order_release); + if (moved) sched_wake(to); + return moved; +} + +// Step `actor`, this core's, once to make room in its full mailbox, so a +// message for it keeps its place instead of going to the back of a queue +// (#3093). Waits out a step lock another thread holds for a moment. Returns +// 0 when the actor will take no more messages: dead, or with no handler. +static int make_room(Scheduler* sched, ActorBase* actor) { + if (atomic_load_explicit(&actor->dead, memory_order_acquire) || !actor->step) return 0; + if (!atomic_flag_test_and_set_explicit(&actor->step_lock, memory_order_acquire)) { + // Down to half full, as the loop's drain before delivering did, so + // a run of messages does not take the lock once per message. + int stepped = 0; + while (atomic_load_explicit(&actor->mailbox.count, memory_order_acquire) > MAILBOX_SIZE / 2 && + !atomic_load_explicit(&actor->dead, memory_order_acquire)) { + aether_step_safe(actor); + stepped++; + } + sched->messages_processed += stepped; + atomic_flag_clear_explicit(&actor->step_lock, memory_order_release); + } else { + AETHER_PAUSE(); + } + return 1; +} + void* AETHER_HOT scheduler_thread(void* arg) { Scheduler* sched = (Scheduler*)arg; current_core_id = sched->core_id; @@ -1605,14 +1739,16 @@ void* AETHER_HOT scheduler_thread(void* arg) { // payload/slot cleanup and a processed credit, or the // sent/processed balance never converges (#1301 drain test). if (unlikely(atomic_load_explicit(&actor->dead, memory_order_acquire))) { + if (!actor->auto_process) inflight_leave(actor, 1); scheduler_drop_dead_message(sched, &msg); work_done = 1; continue; } - // Actor may have been migrated to another core after this message - // was enqueued. Forward to the actor's current core rather than - // delivering here (which would race with the new core's processing). + // Not this core's actor. A counted message holds its actor where + // it was routed (move_begin waits for none), so this is a message + // routed by a stale read or one of the runtime's own: forward it, + // still counted, to the actor's core. if (unlikely(atomic_load_explicit(&actor->assigned_core, memory_order_acquire) != sched->core_id)) { int new_core = atomic_load_explicit(&actor->assigned_core, memory_order_relaxed); #ifdef AETHER_DEBUG_ORDERING @@ -1640,9 +1776,9 @@ void* AETHER_HOT scheduler_thread(void* arg) { // Actor has an invalid assigned_core — deliver directly to mailbox // to prevent silent message loss. if (!mailbox_send(&actor->mailbox, msg)) { - if (!queue_enqueue(&sched->from_queues[sched->core_id], actor, msg)) { - overflow_append(sched->core_id, actor, msg); - } + requeue_own(sched->core_id, actor, msg); + } else if (!actor->auto_process) { + inflight_leave(actor, 1); } atomic_store_explicit(&actor->active, 1, memory_order_relaxed); } @@ -1661,98 +1797,93 @@ void* AETHER_HOT scheduler_thread(void* arg) { // For actors processed on main thread, just deliver to mailbox (don't process) if (unlikely(atomic_load_explicit(&actor->main_thread_only, memory_order_acquire))) { - if (!mailbox_send(&actor->mailbox, msg)) { - if (!queue_enqueue(&sched->from_queues[sched->core_id], actor, msg)) { - overflow_append(sched->core_id, actor, msg); + // Its place kept as below (#3093): the main thread drains the + // mailbox while the actor is its own, and once main-thread + // mode has let go of it this core makes room by stepping it. + int placed = 1; + while (!mailbox_send(&actor->mailbox, msg)) { + if (atomic_load_explicit(&actor->main_thread_only, memory_order_acquire)) { + aether_sched_yield(); + } else if (!make_room(sched, actor)) { + placed = 0; + break; } } + inflight_leave(actor, 1); + if (!placed) scheduler_drop_dead_message(sched, &msg); work_done = 1; continue; } - // Drain actor mailbox aggressively BEFORE trying to add new message - if (atomic_load_explicit(&actor->mailbox.count, memory_order_relaxed) > MAILBOX_SIZE / 2) { - int drained = 0; - if (actor->step && !atomic_flag_test_and_set_explicit(&actor->step_lock, memory_order_acquire)) { - uint64_t _drain_start = atomic_load_explicit(&g_aether_config.preempt_enabled, memory_order_relaxed) ? aether_now_ns() : 0; - while (atomic_load_explicit(&actor->mailbox.count, memory_order_relaxed) > 0 && drained < 128) { - if (unlikely(atomic_load_explicit(&actor->assigned_core, memory_order_relaxed) != sched->core_id)) - break; - aether_step_safe(actor); - drained++; - // Preemption: yield if handler batch exceeds threshold - if (_drain_start && (aether_now_ns() - _drain_start) >= g_aether_config.preempt_threshold_ns) - break; - } - atomic_flag_clear_explicit(&actor->step_lock, memory_order_release); - } - sched->messages_processed += drained; + // From here the message is this core's to deliver, and so is + // every message of the same actor that follows it in the batch: + // a run (a fan-out's batch holds a worker's messages together). + // They go into the mailbox in their order, a full mailbox made + // room in by stepping the actor here, its owner, so no message + // gives up its place: re-queued at the back of this core's + // queue, as it was, a message could be overtaken by a later one + // of the same sender (#3093). The run is then uncounted at once, + // with every message of it in the mailbox (#3090), which keeps + // the count off this loop's per-message path. + int run_end = i + 1; + while (run_end < sched->coalesce_buffer.count && + sched->coalesce_buffer.actors[run_end] == (void*)actor) { + run_end++; } - - // Re-check: actor may have been stolen during aggressive drain - if (unlikely(atomic_load_explicit(&actor->assigned_core, memory_order_relaxed) != sched->core_id)) { - int new_core = atomic_load_explicit(&actor->assigned_core, memory_order_relaxed); - if (new_core >= 0 && new_core < num_cores) { - int forwarded = 0; - for (int r = 0; r < 8; r++) { - if (queue_enqueue(&schedulers[new_core].from_queues[sched->core_id], actor, msg)) { - sched_wake(&schedulers[new_core]); - forwarded = 1; break; - } - int cur = atomic_load_explicit(&actor->assigned_core, memory_order_relaxed); - if (cur >= 0 && cur < num_cores) new_core = cur; - AETHER_PAUSE(); - } - if (!forwarded) overflow_append(new_core, actor, msg); - } else { - if (!mailbox_send(&actor->mailbox, msg)) { - if (!queue_enqueue(&sched->from_queues[sched->core_id], actor, msg)) { - overflow_append(sched->core_id, actor, msg); - } - } - atomic_store_explicit(&actor->active, 1, memory_order_relaxed); + int placed = i; + while (placed < run_end) { + if (mailbox_send(&actor->mailbox, sched->coalesce_buffer.messages[placed])) { + placed++; + continue; } - work_done = 1; + if (!make_room(sched, actor)) break; + } + if (placed > i) inflight_leave(actor, placed - i); + for (int k = placed; k < run_end; k++) { + // Dead, or never stepped (no handler): it takes no more + // messages, as a send to a dead actor is dropped. + inflight_leave(actor, 1); + scheduler_drop_dead_message(sched, &sched->coalesce_buffer.messages[k]); + } + int delivered = placed - i; + i = run_end - 1; + work_done = 1; + if (delivered == 0) continue; + atomic_store_explicit(&actor->active, 1, memory_order_relaxed); + + // A sender on another core asked for this actor (#3090): it + // moves there before it runs, so what it sends next leaves from + // that core. A chain of sends (a ring, a pipeline) gathers on one + // core this way, where its sends are local; moved after running, + // as it used to be, each actor took the place its sender had just + // left, and the chain only shifted. + int ask = atomic_load_explicit(&actor->migrate_to, memory_order_relaxed); + if (ask >= 0 && ask != sched->core_id && move_actor(sched, actor, ask, -1)) { continue; } - // Now try to deliver message and immediately process. - // FUSED DELIVER+PROCESS: Instead of delivering all messages first and - // then scanning all actors (O(N) with N=total actors per core), we - // deliver to the mailbox and immediately process if this actor is local - // and unlocked. This eliminates the second O(N) pass entirely, making - // the scheduler loop O(batch_size) instead of O(actor_count). - if (!mailbox_send(&actor->mailbox, msg)) { - if (!queue_enqueue(&sched->from_queues[sched->core_id], actor, msg)) { - overflow_append(sched->core_id, actor, msg); + // Immediate processing: if actor is on our core and step_lock + // is available, drain its mailbox right here. FUSED DELIVER + + // PROCESS: no second pass over every actor of the core, so the + // loop is O(batch_size) instead of O(actor_count). + if (likely(actor->step) && + atomic_load_explicit(&actor->assigned_core, memory_order_relaxed) == sched->core_id && + !atomic_flag_test_and_set_explicit(&actor->step_lock, memory_order_acquire)) { + int processed = 0; + while (atomic_load_explicit(&actor->mailbox.count, memory_order_relaxed) > 0 && + processed < 64 && + atomic_load_explicit(&actor->assigned_core, memory_order_relaxed) == sched->core_id) { + aether_step_safe(actor); + processed++; + // Yield to outer loop if cross-core messages arrived + // (e.g. StopAnimation sent from main while actor sleeps). + if (has_pending_cross_core(sched)) break; } - } else { - atomic_store_explicit(&actor->active, 1, memory_order_relaxed); - work_done = 1; - - // Immediate processing: if actor is on our core and step_lock - // is available, drain its mailbox right here. - if (likely(actor->step) && - !actor->auto_process && - !atomic_load_explicit(&actor->main_thread_only, memory_order_acquire) && - atomic_load_explicit(&actor->assigned_core, memory_order_relaxed) == sched->core_id && - !atomic_flag_test_and_set_explicit(&actor->step_lock, memory_order_acquire)) { - int processed = 0; - while (atomic_load_explicit(&actor->mailbox.count, memory_order_relaxed) > 0 && - processed < 64 && - atomic_load_explicit(&actor->assigned_core, memory_order_relaxed) == sched->core_id) { - aether_step_safe(actor); - processed++; - // Yield to outer loop if cross-core messages arrived - // (e.g. StopAnimation sent from main while actor sleeps). - if (has_pending_cross_core(sched)) break; - } - atomic_flag_clear_explicit(&actor->step_lock, memory_order_release); - sched->messages_processed += processed; - if (processed > 0 && - atomic_load_explicit(&actor->mailbox.count, memory_order_relaxed) == 0) { - atomic_store_explicit(&actor->active, 0, memory_order_relaxed); - } + atomic_flag_clear_explicit(&actor->step_lock, memory_order_release); + sched->messages_processed += processed; + if (processed > 0 && + atomic_load_explicit(&actor->mailbox.count, memory_order_relaxed) == 0) { + atomic_store_explicit(&actor->active, 0, memory_order_relaxed); } } } @@ -1760,33 +1891,15 @@ void* AETHER_HOT scheduler_thread(void* arg) { // TIER 1 ALWAYS ON: Adjust adaptive batch size based on what we received adaptive_batch_adjust(&sched->batch_state, sched->coalesce_buffer.count); - // Check migration for actors we just processed in the coalesce buffer. - // This is O(coalesce_count) not O(actor_count) — cheap and targeted. - // Without this, cross-core topologies (e.g. ring) never converge. + // Moves asked for while an actor was busy (its messages ran here + // first, or another core's message was in flight): this batch's + // actors are asked again now. O(coalesce_count), not O(actor_count). for (int mi = 0; mi < sched->coalesce_buffer.count; mi++) { ActorBase* mig_actor = (ActorBase*)sched->coalesce_buffer.actors[mi]; int mig_to = atomic_load_explicit(&mig_actor->migrate_to, memory_order_relaxed); - if (unlikely(mig_to >= 0 && mig_to != sched->core_id && mig_to < num_cores && + if (unlikely(mig_to >= 0 && mig_to != sched->core_id && atomic_load_explicit(&mig_actor->assigned_core, memory_order_relaxed) == sched->core_id)) { - Scheduler* dst = &schedulers[mig_to]; - OptimizedSpinlock* first_lock = (sched->core_id < mig_to) ? &sched->actor_lock : &dst->actor_lock; - OptimizedSpinlock* second_lock = (sched->core_id < mig_to) ? &dst->actor_lock : &sched->actor_lock; - if (!atomic_flag_test_and_set_explicit(&first_lock->lock, memory_order_acquire)) { - if (!atomic_flag_test_and_set_explicit(&second_lock->lock, memory_order_acquire)) { - if (actor_table_has_room_locked(dst)) { - // Find and remove actor from our list - int ai = actor_table_find_locked(sched, mig_actor, -1); - if (ai >= 0) { - actor_table_remove_locked(sched, ai); - atomic_store_explicit(&mig_actor->assigned_core, mig_to, memory_order_relaxed); - atomic_store_explicit(&mig_actor->migrate_to, -1, memory_order_relaxed); - actor_table_push_locked(dst, mig_actor); - } - } - atomic_flag_clear_explicit(&second_lock->lock, memory_order_release); - } - atomic_flag_clear_explicit(&first_lock->lock, memory_order_release); - } + move_actor(sched, mig_actor, mig_to, -1); } } @@ -1819,10 +1932,8 @@ void* AETHER_HOT scheduler_thread(void* arg) { int mig_to = atomic_load_explicit(&actor->migrate_to, memory_order_relaxed); if (mbox_count == 0 && mig_to < 0) continue; // Skip inactive actors (fast path!) - int was_active = atomic_load_explicit(&actor->active, memory_order_relaxed); - if (!was_active) { + if (!atomic_load_explicit(&actor->active, memory_order_relaxed)) { atomic_store_explicit(&actor->active, 1, memory_order_relaxed); - was_active = 1; } if (likely(actor->step)) { @@ -1833,9 +1944,9 @@ void* AETHER_HOT scheduler_thread(void* arg) { if (spsc_count > 0) { int sent_count = mailbox_send_batch(&actor->mailbox, spsc_msgs, spsc_count); for (int m = sent_count; m < spsc_count; m++) { - if (!queue_enqueue(&sched->from_queues[sched->core_id], actor, spsc_msgs[m])) { - overflow_append(sched->core_id, actor, spsc_msgs[m]); - } + // Into the queues: counted as every such message is. + inflight_enter(actor, 1); + requeue_own(sched->core_id, actor, spsc_msgs[m]); } } @@ -1861,60 +1972,22 @@ void* AETHER_HOT scheduler_thread(void* arg) { } work_done = 1; - // THEN: Message-driven migration — move actor to the core that - // communicates with it most. Processing first ensures the actor - // makes progress even under constant migration pressure. - // - // SAFETY: Only migrate actors that have been initialized (have - // messages in their mailbox, are currently active, OR were active - // at the start of this iteration). The third condition restores - // co-location: after processing, active=0 and mailbox=0, but - // was_active=1 means the setup-race window has already closed. - // A truly freshly-spawned actor (never activated) has was_active=0, - // active=0, mailbox=0 — migration is still blocked for that case. - // Re-read migrate_to (may have changed during processing) + // THEN: a move another core asked for (message-driven + // migration, or a steal, #3090). Processing first ensures the + // actor makes progress even under constant migration pressure. + // move_actor moves it only with no message of it in flight. + // `i` indexes the snapshot: since it was taken a deregistration + // may have moved another actor into slot i, so the live table is + // asked where the actor is, slot i first (#2486). mig_to = atomic_load_explicit(&actor->migrate_to, memory_order_relaxed); - if (unlikely(mig_to >= 0 && - mig_to != sched->core_id && - mig_to < num_cores && - (atomic_load_explicit(&actor->active, memory_order_relaxed) || atomic_load_explicit(&actor->mailbox.count, memory_order_relaxed) > 0 || was_active))) { - int dst_core = mig_to; - Scheduler* dst = &schedulers[dst_core]; - - // Lock in ascending core-id order to prevent deadlock - OptimizedSpinlock* first_lock = (sched->core_id < dst_core) ? &sched->actor_lock : &dst->actor_lock; - OptimizedSpinlock* second_lock = (sched->core_id < dst_core) ? &dst->actor_lock : &sched->actor_lock; - - if (!atomic_flag_test_and_set_explicit( - &first_lock->lock, memory_order_acquire)) { - if (!atomic_flag_test_and_set_explicit( - &second_lock->lock, memory_order_acquire)) { - // `i` indexes the snapshot. Since it was taken a steal or - // a deregistration may have moved another actor into - // slot i or taken this one, so the live table is asked - // where the actor is, slot i first (#2486). - int at = actor_table_has_room_locked(dst) - ? actor_table_find_locked(sched, actor, i) : -1; - if (at >= 0) { - actor_table_remove_locked(sched, at); - atomic_store_explicit(&actor->assigned_core, dst_core, memory_order_relaxed); - atomic_store_explicit(&actor->migrate_to, -1, memory_order_relaxed); - actor_table_push_locked(dst, actor); - - atomic_flag_clear_explicit(&second_lock->lock, memory_order_release); - atomic_flag_clear_explicit(&first_lock->lock, memory_order_release); - - // Re-examine this slot: it now holds the actor - // moved in from the end, or NULL. In a snapshot of - // a table that has since grown it is unchanged, - // and the second look at it finds nothing to do. - i--; - continue; - } - atomic_flag_clear_explicit(&second_lock->lock, memory_order_release); - } - atomic_flag_clear_explicit(&first_lock->lock, memory_order_release); - } + if (unlikely(mig_to >= 0 && mig_to != sched->core_id && + move_actor(sched, actor, mig_to, i))) { + // Re-examine this slot: it now holds the actor moved in from + // the end, or NULL. In a snapshot of a table that has since + // grown it is unchanged, and the second look at it finds + // nothing to do. + i--; + continue; } } } // end gated actor scan @@ -1965,7 +2038,13 @@ void* AETHER_HOT scheduler_thread(void* arg) { // (e.g. Spawn) to the new core while earlier messages // (e.g. Setup) are still queued on the old core, breaking // the FIFO ordering that actors depend on for initialization. - ActorBase* stolen = NULL; + // The victim moves it (#3090): this core asks + // for one, by migrate_to, and the victim's loop + // moves it with no message of it in flight, as + // every move between cores is made. Moved from + // here, it left the victim's queued messages + // for it behind, and the victim could be in a + // local send to it as it went. for (int s = victim_count - 1; s >= 4; s--) { ActorBase* candidate = atomic_load_explicit(&victim_table->slots[s], memory_order_relaxed); @@ -1979,18 +2058,14 @@ void* AETHER_HOT scheduler_thread(void* arg) { // and the move would change the core its // thread parks on and stops with (#2598). if (candidate->auto_process) continue; - // Move the last actor into its slot and steal it - actor_table_remove_locked(victim, s); - stolen = candidate; + int none = -1; + if (atomic_compare_exchange_strong_explicit( + &candidate->migrate_to, &none, sched->core_id, + memory_order_relaxed, memory_order_relaxed)) { + atomic_fetch_add(&sched->steal_attempts, 1); + } break; } - if (stolen) { - atomic_store_explicit(&stolen->assigned_core, sched->core_id, memory_order_relaxed); - atomic_store_explicit(&stolen->migrate_to, -1, memory_order_relaxed); - actor_table_push_locked(sched, stolen); - work_done = 1; - atomic_fetch_add(&sched->steal_attempts, 1); - } } atomic_flag_clear_explicit(&second_lock->lock, memory_order_release); } @@ -2502,7 +2577,16 @@ static void scheduler_free_core_tables(void) { int count = atomic_load_explicit(&sched->actor_count, memory_order_relaxed); for (int k = 0; table && k < count; k++) { ActorBase* actor = atomic_load_explicit(&table->slots[k], memory_order_relaxed); - if (!actor || !actor->scheduler_owned) continue; + if (!actor) continue; + if (!actor->scheduler_owned) { + // A caller's actor outlives this lifecycle, and its count of + // messages in flight (#3090) starts again from none with the + // queues that go with it: no sender is counting one now (a + // send is refused while the scheduler is not running, and + // the last foreign one has finished, above). + atomic_store_explicit(&actor->inflight, 0, memory_order_relaxed); + continue; + } if (actor->auto_process) { // Its own thread may still be running it (#2517): this is a // release, and the second of it and the thread's exit ends @@ -2561,6 +2645,14 @@ void scheduler_cleanup(void) { static int register_actor(ActorBase* actor, int preferred_core); // An actor the caller allocated: the scheduler runs it and never frees it. +// Its count of messages in flight is left as it is: a caller's actor can be +// registered again after a shutdown and a scheduler_init() while a thread of +// the caller's is sending to it, and a message counted for it before the +// registration is still on its way. Set to 0 here, as it used to be, the +// delivery of that message took the count to -1, which reads as a move in +// progress, and the next sender waited for it forever (#3090). The count +// starts at 0 in a zeroed actor, and teardown sets it back to 0 +// (scheduler_free_core_tables). int scheduler_register_actor(ActorBase* actor, int preferred_core) { actor->scheduler_owned = 0; atomic_init(&actor->inbox, NULL); @@ -2765,10 +2857,10 @@ static int scheduler_io_poll(Scheduler* sched, int timeout_ms) { // Route through the scheduler's self-channel (from_queues[core_id]) // instead of mailbox_send. The mailbox is SPSC and the accept thread // is already the producer via direct_send — adding a second producer - // (this scheduler thread) would corrupt the ring buffer. - if (!queue_enqueue(&sched->from_queues[sched->core_id], actor, msg)) { - overflow_append(sched->core_id, actor, msg); - } + // (this scheduler thread) would corrupt the ring buffer. Into the + // queues: counted as every such message is (#3090). + if (!actor->auto_process) inflight_enter(actor, 1); + requeue_own(sched->core_id, actor, msg); atomic_store_explicit(&actor->active, 1, memory_order_relaxed); } return n; @@ -2818,15 +2910,21 @@ void scheduler_send_local(ActorBase* actor, Message msg) { // for the work-stealing handoff: if the actor is stolen to another core // after this send, the new owner will see both active=1 and the message. atomic_store_explicit(&actor->active, 1, memory_order_relaxed); - if (unlikely(!mailbox_send(&actor->mailbox, msg))) { + // Straight into the mailbox only when nothing of the actor's is in + // the queues (#3093): a message of it waiting in this core's queue, + // its mailbox full when it was sent, is older, and one put in the + // mailbox past it was stepped first. Otherwise queued behind it. + int queued = current_core_id >= 0 && + atomic_load_explicit(&actor->inflight, memory_order_relaxed) != 0; + if (unlikely(queued || !mailbox_send(&actor->mailbox, msg))) { // Mailbox full: re-queue via self-channel or overflow buffer. // Without this, the message is lost but messages_sent was already // incremented, causing scheduler_wait to hang forever. int core = current_core_id; if (core >= 0 && core < num_cores) { - if (!queue_enqueue(&schedulers[core].from_queues[core], actor, msg)) { - overflow_append(core, actor, msg); - } + // Into the queues: counted as every such message is (#3090). + inflight_enter(actor, 1); + requeue_own(core, actor, msg); } else { // Main thread: spin-retry mailbox (rare, small mailbox pressure) while (!mailbox_send(&actor->mailbox, msg)) { AETHER_PAUSE(); } @@ -2939,37 +3037,47 @@ static void send_through_core(ActorBase* actor, Message msg, int from_core) { // Already same-core AND caller is actually running on that core's // scheduler thread — deliver directly to mailbox (no queue overhead). // The current_core_id check prevents non-scheduler threads (e.g. main) - // from racing with the scheduler thread on mailbox access. + // from racing with the scheduler thread on mailbox access. Not counted: + // only this core moves the actor, and not while it is sending. if (from_core >= 0 && from_core == target_core && from_core == current_core_id) { - if (unlikely(!mailbox_send(&actor->mailbox, msg))) { - // Mailbox full: re-queue via self-channel or overflow. - if (!queue_enqueue(&schedulers[from_core].from_queues[from_core], actor, msg)) { - overflow_append(from_core, actor, msg); - } + // Behind anything of the actor's already in the queues, as in + // scheduler_send_local (#3093). + if (unlikely(atomic_load_explicit(&actor->inflight, memory_order_relaxed) != 0 || + !mailbox_send(&actor->mailbox, msg))) { + // Mailbox full: re-queue via self-channel or overflow, counted + // as every message in the queues is. + inflight_enter(actor, 1); + requeue_own(from_core, actor, msg); } atomic_store_explicit(&actor->active, 1, memory_order_relaxed); AETHER_STAT_INC(direct_sends); return; } - // Cross-core send: migrate target actor to sender's core. - // This converges communicating actors onto the same core, turning cross-core - // sends into local sends. For ring/ping-pong patterns, convergence happens - // within a few messages. - // - // GUARD: Only set migrate_to if the actor has been activated (processed at least - // one message). Setting migrate_to on a freshly spawned actor before its first - // message (Setup) is delivered causes a race: the scheduler thread migrates the - // actor to the sender's core, then the next send (Spawn) takes the fast local - // path (scheduler_send_local) and bypasses Setup still queued on the old core. - if (from_core >= 0 && from_core == current_core_id && !actor->auto_process && - atomic_load_explicit(&actor->active, memory_order_relaxed)) { - if (from_core != target_core) { - atomic_store_explicit(&actor->migrate_to, from_core, memory_order_relaxed); + // Through a core's queues (#3090, #3093): counted before the actor's core + // is read again, so the actor cannot move from the core this message is + // routed to before the message reaches its mailbox there. + if (!actor->auto_process) { + inflight_enter(actor, 1); + target_core = atomic_load_explicit(&actor->assigned_core, memory_order_acquire); + if (unlikely(target_core < 0 || target_core >= num_cores)) { + target_core = actor->id % num_cores; } } + // A sender on another core asks for the actor (message-driven + // migration): its core moves it here before it runs this message, when + // no other message of it is in flight (scheduler_thread, move_actor), so + // communicating actors gather on one core and their sends turn local. A + // ring gathers in its first lap. Asked for only once it had run, as it + // used to be, an actor in a chain was never asked: the next one is idle + // when the message reaches it (#3090). + if (from_core >= 0 && from_core == current_core_id && !actor->auto_process && + from_core != target_core) { + atomic_store_explicit(&actor->migrate_to, from_core, memory_order_relaxed); + } + // Enqueue to target core's per-sender SPSC channel (SPSC: only current_core_id writes here). int from_idx = (current_core_id >= 0 && current_core_id < MAX_CORES) ? current_core_id : MAX_CORES; @@ -3128,8 +3236,34 @@ void scheduler_send_batch_flush(void) { int by_core[MAX_CORES]; memset(by_core, 0, sizeof(by_core)); + // Every message is counted in flight before its actor's core is read + // (#3090, #3093), as in send_through_core. batch_add sent an actor with + // its own thread elsewhere, so each one here goes through a queue. One + // count per actor in the batch, not per message: a fan-out's batch holds + // a few actors many times each, and each count is a write to a line its + // actor's core is using. + { + ActorBase* seen[32]; + int seen_n[32]; + int nseen = 0; + for (int i = 0; i < count; i++) { + ActorBase* a = g_batch_buffer->actors[i]; + int k = 0; + while (k < nseen && seen[k] != a) k++; + if (k < nseen) { + seen_n[k]++; + } else if (nseen < 32) { + seen[nseen] = a; + seen_n[nseen++] = 1; + } else { + inflight_enter(a, 1); + } + } + for (int k = 0; k < nseen; k++) inflight_enter(seen[k], seen_n[k]); + } + for (int i = 0; i < count; i++) { - int tc = atomic_load_explicit(&g_batch_buffer->actors[i]->assigned_core, memory_order_relaxed); + int tc = atomic_load_explicit(&g_batch_buffer->actors[i]->assigned_core, memory_order_acquire); if (tc < 0 || tc >= num_cores) tc = g_batch_buffer->actors[i]->id % num_cores; targets[i] = tc; by_core[tc]++; @@ -3175,8 +3309,10 @@ void scheduler_send_batch_flush(void) { ); pthread_mutex_unlock(&schedulers[c].foreign_lock); - // Fallback for overflow (rare) - scheduler_send_remote handles its own counting + // Fallback for overflow (rare) - scheduler_send_remote handles its own counting, + // the message in flight included, so this batch's count is given back first. for (int j = enqueued; j < cnt; j++) { + inflight_leave((ActorBase*)sorted_actors[start + j], 1); scheduler_send_remote(sorted_actors[start + j], sorted_msgs[start + j], -1); } @@ -3237,6 +3373,7 @@ ActorBase* scheduler_spawn_actor(int preferred_core, void (*step)(void*), size_t actor->spsc_queue = NULL; // Lazy-allocated by send_buffer_flush atomic_init(&actor->inbox, NULL); atomic_init(&actor->thread_parked, 0); + atomic_init(&actor->inflight, 0); atomic_init(&actor->assigned_core, preferred_core); atomic_init(&actor->migrate_to, -1); atomic_init(&actor->main_thread_only, 0); diff --git a/runtime/scheduler/multicore_scheduler.h b/runtime/scheduler/multicore_scheduler.h index abee62f80..53e182720 100644 --- a/runtime/scheduler/multicore_scheduler.h +++ b/runtime/scheduler/multicore_scheduler.h @@ -173,7 +173,14 @@ struct ActorInbox; /* runtime/actors/aether_actor_inbox.h */ /* 1 while the actor's own thread sleeps in its idle park (#2592): a \ * sender that has just put a message in the inbox and reads 1 wakes \ * the thread. */ \ - atomic_int thread_parked; + atomic_int thread_parked; \ + /* Messages for this actor on their way through a core's queues or a \ + * sender's overflow buffer: counted before the sender reads \ + * assigned_core, uncounted when the message reaches the mailbox \ + * (#3090, #3093). The actor moves to another core only while this is \ + * 0, with AETHER_INFLIGHT_MOVING set for the move, so no message is \ + * left behind on the core it leaves. */ \ + atomic_int inflight; typedef struct { AETHER_ACTOR_BASE_FIELDS @@ -184,6 +191,27 @@ typedef struct { #define AETHER_ACTOR_RELEASED 2 #define AETHER_ACTOR_THREAD_GONE 4 +/* ActorBase.inflight (#3090, #3093): set while the actor's core moves it to + * another; the counts of messages in flight sit below it. */ +#define AETHER_INFLIGHT_MOVING (1 << 30) + +/* A sender about to route `n` messages for `actor` through a core's queues + * counts them first, then reads assigned_core: while the count is not 0 the + * actor stays on its core, so the messages reach it there. During a move it + * waits for the move to end, and then reads the new core. */ +static inline void inflight_enter(ActorBase* actor, int n) { + int v = atomic_fetch_add_explicit(&actor->inflight, n, memory_order_acq_rel); + while (unlikely(v & AETHER_INFLIGHT_MOVING)) { + AETHER_CPU_PAUSE(); + v = atomic_load_explicit(&actor->inflight, memory_order_acquire); + } +} + +/* `n` counted messages reached the actor's mailbox (or were dropped). */ +static inline void inflight_leave(ActorBase* actor, int n) { + atomic_fetch_sub_explicit(&actor->inflight, n, memory_order_release); +} + /* Generated actor structs are declared aligned(64) (codegen_actor.c), one * cache line, so the runtime allocates every actor on this boundary (#2485). */ #define AETHER_ACTOR_ALIGN 64 @@ -295,6 +323,10 @@ void scheduler_wait(void); // Wait for quiescence (all pending messages pro void scheduler_shutdown(void); // Wait + stop + join threads. Call once at program exit. void scheduler_cleanup(void); +/* An actor the caller allocated: zeroed before its first registration + * (calloc, or memset), with its mailbox initialised and its step set. It + * may be registered again after a shutdown; its count of messages in + * flight carries over (#3090). */ int scheduler_register_actor(ActorBase* actor, int preferred_core); void scheduler_deregister_actor(ActorBase* actor); void scheduler_send_local(ActorBase* actor, Message msg); diff --git a/tests/regression/test_actor_pair_fifo.ae b/tests/regression/test_actor_pair_fifo.ae new file mode 100644 index 000000000..7445f5050 --- /dev/null +++ b/tests/regression/test_actor_pair_fifo.ae @@ -0,0 +1,111 @@ +// #3090: the messages one actor sends another arrive in the order it sent +// them (tutorial.md: "Message ordering is guaranteed within a single +// actor"), while the target moves between cores. +// +// Eight senders, spread over the cores, each stream numbered messages to +// four targets at once. A target busy with messages when a send from +// another core reaches it is moved to that core (message-driven +// migration), and its messages still queued on the old core are forwarded +// after it; a sender's later message, sent to the new core directly, could +// overtake an earlier one still on its way. Each target checks that every +// sender's numbers arrive one after another. + +message Go { rounds: int } +message Msg { from: int, seq: int } + +actor Target { + state int[8] next + state long out_of_order = 0 + state long received = 0 + + receive { + Msg(from, seq) -> { + if seq != next[from] { + out_of_order = out_of_order + 1 + } + next[from] = seq + 1 + received = received + 1 + } + } +} + +actor Sender { + state who = 0 + state t0 = 0 + state t1 = 0 + state t2 = 0 + state t3 = 0 + state seq = 0 + + receive { + Go(rounds) -> { + i = 0 + while i < rounds { + t0 ! Msg { from: who, seq: seq } + t1 ! Msg { from: who, seq: seq } + t2 ! Msg { from: who, seq: seq } + t3 ! Msg { from: who, seq: seq } + seq = seq + 1 + i = i + 1 + } + } + } +} + +main() { + println("=== per-pair message order under migration (#3090) ===") + + t0 = spawn(Target()) + t1 = spawn(Target()) + t2 = spawn(Target()) + t3 = spawn(Target()) + s0 = spawn(Sender()) + s1 = spawn(Sender()) + s2 = spawn(Sender()) + s3 = spawn(Sender()) + s4 = spawn(Sender()) + s5 = spawn(Sender()) + s6 = spawn(Sender()) + s7 = spawn(Sender()) + senders = [s0, s1, s2, s3, s4, s5, s6, s7] + + i = 0 + while i < 8 { + s = senders[i] + s.who = i + s.t0 = t0 + s.t1 = t1 + s.t2 = t2 + s.t3 = t3 + i = i + 1 + } + + // Waves, so the targets idle, move and fill up again more than once. + rounds = 20000 + waves = 5 + w = 0 + while w < waves { + // Each wave continues every stream from where the last one ended. + i = 0 + while i < 8 { + senders[i] ! Go { rounds: rounds } + i = i + 1 + } + wait_for_idle() + w = w + 1 + } + + long received = t0.received + t1.received + t2.received + t3.received + long out_of_order = t0.out_of_order + t1.out_of_order + t2.out_of_order + t3.out_of_order + long expected = 8 * 4 * rounds * waves + println("received ${received} of ${expected}, out of order ${out_of_order}") + if received != expected || out_of_order != 0 { + println(" FAIL messages were lost or arrived out of order") + println("") + println("1 FAILURE(S)") + return 1 + } + println(" PASS every sender's messages arrived in order") + println("") + println("All PASS") +} diff --git a/tests/runtime/bench_scheduler.c b/tests/runtime/bench_scheduler.c index 9695485d2..61d05b0e6 100644 --- a/tests/runtime/bench_scheduler.c +++ b/tests/runtime/bench_scheduler.c @@ -48,26 +48,15 @@ typedef struct { long total_latency_us; } BenchActor; +// One message per call, the step contract (docs/c-embedding.md): the +// scheduler counts every call as one message processed. void bench_actor_step(BenchActor* self) { Message msg; - int batch_count = 0; - - while (mailbox_receive(&self->mailbox, &msg)) { + if (mailbox_receive(&self->mailbox, &msg)) { self->count_local++; // Plain increment — avoids the atomic-RMW cost on the hot path - batch_count++; - - // Publish every 64 messages for cross-thread visibility - if (batch_count >= 64) { - atomic_store(&self->count_visible, self->count_local); - batch_count = 0; - } + // Published with a plain store, not a read-modify-write. + atomic_store_explicit(&self->count_visible, self->count_local, memory_order_release); } - - // Final publish - if (batch_count > 0) { - atomic_store(&self->count_visible, self->count_local); - } - atomic_store_explicit(&self->active, (self->mailbox.count > 0), memory_order_relaxed); } diff --git a/tests/runtime/bench_zerocopy.c b/tests/runtime/bench_zerocopy.c index 1651c22f1..f2624b9de 100644 --- a/tests/runtime/bench_zerocopy.c +++ b/tests/runtime/bench_zerocopy.c @@ -33,7 +33,7 @@ typedef struct { void bench_actor_step(BenchActor* self) { Message msg; - while (mailbox_receive(&self->mailbox, &msg)) { + if (mailbox_receive(&self->mailbox, &msg)) { self->received_count++; if (msg.zerocopy.owned && msg.zerocopy.data) { self->total_bytes += msg.zerocopy.size; diff --git a/tests/runtime/test_scheduler.c b/tests/runtime/test_scheduler.c index 153ee4bb7..9ff0b1fa7 100644 --- a/tests/runtime/test_scheduler.c +++ b/tests/runtime/test_scheduler.c @@ -53,9 +53,15 @@ typedef struct { // Actor Step Functions // ============================================================================ +// Each takes one message per call, the step contract (docs/c-embedding.md): +// the scheduler counts every call as one message processed, so a step that +// drained its mailbox left scheduler_wait() counting messages as never +// processed. These did, and passed only while every step found one message +// waiting; delivered a run at a time (#3090), a mailbox holds several. + void counter_step(CounterActor* self) { Message msg; - while (mailbox_receive(&self->mailbox, &msg)) { + if (mailbox_receive(&self->mailbox, &msg)) { atomic_fetch_add(&self->count, 1); atomic_store(&self->last_value, msg.payload_int); } @@ -64,7 +70,7 @@ void counter_step(CounterActor* self) { void order_step(OrderActor* self) { Message msg; - while (mailbox_receive(&self->mailbox, &msg)) { + if (mailbox_receive(&self->mailbox, &msg)) { int idx = atomic_load(&self->count); if (idx < 1000) { atomic_store(&self->received[idx], msg.payload_int); @@ -89,7 +95,7 @@ typedef struct { void pingpong_step(PingPongActor* self) { Message msg; - while (mailbox_receive(&self->mailbox, &msg)) { + if (mailbox_receive(&self->mailbox, &msg)) { if (msg.type == PINGPONG_PING) { atomic_fetch_add(&self->pings_received, 1); PingPongActor* sender = (PingPongActor*)msg.payload_ptr; @@ -551,7 +557,7 @@ typedef struct { static void echo_step(void* self) { EchoActor* a = (EchoActor*)self; Message msg; - while (mailbox_receive(&a->mailbox, &msg)) { + if (mailbox_receive(&a->mailbox, &msg)) { if (a->victim) scheduler_release_actor(a->victim); atomic_fetch_add(&a->count, 1); } @@ -751,6 +757,52 @@ void test_scheduler_late_send_after_release(void) { scheduler_cleanup(); } +// #3090: a released actor's block is not handed to a new actor while a +// message sent to it before the release is still in a core's queue. The +// scheduler is initialised but not started, so the messages wait there, +// counted in the actor's `inflight`; the actor is released and reclaimed, +// and an actor of the same size is spawned. Given the old block, it took +// those messages as its own once the cores ran, and their uncounts took its +// count below zero, which reads as a move in progress. +void test_scheduler_reuse_waits_for_messages_in_flight(void) { + scheduler_init(2); + // No main-thread mode, which would run the sends below on this thread, + // and with it no spawn that starts the cores: they start when asked. + int inline_was = atomic_load(&g_aether_config.inline_mode_disabled); + atomic_store(&g_aether_config.inline_mode_disabled, 1); + CounterActor* a = (CounterActor*)scheduler_spawn_actor(1, (void (*)(void*))counter_step, + sizeof(CounterActor)); + ASSERT_NOT_NULL(a); + atomic_init(&a->count, 0); + for (int i = 0; i < 10; i++) { + scheduler_send_remote((ActorBase*)a, message_create_simple(1, 0, i), -1); + } + int counted = atomic_load(&a->inflight); + scheduler_release_actor((ActorBase*)a); + int pending = scheduler_reclaim_released(); + + CounterActor* b = (CounterActor*)scheduler_spawn_actor(1, (void (*)(void*))counter_step, + sizeof(CounterActor)); + int fresh = b != NULL && b != a; + if (b) atomic_init(&b->count, 0); + scheduler_start(); + scheduler_wait(); // returns: the ten were dropped and credited + int stolen = b ? atomic_load(&b->count) : -1; + int b_inflight = b ? atomic_load(&b->inflight) : -1; + int a_inflight = atomic_load(&a->inflight); + + scheduler_shutdown(); + if (b) scheduler_release_actor((ActorBase*)b); + atomic_store(&g_aether_config.inline_mode_disabled, inline_was); + scheduler_cleanup(); + ASSERT_EQ(10, counted); + ASSERT_EQ(0, pending); + ASSERT_TRUE(fresh); // the old block still had messages on their way + ASSERT_EQ(0, stolen); // none of them reached the new actor + ASSERT_EQ(0, b_inflight); + ASSERT_EQ(0, a_inflight); // dropped against the released block, uncounted +} + void test_scheduler_basic_messaging(void) { scheduler_init(2); @@ -1441,7 +1493,7 @@ static void ordered_step(void* self) { static void burst_step(void* self) { BurstActor* b = (BurstActor*)self; Message msg; - while (mailbox_receive(&b->mailbox, &msg)) { + if (mailbox_receive(&b->mailbox, &msg)) { // On the core thread, to an actor of the same core. for (int i = 0; i < ORDER_EACH; i++) { scheduler_send_local(b->target, message_create_simple(2, 3, i)); @@ -1534,6 +1586,7 @@ void register_scheduler_tests(void) { register_test_with_category("Scheduler batch send wakes parked cores", test_scheduler_batch_wakes_parked_cores, TEST_CATEGORY_RUNTIME); register_test_with_category("Scheduler actor thread inbox keeps every sender's order", test_scheduler_actor_thread_inbox_keeps_order, TEST_CATEGORY_RUNTIME); register_test_with_category("Scheduler drops a send to a released actor", test_scheduler_late_send_after_release, TEST_CATEGORY_RUNTIME); + register_test_with_category("Scheduler reuses no block with messages on their way", test_scheduler_reuse_waits_for_messages_in_flight, TEST_CATEGORY_RUNTIME); register_test_with_category("Scheduler basic messaging", test_scheduler_basic_messaging, TEST_CATEGORY_RUNTIME); register_test_with_category("Scheduler message ordering", test_scheduler_message_ordering, TEST_CATEGORY_RUNTIME); register_test_with_category("Scheduler cross-core messaging", test_scheduler_cross_core, TEST_CATEGORY_RUNTIME); diff --git a/tests/runtime/test_scheduler_stress.c b/tests/runtime/test_scheduler_stress.c index 4e1a90c1e..63769fcff 100644 --- a/tests/runtime/test_scheduler_stress.c +++ b/tests/runtime/test_scheduler_stress.c @@ -35,10 +35,12 @@ typedef struct { atomic_int errors; } StressActor; +// One message per call, the step contract (docs/c-embedding.md): the +// scheduler counts every call as one message processed. This one drained up +// to 100, and took the 101st out of the mailbox without counting it. void stress_actor_step(StressActor* self) { Message msg; - int batch = 0; - while (mailbox_receive(&self->mailbox, &msg) && batch++ < 100) { + if (mailbox_receive(&self->mailbox, &msg)) { atomic_fetch_add(&self->count, 1); } atomic_store_explicit(&self->active, (self->mailbox.count > 0), memory_order_relaxed); @@ -57,7 +59,7 @@ typedef struct { static void order_step(OrderActor* self) { Message msg; - while (mailbox_receive(&self->mailbox, &msg)) { + if (mailbox_receive(&self->mailbox, &msg)) { if (msg.payload_int && self->last_seq >= 0 && msg.payload_int != self->last_seq + 1) { atomic_fetch_add(&self->out_of_order, 1); } @@ -79,7 +81,7 @@ typedef struct { static void cascade_step(CascadeActor* self) { Message msg; - while (mailbox_receive(&self->mailbox, &msg)) { + if (mailbox_receive(&self->mailbox, &msg)) { atomic_fetch_add(&self->count, 1); // Forward to next actor diff --git a/tests/runtime/test_worksteal_race.c b/tests/runtime/test_worksteal_race.c index 88ecc30f4..04e6b2f1e 100644 --- a/tests/runtime/test_worksteal_race.c +++ b/tests/runtime/test_worksteal_race.c @@ -28,7 +28,7 @@ typedef struct { static void wsteal_step(WStealActor* self) { Message msg; - while (mailbox_receive(&self->mailbox, &msg)) { + if (mailbox_receive(&self->mailbox, &msg)) { // one per call (c-embedding.md) atomic_fetch_add(&self->count, 1); } atomic_store_explicit(&self->active, (self->mailbox.count > 0), memory_order_relaxed); From 0a41ce009dedff77d6a0743de3185183b19e8a53 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Wed, 21 Oct 2026 20:02:04 -0300 Subject: [PATCH 03/50] Every extern agrees with its C definition on each width, checked in CI (#3097) An extern without @c_import gets a C prototype the compiler writes from its Aether types (long is int64_t, int is int, float is double, bool is int). The C that defines the function is compiled on its own, so no C compiler sees the two side by side, and where they disagree the call is made with the wrong width. #3097 named the C `long` case (32 bits on Windows, 64 elsewhere). A check over the whole tree found more. tests/extern_widths/check.ae, in Aether, reads every .ae and .c file under std, contrib, runtime, tests, examples, tools, lsp and benchmarks: - For each C file, it takes every function defined at file scope, its result and argument types reduced to a width class. Comments, literals and preprocessor lines are skipped, braces counted, and macro calls before a definition dropped. - For each extern, it pairs it with the C definitions of the symbol it binds (@extern("sym") included): those in its own directory when that defines it, else the shared runtime, std and contrib. Every definition is checked, so each #ifdef branch counts. - It reports any slot whose widths differ: arity, a result Aether reads that C does not return, and any plain C `long` in a bound signature, since no Aether type has its width on every platform. - Not checked: externs marked @c_import (the C compiler checks those against the header), externs with no C here (libc, system and vendored libraries), and types it cannot size (typedefs, structs by value). A pointer-sized C integer counts as 64 bits, since every target that builds the bound C is 64-bit. `make check-extern-widths` builds it and runs a self-test first: over tests/extern_widths/fixture, where it must report exactly the fixture's 11 mismatches (one of each kind, including an @extern alias and an #ifdef branch) and none of its agreeing definitions, macro-made ones, statics, or definitions in comments and strings. Then it runs over the tree. `make ci` runs it after check-tests. The whole tree takes 0.6 s: 4034 externs, 4557 C definitions. It found 55 externs that disagreed with their C (88 counting each #ifdef branch). All are fixed: - A C `long`, cut to 32 bits on Windows: aether_observe, aether_observe_fields and aether_unobserve's token (the runtime's tokens are int64_t throughout, the C test too); aether_tracking_bytes (and its sequence numbers, printed with %lld); the Lua host's lua_vm_set_global_int, lua_vm_result_int, lua_arg_int, lua_push_int, lua_table_seti_int, lua_table_set_int and lua_vm_set_instruction_limit, stubs included. Lua integers are 64-bit, and these are int64_t now. aether_casper_pwd_uid returns int, as std.casper reads it. getrusage_max_rss_kb in eight tests/integration shims returns int, as their probes read it. - An Aether `int` for a C size_t or uint64_t: the upper half of a 32-bit argument is unspecified on x86-64 and AArch64, so the callee could read garbage, such as the rest of a 64-bit value the int was cut from. Now `long`: - string_new_with_length's length, in std.string, std.brotli, std.cryptography, std.encoding, std.fs, std.http, std.lzf, std.zlib, std.zstd, contrib.avcodec, contrib.sqlite and a test probe. Also in the compiler's prelude (codegen.c, two declarations, int64_t), with the casts at its uses (the take of a C string, the actor-message string copy in codegen_expr.c). All of these share one prototype in a program, so they change together. - std.alloc's sizes and std.arena's. arena.used and arena.size return `long`, and docs/stdlib-reference.md says so. - Every contrib host's *_run_sandboxed_with_map map token (aether x2, duktape, factor, go x2, js, lua, perl, python, racket, rhombus, ruby). - aether_set_caller's count in the caller_info test. - An int argument widens to a long parameter at a call, so no caller changes. - tinygo_call_dynamic's `char return_kind` is an int, the width the extern passes. Found on the way: the Lua host's lua_push_double tested |v| against (long)v, so a negative whole Double never matched and went to Lua as a string. It compares v now, and checks the range before converting, where a double past 2^63 was undefined. docs/c-interop.md: the type table gains `long` -> int64_t, and a note says widths must agree, that a C size_t or uint64_t is a `long`, and why a bound C function must not use plain `long`. Checked: - make check-extern-widths: the self-test reports its 11, the tree 0. - make test (593) passes, the observe tests with int64_t tokens included. - make test-ae passes (RESULT). - The POSIX side compiles with -Wall -Wextra and no warning (MSYS2 gcc -fsyntax-only), compiler included. - Not built here: contrib/host/lua, tinygo and avcodec (no Lua, libffi or FFmpeg on this machine). --- Makefile | 23 + compiler/codegen/codegen.c | 9 +- compiler/codegen/codegen_expr.c | 2 +- contrib/avcodec/module.ae | 2 +- contrib/host/aether/module.ae | 4 +- contrib/host/duktape/module.ae | 2 +- contrib/host/factor/module.ae | 2 +- contrib/host/go/module.ae | 4 +- contrib/host/js/module.ae | 2 +- contrib/host/lua/aether_host_lua.c | 43 +- contrib/host/lua/aether_host_lua.h | 14 +- contrib/host/lua/module.ae | 2 +- contrib/host/perl/module.ae | 2 +- contrib/host/python/module.ae | 2 +- contrib/host/racket/module.ae | 2 +- contrib/host/rhombus/module.ae | 2 +- contrib/host/ruby/module.ae | 2 +- contrib/host/tinygo/aether_host_tinygo.c | 4 +- contrib/host/tinygo/aether_host_tinygo.h | 5 +- contrib/sqlite/module.ae | 2 +- docs/c-interop.md | 3 + docs/stdlib-reference.md | 4 +- ...261021T224648Z-fixed-3097-extern-widths.md | 20 + runtime/aether_observe.c | 14 +- runtime/aether_observe.h | 8 +- std/alloc/module.ae | 6 +- std/arena/module.ae | 14 +- std/brotli/module.ae | 2 +- std/casper/aether_casper.c | 6 +- std/casper/aether_casper.h | 2 +- std/cryptography/module.ae | 2 +- std/encoding/module.ae | 2 +- std/fs/module.ae | 2 +- std/http/module.ae | 2 +- std/lzf/module.ae | 2 +- std/string/module.ae | 2 +- std/tracking/aether_tracking.c | 14 +- std/tracking/aether_tracking.h | 3 +- std/zlib/module.ae | 2 +- std/zstd/module.ae | 2 +- tests/extern_widths/check.ae | 917 ++++++++++++++++++ tests/extern_widths/fixture/widths.ae | 41 + tests/extern_widths/fixture/widths.c | 59 ++ .../bytes_finish_return_no_leak/shim.c | 10 +- .../caller_info/caller_info_inproc.ae | 2 +- .../heap_leak_cross_fn_recursion/shim.c | 10 +- .../heap_leak_interp_as_arg/shim.c | 10 +- .../heap_tracker_return_escape_no_leak/shim.c | 10 +- tests/integration/std_strbuilder_csv/shim.c | 8 +- .../integration/std_strbuilder_no_leak/shim.c | 8 +- .../string_new_with_length_no_leak/probe.ae | 2 +- .../string_new_with_length_no_leak/shim.c | 8 +- .../string_substring_n_no_leak/shim.c | 8 +- tests/runtime/test_runtime_observe.c | 32 +- 54 files changed, 1219 insertions(+), 148 deletions(-) create mode 100644 new_changelogs/20261021T224648Z-fixed-3097-extern-widths.md create mode 100644 tests/extern_widths/check.ae create mode 100644 tests/extern_widths/fixture/widths.ae create mode 100644 tests/extern_widths/fixture/widths.c diff --git a/Makefile b/Makefile index 748ac4d14..00f93911c 100644 --- a/Makefile +++ b/Makefile @@ -2800,6 +2800,7 @@ help: @echo " make check-standalone - Compile every standalone C main (benches, demos)" @echo " make check-docs - Compile the documentation's complete examples" @echo " make check-tests - The test suite can fail: verdicts reach the exit code, prune list is consistent, every std module has tests, listeners bind loopback, shell tests end background programs through server_jobs.sh, test programs write temp files under names of their own" + @echo " make check-extern-widths - Every extern bound to C in this tree agrees with the C definition on each width; no plain C long in a bound signature" @echo " make check-contrib-modules - Type-check every non-host contrib module" @echo " make check-changelog - Validate changelog fragments, catch a release-fold" @echo " make add-changelog - Write a new_changelogs/ fragment (SECTION=, SLUG=)" @@ -2990,6 +2991,7 @@ ci: clean @$(CI_STAGE) 900 check-standalone $(MAKE) check-standalone @$(CI_STAGE) 900 check-docs $(MAKE) check-docs @$(CI_STAGE) 900 check-tests $(MAKE) check-tests + @$(CI_STAGE) 900 check-extern-widths $(MAKE) check-extern-widths @echo "" @echo "[5/11] Running .ae integration tests..." @$(MAKE) test-ae @@ -3051,6 +3053,27 @@ ownership-grid ownership-grid-check: compiler ae stdlib NPROC=$(NPROC) ./$(OWNERSHIP_GRID_RUNNER) \ $(if $(filter ownership-grid-check,$@),--check) $(OWNERSHIP_GRID_ARGS) +# ----------------------------------------------------------------- +# Extern widths (#3097): every extern bound to a C function of this tree +# agrees with the C definition on the width of each argument and of the +# result, and no bound C signature uses a plain `long` (32 bits on Windows, +# 64 elsewhere). No C compiler sees an extern's prototype beside the C +# definition, so nothing else catches a disagreement: the call is made with +# the wrong width. The checker is Aether (tests/extern_widths/check.ae). Its +# self-test first: the fixture holds exactly 11 mismatches, one of each +# kind, among definitions that agree. +# ----------------------------------------------------------------- +EXTERN_WIDTHS_CHECKER := $(BUILD_DIR)/extern_widths/check$(EXE_EXT) +EXTERN_WIDTHS_DIRS := std contrib runtime tests examples tools lsp benchmarks +.PHONY: check-extern-widths +check-extern-widths: compiler ae stdlib + @mkdir -p $(BUILD_DIR)/extern_widths + @./$(BUILD_DIR)/ae$(EXE_EXT) build tests/extern_widths/check.ae -o $(EXTERN_WIDTHS_CHECKER) + @./$(EXTERN_WIDTHS_CHECKER) --expect 11 tests/extern_widths/fixture \ + > $(BUILD_DIR)/extern_widths/self_test.log 2>&1 || \ + { cat $(BUILD_DIR)/extern_widths/self_test.log; exit 1; } + @./$(EXTERN_WIDTHS_CHECKER) --exclude tests/extern_widths/fixture $(EXTERN_WIDTHS_DIRS) + # The C a program gets when the C compiler is not GCC-compatible # (AETHER_GCC_COMPAT 0, which is what MSVC compiles): strict C11 under gcc, # and under cl.exe too when it is on PATH (#2725). CI runs the gcc half on the diff --git a/compiler/codegen/codegen.c b/compiler/codegen/codegen.c index d398a7d39..76d4d9085 100644 --- a/compiler/codegen/codegen.c +++ b/compiler/codegen/codegen.c @@ -8639,7 +8639,7 @@ void generate_program(CodeGenerator* gen, ASTNode* program) { print_line(gen, "#include "); print_line(gen, "#include "); print_line(gen, "extern void* aether_caps_malloc(size_t bytes);"); - print_line(gen, "extern const char* string_new_with_length(void* data, int length);"); + print_line(gen, "extern const char* string_new_with_length(void* data, int64_t length);"); print_line(gen, "extern void string_retain(const char*);"); print_line(gen, "extern void aether_string_publish(const void*);"); /* The header of a counted string, as the runtime lays it out. A string @@ -8664,7 +8664,7 @@ void generate_program(CodeGenerator* gen, ASTNode* program) { (unsigned)AETHER_STRING_SHARED_BIT); print_line(gen, " return s;"); print_line(gen, " }"); - print_line(gen, " return string_new_with_length((void*)s, (int)strlen(s));"); + print_line(gen, " return string_new_with_length((void*)s, (int64_t)strlen(s));"); print_line(gen, "}"); /* A string handed out as a `ptr` result: a pinned string (a literal, a * fault's name) is its C characters, as a literal used as a `ptr` is @@ -9003,8 +9003,9 @@ void generate_program(CodeGenerator* gen, ASTNode* program) { * the duplicates are tolerated by C: no conflicting-types error. * string_new_with_length is declared as the std modules' extern is * (a `ptr` buffer, `-> string @heap`: `const char*` from `void*`, #3018, - * #3030) for the same reason. */ - print_line(gen, "extern const char* string_new_with_length(void* data, int length);"); + * #3030) for the same reason, with a `long` length: the runtime takes a + * size_t, which an `int` filled only half of (#3097). */ + print_line(gen, "extern const char* string_new_with_length(void* data, int64_t length);"); /* Issue #467: list / map heap-string-value owning-add helpers. * Prototypes emitted here so codegen can route `list.add(l, * heap_str)` / `map.put(m, k, heap_str)` calls to these symbols diff --git a/compiler/codegen/codegen_expr.c b/compiler/codegen/codegen_expr.c index fe11f4b22..37d703ee3 100644 --- a/compiler/codegen/codegen_expr.c +++ b/compiler/codegen/codegen_expr.c @@ -5355,7 +5355,7 @@ void emit_message_string_copy(CodeGenerator* gen, const char* lv, ASTNode* init) is_heap_string_expr(gen, init); fprintf(gen->output, "if (%s) { const char* _mt = %s; size_t _ml = aether_string_length(_mt); " - "%s = (const char*)string_new_with_length((void*)aether_string_data(_mt), (int)_ml); ", + "%s = (const char*)string_new_with_length((void*)aether_string_data(_mt), (int64_t)_ml); ", lv, lv, lv); if (owned_temp) fprintf(gen->output, "aether_heap_str_free(_mt); "); fprintf(gen->output, "} "); diff --git a/contrib/avcodec/module.ae b/contrib/avcodec/module.ae index dfb1acc39..5ea9ed233 100644 --- a/contrib/avcodec/module.ae +++ b/contrib/avcodec/module.ae @@ -74,7 +74,7 @@ extern avc_audio_get_pcm_length() -> int extern avc_audio_sample_rate() -> int extern avc_audio_channels() -> int extern avc_audio_release() -extern string_new_with_length(data: ptr, length: int) -> string @heap +extern string_new_with_length(data: ptr, length: long) -> string @heap // Open a media source. `want_w`/`want_h` <= 0 means "source size"; anything // else scales during the RGBA conversion, so resizing costs no extra pass. diff --git a/contrib/host/aether/module.ae b/contrib/host/aether/module.ae index 599bea07a..7ec086b77 100644 --- a/contrib/host/aether/module.ae +++ b/contrib/host/aether/module.ae @@ -51,9 +51,9 @@ extern aether_host_run_script_sandboxed(perms: sandbox.Grants, script_path: stri // via aether_map_get/aether_map_put. The map is serialized to // shared memory before launch and read back after the child exits. // Returns: child exit code (0 = success). -extern aether_host_run_sandboxed_with_map(perms: sandbox.Grants, binary_path: string, map_token: int) -> int +extern aether_host_run_sandboxed_with_map(perms: sandbox.Grants, binary_path: string, map_token: long) -> int extern aether_host_run_script_sandboxed_with_map( - perms: sandbox.Grants, script_path: string, map_token: int) -> int + perms: sandbox.Grants, script_path: string, map_token: long) -> int // Run without sandbox (full access). Still a subprocess — useful for // testing or trusted scripts that need unrestricted access. diff --git a/contrib/host/duktape/module.ae b/contrib/host/duktape/module.ae index 103c19e1d..71497fa21 100644 --- a/contrib/host/duktape/module.ae +++ b/contrib/host/duktape/module.ae @@ -25,7 +25,7 @@ exports ( ) extern duktape_run_sandboxed(perms: sandbox.Grants, code: string) -> int -extern duktape_run_sandboxed_with_map(perms: sandbox.Grants, code: string, map_token: int) -> int +extern duktape_run_sandboxed_with_map(perms: sandbox.Grants, code: string, map_token: long) -> int extern duktape_run(code: string) -> int extern duktape_init() -> int extern duktape_finalize() diff --git a/contrib/host/factor/module.ae b/contrib/host/factor/module.ae index dee1e5ec8..430b2fe6f 100644 --- a/contrib/host/factor/module.ae +++ b/contrib/host/factor/module.ae @@ -32,7 +32,7 @@ extern factor_run_sandboxed(perms: sandbox.Grants, code: string) -> int // First-class shared map: the Factor script reads/writes the Aether-owned // shared map live via FFI words aether-map-get / aether-map-put, so keys the // script discovers at runtime come back too. Enumerate with map.keys(map). -extern factor_run_sandboxed_with_map(perms: sandbox.Grants, code: string, map_token: int) -> int +extern factor_run_sandboxed_with_map(perms: sandbox.Grants, code: string, map_token: long) -> int extern factor_run(code: string) -> int // Evaluate `code` in the persistent VM; returns its captured output as a // string ("" on failure). State persists across calls. The string is a diff --git a/contrib/host/go/module.ae b/contrib/host/go/module.ae index cd75ef0b4..d623dc500 100644 --- a/contrib/host/go/module.ae +++ b/contrib/host/go/module.ae @@ -54,5 +54,5 @@ extern go_run(binary_path: string) -> int extern go_run_script(script_path: string) -> int // Shared-map variants for typed input/output exchange. -extern go_run_sandboxed_with_map(perms: sandbox.Grants, binary_path: string, map_token: int) -> int -extern go_run_script_sandboxed_with_map(perms: sandbox.Grants, script_path: string, map_token: int) -> int +extern go_run_sandboxed_with_map(perms: sandbox.Grants, binary_path: string, map_token: long) -> int +extern go_run_script_sandboxed_with_map(perms: sandbox.Grants, script_path: string, map_token: long) -> int diff --git a/contrib/host/js/module.ae b/contrib/host/js/module.ae index bd1c2217c..aff080b0f 100644 --- a/contrib/host/js/module.ae +++ b/contrib/host/js/module.ae @@ -25,7 +25,7 @@ exports ( ) @extern("duktape_run_sandboxed") js_run_sandboxed(perms: sandbox.Grants, code: string) -> int -@extern("duktape_run_sandboxed_with_map") js_run_sandboxed_with_map(perms: sandbox.Grants, code: string, map_token: int) -> int +@extern("duktape_run_sandboxed_with_map") js_run_sandboxed_with_map(perms: sandbox.Grants, code: string, map_token: long) -> int @extern("duktape_run") js_run(code: string) -> int @extern("duktape_init") js_init() -> int @extern("duktape_finalize") js_finalize() diff --git a/contrib/host/lua/aether_host_lua.c b/contrib/host/lua/aether_host_lua.c index 8cc637541..be824c748 100644 --- a/contrib/host/lua/aether_host_lua.c +++ b/contrib/host/lua/aether_host_lua.c @@ -481,7 +481,7 @@ void lua_vm_set_global_str(void* vm, const char* name, const char* value) { g_lua.lua_setglobal(s, name); } -void lua_vm_set_global_int(void* vm, const char* name, long value) { +void lua_vm_set_global_int(void* vm, const char* name, int64_t value) { if (!vm || !name) return; lua_State* s = (lua_State*)vm; g_lua.lua_pushinteger(s, (lua_Integer)value); @@ -532,10 +532,10 @@ int lua_vm_result_type(void* vm) { if (!vm) return AE_LUA_TNIL; return ae_lua_tag_at((lua_State*)vm, -1); } -long lua_vm_result_int(void* vm) { +int64_t lua_vm_result_int(void* vm) { if (!vm) return 0; int isnum = 0; - return (long)g_lua.lua_tointegerx((lua_State*)vm, -1, &isnum); + return (int64_t)g_lua.lua_tointegerx((lua_State*)vm, -1, &isnum); } const char* lua_vm_result_str(void* vm) { if (!vm) return ""; @@ -567,16 +567,16 @@ const char* lua_arg_str(void* vm, int i) { const char* r = g_lua.lua_tolstring((lua_State*)vm, i, NULL); return r ? r : ""; } -long lua_arg_int(void* vm, int i) { +int64_t lua_arg_int(void* vm, int i) { if (!vm) return 0; int isnum = 0; - return (long)g_lua.lua_tointegerx((lua_State*)vm, i, &isnum); + return (int64_t)g_lua.lua_tointegerx((lua_State*)vm, i, &isnum); } int lua_arg_bool(void* vm, int i) { if (!vm) return 0; return g_lua.lua_toboolean((lua_State*)vm, i); } -void lua_push_int(void* vm, long v) { +void lua_push_int(void* vm, int64_t v) { if (vm) g_lua.lua_pushinteger((lua_State*)vm, (lua_Integer)v); } void lua_push_str(void* vm, const char* v) { @@ -652,7 +652,7 @@ void lua_table_seti_str(void* vm, int i, const char* v) { g_lua.lua_pushstring(s, v ? v : ""); g_lua.lua_seti(s, -2, (lua_Integer)i); } -void lua_table_seti_int(void* vm, int i, long v) { +void lua_table_seti_int(void* vm, int i, int64_t v) { if (!vm || g_tbl_depth <= 0) return; lua_State* s = (lua_State*)vm; g_lua.lua_pushinteger(s, (lua_Integer)v); @@ -672,7 +672,7 @@ void lua_table_set_str(void* vm, const char* key, const char* v) { g_lua.lua_pushstring(s, v ? v : ""); g_lua.lua_setfield(s, -2, key); } -void lua_table_set_int(void* vm, const char* key, long v) { +void lua_table_set_int(void* vm, const char* key, int64_t v) { if (!vm || g_tbl_depth <= 0 || !key) return; lua_State* s = (lua_State*)vm; g_lua.lua_pushinteger(s, (lua_Integer)v); @@ -722,8 +722,11 @@ void lua_push_double(void* vm, double v) { * float push isn't resolved. Most RESP Doubles arrive integral. */ lua_State* s = (lua_State*)vm; double r = v < 0 ? -v : v; - if (r == (double)(long)v) { - g_lua.lua_pushinteger(s, (lua_Integer)(long)v); + /* In range before the conversion: a double past 2^63 does not fit. Then + * `v` itself against its integer, not `r`: |v| never equalled a negative + * integer, and every negative whole Double went out as a string. */ + if (r < 9223372036854775808.0 && v == (double)(int64_t)v) { + g_lua.lua_pushinteger(s, (lua_Integer)(int64_t)v); } else { char buf[40]; snprintf(buf, sizeof(buf), "%.17g", v); @@ -735,8 +738,8 @@ void lua_push_double(void* vm, double v) { // The host arms a count hook; when the budget is hit the hook raises a Lua // error (the script-timeout shape). One global budget per process is enough // for the single-threaded EVAL model; a richer per-VM budget can follow. -static long g_lua_instr_budget = 0; -static long g_lua_instr_used = 0; +static int64_t g_lua_instr_budget = 0; +static int64_t g_lua_instr_used = 0; static void ae_lua_count_hook(lua_State* s, lua_Debug* ar) { (void)ar; @@ -749,7 +752,7 @@ static void ae_lua_count_hook(lua_State* s, lua_Debug* ar) { /* Arm the count hook: fire every `every` VM instructions, abort after * `budget` total. `budget`<=0 disarms. LUA_MASKCOUNT == 1<<3 == 8. */ -void lua_vm_set_instruction_limit(void* vm, long budget, int every) { +void lua_vm_set_instruction_limit(void* vm, int64_t budget, int every) { if (!vm) return; lua_State* s = (lua_State*)vm; g_lua_instr_budget = budget; @@ -781,33 +784,33 @@ void* lua_vm_new(void) { (void)lua_init(); return 0; } void lua_vm_free(void* vm) { (void)vm; } int lua_vm_register(void* vm, const char* n, void* f) { (void)vm; (void)n; (void)f; return -1; } void lua_vm_set_global_str(void* vm, const char* n, const char* v) { (void)vm; (void)n; (void)v; } -void lua_vm_set_global_int(void* vm, const char* n, long v) { (void)vm; (void)n; (void)v; } +void lua_vm_set_global_int(void* vm, const char* n, int64_t v) { (void)vm; (void)n; (void)v; } void lua_vm_set_global_strlist(void* vm, const char* n, void* it) { (void)vm; (void)n; (void)it; } int lua_vm_eval(void* vm, const char* c) { (void)vm; (void)c; return -1; } int lua_vm_result_type(void* vm) { (void)vm; return 0; } -long lua_vm_result_int(void* vm) { (void)vm; return 0; } +int64_t lua_vm_result_int(void* vm) { (void)vm; return 0; } const char* lua_vm_result_str(void* vm) { (void)vm; return ""; } int lua_vm_result_bool(void* vm) { (void)vm; return 0; } void lua_vm_pop_result(void* vm) { (void)vm; } int lua_arg_count(void* vm) { (void)vm; return 0; } int lua_arg_type(void* vm, int i) { (void)vm; (void)i; return 0; } const char* lua_arg_str(void* vm, int i) { (void)vm; (void)i; return ""; } -long lua_arg_int(void* vm, int i) { (void)vm; (void)i; return 0; } +int64_t lua_arg_int(void* vm, int i) { (void)vm; (void)i; return 0; } int lua_arg_bool(void* vm, int i) { (void)vm; (void)i; return 0; } -void lua_push_int(void* vm, long v) { (void)vm; (void)v; } +void lua_push_int(void* vm, int64_t v) { (void)vm; (void)v; } void lua_push_str(void* vm, const char* v) { (void)vm; (void)v; } void lua_push_bool(void* vm, int v) { (void)vm; (void)v; } void lua_push_nil(void* vm) { (void)vm; } void lua_push_tagged_table(void* vm, const char* f, const char* m) { (void)vm; (void)f; (void)m; } int lua_raise_error(void* vm, const char* m) { (void)vm; (void)m; return 0; } -void lua_vm_set_instruction_limit(void* vm, long b, int e) { (void)vm; (void)b; (void)e; } +void lua_vm_set_instruction_limit(void* vm, int64_t b, int e) { (void)vm; (void)b; (void)e; } // #910 table builder — unavailable stubs. void lua_table_begin(void* vm, int a, int r) { (void)vm; (void)a; (void)r; } void lua_table_seti_str(void* vm, int i, const char* v) { (void)vm; (void)i; (void)v; } -void lua_table_seti_int(void* vm, int i, long v) { (void)vm; (void)i; (void)v; } +void lua_table_seti_int(void* vm, int i, int64_t v) { (void)vm; (void)i; (void)v; } void lua_table_seti_bool(void* vm, int i, int v) { (void)vm; (void)i; (void)v; } void lua_table_set_str(void* vm, const char* k, const char* v) { (void)vm; (void)k; (void)v; } -void lua_table_set_int(void* vm, const char* k, long v) { (void)vm; (void)k; (void)v; } +void lua_table_set_int(void* vm, const char* k, int64_t v) { (void)vm; (void)k; (void)v; } void lua_table_set_bool(void* vm, const char* k, int v) { (void)vm; (void)k; (void)v; } void lua_table_end_seti(void* vm, int i) { (void)vm; (void)i; } void lua_table_end_setfield(void* vm, const char* k) { (void)vm; (void)k; } diff --git a/contrib/host/lua/aether_host_lua.h b/contrib/host/lua/aether_host_lua.h index 0ca55ad1f..18f80e280 100644 --- a/contrib/host/lua/aether_host_lua.h +++ b/contrib/host/lua/aether_host_lua.h @@ -14,34 +14,34 @@ void* lua_vm_new(void); void lua_vm_free(void* vm); int lua_vm_register(void* vm, const char* name, void* host_fn); void lua_vm_set_global_str(void* vm, const char* name, const char* value); -void lua_vm_set_global_int(void* vm, const char* name, long value); +void lua_vm_set_global_int(void* vm, const char* name, int64_t value); void lua_vm_set_global_strlist(void* vm, const char* name, void* items); int lua_vm_eval(void* vm, const char* code); int lua_vm_result_type(void* vm); -long lua_vm_result_int(void* vm); +int64_t lua_vm_result_int(void* vm); const char* lua_vm_result_str(void* vm); int lua_vm_result_bool(void* vm); void lua_vm_pop_result(void* vm); int lua_arg_count(void* vm); int lua_arg_type(void* vm, int i); const char* lua_arg_str(void* vm, int i); -long lua_arg_int(void* vm, int i); +int64_t lua_arg_int(void* vm, int i); int lua_arg_bool(void* vm, int i); -void lua_push_int(void* vm, long v); +void lua_push_int(void* vm, int64_t v); void lua_push_str(void* vm, const char* v); void lua_push_bool(void* vm, int v); void lua_push_nil(void* vm); void lua_push_tagged_table(void* vm, const char* field, const char* msg); int lua_raise_error(void* vm, const char* msg); -void lua_vm_set_instruction_limit(void* vm, long budget, int every); +void lua_vm_set_instruction_limit(void* vm, int64_t budget, int every); /* #910 host-callback table builder (RESP multibulk → Lua table). */ void lua_table_begin(void* vm, int narr, int nrec); void lua_table_seti_str(void* vm, int i, const char* v); -void lua_table_seti_int(void* vm, int i, long v); +void lua_table_seti_int(void* vm, int i, int64_t v); void lua_table_seti_bool(void* vm, int i, int v); void lua_table_set_str(void* vm, const char* key, const char* v); -void lua_table_set_int(void* vm, const char* key, long v); +void lua_table_set_int(void* vm, const char* key, int64_t v); void lua_table_set_bool(void* vm, const char* key, int v); void lua_table_end_seti(void* vm, int i); void lua_table_end_setfield(void* vm, const char* key); diff --git a/contrib/host/lua/module.ae b/contrib/host/lua/module.ae index 77ccafae3..d6f9f2414 100644 --- a/contrib/host/lua/module.ae +++ b/contrib/host/lua/module.ae @@ -69,7 +69,7 @@ const LUA_TOTHER = 6 // --- Tier 1: fire-and-forget ----------------------------------------------- extern lua_run_sandboxed(perms: sandbox.Grants, code: string) -> int -extern lua_run_sandboxed_with_map(perms: sandbox.Grants, code: string, map_token: int) -> int +extern lua_run_sandboxed_with_map(perms: sandbox.Grants, code: string, map_token: long) -> int extern lua_run(code: string) -> int extern lua_init() -> int extern lua_finalize() diff --git a/contrib/host/perl/module.ae b/contrib/host/perl/module.ae index a038ef5b1..f1e938f75 100644 --- a/contrib/host/perl/module.ae +++ b/contrib/host/perl/module.ae @@ -31,7 +31,7 @@ exports ( // Canonical `perl.*` shape (matches python.run / lua.run / etc.). @extern("aether_perl_run_sandboxed") perl_run_sandboxed(perms: sandbox.Grants, code: string) -> int -@extern("aether_perl_run_sandboxed_with_map") perl_run_sandboxed_with_map(perms: sandbox.Grants, code: string, map_token: int) -> int +@extern("aether_perl_run_sandboxed_with_map") perl_run_sandboxed_with_map(perms: sandbox.Grants, code: string, map_token: long) -> int @extern("aether_perl_run") perl_run(code: string) -> int @extern("aether_perl_init") perl_init() -> int @extern("aether_perl_finalize") perl_finalize() diff --git a/contrib/host/python/module.ae b/contrib/host/python/module.ae index 6384c8f2d..e9dd4b6af 100644 --- a/contrib/host/python/module.ae +++ b/contrib/host/python/module.ae @@ -24,7 +24,7 @@ exports ( // Run Python code string within a sandbox // Returns: 0 on success, -1 on error extern python_run_sandboxed(perms: sandbox.Grants, code: string) -> int -extern python_run_sandboxed_with_map(perms: sandbox.Grants, code: string, map_token: int) -> int +extern python_run_sandboxed_with_map(perms: sandbox.Grants, code: string, map_token: long) -> int // Run Python code string without sandbox (full access) // Returns: 0 on success, -1 on error diff --git a/contrib/host/racket/module.ae b/contrib/host/racket/module.ae index d007efefb..e261af68a 100644 --- a/contrib/host/racket/module.ae +++ b/contrib/host/racket/module.ae @@ -40,7 +40,7 @@ extern racket_run_sandboxed(perms: sandbox.Grants, code: string) -> int // First-class shared map: the Racket script reads/writes the Aether-owned // shared map live via aether-map-get / aether-map-put, so keys it discovers // at runtime come back too. Enumerate with map.keys(map). -extern racket_run_sandboxed_with_map(perms: sandbox.Grants, code: string, map_token: int) -> int +extern racket_run_sandboxed_with_map(perms: sandbox.Grants, code: string, map_token: long) -> int extern racket_run(code: string) -> int // Evaluate `code` in the persistent VM; returns its captured stdout as a // string ("" on failure). State persists across calls. The string is a diff --git a/contrib/host/rhombus/module.ae b/contrib/host/rhombus/module.ae index 3b0af5d1d..73cecd02f 100644 --- a/contrib/host/rhombus/module.ae +++ b/contrib/host/rhombus/module.ae @@ -48,7 +48,7 @@ extern rhombus_run_sandboxed(perms: sandbox.Grants, code: string) -> int // First-class shared map: the Rhombus script reads/writes the Aether-owned // shared map live via aether-map-get / aether-map-put. Enumerate with // map.keys(map). -extern rhombus_run_sandboxed_with_map(perms: sandbox.Grants, code: string, map_token: int) -> int +extern rhombus_run_sandboxed_with_map(perms: sandbox.Grants, code: string, map_token: long) -> int extern rhombus_run(code: string) -> int // Evaluate `code` in the persistent VM; returns its captured stdout as a // string ("" on failure). State persists across calls. The string is a diff --git a/contrib/host/ruby/module.ae b/contrib/host/ruby/module.ae index a8be4d549..ed3492073 100644 --- a/contrib/host/ruby/module.ae +++ b/contrib/host/ruby/module.ae @@ -21,7 +21,7 @@ exports ( ) extern ruby_run_sandboxed(perms: sandbox.Grants, code: string) -> int -extern ruby_run_sandboxed_with_map(perms: sandbox.Grants, code: string, map_token: int) -> int +extern ruby_run_sandboxed_with_map(perms: sandbox.Grants, code: string, map_token: long) -> int extern ruby_run(code: string) -> int extern ruby_init_host() -> int extern ruby_finalize_host() diff --git a/contrib/host/tinygo/aether_host_tinygo.c b/contrib/host/tinygo/aether_host_tinygo.c index 42691ad6c..5024f7c8d 100644 --- a/contrib/host/tinygo/aether_host_tinygo.c +++ b/contrib/host/tinygo/aether_host_tinygo.c @@ -215,7 +215,7 @@ static ffi_type* kind_to_ffi(char k) { } int tinygo_call_dynamic(void* handle, const char* sym, - char return_kind, const char* arg_kinds, + int return_kind, const char* arg_kinds, void** args, void* result_out) { void* fn = tg_resolve(handle, sym); if (!fn) return 0; @@ -264,7 +264,7 @@ int tinygo_call_dynamic(void* handle, const char* sym, #else /* !AETHER_HAS_LIBFFI */ int tinygo_call_dynamic(void* handle, const char* sym, - char return_kind, const char* arg_kinds, + int return_kind, const char* arg_kinds, void** args, void* result_out) { (void)handle; (void)sym; (void)return_kind; (void)arg_kinds; (void)args; (void)result_out; diff --git a/contrib/host/tinygo/aether_host_tinygo.h b/contrib/host/tinygo/aether_host_tinygo.h index 7900d44ab..51629106c 100644 --- a/contrib/host/tinygo/aether_host_tinygo.h +++ b/contrib/host/tinygo/aether_host_tinygo.h @@ -110,14 +110,15 @@ struct AetherString* tinygo_call_str_str_owned (void* h, const char* s, const c // // kinds: a NUL-terminated array of single-character codes // 'v' void 'i' int32 'l' int64 'd' double 's' const char* 'p' void* -// return_kind is one of the same codes. +// return_kind is one of the same codes, passed as an int (the width the +// Aether extern gives it, #3097). // args is an array of argument-value pointers; each pointer points // to a value of the matching kind. The result (int64-sized buffer) // is written into *result_out. // // Returns 1 on success, 0 on failure (consult last_error). int tinygo_call_dynamic(void* handle, const char* sym, - char return_kind, const char* arg_kinds, + int return_kind, const char* arg_kinds, void** args, void* result_out); #endif diff --git a/contrib/sqlite/module.ae b/contrib/sqlite/module.ae index 14e5e6280..ba88c5c7f 100644 --- a/contrib/sqlite/module.ae +++ b/contrib/sqlite/module.ae @@ -252,7 +252,7 @@ extern sqlite_errmsg_raw(db: ptr) -> string // Length-aware AetherString constructor — re-used from std.string. // Lets `column_blob` return a binary-safe AetherString rather than // a strlen-truncated copy. -extern string_new_with_length(data: ptr, length: int) -> string @heap +extern string_new_with_length(data: ptr, length: long) -> string @heap // ---- Go-style wrappers ---- diff --git a/docs/c-interop.md b/docs/c-interop.md index 18fefafd6..72b5f5433 100644 --- a/docs/c-interop.md +++ b/docs/c-interop.md @@ -49,12 +49,15 @@ extern void_function(param: type) // No return type = void | Aether Type | C Type | |-------------|--------| | `int` | `int` | +| `long` | `int64_t` | | `float` | `double` | | `string` | `const char*` | | `bool` | `int` | | `byte` | `unsigned char` | | `ptr` | `void*` | +> **Widths must agree.** The compiler writes an extern's C prototype from these types, and no C compiler sees it beside the C function's own definition, so a mismatch is not an error: the call is made with the wrong width. A C `size_t`, `int64_t` or `uint64_t` is a `long` here, not an `int` (the upper half of a 32-bit argument is unspecified). Do not declare a bound C function with a plain C `long`: it is 32 bits on Windows and 64 elsewhere, so no Aether type matches it everywhere; use `int64_t` or `int`. `make check-extern-widths` checks every extern in this repository against the C that defines it (#3097). An extern marked `@c_import` is checked by the C compiler against the header instead. + > **`byte` mapping note.** `byte` lowers to `unsigned char`, not `uint8_t`. The two are typedef-compatible on every platform Aether targets, but C's strict-aliasing rules give `unsigned char *` an exemption (it can legally alias any type's bytes for read/write); `uint8_t *` does not. Since `byte` is exactly the type used in C extern signatures that scrape bytes from other types' storage (packed binary protocol parsers, NaN-boxing tag readers, on-disk file headers), `unsigned char` is the right choice. > **Binding a C `bool` return: use `byte`, not `bool`.** Aether's `bool` maps to C `int` (above), so declaring a C function that returns C's one-byte `_Bool` as `-> bool` reads a full `int` and picks up three bytes of stack garbage past the result, a success can read back as something like `-255`. Bind a `bool`-returning C function with `-> byte` instead: it reads exactly the one byte the ABI wrote, and `0` vs non-zero tests the same way. diff --git a/docs/stdlib-reference.md b/docs/stdlib-reference.md index 60d8a8285..34cde2bad 100644 --- a/docs/stdlib-reference.md +++ b/docs/stdlib-reference.md @@ -3866,8 +3866,8 @@ main() { - `arena.alloc_aligned(arena, bytes, alignment)` → `ptr` - Same as `alloc` with explicit alignment (must be a power of 2). - `arena.reset(arena)` - Free every allocation in one shot. Pointers become invalid. - `arena.destroy(arena)` - Free the arena and its memory. -- `arena.used(arena)` → `int` - Bytes currently allocated (sum across overflow blocks). -- `arena.size(arena)` → `int` - Total capacity (sum across overflow blocks). +- `arena.used(arena)` → `long` - Bytes currently allocated (sum across overflow blocks). +- `arena.size(arena)` → `long` - Total capacity (sum across overflow blocks). Arenas don't track AetherString refcounts, strings allocated through the regular stdlib still need `release()` (or `defer release(...)`); the arena is for bulk raw allocations that the user controls themselves. Avoid handing arena-allocated pointers to functions that retain them past the next `arena.reset()` or `arena.destroy()`. diff --git a/new_changelogs/20261021T224648Z-fixed-3097-extern-widths.md b/new_changelogs/20261021T224648Z-fixed-3097-extern-widths.md new file mode 100644 index 000000000..21909901f --- /dev/null +++ b/new_changelogs/20261021T224648Z-fixed-3097-extern-widths.md @@ -0,0 +1,20 @@ +- **Every extern agrees with its C definition on the width of each argument + and result, and `make check-extern-widths` keeps it so.** An extern's C + prototype is written by the compiler and never seen beside the C that + defines the function, so where the two disagreed the call went ahead with + the wrong width. The new check, in Aether, found 55 externs that + disagreed with their C (#3097): + - C functions declared with a plain `long`, 32 bits on Windows: the Lua + host's integers, `std.tracking`'s byte count and `std.observe`'s + tokens were cut to 32 bits there. They are `int64_t` now. + - Sizes and tokens that are `size_t` or `uint64_t` in C but were + declared `int`: `string_new_with_length` in 11 modules and in the + compiler's own prelude, `std.alloc` and `std.arena` sizes, and every + contrib host's `*_run_sandboxed_with_map` map token. They are `long` + now. `arena.used` and `arena.size` return `long`. + - A `char` parameter (`tinygo_call_dynamic`) and `std.casper`'s uid, + both now as the extern reads them. + + The check runs in CI. A C `long` in a bound signature fails it, since no + Aether type has that width on every platform. The Lua host also pushes a + negative whole Double as an integer now; it used to go out as a string. diff --git a/runtime/aether_observe.c b/runtime/aether_observe.c index 9d7a7fc44..9ae53d298 100644 --- a/runtime/aether_observe.c +++ b/runtime/aether_observe.c @@ -26,7 +26,7 @@ #define OBS_TOMB ((void*)1) typedef struct { - long token; + int64_t token; AetherObserverClosure cb; int with_field; /* registered through aether_observe_fields (#2299) */ int dead; /* unobserved while a pass on this object was running */ @@ -45,7 +45,7 @@ static Slot* g_slots; static size_t g_cap; static size_t g_used; /* live + tombstones */ static size_t g_live; -static long g_next_token = 1; +static int64_t g_next_token = 1; static int g_total_observers; /* fast-path gate for notify */ /* PTHREAD_MUTEX_INITIALIZER is POSIX-only: the Win32 shim maps @@ -166,7 +166,7 @@ static void obs_sweep_dead(Slot* s) { if (s->count == 0) obs_release_slot(s); } -static long obs_register(void* obj, AetherObserverClosure cb, int with_field) { +static int64_t obs_register(void* obj, AetherObserverClosure cb, int with_field) { if (!obj || !cb.fn) { /* The environment is ours from the call on, rejected or not: the * caller crossed an extern boundary and will not release it. */ @@ -175,7 +175,7 @@ static long obs_register(void* obj, AetherObserverClosure cb, int with_field) { } pthread_mutex_lock(obs_lock()); Slot* s = obs_slot_for(obj); - long token = 0; + int64_t token = 0; if (s) { if (s->count == s->cap) { int new_cap = s->cap ? s->cap * 2 : 4; @@ -199,11 +199,11 @@ static long obs_register(void* obj, AetherObserverClosure cb, int with_field) { return token; } -long aether_observe(void* obj, AetherObserverClosure cb) { +int64_t aether_observe(void* obj, AetherObserverClosure cb) { return obs_register(obj, cb, 0); } -long aether_observe_fields(void* obj, AetherObserverClosure cb) { +int64_t aether_observe_fields(void* obj, AetherObserverClosure cb) { return obs_register(obj, cb, 1); } @@ -224,7 +224,7 @@ static int obs_retire(Slot* s, int idx) { return 1; } -int aether_unobserve(void* obj, long token) { +int aether_unobserve(void* obj, int64_t token) { if (!obj || token <= 0) return 0; int found = 0; pthread_mutex_lock(obs_lock()); diff --git a/runtime/aether_observe.h b/runtime/aether_observe.h index 93ac2cfe4..36ef7cf03 100644 --- a/runtime/aether_observe.h +++ b/runtime/aether_observe.h @@ -15,6 +15,8 @@ #ifndef AETHER_OBSERVE_H #define AETHER_OBSERVE_H +#include + #ifdef __cplusplus extern "C" { #endif @@ -33,18 +35,18 @@ typedef struct { void (*fn)(void); void* env; } AetherObserverClosure; * memory ran out. The closure environment is owned by the runtime from this * call on: released by aether_unobserve / aether_unobserve_all, or at once * when the registration is refused. */ -long aether_observe(void* obj, AetherObserverClosure cb); +int64_t aether_observe(void* obj, AetherObserverClosure cb); /* As aether_observe, for an observer that also takes the changed field: it * is invoked as `fn(env, obj, field)`, with `field` the stored field's index * in declaration order (0 for the first), or AETHER_OBSERVE_ANY_FIELD when * the notification did not name one (#2299). */ -long aether_observe_fields(void* obj, AetherObserverClosure cb); +int64_t aether_observe_fields(void* obj, AetherObserverClosure cb); /* Remove the registration `token` made on `obj`. Returns 1 when it was * found and removed, 0 otherwise. Safe to call from inside the observer * being removed: the environment is released once the current pass ends. */ -int aether_unobserve(void* obj, long token); +int aether_unobserve(void* obj, int64_t token); /* Remove every observer of `obj` (for an object about to be freed). Returns * the number removed. */ diff --git a/std/alloc/module.ae b/std/alloc/module.ae index 971ca1a93..5b0bc12ad 100644 --- a/std/alloc/module.ae +++ b/std/alloc/module.ae @@ -8,9 +8,9 @@ exports( extern aether_allocator_system() -> ptr extern aether_allocator_arena(arena: ptr) -> ptr extern aether_allocator_arena_destroy(a: ptr) -extern aether_allocator_alloc(a: ptr, size: int) -> ptr -extern aether_allocator_realloc(a: ptr, block: ptr, old_size: int, new_size: int) -> ptr -extern aether_allocator_free(a: ptr, block: ptr, size: int) +extern aether_allocator_alloc(a: ptr, size: long) -> ptr +extern aether_allocator_realloc(a: ptr, block: ptr, old_size: long, new_size: long) -> ptr +extern aether_allocator_free(a: ptr, block: ptr, size: long) system() -> ptr { return aether_allocator_system() diff --git a/std/arena/module.ae b/std/arena/module.ae index ea5957805..be34187cb 100644 --- a/std/arena/module.ae +++ b/std/arena/module.ae @@ -33,13 +33,13 @@ exports( // C-style externs, which the wrappers below call (the module exports one // name per operation, #2976). -extern arena_create(size: int) -> ptr -extern arena_alloc(arena: ptr, bytes: int) -> ptr -extern arena_alloc_aligned(arena: ptr, bytes: int, alignment: int) -> ptr +extern arena_create(size: long) -> ptr +extern arena_alloc(arena: ptr, bytes: long) -> ptr +extern arena_alloc_aligned(arena: ptr, bytes: long, alignment: long) -> ptr extern arena_reset(arena: ptr) extern arena_destroy(arena: ptr) -extern arena_get_used(arena: ptr) -> int -extern arena_get_size(arena: ptr) -> int +extern arena_get_used(arena: ptr) -> long +extern arena_get_size(arena: ptr) -> long // Aether-native wrappers. @@ -74,11 +74,11 @@ destroy(arena: ptr) { // Bytes currently allocated from the arena (sum across overflow // blocks). -used(arena: ptr) -> int { +used(arena: ptr) -> long { return arena_get_used(arena) } // Total capacity (sum across overflow blocks). -size(arena: ptr) -> int { +size(arena: ptr) -> long { return arena_get_size(arena) } diff --git a/std/brotli/module.ae b/std/brotli/module.ae index 861a95c3d..34991400a 100644 --- a/std/brotli/module.ae +++ b/std/brotli/module.ae @@ -52,7 +52,7 @@ extern brotli_release_compress() // Length-preserving string constructor from std.string — takes an explicit // byte count so binary payloads with embedded NULs survive the copy out of // the borrowed C buffer into an owned AetherString. -extern string_new_with_length(data: ptr, length: int) -> string @heap +extern string_new_with_length(data: ptr, length: long) -> string @heap // Quality bounds, matching libbrotli's own range. 11 is the library default // and is SLOW; 4..6 is the usual choice for a live HTTP response. diff --git a/std/casper/aether_casper.c b/std/casper/aether_casper.c index 050108a93..29b201fe2 100644 --- a/std/casper/aether_casper.c +++ b/std/casper/aether_casper.c @@ -99,11 +99,11 @@ char* aether_casper_resolve(void* netchan, const char* hostname) { return (char*)string_new(buf); } -long aether_casper_pwd_uid(void* pwdchan, const char* username) { +int aether_casper_pwd_uid(void* pwdchan, const char* username) { if (!pwdchan || !username) return -1; struct passwd* pw = cap_getpwnam((cap_channel_t*)pwdchan, username); if (!pw) return -1; - return (long)pw->pw_uid; + return (int)pw->pw_uid; } char* aether_casper_pwd_home(void* pwdchan, const char* username) { @@ -141,7 +141,7 @@ int aether_casper_available(void) { return 0; } char* aether_casper_resolve(void* n, const char* h) { (void)n; (void)h; return 0; } -long aether_casper_pwd_uid(void* p, const char* u) { +int aether_casper_pwd_uid(void* p, const char* u) { (void)p; (void)u; return -1; } char* aether_casper_pwd_home(void* p, const char* u) { diff --git a/std/casper/aether_casper.h b/std/casper/aether_casper.h index ec0a3996b..e47013681 100644 --- a/std/casper/aether_casper.h +++ b/std/casper/aether_casper.h @@ -60,7 +60,7 @@ char* aether_casper_resolve(void* netchan, const char* hostname); // Look up `username` via the pwd service channel. Returns the numeric // uid, or -1 if the user is not found / on error. -long aether_casper_pwd_uid(void* pwdchan, const char* username); +int aether_casper_pwd_uid(void* pwdchan, const char* username); // Look up `username`'s home directory via the pwd service channel. // Returns a heap string, or NULL on failure. diff --git a/std/cryptography/module.ae b/std/cryptography/module.ae index 3530dc2e8..dffb64a7e 100644 --- a/std/cryptography/module.ae +++ b/std/cryptography/module.ae @@ -97,7 +97,7 @@ extern cryptography_digest_free_raw(handle: ptr) extern malloc(size: int) -> ptr extern free(p: ptr) -extern string_new_with_length(data: ptr, length: int) -> string @heap +extern string_new_with_length(data: ptr, length: long) -> string @heap // Compute the SHA-1 hex digest of the first `length` bytes of `data`. // Returns (digest, "") on success, ("", error) when OpenSSL is diff --git a/std/encoding/module.ae b/std/encoding/module.ae index c11e546ec..c920c58d2 100644 --- a/std/encoding/module.ae +++ b/std/encoding/module.ae @@ -108,7 +108,7 @@ extern cryptography_base64_decode_raw(b64: string) -> int extern cryptography_get_base64_decode() -> ptr extern cryptography_get_base64_decode_length() -> int extern cryptography_release_base64_decode() -extern string_new_with_length(data: ptr, length: int) -> string @heap +extern string_new_with_length(data: ptr, length: long) -> string @heap // Standard-alphabet base64 WITHOUT padding (RFC 4648 §4, the `+/` alphabet; // no trailing `=`). Encodes the bytes of `data`. diff --git a/std/fs/module.ae b/std/fs/module.ae index 4b8e795dd..903151dc8 100644 --- a/std/fs/module.ae +++ b/std/fs/module.ae @@ -413,7 +413,7 @@ extern string_substring(str: string, start: int, end: int) -> string @heap // so binary payloads with embedded NULs survive the copy. Returns a // ref-counted AetherString with the exact byte content — consumers // reading up to `length` see the full file. -extern string_new_with_length(data: ptr, length: int) -> string @heap +extern string_new_with_length(data: ptr, length: long) -> string @heap // ---- Go-style wrappers ---- diff --git a/std/http/module.ae b/std/http/module.ae index 00cbf02ac..de658499b 100644 --- a/std/http/module.ae +++ b/std/http/module.ae @@ -870,7 +870,7 @@ extern http_request_body_complete(req: ptr) -> int extern http_get_request_body_read() -> ptr extern http_get_request_body_read_length() -> int extern http_release_request_body_read() -extern string_new_with_length(data: ptr, length: int) -> string @heap +extern string_new_with_length(data: ptr, length: long) -> string @heap extern http_request_query(req: ptr) -> string // Trusted TCP peer address — the IP `getpeername(2)` reports for the diff --git a/std/lzf/module.ae b/std/lzf/module.ae index a916e5450..41382cee6 100644 --- a/std/lzf/module.ae +++ b/std/lzf/module.ae @@ -33,7 +33,7 @@ extern lzf_get_decompress_bytes() -> ptr extern lzf_get_decompress_length() -> int extern lzf_release_decompress() -extern string_new_with_length(data: ptr, length: int) -> string @heap +extern string_new_with_length(data: ptr, length: long) -> string @heap // A slice with no bound (`p as byte[]`, `.len` -1) has no length. fn unbounded(fname: string) { diff --git a/std/string/module.ae b/std/string/module.ae index 49d5c182d..9352c49dc 100644 --- a/std/string/module.ae +++ b/std/string/module.ae @@ -55,7 +55,7 @@ extern string_from_literal(cstr: string) -> string @heap // like a string's header is copied, not followed (#2301, #3030); a `string` // is passed as its characters, so `length` must not exceed its length // (string.substring(s, 0, n) stops at the end). -extern string_new_with_length(data: ptr, length: int) -> string @heap +extern string_new_with_length(data: ptr, length: long) -> string @heap extern string_empty() -> string @heap // Reference counting. diff --git a/std/tracking/aether_tracking.c b/std/tracking/aether_tracking.c index 137720c76..a60f302a1 100644 --- a/std/tracking/aether_tracking.c +++ b/std/tracking/aether_tracking.c @@ -13,7 +13,7 @@ typedef struct { void* key; size_t size; - long seq; + int64_t seq; } TrackEntry; /* handle is the first member so the returned AetherAllocator* aliases the @@ -26,7 +26,7 @@ typedef struct AetherTracking { size_t live; size_t used; size_t total_bytes; - long seq; + int64_t seq; } AetherTracking; static size_t track_hash(void* p, size_t cap) { @@ -38,7 +38,7 @@ static size_t track_hash(void* p, size_t cap) { } static void track_put_raw(TrackEntry* entries, size_t cap, - void* key, size_t size, long seq) { + void* key, size_t size, int64_t seq) { size_t i = track_hash(key, cap); while (entries[i].key != TRACK_EMPTY) i = (i + 1) & (cap - 1); entries[i].key = key; @@ -137,9 +137,9 @@ int aether_tracking_count(AetherAllocator* t) { return (int)((AetherTracking*)t)->live; } -long aether_tracking_bytes(AetherAllocator* t) { +int64_t aether_tracking_bytes(AetherAllocator* t) { if (!t) return -1; - return (long)((AetherTracking*)t)->total_bytes; + return (int64_t)((AetherTracking*)t)->total_bytes; } int aether_tracking_report(AetherAllocator* t) { @@ -154,8 +154,8 @@ int aether_tracking_report(AetherAllocator* t) { for (size_t i = 0; i < st->cap; i++) { void* k = st->entries[i].key; if (k != TRACK_EMPTY && k != TRACK_TOMB) - fprintf(stderr, " leak #%ld: %zu bytes\n", - st->entries[i].seq, st->entries[i].size); + fprintf(stderr, " leak #%lld: %zu bytes\n", + (long long)st->entries[i].seq, st->entries[i].size); } return (int)st->live; } diff --git a/std/tracking/aether_tracking.h b/std/tracking/aether_tracking.h index 539d40391..489b73316 100644 --- a/std/tracking/aether_tracking.h +++ b/std/tracking/aether_tracking.h @@ -3,12 +3,13 @@ #define AETHER_TRACKING_H #include "../alloc/aether_alloc.h" +#include /* std.tracking (#1049): a leak-detecting allocator wrapper. */ AetherAllocator* aether_tracking_wrap(AetherAllocator* inner); int aether_tracking_count(AetherAllocator* t); -long aether_tracking_bytes(AetherAllocator* t); +int64_t aether_tracking_bytes(AetherAllocator* t); int aether_tracking_report(AetherAllocator* t); void aether_tracking_destroy(AetherAllocator* t); diff --git a/std/zlib/module.ae b/std/zlib/module.ae index 3dbfb63eb..0484df918 100644 --- a/std/zlib/module.ae +++ b/std/zlib/module.ae @@ -84,7 +84,7 @@ extern zlib_release_stream(handle: ptr) // Length-preserving string constructor from std.string — takes an // explicit byte count so binary payloads with embedded NULs survive // the copy out of the TLS buffer into an owned AetherString. -extern string_new_with_length(data: ptr, length: int) -> string @heap +extern string_new_with_length(data: ptr, length: long) -> string @heap // A slice with no bound (`p as byte[]`, `.len` -1) has no length. fn unbounded(fname: string) { diff --git a/std/zstd/module.ae b/std/zstd/module.ae index 847b555b6..bd6420fd0 100644 --- a/std/zstd/module.ae +++ b/std/zstd/module.ae @@ -53,7 +53,7 @@ extern zstd_release_compress() // Length-preserving string constructor from std.string — takes an explicit // byte count so binary payloads with embedded NULs survive the copy out of // the borrowed C buffer into an owned AetherString. -extern string_new_with_length(data: ptr, length: int) -> string @heap +extern string_new_with_length(data: ptr, length: long) -> string @heap // libzstd's own range. 3 is its default and a good general choice; higher // levels cost markedly more time for modest gains. diff --git a/tests/extern_widths/check.ae b/tests/extern_widths/check.ae new file mode 100644 index 000000000..afed69bbb --- /dev/null +++ b/tests/extern_widths/check.ae @@ -0,0 +1,917 @@ +// Every extern an Aether module declares for a C function of this tree must +// agree with that function's C definition on the width of each argument and +// of its result (#3097). +// +// An extern without @c_import gets a prototype the compiler writes from its +// Aether types (`long` is int64_t, `int` is int, `float` is double), and the +// C file that defines the function is compiled on its own, so no C compiler +// ever sees the two side by side. Where they disagree the call is still +// made, with the wrong width: a C function declared with a plain `long` +// takes and returns 32 bits on Windows and 64 elsewhere, while the extern +// that calls it a `long` passes and reads 64. The Lua host's integers, +// std.tracking's byte count and std.observe's tokens were cut to 32 bits on +// Windows this way. +// +// This reads the tree, pairs each extern with the C definitions of the +// symbol it names, and fails on any slot whose widths differ, and on a C +// `long` in a bound signature at all, since no Aether type has its width on +// every platform. An extern is paired with the C in its own directory when +// that defines the symbol, and otherwise with the shared runtime, std and +// contrib. An extern with no C definition here (libc, a system or vendored +// library) is not checked, nor one marked @c_import: the C compiler checks +// that call against the real prototype. +// +// check [--exclude DIR]... [--expect N] DIR... +// +// Exits 1 on a mismatch; with --expect, unless exactly N were found (the +// self-test over tests/extern_widths/fixture). `make check-extern-widths` +// runs both. + +import std.fs +import std.os +import std.io +import std.string +import std.strbuilder +import std.collections + +extern exit(code: int) + +say(s: string) { + _w = io.stdout_write("${s}\n") +} + +// --------------------------------------------------------------------------- +// Text helpers. + +is_space(c: int) -> int { + if c == 32 || c == 9 || c == 13 || c == 10 { + return 1 + } + return 0 +} + +is_ident_char(c: int) -> int { + if (c >= 97 && c <= 122) || (c >= 65 && c <= 90) || (c >= 48 && c <= 57) || c == 95 { + return 1 + } + return 0 +} + +// The lines of a text, without their line breaks. +split_lines(text: string) -> ptr { + out = collections.string_list_new() + n = string.length(text) + start = 0 + while start < n { + cut = string.index_of_from(text, "\n", start) + stop = cut + if cut < 0 { + stop = n + } + line_end = stop + if line_end > start && string.char_at_n(text, n, line_end - 1) == 13 { + line_end = line_end - 1 + } + collections.string_list_add(out, string.substring(text, start, line_end)) + start = stop + 1 + } + return out +} + +// The identifiers of a text, in order, and each `*` as a word of its own. +word_list(text: string) -> ptr { + out = collections.string_list_new() + n = string.length(text) + i = 0 + while i < n { + c = string.char_at_n(text, n, i) + if is_ident_char(c) == 1 { + j = i + while j < n && is_ident_char(string.char_at_n(text, n, j)) == 1 { + j = j + 1 + } + collections.string_list_add(out, string.substring(text, i, j)) + i = j + } else { + if c == 42 { + collections.string_list_add(out, "*") + } + i = i + 1 + } + } + return out +} + +// The words of a list from `from` up to `to`, joined by single spaces. +join_words(words: ptr, from: int, to: int) -> string { + b = strbuilder.new(64) + i = from + while i < to { + if i > from { + strbuilder.append(b, " ") + } + strbuilder.append(b, collections.string_list_get(words, i)) + i = i + 1 + } + return strbuilder.finish(b) +} + +// A text's words joined by single spaces. +words_of(text: string) -> string { + words = word_list(text) + out = join_words(words, 0, collections.string_list_size(words)) + collections.string_list_free(words) + return out +} + +// The index of the `)` closing the `(` at `open`, or -1. +close_paren(text: string, open: int) -> int { + n = string.length(text) + depth = 0 + i = open + while i < n { + c = string.char_at_n(text, n, i) + if c == 40 { + depth = depth + 1 + } else if c == 41 { + depth = depth - 1 + if depth == 0 { + return i + } + } + i = i + 1 + } + return -1 +} + +// The last index of `c` in `s`, or -1. +last_index_of_char(s: string, c: int) -> int { + n = string.length(s) + i = n - 1 + while i >= 0 { + if string.char_at_n(s, n, i) == c { + return i + } + i = i - 1 + } + return -1 +} + +// The parts of a list split at the commas outside brackets, trimmed. An +// empty list gives no part. +split_top(text: string) -> ptr { + out = collections.string_list_new() + n = string.length(text) + depth = 0 + start = 0 + i = 0 + while i < n { + c = string.char_at_n(text, n, i) + if c == 40 || c == 91 { + depth = depth + 1 + } else if c == 41 || c == 93 { + depth = depth - 1 + } else if c == 44 && depth == 0 { + collections.string_list_add(out, string.trim(string.substring(text, start, i))) + start = i + 1 + } + i = i + 1 + } + last = string.trim(string.substring(text, start, n)) + if string.length(last) > 0 || collections.string_list_size(out) > 0 { + collections.string_list_add(out, last) + } + return out +} + +// `text` without its `__attribute__((...))` and `__declspec(...)` groups. +strip_attributes(text: string) -> string { + out = string.copy(text) + k = 0 + while k < 2 { + key = "__attribute__" + if k == 1 { + key = "__declspec" + } + at = string.index_of(out, key) + while at >= 0 { + open = string.index_of_from(out, "(", at) + close = -1 + if open >= 0 { + close = close_paren(out, open) + } + if close < 0 { + at = -1 + } else { + out = "${string.substring(out, 0, at)} ${string.substring(out, close + 1, string.length(out))}" + at = string.index_of(out, key) + } + } + k = k + 1 + } + return out +} + +// --------------------------------------------------------------------------- +// Width classes: void, ptr, i8, i16, i32, i64, iptr (pointer-sized), long +// (C's own: 32 bits on Windows, 64 elsewhere), f32, f64, f80; byval for a +// struct passed by value, and ? for a type this cannot size. A slot with +// byval or ? on either side is not compared. + +// A C word that is no part of a type's width: a qualifier, or a calling- +// convention or export macro (AETHER_HOT, AE_EXPORT, __cdecl). +c_qualifier(w: string) -> int { + if w == "const" || w == "volatile" || w == "restrict" || w == "register" || w == "extern" || + w == "inline" { + return 1 + } + if string.starts_with(w, "AETHER_") == 1 || string.starts_with(w, "AE_") == 1 || + (string.starts_with(w, "__") == 1 && w != "__int64") { + return 1 + } + return 0 +} + +// A C word that can end a type, so a parameter ending in it has no name. +c_type_word(w: string) -> int { + if w == "int" || w == "long" || w == "short" || w == "char" || w == "double" || w == "float" || + w == "unsigned" || w == "signed" || w == "void" || w == "_Bool" || w == "bool" || w == "__int64" { + return 1 + } + return string.ends_with(w, "_t") +} + +// The width class of C type words (no pointer among them). +c_words_class(words: ptr, from: int, to: int) -> string { + kept = strbuilder.new(32) + any_sign = 0 + i = from + while i < to { + w = collections.string_list_get(words, i) + if w == "unsigned" || w == "signed" { + any_sign = 1 + } else if w == "__int64" { + if strbuilder.length(kept) > 0 { + strbuilder.append(kept, " ") + } + strbuilder.append(kept, "long long") + } else if c_qualifier(w) == 0 { + if strbuilder.length(kept) > 0 { + strbuilder.append(kept, " ") + } + strbuilder.append(kept, w) + } + i = i + 1 + } + k = strbuilder.finish(kept) + if k == "" && any_sign == 1 { + k = "int" + } + // "short int", "long int", "long long int": the int adds nothing. + if string.ends_with(k, " int") == 1 { + k = string.substring(k, 0, string.length(k) - 4) + } + if string.starts_with(k, "enum ") == 1 { + return "i32" + } + if string.starts_with(k, "struct ") == 1 || string.starts_with(k, "union ") == 1 { + return "byval" + } + return match k { + "void" -> "void" + "char" -> "i8" + "_Bool" -> "i8" + "bool" -> "i8" + "int8_t" -> "i8" + "uint8_t" -> "i8" + "short" -> "i16" + "int16_t" -> "i16" + "uint16_t" -> "i16" + "int" -> "i32" + "int32_t" -> "i32" + "uint32_t" -> "i32" + "long" -> "long" + "long long" -> "i64" + "int64_t" -> "i64" + "uint64_t" -> "i64" + "size_t" -> "iptr" + "ssize_t" -> "iptr" + "intptr_t" -> "iptr" + "uintptr_t" -> "iptr" + "ptrdiff_t" -> "iptr" + "float" -> "f32" + "double" -> "f64" + "long double" -> "f80" + _ -> "?" + } +} + +// The width class of a C type (`named` 1: a parameter, whose last word is +// its name unless that word is part of the type). +c_class(text: string, named: int) -> string { + t = strip_attributes(text) + if string.contains(t, "*") == 1 || string.contains(t, "[") == 1 || string.contains(t, "(") == 1 { + return "ptr" + } + words = word_list(t) + nw = collections.string_list_size(words) + to = nw + if named == 1 && nw >= 2 && c_type_word(collections.string_list_get(words, nw - 1)) == 0 { + typed = 0 + i = 0 + while i < nw - 1 { + if c_qualifier(collections.string_list_get(words, i)) == 0 { + typed = typed + 1 + } + i = i + 1 + } + if typed >= 1 { + to = nw - 1 + } + } + out = c_words_class(words, 0, to) + collections.string_list_free(words) + return out +} + +// The C definitions found: each one's symbol, its classes ("ret|p1|p2", +// "..." last for variadic), its text, where it is, and its directory. +struct CDefs { + names: ptr + sigs: ptr + texts: ptr + locs: ptr + dirs: ptr +} + +// The function defined by the text before a `{` at file scope, if any. +c_header(defs: CDefs, header: string, path: string, dir: string, line: int) { + h = string.trim(strip_attributes(header)) + hn = string.length(h) + if hn == 0 || string.char_at_n(h, hn, hn - 1) != 41 { + return + } + // The `(` matching the final `)`. + depth = 0 + open = -1 + i = hn - 1 + while i >= 0 && open < 0 { + c = string.char_at_n(h, hn, i) + if c == 41 { + depth = depth + 1 + } else if c == 40 { + depth = depth - 1 + if depth == 0 { + open = i + } + } + i = i - 1 + } + if open <= 0 { + return + } + before = string.trim(string.substring(h, 0, open)) + bn = string.length(before) + s = bn + while s > 0 && is_ident_char(string.char_at_n(before, bn, s - 1)) == 1 { + s = s - 1 + } + name = string.substring(before, s, bn) + ret = string.trim(string.substring(before, 0, s)) + // Macro calls at file scope, which end in no `;`, run on into the next + // header: the type starts after the last of them. + rp = last_index_of_char(ret, 41) + if rp >= 0 { + ret = string.trim(string.substring(ret, rp + 1, string.length(ret))) + } + if name == "" || ret == "" { + return + } + rw = " ${words_of(ret)} " + if string.contains(rw, " static ") == 1 || string.contains(rw, " typedef ") == 1 { + return + } + sig = strbuilder.new(64) + strbuilder.append(sig, c_class(ret, 0)) + params = split_top(string.substring(h, open + 1, hn - 1)) + np = collections.string_list_size(params) + if !(np == 1 && words_of(collections.string_list_get(params, 0)) == "void") { + j = 0 + while j < np { + p = collections.string_list_get(params, j) + strbuilder.append(sig, "|") + if p == "..." { + strbuilder.append(sig, "...") + } else { + strbuilder.append(sig, c_class(p, 1)) + } + j = j + 1 + } + } + collections.string_list_free(params) + collections.string_list_add(defs.names, name) + collections.string_list_add(defs.sigs, strbuilder.finish(sig)) + collections.string_list_add(defs.texts, h) + collections.string_list_add(defs.locs, "${path}:${line}") + collections.string_list_add(defs.dirs, dir) +} + +// Every function a C file defines at file scope. Comments, literals and +// preprocessor lines are skipped and braces counted, so what a body's `{` +// at file scope follows, since the last `;` or `}` there, is its header. +scan_c(defs: CDefs, path: string, dir: string) { + text, err = fs.read(path) + if err != "" { + return + } + n = string.length(text) + hb = strbuilder.new(256) + hset = 0 + hline = 1 + depth = 0 + line = 1 + line_start = 1 + i = 0 + while i < n { + c = string.char_at_n(text, n, i) + if c == 10 { + line = line + 1 + line_start = 1 + if depth == 0 { + strbuilder.append(hb, " ") + } + i = i + 1 + } else if line_start == 1 && c == 35 { + // A preprocessor line, with its continuations. + stop = 0 + while i < n && stop == 0 { + d = string.char_at_n(text, n, i) + if d == 92 && i + 1 < n && string.char_at_n(text, n, i + 1) == 10 { + line = line + 1 + i = i + 2 + } else if d == 10 { + stop = 1 + } else { + i = i + 1 + } + } + } else if c == 47 && i + 1 < n && string.char_at_n(text, n, i + 1) == 47 { + while i < n && string.char_at_n(text, n, i) != 10 { + i = i + 1 + } + } else if c == 47 && i + 1 < n && string.char_at_n(text, n, i + 1) == 42 { + i = i + 2 + while i + 1 < n && !(string.char_at_n(text, n, i) == 42 && string.char_at_n(text, n, i + 1) == 47) { + if string.char_at_n(text, n, i) == 10 { + line = line + 1 + } + i = i + 1 + } + i = i + 2 + if depth == 0 { + strbuilder.append(hb, " ") + } + } else if c == 34 || c == 39 { + q = c + i = i + 1 + stop = 0 + while i < n && stop == 0 { + d = string.char_at_n(text, n, i) + if d == 92 { + i = i + 2 + } else if d == q { + stop = 1 + } else { + if d == 10 { + line = line + 1 + } + i = i + 1 + } + } + i = i + 1 + line_start = 0 + if depth == 0 { + strbuilder.append(hb, "0") + } + } else { + if is_space(c) == 0 { + line_start = 0 + } + if c == 123 { + if depth == 0 { + c_header(defs, strbuilder.finish(hb), path, dir, hline) + hb = strbuilder.new(256) + hset = 0 + } + depth = depth + 1 + } else if c == 125 { + if depth > 0 { + depth = depth - 1 + } + if depth == 0 { + strbuilder.clear(hb) + hset = 0 + } + } else if depth == 0 { + if c == 59 { + strbuilder.clear(hb) + hset = 0 + } else { + if hset == 0 && is_space(c) == 0 { + hline = line + hset = 1 + } + strbuilder.append_byte(hb, c) + } + } + i = i + 1 + } + } + strbuilder.free(hb) +} + +// --------------------------------------------------------------------------- +// Aether side. + +// The width class of an Aether type. Annotations (@retain, @into, @aether, +// @noescape, @heap) say nothing of width. +ae_class(text: string) -> string { + parts = split_top(string.replace_all(string.trim(text), " ", ",")) + np = collections.string_list_size(parts) + kept = strbuilder.new(32) + i = 0 + while i < np { + w = collections.string_list_get(parts, i) + if w != "" && string.starts_with(w, "@") == 0 { + if strbuilder.length(kept) > 0 { + strbuilder.append(kept, " ") + } + strbuilder.append(kept, w) + } + i = i + 1 + } + collections.string_list_free(parts) + k = strbuilder.finish(kept) + if string.starts_with(k, "*") == 1 || k == "fn" || string.starts_with(k, "fn(") == 1 { + return "ptr" + } + return match k { + "int" -> "i32" + "uint32" -> "i32" + "long" -> "i64" + "uint64" -> "i64" + "long long" -> "i64" + "Duration" -> "i64" + "uint16" -> "i16" + "uint8" -> "i8" + "byte" -> "i8" + // An extern's prototype spells bool `int` (codegen_func.c). + "bool" -> "i32" + "float" -> "f64" + "f32" -> "f32" + "float32" -> "f32" + "size_t" -> "iptr" + "string" -> "ptr" + "ptr" -> "ptr" + "sandbox" -> "ptr" + "actor_ref" -> "ptr" + _ -> "?" + } +} + +// Whether C in `dir` serves externs anywhere: the runtime, std and contrib. +shared_dir(dir: string) -> int { + if string.starts_with(dir, "std") == 1 || string.starts_with(dir, "contrib") == 1 || + string.starts_with(dir, "runtime") == 1 { + return 1 + } + return 0 +} + +// Two width classes that disagree. A slot this cannot size is not compared. +// A pointer-sized C integer is taken as 64 bits: every target that builds +// the bound C is 64-bit. +mismatch(a: string, c: string) -> int { + if a == "?" || c == "?" || a == "byval" || c == "byval" { + return 0 + } + if c == "long" { + return 1 + } + if c == "iptr" && a == "i64" { + return 0 + } + if a == c { + return 0 + } + return 1 +} + +// A class as the report names it. +class_text(c: string) -> string { + return match c { + "long" -> "long (32 bits on Windows, 64 elsewhere)" + "i8" -> "8 bits" + "i16" -> "16 bits" + "i32" -> "32 bits" + "i64" -> "64 bits" + "iptr" -> "pointer-sized" + "f32" -> "float" + "f64" -> "double" + "f80" -> "long double" + "ptr" -> "a pointer" + _ -> c + } +} + +// One extern against one C definition; returns 1 on a mismatch, reported. +compare_one(defs: CDefs, d: int, apath: string, aline: int, name: string, params: ptr, ret: string) -> int { + sig = split_top(string.replace_all(collections.string_list_get(defs.sigs, d), "|", ",")) + ns = collections.string_list_size(sig) + cret = collections.string_list_get(sig, 0) + what = strbuilder.new(128) + // No result in Aether ignores whatever C returns. + if ret != "" { + if cret == "void" { + strbuilder.append(what, "; returns ${ret} in Aether, nothing in C") + } else if mismatch(ae_class(ret), cret) == 1 { + strbuilder.append(what, "; returns ${ret} (${class_text(ae_class(ret))}) in Aether, ${class_text(cret)} in C") + } + } + na = collections.string_list_size(params) + nc = ns - 1 + variadic = 0 + if nc > 0 && collections.string_list_get(sig, ns - 1) == "..." { + variadic = 1 + nc = nc - 1 + } + if na != nc && !(variadic == 1 && na >= nc) { + strbuilder.append(what, "; takes ${na} argument(s) in Aether, ${nc} in C") + } else { + k = 0 + while k < nc { + at = collections.string_list_get(params, k) + ct = collections.string_list_get(sig, k + 1) + if mismatch(ae_class(at), ct) == 1 { + strbuilder.append(what, "; argument ${k + 1} is ${at} (${class_text(ae_class(at))}) in Aether, ${class_text(ct)} in C") + } + k = k + 1 + } + } + collections.string_list_free(sig) + report = strbuilder.finish(what) + if report == "" { + return 0 + } + say("${apath}:${aline}: extern ${name}: ${string.substring(report, 2, string.length(report))}") + say(" C: ${collections.string_list_get(defs.texts, d)} (${collections.string_list_get(defs.locs, d)})") + return 1 +} + +// One extern against every C definition of its symbol: those in its own +// directory, or else the shared ones. Returns the mismatches reported. +check_extern(defs: CDefs, apath: string, aline: int, adir: string, name: string, csym: string, + params: ptr, ret: string) -> int { + total = collections.string_list_size(defs.names) + local_n = 0 + i = 0 + while i < total { + if collections.string_list_get(defs.dirs, i) == adir && collections.string_list_get(defs.names, i) == csym { + local_n = local_n + 1 + } + i = i + 1 + } + found = 0 + i = 0 + while i < total { + if collections.string_list_get(defs.names, i) == csym { + dd = collections.string_list_get(defs.dirs, i) + if (local_n > 0 && dd == adir) || (local_n == 0 && shared_dir(dd) == 1) { + found = found + compare_one(defs, i, apath, aline, name, params, ret) + } + } + i = i + 1 + } + return found +} + +is_plain_name(s: string) -> int { + n = string.length(s) + if n == 0 { + return 0 + } + i = 0 + while i < n { + if is_ident_char(string.char_at_n(s, n, i)) == 0 { + return 0 + } + i = i + 1 + } + return 1 +} + +// Every extern of an Aether file, checked: (mismatches, externs checked). +scan_ae(defs: CDefs, path: string, dir: string) -> (int, int) { + text, err = fs.read(path) + if err != "" { + return 0, 0 + } + lines = split_lines(text) + nl = collections.string_list_size(lines) + found = 0 + checked = 0 + k = 0 + while k < nl { + ln = string.trim(collections.string_list_get(lines, k)) + rest = "" + csym = "" + if string.starts_with(ln, "extern ") == 1 { + rest = string.substring(ln, 7, string.length(ln)) + } else if string.starts_with(ln, "export extern ") == 1 { + rest = string.substring(ln, 14, string.length(ln)) + } else if string.starts_with(ln, "@extern(\"") == 1 { + q = string.index_of_from(ln, "\"", 9) + close = string.index_of_from(ln, ")", 9) + if q > 9 && close > q { + csym = string.substring(ln, 9, q) + rest = string.trim(string.substring(ln, close + 1, string.length(ln))) + } + } + aline = k + 1 + open = string.index_of(rest, "(") + if open > 0 { + // The declaration, over as many lines as its parentheses take. + decl = string.copy(rest) + while close_paren(decl, open) < 0 && k + 1 < nl { + k = k + 1 + decl = "${decl} ${string.trim(collections.string_list_get(lines, k))}" + } + close = close_paren(decl, open) + name = string.trim(string.substring(decl, 0, open)) + if close > open && is_plain_name(name) == 1 { + tail = string.substring(decl, close + 1, string.length(decl)) + cmt = string.index_of(tail, "//") + if cmt >= 0 { + tail = string.substring(tail, 0, cmt) + } + if string.contains(tail, "@c_import") == 0 { + ret = "" + arrow = string.index_of(tail, "->") + if arrow >= 0 { + ret = string.trim(string.substring(tail, arrow + 2, string.length(tail))) + // A tuple result is not one C value. + if string.starts_with(ret, "(") == 1 { + ret = "?" + } + } + params = collections.string_list_new() + raw = split_top(string.substring(decl, open + 1, close)) + nr = collections.string_list_size(raw) + j = 0 + while j < nr { + p = collections.string_list_get(raw, j) + colon = string.index_of(p, ":") + if colon >= 0 { + collections.string_list_add(params, string.trim(string.substring(p, colon + 1, string.length(p)))) + } else { + collections.string_list_add(params, p) + } + j = j + 1 + } + collections.string_list_free(raw) + if csym == "" { + csym = string.copy(name) + } + checked = checked + 1 + found = found + check_extern(defs, path, aline, dir, name, csym, params, ret) + collections.string_list_free(params) + } + } + } + k = k + 1 + } + collections.string_list_free(lines) + return found, checked +} + +// --------------------------------------------------------------------------- + +excluded(excludes: ptr, p: string) -> int { + n = collections.string_list_size(excludes) + i = 0 + while i < n { + if p == collections.string_list_get(excludes, i) { + return 1 + } + i = i + 1 + } + return 0 +} + +dir_of(p: string) -> string { + slash = last_index_of_char(p, 47) + if slash < 0 { + return "." + } + return string.substring(p, 0, slash) +} + +main() { + dirs = collections.string_list_new() + excludes = collections.string_list_new() + expect = -1 + argc = os.args_count() + i = 1 + while i < argc { + a = os.args_get(i) + if a == "--exclude" && i + 1 < argc { + i = i + 1 + collections.string_list_add(excludes, string.replace_all(os.args_get(i), "\\", "/")) + } else if a == "--expect" && i + 1 < argc { + i = i + 1 + expect = string.get_int(os.args_get(i)) + } else { + collections.string_list_add(dirs, string.replace_all(a, "\\", "/")) + } + i = i + 1 + } + if collections.string_list_size(dirs) == 0 { + say("usage: check [--exclude DIR]... [--expect N] DIR...") + exit(2) + } + + cfiles = collections.string_list_new() + aefiles = collections.string_list_new() + nd = collections.string_list_size(dirs) + i = 0 + while i < nd { + root = collections.string_list_get(dirs, i) + _n, werr = fs.walk(root) | path: string, kind: int, depth: int | { + p = string.replace_all(path, "\\", "/") + verdict = 0 + if kind == 2 { + base = p + slash = last_index_of_char(p, 47) + if slash >= 0 { + base = string.substring(p, slash + 1, string.length(p)) + } + if base == "build" || base == ".git" || base == "target" || base == "zig-out" || + base == "node_modules" || excluded(excludes, p) == 1 { + verdict = 1 + } + } else if kind == 1 { + if string.ends_with(p, ".c") == 1 { + collections.string_list_add(cfiles, p) + } else if string.ends_with(p, ".ae") == 1 { + collections.string_list_add(aefiles, p) + } + } + return verdict + } + if werr != "" { + say("extern widths: cannot walk ${root}: ${werr}") + exit(2) + } + i = i + 1 + } + + defs = CDefs { + names: collections.string_list_new(), + sigs: collections.string_list_new(), + texts: collections.string_list_new(), + locs: collections.string_list_new(), + dirs: collections.string_list_new() + } + nc = collections.string_list_size(cfiles) + i = 0 + while i < nc { + p = collections.string_list_get(cfiles, i) + scan_c(defs, p, dir_of(p)) + i = i + 1 + } + + found = 0 + checked = 0 + na = collections.string_list_size(aefiles) + i = 0 + while i < na { + p = collections.string_list_get(aefiles, i) + f, c = scan_ae(defs, p, dir_of(p)) + found = found + f + checked = checked + c + i = i + 1 + } + + ndefs = collections.string_list_size(defs.names) + say("extern widths: ${checked} externs in ${na} Aether files against ${ndefs} C definitions in ${nc} C files: ${found} mismatch(es)") + if expect >= 0 { + if found != expect { + say("extern widths: the self-test expected ${expect} mismatch(es)") + exit(1) + } + exit(0) + } + if found > 0 { + exit(1) + } +} diff --git a/tests/extern_widths/fixture/widths.ae b/tests/extern_widths/fixture/widths.ae new file mode 100644 index 000000000..ee3a9b6ab --- /dev/null +++ b/tests/extern_widths/fixture/widths.ae @@ -0,0 +1,41 @@ +// The Aether side of the extern-width self-test (tests/extern_widths/check.ae, +// #3097). Never built: the check only reads it. widths.c defines these; +// `make check-extern-widths` expects exactly 11 mismatches here. + +// Agree with their C definitions. +extern ew_ok_long(v: long) -> long +extern ew_ok_size(n: long) -> long +extern ew_ok_bool(flag: bool) -> bool +extern ew_ok_double(x: float) -> float +extern ew_ok_string(s: string) -> string +extern ew_ok_ptr(p: ptr) -> ptr +extern ew_ok_ignored_result(v: int) +extern ew_ok_variadic(fmt: string, a: int, b: long) -> int +extern ew_ok_multiline(a: ptr, + b: long, + c: string) -> int +extern ew_ok_from_macro() -> int +extern ew_ok_after_macro(v: int) -> int + +// Disagree with their C definitions, one mismatch each. +extern ew_bad_long_result() -> long +extern ew_bad_long_param(v: long) +extern ew_bad_int_to_int64(v: int) +extern ew_bad_int_to_size(n: int) +extern ew_bad_float(x: float) -> float +extern ew_bad_pointer_as_int() -> int +extern ew_bad_arity(a: int) -> int +extern ew_bad_void_result(v: int) -> int +extern ew_bad_unsigned_long() -> long +@extern("ew_bad_aliased") ew_alias(v: int) -> int + +// Two C definitions, the second disagreeing: one mismatch. +extern ew_bad_second_branch(v: int) -> int + +// The header owns the prototype: not checked. +extern ew_skipped_c_import(v: long) -> long @c_import + +// No C definition here (libc): not checked. +extern strlen(s: string) -> long + +// extern ew_bad_in_a_comment(v: long) -> long diff --git a/tests/extern_widths/fixture/widths.c b/tests/extern_widths/fixture/widths.c new file mode 100644 index 000000000..003ccf192 --- /dev/null +++ b/tests/extern_widths/fixture/widths.c @@ -0,0 +1,59 @@ +/* The C side of the extern-width self-test (tests/extern_widths/check.ae, + * #3097). Never built: the check only reads it. widths.ae binds these. */ + +#include +#include + +/* Agree with their externs. */ +int64_t ew_ok_long(int64_t v) { return v; } +size_t ew_ok_size(size_t n) { return n; } +int ew_ok_bool(int flag) { return flag; } +double ew_ok_double(double x) { return x; } +const char* ew_ok_string(const char* s) { return s; } +void* ew_ok_ptr(void* p) { return p; } +int ew_ok_ignored_result(int v) { return v; } +int ew_ok_variadic(const char* fmt, ...) { (void)fmt; return 0; } +int ew_ok_multiline(void* a, + int64_t b, + const char* c) { + (void)a; (void)b; (void)c; + return 0; +} + +/* A macro call at file scope ends in no `;`: the next definition's type + * starts after it. */ +#define EW_DEFINE(name) int name(void) { return 0; } +EW_DEFINE(ew_ok_from_macro) +int ew_ok_after_macro(int v) { return v; } + +/* Comments and literals hold no definitions: + * long ew_bad_in_comment(long v) { return v; } */ +static const char* ew_text = "long ew_bad_in_string(long v) { return v; }"; + +/* A static function has no symbol an extern could bind. */ +static long ew_static(long v) { return v; } + +/* Disagree with their externs, one mismatch each. */ +long ew_bad_long_result(void) { return 0; } +void ew_bad_long_param(long v) { (void)v; } +void ew_bad_int_to_int64(int64_t v) { (void)v; } +void ew_bad_int_to_size(size_t n) { (void)n; } +float ew_bad_float(float x) { return x; } +void* ew_bad_pointer_as_int(void) { return 0; } +int ew_bad_arity(int a, int b) { return a + b; } +void ew_bad_void_result(int v) { (void)v; } +unsigned long ew_bad_unsigned_long(void) { return 0; } +int ew_bad_aliased(int64_t v) { return (int)v; } + +/* Two definitions under preprocessor branches: both are checked, and the + * second disagrees. */ +#ifdef EW_FIRST +int ew_bad_second_branch(int v) { return v; } +#else +int ew_bad_second_branch(long v) { return (int)v; } +#endif + +/* @c_import: the C compiler checks it against this prototype. */ +long ew_skipped_c_import(long v) { return v; } + +void ew_use(void) { (void)ew_text; (void)ew_static; } diff --git a/tests/integration/bytes_finish_return_no_leak/shim.c b/tests/integration/bytes_finish_return_no_leak/shim.c index 39c3b31ef..6737adca6 100644 --- a/tests/integration/bytes_finish_return_no_leak/shim.c +++ b/tests/integration/bytes_finish_return_no_leak/shim.c @@ -16,22 +16,22 @@ * shows sustained growth. macOS via mach task_info, Linux/BSD via * /proc/self/statm, Windows -1. */ #if defined(_WIN32) || defined(__MINGW32__) || defined(__MINGW64__) -long getrusage_max_rss_kb(void) { return -1; } +int getrusage_max_rss_kb(void) { return -1; } #elif defined(__APPLE__) || defined(__MACH__) #include -long getrusage_max_rss_kb(void) { +int getrusage_max_rss_kb(void) { mach_task_basic_info_data_t info; mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT; if (task_info(mach_task_self(), MACH_TASK_BASIC_INFO, (task_info_t)&info, &count) != KERN_SUCCESS) { return -1; } - return (long)(info.resident_size / 1024); /* bytes -> KB */ + return (int)(info.resident_size / 1024); /* bytes -> KB */ } #else /* Linux / other POSIX with /proc */ #include #include -long getrusage_max_rss_kb(void) { +int getrusage_max_rss_kb(void) { FILE* f = fopen("/proc/self/statm", "r"); if (!f) return -1; long total_pages = 0, resident_pages = 0; @@ -41,6 +41,6 @@ long getrusage_max_rss_kb(void) { } fclose(f); long page_kb = sysconf(_SC_PAGESIZE) / 1024; - return resident_pages * page_kb; + return (int)(resident_pages * page_kb); } #endif diff --git a/tests/integration/caller_info/caller_info_inproc.ae b/tests/integration/caller_info/caller_info_inproc.ae index b8c2b330a..02a0aa3d9 100644 --- a/tests/integration/caller_info/caller_info_inproc.ae +++ b/tests/integration/caller_info/caller_info_inproc.ae @@ -12,7 +12,7 @@ import std.host import std.string -extern aether_set_caller(identity: string, keys: ptr, vals: ptr, n: int, deadline: long) -> int +extern aether_set_caller(identity: string, keys: ptr, vals: ptr, n: long, deadline: long) -> int extern aether_clear_caller() main() { diff --git a/tests/integration/heap_leak_cross_fn_recursion/shim.c b/tests/integration/heap_leak_cross_fn_recursion/shim.c index 23f4f7840..d7d1feeda 100644 --- a/tests/integration/heap_leak_cross_fn_recursion/shim.c +++ b/tests/integration/heap_leak_cross_fn_recursion/shim.c @@ -20,11 +20,11 @@ * ru_maxrss; Valgrind/ASan-LSan are its precise gate. macOS also has * the deterministic `leaks(1)` gate — see tests/run_macos_leaks.sh.) */ #if defined(_WIN32) || defined(__MINGW32__) || defined(__MINGW64__) -long getrusage_max_rss_kb(void) { return -1; } +int getrusage_max_rss_kb(void) { return -1; } #elif defined(__APPLE__) || defined(__MACH__) #include #include -long getrusage_max_rss_kb(void) { +int getrusage_max_rss_kb(void) { /* Return malloc-zone-cached free pages to the OS so resident_size * measures the live set, not the allocator's high-water cache. */ malloc_zone_pressure_relief(malloc_default_zone(), 0); @@ -34,13 +34,13 @@ long getrusage_max_rss_kb(void) { (task_info_t)&info, &count) != KERN_SUCCESS) { return -1; } - return (long)(info.resident_size / 1024); /* bytes → KB */ + return (int)(info.resident_size / 1024); /* bytes → KB */ } #else #include -long getrusage_max_rss_kb(void) { +int getrusage_max_rss_kb(void) { struct rusage r; if (getrusage(RUSAGE_SELF, &r) != 0) return -1; - return r.ru_maxrss; /* already KB on Linux/BSD */ + return (int)r.ru_maxrss; /* already KB on Linux/BSD */ } #endif diff --git a/tests/integration/heap_leak_interp_as_arg/shim.c b/tests/integration/heap_leak_interp_as_arg/shim.c index 23f4f7840..d7d1feeda 100644 --- a/tests/integration/heap_leak_interp_as_arg/shim.c +++ b/tests/integration/heap_leak_interp_as_arg/shim.c @@ -20,11 +20,11 @@ * ru_maxrss; Valgrind/ASan-LSan are its precise gate. macOS also has * the deterministic `leaks(1)` gate — see tests/run_macos_leaks.sh.) */ #if defined(_WIN32) || defined(__MINGW32__) || defined(__MINGW64__) -long getrusage_max_rss_kb(void) { return -1; } +int getrusage_max_rss_kb(void) { return -1; } #elif defined(__APPLE__) || defined(__MACH__) #include #include -long getrusage_max_rss_kb(void) { +int getrusage_max_rss_kb(void) { /* Return malloc-zone-cached free pages to the OS so resident_size * measures the live set, not the allocator's high-water cache. */ malloc_zone_pressure_relief(malloc_default_zone(), 0); @@ -34,13 +34,13 @@ long getrusage_max_rss_kb(void) { (task_info_t)&info, &count) != KERN_SUCCESS) { return -1; } - return (long)(info.resident_size / 1024); /* bytes → KB */ + return (int)(info.resident_size / 1024); /* bytes → KB */ } #else #include -long getrusage_max_rss_kb(void) { +int getrusage_max_rss_kb(void) { struct rusage r; if (getrusage(RUSAGE_SELF, &r) != 0) return -1; - return r.ru_maxrss; /* already KB on Linux/BSD */ + return (int)r.ru_maxrss; /* already KB on Linux/BSD */ } #endif diff --git a/tests/integration/heap_tracker_return_escape_no_leak/shim.c b/tests/integration/heap_tracker_return_escape_no_leak/shim.c index df21a2dbf..73f2f8ca6 100644 --- a/tests/integration/heap_tracker_return_escape_no_leak/shim.c +++ b/tests/integration/heap_tracker_return_escape_no_leak/shim.c @@ -24,7 +24,7 @@ #if defined(_WIN32) || defined(__MINGW32__) || defined(__MINGW64__) -long getrusage_max_rss_kb(void) { +int getrusage_max_rss_kb(void) { /* Sentinel: probe will skip the bench-growth assertion. */ return -1; } @@ -33,14 +33,14 @@ long getrusage_max_rss_kb(void) { #include -long getrusage_max_rss_kb(void) { +int getrusage_max_rss_kb(void) { mach_task_basic_info_data_t info; mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT; if (task_info(mach_task_self(), MACH_TASK_BASIC_INFO, (task_info_t)&info, &count) != KERN_SUCCESS) { return -1; } - return (long)(info.resident_size / 1024); /* bytes -> KB */ + return (int)(info.resident_size / 1024); /* bytes -> KB */ } #else /* Linux / other POSIX with /proc */ @@ -48,7 +48,7 @@ long getrusage_max_rss_kb(void) { #include #include -long getrusage_max_rss_kb(void) { +int getrusage_max_rss_kb(void) { FILE* f = fopen("/proc/self/statm", "r"); if (!f) return -1; long total_pages = 0, resident_pages = 0; @@ -58,7 +58,7 @@ long getrusage_max_rss_kb(void) { } fclose(f); long page_kb = sysconf(_SC_PAGESIZE) / 1024; - return resident_pages * page_kb; + return (int)(resident_pages * page_kb); } #endif diff --git a/tests/integration/std_strbuilder_csv/shim.c b/tests/integration/std_strbuilder_csv/shim.c index 8213e0850..4fc87833e 100644 --- a/tests/integration/std_strbuilder_csv/shim.c +++ b/tests/integration/std_strbuilder_csv/shim.c @@ -14,18 +14,18 @@ * normalise to KB so the probe's threshold means the same thing on * every platform. */ #if defined(_WIN32) || defined(__MINGW32__) || defined(__MINGW64__) -long getrusage_max_rss_kb(void) { return -1; } +int getrusage_max_rss_kb(void) { return -1; } #else #include -long getrusage_max_rss_kb(void) { +int getrusage_max_rss_kb(void) { struct rusage r; if (getrusage(RUSAGE_SELF, &r) != 0) return -1; #if defined(__APPLE__) /* Darwin reports bytes; convert to KB to match Linux/BSD. */ - return (long)(r.ru_maxrss / 1024); + return (int)(r.ru_maxrss / 1024); #else /* Linux and BSD: already in KB per POSIX. */ - return (long)r.ru_maxrss; + return (int)r.ru_maxrss; #endif } #endif diff --git a/tests/integration/std_strbuilder_no_leak/shim.c b/tests/integration/std_strbuilder_no_leak/shim.c index 8213e0850..4fc87833e 100644 --- a/tests/integration/std_strbuilder_no_leak/shim.c +++ b/tests/integration/std_strbuilder_no_leak/shim.c @@ -14,18 +14,18 @@ * normalise to KB so the probe's threshold means the same thing on * every platform. */ #if defined(_WIN32) || defined(__MINGW32__) || defined(__MINGW64__) -long getrusage_max_rss_kb(void) { return -1; } +int getrusage_max_rss_kb(void) { return -1; } #else #include -long getrusage_max_rss_kb(void) { +int getrusage_max_rss_kb(void) { struct rusage r; if (getrusage(RUSAGE_SELF, &r) != 0) return -1; #if defined(__APPLE__) /* Darwin reports bytes; convert to KB to match Linux/BSD. */ - return (long)(r.ru_maxrss / 1024); + return (int)(r.ru_maxrss / 1024); #else /* Linux and BSD: already in KB per POSIX. */ - return (long)r.ru_maxrss; + return (int)r.ru_maxrss; #endif } #endif diff --git a/tests/integration/string_new_with_length_no_leak/probe.ae b/tests/integration/string_new_with_length_no_leak/probe.ae index b422b8d9b..46d82dfb4 100644 --- a/tests/integration/string_new_with_length_no_leak/probe.ae +++ b/tests/integration/string_new_with_length_no_leak/probe.ae @@ -30,7 +30,7 @@ import std.string -extern string_new_with_length(data: ptr, length: int) -> string @heap +extern string_new_with_length(data: ptr, length: long) -> string @heap extern getrusage_max_rss_kb() -> int extern exit(code: int) diff --git a/tests/integration/string_new_with_length_no_leak/shim.c b/tests/integration/string_new_with_length_no_leak/shim.c index 8213e0850..4fc87833e 100644 --- a/tests/integration/string_new_with_length_no_leak/shim.c +++ b/tests/integration/string_new_with_length_no_leak/shim.c @@ -14,18 +14,18 @@ * normalise to KB so the probe's threshold means the same thing on * every platform. */ #if defined(_WIN32) || defined(__MINGW32__) || defined(__MINGW64__) -long getrusage_max_rss_kb(void) { return -1; } +int getrusage_max_rss_kb(void) { return -1; } #else #include -long getrusage_max_rss_kb(void) { +int getrusage_max_rss_kb(void) { struct rusage r; if (getrusage(RUSAGE_SELF, &r) != 0) return -1; #if defined(__APPLE__) /* Darwin reports bytes; convert to KB to match Linux/BSD. */ - return (long)(r.ru_maxrss / 1024); + return (int)(r.ru_maxrss / 1024); #else /* Linux and BSD: already in KB per POSIX. */ - return (long)r.ru_maxrss; + return (int)r.ru_maxrss; #endif } #endif diff --git a/tests/integration/string_substring_n_no_leak/shim.c b/tests/integration/string_substring_n_no_leak/shim.c index 8213e0850..4fc87833e 100644 --- a/tests/integration/string_substring_n_no_leak/shim.c +++ b/tests/integration/string_substring_n_no_leak/shim.c @@ -14,18 +14,18 @@ * normalise to KB so the probe's threshold means the same thing on * every platform. */ #if defined(_WIN32) || defined(__MINGW32__) || defined(__MINGW64__) -long getrusage_max_rss_kb(void) { return -1; } +int getrusage_max_rss_kb(void) { return -1; } #else #include -long getrusage_max_rss_kb(void) { +int getrusage_max_rss_kb(void) { struct rusage r; if (getrusage(RUSAGE_SELF, &r) != 0) return -1; #if defined(__APPLE__) /* Darwin reports bytes; convert to KB to match Linux/BSD. */ - return (long)(r.ru_maxrss / 1024); + return (int)(r.ru_maxrss / 1024); #else /* Linux and BSD: already in KB per POSIX. */ - return (long)r.ru_maxrss; + return (int)r.ru_maxrss; #endif } #endif diff --git a/tests/runtime/test_runtime_observe.c b/tests/runtime/test_runtime_observe.c index d8a4cb95d..ab09c40c9 100644 --- a/tests/runtime/test_runtime_observe.c +++ b/tests/runtime/test_runtime_observe.c @@ -16,7 +16,7 @@ typedef struct { int* hits; void* last_obj; void* target; /* for observers that act on the table from inside a pass */ - long token; + int64_t token; } Env; static void env_dtor(void* p) { @@ -70,8 +70,8 @@ TEST_CATEGORY(observe_notifies_in_registration_order, TEST_CATEGORY_RUNTIME) { int obj = 0; Env* a = make_env(&freed, &hits_a); Env* b = make_env(&freed, &hits_b); - long ta = aether_observe(&obj, closure_of(on_change, a)); - long tb = aether_observe(&obj, closure_of(on_change, b)); + int64_t ta = aether_observe(&obj, closure_of(on_change, a)); + int64_t tb = aether_observe(&obj, closure_of(on_change, b)); ASSERT_TRUE(ta > 0); ASSERT_TRUE(tb > ta); ASSERT_EQ(2, aether_observer_count(&obj)); @@ -104,8 +104,8 @@ TEST_CATEGORY(observe_notifies_in_registration_order, TEST_CATEGORY_RUNTIME) { TEST_CATEGORY(observe_is_per_object, TEST_CATEGORY_RUNTIME) { int freed = 0, hits_x = 0, hits_y = 0; int x = 0, y = 0; - long tx = aether_observe(&x, closure_of(on_change, make_env(&freed, &hits_x))); - long ty = aether_observe(&y, closure_of(on_change, make_env(&freed, &hits_y))); + int64_t tx = aether_observe(&x, closure_of(on_change, make_env(&freed, &hits_x))); + int64_t ty = aether_observe(&y, closure_of(on_change, make_env(&freed, &hits_y))); aether_observe_notify(&x); ASSERT_EQ(1, hits_x); @@ -123,7 +123,7 @@ TEST_CATEGORY(observe_table_grows_past_initial_capacity, TEST_CATEGORY_RUNTIME) int freed = 0; int hits[N]; int objs[N]; - long tokens[N]; + int64_t tokens[N]; for (int i = 0; i < N; i++) { hits[i] = 0; tokens[i] = aether_observe(&objs[i], closure_of(on_change, make_env(&freed, &hits[i]))); @@ -171,7 +171,7 @@ static void reentrant_observer(void* env, void* obj) { TEST_CATEGORY(observe_guards_reentrant_notify, TEST_CATEGORY_RUNTIME) { int freed = 0, hits = 0; int obj = 0; - long t = aether_observe(&obj, closure_of(reentrant_observer, make_env(&freed, &hits))); + int64_t t = aether_observe(&obj, closure_of(reentrant_observer, make_env(&freed, &hits))); ASSERT_EQ(0, aether_observe_is_notifying(&obj)); aether_observe_notify(&obj); ASSERT_EQ(1, hits); @@ -196,8 +196,8 @@ TEST_CATEGORY(observe_reentrancy_guard_is_per_object, TEST_CATEGORY_RUNTIME) { int a = 0, b = 0; Env* ea = make_env(&freed, &hits_a); ea->target = &b; - long ta = aether_observe(&a, closure_of(cross_observer, ea)); - long tb = aether_observe(&b, closure_of(on_change, make_env(&freed, &hits_b))); + int64_t ta = aether_observe(&a, closure_of(cross_observer, ea)); + int64_t tb = aether_observe(&b, closure_of(on_change, make_env(&freed, &hits_b))); aether_observe_notify(&a); ASSERT_EQ(1, hits_a); ASSERT_EQ(1, hits_b); @@ -223,7 +223,7 @@ TEST_CATEGORY(observe_unobserve_from_inside_observer_is_deferred, TEST_CATEGORY_ e->token = aether_observe(&obj, closure_of(self_removing_observer, e)); /* A second observer registered after the self-remover still runs in the * same pass: removal never skips a neighbour. */ - long t2 = aether_observe(&obj, closure_of(on_change, make_env(&freed, &later_hits))); + int64_t t2 = aether_observe(&obj, closure_of(on_change, make_env(&freed, &later_hits))); aether_observe_notify(&obj); ASSERT_EQ(1, hits); @@ -250,8 +250,8 @@ TEST_CATEGORY(observe_removed_neighbour_does_not_run, TEST_CATEGORY_RUNTIME) { int freed = 0, hits_first = 0, hits_second = 0; int obj = 0; Env* first = make_env(&freed, &hits_first); - long t1 = aether_observe(&obj, closure_of(remove_neighbour_observer, first)); - long t2 = aether_observe(&obj, closure_of(on_change, make_env(&freed, &hits_second))); + int64_t t1 = aether_observe(&obj, closure_of(remove_neighbour_observer, first)); + int64_t t2 = aether_observe(&obj, closure_of(on_change, make_env(&freed, &hits_second))); first->token = t2; aether_observe_notify(&obj); @@ -303,7 +303,7 @@ TEST_CATEGORY(observe_added_during_pass_runs_next_pass, TEST_CATEGORY_RUNTIME) { int obj = 0; Env* e = make_env(&freed, &hits); e->target = &added_hits; - long t = aether_observe(&obj, closure_of(adding_observer, e)); + int64_t t = aether_observe(&obj, closure_of(adding_observer, e)); aether_observe_notify(&obj); ASSERT_EQ(1, hits); ASSERT_EQ(0, added_hits); @@ -311,7 +311,7 @@ TEST_CATEGORY(observe_added_during_pass_runs_next_pass, TEST_CATEGORY_RUNTIME) { aether_observe_notify(&obj); ASSERT_EQ(2, hits); ASSERT_EQ(1, added_hits); - long added_token = e->token; + int64_t added_token = e->token; ASSERT_EQ(1, aether_unobserve(&obj, t)); /* releases e */ ASSERT_EQ(1, aether_unobserve(&obj, added_token)); ASSERT_EQ(2, freed); @@ -337,8 +337,8 @@ TEST_CATEGORY(observe_fields_receives_the_field, TEST_CATEGORY_RUNTIME) { AetherObserverClosure fc; fc.fn = (void (*)(void))on_field; fc.env = f; - long tf = aether_observe_fields(&obj, fc); - long tp = aether_observe(&obj, closure_of(on_change, p)); + int64_t tf = aether_observe_fields(&obj, fc); + int64_t tp = aether_observe(&obj, closure_of(on_change, p)); ASSERT_TRUE(tf > 0); ASSERT_TRUE(tp > tf); From 208d489458d6f158d7fbbd17a2be96902112d2a2 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Wed, 21 Oct 2026 20:02:10 -0300 Subject: [PATCH 04/50] An actor state can be named like any of the runtime's actor fields (#3094) `state id`, `state active`, `state mailbox` (any of the 21 names in AETHER_ACTOR_BASE_FIELDS) passed the checker, then failed in the C compiler: "duplicate member 'id'". The generated actor struct spliced the base's fields in beside the states, so a state of one of those names was a second member of it. The base is a member of its own now. The generated struct starts with `ActorBase _aether_base;` and the states follow under the names the program gave them. The scheduler still gets a pointer to an ActorBase: the member is first, at offset 0. That is now strictly conforming C, where a spliced prefix relied on a common initial sequence. The static assertion beside each actor checks offsetof(Name, _aether_base) == 0 instead of the last base field's offset. Everything the compiler emits on a generated actor's base goes through the member now. That covers the step function (mailbox, active, the receive timeout's timeout_ns and last_activity_ns), the spawn function (active, migrate_to, dead, auto_process, thread, destroy_state, the timeout) and the typed send (assigned_core). All of these are in codegen_actor.c; nothing else reaches the base of a generated actor except through an ActorBase pointer. A C embedder's own actor structs, built with the macro or with an `ActorBase` member, are unchanged. A state named `_aether_base` itself is the one name left: the checker refuses it with "a state cannot be named '_aether_base'" and a hint, so it never reaches the C compiler. docs/actor-concurrency.md says how a generated actor holds the base. docs/architecture.md and docs/scheduler-quick-reference.md list `inflight` among the base's fields (#3090 added it). Tests: - tests/regression/test_actor_state_base_field_names.ae: an actor with 13 states named like base fields, written by its handlers, read back from main and answered through `ask`. A second actor arms a receive timeout at spawn beside states named timeout_ns and last_activity_ns, which keep their own values. On the compiler before: "duplicate member" for each. - tests/integration/actor_state_base_member_reject: the checker refuses `state _aether_base` with its message, and no C is written. Found while testing: a receive timeout never fires in a program with two actors or more, because no core steps an armed actor with an empty mailbox. Filed as #3102, fixed in its own commit. Checked: make test (596) passes, std/actors' registry test passes, and the full make test-ae sweep (RESULT). --- compiler/analysis/typechecker.c | 8 ++ compiler/codegen/codegen_actor.c | 72 +++++------ docs/actor-concurrency.md | 2 + docs/architecture.md | 2 +- docs/scheduler-quick-reference.md | 2 +- ...5900Z-fixed-3094-actor-state-base-names.md | 7 ++ tests/ae_sweep_prune.txt | 1 + .../base_member.ae | 22 ++++ .../test_actor_state_base_member_reject.sh | 34 +++++ .../test_actor_state_base_field_names.ae | 118 ++++++++++++++++++ 10 files changed, 231 insertions(+), 37 deletions(-) create mode 100644 new_changelogs/20261021T225900Z-fixed-3094-actor-state-base-names.md create mode 100644 tests/integration/actor_state_base_member_reject/base_member.ae create mode 100644 tests/integration/actor_state_base_member_reject/test_actor_state_base_member_reject.sh create mode 100644 tests/regression/test_actor_state_base_field_names.ae diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index 2b19350d3..557f1935e 100644 --- a/compiler/analysis/typechecker.c +++ b/compiler/analysis/typechecker.c @@ -7811,6 +7811,14 @@ int typecheck_actor_definition(ASTNode* actor, SymbolTable* table) { ASTNode* child = actor->children[i]; if (child->type == AST_STATE_DECLARATION) { + /* The generated actor keeps the runtime's actor fields in a + * member of this name, beside the states (#3094): a state may be + * named like any of those fields, but not like the member. */ + if (child->value && strcmp(child->value, "_aether_base") == 0) { + type_error_hint("a state cannot be named '_aether_base'", + "the generated actor keeps the runtime's actor fields under that name; rename the state", + child->line, child->column); + } int checked = 0; if ((!child->node_type || child->node_type->kind == TYPE_UNKNOWN) && child->child_count > 0 && child->children[0]) { diff --git a/compiler/codegen/codegen_actor.c b/compiler/codegen/codegen_actor.c index fc9852c41..56e723984 100644 --- a/compiler/codegen/codegen_actor.c +++ b/compiler/codegen/codegen_actor.c @@ -432,12 +432,16 @@ void generate_actor_definition(CodeGenerator* gen, ASTNode* actor) { print_line(gen, "%s {", actor->value); indent(gen); - /* The prefix of a generated actor IS an ActorBase: the scheduler is handed - * a pointer to it and casts. The fields come from the runtime's one - * definition (AETHER_ACTOR_BASE_FIELDS), so a field added there is here - * too; a hand-written copy that missed one put the first state field at - * its offset, and the scheduler wrote through it. */ - print_line(gen, "AETHER_ACTOR_BASE_FIELDS"); + /* The first member of a generated actor IS an ActorBase: the scheduler is + * handed a pointer to it and casts. It is the runtime's one definition, + * so a field added there is here too; a hand-written copy that missed one + * put the first state field at its offset, and the scheduler wrote + * through it. A member of its own, not the base's fields spliced in: a + * state named like one of them (`state id`, `state active`) was a second + * member of that name and did not compile (#3094). The generated code + * reaches the base's fields through it, and the state fields keep the + * names the program gave them. */ + print_line(gen, "ActorBase _aether_base;"); print_line(gen, ""); // State fields (user-defined) @@ -478,13 +482,11 @@ void generate_actor_definition(CodeGenerator* gen, ASTNode* actor) { unindent(gen); print_line(gen, "} %s;", actor->value); emit_actor_destroy_state(gen, actor); - /* The scheduler casts this to ActorBase*, so every field it touches has to - * sit at the ActorBase offset. Checking the last one pins the whole - * prefix. */ + /* The scheduler casts this to ActorBase*, so the base has to be the first + * member, at offset 0. */ print_line(gen, "#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L"); - print_line(gen, "_Static_assert(offsetof(%s, scheduler_owned) == offsetof(ActorBase, scheduler_owned),", - actor->value); - print_line(gen, " \"%s prefix must match ActorBase\");", actor->value); + print_line(gen, "_Static_assert(offsetof(%s, _aether_base) == 0,", actor->value); + print_line(gen, " \"%s's ActorBase must be its first member\");", actor->value); print_line(gen, "#endif"); print_line(gen, ""); @@ -820,13 +822,13 @@ void generate_actor_definition(CodeGenerator* gen, ASTNode* actor) { // Timeout check — fires if idle longer than timeout_ns if (timeout_arm) { print_line(gen, "// Timeout check"); - print_line(gen, "if (self->timeout_ns > 0 && self->last_activity_ns > 0) {"); + print_line(gen, "if (self->_aether_base.timeout_ns > 0 && self->_aether_base.last_activity_ns > 0) {"); indent(gen); print_line(gen, "uint64_t _now = (uint64_t)_aether_clock_ns();"); - print_line(gen, "if ((_now - self->last_activity_ns) >= self->timeout_ns) {"); + print_line(gen, "if ((_now - self->_aether_base.last_activity_ns) >= self->_aether_base.timeout_ns) {"); indent(gen); - print_line(gen, "self->timeout_ns = 0; // one-shot"); - print_line(gen, "self->last_activity_ns = 0;"); + print_line(gen, "self->_aether_base.timeout_ns = 0; // one-shot"); + print_line(gen, "self->_aether_base.last_activity_ns = 0;"); // Generate the timeout body if (timeout_arm->child_count >= 2 && timeout_arm->children[1]) { ASTNode* tbody = timeout_arm->children[1]; @@ -867,24 +869,24 @@ void generate_actor_definition(CodeGenerator* gen, ASTNode* actor) { print_line(gen, "Message msg;"); print_line(gen, ""); - print_line(gen, "if (unlikely(!mailbox_receive(&self->mailbox, &msg))) {"); + print_line(gen, "if (unlikely(!mailbox_receive(&self->_aether_base.mailbox, &msg))) {"); indent(gen); if (timeout_arm) { // Start timeout countdown when mailbox is empty - print_line(gen, "if (self->timeout_ns > 0 && self->last_activity_ns == 0) {"); + print_line(gen, "if (self->_aether_base.timeout_ns > 0 && self->_aether_base.last_activity_ns == 0) {"); indent(gen); - print_line(gen, "self->last_activity_ns = (uint64_t)_aether_clock_ns();"); + print_line(gen, "self->_aether_base.last_activity_ns = (uint64_t)_aether_clock_ns();"); unindent(gen); print_line(gen, "}"); } - print_line(gen, "atomic_store_explicit(&self->active, 0, memory_order_relaxed);"); + print_line(gen, "atomic_store_explicit(&self->_aether_base.active, 0, memory_order_relaxed);"); print_line(gen, "return;"); unindent(gen); print_line(gen, "}"); if (timeout_arm) { // Message received — cancel timeout (one-shot: fire only if no messages ever arrive) - print_line(gen, "self->timeout_ns = 0;"); - print_line(gen, "self->last_activity_ns = 0;"); + print_line(gen, "self->_aether_base.timeout_ns = 0;"); + print_line(gen, "self->_aether_base.last_activity_ns = 0;"); } print_line(gen, "aether_reply_slot_set(msg._reply_slot);"); print_line(gen, ""); @@ -1099,10 +1101,10 @@ void generate_actor_definition(CodeGenerator* gen, ASTNode* actor) { * its block came from an allocator that scheduler_release_actor's free * does not match (#2485). */ print_line(gen, "if (!actor) return NULL;"); - print_line(gen, "atomic_init(&actor->active, 0); // inactive until first message send"); - print_line(gen, "atomic_init(&actor->migrate_to, -1);"); - print_line(gen, "atomic_init(&actor->dead, 0);"); - print_line(gen, "actor->auto_process = 0;"); + print_line(gen, "atomic_init(&actor->_aether_base.active, 0); // inactive until first message send"); + print_line(gen, "atomic_init(&actor->_aether_base.migrate_to, -1);"); + print_line(gen, "atomic_init(&actor->_aether_base.dead, 0);"); + print_line(gen, "actor->_aether_base.auto_process = 0;"); print_line(gen, ""); for (int i = 0; i < actor->child_count; i++) { @@ -1143,7 +1145,7 @@ void generate_actor_definition(CodeGenerator* gen, ASTNode* actor) { } } } - print_line(gen, "actor->destroy_state = _aether_state_drop_%s; /* #2528 */", actor->value); + print_line(gen, "actor->_aether_base.destroy_state = _aether_state_drop_%s; /* #2528 */", actor->value); // Auto-initialize "my_ref" to the actor's own pointer so it is valid // immediately after spawn — no Setup message needed. This eliminates @@ -1162,16 +1164,16 @@ void generate_actor_definition(CodeGenerator* gen, ASTNode* actor) { print_line(gen, ""); print_line(gen, "#if AETHER_HAS_THREADS"); - print_line(gen, "if (actor->auto_process) {"); + print_line(gen, "if (actor->_aether_base.auto_process) {"); indent(gen); // Without its thread the actor is an ordinary one, stepped by its core // and ended by its release (#2517). The runtime starts the thread: the // program's C has the thread types but not the platform calls behind // them, which on Windows would mean (#2673). - print_line(gen, "if (aether_actor_thread_start(&actor->thread, actor) != 0) {"); + print_line(gen, "if (aether_actor_thread_start(&actor->_aether_base.thread, actor) != 0) {"); indent(gen); - print_line(gen, "actor->thread = 0;"); - print_line(gen, "actor->auto_process = 0;"); + print_line(gen, "actor->_aether_base.thread = 0;"); + print_line(gen, "actor->_aether_base.auto_process = 0;"); unindent(gen); print_line(gen, "}"); unindent(gen); @@ -1194,14 +1196,14 @@ void generate_actor_definition(CodeGenerator* gen, ASTNode* actor) { if (timeout_arm && timeout_arm->child_count >= 1) { print_line(gen, "// Receive timeout (milliseconds -> nanoseconds)"); print_indent(gen); - fprintf(gen->output, "actor->timeout_ns = (uint64_t)("); + fprintf(gen->output, "actor->_aether_base.timeout_ns = (uint64_t)("); const char* prev_alias = gen->state_self_alias; gen->state_self_alias = "actor"; generate_expression(gen, timeout_arm->children[0]); gen->state_self_alias = prev_alias; fprintf(gen->output, ") * 1000000ULL;\n"); - print_line(gen, "actor->last_activity_ns = (uint64_t)_aether_clock_ns(); // Start timeout countdown at spawn"); - print_line(gen, "atomic_store_explicit(&actor->active, 1, memory_order_release); // Activate for timeout polling"); + print_line(gen, "actor->_aether_base.last_activity_ns = (uint64_t)_aether_clock_ns(); // Start timeout countdown at spawn"); + print_line(gen, "atomic_store_explicit(&actor->_aether_base.active, 1, memory_order_release); // Activate for timeout polling"); } print_line(gen, ""); @@ -1213,7 +1215,7 @@ void generate_actor_definition(CodeGenerator* gen, ASTNode* actor) { print_line(gen, "void _aether_send_%s(%s* actor, int type, int payload) {", actor->value, actor->value); indent(gen); print_line(gen, "Message msg = {type, 0, payload, NULL};"); - print_line(gen, "if (atomic_load_explicit(&actor->assigned_core, memory_order_relaxed) == aether_core_id_get()) {"); + print_line(gen, "if (atomic_load_explicit(&actor->_aether_base.assigned_core, memory_order_relaxed) == aether_core_id_get()) {"); indent(gen); print_line(gen, "scheduler_send_local((ActorBase*)actor, msg);"); unindent(gen); diff --git a/docs/actor-concurrency.md b/docs/actor-concurrency.md index 51ad88b86..e226cb80f 100644 --- a/docs/actor-concurrency.md +++ b/docs/actor-concurrency.md @@ -86,6 +86,8 @@ typedef struct { The fields, in order: `active`, `id`, `mailbox`, `step`, `thread`, `auto_process`, `assigned_core`, `migrate_to`, `main_thread_only`, `spsc_queue`, `reply_slot`, `step_lock`, `timeout_ns`, `last_activity_ns`, `dead`, `alloc_size`, `destroy_state`, `scheduler_owned`, `inbox`, `thread_parked`, `inflight`. The header comments each one. +An actor the compiler generates holds the base as its first member instead, `ActorBase _aether_base;`, followed by its states. So a state can be named like any of these fields (`state id`, `state active`); only a state named `_aether_base` is refused (#3094). + ## Message Passing ### Mailbox diff --git a/docs/architecture.md b/docs/architecture.md index 2cc541e55..27c84a97b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -219,7 +219,7 @@ rejects timestamp macros outright. int count; // the actor's own state follows } CounterActor; ``` - The fields, in order: `active`, `id`, `mailbox`, `step`, `thread`, `auto_process`, `assigned_core`, `migrate_to`, `main_thread_only`, `spsc_queue`, `reply_slot`, `step_lock`, `timeout_ns`, `last_activity_ns`, `dead`, `alloc_size`, `destroy_state`, `scheduler_owned`, `inbox`, `thread_parked`. The header comments each one. + The fields, in order: `active`, `id`, `mailbox`, `step`, `thread`, `auto_process`, `assigned_core`, `migrate_to`, `main_thread_only`, `spsc_queue`, `reply_slot`, `step_lock`, `timeout_ns`, `last_activity_ns`, `dead`, `alloc_size`, `destroy_state`, `scheduler_owned`, `inbox`, `thread_parked`, `inflight`. The header comments each one. 2. **Message Structure** (`runtime/actors/actor_state_machine.h`) ```c diff --git a/docs/scheduler-quick-reference.md b/docs/scheduler-quick-reference.md index b54ed8fb1..11fbb0c63 100644 --- a/docs/scheduler-quick-reference.md +++ b/docs/scheduler-quick-reference.md @@ -216,7 +216,7 @@ Both migration and work stealing use ascending core-id lock ordering to prevent - Avoid blocking operations in actor step functions ### Crashes -- Verify every actor struct starts with `ActorBase`'s fields: expand `AETHER_ACTOR_BASE_FIELDS` first (or put an `ActorBase` member first) rather than copying the list, which drifts as fields are added. In order, they are `active`, `id`, `mailbox`, `step`, `thread`, `auto_process`, `assigned_core`, `migrate_to`, `main_thread_only`, `spsc_queue`, `reply_slot`, `step_lock`, `timeout_ns`, `last_activity_ns`, `dead`, `alloc_size`, `destroy_state`, `scheduler_owned`, `inbox`, `thread_parked`. +- Verify every actor struct starts with `ActorBase`'s fields: expand `AETHER_ACTOR_BASE_FIELDS` first (or put an `ActorBase` member first) rather than copying the list, which drifts as fields are added. In order, they are `active`, `id`, `mailbox`, `step`, `thread`, `auto_process`, `assigned_core`, `migrate_to`, `main_thread_only`, `spsc_queue`, `reply_slot`, `step_lock`, `timeout_ns`, `last_activity_ns`, `dead`, `alloc_size`, `destroy_state`, `scheduler_owned`, `inbox`, `thread_parked`, `inflight`. - Initialize `migrate_to = -1` after actor creation - Use `scheduler_send_remote` instead of direct mailbox writes for cross-core messages - Call `scheduler_shutdown()` before process exit (waits for quiescence, stops and joins threads) diff --git a/new_changelogs/20261021T225900Z-fixed-3094-actor-state-base-names.md b/new_changelogs/20261021T225900Z-fixed-3094-actor-state-base-names.md new file mode 100644 index 000000000..494250cf7 --- /dev/null +++ b/new_changelogs/20261021T225900Z-fixed-3094-actor-state-base-names.md @@ -0,0 +1,7 @@ +- **An actor state can be named like any of the runtime's actor fields.** + `state id`, `state active` or `state mailbox` passed the checker and then + failed in the C compiler with a duplicate member, because the generated + actor struct spliced the runtime's actor fields in beside the states. The + base is a member of its own now (`ActorBase _aether_base`), so those + names are ordinary states. Only a state named `_aether_base` itself is + refused, by the checker with a message (#3094). diff --git a/tests/ae_sweep_prune.txt b/tests/ae_sweep_prune.txt index 9897f5bf6..9d63db29c 100644 --- a/tests/ae_sweep_prune.txt +++ b/tests/ae_sweep_prune.txt @@ -11,6 +11,7 @@ /lib/ tests/integration/actor_alignment/ tests/integration/actor_ref_after_release/ +tests/integration/actor_state_base_member_reject/ tests/integration/ae_inspect/ tests/integration/ae_add_release_artifact/ tests/integration/ae_run_cflags/ diff --git a/tests/integration/actor_state_base_member_reject/base_member.ae b/tests/integration/actor_state_base_member_reject/base_member.ae new file mode 100644 index 000000000..d3cc885cf --- /dev/null +++ b/tests/integration/actor_state_base_member_reject/base_member.ae @@ -0,0 +1,22 @@ +// A state named like the member the generated actor keeps the runtime's +// actor fields in (#3094): refused by the checker, not left to the C +// compiler. Any other base field name is a normal state +// (tests/regression/test_actor_state_base_field_names.ae). + +message Hi { n: int } + +actor A { + state _aether_base = 0 + + receive { + Hi(n) -> { + _aether_base = n + } + } +} + +main() { + a = spawn(A()) + a ! Hi { n: 1 } + wait_for_idle() +} diff --git a/tests/integration/actor_state_base_member_reject/test_actor_state_base_member_reject.sh b/tests/integration/actor_state_base_member_reject/test_actor_state_base_member_reject.sh new file mode 100644 index 000000000..a1d296f7a --- /dev/null +++ b/tests/integration/actor_state_base_member_reject/test_actor_state_base_member_reject.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# #3094: a generated actor keeps the runtime's actor fields in a member +# named `_aether_base`, so every other name is a normal state, the base's +# field names (`id`, `active`, `mailbox`...) included. A state named +# `_aether_base` itself is refused by the checker with a message naming +# it, rather than failing in the C compiler as a duplicate member. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AETHERC="$ROOT/build/aetherc" + +SRC="$SCRIPT_DIR/base_member.ae" +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +if "$AETHERC" "$SRC" "$TMPDIR/out.c" >"$TMPDIR/err.log" 2>&1; then + echo " [FAIL] aetherc accepted a state named _aether_base" + exit 1 +fi + +if ! grep -q "a state cannot be named '_aether_base'" "$TMPDIR/err.log"; then + echo " [FAIL] the error does not name the state" + cat "$TMPDIR/err.log" + exit 1 +fi + +# The checker's error, not a C compiler's: no C was written. +if grep -q "duplicate member" "$TMPDIR/err.log"; then + echo " [FAIL] the state reached the C compiler" + cat "$TMPDIR/err.log" + exit 1 +fi + +echo " [PASS] actor_state_base_member_reject: a state named _aether_base is refused" diff --git a/tests/regression/test_actor_state_base_field_names.ae b/tests/regression/test_actor_state_base_field_names.ae new file mode 100644 index 000000000..336d580d7 --- /dev/null +++ b/tests/regression/test_actor_state_base_field_names.ae @@ -0,0 +1,118 @@ +// #3094: an actor state may be named like any of the runtime's own actor +// fields. The generated actor struct spliced those fields in +// (AETHER_ACTOR_BASE_FIELDS), so `state id` was a second member named `id` +// and the C compiler refused it ("duplicate member"). The base is a member +// of its own now; each state here shadows a base field by name, is written +// by the handlers and read back from main, while the actor still receives, +// replies and arms its receive timeout through the base fields underneath. + +message Bump { n: int } +message Get {} +message Value { v: int } + +actor Shadow { + state id = 0 + state active = 0 + state mailbox = 0 + state step = 0 + state thread = 0 + state dead = 0 + state inbox = 0 + state reply_slot = 0 + state step_lock = 0 + state assigned_core = 0 + state migrate_to = 0 + state timeout_ns = 0 + state last_activity_ns = 0 + + receive { + Bump(n) -> { + id = id + n + active = active + 1 + mailbox = mailbox + 2 * n + step = step + 1 + thread = n + dead = dead + 1 + inbox = inbox + 3 + reply_slot = 7 + step_lock = 8 + assigned_core = 9 + migrate_to = 10 + timeout_ns = timeout_ns + n + last_activity_ns = 11 + } + Get() -> { + reply Value { v: id } + } + } +} + +// A receive timeout is armed in the base's timeout fields, not in these. +message Ping {} + +actor Waiter { + state timeout_ns = 5 + state last_activity_ns = 6 + state fired = 0 + + receive { + Ping() -> { + fired = fired + 0 + } + } after 50 -> { + fired = 1 + } +} + +main() { + println("=== actor states named like the base's fields (#3094) ===") + failures = 0 + + s = spawn(Shadow()) + i = 1 + while i <= 4 { + s ! Bump { n: i } + i = i + 1 + } + wait_for_idle() + + int v = s ? Get {} + if s.id != 10 || v != 10 { + println(" FAIL id ${s.id}, replied ${v}, expected 10") + failures = failures + 1 + } + if s.active != 4 || s.mailbox != 20 || s.step != 4 || s.thread != 4 || s.dead != 4 { + println(" FAIL active ${s.active} mailbox ${s.mailbox} step ${s.step} thread ${s.thread} dead ${s.dead}") + failures = failures + 1 + } + if s.inbox != 12 || s.reply_slot != 7 || s.step_lock != 8 || s.assigned_core != 9 || s.migrate_to != 10 { + println(" FAIL inbox ${s.inbox} reply_slot ${s.reply_slot} step_lock ${s.step_lock} assigned_core ${s.assigned_core} migrate_to ${s.migrate_to}") + failures = failures + 1 + } + if s.timeout_ns != 10 || s.last_activity_ns != 11 { + println(" FAIL timeout_ns ${s.timeout_ns} last_activity_ns ${s.last_activity_ns}") + failures = failures + 1 + } + + // The spawn arms the receive timeout in the base's fields; the states of + // the same names keep their own values. + w = spawn(Waiter()) + w ! Ping {} + wait_for_idle() + if w.timeout_ns != 5 || w.last_activity_ns != 6 { + println(" FAIL timeout_ns ${w.timeout_ns}, last_activity_ns ${w.last_activity_ns}, expected 5 and 6") + failures = failures + 1 + } + + if failures == 0 { + println(" PASS every state kept its own value beside the base's fields") + println("") + println("All PASS") + } else { + println("") + println("${failures} FAILURE(S)") + exit(1) + } +} + +extern exit(code: int) From 16fc876df037a8450f01dc3b586e72c10d7f0688 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Wed, 21 Oct 2026 21:37:59 -0300 Subject: [PATCH 05/50] A receive timeout fires with two actors or more (#3102) `receive { ... } after N -> { ... }` fired only in main-thread mode (a program with one actor), where scheduler_wait polls actors with an armed timeout and steps them. The generated step function tests the deadline, so a timeout fires only when something steps the actor. On the cores: - a core's scan skipped every actor with an empty mailbox and no move pending; - an actor's own thread stepped it only for a message; - the multicore scheduler_wait did not wait for timeouts. So the timeout of an actor that received nothing never fired, and wait_for_idle() returned at once. tests/syntax/test_actor_timeout_multiple.ae printed "PASS: both timeouts fired" with neither handler's line in its output. - The scan (scheduler_thread): an actor with an empty mailbox and an armed timeout is stepped, under its step lock, once its deadline has passed. The test is the generated step's own (now - last_activity_ns >= timeout_ns), and both read the same monotonic clock. An actor with no timeout costs one more load of a field beside the two the scan already reads. A parked core wakes within its timed wait (32 ms at most), so a timeout fires at most that late. - The actor thread (aether_actor_thread): the same, before it spins or parks; its park is a timed wait too. - scheduler_wait: once the cores are quiet, an armed timeout keeps it waiting, 1 ms at a time, because its arm may send more. It waits as long as main-thread mode's wait does (100,000 rounds). Tests: - tests/runtime/test_scheduler.c, "Scheduler fires receive timeouts on a core and an actor thread": two actors whose step tests a 20 ms deadline as a generated one does, one stepped by its core and one by its own thread, and neither sent anything. Each must fire within 10 s. Without the fix neither fires (the test waits out its 10 s each). - tests/syntax/test_actor_timeout_multiple.ae now checks that both timeouts fired, after wait_for_idle(). It prints both handler lines and passes. - The other timeout tests (test_actor_timeout, _cancel, _variable, test_receive_arm_heap_locals) and examples/actors/timeout-demo.ae run as before. docs/actor-concurrency.md, "Actor Timeouts", says how the timeout fires with no message and that wait_for_idle() waits for it. Found while testing #3094. Checked: make test (597) passes, and the full make test-ae sweep (RESULT). --- docs/actor-concurrency.md | 2 +- ...03726Z-fixed-3102-receive-timeouts-fire.md | 9 ++++ runtime/actors/aether_actor_thread.c | 11 +++++ runtime/scheduler/multicore_scheduler.c | 28 ++++++++++- tests/runtime/test_scheduler.c | 48 +++++++++++++++++++ tests/syntax/test_actor_timeout_multiple.ae | 23 +++++++-- 6 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 new_changelogs/20261022T003726Z-fixed-3102-receive-timeouts-fire.md diff --git a/docs/actor-concurrency.md b/docs/actor-concurrency.md index e226cb80f..da5627b27 100644 --- a/docs/actor-concurrency.md +++ b/docs/actor-concurrency.md @@ -184,7 +184,7 @@ actor Monitor { } ``` -The timeout is one-shot: it is cancelled when any message is received. The countdown starts when the actor's mailbox becomes empty. Internally, the generated step function checks `_aether_clock_ns()` against a deadline before each `mailbox_receive()` call. +The timeout is one-shot: it is cancelled when any message is received. The countdown starts when the actor's mailbox becomes empty. Internally, the generated step function checks `_aether_clock_ns()` against a deadline before each `mailbox_receive()` call. With no message to step it for, the actor's core (or its own thread) steps it once the deadline passes, so the timeout fires within a park's timed wait of it (32 ms at most), and `wait_for_idle()` waits for every armed timeout to fire, as it does in main-thread mode (#3102). The timeout expression can reference actor state fields, not just integer literals, useful when the interval is configurable per-actor or computed: diff --git a/new_changelogs/20261022T003726Z-fixed-3102-receive-timeouts-fire.md b/new_changelogs/20261022T003726Z-fixed-3102-receive-timeouts-fire.md new file mode 100644 index 000000000..083858552 --- /dev/null +++ b/new_changelogs/20261022T003726Z-fixed-3102-receive-timeouts-fire.md @@ -0,0 +1,9 @@ +- **A receive timeout fires in a program with more than one actor.** + `receive { ... } after N -> { ... }` fired only in main-thread mode, a + program with one actor. On the scheduler's cores nothing stepped an + actor whose mailbox was empty, so the timeout of an actor that received + nothing never fired, and `wait_for_idle()` returned without waiting + for it. Now the actor's core, or its own thread, steps it once the + deadline passes, within a park's timed wait (32 ms at most). And + `wait_for_idle()` waits for armed timeouts, as main-thread mode does + (#3102). diff --git a/runtime/actors/aether_actor_thread.c b/runtime/actors/aether_actor_thread.c index 073e5ecba..0834f7e40 100644 --- a/runtime/actors/aether_actor_thread.c +++ b/runtime/actors/aether_actor_thread.c @@ -90,6 +90,17 @@ void* aether_actor_thread(void* arg) { continue; } + // An armed receive timeout whose deadline has passed (#3102): the + // step fires the `after` arm, by the same clock and test as the + // generated step's. Only a message stepped the actor before, so the + // timeout of one that received nothing never fired. + if (actor->timeout_ns > 0 && actor->last_activity_ns > 0 && actor->step && + aether_now_ns() - actor->last_activity_ns >= actor->timeout_ns) { + idle = 0; + actor->step(arg); + continue; + } + // Nothing to do: leave once the actor's core has stopped. Checked // against every core slot, not just the cores of the running // scheduler: scheduler_cleanup() sets num_cores to 0, and a thread diff --git a/runtime/scheduler/multicore_scheduler.c b/runtime/scheduler/multicore_scheduler.c index 1f2361dbb..91ea25df9 100644 --- a/runtime/scheduler/multicore_scheduler.c +++ b/runtime/scheduler/multicore_scheduler.c @@ -1930,7 +1930,23 @@ void* AETHER_HOT scheduler_thread(void* arg) { // full processing path. This makes the loop O(active) not O(total). int mbox_count = atomic_load_explicit(&actor->mailbox.count, memory_order_relaxed); int mig_to = atomic_load_explicit(&actor->migrate_to, memory_order_relaxed); - if (mbox_count == 0 && mig_to < 0) continue; // Skip inactive actors (fast path!) + if (mbox_count == 0 && mig_to < 0) { + // An armed receive timeout whose deadline has passed (#3102): + // the step fires the `after` arm, by the same clock and test + // as the generated step's. Skipped with the rest, an actor + // that received nothing was never stepped, and its timeout + // never fired with more than one actor in the program. + if (likely(actor->timeout_ns == 0) || actor->last_activity_ns == 0 || + !actor->step) continue; + if (aether_now_ns() - actor->last_activity_ns < actor->timeout_ns) continue; + if (!atomic_flag_test_and_set_explicit(&actor->step_lock, memory_order_acquire)) { + if (atomic_load_explicit(&actor->assigned_core, memory_order_relaxed) == sched->core_id) + aether_step_safe(actor); + atomic_flag_clear_explicit(&actor->step_lock, memory_order_release); + } + work_done = 1; + continue; + } if (!atomic_load_explicit(&actor->active, memory_order_relaxed)) { atomic_store_explicit(&actor->active, 1, memory_order_relaxed); @@ -2462,6 +2478,7 @@ void scheduler_wait(void) { int stable_count = 0; uint64_t last_processed = 0; int stall_checks = 0; + int timeout_rounds = 0; while (stable_count < 5) { atomic_thread_fence(memory_order_acquire); @@ -2469,6 +2486,15 @@ void scheduler_wait(void) { if (pending == 0) { stable_count++; + // Quiet, but a receive timeout is armed (#3102): its core fires + // it once the deadline passes, and its arm may send more, so the + // wait goes on, as main-thread mode's does above (and as long). + if (stable_count == 5 && timeout_rounds < 100000 && has_pending_actor_timeout()) { + stable_count = 0; + timeout_rounds++; + aether_usleep(1000); + continue; + } } else { stable_count = 0; } diff --git a/tests/runtime/test_scheduler.c b/tests/runtime/test_scheduler.c index 9ff0b1fa7..d44a03635 100644 --- a/tests/runtime/test_scheduler.c +++ b/tests/runtime/test_scheduler.c @@ -1379,6 +1379,53 @@ void test_scheduler_actor_thread_parks_when_idle(void) { scheduler_cleanup(); } +// #3102: a receive timeout armed on an actor that receives nothing fires, on +// its core and on its own thread. Only a message stepped an actor before, so +// with more than one actor (no main-thread mode) the timeout never fired. +// The step tests the deadline as a generated one does, and counts a firing. +static void timeout_step(void* self) { + CounterActor* a = (CounterActor*)self; + if (a->timeout_ns > 0 && a->last_activity_ns > 0 && + aether_now_ns() - a->last_activity_ns >= a->timeout_ns) { + a->timeout_ns = 0; + a->last_activity_ns = 0; + atomic_fetch_add(&a->count, 1); + return; + } + Message msg; + if (mailbox_receive(&a->mailbox, &msg)) atomic_store(&a->last_value, msg.payload_int); + atomic_store_explicit(&a->active, (a->mailbox.count > 0), memory_order_relaxed); +} + +void test_scheduler_fires_receive_timeouts(void) { + scheduler_init(2); + CounterActor* on_core = (CounterActor*)scheduler_spawn_actor(-1, timeout_step, sizeof(CounterActor)); + CounterActor* on_thread = (CounterActor*)scheduler_spawn_actor(-1, timeout_step, sizeof(CounterActor)); + ASSERT_NOT_NULL(on_core); + ASSERT_NOT_NULL(on_thread); + atomic_store(&on_core->count, 0); + atomic_store(&on_thread->count, 0); + on_thread->auto_process = 1; + on_thread->timeout_ns = 20 * 1000000ULL; + on_thread->last_activity_ns = aether_now_ns(); + ASSERT_EQ(0, pthread_create(&on_thread->thread, NULL, aether_actor_thread, on_thread)); + on_core->timeout_ns = 20 * 1000000ULL; + on_core->last_activity_ns = aether_now_ns(); + + int core_fired = wait_count(&on_core->count, 1); + int thread_fired = wait_count(&on_thread->count, 1); + + pthread_t t = on_thread->thread; + scheduler_release_actor((ActorBase*)on_thread); + int gone = wait_thread_gone((ActorBase*)on_thread); + pthread_join(t, NULL); + scheduler_shutdown(); + scheduler_cleanup(); + ASSERT_EQ(1, core_fired); + ASSERT_EQ(1, thread_fired); + ASSERT_TRUE(gone); +} + // #3087: a batch of sends wakes the cores it lands on. A loop of sends in // main() goes through the batch path (scheduler_send_batch_*), which put each // core's share on that core's queue and told no core: a parked core slept on @@ -1583,6 +1630,7 @@ void register_scheduler_tests(void) { register_test_with_category("Scheduler teardown with a live actor thread", test_scheduler_teardown_with_live_actor_thread, TEST_CATEGORY_RUNTIME); register_test_with_category("Scheduler actor thread holds a released actor it stepped", test_scheduler_actor_thread_holds_released_actor, TEST_CATEGORY_RUNTIME); register_test_with_category("Scheduler actor thread parks when idle", test_scheduler_actor_thread_parks_when_idle, TEST_CATEGORY_RUNTIME); + register_test_with_category("Scheduler fires receive timeouts on a core and an actor thread", test_scheduler_fires_receive_timeouts, TEST_CATEGORY_RUNTIME); register_test_with_category("Scheduler batch send wakes parked cores", test_scheduler_batch_wakes_parked_cores, TEST_CATEGORY_RUNTIME); register_test_with_category("Scheduler actor thread inbox keeps every sender's order", test_scheduler_actor_thread_inbox_keeps_order, TEST_CATEGORY_RUNTIME); register_test_with_category("Scheduler drops a send to a released actor", test_scheduler_late_send_after_release, TEST_CATEGORY_RUNTIME); diff --git a/tests/syntax/test_actor_timeout_multiple.ae b/tests/syntax/test_actor_timeout_multiple.ae index 731244248..d99d772f5 100644 --- a/tests/syntax/test_actor_timeout_multiple.ae +++ b/tests/syntax/test_actor_timeout_multiple.ae @@ -1,30 +1,47 @@ -// Test: two actors with different timeouts +// Test: two actors with different timeouts, both of which fire. +// +// With two actors the program runs on the scheduler's cores, not in +// main-thread mode. No core stepped an actor whose mailbox was empty, so +// neither timeout fired, and wait_for_idle() returned at once: this test +// printed its PASS line without either handler having run (#3102). Now the +// core fires each timeout once its deadline passes, and wait_for_idle() +// waits for them, as it does in main-thread mode. message Ping {} +extern exit(code: int) + actor FastWaiter { state _count = 0 + state fired = 0 receive { Ping -> { _count = _count + 1 } } after 50 -> { println("FastWaiter: timeout at 50ms") + fired = 1 } } actor SlowWaiter { state _count = 0 + state fired = 0 receive { Ping -> { _count = _count + 1 } } after 150 -> { println("SlowWaiter: timeout at 150ms") + fired = 1 } } main() { - _f = spawn(FastWaiter()) - _s = spawn(SlowWaiter()) + f = spawn(FastWaiter()) + s = spawn(SlowWaiter()) wait_for_idle() + if f.fired != 1 || s.fired != 1 { + println("FAIL: fired ${f.fired} and ${s.fired}, expected both") + exit(1) + } println("PASS: both timeouts fired") } From a3c6dac3c6b598c9fe7571bb256e8718266e0640 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Wed, 21 Oct 2026 23:55:34 -0300 Subject: [PATCH 06/50] A function indexing its slice parameters runs a view body for views (#3029) Called where the C compiler cannot see the argument's origin (another translation unit, a function value, a library boundary), every index of a `T[]` parameter tested for the unbounded-view sentinel and then the bound. GCC cannot unswitch that pair, so the loop around it did not vectorize: 4.2x on an int sum and 6.4x on an f32 map at -O3 in the issue's table. The owner chose codegen versioning (option 2). A function whose body indexes slice parameters that it never binds again, never takes the address of, and holds no closure, is emitted twice (codegen_func.c, generate_function_definition): - `name__aether_view`, static, where an index of those parameters is aether_slice_view_at, the element's address with no test, and a store is aether_slice_view_at_w, which keeps the read-only check (runtime/aether_slice.h); - the function itself, whose entry tests once `if (xs.len == AETHER_SLICE_UNBOUNDED && ...) return name__aether_view(args)` and otherwise runs its own checked body. A view has no length to test, which is what the checked access does with one too, so both bodies give the same answers and the same panics. Per function, not per loop: a body's declarations, defers and labels are the function's own. Not versioned: - a slice bound again (`xs = xs[1..3]`), since its uses are then not all the argument as it arrived; - a slice whose address is taken (`&xs`): through the address it could be made bounded after the entry's test, and the view body would index it with no check; - a body holding a closure or a quote, which is a body of its own, emitted once. The second rendering of the same source reported every diagnostic twice: the AETHER_EXPLAIN_THREADED notes of tests/integration/threaded_dispatch came out 10 for 5 loops in the make test-ae sweep. Diagnostics can now be muted (aether_diagnostics_set_muted in aether_error.c: neither printed nor counted); codegen mutes the view body, and the function's own body, emitted after it, reports each once. The threaded-dispatch notes check the same flag. That test's VM functions take their program as a slice, so each loop is threaded in both bodies: 18 tables for 9 loops, as the test now says. Measured with the origin hidden (each kernel called through a function value chosen at run time, n = 4096, 200,000 reps, a view and a bounded slice over the same memory), 7 interleaved rounds in a quiet window, ps per element, medians, before -> after: | kernel | -O2 | -O3 | -O3 -march=native | |------------------|-----------|----------|-------------------| | int sum, view | 193 -> 191 | 194 -> 83 | 217 -> 71 | | f32 map, view | 291 -> 174 | 302 -> 53 | 217 -> 42 | | int sum, bounded | 194 -> 195 | 196 -> 193 | 216 -> 214 | | f32 map, bounded | 291 -> 289 | 291 -> 289 | 218 -> 216 | The f32 map at -O3 reaches 53 ps against the issue's 45 ps upper bound, and -O2 matches its 1.7x and 1x. The int sum accumulates in a `long`, so each element is widened before it is added, which halves the vector width against the issue's int accumulator: 83 ps, against 47. Bounded slices take the checked body, as before. Tests: - tests/compiler/test_codegen.c: the view clone and the entry test are emitted, and a slice bound again, addressed or captured is not versioned. - tests/regression/test_slice_view_versioning.ae: - sums and fills give the same answers through views and bounded slices; - a view beside a bounded slice takes the checked body; - a bounded slice still traps out of bounds inside a versioned function. - tests/integration/threaded_dispatch: 5 notes, 18 tables. docs/language-reference.md (Slices) says when a function is versioned and what it changes. Checked: make test (597) passes, as do the regression test, threaded_dispatch, lane_wide and the codegen tests. --- compiler/aether_error.c | 19 ++- compiler/aether_error.h | 4 + compiler/codegen/codegen.h | 11 ++ compiler/codegen/codegen_expr.c | 27 +++ compiler/codegen/codegen_func.c | 155 +++++++++++++++++- compiler/codegen/codegen_stmt.c | 3 +- docs/language-reference.md | 2 + ...-performance-3029-slice-view-versioning.md | 13 ++ runtime/aether_slice.h | 15 ++ tests/compiler/test_codegen.c | 48 ++++++ .../test_threaded_dispatch.sh | 10 +- .../regression/test_slice_view_versioning.ae | 107 ++++++++++++ 12 files changed, 403 insertions(+), 11 deletions(-) create mode 100644 new_changelogs/20261022T030000Z-performance-3029-slice-view-versioning.md create mode 100644 tests/regression/test_slice_view_versioning.ae diff --git a/compiler/aether_error.c b/compiler/aether_error.c index 64055ffe5..6d61b6976 100644 --- a/compiler/aether_error.c +++ b/compiler/aether_error.c @@ -44,6 +44,19 @@ static const char* current_filename = NULL; static const char* current_source = NULL; static int error_count_global = 0; static int warning_count_global = 0; +/* While set, a diagnostic is neither printed nor counted. Codegen sets it + * around the second rendering of one function (#3029's view body): the + * same source emitted twice would otherwise report everything twice, and + * the other rendering reports it once. */ +static int diagnostics_muted = 0; + +void aether_diagnostics_set_muted(int muted) { + diagnostics_muted = muted ? 1 : 0; +} + +int aether_diagnostics_muted(void) { + return diagnostics_muted; +} // Get suggestion based on error code static const char* get_common_suggestion(AetherErrorCode code) { @@ -172,8 +185,8 @@ static const char* get_line(const char* source, int line_num, int* line_length) } void aether_error_report(AetherError* error) { - if (!error) return; - + if (!error || diagnostics_muted) return; + error_count_global++; /* A diagnostic built by hand often leaves filename and source_code NULL @@ -288,7 +301,7 @@ void aether_error_report(AetherError* error) { } void aether_warning_report(AetherError* warning) { - if (!warning) return; + if (!warning || diagnostics_muted) return; warning_count_global++; diff --git a/compiler/aether_error.h b/compiler/aether_error.h index 4c72059a8..6275772b4 100644 --- a/compiler/aether_error.h +++ b/compiler/aether_error.h @@ -60,6 +60,10 @@ void aether_error_get_source(const char** out_filename, const char** out_source) void aether_error_set_source(const char* filename, const char* source); void aether_error_report(AetherError* error); void aether_warning_report(AetherError* warning); +// While muted, errors, warnings and codegen notes are neither printed nor +// counted. Codegen mutes the second rendering of one function (#3029). +void aether_diagnostics_set_muted(int muted); +int aether_diagnostics_muted(void); // Helper functions void aether_error_simple(const char* message, int line, int column); diff --git a/compiler/codegen/codegen.h b/compiler/codegen/codegen.h index 2e42c05fd..0db003247 100644 --- a/compiler/codegen/codegen.h +++ b/compiler/codegen/codegen.h @@ -84,6 +84,17 @@ typedef struct { const char* current_origin_module; int module_global_var_count; int generating_lvalue; // Track if we're generating an assignment target (lvalue) + /* #3029: versioning on the unbounded-view sentinel. While a function's + * view body is emitted, `slice_view_names` are its slice parameters, + * every one an unbounded view there, indexed with no test of the + * sentinel; `fn_cname_override` is that body's C name. While the + * function itself is emitted, `slice_view_dispatch` names the same + * parameters for the test at its entry that calls the view body. */ + const char** slice_view_names; + int slice_view_count; + const char* fn_cname_override; + const char** slice_view_dispatch; + int slice_view_dispatch_count; int in_const_init; // #2799: depth inside a file-scope constant's initializer, where a // constant named is written as its own initializer (ISO C) int in_condition; // Track if we're in a condition (if/while) to avoid double parens diff --git a/compiler/codegen/codegen_expr.c b/compiler/codegen/codegen_expr.c index 37d703ee3..6b0fc5533 100644 --- a/compiler/codegen/codegen_expr.c +++ b/compiler/codegen/codegen_expr.c @@ -11575,6 +11575,33 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { * through aether_slice_at_w, which refuses a read-only * view; a read is checked as it was. */ const char* elem = slice_elem_c_type(base->node_type); + /* #3029: in a function's view body its slice parameters + * are unbounded views, tested once at entry: the element's + * address with no test of the sentinel or a bound (a + * store still refuses a read-only view). */ + int view = 0; + if (gen->slice_view_count > 0 && base->type == AST_IDENTIFIER && base->value) { + for (int k = 0; k < gen->slice_view_count; k++) + if (strcmp(gen->slice_view_names[k], base->value) == 0) view = 1; + } + if (view) { + fprintf(gen->output, "(*(%s*)aether_slice_view_at%s(", elem, + gen->generating_lvalue ? "_w" : ""); + int saved = gen->generating_lvalue; + gen->generating_lvalue = 0; + generate_expression(gen, base); + fprintf(gen->output, ", (int64_t)("); + generate_read(gen, expr->children[1]); + gen->generating_lvalue = saved; + fprintf(gen->output, "), sizeof(%s)", elem); + if (gen->generating_lvalue) { + fprintf(gen->output, ", "); + emit_slice_diag_file(gen, expr); + fprintf(gen->output, ", %d", expr->line); + } + fprintf(gen->output, "))"); + break; + } fprintf(gen->output, "(*(%s*)aether_slice_at%s(", elem, gen->generating_lvalue ? "_w" : ""); /* The base is READ even when `s[i]` is a store target, diff --git a/compiler/codegen/codegen_func.c b/compiler/codegen/codegen_func.c index e0da925ad..c7e176744 100644 --- a/compiler/codegen/codegen_func.c +++ b/compiler/codegen/codegen_func.c @@ -1,4 +1,5 @@ #include "codegen_internal.h" +#include "../aether_error.h" // True when this function definition is annotated `@c_callback`. // The annotation (#235) marks a function as having a stable, externally- @@ -1827,7 +1828,10 @@ static void emit_function(CodeGenerator* gen, ASTNode* func, const char* clause_ // generated C colliding at link time. Closes #279. // A clause function is static whatever its set's linkage: only the // set's dispatcher calls it. - if (as_clause) { + if (gen->fn_cname_override) { + // A function's view body (#3029): only its own entry calls it. + fprintf(gen->output, "static AETHER_MAYBE_UNUSED "); + } else if (as_clause) { fprintf(gen->output, fn_body_inlinable(func) ? "static inline AETHER_MAYBE_UNUSED " : "static AETHER_MAYBE_UNUSED "); } else if (fn_has_internal_linkage(func)) { @@ -1867,8 +1871,9 @@ static void emit_function(CodeGenerator* gen, ASTNode* func, const char* clause_ // mangling); the symbol is what other translation units reach for // when they take the address of this function. const char* cb_sym = c_callback_symbol(func); - fprintf(gen->output, " %s(", as_clause ? clause_cname - : (cb_sym ? cb_sym : safe_c_name(func->value))); + fprintf(gen->output, " %s(", gen->fn_cname_override ? gen->fn_cname_override + : as_clause ? clause_cname + : (cb_sym ? cb_sym : safe_c_name(func->value))); // Generate parameters - handle pattern matching. A parameter's C name // carries its position: `_pattern_` for a literal or struct @@ -2003,6 +2008,33 @@ static void emit_function(CodeGenerator* gen, ASTNode* func, const char* clause_ fprintf(gen->output, ") {\n"); + /* #3029: one test of the sentinel at entry. Every slice parameter the + * body indexes an unbounded view: the view body runs instead, with the + * arguments as they arrived. Otherwise this checked body does. */ + if (gen->slice_view_dispatch_count > 0) { + fprintf(gen->output, " if ("); + for (int k = 0; k < gen->slice_view_dispatch_count; k++) { + fprintf(gen->output, "%s%s.len == AETHER_SLICE_UNBOUNDED", k ? " && " : "", + gen->slice_view_dispatch[k]); + } + fprintf(gen->output, ") %s%s__aether_view(", result ? "return " : "{ ", + safe_c_name(func->value)); + int na = 0; + for (int i = 0; i < func->child_count; i++) { + ASTNode* child = func->children[i]; + if (!child || !child->value || (child->type != AST_PATTERN_VARIABLE && + child->type != AST_VARIABLE_DECLARATION)) continue; + int is_promoted = 0; + for (int pp = 0; pp < promoted_count; pp++) + if (promoted[pp] && strcmp(promoted[pp], child->value) == 0) is_promoted = 1; + fprintf(gen->output, "%s%s%s", na ? ", " : "", + (is_promoted || is_sized_array_param(child->node_type)) ? "_param_" : "", + child->value); + na++; + } + fprintf(gen->output, result ? ");\n" : "); return; }\n"); + } + /* std.mem scalar accessors: emit the body inline instead of the extern * call (#1733). * @@ -2290,7 +2322,124 @@ static void emit_function(CodeGenerator* gen, ASTNode* func, const char* clause_ print_line(gen, ""); } +/* ---- #3029: versioning on the unbounded-view sentinel --------------------- + * + * A function whose caller the C compiler cannot see (another translation + * unit, a function pointer, a library boundary) cannot tell a bounded slice + * from an unbounded view (`p as T[]`, its length the sentinel), so every + * index of a slice parameter tests the sentinel and then the bound, and the + * loop around it does not vectorize: 4.2x on an int sum and 6.4x on an f32 + * map at -O3 (the issue's table). Such a function is emitted twice: a view + * body, where every slice parameter it indexes is a view and an index is + * the element's address with no test (a view has no length to test, which + * is what the checked access does with it too), and the function itself, + * whose entry tests the sentinel once and calls the view body when every + * one of them is a view, and runs its own checked body otherwise. Per + * function, not per loop: a body's declarations, defers and labels are the + * function's. A slice the function binds again, takes the address of, or + * that a closure inside it captures, is not versioned. */ + +/* Whether `operand`, under any member accesses, is the identifier `name`: + * `&xs` or `&xs.field`, the address of the slice itself rather than of an + * element. */ +static int view_param_address_of(ASTNode* operand, const char* name) { + while (operand && operand->type == AST_MEMBER_ACCESS && operand->child_count > 0) + operand = operand->children[0]; + return operand && operand->type == AST_IDENTIFIER && operand->value && + strcmp(operand->value, name) == 0; +} + +/* Whether `node` binds, assigns or names `name` other than by reading it, + * or takes its address: through the address the slice can be made + * bounded after the entry's test, and the view body would index it with + * no check. */ +static int view_param_rebound(ASTNode* node, const char* name) { + if (!node) return 0; + if (node->value && node->type != AST_IDENTIFIER && strcmp(node->value, name) == 0) return 1; + if (node->type == AST_UNARY_EXPRESSION && node->value && strcmp(node->value, "&") == 0 && + node->child_count > 0 && view_param_address_of(node->children[0], name)) return 1; + for (int i = 0; i < node->child_count; i++) + if (view_param_rebound(node->children[i], name)) return 1; + return 0; +} + +/* Whether `node` indexes the identifier `name`. */ +static int view_param_indexed(ASTNode* node, const char* name) { + if (!node) return 0; + if (node->type == AST_ARRAY_ACCESS && node->child_count >= 2 && node->children[0] && + node->children[0]->type == AST_IDENTIFIER && node->children[0]->value && + strcmp(node->children[0]->value, name) == 0) return 1; + for (int i = 0; i < node->child_count; i++) + if (view_param_indexed(node->children[i], name)) return 1; + return 0; +} + +/* Whether `node` holds a closure or a quote: a body of its own, emitted + * once, which a second copy of the function must not emit again. */ +static int view_body_has_closure(ASTNode* node) { + if (!node) return 0; + if (node->type == AST_CLOSURE) return 1; + for (int i = 0; i < node->child_count; i++) + if (view_body_has_closure(node->children[i])) return 1; + return 0; +} + +/* The slice parameters of `func` that its body indexes and never binds + * again, at most `max`; 0 when the function is not one to version. */ +static int slice_view_params(CodeGenerator* gen, ASTNode* func, const char** out, int max) { + if (!func || func->type != AST_FUNCTION_DEFINITION || !func->value) return 0; + if (annotation_has_marker(func->annotation, "varargs") || is_c_callback(func)) return 0; + ASTNode* body = NULL; + for (int i = 0; i < func->child_count; i++) { + ASTNode* child = func->children[i]; + if (!child) return 0; + if (child->type == AST_BLOCK) { + body = child; + } else if (child->type != AST_PATTERN_VARIABLE && + child->type != AST_VARIABLE_DECLARATION) { + return 0; // a pattern or a guard: a clause shape, dispatched elsewhere + } + } + if (!body || view_body_has_closure(body)) return 0; + char** promoted = NULL; + int promoted_count = 0; + get_promoted_names_for_func(gen, fn_scope_name(gen->program, func), &promoted, &promoted_count); + int n = 0; + for (int i = 0; i < func->child_count && n < max; i++) { + ASTNode* child = func->children[i]; + if (child->type == AST_BLOCK || !child->value || !type_is_slice(child->node_type)) continue; + int is_promoted = 0; + for (int p = 0; p < promoted_count; p++) + if (promoted[p] && strcmp(promoted[p], child->value) == 0) is_promoted = 1; + if (is_promoted || view_param_rebound(body, child->value)) continue; + if (view_param_indexed(body, child->value)) out[n++] = child->value; + } + return n; +} + void generate_function_definition(CodeGenerator* gen, ASTNode* func) { + const char* views[8]; + int nviews = slice_view_params(gen, func, views, 8); + if (nviews > 0) { + gen->slice_view_names = views; + gen->slice_view_count = nviews; + gen->fn_cname_override = cg_internf("%s__aether_view", safe_c_name(func->value)); + /* The same source is rendered twice: the view body reports + * nothing, and the function's own body, emitted next, reports + * every diagnostic once. */ + aether_diagnostics_set_muted(1); + emit_function(gen, func, NULL); + aether_diagnostics_set_muted(0); + gen->slice_view_names = NULL; + gen->slice_view_count = 0; + gen->fn_cname_override = NULL; + gen->slice_view_dispatch = views; + gen->slice_view_dispatch_count = nviews; + emit_function(gen, func, NULL); + gen->slice_view_dispatch = NULL; + gen->slice_view_dispatch_count = 0; + return; + } emit_function(gen, func, NULL); } diff --git a/compiler/codegen/codegen_stmt.c b/compiler/codegen/codegen_stmt.c index f21edeaa2..8c518a52b 100644 --- a/compiler/codegen/codegen_stmt.c +++ b/compiler/codegen/codegen_stmt.c @@ -5911,7 +5911,8 @@ static void td_explain(ASTNode* loop, const char* why) { const char* v = getenv("AETHER_EXPLAIN_THREADED"); on = (v && *v && strcmp(v, "0") != 0) ? 1 : 0; } - if (!on || !loop || loop->child_count < 2 || !loop->children[1]) return; + if (!on || aether_diagnostics_muted() || !loop || loop->child_count < 2 || + !loop->children[1]) return; /* Neither the while nor the switch node carries a position; the * switch's selector expression does. */ ASTNode* body = loop->children[1]; diff --git a/docs/language-reference.md b/docs/language-reference.md index cd140730c..d18bd4f67 100644 --- a/docs/language-reference.md +++ b/docs/language-reference.md @@ -411,6 +411,8 @@ view = raw as byte[] // view.len == -1, view[i] is unchecked bytes = view[0..64] // bytes.len == 64, bytes[i] is checked ``` +A function that indexes a slice parameter is compiled twice when nothing in it binds that parameter again or takes its address, and it holds no closure (#3029). Its entry tests once whether every such parameter is an unbounded view. If so, a second body runs, where an index of them is the element's address with no test, and the loop around it can vectorize. Otherwise the function's own body runs, with every check. The answer and every bounds check are the same either way. What changes is the cost when the C compiler cannot see where the slice came from (the function is reached through a function value, from another translation unit or a library): there, an index of a view used to test the length sentinel and the bound at every element. + In C the slice is `AetherSlice { void* ptr; uint64_t len; }` (`runtime/aether_slice.h`). A `T[]` parameter or return of an *Aether* function is that struct by value; a `T[]` parameter or return of an `extern` C function, and a `T[]` field of an `extern struct`, stay the bare `T*` C declares. See [c-interop.md](c-interop.md#typed-array-view-expr-as-t) for the boundary rules. ### Sequence Types (`*StringSeq`) diff --git a/new_changelogs/20261022T030000Z-performance-3029-slice-view-versioning.md b/new_changelogs/20261022T030000Z-performance-3029-slice-view-versioning.md new file mode 100644 index 000000000..a1b45f61e --- /dev/null +++ b/new_changelogs/20261022T030000Z-performance-3029-slice-view-versioning.md @@ -0,0 +1,13 @@ +- **A function that indexes its slice parameters runs a view body when it + is given views.** Called where the C compiler cannot see the argument's + origin (another file, a function value, a library boundary), every index + of a `T[]` parameter tested for an unbounded view (`p as T[]`) and then + the bound, and the loop around it did not vectorize. Such a function now + has two bodies: one test at entry calls a view body, which indexes with + no test, when every slice parameter it indexes is a view, and the + function's own checked body runs otherwise. A bounded slice is checked + as before and still panics out of bounds. Measured with the origin + hidden, ps per element at -O3: an f32 map over views 302 to 53, an int + sum 194 to 83; bounded slices unchanged. A slice the function binds + again, takes the address of, or that a closure in it captures, is not + versioned (#3029). diff --git a/runtime/aether_slice.h b/runtime/aether_slice.h index dfd68a3d2..3c62e2f55 100644 --- a/runtime/aether_slice.h +++ b/runtime/aether_slice.h @@ -171,6 +171,21 @@ static inline void* aether_slice_at_w(AetherSlice s, int64_t i, size_t elem_size return aether_slice_at(s, i, elem_size, file, line); } +/* #3029: `s[i]` in a function's view body, where the function's entry has + * tested once that `s` is an unbounded view: the element's address with no + * test, which is what aether_slice_at does for a view after its test of + * the sentinel. Without that test in the loop the compiler can vectorize. */ +static inline void* aether_slice_view_at(AetherSlice s, int64_t i, size_t elem_size) { + return (char*)s.ptr + (uint64_t)i * elem_size; +} + +/* #3029: a store there, which still refuses a read-only view. */ +static inline void* aether_slice_view_at_w(AetherSlice s, int64_t i, size_t elem_size, + const char* file, int line) { + if (s.flags & AETHER_SLICE_READONLY) aether_slice_fail_readonly(file, line); + return (char*)s.ptr + (uint64_t)i * elem_size; +} + /* `s[lo..hi]` — the half-open sub-slice [lo, hi), sharing the backing * store. `hi` past the end, `lo > hi`, or a negative bound is a panic; * on an unbounded view only `lo <= hi` and `lo >= 0` can be checked. */ diff --git a/tests/compiler/test_codegen.c b/tests/compiler/test_codegen.c index d5124694f..40b6ce6d5 100644 --- a/tests/compiler/test_codegen.c +++ b/tests/compiler/test_codegen.c @@ -132,6 +132,54 @@ TEST(codegen_fnptr_local_string_arg_passes_bytes) { free(buf); } +/* #3029: a function indexing a slice parameter it never binds again is + * versioned on the unbounded-view sentinel: one test at entry calls a view + * body, which indexes with no test; the function's own body keeps its + * checks. */ +TEST(codegen_slice_param_versioned_on_the_view_sentinel) { + char* buf = generate_typechecked( + "total(xs: int[], n: int) -> int {\n" + " t = 0\n" + " i = 0\n" + " while i < n {\n" + " t = t + xs[i]\n" + " i = i + 1\n" + " }\n" + " return t\n" + "}\n" + "main() { }"); + ASSERT_NOT_NULL(buf); + ASSERT_TRUE(strstr(buf, "total__aether_view(AetherSlice xs, int n)") != NULL); + ASSERT_TRUE(strstr(buf, "if (xs.len == AETHER_SLICE_UNBOUNDED) return total__aether_view(xs, n);") != NULL); + ASSERT_TRUE(strstr(buf, "aether_slice_view_at(xs, ") != NULL); + ASSERT_TRUE(strstr(buf, "aether_slice_at(xs, ") != NULL); // the checked body + free(buf); +} + +/* A slice the function binds again, takes the address of, or a body + * holding a closure, is not versioned: its uses are not all the argument + * as it arrived. Through `&xs` the slice can be made bounded after the + * entry's test. */ +TEST(codegen_slice_param_not_versioned_when_rebound_or_captured) { + char* buf = generate_typechecked( + "rebinds(xs: int[]) -> int {\n" + " xs = xs[1..3]\n" + " return xs[0]\n" + "}\n" + "addressed(xs: int[]) -> int {\n" + " p = &xs\n" + " return xs[0]\n" + "}\n" + "captures(xs: int[]) -> int {\n" + " f = || { return xs[0] }\n" + " return f() + xs[1]\n" + "}\n" + "main() { }"); + ASSERT_NOT_NULL(buf); + ASSERT_TRUE(strstr(buf, "__aether_view") == NULL); + free(buf); +} + TEST(codegen_fnptr_param_string_arg_passes_bytes) { char* buf = generate_typechecked( "through(cb: fn(int, string) -> int, s: string) -> int { return cb(1, s) }\n" diff --git a/tests/integration/threaded_dispatch/test_threaded_dispatch.sh b/tests/integration/threaded_dispatch/test_threaded_dispatch.sh index e79655796..e7320f0ea 100755 --- a/tests/integration/threaded_dispatch/test_threaded_dispatch.sh +++ b/tests/integration/threaded_dispatch/test_threaded_dispatch.sh @@ -33,11 +33,13 @@ if "$AETHERC" "$REG" "$tmp/reg.c" > "$tmp/reg.log" 2>&1; then tables=$(grep -c 'static void\* const _ae_td_tbl_' "$tmp/reg.c") jumps=$(grep -c 'goto \*_ae_td_tbl_' "$tmp/reg.c") # Nine loops: basic, with_default, head_continue, labeled, nested (two), - # cleanups, wide, with_tail. - if [ "$tables" = "9" ] && [ "$jumps" -gt 9 ]; then - ok "the regression test's 9 dispatch loops are threaded ($jumps dispatch sites)" + # cleanups, wide, with_tail. Each function indexes the program slice it + # is given, so each has two bodies (#3029: a view body, and the checked + # one), and every loop is threaded in both: 18 tables. + if [ "$tables" = "18" ] && [ "$jumps" -gt 18 ]; then + ok "the regression test's 9 dispatch loops are threaded in both bodies ($jumps dispatch sites)" else - bad "expected 9 dispatch tables and more than 9 jumps, got $tables and $jumps" + bad "expected 18 dispatch tables and more than 18 jumps, got $tables and $jumps" fi else bad "aetherc failed on the regression test" diff --git a/tests/regression/test_slice_view_versioning.ae b/tests/regression/test_slice_view_versioning.ae new file mode 100644 index 000000000..0741cb044 --- /dev/null +++ b/tests/regression/test_slice_view_versioning.ae @@ -0,0 +1,107 @@ +// #3029: a function indexing a slice parameter it never binds again is +// emitted twice, and its entry tests the unbounded-view sentinel once: a view +// runs a body that indexes with no test, anything else the checked body. +// Both must answer the same, a bounded slice must still trap out of bounds +// in the versioned function, and a store through a view must still land. + +import std.string + +extern exit(code: int) +extern malloc(n: long) -> ptr +extern free(p: ptr) + +fn check(cond: bool, msg: string) { + if !cond { + println("FAIL: ${msg}") + exit(1) + } +} + +// Read: a sum over the first n elements. +fn total(xs: int[], n: int) -> long { + long t = 0 + i = 0 + while i < n { + t = t + xs[i] + i = i + 1 + } + return t +} + +// Write: fills the first n elements, then reads one back. +fn fill(ys: int[], n: int, v: int) -> int { + i = 0 + while i < n { + ys[i] = v + i + i = i + 1 + } + return ys[n - 1] +} + +// Two slices: the view body only when both are views. +fn dot(a: float[], b: float[], n: int) -> float { + s = 0.0 + i = 0 + while i < n { + s = s + a[i] * b[i] + i = i + 1 + } + return s +} + +// An index past a bounded slice's end. +fn at(xs: int[], i: int) -> int { + return xs[i] +} + +main() { + println("=== slice parameters versioned on the view sentinel (#3029) ===") + n = 1000 + + bounded = make([]int, n) + i = 0 + while i < n { + bounded[i] = i + i = i + 1 + } + raw = malloc(n * 4) + view = raw as int[] + check(fill(view, n, 0) == n - 1, "a store through a view lands") + check(view.len == -1, "a pointer as int[] is an unbounded view") + + check(total(bounded, n) == 499500, "bounded sum") + check(total(view, n) == 499500, "view sum, the same answer") + check(fill(bounded, n, 7) == n + 6, "bounded fill") + check(total(bounded, n) == 499500 + 7 * n, "bounded sum after the fill") + + fa = make([]float, 4) + fraw = malloc(4 * 8) + fv = fraw as float[] + i = 0 + while i < 4 { + fa[i] = 1.0 + i + fv[i] = 2.0 + i = i + 1 + } + check(dot(fv, fv, 4) == 16.0, "two views") + check(dot(fa, fv, 4) == 20.0, "a slice and a view: the checked body") + check(dot(fa, fa, 4) == 30.0, "two slices") + + // Still trapped in the versioned function: the bounded slice runs the + // checked body. + caught = 0 + try { + x = at(bounded, n) + println("FAIL: no panic (${x})") + exit(1) + } catch e { + check(string.ends_with(e, " out of range for length 1000") == 1, "the bound's message: ${e}") + caught = 1 + } + check(caught == 1, "the out-of-bounds index trapped") + check(at(view, 5) == 5, "a view is not bounded, as before") + + free(raw) + free(fraw) + println("All PASS") +} From a1bedf1502d3712c1d7219e94a0e241e9e8c307d Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Wed, 21 Oct 2026 23:55:53 -0300 Subject: [PATCH 07/50] A write to .len of a slice or an array is a type error (#3104) `.len` is a value computed from the slice (#1286): a slice's length is fixed when the slice is made, and an array's is a constant. A write to it passed the checker and stopped at the C compiler on generated code, a store to a non-lvalue ("lvalue required"): xs.len = 2 ((int)aether_slice_len(xs)) = 2 xs.len += 1 ((int)aether_slice_len(xs)) = (... + 1) xs.len++ (((int)aether_slice_len(xs)))++ a.len = 3 ((4) = 3) (int[4] a) reject_length_write (typechecker.c) refuses each, by the checker, at the three places a write is checked: - the assignment statement; - `=` as an expression, which a compound assignment becomes; - the operand of `++` and `--`, prefix and postfix. The message names the slice ("cannot write to the length of 'xs': a slice's length is fixed when the slice is made, and an array's is a constant"). The hint points at `xs[lo..hi]` for fewer elements. A struct's own field named `len` is storage like any other and takes every form. Found testing #3029, whose versioning asks whether a function writes to its slice parameters. Test: tests/integration/length_write. It refuses `=`, a compound assignment, `++` postfix, `--` prefix and an array's `.len = 3`, each with the message and the hint, and builds and runs a struct with a `len` field that takes them all. On the old compiler every refused program compiled. docs/language-reference.md (Slices): `s.len` is read, never written. Checked: make test (597) passes. --- compiler/analysis/typechecker.c | 37 +++++- docs/language-reference.md | 2 +- ...22T030100Z-fixed-3104-len-write-refused.md | 5 + .../length_write/test_length_write.sh | 118 ++++++++++++++++++ 4 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 new_changelogs/20261022T030100Z-fixed-3104-len-write-refused.md create mode 100644 tests/integration/length_write/test_length_write.sh diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index 557f1935e..88c30afe8 100644 --- a/compiler/analysis/typechecker.c +++ b/compiler/analysis/typechecker.c @@ -1728,6 +1728,34 @@ static int reject_string_char_write(ASTNode* target, SymbolTable* table, ASTNode return 1; } +/* #3104: a write to the length of a slice or an array: `xs.len = n`, + * `xs.len += n`, `xs.len++`. `.len` is a value computed from the slice + * (#1286): a slice's length is fixed when the slice is made, and an + * array's is a constant. The write reached the C compiler as a store to a + * non-lvalue ("lvalue required"). Returns 1 when it reported. */ +static int reject_length_write(ASTNode* target, SymbolTable* table, ASTNode* at) { + if (!target || target->type != AST_MEMBER_ACCESS || !target->value || + strcmp(target->value, "len") != 0 || target->child_count < 1 || !target->children[0]) + return 0; + Type* ot = infer_type(target->children[0], table); + int is_array = ot && ot->kind == TYPE_ARRAY; + if (ot) free_type(ot); + if (!is_array) return 0; + ASTNode* base = target->children[0]; + int named = base->type == AST_IDENTIFIER && base->value; + char msg[300]; + char hint[300]; + snprintf(msg, sizeof(msg), + "cannot write to the length of %s%s%s: a slice's length is fixed when the slice " + "is made, and an array's is a constant", + named ? "'" : "", named ? base->value : "a slice or an array", named ? "'" : ""); + snprintf(hint, sizeof(hint), + "for fewer elements, take a slice of the same ones: `%s[lo..hi]`", + named ? base->value : "xs"); + type_error_hint(msg, hint, at->line, at->column); + return 1; +} + static int is_integer_scalar(TypeKind kind) { return kind == TYPE_INT || kind == TYPE_INT64 || kind == TYPE_UINT64 || kind == TYPE_UINT32 || kind == TYPE_UINT16 || kind == TYPE_UINT8; @@ -13686,7 +13714,8 @@ int typecheck_statement(ASTNode* stmt, SymbolTable* table) { ASTNode* right = stmt->children[1]; if (reject_const_element_write(left, table, stmt) || /* #2516 */ reject_string_char_write(left, table, stmt) || /* #3022 */ - reject_readonly_view_write(left, stmt)) /* #3025 */ + reject_readonly_view_write(left, stmt) || /* #3025 */ + reject_length_write(left, table, stmt)) /* #3104 */ return 0; Symbol* symbol = lookup_symbol(table, left->value); @@ -16758,7 +16787,8 @@ static int reject_step_operand(ASTNode* expr, SymbolTable* table, const char* wr if (!t) return 0; if (reject_const_element_write(t, table, expr) || /* #2516 */ reject_string_char_write(t, table, expr) || - reject_readonly_view_write(t, expr)) /* #3025 */ + reject_readonly_view_write(t, expr) || /* #3025 */ + reject_length_write(t, table, expr)) /* #3104 */ return 1; char msg[512]; char hint[640]; @@ -17140,7 +17170,8 @@ int typecheck_binary_expression(ASTNode* expr, SymbolTable* table) { if (operator == TOKEN_ASSIGN) { if (reject_const_element_write(left, table, expr) || /* #2516 */ reject_string_char_write(left, table, expr) || /* #3022 */ - reject_readonly_view_write(left, expr)) { /* #3025 */ + reject_readonly_view_write(left, expr) || /* #3025 */ + reject_length_write(left, table, expr)) { /* #3104 */ free_type(left_type); free_type(right_type); return 0; diff --git a/docs/language-reference.md b/docs/language-reference.md index d18bd4f67..dc60aa4de 100644 --- a/docs/language-reference.md +++ b/docs/language-reference.md @@ -392,7 +392,7 @@ main() { 2 2 5 ``` -- **`s.len`** is the element count, an `int`. +- **`s.len`** is the element count, an `int`. It is read, never written: a slice's length is fixed when the slice is made, and an array's is a constant, so `s.len = n` (or `+=`, `++`) is a type error that points at `s[lo..hi]` for fewer elements (#3104). - **`s[i]` is bounds-checked.** An index outside `0 ..< s.len` is a runtime panic naming the source line, the index and the length (`slice index 5 out of range for length 3`). It is an ordinary panic: `try` catches it, and an actor step that trips it dies cleanly. A negative index is out of range, never a read before the buffer. - **`s[lo..hi]`** is the half-open sub-slice `[lo, hi)`, checked the same way (`lo <= hi <= s.len`). `s[lo..]` runs to the end, `s[..hi]` from the start, `s[..]` is the whole thing. The base may be a slice or a `T[N]` array; the result is always a `T[]` over the *same* elements, so a write through it is visible through the original. - **A `T[N]` array converts to `T[]` implicitly** wherever a slice is expected: a parameter, a `T[]`-typed binding or field, a `return` from a `-> T[]` function. The length travels with it. `null`, and the empty literal `[]`, convert to the empty slice: `.len` is `0`, it views no storage, and it compares equal to `null`. So `s == []` is `s == null` (it asks whether `s` views any storage; test `s.len == 0` for emptiness), a C function's `T[]` parameter takes `[]` as the null pointer, and an if-expression or a `match` given to a `T[]` converts each arm on its own. A module-level `var` or `const` of type `T[]` takes either as its initial value. diff --git a/new_changelogs/20261022T030100Z-fixed-3104-len-write-refused.md b/new_changelogs/20261022T030100Z-fixed-3104-len-write-refused.md new file mode 100644 index 000000000..ace056df8 --- /dev/null +++ b/new_changelogs/20261022T030100Z-fixed-3104-len-write-refused.md @@ -0,0 +1,5 @@ +- **A write to `.len` of a slice or an array is a type error.** `xs.len = 2`, + `xs.len += 1`, `xs.len++` and `a.len = 3` on an array passed the checker + and failed in the C compiler on generated code ("lvalue required"). The + checker refuses each with its own message and points at `xs[lo..hi]` for + fewer elements (#3104). diff --git a/tests/integration/length_write/test_length_write.sh b/tests/integration/length_write/test_length_write.sh new file mode 100644 index 000000000..50411e785 --- /dev/null +++ b/tests/integration/length_write/test_length_write.sh @@ -0,0 +1,118 @@ +#!/bin/sh +# #3104: `.len` of a slice or an array is a value computed from it (#1286): a +# slice's length is fixed when the slice is made, and an array's is a +# constant. A write to it passed the checker and stopped at the C compiler +# ("lvalue required"), on generated C: `((int)aether_slice_len(xs)) = 2`, +# and `((4) = 3)` for an array. Each way of spelling the write is refused +# here with the checker's own message: `=` as a statement, a compound +# assignment, `++` and `--` in both positions, and `=` on an array. A +# struct's own field named `len` is storage and still takes every one of +# them. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +AETHERC="$ROOT/build/aetherc" +if [ ! -x "$AE" ] && [ ! -x "$AE.exe" ]; then echo " [SKIP] length_write: $AE not built"; exit 0; fi +export AETHER_HOME="$ROOT" +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp" || true' EXIT +fail=0 + +# ; the program is on stdin. +refused() { + name="$1"; want="$2" + cat > "$tmp/$name.ae" + if "$AETHERC" "$tmp/$name.ae" "$tmp/$name.c" >"$tmp/$name.log" 2>&1; then + echo " [FAIL] length_write: $name compiled (it must be refused before C)" + fail=1; return + fi + if ! grep -qF "$want" "$tmp/$name.log"; then + echo " [FAIL] length_write: $name refused, but not with: $want" + sed 's/^/ /' "$tmp/$name.log" | head -6 + fail=1 + fi +} + +fixed="a slice's length is fixed when the slice is made, and an array's is a constant" + +refused assign "cannot write to the length of 'xs': $fixed" <<'AE' +shorten(xs: int[]) -> int { + xs.len = 2 + return xs[0] +} +main() { + println("${shorten(make([]int, 4))}") +} +AE +refused compound "cannot write to the length of 'xs': $fixed" <<'AE' +grow(xs: int[]) -> int { + xs.len += 1 + return 0 +} +main() { + println("${grow(make([]int, 4))}") +} +AE +refused step_post "cannot write to the length of 'xs': $fixed" <<'AE' +step(xs: int[]) -> int { + xs.len++ + return 0 +} +main() { + println("${step(make([]int, 4))}") +} +AE +refused step_pre "cannot write to the length of 'ys': $fixed" <<'AE' +main() { + ys = make([]int, 4) + --ys.len + println("${ys.len}") +} +AE +refused array "cannot write to the length of 'a': $fixed" <<'AE' +main() { + int[4] a + a.len = 3 + println("${a.len}") +} +AE +refused hint "take a slice of the same ones: \`xs[lo..hi]\`" <<'AE' +shorten(xs: int[]) -> int { + xs.len = 2 + return xs[0] +} +main() { + println("${shorten(make([]int, 4))}") +} +AE + +# A field of a struct's own named `len` is storage like any other. +cat > "$tmp/accepted.ae" <<'AE' +struct Run { + len: int +} +main() { + r = Run { len: 1 } + r.len = 5 + r.len += 2 + r.len++ + --r.len + xs = make([]int, 4) + println("${r.len} ${xs.len} ${xs[1..3].len}") +} +AE +if ! "$AE" build "$tmp/accepted.ae" -o "$tmp/accepted" >"$tmp/build.log" 2>&1; then + echo " [FAIL] length_write: the accepted forms did not build:" + grep -m6 -iE "error" "$tmp/build.log" | sed 's/^/ /' + fail=1 +else + got="$("$tmp/accepted" 2>&1 | tr -d '\r')" + if [ "$got" != "7 4 2" ]; then + echo " [FAIL] length_write: the accepted forms printed '$got', want '7 4 2'" + fail=1 + fi +fi + +if [ "$fail" -eq 0 ]; then + echo " [PASS] length_write: a write to a slice's or an array's .len is refused before C, by =, a compound assignment, ++ and --; a struct's field named len still takes them" +fi +exit "$fail" From 638c0ed0a258935e450f1edded8d370168bdfcfd Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Wed, 21 Oct 2026 23:56:11 -0300 Subject: [PATCH 08/50] Shell tests compare files with one helper every leg can run (#3110) cmp and diff come from diffutils, which an MSYS2 install can lack (the Windows CI shell has neither). A test that called one there failed for the missing tool and read as a wrong result. In the local make test-ae sweep: [FAIL] ae_add_linux_libc: wrong bytes for linux-x86_64 ...: line 98: cmp: command not found tests/lib/same_bytes.sh defines `same_bytes a b`. It uses cmp where there is one and otherwise compares cksum's line (CRC and byte count; coreutils, on every leg). A file it cannot read matches nothing. Every comparison of that kind goes through it: - ae_add_linux_libc: its five cmp calls; - http_server_h2: the POST round trip; - fmt_gate: the branch that names which invariant broke, which named the wrong one without cmp; - tests/differential/opt_diff_one.sh: its own cksum fallback is replaced by the helper; - tests/differential/run_differential.sh: diff -u decided whether two outputs agree, so without diff every case read as diverged. diff now only shows a difference, when there is one and diff is present. Its self-check proved only that a difference is detected, which a tool that cannot run also "detects". It now proves both directions, as opt_diff_one's --self-check already did. Checked: ae_add_linux_libc passes in the MSYS2 shell that has no cmp (it failed there), fmt_gate passes, and the opt_diff self-check passes. make test (597) passes. --- tests/differential/opt_diff_one.sh | 11 ++++----- tests/differential/run_differential.sh | 23 ++++++++++++------- .../test_ae_add_linux_libc.sh | 11 +++++---- tests/integration/fmt_gate/test_fmt_gate.sh | 3 ++- .../http_server_h2/test_http_server_h2.sh | 5 ++-- tests/lib/same_bytes.sh | 16 +++++++++++++ 6 files changed, 46 insertions(+), 23 deletions(-) create mode 100644 tests/lib/same_bytes.sh diff --git a/tests/differential/opt_diff_one.sh b/tests/differential/opt_diff_one.sh index 4894bae68..9fd56d22c 100755 --- a/tests/differential/opt_diff_one.sh +++ b/tests/differential/opt_diff_one.sh @@ -18,14 +18,11 @@ # so a program that prints argv[0] or its own location agrees with itself. set -u -# cmp and diff come from diffutils, which an MSYS2 install can lack; cksum is -# in coreutils everywhere. +# cmp comes from diffutils, which an MSYS2 install can lack: the outputs are +# compared by the helper every test uses, which falls back to cksum. +. "$(cd "$(dirname "$0")" && pwd)/../lib/same_bytes.sh" same_output() { - if command -v cmp >/dev/null 2>&1; then - cmp -s "$1" "$2" - else - [ "$(cksum < "$1")" = "$(cksum < "$2")" ] - fi + same_bytes "$1" "$2" } show_diff() { diff --git a/tests/differential/run_differential.sh b/tests/differential/run_differential.sh index 5f57a2d08..e269c30e4 100755 --- a/tests/differential/run_differential.sh +++ b/tests/differential/run_differential.sh @@ -16,6 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +. "$ROOT/tests/lib/same_bytes.sh" AE="$ROOT/build/ae" if [ ! -x "$AE" ]; then @@ -108,23 +109,29 @@ for case_file in "$SCRIPT_DIR"/cases/*.ae; do fail=$((fail + 1)) continue fi - if ! diff -u "$TMP/$name.exe.out" "$TMP/$name.lib.out" >"$TMP/$name.diff" 2>&1; then + if ! same_bytes "$TMP/$name.exe.out" "$TMP/$name.lib.out"; then echo " [FAIL] differential/$name: output differs between lowering paths" - echo " --- --emit=exe / +++ --emit=lib" - sed 's/^/ /' "$TMP/$name.diff" | head -20 + if command -v diff >/dev/null 2>&1; then + echo " --- --emit=exe / +++ --emit=lib" + diff -u "$TMP/$name.exe.out" "$TMP/$name.lib.out" | sed 's/^/ /' | head -20 + else + echo " --emit=exe, first 10 lines:"; head -n 10 "$TMP/$name.exe.out" | sed 's/^/ /' + echo " --emit=lib, first 10 lines:"; head -n 10 "$TMP/$name.lib.out" | sed 's/^/ /' + fi fail=$((fail + 1)) continue fi pass=$((pass + 1)) done -# Self-check: prove the comparison can actually fail. Without this the suite -# would report success just as happily if the diff were broken and every -# comparison were vacuous. +# Self-check: prove the comparison tells two outputs apart AND matches two +# equal ones. Checking only the first passes when the tool cannot run, since +# a command that fails reads as "different". printf 'a\n' > "$TMP/self.a" printf 'b\n' > "$TMP/self.b" -if diff -q "$TMP/self.a" "$TMP/self.b" >/dev/null 2>&1; then - echo " [FAIL] differential: the comparison does not detect a difference" +printf 'a\n' > "$TMP/self.c" +if same_bytes "$TMP/self.a" "$TMP/self.b" || ! same_bytes "$TMP/self.a" "$TMP/self.c"; then + echo " [FAIL] differential: the comparison does not tell outputs apart" exit 1 fi diff --git a/tests/integration/ae_add_linux_libc/test_ae_add_linux_libc.sh b/tests/integration/ae_add_linux_libc/test_ae_add_linux_libc.sh index 5d7ac918f..7ece7e0d7 100755 --- a/tests/integration/ae_add_linux_libc/test_ae_add_linux_libc.sh +++ b/tests/integration/ae_add_linux_libc/test_ae_add_linux_libc.sh @@ -5,6 +5,7 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +. "$ROOT/tests/lib/same_bytes.sh" AE="$ROOT/build/ae${EXE_EXT:-}" COMPILER="${CC:-cc}" [ -x "$AE" ] || { echo "[SKIP] ae_add_linux_libc: ae not built"; exit 0; } @@ -95,13 +96,13 @@ for triple in linux-x86_64 linux-x86_64-musl linux-arm64 linux-arm64-musl; do new_proj "$triple" fetch --target "$triple" || { cat "$P/add.log"; fail "fetch $triple"; } INST="$P/home/.aether/packages/$PKG/libpkg.so" - cmp "$REL/libpkg-v1.0.0-$triple.so" "$INST" || fail "wrong bytes for $triple" + same_bytes "$REL/libpkg-v1.0.0-$triple.so" "$INST" || fail "wrong bytes for $triple" grep -q 'Checksum verified' "$P/add.log" || fail "unverified $triple" # Later fetches must not replace the already fetched glibc or musl bytes. # Each consumer has its own cache, as on separate hosts/build jobs. for prev in "$TMP"/project_linux-*; do expected="${prev##*/project_}" - cmp "$REL/libpkg-v1.0.0-$expected.so" "$prev/home/.aether/packages/$PKG/libpkg.so" \ + same_bytes "$REL/libpkg-v1.0.0-$expected.so" "$prev/home/.aether/packages/$PKG/libpkg.so" \ || fail "fetch $triple changed $expected" done done @@ -111,7 +112,7 @@ fetch --target linux-x86_64 || fail 'same-target cache reuse failed' if fetch --target linux-x86_64-musl; then fail 'glibc cache accepted for musl'; fi grep -q 'has target linux-x86_64; requested linux-x86_64-musl' "$P/add.log" \ || { cat "$P/add.log"; fail 'cache mismatch diagnostic'; } -cmp "$REL/libpkg-v1.0.0-linux-x86_64.so" "$P/home/.aether/packages/$PKG/libpkg.so" \ +same_bytes "$REL/libpkg-v1.0.0-linux-x86_64.so" "$P/home/.aether/packages/$PKG/libpkg.so" \ || fail 'incompatible request overwrote cached glibc bytes' # An old cache has no libc provenance, so it cannot safely satisfy a request. rm "$P/home/.aether/packages/$PKG/.aether-binary-target" @@ -132,7 +133,7 @@ case "$HOST_TRIPLE" in linux-x86_64|linux-x86_64-musl|linux-arm64|linux-arm64-musl) new_proj automatic fetch || { cat "$P/add.log"; fail 'automatic host fetch'; } - cmp "$REL/libpkg-v1.0.0-$HOST_TRIPLE.so" "$P/home/.aether/packages/$PKG/libpkg.so" \ + same_bytes "$REL/libpkg-v1.0.0-$HOST_TRIPLE.so" "$P/home/.aether/packages/$PKG/libpkg.so" \ || fail 'automatic fetch selected the wrong libc' case "$HOST_TRIPLE" in *-musl) other="${HOST_TRIPLE%-musl}" ;; @@ -140,7 +141,7 @@ case "$HOST_TRIPLE" in esac P="$TMP/project_$other" if fetch; then fail 'automatic selection reused the other libc from cache'; fi - cmp "$REL/libpkg-v1.0.0-$other.so" "$P/home/.aether/packages/$PKG/libpkg.so" \ + same_bytes "$REL/libpkg-v1.0.0-$other.so" "$P/home/.aether/packages/$PKG/libpkg.so" \ || fail 'automatic selection changed foreign cached bytes' ;; esac diff --git a/tests/integration/fmt_gate/test_fmt_gate.sh b/tests/integration/fmt_gate/test_fmt_gate.sh index 24e7ef0c6..706e9aa27 100755 --- a/tests/integration/fmt_gate/test_fmt_gate.sh +++ b/tests/integration/fmt_gate/test_fmt_gate.sh @@ -27,6 +27,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +. "$ROOT/tests/lib/same_bytes.sh" AE="$ROOT/build/ae" AETHERC="$ROOT/build/aetherc" @@ -233,7 +234,7 @@ while IFS= read -r f; do echo " [FAIL] fmt_gate: two compiles of UNFORMATTED $f differ (emission nondeterminism, not a formatter fault)" echo " sizes: $(wc -c < "$base.orig.c") vs $(wc -c < "$base.orig2.c") bytes" fmt_gate_dump_diff "$base.orig.c" "$base.orig2.c" - elif cmp -s "$f" "$TMPDIR/idem/$f" 2>/dev/null; then + elif same_bytes "$f" "$TMPDIR/idem/$f"; then echo " [FAIL] fmt_gate: formatting left $f byte-identical yet its C differs (emission instability)" fmt_gate_dump_diff "$base.orig.c" "$base.fmt.c" else diff --git a/tests/integration/http_server_h2/test_http_server_h2.sh b/tests/integration/http_server_h2/test_http_server_h2.sh index 4b6fb4852..0c6436cbd 100755 --- a/tests/integration/http_server_h2/test_http_server_h2.sh +++ b/tests/integration/http_server_h2/test_http_server_h2.sh @@ -45,6 +45,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" . "$ROOT/tests/lib/wait_port.sh" . "$ROOT/tests/lib/server_jobs.sh" +. "$ROOT/tests/lib/same_bytes.sh" AE="$ROOT/build/ae" if ! command -v curl >/dev/null 2>&1; then @@ -207,8 +208,8 @@ curl --silent --show-error --max-time 30 \ "$URL_BASE/echo" 2>"$TMPDIR/post.err" || { echo " [FAIL] /echo large POST failed:"; cat "$TMPDIR/post.err"; exit 1 } -# Compare bytes; cmp returns 0 on match. -cmp -s "$POST_BODY" "$POST_RESP" || { +# Compare bytes (tests/lib/same_bytes.sh: cmp, or cksum without diffutils). +same_bytes "$POST_BODY" "$POST_RESP" || { echo " [FAIL] /echo large POST round-trip mismatch" echo " sent $(wc -c <"$POST_BODY") bytes; received $(wc -c <"$POST_RESP") bytes" exit 1 diff --git a/tests/lib/same_bytes.sh b/tests/lib/same_bytes.sh new file mode 100644 index 000000000..f4249a221 --- /dev/null +++ b/tests/lib/same_bytes.sh @@ -0,0 +1,16 @@ +# same_bytes FILE1 FILE2: succeeds when the two files hold the same bytes. +# +# cmp comes from diffutils, which an MSYS2 install can lack (the Windows CI +# shell has none), and a test that called it there failed for a missing tool +# while reading as a wrong result ("cmp: command not found", then "wrong +# bytes"). cksum is in coreutils on every leg: it prints a CRC and the byte +# count, so two files with the same line hold the same bytes for any test's +# purpose. A file that cannot be read is never the same as anything. +same_bytes() { + [ -r "$1" ] && [ -r "$2" ] || return 1 + if command -v cmp >/dev/null 2>&1; then + cmp -s "$1" "$2" + else + [ "$(cksum < "$1")" = "$(cksum < "$2")" ] + fi +} From 127c7b125d44126653970b75ba37bc59077e7c29 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Wed, 21 Oct 2026 23:56:29 -0300 Subject: [PATCH 09/50] Undefined behaviour the UBSan run of the corpus found, fixed at its root (#2488) The `make test-ae` list built and run with -fsanitize=undefined on Linux (ubuntu-22.04, GCC 11), with libaether, aetherc and ae instrumented too: CI run 39711071570, on 1a55d924. Six programs wrote reports, from five causes, and one test failed. Each is fixed where it lives: - std/lzf/lzf_c.c:159, a misaligned u16 load (test_lzf_roundtrip, test_issue2301_codec_slices). Our lzfP.h set STRICT_ALIGN to 0, against its own comment ("do it if unsure ... disable by default"), so the compressor compared two bytes as `*(u16 *)p` at any address. It is 1 now: the byte-wise compare reads the same two bytes, so the output is unchanged, and GCC fuses it into one load where the target allows. - std/mem/aether_mem.c:585 and 590, mem.get_u64_le and get_u64_be (crypto_ascon_aead, crypto_ascon_aead128). `(int64_t)b[7] << 56` overflowed a signed integer for a top byte of 128 or more. The bytes are assembled as uint64_t, with one conversion at the end. - std/http/middleware/aether_middleware.c:154, basic auth's base64 decoder (an HTTP server test). `val = (val << 6) | d` kept every bit in an int, which overflowed after six characters. It keeps only the bits not yet written, unsigned. The same shape was in std/http/server/h2's base64url decoder for h2c's HTTP2-Settings, which the run never reached (no libnghttp2 on that runner). It is fixed the same way. - tests/integration/os_spawn_to: its --crash child wrote through null to fault, and UBSan reported the write instead. The child now calls crash.c, which the test ships with @source: - RaiseException(EXCEPTION_ACCESS_VIOLATION) on Windows, so the child still ends with 0xC0000005 through the unhandled-exception path the test is there for; - raise(SIGSEGV) with its default action elsewhere. - tests/integration/lane_wide failed only in that build. Its check that no vmovaps moves a 16-byte-aligned heap slice element also matched moves to and from the stack frame (`vmovaps %ymm0, 32(%rsp)`), which the compiler aligns itself, and which the instrumented build spills. Moves through %rsp or %rbp no longer count. Found beside them: runtime/aether_shared_map.c read its four-byte header through a plain char. A byte of 128 or more is negative there, and shifting a negative value left is undefined. It reads unsigned now. Test: std/mem/test_mem_accessors.ae reads a u64 whose top byte is 0xc6, both orders. The other findings are covered by the tests that found them, and by the UBSan run itself, rerun on the final branch. Checked: - make test (597) passes; - lzf roundtrip, issue2301 codec slices, the four crypto_ascon tests, std/mem accessors, os_spawn_to (0xC0000005 from RaiseException on Windows) and lane_wide all pass. --- ...T030200Z-fixed-2488-undefined-behaviour.md | 8 ++++ runtime/aether_shared_map.c | 10 ++++- std/http/middleware/aether_middleware.c | 9 ++++- std/http/server/h2/aether_h2.c | 10 ++++- std/lzf/lzfP.h | 8 +++- std/mem/aether_mem.c | 38 +++++++++++-------- std/mem/test_mem_accessors.ae | 13 +++++++ tests/integration/lane_wide/test_lane_wide.sh | 10 +++-- tests/integration/os_spawn_to/crash.c | 24 ++++++++++++ .../os_spawn_to/test_os_spawn_to.ae | 9 ++++- 10 files changed, 111 insertions(+), 28 deletions(-) create mode 100644 new_changelogs/20261022T030200Z-fixed-2488-undefined-behaviour.md create mode 100644 tests/integration/os_spawn_to/crash.c diff --git a/new_changelogs/20261022T030200Z-fixed-2488-undefined-behaviour.md b/new_changelogs/20261022T030200Z-fixed-2488-undefined-behaviour.md new file mode 100644 index 000000000..c9c7e6961 --- /dev/null +++ b/new_changelogs/20261022T030200Z-fixed-2488-undefined-behaviour.md @@ -0,0 +1,8 @@ +- **Undefined behaviour the UBSan run of the test corpus found is gone.** + `mem.get_u64_le` and `mem.get_u64_be` overflowed a signed integer for a + top byte of 128 or more; HTTP basic auth's base64 decoder and h2c's + HTTP2-Settings decoder overflowed an int after six characters; std.lzf + compared two bytes through an unaligned `u16` load; the shared map read + its header through a plain char. The values were right on the compilers + in use, but C leaves each undefined, so a compiler was free to break + them (#2488). diff --git a/runtime/aether_shared_map.c b/runtime/aether_shared_map.c index b8df61387..1ec747da4 100644 --- a/runtime/aether_shared_map.c +++ b/runtime/aether_shared_map.c @@ -257,8 +257,14 @@ void aether_shared_map_read_outputs_from_shm(AetherSharedMap* map, const char* s char* data = mmap(NULL, st.st_size, PROT_READ, MAP_SHARED, fd, 0); if (data == MAP_FAILED) { close(fd); return; } - // Skip frozen_count header - int frozen = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24); + // Skip frozen_count header: four bytes, least significant first, as + // aether_shared_map_to_shm writes them. Read unsigned: as a plain char a + // byte of 128 or more is negative, and shifting it left is undefined in + // C (#2488); it also made the count negative. + const unsigned char* hdr = (const unsigned char*)data; + unsigned long frozen_u = (unsigned long)hdr[0] | ((unsigned long)hdr[1] << 8) | + ((unsigned long)hdr[2] << 16) | ((unsigned long)hdr[3] << 24); + int frozen = frozen_u > 0x7fffffffUL ? 0x7fffffff : (int)frozen_u; int pos = 4; // Parse key\0value\0 pairs — skip first 'frozen' entries (inputs), diff --git a/std/http/middleware/aether_middleware.c b/std/http/middleware/aether_middleware.c index 859565924..479e0d4a0 100644 --- a/std/http/middleware/aether_middleware.c +++ b/std/http/middleware/aether_middleware.c @@ -144,18 +144,23 @@ static char* b64_decode(const char* src, size_t* out_len) { size_t src_len = strlen(src); char* out = (char*)malloc(src_len + 1); if (!out) return NULL; - int bits = 0, val = 0; + /* `val` keeps only the bits not yet written, fewer than 8 between + * characters. An int that kept every bit overflowed after six + * characters, which C leaves undefined (UBSan, #2488). */ + int bits = 0; + unsigned int val = 0; size_t out_pos = 0; for (size_t i = 0; i < src_len; i++) { int c = (unsigned char)src[i]; if (c == '=' || c == '\r' || c == '\n' || c == ' ' || c == '\t') continue; int d = b64_decode_char(c); if (d < 0) { free(out); return NULL; } - val = (val << 6) | d; + val = (val << 6) | (unsigned int)d; bits += 6; if (bits >= 8) { bits -= 8; out[out_pos++] = (char)((val >> bits) & 0xff); + val &= (1u << bits) - 1u; } } out[out_pos] = '\0'; diff --git a/std/http/server/h2/aether_h2.c b/std/http/server/h2/aether_h2.c index 4a98c0177..25e0c21c6 100644 --- a/std/http/server/h2/aether_h2.c +++ b/std/http/server/h2/aether_h2.c @@ -1179,15 +1179,21 @@ static uint8_t* b64url_decode(const char* in, size_t in_len, size_t* out_len) { uint8_t* out = malloc(cap); if (!out) return NULL; size_t op = 0; - int v = 0, bits = 0; + /* `v` keeps only the bits not yet written, fewer than 8 between + * characters. An int that kept every bit overflowed after six + * characters, which C leaves undefined (#2488, the same decoder shape + * UBSan found in std/http/middleware). */ + unsigned int v = 0; + int bits = 0; for (size_t i = 0; i < in_len; i++) { int8_t c = lookup[(unsigned char)in[i]]; if (c < 0) { free(out); return NULL; } - v = (v << 6) | c; + v = (v << 6) | (unsigned int)c; bits += 6; if (bits >= 8) { bits -= 8; out[op++] = (uint8_t)((v >> bits) & 0xff); + v &= (1u << bits) - 1u; } } *out_len = op; diff --git a/std/lzf/lzfP.h b/std/lzf/lzfP.h index e65b4e69f..8d8cb3965 100644 --- a/std/lzf/lzfP.h +++ b/std/lzf/lzfP.h @@ -78,9 +78,15 @@ /* * Unconditionally aligning does not cost very much, so do it if unsure. * Unaligned accesses are not safe even on x86, so disable by default. + * + * Aether: 1, as the comment above says. At 0 the compressor compares two + * bytes as `*(u16 *)p` at any address, an unaligned load C leaves + * undefined (UBSan reports it, #2488). The byte-wise compare reads the + * same two bytes, so the output is unchanged, and GCC and Clang fuse it + * into one load where the target allows one. */ #ifndef STRICT_ALIGN -# define STRICT_ALIGN 0 /* was: # !(defined(__i386) || defined (__amd64)) */ +# define STRICT_ALIGN 1 /* was: 0, and before that !(defined(__i386) || defined (__amd64)) */ #endif /* diff --git a/std/mem/aether_mem.c b/std/mem/aether_mem.c index 17aae0620..13cbacb52 100644 --- a/std/mem/aether_mem.c +++ b/std/mem/aether_mem.c @@ -572,29 +572,35 @@ int aether_mem_set_u32_be(void* p, int offset, int64_t value) { return 1; } +/* The 64-bit readers assemble the bytes unsigned. Shifted as int64_t, a top + * byte of 128 or more overflowed a signed integer (`(int64_t)0xc6 << 56`), + * which C leaves undefined (UBSan, #2488). The bits are the same; the one + * conversion to int64_t at the end is two's complement on every target. */ int64_t aether_mem_get_u64_le(const void* p, int offset) { if (!p) return 0; const uint8_t* b = (const uint8_t*)p + offset; - return ((int64_t)b[0]) | - ((int64_t)b[1] << 8) | - ((int64_t)b[2] << 16) | - ((int64_t)b[3] << 24) | - ((int64_t)b[4] << 32) | - ((int64_t)b[5] << 40) | - ((int64_t)b[6] << 48) | - ((int64_t)b[7] << 56); + uint64_t v = ((uint64_t)b[0]) | + ((uint64_t)b[1] << 8) | + ((uint64_t)b[2] << 16) | + ((uint64_t)b[3] << 24) | + ((uint64_t)b[4] << 32) | + ((uint64_t)b[5] << 40) | + ((uint64_t)b[6] << 48) | + ((uint64_t)b[7] << 56); + return (int64_t)v; } int64_t aether_mem_get_u64_be(const void* p, int offset) { if (!p) return 0; const uint8_t* b = (const uint8_t*)p + offset; - return ((int64_t)b[0] << 56) | - ((int64_t)b[1] << 48) | - ((int64_t)b[2] << 40) | - ((int64_t)b[3] << 32) | - ((int64_t)b[4] << 24) | - ((int64_t)b[5] << 16) | - ((int64_t)b[6] << 8) | - ((int64_t)b[7]); + uint64_t v = ((uint64_t)b[0] << 56) | + ((uint64_t)b[1] << 48) | + ((uint64_t)b[2] << 40) | + ((uint64_t)b[3] << 32) | + ((uint64_t)b[4] << 24) | + ((uint64_t)b[5] << 16) | + ((uint64_t)b[6] << 8) | + ((uint64_t)b[7]); + return (int64_t)v; } int aether_mem_set_u64_le(void* p, int offset, int64_t value) { if (!p) return 0; diff --git a/std/mem/test_mem_accessors.ae b/std/mem/test_mem_accessors.ae index ba87b1014..0c480758b 100644 --- a/std/mem/test_mem_accessors.ae +++ b/std/mem/test_mem_accessors.ae @@ -184,6 +184,19 @@ main() { spec.assert_eq(mem.get_uint8(b, 0), mem.get_uint8(b, 15), "LE byte 0 equals BE byte 7") } + + spec.it("reads a u64 whose top byte is 128 or more") callback { + // 0xc600000000000001: the reader assembles the bytes unsigned, + // so a top byte of 0xc6 overflows nothing (#2488) and comes + // back as the negative long it spells. + b = zeroed(ar, BUFSZ) + mem.set_u64_le(b, 0, -4179340454199820287) + spec.assert_eq(mem.get_uint8(b, 7), 198, "LE top byte last") + spec.assert_eq_long(mem.get_u64_le(b, 0), -4179340454199820287, + "LE round trip") + spec.assert_eq_long(mem.get_u64_be(b, 0), 72057594037928134, + "the same bytes read big-endian") + } } spec.describe(fw, "std.mem floats") { diff --git a/tests/integration/lane_wide/test_lane_wide.sh b/tests/integration/lane_wide/test_lane_wide.sh index 0c6372bb6..1220ac819 100644 --- a/tests/integration/lane_wide/test_lane_wide.sh +++ b/tests/integration/lane_wide/test_lane_wide.sh @@ -176,11 +176,15 @@ case "$arch" in # Heap storage is 16-byte aligned, so no aligned 32-byte move # may go through a slice element. This bites on the Linux and # macOS legs: MinGW's GCC emits vmovups here whatever the - # type's alignment, so on Windows it cannot fail. + # type's alignment, so on Windows it cannot fail. A move to + # or from the stack frame (%rsp, %rbp) is not one: the + # compiler aligns its own spill slots, and an instrumented + # build (UBSan, #2488) spills `k` there. sc="$(sed -n '/^_\{0,1\}scale:/,/ret/p' "$tmp/out.s")" - if printf '%s\n' "$sc" | grep -qE 'vmovaps[^;]*%ymm'; then + heap_moves="$(printf '%s\n' "$sc" | grep -E 'vmovaps[^;]*%ymm' | grep -vE '\(%(rsp|rbp)\)')" + if [ -n "$heap_moves" ]; then echo " [FAIL] lane_wide: with -mavx2, scale moves a slice element with vmovaps (faults on 16-byte-aligned heap)" - printf '%s\n' "$sc" | grep -E 'vmovaps' | head -3 | sed 's/^/ /' + printf '%s\n' "$heap_moves" | head -3 | sed 's/^/ /' fail=1 fi if ! printf '%s\n' "$sc" | grep -qE 'vmulps.*%ymm'; then diff --git a/tests/integration/os_spawn_to/crash.c b/tests/integration/os_spawn_to/crash.c new file mode 100644 index 000000000..7c96c7c80 --- /dev/null +++ b/tests/integration/os_spawn_to/crash.c @@ -0,0 +1,24 @@ +/* The --crash child of test_os_spawn_to.ae: a crash with a defined cause. + * + * A write through a null pointer faulted too, but C leaves it undefined, + * and an instrumented build reported the write instead of crashing on it + * (UBSan, #2488). On Windows the access violation is raised as the + * exception itself, so the child still ends with 0xC0000005 through the + * unhandled-exception path the test covers (no error-reporting dialog holds + * a child in its job). Elsewhere SIGSEGV is raised with its default action, + * which ends the process as the fault did. */ +#include +#ifdef _WIN32 +#include +void os_spawn_to_crash(void) { + RaiseException(EXCEPTION_ACCESS_VIOLATION, EXCEPTION_NONCONTINUABLE, 0, NULL); + abort(); +} +#else +#include +void os_spawn_to_crash(void) { + signal(SIGSEGV, SIG_DFL); + raise(SIGSEGV); + abort(); +} +#endif diff --git a/tests/integration/os_spawn_to/test_os_spawn_to.ae b/tests/integration/os_spawn_to/test_os_spawn_to.ae index 03de05453..cb107fef6 100644 --- a/tests/integration/os_spawn_to/test_os_spawn_to.ae +++ b/tests/integration/os_spawn_to/test_os_spawn_to.ae @@ -11,6 +11,7 @@ // The children are this program, re-run with a flag, so the test needs no // other binary and runs the same on every platform. // sweep-ok: the 30 s sleep is the --parent child's, killed at its 1 s deadline. +@source("crash.c") import std.os import std.fs import std.io @@ -18,6 +19,10 @@ import std.list import std.mem import std.string +// crash.c: an access violation on Windows, SIGSEGV elsewhere, raised as +// such rather than by a write through null, which C leaves undefined. +extern os_spawn_to_crash() + check(label: string, ok: bool) -> int { if ok { println("ok ${label}") @@ -56,8 +61,8 @@ child(mode: string) -> int { return 0 } if mode == "--crash" { - // A write through a null pointer: an access violation, SIGSEGV. - _x = mem.set_byte_unchecked(null, 0, 1) + // An access violation on Windows, SIGSEGV elsewhere (crash.c). + os_spawn_to_crash() println("not reached") return 0 } From 33b97e6ce61fdbdd0f4d0821c3624e62021e2dce Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Wed, 21 Oct 2026 23:56:40 -0300 Subject: [PATCH 10/50] make test-ae passes only on a written tally with no failure (#3111) A local Windows sweep (MSYS2, ulimit -v 2000000, NPROC=2) ran every test: 1849 markers, of which 1760 PASS, 2 FAIL and 87 SKIP. make still exited 0. The recipe's shell stopped about 20 ms after the last shell test. It printed neither "=== FAILURE DETAILS ===" nor the "Aether Tests:" tally, and never ran its own `exit 1` or removed its temp dir. The tally code is not what failed: run against that temp dir under the same cap, it prints the right counts. What stopped the shell is still being found, by a rerun under the same conditions. Whatever stops that shell, the gate must not read as a pass: - The sweep writes build/test-ae.verdict ("passed failed skipped total") right after it prints its tally. It removes the old file in a recipe line of its own before it starts. - A last recipe line, a shell of its own after the sweep's (tests/scripts/sweep_verdict.sh), passes only on a verdict that exists, reads as four numbers, has no failure and adds up to a nonzero total. A sweep that stops early now fails with "the sweep ended without its tally", and its other failures still exit 1 as before. Checked: sweep_verdict.sh on a missing, a failing, an inconsistent, an unreadable and an empty verdict (each exits 1 and says why), and on a passing one (exits 0). The full capped sweep runs next with this in place. --- Makefile | 5 +++++ tests/scripts/sweep_verdict.sh | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 tests/scripts/sweep_verdict.sh diff --git a/Makefile b/Makefile index 00f93911c..57b007c8a 100644 --- a/Makefile +++ b/Makefile @@ -1413,6 +1413,7 @@ test-ae: compiler ae stdlib @# A bad AE_SWEEP_SHARD (#2757) must fail here: inside the pipelines below @# it would leave the sweep with nothing to run, and nothing reads as a pass. @sh tests/scripts/sweep_shard.sh < /dev/null + @rm -f $(BUILD_DIR)/test-ae.verdict @tmpdir=$$(mktemp -d); \ root=$$(pwd); \ script="$$root/tests/scripts/run_ae_test.sh"; \ @@ -1478,8 +1479,12 @@ test-ae: compiler ae stdlib fi; \ sh "$$root/tests/scripts/sweep_timings.sh" "$$tmpdir"; \ echo "Aether Tests: $$passed passed, $$failed failed, $$skipped skipped, $$total total"; \ + printf '%s %s %s %s\n' "$$passed" "$$failed" "$$skipped" "$$total" > "$$root/$(BUILD_DIR)/test-ae.verdict"; \ rm -rf "$$tmpdir"; \ if [ "$$failed" -gt 0 ]; then exit 1; fi + @# A shell that stopped before its tally can still leave make with status + @# 0: only a written tally with no failure is a pass. + @sh tests/scripts/sweep_verdict.sh $(BUILD_DIR)/test-ae.verdict endif # Install smoke test: installs to a temp dir, runs ae init + ae run, cleans up diff --git a/tests/scripts/sweep_verdict.sh b/tests/scripts/sweep_verdict.sh new file mode 100644 index 000000000..678057caa --- /dev/null +++ b/tests/scripts/sweep_verdict.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# The last step of `make test-ae`: passes only when the sweep wrote its tally +# and that tally has no failure. +# +# sweep_verdict.sh +# +# The sweep is one long shell command. It writes the verdict file +# (" ") right after it prints its +# "Aether Tests:" line, and exits 1 itself on a failure. A shell that stops +# before then, ended from outside or lost, can still leave make with status +# 0, and a sweep that printed no tally then read as a pass. This step runs in +# a shell of its own after that one, so it sees what the sweep left behind. +f="$1" +if [ ! -s "$f" ]; then + echo "test-ae: the sweep ended without its tally ($f was not written), so it did not pass" >&2 + exit 1 +fi +read -r passed failed skipped total < "$f" +case "$passed$failed$skipped$total" in + ''|*[!0-9]*) + echo "test-ae: the sweep's tally is unreadable: '$(cat "$f")'" >&2 + exit 1 ;; +esac +if [ "$failed" -ne 0 ] || [ "$total" -ne $((passed + failed + skipped)) ] || [ "$total" -eq 0 ]; then + echo "test-ae: the sweep's tally is not a pass: $passed passed, $failed failed, $skipped skipped, $total total" >&2 + exit 1 +fi +exit 0 From ccb2582e8f9f6247706e0a135c0970e38535b00a Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Wed, 21 Oct 2026 19:44:50 -0300 Subject: [PATCH 11/50] What a call keeps is read from the callee's declaration, std's stores declare theirs (#3079, #3086) Whether a call keeps what it is given (E0902 for a borrowed parameter, the @scoped escape check) was decided by the name the call spells ("list.add", "map.put", "list_add" and the rest). So `collections.list_add(routes, box_closure(cb))` kept `cb` with no error, a program's own `list_add` that keeps nothing refused it, and a raw borrowed `ptr` put in a container was never checked (#3001's holders-only rule). The owner decided #3086: a call keeps an argument exactly when the parameter it reaches is declared to keep it (R3), and std declares its stores. std, at the C boundary only (R4: no `@retain` on an Aether function): - std.list's `add`, std.map's `put` and std.collections' `list_add` and `map_put` only forwarded to the C store and turned its int into "" or an error string. C does that now (`list_add`, `map_put` in aether_collections.c) and they are externs, with the same names, arguments and results. - The item of list_add, list_add_raw, list_set, map_put and map_put_raw is `@aether @retain ptr`. A key is copied, so it stays borrowed; set.add and set.try_add copy their item and keep nothing. - string.seq_cons keeps its head and its tail: both are `@retain`, bound to a new C entry, string_seq_cons_adopt, which takes over the references the call hands it. string_seq_cons keeps taking references of its own for C and for the compiler's sequence literals. An extern's `@keep` was no fit: it is an Aether function's `@keep` across a binary boundary, which takes a string only through string.keep and whose fresh arguments the caller does not free. - Codegen hands an `@retain` parameter a `*StringSeq` as it does a string or a closure (R4): a fresh one as it is, a place's retained (emit_seq_take). Its container rewrite still gives a counted value its reference, so nothing is retained twice. A store says where it keeps what it is given, by declaration (approved): `@holds` on an extern's `ptr` parameter names the argument that holds what its `@retain` parameters keep (std's list and map stores, their container), and `@fresh` after an extern's `-> ptr` says the call allocates memory nothing else holds (list_new, list_new_in, map_new). A keep through such a store outlives the caller only when the holder does: a local bound only from `@fresh` calls (or null) that goes nowhere a borrowed `ptr` would escape to (c_name_outlives' probe) ends with the function. A parameter, a field, a module var, or a list returned or stored keeps it past the call. jq's free_node (a worklist of its root) and liquid's layout chain (the caller's lists in local chains, handed to helpers that read and free them) are accepted as they are written. Checker: - A call typed against an extern records it on the node (`callee_extern`, set where typecheck_function_call resolves the symbol). call_callee_decl reads it, so a module's private extern (std.list's `list_add_raw`) is seen by its declaration. The artifact codec refuses an AST that has it, as it does arg_order, and its size tripwire moves to 152. - check_ptr_keeps_in's container branch is gone: call_keeps_arg, where an extern's `@retain` is KEEP, decides. A borrowed `ptr` put in a list or a map is E0902, as one stored in a module `var` is. - @scoped asks call_arg_mode for KEEP: "it is passed to a parameter that keeps it". A @scoped tail given to string.seq_cons escapes too. - A `fn?` parameter is borrowed as the closure it may hold, and a struct or sum parameter one of whose fields or variants holds a closure or a `ptr` (an optional of one included) is borrowed as a holder. - The name list (scoped_store_call's sinks) is gone. Bug found and fixed: a glob import that names a builtin (std.string's `free`) left the builtin's symbol in place but registered the alias, and the pass that refreshes aliases after inference gave that symbol the import's type and declaration. A module's merged body, where a bare `free` is the builtin, was checked against `string.free`; with the declaration read for what a call keeps, its `free(p)` was E0902 (tests/integration/glob_import_builtin_free). The refresh now touches only symbols the import bound, and a program's call of such a name is resolved through its alias, as codegen already rewrote it. Bug found and fixed: `ae fmt` read a name written after `@` as a value, so the `*` of a pointer type after an attribute was spaced as an operator: `tail: @retain *StringSeq` came out `@retain * StringSeq`. A name after `@` ends no value now (tests/integration/fmt_type_prefix). tests/regression/test_string_field_arg_released_once_in_loop.ae was written in the old spacing and is written tight now. Bug found and fixed: std.collections declared `map_put_string_owned(map, key: @aether @retain string, value)`, while C copies the key, so a counted key handed the map a reference it never released. std.map declared the same function without `@retain`, so which one applied depended on import order. The key is borrowed in both now. Callers migrated (declare `@keep`, the value being kept where the caller reaches it): std.spec's _suite_new (fw, parent) and _it_end (it_rec); std.cbor's object_set, set, array_add, push, map_set and map_push; std.msgpack's array_add and map_set; std.resp's array_add and map_add; std.jsonpath's _push, _fork_single and _collect_descendants (value), and the evaluation helpers that thread a node into them (`current` of _eval_test, _eval_value_fn, _arg_to_value, _arg_nodelist_size, _eval_regex_fn: a node of the `root` its public `query` already keeps); contrib/jq's _collect_pattern_vars and _push_child; liquid's context_set_partial_cache; contrib/parsers/xml_expat's bind_start, bind_end and bind_text (cb); tests/syntax/test_closure_context's add_child. std.worker's `run` takes `done: @keep fn(@keep ptr)` (and the extern `aether_worker_run` the same): the job's result is the completion's, so `map`'s completion stores it with `| r: @keep ptr |`. A completion that only reads it fits as before. Docs: the reference's E0902 paragraphs say a std container keeps what it is given by its declaration, the `@retain` section lists the stores and a `*StringSeq`, and a "Migrating from 0.838" section gives the rewrite; memory-management.md's @scoped list reads the declarations; docs/api regenerated. Tests: tests/integration/keeps_borrowed gains the qualified store, a program's own list_add, a `fn?` stored in a module var, a struct with a `fn?` and a sum holding a closure captured by an escaping closure, a borrowed `ptr` into list.add, list.add_raw, list.set and map.put (refused) and with `@keep` (accepted), the jq and liquid shapes of a local holder (accepted), and the same list returned, stored in a field or a module var, a parameter list and one a function hands back (refused). tests/integration/scoped_escape gains a @scoped tail given to string.seq_cons. The qualified store, the own list_add, the `fn?`, struct and sum cases and the raw `ptr` refusals fail on 0.838; the local holders are accepted there too, and stay accepted. Checks: make test (593); the integration tests by area (181: keep, borrow, scoped, containers, lists, maps, sets, sequences, workers, json, jq, cbor, msgpack, resp, fmt, glob, imports, closures, callbacks, externs, spec, liquid, xml) and the .ae-only ones, all passing; std's list, map, collections and string tests and every container and sequence regression test on both C paths. Corpus and grid-cell sweep against 639d2643 (every .ae file in std, contrib, examples and tests, and the 2419 ownership grid cells): no exit status changes; the only diagnostic changes are the two @scoped messages above, once the worktree paths in them are made equal. Ownership grid shards 1-4 of 16 on both C paths: OK 606, NOCOMPILE 1 (K1-P17-T8S, as on the baseline), none worse or better than the baseline. --- compiler/aether_aea.c | 2 +- compiler/analysis/typechecker.c | 186 +++++++++---- compiler/ast.c | 2 + compiler/ast.h | 6 + compiler/codegen/codegen_expr.c | 9 + compiler/parser/parser.c | 28 +- contrib/jq/ast.ae | 2 +- contrib/jq/eval.ae | 2 +- contrib/parsers/xml_expat/module.ae | 6 +- contrib/templating/liquid/module.ae | 2 +- docs/api/collections.html | 5 +- docs/api/index.html | 4 +- docs/api/list.html | 15 +- docs/api/map.html | 15 +- docs/api/string.html | 5 +- docs/api/worker.html | 4 +- docs/language-reference.md | 56 +++- docs/memory-management.md | 9 +- ...fixed-3079-container-by-resolved-callee.md | 32 +++ std/cbor/module.ae | 12 +- std/collections/aether_collections.c | 13 + std/collections/aether_collections.h | 5 + std/collections/aether_stringseq.c | 14 + std/collections/aether_stringseq.h | 4 + std/collections/module.ae | 51 ++-- std/jsonpath/query.ae | 21 +- std/list/module.ae | 31 +-- std/map/module.ae | 27 +- std/msgpack/module.ae | 4 +- std/resp/module.ae | 4 +- std/spec/module.ae | 4 +- std/string/module.ae | 4 +- std/worker/module.ae | 9 +- tests/compiler/test_aea.c | 10 +- .../fmt_type_prefix/test_fmt_type_prefix.sh | 8 +- .../keeps_borrowed/test_keeps_borrowed.sh | 263 +++++++++++++++++- .../scoped_escape/reject_seq_cons_tail.ae | 7 + .../scoped_escape/test_scoped_escape.sh | 4 +- ..._string_field_arg_released_once_in_loop.ae | 2 +- tests/syntax/test_closure_context.ae | 2 +- tools/ae_fmt.c | 7 +- 41 files changed, 715 insertions(+), 181 deletions(-) create mode 100644 new_changelogs/20261021T221816Z-fixed-3079-container-by-resolved-callee.md create mode 100644 tests/integration/scoped_escape/reject_seq_cons_tail.ae diff --git a/compiler/aether_aea.c b/compiler/aether_aea.c index 6d68146fa..398bf4bbf 100644 --- a/compiler/aether_aea.c +++ b/compiler/aether_aea.c @@ -124,7 +124,7 @@ static int encode_type(Buf* b, const Type* t, const char** err) { static int encode_node(Buf* b, const ASTNode* n, const char* source_file, int depth, const char** err) { if (depth > AEA_MAX_DEPTH) { *err = "the AST is deeper than an artifact may hold"; return 0; } - if (n->is_imported || n->origin_module || n->warned || n->arg_order) { + if (n->is_imported || n->origin_module || n->warned || n->arg_order || n->callee_extern) { *err = "the AST has been merged or analysed (not a fresh parse)"; return 0; } diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index 88c30afe8..29bb13e54 100644 --- a/compiler/analysis/typechecker.c +++ b/compiler/analysis/typechecker.c @@ -7435,7 +7435,14 @@ int typecheck_program(ASTNode* program) { Symbol* full_sym = lookup_symbol(global_table, full_name); Symbol* short_sym = lookup_symbol_local(global_table, short_name); - if (full_sym && short_sym && short_sym->is_function && full_sym->type) { + /* #3086: only a symbol the import bound. One it left alone (the + * builtin `free`, which std.string's glob names too) was given the + * import's type and declaration here, so a module's body, where a + * bare `free` is the builtin, was checked against `string.free`. A + * program's call of the name is resolved through the alias + * (typecheck_function_call_inner). */ + if (full_sym && short_sym && short_sym->import_bound && short_sym->is_function && + full_sym->type) { if (short_sym->type) free_type(short_sym->type); short_sym->type = clone_type(full_sym->type); short_sym->node = full_sym->node; @@ -7994,25 +8001,7 @@ static int scoped_mentions(ASTNode* node, const char* name) { return 0; } -/* Curated container-insert builtins: handing a @scoped value to one of these - * stores it beyond the block. Same curation posture as codegen's - * is_nonstoring_builtin allowlist. Names are the as-written qualified - * spellings (the AST stores callees in dotted form). */ -static int scoped_store_call(const char* fn) { - if (!fn) return 0; - /* The std calls that keep their argument in a container. #3001: the - * list named `list.push`, `map.set` and `seq.cons`, which std does not - * have, and missed the sequence cons and the raw and owning forms. */ - /* #3034: and std.collections' unqualified `list_add` and `map_put`, - * which tinyweb stores its route handlers through. */ - static const char* sinks[] = { - "list.add", "list.add_raw", "list.add_string_owned", "list.set", - "map.put", "map.put_raw", "map.put_string_owned", - "set.add", "set.try_add", "string.seq_cons", "list_add", "map_put", NULL }; - for (int i = 0; sinks[i]; i++) - if (strcmp(fn, sinks[i]) == 0) return 1; - return 0; -} +static int call_arg_mode(ASTNode* program, ASTNode* call, int i); /* defined below */ /* Reason string if `node` escapes `name` AT THIS NODE, else NULL. */ static const char* scoped_escape_reason(ASTNode* node, const char* name) { @@ -8042,14 +8031,18 @@ static const char* scoped_escape_reason(ASTNode* node, const char* name) { return "it is captured by a closure that can outlive the block"; if (node->type == AST_QUOTE && scoped_mentions(node, name)) return "it is captured by a quote, whose tree can outlive the block"; - if (node->type == AST_FUNCTION_CALL && node->value && - scoped_store_call(node->value)) { - int first = strcmp(node->value, "call") == 0 ? 1 : 0; - for (int i = first; i < node->child_count; i++) { + /* #3086: given to a parameter that keeps it, as its callee declares: + * an Aether function's `@keep` one, an extern's `@retain` one (std's + * list and map stores), or one of a closure type that says so. #3001 + * named the stores instead, and #3079 knew them by the function a call + * reaches; a parameter that copies its argument (a set's item, a map's + * key) keeps nothing. */ + if (node->type == AST_FUNCTION_CALL && node->value) { + for (int i = 0; i < node->child_count; i++) { ASTNode* a = node->children[i]; - if (a && a->type == AST_IDENTIFIER && a->value && - strcmp(a->value, name) == 0) - return "it is inserted into a container"; + if (a && a->type == AST_IDENTIFIER && a->value && strcmp(a->value, name) == 0 && + call_arg_mode(g_typecheck_program, node, i) == FN_MODE_KEEP) + return "it is passed to a parameter that keeps it"; } } return NULL; @@ -8813,6 +8806,8 @@ static int keep_set_other_param(const KeepSet* s, const char* param) { static int is_bare_ptr_type(Type* t); +static int type_holds_borrowable(ASTNode* program, Type* t, int depth); + /* #3034: does the struct `name` hold, by value, a bare `ptr` or a closure * (in a field, or in a struct field held by value)? */ static int struct_holds_borrowable(ASTNode* program, const char* name, int depth) { @@ -8824,17 +8819,30 @@ static int struct_holds_borrowable(ASTNode* program, const char* name, int depth for (int f = 0; f < sd->child_count; f++) { ASTNode* fd = sd->children[f]; Type* ft = fd && fd->type == AST_STRUCT_FIELD ? fd->node_type : NULL; - if (!ft) continue; - if (is_bare_ptr_type(ft) || (ft->kind == TYPE_FUNCTION && !ft->is_fnptr)) return 1; - if (ft->kind == TYPE_STRUCT && ft->struct_name && - struct_holds_borrowable(program, ft->struct_name, depth + 1)) - return 1; + if (ft && type_holds_borrowable(program, ft, depth + 1)) return 1; } return 0; } return 0; } +/* #3034: does a value of type `t` hold, by value, a bare `ptr` or a + * closure: it is one, or a struct with one in a field (or in a struct + * field held by value). #3079: or an optional of one (a `fn?` field), or + * a sum one of whose variants holds one. */ +static int type_holds_borrowable(ASTNode* program, Type* t, int depth) { + if (!t || depth > 8) return 0; + if (is_bare_ptr_type(t) || (t->kind == TYPE_FUNCTION && !t->is_fnptr)) return 1; + if (t->kind == TYPE_OPTIONAL) return type_holds_borrowable(program, t->element_type, depth + 1); + if (t->kind == TYPE_STRUCT && t->struct_name) + return struct_holds_borrowable(program, t->struct_name, depth + 1); + if (t->kind == TYPE_SUM) { + for (int i = 0; t->tuple_types && i < t->tuple_count; i++) + if (type_holds_borrowable(program, t->tuple_types[i], depth + 1)) return 1; + } + return 0; +} + /* #3034: what a parameter of type `t` is borrowed as, besides a bare * `ptr`: a `fn` (a closure, `*is_fn`) or a struct taken by value that holds * a pointer or a closure, spelled as the type is; NULL for anything else (a @@ -8842,12 +8850,16 @@ static int struct_holds_borrowable(ASTNode* program, const char* name, int depth static const char* borrowed_param_kind(ASTNode* program, Type* t, int* is_fn) { *is_fn = 0; if (!t) return NULL; - if (t->kind == TYPE_FUNCTION && !t->is_fnptr) { + /* #3079: a `fn?` is borrowed as the closure it may hold. */ + Type* inner = t->kind == TYPE_OPTIONAL ? t->element_type : t; + if (inner && inner->kind == TYPE_FUNCTION && !inner->is_fnptr) { *is_fn = 1; return aether_intern(type_spelled(t)); } - if (t->kind == TYPE_STRUCT && t->struct_name && !t->distinct_name && - struct_holds_borrowable(program, t->struct_name, 0)) + /* A struct taken by value that holds a pointer or a closure, and #3079 + * a sum one of whose variants does (or an optional of either). */ + if (inner && (inner->kind == TYPE_STRUCT || inner->kind == TYPE_SUM) && + !inner->distinct_name && type_holds_borrowable(program, inner, 0)) return aether_intern(type_spelled(t)); return NULL; } @@ -8951,11 +8963,15 @@ static ASTNode* call_extern_decl(ASTNode* program, const char* name) { } /* The declaration a call is checked against: an Aether function, or an - * extern. NULL for a call through a value or a builtin. */ + * extern. NULL for a call through a value or a builtin. #3086: an extern a + * module keeps to itself (std.list's `list_add_raw`, a bare `extern`) is + * not merged into the program, so it is the declaration the call was typed + * against (callee_extern), the module's own. */ static ASTNode* call_callee_decl(ASTNode* program, ASTNode* call) { if (!program || !call || !call->value) return NULL; ASTNode* def = effect_resolve_callee(program, call->value); - return def ? def : call_extern_decl(program, call->value); + if (def) return def; + return call->callee_extern ? call->callee_extern : call_extern_decl(program, call->value); } /* The parameter of `decl` that argument `i` of `call` fills, or NULL. */ @@ -9336,6 +9352,7 @@ static void bind_pattern_holders(KeepSet* s, ASTNode* pat, Carried c, Type* t, } } +static int call_keeps_in_local_holder(ASTNode* program, ASTNode* call, int i, const KeepSet* s); static void check_ptr_keeps_in(ASTNode* program, ASTNode* n, KeepSet* s, const char** globals, int nglobals, int in_closure); @@ -9941,21 +9958,16 @@ static void check_ptr_keeps_in(ASTNode* program, ASTNode* n, KeepSet* s, report_borrowed_keep(s, f, c, "sent in a message"); } } + /* #3086: a parameter that keeps what it is given, as its callee + * declares it (call_arg_mode): std's list and map stores declare theirs + * `@retain` at the C boundary, so a borrowed `ptr` put in a container is + * E0902 as one stored in a module `var` is, not only a closure or a + * struct that holds one (#3001's holders-only rule). */ if (n->type == AST_FUNCTION_CALL && n->value) { - int container = scoped_store_call(n->value); for (int i = 0; i < n->child_count; i++) { if (!(c = carries_borrowed_ptr(n->children[i], s)).n) continue; - if (call_keeps_arg(program, n, i)) { + if (call_keeps_arg(program, n, i) && !call_keeps_in_local_holder(program, n, i, s)) report_borrowed_keep(s, n->children[i], c, "passed to a parameter that keeps it"); - } else if (container) { - /* What holds a parameter, and #3034 a `fn` or struct - * parameter itself (a closure is boxed into the slot). */ - Carried kept = { 0 }; - for (int k = 0; k < c.n; k++) - if (c.vias[k] || keep_set_other_param(s, c.params[k]) >= 0) - carried_add_at(&kept, c.params[k], c.vias[k], c.parts[k]); - if (kept.n) report_borrowed_keep(s, n->children[i], kept, "stored in a container"); - } } } for (int i = 0; i < n->child_count; i++) @@ -10118,6 +10130,70 @@ static int c_name_outlives(const KeepCtx* c, const char* name, int depth) { return s.escaped; } +/* #3086: is `e` a holder (`@holds`) whose memory ends with the function: a + * local bound only from calls to `@fresh` externs (a container the function + * makes, std's list and map constructors) or `null`, and that goes nowhere + * a borrowed `ptr` parameter would escape to (the E0902 facts, asked as + * c_name_outlives asks them). Decided by those declarations alone: a + * parameter, a module `var`, a field or anything else is taken to outlive + * it. */ +typedef struct { int found; int other; } HolderBindings; + +static void scan_holder_bindings(ASTNode* program, ASTNode* n, const char* name, + HolderBindings* b) { + if (!n || b->other) return; + if (n->type == AST_FUNCTION_DEFINITION || n->type == AST_BUILDER_FUNCTION) return; + ASTNode* v = NULL; + if (n->type == AST_VARIABLE_DECLARATION && n->value && !strcmp(n->value, name) && + n->child_count > 0) + v = n->children[n->child_count - 1]; + else if (n->type == AST_BINARY_EXPRESSION && n->value && !strcmp(n->value, "=") && + n->child_count == 2 && n->children[0] && n->children[0]->type == AST_IDENTIFIER && + n->children[0]->value && !strcmp(n->children[0]->value, name)) + v = n->children[1]; + if (v) { + b->found = 1; + v = strip_ptr_casts(v); + ASTNode* decl = v && v->type == AST_FUNCTION_CALL ? call_callee_decl(program, v) : NULL; + int fresh = v && (v->type == AST_NULL_LITERAL || + (decl && decl->type == AST_EXTERN_FUNCTION && + annotation_has_marker(decl->annotation, "fresh_return"))); + if (!fresh) b->other = 1; + } + for (int i = 0; i < n->child_count; i++) scan_holder_bindings(program, n->children[i], name, b); +} + +static int holder_outlives(const KeepCtx* c, ASTNode* e, int depth) { + e = strip_ptr_casts(e); + if (!c || depth > 8 || !e || e->type != AST_IDENTIFIER || !e->value) return 1; + const char* name = e->value; + for (int g = 0; g < c->nglobals; g++) + if (!strcmp(c->globals[g], name)) return 1; + for (int i = 0; c->fn && i < c->fn->child_count && c->fn->children[i] != c->body; i++) { + ASTNode* p = c->fn->children[i]; + if (p && p->value && !strcmp(p->value, name)) return 1; + } + HolderBindings b = { 0, 0 }; + scan_holder_bindings(c->program, c->body, name, &b); + if (!b.found || b.other) return 1; + return c_name_outlives(c, name, depth); +} + +/* #3086: does `call` keep argument `i` only in a holder that ends with the + * function? An extern names the argument that holds what its `@retain` + * parameters are given with `@holds` (std's list and map stores); without + * one, a keep is taken to outlive the call. */ +static int call_keeps_in_local_holder(ASTNode* program, ASTNode* call, int i, const KeepSet* s) { + if (!s || !s->ctx || call_value_type(call)) return 0; + ASTNode* decl = call_callee_decl(program, call); + if (!decl || decl->type != AST_EXTERN_FUNCTION) return 0; + for (int h = 0; h < decl->child_count && h < call->child_count; h++) { + if (h == i || !param_has_mode(decl->children[h], "holder_param")) continue; + return !holder_outlives(s->ctx, call->children[h], s->depth + 1); + } + return 0; +} + /* Does the memory the store target `lhs` writes (`p.f`, `p[i]`, `(p as * T[])[i]`) outlive the function? That of the pointer it is reached * through: a local allocation that does not escape ends with it. */ @@ -19140,6 +19216,16 @@ static int typecheck_function_call_inner(ASTNode* call, SymbolTable* table) { // Use qualified lookup to handle namespaced calls like string.new -> string_new Symbol* symbol = lookup_qualified_symbol(table, call->value); + /* #3086: a bare name the program's import binds is the import's in the + * program (the alias below rewrites the call to it), also where the + * import left a builtin of the name in place (std.string's glob and + * `free`): it is checked against the import's declaration. Not in a + * module's merged body, whose bare names are its own. */ + if (symbol && call->value && !strchr(call->value, '.') && !table->merged_from && + !symbol->import_bound && find_import_alias(call->value)) { + Symbol* aliased = lookup_qualified_symbol(table, find_import_alias(call->value)); + if (aliased && aliased->is_function) symbol = aliased; + } /* #924 re-export: a `hub.fn(...)` call where hub re-exports fn from * another module resolves (via lookup_qualified_symbol) to the defining @@ -19449,6 +19535,10 @@ static int typecheck_function_call_inner(ASTNode* call, SymbolTable* table) { return 0; } + /* #3086: the extern the call resolves to, for call_callee_decl. */ + if (symbol->node && symbol->node->type == AST_EXTERN_FUNCTION) + call->callee_extern = symbol->node; + if (!check_quote_callee(call, symbol)) return 0; if (!bind_named_arguments(call, symbol)) return 0; diff --git a/compiler/ast.c b/compiler/ast.c index 0a6d97f5d..47ea8d45b 100644 --- a/compiler/ast.c +++ b/compiler/ast.c @@ -1060,6 +1060,7 @@ ASTNode* create_ast_node(ASTNodeType type, const char* value, int line, int colu node->origin_module = NULL; node->arg_order = NULL; node->arg_order_count = 0; + node->callee_extern = NULL; return node; } @@ -1134,6 +1135,7 @@ ASTNode* clone_ast_node(ASTNode* node) { memcpy(clone->arg_order, node->arg_order, sizeof(int) * (size_t)node->arg_order_count); clone->arg_order_count = node->arg_order_count; } + clone->callee_extern = node->callee_extern; if (node->value_len > 0) ast_set_literal_bytes(clone, node->value, node->value_len); /* #2520 */ /* Slot for slot: an empty one stays where it is, as the parser left it. */ diff --git a/compiler/ast.h b/compiler/ast.h index 26c35735d..2cb0e9202 100644 --- a/compiler/ast.h +++ b/compiler/ast.h @@ -651,6 +651,12 @@ typedef struct ASTNode { * on every other node. */ int* arg_order; int arg_order_count; + + /* #3086: on a call the checker typed against an extern, that extern's + * declaration: a module's own extern is not merged into the program, + * so this is how what the call keeps is read from its declaration. Not + * owned; NULL on every other node. */ + struct ASTNode* callee_extern; } ASTNode; // Type functions diff --git a/compiler/codegen/codegen_expr.c b/compiler/codegen/codegen_expr.c index 6b0fc5533..efc62a7df 100644 --- a/compiler/codegen/codegen_expr.c +++ b/compiler/codegen/codegen_expr.c @@ -10867,6 +10867,15 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { if (fresh_t) { emit_composite_arg_temp(gen, arg, fresh_t); } else if (array_literal_arg_temp(gen, arg)) { + } else if (ext_name && arg->node_type && + is_string_seq_ptr_type(arg->node_type) && + is_retain_extern_param(gen, ext_name, arg_printed)) { + /* #3086: an `@retain` extern keeps the sequence + * (std's `string.seq_cons` its tail), so the call + * hands it a reference of its own, as it does a + * string or a closure (R4): a fresh one as it + * is, a place's retained. */ + emit_seq_take(gen, arg); } else if (extern_param_t && extern_param_t->c_alias && strcmp(extern_param_t->c_alias, "va_list") == 0) { diff --git a/compiler/parser/parser.c b/compiler/parser/parser.c index 5e69ad915..eedda3cf4 100644 --- a/compiler/parser/parser.c +++ b/compiler/parser/parser.c @@ -5569,6 +5569,14 @@ ASTNode* parse_extern_struct_field(Parser* parser) { * hands `@retain` a string's characters with a reference of its * own, right for C and not what the Aether function expects. * + * @holds the `ptr` argument is where the function keeps what its + * `@retain` parameters are given (#3086): std's list and map + * stores declare their container so. A keep through such a + * call outlives the caller only when that argument's memory + * does: a container the caller makes with an `@fresh` extern + * and lets go nowhere keeps nothing past it. Only meaningful + * on `ptr`. + * * Multiple annotations stack: `name: @aether @retain string` is legal. * Order is irrelevant; storage is a comma-separated set on * `param->annotation`, tested by substring. */ @@ -5588,12 +5596,14 @@ static void parse_extern_param_attrs_and_type(Parser* parser, ASTNode* param) { tag = "into_param"; } else if (strcmp(attr->value, "keep") == 0) { tag = "keep_param"; + } else if (strcmp(attr->value, "holds") == 0) { + tag = "holder_param"; } if (tag) advance_token(parser); } if (!tag) { parser_error(parser, "unknown extern-param attribute (expected @aether, @retain, " - "@noescape, @into or @keep)"); + "@noescape, @into, @keep or @holds)"); break; } /* Append to the comma-separated set, deduping. */ @@ -5625,6 +5635,10 @@ static void parse_extern_param_attrs_and_type(Parser* parser, ASTNode* param) { param->node_type->kind != TYPE_PTR && param->node_type->kind != TYPE_FUNCTION) { parser_error(parser, "@noescape on an extern parameter is only valid on `ptr` or `fn`"); } + if (param->annotation && strstr(param->annotation, "holder_param") && + param->node_type->kind != TYPE_PTR) { + parser_error(parser, "@holds on an extern parameter is only valid on `ptr`"); + } } /* Trailing `@`-attributes on an extern signature, shared by the bare @@ -5635,6 +5649,10 @@ static void parse_extern_param_attrs_and_type(Parser* parser, ASTNode* param) { * codegen_stmt.c. Only meaningful on `-> string`; anything else * is a parse error since integers and pointers aren't heap- * tracked and void has nothing to own. + * @fresh the `-> ptr` result is memory the call allocates, which nothing + * else holds (#3086): std's list and map constructors. A + * container bound from one is a holder (`@holds`) that keeps + * nothing past its function unless it escapes. * @borrow the unannotated default. A no-op, accepted so callers can be * explicit and symmetric with the tuple form. * @c_import a C header owns the prototype, so codegen emits no declaration @@ -5674,6 +5692,14 @@ static void parse_extern_trailing_attrs(Parser* parser, ASTNode* ext) { annotation_add_marker(ext->annotation, "heap_return"); } advance_token(parser); /* consume 'heap' */ + } else if (strcmp(tag->value, "fresh") == 0) { + advance_token(parser); /* consume '@' */ + if (!ext->node_type || ext->node_type->kind != TYPE_PTR) { + parser_error(parser, "@fresh on extern return is only valid on `-> ptr`"); + } else { + ext->annotation = annotation_add_marker(ext->annotation, "fresh_return"); + } + advance_token(parser); /* consume 'fresh' */ } else if (strcmp(tag->value, "borrow") == 0) { advance_token(parser); /* consume '@' */ advance_token(parser); /* consume 'borrow' */ diff --git a/contrib/jq/ast.ae b/contrib/jq/ast.ae index 2558b168a..1baf3ad6f 100644 --- a/contrib/jq/ast.ae +++ b/contrib/jq/ast.ae @@ -182,7 +182,7 @@ free_node(n: ptr) { list.free(work) } -_push_child(work: ptr, child: ptr) { +_push_child(work: ptr, child: @keep ptr) { if child != null { list.add_raw(work, child) } } diff --git a/contrib/jq/eval.ae b/contrib/jq/eval.ae index 9f137f690..2754e84ac 100644 --- a/contrib/jq/eval.ae +++ b/contrib/jq/eval.ae @@ -1714,7 +1714,7 @@ _bind_alternatives(ctx: *Ctx, patterns: ptr, v: @keep ptr, env: @keep ptr, s: *S } // The P_VAR / P_ENTRY-with-$name nodes of a pattern, appended to `names`. -_collect_pattern_vars(pat: ptr, names: ptr) { +_collect_pattern_vars(pat: @keep ptr, names: ptr) { pn = pat as *Node if pn.kind == ast.P_VAR { list.add_raw(names, pat) diff --git a/contrib/parsers/xml_expat/module.ae b/contrib/parsers/xml_expat/module.ae index 89d6134b7..8abf231f8 100644 --- a/contrib/parsers/xml_expat/module.ae +++ b/contrib/parsers/xml_expat/module.ae @@ -259,17 +259,17 @@ extern xml_parser_install_handlerset(p: ptr, hs: ptr) // The first `env: ptr` parameter is required by the dispatcher's // ABI but is unused in the user's body (the closure already has // captured-variable access via its own env). -bind_start(_ctx: ptr, cb: fn) { +bind_start(_ctx: ptr, cb: @keep fn) { boxed = box_closure(cb) map_put_raw(_ctx, "start", boxed) } -bind_end(_ctx: ptr, cb: fn) { +bind_end(_ctx: ptr, cb: @keep fn) { boxed = box_closure(cb) map_put_raw(_ctx, "end", boxed) } -bind_text(_ctx: ptr, cb: fn) { +bind_text(_ctx: ptr, cb: @keep fn) { boxed = box_closure(cb) map_put_raw(_ctx, "text", boxed) } diff --git a/contrib/templating/liquid/module.ae b/contrib/templating/liquid/module.ae index 9b13881e8..5bc851e42 100644 --- a/contrib/templating/liquid/module.ae +++ b/contrib/templating/liquid/module.ae @@ -2679,7 +2679,7 @@ partial_cache_key() -> string { // Render with `cache`: every include, render and layout parent the context // reaches is parsed once and reused, including through `{% render %}`'s // fresh contexts. -context_set_partial_cache(ctx: ptr, cache: ptr) { +context_set_partial_cache(ctx: ptr, cache: @keep ptr) { map_put(ctx, partial_cache_key(), cache) } diff --git a/docs/api/collections.html b/docs/api/collections.html index d8be1905b..99b35064e 100644 --- a/docs/api/collections.html +++ b/docs/api/collections.html @@ -174,6 +174,7 @@

list_new_in extern

list_add_raw extern

list_add_raw(list, item) -> int

list: ptr, item: ptr

+

The stores keep their item past the call (#3086): a `ptr` stays the container's, so a function that stores a borrowed `ptr` parameter declares it `@keep`, and a string or a closure is handed a reference of its own (`@retain`, R4).

list_get_raw extern

@@ -364,7 +365,7 @@

string_list_sort_lex extern<

list: ptr

-

list_add fn

+

list_add extern

list_add(list, item) -> string

list: ptr, item: ptr

Append an item to a list. Returns "" on success, error on failure (OOM during resize, null list).

@@ -376,7 +377,7 @@

list_get fn

Index into a list. - (item, "") : index in range - (null, "index out of range") : index < 0 or index >= size - (null, "null list") : `list` is null An empty error means the value is good, as everywhere else in std (#2439).

-

map_put fn

+

map_put extern

map_put(map, key, value) -> string

map: ptr, key: string, value: ptr

Insert or update a key-value pair. Returns "" on success, error on failure (OOM during resize, null map, null key).

diff --git a/docs/api/index.html b/docs/api/index.html index 21ebf7a2d..cd65c7877 100644 --- a/docs/api/index.html +++ b/docs/api/index.html @@ -1487,6 +1487,7 @@

Search Results

  • match_stringslanguage
  • newlist
  • new_inlist
  • +
  • addlist
  • add_rawlist
  • add_string_ownedlist
  • get_rawlist
  • @@ -1495,7 +1496,6 @@

    Search Results

  • removelist
  • clearlist
  • freelist
  • -
  • addlist
  • getlist
  • get_stringlist
  • init_rawlog
  • @@ -1532,6 +1532,7 @@

    Search Results

  • compresslzf
  • decompresslzf
  • newmap
  • +
  • putmap
  • put_rawmap
  • put_string_ownedmap
  • get_rawmap
  • @@ -1544,7 +1545,6 @@

    Search Results

  • keys_freemap
  • keys_size_rawmap
  • keys_get_rawmap
  • -
  • putmap
  • getmap
  • get_stringmap
  • keysmap
  • diff --git a/docs/api/list.html b/docs/api/list.html index 9e6c19668..535a0ad6c 100644 --- a/docs/api/list.html +++ b/docs/api/list.html @@ -17,6 +17,7 @@

    Exports

    @@ -138,10 +138,17 @@

    new_in extern

    new_in(alloc) -> ptr

    alloc: ptr

    +
    +

    add extern

    +
    add(list, item) -> string
    +

    list: ptr, item: ptr

    +

    Append an item to a list. Returns "" on success, error on failure.

    +

    add_raw extern

    add_raw(list, item) -> int

    list: ptr, item: ptr

    +

    The stores keep their item past the call (#3086): a `ptr` stays the list's, so a function that adds a borrowed `ptr` parameter declares it `@keep`, and a string or a closure is handed a reference of its own (`@retain`, R4).

    add_string_owned extern

    @@ -179,12 +186,6 @@

    free extern

    free(list)

    list: ptr

    -
    -

    add fn

    -
    add(list, item) -> string
    -

    list: ptr, item: ptr

    -

    Append an item to a list. Returns "" on success, error on failure.

    -

    get fn

    get(list, index) -> (ptr, string)
    diff --git a/docs/api/map.html b/docs/api/map.html index 46181816b..1cefebc04 100644 --- a/docs/api/map.html +++ b/docs/api/map.html @@ -16,6 +16,7 @@

    Aether

    Exports

    +
    +

    put extern

    +
    put(map, key, value) -> string
    +

    map: ptr, key: string, value: ptr

    +

    Insert or update a key-value pair. Returns "" on success, error on failure (null map/key or OOM).

    +

    put_raw extern

    put_raw(map, key, value) -> int

    map: ptr, key: string, value: ptr

    +

    The stores keep their value past the call (#3086): a `ptr` stays the map's, so a function that puts a borrowed `ptr` parameter declares it `@keep`, and a string or a closure is handed a reference of its own (`@retain`, R4). The key is copied.

    put_string_owned extern

    @@ -201,12 +208,6 @@

    keys_get_raw extern

    keys_get_raw(keys, index) -> string

    keys: ptr, index: int

    -
    -

    put fn

    -
    put(map, key, value) -> string
    -

    map: ptr, key: string, value: ptr

    -

    Insert or update a key-value pair. Returns "" on success, error on failure (null map/key or OOM).

    -

    get fn

    get(map, key) -> (ptr, string)
    diff --git a/docs/api/string.html b/docs/api/string.html index 3203c54fe..3be9c71c1 100644 --- a/docs/api/string.html +++ b/docs/api/string.html @@ -419,9 +419,8 @@

    seq_empty extern

    Cons-cell construction + destructuring. Names mirror the C runtime symbols; users call them via dot-notation `string.seq_X(...)` (the qualified-name resolver joins `string` + `seq_X` → `string_seq_X`). A `@heap` result hands the caller a reference of its own; `seq_tail` lends the sequence's own tail.

    -

    seq_cons extern

    -
    seq_cons(head, tail) -> *StringSeq
    -

    head: string, tail: *StringSeq

    +

    seq_cons fn

    +
    seq_cons()

    seq_head extern

    diff --git a/docs/api/worker.html b/docs/api/worker.html index 6f5883f3e..54e97e8b3 100644 --- a/docs/api/worker.html +++ b/docs/api/worker.html @@ -129,8 +129,8 @@

    worker

    run fn

    run(work, done) -> bool
    -

    work: fn, done: fn

    -

    Run `work` on a background thread; when it returns a `ptr` result, deliver that result to `done` on the loop thread (through the installed poster, or the drain queue if none). Returns true if the worker launched, false on failure (no threads / out of memory) — on false neither closure runs.

    +

    work: fn, done: fn(ptr)

    +

    Run `work` on a background thread; when it returns a `ptr` result, deliver that result to `done` on the loop thread (through the installed poster, or the drain queue if none). Returns true if the worker launched, false on failure (no threads / out of memory) — on false neither closure runs. The result is the completion's: `done` takes it `@keep` (#3086), so it may store it (in a list, as `map` does), and a completion that only reads it fits as well.

    run_detached fn

    diff --git a/docs/language-reference.md b/docs/language-reference.md index dc60aa4de..c00b36a5c 100644 --- a/docs/language-reference.md +++ b/docs/language-reference.md @@ -2113,8 +2113,8 @@ Every place that holds a string (a local, a struct field, an element, a module-l - **A function may not free a parameter** (E0901): `string.free(p)` or `release(p)` on a `string` parameter, or `string.seq_free(p)` on a `*StringSeq` one, would release a reference the function does not own. Store it to keep it; a parameter the body re-binds holds a string of its own and may be freed. - **`string.free(x)` releases the reference `x` holds and leaves `x` empty**, for a local, a field or a module-level `var`. Nothing frees it again. -- **A `ptr` parameter is borrowed for the call unless it is declared `@keep`** (E0902). A `ptr` owns nothing, so a pointer a function keeps (stores where it outlives the call, returns, sends in a message, or passes to another `@keep` parameter) is declared `p: @keep ptr`; a pointer computed from it (`p + n`, `p - n`) points into the same memory and counts as `p`. A struct local to the function, and a closure that runs and is dropped inside the call, do not outlive it: putting the pointer in one (`s = Pair { a: p }`, `s.a = p`, `run(|| { use(p) })`) keeps nothing, and the struct or the closure is a keep only when it escapes itself, returned, stored in a field, a module-level `var` or a container, or passed to a `@keep` or `@retain` parameter. A tuple (`t = (p, 1)`), an optional, and anything read out of a holder (a field, a tuple part, an optional's payload `o!` or `o ?? d`, a `match` binding) hold it the same way, except that a part of a tuple or a field of a struct built from parts holds only what went into that part or field (after `t = (p, q)` and `a, b = t`, `b` holds `q` and not `p`; after `s = Pair { a: p, b: q }` or `s.b = q`, `s.b` holds `q`), and so does what a closure that captured it returns (`call(f)`). A closure that captured the pointer and stores it somewhere that outlives the call keeps it too, and so does one that reads it through an address of a local, or a local a closure assigns, stored there after the closure was written: it is checked where it is called or passed (`g = || { kept = pb.item }`, `b.item = p`, `call(g)`). The checker verifies each function against its own body, and a call against the callee's signature; a loop's body is checked with what every pass through it binds, so `kept = q` placed before `q = p` in a loop keeps `p`, unless the body first binds `q` anew (`q = list.new()`). A `string` reaches a `@keep ptr` only through `string.keep(s)`, which hands over a reference of its own (`string.release_kept(p)` gives it back), or as a literal or a `const` bound to one, which is pinned, and a `*StringSeq` only through `string.seq_retain(s)` (`string.seq_free` gives it back). C memory viewed as `string[]` (`(names as string[])[i] = s`) is given the string's characters and no reference, so the string must outlive C's reads: a string built in the statement never does, and a local does only when the memory ends with the function (an argv array the function allocates, hands to C and does not let escape). Memory that outlives the function (reached through a parameter, a module-level `var`, a field or a heap box, or whose pointer is returned or stored) takes `(names as ptr[])[i] = string.keep(s)`, given back with `string.release_kept` where the memory is freed, and refuses any other string but a literal or a `const` bound to one. A closure's `ptr` parameter borrows unless it is declared `@keep` (`|el: @keep ptr, t: float|`), and the mode is part of a function's type, `fn(@keep ptr, float)`: a call through a value of that type is checked as a direct call is, and a function goes only where its type says what it keeps, never into a bare `fn`, a `ptr` or a typed C function pointer, whose parameters borrow ([Closure types that say what they keep](#closure-types-that-say-what-they-keep-fnkeep-ptr-float)). A typed pointer (`*Node`) and a `distinct` type over `ptr` are not checked: a string converts to neither. -- **A `fn` parameter, and a struct parameter taken by value that holds a `ptr` or a closure, are borrowed the same way** (E0902). A closure may capture a borrowed `ptr` of its caller, and a struct's `ptr` field points at the caller's memory, so a function that keeps such a parameter (stores it where it outlives the call, boxes it there with `box_closure`, puts it in a container, or passes it to a parameter that keeps it) declares it `@keep`: `on_event(cb: @keep fn)`, `register(h: @keep Handler)`. The function's own body is checked, as for a `ptr` parameter, and a caller is then checked against the declaration: a closure that captures a borrowed `ptr`, given to a `@keep fn` parameter, is E0902 at the caller. A closure the function only calls, or hands to a borrowing parameter (`run(|| { use(p) })`), keeps nothing, and neither does handing the parameter back with `return`. A store into memory the function allocates and frees (an array it hands to C) keeps nothing either, for any parameter. The declaration changes no count: the call borrows the closure as every call does, and the callee's store takes its own reference. +- **A `ptr` parameter is borrowed for the call unless it is declared `@keep`** (E0902). A `ptr` owns nothing, so a pointer a function keeps (stores where it outlives the call, returns, sends in a message, puts in a std container, or passes to another `@keep` parameter) is declared `p: @keep ptr`; a pointer computed from it (`p + n`, `p - n`) points into the same memory and counts as `p`. A struct local to the function, and a closure that runs and is dropped inside the call, do not outlive it: putting the pointer in one (`s = Pair { a: p }`, `s.a = p`, `run(|| { use(p) })`) keeps nothing, and the struct or the closure is a keep only when it escapes itself, returned, stored in a field, a module-level `var` or a container, or passed to a `@keep` or `@retain` parameter. A tuple (`t = (p, 1)`), an optional, and anything read out of a holder (a field, a tuple part, an optional's payload `o!` or `o ?? d`, a `match` binding) hold it the same way, except that a part of a tuple or a field of a struct built from parts holds only what went into that part or field (after `t = (p, q)` and `a, b = t`, `b` holds `q` and not `p`; after `s = Pair { a: p, b: q }` or `s.b = q`, `s.b` holds `q`), and so does what a closure that captured it returns (`call(f)`). A closure that captured the pointer and stores it somewhere that outlives the call keeps it too, and so does one that reads it through an address of a local, or a local a closure assigns, stored there after the closure was written: it is checked where it is called or passed (`g = || { kept = pb.item }`, `b.item = p`, `call(g)`). The checker verifies each function against its own body, and a call against the callee's signature; a loop's body is checked with what every pass through it binds, so `kept = q` placed before `q = p` in a loop keeps `p`, unless the body first binds `q` anew (`q = list.new()`). A `string` reaches a `@keep ptr` only through `string.keep(s)`, which hands over a reference of its own (`string.release_kept(p)` gives it back), or as a literal or a `const` bound to one, which is pinned, and a `*StringSeq` only through `string.seq_retain(s)` (`string.seq_free` gives it back). C memory viewed as `string[]` (`(names as string[])[i] = s`) is given the string's characters and no reference, so the string must outlive C's reads: a string built in the statement never does, and a local does only when the memory ends with the function (an argv array the function allocates, hands to C and does not let escape). Memory that outlives the function (reached through a parameter, a module-level `var`, a field or a heap box, or whose pointer is returned or stored) takes `(names as ptr[])[i] = string.keep(s)`, given back with `string.release_kept` where the memory is freed, and refuses any other string but a literal or a `const` bound to one. A closure's `ptr` parameter borrows unless it is declared `@keep` (`|el: @keep ptr, t: float|`), and the mode is part of a function's type, `fn(@keep ptr, float)`: a call through a value of that type is checked as a direct call is, and a function goes only where its type says what it keeps, never into a bare `fn`, a `ptr` or a typed C function pointer, whose parameters borrow ([Closure types that say what they keep](#closure-types-that-say-what-they-keep-fnkeep-ptr-float)). A typed pointer (`*Node`) and a `distinct` type over `ptr` are not checked: a string converts to neither. +- **A `fn` or `fn?` parameter, and a struct or sum parameter taken by value that holds a `ptr` or a closure (in a field or a variant, or an optional of one), are borrowed the same way** (E0902). A closure may capture a borrowed `ptr` of its caller, and a struct's `ptr` field points at the caller's memory, so a function that keeps such a parameter (stores it where it outlives the call, boxes it there with `box_closure`, puts it in a container, or passes it to a parameter that keeps it) declares it `@keep`: `on_event(cb: @keep fn)`, `register(h: @keep Handler)`. What a call keeps is read from the callee's declaration, never from the name it spells: std's list and map stores (`list.add`, `list.set`, `map.put`, their raw forms and their `collections.` spellings) declare the item `@retain` at the C boundary, so they keep it, and a program's own `list_add` keeps what its own body keeps. The function's own body is checked, as for a `ptr` parameter, and a caller is then checked against the declaration: a closure that captures a borrowed `ptr`, given to a `@keep fn` parameter, is E0902 at the caller. A closure the function only calls, or hands to a borrowing parameter (`run(|| { use(p) })`), keeps nothing, and neither does handing the parameter back with `return`. A store into memory the function allocates and frees (an array it hands to C) keeps nothing either, for any parameter, and neither does one into a std container the function makes (`list.new()`, `map.new()`) and lets go nowhere: the stores name the container that holds what they keep (`@holds`), and a list a function makes, uses and frees, or hands only to functions that borrow it, ends with the function. A container that is a parameter, a field or a module-level `var`, or that is returned or stored, keeps it past the call. The declaration changes no count: the call borrows the closure as every call does, and the callee's store takes its own reference. - **A `ptr` parameter declared `@into` may reach the function's result and nowhere else** (E0902). A function that builds a value around its argument (a recorder, an iterator, a view) declares `p: @into ptr` and may `return` it, as the pointer, a view of it (`p as *Node`, `p + n`), in a struct, a tuple part or an optional, in a closure that captures it, or in the result of another `@into` call; any other escape is E0902 as for a borrowed parameter. Its result type must be able to hold a pointer (a pointer, a function, or a struct, tuple, optional, array or sum with such a part), and only a bare `ptr` takes `@into`. The caller holds the result as it holds a struct local that holds its argument: the result may live in a local, be read and be lent to borrowing parameters (`(&q) as ptr`), and it escapes, with the argument, only where the argument itself would (returned, stored in a module-level `var`, a field through a pointer or a container, sent, captured by a closure that escapes, given to a `@keep` or `@retain` parameter). With several `@into` parameters the result holds every one of them. A function that returns another `@into` call's result made from its own parameter declares that parameter `@into` too, and its callers are checked the same way. A string reaches an `@into ptr` only through `string.keep(s)` (or as a literal or a `const` bound to one), a closure given to one is boxed on the heap with a reference of its own (`release_closure` gives it back), and `@into` is part of a function's type as `@keep` is (`fn(@into ptr) -> Recorder`): a call through such a value holds its result to the same rule. An extern's parameter may be `@into` too ([`@retain`, `@noescape` and `@into`](#retain-noescape-and-into-what-an-extern-does-with-a-parameter)). ```aether,run @@ -2259,6 +2259,41 @@ hi std and contrib declare the parameters they keep: `worker.run`, `run_detached`, `set_main_poster` and `map`, `observe.observe` and `observe_fields`, `schema.refine`, `spec.before_each` and `after_each`, `quickjs.function`, and tinyweb's route, filter, websocket, SSE and `on_*` registrations. A closure that captures a borrowed `ptr`, given to one of them, is E0902 where it is given: copy what it needs, or declare the `ptr` `@keep` too. +#### Migrating from 0.838 + +What a call keeps is read from the callee's declaration (#3086). std's list and map stores declare their item `@retain` at the C boundary: `list.add`, `list.add_raw`, `list.set`, `map.put` and `map.put_raw`, and their `collections.` spellings, which are now externs over C functions of the same names with the same results. `string.seq_cons` declares its head and its tail `@retain` too. So a borrowed `ptr` parameter put in a list or a map is E0902, as storing it in a module-level `var` is. On 0.838.0 only a closure or a struct that held one was checked there, and the pointer itself outlived the call in the container. Declare the parameter `@keep`, or store a copy. A list or a map the function makes itself (`list.new()`, `map.new()`) and lets go nowhere is no such place: a borrowed `ptr` stored in it, as a worklist or a chain of the caller's lists, keeps nothing past the call. A string, a literal or a closure of the function's own is handed a reference of its own, as before. + +```aether,fails +import std.list + +var seen: ptr = null +remember(p: ptr) { _r = list.add(seen, p) } // E0902: the list keeps `p` + +main() { + seen = list.new() + remember(null) +} +``` + +```aether,run +import std.list + +var seen: ptr = null +remember(p: @keep ptr) { _r = list.add(seen, p) } // the parameter says it is kept + +main() { + seen = list.new() + remember(null) + println(list.size(seen)) +} +``` + +```output +1 +``` + +A store that copies what it is given keeps nothing: a map's key, a set's item. `@scoped` reads the same declarations: a `@scoped` value given to a parameter that keeps it escapes its block ("it is passed to a parameter that keeps it"), as one given to `list.add`, `map.put` or `string.seq_cons` (head or tail) does, and one given to `set.add`, which copies it, does not. + --- ## Optionals @@ -3686,7 +3721,22 @@ the argument only during the call, neither storing it, handing it to another thread, nor freeing it. It is accepted on `ptr` and `fn` parameters. The std externs that keep a closure are declared `@retain`: `observe`, the worker pool's jobs and main-thread poster, and the HTTP server's route, SSE and -WebSocket handlers. +WebSocket handlers. So are the stores that keep what they are given (#3086): +the item of std's list and map stores (`list.add`, `list.add_raw`, +`list.set`, `map.put`, `map.put_raw`), and the head and the tail of +`string.seq_cons`. A `*StringSeq` given to an `@retain` parameter is handed +a reference of its own as a string is: a fresh one as it is, a place's +retained. What a call keeps is read from these declarations: a borrowed +`ptr` given to one is E0902, and a `@scoped` value given to one escapes. + +A store names the argument that holds what it keeps with `@holds` on that +`ptr` parameter (std's list and map stores, their container), and an extern +whose result is memory the call allocates, which nothing else holds, says so +with `@fresh` after its result type (`list_new() -> ptr @fresh`, std's list +and map constructors). A keep through a store then outlives the caller only +when the holder does: a local bound only from `@fresh` calls that goes +nowhere a borrowed `ptr` would escape to ends with the function, so a +borrowed `ptr` stored in it keeps nothing past the call. `@into` on a `ptr` parameter says the C function's result points into or holds the argument, and keeps it nowhere else. The compiler cannot read C, diff --git a/docs/memory-management.md b/docs/memory-management.md index a70053496..cbfb5b680 100644 --- a/docs/memory-management.md +++ b/docs/memory-management.md @@ -443,9 +443,12 @@ main() { The type checker refuses every way the binding's value itself could outlive the block: returning it, assigning or aliasing it into another binding or a struct field, placing it in an array, struct or message literal, capturing it -in a closure or a quote (either can outlive the block), and inserting it into -a container (`list.add`, `list.set`, `map.put`, `set.add`, `string.seq_cons`, -and their raw and owning forms): +in a closure or a quote (either can outlive the block), and giving it to a +parameter that keeps it, as the callee declares (an Aether function's +`@keep` one, an extern's `@retain` one): the item of a list or map store +(`list.add`, `list.set`, `map.put` and their raw forms), the head or the tail +of `string.seq_cons`. A parameter that copies what it is given keeps nothing: +`set.add` copies its item, and a map copies its key. ```aether,fails import std.string diff --git a/new_changelogs/20261021T221816Z-fixed-3079-container-by-resolved-callee.md b/new_changelogs/20261021T221816Z-fixed-3079-container-by-resolved-callee.md new file mode 100644 index 000000000..e6a0d3034 --- /dev/null +++ b/new_changelogs/20261021T221816Z-fixed-3079-container-by-resolved-callee.md @@ -0,0 +1,32 @@ +- **Migrating from 0.838: a function that puts a borrowed `ptr` parameter + in a std container declares it `@keep`.** What a call keeps is read from + the callee's declaration, never from the name it spells (#3079, #3086). + std's list and map stores declare their item `@retain` at the C + boundary: `list.add`, `list.add_raw`, `list.set`, `map.put` and + `map.put_raw` (and their `collections.` spellings, now externs over C + functions of the same names, with the same results), and `string.seq_cons` + its head and its tail. A borrowed `ptr` parameter put in a list or a map + is E0902, as one stored in a module `var` is; on 0.838.0 only a closure + or a struct holding one was checked, and `collections.list_add(routes, + box_closure(cb))` not even that, while a program's own `list_add` that + kept nothing was refused. + + ```aether + remember(p: ptr) { _r = list.add(seen, p) } // E0902: the list keeps `p` + remember(p: @keep ptr) { _r = list.add(seen, p) } // the parameter says it is kept + ``` + + A list or a map the function makes itself and lets go nowhere is no + such place: the stores name the container that holds what they keep + (`@holds`), the constructors say their result is fresh (`@fresh`), and + a borrowed `ptr` parked in such a worklist keeps nothing past the call. + + A `fn?` parameter, and a struct or sum parameter one of whose fields or + variants holds a closure or a `ptr` (an optional of one included), are + borrowed as a `fn` parameter is. `@scoped` reads the same declarations: + a value given to a parameter that keeps it escapes, including + `string.seq_cons`'s tail; one given to `set.add`, which copies it, does + not. `collections.map_put_string_owned` declared its copied key + `@retain`, so a counted key handed the map a reference it never released + (std.map declared the same function without it, so which applied + depended on import order); the key is borrowed now. diff --git a/std/cbor/module.ae b/std/cbor/module.ae index 4d47a1d64..32efcdd50 100644 --- a/std/cbor/module.ae +++ b/std/cbor/module.ae @@ -1303,7 +1303,7 @@ map_get(obj_ptr: ptr, key_ptr: ptr) -> ptr! { // Insert or update the text key `key` on a map. The map takes ownership of // `value`; a value it replaces is freed. -object_set(obj_ptr: ptr, key: string, value: ptr) -> string { +object_set(obj_ptr: ptr, key: string, value: @keep ptr) -> string { if obj_ptr == null { return "null map" } cv = obj_ptr as *CborValue if cv.type != CBOR_MAP { @@ -1332,11 +1332,11 @@ object_set(obj_ptr: ptr, key: string, value: ptr) -> string { } // Terse helper alias for object_set (similar to json.set) -set(obj_ptr: ptr, key: string, value: ptr) -> string { +set(obj_ptr: ptr, key: string, value: @keep ptr) -> string { return object_set(obj_ptr, key, value) } -array_add(arr_ptr: ptr, value: ptr) -> string { +array_add(arr_ptr: ptr, value: @keep ptr) -> string { if arr_ptr == null { return "null array" } cv = arr_ptr as *CborValue if cv.type != CBOR_ARRAY { @@ -1348,11 +1348,11 @@ array_add(arr_ptr: ptr, value: ptr) -> string { } // Terse helper alias for array_add (similar to json.push) -push(arr_ptr: ptr, value: ptr) -> string { +push(arr_ptr: ptr, value: @keep ptr) -> string { return array_add(arr_ptr, value) } -map_set(obj_ptr: ptr, key_ptr: ptr, value_ptr: ptr) -> string { +map_set(obj_ptr: ptr, key_ptr: @keep ptr, value_ptr: @keep ptr) -> string { if obj_ptr == null { return "null map" } cv = obj_ptr as *CborValue if cv.type != CBOR_MAP { @@ -1366,7 +1366,7 @@ map_set(obj_ptr: ptr, key_ptr: ptr, value_ptr: ptr) -> string { } // Terse helper alias for map_set -map_push(obj_ptr: ptr, key_ptr: ptr, value_ptr: ptr) -> string { +map_push(obj_ptr: ptr, key_ptr: @keep ptr, value_ptr: @keep ptr) -> string { return map_set(obj_ptr, key_ptr, value_ptr) } diff --git a/std/collections/aether_collections.c b/std/collections/aether_collections.c index 5d79eab30..29ace3998 100644 --- a/std/collections/aether_collections.c +++ b/std/collections/aether_collections.c @@ -128,6 +128,14 @@ int list_add_raw(ArrayList* list, void* item) { return 1; } +/* std.list's `add` and std.collections' `list_add` (#3086): the store, + * with list_add_raw's int turned into the "" | error string the Aether + * API returns. A C function, so the `item` it keeps is declared at the C + * boundary (`@aether @retain ptr`), as every std store's is. */ +const char* list_add(ArrayList* list, void* item) { + return list_add_raw(list, item) ? "" : "list.add failed"; +} + /* Heap-string-aware add (#467). Retains the AetherString so the * sender's reassign-wrapper / function-exit defer doesn't dangle * the list's reference, and tags this specific element as owned @@ -593,6 +601,11 @@ int map_put_raw(HashMap* map, const char* key, void* value) { return 1; } +/* std.map's `put` and std.collections' `map_put` (#3086), as list_add. */ +const char* map_put(HashMap* map, const char* key, void* value) { + return map_put_raw(map, key, value) ? "" : "map.put failed"; +} + /* Heap-string-aware put (#467). Mirrors list_add_string_owned for * map values: retains the value (AetherString refcount bump), * tags the map as owning string values, then stores via the plain diff --git a/std/collections/aether_collections.h b/std/collections/aether_collections.h index 570dbb6ba..5bb8998ff 100644 --- a/std/collections/aether_collections.h +++ b/std/collections/aether_collections.h @@ -27,6 +27,9 @@ typedef struct StrArray StrArray; ArrayList* list_new(void); ArrayList* list_new_in(AetherAllocator* alloc); int list_add_raw(ArrayList* list, void* item); +/* std.list's `add` and std.collections' `list_add` (#3086): list_add_raw's + * store, its int turned into the "" | error string the Aether API returns. */ +const char* list_add(ArrayList* list, void* item); /* Heap-string-aware add (#467). Retains the value (AetherString * refcount bump), tags the list as owning string elements, then @@ -70,6 +73,8 @@ void list_free(ArrayList* list); HashMap* map_new(void); int map_put_raw(HashMap* map, const char* key, void* value); +/* std.map's `put` and std.collections' `map_put` (#3086), as list_add. */ +const char* map_put(HashMap* map, const char* key, void* value); /* Heap-string-aware put (#467). Retains the value and tags the * map so map_free / map_clear release each string value before diff --git a/std/collections/aether_stringseq.c b/std/collections/aether_stringseq.c index 0c6de2450..31ee941b3 100644 --- a/std/collections/aether_stringseq.c +++ b/std/collections/aether_stringseq.c @@ -48,6 +48,20 @@ StringSeq* string_seq_cons(const char* head, StringSeq* tail) { return cell; } +/* `string.seq_cons` from Aether (#3086). Its `head` and `tail` are declared + * `@retain`, so the cell keeps both by its declaration, and the call hands + * it a reference to each, which it takes over: a string handed here is + * released once the cell holds its own, and the tail's reference is the + * cell's. string_seq_cons takes references of its own instead, for C and + * for the compiler's sequence literals. When the cell cannot be made, the + * handed references are released, as the cell would have released them. */ +StringSeq* string_seq_cons_adopt(const char* head, StringSeq* tail) { + StringSeq* cell = seq_cell(head, tail); + string_release(head); + if (!cell) string_seq_free(tail); + return cell; +} + const char* string_seq_head(StringSeq* s) { /* Cast through `const char*` is safe: the head is one of an * AetherString* (recognised via the magic-byte check) or a diff --git a/std/collections/aether_stringseq.h b/std/collections/aether_stringseq.h index 726f35b88..9646d8a3c 100644 --- a/std/collections/aether_stringseq.h +++ b/std/collections/aether_stringseq.h @@ -70,6 +70,10 @@ StringSeq* string_seq_empty(void); * the local ref — the new cell holds its own. */ StringSeq* string_seq_cons(const char* head, StringSeq* tail); +/* `string.seq_cons` from Aether (#3086): the same cell, taking over the + * references the call hands it to `head` and `tail` (both `@retain`). */ +StringSeq* string_seq_cons_adopt(const char* head, StringSeq* tail); + /* Borrowed reference to the head element. Returns "" on the empty * list (matches the existing `string.length("")` shape — caller can * treat the empty case identically). The returned pointer is diff --git a/std/collections/module.ae b/std/collections/module.ae index dbeeebfba..0e2106064 100644 --- a/std/collections/module.ae +++ b/std/collections/module.ae @@ -27,25 +27,32 @@ exports( ) // ---- ArrayList raw externs ---- -extern list_new() -> ptr -extern list_new_in(alloc: ptr) -> ptr -extern list_add_raw(list: ptr, item: @aether ptr) -> int +extern list_new() -> ptr @fresh +extern list_new_in(alloc: ptr) -> ptr @fresh +// The stores keep their item past the call (#3086): a `ptr` stays the +// container's, so a function that stores a borrowed `ptr` parameter +// declares it `@keep`, and a string or a closure is handed a reference of +// its own (`@retain`, R4). +extern list_add_raw(list: @holds ptr, item: @aether @retain ptr) -> int +// Append an item to a list. Returns "" on success, error on failure (OOM +// during resize, null list). +extern list_add(list: @holds ptr, item: @aether @retain ptr) -> string // String-aware add (#467), for C and `ptr` callers: the list takes a // reference of its own (a counted string retained, a plain C pointer // copied), tags the element and stores it; list_free releases owned // elements before freeing the backing array. `list.add(l, s)` of a // `string` in Aether code is a store into the list (R3): the compiler // hands the list a reference, moving a fresh value in. -extern list_add_string_owned(list: ptr, item: @aether ptr) -> int +extern list_add_string_owned(list: @holds ptr, item: @aether ptr) -> int extern list_get_raw(list: ptr, index: int) -> ptr -extern list_set(list: ptr, index: int, item: @aether ptr) +extern list_set(list: @holds ptr, index: int, item: @aether @retain ptr) extern list_size(list: ptr) -> int extern list_remove(list: ptr, index: int) extern list_clear(list: ptr) extern list_free(list: ptr) // ---- HashMap raw externs ---- -extern map_new() -> ptr +extern map_new() -> ptr @fresh // `key` is BORROWED: map_put_raw copies the key into the map's own // AetherString (string_new at put time, released at map_free), so it // never holds a reference to the caller's key bytes after the call. @@ -56,10 +63,16 @@ extern map_new() -> ptr // Keys cross as `@aether string`: the C map reads the AetherString's length, // so a key with an embedded NUL is the whole key (#2469). Unwrapped to a C // string, "a\0x" and "a\0y" were one key, "a". -extern map_put_raw(map: ptr, key: @aether string, value: @aether ptr) -> int +extern map_put_raw(map: @holds ptr, key: @aether string, value: @aether @retain ptr) -> int +// Insert or update a key-value pair. Returns "" on success, error on +// failure (OOM during resize, null map, null key). +extern map_put(map: @holds ptr, key: @aether string, value: @aether @retain ptr) -> string // Heap-string-aware put (#467). Retains the value and tags the -// map so map_free releases each string value before freeing. -extern map_put_string_owned(map: ptr, key: @aether @retain string, value: @aether ptr) -> int +// map so map_free releases each string value before freeing. The key is +// copied, as map_put_raw's is, so it is not `@retain` either (#3086: it +// was, and a call with a counted key handed the map a reference it never +// released). +extern map_put_string_owned(map: @holds ptr, key: @aether string, value: @aether ptr) -> int extern map_get_raw(map: ptr, key: @aether string) -> ptr extern map_has(map: ptr, key: @aether string) -> int extern map_remove(map: ptr, key: @aether string) @@ -136,16 +149,6 @@ extern string_list_sort_lex(list: ptr) // ---- Go-style wrappers ---- -// Append an item to a list. Returns "" on success, error on failure -// (OOM during resize, null list). -list_add(list: ptr, item: ptr) -> { - ok = list_add_raw(list, item) - if ok == 0 { - return "list.add failed" - } - return "" -} - // Index into a list. // - (item, "") : index in range // - (null, "index out of range") : index < 0 or index >= size @@ -161,16 +164,6 @@ list_get(list: ptr, index: int) -> { return list_get_raw(list, index), "" } -// Insert or update a key-value pair. Returns "" on success, error on -// failure (OOM during resize, null map, null key). -map_put(map: ptr, key: string, value: ptr) -> { - ok = map_put_raw(map, key, value) - if ok == 0 { - return "map.put failed" - } - return "" -} - // Look up a key. // - (value, "") : key present // - (null, "") : key absent (not an error — use map.has to disambiguate) diff --git a/std/jsonpath/query.ae b/std/jsonpath/query.ae index df2443298..1227fd1cf 100644 --- a/std/jsonpath/query.ae +++ b/std/jsonpath/query.ae @@ -172,9 +172,10 @@ _new_result() -> ptr { return c } -// Append one matched node. The list keeps a reference of its own to the -// path (list_add_string_owned); the caller still owns what it passed. -_push(cursors: ptr, value: ptr, path: string) { +// Append one matched node. The cursor keeps `value` (`@keep`, #3086); the +// list takes a reference of its own to the path (list_add_string_owned), +// which the caller still owns. +_push(cursors: ptr, value: @keep ptr, path: string) { c = cursors as *Cursor list.add_raw(c.values, value) list.add_string_owned(c.paths, path) @@ -199,7 +200,7 @@ _fork(cursors: ptr) -> ptr { return out } -_fork_single(value: ptr, path: string) -> ptr { +_fork_single(value: @keep ptr, path: string) -> ptr { out = _new_result() _push(out, value, path) return out @@ -328,7 +329,7 @@ _expand_descendants(cursors: ptr) -> ptr { // Pre-order: this node, then each child. Child paths are plain builder // buffers; the recursive call copies them into the result list and the // local buffer is released after the subtree it labels is walked. -_collect_descendants(value: ptr, path: string, out: ptr) { +_collect_descendants(value: @keep ptr, path: string, out: ptr) { _push(out, value, path) t = json.type(value) @@ -570,7 +571,7 @@ _eval_filter(f: ptr, current: @keep ptr, root: @keep ptr) -> int { // An existence test is true when the embedded query matches at least one // node; a LogicalType function test is true when the function says so. -_eval_test(t: ptr, current: ptr, root: @keep ptr) -> int { +_eval_test(t: ptr, current: @keep ptr, root: @keep ptr) -> int { tp = t as *Test if tp.kind == model.T_FUNCTION { @@ -884,7 +885,7 @@ _json_cmp(a: ptr, b: ptr) -> int { // length() / count() / value() — ValueType results. Returns a freshly // allocated json value the caller owns, or null for Nothing. -_eval_value_fn(fp: *TestFn, current: ptr, root: @keep ptr) -> ptr { +_eval_value_fn(fp: *TestFn, current: @keep ptr, root: @keep ptr) -> ptr { if fp.kind == model.FN_LENGTH { arg = list.get_raw(fp.args, 0) v = _arg_to_value(arg, current, root) @@ -925,7 +926,7 @@ _eval_value_fn(fp: *TestFn, current: ptr, root: @keep ptr) -> ptr { // Evaluate a function argument down to a single value, or null when the // argument's nodelist is not exactly one node (RFC 9535 §2.4.4: length() // of a multi-node or empty nodelist is Nothing). -_arg_to_value(arg: ptr, current: ptr, root: @keep ptr) -> ptr { +_arg_to_value(arg: ptr, current: @keep ptr, root: @keep ptr) -> ptr { ap = arg as *FnArg if ap.lit != null { @@ -986,7 +987,7 @@ _release_arg_value(arg: ptr, value: ptr) { } // The node COUNT of a query argument, for count(). -_arg_nodelist_size(arg: ptr, current: ptr, root: @keep ptr) -> int { +_arg_nodelist_size(arg: ptr, current: @keep ptr, root: @keep ptr) -> int { ap = arg as *FnArg if ap.test == null { return 0 } @@ -1014,7 +1015,7 @@ _arg_nodelist_size(arg: ptr, current: ptr, root: @keep ptr) -> int { // the WHOLE string, search() any substring. std.regex is PCRE2, so // match() gets explicit anchors. Both return false (not an error) when // either argument is not a string, per the RFC's type rules. -_eval_regex_fn(fp: *TestFn, current: ptr, root: @keep ptr, substring: int) -> int { +_eval_regex_fn(fp: *TestFn, current: @keep ptr, root: @keep ptr, substring: int) -> int { if list.size(fp.args) != 2 { return 0 } lhs_arg = list.get_raw(fp.args, 0) diff --git a/std/list/module.ae b/std/list/module.ae index 3de5685fe..d173e9a7a 100644 --- a/std/list/module.ae +++ b/std/list/module.ae @@ -7,14 +7,20 @@ // returns. exports( - list_new, list_new_in, list_add_raw, list_add_string_owned, list_get_raw, list_set, list_size, - list_remove, list_clear, list_free, - add, get, get_string + list_new, list_new_in, list_add, list_add_raw, list_add_string_owned, list_get_raw, list_set, + list_size, list_remove, list_clear, list_free, + get, get_string ) -extern list_new() -> ptr -extern list_new_in(alloc: ptr) -> ptr -extern list_add_raw(list: ptr, item: @aether ptr) -> int +extern list_new() -> ptr @fresh +extern list_new_in(alloc: ptr) -> ptr @fresh +// The stores keep their item past the call (#3086): a `ptr` stays the +// list's, so a function that adds a borrowed `ptr` parameter declares it +// `@keep`, and a string or a closure is handed a reference of its own +// (`@retain`, R4). +extern list_add_raw(list: @holds ptr, item: @aether @retain ptr) -> int +// Append an item to a list. Returns "" on success, error on failure. +extern list_add(list: @holds ptr, item: @aether @retain ptr) -> string // String-aware add, for C and `ptr` callers. The list takes a // reference of its own: a counted string is retained, a plain C // pointer is copied. The caller keeps and releases its own, so the @@ -22,24 +28,15 @@ extern list_add_raw(list: ptr, item: @aether ptr) -> int // owned element before freeing the backing array. `list.add(l, s)` of // a `string` in Aether code is a store into the list (R3): the // compiler hands the list a reference, moving a fresh value in. -extern list_add_string_owned(list: ptr, item: @aether ptr) -> int +extern list_add_string_owned(list: @holds ptr, item: @aether ptr) -> int extern list_get_raw(list: ptr, index: int) -> ptr -extern list_set(list: ptr, index: int, item: @aether ptr) +extern list_set(list: @holds ptr, index: int, item: @aether @retain ptr) extern list_size(list: ptr) -> int extern list_remove(list: ptr, index: int) extern list_clear(list: ptr) extern list_free(list: ptr) extern aether_string_is_string(p: @aether ptr) -> int -// Append an item to a list. Returns "" on success, error on failure. -add(list: ptr, item: ptr) -> { - ok = list_add_raw(list, item) - if ok == 0 { - return "list.add failed" - } - return "" -} - // Index into a list. // - (item, "") : index in range // - (null, "index out of range") : index < 0 or index >= size diff --git a/std/map/module.ae b/std/map/module.ae index a81cc0e14..4bf478650 100644 --- a/std/map/module.ae +++ b/std/map/module.ae @@ -7,17 +7,24 @@ // returns. exports( - map_new, map_put_raw, map_put_string_owned, map_get_raw, map_has, map_remove, + map_new, map_put, map_put_raw, map_put_string_owned, map_get_raw, map_has, map_remove, map_size, map_clear, map_free, map_keys_raw, map_keys_free, map_keys_size_raw, map_keys_get_raw, - put, get, get_string, keys, keys_size, keys_get + get, get_string, keys, keys_size, keys_get ) // Keys cross as `@aether string`: the C map reads the AetherString's length, // so a key with an embedded NUL is the whole key (#2469). Unwrapped to a C // string, "a\0x" and "a\0y" were one key, "a". -extern map_new() -> ptr -extern map_put_raw(map: ptr, key: @aether string, value: @aether ptr) -> int +extern map_new() -> ptr @fresh +// The stores keep their value past the call (#3086): a `ptr` stays the +// map's, so a function that puts a borrowed `ptr` parameter declares it +// `@keep`, and a string or a closure is handed a reference of its own +// (`@retain`, R4). The key is copied. +extern map_put_raw(map: @holds ptr, key: @aether string, value: @aether @retain ptr) -> int +// Insert or update a key-value pair. Returns "" on success, error on +// failure (null map/key or OOM). +extern map_put(map: @holds ptr, key: @aether string, value: @aether @retain ptr) -> string // String-aware put, for C and `ptr` callers. The map takes a // reference of its own: a counted string is retained, a plain C // pointer is copied. The caller keeps and releases its own, so the @@ -25,7 +32,7 @@ extern map_put_raw(map: ptr, key: @aether string, value: @aether ptr) -> int // owned value. `map.put(m, k, s)` of a `string` in Aether code is a // store into the map (R3): the compiler hands the map a reference, // moving a fresh value in. -extern map_put_string_owned(map: ptr, key: @aether string, value: @aether ptr) -> int +extern map_put_string_owned(map: @holds ptr, key: @aether string, value: @aether ptr) -> int extern map_get_raw(map: ptr, key: @aether string) -> ptr extern map_has(map: ptr, key: @aether string) -> int extern map_remove(map: ptr, key: @aether string) @@ -38,16 +45,6 @@ extern map_keys_size_raw(keys: ptr) -> int extern map_keys_get_raw(keys: ptr, index: int) -> string extern aether_string_is_string(p: @aether ptr) -> int -// Insert or update a key-value pair. Returns "" on success, error on -// failure (null map/key or OOM). -put(map: ptr, key: string, value: ptr) -> { - ok = map_put_raw(map, key, value) - if ok == 0 { - return "map.put failed" - } - return "" -} - // Look up a key. // - (value, "") : key present // - (null, "") : key absent (not an error — use map.has) diff --git a/std/msgpack/module.ae b/std/msgpack/module.ae index a1b9a75c6..cffc71a2a 100644 --- a/std/msgpack/module.ae +++ b/std/msgpack/module.ae @@ -285,7 +285,7 @@ array_get(val: ptr, index: int) -> ptr { return list.get_raw(m.arr_val, index) } -array_add(val: ptr, item: ptr) -> int { +array_add(val: ptr, item: @keep ptr) -> int { if val == null || item == null { return 0 } let m = val as *MsgValue if m.type_id != TYPE_ARRAY { return 0 } @@ -345,7 +345,7 @@ map_get(val: ptr, key: string) -> ptr { return null } -map_set(val: ptr, key: string, value: ptr) -> int { +map_set(val: ptr, key: string, value: @keep ptr) -> int { if val == null || value == null { return 0 } let m = val as *MsgValue if m.type_id != TYPE_MAP { return 0 } diff --git a/std/resp/module.ae b/std/resp/module.ae index 46ea58e0b..b20ead145 100644 --- a/std/resp/module.ae +++ b/std/resp/module.ae @@ -203,12 +203,12 @@ new_map() -> ptr { } // Append a child to an array/set/push (ownership moves into the aggregate). -array_add(agg: ptr, child: ptr) { +array_add(agg: ptr, child: @keep ptr) { a = agg as *RespValue if a.items != null { list.add_raw(a.items, child) } } // Append a key/value pair to a map (ownership of both moves into the map). -map_add(m: ptr, key: ptr, val: ptr) { +map_add(m: ptr, key: @keep ptr, val: @keep ptr) { mv = m as *RespValue if mv.map_keys != null { list.add_raw(mv.map_keys, key) } if mv.map_vals != null { list.add_raw(mv.map_vals, val) } diff --git a/std/spec/module.ae b/std/spec/module.ae index 89eb8c824..3d6dc0666 100644 --- a/std/spec/module.ae +++ b/std/spec/module.ae @@ -165,7 +165,7 @@ init() -> ptr { // "parent" — the enclosing suite, or null for a top-level describe // --------------------------------------------------------------------------- -_suite_new(name: string, fw: ptr, parent: ptr) -> ptr { +_suite_new(name: string, fw: @keep ptr, parent: @keep ptr) -> ptr { s = map.new() map.put_raw(s, "name", name) map.put_raw(s, "befores", list.new()) @@ -441,7 +441,7 @@ _it_begin(_ctx: @keep ptr, description: string) -> (ptr, int, long) { } // Second half: everything after the test body ran. -_it_end(_ctx: @keep ptr, description: string, it_rec: ptr, old_failed: int, t_start_ns: long, budget_ns: long) { +_it_end(_ctx: @keep ptr, description: string, it_rec: @keep ptr, old_failed: int, t_start_ns: long, budget_ns: long) { fw = _fw_of(_ctx) passed_ref = map.get_raw(fw, "passed") failed_ref = map.get_raw(fw, "failed") diff --git a/std/string/module.ae b/std/string/module.ae index 9352c49dc..77d6f762e 100644 --- a/std/string/module.ae +++ b/std/string/module.ae @@ -273,7 +273,9 @@ extern string_join(seq: *StringSeq, sep: string) -> string @heap // A `@heap` result hands the caller a reference of its own; `seq_tail` // lends the sequence's own tail. extern string_seq_empty() -> *StringSeq @heap -extern string_seq_cons(head: string, tail: *StringSeq) -> *StringSeq @heap +// The cell keeps its head and its tail (#3086), so both are `@retain`: the +// call hands it a reference to each, which string_seq_cons_adopt takes over. +@extern("string_seq_cons_adopt") string_seq_cons(head: @retain string, tail: @retain *StringSeq) -> *StringSeq @heap extern string_seq_head(s: *StringSeq) -> string extern string_seq_tail(s: *StringSeq) -> *StringSeq extern string_seq_is_empty(s: *StringSeq) -> int diff --git a/std/worker/module.ae b/std/worker/module.ae index 922898c65..3a922dd3d 100644 --- a/std/worker/module.ae +++ b/std/worker/module.ae @@ -78,7 +78,7 @@ exports( // and invokes it (on the worker thread for `work`, on the loop thread for // `done`) with the emitted fn(env, args...) convention. `ptr` job handles are // opaque WorkerJob*. -extern aether_worker_run(work: @retain fn, done: @retain fn) -> int +extern aether_worker_run(work: @retain fn, done: @retain fn(@keep ptr)) -> int extern aether_worker_run_detached(work: @retain fn) -> int extern aether_worker_set_main_poster(poster: @retain fn) extern aether_worker_deliver(job: ptr) @@ -94,7 +94,10 @@ extern aether_worker_pool_shutdown() // that result to `done` on the loop thread (through the installed poster, or // the drain queue if none). Returns true if the worker launched, false on // failure (no threads / out of memory) — on false neither closure runs. -run(work: @keep fn, done: @keep fn) -> bool { +// The result is the completion's: `done` takes it `@keep` (#3086), so it may +// store it (in a list, as `map` does), and a completion that only reads it +// fits as well. +run(work: @keep fn, done: @keep fn(@keep ptr)) -> bool { return aether_worker_run(work, done) == 1 } @@ -188,7 +191,7 @@ map(items: ptr, f: @keep fn) -> ptr { let r: ptr = call(f, item) return r }, - | r: ptr | { list.set(out, slot, r) } + | r: @keep ptr | { list.set(out, slot, r) } ) i = i + 1 } diff --git a/tests/compiler/test_aea.c b/tests/compiler/test_aea.c index 1d8ae40b7..9a9db491b 100644 --- a/tests/compiler/test_aea.c +++ b/tests/compiler/test_aea.c @@ -415,7 +415,13 @@ TEST_CATEGORY(aea_codec_covers_every_ast_field, TEST_CATEGORY_COMPILER) { * checker binds them to their parameters. It is set only by the checker * (NULL after a parse), so like `origin_module` the codec does not carry * it and refuses an AST that has it; neither the wire format nor - * AEA_FORMAT_VERSION changed. */ + * AEA_FORMAT_VERSION changed. + * + * ASTNode grew to 152 with `callee_extern` (#3086): the extern a call + * was typed against, recorded by the type checker. Like `arg_order` it + * is NULL after a parse, so the codec does not carry it and refuses an + * AST that has it; neither the wire format nor AEA_FORMAT_VERSION + * changed. */ /* Type grew to 136 with `closure_literal` (#2460), a type-checker * back-pointer like compound_node: NULL after a parse and refused by * encode_type, so the wire format and AEA_FORMAT_VERSION are unchanged @@ -430,7 +436,7 @@ TEST_CATEGORY(aea_codec_covers_every_ast_field, TEST_CATEGORY_COMPILER) { * parse state, so the codec carries it and AEA_FORMAT_VERSION went to * 5. */ if (sizeof(void*) == 8 && sizeof(int) == 4) { - ASSERT_EQ(144, (int)sizeof(ASTNode)); + ASSERT_EQ(152, (int)sizeof(ASTNode)); ASSERT_EQ(152, (int)sizeof(Type)); } } diff --git a/tests/integration/fmt_type_prefix/test_fmt_type_prefix.sh b/tests/integration/fmt_type_prefix/test_fmt_type_prefix.sh index 6f6432fb0..5a127f081 100644 --- a/tests/integration/fmt_type_prefix/test_fmt_type_prefix.sh +++ b/tests/integration/fmt_type_prefix/test_fmt_type_prefix.sh @@ -7,7 +7,9 @@ # `[Dir] string`. A `]` that closes a prefix group (a `[` not following a # value, holding nothing or one name) followed by a type name or `*` is now # written tight. An index before a word (`a[0] as long`) and an array literal -# before a keyword (`[y] as ptr`) keep their space. +# before a keyword (`[y] as ptr`) keep their space. #3086: so is the `*` of a +# pointer type after an attribute, `n: @retain *Node`, which was written +# `@retain * Node` as if the attribute's name were a value. # # The input and the expected output are here rather than as .ae files, so # the fmt gate and the .ae sweep do not pick them up. @@ -27,6 +29,7 @@ trap 'rm -rf "$TMPDIR"' EXIT cat >"$TMPDIR/in.ae" <<'EOF' enum Dir { N, S } struct Node { v: int } +extern keep_node(n: @retain *Node, m: @keep *Node) -> int labels_of(n: int) -> [Dir] string { labels: [Dir] string = ["n", "s"] return labels @@ -47,6 +50,7 @@ EOF cat >"$TMPDIR/want.ae" <<'EOF' enum Dir { N, S } struct Node { v: int } +extern keep_node(n: @retain *Node, m: @keep *Node) -> int labels_of(n: int) -> [Dir]string { labels: [Dir]string = ["n", "s"] return labels @@ -84,5 +88,5 @@ if [ "$(cat "$TMPDIR/got.ae")" != "$(cat "$TMPDIR/again.ae")" ]; then exit 1 fi -echo " [PASS] fmt_type_prefix: []T, []*T and [E]T tight; index and literal spacing kept" +echo " [PASS] fmt_type_prefix: []T, []*T, [E]T and @retain *T tight; index and literal spacing kept" exit 0 diff --git a/tests/integration/keeps_borrowed/test_keeps_borrowed.sh b/tests/integration/keeps_borrowed/test_keeps_borrowed.sh index e9ec45add..7b743d172 100644 --- a/tests/integration/keeps_borrowed/test_keeps_borrowed.sh +++ b/tests/integration/keeps_borrowed/test_keeps_borrowed.sh @@ -463,7 +463,7 @@ put(l: ptr, p: ptr) { main() { put(list.new(), null) } AE -refused closure_in_container 5:17 "'p' is a borrowed \`ptr\` parameter captured by a closure that is stored in a container" +refused closure_in_container 5:17 "'p' is a borrowed \`ptr\` parameter captured by a closure that is passed to a parameter that keeps it" cat > "$TMP/closure_to_keep.ae" <<'AE' var kept: ptr = null @@ -1978,5 +1978,266 @@ main() { relay(null) } AE refused struct_param_keep_declared 13:14 "'p' is a borrowed \`ptr\` parameter held by a struct that is passed to a parameter that keeps it" +# #3079, #3086: what a call keeps is read from the declaration it reaches, +# never from the name it spells. std.collections' `list_add` declares its +# item `@retain`, so it keeps what it is given, a boxed closure parameter +# here. +cat > "$TMP/container_qualified.ae" <<'AE' +import std.collections + +var routes: ptr = null + +route(cb: fn) { + collections.list_add(routes, box_closure(cb)) +} + +main() { + routes = collections.list_new() + route(|| { println("x") }) +} +AE +refused container_qualified 6:34 "'cb' is a borrowed \`fn\` parameter, held by a boxed closure that is passed to a parameter that keeps it" + +# A program's own `list_add` that keeps nothing is no container. +cat > "$TMP/own_list_add.ae" <<'AE' +count(l: ptr, x: ptr) -> int { + return 0 +} + +list_add(l: ptr, x: ptr) -> int { + return count(l, x) +} + +own(cb: fn) { + _ = list_add(null, box_closure(cb)) +} + +main() { own(|| { println("x") }) } +AE +accepted own_list_add + +# A `fn?` parameter is borrowed as the closure it may hold. +cat > "$TMP/fn_opt_param_kept.ae" <<'AE' +var kept: fn? = none + +keep(cb: fn?) { + kept = cb +} + +main() { keep(none) } +AE +refused fn_opt_param_kept 4:5 "'cb' is a borrowed \`fn?\` parameter, stored in a module-level \`var\`" + +# So is a struct with a `fn?` field, and a sum one of whose variants holds a +# closure, captured by a closure that escapes. +cat > "$TMP/struct_opt_fn_captured.ae" <<'AE' +var kept: ptr = null + +struct Holder { cb: fn? } + +keep(h: Holder) { + g = || { println("${h.cb == none}") } + kept = box_closure(g) +} + +main() { keep(Holder { cb: none }) } +AE +refused struct_opt_fn_captured 7:5 "'h' is a borrowed \`Holder\` parameter, which holds a pointer" + +cat > "$TMP/sum_fn_captured.ae" <<'AE' +var kept: ptr = null + +struct Act { cb: fn } +struct Plain { n: int } +type Acting = Act | Plain + +keep(a: Acting) { + g = || { + match a { + Act -> { call(a.cb) } + Plain -> { println("plain") } + } + } + kept = box_closure(g) +} + +main() { keep(Plain { n: 1 }) } +AE +refused sum_fn_captured 14:5 "'a' is a borrowed \`Acting\` parameter, which holds a pointer" + +# #3086: a borrowed `ptr` parameter put in a std container is kept, as one +# stored in a module `var` is: list.add, list.add_raw, list.set and map.put +# (its value) declare the item `@retain` at the C boundary. +cat > "$TMP/ptr_into_containers.ae" <<'AE' +import std.list +import std.map + +var seen: ptr = null +var names: ptr = null + +remember(p: ptr) { + _r = list.add(seen, p) +} + +remember_raw(p: ptr) { + _x = list.add_raw(seen, p) +} + +replace(p: ptr) { + list.set(seen, 0, p) +} + +name_it(k: string, p: ptr) { + _e = map.put(names, k, p) +} + +main() { + seen = list.new() + names = map.new() + remember(null) + remember_raw(null) + replace(null) + name_it("a", null) +} +AE +refused ptr_into_containers 8:25 "'p' is a borrowed \`ptr\` parameter, passed to a parameter that keeps it" +for at in 12:29 16:23 20:28; do + case "$out" in + *"ptr_into_containers.ae:$at"*) ;; + *) echo " [FAIL] ptr_into_containers: no E0902 at $at"; fails=$((fails + 1)) ;; + esac +done + +# Declared `@keep`, the parameter may be stored; a map's key is copied, and +# a string, a literal or a closure of the function's own is handed a +# reference of its own. +cat > "$TMP/containers_accepted.ae" <<'AE' +import std.list +import std.map +import std.string + +var seen: ptr = null +var names: ptr = null + +remember(p: @keep ptr) { + _r = list.add(seen, p) +} + +name_it(k: string, s: string) { + _e = map.put(names, k, string.concat(s, "!")) + _f = map.put(names, "lit", "v") + _g = list.add(seen, s) + _h = list.add(seen, || { println(k) }) +} + +main() { + seen = list.new() + names = map.new() + remember(null) + name_it("a", "b") +} +AE +accepted containers_accepted + +# #3086: a store keeps its item in the argument the extern names `@holds` +# (std's list and map stores), so a keep through one outlives the call only +# when that container does. A list the function makes (`list.new()` is +# `@fresh`) and lets go nowhere keeps nothing past it: jq's free_node parks +# its root in one, liquid's layout chain hands its lists to helpers that +# read and free them. +cat > "$TMP/holder_local.ae" <<'AE' +import std.list + +walk_list(chain: ptr) -> int { + return list.size(chain) +} + +drop_list(chain: ptr) { + list.free(chain) +} + +free_tree(n: ptr) { + if n == null { return } + work = list.new() + list.add_raw(work, n) + _e = list.add(work, n) + list.free(work) +} + +layout(kinds: ptr, contents: ptr) -> int { + chain_kinds = list.new() + chain_contents = list.new() + list.add_raw(chain_kinds, kinds) + list.add_raw(chain_contents, contents) + n = walk_list(chain_kinds) + walk_list(chain_contents) + drop_list(chain_kinds) + drop_list(chain_contents) + return n +} + +main() { + free_tree(null) + println(layout(null, null)) +} +AE +accepted holder_local + +# The same list returned, stored in a field or a module var, or a list the +# function did not make (a parameter, one a function hands back) keeps what +# it holds past the call: E0902 at the store. +cat > "$TMP/holder_escapes.ae" <<'AE' +import std.list + +struct Box { items: ptr } + +var shelf: ptr = null + +returned(p: ptr) -> ptr { + l = list.new() + list.add_raw(l, p) + return l +} + +in_field(b: *Box, p: ptr) { + l = list.new() + list.add_raw(l, p) + b.items = l +} + +in_var(p: ptr) { + l = list.new() + list.add_raw(l, p) + shelf = l +} + +into_param(l: ptr, p: ptr) { + list.add_raw(l, p) +} + +the_shelf() -> ptr { + return shelf +} + +not_fresh(p: ptr) { + l = the_shelf() + list.add_raw(l, p) +} + +main() { + shelf = list.new() + _l = returned(null) + in_var(null) + into_param(shelf, null) + not_fresh(null) +} +AE +refused holder_escapes 9:21 "'p' is a borrowed \`ptr\` parameter, passed to a parameter that keeps it" +for at in 15:21 21:21 26:21 35:21; do + case "$out" in + *"holder_escapes.ae:$at"*) ;; + *) echo " [FAIL] holder_escapes: no E0902 at $at"; fails=$((fails + 1)) ;; + esac +done + if [ "$fails" -ne 0 ]; then exit 1; fi echo " [PASS] keeps_borrowed: a borrowed ptr parameter is never kept, alone or in a struct or closure that escapes; @keep, string.keep, string.seq_retain, a re-bound parameter, a struct local and a closure run in the call are" diff --git a/tests/integration/scoped_escape/reject_seq_cons_tail.ae b/tests/integration/scoped_escape/reject_seq_cons_tail.ae new file mode 100644 index 000000000..e6224d80b --- /dev/null +++ b/tests/integration/scoped_escape/reject_seq_cons_tail.ae @@ -0,0 +1,7 @@ +import std.string +f() -> int { + @scoped t = string.seq_cons("a", null) + q = string.seq_cons("b", t) + return string.seq_length(q) +} +main() { println(f()) } diff --git a/tests/integration/scoped_escape/test_scoped_escape.sh b/tests/integration/scoped_escape/test_scoped_escape.sh index d059cca6f..000cec335 100755 --- a/tests/integration/scoped_escape/test_scoped_escape.sh +++ b/tests/integration/scoped_escape/test_scoped_escape.sh @@ -51,8 +51,10 @@ check_rejected reject_alias check_rejected reject_closure check_rejected reject_container # #3001: the sequence cons is a container insert too; the list named -# `seq.cons`, which std does not have. +# `seq.cons`, which std does not have. #3086: decided by its declaration, +# whose head and tail are both `@retain`, so a @scoped tail escapes too. check_rejected reject_seq_cons +check_rejected reject_seq_cons_tail check_rejected reject_list_return check_help reject_list_return '`list.size(l)`' check_rejected reject_slice_return diff --git a/tests/regression/test_string_field_arg_released_once_in_loop.ae b/tests/regression/test_string_field_arg_released_once_in_loop.ae index 74daff827..e47e13136 100644 --- a/tests/regression/test_string_field_arg_released_once_in_loop.ae +++ b/tests/regression/test_string_field_arg_released_once_in_loop.ae @@ -31,7 +31,7 @@ named(n: string) -> *Named { return p } -add_named(into: ptr, n: string, v: @keep * Named) { +add_named(into: ptr, n: string, v: @keep *Named) { q = named(n) q.next = v list.add_raw(into, q as ptr) diff --git a/tests/syntax/test_closure_context.ae b/tests/syntax/test_closure_context.ae index ff764b306..a1a1f949a 100644 --- a/tests/syntax/test_closure_context.ae +++ b/tests/syntax/test_closure_context.ae @@ -13,7 +13,7 @@ create_panel(title: string) { } // add_child adds to the current builder context -add_child(child: ptr) { +add_child(child: @keep ptr) { parent = builder_context() if parent != null { list.add(parent, child) diff --git a/tools/ae_fmt.c b/tools/ae_fmt.c index bcdfd06c0..706732de0 100644 --- a/tools/ae_fmt.c +++ b/tools/ae_fmt.c @@ -488,7 +488,12 @@ static char* layout(Lexeme* lex, size_t n){ /* A `!` written tight after a value is its postfix unwrap * (`f()!`) and ends a value; after anything else it is a * prefix `!` or a send. */ - int value = is_value_end(L->text) || (eq(L->text,"!") && tight && prev_value); + /* #3086: a name written after `@` is an attribute (`@retain`, + * `@keep`), which ends no value: the `*` after it begins a type, + * `tail: @retain *StringSeq`, which was written `@retain * + * StringSeq`. */ + int value = (is_value_end(L->text) && !(prev && eq(prev,"@"))) || + (eq(L->text,"!") && tight && prev_value); prevprev_value = prev_value; prev_value = value; prev_tight = tight; prevprev=prev; prev=L->text; From 5e139595049f4f81f44a7eece3f4f1afa42d975b Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Wed, 21 Oct 2026 23:49:50 -0300 Subject: [PATCH 12/50] A program's own function named like a std container store is called as itself (#3080) A program's `list_add(xs: ptr, f: fn) -> int` failed in the C compiler ("initialization of 'int' from 'const char *'"). No C symbol `list_add` existed then: std.list's `add` and std.collections' `list_add` were Aether wrappers, and codegen rewrote a call into std's container store whenever its C name was `list_add`, `map_put`, `list_set` or one of their raw and owning forms (g_closure_store_entries and the list, map and set shape tests). The program's own function was compiled as `_aether_list_add_closure(...)`, and its own `map_put` given a string as std's owning store. #3086 made `list_add` and `map_put` the C functions std's externs bind, so they are in the std C symbol table, and a program function of one of those names gets a C name of its own (`ae_list_add`, #1366), as one named `list_set` already did. The rewrites key on the C function a std extern binds, which a program function can no longer be, so no gate is needed in codegen: the earlier draft of this fix (a name list shared with the checker) is gone with #3086. Test: tests/regression/test_issue3080_own_container_names.ae defines its own `list_add`, `map_put` and `list_set` and checks each runs as itself, on both C paths. It fails on 0.838 (the C compiler refuses the list_add and map_put calls). --- ...T222832Z-fixed-3080-own-container-names.md | 7 +++ .../test_issue3080_own_container_names.ae | 49 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 new_changelogs/20261021T222832Z-fixed-3080-own-container-names.md create mode 100644 tests/regression/test_issue3080_own_container_names.ae diff --git a/new_changelogs/20261021T222832Z-fixed-3080-own-container-names.md b/new_changelogs/20261021T222832Z-fixed-3080-own-container-names.md new file mode 100644 index 000000000..170982d54 --- /dev/null +++ b/new_changelogs/20261021T222832Z-fixed-3080-own-container-names.md @@ -0,0 +1,7 @@ +- **A program's own function named like one of std's container stores is + called as itself.** A program's `list_add(xs: ptr, f: fn)` was compiled + as std's closure store (`_aether_list_add_closure`) and failed in the C + compiler, and its own `map_put` given a string as std's owning store. + `list_add` and `map_put` are std's C functions now (#3086), so a program + function of either name gets a C name of its own, as one named like any + other std C function does (#3080). diff --git a/tests/regression/test_issue3080_own_container_names.ae b/tests/regression/test_issue3080_own_container_names.ae new file mode 100644 index 000000000..f159727e7 --- /dev/null +++ b/tests/regression/test_issue3080_own_container_names.ae @@ -0,0 +1,49 @@ +// Regression (#3080): a program's own function named like one of std's +// container stores is called as itself. +// +// Codegen rewrote a call into std's container store whenever its C name +// was `list_add`, `map_put`, `list_set` or one of their raw forms, so a +// program's own `list_add(xs, f: fn)` was compiled as +// `_aether_list_add_closure(...)` and failed in the C compiler ("makes +// integer from pointer"), and its own `map_put` given a string was +// compiled as std's owning store. `list_add` and `map_put` are std's C +// functions now (#3086), so a program function of either name gets a C name +// of its own (`ae_list_add`), as one named `list_set` already did, and no +// rewrite matches it. + +extern exit(code: int) + +fn check(cond: bool, msg: string) { + if !cond { + println("FAIL #3080: ${msg}") + exit(1) + } +} + +var seen = 0 + +list_add(xs: ptr, f: fn) -> int { + seen = seen + 1 + return 7 +} + +map_put(m: ptr, k: string, v: string) -> int { + seen = seen + 10 + return 8 +} + +list_set(xs: ptr, i: int, v: string) { + seen = seen + 100 +} + +main() { + ran = 0 + r = list_add(null, || { ran = 1 }) + check(r == 7, "own list_add returns its own value, got ${r}") + s = map_put(null, "k", "v") + check(s == 8, "own map_put returns its own value, got ${s}") + list_set(null, 0, "v") + check(seen == 111, "each own function ran once, seen ${seen}") + check(ran == 0, "own list_add never calls the closure") + println("ok") +} From 648a40d3896a2e64601c8e6acc239aea20aec9e0 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Thu, 22 Oct 2026 00:03:13 -0300 Subject: [PATCH 13/50] docs/api shows the 64-bit widths #3097 gave arena.used, arena.size and the string length The extern width fix made them `long`; the generated reference still said `int`. --- docs/api/arena.html | 4 ++-- docs/api/string.html | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api/arena.html b/docs/api/arena.html index b4873af04..c9a092dc4 100644 --- a/docs/api/arena.html +++ b/docs/api/arena.html @@ -155,13 +155,13 @@

    destroy fn

    used fn

    -
    used(arena) -> int
    +
    used(arena) -> long

    arena: ptr

    Bytes currently allocated from the arena (sum across overflow blocks).

    size fn

    -
    size(arena) -> int
    +
    size(arena) -> long

    arena: ptr

    Total capacity (sum across overflow blocks).

    diff --git a/docs/api/string.html b/docs/api/string.html index 3be9c71c1..f062ab7bb 100644 --- a/docs/api/string.html +++ b/docs/api/string.html @@ -235,7 +235,7 @@

    from_literal extern

    new_with_length extern

    new_with_length(data, length) -> string
    -

    data: ptr, length: int

    +

    data: ptr, length: long

    `length` bytes from `data` as a new string. A `ptr` (a buffer C filled) is copied as given, never read as a string, so a binary payload that begins like a string's header is copied, not followed (#2301, #3030); a `string` is passed as its characters, so `length` must not exceed its length (string.substring(s, 0, n) stops at the end).

    From cd23302a48470519afa06aa17580a75823dff5c4 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Thu, 22 Oct 2026 00:02:20 -0300 Subject: [PATCH 14/50] The code generator frees its closure-variable table closure_var_bind records which closure literal a variable holds: one entry per variable, with a strdup'd scope and name. code_generator_release freed the closure registry beside it but never this table. So every compile of a program that binds a closure to a variable leaked the array and its strings. No test exercised that: #3029's codegen test (codegen_slice_param_not_versioned_when_rebound_or_captured, `f = || { return xs[0] }`) is the first codegen unit test to bind one. CI's ASan/LSan and Valgrind legs then reported it: - LSan: 384 bytes direct and 11 indirect, from aether_xrealloc <- closure_var_bind <- discover_closures_scoped <- discover_closures <- generate_program; - Valgrind: the same 395 bytes definitely lost. code_generator_release now frees each entry's scope and name, then the array, beside the closure registry. Checked: make test (598) passes. The leak checkers that found it run only on Linux and macOS, so the CI legs that reported it are the proof. --- compiler/codegen/codegen.c | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/compiler/codegen/codegen.c b/compiler/codegen/codegen.c index 76d4d9085..1103db18d 100644 --- a/compiler/codegen/codegen.c +++ b/compiler/codegen/codegen.c @@ -903,6 +903,18 @@ void code_generator_release(CodeGenerator* gen) { gen->closures = NULL; gen->closure_count = 0; gen->closure_capacity = 0; + /* Which closure literal a variable holds (closure_var_bind): a + * strdup'd scope and name per entry. Nothing freed it, so every + * compile of a program that binds a closure to a variable leaked + * the table. */ + for (int i = 0; i < gen->closure_var_count; i++) { + free(gen->closure_var_map[i].scope); + free(gen->closure_var_map[i].var_name); + } + free(gen->closure_var_map); + gen->closure_var_map = NULL; + gen->closure_var_count = 0; + gen->closure_var_capacity = 0; for (int i = 0; i < gen->promoted_func_count; i++) { for (int j = 0; j < gen->promoted_funcs[i].count; j++) { free(gen->promoted_funcs[i].names[j]); From ed7856607eb949c07bdbd110640b04e2eaab29bb Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Thu, 22 Oct 2026 00:03:10 -0300 Subject: [PATCH 15/50] Scheduler threads are counted and placed by the process's CPUs; Linux keeps binding, measured (#3089, #3106) #3089: measured whether Linux should keep binding scheduler thread i to one CPU, as #3087 measured for Windows. Method: - a CI job on a 4-CPU ubuntu-24.04 runner (EPYC 7763, kernel 6.17), two runs, with the job on a branch of its own; - the cross-language suite's thread ring, fork-join and skynet, 2M messages, 10 interleaved runs each; - bound (as today) against unbound: the same binary, with pthread_setaffinity_np made a no-op by an LD_PRELOAD shim, which reported the calls it saw in both modes. M msg/s, medians, bound / unbound: | load | ring | fork-join | skynet | |-------------------------------|---------------|-------------|-------------| | quiet (run 1) | 14.00 / 16.07 | 3.79 / 4.13 | 0.11 / 0.11 | | quiet (run 2) | 21.36 / 21.45 | 4.34 / 4.61 | 0.13 / 0.28 | | a loop pinned to CPU 1 | 10.86 / 9.00 | 2.28 / 3.22 | 0.11 / 0.11 | | loops pinned to CPUs 1 and 2 | 10.79 / 7.39 | 1.98 / 1.77 | 0.13 / 0.06 | | two loops the kernel placed | 10.82 / 7.38 | 1.96 / 1.82 | 0.13 / 0.09 | | a loop pinned to CPU 0 | 10.72 / 11.81 | 2.90 / 4.26 | 0.13 / 0.13 | | a loop pinned to every CPU | 6.66 / 4.99 | 1.17 / 1.17 | 0.05 / 0.04 | | four loops the kernel placed | 6.73 / 6.05 | 1.19 / 1.21 | 0.04 / 0.03 | - Bound won 7 of the 12 loaded cases, by 1.1x to 2.4x, and held the ring at 10.8 with a range of 10.5 to 11.0 where unbound fell to 7.4. - Unbound won only where another program's thread was pinned to a scheduler thread's own CPU: CPU 0 under the ring, fork-join's CPU 1. It won there by 1.1x to 1.5x. - Quiet is a tie, within the run-to-run spread. The kernel moves movable load off a bound thread's CPU; Windows did not, which is why #3087 made it a hint there. So Linux keeps the hard binding. #3106, found preparing #3089: placement and count were the machine's. - Placement: thread i went to CPU i, a number from 0. Under `taskset -c 4-7` the threads bound themselves to CPUs 0..3, outside the CPUs the program was started on; setting a thread's own affinity needs no privilege. In a cpuset cgroup, a binding outside the set was refused. Now scheduler_start reads the CPUs the process may run on, in ascending order, before any thread places itself (read_process_cpus: the calling thread's affinity on Linux, the process mask on Windows). Thread i goes to the (i mod n)-th of them, bound on Linux and as its ideal processor on Windows. scheduler_core_cpu(i) reports it. A mask that cannot be read leaves the threads where the OS puts them. macOS has no CPU numbers to place by and keeps its affinity tag. - Count: cpu_recommend_cores took the machine's online count, capped at 16. A program in a 2-CPU container started 16 scheduler threads. It now takes no more than aether_cpu_available() (the affinity mask, and on Linux a cgroup CPU quota), as std.worker and the HTTP server already do, still capped at 16. Tests: - tests/runtime/test_scheduler.c, "Scheduler counts and places its threads by the process's CPUs" (Linux, Windows): - the process is limited to its highest allowed CPU; - cpu_recommend_cores() is then 1; - both scheduler threads are placed on that CPU: Linux reads each thread's binding with pthread_getaffinity_np, Windows its ideal processor; - the mask is restored. The old code put the threads on CPUs 0 and 1 (CPU_SET(core_id)) and counted the machine's CPUs. - tests/integration/cpu_core_count: each mocked machine count is now capped by the host's available CPUs. docs/runtime-optimizations.md gives the count, the placement and the Linux numbers, and docs/getting-started.md the Linux note. The experiment job (.github/workflows/experiment-pinning.yml) lived only on its own branch and did not earn a place: the question is answered, and a 4-CPU runner's numbers move run to run (the quiet ring was 14.0 in one run and 21.4 in the other). The branch is deleted. Checked: make test (598) passes; so do cpu_core_count, cpu_available (1-CPU and 2-CPU cpusets honoured) and the multi-actor timeout test. --- docs/getting-started.md | 2 +- docs/runtime-optimizations.md | 11 ++- ...-3106-scheduler-threads-by-process-cpus.md | 10 +++ runtime/scheduler/multicore_scheduler.c | 58 +++++++++++++- runtime/scheduler/multicore_scheduler.h | 6 ++ runtime/utils/aether_cpu_detect.c | 11 +++ tests/integration/cpu_core_count/core_count.c | 7 +- tests/runtime/test_scheduler.c | 77 +++++++++++++++++++ 8 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 new_changelogs/20261022T040000Z-fixed-3106-scheduler-threads-by-process-cpus.md diff --git a/docs/getting-started.md b/docs/getting-started.md index c5f0fc75b..281a1a4fc 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -813,7 +813,7 @@ builds a few programs (hello-world, a stateful actor, an actor pipeline, ping-po **Linux:** - Kernel 4.14+ recommended for full NUMA support - AddressSanitizer may require `gcc-multilib` on some distributions -- Full thread affinity support for deterministic performance +- Each scheduler thread is bound to one of the CPUs the process may use (its `taskset` or cpuset mask), and a container's CPU quota limits how many threads start **Windows:** - Pre-built binaries work in any terminal (PowerShell, CMD, Windows Terminal) diff --git a/docs/runtime-optimizations.md b/docs/runtime-optimizations.md index 022268af0..a3047cc23 100644 --- a/docs/runtime-optimizations.md +++ b/docs/runtime-optimizations.md @@ -336,7 +336,7 @@ The benchmark build uses `-flto` for both compilation and linking, enabling cros **Implementation:** `runtime/utils/aether_cpu_detect.c` -The `cpu_recommend_cores` function uses CPUID on x86 and falls back to OS-level APIs (`sysctl` on macOS, `sysconf` on Linux, `GetNativeSystemInfo` on Windows) when CPUID returns zero (ARM, virtualized environments). +The `cpu_recommend_cores` function uses CPUID on x86 and falls back to OS-level APIs (`sysctl` on macOS, `sysconf` on Linux, `GetNativeSystemInfo` on Windows) when CPUID returns zero (ARM, virtualized environments). It then takes no more threads than the CPUs the process may use, `aether_cpu_available()`: the affinity mask (`taskset`, a cpuset cgroup, a Windows job or `start /affinity`) and, on Linux, a cgroup CPU quota (`docker --cpus`, a Kubernetes limit). The cap of 16 still applies. The machine's count started 16 scheduler threads for a 2-CPU container, which then preempted each other (#3106). `std.worker` and the HTTP server size their pools the same way. ### Apple Silicon P-Core Detection @@ -356,6 +356,15 @@ Detection uses `sysctlbyname("hw.perflevel0.physicalcpu")` to query the P-core c Note: macOS does not support hard core pinning by design, and Windows uses a hint by choice: bound to one logical CPU, a scheduler thread could not leave it when another program ran there, and shared it a time slice at a time (#3087). The OS keeps a thread on its CPU and moves it when that CPU is busy. +Linux keeps the hard binding, measured (#3089). On a 4-CPU CI runner, bound and unbound threads were compared under busy loops: +- bound won 7 of the 12 loaded cases (thread ring 10.8 against 7.4 M msg/s beside two loops the kernel placed, skynet up to 2.4x); +- bound lost only where another program's thread was pinned to a scheduler thread's own CPU, by 1.1x to 1.5x; +- on a quiet machine they tied. + +Linux moves movable load off a bound thread's CPU, which Windows did not. + +On Linux and Windows, scheduler thread i is placed on the i-th CPU the process may run on, counted from its affinity mask when the threads start, not on CPU i (#3106). Under `taskset -c 4-7` the threads stay on CPUs 4 to 7, where binding to CPU 0 escaped the mask. In a cpuset cgroup no binding is refused for a CPU outside the set. + ### Per-Core Message Counters **Implementation:** `runtime/scheduler/multicore_scheduler.c`, `runtime/scheduler/multicore_scheduler.h` diff --git a/new_changelogs/20261022T040000Z-fixed-3106-scheduler-threads-by-process-cpus.md b/new_changelogs/20261022T040000Z-fixed-3106-scheduler-threads-by-process-cpus.md new file mode 100644 index 000000000..0143a3282 --- /dev/null +++ b/new_changelogs/20261022T040000Z-fixed-3106-scheduler-threads-by-process-cpus.md @@ -0,0 +1,10 @@ +- **The actor scheduler counts and places its threads by the CPUs the + process may use.** Thread i was bound to CPU i (Linux) or given it as its + ideal processor (Windows), a number from 0: under `taskset -c 4-7` the + threads left the CPUs the program was started on, and in a cpuset + container a binding outside the set was refused. They now go to the + i-th CPU of the process's affinity mask. The thread count was the + machine's (16 threads for a 2-CPU container); it is now no more than + the affinity mask and a cgroup CPU quota allow, as `std.worker` and the + HTTP server already sized their pools. Linux keeps the hard binding, + measured under background load (#3089, #3106). diff --git a/runtime/scheduler/multicore_scheduler.c b/runtime/scheduler/multicore_scheduler.c index 91ea25df9..d511fb151 100644 --- a/runtime/scheduler/multicore_scheduler.c +++ b/runtime/scheduler/multicore_scheduler.c @@ -823,13 +823,62 @@ static void overflow_flush(int from_core) { tls_overflow_any = any; } +// The CPUs this process may run on, in ascending order, read by the thread +// that starts the scheduler before any scheduler thread places itself +// (#3106). Scheduler thread i goes to the (i mod n)-th of them. It went to +// CPU i, a number from 0: under `taskset -c 4-7` a thread bound itself to +// CPU 0, outside the CPUs the program was started on (setting a thread's +// own affinity needs no privilege), and in a cpuset cgroup a CPU outside +// the set refused the binding, so placement depended on CPU numbers. 0 +// CPUs (the mask could not be read) leaves every thread where the OS puts +// it. +#define AETHER_PLACE_MAX_CPUS 1024 +static int g_place_cpus[AETHER_PLACE_MAX_CPUS]; +static int g_place_count = 0; + +static void read_process_cpus(void) { + g_place_count = 0; +#if defined(__linux__) + cpu_set_t set; + CPU_ZERO(&set); + if (sched_getaffinity(0, sizeof(set), &set) == 0) { + for (int c = 0; c < CPU_SETSIZE && g_place_count < AETHER_PLACE_MAX_CPUS; c++) + if (CPU_ISSET(c, &set)) g_place_cpus[g_place_count++] = c; + } +#elif defined(_WIN32) + // The process mask covers its processor group, numbered as + // SetThreadIdealProcessor numbers them. + DWORD_PTR proc_mask = 0, sys_mask = 0; + if (GetProcessAffinityMask(GetCurrentProcess(), &proc_mask, &sys_mask)) { + for (int c = 0; c < (int)(sizeof(DWORD_PTR) * 8); c++) + if (proc_mask & ((DWORD_PTR)1 << c)) g_place_cpus[g_place_count++] = c; + } +#endif +} + +// The CPU scheduler thread `core_id` is placed on, or -1 for none. +int scheduler_core_cpu(int core_id) { + if (core_id < 0 || g_place_count <= 0) return -1; + return g_place_cpus[core_id % g_place_count]; +} + // Keep a scheduler thread on its CPU (NUMA awareness): bound to it on Linux, // a hint on macOS and Windows. +// +// Linux keeps the hard binding (#3089). Measured on a 4-CPU CI runner +// (kernel 6.17) against the same threads unbound, under busy loops: bound +// won 7 of the 12 loaded cases (thread ring 10.8 against 7.4 M msg/s beside +// two loops the kernel placed, skynet up to 2.4x), lost only where another +// program's thread was pinned to a scheduler thread's own CPU (1.1x to +// 1.5x), and tied on a quiet machine. Linux moves movable load off a bound +// thread's CPU; Windows did not, which is why #3087 made it a hint there. static void pin_to_core(int core_id) { #ifdef __linux__ + int cpu = scheduler_core_cpu(core_id); + if (cpu < 0) return; cpu_set_t cpuset; CPU_ZERO(&cpuset); - CPU_SET(core_id, &cpuset); + CPU_SET(cpu, &cpuset); #if defined(__ANDROID__) // bionic has no pthread_setaffinity_np; sched_setaffinity on the calling // thread's own id (0) is the same request. @@ -858,7 +907,8 @@ static void pin_to_core(int core_id) { // into a fast or a slow mode by where the other load landed. With the // hint Windows keeps the thread on its CPU and moves it when that CPU is // busy, as macOS's affinity tag above lets it. - SetThreadIdealProcessor(GetCurrentThread(), (DWORD)core_id); + int cpu = scheduler_core_cpu(core_id); + if (cpu >= 0) SetThreadIdealProcessor(GetCurrentThread(), (DWORD)cpu); #endif } @@ -2318,6 +2368,10 @@ void scheduler_start(void) { // Reset per this start() call in case scheduler_init was not called again. atomic_store_explicit(&g_threads_ready, 0, memory_order_relaxed); + // The CPUs the threads are placed among, read before any of them binds + // itself (#3106); pthread_create publishes them to the threads. + read_process_cpus(); + for (int i = 0; i < num_cores; i++) { atomic_store_explicit(&schedulers[i].running, 1, memory_order_release); int rc = pthread_create(&schedulers[i].thread, NULL, scheduler_thread, &schedulers[i]); diff --git a/runtime/scheduler/multicore_scheduler.h b/runtime/scheduler/multicore_scheduler.h index 53e182720..f1566b59b 100644 --- a/runtime/scheduler/multicore_scheduler.h +++ b/runtime/scheduler/multicore_scheduler.h @@ -298,6 +298,12 @@ void scheduler_init_with_opts(int cores, AetherOptFlags opts); void scheduler_start(void); void scheduler_ensure_threads_running(void); // Start threads if not already started (for main-thread mode transition) +// The CPU scheduler thread `core_id` is placed on: the (core_id mod n)-th +// of the n CPUs the process may run on, read when the threads last started +// (#3106). -1 when that set could not be read or the platform has no CPU +// numbers to place by (macOS). +int scheduler_core_cpu(int core_id); + /* Main-thread mode is "one actor, every send from the thread running * main()": that thread steps the actor inline at each send. A send from * any other thread cannot join in — stepping the same actor from two diff --git a/runtime/utils/aether_cpu_detect.c b/runtime/utils/aether_cpu_detect.c index fd26a8798..cb724a8bd 100644 --- a/runtime/utils/aether_cpu_detect.c +++ b/runtime/utils/aether_cpu_detect.c @@ -17,6 +17,8 @@ #include #endif +#include "aether_cpu_available.h" + #if AETHER_HAS_SIMD /* CPUID intrinsics come from different headers per toolchain. MSVC exposes * __cpuidex via ; a GNU compiler (gcc/clang, INCLUDING zig cc's @@ -368,6 +370,15 @@ int cpu_recommend_cores(void) { #endif } + // No more threads than CPUs the process may use: its affinity mask + // (taskset, a cpuset cgroup, a Windows job or start /affinity) and a + // cgroup CPU quota (docker --cpus, a Kubernetes limit). The machine's + // count started 16 scheduler threads for a 2-CPU container, which then + // preempted each other (#3106); std.worker and the HTTP server size + // their pools the same way. + int available = aether_cpu_available(); + if (available > 0 && available < cores) cores = available; + if (cores > 16) cores = 16; if (cores < 1) cores = 1; diff --git a/tests/integration/cpu_core_count/core_count.c b/tests/integration/cpu_core_count/core_count.c index 967290157..7eabbbc48 100644 --- a/tests/integration/cpu_core_count/core_count.c +++ b/tests/integration/cpu_core_count/core_count.c @@ -81,12 +81,17 @@ int main(void) { const int cases[][3] = { {1, 12, 12}, {8, 4, 4}, {64, 32, 16}, {8, -1, 8}, {0, -1, 1} }; + // The machine's count is mocked; the CPUs this process may use are the + // host's own (#3106: the scheduler takes no more threads than those), so + // a case's count is capped by them. for (unsigned i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { package_count = cases[i][0]; online_count = cases[i][1]; g_cpu_info_initialized = 0; memset(&g_cpu_info, 0, sizeof(g_cpu_info)); - assert(cpu_recommend_cores() == cases[i][2]); + int available = aether_cpu_available(); + int want = cases[i][2] < available ? cases[i][2] : available; + assert(cpu_recommend_cores() == want); } #else assert(cpu_recommend_cores() >= 1 && cpu_recommend_cores() <= 16); diff --git a/tests/runtime/test_scheduler.c b/tests/runtime/test_scheduler.c index d44a03635..d6edfbe6c 100644 --- a/tests/runtime/test_scheduler.c +++ b/tests/runtime/test_scheduler.c @@ -3,12 +3,16 @@ * Tests multicore scheduler correctness and performance */ +#if defined(__linux__) && !defined(_GNU_SOURCE) +#define _GNU_SOURCE // sched_getaffinity, pthread_getaffinity_np, CPU_COUNT (#3106) +#endif #include "test_harness.h" #include "../../runtime/actors/actor_state_machine.h" #include "../../runtime/scheduler/multicore_scheduler.h" #include "../../runtime/actors/aether_actor_thread.h" #include "../../runtime/actors/aether_send_message.h" #include "../../runtime/aether_process_mem.h" +#include "../../runtime/utils/aether_cpu_detect.h" #include @@ -1426,6 +1430,76 @@ void test_scheduler_fires_receive_timeouts(void) { ASSERT_TRUE(gone); } +// #3106: scheduler threads are counted and placed by the CPUs the process +// may run on, not by the machine's. The process is limited to its highest +// allowed CPU: thread i went to CPU i, from 0, outside that limit, and the +// count was the machine's. Linux checks each thread's binding, Windows its +// ideal processor (a hint, #3087); both check the count. +#if defined(__linux__) || defined(_WIN32) +void test_scheduler_places_threads_among_process_cpus(void) { +#if defined(__linux__) + cpu_set_t saved; + CPU_ZERO(&saved); + ASSERT_EQ(0, sched_getaffinity(0, sizeof(saved), &saved)); + int only = -1; + for (int c = 0; c < CPU_SETSIZE; c++) if (CPU_ISSET(c, &saved)) only = c; + ASSERT_TRUE(only >= 0); + cpu_set_t one; + CPU_ZERO(&one); + CPU_SET(only, &one); + ASSERT_EQ(0, sched_setaffinity(0, sizeof(one), &one)); +#else + DWORD_PTR saved = 0, system_mask = 0; + ASSERT_TRUE(GetProcessAffinityMask(GetCurrentProcess(), &saved, &system_mask) != 0); + int only = -1; + for (int c = 0; c < (int)(sizeof(DWORD_PTR) * 8); c++) + if (saved & ((DWORD_PTR)1 << c)) only = c; + ASSERT_TRUE(only >= 0); + ASSERT_TRUE(SetProcessAffinityMask(GetCurrentProcess(), (DWORD_PTR)1 << only) != 0); +#endif + + int recommended = cpu_recommend_cores(); + + // Two actors, one per core, so the threads start (no main-thread mode). + scheduler_init(2); + CounterActor* a = (CounterActor*)scheduler_spawn_actor(0, (void (*)(void*))counter_step, + sizeof(CounterActor)); + CounterActor* b = (CounterActor*)scheduler_spawn_actor(1, (void (*)(void*))counter_step, + sizeof(CounterActor)); + scheduler_start(); + int placed[2] = {-1, -1}; + int bound[2] = {-1, -1}; + for (int i = 0; i < 2; i++) { + placed[i] = scheduler_core_cpu(i); +#if defined(__linux__) + cpu_set_t got; + CPU_ZERO(&got); + if (pthread_getaffinity_np(schedulers[i].thread, sizeof(got), &got) == 0) + bound[i] = (CPU_COUNT(&got) == 1 && CPU_ISSET(only, &got)) ? only : -2; +#else + PROCESSOR_NUMBER pn; + if (GetThreadIdealProcessorEx((HANDLE)schedulers[i].thread, &pn)) + bound[i] = (int)pn.Number; +#endif + } + (void)a; + (void)b; + scheduler_shutdown(); + scheduler_cleanup(); + +#if defined(__linux__) + sched_setaffinity(0, sizeof(saved), &saved); +#else + SetProcessAffinityMask(GetCurrentProcess(), saved); +#endif + ASSERT_EQ(1, recommended); + for (int i = 0; i < 2; i++) { + ASSERT_EQ(only, placed[i]); + ASSERT_EQ(only, bound[i]); + } +} +#endif + // #3087: a batch of sends wakes the cores it lands on. A loop of sends in // main() goes through the batch path (scheduler_send_batch_*), which put each // core's share on that core's queue and told no core: a parked core slept on @@ -1631,6 +1705,9 @@ void register_scheduler_tests(void) { register_test_with_category("Scheduler actor thread holds a released actor it stepped", test_scheduler_actor_thread_holds_released_actor, TEST_CATEGORY_RUNTIME); register_test_with_category("Scheduler actor thread parks when idle", test_scheduler_actor_thread_parks_when_idle, TEST_CATEGORY_RUNTIME); register_test_with_category("Scheduler fires receive timeouts on a core and an actor thread", test_scheduler_fires_receive_timeouts, TEST_CATEGORY_RUNTIME); +#if defined(__linux__) || defined(_WIN32) + register_test_with_category("Scheduler counts and places its threads by the process's CPUs", test_scheduler_places_threads_among_process_cpus, TEST_CATEGORY_RUNTIME); +#endif register_test_with_category("Scheduler batch send wakes parked cores", test_scheduler_batch_wakes_parked_cores, TEST_CATEGORY_RUNTIME); register_test_with_category("Scheduler actor thread inbox keeps every sender's order", test_scheduler_actor_thread_inbox_keeps_order, TEST_CATEGORY_RUNTIME); register_test_with_category("Scheduler drops a send to a released actor", test_scheduler_late_send_after_release, TEST_CATEGORY_RUNTIME); From a6d457a0c6137077fc43e422372c50a83a951506 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Thu, 22 Oct 2026 00:03:49 -0300 Subject: [PATCH 16/50] A literal clause fits a written result type that holds its value (#3082) `g(0) -> 5` beside `g(k) -> uint8 { return 7 }` was refused: "the clauses of 'g' are written to return uint8 (line 2), and this clause returns int, which uint8 does not hold". clause_set_join fitted an inferred clause to the written type by their join, and the join of an int literal and a uint8 is an int. A local bound in several branches takes the same literal beside its written type (#3070, ast_number_holds). clause_set_join now asks ast_number_holds first: an inferred clause whose value the written type holds (a literal in its range, or a value that joins to it) fits it, as a binding fits a local's written type. A literal it does not hold (`300` or `-1` beside `-> uint8`) is refused as before. The reference says a literal clause fits a written type that holds its value. Test: tests/integration/clause_set_result_join gains literal_beside_written (runs to "5 7"; fails before) and literal_outside_written (300 and -1 beside uint8 stay refused at the first). Checks: make test (597); the clause, literal and join integration tests (16); corpus and grid-cell sweep against 639d2643: no exit status or diagnostic changes but #3086's. --- compiler/ast.c | 9 ++++- docs/language-reference.md | 4 +- ...ixed-3082-clause-literal-beside-written.md | 5 +++ .../test_clause_set_result_join.sh | 38 +++++++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 new_changelogs/20261021T225037Z-fixed-3082-clause-literal-beside-written.md diff --git a/compiler/ast.c b/compiler/ast.c index 47ea8d45b..2fb2cdd9c 100644 --- a/compiler/ast.c +++ b/compiler/ast.c @@ -1434,7 +1434,14 @@ void clause_set_join(ASTNode* const* nodes, int count, ClauseJoin* out) { join_arm = join_arm_after(join_arm, arm); /* Beside a written type, an inferred clause fits it (their join is * it, and one with no join does not); a second written type is the - * same one or none. */ + * same one or none. #3082: a value the written type holds fits it + * (ast_number_holds), as a local's written type holds a binding + * (#3070): `g(0) -> 5` beside `-> uint8`, which the join of an int + * and a uint8 (an int) refused. */ + if (written && cl->type_inferred && ast_number_holds(join, t, arm)) { + if (k) free_type(k); + k = clone_type(join); + } int unfit = written && (!k || !types_equal(k, join) || (!cl->type_inferred && !types_equal(t, join))); if (!k || unfit) { diff --git a/docs/language-reference.md b/docs/language-reference.md index c00b36a5c..9b55c6389 100644 --- a/docs/language-reference.md +++ b/docs/language-reference.md @@ -1062,7 +1062,9 @@ n7 A clause set is one function with one result, decided over every clause. A clause that writes a type gives it, and what every other clause returns must -fit it. With none written, the result is the join of what the clauses return, +fit it, as a binding fits a local's written type: a literal fits when the type +holds its value (`g(0) -> 5` beside `g(k) -> uint8 { ... }`; `300` does not). +With none written, the result is the join of what the clauses return, as an [if-expression](#if-expressions)'s branches join: `pick(0, s: int, b: long) -> s` beside `pick(k, s: int, b: long) -> b` returns a `long`, a clause that returns `0` beside one that returns a diff --git a/new_changelogs/20261021T225037Z-fixed-3082-clause-literal-beside-written.md b/new_changelogs/20261021T225037Z-fixed-3082-clause-literal-beside-written.md new file mode 100644 index 000000000..e5123787c --- /dev/null +++ b/new_changelogs/20261021T225037Z-fixed-3082-clause-literal-beside-written.md @@ -0,0 +1,5 @@ +- **A literal clause fits a written result type that holds its value.** + `g(0) -> 5` beside `g(k) -> uint8 { ... }` was refused as an `int`, + which `uint8` does not hold, while a literal bound beside a local's + written type is taken. A literal clause is held as a binding is now; + `300` beside `-> uint8` is still refused (#3082). diff --git a/tests/integration/clause_set_result_join/test_clause_set_result_join.sh b/tests/integration/clause_set_result_join/test_clause_set_result_join.sh index 05d95ce9d..920cb72c6 100644 --- a/tests/integration/clause_set_result_join/test_clause_set_result_join.sh +++ b/tests/integration/clause_set_result_join/test_clause_set_result_join.sh @@ -154,5 +154,43 @@ case "$out" in fails=$((fails + 1)) ;; esac +# #3082: a literal clause fits a written type that holds its value, as a +# literal bound in a branch fits a local's written type (#3070): `g(0) -> 5` +# beside `-> uint8` was refused as an int, which uint8 does not hold. +cat > "$TMP/literal_beside_written.ae" <<'AE' +g(0) -> 5 +g(k) -> uint8 { + return 7 +} + +main() { println("${g(0)} ${g(1)}") } +AE +out=$(cd "$TMP" && "$AE" run literal_beside_written.ae 2>&1 | tr -d '\r') +case "$out" in + *"5 7"*) ;; + *) echo " [FAIL] literal_beside_written: did not run to 5 7" + echo "$out" | sed 's/^/ /' + fails=$((fails + 1)) ;; +esac + +# A literal the written type does not hold is still refused: 300 and -1 +# beside `-> uint8`. +cat > "$TMP/literal_outside_written.ae" <<'AE' +g(0) -> 300 +g(1) -> -1 +g(k) -> uint8 { + return 7 +} + +main() { println("${g(0)}") } +AE +out=$(cd "$TMP" && "$AE" check literal_outside_written.ae 2>&1) +case "$out" in + *"error[E0200]: the clauses of 'g' are written to return uint8 (line 3), and this clause returns int, which uint8 does not hold"*"literal_outside_written.ae:1:"*) ;; + *) echo " [FAIL] literal_outside_written: 300 beside uint8 not refused at line 1" + echo "$out" | sed 's/^/ /' + fails=$((fails + 1)) ;; +esac + if [ "$fails" -ne 0 ]; then exit 1; fi echo " [PASS] clause_set_result_join: clause results no one type holds are refused, naming both, once, and a set of only \`none\` names its missing T" From a0f680194ca180ebccb3d29ec37f3d3ff054eb09 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Thu, 22 Oct 2026 00:03:53 -0300 Subject: [PATCH 17/50] The TLS modules, the load balancer and tinyweb's tests call std's public functions, not other modules' raw bindings (#3107) They called std.net's, std.fs's and std.http's `*_raw` C bindings directly, skipping what each public function adds: - The TLS client dials with tcp.connect and the TLS client and server read records with tcp.read_n; both close with tcp.close, and std.net is no longer imported. The adopt-or-dial step in connect_full assigns `sock` in a branch, as written; the fresh-name dance its comment blamed on an old unresolved-type bug is gone. - tls13_server loads its certificate and key with fs.read, and tls13_cert its trust stores with fs.read and fs.list_dir. fs.read opens in binary mode, so Windows no longer reads them through text mode, and a store that cannot be opened names the path and the OS reason. - The HTTP load balancer starts with http.server_start and sets TLS with http.server_set_tls. - tinyweb's integration and schema tests and its schema showcase send requests through std.http.client, which returns the status and the body (the one-line http.get turns a non-2xx into an error string). The test server starts with http.server_start; the comment that said only the raw call runs the accept loop was wrong. The TLS client test checks the error of its sha2.new, which it left unread (the one warning its build printed). --- contrib/tinyweb/example_schema_api.ae | 30 ++++-- contrib/tinyweb/test_integration.ae | 46 +++++---- contrib/tinyweb/test_schema_api.ae | 96 +++++++++++-------- ...022T025352Z-changed-3107-std-public-api.md | 10 ++ std/cryptography/tls13_cert/module.ae | 21 ++-- std/cryptography/tls13_client/module.ae | 74 +++++++------- std/cryptography/tls13_server/module.ae | 24 ++--- std/http/server/lb/module.ae | 12 +-- .../crypto_tls13_client/test_tls13_client.ae | 1 + 9 files changed, 172 insertions(+), 142 deletions(-) create mode 100644 new_changelogs/20261022T025352Z-changed-3107-std-public-api.md diff --git a/contrib/tinyweb/example_schema_api.ae b/contrib/tinyweb/example_schema_api.ae index 60ad0afba..ac4dce5f5 100644 --- a/contrib/tinyweb/example_schema_api.ae +++ b/contrib/tinyweb/example_schema_api.ae @@ -22,9 +22,8 @@ import contrib.tinyweb import contrib.tinyweb.schema_api import std.schema import std.list -import std.http(http_post_raw, http_get_raw, http_response_status_code, - http_response_body_str, http_response_free) -import std.string(string_starts_with) +import std.http.client +import std.string extern exit(code: int) extern sleep(ms: int) @@ -59,19 +58,32 @@ actor Sched { } message Ping {} +// The whole response through std.http.client: the showcase prints the +// status (201, 422) next to the body. Null when no response came back at +// all, which reads as status 0. +fn fetch(method: string, path: string, body: string) -> ptr { + req = client.request(method, "http://127.0.0.1:18096${path}") + if string.length(body) > 0 { + client.set_body(req, string.bytes(body), "application/json") + } + resp, err = client.send_request(req) + client.request_free(req) + if err != "" { println(" ${method} ${path}: ${err}") } + return resp +} fn get(path: string) -> ptr { - return http_get_raw("http://127.0.0.1:18096${path}") + return fetch("GET", path, "") } fn post(path: string, body: string) -> ptr { - return http_post_raw("http://127.0.0.1:18096${path}", body, "application/json") + return fetch("POST", path, body) } fn show(label: string, resp: ptr) { - st = http_response_status_code(resp) - body = http_response_body_str(resp) + st = client.response_status(resp) + body = client.response_body(resp) print(" ${label}: ${st}\n") print(" ${body}\n") - http_response_free(resp) + client.response_free(resp) } main() { @@ -102,7 +114,7 @@ main() { } schema.field("coupon", schema.STR) { schema.refine(| v: string | { - if string_starts_with(v, "SAVE") == 1 { + if string.starts_with(v, "SAVE") == 1 { return 1 } return 0 diff --git a/contrib/tinyweb/test_integration.ae b/contrib/tinyweb/test_integration.ae index 5db4ffee4..f82678249 100644 --- a/contrib/tinyweb/test_integration.ae +++ b/contrib/tinyweb/test_integration.ae @@ -10,6 +10,8 @@ // As specs each reports independently. import std.http +import std.http.client +import std.string import std.spec // ---- Handlers ---- @@ -50,10 +52,9 @@ actor SrvActor { receive { StartSrv(raw) -> { s = raw - // http.server_start is a Go-style wrapper returning an error - // string; this is the raw int-returning extern that actually - // runs the accept loop. - http_server_start_raw(raw) + // Runs the accept loop until http.server_stop. + err = http.server_start(raw) + if err != "" { println("tinyweb server: ${err}") } } } } @@ -65,29 +66,40 @@ actor SchedHelper { // ---- Helpers ---- -// Return the raw HttpResponse so specs can assert on status separately -// from body — the Go-style http.get/http.post wrappers collapse non-2xx -// into an error string with no status access, which would make the 404 -// case untestable. -_get(path: string) { - return http_get_raw("http://127.0.0.1:8080${path}") +// The whole response through std.http.client, so a spec can assert on the +// status apart from the body: http.get and http.post turn anything but a +// 2xx into an error string, which would leave the 404 case untestable. Null +// when no response came back at all, which reads as status 0. +_call(method: string, path: string, body: string) -> ptr { + req = client.request(method, "http://127.0.0.1:8080${path}") + if string.length(body) > 0 { + client.set_body(req, string.bytes(body), "text/plain") + } + resp, err = client.send_request(req) + client.request_free(req) + if err != "" { println(" ${method} ${path}: ${err}") } + return resp +} + +_get(path: string) -> ptr { + return _call("GET", path, "") } -_post(path: string, body: string) { - return http_post_raw("http://127.0.0.1:8080${path}", body, "text/plain") +_post(path: string, body: string) -> ptr { + return _call("POST", path, body) } // Assert status + exact body, then free. Freeing here rather than at each // call site is what keeps the specs readable. _expect(resp: ptr, code: int, body: string, msg: string) { - spec.assert_eq(http.response_status_code(resp), code, "${msg}: status") - spec.assert_str_eq(http.response_body_str(resp), body, "${msg}: body") - http.response_free(resp) + spec.assert_eq(client.response_status(resp), code, "${msg}: status") + spec.assert_str_eq(client.response_body(resp), body, "${msg}: body") + client.response_free(resp) } _expect_code(resp: ptr, code: int, msg: string) { - spec.assert_eq(http.response_status_code(resp), code, "${msg}: status") - http.response_free(resp) + spec.assert_eq(client.response_status(resp), code, "${msg}: status") + client.response_free(resp) } main() { diff --git a/contrib/tinyweb/test_schema_api.ae b/contrib/tinyweb/test_schema_api.ae index 7b1e2fda3..bd2078882 100644 --- a/contrib/tinyweb/test_schema_api.ae +++ b/contrib/tinyweb/test_schema_api.ae @@ -26,12 +26,11 @@ import contrib.tinyweb import contrib.tinyweb.schema_api -import std.http(http_post_raw, http_get_raw, http_put_raw, http_delete_raw, - http_response_status_code, http_response_body_str, http_response_free) +import std.http.client import std.list import std.schema import std.spec -import std.string(string_contains) +import std.string extern exit(code: int) extern sleep(ms: int) @@ -73,17 +72,30 @@ actor SchedHelper { } message Ping {} +// The whole response through std.http.client, so a spec can assert on the +// status (422, 400, 204) as well as the body. Null when no response came +// back at all, which reads as status 0. +fn fetch(method: string, path: string, body: string) -> ptr { + req = client.request(method, "http://127.0.0.1:18092${path}") + if string.length(body) > 0 { + client.set_body(req, string.bytes(body), "application/json") + } + resp, err = client.send_request(req) + client.request_free(req) + if err != "" { println(" ${method} ${path}: ${err}") } + return resp +} fn post(path: string, body: string) -> ptr { - return http_post_raw("http://127.0.0.1:18092${path}", body, "application/json") + return fetch("POST", path, body) } fn put(path: string, body: string) -> ptr { - return http_put_raw("http://127.0.0.1:18092${path}", body, "application/json") + return fetch("PUT", path, body) } fn get(path: string) -> ptr { - return http_get_raw("http://127.0.0.1:18092${path}") + return fetch("GET", path, "") } fn del(path: string) -> ptr { - return http_delete_raw("http://127.0.0.1:18092${path}") + return fetch("DELETE", path, "") } main() { @@ -137,24 +149,24 @@ main() { spec.describe(fw, "schema_api: validated writes") { spec.it(fw, "lets a valid body through to the handler") callback { r = post("/users/", "{\"age\":25,\"name\":\"Ada\"}") - spec.assert_eq(http_response_status_code(r), 201, "handler ran, 201") - spec.assert_eq(string_contains(http_response_body_str(r), "ok"), 1, + spec.assert_eq(client.response_status(r), 201, "handler ran, 201") + spec.assert_eq(string.contains(client.response_body(r), "ok"), 1, "the handler's own body came back") - http_response_free(r) + client.response_free(r) } spec.it(fw, "validates a nested object") callback { r = post("/users/", "{\"age\":30,\"name\":\"Bo\",\"address\":{\"city\":\"Oslo\"}}") - spec.assert_eq(http_response_status_code(r), 201, "a valid nested body reaches the handler") - http_response_free(r) + spec.assert_eq(client.response_status(r), 201, "a valid nested body reaches the handler") + client.response_free(r) } spec.it(fw, "points a nested error at its path") callback { r = post("/users/", "{\"age\":30,\"name\":\"Bo\",\"address\":{\"city\":\"\"}}") - spec.assert_eq(http_response_status_code(r), 422, "422 for an empty nested field") - spec.assert_eq(string_contains(http_response_body_str(r), "\"pointer\":\"/data/attributes/address/city\""), 1, + spec.assert_eq(client.response_status(r), 422, "422 for an empty nested field") + spec.assert_eq(string.contains(client.response_body(r), "\"pointer\":\"/data/attributes/address/city\""), 1, "the error's JSON pointer is the field's path") - http_response_free(r) + client.response_free(r) } // The point of validating at the edge: the handler must NOT run. @@ -162,18 +174,18 @@ main() { // branch on the reason rather than parsing prose. spec.it(fw, "rejects a value outside its range with a coded 422") callback { r = post("/users/", "{\"age\":12,\"name\":\"Bob\"}") - spec.assert_eq(http_response_status_code(r), 422, "422 not 201") - spec.assert_eq(string_contains(http_response_body_str(r), "too_small"), 1, + spec.assert_eq(client.response_status(r), 422, "422 not 201") + spec.assert_eq(string.contains(client.response_body(r), "too_small"), 1, "the error code names the constraint") - http_response_free(r) + client.response_free(r) } spec.it(fw, "rejects a missing required field") callback { r = post("/users/", "{\"age\":30}") - spec.assert_eq(http_response_status_code(r), 422, "422 for a missing field") - spec.assert_eq(string_contains(http_response_body_str(r), "missing"), 1, + spec.assert_eq(client.response_status(r), 422, "422 for a missing field") + spec.assert_eq(string.contains(client.response_body(r), "missing"), 1, "the error says what is missing") - http_response_free(r) + client.response_free(r) } // Unparseable JSON is a DIFFERENT failure from a well-formed body @@ -182,8 +194,8 @@ main() { // serialiser. spec.it(fw, "distinguishes malformed JSON with a 400") callback { r = post("/users/", "{\"age\":") - spec.assert_eq(http_response_status_code(r), 400, "400 not 422") - http_response_free(r) + spec.assert_eq(client.response_status(r), 400, "400 not 422") + client.response_free(r) } // update is the other VALIDATED verb, so a validation regression on @@ -193,15 +205,15 @@ main() { // understands. spec.it(fw, "validates the update path too") callback { ok = put("/users/7", "{\"age\":40,\"name\":\"Grace\"}") - spec.assert_eq(http_response_status_code(ok), 200, "a valid PUT reaches the handler") - spec.assert_eq(string_contains(http_response_body_str(ok), "update"), 1, + spec.assert_eq(client.response_status(ok), 200, "a valid PUT reaches the handler") + spec.assert_eq(string.contains(client.response_body(ok), "update"), 1, "and it is the update handler that ran") - http_response_free(ok) + client.response_free(ok) bad = put("/users/7", "{\"age\":5,\"name\":\"Tiny\"}") - spec.assert_eq(http_response_status_code(bad), 422, + spec.assert_eq(client.response_status(bad), 422, "an invalid PUT is rejected like an invalid POST") - http_response_free(bad) + client.response_free(bad) } } @@ -212,25 +224,25 @@ main() { spec.describe(fw, "schema_api: unvalidated verbs") { spec.it(fw, "routes index to the collection handler") callback { r = get("/users/") - spec.assert_eq(http_response_status_code(r), 200, "index responds 200") - spec.assert_eq(string_contains(http_response_body_str(r), "index"), 1, + spec.assert_eq(client.response_status(r), 200, "index responds 200") + spec.assert_eq(string.contains(client.response_body(r), "index"), 1, "the index handler ran, not show") - http_response_free(r) + client.response_free(r) } spec.it(fw, "routes show to the member handler") callback { r = get("/users/42") - spec.assert_eq(http_response_status_code(r), 200, "show responds 200") - spec.assert_eq(string_contains(http_response_body_str(r), "show"), 1, + spec.assert_eq(client.response_status(r), 200, "show responds 200") + spec.assert_eq(string.contains(client.response_body(r), "show"), 1, "the show handler ran, not index") - http_response_free(r) + client.response_free(r) } spec.it(fw, "routes destroy and preserves its status") callback { r = del("/users/42") - spec.assert_eq(http_response_status_code(r), 204, + spec.assert_eq(client.response_status(r), 204, "the handler's 204 is not rewritten") - http_response_free(r) + client.response_free(r) } } @@ -239,19 +251,19 @@ main() { spec.describe(fw, "schema_api: generated documents") { spec.it(fw, "serves a draft-07 JSON Schema for the resource") callback { r = get("/users/schema") - spec.assert_eq(http_response_status_code(r), 200, "schema route responds") - spec.assert_eq(string_contains(http_response_body_str(r), "\"$schema\""), 1, + spec.assert_eq(client.response_status(r), 200, "schema route responds") + spec.assert_eq(string.contains(client.response_body(r), "\"$schema\""), 1, "the document declares its dialect") - http_response_free(r) + client.response_free(r) } spec.it(fw, "serves an aggregate OpenAPI 3.0 document") callback { r = get("/openapi.json") - spec.assert_eq(http_response_status_code(r), 200, "openapi route responds") + spec.assert_eq(client.response_status(r), 200, "openapi route responds") spec.assert_eq( - string_contains(http_response_body_str(r), "\"openapi\":\"3.0.3\""), 1, + string.contains(client.response_body(r), "\"openapi\":\"3.0.3\""), 1, "the document declares its OpenAPI version") - http_response_free(r) + client.response_free(r) } } diff --git a/new_changelogs/20261022T025352Z-changed-3107-std-public-api.md b/new_changelogs/20261022T025352Z-changed-3107-std-public-api.md new file mode 100644 index 000000000..e29e75116 --- /dev/null +++ b/new_changelogs/20261022T025352Z-changed-3107-std-public-api.md @@ -0,0 +1,10 @@ +- **std's TLS modules, the HTTP load balancer and tinyweb's tests call other + modules through their public functions.** They called the `*_raw` C + bindings of std.net, std.fs and std.http directly. The TLS modules now + dial with `tcp.connect`, read with `tcp.read_n`, and load certificates, + keys and trust stores with `fs.read` and `fs.list_dir`. `fs.read` opens in + binary mode, so on Windows a certificate file is no longer read through + text mode, and a trust store that cannot be opened names the OS reason. + The load balancer starts with `http.server_start` and `server_set_tls`. + tinyweb's tests and schema showcase use std.http.client, which gives the + status and the body (#3107). diff --git a/std/cryptography/tls13_cert/module.ae b/std/cryptography/tls13_cert/module.ae index 58331c6e8..7c72a0cc2 100644 --- a/std/cryptography/tls13_cert/module.ae +++ b/std/cryptography/tls13_cert/module.ae @@ -1836,10 +1836,9 @@ trust_store_add(anchors: ptr, path: string) -> (int, string) { if fs.dir_exists(path) == 1 { return add_pem_dir(anchors, path) } - fh = fs.file_open_raw(path, "r") - if fh == null { return 0, "cannot open trust store ${path}" } - text = fs.file_read_all_raw(fh) - fs.file_close(fh) + // fs.read's error already names the path and the OS reason. + text, err = fs.read(path) + if string.equals(err, "") != 1 { return 0, "cannot open trust store ${err}" } return add_pem_text(anchors, text), "" } @@ -1854,8 +1853,8 @@ trust_store_add_pem(anchors: ptr, text: string) -> int { // Every file directly inside `dir`, through add_pem_text. fn add_pem_dir(anchors: ptr, dir: string) -> (int, string) { - entries = fs.dir_list_raw(dir) - if entries == null { return 0, "cannot list trust store directory ${dir}" } + entries, lerr = fs.list_dir(dir) + if string.equals(lerr, "") != 1 { return 0, "cannot list trust store directory ${dir}" } total = 0 count = fs.dir_list_count(entries) i = 0 @@ -1867,12 +1866,10 @@ fn add_pem_dir(anchors: ptr, dir: string) -> (int, string) { // 0 is "the filesystem did not say", so it is tried too. if kind != fs.STAT_KIND_DIR && string.starts_with(name, ".") != 1 { full = fs.path_join(dir, name) - fh = fs.file_open_raw(full, "r") - if fh != null { - text = fs.file_read_all_raw(fh) - fs.file_close(fh) - if text != null { total = total + add_pem_text(anchors, text) } - } + // A file that cannot be read (a dangling link, no permission) is + // skipped, as OpenSSL's directory lookup skips it. + text, rerr = fs.read(full) + if string.equals(rerr, "") == 1 { total = total + add_pem_text(anchors, text) } } i = i + 1 } diff --git a/std/cryptography/tls13_client/module.ae b/std/cryptography/tls13_client/module.ae index feb484779..cebdaf09f 100644 --- a/std/cryptography/tls13_client/module.ae +++ b/std/cryptography/tls13_client/module.ae @@ -107,7 +107,6 @@ import std.cryptography.tls13_hs import std.cryptography.tls13_cert import std.cryptography.tls13_record import std.cryptography -import std.net import std.tcp import std.list import std.os @@ -434,7 +433,7 @@ fn recio_fill(r: *RecordIO) -> int { filling = 1 result = -1 while filling == 1 { - chunk, n, err = net.tcp_receive_n_raw(r.sock, 4096) + chunk, n, err = tcp.read_n(r.sock, 4096) if string.equals(err, "") == 1 { if n <= 0 { result = n; filling = 0 } else { bytes.copy_from_string(r.buf, r.len, chunk, n) @@ -716,18 +715,11 @@ fn connect_ex(host: string, port: int, x25519_priv: ptr, client_cert: ptr, clien // cafile a PEM bundle to trust INSTEAD of the system store (parity with // set_cafile), or "" for the normal resolution. fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, client_cert_len: int, client_key: ptr, adopt_sock: @keep ptr, insecure: int, cafile: string) -> (ptr, string) { - // Dial into a FRESH name and pick afterwards, rather than assigning to - // `sock` twice. A conditional re-assignment here left the slot's type - // unresolved ("unresolved type in codegen, defaulting to int"), and that - // unresolved slot propagated far enough to retype an unrelated local in - // std/cryptography/tls13_cert as void*. - dialled = null - if adopt_sock == null { - dialled = net.tcp_connect_raw(host, port) - } sock = adopt_sock - if adopt_sock == null { sock = dialled } - if sock == null { return null, "connect failed" } + if sock == null { + sock, dial_err = tcp.connect(host, port) + if string.equals(dial_err, "") != 1 { return null, dial_err } + } // Initial key exchange group is x25519, using the caller's private scalar. // On a HelloRetryRequest we may switch to secp256r1 with a fresh scalar. cur_group = tls13_hs.GROUP_X25519 @@ -750,7 +742,7 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli transcript_add(tr, bytes.view(ch)[0..ch_len]) serr = send_plaintext_handshake(sock, bytes.view(ch)[0..ch_len]) if string.equals(serr, "") != 1 { - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); net.tcp_close(sock) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); tcp.close(sock) return null, serr } rio = recio_new(sock) @@ -758,7 +750,7 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli // ServerHello (may be a HelloRetryRequest we answer once). shrec, shlen, sherr = recio_next_hs(rio) if string.equals(sherr, "") != 1 { - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); net.tcp_close(sock) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) return null, sherr } sh_msg_len = shlen - 5 @@ -769,7 +761,7 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli perr = tls13_hs.parse_server_hello(bytes.view(sh)[0..sh_msg_len], &parsed) if string.equals(perr, "") != 1 { bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); net.tcp_close(sock) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) return null, perr } @@ -787,7 +779,7 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli // Server asked to retry a group we don't support (or echoed x25519, // which shouldn't happen after a HRR); give up. bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); net.tcp_close(sock) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) return null, "HelloRetryRequest asked for an unsupported group" } cleanup_hello(pub, rnd, sid, ch) // free CH1 + its keyshare/nonces @@ -805,7 +797,7 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli serr2 = send_plaintext_handshake(sock, bytes.view(ch)[0..ch_len]) if string.equals(serr2, "") != 1 { cleanup_hello(pub2, rnd, sid, ch); bytes.free(cur_priv) - transcript_free(tr); recio_free(rio); net.tcp_close(sock) + transcript_free(tr); recio_free(rio); tcp.close(sock) return null, serr2 } pub = pub2 // so later cleanup frees the right keyshare @@ -814,7 +806,7 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli shrec, shlen, sherr = recio_next_hs(rio) if string.equals(sherr, "") != 1 { cleanup_hello(pub, rnd, sid, ch); bytes.free(cur_priv) - transcript_free(tr); recio_free(rio); net.tcp_close(sock) + transcript_free(tr); recio_free(rio); tcp.close(sock) return null, sherr } sh_msg_len = shlen - 5 @@ -825,13 +817,13 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli if string.equals(perr, "") != 1 { bytes.free(sh); bytes.free(shrec) cleanup_hello(pub, rnd, sid, ch); bytes.free(cur_priv) - transcript_free(tr); recio_free(rio); net.tcp_close(sock) + transcript_free(tr); recio_free(rio); tcp.close(sock) return null, perr } if parsed.is_hello_retry == 1 { bytes.free(sh); bytes.free(shrec) cleanup_hello(pub, rnd, sid, ch); bytes.free(cur_priv) - transcript_free(tr); recio_free(rio); net.tcp_close(sock) + transcript_free(tr); recio_free(rio); tcp.close(sock) return null, "server sent a second HelloRetryRequest" } } @@ -857,7 +849,7 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli if parsed.key_share != null { bytes.free(parsed.key_share) } bytes.free(sh); bytes.free(shrec) cleanup_hello(pub, rnd, sid, ch); if own_priv == 1 { bytes.free(cur_priv) } - transcript_free(tr); recio_free(rio); net.tcp_close(sock) + transcript_free(tr); recio_free(rio); tcp.close(sock) return null, agerr } // Negotiate the record AEAD + key-schedule hash from the chosen suite. @@ -865,7 +857,7 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli if string.equals(suite_err, "") != 1 { if parsed.key_share != null { bytes.free(parsed.key_share) } bytes.free(shared); bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); net.tcp_close(sock) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) return null, suite_err } @@ -880,7 +872,7 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli if own_priv == 1 { bytes.free(cur_priv) } // P-256 retry scalar (HRR path) bytes.free(p256_priv) // the pre-offered P-256 ephemeral if result == null { - recio_free(rio); net.tcp_close(sock) + recio_free(rio); tcp.close(sock) } return result, result_err } @@ -894,15 +886,15 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli // the classical exchange. Same authentication + Finished as connect() via the // shared tail. Returns a live ClientConn or (null, error). connect_pq(host: string, port: int) -> (ptr, string) { - sock = net.tcp_connect_raw(host, port) - if sock == null { return null, "connect failed" } + sock, dial_err = tcp.connect(host, port) + if string.equals(dial_err, "") != 1 { return null, dial_err } // Ephemerals: an ML-KEM-768 keypair (keep dk to decapsulate) and an X25519 // scalar (keep to agree). keygen seeds itself from the CSPRNG and fails // rather than hand back a key from a failed draw. ek, dk, kerr = mlkem.mlkem768_keygen() if kerr != "" { - net.tcp_close(sock) + tcp.close(sock) return null, kerr } xpriv = rand_into(32) @@ -924,7 +916,7 @@ connect_pq(host: string, port: int) -> (ptr, string) { serr = send_plaintext_handshake(sock, bytes.view(ch)[0..ch_len]) if string.equals(serr, "") != 1 { bytes.free(ek); bytes.free(dk); bytes.free(xpriv); bytes.free(xpub); bytes.free(hybrid) - cleanup_hello(null, rnd, sid, ch); transcript_free(tr); net.tcp_close(sock) + cleanup_hello(null, rnd, sid, ch); transcript_free(tr); tcp.close(sock) return null, serr } rio = recio_new(sock) @@ -932,7 +924,7 @@ connect_pq(host: string, port: int) -> (ptr, string) { shrec, shlen, sherr = recio_next_hs(rio) if string.equals(sherr, "") != 1 { bytes.free(ek); bytes.free(dk); bytes.free(xpriv); bytes.free(xpub); bytes.free(hybrid) - cleanup_hello(null, rnd, sid, ch); transcript_free(tr); recio_free(rio); net.tcp_close(sock) + cleanup_hello(null, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) return null, sherr } sh_msg_len = shlen - 5 @@ -999,7 +991,7 @@ connect_pq(host: string, port: int) -> (ptr, string) { bytes.free(sh); bytes.free(shrec) cleanup_hello(null, rnd, sid, ch); transcript_free(tr) if result == null { - recio_free(rio); net.tcp_close(sock) + recio_free(rio); tcp.close(sock) } return result, result_err } @@ -1584,7 +1576,7 @@ close_conn(conn: ptr) { if c.res_master != null { bytes.free(c.res_master) } if c.res_hash_algo != null { bytes.free(c.res_hash_algo) } recio_free(c.rio as *RecordIO) - net.tcp_close(c.sock) + tcp.close(c.sock) libc_free(conn) } @@ -1775,8 +1767,8 @@ fn connect_resume_ed(host: string, port: int, x25519_priv: ptr, ticket: ptr, ear // PSK = HKDF-Expand-Label(res_master, "resumption", ticket_nonce, Hash.len) psk = tls13_ks.psk_from_resumption(algo, st.res_master, hl, bytes.view(st.nonce)[0..st.nonce_len]) - sock = net.tcp_connect_raw(host, port) - if sock == null { bytes.free(psk); return null, "connect failed" } + sock, dial_err = tcp.connect(host, port) + if string.equals(dial_err, "") != 1 { bytes.free(psk); return null, dial_err } pub = x25519.base_point(x25519_priv) rnd = rand_into(32) sid = rand_into(32) @@ -1806,7 +1798,7 @@ fn connect_resume_ed(host: string, port: int, x25519_priv: ptr, ticket: ptr, ear transcript_add(tr, bytes.view(ch)[0..ch_len]) serr = send_plaintext_handshake(sock, bytes.view(ch)[0..ch_len]) if string.equals(serr, "") != 1 { - bytes.free(early); bytes.free(psk); cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); net.tcp_close(sock) + bytes.free(early); bytes.free(psk); cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); tcp.close(sock) return null, serr } @@ -1832,7 +1824,7 @@ fn connect_resume_ed(host: string, port: int, x25519_priv: ptr, ticket: ptr, ear shrec, shlen, sherr = recio_next_hs(rio) if string.equals(sherr, "") != 1 { - bytes.free(psk); cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); net.tcp_close(sock) + bytes.free(psk); cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) return null, sherr } sh_msg_len = shlen - 5 @@ -1844,13 +1836,13 @@ fn connect_resume_ed(host: string, port: int, x25519_priv: ptr, ticket: ptr, ear perr = tls13_hs.parse_server_hello(bytes.view(sh)[0..sh_msg_len], &parsed) if string.equals(perr, "") != 1 { bytes.free(psk); bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); net.tcp_close(sock) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) return null, perr } if parsed.psk_accepted != 1 { if parsed.key_share != null { bytes.free(parsed.key_share) } bytes.free(psk); bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); net.tcp_close(sock) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) return null, "server rejected the PSK (no resumption)" } @@ -1859,14 +1851,14 @@ fn connect_resume_ed(host: string, port: int, x25519_priv: ptr, ticket: ptr, ear if string.equals(agerr, "") != 1 { if parsed.key_share != null { bytes.free(parsed.key_share) } bytes.free(psk); bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); net.tcp_close(sock) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) return null, agerr } rec_aead, rec_key_len, hash_algo, hash_len, suite_err = suite_params(parsed.cipher_suite) if string.equals(suite_err, "") != 1 { if parsed.key_share != null { bytes.free(parsed.key_share) } bytes.free(psk); bytes.free(shared); bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); net.tcp_close(sock) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) return null, suite_err } @@ -1984,7 +1976,7 @@ fn connect_resume_ed(host: string, port: int, x25519_priv: ptr, ticket: ptr, ear bytes.free(sh); bytes.free(shrec) cleanup_hello(pub, rnd, sid, ch); transcript_free(tr) if result == null { - recio_free(rio); net.tcp_close(sock) + recio_free(rio); tcp.close(sock) } return result, result_err } @@ -2607,7 +2599,7 @@ pure_tls_client_connect(fd: int, host: string, verify_mode: int, cafile: string) priv = rand_into(32) if priv == null { - net.tcp_close(sock) + tcp.close(sock) aether_pure_tls_client_set_error("no randomness for the key share") return null } diff --git a/std/cryptography/tls13_server/module.ae b/std/cryptography/tls13_server/module.ae index 5aa157b22..16972e4ff 100644 --- a/std/cryptography/tls13_server/module.ae +++ b/std/cryptography/tls13_server/module.ae @@ -22,7 +22,6 @@ import std.string import std.list(list_new, list_free, list_add_raw, list_get_raw, list_size) import std.mem(copy) import std.fs -import std.net import std.tcp import std.cryptography.sha2 import std.cryptography.hmac @@ -86,7 +85,7 @@ fn recio_fill(r: *RecordIO) -> int { filling = 1 result = -1 while filling == 1 { - chunk, n, err = net.tcp_receive_n_raw(r.sock, 4096) + chunk, n, err = tcp.read_n(r.sock, 4096) if string.equals(err, "") == 1 { if n <= 0 { result = n; filling = 0 } else { bytes.copy_from_string(r.buf, r.len, chunk, n) @@ -513,7 +512,7 @@ close_conn(conn: ptr) { tls13_record.free_ctx(c.app_send as ptr) tls13_record.free_ctx(c.app_recv as ptr) recio_free(c.rio as *RecordIO) - net.tcp_close(c.sock) + tcp.close(c.sock) libc_free(conn) } @@ -620,19 +619,14 @@ pure_tls_server_accept(fd: int, cert_path: string, key_path: string) -> ptr { sock = tcp_socket_from_fd_owned(fd) if sock == null { return null } - fh_c = fs.file_open_raw(cert_path, "r") - if fh_c == null { net.tcp_close(sock); return null } - cert_text = fs.file_read_all_raw(fh_c) - fs.file_close(fh_c) - - fh_k = fs.file_open_raw(key_path, "r") - if fh_k == null { net.tcp_close(sock); return null } - key_text = fs.file_read_all_raw(fh_k) - fs.file_close(fh_k) + cert_text, cerr = fs.read(cert_path) + if string.equals(cerr, "") != 1 { tcp.close(sock); return null } + key_text, kerr = fs.read(key_path) + if string.equals(kerr, "") != 1 { tcp.close(sock); return null } chain_list, lens_list = parse_cert_chain_pem(cert_text) if chain_list == null { - net.tcp_close(sock) + tcp.close(sock) return null } @@ -646,7 +640,7 @@ pure_tls_server_accept(fd: int, cert_path: string, key_path: string) -> ptr { if priv_key == null { free_cert_chain(chain_list) list_free(lens_list) - net.tcp_close(sock) + tcp.close(sock) return null } @@ -657,7 +651,7 @@ pure_tls_server_accept(fd: int, cert_path: string, key_path: string) -> ptr { bytes.free(priv_key) free_cert_chain(chain_list) list_free(lens_list) - net.tcp_close(sock) + tcp.close(sock) return null } diff --git a/std/http/server/lb/module.ae b/std/http/server/lb/module.ae index 60b31512c..9b0c38ee4 100644 --- a/std/http/server/lb/module.ae +++ b/std/http/server/lb/module.ae @@ -90,9 +90,9 @@ // Harness: benchmarks/http/run_lb_reuse.sh (committed). Re-run on a dedicated // (non-co-located) box for absolute numbers. -import std.http(http_server_create, server_set_host, http_server_start_raw, +import std.http(http_server_create, server_set_host, server_start, http_server_use_middleware, request_path, request_body, - http_response_set_status, http_response_set_body, http_server_set_tls_raw) + http_response_set_status, http_response_set_body, server_set_tls) import std.http.proxy(upstream_pool_new, upstream_add, health_checks_enable, breaker_configure, cache_new, opts_new, opts_bind_cache, mount, upstream_drain, upstream_undrain) @@ -209,7 +209,7 @@ _serve(host: string, port: int, algo: string, backends: string, prefix: string, } server_set_host(server, host) if string_length(cert_path) > 0 { - terr = http_server_set_tls_raw(server, cert_path, key_path) + terr = server_set_tls(server, cert_path, key_path) if string_length(terr) > 0 { return "lb: tls: ${terr}" } @@ -233,9 +233,9 @@ _serve(host: string, port: int, algo: string, backends: string, prefix: string, if string_length(merr) > 0 { return "lb: mount: ${merr}" } - rc = http_server_start_raw(server) // blocks - if rc < 0 { - return "lb: server start failed" + serr = server_start(server) // blocks + if string_length(serr) > 0 { + return "lb: ${serr}" } return "" } diff --git a/tests/integration/crypto_tls13_client/test_tls13_client.ae b/tests/integration/crypto_tls13_client/test_tls13_client.ae index d975a4eb8..d9e4a3342 100644 --- a/tests/integration/crypto_tls13_client/test_tls13_client.ae +++ b/tests/integration/crypto_tls13_client/test_tls13_client.ae @@ -50,6 +50,7 @@ fn to_hex(b: ptr, n: int) -> string { } fn sha256b(data: ptr, len: int) -> ptr { ctx, err = sha2.new("sha256") + if err != "" { panic("sha2.new: ${err}") } sha2.update(ctx, bytes.view(data)[0..len]) return sha2.final_bytes(ctx) } From 2db402926839ce3d16b9d7868df21cb5f16feb12 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Thu, 22 Oct 2026 00:14:48 -0300 Subject: [PATCH 18/50] A module's own import names the file beside it, wherever the compiler runs (#3103) contrib/jq's test_with_jsonpath.ae and test_after_jsonpath.ae failed to build from inside contrib/jq: "'free_query' is not exported from module 'parser'". Built from the repository root they worked. std.jsonpath's facade does `import parser` for std/jsonpath/parser.ae, and module_resolve_local_path searched the current directory (the lib path, src/, the project root) before the importing module's own directory, so from contrib/jq it found contrib/jq/parser.ae and handed that to std.jsonpath. #2255 had kept the two `parser` modules apart once each was found; this is the finding. A module's own imports are resolved beside it first (the registry records, around a module's import loop, that source_dir is that module's directory: source_is_module), then where an entry program's are. The entry program's imports keep the current directory, the project root, first. The source-directory probes are one helper, resolve_under_source_dir, used in both orders. Found with it: contrib/jq's _str_indices allocated its result before returning `null` for an empty needle, which leaked it, and it and is_keyword ended with a `return` no path reaches (W1002 on every build that imports jq); std.jsonpath's _parse_segments did too, after its `while 1 == 1` (W1002 on every build that imports std.jsonpath); and jq's two jsonpath tests, which the error had stopped before the checker reached them, called `json.json_free` (W1006), as did two comments. The corpus sweep's diagnostics then showed the same dead `return` in liquid's `if` and `case` renderers (after `while 1 == 1`) and in four examples (after a `match` every arm of which returns: match-advanced, match-expressions, sum-types, message-with-string-seq); each is removed, and no W1002 is left outside the tests that expect one. docs/module-system-design.md listed the search without the importing file's directory at all; it lists it, and says a module's own imports search there first. Test: tests/integration/module_import_beside_itself builds, in a directory with a parser.ae of its own, a program that imports that parser and std.jsonpath, and runs a query through each. It fails before ("'free_query' is not exported") and passes after. Checks: make test (598); the module, import, namespace and package integration tests (113); std.jsonpath's tests and its parse regression on both C paths; jq's two jsonpath tests run from contrib/jq; liquid's tests and the four examples on both C paths. Corpus and grid-cell sweep against #3082 (4707 files): the two jq jsonpath tests now build (1 to 0), and the only diagnostic changes are the W1002 warnings gone from jq and std.jsonpath and everything that imports them. --- compiler/aether_module.c | 69 ++++++++++++------- compiler/aether_module.h | 3 + contrib/jq/eval.ae | 3 +- contrib/jq/lexer.ae | 1 - contrib/jq/module.ae | 2 +- contrib/jq/test_after_jsonpath.ae | 2 +- contrib/jq/test_with_jsonpath.ae | 2 +- contrib/jq/value.ae | 2 +- contrib/templating/liquid/module.ae | 2 - docs/module-system-design.md | 11 ++- examples/actors/message-with-string-seq.ae | 1 - examples/basics/match-advanced.ae | 1 - examples/basics/match-expressions.ae | 1 - examples/basics/sum-types.ae | 1 - ...-fixed-3103-module-import-beside-itself.md | 9 +++ std/jsonpath/parser.ae | 1 - .../test_module_import_beside_itself.sh | 64 +++++++++++++++++ 17 files changed, 135 insertions(+), 40 deletions(-) create mode 100644 new_changelogs/20261022T025307Z-fixed-3103-module-import-beside-itself.md create mode 100644 tests/integration/module_import_beside_itself/test_module_import_beside_itself.sh diff --git a/compiler/aether_module.c b/compiler/aether_module.c index a9936b6bf..6be9292a8 100644 --- a/compiler/aether_module.c +++ b/compiler/aether_module.c @@ -1119,6 +1119,29 @@ static char* pkg_probe_root(const char* root, const char* sub_path) { } // Resolve a local module path (e.g., "mypackage.utils") to a file path. +/* The module `converted` (a path with slashes) under `base`, the importing + * file's directory with its trailing slash, in the shapes and the lib path + * order the CWD-relative probes use (Issue #413). */ +static char* resolve_under_source_dir(const char* base, const char* converted) { + char path[4096]; + for (int li = 0; li < global_module_registry->lib_dir_count; li++) { + const char* lib = global_module_registry->lib_dirs[li]; + snprintf(path, sizeof(path), "%s%s/%s/module.ae", base, lib, converted); + if (module_probe(path)) return strdup(path); + snprintf(path, sizeof(path), "%s%s/%s.ae", base, lib, converted); + if (module_probe(path)) return strdup(path); + } + snprintf(path, sizeof(path), "%ssrc/%s/module.ae", base, converted); + if (module_probe(path)) return strdup(path); + snprintf(path, sizeof(path), "%ssrc/%s.ae", base, converted); + if (module_probe(path)) return strdup(path); + snprintf(path, sizeof(path), "%s%s/module.ae", base, converted); + if (module_probe(path)) return strdup(path); + snprintf(path, sizeof(path), "%s%s.ae", base, converted); + if (module_probe(path)) return strdup(path); + return NULL; +} + char* module_resolve_local_path(const char* module_path) { char converted[512]; char path[4096]; @@ -1130,6 +1153,17 @@ char* module_resolve_local_path(const char* module_path) { if (*p == '.') *p = '/'; } + /* #3103: a module's own import names a file beside it first. Tried after + * the CWD, std.jsonpath's `import parser` found contrib/jq/parser.ae + * whenever the compiler ran in contrib/jq, and a program importing + * both failed there ("'free_query' is not exported from module + * 'parser'"). The entry program keeps the CWD (the project root) first. */ + const char* src_dir = global_module_registry->source_dir; + if (global_module_registry->source_is_module && src_dir && src_dir[0]) { + char* found = resolve_under_source_dir(src_dir, converted); + if (found) return found; + } + /* Try 1/2: search every CWD-relative lib_dirs[] entry in order, * first hit wins. Each entry probes both shapes (`/ * /module.ae` then `/.ae`) so the @@ -1161,25 +1195,9 @@ char* module_resolve_local_path(const char* module_path) { if (module_probe(path)) return strdup(path); // Try 6b: Search relative to source file directory - if (global_module_registry->source_dir && global_module_registry->source_dir[0]) { - /* Mirror the CWD-relative loop above, but anchored at the - * source file's directory. Same left-to-right semantics - * across the multi-entry lib path. Issue #413. */ - for (int li = 0; li < global_module_registry->lib_dir_count; li++) { - const char* lib = global_module_registry->lib_dirs[li]; - snprintf(path, sizeof(path), "%s%s/%s/module.ae", global_module_registry->source_dir, lib, converted); - if (module_probe(path)) return strdup(path); - snprintf(path, sizeof(path), "%s%s/%s.ae", global_module_registry->source_dir, lib, converted); - if (module_probe(path)) return strdup(path); - } - snprintf(path, sizeof(path), "%ssrc/%s/module.ae", global_module_registry->source_dir, converted); - if (module_probe(path)) return strdup(path); - snprintf(path, sizeof(path), "%ssrc/%s.ae", global_module_registry->source_dir, converted); - if (module_probe(path)) return strdup(path); - snprintf(path, sizeof(path), "%s%s/module.ae", global_module_registry->source_dir, converted); - if (module_probe(path)) return strdup(path); - snprintf(path, sizeof(path), "%s%s.ae", global_module_registry->source_dir, converted); - if (module_probe(path)) return strdup(path); + if (src_dir && src_dir[0] && !global_module_registry->source_is_module) { + char* found = resolve_under_source_dir(src_dir, converted); + if (found) return found; } // Try 7-9: Search installed packages at ~/.aether/packages/ @@ -2052,9 +2070,10 @@ static void module_bind_import_identity(ASTNode* imp, const char* resolved, } /* Put back the source directory saved around a module's imports. */ -static void module_restore_source_dir(char* saved) { +static void module_restore_source_dir(char* saved, int saved_is_module) { free(global_module_registry->source_dir); global_module_registry->source_dir = saved; + global_module_registry->source_is_module = saved_is_module; } // Recursive helper: load a single module and its transitive imports @@ -2180,8 +2199,10 @@ static int orchestrate_module(const char* module_name, const char* file_path, * the parent's source_dir. The parent's is kept as it is, whole, and * put back (#2543). */ char* saved_source_dir = global_module_registry->source_dir; + int saved_is_module = global_module_registry->source_is_module; global_module_registry->source_dir = NULL; module_set_source_dir(file_path); + global_module_registry->source_is_module = 1; // Recursively process this module's imports for (int i = 0; i < ast->child_count; i++) { @@ -2192,7 +2213,7 @@ static int orchestrate_module(const char* module_name, const char* file_path, if (import_lane_target(child)) { if (!module_bind_target_import(child, file_path)) { /* #2607 */ free(sub_file); - module_restore_source_dir(saved_source_dir); + module_restore_source_dir(saved_source_dir, saved_is_module); return 0; } } else { @@ -2207,13 +2228,13 @@ static int orchestrate_module(const char* module_name, const char* file_path, if (sub_file) { if (!orchestrate_module(sub_path, sub_file, graph)) { free(sub_file); - module_restore_source_dir(saved_source_dir); + module_restore_source_dir(saved_source_dir, saved_is_module); return 0; } free(sub_file); module_note_lane_target(child); /* #2607 */ if (!check_selective_import_exports(child, file_path)) { - module_restore_source_dir(saved_source_dir); + module_restore_source_dir(saved_source_dir, saved_is_module); return 0; } } else { @@ -2221,7 +2242,7 @@ static int orchestrate_module(const char* module_name, const char* file_path, } } - module_restore_source_dir(saved_source_dir); + module_restore_source_dir(saved_source_dir, saved_is_module); return 1; } diff --git a/compiler/aether_module.h b/compiler/aether_module.h index 9fee15479..80acd3b50 100644 --- a/compiler/aether_module.h +++ b/compiler/aether_module.h @@ -41,6 +41,9 @@ typedef struct { // Source file directory for relative resolution, with its trailing // slash: a heap copy of any length (#2543), NULL or "" when none. char* source_dir; + // #3103: 1 while a module's own imports are resolved (source_dir is + // that module's directory), 0 for the entry program's. + int source_is_module; // Lib-search path: an ordered list of directories (PATH-style), // searched left-to-right; first hit wins. Issue #413. Default is // a single entry, `"lib"`, populated at registry init. Each entry diff --git a/contrib/jq/eval.ae b/contrib/jq/eval.ae index 2754e84ac..2a4dc7a7e 100644 --- a/contrib/jq/eval.ae +++ b/contrib/jq/eval.ae @@ -3637,9 +3637,9 @@ _contains(a: ptr, b: ptr) -> int { // Codepoint offsets of every occurrence of `sub` in `s` (overlapping). _str_indices(s: string, sub: string) -> ptr { - out = json.arr() m = string.length(sub) if m == 0 { return json.null_value() } + out = json.arr() start = 0 while 1 == 1 { idx = string.index_of_from(s, sub, start) @@ -3647,7 +3647,6 @@ _str_indices(s: string, sub: string) -> ptr { json.array_add_raw(out, json.from_int(value.codepoint_of_byte(s, idx) as long)) start = idx + 1 } - return out } _flatten_into(out: ptr, arr: ptr, depth: int) { diff --git a/contrib/jq/lexer.ae b/contrib/jq/lexer.ae index 69698b6b9..36bf8188a 100644 --- a/contrib/jq/lexer.ae +++ b/contrib/jq/lexer.ae @@ -141,7 +141,6 @@ is_keyword(s: string) -> int { "__loc__" -> { return 1 } _ -> { return 0 } } - return 0 } // --- character classes --------------------------------------------- diff --git a/contrib/jq/module.ae b/contrib/jq/module.ae index 89ca24ca3..cdf8377a5 100644 --- a/contrib/jq/module.ae +++ b/contrib/jq/module.ae @@ -23,7 +23,7 @@ // input, _ = json.parse("[{\"name\":\"ann\",\"age\":41},{\"name\":\"bob\",\"age\":7}]") // out, err = jq.run_json(prog, input) // one line per output // print(out) // "ann" -// json.json_free(input) +// json.free(input) // jq.free(prog) // } // diff --git a/contrib/jq/test_after_jsonpath.ae b/contrib/jq/test_after_jsonpath.ae index 0969e0055..f2978244a 100644 --- a/contrib/jq/test_after_jsonpath.ae +++ b/contrib/jq/test_after_jsonpath.ae @@ -34,7 +34,7 @@ main() { spec.assert_str_eq(err2, "", "no jsonpath error on descent") spec.assert_eq(jsonpath.result_size(r2), 1, "one match") jsonpath.free_result(r2) - json.json_free(doc) + json.free(doc) } } diff --git a/contrib/jq/test_with_jsonpath.ae b/contrib/jq/test_with_jsonpath.ae index 44161bab4..657eeab36 100644 --- a/contrib/jq/test_with_jsonpath.ae +++ b/contrib/jq/test_with_jsonpath.ae @@ -34,7 +34,7 @@ main() { spec.assert_str_eq(err2, "", "no jsonpath error on descent") spec.assert_eq(jsonpath.result_size(r2), 1, "one match") jsonpath.free_result(r2) - json.json_free(doc) + json.free(doc) } } diff --git a/contrib/jq/value.ae b/contrib/jq/value.ae index 8a0eb6e6f..34c3c0c69 100644 --- a/contrib/jq/value.ae +++ b/contrib/jq/value.ae @@ -113,7 +113,7 @@ string_value(v: ptr) -> string { return json.get_string_raw(v) } -// Deep copy. Owned by the caller; free with json.json_free. +// Deep copy. Owned by the caller; free with json.free. copy(v: ptr) -> ptr { t = type_of(v) if t == T_NULL { return json.null_value() } diff --git a/contrib/templating/liquid/module.ae b/contrib/templating/liquid/module.ae index 5bc851e42..015b62c08 100644 --- a/contrib/templating/liquid/module.ae +++ b/contrib/templating/liquid/module.ae @@ -3706,7 +3706,6 @@ render_if_block(kinds: ptr, contents: ptr, start: int, end: int, } branch_start = nxt + 1 } - return start, "unreachable" } // Render a `{% case x %}{% when v %}...{% when w %}...{% else %}...{% endcase %}` @@ -3777,7 +3776,6 @@ render_case_block(kinds: ptr, contents: ptr, start: int, end: int, } branch_start = nxt + 1 } - return start, "unreachable" } // Parse an integer modifier `limit:N` / `offset:N` / `cols:N` from a tag's diff --git a/docs/module-system-design.md b/docs/module-system-design.md index 879648952..e43cbf0ee 100644 --- a/docs/module-system-design.md +++ b/docs/module-system-design.md @@ -544,13 +544,20 @@ The compiler searches for local modules in this order: 4. `src/.ae` - Single-file module in src 5. `/module.ae` - Project root 6. `.ae` - Single file in project root +7. Steps 1 to 6 under the directory of the importing file, so a program + built from another directory still finds the modules beside it + +A module's own imports search its directory first (step 7, then steps 1 +to 6): `std.jsonpath`'s `import parser` names `std/jsonpath/parser.ae` +wherever the compiler runs, even in a directory that has a `parser.ae` of +its own (#3103). The entry program's imports keep the project root first. For nested modules like `import mypackage.utils`: - Dots are converted to slashes: `mypackage/utils/module.ae` - Same search paths are used with the converted path -7. `~/.aether/packages//src/module.ae` - Installed package (via `ae add`) -8. `~/.aether/packages//module.ae` - Installed package (flat layout) +8. `~/.aether/packages//src/module.ae` - Installed package (via `ae add`) +9. `~/.aether/packages//module.ae` - Installed package (flat layout) Packages installed with `ae add` are searched after local paths. The package name is the first component of the import path. diff --git a/examples/actors/message-with-string-seq.ae b/examples/actors/message-with-string-seq.ae index 61cb4d190..33be8c129 100644 --- a/examples/actors/message-with-string-seq.ae +++ b/examples/actors/message-with-string-seq.ae @@ -54,7 +54,6 @@ run_batch(s: *StringSeq, count: int) -> int { return run_batch(t, count + 1) } } - return count } main() { diff --git a/examples/basics/match-advanced.ae b/examples/basics/match-advanced.ae index c716a9403..201734e25 100644 --- a/examples/basics/match-advanced.ae +++ b/examples/basics/match-advanced.ae @@ -15,7 +15,6 @@ classify(n) { return "positive" } } - return "unknown" } main() { diff --git a/examples/basics/match-expressions.ae b/examples/basics/match-expressions.ae index bd3633e48..f2ae7a1ec 100644 --- a/examples/basics/match-expressions.ae +++ b/examples/basics/match-expressions.ae @@ -19,7 +19,6 @@ day_name(n) { 7 -> { return "Sunday"; } _ -> { return "Invalid"; } } - return "Unknown"; } // Describe a number diff --git a/examples/basics/sum-types.ae b/examples/basics/sum-types.ae index 971743d9e..2040feccb 100644 --- a/examples/basics/sum-types.ae +++ b/examples/basics/sum-types.ae @@ -29,7 +29,6 @@ name(s: Shape) -> string { Rect -> { return "rect" } Empty -> { return "empty" } } - return "?" } describe(s: Shape) { diff --git a/new_changelogs/20261022T025307Z-fixed-3103-module-import-beside-itself.md b/new_changelogs/20261022T025307Z-fixed-3103-module-import-beside-itself.md new file mode 100644 index 000000000..ec6c1b8fb --- /dev/null +++ b/new_changelogs/20261022T025307Z-fixed-3103-module-import-beside-itself.md @@ -0,0 +1,9 @@ +- **A module's own import names the file beside it, wherever the compiler + runs.** A program built in a directory with a `parser.ae` of its own + (contrib/jq) and importing `std.jsonpath` failed: "'free_query' is not + exported from module 'parser'". std.jsonpath's `import parser` searched + the current directory before its own, and found the program's file. A + module's own imports look beside it first now; the entry program's keep + the project root first. contrib/jq's `_str_indices` no longer leaks its + result for an empty needle, and jq, std.jsonpath, liquid and four + examples build without their W1002 warnings (#3103). diff --git a/std/jsonpath/parser.ae b/std/jsonpath/parser.ae index b9981cde8..5477292c1 100644 --- a/std/jsonpath/parser.ae +++ b/std/jsonpath/parser.ae @@ -310,7 +310,6 @@ _parse_segments(p: *Parser) -> ptr { } list.add_raw(segs, seg) } - return segs } _parse_segment(p: *Parser) -> ptr { diff --git a/tests/integration/module_import_beside_itself/test_module_import_beside_itself.sh b/tests/integration/module_import_beside_itself/test_module_import_beside_itself.sh new file mode 100644 index 000000000..d3faab639 --- /dev/null +++ b/tests/integration/module_import_beside_itself/test_module_import_beside_itself.sh @@ -0,0 +1,64 @@ +#!/bin/sh +# Regression (#3103): a module's own import names the file beside it, not +# one in the directory the compiler runs in. +# +# std.jsonpath's facade does `import parser` for std/jsonpath/parser.ae. +# The local-path search tried the current directory before the importing +# module's own, so a program built in a directory that has a parser.ae of +# its own (contrib/jq, which imports both packages in its tests) handed +# std.jsonpath that file: "'free_query' is not exported from module +# 'parser'". The same program built from the repository root worked. A +# module's own imports now look beside it first; the entry program's still +# look in the current directory (the project root) first. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +[ -n "${EXE_EXT:-}" ] && AE="$AE$EXE_EXT" + +if [ ! -x "$AE" ] && [ ! -x "$AE.exe" ]; then + echo " [SKIP] module_import_beside_itself: ae not built" + exit 0 +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +# A parser of the program's own, in the directory the build runs in. +cat > "$tmp/parser.ae" <<'AE' +exports(parse) + +parse(text: string) -> int { + return 1 +} +AE + +cat > "$tmp/main.ae" <<'AE' +import std.json +import std.jsonpath +import parser + +main() { + doc, jerr = json.parse("{\"b\": [1, 2, 3]}") + if jerr != "" { + println("json: ${jerr}") + return + } + r, err = jsonpath.query(doc, "$.b[*]") + if err != "" { + println("jsonpath: ${err}") + return + } + println("matches=${jsonpath.result_size(r)} own=${parser.parse("x")}") + jsonpath.free_result(r) + json.free(doc) +} +AE + +out="$(cd "$tmp" && AETHER_HOME="$ROOT" "$AE" run main.ae 2>&1 | tr -d '\r')" +if ! printf '%s\n' "$out" | grep -Fxq "matches=3 own=1"; then + echo " [FAIL] module_import_beside_itself: expected 'matches=3 own=1'" + printf '%s\n' "$out" | tail -6 | sed 's/^/ /' + exit 1 +fi +echo " [PASS] module_import_beside_itself: std.jsonpath's parser is its own, the program's is the program's" From c80c6b06ae8c5c4024650b3eec441583f6f7305c Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Thu, 22 Oct 2026 00:27:15 -0300 Subject: [PATCH 19/50] A scheduler thread gives its overflow buffers back when it leaves Each scheduler thread keeps a deferred-send buffer per target core in thread-local storage (tls_overflow). It grows by doubling under a burst and keeps its capacity, so the next burst does not pay for it again. Nothing freed those buffers. When a thread left its loop at scheduler shutdown, the arrays it had grown became unreachable. CI's macOS leaks gate found it on test_actor_pair_fifo (#3093's test, pairs of actors trading 3.2M messages): 18 leaks, 115 MB, every one `realloc in overflow_append` from send_through_core. Shown on Windows, with a counter printed at each thread's exit (not committed), three runs of that test: - before: 15 to 28 MB still held per core, in 2 to 6 buffers, on every core that took part; - after: 0 bytes on all 16 cores, every run. overflow_release, called by scheduler_thread after its final overflow_flush, frees each buffer's two arrays and zeroes it. An entry still waiting at that point means the final flush found its target's queue full while the cores were stopping. It is dropped and uncounted (inflight_leave), as a message for a released actor is (scheduler_drop_dead_message), and taken off g_overflow_total. Checked: make test (598) passes, as do test_actor_pair_fifo (three runs), test_actor_balanced_spawn, test_actor_timeout_multiple and sched_foreign_send_lifecycle. The macOS leaks gate is the regression test. --- runtime/scheduler/multicore_scheduler.c | 36 +++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/runtime/scheduler/multicore_scheduler.c b/runtime/scheduler/multicore_scheduler.c index d511fb151..a83520aeb 100644 --- a/runtime/scheduler/multicore_scheduler.c +++ b/runtime/scheduler/multicore_scheduler.c @@ -668,6 +668,38 @@ static void overflow_append(int target, ActorBase* actor, Message msg) { atomic_fetch_add_explicit(&g_overflow_total, 1, memory_order_relaxed); } +// The overflow buffers are this thread's own (thread-local), and they keep +// what they grew to, so a burst is not paid for again. A scheduler thread +// gives them back when it leaves its loop: nothing else can reach them once +// the thread is gone, and a pair of actors trading messages at full speed +// grew them to 15 to 28 MB per core, all of it lost at every scheduler +// shutdown. An entry still in one when the thread leaves (its final flush +// found the target's queue full, with the cores stopping) is dropped and +// uncounted, as a message for a released actor is. +static void scheduler_drop_dead_message(Scheduler* sched, Message* msg); + +static void overflow_release(Scheduler* sched) { + int dropped = 0; + for (int t = 0; t <= MAX_CORES; t++) { + OverflowBuf* b = &tls_overflow[t]; + for (int i = b->head; i < b->head + b->count; i++) { + ActorBase* actor = b->actors[i]; + if (actor && !actor->auto_process) inflight_leave(actor, 1); + scheduler_drop_dead_message(sched, &b->msgs[i]); + dropped++; + } + free(b->actors); + free(b->msgs); + b->actors = NULL; + b->msgs = NULL; + b->head = 0; + b->count = 0; + b->capacity = 0; + } + if (dropped > 0) atomic_fetch_sub_explicit(&g_overflow_total, dropped, memory_order_relaxed); + tls_overflow_any = 0; +} + // Queue a counted message for one of this core's own actors on this core's // own queue, behind whatever this core's own overflow buffer holds: an entry // there overflowed when the queue was full, so it is older, and a message @@ -693,8 +725,6 @@ static void requeue_own(int core, ActorBase* actor, Message msg) { // Cross-core: Always uses head-pointer advancement instead of memmove. // This makes the cost O(drained) instead of O(total), eliminating the // multi-MB memmoves that caused phase-2 stalls with large overflow. -static void scheduler_drop_dead_message(Scheduler* sched, Message* msg); - static void overflow_flush(int from_core) { if (!tls_overflow_any) return; int from_idx = (from_core >= 0 && from_core < MAX_CORES) ? from_core : MAX_CORES; @@ -2203,6 +2233,8 @@ void* AETHER_HOT scheduler_thread(void* arg) { // empty here. But under extreme contention a late overflow_append could // slip in — flush it so no messages are lost. overflow_flush(sched->core_id); + // The buffers are this thread's: they go back with it. + overflow_release(sched); // Done reading: this core no longer holds back any free (#2509). reclaim_core_offline(sched); From fdc45102ee544d8bf5b7ce7f590b0b446370b143 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Thu, 22 Oct 2026 00:32:14 -0300 Subject: [PATCH 20/50] An index into a fixed-size array is checked against its length (#3113) A slice index outside `0 ..< s.len` is a named panic; an index into a fixed-size array `T[N]` was not checked at all. Codegen wrote a plain C `a[i]`, so `a = [1, 2, 3]; i = 4; a[i] = 99` wrote into the stack frame and `at(a, 5)` read whatever lay past the array, exit 0, and so did an array field, an array parameter, an actor's array state and a `const` table. Every index into a `T[N]` is checked now, where the one place codegen writes an index (AST_ARRAY_ACCESS) emits it: `a[aether_array_index(i, N, file, line)]`. The helper (runtime/aether_slice.h, beside the slice's) returns the index, so the element stays an lvalue for a store, `+=`, `++` and `--`, and the index is evaluated once; outside the range it raises the slice's panic with the array's length ("array index 4 out of range for length 3", after the source line), which `try` catches. A negative index is out of range. A literal index the checker has seen in range is written as it is. The checker refuses an index known when the program is compiled that is out of range: a literal, a `const`, or arithmetic over them, evaluated by contract_eval_int64, the evaluator contracts and switch tables use. A name counts as a constant only when the scope sees the `const` itself (the symbol's is_const), so a local or a parameter named like one is a value known when the program runs and is checked then. The reference states the rule in Array Types and beside the slice rule. Measured, gcc -O2 (`ae build`), best of 15 runs, before and after alternating, every digest unchanged, 1 MiB per digest: blake2b +0.7%, blake2s -0.1%, gost3411_2012-256 -1.4%, sha256 -0.3%, sha512 -0.2%, sha3-256 -1.6%, md5 -0.1%, sha1 +1.3%, sm3 0.0%, whirlpool -1.1%, ripemd160 -1.2%, tiger -1.4%, skein512 +0.8%. That is noise because the C compiler removes every test: in the assembly of those modules (42 indexes: blake2's SIGMA, IV_B and IV_S, gost's STREEBOG_T and STREEBOG_C, sha2's K256 and K512, sha3's RHO and RC_K, sm3's T_SM3, whirlpool's WP_SBOX, tiger's four S-boxes, skein's SKEIN_ROT and SKEIN_PERM) no array index panic is left. Of the cross-language benchmarks, ping_pong, counting, thread_ring and skynet generate the same C as before; fork_join's one index (`workers[i % num_workers]`) is removed at -O3, and its two binaries differ only in the PE header's timestamp and checksum. No case was found where the test stays. Test: tests/integration/array_index_checked, on both C paths: a store, a read through a parameter, `+=`, `++`, `--` with a negative index, a struct field through a pointer, a `const` table and an actor's state, each out of range and caught by `try` with its line, the array beside the one written unchanged, and every form in range; an uncaught one panics with its message; a literal, a `const` expression and a negative literal out of range are refused at compile time; a parameter named like a `const` is checked when the program runs. The refusals compile, and the program runs past every bad index, before. Checks: make test (598); 91 integration tests over arrays, slices, consts, actor state and every crypto module; the corpus and grid-cell sweep against #3103 (4712 files): no exit status changes, so no program in it has a constant index out of range, and the C changes in 180 files, none of them a grid cell; every runnable test among those 180 run on both C paths (55 programs) and the 43 integration directories that hold the rest. The GPU backends' tests (contrib/d3d12, metal, vulkan) were left out. --- compiler/analysis/typechecker.c | 50 ++++++++ compiler/codegen/codegen_expr.c | 26 ++++ docs/language-reference.md | 4 +- ...T033000Z-fixed-3113-array-index-checked.md | 12 ++ runtime/aether_slice.h | 19 +++ tests/ae_sweep_prune.txt | 2 + .../integration/array_index_checked/caught.ae | 100 ++++++++++++++++ .../array_index_checked/panic_shadowed.ae | 12 ++ .../array_index_checked/reject_const.ae | 8 ++ .../array_index_checked/reject_literal.ae | 6 + .../array_index_checked/reject_negative.ae | 5 + .../test_array_index_checked.sh | 111 ++++++++++++++++++ 12 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 new_changelogs/20261022T033000Z-fixed-3113-array-index-checked.md create mode 100644 tests/integration/array_index_checked/caught.ae create mode 100644 tests/integration/array_index_checked/panic_shadowed.ae create mode 100644 tests/integration/array_index_checked/reject_const.ae create mode 100644 tests/integration/array_index_checked/reject_literal.ae create mode 100644 tests/integration/array_index_checked/reject_negative.ae create mode 100644 tests/integration/array_index_checked/test_array_index_checked.sh diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index 29bb13e54..a81fdfc41 100644 --- a/compiler/analysis/typechecker.c +++ b/compiler/analysis/typechecker.c @@ -2275,6 +2275,35 @@ static int is_const_array_element(ASTNode* elem, SymbolTable* table) { return is_const_expression(elem, table); } +/* #3113: does `e` name only literals and the program's `const`s, as this + * scope sees them? A local, a parameter or a module `var` of the name + * (one that hides a `const` included) is a value known only at run time. */ +static int index_names_only_consts(ASTNode* e, SymbolTable* table) { + if (!e) return 0; + switch (e->type) { + case AST_LITERAL: + return 1; + case AST_IDENTIFIER: { + Symbol* s = e->value ? lookup_symbol(table, e->value) : NULL; + return s && s->is_const; /* #2516: set for a `const`, never a `var` */ + } + case AST_UNARY_EXPRESSION: + case AST_BINARY_EXPRESSION: + for (int i = 0; i < e->child_count; i++) + if (!index_names_only_consts(e->children[i], table)) return 0; + return e->child_count > 0; + default: + return 0; + } +} + +/* #3113: the value of an index known when the program is compiled: a + * literal, a `const`, or arithmetic over them. */ +static int tc_index_constant(ASTNode* idx, SymbolTable* table, int64_t* out) { + return index_names_only_consts(idx, table) && + contract_eval_int64(idx, g_typecheck_program, out); +} + /* #2590: a struct literal whose every field is a compile-time constant or * such a literal itself. A module-level `var` takes one: it lowers to an * initializer list (`static T g = { .a = 0 };`), which C accepts for a @@ -16263,6 +16292,27 @@ int typecheck_expression(ASTNode* expr, SymbolTable* table) { } if (idx_type) free_type(idx_type); + /* #3113: an index into a `T[N]` is in `0 ..< N`, checked + * when the program runs; one known when it is compiled and + * outside that is refused here. */ + int64_t civ = 0; + if (type_is_sized_array(arr_type) && !arr_type->index_enum_name && + tc_index_constant(expr->children[1], table, &civ) && + (civ < 0 || civ >= arr_type->array_size)) { + char error_msg[320]; + char tn[160]; + snprintf(error_msg, sizeof(error_msg), + "array index %lld is out of range for '%s', whose indexes are " + "0 through %d", + (long long)civ, ret_type_spell(arr_type, tn, sizeof(tn)), + arr_type->array_size - 1); + type_error(error_msg, expr->children[1]->line ? expr->children[1]->line + : expr->line, + expr->children[1]->line ? expr->children[1]->column + : expr->column); + return 0; + } + // Propagate element type from the array expression. The // array's element type is authoritative: the early inference // pass may have stamped this access from the array's FIRST diff --git a/compiler/codegen/codegen_expr.c b/compiler/codegen/codegen_expr.c index efc62a7df..a1ec2b911 100644 --- a/compiler/codegen/codegen_expr.c +++ b/compiler/codegen/codegen_expr.c @@ -1,6 +1,7 @@ #include "../analysis/quote.h" #include "../analysis/hoist.h" #include "../analysis/missing_return.h" +#include "../analysis/contract_eval.h" #include "codegen_internal.h" #include "../analysis/sandbox_trust.h" #include "../aether_defines.h" @@ -6015,6 +6016,16 @@ static const char* slice_elem_c_type(const Type* t) { * Windows path's backslashes were emitted raw, and each `\G`, `\s`, `\d` * became an unknown-escape warning per access (a `\a` a BEL in the * message). Control characters are written as octal escapes. */ +/* #3113: is `idx` an integer literal in `0 ..< n`? Such an index into a + * `T[N]` needs no run-time test (the checker refuses one outside it). */ +static int index_literal_within(const ASTNode* idx, int n) { + if (!idx || idx->type != AST_LITERAL || !idx->value || !idx->node_type) return 0; + TypeKind k = idx->node_type->kind; + if (k != TYPE_INT && k != TYPE_INT64) return 0; + int64_t v = 0; + return contract_eval_int64((ASTNode*)idx, NULL, &v) && v >= 0 && v < n; +} + static void emit_slice_diag_file(CodeGenerator* gen, const ASTNode* n) { const char* path = (n && n->source_file) ? n->source_file : "?"; fputc('"', gen->output); @@ -11628,11 +11639,26 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { } int str_base = base && base->node_type && base->node_type->kind == TYPE_STRING; + /* #3113: an index into a fixed-size `T[N]` is checked + * against N, as a slice's is against its length: a local, + * a field, a parameter, an actor's state or a `const` + * table, read or stored, `+=` and `++` alike, the index + * evaluated once. A literal index the checker has seen in + * range needs no test. */ + int sized = base && type_is_sized_array(base->node_type) && + !index_literal_within(expr->children[1], + base->node_type->array_size); if (str_base) fprintf(gen->output, "_aether_safe_str("); generate_expression(gen, base); if (str_base) fprintf(gen->output, ")"); fprintf(gen->output, "["); + if (sized) fprintf(gen->output, "aether_array_index((int64_t)("); generate_read(gen, expr->children[1]); + if (sized) { + fprintf(gen->output, "), %d, ", base->node_type->array_size); + emit_slice_diag_file(gen, expr); + fprintf(gen->output, ", %d)", expr->line); + } fprintf(gen->output, "]"); } break; diff --git a/docs/language-reference.md b/docs/language-reference.md index 9b55c6389..cd69674c2 100644 --- a/docs/language-reference.md +++ b/docs/language-reference.md @@ -324,6 +324,8 @@ main() { 7 0 ``` +**An index into a fixed-size array is checked, as a slice's is.** `a[i]` on a `T[N]` with `i` outside `0 ..< N` is a runtime panic naming the source line, the index and the length (`array index 4 out of range for length 3`), the same ordinary panic a slice index raises: `try` catches it, and a negative index is out of range. The test covers every index into a `T[N]`: a read, a store, `+=` and `++`, through a local, a field, a parameter, an actor's state or a `const` table. An index known when the program is compiled (a literal, a `const`, or arithmetic over them) that is out of range is a compile error. With a constant `N` and a loop bounded by it, the C compiler drops the test. + **An array literal's element type is the join of its elements.** `[1, 2.5, 3]` and `[0.18 * math.PI, 0.25 * math.PI]` are `float` arrays; an all-integer literal is `int`, widening to `long` if any element needs it. The type is decided after every element is typed, so a module constant or a call in any position counts. A literal of another type is an array of its first element's type, or, in a place that is an array of a sum, of that sum, each variant wrapped ([Constructing, implicit wrap](#constructing-implicit-wrap)); `null` and `none` have no type of their own: `[null, s]` is an array of `s`'s type, its `null` the null string, pointer or empty closure as in any slot of that type, and `[none, x]` an array of `x`'s optional type. Every element must fit the element type: `[1, "x"]`, or `[p, null]` with `p` a struct, is a compile error naming the element. Arrays are one-dimensional: an array literal cannot contain an array literal (use a flat `T[rows * cols]` indexed as `row * cols + col`, or a list of arrays). ```aether,run @@ -393,7 +395,7 @@ main() { ``` - **`s.len`** is the element count, an `int`. It is read, never written: a slice's length is fixed when the slice is made, and an array's is a constant, so `s.len = n` (or `+=`, `++`) is a type error that points at `s[lo..hi]` for fewer elements (#3104). -- **`s[i]` is bounds-checked.** An index outside `0 ..< s.len` is a runtime panic naming the source line, the index and the length (`slice index 5 out of range for length 3`). It is an ordinary panic: `try` catches it, and an actor step that trips it dies cleanly. A negative index is out of range, never a read before the buffer. +- **`s[i]` is bounds-checked.** An index outside `0 ..< s.len` is a runtime panic naming the source line, the index and the length (`slice index 5 out of range for length 3`). It is an ordinary panic: `try` catches it, and an actor step that trips it dies cleanly. A negative index is out of range, never a read before the buffer. An index into a fixed-size `T[N]` array is checked the same way, against `N` ([Array Types](#array-types)). - **`s[lo..hi]`** is the half-open sub-slice `[lo, hi)`, checked the same way (`lo <= hi <= s.len`). `s[lo..]` runs to the end, `s[..hi]` from the start, `s[..]` is the whole thing. The base may be a slice or a `T[N]` array; the result is always a `T[]` over the *same* elements, so a write through it is visible through the original. - **A `T[N]` array converts to `T[]` implicitly** wherever a slice is expected: a parameter, a `T[]`-typed binding or field, a `return` from a `-> T[]` function. The length travels with it. `null`, and the empty literal `[]`, convert to the empty slice: `.len` is `0`, it views no storage, and it compares equal to `null`. So `s == []` is `s == null` (it asks whether `s` views any storage; test `s.len == 0` for emptiness), a C function's `T[]` parameter takes `[]` as the null pointer, and an if-expression or a `match` given to a `T[]` converts each arm on its own. A module-level `var` or `const` of type `T[]` takes either as its initial value. - **A string is not a slice.** Passing one where a `T[]` is expected (a parameter, a binding, a `return`) is a type error; `string.bytes(s)` is the bounded, read-only `byte[]` view of a string's bytes, and it decays to their pointer at a C extern like any slice. Reading it costs what any slice's read does; a store through it (an element store, `+=`, `++`), or through any view made from it (a sub-slice, a pattern's tail), in this function or one it is given to, panics with "a write through a read-only view of a string", and one the checker can see through a local bound to it is a compile error (E0200) (#3025). A string's own bytes are read-only too: `s[i]` reads one, and a write (`s[i] = v`, `s[i] += v`, `s[i]++`) is a compile error (E0200). Build the new text as a new string, or work on the bytes in a `byte[N]` array of your own. diff --git a/new_changelogs/20261022T033000Z-fixed-3113-array-index-checked.md b/new_changelogs/20261022T033000Z-fixed-3113-array-index-checked.md new file mode 100644 index 000000000..5c663c274 --- /dev/null +++ b/new_changelogs/20261022T033000Z-fixed-3113-array-index-checked.md @@ -0,0 +1,12 @@ +- **An index into a fixed-size array is checked, as a slice's is.** + `a[i]` on a `T[N]` compiled to a plain C `a[i]`, so `a[4] = 99` on an + `int[3]` wrote into the stack frame and a read past the end returned + garbage, with no panic. Every index into a `T[N]` (a read, a store, `+=`, + `++`, through a local, a field, a parameter, an actor's state or a + `const` table) is now checked against N, and one outside `0 ..< N` is the + slice's panic: `array index 4 out of range for length 3`, with the source + line, which `try` catches. An index known when the program is compiled + that is out of range is a compile error. With a constant N and a loop + bounded by it the C compiler drops the test: the cryptography modules' + table lookups and the cross-language benchmarks run as fast as before + (#3113). diff --git a/runtime/aether_slice.h b/runtime/aether_slice.h index 3c62e2f55..bf5cba3cb 100644 --- a/runtime/aether_slice.h +++ b/runtime/aether_slice.h @@ -138,6 +138,25 @@ static inline void aether_slice_fail_index(int64_t idx, uint64_t len, file, line, (long long)idx, (unsigned long long)len); } +/* #3113: an index into a fixed-size array `T[N]` outside `0 ..< N`, the + * slice's panic with the array's length. */ +static inline void aether_array_fail_index(int64_t idx, uint64_t len, + const char* file, int line) { + aether_slice_panicf("%s:%d: array index %lld out of range for length %llu", + file, line, (long long)idx, (unsigned long long)len); +} + +/* #3113: `a[i]` on a `T[N]`: `i`, checked against N, for the generated C + * to index with (`a[aether_array_index(i, N, ...)]`), so the element stays + * an lvalue and `i` is evaluated once. A negative index is out of range. + * With a constant N and a loop bounded by it the C compiler drops the + * test. */ +static inline int64_t aether_array_index(int64_t i, uint64_t len, + const char* file, int line) { + if ((uint64_t)i >= len) aether_array_fail_index(i, len, file, line); + return i; +} + static inline void aether_slice_fail_range(int64_t lo, int64_t hi, uint64_t len, const char* file, int line) { aether_slice_panicf("%s:%d: slice range %lld..%lld out of range for length %llu", diff --git a/tests/ae_sweep_prune.txt b/tests/ae_sweep_prune.txt index 9d63db29c..d4c713215 100644 --- a/tests/ae_sweep_prune.txt +++ b/tests/ae_sweep_prune.txt @@ -273,3 +273,5 @@ tests/integration/emit_header_layout/ tests/integration/extern_fnptr_bare_function/ # #2683: mainfile.ae and imported.ae must not compile; the driver checks the diagnostic. tests/integration/local_named_after_called_function/ +# #3113: reject_*.ae must not compile and panic_shadowed.ae must panic; the driver checks each. +tests/integration/array_index_checked/ diff --git a/tests/integration/array_index_checked/caught.ae b/tests/integration/array_index_checked/caught.ae new file mode 100644 index 000000000..80166fb38 --- /dev/null +++ b/tests/integration/array_index_checked/caught.ae @@ -0,0 +1,100 @@ +// #3113: every index into a fixed-size array is checked against its length. +// Each out-of-range index below is caught by `try`, and the array beside the +// one written is untouched. +struct Ctx { + w: long[4] + n: int +} + +const SIGMA: int[4] = [3, 1, 2, 0] + +message Store { + i: int + v: int +} + +at(a: int[3], i: int) -> int { + return a[i] +} + +put(c: *Ctx, i: int, v: long) { + c.w[i] = v +} + +actor Keeper { + state slots = [0, 0, 0] + + receive { + Store(i, v) -> { + try { + slots[i] = v + println("stored ${slots[i]}") + } catch e { + println("caught: ${e}") + } + } + } +} + +main() { + a = [1, 2, 3] + b = [7, 8, 9] + i = 4 + try { + a[i] = 99 + println("NOT REACHED write") + } catch e { + println("caught: ${e}") + } + try { + println("${at(a, 5)}") + println("NOT REACHED param") + } catch e { + println("caught: ${e}") + } + j = 3 + try { + a[j] += 1 + println("NOT REACHED compound") + } catch e { + println("caught: ${e}") + } + try { + a[j]++ + println("NOT REACHED increment") + } catch e { + println("caught: ${e}") + } + k = -1 + try { + a[k]-- + println("NOT REACHED negative") + } catch e { + println("caught: ${e}") + } + c = Ctx { n: 0 } + try { + put(&c, 4, 5) + println("NOT REACHED field") + } catch e { + println("caught: ${e}") + } + m = 4 + try { + println("${SIGMA[m]}") + println("NOT REACHED const") + } catch e { + println("caught: ${e}") + } + // In range, every form still works. + a[0] += 10 + a[1]++ + a[2] = at(a, 0) + SIGMA[i - 4] + put(&c, 3, 40) + println("a=${a[0]} ${a[1]} ${a[2]} b=${b[0]} ${b[1]} ${b[2]} c=${c.w[3]}") + + keeper = spawn(Keeper()) + keeper ! Store { i: 2, v: 5 } + keeper ! Store { i: 3, v: 6 } + wait_for_idle() +} diff --git a/tests/integration/array_index_checked/panic_shadowed.ae b/tests/integration/array_index_checked/panic_shadowed.ae new file mode 100644 index 000000000..324a95588 --- /dev/null +++ b/tests/integration/array_index_checked/panic_shadowed.ae @@ -0,0 +1,12 @@ +// #3113: a parameter named like a const is a run-time value: its index is +// checked when the program runs, not refused by the const it hides. +const N = 1 + +pick(a: int[3], N: int) -> int { + return a[N] +} + +main() { + a = [1, 2, 3] + println("${pick(a, 7)}") +} diff --git a/tests/integration/array_index_checked/reject_const.ae b/tests/integration/array_index_checked/reject_const.ae new file mode 100644 index 000000000..fe2e8a029 --- /dev/null +++ b/tests/integration/array_index_checked/reject_const.ae @@ -0,0 +1,8 @@ +// #3113: an index that is a const, or arithmetic over consts, outside a +// T[N] is a compile error. +const N = 2 + +main() { + a = [1, 2, 3, 4] + println("${a[N * 2]}") +} diff --git a/tests/integration/array_index_checked/reject_literal.ae b/tests/integration/array_index_checked/reject_literal.ae new file mode 100644 index 000000000..114f02b28 --- /dev/null +++ b/tests/integration/array_index_checked/reject_literal.ae @@ -0,0 +1,6 @@ +// #3113: a literal index outside a T[N] is a compile error. +main() { + a = [1, 2, 3] + a[3] = 9 + println("${a[0]}") +} diff --git a/tests/integration/array_index_checked/reject_negative.ae b/tests/integration/array_index_checked/reject_negative.ae new file mode 100644 index 000000000..cffe94629 --- /dev/null +++ b/tests/integration/array_index_checked/reject_negative.ae @@ -0,0 +1,5 @@ +// #3113: a negative literal index is out of range. +main() { + a = [1, 2, 3] + println("${a[-1]}") +} diff --git a/tests/integration/array_index_checked/test_array_index_checked.sh b/tests/integration/array_index_checked/test_array_index_checked.sh new file mode 100644 index 000000000..891b17d7e --- /dev/null +++ b/tests/integration/array_index_checked/test_array_index_checked.sh @@ -0,0 +1,111 @@ +#!/bin/sh +# Issue #3113: an index into a fixed-size array `T[N]` is checked against N, +# as a slice index is against its length. The generated C was a plain +# `a[i]`, so `a[4] = 99` on an `int[3]` wrote into the stack frame and +# `at(a, 5)` read whatever lay past the array, exit 0. Every index is +# checked now: a read, a store, `+=`, `++` and `--`, through a local, a +# parameter, a struct field, an actor's state and a `const` table, with the +# slice's panic ("array index 4 out of range for length 3", the source line +# before it), which `try` catches. An index known when the program is +# compiled (a literal, a `const`, arithmetic over them) out of range is a +# compile error; a parameter named like a `const` is not one. +# Run on both C paths. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +[ -n "${EXE_EXT:-}" ] && AE="$AE$EXE_EXT" +if [ ! -x "$AE" ] && [ ! -x "$AE.exe" ]; then + echo " [SKIP] array_index_checked: $AE not built" + exit 0 +fi +TMPDIR="$(mktemp -d)"; trap 'rm -rf "$TMPDIR"' EXIT +fail=0 + +# $1 = source basename, $2 = extended regex the diagnostic must match +check_rejected() { + name="$1"; want="$2" + if "$AE" build "$SCRIPT_DIR/$name.ae" -o "$TMPDIR/out" >"$TMPDIR/$name.log" 2>&1; then + echo " [FAIL] array_index_checked: $name.ae compiled (should be rejected)" + fail=1; return + fi + if ! tr -d '\r' < "$TMPDIR/$name.log" | grep -qE "$want"; then + echo " [FAIL] array_index_checked: $name.ae rejected, but not for the stated reason" + echo " expected a diagnostic matching: $want" + sed 's/^/ /' "$TMPDIR/$name.log" | head -8 + fail=1 + fi +} + +check_rejected reject_literal \ + "array index 3 is out of range for 'int\[3\]', whose indexes are 0 through 2" +check_rejected reject_const \ + "array index 4 is out of range for 'int\[4\]', whose indexes are 0 through 3" +check_rejected reject_negative \ + "array index -1 is out of range for 'int\[3\]', whose indexes are 0 through 2" + +for cc in default strict; do + if [ "$cc" = strict ]; then + export AE_CC="${CC:-gcc} -DAETHER_GCC_COMPAT=0" + else + unset AE_CC + fi + + # Each out-of-range index caught; the arrays beside it untouched. + if ! "$AE" build "$SCRIPT_DIR/caught.ae" -o "$TMPDIR/caught" >"$TMPDIR/caught.build.log" 2>&1; then + echo " [FAIL] array_index_checked ($cc): caught.ae did not build" + sed 's/^/ /' "$TMPDIR/caught.build.log" | head -8 + fail=1 + continue + fi + "$TMPDIR/caught" >"$TMPDIR/caught.run.log" 2>&1 + rc=$? + tr -d '\r' < "$TMPDIR/caught.run.log" > "$TMPDIR/caught.txt" + if [ $rc -ne 0 ] || grep -q 'NOT REACHED' "$TMPDIR/caught.txt"; then + echo " [FAIL] array_index_checked ($cc): an out-of-range index was not caught (rc=$rc)" + sed 's/^/ /' "$TMPDIR/caught.txt" | head -12 + fail=1 + continue + fi + for want in \ + 'caught: .*caught\.ae:44: array index 4 out of range for length 3' \ + 'caught: .*caught\.ae:17: array index 5 out of range for length 3' \ + 'caught: .*caught\.ae:[0-9]*: array index 3 out of range for length 3' \ + 'caught: .*caught\.ae:[0-9]*: array index -1 out of range for length 3' \ + 'caught: .*caught\.ae:21: array index 4 out of range for length 4' \ + 'caught: .*caught\.ae:[0-9]*: array index 4 out of range for length 4' \ + '^a=11 3 14 b=7 8 9 c=40$' \ + '^stored 5$' \ + 'caught: .*caught\.ae:30: array index 3 out of range for length 3'; do + if ! grep -qE -- "$want" "$TMPDIR/caught.txt"; then + echo " [FAIL] array_index_checked ($cc): no line matching: $want" + sed 's/^/ /' "$TMPDIR/caught.txt" | head -14 + fail=1 + fi + done + n=$(grep -c 'array index 3 out of range for length 3' "$TMPDIR/caught.txt") + if [ "$n" -ne 3 ]; then + echo " [FAIL] array_index_checked ($cc): '+=', '++' and the actor's store: $n of 3 caught" + fail=1 + fi + + # Uncaught, it is a panic: exit non-zero, the message on the way out. + if ! "$AE" build "$SCRIPT_DIR/panic_shadowed.ae" -o "$TMPDIR/shadowed" >"$TMPDIR/shadowed.build.log" 2>&1; then + echo " [FAIL] array_index_checked ($cc): panic_shadowed.ae did not build" + sed 's/^/ /' "$TMPDIR/shadowed.build.log" | head -8 + fail=1 + continue + fi + if "$TMPDIR/shadowed" >"$TMPDIR/shadowed.run.log" 2>&1; then + echo " [FAIL] array_index_checked ($cc): panic_shadowed exited 0 (should panic)" + fail=1 + elif ! tr -d '\r' < "$TMPDIR/shadowed.run.log" | + grep -qE 'panic_shadowed\.ae:6: array index 7 out of range for length 3'; then + echo " [FAIL] array_index_checked ($cc): panic_shadowed panicked, but not with the expected message" + sed 's/^/ /' "$TMPDIR/shadowed.run.log" | head -8 + fail=1 + fi +done +unset AE_CC + +[ $fail -eq 0 ] && echo " [PASS] array_index_checked: every index into a T[N] checked on both C paths, try catches it, constant ones refused" +exit $fail From 62b515585f3e2fce95c405e104a554473756f449 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Thu, 22 Oct 2026 00:31:13 -0300 Subject: [PATCH 21/50] An array length is an integer constant expression: a named constant, an expression, any base (#3115) The parser took the length as one NUMBER token read with atoi, so `long[QWORDS]` beside `const QWORDS: int = 128` and `long[NW + 1]` were "Expected NUMBER, got IDENTIFIER", `byte[0x40]` was an array of length 0, and `int[0]` compiled as a slice. std wrote a number beside the constant it stood for. A length is now a literal in any base the language reads, a top-level `const` of the same file whose value is an integer constant expression (declared above or below, built from other constants), or `+ - * / % << >>` and parentheses over those. The file's integer constants are scanned once, on the first length that needs them. Every value stays within +-2^31, so no step overflows. A length below 1, an unknown or non-integer name and a division by zero are compile errors that say what a length may be. The two lookaheads that tell `Pair[N] ps` from an index expression skip the whole bracket group. Sub-parsers are freed through free_parser. Tests: tests/regression/test_issue3115_array_length_const.ae (locals, fields, parameters, arrays of structs, hex, shifts, a constant built from one declared after it), which the old parser refuses; and tests/integration/array_length_errors for the six errors. --- compiler/parser/parser.c | 285 ++++++++++++++++-- compiler/parser/parser.h | 7 + docs/language-reference.md | 15 + ...2T032820Z-fixed-3115-array-length-const.md | 8 + .../test_array_length_errors.sh | 63 ++++ .../test_issue3115_array_length_const.ae | 71 +++++ 6 files changed, 431 insertions(+), 18 deletions(-) create mode 100644 new_changelogs/20261022T032820Z-fixed-3115-array-length-const.md create mode 100644 tests/integration/array_length_errors/test_array_length_errors.sh create mode 100644 tests/regression/test_issue3115_array_length_const.ae diff --git a/compiler/parser/parser.c b/compiler/parser/parser.c index eedda3cf4..84cbe612a 100644 --- a/compiler/parser/parser.c +++ b/compiler/parser/parser.c @@ -2,6 +2,7 @@ #include #include #include +#include #include "parser.h" #include "../aether_defines.h" @@ -26,11 +27,20 @@ Parser* create_parser(Token** tokens, int token_count) { parser->when_top_level = 0; parser->depth = 0; parser->depth_exceeded = 0; + parser->int_consts = NULL; + parser->int_const_count = 0; + parser->int_consts_scanned = 0; return parser; } +typedef struct ParserIntConst { + const char* name; /* the token's text, alive for the whole parse */ + long long value; +} ParserIntConst; + void free_parser(Parser* parser) { if (parser) { + free(parser->int_consts); free(parser); } } @@ -50,6 +60,22 @@ Token* peek_ahead(Parser* parser, int offset) { return parser->tokens[pos]; } +/* #3115: the offset of the `]` that closes the `[` at offset `open` from the + * current token, on that `[`'s line; -1 when there is none. A declaration's + * array type may hold an expression (`Pair[N + 1] ps`), so the lookaheads + * that tell `T[n] name` from an index expression skip the whole group. */ +static int closing_bracket_offset(Parser* parser, int open) { + Token* o = peek_ahead(parser, open); + if (!o || o->type != TOKEN_LEFT_BRACKET) return -1; + int depth = 0; + for (int k = open;; k++) { + Token* t = peek_ahead(parser, k); + if (!t || t->line != o->line) return -1; + if (t->type == TOKEN_LEFT_BRACKET) depth++; + else if (t->type == TOKEN_RIGHT_BRACKET && --depth == 0) return k; + } +} + // Disambiguate a `|` (or `||`) right after a function call: is it the start // of a trailing closure `func(args) |params| { ... }` / `func(args) || { ... }`, // or a bitwise/logical operator `func(args) | EXPR` / `func(args) || EXPR`? @@ -386,6 +412,232 @@ static Type* parse_struct_type_name(Parser* parser, Token* first) { return type; } +/* #3115: an integer literal's text as the language reads it (`128`, `0x40`, + * `0b1000`, `0o20`, `1_024`). 0 for anything else, a float among them. */ +static int int_literal_text(const char* text, long long* out) { + char digits[72]; + size_t k = 0; + for (const char* c = text; c && *c && k + 1 < sizeof digits; c++) + if (*c != '_') digits[k++] = *c; + digits[k] = '\0'; + int base = 0; + const char* s = digits; + if (s[0] == '0' && (s[1] == 'b' || s[1] == 'B')) { base = 2; s += 2; } + else if (s[0] == '0' && (s[1] == 'o' || s[1] == 'O')) { base = 8; s += 2; } + char* end = NULL; + unsigned long long v = strtoull(s, &end, base); + if (!end || *end || end == s || v > (unsigned long long)LLONG_MAX) return 0; + *out = (long long)v; + return 1; +} + +/* #3115: an integer constant expression over the tokens from `pos`: + * literals, names among the file's integer constants, unary minus, + * parentheses, and `* / %`, `+ -`, `<< >>` with C's precedence. It stops at + * the first token that cannot continue it. `fail` names why it is not one: + * the first unknown name, or a division by zero. */ +typedef struct { + Parser* p; + int pos; + const char* fail; + Token* fail_at; +} ConstEval; + +static Token* ce_peek(ConstEval* ce) { + return ce->pos < ce->p->token_count ? ce->p->tokens[ce->pos] : NULL; +} + +static int ce_lookup(Parser* p, const char* name, long long* out) { + for (int i = 0; i < p->int_const_count; i++) + if (strcmp(p->int_consts[i].name, name) == 0) { *out = p->int_consts[i].value; return 1; } + return 0; +} + +/* Every value an array-length expression reaches stays within +-2^31: a + * length is at most INT_MAX, and inside that bound no `*`, `+`, `-` or + * `<<` of two such values overflows a long long. */ +#define CE_LIMIT (1LL << 31) + +static int ce_in_range(ConstEval* ce, long long v, Token* at) { + if (v >= -CE_LIMIT && v <= CE_LIMIT) return 1; + if (!ce->fail) { ce->fail = "range"; ce->fail_at = at; } + return 0; +} + +static int ce_shift(ConstEval* ce, long long* out); + +static int ce_unary(ConstEval* ce, long long* out) { + Token* t = ce_peek(ce); + if (!t) return 0; + if (t->type == TOKEN_MINUS) { + ce->pos++; + if (!ce_unary(ce, out)) return 0; + *out = -*out; + return 1; + } + if (t->type == TOKEN_LEFT_PAREN) { + ce->pos++; + if (!ce_shift(ce, out)) return 0; + Token* r = ce_peek(ce); + if (!r || r->type != TOKEN_RIGHT_PAREN) return 0; + ce->pos++; + return 1; + } + if (t->type == TOKEN_NUMBER) { + if (!int_literal_text(t->value, out) || !ce_in_range(ce, *out, t)) return 0; + ce->pos++; + return 1; + } + if (t->type == TOKEN_IDENTIFIER) { + if (!ce_lookup(ce->p, t->value, out)) { + if (!ce->fail) { ce->fail = "unknown"; ce->fail_at = t; } + return 0; + } + ce->pos++; + return 1; + } + return 0; +} + +static int ce_mul(ConstEval* ce, long long* out) { + if (!ce_unary(ce, out)) return 0; + for (;;) { + Token* t = ce_peek(ce); + if (!t || (t->type != TOKEN_MULTIPLY && t->type != TOKEN_DIVIDE && + t->type != TOKEN_MODULO)) return 1; + ce->pos++; + long long r; + if (!ce_unary(ce, &r)) return 0; + if (t->type == TOKEN_MULTIPLY) *out *= r; + else if (r == 0) { + if (!ce->fail) { ce->fail = "zero"; ce->fail_at = t; } + return 0; + } else if (t->type == TOKEN_DIVIDE) *out /= r; + else *out %= r; + if (!ce_in_range(ce, *out, t)) return 0; + } +} + +static int ce_add(ConstEval* ce, long long* out) { + if (!ce_mul(ce, out)) return 0; + for (;;) { + Token* t = ce_peek(ce); + if (!t || (t->type != TOKEN_PLUS && t->type != TOKEN_MINUS)) return 1; + ce->pos++; + long long r; + if (!ce_mul(ce, &r)) return 0; + *out = t->type == TOKEN_PLUS ? *out + r : *out - r; + if (!ce_in_range(ce, *out, t)) return 0; + } +} + +static int ce_shift(ConstEval* ce, long long* out) { + if (!ce_add(ce, out)) return 0; + for (;;) { + Token* t = ce_peek(ce); + if (!t || (t->type != TOKEN_LSHIFT && t->type != TOKEN_RSHIFT)) return 1; + ce->pos++; + long long r; + if (!ce_add(ce, &r)) return 0; + /* a shift of a negative value, or by a negative or too-wide count, + * is no length */ + if (*out < 0 || r < 0 || r > 31) { + if (!ce->fail) { ce->fail = "range"; ce->fail_at = t; } + return 0; + } + *out = t->type == TOKEN_LSHIFT ? *out << r : *out >> r; + if (!ce_in_range(ce, *out, t)) return 0; + } +} + +/* #3115: collect the file's integer constants: every top-level + * `const NAME = e` or `const NAME: T = e` whose whole value is an integer + * constant expression. A constant built from a later one is found by + * scanning again, until a pass finds nothing new. */ +static void scan_int_consts(Parser* p) { + p->int_consts_scanned = 1; + int cap = 0; + for (int pass = 0; pass < 16; pass++) { + int found = 0, depth = 0; + for (int i = 0; i < p->token_count; i++) { + Token* t = p->tokens[i]; + if (t->type == TOKEN_LEFT_BRACE) { depth++; continue; } + if (t->type == TOKEN_RIGHT_BRACE) { if (depth > 0) depth--; continue; } + if (depth != 0 || t->type != TOKEN_CONST || i + 2 >= p->token_count) continue; + Token* name = p->tokens[i + 1]; + if (name->type != TOKEN_IDENTIFIER) continue; + long long known; + if (ce_lookup(p, name->value, &known)) continue; + /* skip `: T` to the `=` on the same line */ + int j = i + 2; + while (j < p->token_count && p->tokens[j]->type != TOKEN_ASSIGN && + p->tokens[j]->line == name->line) + j++; + if (j >= p->token_count || p->tokens[j]->type != TOKEN_ASSIGN) continue; + ConstEval ce = { p, j + 1, NULL, NULL }; + long long v; + if (!ce_shift(&ce, &v) || ce.pos == j + 1) continue; + /* the value is all of the expression: what follows starts a new + * statement (a later line, a `;`, the end) */ + Token* last = p->tokens[ce.pos - 1]; + Token* next = ce.pos < p->token_count ? p->tokens[ce.pos] : NULL; + if (next && next->type != TOKEN_EOF && next->type != TOKEN_SEMICOLON && + next->line == last->line) + continue; + if (p->int_const_count == cap) { + cap = cap ? cap * 2 : 16; + ParserIntConst* grown = realloc(p->int_consts, (size_t)cap * sizeof *grown); + if (!grown) return; + p->int_consts = grown; + } + p->int_consts[p->int_const_count].name = name->value; + p->int_consts[p->int_const_count].value = v; + p->int_const_count++; + found = 1; + } + if (!found) return; + } +} + +/* #3115: a fixed-size array's length, after its `[`: an integer constant + * expression of at least 1. Reports and returns 0 for anything else. */ +static int parse_array_length(Parser* parser) { + Token* start = peek_token(parser); + if (!parser->int_consts_scanned) scan_int_consts(parser); + ConstEval ce = { parser, parser->current_token, NULL, NULL }; + long long v = 0; + int ok = ce_shift(&ce, &v); + char msg[256]; + if (!ok || ce.pos == parser->current_token) { + if (ce.fail && strcmp(ce.fail, "zero") == 0) { + parser_error_at(parser, ce.fail_at, "division by zero in an array length"); + } else if (ce.fail && strcmp(ce.fail, "range") == 0) { + snprintf(msg, sizeof msg, "an array length must be from 1 to %d; this one " + "reaches a value outside that", INT_MAX); + parser_error_at(parser, ce.fail_at, msg); + } else if (ce.fail) { + snprintf(msg, sizeof msg, + "array length '%s' is not an integer constant of this file: a length is " + "a number, a `const` of the file holding one, or an expression over those", + ce.fail_at->value); + parser_error_at(parser, ce.fail_at, msg); + } else { + parser_error_at(parser, start, + "an array length is a number, a `const` of the file holding one, or an " + "expression over those (`int[4]`, `long[QWORDS]`, `long[NW + 1]`)"); + } + return 0; + } + if (v < 1 || v > INT_MAX) { + snprintf(msg, sizeof msg, "an array length must be from 1 to %d, but this one is %lld", + INT_MAX, v); + parser_error_at(parser, start, msg); + return 0; + } + parser->current_token = ce.pos; + return (int)v; +} + // #340: a type may carry a postfix `?` making it optional (`int?`, `string?`, // `Vec2?`, `*Node?`). Parse the base type, then wrap once per `?`. In type // position `?` is unambiguous (the actor-ask `?` only appears in expressions). @@ -990,10 +1242,10 @@ static Type* parse_type_unsuffixed(Parser* parser) { // Dynamic array type = create_array_type(type, -1); } else { - // Fixed-size array - Token* size_token = expect_token(parser, TOKEN_NUMBER); - if (size_token) { - int size = atoi(size_token->value); + // Fixed-size array: the length is an integer constant + // expression (#3115), reported here when it is not one. + int size = parse_array_length(parser); + if (size > 0) { if (!expect_token(parser, TOKEN_RIGHT_BRACKET)) return NULL; type = create_array_type(type, size); } @@ -1085,7 +1337,7 @@ static ASTNode* parse_interp_string_expr(const char* raw, int line, int column) int n = sub_tokens[sub_count - 1]->type == TOKEN_EOF ? sub_count - 1 : sub_count; Parser* sub = create_parser(sub_tokens, n); ASTNode* expr_node = parse_expression(sub); - free(sub); + free_parser(sub); /* AST nodes copy a token's text (create_ast_node strdups it) and * keep no reference to the token itself, so these are ours to * release: every `${...}` used to leak its whole token run @@ -3363,7 +3615,7 @@ static ASTNode* parse_statement_inner(Parser* parser) { } // The array form of the same declaration, with an element // type spelled by an identifier: `uint32[4] xs = [...]`, - // `uint16[2] hs`, `Pair[8] ps`. Shape IDENT `[` NUMBER? `]` + // `uint16[2] hs`, `Pair[N + 1] ps`. Shape IDENT `[` length? `]` // IDENT on one line — an index expression statement (`xs[i]`) // is never followed by an identifier on its own line, exactly // as `IDENT IDENT` above; the line check keeps `xs[1]` followed @@ -3372,12 +3624,10 @@ static ASTNode* parse_statement_inner(Parser* parser) { // without this branch `uint32[4] xs` parsed as an index into an // undefined variable `uint32`. if (next && next->type == TOKEN_LEFT_BRACKET) { - Token* t2 = peek_ahead(parser, 2); - int off = (t2 && t2->type == TOKEN_NUMBER) ? 3 : 2; - Token* rb = peek_ahead(parser, off); - Token* nm = peek_ahead(parser, off + 1); - if (rb && rb->type == TOKEN_RIGHT_BRACKET && - nm && nm->type == TOKEN_IDENTIFIER && nm->line == rb->line) { + int rbo = closing_bracket_offset(parser, 1); + Token* rb = rbo > 0 ? peek_ahead(parser, rbo) : NULL; + Token* nm = rbo > 0 ? peek_ahead(parser, rbo + 1) : NULL; + if (rb && nm && nm->type == TOKEN_IDENTIFIER && nm->line == rb->line) { return parse_variable_declaration(parser); } } @@ -5254,12 +5504,11 @@ ASTNode* parse_actor_definition(Parser* parser) { if (t1 && t1->type == TOKEN_IDENTIFIER && t1->line == next_tok->line) { ident_typed = 1; } else if (t1 && t1->type == TOKEN_LEFT_BRACKET) { - Token* t2 = peek_ahead(parser, 2); - int off = (t2 && t2->type == TOKEN_NUMBER) ? 3 : 2; - Token* rb = peek_ahead(parser, off); - Token* nm = peek_ahead(parser, off + 1); - ident_typed = rb && rb->type == TOKEN_RIGHT_BRACKET && - nm && nm->type == TOKEN_IDENTIFIER && nm->line == rb->line; + int rbo = closing_bracket_offset(parser, 1); + Token* rb = rbo > 0 ? peek_ahead(parser, rbo) : NULL; + Token* nm = rbo > 0 ? peek_ahead(parser, rbo + 1) : NULL; + ident_typed = rb && nm && nm->type == TOKEN_IDENTIFIER && + nm->line == rb->line; } } diff --git a/compiler/parser/parser.h b/compiler/parser/parser.h index fb979f1b0..69930bbce 100644 --- a/compiler/parser/parser.h +++ b/compiler/parser/parser.h @@ -34,6 +34,13 @@ typedef struct { // matters too: the guard returns without consuming a // token, so the loop's force-advance would otherwise // emit one error per remaining token. + /* #3115: the file's integer constants, for an array length written as a + * name or an expression (`long[QWORDS]`, `long[NW + 1]`). Scanned once, + * on the first length that needs them, from every top-level + * `const NAME[: T] = `. */ + struct ParserIntConst* int_consts; + int int_const_count; + int int_consts_scanned; } Parser; // The deepest real nesting in this repository is 22 braces and 9 parens, so diff --git a/docs/language-reference.md b/docs/language-reference.md index cd69674c2..0f08db690 100644 --- a/docs/language-reference.md +++ b/docs/language-reference.md @@ -288,6 +288,21 @@ Pair[8] pairs // an array of structs, by value The element type may be any type name: a keyword (`int`, `byte`, `long`), one of the unsigned widths (`uint8`, `uint16`, `uint32`, `uint64`) or a struct. The declared element type is what the C carries (`uint16_t hs[3]`), so a value that fits the width is never widened to `int`. A `uint32` prints as itself through `print`, `println`, a `%u` format and `${x}` interpolation (`4000000000`, not `-294967296`). +**The length is an integer constant expression** (#3115): a number in any base the language reads (`byte[0x40]`, `int[1_024]`), a `const` of the same file that holds one, or `+ - * / % << >>` and parentheses over those. A `const` may be declared anywhere in the file, above or below the array, and may itself be built from other constants. The length is at least 1. A name that is not such a constant, a length below 1 and a division by zero are compile errors at the length. + +```aether,fragment +const QWORDS: int = 128 // lanes per 1024-byte block +const NW = 8 + +struct SkeinCtx { + ks: long[NW + 1] // the extended key: NW words and their parity +} + +fill(mem: long[]) { + long[QWORDS] scratch +} +``` + **Array-to-pointer decay.** A named fixed-size array decays to a pointer to its first element in pointer context, when assigned to an inferred binding, passed as a `ptr`-typed argument, or compared against a pointer (the same rule C uses): ```aether,fragment diff --git a/new_changelogs/20261022T032820Z-fixed-3115-array-length-const.md b/new_changelogs/20261022T032820Z-fixed-3115-array-length-const.md new file mode 100644 index 000000000..b0903aa4f --- /dev/null +++ b/new_changelogs/20261022T032820Z-fixed-3115-array-length-const.md @@ -0,0 +1,8 @@ +- **An array length may be a named constant or an expression over them.** + The parser took only a decimal number and read it with `atoi`, so + `long[QWORDS]` beside `const QWORDS: int = 128` and `long[NW + 1]` were + syntax errors, `byte[0x40]` was an array of length 0, and `int[0]` + compiled as a slice. A length is now an integer constant expression (a + literal in any base, a `const` of the file, `+ - * / % << >>` and + parentheses), at least 1, in every place a type is written, and anything + else is an error that says what a length may be (#3115). diff --git a/tests/integration/array_length_errors/test_array_length_errors.sh b/tests/integration/array_length_errors/test_array_length_errors.sh new file mode 100644 index 000000000..e1e304311 --- /dev/null +++ b/tests/integration/array_length_errors/test_array_length_errors.sh @@ -0,0 +1,63 @@ +#!/bin/sh +# Regression (#3115): an array length that is not an integer constant +# expression of at least 1 is a compile error that says what a length may +# be. Before, any length but a decimal number was "Expected NUMBER, got +# IDENTIFIER", and `int[0]` compiled as a slice. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AETHERC="$ROOT/build/aetherc" +[ -x "$AETHERC" ] || AETHERC="$ROOT/build/aetherc.exe" + +TMPDIR="$(mktemp -d)"; trap 'rm -rf "$TMPDIR"' EXIT +fail=0 + +# expect +expect() { + printf '%s\n' "$3" > "$TMPDIR/$1.ae" + if "$AETHERC" --check "$TMPDIR/$1.ae" > "$TMPDIR/$1.out" 2>&1; then + echo " [FAIL] array_length_errors: $1 compiled" + fail=1 + elif ! grep -qF "$2" "$TMPDIR/$1.out"; then + echo " [FAIL] array_length_errors: $1 did not say: $2" + sed 's/^/ /' "$TMPDIR/$1.out" | head -6 + fail=1 + fi +} + +expect unknown_name "array length 'NOPE' is not an integer constant of this file" \ +'main() { + long[NOPE] xs +}' + +expect float_const "array length 'F' is not an integer constant of this file" \ +'const F = 1.5 +main() { + int[F] xs +}' + +expect zero "an array length must be from 1 to" \ +'main() { + int[0] xs +}' + +expect negative "an array length must be from 1 to" \ +'const N = 2 +main() { + int[N - 5] xs +}' + +expect division_by_zero "division by zero in an array length" \ +'const N = 0 +main() { + int[4 / N] xs +}' + +expect too_large "reaches a value outside that" \ +'struct S { + xs: byte[1 << 40] +} +main() { }' + +[ $fail -eq 0 ] && echo " [PASS] array_length_errors" +exit $fail diff --git a/tests/regression/test_issue3115_array_length_const.ae b/tests/regression/test_issue3115_array_length_const.ae new file mode 100644 index 000000000..414405a9a --- /dev/null +++ b/tests/regression/test_issue3115_array_length_const.ae @@ -0,0 +1,71 @@ +// Regression (#3115): a fixed-size array's length is an integer constant +// expression, not only a decimal number. +// +// The parser took a NUMBER token and read it with atoi, so `long[QWORDS]` +// beside `const QWORDS: int = 128` was "Expected NUMBER, got IDENTIFIER", +// `long[NW + 1]` the same, and `byte[0x10]` an array of length 0. A length +// is now a literal in any base, a `const` of the file holding an integer, or +// `+ - * / % << >>` and parentheses over those, in every place a type is +// written: locals, struct fields, parameters and arrays of structs. + +extern exit(code: int) + +fn check(cond: bool, msg: string) { + if !cond { + println("FAIL #3115: ${msg}") + exit(1) + } +} + +const QWORDS: int = 128 +const NW = 8 +// Built from another const, and from one declared below it. +const PAIRS: int = NW / 2 +const LATER_TWICE = LATER * 2 +const LATER: int = 3 + +struct Pair { + a: int + b: int +} + +struct Ctx { + ks: long[NW + 1] + tw: long[(NW - 5)] + tags: byte[0x10] + bits: int[1 << 3] +} + +fn sum(xs: int[NW]) -> int { + total = 0 + i = 0 + while i < xs.len { + total = total + xs[i] + i = i + 1 + } + return total +} + +main() { + long[QWORDS] lanes + lanes[QWORDS - 1] = 7 + check(lanes.len == 128, "long[QWORDS] has 128 lanes") + check(lanes[127] == 7, "the last lane holds what was stored") + + Pair[PAIRS] ps + ps[PAIRS - 1].a = 5 + check(ps.len == 4, "Pair[PAIRS] has NW / 2 elements") + + int[LATER_TWICE] later + check(later.len == 6, "a const built from one declared after it") + + c = Ctx {} + check(c.ks.len == 9, "a field of NW + 1 words") + check(c.tw.len == 3, "a parenthesized length") + check(c.tags.len == 16, "a hex length is its value, not 0") + check(c.bits.len == 8, "a shifted length") + + xs = [1, 2, 3, 4, 5, 6, 7, 8] + check(sum(xs) == 36, "a parameter typed int[NW]") + println("ok") +} From 70de8745b0c6e343c0b1546666fc08dacd599846 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Thu, 22 Oct 2026 00:43:17 -0300 Subject: [PATCH 22/50] In a module that defines its own free, free(xs) of a slice frees the slice (#3116) The merger renames a module's own calls to its functions before anything is typed, so in a module defining `free(p: ptr)` every `free(x)` became that function. A slice decayed to its pointer and the module read it as its own object: std.bignum's free, given its limbs, read them as a *BN and crashed. The builtin `free`, the one release of a `make` buffer, was out of reach in any module with a `free` API. The merger now marks the call it renames from `free` with one argument (annotation own_free), and the checker, once the argument's type is known, gives a slice to the builtin unless the module's own `free` takes a slice itself. The view refusal of #3072 then applies as anywhere else. Test: tests/integration/module_own_free_slice (a module freeing a slice field and a temporary through its own free(p: ptr), one whose free takes the slice itself, and the view refusal inside such a module). On the base compiler the first crashes and the third is not refused. --- compiler/aether_module.c | 6 + compiler/analysis/typechecker.c | 22 +++ docs/language-reference.md | 2 +- ...34317Z-fixed-3116-module-own-free-slice.md | 7 + .../test_module_own_free_slice.sh | 139 ++++++++++++++++++ 5 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 new_changelogs/20261022T034317Z-fixed-3116-module-own-free-slice.md create mode 100644 tests/integration/module_own_free_slice/test_module_own_free_slice.sh diff --git a/compiler/aether_module.c b/compiler/aether_module.c index 6be9292a8..941a22d44 100644 --- a/compiler/aether_module.c +++ b/compiler/aether_module.c @@ -2811,10 +2811,16 @@ static void rename_intra_module_refs(ASTNode* node, const char* prefix, int renamed = name_in_list(node->value, local_names, local_count); for (int i = 0; !renamed && i < func_count; i++) { if (strcmp(node->value, func_names[i]) == 0) { + /* #3116: the module's own `free` taking one argument. The + * checker gives a slice argument to the builtin instead, + * once its type is known (typecheck_function_call_inner). */ + int own_free = strcmp(node->value, "free") == 0 && + node->child_count == 1 && !node->annotation; char prefixed[256]; merged_function_name_ns(prefix, node->value, prefixed, sizeof(prefixed)); free(node->value); node->value = strdup(prefixed); + if (own_free) node->annotation = strdup("own_free"); renamed = 1; break; } diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index a81fdfc41..09053f18d 100644 --- a/compiler/analysis/typechecker.c +++ b/compiler/analysis/typechecker.c @@ -19204,6 +19204,28 @@ static int typecheck_function_call_inner(ASTNode* call, SymbolTable* table) { if (!call || call->type != AST_FUNCTION_CALL) return 0; note_short_name(call, table); /* #2976: as written, before any rewrite */ + /* #3116: `free(x)` in a module that defines its own `free`, which the + * merger renamed to that function. A slice argument is freed by the + * builtin, the one release of a `make` buffer: the module's function can + * only read the slice's pointer as its own object, and named alike it + * left the builtin out of reach in the module. Unless the function itself + * takes a slice, which is then its own business. */ + if (call->annotation && strcmp(call->annotation, "own_free") == 0) { + free(call->annotation); + call->annotation = NULL; + Type* at = call->child_count == 1 ? infer_type(call->children[0], table) : NULL; + int slice_arg = type_is_slice(at); + if (at) free_type(at); + if (slice_arg) { + Symbol* fs = lookup_symbol(table, call->value); + ASTNode* p0 = (fs && is_definition(fs->node)) ? def_param_at(fs->node, 0) : NULL; + if (!(p0 && type_is_slice(p0->node_type))) { + free(call->value); + call->value = strdup("free"); + } + } + } + /* #928 UFCS, shape (a): the parser tagged this as a method-call on a * non-identifier receiver (e.g. `expect(5).to_eq(...)`) and stashed the * receiver subtree at children[0]. Rewrite to `method(recv, args)` up diff --git a/docs/language-reference.md b/docs/language-reference.md index 0f08db690..fcc2d72a5 100644 --- a/docs/language-reference.md +++ b/docs/language-reference.md @@ -415,7 +415,7 @@ main() { - **A `T[N]` array converts to `T[]` implicitly** wherever a slice is expected: a parameter, a `T[]`-typed binding or field, a `return` from a `-> T[]` function. The length travels with it. `null`, and the empty literal `[]`, convert to the empty slice: `.len` is `0`, it views no storage, and it compares equal to `null`. So `s == []` is `s == null` (it asks whether `s` views any storage; test `s.len == 0` for emptiness), a C function's `T[]` parameter takes `[]` as the null pointer, and an if-expression or a `match` given to a `T[]` converts each arm on its own. A module-level `var` or `const` of type `T[]` takes either as its initial value. - **A string is not a slice.** Passing one where a `T[]` is expected (a parameter, a binding, a `return`) is a type error; `string.bytes(s)` is the bounded, read-only `byte[]` view of a string's bytes, and it decays to their pointer at a C extern like any slice. Reading it costs what any slice's read does; a store through it (an element store, `+=`, `++`), or through any view made from it (a sub-slice, a pattern's tail), in this function or one it is given to, panics with "a write through a read-only view of a string", and one the checker can see through a local bound to it is a compile error (E0200) (#3025). A string's own bytes are read-only too: `s[i]` reads one, and a write (`s[i] = v`, `s[i] += v`, `s[i]++`) is a compile error (E0200). Build the new text as a new string, or work on the bytes in a `byte[N]` array of your own. - **A slice decays to its pointer** wherever a `ptr` is expected, exactly as a `T[N]` array does: a `ptr` parameter, a C extern's parameter (including one declared `T[]`), a comparison against `null`, and pointer arithmetic (`s + n` is a `ptr` to element `n`). `s as ptr` spells the same conversion explicitly, for a `ptr` return or binding. The length is dropped at that boundary; nothing else about the extern ABI changes. -- **`make([]T, n)`** allocates `n` zeroed elements and returns them as a bounded slice; `free(s)` releases them. Only the slice that holds such a buffer, or a view of a raw pointer the program owns from its start (`(p as T[])[0..n]`), may be freed: a view owns nothing. `free` of a sub-slice (`b[2..4]`, `b[1..]`, a list pattern's tail), of an array viewed as a slice, of `string.bytes(s)`, or of an array `T[N]`, is a compile error where the checker sees it (a local bound once to one, or the expression itself), and a named panic at run time anywhere else (`free of a view of elements another slice or an array holds, which owns nothing`). Before, the allocator was given a pointer it never handed out. +- **`make([]T, n)`** allocates `n` zeroed elements and returns them as a bounded slice; `free(s)` releases them. Only the slice that holds such a buffer, or a view of a raw pointer the program owns from its start (`(p as T[])[0..n]`), may be freed: a view owns nothing. `free` of a sub-slice (`b[2..4]`, `b[1..]`, a list pattern's tail), of an array viewed as a slice, of `string.bytes(s)`, or of an array `T[N]`, is a compile error where the checker sees it (a local bound once to one, or the expression itself), and a named panic at run time anywhere else (`free of a view of elements another slice or an array holds, which owns nothing`). Before, the allocator was given a pointer it never handed out. In a module that defines a `free` of its own (`bignum.free`, `json.free`), `free(s)` of a slice is still this release, unless the module's `free` itself takes a slice: the module's function would only see the slice's pointer (#3116). - A slice does not own the storage it views. It is valid for exactly as long as that storage: an array in an enclosing scope, a `make` buffer until its `free`, a C buffer until C reclaims it. The elements are the storage's: a string read out of a `string[]` slice (`t = s[i]`, a return, a field) takes a reference of its own, and a store into an element (`s[i] = v`) takes the new string and releases the one it replaces, as a store into the array itself does. Memory C owns, viewed as `p as string[]`, owns nothing: a store there gives C the characters and takes no reference ([memory-management.md](memory-management.md#ptr-slots)). - **A slice of a function's own storage stays in the function** (E0900). An array literal in a slice slot, and a function's own fixed-size array, live in its frame, which ends when the function returns. A slice of either may be used while the function runs and passed down to the functions it calls, but it may not leave the function: returned (alone, in a struct or tuple, or in a closure that captured it), stored in a module-level `var`, or stored in a field of something that may outlive the function: a struct behind a pointer, or a global (a struct value of the function's own may take it, and is then itself such a slice). Each of those is refused at the value that leaves, `'lit' returns a slice of its own storage (an array literal)`. Keep such a slice in storage that lives as long as it: a `make([]T, n)` buffer, a slice the caller passed in, or a module-level array. A slice parameter (`xs: int[]`) views the caller's storage and may be returned; a fixed-size array parameter (`a: int[3]`) is the function's own copy of the caller's array, so a slice of it is a slice of the function's storage. What a name holds where it leaves is what counts, path by path: a local or a field of a local struct re-bound to a `make` buffer before the `return` holds no slice of the frame any more, but one that still holds it on any path to the `return` (an `if` that re-binds it in one branch only, a loop that may `break` before the re-bind) is refused. A call of a closure the function binds (`reset()` after `reset = || { v = make([]int, 4) }`) counts what the closure re-binds for sure: a name of the function it binds on every way through its body before any `return` (both branches of an `if`, every arm of a `match` that covers its value, not a loop's body) holds what the closure gave it after the call. The name is the function's own when the function binds it ahead of the closure, in a block around it, and the closure does not declare one of its own first; a closure bound more than once, or called before it is bound, re-binds nothing that counts. A `try` around such a call starts its handler from what held a slice before any statement of its body ran, or after any. The other way is counted, conservatively: a closure that may store a slice of the function's storage into one of its names (`setview = || { v = arr }`) gives that name a slice from where the closure is written on, whether and whenever it is called, and a store a closure makes through a pointer or into a module-level `var` is judged as the same store written in the function. What the closure stores is judged as the function's own stores are: a fixed-size array of the function or an array literal, a name that holds such a slice at some point of the function, a local of the closure bound to one, or a parameter of the closure it is called with one for (any parameter that can hold a slice, when the closure is passed on or stored). A slice parameter of the function, a `make` buffer, a module-level `var` or `[]` stored by a closure leaves the name as it was. A call's result is taken to be no view of the caller, so a function that hands back the slice it was given is not seen through, in a closure (`v = same(arr)`) or not. diff --git a/new_changelogs/20261022T034317Z-fixed-3116-module-own-free-slice.md b/new_changelogs/20261022T034317Z-fixed-3116-module-own-free-slice.md new file mode 100644 index 000000000..92d35f22d --- /dev/null +++ b/new_changelogs/20261022T034317Z-fixed-3116-module-own-free-slice.md @@ -0,0 +1,7 @@ +- **A module that defines its own `free` can free a slice.** The merger + renames a module's calls to its own functions, so in a module defining + `free(p: ptr)` (`bignum.free`, `json.free`) every `free(xs)` called that + function: a slice decayed to its pointer, the module read it as its own + object and crashed, and the builtin that releases a `make` buffer was + out of reach. A slice argument now goes to the builtin unless the + module's `free` takes a slice itself (#3116). diff --git a/tests/integration/module_own_free_slice/test_module_own_free_slice.sh b/tests/integration/module_own_free_slice/test_module_own_free_slice.sh new file mode 100644 index 000000000..c4055af4b --- /dev/null +++ b/tests/integration/module_own_free_slice/test_module_own_free_slice.sh @@ -0,0 +1,139 @@ +#!/bin/sh +# Regression (#3116): in a module that defines its own `free`, `free(xs)` of +# a slice frees the slice with the builtin. +# +# The merger renames a module's own calls to its functions, so in a module +# defining `free(p: ptr)` (bignum.free, json.free) every `free(x)` was that +# function: a slice decayed to its pointer and the module read it as its own +# object, and the builtin, the one release of a `make` buffer, was out of +# reach. Now a slice argument goes to the builtin unless the module's own +# `free` takes a slice itself. +# +# 1. holder: free(p: ptr) frees a slice field and a temporary slice; the +# program runs and every value is right. +# 2. takes_slice: the module's free(xs: int[]) takes the slice, so its own +# body runs for it. +# 3. frees_view: `free` of a sub-slice in such a module is refused, as +# everywhere else (#3072). + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +[ -x "$AE" ] || AE="$ROOT/build/ae.exe" + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +fail=0 + +cat > "$tmp/holder.ae" <<'AE' +exports(new, total, free) + +struct Holder { + xs: int[] +} + +new(n: int) -> ptr { + h = heap.new(Holder) + h.xs = make([]int, n) + i = 0 + while i < n { + h.xs[i] = i + 1 + i = i + 1 + } + return h as ptr +} + +// Twice the sum, through a temporary slice the module frees. +total(p: ptr) -> int { + h = p as *Holder + tmp = make([]int, h.xs.len) + sum = 0 + i = 0 + while i < h.xs.len { + tmp[i] = h.xs[i] * 2 + sum = sum + tmp[i] + i = i + 1 + } + free(tmp) + return sum +} + +// The module's own free: a Holder, whose slice field the builtin frees. +free(p: ptr) { + h = p as *Holder + free(h.xs) + heap.free(h) +} +AE + +cat > "$tmp/takes_slice.ae" <<'AE' +exports(ints, free, freed) + +var released = 0 + +ints(n: int) -> int[] { + return make([]int, n) +} + +// This module's free takes the slice itself, so a slice is its business. +free(xs: int[]) { + released = released + xs.len + heap.free(xs as ptr) +} + +freed() -> int { + return released +} +AE + +cat > "$tmp/main.ae" <<'AE' +import holder +import takes_slice + +main() { + h = holder.new(4) + t = holder.total(h) + holder.free(h) + xs = takes_slice.ints(3) + takes_slice.free(xs) + println("total=${t} freed=${takes_slice.freed()}") +} +AE + +out="$(cd "$tmp" && AETHER_HOME="$ROOT" "$AE" run main.ae 2>&1 | tr -d '\r')" +if ! printf '%s\n' "$out" | grep -Fxq "total=20 freed=3"; then + echo " [FAIL] module_own_free_slice: expected 'total=20 freed=3'" + printf '%s\n' "$out" | sed 's/^/ /' | head -12 + fail=1 +fi + +cat > "$tmp/frees_view.ae" <<'AE' +exports(drop, free) + +drop(xs: int[]) { + free(xs[1..]) +} + +free(p: ptr) { + heap.free(p) +} +AE + +cat > "$tmp/main_view.ae" <<'AE' +import frees_view + +main() { + xs = make([]int, 4) + frees_view.drop(xs) +} +AE + +out="$(cd "$tmp" && AETHER_HOME="$ROOT" "$AE" build main_view.ae -o main_view 2>&1 | tr -d '\r')" +if ! printf '%s\n' "$out" | grep -q "which owns nothing"; then + echo " [FAIL] module_own_free_slice: a free of a view in such a module was not refused" + printf '%s\n' "$out" | sed 's/^/ /' | head -12 + fail=1 +fi + +[ $fail -eq 0 ] && echo " [PASS] module_own_free_slice" +exit $fail From 3509c2ac86fc0af451bc907ee3eac90f16c63e00 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Thu, 22 Oct 2026 01:03:38 -0300 Subject: [PATCH 23/50] std.bignum and the cryptography modules hold their buffers as arrays and slices (#3107) They kept limbs, message schedules, states and round keys in std.intarr and std.longarr handles and read them through those modules' raw C bindings (intarr_get_raw, longarr_get_unchecked, ...), 164 calls in bignum alone. - Fixed-size buffers are fixed-size array fields or locals: sha1's and sha2's schedules, sha3's lanes, sm3's expansion, the ripemds' and tiger's words, sparkle's state, whirlpool's and skein's state and keys (`long[NW + 1]`, #3115), dstu7564's states, sm4's and des3's round keys, blake2's and chacha's working vectors. Helpers that took the handle take a slice (`long[]`) of the array. - des3 fills each working key in place (generate_working_key(out, ...)), so no array is returned by value. - Argon2's 128-lane scratch blocks are a `struct Block { w: long[QWORDS] }` passed by pointer, so every index is checked against the constant and gcc drops the check where the loop bounds it. Its memory matrix is a calloc'd view `(p as long[])[0..lanes]`, so a cost the machine cannot give is still a null result, not a panic. - ML-KEM's polynomials are `make([]int, N)` slices; a matrix row is a sub-slice of the matrix, not a copy. - bignum's magnitudes are `int[]` slices, freed by the builtin (#3116), and its Miller-Rabin witnesses a const table. bignum.test_bit(n, i) reads a bit in place and replaces the exported limb(), which p256 used with the BN struct's layout to walk scalar bits. The to_bytes comments that described three return formats describe the one it has. Measured on Windows, gcc -O2, best of four interleaved rounds of a throughput benchmark against the same compiler (with #3113's checks): 2048-bit mod_pow 13755 -> 37647 per 1000 s (2.7x), ML-KEM-768 keygen+encaps +9.8%, SHA3-256 +15.4%, BLAKE2b +13.9%, BLAKE2s +6.9%, 3DES +23.1%, Streebog +15.1%, Kupyna +5.7%, Skein +4.8%, Argon2id +2.6%, SHA-1, SHA-512, the RIPEMDs, Whirlpool, SM4 and ChaCha20 within +-2.5%. Eight more rounds of the four that came out below: SM3 +0.5%, SHA-256 +0.5%, Esch256 -0.5% (noise), Tiger -2.3% (one index check per 8 bytes, in its byte-at-a-time feed). The bignum results are identical; the crypto, curve, RSA, TLS and Wycheproof suites pass (45 tests). --- docs/api/bignum.html | 20 +- docs/stdlib-reference.md | 1 + ...T050000Z-performance-3107-crypto-arrays.md | 13 + std/bignum/module.ae | 423 +++++++++--------- std/cryptography/argon2/module.ae | 129 +++--- std/cryptography/blake2/module.ae | 124 +++-- std/cryptography/chacha20poly1305/module.ae | 48 +- std/cryptography/des3/module.ae | 92 ++-- std/cryptography/dstu7564/module.ae | 159 ++++--- std/cryptography/gost3411_2012/module.ae | 8 +- std/cryptography/mlkem/module.ae | 248 +++++----- std/cryptography/p256/module.ae | 27 +- std/cryptography/ripemd128/module.ae | 12 +- std/cryptography/ripemd160/module.ae | 12 +- std/cryptography/ripemd256/module.ae | 12 +- std/cryptography/ripemd320/module.ae | 12 +- std/cryptography/sha1/module.ae | 61 ++- std/cryptography/sha2/module.ae | 38 +- std/cryptography/sha3/module.ae | 80 ++-- std/cryptography/skein/module.ae | 124 ++--- std/cryptography/sm3/module.ae | 15 +- std/cryptography/sm4/module.ae | 28 +- std/cryptography/sparkle/module.ae | 151 +++---- std/cryptography/tiger/module.ae | 14 +- std/cryptography/whirlpool/module.ae | 123 ++--- 25 files changed, 869 insertions(+), 1105 deletions(-) create mode 100644 new_changelogs/20261022T050000Z-performance-3107-crypto-arrays.md diff --git a/docs/api/bignum.html b/docs/api/bignum.html index c743a2c7d..28aa9331c 100644 --- a/docs/api/bignum.html +++ b/docs/api/bignum.html @@ -43,7 +43,7 @@

    Exports

  • from_decimal
  • free
  • clone
  • -
  • limb
  • +
  • test_bit

  • Modules

    @@ -161,12 +161,13 @@

    from_bytes_unsigned fn

    to_bytes fn

    to_bytes(n) -> ptr

    n: ptr

    -

    Signed two's-complement big-endian. Returns a std.intarr where slot i holds byte i (0..255); the byte count is the intarr's size. Callers read with intarr_get_raw and mask & 0xFF. NOTE: returns the bytes in an intarr-of-bytes; the test harness reads them back through bignum.* helpers. To keep the public surface a plain `ptr` of raw bytes, we copy into a malloc'd byte buffer.

    +

    The minimal two's-complement big-endian encoding of n (BigInteger's toByteArray), as a std.bytes the caller frees with bytes.free.

    to_bytes_unsigned fn

    to_bytes_unsigned(n) -> ptr

    n: ptr

    +

    The big-endian bytes of |n| with no sign byte, as a std.bytes the caller frees with bytes.free.

    from_int fn

    @@ -290,8 +291,9 @@

    from_decimal fn

    free fn

    -
    free(r)
    -

    r

    +
    free(mag)
    +

    mag

    +

    all zero

    clone fn

    @@ -299,11 +301,11 @@

    clone fn

    n: ptr

    internal: deep clone

    -
    -

    limb fn

    -
    limb(mag, i) -> long
    -

    mag: ptr, i: int

    -

    Read limb i of a magnitude as an unsigned 32-bit value in a 64-bit int.

    +
    +

    test_bit fn

    +
    test_bit(n, i) -> int
    +

    n: ptr, i: int

    +

    Bit `i` of the magnitude |n|, 0 the least significant: 1 or 0, read from the limb in place with no allocation (BigInteger.testBit for n >= 0). What a scalar multiplication walks bit by bit.

    diff --git a/docs/stdlib-reference.md b/docs/stdlib-reference.md index 34cde2bad..b3610a5e9 100644 --- a/docs/stdlib-reference.md +++ b/docs/stdlib-reference.md @@ -3371,6 +3371,7 @@ compare 1 sign 1 bits 127 - `bignum.add` / `subtract` / `multiply` / `divide` / `remainder` / `mod` `(a, b)` → `ptr` - Arithmetic - `bignum.negate(a)` / `bignum.abs(a)` → `ptr`, `bignum.shift_left(a, n)` / `shift_right(a, n)` → `ptr` - `bignum.compare(a, b)` → `int`, `bignum.is_zero(a)` / `bignum.sign(a)` / `bignum.bit_length(a)` → `int` +- `bignum.test_bit(a, i)` → `int` - Bit `i` of `|a|` (0 the least significant), read in place with no allocation: what a scalar multiplication walks - `bignum.mod_pow(base, exp, m)` → `ptr` - Modular exponentiation, without forming `base^exp` - `bignum.gcd(a, b)` / `bignum.mod_inverse(a, m)` → `ptr`, `bignum.is_probable_prime(n, rounds)` → `int` - `bignum.free(n)` - Release a handle diff --git a/new_changelogs/20261022T050000Z-performance-3107-crypto-arrays.md b/new_changelogs/20261022T050000Z-performance-3107-crypto-arrays.md new file mode 100644 index 000000000..621b584c5 --- /dev/null +++ b/new_changelogs/20261022T050000Z-performance-3107-crypto-arrays.md @@ -0,0 +1,13 @@ +- **std.bignum and the cryptography modules hold their buffers as arrays and + slices, and bignum's modular exponentiation is 2.7x faster.** They kept + their limbs, message schedules, states and round keys in std.intarr and + std.longarr handles, read through another module's raw C bindings. The + fixed-size buffers are now fixed-size array fields and locals (a SHA-2 + schedule is a `long[80]`, an Argon2 block a `long[QWORDS]` struct), the + sized ones `make([]int, n)` slices, so every index is checked. Measured on + Windows, gcc -O2, best of four interleaved rounds against the same + compiler: a 2048-bit `mod_pow` 2.7x faster, ML-KEM-768 +10%, SHA-3 +15%, + BLAKE2b +14%, 3DES +23%, Streebog +15%, Argon2 +3%, the other hashes and + ciphers within noise, and Tiger -2% (one index check per 8 bytes it + feeds). `bignum.test_bit(n, i)` reads one bit in place; p256's scalar + multiplication read it through the BN struct's layout before (#3107). diff --git a/std/bignum/module.ae b/std/bignum/module.ae index 3b87972f8..3596878c7 100644 --- a/std/bignum/module.ae +++ b/std/bignum/module.ae @@ -12,10 +12,10 @@ // Zero is represented canonically as sign==0 with an empty (len==0) // magnitude (BC's ZeroMagnitude). // -// Limbs are unsigned 32-bit quantities stored in a std.intarr. Aether's -// `int` is 64-bit and `intarr` stores 32-bit slots that sign-extend on -// read, so every limb read is masked with `& 0xFFFFFFFF` before use, and -// limb arithmetic is done in 64-bit with explicit lo32/hi32 carry split. +// Limbs are unsigned 32-bit quantities stored in an `int[]`. An Aether +// `int` is 32-bit and signed, so a limb sign-extends when it is widened: +// every limb read is masked with `& 0xFFFFFFFF`, and limb arithmetic is +// done in 64-bit with an explicit lo32/hi32 carry split. // Logical shifts route through std.bits (lsr32 / lsr64) since Aether `>>` // is arithmetic. // @@ -37,7 +37,6 @@ // return NEW bignums and never mutate their inputs. There is no GC; // callers free results with bignum.free. -import std.intarr import std.bits import std.strbuilder import std.bytes @@ -52,7 +51,7 @@ exports( mod_pow, gcd, mod_inverse, is_probable_prime, shift_left, shift_right, to_hex, to_decimal, from_decimal, free, - clone, limb + clone, test_bit ) // libc allocator — whitelisted externs, no shim. The struct is three @@ -68,14 +67,14 @@ extern malloc(size: int) -> ptr struct BN { sign: int // -1, 0, or 1 - mag: ptr // std.intarr of 32-bit limbs, big-endian ([0] = MSB), or null + mag: int[] // 32-bit limbs, big-endian ([0] = MSB); empty for zero len: int // number of limbs; 0 for zero } // ---- internal construction helpers ---- // Allocate a BN. `mag` may be null (only valid with len 0 / sign 0). -bn_alloc(s: int, mag: @keep ptr, len: int) -> *BN { +bn_alloc(s: int, mag: @keep int[], len: int) -> *BN { n = malloc(sizeof(BN)) as *BN n.sign = s n.mag = mag @@ -89,8 +88,8 @@ bn_zero() -> *BN { } // Read limb i of a magnitude as an unsigned 32-bit value in a 64-bit int. -limb(mag: ptr, i: int) -> long { - return intarr_get_raw(mag, i) & 0xFFFFFFFF +limb(mag: int[], i: int) -> long { + return mag[i] & 0xFFFFFFFF } // Build a BN from a freshly-built magnitude array, stripping leading @@ -98,17 +97,17 @@ limb(mag: ptr, i: int) -> long { // when no stripping is needed; otherwise copies into a tight array and // frees the original. `signum` is the intended sign for a non-zero // result. -bn_from_mag_checked(signum: int, mag: @keep ptr, len: int) -> *BN { +bn_from_mag_checked(signum: int, mag: @keep int[], len: int) -> *BN { i = 0 while i < len { - if (intarr_get_raw(mag, i) & 0xFFFFFFFF) != 0 { + if (mag[i] & 0xFFFFFFFF) != 0 { break } i = i + 1 } if i == len { // all zero - intarr_free(mag) + free(mag) return bn_zero() } if i == 0 { @@ -117,22 +116,22 @@ bn_from_mag_checked(signum: int, mag: @keep ptr, len: int) -> *BN { } // copy the significant tail into a tight array newlen = len - i - nm = intarr_new_raw(newlen) + nm = make([]int, newlen) j = 0 while j < newlen { - intarr_set_raw(nm, j, intarr_get_raw(mag, i + j)) + nm[j] = mag[i + j] j = j + 1 } - intarr_free(mag) + free(mag) return bn_alloc(signum, nm, newlen) } // Clone a magnitude array of `len` limbs into a new one. -mag_clone(mag: ptr, len: int) -> ptr { - nm = intarr_new_raw(len) +mag_clone(mag: int[], len: int) -> int[] { + nm = make([]int, len) i = 0 while i < len { - intarr_set_raw(nm, i, intarr_get_raw(mag, i)) + nm[i] = mag[i] i = i + 1 } return nm @@ -143,7 +142,7 @@ mag_clone(mag: ptr, len: int) -> ptr { // Build a magnitude (big-endian limbs) from a byte span [off, off+length), // stripping leading zero bytes. Returns (mag, len); len 0 / null mag for // all-zero input. -make_magnitude_be(b: ptr, off: int, length: int) -> (ptr, int) { +make_magnitude_be(b: ptr, off: int, length: int) -> (int[], int) { e = off + length // strip leading zero bytes start = off @@ -158,7 +157,7 @@ make_magnitude_be(b: ptr, off: int, length: int) -> (ptr, int) { return null, 0 } nints = (nbytes + 3) / 4 - mag = intarr_new_raw(nints) + mag = make([]int, nints) // first limb takes the leading partial group first = ((nbytes - 1) % 4) + 1 v = 0 @@ -167,7 +166,7 @@ make_magnitude_be(b: ptr, off: int, length: int) -> (ptr, int) { v = (v << 8) | (mem_get_byte(b, start + k) & 0xFF) k = k + 1 } - intarr_set_raw(mag, 0, v & 0xFFFFFFFF) + mag[0] = v & 0xFFFFFFFF // remaining limbs are full 4-byte groups pos = start + first li = 1 @@ -178,7 +177,7 @@ make_magnitude_be(b: ptr, off: int, length: int) -> (ptr, int) { w = (w << 8) | (mem_get_byte(b, pos + j) & 0xFF) j = j + 1 } - intarr_set_raw(mag, li, w & 0xFFFFFFFF) + mag[li] = w & 0xFFFFFFFF pos = pos + 4 li = li + 1 } @@ -220,18 +219,18 @@ from_bytes(b: ptr, length: int) -> ptr { } if ib >= e { // all 0xFF -> value is -1 - m = intarr_new_raw(1) - intarr_set_raw(m, 0, 1) + m = make([]int, 1) + m[0] = 1 return bn_alloc(-1, m, 1) } if (mem_get_byte(b, ib) & 0xFF) == 0x00 { ib = ib - 1 } numbytes = e - ib - // build the inverted+1 (negated) byte buffer into a temp intarr-as-bytes + // build the inverted+1 (negated) bytes into a temporary int[], one per slot // We compute the magnitude bytes directly: inverse = ~byte, then +1 // with carry propagation from the least-significant end. - inv = intarr_new_raw(numbytes) // one byte per slot + inv = make([]int, numbytes) // one byte per slot idx = 0 src = ib while idx < numbytes { @@ -254,7 +253,7 @@ from_bytes(b: ptr, length: int) -> ptr { } // make magnitude from these bytes (big-endian) mag, len = make_magnitude_from_bytearr(inv, numbytes) - intarr_free(inv) + free(inv) if len == 0 { return bn_zero() } @@ -262,15 +261,15 @@ from_bytes(b: ptr, length: int) -> ptr { } // byte stored one-per-int-slot helpers (for the negation scratch buffer) -inv_get_byte(a: ptr, i: int) -> int { - return intarr_get_raw(a, i) & 0xFF +inv_get_byte(a: int[], i: int) -> int { + return a[i] & 0xFF } -inv_set_byte(a: ptr, i: int, v: int) { - intarr_set_raw(a, i, v & 0xFF) +inv_set_byte(a: int[], i: int, v: int) { + a[i] = v & 0xFF } -// make_magnitude_be variant that reads from an intarr-of-bytes (big-endian) -make_magnitude_from_bytearr(a: ptr, length: int) -> (ptr, int) { +// make_magnitude_be variant that reads big-endian bytes held one per int +make_magnitude_from_bytearr(a: int[], length: int) -> (int[], int) { start = 0 while start < length { if inv_get_byte(a, start) != 0 { @@ -283,7 +282,7 @@ make_magnitude_from_bytearr(a: ptr, length: int) -> (ptr, int) { return null, 0 } nints = (nbytes + 3) / 4 - mag = intarr_new_raw(nints) + mag = make([]int, nints) first = ((nbytes - 1) % 4) + 1 v = 0 k = 0 @@ -291,7 +290,7 @@ make_magnitude_from_bytearr(a: ptr, length: int) -> (ptr, int) { v = (v << 8) | inv_get_byte(a, start + k) k = k + 1 } - intarr_set_raw(mag, 0, v & 0xFFFFFFFF) + mag[0] = v & 0xFFFFFFFF pos = start + first li = 1 while li < nints { @@ -301,7 +300,7 @@ make_magnitude_from_bytearr(a: ptr, length: int) -> (ptr, int) { w = (w << 8) | inv_get_byte(a, pos + j) j = j + 1 } - intarr_set_raw(mag, li, w & 0xFFFFFFFF) + mag[li] = w & 0xFFFFFFFF pos = pos + 4 li = li + 1 } @@ -340,13 +339,13 @@ from_int(v: long) -> ptr { hi = bits.lsr64(umag, 32) & 0xFFFFFFFF lo = umag & 0xFFFFFFFF if hi != 0 { - mag = intarr_new_raw(2) - intarr_set_raw(mag, 0, hi & 0xFFFFFFFF) - intarr_set_raw(mag, 1, lo & 0xFFFFFFFF) + mag = make([]int, 2) + mag[0] = hi & 0xFFFFFFFF + mag[1] = lo & 0xFFFFFFFF return bn_alloc(s, mag, 2) } - mag = intarr_new_raw(1) - intarr_set_raw(mag, 0, lo & 0xFFFFFFFF) + mag = make([]int, 1) + mag[0] = lo & 0xFFFFFFFF return bn_alloc(s, mag, 1) } @@ -377,7 +376,7 @@ bitlen32(v: long) -> int { // Bit length of a magnitude (unsigned), no sign adjustment. Position of // the highest set bit + 1. Returns 0 for an all-zero / null magnitude. -mag_bit_length(mag: ptr, len: int) -> int { +mag_bit_length(mag: int[], len: int) -> int { indx = 0 while indx < len { if limb(mag, indx) != 0 { @@ -391,6 +390,15 @@ mag_bit_length(mag: ptr, len: int) -> int { return 32 * ((len - indx) - 1) + bitlen32(limb(mag, indx)) } +// Bit `i` of the magnitude |n|, 0 the least significant: 1 or 0, read from +// the limb in place with no allocation (BigInteger.testBit for n >= 0). What +// a scalar multiplication walks bit by bit. +test_bit(n: ptr, i: int) -> int { + bn = n as *BN + if bn.len <= 0 || i < 0 { return 0 } + return mag_test_bit(bn.mag, bn.len, i) +} + // BC CalcBitLength, including the negative power-of-two adjustment. bit_length(n: ptr) -> int { bn = n as *BN @@ -439,7 +447,7 @@ bit_length(n: ptr) -> int { // Unsigned compare of two magnitudes (no leading zeros assumed equal-ish; // BC CompareNoLeadingZeros operates on whole arrays from index 0). -compare_mag(x: ptr, xlen: int, y: ptr, ylen: int) -> int { +compare_mag(x: int[], xlen: int, y: int[], ylen: int) -> int { if xlen != ylen { if xlen < ylen { return -1 @@ -480,19 +488,19 @@ compare(a: ptr, b: ptr) -> int { // Returns a freshly allocated magnitude (mag, len) = x + y (unsigned), // big-endian. Result may be one limb longer than max(xlen, ylen). -mag_add(x: ptr, xlen: int, y: ptr, ylen: int) -> (ptr, int) { +mag_add(x: int[], xlen: int, y: int[], ylen: int) -> (int[], int) { // ensure x is the longer if xlen < ylen { return mag_add(y, ylen, x, xlen) } - res = intarr_new_raw(xlen + 1) + res = make([]int, xlen + 1) long m = 0 ti = xlen - 1 vi = ylen - 1 ri = xlen // result index (xlen+1 slots, [0..xlen]) while vi >= 0 { m = m + limb(x, ti) + limb(y, vi) - intarr_set_raw(res, ri, (m & 0xFFFFFFFF)) + res[ri] = (m & 0xFFFFFFFF) m = bits.lsr64(m, 32) ti = ti - 1 vi = vi - 1 @@ -500,26 +508,26 @@ mag_add(x: ptr, xlen: int, y: ptr, ylen: int) -> (ptr, int) { } while ti >= 0 { m = m + limb(x, ti) - intarr_set_raw(res, ri, (m & 0xFFFFFFFF)) + res[ri] = (m & 0xFFFFFFFF) m = bits.lsr64(m, 32) ti = ti - 1 ri = ri - 1 } // final carry into the top slot (index 0) - intarr_set_raw(res, 0, (m & 0xFFFFFFFF)) + res[0] = (m & 0xFFFFFFFF) return res, xlen + 1 } // Returns (mag, len) = x - y (unsigned), assuming x >= y. Result has xlen // limbs (caller strips leading zeros via checked construction). -mag_sub(x: ptr, xlen: int, y: ptr, ylen: int) -> (ptr, int) { - res = intarr_new_raw(xlen) +mag_sub(x: int[], xlen: int, y: int[], ylen: int) -> (int[], int) { + res = make([]int, xlen) borrow = 0 ti = xlen - 1 vi = ylen - 1 while vi >= 0 { diff = limb(x, ti) - limb(y, vi) + borrow - intarr_set_raw(res, ti, (diff & 0xFFFFFFFF)) + res[ti] = (diff & 0xFFFFFFFF) // borrow is -1 if diff went negative, else 0 if diff < 0 { borrow = -1 @@ -531,7 +539,7 @@ mag_sub(x: ptr, xlen: int, y: ptr, ylen: int) -> (ptr, int) { } while ti >= 0 { diff = limb(x, ti) + borrow - intarr_set_raw(res, ti, (diff & 0xFFFFFFFF)) + res[ti] = (diff & 0xFFFFFFFF) if diff < 0 { borrow = -1 } else { @@ -628,7 +636,7 @@ clone(n: ptr) -> ptr { // Schoolbook multiply: accumulates y*z into the freshly-zeroed `x` // (big-endian limbs, length y.len + z.len). Faithful port of BC's // Multiply(x, y, z) where x is the result buffer. Mutates `x` in place. -mag_multiply(x: ptr, xlen: int, y: ptr, ylen: int, z: ptr, zlen: int) { +mag_multiply(x: int[], xlen: int, y: int[], ylen: int, z: int[], zlen: int) { i = zlen xbase = xlen - ylen while i > 0 { @@ -639,14 +647,14 @@ mag_multiply(x: ptr, xlen: int, y: ptr, ylen: int, z: ptr, zlen: int) { j = ylen - 1 while j >= 0 { val = val + a * limb(y, j) + limb(x, xbase + j) - intarr_set_raw(x, xbase + j, (val & 0xFFFFFFFF)) + x[xbase + j] = (val & 0xFFFFFFFF) val = bits.lsr64(val, 32) j = j - 1 } } xbase = xbase - 1 if xbase >= 0 { - intarr_set_raw(x, xbase, (val & 0xFFFFFFFF)) + x[xbase] = (val & 0xFFFFFFFF) } } } @@ -672,11 +680,11 @@ karatsuba_threshold() -> int { // Convert a big-endian magnitude (mag[0]=MSB) of `len` limbs into a fresh // little-endian limb array (out[0]=LSB) of `len` limbs. -le_from_mag(mag: ptr, len: int) -> ptr { - out = intarr_new_raw(len) +le_from_mag(mag: int[], len: int) -> int[] { + out = make([]int, len) i = 0 while i < len { - intarr_set_raw(out, i, intarr_get_raw(mag, len - 1 - i)) + out[i] = mag[len - 1 - i] i = i + 1 } return out @@ -684,30 +692,30 @@ le_from_mag(mag: ptr, len: int) -> ptr { // Convert a little-endian limb array (in[0]=LSB) of `len` limbs into a // fresh big-endian magnitude (out[0]=MSB) of `len` limbs. -mag_from_le(le: ptr, len: int) -> ptr { - out = intarr_new_raw(len) +mag_from_le(le: int[], len: int) -> int[] { + out = make([]int, len) i = 0 while i < len { - intarr_set_raw(out, i, intarr_get_raw(le, len - 1 - i)) + out[i] = le[len - 1 - i] i = i + 1 } return out } // Read LE limb i as unsigned 32-bit in a 64-bit int (0 past the end). -le_limb(a: ptr, alen: int, i: int) -> long { +le_limb(a: int[], alen: int, i: int) -> long { if i < 0 { return 0 } if i >= alen { return 0 } - return intarr_get_raw(a, i) & 0xFFFFFFFF + return a[i] & 0xFFFFFFFF } // Schoolbook multiply of two LE arrays into a fresh LE array of xn+yn. -le_schoolbook(x: ptr, xn: int, y: ptr, yn: int) -> ptr { - res = intarr_new_raw(xn + yn) // zero-filled +le_schoolbook(x: int[], xn: int, y: int[], yn: int) -> int[] { + res = make([]int, xn + yn) // zero-filled i = 0 while i < xn { a = le_limb(x, xn, i) @@ -715,12 +723,12 @@ le_schoolbook(x: ptr, xn: int, y: ptr, yn: int) -> ptr { long carry = 0 j = 0 while j < yn { - long v = a * le_limb(y, yn, j) + (intarr_get_raw(res, i + j) & 0xFFFFFFFF) + carry - intarr_set_raw(res, i + j, (v & 0xFFFFFFFF)) + long v = a * le_limb(y, yn, j) + (res[i + j] & 0xFFFFFFFF) + carry + res[i + j] = (v & 0xFFFFFFFF) carry = bits.lsr64(v, 32) j = j + 1 } - intarr_set_raw(res, i + yn, (carry & 0xFFFFFFFF)) + res[i + yn] = (carry & 0xFFFFFFFF) } i = i + 1 } @@ -729,12 +737,12 @@ le_schoolbook(x: ptr, xn: int, y: ptr, yn: int) -> ptr { // LE add: dst[0..dlen) += src[0..slen) (slen <= dlen). dst must have room // for the carry (callers size accordingly). Returns the final carry. -le_add_into(dst: ptr, dlen: int, src: ptr, slen: int, off: int) -> long { +le_add_into(dst: int[], dlen: int, src: int[], slen: int, off: int) -> long { long carry = 0 i = 0 while i < slen { - long v = (intarr_get_raw(dst, off + i) & 0xFFFFFFFF) + (intarr_get_raw(src, i) & 0xFFFFFFFF) + carry - intarr_set_raw(dst, off + i, (v & 0xFFFFFFFF)) + long v = (dst[off + i] & 0xFFFFFFFF) + (src[i] & 0xFFFFFFFF) + carry + dst[off + i] = (v & 0xFFFFFFFF) carry = bits.lsr64(v, 32) i = i + 1 } @@ -743,8 +751,8 @@ le_add_into(dst: ptr, dlen: int, src: ptr, slen: int, off: int) -> long { if k >= dlen { break } - long v = (intarr_get_raw(dst, k) & 0xFFFFFFFF) + carry - intarr_set_raw(dst, k, (v & 0xFFFFFFFF)) + long v = (dst[k] & 0xFFFFFFFF) + carry + dst[k] = (v & 0xFFFFFFFF) carry = bits.lsr64(v, 32) k = k + 1 } @@ -753,7 +761,7 @@ le_add_into(dst: ptr, dlen: int, src: ptr, slen: int, off: int) -> long { // Karatsuba multiply of two LE arrays into a fresh LE array of xn+yn. // Recurses; falls back to schoolbook below the threshold. -le_karatsuba(x: ptr, xn: int, y: ptr, yn: int) -> ptr { +le_karatsuba(x: int[], xn: int, y: int[], yn: int) -> int[] { nmin = xn if yn < nmin { nmin = yn @@ -781,29 +789,29 @@ le_karatsuba(x: ptr, xn: int, y: ptr, yn: int) -> ptr { // low parts (already in place via offset views — but we need contiguous // arrays for recursion, so build them). xl = x[0..xl_len), xh = x[half..) - xl = intarr_new_raw(xl_len) + xl = make([]int, xl_len) i = 0 while i < xl_len { - intarr_set_raw(xl, i, intarr_get_raw(x, i)) + xl[i] = x[i] i = i + 1 } - yl = intarr_new_raw(yl_len) + yl = make([]int, yl_len) i = 0 while i < yl_len { - intarr_set_raw(yl, i, intarr_get_raw(y, i)) + yl[i] = y[i] i = i + 1 } // high parts may be empty (len 0); guard recursion with len-0 producing 0 - xh = intarr_new_raw(xh_len) + xh = make([]int, xh_len) i = 0 while i < xh_len { - intarr_set_raw(xh, i, intarr_get_raw(x, half + i)) + xh[i] = x[half + i] i = i + 1 } - yh = intarr_new_raw(yh_len) + yh = make([]int, yh_len) i = 0 while i < yh_len { - intarr_set_raw(yh, i, intarr_get_raw(y, half + i)) + yh[i] = y[half + i] i = i + 1 } @@ -817,10 +825,10 @@ le_karatsuba(x: ptr, xn: int, y: ptr, yn: int) -> ptr { sx_len = xh_len } sx_len = sx_len + 1 - sx = intarr_new_raw(sx_len) + sx = make([]int, sx_len) i = 0 while i < xl_len { - intarr_set_raw(sx, i, intarr_get_raw(xl, i)) + sx[i] = xl[i] i = i + 1 } le_add_into(sx, sx_len, xh, xh_len, 0) @@ -830,10 +838,10 @@ le_karatsuba(x: ptr, xn: int, y: ptr, yn: int) -> ptr { sy_len = yh_len } sy_len = sy_len + 1 - sy = intarr_new_raw(sy_len) + sy = make([]int, sy_len) i = 0 while i < yl_len { - intarr_set_raw(sy, i, intarr_get_raw(yl, i)) + sy[i] = yl[i] i = i + 1 } le_add_into(sy, sy_len, yh, yh_len, 0) @@ -846,7 +854,7 @@ le_karatsuba(x: ptr, xn: int, y: ptr, yn: int) -> ptr { // result = z0 + z1*B^half + z2*B^(2*half) reslen = xn + yn - res = intarr_new_raw(reslen) // zero + res = make([]int, reslen) // zero // add z0 le_add_into(res, reslen, z0, xl_len + yl_len, 0) // add z1 shifted by half @@ -854,33 +862,33 @@ le_karatsuba(x: ptr, xn: int, y: ptr, yn: int) -> ptr { // add z2 shifted by 2*half le_add_into(res, reslen, z2, xh_len + yh_len, 2 * half) - intarr_free(xl); intarr_free(xh); intarr_free(yl); intarr_free(yh) - intarr_free(sx); intarr_free(sy) - intarr_free(z0); intarr_free(z1); intarr_free(z2) + free(xl); free(xh); free(yl); free(yh) + free(sx); free(sy) + free(z0); free(z1); free(z2) return res } // Karatsuba helper that tolerates zero-length operands (high halves can be // empty); returns a fresh LE array of xn+yn limbs (all zero if either is // empty). -le_mul_maybe_zero(x: ptr, xn: int, y: ptr, yn: int) -> ptr { +le_mul_maybe_zero(x: int[], xn: int, y: int[], yn: int) -> int[] { if xn == 0 { - return intarr_new_raw(yn) // all zero, length xn+yn = yn + return make([]int, yn) // all zero, length xn+yn = yn } if yn == 0 { - return intarr_new_raw(xn) + return make([]int, xn) } return le_karatsuba(x, xn, y, yn) } // LE subtract in place: dst[0..dlen) -= src[0..slen). Assumes dst >= src // (true for the Karatsuba z1 reconstruction). slen <= dlen. -le_sub_into(dst: ptr, dlen: int, src: ptr, slen: int) { +le_sub_into(dst: int[], dlen: int, src: int[], slen: int) { long borrow = 0 i = 0 while i < slen { - long v = (intarr_get_raw(dst, i) & 0xFFFFFFFF) - (intarr_get_raw(src, i) & 0xFFFFFFFF) - borrow - intarr_set_raw(dst, i, (v & 0xFFFFFFFF)) + long v = (dst[i] & 0xFFFFFFFF) - (src[i] & 0xFFFFFFFF) - borrow + dst[i] = (v & 0xFFFFFFFF) if v < 0 { borrow = 1 } else { @@ -893,8 +901,8 @@ le_sub_into(dst: ptr, dlen: int, src: ptr, slen: int) { if k >= dlen { break } - long v = (intarr_get_raw(dst, k) & 0xFFFFFFFF) - borrow - intarr_set_raw(dst, k, (v & 0xFFFFFFFF)) + long v = (dst[k] & 0xFFFFFFFF) - borrow + dst[k] = (v & 0xFFFFFFFF) if v < 0 { borrow = 1 } else { @@ -929,11 +937,11 @@ multiply(a: ptr, b: ptr) -> ptr { zle = le_karatsuba(xle, ba.len, yle, bb.len) reslen = ba.len + bb.len res = mag_from_le(zle, reslen) - intarr_free(xle); intarr_free(yle); intarr_free(zle) + free(xle); free(yle); free(zle) return bn_from_mag_checked(rsign, res, reslen) } reslen = ba.len + bb.len - res = intarr_new_raw(reslen) // calloc: zero-filled + res = make([]int, reslen) // calloc: zero-filled mag_multiply(res, reslen, ba.mag, ba.len, bb.mag, bb.len) return bn_from_mag_checked(rsign, res, reslen) } @@ -951,7 +959,7 @@ multiply(a: ptr, b: ptr) -> ptr { // zero), remainder sign = a.sign. // Test bit `pos` (0 = LSB) of a magnitude (big-endian limbs). -mag_test_bit(mag: ptr, len: int, pos: int) -> int { +mag_test_bit(mag: int[], len: int, pos: int) -> int { limbfromlsb = bits.lsr32(pos, 5) // pos / 32 bitinlimb = pos & 0x1f idx = len - 1 - limbfromlsb // big-endian: LSB limb is last @@ -965,8 +973,8 @@ mag_test_bit(mag: ptr, len: int, pos: int) -> int { } // Limb i of a little-endian scratch array, as an unsigned 32-bit value. -ul(a: ptr, i: int) -> long { - return intarr_get_raw(a, i) & 0xFFFFFFFF +ul(a: int[], i: int) -> long { + return a[i] & 0xFFFFFFFF } // D3's test that the quotient estimate is too large: qhat >= 2^32, or @@ -993,14 +1001,14 @@ join(hi: long, lo: long) -> long { // (both non-negative, canonical). The caller guarantees den is non-zero and // no larger than num, both canonical (no leading zero limbs); it adjusts // signs and frees. -mag_divmod_bn(num: ptr, numlen: int, den: ptr, denlen: int) -> (ptr, ptr) { +mag_divmod_bn(num: int[], numlen: int, den: int[], denlen: int) -> (ptr, ptr) { m = numlen n = denlen u = le_from_mag(num, m) v = le_from_mag(den, n) qlen = m - n + 1 - q = intarr_new_raw(qlen) - r = intarr_new_raw(n) + q = make([]int, qlen) + r = make([]int, n) long b = 4294967296 long mask = 0xFFFFFFFF @@ -1012,32 +1020,32 @@ mag_divmod_bn(num: ptr, numlen: int, den: ptr, denlen: int) -> (ptr, ptr) { j = m - 1 while j >= 0 { long cur = join(k, ul(u, j)) - intarr_set_raw(q, j, bits.udiv64(cur, d) & mask) + q[j] = bits.udiv64(cur, d) & mask k = bits.urem64(cur, d) j = j - 1 } - intarr_set_raw(r, 0, k & mask) + r[0] = k & mask } else { // D1: normalise, so the divisor's top limb has its high bit set. // un has one limb more than u. A shift of 0 leaves both as they are // (a 64-bit shift right by 32 of a 32-bit limb is 0), and no left // shift here reaches the sign bit (a limb shifted by at most 31). - sh = bits.clz32(intarr_get_raw(v, n - 1)) - vn = intarr_new_raw(n) + sh = bits.clz32(v[n - 1]) + vn = make([]int, n) i = n - 1 while i > 0 { - intarr_set_raw(vn, i, ((ul(v, i) << sh) | bits.lsr64(ul(v, i - 1), 32 - sh)) & mask) + vn[i] = ((ul(v, i) << sh) | bits.lsr64(ul(v, i - 1), 32 - sh)) & mask i = i - 1 } - intarr_set_raw(vn, 0, (ul(v, 0) << sh) & mask) - un = intarr_new_raw(m + 1) - intarr_set_raw(un, m, bits.lsr64(ul(u, m - 1), 32 - sh)) + vn[0] = (ul(v, 0) << sh) & mask + un = make([]int, m + 1) + un[m] = bits.lsr64(ul(u, m - 1), 32 - sh) i = m - 1 while i > 0 { - intarr_set_raw(un, i, ((ul(u, i) << sh) | bits.lsr64(ul(u, i - 1), 32 - sh)) & mask) + un[i] = ((ul(u, i) << sh) | bits.lsr64(ul(u, i - 1), 32 - sh)) & mask i = i - 1 } - intarr_set_raw(un, 0, (ul(u, 0) << sh) & mask) + un[0] = (ul(u, 0) << sh) & mask long vtop = ul(vn, n - 1) long vnext = ul(vn, n - 2) @@ -1064,12 +1072,12 @@ mag_divmod_bn(num: ptr, numlen: int, den: ptr, denlen: int) -> (ptr, ptr) { while i < n { long p = bits.wrapping_mul64(qhat, ul(vn, i)) t = ul(un, i + j) - k - (p & mask) - intarr_set_raw(un, i + j, t & mask) + un[i + j] = t & mask k = bits.lsr64(p, 32) - (t >> 32) i = i + 1 } t = ul(un, j + n) - k - intarr_set_raw(un, j + n, t & mask) + un[j + n] = t & mask // D5/D6: the estimate was one too large (rare): add one divisor // back. if t < 0 { @@ -1078,13 +1086,13 @@ mag_divmod_bn(num: ptr, numlen: int, den: ptr, denlen: int) -> (ptr, ptr) { i = 0 while i < n { t = ul(un, i + j) + ul(vn, i) + k - intarr_set_raw(un, i + j, t & mask) + un[i + j] = t & mask k = bits.lsr64(t, 32) i = i + 1 } - intarr_set_raw(un, j + n, (ul(un, j + n) + k) & mask) + un[j + n] = (ul(un, j + n) + k) & mask } - intarr_set_raw(q, j, qhat & mask) + q[j] = qhat & mask j = j - 1 } // D8: the remainder is un's low n limbs shifted back. Only the low @@ -1093,19 +1101,19 @@ mag_divmod_bn(num: ptr, numlen: int, den: ptr, denlen: int) -> (ptr, ptr) { long low = bits.lsr64(mask, 32 - sh) i = 0 while i < n - 1 { - intarr_set_raw(r, i, bits.lsr64(ul(un, i), sh) | ((ul(un, i + 1) & low) << (32 - sh))) + r[i] = bits.lsr64(ul(un, i), sh) | ((ul(un, i + 1) & low) << (32 - sh)) i = i + 1 } - intarr_set_raw(r, n - 1, bits.lsr64(ul(un, n - 1), sh)) - intarr_free(vn) - intarr_free(un) + r[n - 1] = bits.lsr64(ul(un, n - 1), sh) + free(vn) + free(un) } - intarr_free(u) - intarr_free(v) + free(u) + free(v) qbe = mag_from_le(q, qlen) rbe = mag_from_le(r, n) - intarr_free(q) - intarr_free(r) + free(q) + free(r) return bn_from_mag_checked(1, qbe, qlen), bn_from_mag_checked(1, rbe, n) } @@ -1209,57 +1217,57 @@ mont_mdash(m0: long) -> long { // CIOS Montgomery multiply: t = (x * y * R^(-1)) mod m, all LE, n limbs. // x, y, mle are LE arrays of exactly n limbs (zero-padded). mdash = // -m^(-1) mod 2^32. Returns a fresh LE array of n limbs in [0, m). -mont_mul(x: ptr, y: ptr, mle: ptr, n: int, mdash: long) -> ptr { +mont_mul(x: int[], y: int[], mle: int[], n: int, mdash: long) -> int[] { // accumulator t has n+2 limbs to hold intermediate carry safely. - t = intarr_new_raw(n + 2) // zero + t = make([]int, n + 2) // zero i = 0 while i < n { - long xi = intarr_get_raw(x, i) & 0xFFFFFFFF + long xi = x[i] & 0xFFFFFFFF // t = t + xi * y long carry = 0 j = 0 while j < n { - long v = (intarr_get_raw(t, j) & 0xFFFFFFFF) + xi * (intarr_get_raw(y, j) & 0xFFFFFFFF) + carry - intarr_set_raw(t, j, (v & 0xFFFFFFFF)) + long v = (t[j] & 0xFFFFFFFF) + xi * (y[j] & 0xFFFFFFFF) + carry + t[j] = (v & 0xFFFFFFFF) carry = bits.lsr64(v, 32) j = j + 1 } - long sum = (intarr_get_raw(t, n) & 0xFFFFFFFF) + carry - intarr_set_raw(t, n, (sum & 0xFFFFFFFF)) - intarr_set_raw(t, n + 1, ((intarr_get_raw(t, n + 1) & 0xFFFFFFFF) + bits.lsr64(sum, 32)) & 0xFFFFFFFF) + long sum = (t[n] & 0xFFFFFFFF) + carry + t[n] = (sum & 0xFFFFFFFF) + t[n + 1] = ((t[n + 1] & 0xFFFFFFFF) + bits.lsr64(sum, 32)) & 0xFFFFFFFF // u = (t[0] * mdash) mod 2^32 ; t = (t + u * m) / 2^32 - long u = ((intarr_get_raw(t, 0) & 0xFFFFFFFF) * mdash) & 0xFFFFFFFF + long u = ((t[0] & 0xFFFFFFFF) * mdash) & 0xFFFFFFFF long carry2 = 0 j = 0 while j < n { - long v = (intarr_get_raw(t, j) & 0xFFFFFFFF) + u * (intarr_get_raw(mle, j) & 0xFFFFFFFF) + carry2 - intarr_set_raw(t, j, (v & 0xFFFFFFFF)) + long v = (t[j] & 0xFFFFFFFF) + u * (mle[j] & 0xFFFFFFFF) + carry2 + t[j] = (v & 0xFFFFFFFF) carry2 = bits.lsr64(v, 32) j = j + 1 } - long s2 = (intarr_get_raw(t, n) & 0xFFFFFFFF) + carry2 - intarr_set_raw(t, n, (s2 & 0xFFFFFFFF)) - intarr_set_raw(t, n + 1, ((intarr_get_raw(t, n + 1) & 0xFFFFFFFF) + bits.lsr64(s2, 32)) & 0xFFFFFFFF) + long s2 = (t[n] & 0xFFFFFFFF) + carry2 + t[n] = (s2 & 0xFFFFFFFF) + t[n + 1] = ((t[n + 1] & 0xFFFFFFFF) + bits.lsr64(s2, 32)) & 0xFFFFFFFF // shift t down by one limb (divide by 2^32) k = 0 while k < n + 1 { - intarr_set_raw(t, k, intarr_get_raw(t, k + 1)) + t[k] = t[k + 1] k = k + 1 } - intarr_set_raw(t, n + 1, 0) + t[n + 1] = 0 i = i + 1 } // t now has up to n+1 significant limbs; conditional subtract of m. // Compare (t as n+1 limbs) >= m (n limbs). - out = intarr_new_raw(n) + out = make([]int, n) ge = 0 - if (intarr_get_raw(t, n) & 0xFFFFFFFF) != 0 { + if (t[n] & 0xFFFFFFFF) != 0 { ge = 1 } else { c = n - 1 while c >= 0 { - long tv = intarr_get_raw(t, c) & 0xFFFFFFFF - long mv = intarr_get_raw(mle, c) & 0xFFFFFFFF + long tv = t[c] & 0xFFFFFFFF + long mv = mle[c] & 0xFFFFFFFF if tv != mv { if tv > mv { ge = 1 @@ -1274,8 +1282,8 @@ mont_mul(x: ptr, y: ptr, mle: ptr, n: int, mdash: long) -> ptr { long borrow = 0 j = 0 while j < n { - long v = (intarr_get_raw(t, j) & 0xFFFFFFFF) - (intarr_get_raw(mle, j) & 0xFFFFFFFF) - borrow - intarr_set_raw(out, j, (v & 0xFFFFFFFF)) + long v = (t[j] & 0xFFFFFFFF) - (mle[j] & 0xFFFFFFFF) - borrow + out[j] = (v & 0xFFFFFFFF) if v < 0 { borrow = 1 } else { @@ -1286,33 +1294,33 @@ mont_mul(x: ptr, y: ptr, mle: ptr, n: int, mdash: long) -> ptr { } else { j = 0 while j < n { - intarr_set_raw(out, j, intarr_get_raw(t, j)) + out[j] = t[j] j = j + 1 } } - intarr_free(t) + free(t) return out } // Build a zero-padded LE array of exactly n limbs from a non-negative BN's // magnitude (value assumed < 2^(32n)). For a zero BN, returns all zeros. -le_padded_from_bn(n_bn: ptr, n: int) -> ptr { +le_padded_from_bn(n_bn: ptr, n: int) -> int[] { bn = n_bn as *BN - out = intarr_new_raw(n) // zero + out = make([]int, n) // zero if bn.sign == 0 { return out } // mag is big-endian; copy into LE. i = 0 while i < bn.len { - intarr_set_raw(out, i, intarr_get_raw(bn.mag, bn.len - 1 - i)) + out[i] = bn.mag[bn.len - 1 - i] i = i + 1 } return out } // Make a non-negative BN from an LE limb array (out[0]=LSB) of n limbs. -bn_from_le(le: ptr, n: int) -> ptr { +bn_from_le(le: int[], n: int) -> ptr { mag = mag_from_le(le, n) return bn_from_mag_checked(1, mag, n) } @@ -1322,7 +1330,7 @@ bn_from_le(le: ptr, n: int) -> ptr { mod_pow_monty(base: ptr, exp: ptr, m: ptr) -> ptr { bm = m as *BN n = bm.len - long m0 = intarr_get_raw(bm.mag, n - 1) & 0xFFFFFFFF // LSB limb (BE last) + long m0 = bm.mag[n - 1] & 0xFFFFFFFF // LSB limb (BE last) long mdash = mont_mdash(m0) mle = le_padded_from_bn(m, n) @@ -1348,23 +1356,23 @@ mod_pow_monty(base: ptr, exp: ptr, m: ptr) -> ptr { i = nbits - 1 while i >= 0 { sq = mont_mul(acc, acc, mle, n, mdash) - intarr_free(acc) + free(acc) acc = sq if mag_test_bit(be.mag, be.len, i) == 1 { pr = mont_mul(acc, base_le, mle, n, mdash) - intarr_free(acc) + free(acc) acc = pr } i = i - 1 } // convert out of Montgomery form: result = acc * 1 * R^(-1) mod m. - one_mont = intarr_new_raw(n) // value 1 in plain form (LE) - intarr_set_raw(one_mont, 0, 1) + one_mont = make([]int, n) // value 1 in plain form (LE) + one_mont[0] = 1 final_le = mont_mul(acc, one_mont, mle, n, mdash) result = bn_from_le(final_le, n) - intarr_free(acc); intarr_free(final_le); intarr_free(one_mont) - intarr_free(one_le); intarr_free(base_le); intarr_free(mle) + free(acc); free(final_le); free(one_mont) + free(one_le); free(base_le); free(mle) return result } @@ -1510,6 +1518,9 @@ mod_inverse(a: ptr, m: ptr) -> ptr { return old_s } +// The deterministic Miller-Rabin witnesses: the first 13 primes. +const MR_BASES: int[13] = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41] + // Miller-Rabin primality test with a fixed set of small witness bases. // `rounds` caps how many bases to try (the fixed list is deterministic for // all n < 3.317e24 with the first 13 primes; beyond that it is a strong @@ -1552,23 +1563,14 @@ is_probable_prime(n: ptr, rounds: int) -> int { bd = d as *BN s = s + 1 } - // fixed deterministic witness bases (first 13 primes) - bases = intarr_new_raw(13) - intarr_set_raw(bases, 0, 2); intarr_set_raw(bases, 1, 3) - intarr_set_raw(bases, 2, 5); intarr_set_raw(bases, 3, 7) - intarr_set_raw(bases, 4, 11); intarr_set_raw(bases, 5, 13) - intarr_set_raw(bases, 6, 17); intarr_set_raw(bases, 7, 19) - intarr_set_raw(bases, 8, 23); intarr_set_raw(bases, 9, 29) - intarr_set_raw(bases, 10, 31); intarr_set_raw(bases, 11, 37) - intarr_set_raw(bases, 12, 41) - nbases = 13 + nbases = MR_BASES.len limit = rounds if limit > nbases { limit = nbases } if limit < 1 { limit = 1 } result = 1 bi = 0 while bi < limit { - a = from_int(intarr_get_raw(bases, bi)) + a = from_int(MR_BASES[bi]) // skip bases >= n-1 (only relevant for tiny n, already handled above) if compare(a, nm1) >= 0 { free(a) @@ -1611,7 +1613,6 @@ is_probable_prime(n: ptr, rounds: int) -> int { } } } - intarr_free(bases) free(one); free(two); free(three); free(nm1); free(d) return result } @@ -1620,32 +1621,32 @@ is_probable_prime(n: ptr, rounds: int) -> int { // BC ShiftLeft of a magnitude by n bits (n > 0). Returns (mag, len). n is // read as an unsigned 32-bit count (lsr32 / & 0x1f), so INT_MIN shifts by 2^31. -mag_shift_left(mag: ptr, maglen: int, n: int) -> (ptr, int) { +mag_shift_left(mag: int[], maglen: int, n: int) -> (int[], int) { nints = bits.lsr32(n, 5) // n / 32 nbits = n & 0x1f if nbits == 0 { newlen = maglen + nints - nm = intarr_new_raw(newlen) + nm = make([]int, newlen) i = 0 while i < maglen { - intarr_set_raw(nm, i, intarr_get_raw(mag, i)) + nm[i] = mag[i] i = i + 1 } // trailing nints limbs already zero return nm, newlen } nbits2 = 32 - nbits - highbits = bits.lsr32((intarr_get_raw(mag, 0) & 0xFFFFFFFF), nbits2) & 0xFFFFFFFF + highbits = bits.lsr32((mag[0] & 0xFFFFFFFF), nbits2) & 0xFFFFFFFF newlen = maglen + nints extra = 0 if highbits != 0 { newlen = newlen + 1 extra = 1 } - nm = intarr_new_raw(newlen) + nm = make([]int, newlen) i = 0 if extra == 1 { - intarr_set_raw(nm, 0, highbits & 0xFFFFFFFF) + nm[0] = highbits & 0xFFFFFFFF i = 1 } m = limb(mag, 0) @@ -1653,13 +1654,13 @@ mag_shift_left(mag: ptr, maglen: int, n: int) -> (ptr, int) { while j < maglen - 1 { nxt = limb(mag, j + 1) val = ((m << nbits) & 0xFFFFFFFF) | bits.lsr32((nxt & 0xFFFFFFFF), nbits2) & 0xFFFFFFFF - intarr_set_raw(nm, i, val & 0xFFFFFFFF) + nm[i] = val & 0xFFFFFFFF i = i + 1 m = nxt j = j + 1 } last = (limb(mag, maglen - 1) << nbits) & 0xFFFFFFFF - intarr_set_raw(nm, i, last & 0xFFFFFFFF) + nm[i] = last & 0xFFFFFFFF // remaining nints low limbs are already zero return nm, newlen } @@ -1711,8 +1712,8 @@ shift_right(n: ptr, bits_to: int) -> ptr { if bits_to >= bl { // result is 0 for non-negative, -1 for negative (floor division) if bn.sign < 0 { - m = intarr_new_raw(1) - intarr_set_raw(m, 0, 1) + m = make([]int, 1) + m[0] = 1 return bn_alloc(-1, m, 1) } return bn_zero() @@ -1724,14 +1725,14 @@ shift_right(n: ptr, bits_to: int) -> ptr { dropped = mag_has_low_bits(bn.mag, bn.len, bits_to) resultlength = bits.lsr32((bl - bits_to + 31), 5) // (bl - n + 31) / 32 - res = intarr_new_raw(resultlength) + res = make([]int, resultlength) numints = bits.lsr32(bits_to, 5) // n / 32 numbits = bits_to & 31 if numbits == 0 { // copy the leading resultlength limbs of the magnitude i = 0 while i < resultlength { - intarr_set_raw(res, i, intarr_get_raw(bn.mag, i)) + res[i] = bn.mag[i] i = i + 1 } } else { @@ -1743,7 +1744,7 @@ shift_right(n: ptr, bits_to: int) -> ptr { if magpos - 1 >= 0 { v = v | ((limb(bn.mag, magpos - 1) << numbits2) & 0xFFFFFFFF) } - intarr_set_raw(res, i, v & 0xFFFFFFFF) + res[i] = v & 0xFFFFFFFF magpos = magpos - 1 i = i - 1 } @@ -1763,7 +1764,7 @@ shift_right(n: ptr, bits_to: int) -> ptr { } // Returns 1 if any of the low `nbits` bits of the magnitude are set. -mag_has_low_bits(mag: ptr, maglen: int, nbits: int) -> int { +mag_has_low_bits(mag: int[], maglen: int, nbits: int) -> int { if nbits <= 0 { return 0 } @@ -1800,31 +1801,19 @@ bytes_for_bits(nbits: int) -> int { return (nbits + 7) / 8 } -// Signed two's-complement big-endian. Returns a std.intarr where slot i -// holds byte i (0..255); the byte count is the intarr's size. Callers -// read with intarr_get_raw and mask & 0xFF. -// -// NOTE: returns the bytes in an intarr-of-bytes; the test harness reads -// them back through bignum.* helpers. To keep the public surface a plain -// `ptr` of raw bytes, we copy into a malloc'd byte buffer. +// The minimal two's-complement big-endian encoding of n (BigInteger's +// toByteArray), as a std.bytes the caller frees with bytes.free. to_bytes(n: ptr) -> ptr { return to_bytes_impl(n, 0) } +// The big-endian bytes of |n| with no sign byte, as a std.bytes the caller +// frees with bytes.free. to_bytes_unsigned(n: ptr) -> ptr { return to_bytes_impl(n, 1) } -// We return a malloc'd buffer whose first 4 bytes are the length (LE int) -// followed by the bytes. That keeps the signature `-> ptr` while letting -// callers recover the length. The test harness uses to_bytes_len + raw -// byte reads. -// -// Layout: [int32 length][bytes...]. mem.get_int reads the prefix. - -// Returns a std.bytes (AetherBytes) holding the big-endian encoding — -// consistent with the rest of the cryptography port. Caller frees with -// bytes.free. +// The body of to_bytes and to_bytes_unsigned. to_bytes_impl(n: ptr, as_unsigned: int) -> ptr { bn = n as *BN if bn.sign == 0 { @@ -2013,7 +2002,7 @@ free(n: ptr) { } bn = n as *BN if bn.mag != null { - intarr_free(bn.mag) + free(bn.mag) } libc_free(n) } @@ -2040,7 +2029,7 @@ free(n: ptr) { // remainder. Classic schoolbook short division: carry the running remainder // down through the limbs, each step a 64-bit divide because the carry is // below `d` <= 2^32 and each limb is below 2^32. -mag_divmod_small(mag: ptr, len: int, d: long) -> long { +mag_divmod_small(mag: int[], len: int, d: long) -> long { // `long`, not inferred int: the running remainder is shifted left 32 bits // each step and would silently truncate in a 32-bit slot. long rem = 0 @@ -2049,14 +2038,14 @@ mag_divmod_small(mag: ptr, len: int, d: long) -> long { cur = (rem << 32) | limb(mag, i) q = cur / d rem = cur - q * d - intarr_set_raw(mag, i, q) + mag[i] = q i = i + 1 } return rem } // Is every limb zero? -mag_is_zero(mag: ptr, len: int) -> int { +mag_is_zero(mag: int[], len: int) -> int { i = 0 while i < len { if limb(mag, i) != 0 { @@ -2082,14 +2071,14 @@ to_decimal(n: ptr) -> string { // Each pass yields up to 9 digits, emitted least-significant first and // reversed at the end. 10 chunks per limb-word is a generous bound. - chunks = intarr_new_raw(bn.len * 2 + 2) + chunks = make([]int, bn.len * 2 + 2) count = 0 while mag_is_zero(work, len) == 0 { r = mag_divmod_small(work, len, 1000000000) - intarr_set_raw(chunks, count, r) + chunks[count] = r count = count + 1 } - intarr_free(work) + free(work) sb = strbuilder.new(count * 9 + 2) if bn.sign < 0 { @@ -2099,7 +2088,7 @@ to_decimal(n: ptr) -> string { // exactly 9 digits, because an interior chunk of 42 means "000000042". i = count - 1 while i >= 0 { - v = intarr_get_raw(chunks, i) & 0xFFFFFFFF + v = chunks[i] & 0xFFFFFFFF if i == count - 1 { append_dec_nolead(sb, v) } else { @@ -2107,7 +2096,7 @@ to_decimal(n: ptr) -> string { } i = i - 1 } - intarr_free(chunks) + free(chunks) return strbuilder.finish(sb) } diff --git a/std/cryptography/argon2/module.ae b/std/cryptography/argon2/module.ae index e5688e364..8872fa9ce 100644 --- a/std/cryptography/argon2/module.ae +++ b/std/cryptography/argon2/module.ae @@ -25,7 +25,7 @@ // iterations < 1) or allocation failure. // // Aether-specific care: -// * Blocks are 128 uint64 lanes held in one big std.longarr; lanes are +// * Blocks are 128 uint64 lanes held in one long[] matrix; lanes are // loaded / stored LITTLE-ENDIAN. 64-bit `long` add wraps mod 2^64 // (two's complement), exactly Argon2's modular add. // * The G compression's `2 * (low32(a) * low32(b))` product is computed @@ -38,12 +38,12 @@ import std.bits import std.bytes -import std.longarr import std.cryptography.blake2 import std.string extern malloc(size: int) -> ptr @extern("free") libc_free(p: ptr) +extern calloc(count: long, size: long) -> ptr exports( argon2, argon2d, argon2i, argon2id, @@ -59,6 +59,14 @@ const QWORDS: int = 128 // uint64 lanes per 1024-byte block const SYNC_POINTS: int = 4 const VERSION: int = 0x13 +// One 1024-byte block of the memory matrix: QWORDS 64-bit lanes. The +// scratch blocks of a fill are these, passed by pointer, so every index +// is checked against the constant QWORDS (the C compiler drops the check +// where the index is in range by construction). +struct Block { + w: long[QWORDS] +} + // ---- Variable-length hash H' ---- // H'(out_len, input[len]) per RFC 9106 §3.2: // if out_len <= 64: Blake2b(out_len) over LE32(out_len) || input @@ -128,45 +136,45 @@ bytes_as_string(b: byte[]) -> string { return bytes.finish(dup, n) } -// ---- Block <-> longarr helpers ---- -// Memory matrix is one longarr of memoryBlocks*128 lanes. Block `i` +// ---- Block <-> matrix helpers ---- +// The memory matrix is one long[] of memoryBlocks*128 lanes. Block `i` // occupies lanes [i*128 .. i*128+128). // Load a 1024-byte little-endian block from `src` (a std.bytes buffer at // byte offset 0) into matrix block `bidx`. -load_block_from_bytes(mem: ptr, bidx: int, src: ptr) { +load_block_from_bytes(mem: long[], bidx: int, src: ptr) { base = bidx * QWORDS i = 0 while i < QWORDS { off = i * 8 - longarr_set_unchecked(mem, base + i, bytes.get_le64(src, off)) + mem[base + i] = bytes.get_le64(src, off) i = i + 1 } } // Store matrix block `bidx` little-endian into `dst` (a std.bytes buffer). -store_block_to_bytes(mem: ptr, bidx: int, dst: ptr) { +store_block_to_bytes(mem: long[], bidx: int, dst: ptr) { base = bidx * QWORDS i = 0 while i < QWORDS { - w = longarr_get_unchecked(mem, base + i) + w = mem[base + i] off = i * 8 bytes.set_le64(dst, off, w) i = i + 1 } } -// ---- The G compression function over a scratch longarr of 128 lanes ---- +// ---- The G compression function over a 128-lane scratch block ---- // GB modifies four lanes of `v` in place (indices a,b,c,d). // a += b + 2*low32(a)*low32(b); d = rotr64(d^a,32); c += d + 2*low32(c)*low32(d); // b = rotr64(b^c,24); a += b + 2*low32(a)*low32(b); d = rotr64(d^a,16); // c += d + 2*low32(c)*low32(d); b = rotr64(b^c,63). -gb(v: ptr, a: int, b: int, c: int, d: int) { - va = longarr_get_unchecked(v, a) - vb = longarr_get_unchecked(v, b) - vc = longarr_get_unchecked(v, c) - vd = longarr_get_unchecked(v, d) +gb(v: *Block, a: int, b: int, c: int, d: int) { + va = v.w[a] + vb = v.w[b] + vc = v.w[c] + vd = v.w[d] va = va + vb + mul2(va, vb) vd = bits.rotr64(vd ^ va, 32) @@ -178,10 +186,10 @@ gb(v: ptr, a: int, b: int, c: int, d: int) { vc = vc + vd + mul2(vc, vd) vb = bits.rotr64(vb ^ vc, 63) - longarr_set_unchecked(v, a, va) - longarr_set_unchecked(v, b, vb) - longarr_set_unchecked(v, c, vc) - longarr_set_unchecked(v, d, vd) + v.w[a] = va + v.w[b] = vb + v.w[c] = vc + v.w[d] = vd } // 2 * (low32(x) * low32(y)), all mod 2^64. @@ -192,8 +200,8 @@ mul2(x: long, y: long) -> long { } // Apply the Argon2 permutation P to block `v` (rows then columns), in -// place over the 128-lane scratch longarr. -apply_blake(v: ptr) { +// place over the 128-lane scratch block. +apply_blake(v: *Block) { // Rows: for i in 0,16,32,...,112 i = 0 while i < 128 { @@ -227,8 +235,8 @@ apply_blake(v: ptr) { // FillBlock: current = R ^ Z, where R = prev ^ ref and Z = P(R). // If with_xor != 0, current ^= (R ^ Z) instead of being overwritten. -// `rblk` and `zblk` are 128-lane scratch longarrs. -fill_block(mem: ptr, prev_idx: int, ref_idx: int, cur_idx: int, with_xor: int, rblk: ptr, zblk: ptr) { +// `rblk` and `zblk` are 128-lane scratch blocks. +fill_block(mem: long[], prev_idx: int, ref_idx: int, cur_idx: int, with_xor: int, rblk: *Block, zblk: *Block) { pbase = prev_idx * QWORDS rbase = ref_idx * QWORDS cbase = cur_idx * QWORDS @@ -236,9 +244,9 @@ fill_block(mem: ptr, prev_idx: int, ref_idx: int, cur_idx: int, with_xor: int, r // R = prev ^ ref; Z = R (copy). i = 0 while i < QWORDS { - rv = longarr_get_unchecked(mem, pbase + i) ^ longarr_get_unchecked(mem, rbase + i) - longarr_set_unchecked(rblk, i, rv) - longarr_set_unchecked(zblk, i, rv) + rv = mem[pbase + i] ^ mem[rbase + i] + rblk.w[i] = rv + zblk.w[i] = rv i = i + 1 } @@ -247,27 +255,27 @@ fill_block(mem: ptr, prev_idx: int, ref_idx: int, cur_idx: int, with_xor: int, r // current = R ^ Z (optionally XORed into the existing current block). i = 0 while i < QWORDS { - nv = longarr_get_unchecked(rblk, i) ^ longarr_get_unchecked(zblk, i) + nv = rblk.w[i] ^ zblk.w[i] if with_xor != 0 { - nv = longarr_get_unchecked(mem, cbase + i) ^ nv + nv = mem[cbase + i] ^ nv } - longarr_set_unchecked(mem, cbase + i, nv) + mem[cbase + i] = nv i = i + 1 } } // Fill of the address block: Z = P(input); address = input ^ Z. // (The single-argument Fill in BC: R is the input itself, no prev^ref.) -fill_addresses(input: ptr, addr: ptr, zblk: ptr) { +fill_addresses(input: *Block, addr: *Block, zblk: *Block) { i = 0 while i < QWORDS { - longarr_set_unchecked(zblk, i, longarr_get_unchecked(input, i)) + zblk.w[i] = input.w[i] i = i + 1 } apply_blake(zblk) i = 0 while i < QWORDS { - longarr_set_unchecked(addr, i, longarr_get_unchecked(input, i) ^ longarr_get_unchecked(zblk, i)) + addr.w[i] = input.w[i] ^ zblk.w[i] i = i + 1 } } @@ -295,15 +303,20 @@ argon2(atype: int, pass: byte[], salt: byte[], secret: byte[], ad: byte[], t_cos lane_length = segment_length * SYNC_POINTS memory_blocks = parallelism * lane_length - // The big memory matrix: memory_blocks * 128 lanes. - mem = longarr_new_raw(memory_blocks * QWORDS) - if mem == null { + // The big memory matrix: memory_blocks * 128 lanes. calloc rather + // than make: a memory cost the machine cannot give is a null result + // for the caller, not a panic. + long lanes = memory_blocks + lanes = lanes * QWORDS + mem_raw = calloc(lanes, 8) + if mem_raw == null { return null } - rblk = longarr_new_raw(QWORDS) - zblk = longarr_new_raw(QWORDS) - addr_blk = longarr_new_raw(QWORDS) - input_blk = longarr_new_raw(QWORDS) + mem = (mem_raw as long[])[0..lanes] + rblk = Block {} + zblk = Block {} + addr_blk = Block {} + input_blk = Block {} // ---- Initialize: H0 = Blake2b-512(LE32(p,outLen,m,t,v,y) || strings) ---- ctx = blake2.new_b(64) @@ -348,7 +361,7 @@ argon2(atype: int, pass: byte[], salt: byte[], secret: byte[], ad: byte[], t_cos bytes.free(h0) // ---- Fill memory blocks ---- - fill_memory(mem, atype, t_cost, parallelism, memory_blocks, segment_length, lane_length, rblk, zblk, addr_blk, input_blk) + fill_memory(mem, atype, t_cost, parallelism, memory_blocks, segment_length, lane_length, &rblk, &zblk, &addr_blk, &input_blk) // ---- Digest: XOR last block of every lane, then H'(out_len, finalBlock) ---- final_idx = lane_length - 1 @@ -359,7 +372,7 @@ argon2(atype: int, pass: byte[], salt: byte[], secret: byte[], ad: byte[], t_cos while k < QWORDS { fbase = final_idx * QWORDS lbase = last_in_lane * QWORDS - longarr_set_unchecked(mem, fbase + k, longarr_get_unchecked(mem, fbase + k) ^ longarr_get_unchecked(mem, lbase + k)) + mem[fbase + k] = mem[fbase + k] ^ mem[lbase + k] k = k + 1 } i = i + 1 @@ -370,11 +383,7 @@ argon2(atype: int, pass: byte[], salt: byte[], secret: byte[], ad: byte[], t_cos hprime(bytes.view(blk_bytes)[0..1024], out_len, out, 0) bytes.free(blk_bytes) - longarr_free(mem) - longarr_free(rblk) - longarr_free(zblk) - longarr_free(addr_blk) - longarr_free(input_blk) + free(mem) return out } @@ -405,7 +414,7 @@ add_byte_string(ctx: ptr, octets: byte[]) { // ---- Memory filling ---- -fill_memory(mem: ptr, atype: int, iterations: int, parallelism: int, memory_blocks: int, segment_length: int, lane_length: int, rblk: ptr, zblk: ptr, addr_blk: ptr, input_blk: ptr) { +fill_memory(mem: long[], atype: int, iterations: int, parallelism: int, memory_blocks: int, segment_length: int, lane_length: int, rblk: *Block, zblk: *Block, addr_blk: *Block, input_blk: *Block) { pass = 0 while pass < iterations { slice = 0 @@ -435,7 +444,7 @@ is_data_independent(atype: int, pass: int, slice: int) -> int { return 0 } -fill_segment(mem: ptr, atype: int, pass: int, slice: int, lane: int, iterations: int, parallelism: int, memory_blocks: int, segment_length: int, lane_length: int, rblk: ptr, zblk: ptr, addr_blk: ptr, input_blk: ptr) { +fill_segment(mem: long[], atype: int, pass: int, slice: int, lane: int, iterations: int, parallelism: int, memory_blocks: int, segment_length: int, lane_length: int, rblk: *Block, zblk: *Block, addr_blk: *Block, input_blk: *Block) { data_independent = is_data_independent(atype, pass, slice) starting_index = 0 @@ -452,16 +461,16 @@ fill_segment(mem: ptr, atype: int, pass: int, slice: int, lane: int, iterations: // Clear address & input blocks, init input block. i = 0 while i < QWORDS { - longarr_set_unchecked(addr_blk, i, 0) - longarr_set_unchecked(input_blk, i, 0) + addr_blk.w[i] = 0 + input_blk.w[i] = 0 i = i + 1 } - longarr_set_unchecked(input_blk, 0, pass) - longarr_set_unchecked(input_blk, 1, lane) - longarr_set_unchecked(input_blk, 2, slice) - longarr_set_unchecked(input_blk, 3, memory_blocks) - longarr_set_unchecked(input_blk, 4, iterations) - longarr_set_unchecked(input_blk, 5, atype) + input_blk.w[0] = pass + input_blk.w[1] = lane + input_blk.w[2] = slice + input_blk.w[3] = memory_blocks + input_blk.w[4] = iterations + input_blk.w[5] = atype if pass == 0 { if slice == 0 { next_addresses(input_blk, addr_blk, zblk) @@ -483,9 +492,9 @@ fill_segment(mem: ptr, atype: int, pass: int, slice: int, lane: int, iterations: if addr_index == 0 { next_addresses(input_blk, addr_blk, zblk) } - pseudo = longarr_get_unchecked(addr_blk, addr_index) + pseudo = addr_blk.w[addr_index] } else { - pseudo = longarr_get_unchecked(mem, prev_offset * QWORDS + 0) + pseudo = mem[prev_offset * QWORDS + 0] } ref_lane = get_ref_lane(pass, slice, lane, parallelism, pseudo) @@ -505,9 +514,9 @@ fill_segment(mem: ptr, atype: int, pass: int, slice: int, lane: int, iterations: } } -next_addresses(input_blk: ptr, addr_blk: ptr, zblk: ptr) { - // input.v[6]++ - longarr_set_unchecked(input_blk, 6, longarr_get_unchecked(input_blk, 6) + 1) +next_addresses(input_blk: *Block, addr_blk: *Block, zblk: *Block) { + // input.v.w[6]++ + input_blk.w[6] = input_blk.w[6] + 1 fill_addresses(input_blk, addr_blk, zblk) fill_addresses(addr_blk, addr_blk, zblk) } diff --git a/std/cryptography/blake2/module.ae b/std/cryptography/blake2/module.ae index 51be0934a..54d99ac3e 100644 --- a/std/cryptography/blake2/module.ae +++ b/std/cryptography/blake2/module.ae @@ -37,7 +37,6 @@ import std.bits import std.bytes -import std.longarr extern malloc(size: int) -> ptr extern free(p: ptr) @@ -83,7 +82,7 @@ const SIGMA: long[192] = [ // is_b: 1 = BLAKE2b (64-bit, 128-byte block), 0 = BLAKE2s (32-bit, 64-byte). struct Blake2Ctx { - h: ptr // longarr(16): 8 chaining words (low 32 bits used for s) + h: long[16] // 8 chaining words (low 32 bits used for s) block: ptr // std.bytes input block buffer block_len: int // bytes currently in `block` t0: long // byte counter, low @@ -98,11 +97,11 @@ m32(x: long) -> long { } // ---- BLAKE2b G mixing (full 64-bit, natural wrap) ---- -gb(v: ptr, a: int, b: int, c: int, d: int, x: long, y: long) { - va = longarr_get_unchecked(v, a) - vb = longarr_get_unchecked(v, b) - vc = longarr_get_unchecked(v, c) - vd = longarr_get_unchecked(v, d) +gb(v: long[], a: int, b: int, c: int, d: int, x: long, y: long) { + va = v[a] + vb = v[b] + vc = v[c] + vd = v[d] va = va + vb + x vd = bits.rotr64(vd ^ va, 32) @@ -113,18 +112,18 @@ gb(v: ptr, a: int, b: int, c: int, d: int, x: long, y: long) { vc = vc + vd vb = bits.rotr64(vb ^ vc, 63) - longarr_set_unchecked(v, a, va) - longarr_set_unchecked(v, b, vb) - longarr_set_unchecked(v, c, vc) - longarr_set_unchecked(v, d, vd) + v[a] = va + v[b] = vb + v[c] = vc + v[d] = vd } // ---- BLAKE2s G mixing (32-bit, masked) ---- -gs(v: ptr, a: int, b: int, c: int, d: int, x: long, y: long) { - va = longarr_get_unchecked(v, a) & 0xFFFFFFFF - vb = longarr_get_unchecked(v, b) & 0xFFFFFFFF - vc = longarr_get_unchecked(v, c) & 0xFFFFFFFF - vd = longarr_get_unchecked(v, d) & 0xFFFFFFFF +gs(v: long[], a: int, b: int, c: int, d: int, x: long, y: long) { + va = v[a] & 0xFFFFFFFF + vb = v[b] & 0xFFFFFFFF + vc = v[c] & 0xFFFFFFFF + vd = v[d] & 0xFFFFFFFF va = (va + vb + x) & 0xFFFFFFFF vd = bits.rotr32(vd ^ va, 16) & 0xFFFFFFFF @@ -135,10 +134,10 @@ gs(v: ptr, a: int, b: int, c: int, d: int, x: long, y: long) { vc = (vc + vd) & 0xFFFFFFFF vb = bits.rotr32(vb ^ vc, 7) & 0xFFFFFFFF - longarr_set_unchecked(v, a, va) - longarr_set_unchecked(v, b, vb) - longarr_set_unchecked(v, c, vc) - longarr_set_unchecked(v, d, vd) + v[a] = va + v[b] = vb + v[c] = vc + v[d] = vd } // Load one little-endian 64-bit word from the block at byte offset off. @@ -148,20 +147,20 @@ load_le64(b: ptr, off: int) -> long { // last: 1 if this is the final block (set f0 = all-ones). compress(c: *Blake2Ctx, last: int) { - v = longarr_new_raw(16) - m = longarr_new_raw(16) + long[16] v + long[16] m // Load message words little-endian. if c.is_b == 1 { i = 0 while i < 16 { - longarr_set_unchecked(m, i, load_le64(c.block, i * 8)) + m[i] = load_le64(c.block, i * 8) i = i + 1 } } else { i = 0 while i < 16 { - longarr_set_unchecked(m, i, bytes.get_le32(c.block, i * 4) & 0xFFFFFFFF) + m[i] = bytes.get_le32(c.block, i * 4) & 0xFFFFFFFF i = i + 1 } } @@ -169,31 +168,31 @@ compress(c: *Blake2Ctx, last: int) { // v[0..7] = h, v[8..15] = IV. i = 0 while i < 8 { - longarr_set_unchecked(v, i, longarr_get_unchecked(c.h, i)) + v[i] = c.h[i] i = i + 1 } if c.is_b == 1 { i = 0 while i < 8 { - longarr_set_unchecked(v, 8 + i, IV_B[i]) + v[8 + i] = IV_B[i] i = i + 1 } // Mix counter and final flag. - longarr_set_unchecked(v, 12, IV_B[4] ^ c.t0) - longarr_set_unchecked(v, 13, IV_B[5] ^ c.t1) + v[12] = IV_B[4] ^ c.t0 + v[13] = IV_B[5] ^ c.t1 if last == 1 { - longarr_set_unchecked(v, 14, IV_B[6] ^ 0xFFFFFFFFFFFFFFFF) + v[14] = IV_B[6] ^ 0xFFFFFFFFFFFFFFFF } } else { i = 0 while i < 8 { - longarr_set_unchecked(v, 8 + i, IV_S[i]) + v[8 + i] = IV_S[i] i = i + 1 } - longarr_set_unchecked(v, 12, (IV_S[4] ^ c.t0) & 0xFFFFFFFF) - longarr_set_unchecked(v, 13, (IV_S[5] ^ c.t1) & 0xFFFFFFFF) + v[12] = (IV_S[4] ^ c.t0) & 0xFFFFFFFF + v[13] = (IV_S[5] ^ c.t1) & 0xFFFFFFFF if last == 1 { - longarr_set_unchecked(v, 14, (IV_S[6] ^ 0xFFFFFFFF) & 0xFFFFFFFF) + v[14] = (IV_S[6] ^ 0xFFFFFFFF) & 0xFFFFFFFF } } @@ -223,23 +222,23 @@ compress(c: *Blake2Ctx, last: int) { s15 = SIGMA[so + 15] if c.is_b == 1 { - gb(v, 0, 4, 8, 12, longarr_get_unchecked(m, s0), longarr_get_unchecked(m, s1)) - gb(v, 1, 5, 9, 13, longarr_get_unchecked(m, s2), longarr_get_unchecked(m, s3)) - gb(v, 2, 6, 10, 14, longarr_get_unchecked(m, s4), longarr_get_unchecked(m, s5)) - gb(v, 3, 7, 11, 15, longarr_get_unchecked(m, s6), longarr_get_unchecked(m, s7)) - gb(v, 0, 5, 10, 15, longarr_get_unchecked(m, s8), longarr_get_unchecked(m, s9)) - gb(v, 1, 6, 11, 12, longarr_get_unchecked(m, s10), longarr_get_unchecked(m, s11)) - gb(v, 2, 7, 8, 13, longarr_get_unchecked(m, s12), longarr_get_unchecked(m, s13)) - gb(v, 3, 4, 9, 14, longarr_get_unchecked(m, s14), longarr_get_unchecked(m, s15)) + gb(v, 0, 4, 8, 12, m[s0], m[s1]) + gb(v, 1, 5, 9, 13, m[s2], m[s3]) + gb(v, 2, 6, 10, 14, m[s4], m[s5]) + gb(v, 3, 7, 11, 15, m[s6], m[s7]) + gb(v, 0, 5, 10, 15, m[s8], m[s9]) + gb(v, 1, 6, 11, 12, m[s10], m[s11]) + gb(v, 2, 7, 8, 13, m[s12], m[s13]) + gb(v, 3, 4, 9, 14, m[s14], m[s15]) } else { - gs(v, 0, 4, 8, 12, longarr_get_unchecked(m, s0), longarr_get_unchecked(m, s1)) - gs(v, 1, 5, 9, 13, longarr_get_unchecked(m, s2), longarr_get_unchecked(m, s3)) - gs(v, 2, 6, 10, 14, longarr_get_unchecked(m, s4), longarr_get_unchecked(m, s5)) - gs(v, 3, 7, 11, 15, longarr_get_unchecked(m, s6), longarr_get_unchecked(m, s7)) - gs(v, 0, 5, 10, 15, longarr_get_unchecked(m, s8), longarr_get_unchecked(m, s9)) - gs(v, 1, 6, 11, 12, longarr_get_unchecked(m, s10), longarr_get_unchecked(m, s11)) - gs(v, 2, 7, 8, 13, longarr_get_unchecked(m, s12), longarr_get_unchecked(m, s13)) - gs(v, 3, 4, 9, 14, longarr_get_unchecked(m, s14), longarr_get_unchecked(m, s15)) + gs(v, 0, 4, 8, 12, m[s0], m[s1]) + gs(v, 1, 5, 9, 13, m[s2], m[s3]) + gs(v, 2, 6, 10, 14, m[s4], m[s5]) + gs(v, 3, 7, 11, 15, m[s6], m[s7]) + gs(v, 0, 5, 10, 15, m[s8], m[s9]) + gs(v, 1, 6, 11, 12, m[s10], m[s11]) + gs(v, 2, 7, 8, 13, m[s12], m[s13]) + gs(v, 3, 4, 9, 14, m[s14], m[s15]) } r = r + 1 } @@ -248,21 +247,19 @@ compress(c: *Blake2Ctx, last: int) { if c.is_b == 1 { i = 0 while i < 8 { - nv = longarr_get_unchecked(c.h, i) ^ longarr_get_unchecked(v, i) ^ longarr_get_unchecked(v, i + 8) - longarr_set_unchecked(c.h, i, nv) + nv = c.h[i] ^ v[i] ^ v[i + 8] + c.h[i] = nv i = i + 1 } } else { i = 0 while i < 8 { - nv = (longarr_get_unchecked(c.h, i) ^ longarr_get_unchecked(v, i) ^ longarr_get_unchecked(v, i + 8)) & 0xFFFFFFFF - longarr_set_unchecked(c.h, i, nv) + nv = (c.h[i] ^ v[i] ^ v[i + 8]) & 0xFFFFFFFF + c.h[i] = nv i = i + 1 } } - longarr_free(v) - longarr_free(m) } // Increment the 128-bit byte counter (t1:t0) by n. @@ -296,7 +293,6 @@ mk_ctx(out_len: int, is_b: int) -> *Blake2Ctx { bytes.set(ctx.block, j, 0) j = j + 1 } - ctx.h = longarr_new_raw(16) return ctx } @@ -305,20 +301,20 @@ init_h(c: *Blake2Ctx, key_len: int) { if c.is_b == 1 { i = 0 while i < 8 { - longarr_set_unchecked(c.h, i, IV_B[i]) + c.h[i] = IV_B[i] i = i + 1 } // digest_len | (key_len<<8) | (fanout=1<<16) | (depth=1<<24) param = (c.out_len & 0xFF) | ((key_len & 0xFF) << 8) | 0x01010000 - longarr_set_unchecked(c.h, 0, IV_B[0] ^ param) + c.h[0] = IV_B[0] ^ param } else { i = 0 while i < 8 { - longarr_set_unchecked(c.h, i, IV_S[i]) + c.h[i] = IV_S[i] i = i + 1 } param = (c.out_len & 0xFF) | ((key_len & 0xFF) << 8) | 0x01010000 - longarr_set_unchecked(c.h, 0, (IV_S[0] ^ param) & 0xFFFFFFFF) + c.h[0] = (IV_S[0] ^ param) & 0xFFFFFFFF } } @@ -434,13 +430,13 @@ finalize_to_bytes(c: *Blake2Ctx) -> ptr { if c.is_b == 1 { i = 0 while i < 8 { - store_le64(full, i * 8, longarr_get_unchecked(c.h, i)) + store_le64(full, i * 8, c.h[i]) i = i + 1 } } else { i = 0 while i < 8 { - bytes.set_le32(full, i * 4, longarr_get_unchecked(c.h, i) & 0xFFFFFFFF) + bytes.set_le32(full, i * 4, c.h[i] & 0xFFFFFFFF) i = i + 1 } } @@ -460,10 +456,6 @@ free_internals(c: *Blake2Ctx) { bytes.free(c.block) c.block = null } - if c.h != null { - longarr_free(c.h) - c.h = null - } } // Finalize and return the raw digest as a std.bytes buffer. FREES the diff --git a/std/cryptography/chacha20poly1305/module.ae b/std/cryptography/chacha20poly1305/module.ae index 8c2eddce2..d61123f80 100644 --- a/std/cryptography/chacha20poly1305/module.ae +++ b/std/cryptography/chacha20poly1305/module.ae @@ -32,7 +32,6 @@ import std.bytes import std.bits import std.intarr -import std.longarr exports( chacha20_xor, poly1305_mac, aead_seal, aead_open @@ -48,43 +47,42 @@ rl(x: long, n: int) -> long { // ---- ChaCha20 (RFC 8439 §2.3) ---- // One ChaCha20 quarter-round on state words at indices a,b,c,d. -qr(st: ptr, a: int, b: int, c: int, d: int) { - va = longarr_get_unchecked(st, a) - vb = longarr_get_unchecked(st, b) - vc = longarr_get_unchecked(st, c) - vd = longarr_get_unchecked(st, d) +qr(st: long[], a: int, b: int, c: int, d: int) { + va = st[a] + vb = st[b] + vc = st[c] + vd = st[d] va = m32(va + vb); vd = rl(vd ^ va, 16) vc = m32(vc + vd); vb = rl(vb ^ vc, 12) va = m32(va + vb); vd = rl(vd ^ va, 8) vc = m32(vc + vd); vb = rl(vb ^ vc, 7) - longarr_set_unchecked(st, a, va) - longarr_set_unchecked(st, b, vb) - longarr_set_unchecked(st, c, vc) - longarr_set_unchecked(st, d, vd) + st[a] = va + st[b] = vb + st[c] = vc + st[d] = vd } // Produce one 64-byte ChaCha20 block into `out` (offset 0..63) for the // given key (32 bytes), nonce (12 bytes), and 32-bit counter. chacha_block(key: ptr, nonce: ptr, counter: long, out: ptr) { - st = longarr_new_raw(16) + long[16] st // Constants "expand 32-byte k" - longarr_set_unchecked(st, 0, 0x61707865) - longarr_set_unchecked(st, 1, 0x3320646e) - longarr_set_unchecked(st, 2, 0x79622d32) - longarr_set_unchecked(st, 3, 0x6b206574) + st[0] = 0x61707865 + st[1] = 0x3320646e + st[2] = 0x79622d32 + st[3] = 0x6b206574 i = 0 while i < 8 { - longarr_set_unchecked(st, 4 + i, le32(key, i * 4)) + st[4 + i] = le32(key, i * 4) i = i + 1 } - longarr_set_unchecked(st, 12, counter & 0xFFFFFFFF) - longarr_set_unchecked(st, 13, le32(nonce, 0)) - longarr_set_unchecked(st, 14, le32(nonce, 4)) - longarr_set_unchecked(st, 15, le32(nonce, 8)) + st[12] = counter & 0xFFFFFFFF + st[13] = le32(nonce, 0) + st[14] = le32(nonce, 4) + st[15] = le32(nonce, 8) - work = longarr_new_raw(16) - i = 0 - while i < 16 { longarr_set_unchecked(work, i, longarr_get_unchecked(st, i)); i = i + 1 } + // The rounds work on a copy; the state is added back at the end. + long[16] work = st r = 0 while r < 10 { @@ -103,12 +101,10 @@ chacha_block(key: ptr, nonce: ptr, counter: long, out: ptr) { i = 0 while i < 16 { - v = m32(longarr_get_unchecked(work, i) + longarr_get_unchecked(st, i)) + v = m32(work[i] + st[i]) set_le32(out, i * 4, v) i = i + 1 } - longarr_free(st) - longarr_free(work) } le32(b: ptr, off: int) -> long { diff --git a/std/cryptography/des3/module.ae b/std/cryptography/des3/module.ae index 6fad50f95..9feaf7454 100644 --- a/std/cryptography/des3/module.ae +++ b/std/cryptography/des3/module.ae @@ -49,7 +49,6 @@ import std.bytes import std.bits -import std.intarr exports( new_encryptor, new_decryptor, process_block, free, @@ -247,28 +246,27 @@ const SP8: int[64] = [ 0x00001040, 0x00040040, 0x10000000, 0x10041000 ] -// Three working-key schedules; each is intarr(32) of 32-bit words. +// Three working-key schedules of 32 words each. struct Des3Ctx { - wk1: ptr // applied first - wk2: ptr // applied second - wk3: ptr // applied third + wk1: int[32] // applied first + wk2: int[32] // applied second + wk3: int[32] // applied third } // ---- single-DES key schedule (BC GenerateWorkingKey) ---- -// Returns intarr(32). `encrypting` selects the round ordering. -generate_working_key(encrypting: int, key: byte[], key_off: int) -> ptr { - new_key = intarr_new_raw(32) - pc1m = intarr_new_raw(56) - pcr = intarr_new_raw(56) +// Fills the 32 words of `new_key`. `encrypting` selects the round ordering. +generate_working_key(new_key: int[], encrypting: int, key: byte[], key_off: int) { + int[56] pc1m + int[56] pcr j = 0 while j < 56 { l = PC1[j] kb = key[key_off + bits.lsr32(l, 3)] & 0xFF if (kb & BYTEBIT[l & 7]) != 0 { - intarr_set_raw(pc1m, j, 1) + pc1m[j] = 1 } else { - intarr_set_raw(pc1m, j, 0) + pc1m[j] = 0 } j = j + 1 } @@ -282,16 +280,16 @@ generate_working_key(encrypting: int, key: byte[], key_off: int) -> ptr { m = (15 - i) << 1 } n = m + 1 - intarr_set_raw(new_key, m, 0) - intarr_set_raw(new_key, n, 0) + new_key[m] = 0 + new_key[n] = 0 j = 0 while j < 28 { l = j + TOTROT[i] if l < 28 { - intarr_set_raw(pcr, j, intarr_get_raw(pc1m, l)) + pcr[j] = pc1m[l] } else { - intarr_set_raw(pcr, j, intarr_get_raw(pc1m, l - 28)) + pcr[j] = pc1m[l - 28] } j = j + 1 } @@ -299,34 +297,32 @@ generate_working_key(encrypting: int, key: byte[], key_off: int) -> ptr { while j < 56 { l = j + TOTROT[i] if l < 56 { - intarr_set_raw(pcr, j, intarr_get_raw(pc1m, l)) + pcr[j] = pc1m[l] } else { - intarr_set_raw(pcr, j, intarr_get_raw(pc1m, l - 28)) + pcr[j] = pc1m[l - 28] } j = j + 1 } j = 0 while j < 24 { - if intarr_get_raw(pcr, PC2[j]) != 0 { - intarr_set_raw(new_key, m, intarr_get_raw(new_key, m) | BIGBYTE[j]) + if pcr[PC2[j]] != 0 { + new_key[m] = new_key[m] | BIGBYTE[j] } - if intarr_get_raw(pcr, PC2[j + 24]) != 0 { - intarr_set_raw(new_key, n, intarr_get_raw(new_key, n) | BIGBYTE[j]) + if pcr[PC2[j + 24]] != 0 { + new_key[n] = new_key[n] | BIGBYTE[j] } j = j + 1 } i = i + 1 } - intarr_free(pc1m) - intarr_free(pcr) // Pack the processed key. i = 0 while i < 32 { - i1 = intarr_get_raw(new_key, i) - i2 = intarr_get_raw(new_key, i + 1) + i1 = new_key[i] + i2 = new_key[i + 1] v0 = (((i1 & 0x00fc0000) << 6) | ((i1 & 0x00000fc0) << 10) | bits.lsr32(i2 & 0x00fc0000, 10) | @@ -335,16 +331,15 @@ generate_working_key(encrypting: int, key: byte[], key_off: int) -> ptr { ((i1 & 0x0000003f) << 16) | bits.lsr32(i2 & 0x0003f000, 4) | (i2 & 0x0000003f)) & 0xFFFFFFFF - intarr_set_raw(new_key, i, v0) - intarr_set_raw(new_key, i + 1, v1) + new_key[i] = v0 + new_key[i + 1] = v1 i = i + 2 } - return new_key } // ---- single-DES core (BC DesFunc), operating on two 32-bit halves ---- // Returns the new (hi, lo) pair. -des_func(wk: ptr, hi: int, lo: int) -> { +des_func(wk: int[], hi: int, lo: int) -> { long left = hi & 0xFFFFFFFF long right = lo & 0xFFFFFFFF long work = 0 @@ -371,12 +366,12 @@ des_func(wk: ptr, hi: int, lo: int) -> { round = 0 while round < 8 { work = ((right << 28) | bits.lsr32(right, 4)) & 0xFFFFFFFF - work = (work ^ intarr_get_raw(wk, round * 4 + 0)) & 0xFFFFFFFF + work = (work ^ wk[round * 4 + 0]) & 0xFFFFFFFF fval = SP7[work & 0x3f] fval = fval | SP5[bits.lsr32(work, 8) & 0x3f] fval = fval | SP3[bits.lsr32(work, 16) & 0x3f] fval = fval | SP1[bits.lsr32(work, 24) & 0x3f] - work = (right ^ intarr_get_raw(wk, round * 4 + 1)) & 0xFFFFFFFF + work = (right ^ wk[round * 4 + 1]) & 0xFFFFFFFF fval = fval | SP8[work & 0x3f] fval = fval | SP6[bits.lsr32(work, 8) & 0x3f] fval = fval | SP4[bits.lsr32(work, 16) & 0x3f] @@ -384,12 +379,12 @@ des_func(wk: ptr, hi: int, lo: int) -> { left = (left ^ fval) & 0xFFFFFFFF work = ((left << 28) | bits.lsr32(left, 4)) & 0xFFFFFFFF - work = (work ^ intarr_get_raw(wk, round * 4 + 2)) & 0xFFFFFFFF + work = (work ^ wk[round * 4 + 2]) & 0xFFFFFFFF fval = SP7[work & 0x3f] fval = fval | SP5[bits.lsr32(work, 8) & 0x3f] fval = fval | SP3[bits.lsr32(work, 16) & 0x3f] fval = fval | SP1[bits.lsr32(work, 24) & 0x3f] - work = (left ^ intarr_get_raw(wk, round * 4 + 3)) & 0xFFFFFFFF + work = (left ^ wk[round * 4 + 3]) & 0xFFFFFFFF fval = fval | SP8[work & 0x3f] fval = fval | SP6[bits.lsr32(work, 8) & 0x3f] fval = fval | SP4[bits.lsr32(work, 16) & 0x3f] @@ -434,28 +429,21 @@ new_ctx(key: byte[], encrypt: int) -> ptr { // or = key1 when keylen==16). For encryption DesFunc runs k1,k2,k3; for // decryption it runs k3,k2,k1. We fold the order into wk1/wk2/wk3. fe = encrypt // forEncryption flag - - s1 = generate_working_key(fe, key, 0) - s2 = generate_working_key(1 - fe, key, 8) - s3 = null - if keylen == 24 { - s3 = generate_working_key(fe, key, 16) - } else { - // 2-key TDEA: K3 = K1 (same key bytes, same direction). - s3 = generate_working_key(fe, key, 0) - } + // 2-key TDEA: K3 = K1 (same key bytes, same direction). + k3_off = 16 + if keylen != 24 { k3_off = 0 } c = malloc(sizeof(Des3Ctx)) as *Des3Ctx if encrypt == 1 { // E_K1 then E(dir K2) then E_K3 → DesFunc(k1), DesFunc(k2), DesFunc(k3) - c.wk1 = s1 - c.wk2 = s2 - c.wk3 = s3 + generate_working_key(c.wk1, fe, key, 0) + generate_working_key(c.wk2, 1 - fe, key, 8) + generate_working_key(c.wk3, fe, key, k3_off) } else { // decrypt runs DesFunc(k3), DesFunc(k2), DesFunc(k1) - c.wk1 = s3 - c.wk2 = s2 - c.wk3 = s1 + generate_working_key(c.wk1, fe, key, k3_off) + generate_working_key(c.wk2, 1 - fe, key, 8) + generate_working_key(c.wk3, fe, key, 0) } return c } @@ -468,10 +456,6 @@ new_decryptor(key: byte[]) -> ptr { } free(ctx: ptr) { if ctx == null { return } - c = ctx as *Des3Ctx - if c.wk1 != null { intarr_free(c.wk1) } - if c.wk2 != null { intarr_free(c.wk2) } - if c.wk3 != null { intarr_free(c.wk3) } libc_free(ctx) } diff --git a/std/cryptography/dstu7564/module.ae b/std/cryptography/dstu7564/module.ae index e8b14a569..b434cc26d 100644 --- a/std/cryptography/dstu7564/module.ae +++ b/std/cryptography/dstu7564/module.ae @@ -9,7 +9,6 @@ import std.bits import std.bytes -import std.longarr extern malloc(size: int) -> ptr extern free(p: ptr) @@ -100,9 +99,9 @@ struct DstuCtx { block_size: int columns: int rounds: int - state: ptr // longarr(16) - temp_state1: ptr // longarr(16) - temp_state2: ptr // longarr(16) + state: long[16] + temp_state1: long[16] + temp_state2: long[16] input_blocks: long buf_off: int buf: ptr // std.bytes (128 bytes) @@ -128,19 +127,19 @@ mix_column(c: long) -> long { return u ^ rotate(32, v) ^ rotate(40, x1) ^ rotate(48, x1) } -mix_columns(c: *DstuCtx, s: ptr) { +mix_columns(c: *DstuCtx, s: long[]) { col = 0 while col < c.columns { - val = longarr_get_unchecked(s, col) - longarr_set_unchecked(s, col, mix_column(val)) + val = s[col] + s[col] = mix_column(val) col = col + 1 } } -sub_bytes(c_ctx: *DstuCtx, s: ptr) { +sub_bytes(c_ctx: *DstuCtx, s: long[]) { col = 0 while col < c_ctx.columns { - u = longarr_get_unchecked(s, col) + u = s[col] lo = u & 0xFFFFFFFF hi = bits.lsr64(u, 32) & 0xFFFFFFFF @@ -156,21 +155,21 @@ sub_bytes(c_ctx: *DstuCtx, s: ptr) { t7 = S3_KUPYNA[(hi >> 24) & 255] long new_hi = (t4 as long) | ((t5 as long) << 8) | ((t6 as long) << 16) | ((t7 as long) << 24) - longarr_set_unchecked(s, col, (new_lo & 0xFFFFFFFF) | ((new_hi & 0xFFFFFFFF) << 32)) + s[col] = (new_lo & 0xFFFFFFFF) | ((new_hi & 0xFFFFFFFF) << 32) col = col + 1 } } -shift_rows(c_ctx: *DstuCtx, s: ptr) { +shift_rows(c_ctx: *DstuCtx, s: long[]) { if c_ctx.columns == 8 { - c0 = longarr_get_unchecked(s, 0) - c1 = longarr_get_unchecked(s, 1) - c2 = longarr_get_unchecked(s, 2) - c3 = longarr_get_unchecked(s, 3) - c4 = longarr_get_unchecked(s, 4) - c5 = longarr_get_unchecked(s, 5) - c6 = longarr_get_unchecked(s, 6) - c7 = longarr_get_unchecked(s, 7) + c0 = s[0] + c1 = s[1] + c2 = s[2] + c3 = s[3] + c4 = s[4] + c5 = s[5] + c6 = s[6] + c7 = s[7] d = (c0 ^ c4) & 0xFFFFFFFF00000000 c0 = c0 ^ d @@ -220,31 +219,31 @@ shift_rows(c_ctx: *DstuCtx, s: ptr) { c6 = c6 ^ d c7 = c7 ^ d - longarr_set_unchecked(s, 0, c0) - longarr_set_unchecked(s, 1, c1) - longarr_set_unchecked(s, 2, c2) - longarr_set_unchecked(s, 3, c3) - longarr_set_unchecked(s, 4, c4) - longarr_set_unchecked(s, 5, c5) - longarr_set_unchecked(s, 6, c6) - longarr_set_unchecked(s, 7, c7) + s[0] = c0 + s[1] = c1 + s[2] = c2 + s[3] = c3 + s[4] = c4 + s[5] = c5 + s[6] = c6 + s[7] = c7 } else { - c00 = longarr_get_unchecked(s, 0) - c01 = longarr_get_unchecked(s, 1) - c02 = longarr_get_unchecked(s, 2) - c03 = longarr_get_unchecked(s, 3) - c04 = longarr_get_unchecked(s, 4) - c05 = longarr_get_unchecked(s, 5) - c06 = longarr_get_unchecked(s, 6) - c07 = longarr_get_unchecked(s, 7) - c08 = longarr_get_unchecked(s, 8) - c09 = longarr_get_unchecked(s, 9) - c10 = longarr_get_unchecked(s, 10) - c11 = longarr_get_unchecked(s, 11) - c12 = longarr_get_unchecked(s, 12) - c13 = longarr_get_unchecked(s, 13) - c14 = longarr_get_unchecked(s, 14) - c15 = longarr_get_unchecked(s, 15) + c00 = s[0] + c01 = s[1] + c02 = s[2] + c03 = s[3] + c04 = s[4] + c05 = s[5] + c06 = s[6] + c07 = s[7] + c08 = s[8] + c09 = s[9] + c10 = s[10] + c11 = s[11] + c12 = s[12] + c13 = s[13] + c14 = s[14] + c15 = s[15] d = (c00 ^ c08) & 0xFF00000000000000 c00 = c00 ^ d @@ -374,34 +373,34 @@ shift_rows(c_ctx: *DstuCtx, s: ptr) { c14 = c14 ^ d c15 = c15 ^ d - longarr_set_unchecked(s, 0, c00) - longarr_set_unchecked(s, 1, c01) - longarr_set_unchecked(s, 2, c02) - longarr_set_unchecked(s, 3, c03) - longarr_set_unchecked(s, 4, c04) - longarr_set_unchecked(s, 5, c05) - longarr_set_unchecked(s, 6, c06) - longarr_set_unchecked(s, 7, c07) - longarr_set_unchecked(s, 8, c08) - longarr_set_unchecked(s, 9, c09) - longarr_set_unchecked(s, 10, c10) - longarr_set_unchecked(s, 11, c11) - longarr_set_unchecked(s, 12, c12) - longarr_set_unchecked(s, 13, c13) - longarr_set_unchecked(s, 14, c14) - longarr_set_unchecked(s, 15, c15) + s[0] = c00 + s[1] = c01 + s[2] = c02 + s[3] = c03 + s[4] = c04 + s[5] = c05 + s[6] = c06 + s[7] = c07 + s[8] = c08 + s[9] = c09 + s[10] = c10 + s[11] = c11 + s[12] = c12 + s[13] = c13 + s[14] = c14 + s[15] = c15 } } -P_perm(c_ctx: *DstuCtx, s: ptr) { +P_perm(c_ctx: *DstuCtx, s: long[]) { round = 0 while round < c_ctx.rounds { long rc = round col = 0 while col < c_ctx.columns { - val = longarr_get_unchecked(s, col) - longarr_set_unchecked(s, col, val ^ rc) + val = s[col] + s[col] = val ^ rc rc = rc + 0x10 col = col + 1 } @@ -414,7 +413,7 @@ P_perm(c_ctx: *DstuCtx, s: ptr) { } } -Q_perm(c_ctx: *DstuCtx, s: ptr) { +Q_perm(c_ctx: *DstuCtx, s: long[]) { round = 0 while round < c_ctx.rounds { long rc = (((c_ctx.columns - 1) << 4) ^ round) << 56 @@ -422,8 +421,8 @@ Q_perm(c_ctx: *DstuCtx, s: ptr) { col = 0 while col < c_ctx.columns { - val = longarr_get_unchecked(s, col) - longarr_set_unchecked(s, col, val + rc) + val = s[col] + s[col] = val + rc rc = rc - 0x1000000000000000 col = col + 1 } @@ -443,8 +442,8 @@ process_block(c: *DstuCtx, input: ptr, inOff: int) { word = bytes.get_le64(input, pos) pos = pos + 8 - longarr_set_unchecked(c.temp_state1, col, longarr_get_unchecked(c.state, col) ^ word) - longarr_set_unchecked(c.temp_state2, col, word) + c.temp_state1[col] = c.state[col] ^ word + c.temp_state2[col] = word col = col + 1 } @@ -453,8 +452,8 @@ process_block(c: *DstuCtx, input: ptr, inOff: int) { col = 0 while col < c.columns { - val = longarr_get_unchecked(c.state, col) ^ longarr_get_unchecked(c.temp_state1, col) ^ longarr_get_unchecked(c.temp_state2, col) - longarr_set_unchecked(c.state, col, val) + val = c.state[col] ^ c.temp_state1[col] ^ c.temp_state2[col] + c.state[col] = val col = col + 1 } } @@ -483,20 +482,17 @@ new(algo: string) -> { } ctx.block_size = ctx.columns << 3 - ctx.state = longarr_new_raw(16) - ctx.temp_state1 = longarr_new_raw(16) - ctx.temp_state2 = longarr_new_raw(16) ctx.buf = bytes.new(128) i = 0 while i < 16 { - longarr_set_unchecked(ctx.state, i, 0) - longarr_set_unchecked(ctx.temp_state1, i, 0) - longarr_set_unchecked(ctx.temp_state2, i, 0) + ctx.state[i] = 0 + ctx.temp_state1[i] = 0 + ctx.temp_state2[i] = 0 i = i + 1 } - longarr_set_unchecked(ctx.state, 0, ctx.block_size) + ctx.state[0] = ctx.block_size ctx.input_blocks = 0 ctx.buf_off = 0 @@ -556,7 +552,7 @@ finalize_to_bytes(c_ctx: *DstuCtx) -> ptr { // Apply transformation i = 0 while i < c_ctx.columns { - longarr_set_unchecked(c_ctx.temp_state1, i, longarr_get_unchecked(c_ctx.state, i)) + c_ctx.temp_state1[i] = c_ctx.state[i] i = i + 1 } @@ -564,8 +560,8 @@ finalize_to_bytes(c_ctx: *DstuCtx) -> ptr { i = 0 while i < c_ctx.columns { - val = longarr_get_unchecked(c_ctx.state, i) ^ longarr_get_unchecked(c_ctx.temp_state1, i) - longarr_set_unchecked(c_ctx.state, i, val) + val = c_ctx.state[i] ^ c_ctx.temp_state1[i] + c_ctx.state[i] = val i = i + 1 } @@ -574,7 +570,7 @@ finalize_to_bytes(c_ctx: *DstuCtx) -> ptr { col = c_ctx.columns - needed_columns out_pos = 0 while col < c_ctx.columns { - bytes.set_le64(out, out_pos, longarr_get_unchecked(c_ctx.state, col)) + bytes.set_le64(out, out_pos, c_ctx.state[col]) out_pos = out_pos + 8 col = col + 1 } @@ -583,9 +579,6 @@ finalize_to_bytes(c_ctx: *DstuCtx) -> ptr { } free_internals(c: *DstuCtx) { - if c.state != null { longarr_free(c.state); c.state = null } - if c.temp_state1 != null { longarr_free(c.temp_state1); c.temp_state1 = null } - if c.temp_state2 != null { longarr_free(c.temp_state2); c.temp_state2 = null } if c.buf != null { bytes.free(c.buf); c.buf = null } } diff --git a/std/cryptography/gost3411_2012/module.ae b/std/cryptography/gost3411_2012/module.ae index 7c111466a..4c04443ec 100644 --- a/std/cryptography/gost3411_2012/module.ae +++ b/std/cryptography/gost3411_2012/module.ae @@ -9,7 +9,6 @@ import std.bits import std.bytes -import std.longarr extern malloc(size: int) -> ptr extern free(p: ptr) @@ -619,7 +618,7 @@ store_le64(V: ptr, off: int, r: long) { } F_perm(c: *GostCtx, V: ptr) { - res = longarr_new_raw(8) + long[8] res w = 0 while w < 8 { @@ -642,17 +641,16 @@ F_perm(c: *GostCtx, V: ptr) { r = r ^ STREEBOG_T[6 * 256 + v6] r = r ^ STREEBOG_T[7 * 256 + v7] - longarr_set_unchecked(res, w, r) + res[w] = r w = w + 1 } w = 0 while w < 8 { - r_w = longarr_get_unchecked(res, w) + r_w = res[w] store_le64(V, w * 8, r_w) w = w + 1 } - longarr_free(res) } E_cipher(c: *GostCtx, K: ptr, m: ptr) { diff --git a/std/cryptography/mlkem/module.ae b/std/cryptography/mlkem/module.ae index 06c37c28e..c7f657cc2 100644 --- a/std/cryptography/mlkem/module.ae +++ b/std/cryptography/mlkem/module.ae @@ -60,7 +60,6 @@ import std.bits import std.bytes -import std.intarr import std.cryptography import std.cryptography.sha3 @@ -238,39 +237,39 @@ mul_mont(a: int, b: int) -> int { return mont_reduce(a * b) } -// ---- Poly = intarr(256) of coefficients ---- +// ---- Poly = int[256] of coefficients ---- -poly_new() -> ptr { - return intarr_new_raw(N) +poly_new() -> int[] { + return make([]int, N) } -poly_free(p: ptr) { - intarr_free(p) +poly_free(p: int[]) { + free(p) } -pg(p: ptr, i: int) -> int { - return intarr_get_unchecked(p, i) +pg(p: int[], i: int) -> int { + return p[i] } -ps(p: ptr, i: int, v: int) { - intarr_set_unchecked(p, i, v) +ps(p: int[], i: int, v: int) { + p[i] = v } -// PolyVec = array of k poly ptrs, stored as intarr(N*k) flat: poly j at j*N. -polyvec_new(k: int) -> ptr { - return intarr_new_raw(N * k) +// PolyVec = k polys stored flat in one int[N*k]: poly j at j*N. +polyvec_new(k: int) -> int[] { + return make([]int, N * k) } -polyvec_free(p: ptr) { - intarr_free(p) +polyvec_free(p: int[]) { + free(p) } // Coefficient i of poly j in a flat polyvec. -vg(pv: ptr, j: int, i: int) -> int { - return intarr_get_unchecked(pv, j * N + i) +vg(pv: int[], j: int, i: int) -> int { + return pv[j * N + i] } -vs(pv: ptr, j: int, i: int, v: int) { - intarr_set_unchecked(pv, j * N + i, v) +vs(pv: int[], j: int, i: int, v: int) { + pv[j * N + i] = v } // ---- NTT (in place on a single poly via flat polyvec slot) ---- -ntt(pv: ptr, j: int) { +ntt(pv: int[], j: int) { base = j * N k = 1 len = 128 @@ -281,10 +280,10 @@ ntt(pv: ptr, j: int) { k = k + 1 jj = start while jj < start + len { - t = intarr_get_unchecked(pv, base + jj) - u = mul_mont(zeta, intarr_get_unchecked(pv, base + jj + len)) - intarr_set_unchecked(pv, base + jj + len, s16(t - u)) - intarr_set_unchecked(pv, base + jj, s16(t + u)) + t = pv[base + jj] + u = mul_mont(zeta, pv[base + jj + len]) + pv[base + jj + len] = s16(t - u) + pv[base + jj] = s16(t + u) jj = jj + 1 } start = jj + len @@ -293,7 +292,7 @@ ntt(pv: ptr, j: int) { } } -inv_ntt(pv: ptr, j: int) { +inv_ntt(pv: int[], j: int) { base = j * N k = 0 len = 2 @@ -304,10 +303,10 @@ inv_ntt(pv: ptr, j: int) { k = k + 1 jj = start while jj < start + len { - t = intarr_get_unchecked(pv, base + jj) - u = intarr_get_unchecked(pv, base + jj + len) - intarr_set_unchecked(pv, base + jj, barrett_reduce(s16(t + u))) - intarr_set_unchecked(pv, base + jj + len, mul_mont(zeta, s16(t - u))) + t = pv[base + jj] + u = pv[base + jj + len] + pv[base + jj] = barrett_reduce(s16(t + u)) + pv[base + jj + len] = mul_mont(zeta, s16(t - u)) jj = jj + 1 } start = jj + len @@ -316,70 +315,70 @@ inv_ntt(pv: ptr, j: int) { } i = 0 while i < 256 { - intarr_set_unchecked(pv, base + i, mul_mont(intarr_get_unchecked(pv, base + i), ZETAS_INV[127])) + pv[base + i] = mul_mont(pv[base + i], ZETAS_INV[127]) i = i + 1 } } -poly_reduce(pv: ptr, j: int) { +poly_reduce(pv: int[], j: int) { base = j * N i = 0 while i < 256 { - intarr_set_unchecked(pv, base + i, barrett_reduce(intarr_get_unchecked(pv, base + i))) + pv[base + i] = barrett_reduce(pv[base + i]) i = i + 1 } } -poly_ntt(pv: ptr, j: int) { +poly_ntt(pv: int[], j: int) { ntt(pv, j) poly_reduce(pv, j) } -poly_to_mont(pv: ptr, j: int) { +poly_to_mont(pv: int[], j: int) { base = j * N // f = (1<<32) % q = 1353 f = 1353 i = 0 while i < 256 { - intarr_set_unchecked(pv, base + i, mont_reduce(intarr_get_unchecked(pv, base + i) * f)) + pv[base + i] = mont_reduce(pv[base + i] * f) i = i + 1 } } // r[j] += a[j] -poly_add(rpv: ptr, rj: int, apv: ptr, aj: int) { +poly_add(rpv: int[], rj: int, apv: int[], aj: int) { rb = rj * N ab = aj * N i = 0 while i < 256 { - intarr_set_unchecked(rpv, rb + i, s16(intarr_get_unchecked(rpv, rb + i) + intarr_get_unchecked(apv, ab + i))) + rpv[rb + i] = s16(rpv[rb + i] + apv[ab + i]) i = i + 1 } } // r[j] = a[j] - r[j] (BC Subtract semantics) -poly_subtract(rpv: ptr, rj: int, apv: ptr, aj: int) { +poly_subtract(rpv: int[], rj: int, apv: int[], aj: int) { rb = rj * N ab = aj * N i = 0 while i < 256 { - intarr_set_unchecked(rpv, rb + i, s16(intarr_get_unchecked(apv, ab + i) - intarr_get_unchecked(rpv, rb + i))) + rpv[rb + i] = s16(apv[ab + i] - rpv[rb + i]) i = i + 1 } } // Base multiplication of two degree-1 polys in NTT domain. -basemul(rpv: ptr, rbase: int, off: int, a0: int, a1: int, b0: int, b1: int, zeta: int) { +basemul(rpv: int[], rbase: int, off: int, a0: int, a1: int, b0: int, b1: int, zeta: int) { o0 = mul_mont(a1, b1) o0 = mul_mont(o0, zeta) o0 = s16(o0 + mul_mont(a0, b0)) - intarr_set_unchecked(rpv, rbase + off, o0) + rpv[rbase + off] = o0 o1 = mul_mont(a0, b1) o1 = s16(o1 + mul_mont(a1, b0)) - intarr_set_unchecked(rpv, rbase + off + 1, o1) + rpv[rbase + off + 1] = o1 } -basemul_mont(rpv: ptr, rj: int, apv: ptr, aj: int, bpv: ptr, bj: int) { +basemul_mont(rpv: int[], rj: int, apv: int[], aj: int, bpv: int[], bj: int) { rb = rj * N ab = aj * N bb = bj * N @@ -387,17 +386,17 @@ basemul_mont(rpv: ptr, rj: int, apv: ptr, aj: int, bpv: ptr, bj: int) { while i < 64 { z = ZETAS[64 + i] basemul(rpv, rb, 4 * i, - intarr_get_unchecked(apv, ab + 4 * i), intarr_get_unchecked(apv, ab + 4 * i + 1), - intarr_get_unchecked(bpv, bb + 4 * i), intarr_get_unchecked(bpv, bb + 4 * i + 1), z) + apv[ab + 4 * i], apv[ab + 4 * i + 1], + bpv[bb + 4 * i], bpv[bb + 4 * i + 1], z) basemul(rpv, rb, 4 * i + 2, - intarr_get_unchecked(apv, ab + 4 * i + 2), intarr_get_unchecked(apv, ab + 4 * i + 3), - intarr_get_unchecked(bpv, bb + 4 * i + 2), intarr_get_unchecked(bpv, bb + 4 * i + 3), s16(-z)) + apv[ab + 4 * i + 2], apv[ab + 4 * i + 3], + bpv[bb + 4 * i + 2], bpv[bb + 4 * i + 3], s16(-z)) i = i + 1 } } // r[rj] = pointwise-accumulated, reduced. -polyvec_pointwise_acc(rpv: ptr, rj: int, apv: ptr, bpv: ptr, k: int, scratch: ptr) { +polyvec_pointwise_acc(rpv: int[], rj: int, apv: int[], bpv: int[], k: int, scratch: int[]) { basemul_mont(rpv, rj, apv, 0, bpv, 0) i = 1 while i < k { @@ -411,12 +410,12 @@ polyvec_pointwise_acc(rpv: ptr, rj: int, apv: ptr, bpv: ptr, k: int, scratch: pt // ---- 12-bit (de)serialization of NTT polys ---- // Pack poly j into 384 bytes at out[off..]. -poly_to_bytes(pv: ptr, j: int, out: ptr, off: int) { +poly_to_bytes(pv: int[], j: int, out: ptr, off: int) { base = j * N i = 0 while i < 128 { - t0 = cond_sub_q(intarr_get_unchecked(pv, base + 2 * i)) & 0xFFFF - t1 = cond_sub_q(intarr_get_unchecked(pv, base + 2 * i + 1)) & 0xFFFF + t0 = cond_sub_q(pv[base + 2 * i]) & 0xFFFF + t1 = cond_sub_q(pv[base + 2 * i + 1]) & 0xFFFF bytes.set(out, off + 3 * i + 0, t0 & 0xFF) bytes.set(out, off + 3 * i + 1, (bits.lsr32(t0, 8) | (t1 << 4)) & 0xFF) bytes.set(out, off + 3 * i + 2, bits.lsr32(t1, 4) & 0xFF) @@ -424,7 +423,7 @@ poly_to_bytes(pv: ptr, j: int, out: ptr, off: int) { } } -poly_from_bytes(pv: ptr, j: int, src: ptr, off: int) { +poly_from_bytes(pv: int[], j: int, src: ptr, off: int) { base = j * N i = 0 while i < 128 { @@ -433,20 +432,20 @@ poly_from_bytes(pv: ptr, j: int, src: ptr, off: int) { a2 = bytes.get(src, off + 3 * i + 2) & 0xFF c0 = ((a0) | (a1 << 8)) & 0xFFF c1 = (bits.lsr32(a1, 4) | (a2 << 4)) & 0xFFF - intarr_set_unchecked(pv, base + 2 * i, s16(c0)) - intarr_set_unchecked(pv, base + 2 * i + 1, s16(c1)) + pv[base + 2 * i] = s16(c0) + pv[base + 2 * i + 1] = s16(c1) i = i + 1 } } -polyvec_to_bytes(pv: ptr, k: int, out: ptr, off: int) { +polyvec_to_bytes(pv: int[], k: int, out: ptr, off: int) { i = 0 while i < k { poly_to_bytes(pv, i, out, off + i * POLY_BYTES) i = i + 1 } } -polyvec_from_bytes(pv: ptr, k: int, src: ptr, off: int) { +polyvec_from_bytes(pv: int[], k: int, src: ptr, off: int) { i = 0 while i < k { poly_from_bytes(pv, i, src, off + i * POLY_BYTES) @@ -456,7 +455,7 @@ polyvec_from_bytes(pv: ptr, k: int, src: ptr, off: int) { // ---- Message <-> poly ---- -poly_from_msg(pv: ptr, j: int, msg: ptr, off: int) { +poly_from_msg(pv: int[], j: int, msg: ptr, off: int) { base = j * N half = (Q + 1) / 2 // 1665 i = 0 @@ -466,21 +465,21 @@ poly_from_msg(pv: ptr, j: int, msg: ptr, off: int) { while b < 8 { bit = (bits.lsr32(mi, b)) & 1 mask = 0 - bit // 0 or -1 - intarr_set_unchecked(pv, base + 8 * i + b, s16(mask & half)) + pv[base + 8 * i + b] = s16(mask & half) b = b + 1 } i = i + 1 } } -poly_to_msg(pv: ptr, j: int, msg: ptr, off: int) { +poly_to_msg(pv: int[], j: int, msg: ptr, off: int) { base = j * N lower = Q >> 2 // 832 upper = Q - lower // 2497 // cond_sub_q first i = 0 while i < 256 { - intarr_set_unchecked(pv, base + i, cond_sub_q(intarr_get_unchecked(pv, base + i))) + pv[base + i] = cond_sub_q(pv[base + i]) i = i + 1 } i = 0 @@ -488,7 +487,7 @@ poly_to_msg(pv: ptr, j: int, msg: ptr, off: int) { mi = 0 b = 0 while b < 8 { - cj = intarr_get_unchecked(pv, base + 8 * i + b) + cj = pv[base + 8 * i + b] // t = ((lower - cj) & (cj - upper)) >>> 31 (uint), 1 iff cj in (lower,upper) prod = (lower - cj) & (cj - upper) t = bits.lsr32(prod, 31) & 1 @@ -503,25 +502,25 @@ poly_to_msg(pv: ptr, j: int, msg: ptr, off: int) { // ---- d_v compression of v poly ---- // dv=4: 128 bytes for the whole poly. -compress_poly_128(pv: ptr, j: int, out: ptr, off: int) { +compress_poly_128(pv: int[], j: int, out: ptr, off: int) { base = j * N // cond_sub_q i = 0 while i < 256 { - intarr_set_unchecked(pv, base + i, cond_sub_q(intarr_get_unchecked(pv, base + i))) + pv[base + i] = cond_sub_q(pv[base + i]) i = i + 1 } pos = off i = 0 while i < 32 { // N/8 - t0 = compress4(intarr_get_unchecked(pv, base + 8 * i + 0)) - t1 = compress4(intarr_get_unchecked(pv, base + 8 * i + 1)) - t2 = compress4(intarr_get_unchecked(pv, base + 8 * i + 2)) - t3 = compress4(intarr_get_unchecked(pv, base + 8 * i + 3)) - t4 = compress4(intarr_get_unchecked(pv, base + 8 * i + 4)) - t5 = compress4(intarr_get_unchecked(pv, base + 8 * i + 5)) - t6 = compress4(intarr_get_unchecked(pv, base + 8 * i + 6)) - t7 = compress4(intarr_get_unchecked(pv, base + 8 * i + 7)) + t0 = compress4(pv[base + 8 * i + 0]) + t1 = compress4(pv[base + 8 * i + 1]) + t2 = compress4(pv[base + 8 * i + 2]) + t3 = compress4(pv[base + 8 * i + 3]) + t4 = compress4(pv[base + 8 * i + 4]) + t5 = compress4(pv[base + 8 * i + 5]) + t6 = compress4(pv[base + 8 * i + 6]) + t7 = compress4(pv[base + 8 * i + 7]) bytes.set(out, pos + 0, (t0 | (t1 << 4)) & 0xFF) bytes.set(out, pos + 1, (t2 | (t3 << 4)) & 0xFF) bytes.set(out, pos + 2, (t4 | (t5 << 4)) & 0xFF) @@ -534,38 +533,38 @@ compress4(c: int) -> int { return (((c + (Q >> 5)) * 315) >> 16) & 0xF } -decompress_poly_128(pv: ptr, j: int, src: ptr, off: int) { +decompress_poly_128(pv: int[], j: int, src: ptr, off: int) { base = j * N pos = off i = 0 while i < 128 { // N/2 byte_v = bytes.get(src, pos) & 0xFF - intarr_set_unchecked(pv, base + 2 * i + 0, s16((((byte_v & 15) * Q) + 8) >> 4)) - intarr_set_unchecked(pv, base + 2 * i + 1, s16(((bits.lsr32(byte_v, 4) * Q) + 8) >> 4)) + pv[base + 2 * i + 0] = s16((((byte_v & 15) * Q) + 8) >> 4) + pv[base + 2 * i + 1] = s16(((bits.lsr32(byte_v, 4) * Q) + 8) >> 4) pos = pos + 1 i = i + 1 } } // dv=5: 160 bytes. -compress_poly_160(pv: ptr, j: int, out: ptr, off: int) { +compress_poly_160(pv: int[], j: int, out: ptr, off: int) { base = j * N i = 0 while i < 256 { - intarr_set_unchecked(pv, base + i, cond_sub_q(intarr_get_unchecked(pv, base + i))) + pv[base + i] = cond_sub_q(pv[base + i]) i = i + 1 } pos = off i = 0 while i < 32 { - t0 = compress5(intarr_get_unchecked(pv, base + 8 * i + 0)) - t1 = compress5(intarr_get_unchecked(pv, base + 8 * i + 1)) - t2 = compress5(intarr_get_unchecked(pv, base + 8 * i + 2)) - t3 = compress5(intarr_get_unchecked(pv, base + 8 * i + 3)) - t4 = compress5(intarr_get_unchecked(pv, base + 8 * i + 4)) - t5 = compress5(intarr_get_unchecked(pv, base + 8 * i + 5)) - t6 = compress5(intarr_get_unchecked(pv, base + 8 * i + 6)) - t7 = compress5(intarr_get_unchecked(pv, base + 8 * i + 7)) + t0 = compress5(pv[base + 8 * i + 0]) + t1 = compress5(pv[base + 8 * i + 1]) + t2 = compress5(pv[base + 8 * i + 2]) + t3 = compress5(pv[base + 8 * i + 3]) + t4 = compress5(pv[base + 8 * i + 4]) + t5 = compress5(pv[base + 8 * i + 5]) + t6 = compress5(pv[base + 8 * i + 6]) + t7 = compress5(pv[base + 8 * i + 7]) bytes.set(out, pos + 0, (t0 | (t1 << 5)) & 0xFF) bytes.set(out, pos + 1, (bits.lsr32(t1, 3) | (t2 << 2) | (t3 << 7)) & 0xFF) bytes.set(out, pos + 2, (bits.lsr32(t3, 1) | (t4 << 4)) & 0xFF) @@ -579,7 +578,7 @@ compress5(c: int) -> int { return (((c + (Q >> 6)) * 630) >> 16) & 0x1F } -decompress_poly_160(pv: ptr, j: int, src: ptr, off: int) { +decompress_poly_160(pv: int[], j: int, src: ptr, off: int) { base = j * N pos = off i = 0 @@ -598,14 +597,14 @@ decompress_poly_160(pv: ptr, j: int, src: ptr, off: int) { t6 = (bits.lsr32(c3, 6) | (c4 << 2)) & 0x1F t7 = bits.lsr32(c4, 3) & 0x1F pos = pos + 5 - intarr_set_unchecked(pv, base + 8 * i + 0, s16(((t0 * Q) + 16) >> 5)) - intarr_set_unchecked(pv, base + 8 * i + 1, s16(((t1 * Q) + 16) >> 5)) - intarr_set_unchecked(pv, base + 8 * i + 2, s16(((t2 * Q) + 16) >> 5)) - intarr_set_unchecked(pv, base + 8 * i + 3, s16(((t3 * Q) + 16) >> 5)) - intarr_set_unchecked(pv, base + 8 * i + 4, s16(((t4 * Q) + 16) >> 5)) - intarr_set_unchecked(pv, base + 8 * i + 5, s16(((t5 * Q) + 16) >> 5)) - intarr_set_unchecked(pv, base + 8 * i + 6, s16(((t6 * Q) + 16) >> 5)) - intarr_set_unchecked(pv, base + 8 * i + 7, s16(((t7 * Q) + 16) >> 5)) + pv[base + 8 * i + 0] = s16(((t0 * Q) + 16) >> 5) + pv[base + 8 * i + 1] = s16(((t1 * Q) + 16) >> 5) + pv[base + 8 * i + 2] = s16(((t2 * Q) + 16) >> 5) + pv[base + 8 * i + 3] = s16(((t3 * Q) + 16) >> 5) + pv[base + 8 * i + 4] = s16(((t4 * Q) + 16) >> 5) + pv[base + 8 * i + 5] = s16(((t5 * Q) + 16) >> 5) + pv[base + 8 * i + 6] = s16(((t6 * Q) + 16) >> 5) + pv[base + 8 * i + 7] = s16(((t7 * Q) + 16) >> 5) i = i + 1 } } @@ -613,7 +612,7 @@ decompress_poly_160(pv: ptr, j: int, src: ptr, off: int) { // ---- d_u compression of b polyvec ---- // du=10: k*320 bytes. -compress_polyvec_320(pv: ptr, k: int, out: ptr, off: int) { +compress_polyvec_320(pv: int[], k: int, out: ptr, off: int) { // cond_sub_q whole vector j = 0 while j < k { @@ -653,7 +652,7 @@ compress10(c: int) -> int { return hi & 0x3FF } -decompress_polyvec_320(pv: ptr, k: int, src: ptr, off: int) { +decompress_polyvec_320(pv: int[], k: int, src: ptr, off: int) { pos = off j = 0 while j < k { @@ -680,7 +679,7 @@ decompress_polyvec_320(pv: ptr, k: int, src: ptr, off: int) { } // du=11: k*352 bytes. -compress_polyvec_352(pv: ptr, k: int, out: ptr, off: int) { +compress_polyvec_352(pv: int[], k: int, out: ptr, off: int) { j = 0 while j < k { i = 0 @@ -728,7 +727,7 @@ compress11(c: int) -> int { return hi & 0x7FF } -decompress_polyvec_352(pv: ptr, k: int, src: ptr, off: int) { +decompress_polyvec_352(pv: int[], k: int, src: ptr, off: int) { pos = off j = 0 while j < k { @@ -770,7 +769,7 @@ decompress_polyvec_352(pv: ptr, k: int, src: ptr, off: int) { // ---- CBD noise sampling ---- -cbd_eta2(pv: ptr, j: int, buf: ptr) { +cbd_eta2(pv: int[], j: int, buf: ptr) { base = j * N i = 0 while i < 32 { // N/8 @@ -781,14 +780,14 @@ cbd_eta2(pv: ptr, j: int, buf: ptr) { while jj < 8 { a = (bits.lsr32(d, 4 * jj + 0)) & 0x3 b = (bits.lsr32(d, 4 * jj + 2)) & 0x3 - intarr_set_unchecked(pv, base + 8 * i + jj, s16(a - b)) + pv[base + 8 * i + jj] = s16(a - b) jj = jj + 1 } i = i + 1 } } -cbd_eta3(pv: ptr, j: int, buf: ptr) { +cbd_eta3(pv: int[], j: int, buf: ptr) { base = j * N i = 0 while i < 64 { // N/4 @@ -804,7 +803,7 @@ cbd_eta3(pv: ptr, j: int, buf: ptr) { while jj < 4 { a = (bits.lsr32(d, 6 * jj + 0)) & 0x7 b = (bits.lsr32(d, 6 * jj + 3)) & 0x7 - intarr_set_unchecked(pv, base + 4 * i + jj, s16(a - b)) + pv[base + 4 * i + jj] = s16(a - b) jj = jj + 1 } i = i + 1 @@ -812,7 +811,7 @@ cbd_eta3(pv: ptr, j: int, buf: ptr) { } // PRF: SHAKE256(seed[0..32] || nonce), eta2 -> 128 bytes, eta3 -> 192 bytes. -get_noise(pv: ptr, j: int, seed: ptr, seed_off: int, nonce: int, eta: int) { +get_noise(pv: int[], j: int, seed: ptr, seed_off: int, nonce: int, eta: int) { in_buf = bytes.new(SYM + 1) i = 0 while i < SYM { @@ -837,7 +836,7 @@ get_noise(pv: ptr, j: int, seed: ptr, seed_off: int, nonce: int, eta: int) { // ---- Matrix A generation via SHAKE128 rejection sampling ---- // Fill poly a[i][j] (flat) by rejection sampling from SHAKE128(seed || x || y). -gen_matrix_poly(dest: ptr, dj: int, seed: ptr, x: int, y: int) { +gen_matrix_poly(dest: int[], dj: int, seed: ptr, x: int, y: int) { in_buf = bytes.new(SYM + 2) i = 0 while i < SYM { @@ -872,13 +871,13 @@ gen_matrix_poly(dest: ptr, dj: int, seed: ptr, x: int, y: int) { pos = pos + 3 if d1 < Q { if ctr < N { - intarr_set_unchecked(dest, base + ctr, d1) + dest[base + ctr] = d1 ctr = ctr + 1 } } if d2 < Q { if ctr < N { - intarr_set_unchecked(dest, base + ctr, d2) + dest[base + ctr] = d2 ctr = ctr + 1 } } @@ -886,9 +885,9 @@ gen_matrix_poly(dest: ptr, dj: int, seed: ptr, x: int, y: int) { bytes.free(buf) } -// Generate the k*k matrix into `mat` (flat intarr of N*k*k). +// Generate the k*k matrix into `mat` (a flat int[] of N*k*k). // mat poly (i,j) lives at slot (i*k + j) of N coeffs. -gen_matrix(mat: ptr, seed: ptr, k: int, transpose: int) { +gen_matrix(mat: int[], seed: ptr, k: int, transpose: int) { i = 0 while i < k { j = 0 @@ -934,7 +933,7 @@ indcpa_keygen(p: *MlkemParams, d: ptr, kp: ptr) { bytes.free(gbuf) // Matrix A (non-transposed) - mat = intarr_new_raw(N * k * k) + mat = make([]int, N * k * k) gen_matrix(mat, rho, k, 0) skpv = polyvec_new(k) @@ -970,7 +969,6 @@ indcpa_keygen(p: *MlkemParams, d: ptr, kp: ptr) { // pkpv[i] = row = mat_row(mat, i, k) polyvec_pointwise_acc(pkpv, i, row, skpv, k, scratch) - polyvec_free(row) poly_to_mont(pkpv, i) i = i + 1 } @@ -993,32 +991,19 @@ indcpa_keygen(p: *MlkemParams, d: ptr, kp: ptr) { bytes.free(rho) bytes.free(sigma) - intarr_free(mat) + free(mat) polyvec_free(skpv) polyvec_free(e) polyvec_free(pkpv) poly_free(scratch) } -// Matrix `mat` is a flat intarr of N*k*k coefficients; poly (i,j) lives at -// flat slot (i*k + j). Return a freshly-allocated k-poly polyvec copy of row i -// (slots i*k .. i*k+k-1) so the standard polyvec accessors apply. The caller -// MUST free the returned polyvec. -mat_row(mat: ptr, i: int, k: int) -> ptr { - rowpv = polyvec_new(k) - j = 0 - while j < k { - src_slot = i * k + j - sb = src_slot * N - db = j * N - c = 0 - while c < 256 { - intarr_set_unchecked(rowpv, db + c, intarr_get_unchecked(mat, sb + c)) - c = c + 1 - } - j = j + 1 - } - return rowpv +// Matrix `mat` is a flat int[] of N*k*k coefficients; poly (i,j) lives at +// flat slot (i*k + j), so row i (slots i*k .. i*k+k-1) is one contiguous +// run: a k-poly polyvec viewing the matrix, no copy. It is a view: the +// caller does not free it. +mat_row(mat: int[], i: int, k: int) -> int[] { + return mat[i * k * N..(i * k + k) * N] } // ---- K-PKE encrypt ---- @@ -1042,7 +1027,7 @@ indcpa_encrypt(p: *MlkemParams, pk: ptr, pk_off: int, msg: ptr, msg_off: int, co poly_from_msg(kp_pv, 0, msg, msg_off) // Matrix A transposed - mat = intarr_new_raw(N * k * k) + mat = make([]int, N * k * k) gen_matrix(mat, rho, k, 1) sp = polyvec_new(k) @@ -1077,7 +1062,6 @@ indcpa_encrypt(p: *MlkemParams, pk: ptr, pk_off: int, msg: ptr, msg_off: int, co while i < k { row = mat_row(mat, i, k) polyvec_pointwise_acc(bp, i, row, sp, k, scratch) - polyvec_free(row) i = i + 1 } @@ -1119,7 +1103,7 @@ indcpa_encrypt(p: *MlkemParams, pk: ptr, pk_off: int, msg: ptr, msg_off: int, co } bytes.free(rho) - intarr_free(mat) + free(mat) polyvec_free(pkpv) polyvec_free(kp_pv) polyvec_free(sp) diff --git a/std/cryptography/p256/module.ae b/std/cryptography/p256/module.ae index 43963181c..e78ed6e56 100644 --- a/std/cryptography/p256/module.ae +++ b/std/cryptography/p256/module.ae @@ -230,7 +230,13 @@ jac_mul(k: ptr, P: *EcPt, p: ptr) -> *EcPt { dbl = pt_double(q, p) pt_free(q) q = dbl - if bit_at(k, i) == 1 { + // bignum.test_bit reads the limb in place. Reading each scalar bit as + // `bit0(shift_right(k, i))` made a 256-bit allocation and a full + // `mod 2` per bit, ~256 times per scalar multiplication: one P-256 + // verify cost ~2 s and a depth-4 all-ECDSA chain (Cloudflare's) + // ~13 s, long enough to trip a peer's handshake deadline. Every + // scalar reaching here is reduced mod n, so it is non-negative. + if bignum.test_bit(k, i) == 1 { sum = pt_add(q, P, p) pt_free(q) q = sum @@ -240,25 +246,6 @@ jac_mul(k: ptr, P: *EcPt, p: ptr) -> *EcPt { return q } -// Bit i of a non-negative bignum, read straight from its limb array in O(1). -// -// jac_mul used to read each scalar bit as `bit0(shift_right(k, i))`: a fresh -// 256-bit allocation per bit, then a full `mod 2` on it, ~256 times per -// scalar multiplication. That overhead, not the curve arithmetic, made one -// P-256 verify cost ~2 s and a depth-4 all-ECDSA chain (Cloudflare's) ~13 s -// -- long enough to trip the peer's handshake deadline and fail the TLS -// connect outright. Every scalar reaching here is reduced mod n, so it is -// non-negative and `sign` needs no handling. Limbs are 32-bit, big-endian -// (mag[0] is the MSB), matching std.bignum's BN layout. -bit_at(n: ptr, i: int) -> int { - bn = n as *BN - if bn.len <= 0 || i < 0 { return 0 } - li = bn.len - 1 - (i / 32) - if li < 0 { return 0 } - w = bignum.limb(bn.mag, li) - return ((w >> (i % 32)) & 1) as int -} - bit0(n: ptr) -> int { if bignum.is_zero(n) == 1 { return 0 } two = bignum.from_int(2) diff --git a/std/cryptography/ripemd128/module.ae b/std/cryptography/ripemd128/module.ae index b981a3d31..9b90a4a0c 100644 --- a/std/cryptography/ripemd128/module.ae +++ b/std/cryptography/ripemd128/module.ae @@ -23,7 +23,6 @@ import std.bits import std.bytes -import std.intarr extern malloc(size: int) -> ptr extern free(p: ptr) @@ -43,7 +42,7 @@ struct Rmd128Ctx { block_len: int count_lo: long count_hi: long - x: ptr // intarr(16) loaded message words + x: int[16] // loaded message words } m32(x: long) -> long { @@ -68,7 +67,7 @@ f4(x: long, y: long, z: long) -> long { } xg(ctx: *Rmd128Ctx, i: int) -> long { - return intarr_get_unchecked(ctx.x, i) & 0xFFFFFFFF + return ctx.x[i] & 0xFFFFFFFF } // Left-line step functions: a = RL(a + Fi(b,c,d) + x + K, s) @@ -279,7 +278,6 @@ new() -> { bytes.set(ctx.block, j, 0) j = j + 1 } - ctx.x = intarr_new_raw(16) return ctx, "" } @@ -287,7 +285,7 @@ absorb_block(c: *Rmd128Ctx) { i = 0 while i < 16 { w = bytes.get_le32(c.block, i * 4) - intarr_set_unchecked(c.x, i, w) + c.x[i] = w i = i + 1 } process_block(c) @@ -360,10 +358,6 @@ free_internals(c: *Rmd128Ctx) { bytes.free(c.block) c.block = null } - if c.x != null { - intarr_free(c.x) - c.x = null - } } // Finalize and return the raw digest as a std.bytes buffer. FREES the diff --git a/std/cryptography/ripemd160/module.ae b/std/cryptography/ripemd160/module.ae index 00d371622..e0efabc81 100644 --- a/std/cryptography/ripemd160/module.ae +++ b/std/cryptography/ripemd160/module.ae @@ -29,7 +29,6 @@ import std.bits import std.bytes -import std.intarr extern malloc(size: int) -> ptr extern free(p: ptr) @@ -53,7 +52,7 @@ struct Rmd160Ctx { block_len: int // bytes buffered (0..63 between blocks) count_lo: long // total message length in bytes (low 64 bits) count_hi: long // high bits of the byte count - x: ptr // intarr(16) loaded message words + x: int[16] // loaded message words } m32(x: long) -> long { @@ -83,7 +82,7 @@ f5(x: long, y: long, z: long) -> long { } xg(ctx: *Rmd160Ctx, i: int) -> long { - return intarr_get_unchecked(ctx.x, i) & 0xFFFFFFFF + return ctx.x[i] & 0xFFFFFFFF } // Process one 64-byte block: dual-line (left/right) 80-round compression. @@ -323,7 +322,6 @@ new() -> { bytes.set(ctx.block, j, 0) j = j + 1 } - ctx.x = intarr_new_raw(16) return ctx, "" } @@ -333,7 +331,7 @@ absorb_block(c: *Rmd160Ctx) { i = 0 while i < 16 { w = bytes.get_le32(c.block, i * 4) - intarr_set_unchecked(c.x, i, w) + c.x[i] = w i = i + 1 } process_block(c) @@ -413,10 +411,6 @@ free_internals(c: *Rmd160Ctx) { bytes.free(c.block) c.block = null } - if c.x != null { - intarr_free(c.x) - c.x = null - } } // Finalize, return the raw 20-byte digest as a bytes buffer. FREES the ctx. diff --git a/std/cryptography/ripemd256/module.ae b/std/cryptography/ripemd256/module.ae index ebe0c2727..8fe9b6a24 100644 --- a/std/cryptography/ripemd256/module.ae +++ b/std/cryptography/ripemd256/module.ae @@ -23,7 +23,6 @@ import std.bits import std.bytes -import std.intarr extern malloc(size: int) -> ptr extern free(p: ptr) @@ -47,7 +46,7 @@ struct Rmd256Ctx { block_len: int count_lo: long count_hi: long - x: ptr // intarr(16) + x: int[16] // loaded message words } m32(x: long) -> long { @@ -69,7 +68,7 @@ f4(x: long, y: long, z: long) -> long { return ((x & z) | (y & (~z))) & 0xFFFFFFFF } xg(ctx: *Rmd256Ctx, i: int) -> long { - return intarr_get_unchecked(ctx.x, i) & 0xFFFFFFFF + return ctx.x[i] & 0xFFFFFFFF } // Left-line step functions (same as RIPEMD-128). @@ -289,7 +288,6 @@ new() -> { bytes.set(ctx.block, j, 0) j = j + 1 } - ctx.x = intarr_new_raw(16) return ctx, "" } @@ -297,7 +295,7 @@ absorb_block(c: *Rmd256Ctx) { i = 0 while i < 16 { w = bytes.get_le32(c.block, i * 4) - intarr_set_unchecked(c.x, i, w) + c.x[i] = w i = i + 1 } process_block(c) @@ -374,10 +372,6 @@ free_internals(c: *Rmd256Ctx) { bytes.free(c.block) c.block = null } - if c.x != null { - intarr_free(c.x) - c.x = null - } } // Finalize and return the raw digest as a std.bytes buffer. FREES the diff --git a/std/cryptography/ripemd320/module.ae b/std/cryptography/ripemd320/module.ae index 9c7a8b324..5a6a3ba0d 100644 --- a/std/cryptography/ripemd320/module.ae +++ b/std/cryptography/ripemd320/module.ae @@ -22,7 +22,6 @@ import std.bits import std.bytes -import std.intarr extern malloc(size: int) -> ptr extern free(p: ptr) @@ -48,7 +47,7 @@ struct Rmd320Ctx { block_len: int count_lo: long count_hi: long - x: ptr // intarr(16) + x: int[16] // loaded message words } m32(x: long) -> long { @@ -73,7 +72,7 @@ f5(x: long, y: long, z: long) -> long { return (x ^ (y | (~z))) & 0xFFFFFFFF } xg(ctx: *Rmd320Ctx, i: int) -> long { - return intarr_get_unchecked(ctx.x, i) & 0xFFFFFFFF + return ctx.x[i] & 0xFFFFFFFF } process_block(ctx: *Rmd320Ctx) { @@ -322,7 +321,6 @@ new() -> { bytes.set(ctx.block, j, 0) j = j + 1 } - ctx.x = intarr_new_raw(16) return ctx, "" } @@ -330,7 +328,7 @@ absorb_block(c: *Rmd320Ctx) { i = 0 while i < 16 { w = bytes.get_le32(c.block, i * 4) - intarr_set_unchecked(c.x, i, w) + c.x[i] = w i = i + 1 } process_block(c) @@ -409,10 +407,6 @@ free_internals(c: *Rmd320Ctx) { bytes.free(c.block) c.block = null } - if c.x != null { - intarr_free(c.x) - c.x = null - } } // Finalize and return the raw digest as a std.bytes buffer. FREES the diff --git a/std/cryptography/sha1/module.ae b/std/cryptography/sha1/module.ae index 2d3317ca3..1f1ffc14f 100644 --- a/std/cryptography/sha1/module.ae +++ b/std/cryptography/sha1/module.ae @@ -9,7 +9,6 @@ import std.bits import std.bytes -import std.intarr extern malloc(size: int) -> ptr extern free(p: ptr) @@ -29,7 +28,7 @@ struct Sha1Ctx { block: ptr // std.bytes buffer, 64 bytes block_len: int count_lo: long - w: ptr // intarr(80) message schedule + w: int[80] // message schedule } m32(x: long) -> long { @@ -53,24 +52,23 @@ G(u: long, v: long, w: long) -> long { } process_block(c: *Sha1Ctx) { - w = c.w // Load 16 big-endian words i = 0 while i < 16 { word = bytes.get_be32(c.block, i * 4) & 0xFFFFFFFF - intarr_set_unchecked(w, i, word) + c.w[i] = word i = i + 1 } // Expand to 80 words i = 16 while i < 80 { - w3 = intarr_get_unchecked(w, i - 3) & 0xFFFFFFFF - w8 = intarr_get_unchecked(w, i - 8) & 0xFFFFFFFF - w14 = intarr_get_unchecked(w, i - 14) & 0xFFFFFFFF - w16 = intarr_get_unchecked(w, i - 16) & 0xFFFFFFFF + w3 = c.w[i - 3] & 0xFFFFFFFF + w8 = c.w[i - 8] & 0xFFFFFFFF + w14 = c.w[i - 14] & 0xFFFFFFFF + w16 = c.w[i - 16] & 0xFFFFFFFF t = w3 ^ w8 ^ w14 ^ w16 rot = rl(t, 1) - intarr_set_unchecked(w, i, rot) + c.w[i] = rot i = i + 1 } @@ -83,92 +81,92 @@ process_block(c: *Sha1Ctx) { // Round 1 idx = 0 while idx < 20 { - e = m32(e + rl(a, 5) + F(b, cc, d) + (intarr_get_unchecked(w, idx) & 0xFFFFFFFF) + 0x5a827999) + e = m32(e + rl(a, 5) + F(b, cc, d) + (c.w[idx] & 0xFFFFFFFF) + 0x5a827999) b = rl(b, 30) idx = idx + 1 - d = m32(d + rl(e, 5) + F(a, b, cc) + (intarr_get_unchecked(w, idx) & 0xFFFFFFFF) + 0x5a827999) + d = m32(d + rl(e, 5) + F(a, b, cc) + (c.w[idx] & 0xFFFFFFFF) + 0x5a827999) a = rl(a, 30) idx = idx + 1 - cc = m32(cc + rl(d, 5) + F(e, a, b) + (intarr_get_unchecked(w, idx) & 0xFFFFFFFF) + 0x5a827999) + cc = m32(cc + rl(d, 5) + F(e, a, b) + (c.w[idx] & 0xFFFFFFFF) + 0x5a827999) e = rl(e, 30) idx = idx + 1 - b = m32(b + rl(cc, 5) + F(d, e, a) + (intarr_get_unchecked(w, idx) & 0xFFFFFFFF) + 0x5a827999) + b = m32(b + rl(cc, 5) + F(d, e, a) + (c.w[idx] & 0xFFFFFFFF) + 0x5a827999) d = rl(d, 30) idx = idx + 1 - a = m32(a + rl(b, 5) + F(cc, d, e) + (intarr_get_unchecked(w, idx) & 0xFFFFFFFF) + 0x5a827999) + a = m32(a + rl(b, 5) + F(cc, d, e) + (c.w[idx] & 0xFFFFFFFF) + 0x5a827999) cc = rl(cc, 30) idx = idx + 1 } // Round 2 while idx < 40 { - e = m32(e + rl(a, 5) + H(b, cc, d) + (intarr_get_unchecked(w, idx) & 0xFFFFFFFF) + 0x6ed9eba1) + e = m32(e + rl(a, 5) + H(b, cc, d) + (c.w[idx] & 0xFFFFFFFF) + 0x6ed9eba1) b = rl(b, 30) idx = idx + 1 - d = m32(d + rl(e, 5) + H(a, b, cc) + (intarr_get_unchecked(w, idx) & 0xFFFFFFFF) + 0x6ed9eba1) + d = m32(d + rl(e, 5) + H(a, b, cc) + (c.w[idx] & 0xFFFFFFFF) + 0x6ed9eba1) a = rl(a, 30) idx = idx + 1 - cc = m32(cc + rl(d, 5) + H(e, a, b) + (intarr_get_unchecked(w, idx) & 0xFFFFFFFF) + 0x6ed9eba1) + cc = m32(cc + rl(d, 5) + H(e, a, b) + (c.w[idx] & 0xFFFFFFFF) + 0x6ed9eba1) e = rl(e, 30) idx = idx + 1 - b = m32(b + rl(cc, 5) + H(d, e, a) + (intarr_get_unchecked(w, idx) & 0xFFFFFFFF) + 0x6ed9eba1) + b = m32(b + rl(cc, 5) + H(d, e, a) + (c.w[idx] & 0xFFFFFFFF) + 0x6ed9eba1) d = rl(d, 30) idx = idx + 1 - a = m32(a + rl(b, 5) + H(cc, d, e) + (intarr_get_unchecked(w, idx) & 0xFFFFFFFF) + 0x6ed9eba1) + a = m32(a + rl(b, 5) + H(cc, d, e) + (c.w[idx] & 0xFFFFFFFF) + 0x6ed9eba1) cc = rl(cc, 30) idx = idx + 1 } // Round 3 while idx < 60 { - e = m32(e + rl(a, 5) + G(b, cc, d) + (intarr_get_unchecked(w, idx) & 0xFFFFFFFF) + 0x8f1bbcdc) + e = m32(e + rl(a, 5) + G(b, cc, d) + (c.w[idx] & 0xFFFFFFFF) + 0x8f1bbcdc) b = rl(b, 30) idx = idx + 1 - d = m32(d + rl(e, 5) + G(a, b, cc) + (intarr_get_unchecked(w, idx) & 0xFFFFFFFF) + 0x8f1bbcdc) + d = m32(d + rl(e, 5) + G(a, b, cc) + (c.w[idx] & 0xFFFFFFFF) + 0x8f1bbcdc) a = rl(a, 30) idx = idx + 1 - cc = m32(cc + rl(d, 5) + G(e, a, b) + (intarr_get_unchecked(w, idx) & 0xFFFFFFFF) + 0x8f1bbcdc) + cc = m32(cc + rl(d, 5) + G(e, a, b) + (c.w[idx] & 0xFFFFFFFF) + 0x8f1bbcdc) e = rl(e, 30) idx = idx + 1 - b = m32(b + rl(cc, 5) + G(d, e, a) + (intarr_get_unchecked(w, idx) & 0xFFFFFFFF) + 0x8f1bbcdc) + b = m32(b + rl(cc, 5) + G(d, e, a) + (c.w[idx] & 0xFFFFFFFF) + 0x8f1bbcdc) d = rl(d, 30) idx = idx + 1 - a = m32(a + rl(b, 5) + G(cc, d, e) + (intarr_get_unchecked(w, idx) & 0xFFFFFFFF) + 0x8f1bbcdc) + a = m32(a + rl(b, 5) + G(cc, d, e) + (c.w[idx] & 0xFFFFFFFF) + 0x8f1bbcdc) cc = rl(cc, 30) idx = idx + 1 } // Round 4 while idx < 80 { - e = m32(e + rl(a, 5) + H(b, cc, d) + (intarr_get_unchecked(w, idx) & 0xFFFFFFFF) + 0xca62c1d6) + e = m32(e + rl(a, 5) + H(b, cc, d) + (c.w[idx] & 0xFFFFFFFF) + 0xca62c1d6) b = rl(b, 30) idx = idx + 1 - d = m32(d + rl(e, 5) + H(a, b, cc) + (intarr_get_unchecked(w, idx) & 0xFFFFFFFF) + 0xca62c1d6) + d = m32(d + rl(e, 5) + H(a, b, cc) + (c.w[idx] & 0xFFFFFFFF) + 0xca62c1d6) a = rl(a, 30) idx = idx + 1 - cc = m32(cc + rl(d, 5) + H(e, a, b) + (intarr_get_unchecked(w, idx) & 0xFFFFFFFF) + 0xca62c1d6) + cc = m32(cc + rl(d, 5) + H(e, a, b) + (c.w[idx] & 0xFFFFFFFF) + 0xca62c1d6) e = rl(e, 30) idx = idx + 1 - b = m32(b + rl(cc, 5) + H(d, e, a) + (intarr_get_unchecked(w, idx) & 0xFFFFFFFF) + 0xca62c1d6) + b = m32(b + rl(cc, 5) + H(d, e, a) + (c.w[idx] & 0xFFFFFFFF) + 0xca62c1d6) d = rl(d, 30) idx = idx + 1 - a = m32(a + rl(b, 5) + H(cc, d, e) + (intarr_get_unchecked(w, idx) & 0xFFFFFFFF) + 0xca62c1d6) + a = m32(a + rl(b, 5) + H(cc, d, e) + (c.w[idx] & 0xFFFFFFFF) + 0xca62c1d6) cc = rl(cc, 30) idx = idx + 1 } @@ -198,7 +196,6 @@ new() -> { bytes.set(ctx.block, j, 0) j = j + 1 } - ctx.w = intarr_new_raw(80) return ctx, "" } @@ -257,10 +254,6 @@ free_internals(c: *Sha1Ctx) { bytes.free(c.block) c.block = null } - if c.w != null { - intarr_free(c.w) - c.w = null - } } final_bytes(ctx: ptr) -> ptr { diff --git a/std/cryptography/sha2/module.ae b/std/cryptography/sha2/module.ae index 68ad57ce5..d3d74479f 100644 --- a/std/cryptography/sha2/module.ae +++ b/std/cryptography/sha2/module.ae @@ -26,7 +26,6 @@ import std.bits import std.bytes -import std.longarr extern malloc(size: int) -> ptr extern free(p: ptr) @@ -62,7 +61,7 @@ struct Sha2Ctx { block_len: int // bytes currently buffered (0..block_size-1 between blocks) count_lo: long // total message length in bytes (low 64 bits) count_hi: long // high bits of the byte count (for SHA-512's 128-bit length) - w: ptr // longarr(80) message schedule + w: long[80] // message schedule, 80 words (64 used by the 32-bit core) is512: int // 1 => 64-bit core, 0 => 32-bit core block_size: int // 64 (sha256 family) or 128 (sha512 family) digest_len: int // output length in bytes (28/32/48/64/...) @@ -169,28 +168,27 @@ maj64(x: long, y: long, z: long) -> long { // 32-bit words out of the block buffer, expands to 64, runs the 64 // rounds, folds into the hash state. Every 32-bit add is masked. process_block_256(ctx: *Sha2Ctx) { - w = ctx.w // Load 16 words, big-endian. i = 0 while i < 16 { word = bytes.get_be32(ctx.block, i * 4) // get_be32 may return a sign-extended negative int for the high // bit; mask to 32 bits so the schedule math stays unsigned. - longarr_set_unchecked(w, i, m32(word)) + ctx.w[i] = m32(word) i = i + 1 } // Expand to 64 words. i = 16 while i < 64 { - w2 = longarr_get_unchecked(w, i - 2) - w7 = longarr_get_unchecked(w, i - 7) - w15 = longarr_get_unchecked(w, i - 15) - w16 = longarr_get_unchecked(w, i - 16) + w2 = ctx.w[i - 2] + w7 = ctx.w[i - 7] + w15 = ctx.w[i - 15] + w16 = ctx.w[i - 16] // theta operands are pre-masked to 32 bits inside the helpers. t1 = theta1_256(w2 & 0xFFFFFFFF) t0 = theta0_256(w15 & 0xFFFFFFFF) v = m32(t1 + w7 + t0 + w16) - longarr_set_unchecked(w, i, v) + ctx.w[i] = v i = i + 1 } @@ -206,7 +204,7 @@ process_block_256(ctx: *Sha2Ctx) { i = 0 while i < 64 { kv = K256[i] - wv = longarr_get_unchecked(w, i) + wv = ctx.w[i] s1 = sum1_256(e) chv = ch32(e, f, g) t1 = m32(h + s1 + chv + kv + wv) @@ -238,21 +236,20 @@ process_block_256(ctx: *Sha2Ctx) { // naturally at 64 bits (Aether long is 64-bit), so no masking is needed // on the adds — but shifts/rotates still go through std.bits. process_block_512(ctx: *Sha2Ctx) { - w = ctx.w i = 0 while i < 16 { word = bytes.get_be64(ctx.block, i * 8) - longarr_set_unchecked(w, i, word) + ctx.w[i] = word i = i + 1 } i = 16 while i < 80 { - w2 = longarr_get_unchecked(w, i - 2) - w7 = longarr_get_unchecked(w, i - 7) - w15 = longarr_get_unchecked(w, i - 15) - w16 = longarr_get_unchecked(w, i - 16) + w2 = ctx.w[i - 2] + w7 = ctx.w[i - 7] + w15 = ctx.w[i - 15] + w16 = ctx.w[i - 16] v = sigma1_512(w2) + w7 + sigma0_512(w15) + w16 - longarr_set_unchecked(w, i, v) + ctx.w[i] = v i = i + 1 } @@ -268,7 +265,7 @@ process_block_512(ctx: *Sha2Ctx) { i = 0 while i < 80 { kv = K512[i] - wv = longarr_get_unchecked(w, i) + wv = ctx.w[i] t1 = h + sum1_512(e) + ch64(e, f, g) + kv + wv t2 = sum0_512(a) + maj64(a, b, c) h = g @@ -398,7 +395,6 @@ new(algo: string) -> { bytes.set(ctx.block, j, 0) j = j + 1 } - ctx.w = longarr_new_raw(80) return ctx, "" } @@ -589,10 +585,6 @@ free_internals(c: *Sha2Ctx) { bytes.free(c.block) c.block = null } - if c.w != null { - longarr_free(c.w) - c.w = null - } } // Abandon a context without finalizing — the bail-out cleanup path. diff --git a/std/cryptography/sha3/module.ae b/std/cryptography/sha3/module.ae index ee2c71ecc..0fb581f9c 100644 --- a/std/cryptography/sha3/module.ae +++ b/std/cryptography/sha3/module.ae @@ -33,7 +33,6 @@ import std.bits import std.bytes -import std.longarr extern malloc(size: int) -> ptr extern free(p: ptr) @@ -69,10 +68,10 @@ const RHO: long[25] = [ ] struct Sha3Ctx { - s: ptr // longarr(25), 5x5 lane state, lane[x+5y] - bb: ptr // longarr(25), scratch for rho/pi (B) - cc: ptr // longarr(5), scratch column parities - dd: ptr // longarr(5), scratch theta D + s: long[25] // 5x5 lane state, lane[x+5y] + bb: long[25] // scratch for rho/pi (B) + cc: long[5] // scratch column parities + dd: long[5] // scratch theta D buf: ptr // std.bytes rate buffer (max 168 bytes) buf_len: int // bytes currently in buf rate: int // rate in bytes (block size) @@ -84,40 +83,36 @@ struct Sha3Ctx { // ---- Keccak-f[1600] permutation ---- keccak_f(c: *Sha3Ctx) { - s = c.s - bb = c.bb - col = c.cc - dee = c.dd rnd = 0 while rnd < 24 { // theta x = 0 while x < 5 { - v = longarr_get_unchecked(s, x) ^ - longarr_get_unchecked(s, x + 5) ^ - longarr_get_unchecked(s, x + 10) ^ - longarr_get_unchecked(s, x + 15) ^ - longarr_get_unchecked(s, x + 20) - longarr_set_unchecked(col, x, v) + v = c.s[x] ^ + c.s[x + 5] ^ + c.s[x + 10] ^ + c.s[x + 15] ^ + c.s[x + 20] + c.cc[x] = v x = x + 1 } x = 0 while x < 5 { xm1 = (x + 4) % 5 xp1 = (x + 1) % 5 - d = longarr_get_unchecked(col, xm1) ^ - bits.rotl64(longarr_get_unchecked(col, xp1), 1) - longarr_set_unchecked(dee, x, d) + d = c.cc[xm1] ^ + bits.rotl64(c.cc[xp1], 1) + c.dd[x] = d x = x + 1 } x = 0 while x < 5 { - d = longarr_get_unchecked(dee, x) + d = c.dd[x] y = 0 while y < 5 { idx = x + 5 * y - longarr_set_unchecked(s, idx, longarr_get_unchecked(s, idx) ^ d) + c.s[idx] = c.s[idx] ^ d y = y + 1 } x = x + 1 @@ -129,13 +124,13 @@ keccak_f(c: *Sha3Ctx) { y = 0 while y < 5 { src = x + 5 * y - lane = longarr_get_unchecked(s, src) + lane = c.s[src] n = RHO[src] rotated = bits.rotl64(lane, n) nx = y ny = (2 * x + 3 * y) % 5 dst = nx + 5 * ny - longarr_set_unchecked(bb, dst, rotated) + c.bb[dst] = rotated y = y + 1 } x = x + 1 @@ -146,17 +141,17 @@ keccak_f(c: *Sha3Ctx) { while y < 5 { x = 0 while x < 5 { - b0 = longarr_get_unchecked(bb, x + 5 * y) - b1 = longarr_get_unchecked(bb, ((x + 1) % 5) + 5 * y) - b2 = longarr_get_unchecked(bb, ((x + 2) % 5) + 5 * y) - longarr_set_unchecked(s, x + 5 * y, b0 ^ ((~b1) & b2)) + b0 = c.bb[x + 5 * y] + b1 = c.bb[((x + 1) % 5) + 5 * y] + b2 = c.bb[((x + 2) % 5) + 5 * y] + c.s[x + 5 * y] = b0 ^ ((~b1) & b2) x = x + 1 } y = y + 1 } // iota - longarr_set_unchecked(s, 0, longarr_get_unchecked(s, 0) ^ RC_K[rnd]) + c.s[0] = c.s[0] ^ RC_K[rnd] rnd = rnd + 1 } @@ -175,13 +170,12 @@ shl64(v: long, n: int) -> long { // XOR `n` little-endian bytes from `src` (at offset off) into the lane // state, lane by lane. n must be a multiple of 8 here (full rate block). absorb_block(c: *Sha3Ctx, src: ptr, off: int, n: int) { - s = c.s lanes = n / 8 li = 0 while li < lanes { base = off + li * 8 long lane = bytes.get_le64(src, base) - longarr_set_unchecked(s, li, longarr_get_unchecked(s, li) ^ lane) + c.s[li] = c.s[li] ^ lane li = li + 1 } keccak_f(c) @@ -255,25 +249,17 @@ new(variant: string) -> { if ctx == null { return null, "allocation failed" } - ctx.s = longarr_new_raw(25) - ctx.bb = longarr_new_raw(25) - ctx.cc = longarr_new_raw(5) - ctx.dd = longarr_new_raw(5) ctx.buf = bytes.new(168) ctx.buf_len = 0 ok = config_variant(ctx, variant) if ok == 0 { - longarr_free(ctx.s) - longarr_free(ctx.bb) - longarr_free(ctx.cc) - longarr_free(ctx.dd) bytes.free(ctx.buf) free(ctx) return null, "unknown sha3 variant" } i = 0 while i < 25 { - longarr_set_unchecked(ctx.s, i, 0) + ctx.s[i] = 0 i = i + 1 } return ctx, "" @@ -324,7 +310,7 @@ squeeze(c: *Sha3Ctx, n: int) -> ptr { } li = 0 while li * 8 < avail { - lane = longarr_get_unchecked(c.s, li) + lane = c.s[li] base = li * 8 k = 0 while k < 8 { @@ -346,22 +332,6 @@ squeeze(c: *Sha3Ctx, n: int) -> ptr { } free_internals(c: *Sha3Ctx) { - if c.s != null { - longarr_free(c.s) - c.s = null - } - if c.bb != null { - longarr_free(c.bb) - c.bb = null - } - if c.cc != null { - longarr_free(c.cc) - c.cc = null - } - if c.dd != null { - longarr_free(c.dd) - c.dd = null - } if c.buf != null { bytes.free(c.buf) c.buf = null diff --git a/std/cryptography/skein/module.ae b/std/cryptography/skein/module.ae index 6d59e089c..c998204f9 100644 --- a/std/cryptography/skein/module.ae +++ b/std/cryptography/skein/module.ae @@ -30,7 +30,6 @@ import std.bits import std.bytes -import std.longarr extern malloc(size: int) -> ptr extern free(p: ptr) @@ -69,12 +68,12 @@ const NW: int = 8 // state words for Skein-512 const NBYTES: int = 64 // block size in bytes struct SkeinCtx { - chain: ptr // longarr(8), current chaining value - initial: ptr // longarr(8), saved initial state (post-config) - msg: ptr // longarr(8), scratch message words - tf_out: ptr // longarr(8), threefish output - ks: ptr // longarr(9), extended key words - tw: ptr // longarr(3), extended tweak words (t0,t1,t2) + chain: long[NW] // current chaining value + initial: long[NW] // saved initial state (post-config) + msg: long[NW] // scratch message words + tf_out: long[NW] // threefish output + ks: long[NW + 1] // extended key words: NW and their parity + tw: long[3] // extended tweak words (t0,t1,t2) block: ptr // bytes(64), current UBI block buffer block_off: int out_bytes: int @@ -87,35 +86,31 @@ struct SkeinCtx { // Build the extended key schedule from key words (the chain) into ks[0..8]. threefish_setkey(c: *SkeinCtx) { - ks = c.ks long parity = SKEIN_C240 i = 0 while i < NW { - kv = longarr_get_unchecked(c.chain, i) - longarr_set_unchecked(ks, i, kv) + kv = c.chain[i] + c.ks[i] = kv parity = parity ^ kv i = i + 1 } - longarr_set_unchecked(ks, NW, parity) + c.ks[NW] = parity } // Build extended tweak t[0..2] from (t0,t1): t2 = t0 ^ t1. threefish_settweak(c: *SkeinCtx) { - longarr_set_unchecked(c.tw, 0, c.t0) - longarr_set_unchecked(c.tw, 1, c.t1) - longarr_set_unchecked(c.tw, 2, c.t0 ^ c.t1) + c.tw[0] = c.t0 + c.tw[1] = c.t1 + c.tw[2] = c.t0 ^ c.t1 } // Encrypt c.msg under the current key schedule + tweak into c.tf_out. threefish_encrypt(c: *SkeinCtx) { - ks = c.ks - tw = c.tw - v = c.tf_out // load plaintext i = 0 while i < NW { - longarr_set_unchecked(v, i, longarr_get_unchecked(c.msg, i)) + c.tf_out[i] = c.msg[i] i = i + 1 } @@ -125,18 +120,18 @@ threefish_encrypt(c: *SkeinCtx) { // subkey injection at the start of every 4 rounds (s = round/4) i = 0 while i < NW { - ksw = longarr_get_unchecked(ks, (s + i) % (NW + 1)) + ksw = c.ks[(s + i) % (NW + 1)] add = ksw if i == NW - 3 { - add = add + longarr_get_unchecked(tw, s % 3) + add = add + c.tw[s % 3] } if i == NW - 2 { - add = add + longarr_get_unchecked(tw, (s + 1) % 3) + add = add + c.tw[(s + 1) % 3] } if i == NW - 1 { add = add + s } - longarr_set_unchecked(v, i, longarr_get_unchecked(v, i) + add) + c.tf_out[i] = c.tf_out[i] + add i = i + 1 } if s == 18 { @@ -149,24 +144,24 @@ threefish_encrypt(c: *SkeinCtx) { // mix: for j in 0..3, pair (2j, 2j+1) j = 0 while j < 4 { - x0 = longarr_get_unchecked(v, 2 * j) - x1 = longarr_get_unchecked(v, 2 * j + 1) + x0 = c.tf_out[2 * j] + x1 = c.tf_out[2 * j + 1] y0 = x0 + x1 rot = SKEIN_ROT[(round % 8) * 4 + j] y1 = bits.rotl64(x1, rot) ^ y0 - longarr_set_unchecked(v, 2 * j, y0) - longarr_set_unchecked(v, 2 * j + 1, y1) + c.tf_out[2 * j] = y0 + c.tf_out[2 * j + 1] = y1 j = j + 1 } // permute into msg as scratch, then copy back j = 0 while j < NW { - longarr_set_unchecked(c.msg, j, longarr_get_unchecked(v, SKEIN_PERM[j])) + c.msg[j] = c.tf_out[SKEIN_PERM[j]] j = j + 1 } j = 0 while j < NW { - longarr_set_unchecked(v, j, longarr_get_unchecked(c.msg, j)) + c.tf_out[j] = c.msg[j] j = j + 1 } r = r + 1 @@ -207,19 +202,12 @@ ubi_process_block(c: *SkeinCtx) { // load block bytes (LE) into msg words i = 0 while i < NW { - longarr_set_unchecked(c.msg, i, bytes.get_le64(c.block, i * 8)) + c.msg[i] = bytes.get_le64(c.block, i * 8) i = i + 1 } - // keep a copy of message for feed-forward (msg gets clobbered as scratch) - // We snapshot into tf_out-adjacent storage: reuse block? Instead store in - // a local longarr-like via initial? Simpler: copy to chain-sized scratch. - // Save message words first. - save = longarr_new_raw(NW) - j = 0 - while j < NW { - longarr_set_unchecked(save, j, longarr_get_unchecked(c.msg, j)) - j = j + 1 - } + // Keep the message words for the feed-forward: threefish uses msg as + // scratch. + long[NW] save = c.msg threefish_setkey(c) threefish_settweak(c) @@ -228,11 +216,10 @@ ubi_process_block(c: *SkeinCtx) { // chain = tf_out ^ original message j = 0 while j < NW { - nv = longarr_get_unchecked(c.tf_out, j) ^ longarr_get_unchecked(save, j) - longarr_set_unchecked(c.chain, j, nv) + nv = c.tf_out[j] ^ save[j] + c.chain[j] = nv j = j + 1 } - longarr_free(save) } // Feed the bytes of `src` into the UBI, flushing only complete blocks that @@ -285,7 +272,7 @@ ubi_complete(c: *SkeinCtx, type: int, data: byte[]) { create_initial_state(c: *SkeinCtx, out_bits: long) { i = 0 while i < NW { - longarr_set_unchecked(c.chain, i, 0) + c.chain[i] = 0 i = i + 1 } // configuration block, 32 bytes @@ -311,7 +298,7 @@ create_initial_state(c: *SkeinCtx, out_bits: long) { // save initial state i = 0 while i < NW { - longarr_set_unchecked(c.initial, i, longarr_get_unchecked(c.chain, i)) + c.initial[i] = c.chain[i] i = i + 1 } } @@ -327,12 +314,6 @@ new(out_bits: int) -> { if ctx == null { return null, "allocation failed" } - ctx.chain = longarr_new_raw(NW) - ctx.initial = longarr_new_raw(NW) - ctx.msg = longarr_new_raw(NW) - ctx.tf_out = longarr_new_raw(NW) - ctx.ks = longarr_new_raw(NW + 1) - ctx.tw = longarr_new_raw(3) ctx.block = bytes.new(NBYTES) ctx.block_off = 0 ctx.out_bytes = out_bits / 8 @@ -363,22 +344,12 @@ finalize_to_bytes(c: *SkeinCtx) -> ptr { n = c.out_bytes out = bytes.new(n) blocks = (n + NBYTES - 1) / NBYTES - // preserve the pre-output chain (each output UBI uses & preserves it) - pre = longarr_new_raw(NW) - p = 0 - while p < NW { - longarr_set_unchecked(pre, p, longarr_get_unchecked(c.chain, p)) - p = p + 1 - } + // Each output UBI starts from the chain as it was before output. + long[NW] pre = c.chain seq = 0 while seq < blocks { - // restore chain to pre-output state - p = 0 - while p < NW { - longarr_set_unchecked(c.chain, p, longarr_get_unchecked(pre, p)) - p = p + 1 - } + c.chain = pre // output UBI over 8-byte LE counter cb = bytes.new(8) bytes.set_le64(cb, 0, seq) @@ -393,7 +364,7 @@ finalize_to_bytes(c: *SkeinCtx) -> ptr { } w = 0 while w * 8 < to_write { - word = longarr_get_unchecked(c.chain, w) + word = c.chain[w] kk = 0 while kk < 8 { pos = w * 8 + kk @@ -406,35 +377,10 @@ finalize_to_bytes(c: *SkeinCtx) -> ptr { } seq = seq + 1 } - longarr_free(pre) return out } free_internals(c: *SkeinCtx) { - if c.chain != null { - longarr_free(c.chain) - c.chain = null - } - if c.initial != null { - longarr_free(c.initial) - c.initial = null - } - if c.msg != null { - longarr_free(c.msg) - c.msg = null - } - if c.tf_out != null { - longarr_free(c.tf_out) - c.tf_out = null - } - if c.ks != null { - longarr_free(c.ks) - c.ks = null - } - if c.tw != null { - longarr_free(c.tw) - c.tw = null - } if c.block != null { bytes.free(c.block) c.block = null diff --git a/std/cryptography/sm3/module.ae b/std/cryptography/sm3/module.ae index bffd9db7a..ea3daa3f9 100644 --- a/std/cryptography/sm3/module.ae +++ b/std/cryptography/sm3/module.ae @@ -26,7 +26,6 @@ import std.bits import std.bytes -import std.intarr extern malloc(size: int) -> ptr extern free(p: ptr) @@ -71,7 +70,7 @@ struct Sm3Ctx { block_len: int count_lo: long count_hi: long - w: ptr // intarr(68) message expansion + w: int[68] // message expansion } m32(x: long) -> long { @@ -104,16 +103,15 @@ gg1(x: long, y: long, z: long) -> long { } wg(c: *Sm3Ctx, i: int) -> long { - return intarr_get_unchecked(c.w, i) & 0xFFFFFFFF + return c.w[i] & 0xFFFFFFFF } process_block(c: *Sm3Ctx) { - w = c.w // Load 16 big-endian words; expand to 68. i = 0 while i < 16 { word = bytes.get_be32(c.block, i * 4) - intarr_set_unchecked(w, i, word & 0xFFFFFFFF) + c.w[i] = word & 0xFFFFFFFF i = i + 1 } i = 16 @@ -124,7 +122,7 @@ process_block(c: *Sm3Ctx) { wj13 = wg(c, i - 13) r7 = rl(wj13, 7) v = (p1(x & 0xFFFFFFFF) ^ r7 ^ wg(c, i - 6)) & 0xFFFFFFFF - intarr_set_unchecked(w, i, v) + c.w[i] = v i = i + 1 } @@ -212,7 +210,6 @@ new() -> { bytes.set(ctx.block, j, 0) j = j + 1 } - ctx.w = intarr_new_raw(68) return ctx, "" } @@ -291,10 +288,6 @@ free_internals(c: *Sm3Ctx) { bytes.free(c.block) c.block = null } - if c.w != null { - intarr_free(c.w) - c.w = null - } } // Finalize and return the raw digest as a std.bytes buffer. FREES the diff --git a/std/cryptography/sm4/module.ae b/std/cryptography/sm4/module.ae index 557c08850..74c032f6f 100644 --- a/std/cryptography/sm4/module.ae +++ b/std/cryptography/sm4/module.ae @@ -36,7 +36,6 @@ import std.bytes import std.bits -import std.intarr import std.mem exports( @@ -92,7 +91,7 @@ MASK32() -> int { return 0xFFFFFFFF } // 32 round keys. struct Sm4Ctx { - rk: ptr // intarr(32) of 32-bit round keys + rk: int[32] // the 32-bit round keys, in the order this direction uses encrypt: int // 1 = encryptor, 0 = decryptor (only differs in rk order) } @@ -124,7 +123,7 @@ t_key(z: int) -> int { return l_key(tau(z)) } // ---- key expansion ---- new_ctx(key: byte[], encrypt: int) -> ptr { - rk = intarr_new_raw(32) + int[32] rk k0 = (sl_be32(key, 0) ^ FK[0]) & 0xFFFFFFFF k1 = (sl_be32(key, 4) ^ FK[1]) & 0xFFFFFFFF @@ -132,15 +131,15 @@ new_ctx(key: byte[], encrypt: int) -> ptr { k3 = (sl_be32(key, 12) ^ FK[3]) & 0xFFFFFFFF // Generate the 32 encryption round keys into a scratch table first. - enc_rk = intarr_new_raw(32) - intarr_set_raw(enc_rk, 0, (k0 ^ t_key((k1 ^ k2 ^ k3 ^ CK[0]) & 0xFFFFFFFF)) & 0xFFFFFFFF) - intarr_set_raw(enc_rk, 1, (k1 ^ t_key((k2 ^ k3 ^ intarr_get_raw(enc_rk, 0) ^ CK[1]) & 0xFFFFFFFF)) & 0xFFFFFFFF) - intarr_set_raw(enc_rk, 2, (k2 ^ t_key((k3 ^ intarr_get_raw(enc_rk, 0) ^ intarr_get_raw(enc_rk, 1) ^ CK[2]) & 0xFFFFFFFF)) & 0xFFFFFFFF) - intarr_set_raw(enc_rk, 3, (k3 ^ t_key((intarr_get_raw(enc_rk, 0) ^ intarr_get_raw(enc_rk, 1) ^ intarr_get_raw(enc_rk, 2) ^ CK[3]) & 0xFFFFFFFF)) & 0xFFFFFFFF) + int[32] enc_rk + enc_rk[0] = (k0 ^ t_key((k1 ^ k2 ^ k3 ^ CK[0]) & 0xFFFFFFFF)) & 0xFFFFFFFF + enc_rk[1] = (k1 ^ t_key((k2 ^ k3 ^ enc_rk[0] ^ CK[1]) & 0xFFFFFFFF)) & 0xFFFFFFFF + enc_rk[2] = (k2 ^ t_key((k3 ^ enc_rk[0] ^ enc_rk[1] ^ CK[2]) & 0xFFFFFFFF)) & 0xFFFFFFFF + enc_rk[3] = (k3 ^ t_key((enc_rk[0] ^ enc_rk[1] ^ enc_rk[2] ^ CK[3]) & 0xFFFFFFFF)) & 0xFFFFFFFF i = 4 while i < 32 { - prev = (intarr_get_raw(enc_rk, i - 3) ^ intarr_get_raw(enc_rk, i - 2) ^ intarr_get_raw(enc_rk, i - 1) ^ CK[i]) & 0xFFFFFFFF - intarr_set_raw(enc_rk, i, (intarr_get_raw(enc_rk, i - 4) ^ t_key(prev)) & 0xFFFFFFFF) + prev = (enc_rk[i - 3] ^ enc_rk[i - 2] ^ enc_rk[i - 1] ^ CK[i]) & 0xFFFFFFFF + enc_rk[i] = (enc_rk[i - 4] ^ t_key(prev)) & 0xFFFFFFFF i = i + 1 } @@ -148,13 +147,12 @@ new_ctx(key: byte[], encrypt: int) -> ptr { i = 0 while i < 32 { if encrypt == 1 { - intarr_set_raw(rk, i, intarr_get_raw(enc_rk, i)) + rk[i] = enc_rk[i] } else { - intarr_set_raw(rk, i, intarr_get_raw(enc_rk, 31 - i)) + rk[i] = enc_rk[31 - i] } i = i + 1 } - intarr_free(enc_rk) c = malloc(sizeof(Sm4Ctx)) as *Sm4Ctx c.rk = rk @@ -170,8 +168,6 @@ new_decryptor(key: byte[]) -> ptr { } free(ctx: ptr) { if ctx == null { return } - c = ctx as *Sm4Ctx - if c.rk != null { intarr_free(c.rk) } libc_free(ctx) } @@ -187,7 +183,7 @@ process_block(ctx: ptr, in16: ptr) -> ptr { long x3 = bytes.get_be32(in16, 12) & 0xFFFFFFFF i = 0 while i < 32 { - long t = (x1 ^ x2 ^ x3 ^ intarr_get_raw(c.rk, i)) & 0xFFFFFFFF + long t = (x1 ^ x2 ^ x3 ^ c.rk[i]) & 0xFFFFFFFF long nx = (x0 ^ t_round(t)) & 0xFFFFFFFF x0 = x1 x1 = x2 diff --git a/std/cryptography/sparkle/module.ae b/std/cryptography/sparkle/module.ae index 3feeb9237..8cdab6b25 100644 --- a/std/cryptography/sparkle/module.ae +++ b/std/cryptography/sparkle/module.ae @@ -9,7 +9,6 @@ import std.bits import std.bytes -import std.longarr extern malloc(size: int) -> ptr extern free(p: ptr) @@ -26,7 +25,7 @@ const RCON: long[8] = [ ] struct SparkleCtx { - state: ptr // longarr(16) + state: long[16] block: ptr // std.bytes, 16 bytes block_len: int digest_bytes: int @@ -56,19 +55,19 @@ arx_box(rc: long, s00: long, s01: long) -> (long, long) { return s00, s01 } -sparkle_opt12(state: ptr, steps: int) { - s00 = longarr_get_unchecked(state, 0) - s01 = longarr_get_unchecked(state, 1) - s02 = longarr_get_unchecked(state, 2) - s03 = longarr_get_unchecked(state, 3) - s04 = longarr_get_unchecked(state, 4) - s05 = longarr_get_unchecked(state, 5) - s06 = longarr_get_unchecked(state, 6) - s07 = longarr_get_unchecked(state, 7) - s08 = longarr_get_unchecked(state, 8) - s09 = longarr_get_unchecked(state, 9) - s10 = longarr_get_unchecked(state, 10) - s11 = longarr_get_unchecked(state, 11) +sparkle_opt12(state: long[], steps: int) { + s00 = state[0] + s01 = state[1] + s02 = state[2] + s03 = state[3] + s04 = state[4] + s05 = state[5] + s06 = state[6] + s07 = state[7] + s08 = state[8] + s09 = state[9] + s10 = state[10] + s11 = state[11] step = 0 while step < steps { @@ -109,37 +108,37 @@ sparkle_opt12(state: ptr, steps: int) { step = step + 1 } - longarr_set_unchecked(state, 0, s00) - longarr_set_unchecked(state, 1, s01) - longarr_set_unchecked(state, 2, s02) - longarr_set_unchecked(state, 3, s03) - longarr_set_unchecked(state, 4, s04) - longarr_set_unchecked(state, 5, s05) - longarr_set_unchecked(state, 6, s06) - longarr_set_unchecked(state, 7, s07) - longarr_set_unchecked(state, 8, s08) - longarr_set_unchecked(state, 9, s09) - longarr_set_unchecked(state, 10, s10) - longarr_set_unchecked(state, 11, s11) + state[0] = s00 + state[1] = s01 + state[2] = s02 + state[3] = s03 + state[4] = s04 + state[5] = s05 + state[6] = s06 + state[7] = s07 + state[8] = s08 + state[9] = s09 + state[10] = s10 + state[11] = s11 } -sparkle_opt16(state: ptr, steps: int) { - s00 = longarr_get_unchecked(state, 0) - s01 = longarr_get_unchecked(state, 1) - s02 = longarr_get_unchecked(state, 2) - s03 = longarr_get_unchecked(state, 3) - s04 = longarr_get_unchecked(state, 4) - s05 = longarr_get_unchecked(state, 5) - s06 = longarr_get_unchecked(state, 6) - s07 = longarr_get_unchecked(state, 7) - s08 = longarr_get_unchecked(state, 8) - s09 = longarr_get_unchecked(state, 9) - s10 = longarr_get_unchecked(state, 10) - s11 = longarr_get_unchecked(state, 11) - s12 = longarr_get_unchecked(state, 12) - s13 = longarr_get_unchecked(state, 13) - s14 = longarr_get_unchecked(state, 14) - s15 = longarr_get_unchecked(state, 15) +sparkle_opt16(state: long[], steps: int) { + s00 = state[0] + s01 = state[1] + s02 = state[2] + s03 = state[3] + s04 = state[4] + s05 = state[5] + s06 = state[6] + s07 = state[7] + s08 = state[8] + s09 = state[9] + s10 = state[10] + s11 = state[11] + s12 = state[12] + s13 = state[13] + s14 = state[14] + s15 = state[15] step = 0 while step < steps { @@ -202,22 +201,22 @@ sparkle_opt16(state: ptr, steps: int) { s07 = u01 ^ s09 ^ t8ACE } - longarr_set_unchecked(state, 0, s00) - longarr_set_unchecked(state, 1, s01) - longarr_set_unchecked(state, 2, s02) - longarr_set_unchecked(state, 3, s03) - longarr_set_unchecked(state, 4, s04) - longarr_set_unchecked(state, 5, s05) - longarr_set_unchecked(state, 6, s06) - longarr_set_unchecked(state, 7, s07) - longarr_set_unchecked(state, 8, s08) - longarr_set_unchecked(state, 9, s09) - longarr_set_unchecked(state, 10, s10) - longarr_set_unchecked(state, 11, s11) - longarr_set_unchecked(state, 12, s12) - longarr_set_unchecked(state, 13, s13) - longarr_set_unchecked(state, 14, s14) - longarr_set_unchecked(state, 15, s15) + state[0] = s00 + state[1] = s01 + state[2] = s02 + state[3] = s03 + state[4] = s04 + state[5] = s05 + state[6] = s06 + state[7] = s07 + state[8] = s08 + state[9] = s09 + state[10] = s10 + state[11] = s11 + state[12] = s12 + state[13] = s13 + state[14] = s14 + state[15] = s15 } process_block(c: *SparkleCtx, buf: ptr, off: int, steps: int) { @@ -229,16 +228,16 @@ process_block(c: *SparkleCtx, buf: ptr, off: int, steps: int) { tx = ell(t0 ^ t2) ty = ell(t1 ^ t3) - longarr_set_unchecked(c.state, 0, longarr_get_unchecked(c.state, 0) ^ t0 ^ ty) - longarr_set_unchecked(c.state, 1, longarr_get_unchecked(c.state, 1) ^ t1 ^ tx) - longarr_set_unchecked(c.state, 2, longarr_get_unchecked(c.state, 2) ^ t2 ^ ty) - longarr_set_unchecked(c.state, 3, longarr_get_unchecked(c.state, 3) ^ t3 ^ tx) - longarr_set_unchecked(c.state, 4, longarr_get_unchecked(c.state, 4) ^ ty) - longarr_set_unchecked(c.state, 5, longarr_get_unchecked(c.state, 5) ^ tx) + c.state[0] = c.state[0] ^ t0 ^ ty + c.state[1] = c.state[1] ^ t1 ^ tx + c.state[2] = c.state[2] ^ t2 ^ ty + c.state[3] = c.state[3] ^ t3 ^ tx + c.state[4] = c.state[4] ^ ty + c.state[5] = c.state[5] ^ tx if c.state_words == 16 { - longarr_set_unchecked(c.state, 6, longarr_get_unchecked(c.state, 6) ^ ty) - longarr_set_unchecked(c.state, 7, longarr_get_unchecked(c.state, 7) ^ tx) + c.state[6] = c.state[6] ^ ty + c.state[7] = c.state[7] ^ tx sparkle_opt16(c.state, steps) } else { sparkle_opt12(c.state, steps) @@ -266,13 +265,12 @@ new(algo: string) -> { return null, "unknown algorithm" } - ctx.state = longarr_new_raw(16) ctx.block = bytes.new(16) ctx.block_len = 0 i = 0 while i < 16 { - longarr_set_unchecked(ctx.state, i, 0) + ctx.state[i] = 0 i = i + 1 } i = 0 @@ -313,7 +311,7 @@ update(ctx: ptr, data: byte[]) { finalize_to_bytes(c: *SparkleCtx) -> ptr { idx = (c.state_words >> 1) - 1 if c.block_len < 16 { - longarr_set_unchecked(c.state, idx, longarr_get_unchecked(c.state, idx) ^ (1 << 24)) + c.state[idx] = c.state[idx] ^ (1 << 24) bytes.set(c.block, c.block_len, 0x80) c.block_len = c.block_len + 1 while c.block_len < 16 { @@ -321,7 +319,7 @@ finalize_to_bytes(c: *SparkleCtx) -> ptr { c.block_len = c.block_len + 1 } } else { - longarr_set_unchecked(c.state, idx, longarr_get_unchecked(c.state, idx) ^ (1 << 25)) + c.state[idx] = c.state[idx] ^ (1 << 25) } process_block(c, c.block, 0, c.sparkle_steps_big) @@ -329,7 +327,7 @@ finalize_to_bytes(c: *SparkleCtx) -> ptr { out = bytes.new(c.digest_bytes) i = 0 while i < 4 { - bytes.set_le32(out, i * 4, longarr_get_unchecked(c.state, i) & 0xFFFFFFFF) + bytes.set_le32(out, i * 4, c.state[i] & 0xFFFFFFFF) i = i + 1 } @@ -337,20 +335,20 @@ finalize_to_bytes(c: *SparkleCtx) -> ptr { sparkle_opt16(c.state, c.sparkle_steps_slim) i = 0 while i < 4 { - bytes.set_le32(out, 16 + i * 4, longarr_get_unchecked(c.state, i) & 0xFFFFFFFF) + bytes.set_le32(out, 16 + i * 4, c.state[i] & 0xFFFFFFFF) i = i + 1 } sparkle_opt16(c.state, c.sparkle_steps_slim) i = 0 while i < 4 { - bytes.set_le32(out, 32 + i * 4, longarr_get_unchecked(c.state, i) & 0xFFFFFFFF) + bytes.set_le32(out, 32 + i * 4, c.state[i] & 0xFFFFFFFF) i = i + 1 } } else { sparkle_opt12(c.state, c.sparkle_steps_slim) i = 0 while i < 4 { - bytes.set_le32(out, 16 + i * 4, longarr_get_unchecked(c.state, i) & 0xFFFFFFFF) + bytes.set_le32(out, 16 + i * 4, c.state[i] & 0xFFFFFFFF) i = i + 1 } } @@ -359,7 +357,6 @@ finalize_to_bytes(c: *SparkleCtx) -> ptr { } free_internals(c: *SparkleCtx) { - if c.state != null { longarr_free(c.state); c.state = null } if c.block != null { bytes.free(c.block); c.block = null } } diff --git a/std/cryptography/tiger/module.ae b/std/cryptography/tiger/module.ae index df0154702..722994100 100644 --- a/std/cryptography/tiger/module.ae +++ b/std/cryptography/tiger/module.ae @@ -31,7 +31,6 @@ import std.bits import std.bytes -import std.longarr extern malloc(size: int) -> ptr extern free(p: ptr) @@ -314,7 +313,7 @@ struct TigerCtx { a: long b: long c: long - x: ptr // longarr(8), the message words + x: long[8] // the message words x_off: int buf: ptr // bytes(8), partial-word buffer b_off: int @@ -358,10 +357,10 @@ round_cab(t: *TigerCtx, x: long, mul: long) { } xg(t: *TigerCtx, i: int) -> long { - return longarr_get_unchecked(t.x, i) + return t.x[i] } xs(t: *TigerCtx, i: int, v: long) { - longarr_set_unchecked(t.x, i, v) + t.x[i] = v } key_schedule(t: *TigerCtx) { @@ -458,14 +457,13 @@ new() -> { ctx.a = 0x0123456789ABCDEF ctx.b = 0xFEDCBA9876543210 ctx.c = 0xF096A5B4C3B2E187 - ctx.x = longarr_new_raw(8) ctx.x_off = 0 ctx.buf = bytes.new(8) ctx.b_off = 0 ctx.byte_count = 0 i = 0 while i < 8 { - longarr_set_unchecked(ctx.x, i, 0) + ctx.x[i] = 0 bytes.set(ctx.buf, i, 0) i = i + 1 } @@ -513,10 +511,6 @@ emit_le64(out: ptr, off: int, v: long) { } free_internals(t: *TigerCtx) { - if t.x != null { - longarr_free(t.x) - t.x = null - } if t.buf != null { bytes.free(t.buf) t.buf = null diff --git a/std/cryptography/whirlpool/module.ae b/std/cryptography/whirlpool/module.ae index 91989d063..7de64a49b 100644 --- a/std/cryptography/whirlpool/module.ae +++ b/std/cryptography/whirlpool/module.ae @@ -33,7 +33,6 @@ import std.bits import std.bytes -import std.longarr extern malloc(size: int) -> ptr extern free(p: ptr) @@ -64,13 +63,13 @@ const WP_SBOX: long[256] = [ ] struct WpCtx { - hash: ptr // longarr(8), chaining st - c0: ptr // longarr(256), diffusion table C0 - rc: ptr // longarr(11), round constants rc[0..10] - kk: ptr // longarr(8), round key K - ll: ptr // longarr(8), scratch L - block: ptr // longarr(8), the mu(buffer) words - cst: ptr // longarr(8), cipher st + hash: long[8] // chaining state + c0: long[256] // diffusion table C0 + rc: long[11] // round constants rc[0..10] + kk: long[8] // round key K + ll: long[8] // scratch L + block: long[8] // the mu(buffer) words + cst: long[8] // cipher state buf: ptr // bytes(64), byte buffer buf_pos: int bitcount: ptr // bytes(32), 256-bit big-endian length, incremented by 8 @@ -103,7 +102,7 @@ pack8(buf: ptr, b7: long, b6: long, b5: long, b4: long, b3: long, b2: long, b1: // C_k[i] = rotr64(C0[i], 8*k). ctab(c: *WpCtx, k: int, i: int) -> long { - return bits.rotr64(longarr_get_unchecked(c.c0, i), 8 * k) + return bits.rotr64(c.c0[i], 8 * k) } build_tables(c: *WpCtx) { @@ -118,13 +117,13 @@ build_tables(c: *WpCtx) { long v9 = v8 ^ v1 // C0[i] = PackIntoUInt64(v1, v1, v4, v1, v8, v5, v2, v9) word = pack8(scratch, v1, v1, v4, v1, v8, v5, v2, v9) - longarr_set_unchecked(c.c0, i, word) + c.c0[i] = word i = i + 1 } bytes.free(scratch) // rc[0] = 0; rc[r] gathers byte 8*(r-1) from each C table, masked. - longarr_set_unchecked(c.rc, 0, 0) + c.rc[0] = 0 r = 1 while r <= 10 { idx = 8 * (r - 1) @@ -136,7 +135,7 @@ build_tables(c: *WpCtx) { (ctab(c, 5, idx + 5) & 0x0000000000ff0000) ^ (ctab(c, 6, idx + 6) & 0x000000000000ff00) ^ (ctab(c, 7, idx + 7) & 0x00000000000000ff) - longarr_set_unchecked(c.rc, r, v) + c.rc[r] = v r = r + 1 } } @@ -147,18 +146,13 @@ cbyte(c: *WpCtx, k: int, w: long, shift: int) -> long { } process_block(c: *WpCtx) { - hash = c.hash - kk = c.kk - ll = c.ll - block = c.block - st = c.cst // compute and apply K^0 i = 0 while i < 8 { - khv = longarr_get_unchecked(hash, i) - longarr_set_unchecked(kk, i, khv) - longarr_set_unchecked(st, i, longarr_get_unchecked(block, i) ^ khv) + khv = c.hash[i] + c.kk[i] = khv + c.cst[i] = c.block[i] ^ khv i = i + 1 } @@ -167,42 +161,42 @@ process_block(c: *WpCtx) { // key schedule round i = 0 while i < 8 { - v = cbyte(c, 0, longarr_get_unchecked(kk, (i - 0) & 7), 56) - v = v ^ cbyte(c, 1, longarr_get_unchecked(kk, (i - 1) & 7), 48) - v = v ^ cbyte(c, 2, longarr_get_unchecked(kk, (i - 2) & 7), 40) - v = v ^ cbyte(c, 3, longarr_get_unchecked(kk, (i - 3) & 7), 32) - v = v ^ cbyte(c, 4, longarr_get_unchecked(kk, (i - 4) & 7), 24) - v = v ^ cbyte(c, 5, longarr_get_unchecked(kk, (i - 5) & 7), 16) - v = v ^ cbyte(c, 6, longarr_get_unchecked(kk, (i - 6) & 7), 8) - v = v ^ cbyte(c, 7, longarr_get_unchecked(kk, (i - 7) & 7), 0) - longarr_set_unchecked(ll, i, v) + v = cbyte(c, 0, c.kk[(i - 0) & 7], 56) + v = v ^ cbyte(c, 1, c.kk[(i - 1) & 7], 48) + v = v ^ cbyte(c, 2, c.kk[(i - 2) & 7], 40) + v = v ^ cbyte(c, 3, c.kk[(i - 3) & 7], 32) + v = v ^ cbyte(c, 4, c.kk[(i - 4) & 7], 24) + v = v ^ cbyte(c, 5, c.kk[(i - 5) & 7], 16) + v = v ^ cbyte(c, 6, c.kk[(i - 6) & 7], 8) + v = v ^ cbyte(c, 7, c.kk[(i - 7) & 7], 0) + c.ll[i] = v i = i + 1 } i = 0 while i < 8 { - longarr_set_unchecked(kk, i, longarr_get_unchecked(ll, i)) + c.kk[i] = c.ll[i] i = i + 1 } - longarr_set_unchecked(kk, 0, longarr_get_unchecked(kk, 0) ^ longarr_get_unchecked(c.rc, round)) + c.kk[0] = c.kk[0] ^ c.rc[round] // round transformation on st i = 0 while i < 8 { - v = longarr_get_unchecked(kk, i) - v = v ^ cbyte(c, 0, longarr_get_unchecked(st, (i - 0) & 7), 56) - v = v ^ cbyte(c, 1, longarr_get_unchecked(st, (i - 1) & 7), 48) - v = v ^ cbyte(c, 2, longarr_get_unchecked(st, (i - 2) & 7), 40) - v = v ^ cbyte(c, 3, longarr_get_unchecked(st, (i - 3) & 7), 32) - v = v ^ cbyte(c, 4, longarr_get_unchecked(st, (i - 4) & 7), 24) - v = v ^ cbyte(c, 5, longarr_get_unchecked(st, (i - 5) & 7), 16) - v = v ^ cbyte(c, 6, longarr_get_unchecked(st, (i - 6) & 7), 8) - v = v ^ cbyte(c, 7, longarr_get_unchecked(st, (i - 7) & 7), 0) - longarr_set_unchecked(ll, i, v) + v = c.kk[i] + v = v ^ cbyte(c, 0, c.cst[(i - 0) & 7], 56) + v = v ^ cbyte(c, 1, c.cst[(i - 1) & 7], 48) + v = v ^ cbyte(c, 2, c.cst[(i - 2) & 7], 40) + v = v ^ cbyte(c, 3, c.cst[(i - 3) & 7], 32) + v = v ^ cbyte(c, 4, c.cst[(i - 4) & 7], 24) + v = v ^ cbyte(c, 5, c.cst[(i - 5) & 7], 16) + v = v ^ cbyte(c, 6, c.cst[(i - 6) & 7], 8) + v = v ^ cbyte(c, 7, c.cst[(i - 7) & 7], 0) + c.ll[i] = v i = i + 1 } i = 0 while i < 8 { - longarr_set_unchecked(st, i, longarr_get_unchecked(ll, i)) + c.cst[i] = c.ll[i] i = i + 1 } round = round + 1 @@ -211,8 +205,8 @@ process_block(c: *WpCtx) { // Miyaguchi-Preneel: hash ^= st ^ block i = 0 while i < 8 { - nv = longarr_get_unchecked(hash, i) ^ longarr_get_unchecked(st, i) ^ longarr_get_unchecked(block, i) - longarr_set_unchecked(hash, i, nv) + nv = c.hash[i] ^ c.cst[i] ^ c.block[i] + c.hash[i] = nv i = i + 1 } } @@ -221,7 +215,7 @@ process_filled_buffer(c: *WpCtx) { // copy 64 bytes into 8 big-endian words then process i = 0 while i < 8 { - longarr_set_unchecked(c.block, i, bytes.get_be64(c.buf, i * 8)) + c.block[i] = bytes.get_be64(c.buf, i * 8) i = i + 1 } process_block(c) @@ -259,19 +253,12 @@ new() -> { if ctx == null { return null, "allocation failed" } - ctx.hash = longarr_new_raw(8) - ctx.c0 = longarr_new_raw(256) - ctx.rc = longarr_new_raw(11) - ctx.kk = longarr_new_raw(8) - ctx.ll = longarr_new_raw(8) - ctx.block = longarr_new_raw(8) - ctx.cst = longarr_new_raw(8) ctx.buf = bytes.new(64) ctx.bitcount = bytes.new(32) ctx.buf_pos = 0 i = 0 while i < 8 { - longarr_set_unchecked(ctx.hash, i, 0) + ctx.hash[i] = 0 i = i + 1 } j = 0 @@ -352,41 +339,13 @@ finalize_to_bytes(c: *WpCtx) -> ptr { out = bytes.new(64) k = 0 while k < 8 { - bytes.set_be64(out, k * 8, longarr_get_unchecked(c.hash, k)) + bytes.set_be64(out, k * 8, c.hash[k]) k = k + 1 } return out } free_internals(c: *WpCtx) { - if c.hash != null { - longarr_free(c.hash) - c.hash = null - } - if c.c0 != null { - longarr_free(c.c0) - c.c0 = null - } - if c.rc != null { - longarr_free(c.rc) - c.rc = null - } - if c.kk != null { - longarr_free(c.kk) - c.kk = null - } - if c.ll != null { - longarr_free(c.ll) - c.ll = null - } - if c.block != null { - longarr_free(c.block) - c.block = null - } - if c.cst != null { - longarr_free(c.cst) - c.cst = null - } if c.buf != null { bytes.free(c.buf) c.buf = null From 6e9f219dc3911b5d9cf2635cdd6dfa2dd9d3419e Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Thu, 22 Oct 2026 01:03:57 -0300 Subject: [PATCH 24/50] std and contrib use the containers' public functions; list.at and map.lookup added (#3107) 25 files in std and contrib called std.list's and std.map's raw C bindings: 286 calls of list_get_raw, list_add_raw, map_put_raw, map_get_raw and map_keys_*_raw, in the TLS modules, asn1, clapae, message, schema, sandbox, tar, zip, http1, language, the HTTP load balancer, liquid, tinyweb's schema_api, vkgen, xml_expat and their tests. list_get_raw read a null past the end, which a caller took for a stored null. Two public functions they needed did not exist, and are added: - list.at(list, i): the item at an index the caller has bounded; out of range, or a null list, a panic naming the index and the size, as a slice index is. - map.lookup(map, key): the value or null, map.get without its error slot, for a caller that reads absence as null. The calls are list.at, list.add, map.put, map.lookup, map.keys, map.keys_size and map.keys_get. std.message's catalog_free read the key snapshot through MessageMapKeys, a copy of the C struct's layout; it reads it with keys_size and keys_get. liquid's free-id stack is an int[] slice whose .len is its capacity. Selective imports lose the raw names; files that call list.* or map.* import the module. The reference, the list and map READMEs and docs/api say so, and std.list's raw bindings are named as std.list's own. Checks: the 51 tests of the touched modules (clapae, message, schema, tar, zip, sandbox, language, alloc, asn1, http1, liquid's sandbox gate, the TLS certificate, OCSP and server tests, the pure-TLS HTTPS tests, the load balancer, the list and map regressions), liquid's five suites and tinyweb's four. --- contrib/parsers/xml_expat/module.ae | 12 +- contrib/templating/liquid/module.ae | 144 +++++++++--------- contrib/tinyweb/schema_api/module.ae | 7 +- contrib/vulkan/tools/vkgen.ae | 34 ++--- docs/api/index.html | 8 +- docs/api/list.html | 7 + docs/api/map.html | 7 + docs/stdlib-reference.md | 8 +- ...2T050100Z-added-3107-list-at-map-lookup.md | 10 ++ std/alloc/test_alloc_tracking.ae | 5 +- std/clapae/module.ae | 40 ++--- std/clapae/test_clapae.ae | 90 +++++------ std/cryptography/asn1/module.ae | 12 +- std/cryptography/tls13_cert/module.ae | 36 ++--- std/cryptography/tls13_client/module.ae | 4 +- std/cryptography/tls13_server/module.ae | 9 +- std/http/server/lb/module.ae | 2 +- std/http1/module.ae | 14 +- std/language/module.ae | 10 +- std/list/README.md | 8 +- std/list/module.ae | 17 ++- std/map/README.md | 4 +- std/map/module.ae | 12 +- std/message/module.ae | 69 ++++----- std/message/test_message.ae | 50 +++--- std/sandbox/module.ae | 4 +- std/sandbox/test_sandbox.ae | 4 +- std/schema/example.ae | 4 +- std/schema/module.ae | 30 ++-- std/schema/test_schema.ae | 4 +- std/schema/test_schema_nested.ae | 7 +- std/tar/module.ae | 6 +- std/tar/test_tar.ae | 4 +- std/zip/module.ae | 6 +- 34 files changed, 371 insertions(+), 317 deletions(-) create mode 100644 new_changelogs/20261022T050100Z-added-3107-list-at-map-lookup.md diff --git a/contrib/parsers/xml_expat/module.ae b/contrib/parsers/xml_expat/module.ae index 8abf231f8..0ac1d196e 100644 --- a/contrib/parsers/xml_expat/module.ae +++ b/contrib/parsers/xml_expat/module.ae @@ -261,17 +261,17 @@ extern xml_parser_install_handlerset(p: ptr, hs: ptr) // captured-variable access via its own env). bind_start(_ctx: ptr, cb: @keep fn) { boxed = box_closure(cb) - map_put_raw(_ctx, "start", boxed) + map.put(_ctx, "start", boxed) } bind_end(_ctx: ptr, cb: @keep fn) { boxed = box_closure(cb) - map_put_raw(_ctx, "end", boxed) + map.put(_ctx, "end", boxed) } bind_text(_ctx: ptr, cb: @keep fn) { boxed = box_closure(cb) - map_put_raw(_ctx, "text", boxed) + map.put(_ctx, "text", boxed) } // One-shot: parse an in-memory document with closure-based @@ -296,15 +296,15 @@ builder parse_with(input: string): int { if _builder != null { if map_has(_builder, "start") == 1 { - boxed = map_get_raw(_builder, "start") + boxed = map.lookup(_builder, "start") xml_handlerset_set_start(hs, boxed) } if map_has(_builder, "end") == 1 { - boxed = map_get_raw(_builder, "end") + boxed = map.lookup(_builder, "end") xml_handlerset_set_end(hs, boxed) } if map_has(_builder, "text") == 1 { - boxed = map_get_raw(_builder, "text") + boxed = map.lookup(_builder, "text") xml_handlerset_set_text(hs, boxed) } } diff --git a/contrib/templating/liquid/module.ae b/contrib/templating/liquid/module.ae index 015b62c08..22b106f75 100644 --- a/contrib/templating/liquid/module.ae +++ b/contrib/templating/liquid/module.ae @@ -25,6 +25,7 @@ import std.string import std.strbuilder import std.map import std.collections +import std.list import std.cryptography import std.encoding import std.fs @@ -377,15 +378,15 @@ lex(src: string) -> (ptr, string) { string_list_add(kinds, TOK_TEXT) string_list_add(contents, tail) toks = list_new() - list_add_raw(toks, kinds) - list_add_raw(toks, contents) + list.add(toks, kinds) + list.add(toks, contents) return toks, string.concat("unterminated `{{`", loc_suffix(src, n, open_at)) } raw_body = string.substring_n(src, n, open_at + 2, close_at) if has_control_char(raw_body) == 1 { toks = list_new() - list_add_raw(toks, kinds) - list_add_raw(toks, contents) + list.add(toks, kinds) + list.add(toks, contents) return toks, string.concat("control character inside a tag", loc_suffix(src, n, open_at)) } body, ls = peel_left_dash(raw_body) @@ -404,15 +405,15 @@ lex(src: string) -> (ptr, string) { string_list_add(kinds, TOK_TEXT) string_list_add(contents, tail) toks = list_new() - list_add_raw(toks, kinds) - list_add_raw(toks, contents) + list.add(toks, kinds) + list.add(toks, contents) return toks, string.concat("unterminated `{%`", loc_suffix(src, n, open_at)) } raw_body = string.substring_n(src, n, open_at + 2, close_at) if has_control_char(raw_body) == 1 { toks = list_new() - list_add_raw(toks, kinds) - list_add_raw(toks, contents) + list.add(toks, kinds) + list.add(toks, contents) return toks, string.concat("control character inside a tag", loc_suffix(src, n, open_at)) } body, ls = peel_left_dash(raw_body) @@ -440,8 +441,8 @@ lex(src: string) -> (ptr, string) { end_at, end_close = find_block_end(src, n, pos_after, "endcomment") if end_at < 0 { toks = list_new() - list_add_raw(toks, kinds) - list_add_raw(toks, contents) + list.add(toks, kinds) + list.add(toks, contents) return toks, string.concat("unterminated `{% comment %}`", loc_suffix(src, n, open_at)) } // The opener's right-strip would consume the very @@ -458,8 +459,8 @@ lex(src: string) -> (ptr, string) { end_at, end_close = find_block_end(src, n, pos_after, "endraw") if end_at < 0 { toks = list_new() - list_add_raw(toks, kinds) - list_add_raw(toks, contents) + list.add(toks, kinds) + list.add(toks, contents) return toks, string.concat("unterminated `{% raw %}`", loc_suffix(src, n, open_at)) } raw_text = string.substring_n(src, n, pos_after, end_at) @@ -486,8 +487,8 @@ lex(src: string) -> (ptr, string) { lerr2 = emit_liquid_lines(kinds, contents, inner_body) if lerr2 != "" { toks = list_new() - list_add_raw(toks, kinds) - list_add_raw(toks, contents) + list.add(toks, kinds) + list.add(toks, contents) return toks, lerr2 } if rs == 1 { strip_next = 1 } @@ -504,8 +505,8 @@ lex(src: string) -> (ptr, string) { } } toks = list_new() - list_add_raw(toks, kinds) - list_add_raw(toks, contents) + list.add(toks, kinds) + list.add(toks, contents) return toks, "" } @@ -2465,7 +2466,7 @@ filter_date(ctx: ptr, val: string, format: string) -> (string, string) { if map_has(ctx, KEY_NOW) == 0 { return value_nil(), "date: \"now\" needs the time: call context_set_now first" } - e, _nerr = string.to_long(map_get_raw(ctx, KEY_NOW)) + e, _nerr = string.to_long(map.lookup(ctx, KEY_NOW)) epoch = e } else if all_digits(s) == 1 { e, eerr = string.to_long(s) @@ -2617,8 +2618,8 @@ parse_file(path: string) -> (ptr, string) { // handed to a partial cache belongs to the cache. template_free(t: ptr) { if t == null { return } - string_list_free(list_get_raw(t, 0)) - string_list_free(list_get_raw(t, 1)) + string_list_free(list.at(t, 0)) + string_list_free(list.at(t, 1)) list_free(t) } @@ -2661,7 +2662,7 @@ partial_cache_free(cache: ptr) { n = list_size(c.owned) i = 0 while i < n { - template_free(list_get_raw(c.owned, i)) + template_free(list.at(c.owned, i)) i = i + 1 } list_free(c.owned) @@ -2690,11 +2691,11 @@ context_set_partial_cache(ctx: ptr, cache: @keep ptr) { load_partial(ctx: ptr, resolved: string) -> (ptr, string, string, int) { key = partial_cache_key() cache = null - if map_has(ctx, key) == 1 { cache = map_get_raw(ctx, key) } + if map_has(ctx, key) == 1 { cache = map.lookup(ctx, key) } if cache != null { c = cache as *PartialCache if map_has(c.by_path, resolved) == 1 { - return map_get_raw(c.by_path, resolved), "", "", 0 + return map.lookup(c.by_path, resolved), "", "", 0 } } src, ferr = fs.read(resolved) @@ -2705,7 +2706,7 @@ load_partial(ctx: ptr, resolved: string) -> (ptr, string, string, int) { if cache != null { c2 = cache as *PartialCache map_put(c2.by_path, resolved, t) - list_add_raw(c2.owned, t) + list.add(c2.owned, t) return t, "", "", 0 } return t, "", "", 1 @@ -2747,20 +2748,20 @@ context_set_now(ctx: ptr, unix_seconds: long) { // Free the context: its bindings, and every array and object it holds. context_free(ctx: ptr) { - if map_has(ctx, KEY_VALUES) == 1 { store_free(map_get_raw(ctx, KEY_VALUES)) } + if map_has(ctx, KEY_VALUES) == 1 { store_free(map.lookup(ctx, KEY_VALUES)) } bindings_free(ctx) } // Free a context's own maps -- its bare and its typed bindings -- but not a // value store it may share (a `{% render %}` context's is its caller's). bindings_free(ctx: ptr) { - if map_has(ctx, KEY_TYPED) == 1 { map_free(map_get_raw(ctx, KEY_TYPED)) } + if map_has(ctx, KEY_TYPED) == 1 { map_free(map.lookup(ctx, KEY_TYPED)) } map_free(ctx) } // The map of `ctx`'s typed bindings (name -> packed value), made on first use. typed_of(ctx: ptr) -> ptr { - have = map_get_raw(ctx, KEY_TYPED) + have = map.lookup(ctx, KEY_TYPED) if have != null { return have } typed = map_new() map_put(ctx, KEY_TYPED, typed) @@ -2787,12 +2788,12 @@ lookup_value(ctx: ptr, name: string) -> string { if nn == 0 || string.char_at_n(name, nn, 0) < 32 { return value_nil() } // A stored value is never null, so a null from map_get_raw is "not // bound" -- one hash lookup a step rather than map_has and then a get. - typed = map_get_raw(ctx, KEY_TYPED) + typed = map.lookup(ctx, KEY_TYPED) if typed != null { - v = map_get_raw(typed, name) + v = map.lookup(typed, name) if v != null { return v } } - bare = map_get_raw(ctx, name) + bare = map.lookup(ctx, name) if bare != null { return bare } return value_nil() } @@ -2816,7 +2817,7 @@ bump_counter(ctx: ptr, name: string, direction: int) -> (string, string) { map_put(ctx, key, "-1") return "-1", "" } - cur_s = map_get_raw(ctx, key) + cur_s = map.lookup(ctx, key) cur, err = string.to_int(cur_s) if err != "" { return "", string.concat("counter not an integer: ", cur_s) @@ -2864,7 +2865,7 @@ cycle_emit(ctx: ptr, rest: string, full_body: string) -> (string, string) { key = string.concat("\x01cyc:", full_body) pos = 0 if map_has(ctx, key) == 1 { - cur, err = string.to_int(map_get_raw(ctx, key)) + cur, err = string.to_int(map.lookup(ctx, key)) if err == "" { pos = cur } } if pos >= ic { pos = pos % ic } @@ -2983,7 +2984,7 @@ eval_value(ctx: ptr, text: string) -> (string, string) { // `block.super` inside a layout child block is the parent's content for // that block; outside an override it is nil, not an error. if string.equals(a, "block.super") == 1 { - if map_has(ctx, KEY_BLOCK_SUPER) == 1 { return value_str(map_get_raw(ctx, KEY_BLOCK_SUPER)), "" } + if map_has(ctx, KEY_BLOCK_SUPER) == 1 { return value_str(map.lookup(ctx, KEY_BLOCK_SUPER)), "" } return value_nil(), "" } // A number literal keeps the text it was written with. @@ -3851,7 +3852,7 @@ has_reversed(rest: string) -> int { save_value(ctx: ptr, name: string) -> (string, int) { typed = typed_of(ctx) if map_has(typed, name) == 0 { return "", 0 } - return string.copy(map_get_raw(typed, name)), 1 + return string.copy(map.lookup(typed, name)), 1 } // Inverse of save_value. @@ -4307,13 +4308,13 @@ const KEY_NOW = "\x01now" // Read the include-root from ctx. Returns ("", 0) when unset. get_include_root(ctx: ptr) -> (string, int) { if map_has(ctx, KEY_INCLUDE_ROOT) == 0 { return "", 0 } - return map_get_raw(ctx, KEY_INCLUDE_ROOT), 1 + return map.lookup(ctx, KEY_INCLUDE_ROOT), 1 } // Read the current include-depth. 0 if unset. get_include_depth(ctx: ptr) -> int { if map_has(ctx, KEY_INCLUDE_DEPTH) == 0 { return 0 } - v = map_get_raw(ctx, KEY_INCLUDE_DEPTH) + v = map.lookup(ctx, KEY_INCLUDE_DEPTH) n, _ = string.to_int(v) return n } @@ -4459,8 +4460,8 @@ render_include_tag(outer_ctx: @keep ptr, rest: string, strict: int) -> (string, // host gave, so `date` reads the same "now". map_put(child, KEY_INCLUDE_ROOT, root) pkey = partial_cache_key() - if map_has(outer_ctx, pkey) == 1 { map_put(child, pkey, map_get_raw(outer_ctx, pkey)) } - if map_has(outer_ctx, KEY_NOW) == 1 { map_put(child, KEY_NOW, string.copy(map_get_raw(outer_ctx, KEY_NOW))) } + if map_has(outer_ctx, pkey) == 1 { map_put(child, pkey, map.lookup(outer_ctx, pkey)) } + if map_has(outer_ctx, KEY_NOW) == 1 { map_put(child, KEY_NOW, string.copy(map.lookup(outer_ctx, KEY_NOW))) } } // Bump depth on the child ctx for both forms. map_put(child, KEY_INCLUDE_DEPTH, string.from_int(depth + 1)) @@ -4612,7 +4613,7 @@ render_block_from(chain_kinds: ptr, chain_contents: ptr, chain_blocks: ptr, leve ctx: @keep ptr) -> (string, string) { lv = from while lv < levels { - if map_has(list_get_raw(chain_blocks, lv), name) == 1 { break } + if map_has(list.at(chain_blocks, lv), name) == 1 { break } lv = lv + 1 } if lv >= levels { @@ -4622,7 +4623,7 @@ render_block_from(chain_kinds: ptr, chain_contents: ptr, chain_blocks: ptr, leve lv + 1, name, base_kinds, base_contents, base_lo, base_hi, ctx) if serr != "" { return "", serr } - packed = map_get_raw(list_get_raw(chain_blocks, lv), name) + packed = map.lookup(list.at(chain_blocks, lv), name) pn = string_length(packed) comma = string.index_of_from_n(packed, pn, ",", 0) lo, _ = string.to_int(string.substring_n(packed, pn, 0, comma)) @@ -4631,7 +4632,7 @@ render_block_from(chain_kinds: ptr, chain_contents: ptr, chain_blocks: ptr, leve prior = "" if had_prior == 1 { prior, _ = map.get_string(ctx, KEY_BLOCK_SUPER) } map_put(ctx, KEY_BLOCK_SUPER, super_text) - inner, rerr = render_range(list_get_raw(chain_kinds, lv), list_get_raw(chain_contents, lv), + inner, rerr = render_range(list.at(chain_kinds, lv), list.at(chain_contents, lv), lo, hi, ctx) if had_prior == 1 { map_put(ctx, KEY_BLOCK_SUPER, prior) @@ -4702,7 +4703,7 @@ free_layout_chain(chain_kinds: ptr, chain_contents: ptr, chain_blocks: ptr, nb = list_size(chain_blocks) i = 0 while i < nb { - map_free(list_get_raw(chain_blocks, i)) + map_free(list.at(chain_blocks, i)) i = i + 1 } list_free(chain_blocks) @@ -4712,7 +4713,7 @@ free_layout_chain(chain_kinds: ptr, chain_contents: ptr, chain_blocks: ptr, n = list_size(owned) i = 0 while i < n { - template_free(list_get_raw(owned, i)) + template_free(list.at(owned, i)) i = i + 1 } list_free(owned) @@ -4759,9 +4760,9 @@ render_layout_template(child_kinds: ptr, child_contents: ptr, child_n: int, free_layout_chain(chain_kinds, chain_contents, chain_blocks, seen, owned_parents) return "", cerr } - list_add_raw(chain_kinds, cur_kinds) - list_add_raw(chain_contents, cur_contents) - list_add_raw(chain_blocks, blocks) + list.add(chain_kinds, cur_kinds) + list.add(chain_contents, cur_contents) + list.add(chain_blocks, blocks) levels = levels + 1 resolved, perr = resolve_partial_path(root, cur_parent) @@ -4784,7 +4785,7 @@ render_layout_template(child_kinds: ptr, child_contents: ptr, child_n: int, free_layout_chain(chain_kinds, chain_contents, chain_blocks, seen, owned_parents) return "", string.concat("layout: cannot read parent: ", cur_parent) } - if owned == 1 { list_add_raw(owned_parents, parent_t) } + if owned == 1 { list.add(owned_parents, parent_t) } if terr != "" { msg = string.concat("layout parse error in ", cur_parent) msg = string.concat(msg, ": ") @@ -4792,8 +4793,8 @@ render_layout_template(child_kinds: ptr, child_contents: ptr, child_n: int, free_layout_chain(chain_kinds, chain_contents, chain_blocks, seen, owned_parents) return "", msg } - cur_kinds = list_get_raw(parent_t, 0) - cur_contents = list_get_raw(parent_t, 1) + cur_kinds = list.at(parent_t, 0) + cur_contents = list.at(parent_t, 1) cur_n = string_list_size(cur_kinds) next_lay, next_name, lerr = layout_target(cur_kinds, cur_contents, cur_n) if lerr != "" { @@ -4845,8 +4846,8 @@ render(t: ptr, ctx: @keep ptr) -> (string, string) { } render_template(t: ptr, ctx: @keep ptr) -> (string, string) { - kinds = list_get_raw(t, 0) - contents = list_get_raw(t, 1) + kinds = list.at(t, 0) + contents = list.at(t, 1) n = string_list_size(kinds) // A template whose first tag is `{% layout 'parent' %}` renders through @@ -5065,9 +5066,8 @@ struct LiquidNode { struct LiquidStore { nodes: ptr // list: id -> *LiquidNode, null for an id that is free - free_ids: ptr // intarr used as a stack of the free ids + free_ids: int[] // a stack of the free ids; its .len is the capacity free_len: int - free_cap: int live: int // nodes held made: int // nodes made since the last collection depth: int // renders in progress over the contexts sharing this store @@ -5076,14 +5076,13 @@ struct LiquidStore { store_new() -> ptr { st = heap.new(LiquidStore) st.nodes = list_new() - st.free_cap = 16 - st.free_ids = intarr_new_raw(16) + st.free_ids = make([]int, 16) return st as ptr } // The store `ctx` keeps its arrays and objects in, made on first use. store_of(ctx: ptr) -> ptr { - have = map_get_raw(ctx, KEY_VALUES) + have = map.lookup(ctx, KEY_VALUES) if have != null { return have } st = store_new() map_put(ctx, KEY_VALUES, st) @@ -5102,12 +5101,12 @@ store_free(p: ptr) { n = list_size(st.nodes) i = 0 while i < n { - nd = list_get_raw(st.nodes, i) + nd = list.at(st.nodes, i) if nd != null { node_free(nd) } i = i + 1 } list_free(st.nodes) - intarr_free(st.free_ids) + free(st.free_ids) heap.free(st) } @@ -5122,11 +5121,11 @@ node_new(ctx: ptr, kind: int) -> (string, ptr) { id = 0 if st.free_len > 0 { st.free_len = st.free_len - 1 - id = intarr_get_raw(st.free_ids, st.free_len) + id = st.free_ids[st.free_len] list_set(st.nodes, id, nd as ptr) } else { id = list_size(st.nodes) - list_add_raw(st.nodes, nd as ptr) + list.add(st.nodes, nd as ptr) } st.live = st.live + 1 st.made = st.made + 1 @@ -5143,7 +5142,7 @@ store_node(sp: ptr, v: string) -> ptr { st = sp as *LiquidStore id, err = string.to_int(value_payload(v)) if err != "" || id < 0 || id >= list_size(st.nodes) { return null } - p = list_get_raw(st.nodes, id) + p = list.at(st.nodes, id) if p == null { return null } nd = p as *LiquidNode if nd.kind != k { return null } @@ -5152,7 +5151,7 @@ store_node(sp: ptr, v: string) -> ptr { // The node `v` names in `ctx`'s store (store_node). node_of(ctx: ptr, v: string) -> ptr { - return store_node(map_get_raw(ctx, KEY_VALUES), v) + return store_node(map.lookup(ctx, KEY_VALUES), v) } arr_len(nd: ptr) -> int { return string_list_size((nd as *LiquidNode).items) } @@ -5178,25 +5177,24 @@ obj_replace(nd: ptr, key: string, v: string) { // (value, 1) for a key the object has, (nil, 0) otherwise. obj_get(nd: ptr, key: string) -> (string, int) { o = nd as *LiquidNode - v = map_get_raw(o.fields, key) + v = map.lookup(o.fields, key) if v == null { return value_nil(), 0 } return v, 1 } // Keep `id` for the next node. store_push_free(st: *LiquidStore, id: int) { - if st.free_len == st.free_cap { - grown = intarr_new_raw(st.free_cap * 2) + if st.free_len == st.free_ids.len { + grown = make([]int, st.free_ids.len * 2) i = 0 while i < st.free_len { - intarr_set_raw(grown, i, intarr_get_raw(st.free_ids, i)) + grown[i] = st.free_ids[i] i = i + 1 } - intarr_free(st.free_ids) + free(st.free_ids) st.free_ids = grown - st.free_cap = st.free_cap * 2 } - intarr_set_raw(st.free_ids, st.free_len, id) + st.free_ids[st.free_len] = id st.free_len = st.free_len + 1 } @@ -5212,11 +5210,11 @@ store_collect(ctx: ptr, sp: ptr) { st = sp as *LiquidStore pending = string_list_new() typed = typed_of(ctx) - keys = map_keys_raw(typed) - nk = map_keys_size_raw(keys) + keys, _kerr = map.keys(typed) + nk = map.keys_size(keys) i = 0 while i < nk { - v = map_get_raw(typed, map_keys_get_raw(keys, i)) + v = map.lookup(typed, map.keys_get(keys, i)) if is_node_value(v) == 1 { string_list_add(pending, v) } i = i + 1 } @@ -5249,7 +5247,7 @@ store_collect(ctx: ptr, sp: ptr) { total = list_size(st.nodes) id = 0 while id < total { - q = list_get_raw(st.nodes, id) + q = list.at(st.nodes, id) if q != null { held = q as *LiquidNode if held.marked == 1 { @@ -5272,7 +5270,7 @@ store_collect(ctx: ptr, sp: ptr) { // last collection. For measuring; a template never needs it. context_value_nodes(ctx: ptr) -> int { if map_has(ctx, KEY_VALUES) == 0 { return 0 } - return (map_get_raw(ctx, KEY_VALUES) as *LiquidStore).live + return (map.lookup(ctx, KEY_VALUES) as *LiquidStore).live } // ---- Reading values: text, output, truthiness, equality, order ------------- diff --git a/contrib/tinyweb/schema_api/module.ae b/contrib/tinyweb/schema_api/module.ae index f255d06a5..d0e3c7d08 100644 --- a/contrib/tinyweb/schema_api/module.ae +++ b/contrib/tinyweb/schema_api/module.ae @@ -39,7 +39,8 @@ exports( import std.schema import std.map import std.json -import std.list(list_new, list_add_raw, list_get_raw, list_size) +import std.list +import std.list(list_new, list_size) import std.string(string_length, string_concat, string_equals) import std.strbuilder @@ -344,7 +345,7 @@ fn build_openapi(groups: ptr) -> string { strbuilder.append(b, "\"paths\":{") i = 0 while i < n { - m = list_get_raw(groups, i) as *ApiGroup + m = list.at(groups, i) as *ApiGroup name = schema_name_for(m.prefix) if i > 0 { strbuilder.append(b, ",") @@ -374,7 +375,7 @@ fn build_openapi(groups: ptr) -> string { strbuilder.append(b, "\"components\":{\"schemas\":{") i = 0 while i < n { - m = list_get_raw(groups, i) as *ApiGroup + m = list.at(groups, i) as *ApiGroup name = schema_name_for(m.prefix) js = schema.to_json_schema(m.resource) if i > 0 { diff --git a/contrib/vulkan/tools/vkgen.ae b/contrib/vulkan/tools/vkgen.ae index 68fcca01f..1be70f99f 100644 --- a/contrib/vulkan/tools/vkgen.ae +++ b/contrib/vulkan/tools/vkgen.ae @@ -81,7 +81,7 @@ str_of(v: string) -> string { return v } get(m: ptr, k: string) -> string { if map_has(m, k) == 0 { return "" } - return str_of(map_get_raw(m, k)) + return str_of(map.lookup(m, k)) } put(m: ptr, k: string, v: string) { @@ -826,7 +826,7 @@ require_command(c: string, owner: string) { select_selection(api: string, extensions: string) { i = 0 while i < list_size(g_features) { - f = str_of(list_get_raw(g_features, i)) + f = str_of(list.at(g_features, i)) if version_le(get(g_feat_number, f), api) == 1 { put(g_selected, f, "1") } i = i + 1 } @@ -846,7 +846,7 @@ select_selection(api: string, extensions: string) { // order, each block only when its `depends` holds. k = 0 while k < list_size(g_order_owners) { - owner = str_of(list_get_raw(g_order_owners, k)) + owner = str_of(list.at(g_order_owners, k)) if map_has(g_selected, owner) == 1 { reqs = lines_of(get(g_reqs, owner)) r = 0 @@ -883,10 +883,10 @@ select_selection(api: string, extensions: string) { // (VK_MAX_PHYSICAL_DEVICE_NAME_SIZE in VkPhysicalDeviceProperties) is // declared too. Registries from 1.3.207 list them in the features' // requirements; earlier ones name them only in the member. - keys = map_keys_raw(g_req_types) + keys, _kerr = map.keys(g_req_types) t = 0 - while t < map_keys_size_raw(keys) { - ty = str_of(map_keys_get_raw(keys, t)) + while t < map.keys_size(keys) { + ty = str_of(map.keys_get(keys, t)) cat = get(g_cat, ty) if string.equals(cat, "struct") == 1 || string.equals(cat, "union") == 1 { mem = lines_of(get(g_members, ty)) @@ -1056,7 +1056,7 @@ emit_module(path: string, api: string, extensions: string) { // and the extensions' spec versions and names. k = 0 while k < list_size(g_ext_const_order) { - nm = str_of(list_get_raw(g_ext_const_order, k)) + nm = str_of(list.at(g_ext_const_order, k)) v = get(g_ext_consts, nm) if string.equals(v, "=") == 1 { ty = get(g_const_type, nm) @@ -1099,11 +1099,11 @@ emit_module(path: string, api: string, extensions: string) { } strbuilder.append(body, "\n") - enum_blocks = map_keys_raw(g_req_types) + enum_blocks, _berr = map.keys(g_req_types) blocks = list.new() b = 0 - while b < map_keys_size_raw(enum_blocks) { - t = str_of(map_keys_get_raw(enum_blocks, b)) + while b < map.keys_size(enum_blocks) { + t = str_of(map.keys_get(enum_blocks, b)) if string.equals(get(g_cat, t), "enum") == 1 && map_has(g_enum_kind, t) == 1 { list.add(blocks, string.concat(t, "")) } @@ -1114,7 +1114,7 @@ emit_module(path: string, api: string, extensions: string) { emitted = map.new() bi = 0 while bi < list_size(blocks) { - t = str_of(list_get_raw(blocks, bi)) + t = str_of(list.at(blocks, bi)) strbuilder.append(body, "// ${t}\n") all = string.concat(get(g_enum_values, t), get(g_ext_values, t)) vals = lines_of(all) @@ -1161,7 +1161,7 @@ emit_module(path: string, api: string, extensions: string) { done = map.new() oi = 0 while oi < list_size(g_order) { - emit_struct(str_of(list_get_raw(g_order, oi)), body, names, done) + emit_struct(str_of(list.at(g_order, oi)), body, names, done) oi = oi + 1 } @@ -1228,7 +1228,7 @@ emit_module(path: string, api: string, extensions: string) { loads_dev = strbuilder.new(1 << 14) ci = 0 while ci < list_size(g_req_cmd_order) { - c = str_of(list_get_raw(g_req_cmd_order, ci)) + c = str_of(list.at(g_req_cmd_order, ci)) target = c alias = get(g_alias, c) if string.length(alias) > 0 { target = alias } @@ -1562,9 +1562,9 @@ sort_list(l: ptr) { i = 1 while i < n { j = i - while j > 0 && string.compare(str_of(list_get_raw(l, j - 1)), str_of(list_get_raw(l, j))) > 0 { - a = list_get_raw(l, j - 1) - list_set(l, j - 1, list_get_raw(l, j)) + while j > 0 && string.compare(str_of(list.at(l, j - 1)), str_of(list.at(l, j))) > 0 { + a = list.at(l, j - 1) + list_set(l, j - 1, list.at(l, j)) list_set(l, j, a) j = j - 1 } @@ -1665,7 +1665,7 @@ emit_dispatch(path: string, commands_path: string, api: string, extensions: stri strbuilder.append(sb, "#ifndef AETHER_VULKAN_DISPATCH_H\n#define AETHER_VULKAN_DISPATCH_H\n") oi = 0 while oi < list_size(order) { - m = str_of(list_get_raw(order, oi)) + m = str_of(list.at(order, oi)) body = get(groups, m) strbuilder.append(sb, "\n/* ${get(levels, m)} */\n#define ${m}(X) \\\n") // Drop the continuation after the last entry. diff --git a/docs/api/index.html b/docs/api/index.html index cd65c7877..5680430be 100644 --- a/docs/api/index.html +++ b/docs/api/index.html @@ -304,7 +304,7 @@

    language

    list

    std.list - ArrayList Operations (alias for std.collections) API shape: - Raw externs end in `_raw` and return ptr/int in the old C-style convention.

    - 13 exports + 14 exports

    log

    @@ -324,7 +324,7 @@

    lzf

    map

    std.map - HashMap Operations (alias for std.collections) API shape: - Raw externs end in `_raw` and return ptr/int in the old C-style convention.

    - 19 exports + 20 exports

    math

    @@ -652,7 +652,7 @@

    Search Results

  • from_decimalbignum
  • freebignum
  • clonebignum
  • -
  • limbbignum
  • +
  • test_bitbignum
  • lsr32bits
  • lsr64bits
  • rotr32bits
  • @@ -1498,6 +1498,7 @@

    Search Results

  • freelist
  • getlist
  • get_stringlist
  • +
  • atlist
  • init_rawlog
  • shutdownlog
  • writelog
  • @@ -1547,6 +1548,7 @@

    Search Results

  • keys_get_rawmap
  • getmap
  • get_stringmap
  • +
  • lookupmap
  • keysmap
  • keys_sizemap
  • keys_getmap
  • diff --git a/docs/api/list.html b/docs/api/list.html index 535a0ad6c..216503926 100644 --- a/docs/api/list.html +++ b/docs/api/list.html @@ -28,6 +28,7 @@

    Exports

  • free
  • get
  • get_string
  • +
  • at

  • Modules

    @@ -198,6 +199,12 @@

    get_string fn

    list: ptr, index: int

    Index into a list whose element is a string. `get` hands back a `ptr`, which a `string` local would read as text whatever it points at (refused, E0202); this says the element is a string, and checks it is one: - (item, "") : index in range, the element a string - (null, "") : the element is null - (null, "not a string") : the element is some other pointer - (null, "index out of range") : index < 0 or index >= size - (null, "null list") : `list` is null

    +
    +

    at fn

    +
    at(list, index) -> ptr
    +

    list: ptr, index: int

    +

    The item at `index`, for a caller that has bounded the index (a loop over 0 ..< list_size). An index out of range, or a null list, is a panic that names the index and the size, as a slice index is (#3107): the unchecked read handed back a null there, which a caller took for a stored null.

    +
    diff --git a/docs/api/map.html b/docs/api/map.html index 1cefebc04..ca32a4095 100644 --- a/docs/api/map.html +++ b/docs/api/map.html @@ -31,6 +31,7 @@

    Exports

  • keys_get_raw
  • get
  • get_string
  • +
  • lookup
  • keys
  • keys_size
  • keys_get
  • @@ -220,6 +221,12 @@

    get_string fn

    map: ptr, key: string

    Look up a key whose value is a string. `get` hands back a `ptr`, which a `string` local would read as text whatever it points at (refused, E0202); this says the value is a string, and checks it is one: - (value, "") : key present, its value a string - (null, "") : key absent (not an error — use map.has) - (null, "not a string") : the value is some other pointer - (null, "null map") : `map` is null

    +
    +

    lookup fn

    +
    lookup(map, key) -> ptr
    +

    map: ptr, key: string

    +

    The value stored under `key`, or null when the map holds none (or the map is null): `get` without its error slot, for a caller that reads absence as null (#3107).

    +

    keys fn

    keys(map) -> (ptr, string)
    diff --git a/docs/stdlib-reference.md b/docs/stdlib-reference.md index b3610a5e9..80804db78 100644 --- a/docs/stdlib-reference.md +++ b/docs/stdlib-reference.md @@ -49,11 +49,11 @@ header comment is the authoritative description. | `std.ksuid` | KSUID: 160-bit lexicographically sortable identifier. | 1 | [guide](../std/ksuid/README.md) · [source](../std/ksuid/module.ae) | | `std.language` | BCP 47 language tags and matching (RFC 5646, RFC 4647). | 11 | [guide](../std/language/README.md) · [source](../std/language/module.ae) | | `std.lanes` | SIMD lanes: four or eight floats, or two doubles, per vector, with masks and select. | 127 | [guide](../std/lanes/README.md) · [source](../std/lanes/module.ae) | -| `std.list` | Dynamic array, re-exported from `std.collections`. | 13 | [guide](../std/list/README.md) · [source](../std/list/module.ae) | +| `std.list` | Dynamic array, re-exported from `std.collections`. | 14 | [guide](../std/list/README.md) · [source](../std/list/module.ae) | | `std.log` | Levelled logging with timestamps, colours and counters. | 9 | [full section](#logging-stdlog) | | `std.longarr` | Fixed-size packed-long buffer. | 15 | [guide](../std/longarr/README.md) · [source](../std/longarr/module.ae) | | `std.lzf` | One-shot LZF compression and decompression. | 9 | [guide](../std/lzf/README.md) · [source](../std/lzf/module.ae) | -| `std.map` | Hash map, re-exported from `std.collections`, with readable key snapshots. | 19 | [guide](../std/map/README.md) · [source](../std/map/module.ae) | +| `std.map` | Hash map, re-exported from `std.collections`, with readable key snapshots. | 20 | [guide](../std/map/README.md) · [source](../std/map/module.ae) | | `std.math` | Arithmetic, trigonometry, rounding and floating-point helpers. | 45 | [full section](#math-stdmath) | | `std.mem` | Byte-level reads and writes over caller-allocated raw pointers. | 98 | [guide](../std/mem/README.md) · [source](../std/mem/module.ae) | | `std.message` | ICU MessageFormat formatting and message catalogues. | 8 | [guide](../std/message/README.md) · [source](../std/message/module.ae) | @@ -205,13 +205,14 @@ ada [] - `list.add(list, item)` → `string` - Append item, return error string - `list.get(list, index)` → `(ptr, string)` - The item at `index`: `(null, "index out of range")` or `(null, "null list")` on failure - `list.get_string(list, index)` → `(string, string)` - The item as a string, checked: `(null, "not a string")` when the element is some other pointer, `(null, "")` when it is null, and `get`'s errors +- `list.at(list, index)` → `ptr` - The item at an index the caller has bounded (a loop over `0 ..< list.size(l)`); out of range, or a null list, is a panic naming the index and the size, as a slice index is - `list.set(list, index, item)` - Set item at index - `list.remove(list, index)` - Remove item at index - `list.size(list)` - Get number of elements - `list.clear(list)` - Remove all elements - `list.free(list)` - Free list memory -Raw externs: `list_add_raw` (returns 1/0), `list_get_raw` (no bounds check). +Raw externs: `list_add_raw` (returns 1/0), `list_get_raw` (no bounds check): the C bindings the functions above are built on, for std.list itself. Another module calls the functions (#3107). **String elements.** `list.add` and `list.set` of a string take a reference to it, released when the element leaves (`list.remove`, `list.clear`, a store @@ -318,6 +319,7 @@ Aether [] 1 - `map.put(map, key, value)` → `string` - Insert or update, return error string - `map.get(map, key)` → `(ptr, string)` - The value: `(null, "")` if the key is absent, `(null, "null map")` if `map` is null - `map.get_string(map, key)` → `(string, string)` - Get a string value: `(null, "")` if missing, `(null, "not a string")` if the value is some other pointer +- `map.lookup(map, key)` → `ptr` - The value, or null when the key is absent or the map is null: `get` without its error slot - `map.has(map, key)` - Check if key exists - `map.remove(map, key)` - Remove key-value pair - `map.size(map)` - Get number of entries diff --git a/new_changelogs/20261022T050100Z-added-3107-list-at-map-lookup.md b/new_changelogs/20261022T050100Z-added-3107-list-at-map-lookup.md new file mode 100644 index 000000000..dd9bd7e51 --- /dev/null +++ b/new_changelogs/20261022T050100Z-added-3107-list-at-map-lookup.md @@ -0,0 +1,10 @@ +- **`list.at(list, i)` and `map.lookup(map, key)`, and std and contrib use + the containers' public functions.** `list.at` returns the item at an + index the caller has bounded and panics, naming the index and the size, + on one out of range, as a slice index does; `map.lookup` returns the + value or null, `map.get` without its error slot. 25 files in std and + contrib called std.list's and std.map's raw C bindings instead (286 + calls: `list_get_raw`, which read a null past the end, `list_add_raw`, + `map_put_raw`, `map_get_raw`, `map_keys_*_raw`), and std.message read + the map's key snapshot through a copy of its C struct layout. They call + `list.at`, `list.add`, `map.put`, `map.lookup` and `map.keys` (#3107). diff --git a/std/alloc/test_alloc_tracking.ae b/std/alloc/test_alloc_tracking.ae index 4a04d2a5c..a63627600 100644 --- a/std/alloc/test_alloc_tracking.ae +++ b/std/alloc/test_alloc_tracking.ae @@ -2,7 +2,8 @@ import std.alloc(system, raw, resize, release, of_arena, arena_free) import std.tracking(wrap, count, bytes, report, destroy) -import std.list(list_new_in, list_add_raw, add, list_size, list_free) +import std.list +import std.list(list_new_in, add, list_size, list_free) import std.arena extern exit(code: int) @@ -68,7 +69,7 @@ main() { l = list_new_in(lt) i = 0 while i < 64 { - list_add_raw(l, l) + list.add(l, l) i = i + 1 } if list_size(l) != 64 { fail("list size ${list_size(l)}, want 64") } diff --git a/std/clapae/module.ae b/std/clapae/module.ae index c8136a684..95b130dc6 100644 --- a/std/clapae/module.ae +++ b/std/clapae/module.ae @@ -145,7 +145,7 @@ builder arg(name: string): *Arg with arg_new { parent = builder_context() if parent != null { parent_cmd = parent as *Command - list_add_raw(parent_cmd.args, a) + list.add(parent_cmd.args, a) } } return _builder as *Arg @@ -158,7 +158,7 @@ builder subcommand(name: string): *Command with command_new { parent = builder_context() if parent != null { parent_cmd = parent as *Command - list_add_raw(parent_cmd.subcommands, c) + list.add(parent_cmd.subcommands, c) } } return _builder as *Command @@ -245,7 +245,7 @@ free_command(c: *Command) { if c.args != null { n = list_size(c.args) for (i = 0; i < n; i++) { - a = list_get_raw(c.args, i) as *Arg + a = list.at(c.args, i) as *Arg if a != null { heap.free(a) } @@ -255,7 +255,7 @@ free_command(c: *Command) { if c.subcommands != null { n = list_size(c.subcommands) for (i = 0; i < n; i++) { - sub = list_get_raw(c.subcommands, i) as *Command + sub = list.at(c.subcommands, i) as *Command free_command(sub) } list_free(c.subcommands) @@ -289,7 +289,7 @@ fn is_option(a: *Arg) -> int { find_arg_by_long(cmd: *Command, name: string) -> *Arg { n = list_size(cmd.args) for (i = 0; i < n; i++) { - a = list_get_raw(cmd.args, i) as *Arg + a = list.at(cmd.args, i) as *Arg if is_option(a) == 1 && string_equals(a.long_name, name) == 1 { return a } @@ -300,7 +300,7 @@ find_arg_by_long(cmd: *Command, name: string) -> *Arg { find_arg_by_short(cmd: *Command, sh_char: int) -> *Arg { n = list_size(cmd.args) for (i = 0; i < n; i++) { - a = list_get_raw(cmd.args, i) as *Arg + a = list.at(cmd.args, i) as *Arg if is_option(a) == 1 && a.short_char == sh_char { return a } @@ -312,7 +312,7 @@ find_positional_arg(cmd: *Command, idx: int) -> *Arg { pos_count = 0 n = list_size(cmd.args) for (i = 0; i < n; i++) { - a = list_get_raw(cmd.args, i) as *Arg + a = list.at(cmd.args, i) as *Arg if a.is_positional == 1 { if pos_count == idx { return a @@ -326,7 +326,7 @@ find_positional_arg(cmd: *Command, idx: int) -> *Arg { find_subcommand(cmd: *Command, name: string) -> *Command { n = list_size(cmd.subcommands) for (i = 0; i < n; i++) { - sub = list_get_raw(cmd.subcommands, i) as *Command + sub = list.at(cmd.subcommands, i) as *Command if string_equals(sub.name, name) == 1 { return sub } @@ -343,13 +343,13 @@ print_help(cmd: *Command) { has_options = 0 n_args = list_size(cmd.args) for (i = 0; i < n_args; i++) { - a = list_get_raw(cmd.args, i) as *Arg + a = list.at(cmd.args, i) as *Arg if is_option(a) == 1 { has_options = 1 } } if has_options == 1 { print(" [OPTIONS]") } for (i = 0; i < n_args; i++) { - a = list_get_raw(cmd.args, i) as *Arg + a = list.at(cmd.args, i) as *Arg if a.is_positional == 1 { if a.required == 1 { print(" <"); print(a.name); print(">") @@ -369,7 +369,7 @@ print_help(cmd: *Command) { if has_options == 1 { println("Options:") for (i = 0; i < n_args; i++) { - a = list_get_raw(cmd.args, i) as *Arg + a = list.at(cmd.args, i) as *Arg if is_option(a) == 1 { print(" ") if a.short_char != 0 { @@ -398,7 +398,7 @@ print_help(cmd: *Command) { if n_subs > 0 { println("Commands:") for (i = 0; i < n_subs; i++) { - sub = list_get_raw(cmd.subcommands, i) as *Command + sub = list.at(cmd.subcommands, i) as *Command print(" "); print(sub.name) if string_equals(sub.about_text, "") != 1 { print("\t"); print(sub.about_text) @@ -421,7 +421,7 @@ parse(cmd: *Command) -> (int, ptr, string) { argv = list_new() cnt = args_count() for (i = 1; i < cnt; i++) { - list_add_raw(argv, args_get(i)) + list.add(argv, args_get(i)) } res, matches, err = parse_list(cmd, argv) list_free(argv) @@ -471,7 +471,7 @@ parse_list_internal(cmd: *Command, argv: ptr) -> (int, ptr, string) { i = 0 while i < n_argv { - arg_str = list_get_raw(argv, i) + arg_str = list.at(argv, i) if string_equals(arg_str, "-h") == 1 || string_equals(arg_str, "--help") == 1 { // Do NOT exit here — report that help was requested and let the @@ -506,7 +506,7 @@ parse_list_internal(cmd: *Command, argv: ptr) -> (int, ptr, string) { map_put(m.flags, a.name, a) } else { if i + 1 < n_argv { - val = list_get_raw(argv, i + 1) + val = list.at(argv, i + 1) if value_ok(a, val) == 0 { free_matches(m) return RESULT_ERROR, null, int_error_msg(a, val) @@ -531,7 +531,7 @@ parse_list_internal(cmd: *Command, argv: ptr) -> (int, ptr, string) { map_put(m.flags, a.name, a) } else { if i + 1 < n_argv { - val = list_get_raw(argv, i + 1) + val = list.at(argv, i + 1) if value_ok(a, val) == 0 { free_matches(m) return RESULT_ERROR, null, int_error_msg(a, val) @@ -577,7 +577,7 @@ parse_list_internal(cmd: *Command, argv: ptr) -> (int, ptr, string) { if sub != null { sub_argv = list_new() for (k = i + 1; k < n_argv; k++) { - list_add_raw(sub_argv, list_get_raw(argv, k)) + list.add(sub_argv, list.at(argv, k)) } sub_res, sub_matches, sub_err = parse_list_internal(sub, sub_argv) list_free(sub_argv) @@ -609,7 +609,7 @@ parse_list_internal(cmd: *Command, argv: ptr) -> (int, ptr, string) { // required-argument enforcement n_args = list_size(cmd.args) for (k = 0; k < n_args; k++) { - a = list_get_raw(cmd.args, k) as *Arg + a = list.at(cmd.args, k) as *Arg if a.required == 1 { has_val = map_has(m.values, a.name) has_flag = map_has(m.flags, a.name) @@ -629,7 +629,7 @@ parse_list_internal(cmd: *Command, argv: ptr) -> (int, ptr, string) { get_string(m: ptr, name: string) -> string { if m == null { return "" } matches = m as *ArgMatches - v = map_get_raw(matches.values, name) + v = map.lookup(matches.values, name) if v == null { return "" } return v } @@ -640,7 +640,7 @@ get_string(m: ptr, name: string) -> string { get_int(m: ptr, name: string) -> (int, string) { if m == null { return 0, "no matches" } matches = m as *ArgMatches - v = map_get_raw(matches.values, name) + v = map.lookup(matches.values, name) if v == null { return 0, string_concat("argument not provided: ", name) } diff --git a/std/clapae/test_clapae.ae b/std/clapae/test_clapae.ae index 8911d32a9..a233ff1b5 100644 --- a/std/clapae/test_clapae.ae +++ b/std/clapae/test_clapae.ae @@ -43,12 +43,12 @@ main() { // --- typed parse: string option, int option, flags, positional --- cmd = build_cmd() argv = list_new() - list_add_raw(argv, "--config") - list_add_raw(argv, "app.json") - list_add_raw(argv, "--count") - list_add_raw(argv, "7") - list_add_raw(argv, "-dv") // compound flags - list_add_raw(argv, "my_input") // positional + list.add(argv, "--config") + list.add(argv, "app.json") + list.add(argv, "--count") + list.add(argv, "7") + list.add(argv, "-dv") // compound flags + list.add(argv, "my_input") // positional res, m, err = parse_list(cmd, argv) list_free(argv) if res != clapae.RESULT_OK { fail(string_concat("expected OK, err=", err)) } @@ -68,9 +68,9 @@ main() { // --- int validation at the boundary: --count notanumber -> RESULT_ERROR --- cmd2 = build_cmd() a2 = list_new() - list_add_raw(a2, "--count") - list_add_raw(a2, "notanumber") - list_add_raw(a2, "in") + list.add(a2, "--count") + list.add(a2, "notanumber") + list.add(a2, "in") res2, m2, err2 = parse_list(cmd2, a2) list_free(a2) if res2 != clapae.RESULT_ERROR { fail("expected ERROR for non-numeric --count") } @@ -84,7 +84,7 @@ main() { // --- help requested: -h -> RESULT_HELP, library does NOT exit --- cmd3 = build_cmd() a3 = list_new() - list_add_raw(a3, "-h") + list.add(a3, "-h") res3, m3, err3 = parse_list(cmd3, a3) list_free(a3) if res3 != clapae.RESULT_HELP { fail("expected HELP for -h") } @@ -95,9 +95,9 @@ main() { // --- subcommand with its own flag --- cmd4 = build_cmd() a4 = list_new() - list_add_raw(a4, "input_needed") // required positional for parent - list_add_raw(a4, "test") - list_add_raw(a4, "--list") + list.add(a4, "input_needed") // required positional for parent + list.add(a4, "test") + list.add(a4, "--list") res4, m4, err4 = parse_list(cmd4, a4) list_free(a4) if res4 != clapae.RESULT_OK { fail(string_concat("subcommand parse: ", err4)) } @@ -112,8 +112,8 @@ main() { // --- inline short value (-cfile.json) --- cmd5 = build_cmd() a5 = list_new() - list_add_raw(a5, "-cinline.json") - list_add_raw(a5, "pos") + list.add(a5, "-cinline.json") + list.add(a5, "pos") res5, m5, err5 = parse_list(cmd5, a5) list_free(a5) if res5 != clapae.RESULT_OK { fail(string_concat("inline short: ", err5)) } @@ -125,7 +125,7 @@ main() { // --- required positional missing -> RESULT_ERROR --- cmd6 = build_cmd() a6 = list_new() - list_add_raw(a6, "--debug") + list.add(a6, "--debug") res6, m6, err6 = parse_list(cmd6, a6) list_free(a6) if res6 != clapae.RESULT_ERROR { fail("expected ERROR for missing required positional") } @@ -136,8 +136,8 @@ main() { // --- unknown option -> RESULT_ERROR --- cmd7 = build_cmd() a7 = list_new() - list_add_raw(a7, "--nonesuch") - list_add_raw(a7, "x") + list.add(a7, "--nonesuch") + list.add(a7, "x") res7, m7, err7 = parse_list(cmd7, a7) list_free(a7) if res7 != clapae.RESULT_ERROR { fail("expected ERROR for unknown option") } @@ -148,8 +148,8 @@ main() { // --- --opt=value (equals form) --- cmd8 = build_cmd() a8 = list_new() - list_add_raw(a8, "--config=eq.json") - list_add_raw(a8, "pos8") + list.add(a8, "--config=eq.json") + list.add(a8, "pos8") res8, m8, err8 = parse_list(cmd8, a8) list_free(a8) if res8 != clapae.RESULT_OK { fail(string_concat("--opt=val: ", err8)) } @@ -161,7 +161,7 @@ main() { // --- getters on ABSENT arguments --- cmd9 = build_cmd() a9 = list_new() - list_add_raw(a9, "only_positional") + list.add(a9, "only_positional") res9, m9, err9 = parse_list(cmd9, a9) list_free(a9) if res9 != clapae.RESULT_OK { fail(string_concat("absent-getters parse: ", err9)) } @@ -181,9 +181,9 @@ main() { // --- negative and zero int values validate --- cmd10 = build_cmd() a10 = list_new() - list_add_raw(a10, "--count") - list_add_raw(a10, "-5") - list_add_raw(a10, "posn") + list.add(a10, "--count") + list.add(a10, "-5") + list.add(a10, "posn") res10, m10, err10 = parse_list(cmd10, a10) list_free(a10) if res10 != clapae.RESULT_OK { fail(string_concat("negative int: ", err10)) } @@ -200,8 +200,8 @@ main() { arg("num") { long_("num"); kind(clapae.KIND_INT) } } a11 = list_new() - list_add_raw(a11, "--num") - list_add_raw(a11, "notint") + list.add(a11, "--num") + list.add(a11, "notint") res11, m11, err11 = parse_list(cmd11, a11) list_free(a11) if res11 != clapae.RESULT_ERROR { fail("kind(KIND_INT) should validate: expected ERROR for 'notint'") } @@ -214,7 +214,7 @@ main() { arg("out") { long_("out"); string_arg() } } a12 = list_new() - list_add_raw(a12, "--out") // no following value + list.add(a12, "--out") // no following value res12, m12, err12 = parse_list(cmd12, a12) list_free(a12) if res12 != clapae.RESULT_ERROR { fail("expected ERROR: --out requires a value") } @@ -229,9 +229,9 @@ main() { arg("mode") { positional() } // optional third positional } a14 = list_new() - list_add_raw(a14, "from.txt") - list_add_raw(a14, "to.txt") - list_add_raw(a14, "fast") + list.add(a14, "from.txt") + list.add(a14, "to.txt") + list.add(a14, "fast") res14, m14, err14 = parse_list(cmd14, a14) list_free(a14) if res14 != clapae.RESULT_OK { fail(string_concat("multi-positional: ", err14)) } @@ -247,8 +247,8 @@ main() { arg("only") { positional() } } a15 = list_new() - list_add_raw(a15, "one") - list_add_raw(a15, "two") // no slot for a second positional + list.add(a15, "one") + list.add(a15, "two") // no slot for a second positional res15, m15, err15 = parse_list(cmd15, a15) list_free(a15) if res15 != clapae.RESULT_ERROR { fail("expected ERROR: unexpected positional") } @@ -261,7 +261,7 @@ main() { arg("num") { long_("num"); int_arg() } } a16 = list_new() - list_add_raw(a16, "--num=notint") + list.add(a16, "--num=notint") res16, m16, err16 = parse_list(cmd16, a16) list_free(a16) if res16 != clapae.RESULT_ERROR { fail("expected ERROR: --num=notint") } @@ -274,7 +274,7 @@ main() { arg("num") { short(110); int_arg() } // -n } a17ok = list_new() - list_add_raw(a17ok, "-n99") + list.add(a17ok, "-n99") r17a, m17a, e17a = parse_list(cmd17, a17ok) list_free(a17ok) if r17a != clapae.RESULT_OK { fail(string_concat("-n99: ", e17a)) } @@ -283,7 +283,7 @@ main() { if v17 != 99 { fail("-n99 value") } free_matches(m17a as *ArgMatches) a17bad = list_new() - list_add_raw(a17bad, "-nX") + list.add(a17bad, "-nX") r17b, m17b, e17b = parse_list(cmd17, a17bad) list_free(a17bad) if r17b != clapae.RESULT_ERROR { fail("expected ERROR: -nX") } @@ -298,9 +298,9 @@ main() { } } a18 = list_new() - list_add_raw(a18, "add") - list_add_raw(a18, "--count") - list_add_raw(a18, "notnum") + list.add(a18, "add") + list.add(a18, "--count") + list.add(a18, "notnum") res18, m18, err18 = parse_list(cmd18, a18) list_free(a18) if res18 != clapae.RESULT_ERROR { fail("subcommand error should propagate to parent") } @@ -311,8 +311,8 @@ main() { // --- subcommand_matches with the WRONG name returns null --- cmd19 = build_cmd() a19 = list_new() - list_add_raw(a19, "needed") - list_add_raw(a19, "test") + list.add(a19, "needed") + list.add(a19, "test") res19, m19, err19 = parse_list(cmd19, a19) list_free(a19) if res19 != clapae.RESULT_OK { fail(string_concat("subcmd wrongname parse: ", err19)) } @@ -325,11 +325,11 @@ main() { // --- repeated option: last value wins --- cmd20 = build_cmd() a20 = list_new() - list_add_raw(a20, "--config") - list_add_raw(a20, "first.json") - list_add_raw(a20, "--config") - list_add_raw(a20, "second.json") - list_add_raw(a20, "pos20") + list.add(a20, "--config") + list.add(a20, "first.json") + list.add(a20, "--config") + list.add(a20, "second.json") + list.add(a20, "pos20") res20, m20, err20 = parse_list(cmd20, a20) list_free(a20) if res20 != clapae.RESULT_OK { fail(string_concat("repeated option: ", err20)) } diff --git a/std/cryptography/asn1/module.ae b/std/cryptography/asn1/module.ae index bd0f09459..e72746c7c 100644 --- a/std/cryptography/asn1/module.ae +++ b/std/cryptography/asn1/module.ae @@ -951,8 +951,8 @@ encode_set(elements_list: ptr) -> ptr { while i < sz { j = 0 while j < sz - 1 - i { - e1 = list_get_raw(elements_list, j) - e2 = list_get_raw(elements_list, j + 1) + e1 = list.at(elements_list, j) + e2 = list.at(elements_list, j + 1) cmp = compare_bytes(e1, e2) if cmp > 0 { list_set(elements_list, j, e2) @@ -967,7 +967,7 @@ encode_set(elements_list: ptr) -> ptr { tot_len = 0 i = 0 while i < sz { - el = list_get_raw(elements_list, i) + el = list.at(elements_list, i) tot_len = tot_len + bytes.length(el) i = i + 1 } @@ -976,7 +976,7 @@ encode_set(elements_list: ptr) -> ptr { off = 0 i = 0 while i < sz { - el = list_get_raw(elements_list, i) + el = list.at(elements_list, i) off = emit_bytes(body, off, el) bytes.free(el) i = i + 1 @@ -1011,7 +1011,7 @@ encode_sequence_list(elements_list: ptr) -> ptr { tot_len = 0 i = 0 while i < sz { - el = list_get_raw(elements_list, i) + el = list.at(elements_list, i) tot_len = tot_len + bytes.length(el) i = i + 1 } @@ -1019,7 +1019,7 @@ encode_sequence_list(elements_list: ptr) -> ptr { off = 0 i = 0 while i < sz { - el = list_get_raw(elements_list, i) + el = list.at(elements_list, i) off = emit_bytes(body, off, el) bytes.free(el) i = i + 1 diff --git a/std/cryptography/tls13_cert/module.ae b/std/cryptography/tls13_cert/module.ae index 7c72a0cc2..8d8d4ad9b 100644 --- a/std/cryptography/tls13_cert/module.ae +++ b/std/cryptography/tls13_cert/module.ae @@ -720,7 +720,7 @@ fn decode_san_dnsnames(octet_content: @keep ptr, out: *LeafCert) { if alen == 4 || alen == 16 { ip = bytes.new(alen) bytes.copy_from_bytes(ip, 0, name_content, 0, alen) - list_add_raw(out.ip_addrs, ip) + list.add(out.ip_addrs, ip) } } if tag == 0x82 { @@ -736,7 +736,7 @@ fn decode_san_dnsnames(octet_content: @keep ptr, out: *LeafCert) { nm = bytes.new(nlen) if nlen > 0 { bytes.set(nm, nlen - 1, 0) } bytes.copy_from_bytes(nm, 0, name_content, 0, nlen) - list_add_raw(out.dns_names, nm) + list.add(out.dns_names, nm) } } bytes.free(name_content) @@ -779,7 +779,7 @@ leaf_dns_name(c: *LeafCert, i: int) -> ptr { if c.dns_names == null { return null } if i < 0 { return null } if i >= list_size(c.dns_names) { return null } - return list_get_raw(c.dns_names, i) as ptr + return list.at(c.dns_names, i) as ptr } // Free every owned buffer/string in a parsed LeafCert. @@ -797,7 +797,7 @@ free_leaf(c: *LeafCert) { n = list_size(c.dns_names) i = 0 while i < n { - e = list_get_raw(c.dns_names, i) as ptr + e = list.at(c.dns_names, i) as ptr bytes.free(e) i = i + 1 } @@ -807,7 +807,7 @@ free_leaf(c: *LeafCert) { m = list_size(c.ip_addrs) j = 0 while j < m { - a = list_get_raw(c.ip_addrs, j) as ptr + a = list.at(c.ip_addrs, j) as ptr bytes.free(a) j = j + 1 } @@ -1019,7 +1019,7 @@ check_hostname(c: *LeafCert, host: string) -> string { hit = 0 i4 = 0 while i4 < n4 { - cand = list_get_raw(c.ip_addrs, i4) as ptr + cand = list.at(c.ip_addrs, i4) as ptr if cand != null { if bytes.length(cand) == 4 { same = 1 @@ -1043,7 +1043,7 @@ check_hostname(c: *LeafCert, host: string) -> string { if n == 0 { return "certificate has no subjectAltName dNSNames" } i = 0 while i < n { - nm = list_get_raw(c.dns_names, i) as ptr + nm = list.at(c.dns_names, i) as ptr if nm != null { plen = bytes.length(nm) if dnsname_matches(nm, plen, host, hlen) == 1 { return "" } @@ -1644,7 +1644,7 @@ chain_verify(leaf: *LeafCert, anchors: ptr) -> string { if n == 0 { return "no trust anchors" } i = 0 while i < n { - a = list_get_raw(anchors, i) as *LeafCert + a = list.at(anchors, i) as *LeafCert if a != null { if dn_equal(bytes.view(leaf.issuer)[0..leaf.issuer_len], bytes.view(a.subject)[0..a.subject_len]) == 1 { if verify_cert_signature(leaf, a) == 1 { @@ -1663,7 +1663,7 @@ fn chains_to_anchor(cert: *LeafCert, anchors: ptr) -> int { n = list_size(anchors) i = 0 while i < n { - a = list_get_raw(anchors, i) as *LeafCert + a = list.at(anchors, i) as *LeafCert if a != null { if dn_equal(bytes.view(cert.issuer)[0..cert.issuer_len], bytes.view(a.subject)[0..a.subject_len]) == 1 { if verify_cert_signature(cert, a) == 1 { @@ -1710,8 +1710,8 @@ chain_verify_path(leaf: *LeafCert, intermediates: ptr, anchors: ptr) -> string { if idx < 0 { walking = 0 } else { - list_add_raw(used, list_get_raw(intermediates, idx)) - current = list_get_raw(intermediates, idx) as *LeafCert + list.add(used, list.at(intermediates, idx)) + current = list.at(intermediates, idx) as *LeafCert depth = depth + 1 } } @@ -1730,7 +1730,7 @@ fn find_issuer_unused(cert: *LeafCert, intermediates: ptr, used: ptr) -> int { n = list_size(intermediates) i = 0 while i < n { - m = list_get_raw(intermediates, i) as *LeafCert + m = list.at(intermediates, i) as *LeafCert if m != null { if is_in_list(intermediates, i, used) == 0 { if dn_equal(bytes.view(cert.issuer)[0..cert.issuer_len], bytes.view(m.subject)[0..m.subject_len]) == 1 { @@ -1754,7 +1754,7 @@ find_issuer_cert(leaf: *LeafCert, intermediates: ptr, anchors: ptr) -> ptr { n = list_size(intermediates) i = 0 while i < n { - m = list_get_raw(intermediates, i) as *LeafCert + m = list.at(intermediates, i) as *LeafCert if m != null { if dn_equal(bytes.view(leaf.issuer)[0..leaf.issuer_len], bytes.view(m.subject)[0..m.subject_len]) == 1 { if verify_cert_signature(leaf, m) == 1 { return m as ptr } @@ -1767,7 +1767,7 @@ find_issuer_cert(leaf: *LeafCert, intermediates: ptr, anchors: ptr) -> ptr { an = list_size(anchors) j = 0 while j < an { - a = list_get_raw(anchors, j) as *LeafCert + a = list.at(anchors, j) as *LeafCert if a != null { if dn_equal(bytes.view(leaf.issuer)[0..leaf.issuer_len], bytes.view(a.subject)[0..a.subject_len]) == 1 { if verify_cert_signature(leaf, a) == 1 { return a as ptr } @@ -1781,11 +1781,11 @@ find_issuer_cert(leaf: *LeafCert, intermediates: ptr, anchors: ptr) -> ptr { // Is the intermediate at index `idx` (by stored pointer) already in `used`? fn is_in_list(intermediates: ptr, idx: int, used: ptr) -> int { - target = list_get_raw(intermediates, idx) + target = list.at(intermediates, idx) un = list_size(used) j = 0 while j < un { - if list_get_raw(used, j) == target { return 1 } + if list.at(used, j) == target { return 1 } j = j + 1 } return 0 @@ -1898,7 +1898,7 @@ fn add_pem_text(anchors: ptr, text: string) -> int { dlen = bytes.length(der) cerr = parse_certificate(bytes.view(der)[0..dlen], leaf) if string.equals(cerr, "") == 1 { - list_add_raw(anchors, leaf) + list.add(anchors, leaf) added = added + 1 } else { free_leaf(leaf) @@ -1940,7 +1940,7 @@ free_anchors(anchors: ptr) { n = list_size(anchors) i = 0 while i < n { - a = list_get_raw(anchors, i) as *LeafCert + a = list.at(anchors, i) as *LeafCert if a != null { free_leaf(a) free(a) diff --git a/std/cryptography/tls13_client/module.ae b/std/cryptography/tls13_client/module.ae index cebdaf09f..19b3bd8b4 100644 --- a/std/cryptography/tls13_client/module.ae +++ b/std/cryptography/tls13_client/module.ae @@ -2493,7 +2493,7 @@ fn parse_intermediates(body: byte[], out: ptr) { init_intermediate(mc) cerr = tls13_cert.parse_certificate(bytes.view(der)[0..cert_len], mc) if string.equals(cerr, "") == 1 { - list_add_raw(out, mc) + list.add(out, mc) } else { tls13_cert.free_leaf(mc) libc_free(mc) @@ -2527,7 +2527,7 @@ fn free_intermediates(lst: ptr) { n = list_size(lst) i = 0 while i < n { - m = list_get_raw(lst, i) as *LeafCert + m = list.at(lst, i) as *LeafCert if m != null { tls13_cert.free_leaf(m) libc_free(m) diff --git a/std/cryptography/tls13_server/module.ae b/std/cryptography/tls13_server/module.ae index 16972e4ff..92c567e74 100644 --- a/std/cryptography/tls13_server/module.ae +++ b/std/cryptography/tls13_server/module.ae @@ -19,7 +19,8 @@ import std.bytes import std.string -import std.list(list_new, list_free, list_add_raw, list_get_raw, list_size) +import std.list +import std.list(list_new, list_free, list_size) import std.mem(copy) import std.fs import std.tcp @@ -532,8 +533,8 @@ parse_cert_chain_pem(pem_text: string) -> (ptr, ptr) { block = string.substring_n(pem_text, tlen, pos, block_end) der, _label, perr = pem.parse(block) if string.equals(perr, "") == 1 && der != null { - list_add_raw(chain, der) - list_add_raw(lens, null) + list.add(chain, der) + list.add(lens, null) } pos = block_end } @@ -605,7 +606,7 @@ fn free_cert_chain(chain: ptr) { n = list_size(chain) i = 0 while i < n { - c = list_get_raw(chain, i) as ptr + c = list.at(chain, i) as ptr if c != null { bytes.free(c) } i = i + 1 } diff --git a/std/http/server/lb/module.ae b/std/http/server/lb/module.ae index 9b0c38ee4..29054f6e4 100644 --- a/std/http/server/lb/module.ae +++ b/std/http/server/lb/module.ae @@ -347,5 +347,5 @@ _push_s(l: ptr, s: string) -> int { _ = list_add(l, s); return 0 } _push_i(l: ptr, i: int) -> int { _ = list_add(l, "${i}"); return 0 } _lget(l: ptr, i: int) -> string { if i >= list_size(l) { return "" } - return list_get_raw(l, i) + return list.at(l, i) } diff --git a/std/http1/module.ae b/std/http1/module.ae index 7df831fd4..49dbce984 100644 --- a/std/http1/module.ae +++ b/std/http1/module.ae @@ -101,7 +101,7 @@ fn free_str_list(lst: ptr) { n = list_size(lst) i = 0 while i < n { - b = list_get_raw(lst, i) + b = list.at(lst, i) if b != null { bytes.free(b) } i = i + 1 } @@ -169,9 +169,9 @@ header(rp: ptr, name: string) -> string { i = 0 result = "" while i < n { - nb = list_get_raw(r.header_names, i) + nb = list.at(r.header_names, i) if name_is(nb, name) == 1 { - vb = list_get_raw(r.header_values, i) + vb = list.at(r.header_values, i) result = bytes.to_string(vb, bytes.length(vb)) i = n } else { @@ -186,8 +186,8 @@ fn header_bytes(r: *HttpResponse, name: string) -> ptr { n = list_size(r.header_names) i = 0 while i < n { - nb = list_get_raw(r.header_names, i) - if name_is(nb, name) == 1 { return list_get_raw(r.header_values, i) } + nb = list.at(r.header_names, i) + if name_is(nb, name) == 1 { return list.at(r.header_values, i) } i = i + 1 } return null @@ -323,8 +323,8 @@ parse_headers(r: *HttpResponse, buf: byte[], header_end: int, start: int) { if c3 == 32 || c3 == 9 { vend = vend - 1 } else { break } } value = slice_bytes(buf, vstart, vend) - list_add_raw(r.header_names, lname) - list_add_raw(r.header_values, value) + list.add(r.header_names, lname) + list.add(r.header_values, value) } p = eol + 2 } diff --git a/std/language/module.ae b/std/language/module.ae index 8087f7662..c6d55bfb2 100644 --- a/std/language/module.ae +++ b/std/language/module.ae @@ -478,7 +478,7 @@ fn match_strings(m: *Matcher, preferred: string) -> string { i = 0 sz = list_size(pref_list) while i < sz { - p = list_get_raw(pref_list, i) + p = list.at(pref_list, i) if p != null { // Cast to the struct type so heap.free emits the typed // destructor that also releases the owned `tag` string field. @@ -518,7 +518,7 @@ fn match_strings(m: *Matcher, preferred: string) -> string { p = heap.new(Preferred) p.tag = string_copy(tag_str) p.q = q_val - list_add_raw(pref_list, p) + list.add(pref_list, p) } string_seq_free(subparts) } @@ -531,10 +531,10 @@ fn match_strings(m: *Matcher, preferred: string) -> string { sz = list_size(pref_list) i = 1 while i < sz { - cur = list_get_raw(pref_list, i) as *Preferred + cur = list.at(pref_list, i) as *Preferred j = i - 1 while j >= 0 { - prev_pref = list_get_raw(pref_list, j) as *Preferred + prev_pref = list.at(pref_list, j) as *Preferred if prev_pref.q >= cur.q { break } @@ -554,7 +554,7 @@ fn match_strings(m: *Matcher, preferred: string) -> string { prev = string_seq_empty() i = sz - 1 while i >= 0 { - p = list_get_raw(pref_list, i) + p = list.at(pref_list, i) pref = p as *Preferred acc = string_seq_cons(pref.tag, prev) string_seq_free(prev) diff --git a/std/list/README.md b/std/list/README.md index d2c22cd8e..f8886f53b 100644 --- a/std/list/README.md +++ b/std/list/README.md @@ -52,9 +52,11 @@ same pointer back: true **`get` checks the index.** `list.get(l, 99)` on a shorter list returns `(null, "index out of range")`, and a null list returns `(null, "null list")`, -so an empty error always means the value is good. `list_get_raw` is the -unchecked form, for a loop that has already bounded the index by -`list_size`. +so an empty error always means the value is good. A loop that has already +bounded the index by `list_size` reads with `list.at(l, i)`, which returns +the item alone and panics, naming the index and the size, on an index out +of range, as a slice index does. `list_get_raw` is the C binding both are +built on, for std.list itself (#3107). **A string element is read with `get_string`.** `get` hands back a `ptr`, which a `string` local would read as text whatever it points at, so giving diff --git a/std/list/module.ae b/std/list/module.ae index d173e9a7a..4609abed7 100644 --- a/std/list/module.ae +++ b/std/list/module.ae @@ -9,7 +9,7 @@ exports( list_new, list_new_in, list_add, list_add_raw, list_add_string_owned, list_get_raw, list_set, list_size, list_remove, list_clear, list_free, - get, get_string + get, get_string, at ) extern list_new() -> ptr @fresh @@ -54,6 +54,21 @@ get(list: ptr, index: int) -> { return list_get_raw(list, index), "" } +// The item at `index`, for a caller that has bounded the index (a loop over +// 0 ..< list_size). An index out of range, or a null list, is a panic that +// names the index and the size, as a slice index is (#3107): the unchecked +// read handed back a null there, which a caller took for a stored null. +at(list: ptr, index: int) -> ptr { + if list == null { + panic("list.at: the list is null") + } + n = list_size(list) + if index < 0 || index >= n { + panic("list index ${index} out of range for length ${n}") + } + return list_get_raw(list, index) +} + // Index into a list whose element is a string. `get` hands back a `ptr`, // which a `string` local would read as text whatever it points at (refused, // E0202); this says the element is a string, and checks it is one: diff --git a/std/map/README.md b/std/map/README.md index 4652e83d6..49341b30c 100644 --- a/std/map/README.md +++ b/std/map/README.md @@ -39,7 +39,9 @@ unbound: 0 **`get` reports a missing key as success.** It guards `map == null` but not membership, so `map.get(m, "absent")` returns `(null, "")`. Use `map_has` for -the unambiguous membership test, and null-check the pointer. +the unambiguous membership test, and null-check the pointer. `map.lookup(m, +k)` is the same answer without the error slot, the value or null, for a +caller that reads absence as null. **A string value is read with `get_string`.** `get` hands back a `ptr`, and a `ptr` given to a `string` local as it is (`name, _ = map.get(m, "k")` after diff --git a/std/map/module.ae b/std/map/module.ae index 4bf478650..f7ed06e8d 100644 --- a/std/map/module.ae +++ b/std/map/module.ae @@ -10,7 +10,7 @@ exports( map_new, map_put, map_put_raw, map_put_string_owned, map_get_raw, map_has, map_remove, map_size, map_clear, map_free, map_keys_raw, map_keys_free, map_keys_size_raw, map_keys_get_raw, - get, get_string, keys, keys_size, keys_get + get, get_string, lookup, keys, keys_size, keys_get ) // Keys cross as `@aether string`: the C map reads the AetherString's length, @@ -57,6 +57,16 @@ get(map: ptr, key: string) -> { return v, "" } +// The value stored under `key`, or null when the map holds none (or the map +// is null): `get` without its error slot, for a caller that reads absence +// as null (#3107). +lookup(map: ptr, key: string) -> ptr { + if map == null { + return null + } + return map_get_raw(map, key) +} + // Look up a key whose value is a string. `get` hands back a `ptr`, which a // `string` local would read as text whatever it points at (refused, E0202); // this says the value is a string, and checks it is one: diff --git a/std/message/module.ae b/std/message/module.ae index 7d94af8e1..0238955db 100644 --- a/std/message/module.ae +++ b/std/message/module.ae @@ -5,7 +5,6 @@ import std.plural import std.map import std.list import std.strbuilder -import std.mem exports( format, parse, format_pattern, pattern_free, @@ -39,11 +38,6 @@ struct Catalog { messages: ptr // Map of ID -> *Pattern } -struct MessageMapKeys { - keys: ptr - count: int -} - fn skip_ws(p: *MessageParser) { while p.pos < p.len { c = string_char_at(p.msg, p.pos) @@ -62,7 +56,7 @@ fn node_free(n_ptr: ptr) { sz = list_size(n.branches) i = 0 while i < sz { - b = list_get_raw(n.branches, i) as *Branch + b = list.at(n.branches, i) as *Branch if b != null { if b.pattern != null { pattern_nodes_free(b.pattern) @@ -81,7 +75,7 @@ fn pattern_nodes_free(nodes_list: ptr) { sz = list_size(nodes_list) i = 0 while i < sz { - n = list_get_raw(nodes_list, i) as *Node + n = list.at(nodes_list, i) as *Node if n != null { node_free(n) } @@ -181,7 +175,7 @@ fn parse_pattern_err(p: *MessageParser) -> (ptr, string) { n.kind = 4 // Hash n.text = "" n.branches = null - list_add_raw(nodes, n) + list.add(nodes, n) p.pos = p.pos + 1 continue } @@ -192,13 +186,13 @@ fn parse_pattern_err(p: *MessageParser) -> (ptr, string) { return null, err } string_release(err) - list_add_raw(nodes, block_node) + list.add(nodes, block_node) } else { lit_node = parse_literal(p) if string_length(lit_node.text) == 0 { node_free(lit_node) } else { - list_add_raw(nodes, lit_node) + list.add(nodes, lit_node) } } } @@ -341,7 +335,7 @@ fn parse_block(p: *MessageParser) -> (ptr, string) { b = heap.new(Branch) b.selector = selector b.pattern = branch_nodes - list_add_raw(branches, b) + list.add(branches, b) } if is_plural == 1 { @@ -352,7 +346,7 @@ fn parse_block(p: *MessageParser) -> (ptr, string) { sz = list_size(branches) i = 0 while i < sz { - b = list_get_raw(branches, i) as *Branch + b = list.at(branches, i) as *Branch if b != null { pattern_nodes_free(b.pattern) heap.free(b) @@ -368,7 +362,7 @@ fn parse_block(p: *MessageParser) -> (ptr, string) { sz = list_size(branches) i = 0 while i < sz { - b = list_get_raw(branches, i) as *Branch + b = list.at(branches, i) as *Branch if b != null { pattern_nodes_free(b.pattern) heap.free(b) @@ -558,7 +552,7 @@ fn format_nodes(locale: string, nodes: ptr, args: ptr, plural_val: string) -> st sz = list_size(nodes) i = 0 while i < sz { - n = list_get_raw(nodes, i) as *Node + n = list.at(nodes, i) as *Node if n != null { if n.kind == 0 { strbuilder.append(sb, n.text) @@ -589,7 +583,7 @@ fn format_nodes(locale: string, nodes: ptr, args: ptr, plural_val: string) -> st // 1. Check explicit branches `=N`: an exactly equal number bi = 0 while is_numeral && bi < bz { - b = list_get_raw(n.branches, bi) as *Branch + b = list.at(n.branches, bi) as *Branch if b != null && string_starts_with(b.selector, "=") == 1 { sel_val_str = string_substring(b.selector, 1, string_length(b.selector)) sel_num = decimal_canonical(sel_val_str) @@ -607,7 +601,7 @@ fn format_nodes(locale: string, nodes: ptr, args: ptr, plural_val: string) -> st category = decimal_category(locale, count_trimmed) bi = 0 while bi < bz { - b = list_get_raw(n.branches, bi) as *Branch + b = list.at(n.branches, bi) as *Branch if b != null && string_equals(b.selector, category) == 1 { chosen_branch = b break @@ -621,7 +615,7 @@ fn format_nodes(locale: string, nodes: ptr, args: ptr, plural_val: string) -> st if chosen_branch == null { bi = 0 while bi < bz { - b = list_get_raw(n.branches, bi) as *Branch + b = list.at(n.branches, bi) as *Branch if b != null && string_equals(b.selector, "other") == 1 { chosen_branch = b break @@ -632,7 +626,7 @@ fn format_nodes(locale: string, nodes: ptr, args: ptr, plural_val: string) -> st // 4. Degrade gracefully if chosen_branch == null && bz > 0 { - chosen_branch = list_get_raw(n.branches, 0) as *Branch + chosen_branch = list.at(n.branches, 0) as *Branch } if chosen_branch != null { @@ -651,7 +645,7 @@ fn format_nodes(locale: string, nodes: ptr, args: ptr, plural_val: string) -> st bi = 0 while bi < bz { - b = list_get_raw(n.branches, bi) as *Branch + b = list.at(n.branches, bi) as *Branch if b != null && string_equals(b.selector, sel_val) == 1 { chosen_branch = b break @@ -662,7 +656,7 @@ fn format_nodes(locale: string, nodes: ptr, args: ptr, plural_val: string) -> st if chosen_branch == null { bi = 0 while bi < bz { - b = list_get_raw(n.branches, bi) as *Branch + b = list.at(n.branches, bi) as *Branch if b != null && string_equals(b.selector, "other") == 1 { chosen_branch = b break @@ -672,7 +666,7 @@ fn format_nodes(locale: string, nodes: ptr, args: ptr, plural_val: string) -> st } if chosen_branch == null && bz > 0 { - chosen_branch = list_get_raw(n.branches, 0) as *Branch + chosen_branch = list.at(n.branches, 0) as *Branch } if chosen_branch != null { @@ -726,10 +720,10 @@ fn catalog_add(cat_ptr: ptr, id: string, msg: string) { } string_release(err) if map_has(cat.messages, id) == 1 { - old_pat = map_get_raw(cat.messages, id) as *Pattern + old_pat = map.lookup(cat.messages, id) as *Pattern pattern_free(old_pat) } - map_put_raw(cat.messages, id, pat as ptr) + map.put(cat.messages, id, pat as ptr) } fn catalog_format(cat_ptr: ptr, id: string, args: ptr) -> string { @@ -740,7 +734,7 @@ fn catalog_format(cat_ptr: ptr, id: string, args: ptr) -> string { if map_has(cat.messages, id) == 0 { return string_concat("[missing: ", string_concat(id, "]")) } - pat = map_get_raw(cat.messages, id) as *Pattern + pat = map.lookup(cat.messages, id) as *Pattern if pat == null { return string_concat("[missing: ", string_concat(id, "]")) } @@ -751,19 +745,20 @@ fn catalog_free(cat_ptr: ptr) { if cat_ptr == null { return } cat = cat_ptr as *Catalog - mk_ptr = map_keys_raw(cat.messages) - if mk_ptr != null { - mk = mk_ptr as *MessageMapKeys - i = 0 - while i < mk.count { - id = mem.get_ptr(mk.keys, i * 8) - pat = map_get_raw(cat.messages, id) as *Pattern - if pat != null { - pattern_free(pat) - } - i = i + 1 + // map.keys is a snapshot of the keys; keys_size and keys_get read it + // (the map's own layout is the map's business). + keys, _kerr = map.keys(cat.messages) + n = map.keys_size(keys) + i = 0 + while i < n { + pat = map.lookup(cat.messages, map.keys_get(keys, i)) as *Pattern + if pat != null { + pattern_free(pat) } - map_keys_free(mk_ptr) + i = i + 1 + } + if keys != null { + map_keys_free(keys) } map_free(cat.messages) heap.free(cat) diff --git a/std/message/test_message.ae b/std/message/test_message.ae index a243e5fef..1498efa2a 100644 --- a/std/message/test_message.ae +++ b/std/message/test_message.ae @@ -30,30 +30,30 @@ main() { // 1. Simple placeholder args1 = map_new() - map_put_raw(args1, "name", "Ada") + map.put(args1, "name", "Ada") assert_format("en", "Hello, {name}!", args1, "Hello, Ada!") map_free(args1) print("PASS: simple placeholder\n") // 2. Plural with one/other and with # args2 = map_new() - map_put_raw(args2, "count", "1") + map.put(args2, "count", "1") assert_format("en", "{count, plural, one {# item} other {# items}}", args2, "1 item") - map_put_raw(args2, "count", "5") + map.put(args2, "count", "5") assert_format("en", "{count, plural, one {# item} other {# items}}", args2, "5 items") map_free(args2) print("PASS: plural with category and #\n") // 3. Plural with explicit-value branches =0, =1 matching before category args3 = map_new() - map_put_raw(args3, "count", "0") + map.put(args3, "count", "0") assert_format("en", "{count, plural, =0 {No items} =1 {One item} one {# item} other {# items}}", args3, "No items") - map_put_raw(args3, "count", "1") + map.put(args3, "count", "1") assert_format("en", "{count, plural, =0 {No items} =1 {One item} one {# item} other {# items}}", args3, "One item") - map_put_raw(args3, "count", "2") + map.put(args3, "count", "2") assert_format("en", "{count, plural, =0 {No items} =1 {One item} one {# item} other {# items}}", args3, "2 items") map_free(args3) print("PASS: plural with explicit-value branches (=0, =1)\n") @@ -64,17 +64,17 @@ main() { args3b = map_new() plural_msg = "{n, plural, =0 {no items} =1 {one exactly} one {# item} other {# items}}" bad = 0 - map_put_raw(args3b, "n", "1.5") // a fraction is `other` in English + map.put(args3b, "n", "1.5") // a fraction is `other` in English bad = bad + check_format("en", plural_msg, args3b, "1.5 items") - map_put_raw(args3b, "n", "3000000000") // past 32 bits + map.put(args3b, "n", "3000000000") // past 32 bits bad = bad + check_format("en", plural_msg, args3b, "3000000000 items") - map_put_raw(args3b, "n", "many") // not a number: `other` + map.put(args3b, "n", "many") // not a number: `other` bad = bad + check_format("en", plural_msg, args3b, "many items") - map_put_raw(args3b, "n", "1.0") // = 1 exactly, but "1.0" is `other` + map.put(args3b, "n", "1.0") // = 1 exactly, but "1.0" is `other` bad = bad + check_format("en", plural_msg, args3b, "one exactly") - map_put_raw(args3b, "n", "0.5") + map.put(args3b, "n", "0.5") bad = bad + check_format("en", "{n, plural, =0.5 {half} other {# items}}", args3b, "half") - map_put_raw(args3b, "n", "2.5") // Russian fractions are `other`, not `many` + map.put(args3b, "n", "2.5") // Russian fractions are `other`, not `many` bad = bad + check_format("ru", "{n, plural, one {# яблоко} few {# яблока} many {# яблок} other {# яблока}}", args3b, "2.5 яблока") map_free(args3b) if bad != 0 { @@ -85,13 +85,13 @@ main() { // 4. Russian plural (few/many) args4 = map_new() // 21 -> one - map_put_raw(args4, "count", "21") + map.put(args4, "count", "21") assert_format("ru", "{count, plural, one {# яблоко} few {# яблока} many {# яблок} other {# яблока}}", args4, "21 яблоко") // 2 -> few - map_put_raw(args4, "count", "2") + map.put(args4, "count", "2") assert_format("ru", "{count, plural, one {# яблоко} few {# яблока} many {# яблок} other {# яблока}}", args4, "2 яблока") // 5 -> many - map_put_raw(args4, "count", "5") + map.put(args4, "count", "5") assert_format("ru", "{count, plural, one {# яблоко} few {# яблока} many {# яблок} other {# яблока}}", args4, "5 яблок") map_free(args4) print("PASS: Russian plural rules (one/few/many)\n") @@ -99,23 +99,23 @@ main() { // 5. Polish plural args_pl = map_new() // 1 -> one - map_put_raw(args_pl, "count", "1") + map.put(args_pl, "count", "1") assert_format("pl", "{count, plural, one {# przedmiot} few {# przedmioty} many {# przedmiotów} other {# przedmiotu}}", args_pl, "1 przedmiot") // 2 -> few - map_put_raw(args_pl, "count", "2") + map.put(args_pl, "count", "2") assert_format("pl", "{count, plural, one {# przedmiot} few {# przedmioty} many {# przedmiotów} other {# przedmiotu}}", args_pl, "2 przedmioty") // 5 -> many - map_put_raw(args_pl, "count", "5") + map.put(args_pl, "count", "5") assert_format("pl", "{count, plural, one {# przedmiot} few {# przedmioty} many {# przedmiotów} other {# przedmiotu}}", args_pl, "5 przedmiotów") map_free(args_pl) print("PASS: Polish plural rules (one/few/many)\n") // 6. Select (string choice) args5 = map_new() - map_put_raw(args5, "gender", "female") + map.put(args5, "gender", "female") assert_format("en", "{gender, select, male {He} female {She} other {They}}", args5, "She") - map_put_raw(args5, "gender", "unknown") + map.put(args5, "gender", "unknown") assert_format("en", "{gender, select, male {He} female {She} other {They}}", args5, "They") map_free(args5) print("PASS: select formatting\n") @@ -127,8 +127,8 @@ main() { // 8. Nesting args6 = map_new() - map_put_raw(args6, "count", "5") - map_put_raw(args6, "name", "Ada") + map.put(args6, "count", "5") + map.put(args6, "name", "Ada") assert_format("en", "{count, plural, other {{count} people liked {name}''s photo}}", args6, "5 people liked Ada's photo") map_free(args6) print("PASS: nested formatting (placeholders and escaping inside plural)\n") @@ -139,21 +139,21 @@ main() { catalog_add(cat, "item_count", "{count, plural, =0 {No items} =1 {One item} other {# items}}") args7 = map_new() - map_put_raw(args7, "name", "Ada") + map.put(args7, "name", "Ada") res_cat1 = catalog_format(cat, "welcome", args7) if string_equals(res_cat1, "Welcome, Ada!") != 1 { print("FAIL: catalog_format welcome expected \"Welcome, Ada!\", got \"${res_cat1}\"\n") exit(1) } - map_put_raw(args7, "count", "0") + map.put(args7, "count", "0") res_cat2 = catalog_format(cat, "item_count", args7) if string_equals(res_cat2, "No items") != 1 { print("FAIL: catalog_format item_count 0 expected \"No items\", got \"${res_cat2}\"\n") exit(1) } - map_put_raw(args7, "count", "3") + map.put(args7, "count", "3") res_cat3 = catalog_format(cat, "item_count", args7) if string_equals(res_cat3, "3 items") != 1 { print("FAIL: catalog_format item_count 3 expected \"3 items\", got \"${res_cat3}\"\n") diff --git a/std/sandbox/module.ae b/std/sandbox/module.ae index 26a5987e2..0b10dc942 100644 --- a/std/sandbox/module.ae +++ b/std/sandbox/module.ae @@ -144,7 +144,7 @@ fn freeze(perms: ptr) -> ptr { n = list.size(perms) i = 0 while i < n { - e = list_get_raw(perms, i) + e = list.at(perms, i) if e == null { list.add(frozen, null) } else { @@ -165,7 +165,7 @@ fn free_list(perms: ptr) { n = list.size(perms) i = 0 while i < n { - libc_free(list_get_raw(perms, i)) + libc_free(list.at(perms, i)) i = i + 1 } list.free(perms) diff --git a/std/sandbox/test_sandbox.ae b/std/sandbox/test_sandbox.ae index 91925b864..717622f86 100644 --- a/std/sandbox/test_sandbox.ae +++ b/std/sandbox/test_sandbox.ae @@ -33,8 +33,8 @@ fn widen(perms: sandbox.Grants) { } fn has_grant(perms: ptr, i: int, category: string, pattern: string) -> int { - if same(list_get_raw(perms, i * 2), category) != 1 { return 0 } - return same(list_get_raw(perms, i * 2 + 1), pattern) + if same(list.at(perms, i * 2), category) != 1 { return 0 } + return same(list.at(perms, i * 2 + 1), pattern) } main() { diff --git a/std/schema/example.ae b/std/schema/example.ae index f65d8bc65..cc495cd01 100644 --- a/std/schema/example.ae +++ b/std/schema/example.ae @@ -9,7 +9,7 @@ // Run: ae run std/schema/example.ae import std.schema -import std.map(map_new, map_put_raw, map_free) +import std.map(map_new, map_free) import std.map import std.string(string_starts_with) @@ -97,7 +97,7 @@ fn signup(s: ptr, input: ptr) { } fn put(m: ptr, k: string, v: string) { - map_put_raw(m, k, v) + map.put(m, k, v) } main() { diff --git a/std/schema/module.ae b/std/schema/module.ae index 2b1fc3ca3..43874bb02 100644 --- a/std/schema/module.ae +++ b/std/schema/module.ae @@ -84,7 +84,7 @@ import std.json import std.map import std.map(map_new, map_has, map_free) import std.list -import std.list(list_new, list_add_raw, list_get_raw, list_size, list_free) +import std.list(list_new, list_size, list_free) import std.strbuilder import std.string(string_length, string_equals, string_concat, string_char_at, string_char_at_n, string_contains, string_index_of, string_index_of_from, @@ -253,7 +253,7 @@ field(_ctx: @keep ptr, name: string, ty: int) -> ptr { panic("schema.field(\"${name}\") inside the block of ${parent.name}, which is not a record: declare nested fields under a field of type schema.OBJECT or schema.array(schema.OBJECT)") } f = new_field(name, ty) - list_add_raw(s.fields, f) + list.add(s.fields, f) return f } @@ -287,7 +287,7 @@ fn add_rule(_ctx: ptr, kind: int, ival: long, ival2: long, sval: string, refine_ r.ival2 = ival2 r.sval = sval r.refine_fn = refine_fn - list_add_raw(f.rules, r) + list.add(f.rules, r) } // numeric: value >= n | STR: length >= n @@ -404,7 +404,7 @@ fn push_error(errors: ptr, field_name: string, code: string, msg: string) { // (string_concat(_, "")) that errors_free can reclaim with string_free; the // original interp temporary is tracker-freed when this frame returns. e.msg = string_concat(msg, "") - list_add_raw(errors, e) + list.add(errors, e) } // Coerce/type-check a raw string against a field type. @@ -686,7 +686,7 @@ fn run_rules(errors: ptr, f: *Field, path: string, typed: string) -> (string, in rsz = list_size(f.rules) j = 0 while j < rsz { - r = list_get_raw(f.rules, j) as *Rule + r = list.at(f.rules, j) as *Rule if r.kind >= RULE_XFORM_FIRST { next = apply_transform(r.kind, cur) string_free(cur) @@ -714,7 +714,7 @@ parse(schema_ptr: ptr, input: ptr) -> (ptr, ptr) { fsz = list_size(s.fields) i = 0 while i < fsz { - f = list_get_raw(s.fields, i) as *Field + f = list.at(s.fields, i) as *Field present_in = map_has(input, f.name) if present_in == 0 { @@ -797,7 +797,7 @@ fn check_object(s: *Schema, obj: ptr, prefix: string, strict: int, errors: ptr) fsz = list_size(s.fields) i = 0 while i < fsz { - f = list_get_raw(s.fields, i) as *Field + f = list.at(s.fields, i) as *Field path = join_path(prefix, f.name) v = json.object_get_raw(obj, f.name) if v != null && json.type(v) == JSON_NULL { @@ -894,7 +894,7 @@ fn count_fails(errors: ptr, f: *Field, path: string, n: int) -> int { rsz = list_size(f.rules) j = 0 while j < rsz { - r = list_get_raw(f.rules, j) as *Rule + r = list.at(f.rules, j) as *Rule k = r.kind if k == RULE_PRESENT && n == 0 { push_error(errors, path, "invalid_value", "${path} must not be empty") @@ -1132,7 +1132,7 @@ fn object_schema_into(b: ptr, s: *Schema) { fsz = list_size(s.fields) i = 0 while i < fsz { - f = list_get_raw(s.fields, i) as *Field + f = list.at(s.fields, i) as *Field if i > 0 { strbuilder.append(b, ",") } @@ -1146,7 +1146,7 @@ fn object_schema_into(b: ptr, s: *Schema) { first_req = 1 i = 0 while i < fsz { - f = list_get_raw(s.fields, i) as *Field + f = list.at(s.fields, i) as *Field if f.is_optional == 0 && f.has_default == 0 { if first_req == 0 { strbuilder.append(b, ",") @@ -1176,7 +1176,7 @@ fn field_schema_into(b: ptr, f: *Field) { rsz = list_size(f.rules) j = 0 while j < rsz { - r = list_get_raw(f.rules, j) as *Rule + r = list.at(f.rules, j) as *Rule if r.kind == RULE_MIN || r.kind == RULE_LEN { strbuilder.append(b, ",\"minItems\":") strbuilder.append_long(b, r.ival) @@ -1201,7 +1201,7 @@ fn field_schema_into(b: ptr, f: *Field) { rsz = list_size(f.rules) j = 0 while j < rsz { - r = list_get_raw(f.rules, j) as *Rule + r = list.at(f.rules, j) as *Rule k = r.kind if k == RULE_MIN { if is_str { @@ -1295,7 +1295,7 @@ schema_free(schema_ptr: ptr) { fsz = list_size(s.fields) i = 0 while i < fsz { - field_free(list_get_raw(s.fields, i) as *Field) + field_free(list.at(s.fields, i) as *Field) i = i + 1 } list_free(s.fields) @@ -1306,7 +1306,7 @@ fn field_free(f: *Field) { rsz = list_size(f.rules) j = 0 while j < rsz { - r = list_get_raw(f.rules, j) as *Rule + r = list.at(f.rules, j) as *Rule if r.refine_fn != null { libc_free(r.refine_fn) // free the box_closure()'d _AeClosure } @@ -1339,7 +1339,7 @@ errors_free(errors: ptr) { sz = list_size(errors) i = 0 while i < sz { - e = list_get_raw(errors, i) as *SchemaError + e = list.at(errors, i) as *SchemaError // heap.free runs SchemaError's typed destructor, which frees the owned // magic-string fields e.field and e.msg; e.code is a borrowed string // literal (not a magic AetherString), so the destructor skips it. diff --git a/std/schema/test_schema.ae b/std/schema/test_schema.ae index 9ba484732..49117cd11 100644 --- a/std/schema/test_schema.ae +++ b/std/schema/test_schema.ae @@ -4,7 +4,7 @@ // optional, and the parse -> (values, errors) contract with Zod-style codes. import std.schema -import std.map(map_new, map_put_raw, map_free) +import std.map(map_new, map_free) import std.map import std.string(string_equals, to_int, string_contains, string_free) @@ -54,7 +54,7 @@ fn expect_err(label: string, s: ptr, input: ptr, field: string, code: string) { } fn put(m: ptr, k: string, v: string) { - map_put_raw(m, k, v) + map.put(m, k, v) } main() { diff --git a/std/schema/test_schema_nested.ae b/std/schema/test_schema_nested.ae index 2dac36854..6d1e88303 100644 --- a/std/schema/test_schema_nested.ae +++ b/std/schema/test_schema_nested.ae @@ -4,7 +4,8 @@ import std.schema import std.json -import std.map(map_new, map_put_raw, map_free) +import std.map +import std.map(map_new, map_free) import std.string(string_equals, string_contains, string_free) extern exit(code: int) @@ -154,8 +155,8 @@ fn test_strict_and_flat() -> int { // The flat parse cannot carry nesting, and says so. nested = order_schema() input = map_new() - map_put_raw(input, "id", "1") - map_put_raw(input, "customer", "{}") + map.put(input, "id", "1") + map.put(input, "customer", "{}") fv, fe = schema.parse(nested, input) fails = fails + ck(has_error(fe, "customer", "invalid_type"), "flat parse refuses an OBJECT field") fails = fails + ck(string_contains(schema.error_message(fe, 0), "parse_json") == 1 || schema.error_count(fe) > 1, diff --git a/std/tar/module.ae b/std/tar/module.ae index cdbe990b0..c427867d1 100644 --- a/std/tar/module.ae +++ b/std/tar/module.ae @@ -1050,7 +1050,7 @@ extract(archive_path: string, destination: string, options: ExtractOptions) -> s // Clean up remaining dir_list items in case of early return/error var d = list_size(dir_list) - 1 while d >= 0 { - dm = list_get_raw(dir_list, d) as *DirMetadata + dm = list.at(dir_list, d) as *DirMetadata heap.free(dm) d = d - 1 } @@ -1240,7 +1240,7 @@ extract(archive_path: string, destination: string, options: ExtractOptions) -> s } // Queue directory metadata to be applied last - list_add_raw(dir_list, target as ptr) + list.add(dir_list, target as ptr) } else if kind == KIND_SYMLINK { if fs.exists(target.path) == 1 || fs.fs_is_symlink(target.path) == 1 { if options.overwrite == 0 { @@ -1273,7 +1273,7 @@ extract(archive_path: string, destination: string, options: ExtractOptions) -> s // Apply directory metadata in reverse order of creation var d = list_size(dir_list) - 1 while d >= 0 { - dm = list_get_raw(dir_list, d) as *DirMetadata + dm = list.at(dir_list, d) as *DirMetadata if options.preserve_mode == 1 { fs.chmod(dm.path, dm.mode) } diff --git a/std/tar/test_tar.ae b/std/tar/test_tar.ae index e8b52d0fd..81505579c 100644 --- a/std/tar/test_tar.ae +++ b/std/tar/test_tar.ae @@ -24,12 +24,12 @@ delete_recursive(dir: string) { } let list = list_new() fs.walk(dir, | path: string, kind: int, depth: int | { - list_add_raw(list, string_concat(path, "")) + list.add(list, string_concat(path, "")) return 0 }) var i = list_size(list) - 1 while i >= 0 { - p = list_get_raw(list, i) + p = list.at(list, i) st_kind, st_size, st_mtime, st_err = fs.file_stat(p) if st_kind == fs.STAT_KIND_DIR { fs.delete_dir(p) diff --git a/std/zip/module.ae b/std/zip/module.ae index 32319ad97..43033e04d 100644 --- a/std/zip/module.ae +++ b/std/zip/module.ae @@ -689,7 +689,7 @@ writer_free(wp: ptr) { n = list_size(w.entries) i = 0 while i < n { - free_wentry(list_get_raw(w.entries, i) as *ZipWEntry) + free_wentry(list.at(w.entries, i) as *ZipWEntry) i = i + 1 } list.free(w.entries) @@ -764,7 +764,7 @@ fn w_add_entry(w: *ZipWriter, name: string, name_len: int, e.comp_size = comp_len as long e.uncomp_size = data_len as long e.lfh_offset = lfh_off as long - list_add_raw(w.entries, e as ptr) + list.add(w.entries, e as ptr) return "" } @@ -816,7 +816,7 @@ writer_finish(wp: ptr) -> { i = 0 while i < n { - e = list_get_raw(w.entries, i) as *ZipWEntry + e = list.at(w.entries, i) as *ZipWEntry // A field that overflows 32 bits is written as the 0xFFFFFFFF sentinel // in the fixed record and carried in a ZIP64 extra block. From 8a81b39ba2ae181773a7c9497b5f80fdec0fcd46 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Thu, 22 Oct 2026 01:04:10 -0300 Subject: [PATCH 25/50] make check-raw-calls: no std or contrib file calls a raw C binding another module declares (#3107) A std module binds C through `*_raw` externs and builds its public functions on them: the error results, the ownership of what is stored, the bounds of what is read. Nothing kept another module from calling the binding, so 49 files made 97 such calls and std drifted onto the raw layer, which the last three commits moved off. tests/raw_calls/check.ae, in Aether, collects every `*_raw` extern and the directory that declares it, and reports each call (`name_raw(`, `mod.name_raw(`, with or without a space before the paren) and each selective import of one from a file whose own directory does not declare it. A `*_raw` helper of the file is not a binding, and comments and string literals are skipped. Its self-test over tests/raw_calls/fixture expects exactly 4 among uses that must not count. `make check-raw-calls` builds it, runs the self-test, then checks std and contrib; `make ci` runs it beside check-extern-widths. std and contrib: 360 Aether files, 284 raw bindings, 0 calls from another module. --- Makefile | 20 + ...1022T050200Z-added-3107-check-raw-calls.md | 9 + tests/raw_calls/check.ae | 361 ++++++++++++++++++ tests/raw_calls/fixture/lib/module.ae | 17 + tests/raw_calls/fixture/user/module.ae | 25 ++ 5 files changed, 432 insertions(+) create mode 100644 new_changelogs/20261022T050200Z-added-3107-check-raw-calls.md create mode 100644 tests/raw_calls/check.ae create mode 100644 tests/raw_calls/fixture/lib/module.ae create mode 100644 tests/raw_calls/fixture/user/module.ae diff --git a/Makefile b/Makefile index 57b007c8a..5935aa9d5 100644 --- a/Makefile +++ b/Makefile @@ -2806,6 +2806,7 @@ help: @echo " make check-docs - Compile the documentation's complete examples" @echo " make check-tests - The test suite can fail: verdicts reach the exit code, prune list is consistent, every std module has tests, listeners bind loopback, shell tests end background programs through server_jobs.sh, test programs write temp files under names of their own" @echo " make check-extern-widths - Every extern bound to C in this tree agrees with the C definition on each width; no plain C long in a bound signature" + @echo " make check-raw-calls - No std or contrib file calls a raw C binding another module declares" @echo " make check-contrib-modules - Type-check every non-host contrib module" @echo " make check-changelog - Validate changelog fragments, catch a release-fold" @echo " make add-changelog - Write a new_changelogs/ fragment (SECTION=, SLUG=)" @@ -2997,6 +2998,7 @@ ci: clean @$(CI_STAGE) 900 check-docs $(MAKE) check-docs @$(CI_STAGE) 900 check-tests $(MAKE) check-tests @$(CI_STAGE) 900 check-extern-widths $(MAKE) check-extern-widths + @$(CI_STAGE) 600 check-raw-calls $(MAKE) check-raw-calls @echo "" @echo "[5/11] Running .ae integration tests..." @$(MAKE) test-ae @@ -3079,6 +3081,24 @@ check-extern-widths: compiler ae stdlib { cat $(BUILD_DIR)/extern_widths/self_test.log; exit 1; } @./$(EXTERN_WIDTHS_CHECKER) --exclude tests/extern_widths/fixture $(EXTERN_WIDTHS_DIRS) +# ----------------------------------------------------------------- +# Raw calls (#3107): a std module binds C through `*_raw` externs and +# builds its public functions on them (error results, ownership of what +# is stored, bounds). No std or contrib file calls such a binding that +# another module declares; it calls that module's public function. The +# checker is Aether (tests/raw_calls/check.ae). Its self-test first: the +# fixture holds exactly 4 such calls among uses that are not. +# ----------------------------------------------------------------- +RAW_CALLS_CHECKER := $(BUILD_DIR)/raw_calls/check$(EXE_EXT) +.PHONY: check-raw-calls +check-raw-calls: compiler ae stdlib + @mkdir -p $(BUILD_DIR)/raw_calls + @./$(BUILD_DIR)/ae$(EXE_EXT) build tests/raw_calls/check.ae -o $(RAW_CALLS_CHECKER) + @./$(RAW_CALLS_CHECKER) --expect 4 tests/raw_calls/fixture \ + > $(BUILD_DIR)/raw_calls/self_test.log 2>&1 || \ + { cat $(BUILD_DIR)/raw_calls/self_test.log; exit 1; } + @./$(RAW_CALLS_CHECKER) std contrib + # The C a program gets when the C compiler is not GCC-compatible # (AETHER_GCC_COMPAT 0, which is what MSVC compiles): strict C11 under gcc, # and under cl.exe too when it is on PATH (#2725). CI runs the gcc half on the diff --git a/new_changelogs/20261022T050200Z-added-3107-check-raw-calls.md b/new_changelogs/20261022T050200Z-added-3107-check-raw-calls.md new file mode 100644 index 000000000..f99fea3fa --- /dev/null +++ b/new_changelogs/20261022T050200Z-added-3107-check-raw-calls.md @@ -0,0 +1,9 @@ +- **`make check-raw-calls`, run by `make ci`: no std or contrib file calls a + raw C binding another module declares.** A module binds C through + `*_raw` externs and builds its public functions on them; a caller in + another module that names the binding skips the error results, the + ownership and the bounds the public function adds. The checker, in + Aether (tests/raw_calls/check.ae), reports each such call and selective + import, skipping a module's own bindings, helpers named like one, + comments and strings, and runs its self-test over a fixture first + (#3107). diff --git a/tests/raw_calls/check.ae b/tests/raw_calls/check.ae new file mode 100644 index 000000000..ccf6f86f4 --- /dev/null +++ b/tests/raw_calls/check.ae @@ -0,0 +1,361 @@ +// No std or contrib file calls a raw C binding another module declares +// (#3107). +// +// A std module binds C through `*_raw` externs and builds its public +// functions on them: the error results, the ownership of what is stored, +// the bounds of what is read. A caller in another module that names the raw +// binding skips all of it and ties itself to a C signature the module is +// free to change. 49 files made 97 such calls before #3107 moved them to the +// public functions. +// +// This reads every .ae file under the given directories, collects the +// `*_raw` externs each module directory declares, and reports each call of +// one (`name_raw(`, `mod.name_raw(`) or selective import of one +// (`import std.x(name_raw)`) from a file whose own directory does not +// declare it. A name the file's directory declares is its own binding, and +// a `*_raw` name no module declares as an extern (a helper function of the +// file) is not a binding at all. Comments and string literals are skipped. +// +// check [--exclude DIR]... [--expect N] DIR... +// +// Exits 1 on a call it reports; with --expect, unless exactly N were found +// (the self-test over tests/raw_calls/fixture). `make check-raw-calls` runs +// both. + +import std.fs +import std.os +import std.io +import std.string +import std.collections + +extern exit(code: int) + +say(s: string) { + _w = io.stdout_write("${s}\n") +} + +is_ident_char(c: int) -> int { + if (c >= 97 && c <= 122) || (c >= 65 && c <= 90) || (c >= 48 && c <= 57) || c == 95 { + return 1 + } + return 0 +} + +// The lines of a text, without their line breaks. +split_lines(text: string) -> ptr { + out = collections.string_list_new() + n = string.length(text) + start = 0 + while start < n { + cut = string.index_of_from(text, "\n", start) + stop = cut + if cut < 0 { + stop = n + } + line_end = stop + if line_end > start && string.char_at_n(text, n, line_end - 1) == 13 { + line_end = line_end - 1 + } + collections.string_list_add(out, string.substring(text, start, line_end)) + start = stop + 1 + } + return out +} + +// A line with its comment cut off and every string literal's contents +// blanked (the quotes stay), so neither is read as code. +code_of(line: string) -> string { + n = string.length(line) + out = "" + in_str = 0 + i = 0 + while i < n { + c = string.char_at_n(line, n, i) + if in_str == 1 { + if c == 92 { + i = i + 2 + continue + } + if c == 34 { + in_str = 0 + out = "${out}\"" + } + i = i + 1 + continue + } + if c == 34 { + in_str = 1 + out = "${out}\"" + i = i + 1 + continue + } + if c == 47 && i + 1 < n && string.char_at_n(line, n, i + 1) == 47 { + break + } + out = "${out}${string.from_char(c)}" + i = i + 1 + } + return out +} + +// The identifier ending at `end` (exclusive) in `text`, or "". +ident_before(text: string, end: int) -> string { + n = string.length(text) + start = end + while start > 0 && is_ident_char(string.char_at_n(text, n, start - 1)) == 1 { + start = start - 1 + } + return string.substring(text, start, end) +} + +// The `*_raw` names a line of code uses: every identifier ending in `_raw` +// followed by `(`, and every one listed in a selective import. +raw_uses(code: string) -> ptr { + out = collections.string_list_new() + n = string.length(code) + is_import = string.starts_with(string.trim(code), "import ") + i = 0 + while i < n { + c = string.char_at_n(code, n, i) + if is_ident_char(c) == 1 && (i + 1 == n || is_ident_char(string.char_at_n(code, n, i + 1)) == 0) { + name = ident_before(code, i + 1) + if string.ends_with(name, "_raw") == 1 { + j = i + 1 + while j < n && string.char_at_n(code, n, j) == 32 { + j = j + 1 + } + if (j < n && string.char_at_n(code, n, j) == 40) || is_import == 1 { + collections.string_list_add(out, name) + } + } + } + i = i + 1 + } + return out +} + +// The name an extern declaration binds, or "": `extern name(` and +// `@extern("sym") name(`. +extern_name(code: string) -> string { + t = string.trim(code) + rest = "" + if string.starts_with(t, "extern ") == 1 { + rest = string.trim(string.substring(t, 7, string.length(t))) + } else if string.starts_with(t, "@extern(") == 1 { + close = string.index_of(t, ")") + if close < 0 { + return "" + } + rest = string.trim(string.substring(t, close + 1, string.length(t))) + } else { + return "" + } + open = string.index_of(rest, "(") + if open <= 0 { + return "" + } + return string.trim(string.substring(rest, 0, open)) +} + +last_index_of_char(s: string, c: int) -> int { + n = string.length(s) + i = n - 1 + while i >= 0 { + if string.char_at_n(s, n, i) == c { + return i + } + i = i - 1 + } + return -1 +} + +dir_of(p: string) -> string { + slash = last_index_of_char(p, 47) + if slash < 0 { + return "." + } + return string.substring(p, 0, slash) +} + +excluded(excludes: ptr, p: string) -> int { + n = collections.string_list_size(excludes) + i = 0 + while i < n { + if p == collections.string_list_get(excludes, i) { + return 1 + } + i = i + 1 + } + return 0 +} + +// Is `s` in the string list? +list_has(list: ptr, s: string) -> int { + n = collections.string_list_size(list) + i = 0 + while i < n { + if collections.string_list_get(list, i) == s { + return 1 + } + i = i + 1 + } + return 0 +} + +// Does directory `dir` declare extern `name`? `decls` holds "name dir". +declares(decls: ptr, name: string, dir: string) -> int { + return list_has(decls, "${name} ${dir}") +} + +// The directories that declare extern `name`, for the report. +declarers(decls: ptr, name: string) -> string { + out = "" + n = collections.string_list_size(decls) + prefix = "${name} " + i = 0 + while i < n { + d = collections.string_list_get(decls, i) + if string.starts_with(d, prefix) == 1 { + dir = string.substring(d, string.length(prefix), string.length(d)) + if out == "" { + out = dir + } else { + out = "${out}, ${dir}" + } + } + i = i + 1 + } + return out +} + +main() { + dirs = collections.string_list_new() + excludes = collections.string_list_new() + expect = -1 + argc = os.args_count() + i = 1 + while i < argc { + a = os.args_get(i) + if a == "--exclude" && i + 1 < argc { + i = i + 1 + collections.string_list_add(excludes, string.replace_all(os.args_get(i), "\\", "/")) + } else if a == "--expect" && i + 1 < argc { + i = i + 1 + expect = string.get_int(os.args_get(i)) + } else { + collections.string_list_add(dirs, string.replace_all(a, "\\", "/")) + } + i = i + 1 + } + if collections.string_list_size(dirs) == 0 { + say("usage: check [--exclude DIR]... [--expect N] DIR...") + exit(2) + } + + files = collections.string_list_new() + nd = collections.string_list_size(dirs) + i = 0 + while i < nd { + root = collections.string_list_get(dirs, i) + _n, werr = fs.walk(root) | path: string, kind: int, depth: int | { + p = string.replace_all(path, "\\", "/") + verdict = 0 + if kind == 2 { + base = p + slash = last_index_of_char(p, 47) + if slash >= 0 { + base = string.substring(p, slash + 1, string.length(p)) + } + if base == "build" || base == ".git" || base == "target" || + base == "node_modules" || excluded(excludes, p) == 1 { + verdict = 1 + } + } else if kind == 1 && string.ends_with(p, ".ae") == 1 { + collections.string_list_add(files, p) + } + return verdict + } + if werr != "" { + say("raw calls: cannot walk ${root}: ${werr}") + exit(2) + } + i = i + 1 + } + + // Pass 1: every `*_raw` extern and the directory that declares it. + decls = collections.string_list_new() + names = collections.string_list_new() + nf = collections.string_list_size(files) + i = 0 + while i < nf { + p = collections.string_list_get(files, i) + text, rerr = fs.read(p) + if rerr != "" { + say("raw calls: cannot read ${p}: ${rerr}") + exit(2) + } + lines = split_lines(text) + nl = collections.string_list_size(lines) + k = 0 + while k < nl { + name = extern_name(code_of(collections.string_list_get(lines, k))) + if name != "" && string.ends_with(name, "_raw") == 1 { + entry = "${name} ${dir_of(p)}" + if list_has(decls, entry) == 0 { + collections.string_list_add(decls, entry) + } + if list_has(names, name) == 0 { + collections.string_list_add(names, name) + } + } + k = k + 1 + } + collections.string_list_free(lines) + i = i + 1 + } + + // Pass 2: every use of one from a directory that does not declare it. + found = 0 + i = 0 + while i < nf { + p = collections.string_list_get(files, i) + dir = dir_of(p) + text, _rerr = fs.read(p) + lines = split_lines(text) + nl = collections.string_list_size(lines) + k = 0 + while k < nl { + code = code_of(collections.string_list_get(lines, k)) + if extern_name(code) == "" { + uses = raw_uses(code) + nu = collections.string_list_size(uses) + u = 0 + while u < nu { + name = collections.string_list_get(uses, u) + if list_has(names, name) == 1 && + declares(decls, name, dir) == 0 { + say("${p}:${k + 1}: ${name} is the raw C binding of ${declarers(decls, name)}; call that module's public function") + found = found + 1 + } + u = u + 1 + } + collections.string_list_free(uses) + } + k = k + 1 + } + collections.string_list_free(lines) + i = i + 1 + } + + say("raw calls: ${nf} Aether files, ${collections.string_list_size(names)} raw bindings: ${found} call(s) from another module") + if expect >= 0 { + if found != expect { + say("raw calls: the self-test expected ${expect}") + exit(1) + } + exit(0) + } + if found > 0 { + exit(1) + } +} diff --git a/tests/raw_calls/fixture/lib/module.ae b/tests/raw_calls/fixture/lib/module.ae new file mode 100644 index 000000000..7f4493182 --- /dev/null +++ b/tests/raw_calls/fixture/lib/module.ae @@ -0,0 +1,17 @@ +// Fixture for tests/raw_calls/check.ae: a module that binds C and wraps it. +// Its own calls of its raw bindings are its own business, and count as none. + +exports(open, read_n) + +extern lib_open_raw(path: string) -> ptr +@extern("lib_read") lib_read_raw(h: ptr, n: int) -> int + +open(path: string) -> { + h = lib_open_raw(path) + if h == null { return null, "open failed" } + return h, "" +} + +read_n(h: ptr, n: int) -> int { + return lib_read_raw(h, n) +} diff --git a/tests/raw_calls/fixture/user/module.ae b/tests/raw_calls/fixture/user/module.ae new file mode 100644 index 000000000..a5202e053 --- /dev/null +++ b/tests/raw_calls/fixture/user/module.ae @@ -0,0 +1,25 @@ +// Fixture for tests/raw_calls/check.ae: a module that reaches into lib's raw +// layer. Four uses count (marked COUNTS); the rest must not. + +import fixture.lib +import fixture.lib(lib_read_raw) // COUNTS: a selective import + +extern user_own_raw(x: int) -> int + +// A helper of this file named like a binding is not one. +fn local_raw(x: int) -> int { + return x +} + +go(path: string) -> int { + h = lib.lib_open_raw(path) // COUNTS: qualified call + n = lib_read_raw(h, 4) // COUNTS: bare call + m = lib_read_raw (h, 8) // COUNTS: a space before the paren + k = user_own_raw(n) // its own binding + j = local_raw(m) // not a binding + // lib_open_raw(path) in a comment + s = "lib_open_raw(path) in a string" + return k + j + string_length(s) +} + +extern string_length(s: string) -> int From 7cad26ebe28d67e8cf5b13a847545e7c4e9c9ad8 Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Thu, 22 Oct 2026 11:45:34 -0300 Subject: [PATCH 26/50] std's cryptography modules and the raw-calls fixture in ae fmt's layout (#3107) The #3107 commits left six std.cryptography modules and the check-raw-calls fixture off the canonical layout, so fmt_gate failed the sweep on every platform. Layout only: `git diff -w` shows nothing but removed blank lines, and the fixture's four COUNTS markers stay on their lines (check-raw-calls still finds exactly 4). --- std/cryptography/blake2/module.ae | 1 - std/cryptography/des3/module.ae | 1 - std/cryptography/mlkem/module.ae | 2 +- std/cryptography/sha3/module.ae | 1 - std/cryptography/skein/module.ae | 1 - std/cryptography/whirlpool/module.ae | 1 - tests/raw_calls/fixture/user/module.ae | 12 ++++++------ 7 files changed, 7 insertions(+), 12 deletions(-) diff --git a/std/cryptography/blake2/module.ae b/std/cryptography/blake2/module.ae index 54d99ac3e..cb5dd9b0e 100644 --- a/std/cryptography/blake2/module.ae +++ b/std/cryptography/blake2/module.ae @@ -259,7 +259,6 @@ compress(c: *Blake2Ctx, last: int) { i = i + 1 } } - } // Increment the 128-bit byte counter (t1:t0) by n. diff --git a/std/cryptography/des3/module.ae b/std/cryptography/des3/module.ae index 9feaf7454..715b299d4 100644 --- a/std/cryptography/des3/module.ae +++ b/std/cryptography/des3/module.ae @@ -317,7 +317,6 @@ generate_working_key(new_key: int[], encrypting: int, key: byte[], key_off: int) i = i + 1 } - // Pack the processed key. i = 0 while i < 32 { diff --git a/std/cryptography/mlkem/module.ae b/std/cryptography/mlkem/module.ae index c7f657cc2..11ea74290 100644 --- a/std/cryptography/mlkem/module.ae +++ b/std/cryptography/mlkem/module.ae @@ -1003,7 +1003,7 @@ indcpa_keygen(p: *MlkemParams, d: ptr, kp: ptr) { // run: a k-poly polyvec viewing the matrix, no copy. It is a view: the // caller does not free it. mat_row(mat: int[], i: int, k: int) -> int[] { - return mat[i * k * N..(i * k + k) * N] + return mat[i * k * N.. (i * k + k) * N] } // ---- K-PKE encrypt ---- diff --git a/std/cryptography/sha3/module.ae b/std/cryptography/sha3/module.ae index 0fb581f9c..b792cd704 100644 --- a/std/cryptography/sha3/module.ae +++ b/std/cryptography/sha3/module.ae @@ -83,7 +83,6 @@ struct Sha3Ctx { // ---- Keccak-f[1600] permutation ---- keccak_f(c: *Sha3Ctx) { - rnd = 0 while rnd < 24 { // theta diff --git a/std/cryptography/skein/module.ae b/std/cryptography/skein/module.ae index c998204f9..f3315e136 100644 --- a/std/cryptography/skein/module.ae +++ b/std/cryptography/skein/module.ae @@ -106,7 +106,6 @@ threefish_settweak(c: *SkeinCtx) { // Encrypt c.msg under the current key schedule + tweak into c.tf_out. threefish_encrypt(c: *SkeinCtx) { - // load plaintext i = 0 while i < NW { diff --git a/std/cryptography/whirlpool/module.ae b/std/cryptography/whirlpool/module.ae index 7de64a49b..c8ee6e3d1 100644 --- a/std/cryptography/whirlpool/module.ae +++ b/std/cryptography/whirlpool/module.ae @@ -146,7 +146,6 @@ cbyte(c: *WpCtx, k: int, w: long, shift: int) -> long { } process_block(c: *WpCtx) { - // compute and apply K^0 i = 0 while i < 8 { diff --git a/tests/raw_calls/fixture/user/module.ae b/tests/raw_calls/fixture/user/module.ae index a5202e053..7d191e5b6 100644 --- a/tests/raw_calls/fixture/user/module.ae +++ b/tests/raw_calls/fixture/user/module.ae @@ -2,7 +2,7 @@ // layer. Four uses count (marked COUNTS); the rest must not. import fixture.lib -import fixture.lib(lib_read_raw) // COUNTS: a selective import +import fixture.lib(lib_read_raw) // COUNTS: a selective import extern user_own_raw(x: int) -> int @@ -12,11 +12,11 @@ fn local_raw(x: int) -> int { } go(path: string) -> int { - h = lib.lib_open_raw(path) // COUNTS: qualified call - n = lib_read_raw(h, 4) // COUNTS: bare call - m = lib_read_raw (h, 8) // COUNTS: a space before the paren - k = user_own_raw(n) // its own binding - j = local_raw(m) // not a binding + h = lib.lib_open_raw(path) // COUNTS: qualified call + n = lib_read_raw(h, 4) // COUNTS: bare call + m = lib_read_raw(h, 8) // COUNTS: a space before the paren + k = user_own_raw(n) // its own binding + j = local_raw(m) // not a binding // lib_open_raw(path) in a comment s = "lib_open_raw(path) in a string" return k + j + string_length(s) From d1db1007be8164f9d05b4e23b718afbf3962601c Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Thu, 22 Oct 2026 11:45:34 -0300 Subject: [PATCH 27/50] A DER length past the input is refused, however large std.cryptography.asn1's read_tlv checked a definite length as `rr.pos + length > rr.len`. A four-byte length is an int that can be negative, or near 2^31 so that the sum wraps; either way the check passed and the reader handed on content it did not have. Wycheproof's DER ECDSA vectors declare an INTEGER of 0x7fffffff bytes: before #3107 the bignum's raw allocation of a negative limb count failed quietly and read as a rejection, and with its buffers as arrays (#3107) make([]int, n) panicked on the negative count, failing wycheproof_ecdsa and wycheproof_ecdsa_p384. The length is now compared with what is left, `length < 0 || length > rr.len - rr.pos`, which cannot overflow. std.truetype's table check adds two values offset32 has already bounded by the file size, so it was left as it is. Test: tests/regression/test_asn1_length_past_input.ae (lengths of 0x7fffffff, 2^31, one that reads as negative, and one byte past the input, all refused; a whole INTEGER read). On the old check it panics in bignum.from_bytes. Both Wycheproof DER suites pass, rejecting their 31 invalid vectors each. --- ...22T144534Z-fixed-asn1-length-past-input.md | 6 ++ std/cryptography/asn1/module.ae | 5 +- .../regression/test_asn1_length_past_input.ae | 61 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 new_changelogs/20261022T144534Z-fixed-asn1-length-past-input.md create mode 100644 tests/regression/test_asn1_length_past_input.ae diff --git a/new_changelogs/20261022T144534Z-fixed-asn1-length-past-input.md b/new_changelogs/20261022T144534Z-fixed-asn1-length-past-input.md new file mode 100644 index 000000000..f371763d1 --- /dev/null +++ b/new_changelogs/20261022T144534Z-fixed-asn1-length-past-input.md @@ -0,0 +1,6 @@ +- **A DER length past the input is refused, however large.** std.cryptography.asn1 + checked a TLV's length as `pos + length > len`; a four-byte length that is + negative or near 2^31 wrapped that sum past the check, and the reader went + on with content it did not have. An INTEGER declaring 0x7fffffff bytes + reached `bignum.from_bytes`, which panicked (Wycheproof's DER ECDSA + signatures). The length is now compared with what is left of the input. diff --git a/std/cryptography/asn1/module.ae b/std/cryptography/asn1/module.ae index e72746c7c..6ae377207 100644 --- a/std/cryptography/asn1/module.ae +++ b/std/cryptography/asn1/module.ae @@ -278,7 +278,10 @@ read_tlv(r: ptr) -> (ptr, string) { return acc, ok } - if rr.pos + length > rr.len { + // Against what is left, not as rr.pos + length: a four-byte length can + // be negative or near 2^31, and the sum then wraps past the check + // (Wycheproof's DER signatures declare 0x7fffffff). + if length < 0 || length > rr.len - rr.pos { return null, "asn1: content runs past end of input" } // copy content into a fresh bytes diff --git a/tests/regression/test_asn1_length_past_input.ae b/tests/regression/test_asn1_length_past_input.ae new file mode 100644 index 000000000..4f62fcf75 --- /dev/null +++ b/tests/regression/test_asn1_length_past_input.ae @@ -0,0 +1,61 @@ +// A DER length is checked against the bytes left, so a length that is +// negative or near 2^31 is refused instead of wrapping past the check: +// `rr.pos + length > rr.len` overflowed, and an INTEGER declaring 0x7fffffff +// bytes reached bignum.from_bytes, which panicked on a negative element +// count (Wycheproof's ecdsa_p256 DER vectors). + +import std.cryptography.asn1 +import std.bytes +import std.string + +digit(c: int) -> int { + if c >= 97 { return c - 87 } + return c - 48 +} + +der(hex: string) -> ptr { + n = string.length(hex) / 2 + b = bytes.new(n) + i = 0 + while i < n { + bytes.set(b, i, digit(string.char_at(hex, 2 * i)) * 16 + digit(string.char_at(hex, 2 * i + 1))) + i = i + 1 + } + return b +} + +refused(hex: string, what: string) -> int { + buf = der(hex) + r = asn1.reader_new(buf) + _n, err = asn1.read_integer(r) + asn1.reader_free(r) + bytes.free(buf) + if string.equals(err, "asn1: content runs past end of input") == 1 { + return 0 + } + println("FAIL ${what}: got '${err}'") + return 1 +} + +main() { + fails = 0 + fails = fails + refused("02847fffffff01", "a length of 0x7fffffff with one byte left") + fails = fails + refused("0284ffffffff01", "a length that reads as negative") + fails = fails + refused("028480000000", "a length of 2^31") + fails = fails + refused("020205", "a length one byte past the input") + + buf = der("020105") + r = asn1.reader_new(buf) + _n, err = asn1.read_integer(r) + if string.equals(err, "") != 1 { + println("FAIL a whole INTEGER: '${err}'") + fails = fails + 1 + } + asn1.reader_free(r) + bytes.free(buf) + + if fails != 0 { + exit(1) + } + println("PASS asn1 lengths past the input are refused") +} From 7fdc1685d99eeb09af86cd25412835da24cd87b1 Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Thu, 22 Oct 2026 11:47:33 -0300 Subject: [PATCH 28/50] A named function is a function value in an array literal and after return The checker marks a named function used as a value (function_value_annotate) where a value goes: an argument, a local's value, a field, an assignment, an if or match arm. Codegen wraps exactly the names it marked in their env-ignoring adapter. Two places a value goes were never marked: - an array literal's element: the element kept no type, the literal defaulted to an int array, and C got `int fs[2] = {held, NULL}`, so `g = fs[1]; g()` was "Undefined function 'g'" and `fs.length` reached C as a member of an int array (found fixing #3092 and #3099); - a returned value: `return held` from a `-> fn` function was wrapped in a call to an adapter nothing had registered, undeclared in C. Both are marked now, the same way an if-expression's arms are, where the place takes a closure. A place of typed function pointers (a `cfn` result, a part of a tuple result, an array of them) takes the function's address, as a local of that type does (#2623), and a `ptr` place (a `-> ptr` result, an array of `ptr`s) its C symbol, a `@c_callback`'s included, as before. Test: tests/regression/test_fn_value_array_and_return.ae (arrays of named functions called through an element, `[held, null]`'s element called, a function returned as `fn`, typed function pointers returned, in a tuple, a field and an array, and a callback returned as `ptr` and in a `ptr[2]`). On the old checker: "Undefined function 'g'"; marking every place, the `ptr` cases were `_AeClosure` values in `void*` slots, refused by C. --- compiler/analysis/typechecker.c | 62 ++++++++++--- docs/language-reference.md | 2 +- ...022T144733Z-fixed-fn-value-array-return.md | 9 ++ .../test_fn_value_array_and_return.ae | 88 +++++++++++++++++++ 4 files changed, 149 insertions(+), 12 deletions(-) create mode 100644 new_changelogs/20261022T144733Z-fixed-fn-value-array-return.md create mode 100644 tests/regression/test_fn_value_array_and_return.ae diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index 09053f18d..f6a763097 100644 --- a/compiler/analysis/typechecker.c +++ b/compiler/analysis/typechecker.c @@ -5570,11 +5570,15 @@ static int is_borrowed_view_source(const ASTNode* e) { * is checked: each element is then checked as a value of the sum (one of its * variants), and codegen wraps each variant as the sum. Left to its own * elements, the literal was an array of its first one's type, `Circle`, and - * a `Square` beside it was refused. */ -static void sum_place_to_array_literal(ASTNode* value, Type* place) { + * a `Square` beside it was refused. So does a place whose elements are + * typed function pointers (`let fs: Op[2] = [twice, half]`), where a named + * function element is its address (#3098), and one of `ptr`s, where it is + * its C symbol. */ +static void place_to_array_literal(ASTNode* value, Type* place) { if (!value || value->type != AST_ARRAY_LITERAL || value->child_count == 0 || !place || place->kind != TYPE_ARRAY || !place->element_type || - place->element_type->kind != TYPE_SUM) + !(place->element_type->kind == TYPE_SUM || place->element_type->kind == TYPE_PTR || + (place->element_type->kind == TYPE_FUNCTION && place->element_type->is_fnptr))) return; set_node_type(value, create_array_type(clone_type(place->element_type), value->child_count)); } @@ -13020,7 +13024,7 @@ int typecheck_statement(ASTNode* stmt, SymbolTable* table) { ex->type->kind != TYPE_UNKNOWN) slot = ex->type; } - sum_place_to_array_literal(init, slot); + place_to_array_literal(init, slot); } // Match-as-expression: typecheck as statement, then use its type if (init->type == AST_MATCH_STATEMENT) { @@ -13840,7 +13844,7 @@ int typecheck_statement(ASTNode* stmt, SymbolTable* table) { // #1044 implicit enum selector: `d = Blue` where `d` has an enum // type resolves the bare member against that enum. coerce_bare_enum_member(right, symbol->type, table); - sum_place_to_array_literal(right, symbol->type); /* #3071 */ + place_to_array_literal(right, symbol->type); /* #3071 */ Type* right_type = infer_type(right, table); /* #1286: a `T[N]` array or `null` into a `T[]` binding. */ @@ -15038,13 +15042,27 @@ int typecheck_statement(ASTNode* stmt, SymbolTable* table) { /* #3071: an array literal into a written array-of-sum result. */ if (stmt->child_count == 1 && g_tc_return_owner && g_tc_return_owner->type != AST_CLOSURE && !g_tc_return_owner->type_inferred) - sum_place_to_array_literal(stmt->children[i], g_tc_return_type); + place_to_array_literal(stmt->children[i], g_tc_return_type); if (stmt->children[i] && stmt->children[i]->type == AST_MATCH_STATEMENT) { tc_push_value_match(stmt->children[i]); /* #2768 */ typecheck_node(stmt->children[i], table); tc_pop_value_match(); - } else + } else { typecheck_expression(stmt->children[i], table); + /* `return held` from a `-> fn` function: the named function + * is the value returned, so codegen emits its adapter, as + * for an argument. Unmarked, the return was wrapped in a + * call to an adapter nothing had registered: undeclared + * in C. A typed function pointer result, or part of a + * tuple result, takes the function's address instead + * (#2636), and a `ptr` result its C symbol. */ + Type* slot = g_tc_return_type; + if (slot && slot->kind == TYPE_TUPLE && stmt->child_count > 1) + slot = i < slot->tuple_count ? slot->tuple_types[i] : NULL; + if (!(slot && (slot->kind == TYPE_PTR || + (slot->kind == TYPE_FUNCTION && slot->is_fnptr)))) + function_value_annotate(stmt->children[i], table); + } } /* #2697, #2733: a return of something with no value, a call of * a function that returns nothing or a `match` whose arms give @@ -15871,8 +15889,30 @@ int typecheck_expression(ASTNode* expr, SymbolTable* table) { } } // Type check all array elements + Type* place = expr->node_type; + Type* place_elem = place && place->kind == TYPE_ARRAY ? place->element_type : NULL; + Type* fnptr_elem = place_elem && place_elem->kind == TYPE_FUNCTION && + place_elem->is_fnptr ? place_elem : NULL; for (int i = 0; i < expr->child_count; i++) { - typecheck_expression(expr->children[i], table); + ASTNode* el = expr->children[i]; + typecheck_expression(el, table); + /* A named function among the elements is a function value, + * as it is in an argument or a local's value: `[held, null]` + * is an array of closures. Left a bare name, the element + * had no type, the literal defaulted to an int array, and C + * got `int fs[2] = {held, NULL}` (#3092, #3099). In an + * array the place types as typed function pointers (`let + * fs: Op[2] = [twice, half]`), it is the function's address, + * as in a local of that type (#2623); in an array of `ptr`s, + * its C symbol. */ + if (fnptr_elem && fn_name_matches_signature(table, el, fnptr_elem)) { + refuse_moded_fn_in_fnptr_slot(table, el, fnptr_elem); + set_node_type(el, clone_type(fnptr_elem)); + if (el->annotation) free(el->annotation); + el->annotation = strdup("fn_addr"); + } else if (!(place_elem && place_elem->kind == TYPE_PTR)) { + function_value_annotate(el, table); + } if (refuse_valueless(expr->children[i], table, "store")) return 0; } /* The element type was chosen by the early inference pass from @@ -16370,7 +16410,7 @@ int typecheck_expression(ASTNode* expr, SymbolTable* table) { ASTNode* fd = sdef->children[f]; if (fd && fd->type == AST_STRUCT_FIELD && fd->value && strcmp(fd->value, field_init->value) == 0) - sum_place_to_array_literal(field_init->children[0], fd->node_type); + place_to_array_literal(field_init->children[0], fd->node_type); } typecheck_expression(field_init->children[0], table); if (refuse_valueless(field_init->children[0], table, "store")) { @@ -17099,7 +17139,7 @@ int typecheck_binary_expression(ASTNode* expr, SymbolTable* table) { * [c, q]`) takes the place's type before it is checked. */ if (expr->value && strcmp(expr->value, "=") == 0 && right->type == AST_ARRAY_LITERAL) { Type* lt = infer_type(left, table); - sum_place_to_array_literal(right, lt); + place_to_array_literal(right, lt); if (lt) free_type(lt); } typecheck_expression(right, table); @@ -20047,7 +20087,7 @@ static int typecheck_function_call_inner(ASTNode* call, SymbolTable* table) { if (p->node_type && p->node_type->kind == TYPE_ENUM) coerce_bare_enum_member(call->children[aslot], p->node_type, table); adopt_closure_signature(call->children[aslot], p->node_type); - sum_place_to_array_literal(call->children[aslot], p->node_type); /* #3071 */ + place_to_array_literal(call->children[aslot], p->node_type); /* #3071 */ } } diff --git a/docs/language-reference.md b/docs/language-reference.md index fcc2d72a5..07e59faf8 100644 --- a/docs/language-reference.md +++ b/docs/language-reference.md @@ -3899,7 +3899,7 @@ reduce(f: fn(int, int) -> int, x: int, y: int) -> int { Pass an Aether function's address with the `as fn(...)` cast, `walk(my_handler as fn(ptr, ptr) -> void, p, q)` or a C function pointer obtained from an extern. A `string` argument reaches the callee as its bytes: the call wraps it in `aether_string_data(arg)`, as a call to an extern does, so a heap string (interpolated, concatenated) arrives as its characters and not as its `AetherString` header. This holds for every typed-pointer call: a `fn(...)` parameter, a cast local (`f = p as fn(uint32, string) -> int; f(7, name)`) and a function-pointer struct field. A closure cannot go there (it carries an environment, and a C function pointer has none), and the compiler says so at the call (`a closure cannot be passed as a typed function pointer`); a callback that may be a closure takes a bare `fn` parameter and is invoked with `call(cb, …)`, or, when it keeps a `ptr` it is given, a [closure type that says so](#closure-types-that-say-what-they-keep-fnkeep-ptr-float). This is the parameter form of the same typed-fn-pointer machinery used by `as fn(...)` locals and function-pointer struct fields; the prototype matches the C signature exactly (needed for callback APIs like `qsort`, `dictScan`, signal handlers, libcurl/sqlite hooks). -A named Aether function bound to a typed function pointer is its address there too, without the cast: an argument to such a parameter, a struct or message field of that type, a `let` local of it, a module-level `var` (its initializer, and an assignment to it in a function body) or `const` (`const CB: fn(int) -> string = label`, called `CB(1)`), and a function's result. The signature is checked: the parameter types must match, and so must the result, where `fn(ptr, int)` and `fn(ptr, int) -> void` are the same type. +A named Aether function bound to a typed function pointer is its address there too, without the cast: an argument to such a parameter, a struct or message field of that type, a `let` local of it, a module-level `var` (its initializer, and an assignment to it in a function body) or `const` (`const CB: fn(int) -> string = label`, called `CB(1)`), an element of an array literal going into an array of that type (`let ops: Op[2] = [twice, half]`, a field, an argument, a result), and a function's result, or a part of a tuple result of that type. The signature is checked: the parameter types must match, and so must the result, where `fn(ptr, int)` and `fn(ptr, int) -> void` are the same type. A closure cannot be stored in any of those slots, as it cannot be passed to the parameter: a closure literal, or a local bound to one, given to a struct field, an array element, a local, a module-level `var` or a function result of a typed function pointer type is a type error naming the field or variable. Store a named function there, or declare the slot a bare `fn` and call it with `call(...)`. diff --git a/new_changelogs/20261022T144733Z-fixed-fn-value-array-return.md b/new_changelogs/20261022T144733Z-fixed-fn-value-array-return.md new file mode 100644 index 000000000..9d1387e7b --- /dev/null +++ b/new_changelogs/20261022T144733Z-fixed-fn-value-array-return.md @@ -0,0 +1,9 @@ +- **A named function is a value in an array literal and after `return`.** + `[held, null]` was typed as an int array, and C got `int fs[2] = {held, + NULL}`; `return held` from a `-> fn` function called an adapter nothing + had declared. Both are function values now, as an argument or a local's + value already was: `[held, null]` is an array of closures, and the + returned function is the closure a caller calls. Where the place is a + typed function pointer (a `cfn` result, a part of a tuple result, an + array of one such as `let ops: Op[2] = [twice, half]`), the function is + its address there instead. diff --git a/tests/regression/test_fn_value_array_and_return.ae b/tests/regression/test_fn_value_array_and_return.ae new file mode 100644 index 000000000..9d08aa4ed --- /dev/null +++ b/tests/regression/test_fn_value_array_and_return.ae @@ -0,0 +1,88 @@ +// A named function in an array literal or after `return` is a function +// value, as it is in an argument or a local's value. The literal `[held, +// null]` was typed as an int array (C: `int fs[2] = {held, NULL}`), and +// `return held` from a `-> fn` function called an adapter nothing had +// declared, both failing in the C compiler. Where the place is a typed +// function pointer (a `cfn` result, a part of a tuple result, an array of +// one), the function is its address there instead, and where it is a `ptr` +// (a `-> ptr` result, an array of them), its C symbol. + +cfn Op(int) -> int + +struct Table { + ops: Op[2] +} + +held() -> int { + return 7 +} + +twice(x: int) -> int { + return x * 2 +} + +pick() -> fn { + return held +} + +half(x: int) -> int { + return x / 2 +} + +op() -> Op { + return twice +} + +op_and_closure() -> (Op, fn) { + return half, held +} + +ops_of() -> Op[2] { + return [half, twice] +} + +@c_callback on_event(req: ptr, res: ptr, ud: ptr) { +} + +@c_callback on_other(req: ptr, res: ptr, ud: ptr) { +} + +event_handler() -> ptr { + return on_event +} + +apply_both(fs: Op[2], x: int) -> int { + return fs[0](x) + fs[1](x) +} + +main() { + fs = [held, held] + g = fs[1] + ops = [twice, twice] + h = ops[0] + f = pick() + maybe = [held, null] + m = maybe[0] + got = "${g()} ${h(21)} ${f()} ${m()} ${maybe.len}" + if got != "7 42 7 7 2" { + println("FAIL fn values: got '${got}'") + exit(1) + } + o = op() + p, c = op_and_closure() + t = Table { ops: [twice, half] } + r = ops_of() + let ps: Op[2] = [half, half] + ps = [twice, twice] + addr = "${o(4)} ${p(4)} ${c()} ${t.ops[1](8)} ${r[0](8)} ${apply_both([twice, half], 8)} ${ps[1](8)}" + if addr != "8 2 7 4 4 20 16" { + println("FAIL typed function pointers: got '${addr}'") + exit(1) + } + let handlers: ptr[2] = [on_event, on_other] + if event_handler() != handlers[0] || handlers[0] == handlers[1] || handlers[1] == null { + println("FAIL C symbols: a returned callback and an array of them disagree") + exit(1) + } + println("PASS named functions as array elements and returned values") +} From ac827d1ad0a884d25a593e679f287331520fdfc8 Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Thu, 22 Oct 2026 11:48:01 -0300 Subject: [PATCH 29/50] .len is an array's only member; any other name is a type error naming it (#3092) `arr.length` passed the checker and stopped at the C compiler: "request for member 'length' in something not a structure or union". The checker typed `.len` on a slice or an array (#1286) and let every other name through, so `a.size` on a `T[N]` and `.length` on an array of closures reached C as members of a pointer or an array. `.len` is the one member a slice or an array has, so the checker refuses any other with a message that names it: "'length' is not a member of an array: an array's length is `.len`, its only member". An alias was not added: the language spells a length one way, and a second spelling is what #3105 asks the examples to stop teaching. Test: tests/integration/array_members (`.length` on a literal, on a slice parameter and on an array of closures, `.size` on an `int[4]`, all refused with that message; `.len` on each and a struct's own field named `length` compile and run). On the old checker the first compiles to C that fails. --- compiler/analysis/typechecker.c | 13 +++ docs/language-reference.md | 2 +- ...44801Z-fixed-3092-array-only-member-len.md | 6 ++ .../array_members/test_array_members.sh | 83 +++++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 new_changelogs/20261022T144801Z-fixed-3092-array-only-member-len.md create mode 100755 tests/integration/array_members/test_array_members.sh diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index f6a763097..f65a2cf53 100644 --- a/compiler/analysis/typechecker.c +++ b/compiler/analysis/typechecker.c @@ -16488,6 +16488,19 @@ int typecheck_expression(ASTNode* expr, SymbolTable* table) { set_node_type(expr, create_type(TYPE_INT)); return 1; } + if (lt && lt->kind == TYPE_ARRAY) { + /* #3092: `.len` is an array's one member (a slice's and a + * fixed-size array's alike), so any other name is a + * mistake to name here; left alone it reached the C as a + * member of a pointer or an array (`arr.length`). */ + char msg[256]; + snprintf(msg, sizeof(msg), + "'%s' is not a member of an array: an array's length is `.len`, its only member", + expr->value); + free_type(lt); + type_error(msg, expr->line, expr->column); + return 0; + } if (lt && is_lane_type(lt->kind)) { int idx = lane_accessor_index(lt->kind, expr->value); TypeKind elem = lane_scalar_kind(lt->kind); diff --git a/docs/language-reference.md b/docs/language-reference.md index 07e59faf8..36d13d2f4 100644 --- a/docs/language-reference.md +++ b/docs/language-reference.md @@ -409,7 +409,7 @@ main() { 2 2 5 ``` -- **`s.len`** is the element count, an `int`. It is read, never written: a slice's length is fixed when the slice is made, and an array's is a constant, so `s.len = n` (or `+=`, `++`) is a type error that points at `s[lo..hi]` for fewer elements (#3104). +- **`s.len`** is the element count, an `int`. It is read, never written: a slice's length is fixed when the slice is made, and an array's is a constant, so `s.len = n` (or `+=`, `++`) is a type error that points at `s[lo..hi]` for fewer elements (#3104). It is the only member a slice or an array has: any other name, `s.length` or `s.size`, is a type error that names `.len` (#3092). - **`s[i]` is bounds-checked.** An index outside `0 ..< s.len` is a runtime panic naming the source line, the index and the length (`slice index 5 out of range for length 3`). It is an ordinary panic: `try` catches it, and an actor step that trips it dies cleanly. A negative index is out of range, never a read before the buffer. An index into a fixed-size `T[N]` array is checked the same way, against `N` ([Array Types](#array-types)). - **`s[lo..hi]`** is the half-open sub-slice `[lo, hi)`, checked the same way (`lo <= hi <= s.len`). `s[lo..]` runs to the end, `s[..hi]` from the start, `s[..]` is the whole thing. The base may be a slice or a `T[N]` array; the result is always a `T[]` over the *same* elements, so a write through it is visible through the original. - **A `T[N]` array converts to `T[]` implicitly** wherever a slice is expected: a parameter, a `T[]`-typed binding or field, a `return` from a `-> T[]` function. The length travels with it. `null`, and the empty literal `[]`, convert to the empty slice: `.len` is `0`, it views no storage, and it compares equal to `null`. So `s == []` is `s == null` (it asks whether `s` views any storage; test `s.len == 0` for emptiness), a C function's `T[]` parameter takes `[]` as the null pointer, and an if-expression or a `match` given to a `T[]` converts each arm on its own. A module-level `var` or `const` of type `T[]` takes either as its initial value. diff --git a/new_changelogs/20261022T144801Z-fixed-3092-array-only-member-len.md b/new_changelogs/20261022T144801Z-fixed-3092-array-only-member-len.md new file mode 100644 index 000000000..7a51d907b --- /dev/null +++ b/new_changelogs/20261022T144801Z-fixed-3092-array-only-member-len.md @@ -0,0 +1,6 @@ +- **`.len` is an array's only member, and any other name says so.** + `arr.length` passed the checker and stopped at the C compiler ("request + for member 'length' in something not a structure or union"), and so did + `a.size` on a fixed-size array or `.length` on an array of closures. On + a slice or an array, a member other than `.len` is now a type error that + names `.len` (#3092). diff --git a/tests/integration/array_members/test_array_members.sh b/tests/integration/array_members/test_array_members.sh new file mode 100755 index 000000000..f46c4f1d1 --- /dev/null +++ b/tests/integration/array_members/test_array_members.sh @@ -0,0 +1,83 @@ +#!/bin/sh +# #3092: `.len` is an array's one member, a slice's and a fixed-size array's +# alike. `arr.length` passed the checker and stopped at the C compiler +# ("request for member 'length' in something not a structure or union"), +# and so did any other name. Each is refused here with the checker's +# message, which names `.len`; `.len` itself, and a struct's own field +# named `length`, still compile and run. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +AETHERC="$ROOT/build/aetherc" +if [ ! -x "$AE" ] && [ ! -x "$AE.exe" ]; then echo " [SKIP] array_members: $AE not built"; exit 0; fi +export AETHER_HOME="$ROOT" +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp" || true' EXIT +fail=0 + +# ; the program is on stdin and must be refused before C. +refused() { + name="$1"; want="'$2' is not a member of an array: an array's length is \`.len\`, its only member" + cat > "$tmp/$name.ae" + if "$AETHERC" "$tmp/$name.ae" "$tmp/$name.c" >"$tmp/$name.log" 2>&1; then + echo " [FAIL] array_members: $name compiled (it must be refused before C)" + fail=1; return + fi + if ! grep -qF "$want" "$tmp/$name.log"; then + echo " [FAIL] array_members: $name refused, but not with: $want" + sed 's/^/ /' "$tmp/$name.log" | head -6 + fail=1 + fi +} + +refused literal length <<'AE' +main() { + arr = [1, 2] + println("${arr.length}") +} +AE +refused slice_param length <<'AE' +count(xs: int[]) -> int { + return xs.length +} +main() { + println("${count(make([]int, 3))}") +} +AE +refused fixed size <<'AE' +main() { + int[4] a + println("${a.size}") +} +AE +refused closures length <<'AE' +held() { + println("held") +} +main() { + fs = [held, null] + println("${fs.length}") +} +AE + +cat > "$tmp/fine.ae" <<'AE' +struct Rope { + length: int +} +count(xs: int[]) -> int { + return xs.len +} +main() { + int[4] a + r = Rope { length: 7 } + println("${[1, 2].len} ${count(make([]int, 3))} ${a.len} ${r.length}") +} +AE +out="$(cd "$tmp" && "$AE" run fine.ae 2>&1)" +if [ "$out" != "2 3 4 7" ]; then + echo " [FAIL] array_members: .len and a struct field named length, expected '2 3 4 7', got:" + echo "$out" | sed 's/^/ /' | head -6 + fail=1 +fi + +[ "$fail" = 0 ] && echo " [PASS] array_members: every member but .len refused on an array, .len and a struct's length kept" +exit "$fail" From c286e390b3fdbc123d9e3f5419d4e2e7352b735f Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Thu, 22 Oct 2026 11:51:59 -0300 Subject: [PATCH 30/50] Any expression whose value is a function can be called (#3099) `fs[1]()` was refused as "Undefined function '?'". The parser's call took its name from the callee: a name, `module.fn`, or a field it could turn into `recv.cb`. Any other callee was dropped and the call left nameless. - The parser keeps such a callee as the first argument of `call(callee, args)`, the form the checker already gives a closure local's call and a named receiver's closure field (#2653), and marks the call value_callee. - A method-style call on a receiver that is not a name, `hs[0].cb()`, falls back to the receiver's function field when no function `cb` takes the receiver (UFCS shape (a)), as `h.cb()` does. - Codegen reads a closure callee twice (`.fn`, `.env`). A callee that is not a name or a field path is now held in a retained temp, as a cell's closure is (#2755): evaluated once, before the arguments, and alive for its own call even if the call rebinds the element it came from. - A callee whose value is not a function is "the value called here is not a function: it has type int", not call()'s message about a builtin the program never wrote. Tests: tests/regression/test_call_through_value.ae (an element, a call's result, an array of named functions, a closure field through an index, and `scale[pick()](3)` calling pick once) and tests/integration/call_non_function (an int element and a string result refused in those words). On the old compiler the first is "Undefined function '?'"; without the codegen hold, pick is called twice. macOS leaks: 0 for the regression program. --- compiler/analysis/typechecker.c | 27 +++++++- compiler/codegen/codegen_expr.c | 18 +++++- compiler/parser/parser.c | 17 +++++- docs/language-reference.md | 11 ++++ ...2T145159Z-fixed-3099-call-through-value.md | 7 +++ .../test_call_non_function.sh | 46 ++++++++++++++ tests/regression/test_call_through_value.ae | 61 +++++++++++++++++++ 7 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 new_changelogs/20261022T145159Z-fixed-3099-call-through-value.md create mode 100755 tests/integration/call_non_function/test_call_non_function.sh create mode 100644 tests/regression/test_call_through_value.ae diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index f65a2cf53..6b3f2dec4 100644 --- a/compiler/analysis/typechecker.c +++ b/compiler/analysis/typechecker.c @@ -3102,8 +3102,11 @@ static void reject_non_closure_callee(ASTNode* call, SymbolTable* table) { else snprintf(name, sizeof(name), "the first argument"); char msg[640]; - snprintf(msg, sizeof(msg), - "call() needs a closure, but %s has type %s", name, what); + if (annotation_has_marker(call->annotation, "value_callee")) /* #3099: `xs[0]()` */ + snprintf(msg, sizeof(msg), "the value called here is not a function: it has type %s", what); + else + snprintf(msg, sizeof(msg), + "call() needs a closure, but %s has type %s", name, what); const char* hint = t->kind == TYPE_TUPLE ? "a multi-value return bound to one name stays a tuple; destructure " @@ -19291,6 +19294,26 @@ static int typecheck_function_call_inner(ASTNode* call, SymbolTable* table) { * error (the bare method name is already in call->value). */ free(call->annotation); call->annotation = NULL; + /* #3099: a function held in a field of a receiver that is not a + * name (`hs[0].cb()`, `make_h().cb(2)`) is called as one held in + * a named receiver's field is (#2653): `call(recv.cb, ...)`. */ + ASTNode* recv = call->child_count > 0 ? call->children[0] : NULL; + if (recv && call->value && !strchr(call->value, '.')) { + ASTNode* fn_ref = create_ast_node(AST_MEMBER_ACCESS, call->value, call->line, call->column); + add_child(fn_ref, recv); + Type* ft = infer_type(fn_ref, table); + int is_fn = ft && ft->kind == TYPE_FUNCTION; + if (ft) free_type(ft); + if (is_fn) { + call->children[0] = fn_ref; + free(call->value); + call->value = strdup("call"); + return typecheck_function_call(call, table); + } + fn_ref->children[0] = NULL; /* the receiver stays the call's */ + fn_ref->child_count = 0; + free_ast_node(fn_ref); + } } } diff --git a/compiler/codegen/codegen_expr.c b/compiler/codegen/codegen_expr.c index a1ec2b911..c7f223345 100644 --- a/compiler/codegen/codegen_expr.c +++ b/compiler/codegen/codegen_expr.c @@ -609,6 +609,15 @@ static void arg_drain_select(CodeGenerator* gen, ASTNode* expr, int first_arg, /* Open the wrap: declare and fill each temp, bind the arguments to them * (generate_expression then emits the temp's name for each), and start the * `_ad_r` result. The callee's own emission follows. */ +/* A callee that is a name or a field path read through names (`f`, `h.cb`, + * `a.b.cb`): reading it again has no effect and gives the same closure, so + * a closure call may name it twice (`.fn`, `.env`). Anything else, an + * index or a call among them, is evaluated once into a temp (#3099). */ +static int closure_callee_is_path(ASTNode* e) { + while (e && e->type == AST_MEMBER_ACCESS && e->child_count > 0) e = e->children[0]; + return e && e->type == AST_IDENTIFIER; +} + static void arg_drain_open(CodeGenerator* gen, ASTNode* expr, ArgDrainWrap* w) { if (w->count == 0) return; char* names[16] = {0}; @@ -10362,10 +10371,15 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { ad.closure[ad.count] = 1; ad.idx[ad.count++] = 0; } else if (closure_arg && !arg_drain_lookup(closure_arg) && ad.count < 16 && - is_closure_cell_read(gen, closure_arg)) { + (is_closure_cell_read(gen, closure_arg) || + !closure_callee_is_path(closure_arg))) { /* #2755: a closure a cell holds is held for its * own call, which may rebind the cell and give - * back the cell's reference to it. */ + * back the cell's reference to it. #3099: so is + * any callee other than a name or a field path + * (`fs[pick()]`), which is also read twice below + * (`.fn`, `.env`): held once, it is evaluated + * once. */ ad.closure[ad.count] = 2; ad.idx[ad.count++] = 0; } diff --git a/compiler/parser/parser.c b/compiler/parser/parser.c index 84cbe612a..26497ea07 100644 --- a/compiler/parser/parser.c +++ b/compiler/parser/parser.c @@ -2815,6 +2815,11 @@ static ASTNode* parse_postfix_expression(Parser* parser) { // here and handed to the typechecker as the implicit first // argument; ufcs_recv holds it until the call node exists. ASTNode* ufcs_recv = NULL; + /* #3099: any other callee is a value, called as a closure local + * or field is (`call(callee, args)`, the form the checker gives + * those). The callee was dropped and the call left nameless: + * `fs[1]()` was "Undefined function '?'". */ + ASTNode* value_callee = NULL; if (expr && expr->type == AST_IDENTIFIER && expr->value) { // Simple call: foo() func_name = strdup(expr->value); @@ -2837,6 +2842,9 @@ static ASTNode* parse_postfix_expression(Parser* parser) { func_name = strdup(expr->value); ufcs_recv = expr->children[0]; expr->children[0] = NULL; // detach so free_ast_node(expr) won't reclaim it + } else if (expr) { + func_name = strdup("call"); + value_callee = expr; } // heap.new(TypeName) — like sizeof, the argument is a *type @@ -2884,6 +2892,10 @@ static ASTNode* parse_postfix_expression(Parser* parser) { func_call->annotation = strdup("ufcs"); add_child(func_call, ufcs_recv); } + if (value_callee) { + add_child(func_call, value_callee); + func_call->annotation = annotation_add_marker(func_call->annotation, "value_callee"); + } // Parse arguments if (!match_token(parser, TOKEN_RIGHT_PAREN)) { @@ -3049,8 +3061,9 @@ static ASTNode* parse_postfix_expression(Parser* parser) { } } - // Free the original identifier node since we've copied its name - if (expr) free_ast_node(expr); + // Free the original identifier node since we've copied its name; + // a value callee is not freed, it is the call's first argument. + if (expr && expr != value_callee) free_ast_node(expr); expr = func_call; continue; diff --git a/docs/language-reference.md b/docs/language-reference.md index 36d13d2f4..4b4e67550 100644 --- a/docs/language-reference.md +++ b/docs/language-reference.md @@ -914,6 +914,17 @@ the default expression into the call's argument list and rewrites any embedded `__LINE__` / `__FILE__` / `__func__` to use the call site's metadata. +### Calling a function value + +Parentheses after any expression whose value is a function call it: a +local (`g(3)`), a field (`h.cb(2)`, `hs[0].cb()`), an element (`fs[i](x)`), +or another call's result (`make_adder(5)(2)`). `call(f, args)` is the same +call written out. The callee is evaluated once, before the arguments +(`fs[pick()](3)` calls `pick` once), and held for the length of its call, +so a function that rebinds the element or field it was read from runs to +its end. A callee whose value is not a function is a type error, "the value +called here is not a function", naming its type (#3099). + ### Trailing blocks A call can be followed, on the same line as its `)`, by one of four blocks: diff --git a/new_changelogs/20261022T145159Z-fixed-3099-call-through-value.md b/new_changelogs/20261022T145159Z-fixed-3099-call-through-value.md new file mode 100644 index 000000000..b6e48385a --- /dev/null +++ b/new_changelogs/20261022T145159Z-fixed-3099-call-through-value.md @@ -0,0 +1,7 @@ +- **Any expression whose value is a function can be called.** `fs[1]()` was + refused as "Undefined function '?'": the parser kept only a name, a + `module.fn` or a field as a callee and dropped any other. An element, a + call's result (`make_adder(5)(2)`) and a closure field of a receiver that + is not a name (`hs[0].cb()`) are now called as a closure local is, + through `call(callee, args)`. The callee is evaluated once and held for + its call, and one whose value is not a function is refused as such (#3099). diff --git a/tests/integration/call_non_function/test_call_non_function.sh b/tests/integration/call_non_function/test_call_non_function.sh new file mode 100755 index 000000000..7692f44bb --- /dev/null +++ b/tests/integration/call_non_function/test_call_non_function.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# #3099: a call through an expression whose value is not a function is +# refused in the words of what was written, before C. Calls through such a +# callee are lowered to the builtin `call(f, ...)`, whose own message named +# a builtin the program never wrote ("call() needs a closure, but the first +# argument has type int"). +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AETHERC="$ROOT/build/aetherc" +if [ ! -x "$AETHERC" ] && [ ! -x "$AETHERC.exe" ]; then echo " [SKIP] call_non_function: $AETHERC not built"; exit 0; fi +export AETHER_HOME="$ROOT" +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp" || true' EXIT +fail=0 + +# ; the program is on stdin. +refused() { + name="$1"; want="$2" + cat > "$tmp/$name.ae" + if "$AETHERC" "$tmp/$name.ae" "$tmp/$name.c" >"$tmp/$name.log" 2>&1; then + echo " [FAIL] call_non_function: $name compiled (it must be refused before C)" + fail=1; return + fi + if ! grep -qF "$want" "$tmp/$name.log" || grep -qF "call()" "$tmp/$name.log"; then + echo " [FAIL] call_non_function: $name refused, but not with: $want" + sed 's/^/ /' "$tmp/$name.log" | head -6 + fail=1 + fi +} + +refused element "the value called here is not a function: it has type int" <<'AE' +main() { + xs = [1, 2] + xs[0]() +} +AE +refused result "the value called here is not a function: it has type string" <<'AE' +name() -> string { + return "a" +} +main() { + name()() +} +AE + +[ "$fail" = 0 ] && echo " [PASS] call_non_function: a call through a value that is not a function is refused as such" +exit "$fail" diff --git a/tests/regression/test_call_through_value.ae b/tests/regression/test_call_through_value.ae new file mode 100644 index 000000000..5a19114f0 --- /dev/null +++ b/tests/regression/test_call_through_value.ae @@ -0,0 +1,61 @@ +// #3099: any expression whose value is a closure can be called, as a +// closure local or a named receiver's closure field can: an element +// (`fs[1]()`), a call's result (`make_adder(5)(2)`), and a closure field of +// a receiver that is not a name (`hs[0].cb()`). The callee is evaluated +// once, before the arguments: `fs[pick()](3)` calls pick once. + +import std.list + +struct H { + cb: fn +} + +add(a: int, b: int) -> int { + return a + b +} + +make_adder(n: int) -> fn { + return | x: int | { return x + n } +} + +main() { + fails = 0 + fs = [|| { return 1 }, || { return 2 }] + if fs[1]() != 2 { + println("FAIL fs[1]()") + fails = fails + 1 + } + + seen = list.new() + pick = || { + list.add(seen, 1) + return 1 + } + scale = [| x: int | { return x * 10 }, | x: int | { return x * 100 }] + v = scale[pick()](3) + if v != 300 || list.size(seen) != 1 { + println("FAIL scale[pick()](3): ${v}, pick called ${list.size(seen)} times") + fails = fails + 1 + } + list.free(seen) + + if make_adder(5)(2) != 7 { + println("FAIL make_adder(5)(2)") + fails = fails + 1 + } + gs = [add, add] + if gs[0](2, 3) != 5 { + println("FAIL gs[0](2, 3)") + fails = fails + 1 + } + hs = [H { cb: || { return 4 } }] + if hs[0].cb() != 4 { + println("FAIL hs[0].cb()") + fails = fails + 1 + } + + if fails != 0 { + exit(1) + } + println("PASS calls through elements, results and fields") +} From a7fa440a9bd4d0151b04902b8329d0934ebee4f7 Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Thu, 22 Oct 2026 11:54:07 -0300 Subject: [PATCH 31/50] ae fmt writes a slice of fn tight: make([]fn, 2) (#3081) The formatter writes a type name tight after a `[]` or `[K]` that opens a type (`[]int`, `[Dir]string`, `[]*Node`, #2316). What counted as a type name was a word that is not a keyword, and `fn`, the one type that is a keyword, was spaced: `make([] fn, 2)`, even from `make([]fn, 2)`. is_type_name takes `fn` too. Test: tests/integration/fmt_type_prefix gains `make([] fn, 2)` and `make([]fn, 2)`, both written `make([]fn, 2)`; before, both came out `make([] fn, 2)`. fmt_gate passes over the tree. --- .../20261022T145407Z-fixed-3081-fmt-slice-of-fn.md | 4 ++++ tests/integration/fmt_type_prefix/test_fmt_type_prefix.sh | 7 ++++++- tools/ae_fmt.c | 5 +++-- 3 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 new_changelogs/20261022T145407Z-fixed-3081-fmt-slice-of-fn.md diff --git a/new_changelogs/20261022T145407Z-fixed-3081-fmt-slice-of-fn.md b/new_changelogs/20261022T145407Z-fixed-3081-fmt-slice-of-fn.md new file mode 100644 index 000000000..2175b4a13 --- /dev/null +++ b/new_changelogs/20261022T145407Z-fixed-3081-fmt-slice-of-fn.md @@ -0,0 +1,4 @@ +- **`ae fmt` writes `make([]fn, 2)` tight.** A slice's element type is + written against its `[]` (`[]int`, `[]*Node`), but `fn`, the one type + that is a keyword, was taken for a keyword and spaced: `make([] fn, 2)` + (#3081). diff --git a/tests/integration/fmt_type_prefix/test_fmt_type_prefix.sh b/tests/integration/fmt_type_prefix/test_fmt_type_prefix.sh index 5a127f081..2ffdefcec 100644 --- a/tests/integration/fmt_type_prefix/test_fmt_type_prefix.sh +++ b/tests/integration/fmt_type_prefix/test_fmt_type_prefix.sh @@ -7,7 +7,8 @@ # `[Dir] string`. A `]` that closes a prefix group (a `[` not following a # value, holding nothing or one name) followed by a type name or `*` is now # written tight. An index before a word (`a[0] as long`) and an array literal -# before a keyword (`[y] as ptr`) keep their space. #3086: so is the `*` of a +# before a keyword (`[y] as ptr`) keep their space. #3081: so is `fn`, the one +# type that is a keyword, in `make([]fn, 2)`. #3086: so is the `*` of a # pointer type after an attribute, `n: @retain *Node`, which was written # `@retain * Node` as if the attribute's name were a value. # @@ -38,6 +39,8 @@ main() { m = make([] int, 2) p = make([] *Node, 2) q = make([]*Node, 1) + fs = make([] fn, 2) + gs = make([]fn, 2) x = [1, 2] y = m[0] as long z = [y] as ptr @@ -59,6 +62,8 @@ main() { m = make([]int, 2) p = make([]*Node, 2) q = make([]*Node, 1) + fs = make([]fn, 2) + gs = make([]fn, 2) x = [1, 2] y = m[0] as long z = [y] as ptr diff --git a/tools/ae_fmt.c b/tools/ae_fmt.c index 706732de0..0781b77d5 100644 --- a/tools/ae_fmt.c +++ b/tools/ae_fmt.c @@ -373,9 +373,10 @@ static int would_merge(const char* a, const char* b){ } /* A name that can spell a type: an identifier that is not a keyword (a - * number or a string literal is a word token too, and is not one). */ + * number or a string literal is a word token too, and is not one), or + * `fn`, the one type that is a keyword: `make([]fn, 2)` (#3081). */ static int is_type_name(const char* t){ - return (isalpha((unsigned char)t[0]) || t[0]=='_') && !is_keyword(t); + return ((isalpha((unsigned char)t[0]) || t[0]=='_') && !is_keyword(t)) || eq(t,"fn"); } /* #2316: the type prefixes `[]T` (make's element type) and `[E]T` (an From f289fc7416802519c83111e85ba7a13bfeeb6b71 Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Thu, 22 Oct 2026 12:13:42 -0300 Subject: [PATCH 32/50] A call through a null typed function pointer is a named panic (#3098) A typed function pointer (`fn(int) -> int`, a `cfn` type) holding null jumped to address 0 when called and crashed with no message. Every call through one now checks it first and panics with the category `null_function_call` and the call's source position, which a `catch` receives, as a call through the empty closure does (#3091): - a local, a parameter, a module-level `var` or a field of a local: the name or path is checked in place, since reading it again is free; - any other callee (`fs[1](x)`, `w.h.cb(x)`, `hs[1].cb(x)`, `make()(x)`): `call(f, ...)` with a typed function pointer is now lowered through the pointer's signature, the callee evaluated once into `_aether_callee_fn` (the #3091 helper, renamed from `_aether_closure_fn` as it serves both). Since #3099 these calls reached C as closure calls (`.fn`, `.env` on a function pointer) and the C compiler stopped. Test: tests/regression/test_null_function_call.ae (nine routes each panic with the category and their line; a non-null pointer through each shape is called; `fs[pick()](2)` calls pick once) and a `null_function_call` case in panic_categories. Before: a segmentation fault, and the element and nested field calls refused as "Undefined function". --- compiler/codegen/codegen.c | 5 +- compiler/codegen/codegen_expr.c | 76 ++++++-- docs/language-reference.md | 7 +- ...54Z-fixed-3098-null-function-call-panic.md | 8 + tests/compiler/test_codegen.c | 26 +-- .../test_aether_cfn_proc_address.sh | 2 +- .../test_aether_fnptr_call.sh | 6 +- .../test_closure_args_borrowed.sh | 2 +- .../test_closure_args_foreign.sh | 2 +- .../panic_categories/null_function.ae | 8 + .../panic_categories/test_panic_categories.sh | 1 + tests/regression/test_null_function_call.ae | 169 ++++++++++++++++++ 12 files changed, 278 insertions(+), 34 deletions(-) create mode 100644 new_changelogs/20261022T150454Z-fixed-3098-null-function-call-panic.md create mode 100644 tests/integration/panic_categories/null_function.ae create mode 100644 tests/regression/test_null_function_call.ae diff --git a/compiler/codegen/codegen.c b/compiler/codegen/codegen.c index 1103db18d..e6221201f 100644 --- a/compiler/codegen/codegen.c +++ b/compiler/codegen/codegen.c @@ -9147,9 +9147,10 @@ void generate_program(CodeGenerator* gen, ASTNode* program) { /* #3091: the function a call through a closure jumps to. The empty * closure (`null` in a closure slot, also when a `fn?` holds it) has * none, and the call panics with `where`, its category and source - * position, instead of jumping to address 0. */ + * position, instead of jumping to address 0. A call through a typed + * function pointer an expression yields is checked here too (#3098). */ print_line(gen, "typedef void (*_AeFnPtr)(void);"); - print_line(gen, "static inline _AeFnPtr _aether_closure_fn(_AeFnPtr fn, const char* where) {"); + print_line(gen, "static inline _AeFnPtr _aether_callee_fn(_AeFnPtr fn, const char* where) {"); print_line(gen, " if (!fn) aether_panic(where);"); print_line(gen, " return fn;"); print_line(gen, "}"); diff --git a/compiler/codegen/codegen_expr.c b/compiler/codegen/codegen_expr.c index c7f223345..f778f2610 100644 --- a/compiler/codegen/codegen_expr.c +++ b/compiler/codegen/codegen_expr.c @@ -1958,12 +1958,13 @@ static int is_stdlib_string_aware_extern(const char* c_func_name) { * where C wants an ae_opt_int, and `h(none)` an initializer list. */ static void emit_closure_argument(CodeGenerator* gen, ASTNode* arg, Type* pt); -static void generate_fnptr_call_args(CodeGenerator* gen, Type* sig, ASTNode* call) { - for (int i = 0; i < call->child_count; i++) { +static void generate_fnptr_call_args(CodeGenerator* gen, Type* sig, ASTNode* call, int first) { + for (int i = first; i < call->child_count; i++) { ASTNode* arg = call->children[i]; - if (i > 0) fprintf(gen->output, ", "); - Type* param = (sig && sig->param_types && i < sig->param_count) - ? sig->param_types[i] : NULL; + if (i > first) fprintf(gen->output, ", "); + int pi = i - first; + Type* param = (sig && sig->param_types && pi < sig->param_count) + ? sig->param_types[pi] : NULL; if (param && param->kind == TYPE_STRING && arg && arg->node_type && (arg->node_type->kind == TYPE_STRING || arg->node_type->kind == TYPE_PTR)) { fprintf(gen->output, "aether_string_data("); @@ -2013,6 +2014,19 @@ static void emit_fnptr_take_close(CodeGenerator* gen, int took) { if (took) fprintf(gen->output, "))"); } +static void emit_slice_diag_file(CodeGenerator* gen, const ASTNode* n); + +/* #3098: the check before a call through a typed function pointer, which + * panics at the call when `callee` is null instead of jumping to address 0. + * `callee` is a name or a field path, so reading it again is free. Opens a + * comma expression the caller ends by naming the callee. */ +static void emit_fnptr_null_check(CodeGenerator* gen, const char* callee, ASTNode* call) { + fprintf(gen->output, "(%s ? (void)0 : aether_panic(\"null_function_call: call through a null function pointer at \" ", + callee); + emit_slice_diag_file(gen, call); + fprintf(gen->output, " \":%d:%d\"), ", call->line, call->column); +} + static void generate_fnptr_local_call(CodeGenerator* gen, Type* sig, const char* local_name, ASTNode* call, int discarded) { @@ -2029,9 +2043,38 @@ static void generate_fnptr_local_call(CodeGenerator* gen, Type* sig, int narrow = !discarded && fnptr_returns_bool(sig); if (narrow) fprintf(gen->output, "((_Bool)(unsigned char)("); /* #2651: spelled as the declarators spell it (fnptr_c_spelling). */ - fprintf(gen->output, "((%s)(%s))(", fnptr_c_spelling(sig, NULL), - safe_value_name(local_name)); - generate_fnptr_call_args(gen, sig, call); + const char* callee = safe_value_name(local_name); + emit_fnptr_null_check(gen, callee, call); + fprintf(gen->output, "(%s)(%s))(", fnptr_c_spelling(sig, NULL), callee); + generate_fnptr_call_args(gen, sig, call, 0); + fprintf(gen->output, ")"); + if (narrow) fprintf(gen->output, "))"); + emit_fnptr_take_close(gen, took); + arg_drain_close(gen, call, &ad); +} + +/* #3098: `call(f, args)` where `f` is a typed function pointer an + * expression other than a name yields (`fs[1]`, `w.h.cb`, `pick()`): + * evaluated once, through the null check, and cast to its signature. */ +static void generate_fnptr_value_call(CodeGenerator* gen, ASTNode* call, int discarded) { + Type* sig = call->children[0]->node_type; + const char* ret_c = sig->return_type ? get_c_type(sig->return_type) : "void"; + ArgDrainWrap ad; + ad.ret_ct = ret_c; + ad.ret_type = NULL; + ad.have_value = strcmp(ret_c, "void") != 0; + ad.discarded = discarded; + arg_drain_select(gen, call, 1, NULL, NULL, !ad.have_value || discarded, &ad); + arg_drain_open(gen, call, &ad); + int took = emit_fnptr_take_open(gen, call); + int narrow = !discarded && fnptr_returns_bool(sig); + if (narrow) fprintf(gen->output, "((_Bool)(unsigned char)("); + fprintf(gen->output, "((%s)_aether_callee_fn((_AeFnPtr)(", fnptr_c_spelling(sig, NULL)); + generate_expression(gen, call->children[0]); + fprintf(gen->output, "), \"null_function_call: call through a null function pointer at \" "); + emit_slice_diag_file(gen, call); + fprintf(gen->output, " \":%d:%d\"))(", call->line, call->column); + generate_fnptr_call_args(gen, sig, call, 1); fprintf(gen->output, ")"); if (narrow) fprintf(gen->output, "))"); emit_fnptr_take_close(gen, took); @@ -2088,6 +2131,8 @@ int typed_fnptr_call(CodeGenerator* gen, ASTNode* call) { if (!call || call->type != AST_FUNCTION_CALL || !call->value) return 0; if (call->annotation && (strcmp(call->annotation, "fnptr_local_call") == 0 || strncmp(call->annotation, "fnfield_", 8) == 0)) return 1; + if (strcmp(call->value, "call") == 0 && call->child_count > 0 && call->children[0] && + is_fnptr_type(call->children[0]->node_type)) return 1; Type* sig = lookup_fnptr_global(gen, call->value); return sig && sig->kind == TYPE_FUNCTION && sig->is_fnptr; } @@ -9397,9 +9442,10 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { int took = emit_fnptr_take_open(gen, expr); int narrow = gen->discard_call_node != expr && fnptr_returns_bool(field_sig); if (narrow) fprintf(gen->output, "((_Bool)(unsigned char)("); - fprintf(gen->output, "(%s%s%s)(", - recv_c, is_ptr ? "->" : ".", field_c); - generate_fnptr_call_args(gen, field_sig, expr); + const char* callee = cg_internf("%s%s%s", recv_c, is_ptr ? "->" : ".", field_c); + emit_fnptr_null_check(gen, callee, expr); + fprintf(gen->output, "%s)(", callee); + generate_fnptr_call_args(gen, field_sig, expr, 0); fprintf(gen->output, ")"); if (narrow) fprintf(gen->output, "))"); emit_fnptr_take_close(gen, took); @@ -10217,6 +10263,10 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { else if (strcmp(func_name, "builder_depth") == 0) { fprintf(gen->output, "_aether_ctx_depth"); } + else if (strcmp(func_name, "call") == 0 && expr->child_count >= 1 && + is_fnptr_type(expr->children[0]->node_type)) { + generate_fnptr_value_call(gen, expr, ad_call_discarded); + } // call(closure_var, args...) — invoke a closure stored in a variable // Looks up the closure's hoisted function signature and calls through it else if (strcmp(func_name, "call") == 0 && expr->child_count >= 1) { @@ -10412,9 +10462,9 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { fprintf(gen->output, ", %s", atype); sig_pi++; } - /* #3091: through `_aether_closure_fn`, which + /* #3091: through `_aether_callee_fn`, which * panics at this call when the closure is empty. */ - fprintf(gen->output, "))_aether_closure_fn("); + fprintf(gen->output, "))_aether_callee_fn("); generate_expression(gen, closure_arg); fprintf(gen->output, ".fn, \"empty_closure_call: call through an empty closure at \" "); emit_slice_diag_file(gen, expr); diff --git a/docs/language-reference.md b/docs/language-reference.md index 4b4e67550..fc5b04a70 100644 --- a/docs/language-reference.md +++ b/docs/language-reference.md @@ -923,7 +923,9 @@ call written out. The callee is evaluated once, before the arguments (`fs[pick()](3)` calls `pick` once), and held for the length of its call, so a function that rebinds the element or field it was read from runs to its end. A callee whose value is not a function is a type error, "the value -called here is not a function", naming its type (#3099). +called here is not a function", naming its type (#3099). A typed function +pointer (`fn(int) -> int`, a `cfn` type) is called the same way, from a +local, a parameter, a field, an element or a result. ### Trailing blocks @@ -2066,6 +2068,7 @@ downstream triage match on it, so it does not change with the prose after it. | `postcondition_violation` | an `ensures` clause that evaluated false | | `forced_unwrap_none` | `x!` on an optional holding `none` | | `empty_closure_call` | a call through the empty closure (a closure slot or a `fn?` holding `null`) | +| `null_function_call` | a call through a typed function pointer (`fn(int) -> int`, a `cfn` type) holding `null` | A failure that forwards an existing error value (`expr!` on a fallible) prints that error's own message rather than a category, because the message came from @@ -3916,6 +3919,8 @@ A closure cannot be stored in any of those slots, as it cannot be passed to the A typed function pointer's own parameter can be a typed function pointer, `fn(fn(string) -> string, string) -> string`, and a function with that parameter fits it. +A call through a typed function pointer that holds `null` panics at the call with the category `null_function_call` and the call's source position ([Panic categories](#panic-categories)), which a `catch` receives, as a call through the empty closure does (#3098). + A call through a typed function pointer converts a slice argument as a direct call does: `[]` and `null` become the empty slice, and an array a view of its elements (#2856). A closure whose signature the compiler knows converts its arguments the same way, an optional's bare value and `none` included. A function's result is written as any other typed function pointer, `-> fn(ptr) -> string` (the inner `-> string` is part of the type) or `-> Getter` for a `cfn Getter`, and a call to the function is that pointer: it binds to a local, a field or a parameter, and `f as ptr` gives back the pointer it holds. diff --git a/new_changelogs/20261022T150454Z-fixed-3098-null-function-call-panic.md b/new_changelogs/20261022T150454Z-fixed-3098-null-function-call-panic.md new file mode 100644 index 000000000..ee5b1198e --- /dev/null +++ b/new_changelogs/20261022T150454Z-fixed-3098-null-function-call-panic.md @@ -0,0 +1,8 @@ +- **A call through a null function pointer is a named panic.** A typed + function pointer (`fn(int) -> int`, a `cfn` type) holding `null`, called + from a local, a parameter, a field, a module-level `var`, an array + element or a function's result, jumped to address 0 and crashed with no + message. The call now panics with the category `null_function_call` and + its source position, which a `catch` receives (#3098). A typed function + pointer reached through an element or a field of a field (`fs[1](x)`, + `w.h.cb(x)`) is called through its signature, not as a closure. diff --git a/tests/compiler/test_codegen.c b/tests/compiler/test_codegen.c index 40b6ce6d5..608c59646 100644 --- a/tests/compiler/test_codegen.c +++ b/tests/compiler/test_codegen.c @@ -196,8 +196,8 @@ TEST(codegen_fnptr_struct_field_string_arg_passes_bytes) { "by_pointer(p: *Ops, s: string) -> int { return p.measure(s) }\n" "main() { }"); ASSERT_NOT_NULL(buf); - ASSERT_TRUE(strstr(buf, "(o.measure)(aether_string_data(s))") != NULL); - ASSERT_TRUE(strstr(buf, "(p->measure)(aether_string_data(s))") != NULL); + ASSERT_TRUE(strstr(buf, ", o.measure)(aether_string_data(s))") != NULL); + ASSERT_TRUE(strstr(buf, ", p->measure)(aether_string_data(s))") != NULL); free(buf); } @@ -697,7 +697,7 @@ TEST(codegen_fnptr_local_named_release_shadows_builtin) { " release(state) }\n" "main() { }"); ASSERT_NOT_NULL(buf); - ASSERT_TRUE(strstr(buf, "((void(*)(void*))(release))(state)") != NULL); + ASSERT_TRUE(strstr(buf, "(void(*)(void*))(release))(state)") != NULL); ASSERT_TRUE(strstr(buf, "string_release(state)") == NULL); ASSERT_TRUE(strstr(buf, "release() type error") == NULL); free(buf); @@ -716,7 +716,7 @@ TEST(codegen_fnptr_local_named_free_shadows_builtin_and_keeps_its_spelling) { "main() { }"); ASSERT_NOT_NULL(buf); ASSERT_TRUE(strstr(buf, "void* free = h->free;") != NULL); - ASSERT_TRUE(strstr(buf, "((void(*)(void*))(free))(state)") != NULL); + ASSERT_TRUE(strstr(buf, "(void(*)(void*))(free))(state)") != NULL); ASSERT_TRUE(strstr(buf, "ae_free") == NULL); free(buf); } @@ -749,7 +749,7 @@ TEST(codegen_call_through_global_fnptr_var_emits_typed_cast) { "main() { }"); ASSERT_NOT_NULL(buf); ASSERT_TRUE(strstr(buf, "static void* gl_gen_buffers = NULL;") != NULL); - ASSERT_TRUE(strstr(buf, "((void(*)(int, void*))(gl_gen_buffers))(1, ids)") != NULL); + ASSERT_TRUE(strstr(buf, "(void(*)(int, void*))(gl_gen_buffers))(1, ids)") != NULL); free(buf); } @@ -769,7 +769,7 @@ TEST(codegen_cfn_local_param_and_field_lower_like_fn_types) { " r2 = o.scale(1.0, 1.0) + apply(sc, r) }"); ASSERT_NOT_NULL(buf); ASSERT_TRUE(strstr(buf, "void* sc = ((void*)(getp()))") != NULL); - ASSERT_TRUE(strstr(buf, "((double(*)(double, float))(sc))(1.5, 4.0)") != NULL); + ASSERT_TRUE(strstr(buf, "(double(*)(double, float))(sc))(1.5, 4.0)") != NULL); ASSERT_TRUE(strstr(buf, "double (*f)(double, float)") != NULL); ASSERT_TRUE(strstr(buf, "double (*scale)(double, float)") != NULL); ASSERT_TRUE(strstr(buf, "typedef") == NULL || strstr(buf, "Scale;") == NULL); @@ -794,12 +794,14 @@ TEST(codegen_fnptr_bool_return_reads_low_byte_when_used) { " if r(3) { println(\"a\") }\n" " if d.ready(4) { println(\"b\") } }"); ASSERT_NOT_NULL(buf); - ASSERT_TRUE(strstr(buf, "((_Bool)(unsigned char)(((int(*)(int))(r))(3)))") != NULL); - ASSERT_TRUE(strstr(buf, "((_Bool)(unsigned char)((d.ready)(4)))") != NULL); - ASSERT_TRUE(strstr(buf, "((_Bool)(unsigned char)(((int(*)(int))(r))(1)") == NULL); - ASSERT_TRUE(strstr(buf, "((_Bool)(unsigned char)((d.ready)(2)") == NULL); - ASSERT_TRUE(strstr(buf, "((int(*)(int))(r))(1);") != NULL); - ASSERT_TRUE(strstr(buf, "(d.ready)(2);") != NULL); + ASSERT_TRUE(strstr(buf, "((_Bool)(unsigned char)((r ? (void)0 : aether_panic(") != NULL); + ASSERT_TRUE(strstr(buf, "(int(*)(int))(r))(3)))") != NULL); + ASSERT_TRUE(strstr(buf, "((_Bool)(unsigned char)((d.ready ? (void)0 : aether_panic(") != NULL); + ASSERT_TRUE(strstr(buf, ", d.ready)(4)))") != NULL); + ASSERT_TRUE(strstr(buf, "(int(*)(int))(r))(1)))") == NULL); + ASSERT_TRUE(strstr(buf, ", d.ready)(2)))") == NULL); + ASSERT_TRUE(strstr(buf, "(int(*)(int))(r))(1);") != NULL); + ASSERT_TRUE(strstr(buf, ", d.ready)(2);") != NULL); free(buf); } diff --git a/tests/integration/aether_cfn_proc_address/test_aether_cfn_proc_address.sh b/tests/integration/aether_cfn_proc_address/test_aether_cfn_proc_address.sh index ab6c9f7aa..e772ab007 100755 --- a/tests/integration/aether_cfn_proc_address/test_aether_cfn_proc_address.sh +++ b/tests/integration/aether_cfn_proc_address/test_aether_cfn_proc_address.sh @@ -126,7 +126,7 @@ fi # The module's global is the merged `glx_gen_buffers` cell, and every call # through it carries the typed C cast. -if ! grep -qE '\(\(void *\(\*\) *\(int, *void\*\)\) *\(glx_gen_buffers\)\) *\(1, *ids\)' "$tmpdir/app.gen.c"; then +if ! grep -qE '\(void *\(\*\) *\(int, *void\*\)\) *\(glx_gen_buffers\)\) *\(1, *ids\)' "$tmpdir/app.gen.c"; then echo " [FAIL] aether_cfn_proc_address: expected a typed cast on the call through the module var, got:" grep -E 'gen_buffers' "$tmpdir/app.gen.c" | sed 's/^/ /' exit 1 diff --git a/tests/integration/aether_fnptr_call/test_aether_fnptr_call.sh b/tests/integration/aether_fnptr_call/test_aether_fnptr_call.sh index 26113352e..e5809ed83 100755 --- a/tests/integration/aether_fnptr_call/test_aether_fnptr_call.sh +++ b/tests/integration/aether_fnptr_call/test_aether_fnptr_call.sh @@ -50,7 +50,7 @@ fi # The cast must appear in the emitted C — `((int(*)(int, int))(add))(3, 4)`. # Allow flexible whitespace between operators / parens. -if ! grep -qE '\(\(int *\(\*\) *\(int, *int\)\) *\(add\)\) *\(3, *4\)' "$tmpdir/case1.gen.c"; then +if ! grep -qE '\(int *\(\*\) *\(int, *int\)\) *\(add\)\) *\(3, *4\)' "$tmpdir/case1.gen.c"; then echo " [FAIL] case 1: expected typed fn-pointer cast at call site, got:" grep -E 'add' "$tmpdir/case1.gen.c" | sed 's/^/ /' exit 1 @@ -95,7 +95,7 @@ if ! "$AETHERC" "$tmpdir/case2.ae" "$tmpdir/case2.gen.c" 2>"$tmpdir/case2.err"; exit 1 fi -if ! grep -qE '\(\(int *\(\*\) *\(int\)\) *\(dbl\)\) *\(21\)' "$tmpdir/case2.gen.c"; then +if ! grep -qE '\(int *\(\*\) *\(int\)\) *\(dbl\)\) *\(21\)' "$tmpdir/case2.gen.c"; then echo " [FAIL] case 2: expected typed cast for unary fn-pointer call" grep -E 'dbl' "$tmpdir/case2.gen.c" | sed 's/^/ /' exit 1 @@ -121,7 +121,7 @@ if ! "$AETHERC" "$tmpdir/case3.ae" "$tmpdir/case3.gen.c" 2>"$tmpdir/case3.err"; exit 1 fi -if ! grep -qE '\(\(void *\(\*\) *\(int\)\) *\(log_it\)\) *\(42\)' "$tmpdir/case3.gen.c"; then +if ! grep -qE '\(void *\(\*\) *\(int\)\) *\(log_it\)\) *\(42\)' "$tmpdir/case3.gen.c"; then echo " [FAIL] case 3: expected void-returning fn-pointer cast" grep -E 'log_it' "$tmpdir/case3.gen.c" | sed 's/^/ /' exit 1 diff --git a/tests/integration/closure_args_borrowed/test_closure_args_borrowed.sh b/tests/integration/closure_args_borrowed/test_closure_args_borrowed.sh index 47a80c742..c21067812 100644 --- a/tests/integration/closure_args_borrowed/test_closure_args_borrowed.sh +++ b/tests/integration/closure_args_borrowed/test_closure_args_borrowed.sh @@ -96,7 +96,7 @@ h!+ i!" grep -q '_ad_[0-9]* = (char\*)(mk(((const char\*)&_ae_slit_[0-9]*))), aether_unwind_track_str_place((const void\*)\&_ad_[0-9]*, NULL), apply(look, _ad_[0-9]*), aether_unwind_forget((const void\*)\&_ad_[0-9]*), aether_heap_str_free' "$tmp/borrowed.c" \ || fail "the argument to apply() is not freed" "$tmp/borrowed.c" -n="$(grep -c '_ad_[0-9]* = (char\*)(mk(a)), .*_aether_closure_fn(f\.fn, "empty_closure_call: .*"))(f\.env, _ad_[0-9]*), aether_unwind_forget((const void\*)\&_ad_[0-9]*), aether_heap_str_free' "$tmp/borrowed.c")" +n="$(grep -c '_ad_[0-9]* = (char\*)(mk(a)), .*_aether_callee_fn(f\.fn, "empty_closure_call: .*"))(f\.env, _ad_[0-9]*), aether_unwind_forget((const void\*)\&_ad_[0-9]*), aether_heap_str_free' "$tmp/borrowed.c")" [ "$n" = "3" ] || fail "an argument to a call through an fn parameter is not freed ($n of 3)" "$tmp/borrowed.c" # ---- a closure keeps its `string` parameter: the store takes its own diff --git a/tests/integration/closure_args_foreign/test_closure_args_foreign.sh b/tests/integration/closure_args_foreign/test_closure_args_foreign.sh index c0e6739bc..15ed3340c 100644 --- a/tests/integration/closure_args_foreign/test_closure_args_foreign.sh +++ b/tests/integration/closure_args_foreign/test_closure_args_foreign.sh @@ -54,7 +54,7 @@ AE } freed() { - grep -q '_ad_[0-9]* = (char\*)(mk(a)), .*_aether_closure_fn(f\.fn, "empty_closure_call: .*"))(f\.env, _ad_[0-9]*), aether_unwind_forget((const void\*)\&_ad_[0-9]*), aether_heap_str_free' "$tmp/$1.c" + grep -q '_ad_[0-9]* = (char\*)(mk(a)), .*_aether_callee_fn(f\.fn, "empty_closure_call: .*"))(f\.env, _ad_[0-9]*), aether_unwind_forget((const void\*)\&_ad_[0-9]*), aether_heap_str_free' "$tmp/$1.c" } gen control "" "" diff --git a/tests/integration/panic_categories/null_function.ae b/tests/integration/panic_categories/null_function.ae new file mode 100644 index 000000000..7130abf8d --- /dev/null +++ b/tests/integration/panic_categories/null_function.ae @@ -0,0 +1,8 @@ +// A call through a typed function pointer holding null must print its own +// category token. +import std.io + +main() { + let f: fn(int) -> int = null + println("${f(3)}") +} diff --git a/tests/integration/panic_categories/test_panic_categories.sh b/tests/integration/panic_categories/test_panic_categories.sh index 9b9313adc..e5a59710f 100755 --- a/tests/integration/panic_categories/test_panic_categories.sh +++ b/tests/integration/panic_categories/test_panic_categories.sh @@ -46,6 +46,7 @@ expect_category() { expect_category precondition precondition_violation expect_category forced_none forced_unwrap_none expect_category empty_closure empty_closure_call +expect_category null_function null_function_call if [ "$fail" -eq 0 ]; then echo " [PASS] panic_categories: failures lead with their stable category token" diff --git a/tests/regression/test_null_function_call.ae b/tests/regression/test_null_function_call.ae new file mode 100644 index 000000000..408da3a23 --- /dev/null +++ b/tests/regression/test_null_function_call.ae @@ -0,0 +1,169 @@ +// Regression (#3098): a call through a typed function pointer that holds +// null is a named panic at the call, not a jump to address 0. +// +// A `fn(int) -> int` local, parameter, struct field, module-level `var`, a +// `cfn` local, an array element, a field reached through another field or +// an element, and a function's result each crashed with an access +// violation and no message when they held null. Each call now panics with +// the category `null_function_call` and the call's source position, which a +// `catch` receives; a pointer that holds a function is called as before, +// and a callee an expression yields is evaluated once. + +import std.string + +extern exit(code: int) + +cfn Op(int) -> int + +struct H { + cb: fn(int) -> int +} + +struct W { + h: H +} + +var G: fn(int) -> int = null +var picks = 0 + +fn check(cond: bool, msg: string) { + if !cond { + println("FAIL #3098: ${msg}") + exit(1) + } +} + +fn check_panic(e: string, line: string, what: string) { + check(string.starts_with(e, "null_function_call: "), "${what}: category, got '${e}'") + check(string.contains(e, "test_null_function_call.ae:${line}:"), + "${what}: position ${line}, got '${e}'") +} + +twice(x: int) -> int { + return x * 2 +} + +apply(g: fn(int) -> int, x: int) -> int { + return g(x) +} + +none_op() -> Op { + return null +} + +pick() -> int { + picks = picks + 1 + return 0 +} + +local() -> string { + let f: fn(int) -> int = null + try { + f(3) + } catch e { + return e + } + return "no panic" +} + +param() -> string { + try { + apply(null, 3) + } catch e { + return e + } + return "no panic" +} + +cfn_local() -> string { + let o: Op = null + try { + o(3) + } catch e { + return e + } + return "no panic" +} + +global() -> string { + try { + G(3) + } catch e { + return e + } + return "no panic" +} + +field() -> string { + h = H { cb: null } + try { + h.cb(3) + } catch e { + return e + } + return "no panic" +} + +nested_field() -> string { + w = W { h: H { cb: null } } + try { + w.h.cb(3) + } catch e { + return e + } + return "no panic" +} + +element() -> string { + let o: Op = null + fs = [o, o] + try { + fs[1](3) + } catch e { + return e + } + return "no panic" +} + +element_field() -> string { + hs = [H { cb: twice }, H { cb: null }] + try { + hs[1].cb(3) + } catch e { + return e + } + return "no panic" +} + +result() -> string { + try { + none_op()(3) + } catch e { + return e + } + return "no panic" +} + +main() { + check_panic(local(), "62", "a local") + check_panic(param(), "47", "a parameter") + check_panic(cfn_local(), "81", "a cfn local") + check_panic(global(), "90", "a module-level var") + check_panic(field(), "100", "a field") + check_panic(nested_field(), "110", "a field of a field") + check_panic(element(), "121", "an array element") + check_panic(element_field(), "131", "a field of an element") + check_panic(result(), "140", "a function's result") + + let o: Op = twice + hs = [H { cb: twice }, H { cb: twice }] + w = W { h: H { cb: twice } } + check(apply(twice, 4) == 8, "a parameter holding a function") + check(o(5) == 10, "a cfn local holding a function") + check(hs[0].cb(6) == 12, "an element's field holding a function") + check(w.h.cb(7) == 14, "a field of a field holding a function") + fs = [o, o] + check(fs[pick()](2) == 4, "an element holding a function") + check(picks == 1, "the callee evaluated ${picks} times, not once") + println("PASS #3098: a call through a null function pointer is a named panic") +} From e8c7e2d22109137bf76f982e0d70761fce8b3c94 Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Thu, 22 Oct 2026 12:31:52 -0300 Subject: [PATCH 33/50] A C-style for loop's bare init assigns a variable already bound (#2734) `i = 5; for (i = 0; i < 3; i++) {}` left `i` at 5: the loop header declared a new C `i` that shadowed the local. A bare init is now the binding `i = 0` is anywhere else: - of a name bound where the loop is (a local bound before it, a module `var`, an actor's state field) it assigns that variable, emitted as the binding statement ahead of the loop, so it takes the ownership any binding does and keeps the value the loop ends with; - of a new name it declares the loop's own variable, free again after the loop, as before. A typed init (`int i = 0`) and a range-for's variable still declare the loop's own. The parser marks a bare init `type_inferred`; the checker, which sees bindings in source order, clears the mark for a new name, and codegen and the scoped-string rename (#2746) follow the mark. test_issue2798's init case pinned the shadowing (`j == 100` after the loop) and now checks the assignment; the #2746 and #2742 tests that exercise a shadowing string loop variable declare it with its type, and test_for_loop_var_owned gains the assigning case under its steady-heap check. Test: tests/regression/test_for_init_assigns_existing.ae (a local, one stepped in the body, one after break, a string, a module var, a captured local, an actor's state field; a new name, a typed init and a range-for still the loop's own). Before: "a local: 5, want 3". --- compiler/analysis/typechecker.c | 36 +++++- compiler/codegen/codegen.c | 7 +- compiler/codegen/codegen_stmt.c | 30 +++-- compiler/parser/parser.c | 5 + docs/language-reference.md | 27 +++- ...2T152312Z-changed-2734-for-init-assigns.md | 9 ++ .../test_for_init_assigns_existing.ae | 122 ++++++++++++++++++ tests/regression/test_for_loop_var_owned.ae | 13 +- ...issue2798_statement_constructs_in_parts.ae | 4 +- .../test_scoped_string_shadow_exit.ae | 11 +- 10 files changed, 234 insertions(+), 30 deletions(-) create mode 100644 new_changelogs/20261022T152312Z-changed-2734-for-init-assigns.md create mode 100644 tests/regression/test_for_init_assigns_existing.ae diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index 6b3f2dec4..4cd7ad4ee 100644 --- a/compiler/analysis/typechecker.c +++ b/compiler/analysis/typechecker.c @@ -3590,17 +3590,33 @@ static ASTNode* global_var_decl(ASTNode* child); /* The module-level `var` a bare `name = value` binding assigns: the * program's global of that name, when no local of it is in scope (#701). * NULL for any other binding. */ +static ASTNode* module_var_named(const char* name) { + ASTNode* program = aether_typecheck_program_node(); + for (int i = 0; program && i < program->child_count; i++) { + ASTNode* g = global_var_decl(program->children[i]); + if (g && strcmp(g->value, name) == 0) return g; + } + return NULL; +} + static ASTNode* rebound_global_var(ASTNode* stmt, SymbolTable* table) { if (stmt->type != AST_VARIABLE_DECLARATION || !stmt->type_inferred || !stmt->value) return NULL; for (SymbolTable* s = table; s && s->parent; s = s->parent) if (lookup_symbol_local(s, stmt->value)) return NULL; - ASTNode* program = aether_typecheck_program_node(); - for (int i = 0; program && i < program->child_count; i++) { - ASTNode* g = global_var_decl(program->children[i]); - if (g && strcmp(g->value, stmt->value) == 0) return g; + return module_var_named(stmt->value); +} + +/* #2734: does a C-style for loop's bare init (`i = 0`) name a variable bound + * where the loop is: a local bound before it (an actor's state field is + * one), or a module `var`? Then the init assigns it, as `i = 0` does + * anywhere. */ +static int for_init_names_bound(const char* name, SymbolTable* table) { + for (SymbolTable* t = table; t && t->parent; t = t->parent) { + Symbol* s = lookup_symbol_local(t, name); + if (s) return !s->hoist_pending; } - return NULL; + return module_var_named(name) != NULL; } static Type* fnptr_binding_slot(ASTNode* stmt, SymbolTable* table) { @@ -14084,7 +14100,15 @@ int typecheck_statement(ASTNode* stmt, SymbolTable* table) { // Type check init (child 0) if (stmt->child_count > 0 && stmt->children[0]) { - typecheck_statement(stmt->children[0], loop_table); + /* #2734: a bare init of a name not bound here declares the + * loop's own variable, as a typed init or a range-for's + * does; one of a bound name stays the binding that assigns + * it. Codegen reads which from `type_inferred`. */ + ASTNode* init = stmt->children[0]; + if (init->type == AST_VARIABLE_DECLARATION && init->type_inferred && + init->value && !for_init_names_bound(init->value, table)) + init->type_inferred = 0; + typecheck_statement(init, loop_table); } // Type check condition (child 1). #3042 review: as for `if` and diff --git a/compiler/codegen/codegen.c b/compiler/codegen/codegen.c index e6221201f..aee12b3fb 100644 --- a/compiler/codegen/codegen.c +++ b/compiler/codegen/codegen.c @@ -6138,6 +6138,9 @@ static void rename_locals_shadowing_functions(ASTNode* program) { * binding's (a nested binding of the name included, renamed alike), and * field, argument and function names are not value names. * + * A for loop's init that assigns a variable already bound there (#2734) + * declares nothing and keeps its name. + * * #2994: so is every name a `match` arm's pattern binds (`some(v)` over * any optional, the names of a list or a struct pattern), which is that * arm's own: codegen writes the value of a bound `match` in the arm's @@ -6210,8 +6213,8 @@ static void rename_scoped_bindings_in(ASTNode* scope, ASTNode* node, const NameS for (int i = 0; i < node->child_count; i++) rename_scoped_bindings_in(scope, node->children[i], fixed); if (node->type == AST_FOR_LOOP && node->child_count > 0 && node->children[0] && - node->children[0]->type == AST_VARIABLE_DECLARATION && node->children[0]->node_type && - node->children[0]->node_type->kind == TYPE_STRING) { + node->children[0]->type == AST_VARIABLE_DECLARATION && !node->children[0]->type_inferred && + node->children[0]->node_type && node->children[0]->node_type->kind == TYPE_STRING) { rename_scoped_binding(scope, node, node->children[0]->value, fixed); } else if (node->type == AST_CATCH_CLAUSE && node->value) { rename_scoped_binding(scope, node, node->value, fixed); diff --git a/compiler/codegen/codegen_stmt.c b/compiler/codegen/codegen_stmt.c index 8c518a52b..db65b6e67 100644 --- a/compiler/codegen/codegen_stmt.c +++ b/compiler/codegen/codegen_stmt.c @@ -10928,7 +10928,17 @@ static void generate_statement_body(CodeGenerator* gen, ASTNode* stmt) { ASTNode* for_init = stmt->child_count > 0 ? stmt->children[0] : NULL; const char* loop_var = for_init && for_init->type == AST_VARIABLE_DECLARATION ? for_init->value : NULL; - int cell_init = loop_var && is_promoted_capture(gen, loop_var); + int state_var = loop_var && is_actor_state_var(gen, loop_var); + /* #2734: a bare init (`i = 0`) of a name already bound where the + * loop is, a local, a module `var` or an actor's state field, + * assigns it, as `x = ...` does anywhere (the checker leaves + * `type_inferred` on exactly those): emitted as that statement + * ahead of the loop, it takes the ownership any binding does, + * and the loop leaves the variable with its last value. The + * header declared a new one, so `i = 5; for (i = 0; i < 3; i++) + * {}` left `i` at 5. */ + int assign_init = loop_var && for_init->type_inferred; + int cell_init = !assign_init && loop_var && is_promoted_capture(gen, loop_var); /* #2742: the loop's variable is owned as the binding a while-loop * rewrite makes, in the same block of its own: * - a string: the loop declares its own `s` and `_heap_s`, @@ -10945,16 +10955,16 @@ static void generate_statement_body(CodeGenerator* gen, ASTNode* stmt) { * of an outer loop, frees it. Declared in the header, the * variable owned nothing: the value it ended with was never * freed. Any other loop variable keeps the header declaration: - * one that shadows an enclosing local or a module `var` (the - * statement would assign those), and an actor's state field, - * which a handler reads and writes as `self->name`. */ - int state_var = loop_var && is_actor_state_var(gen, loop_var); - int str_init = !cell_init && loop_var && !state_var && for_init->node_type && - for_init->node_type->kind == TYPE_STRING; - int stmt_init = !cell_init && !str_init && loop_var && !state_var && + * one a typed init or a range-for declares over an enclosing + * local, a module `var` or an actor's state field of its name, + * which it shadows. */ + int str_init = !assign_init && !cell_init && loop_var && !state_var && + for_init->node_type && for_init->node_type->kind == TYPE_STRING; + int stmt_init = !assign_init && !cell_init && !str_init && loop_var && !state_var && !is_var_declared(gen, loop_var) && !is_module_global_var(gen, loop_var); ScopedStringLocal str_local = {0}; + if (assign_init) generate_statement(gen, for_init); int own_block = cell_init || str_init || stmt_init; if (own_block) { print_line(gen, "{"); @@ -10990,7 +11000,7 @@ static void generate_statement_body(CodeGenerator* gen, ASTNode* stmt) { int step_ctl = step && !step_stmt && hoist_ready(gen) && ctl_contains(gen, step); /* #2798: so are the constructs in an init the header keeps, ahead * of the loop: nothing in it is evaluated before the init. */ - if (!own_block && for_init && hoist_ready(gen)) { + if (!own_block && !assign_init && for_init && hoist_ready(gen)) { ASTNode* iv = for_init; if (iv->type == AST_VARIABLE_DECLARATION) { iv = iv->child_count > 0 ? iv->children[0] : NULL; @@ -11006,7 +11016,7 @@ static void generate_statement_body(CodeGenerator* gen, ASTNode* stmt) { } if (own_block) print_indent(gen); fprintf(gen->output, "for ("); - if (!own_block && for_init) { + if (!own_block && !assign_init && for_init) { ASTNode* init = for_init; if (init->type == AST_VARIABLE_DECLARATION) { generate_type(gen, init->node_type); diff --git a/compiler/parser/parser.c b/compiler/parser/parser.c index 26497ea07..56329371c 100644 --- a/compiler/parser/parser.c +++ b/compiler/parser/parser.c @@ -4169,6 +4169,11 @@ ASTNode* parse_for_loop(Parser* parser) { if (!name) return NULL; init = create_ast_node(AST_VARIABLE_DECLARATION, name->value, name->line, name->column); init->node_type = create_type(TYPE_UNKNOWN); + /* #2734: a bare `x = expr`, as anywhere else: it assigns a + * variable already bound there. The checker clears the mark + * for a new name, which the loop declares as its own, as it + * does a typed init's (`int i = 0`) or a range-for's. */ + init->type_inferred = 1; Token* eq = peek_token(parser); if (match_token(parser, TOKEN_ASSIGN)) { int errors_before = aether_error_count(); diff --git a/docs/language-reference.md b/docs/language-reference.md index fc5b04a70..f9db9d91b 100644 --- a/docs/language-reference.md +++ b/docs/language-reference.md @@ -1238,9 +1238,28 @@ for (i = 0; i < 10; i = i + 1) { } ``` -The variable the init binds is the loop's, from the condition through the body -to the end of the loop: a binding of its name in the body assigns it, so a loop -can step its variable there, and after the loop the name is free to bind again. +A bare init (`i = 0`) is the binding `i = 0` is anywhere. When the name is +already bound where the loop is (a local, a module-level `var`, an actor's +state field), it assigns that variable, which holds the value the loop ends +with: + +```aether,run +main() { + i = 5 + for (i = 0; i < 3; i++) { + } + println(i) +} +``` +```output +3 +``` + +For a new name it declares the loop's own variable, from the condition through +the body to the end of the loop: a binding of its name in the body assigns it, +so a loop can step its variable there, and after the loop the name is free to +bind again. A typed init (`for (int i = 0; ...)`) and a range-for's variable +always declare the loop's own, shadowing a binding of the name (#2734). ```aether,run main() { @@ -1318,7 +1337,7 @@ for i in 1..n { } ``` -The range `start..end` is exclusive of `end` (like Python's `range(start, end)`). It desugars to a C-style for loop internally. +The range `start..end` is exclusive of `end` (like Python's `range(start, end)`). It desugars to a C-style for loop internally. Its variable is the loop's own, a new one even where the name is bound outside the loop. ### Loop Control diff --git a/new_changelogs/20261022T152312Z-changed-2734-for-init-assigns.md b/new_changelogs/20261022T152312Z-changed-2734-for-init-assigns.md new file mode 100644 index 000000000..5a39cba82 --- /dev/null +++ b/new_changelogs/20261022T152312Z-changed-2734-for-init-assigns.md @@ -0,0 +1,9 @@ +- **A C-style `for` loop's bare init assigns a variable already bound.** + `i = 5; for (i = 0; i < 3; i++) {}` left `i` at 5: the loop declared a new + `i` that shadowed the local. A bare init (`i = 0`) is now the binding it + is anywhere else: it assigns a local, a module-level `var` or an actor's + state field of that name, which keeps the value the loop ends with, and + declares the loop's own variable only for a new name. A typed init + (`int i = 0`) and a range-for's variable still declare the loop's own. + To keep a loop that relied on the shadowing, give its variable a new name + or write its type in the init (`for (int i = 0; ...)`) (#2734). diff --git a/tests/regression/test_for_init_assigns_existing.ae b/tests/regression/test_for_init_assigns_existing.ae new file mode 100644 index 000000000..1676f8141 --- /dev/null +++ b/tests/regression/test_for_init_assigns_existing.ae @@ -0,0 +1,122 @@ +// Regression (#2734): a C-style `for` loop's bare init (`i = 0`) is the +// binding `i = 0` is anywhere else. It assigns a variable already bound +// where the loop is, which keeps the value the loop ends with, and declares +// the loop's own only for a new name. +// +// The header declared a new C variable every time, so `i = 5; for (i = 0; +// i < 3; i++) {}` left `i` at 5. A typed init (`int i = 0`) and a range-for's +// variable still declare the loop's own, shadowing a binding of the name. +import std.string + +extern exit(code: int) + +var steps = 9 +var counted = 0 + +fn check(cond: bool, msg: string) { + if !cond { + println("FAIL #2734: ${msg}") + exit(1) + } +} + +message Count {} + +actor Counter { + state n = 7 + + receive { + Count() -> { + for (n = 0; n < 4; n++) { + } + counted = n + } + } +} + +local_int() -> int { + i = 5 + for (i = 0; i < 3; i++) { + } + return i +} + +stepped_in_body() -> int { + i = 50 + for (i = 0; i < 10;) { + i = i + 4 + } + return i +} + +broken_out() -> int { + i = -1 + for (i = 0; i < 100; i++) { + if i == 6 { + break + } + } + return i +} + +local_string() -> string { + s = "x" + for (s = "a"; string.length(s) < 3; s = string.concat(s, "b")) { + } + return s +} + +module_var() -> int { + for (steps = 0; steps < 2; steps++) { + } + return steps +} + +captured() -> int { + i = 1 + bump = || { i = i + 10 } + for (i = 0; i < 3; i++) { + } + bump() + return i +} + +new_name_is_the_loops() -> string { + total = 0 + for (j = 0; j < 4; j++) { + total = total + j + } + j = "free" + return "${total} ${j}" +} + +typed_init_shadows() -> int { + i = 5 + for (int i = 0; i < 3; i++) { + } + return i +} + +range_shadows() -> int { + i = 5 + for i in 0..3 { + } + return i +} + +main() { + check(local_int() == 3, "a local: ${local_int()}, want 3") + check(stepped_in_body() == 12, "a local stepped in the body: ${stepped_in_body()}") + check(broken_out() == 6, "a local after break: ${broken_out()}") + check(local_string() == "abb", "a string local: '${local_string()}'") + check(module_var() == 2, "a module-level var: ${module_var()}") + check(captured() == 13, "a local a closure captured: ${captured()}") + check(new_name_is_the_loops() == "6 free", "a new name: '${new_name_is_the_loops()}'") + check(typed_init_shadows() == 5, "a typed init: ${typed_init_shadows()}") + check(range_shadows() == 5, "a range-for: ${range_shadows()}") + c = spawn(Counter()) + c ! Count {} + wait_for_idle() + check(counted == 4, "an actor's state field: ${counted}") + println("PASS #2734: a bare for init assigns an existing variable") +} diff --git a/tests/regression/test_for_loop_var_owned.ae b/tests/regression/test_for_loop_var_owned.ae index d32fde381..ebb072c31 100644 --- a/tests/regression/test_for_loop_var_owned.ae +++ b/tests/regression/test_for_loop_var_owned.ae @@ -138,8 +138,17 @@ first_of_length(piece: string, len: int) -> string { return "" } -// The loop's variable shadows a local of its name, as it always did. +// A loop variable declared with its type shadows a local of its name. shadowed(piece: string) -> string { + s = string.concat("out", piece) + for (string s = string.concat("a", ""); string.length(s) < 4; s = string.concat(s, piece)) { + } + return s +} + +// A bare init of a local's name assigns the local (#2734): the string it +// held, and each one the step replaces, are released. +assigned(piece: string) -> string { s = string.concat("out", piece) for (s = string.concat("a", ""); string.length(s) < 4; s = string.concat(s, piece)) { } @@ -206,6 +215,7 @@ main() { check("labeled continue", labeled(piece), 121212) check_str("returned from the loop", first_of_length(piece, 4), "abbb") check_str("shadowed", shadowed(piece), "outb") + check_str("assigned", assigned(piece), "abbb") check("int loops", int_loops(), 39) check("struct loop", struct_loop(), 78) check("struct that owns a string", tag_loop(piece), 2345) @@ -218,6 +228,7 @@ main() { steady("labeled continue", || { labeled(piece) }) steady("returned from the loop", || { first_of_length(piece, 4) }) steady("shadowed", || { shadowed(piece) }) + steady("assigned", || { assigned(piece) }) steady("int loops", || { int_loops() }) steady("struct loop", || { struct_loop() }) steady("struct that owns a string", || { tag_loop(piece) }) diff --git a/tests/regression/test_issue2798_statement_constructs_in_parts.ae b/tests/regression/test_issue2798_statement_constructs_in_parts.ae index ebbdda45c..7b55142f1 100644 --- a/tests/regression/test_issue2798_statement_constructs_in_parts.ae +++ b/tests/regression/test_issue2798_statement_constructs_in_parts.ae @@ -149,13 +149,13 @@ main() { p = Pair { a: mark(1), b: num(true) or { mark(2) } } check(p.a == 1 && p.b == 2 && trail == 12, "a struct literal's field") - // A `for` loop's init (shadowing a local) and increment. + // A `for` loop's init (assigning a local, #2734) and increment. j = 100 total = 0 for (j = (num(true) or { 1 }); j < 4; j = j + 1) { total = total + j } - check(total == 6 && j == 100, "a for loop's init") + check(total == 6 && j == 4, "a for loop's init") k = 0 calls = 0 for (_i = 0; k < 3; tick() + (num(true) or { 0 })) { diff --git a/tests/regression/test_scoped_string_shadow_exit.ae b/tests/regression/test_scoped_string_shadow_exit.ae index 8b875f3b4..e6c7acfea 100644 --- a/tests/regression/test_scoped_string_shadow_exit.ae +++ b/tests/regression/test_scoped_string_shadow_exit.ae @@ -1,7 +1,8 @@ // Regression (#2746): a `return` from inside a block whose string binding // shadows a function-level string frees both values. // -// A C-style for loop's string variable (#2742), an owning `catch` binding +// A C-style for loop's string variable declared with its type (#2742, +// #2734), an owning `catch` binding // (#2333) and the `some(v)` binding of a match over a `string?` call // (#2750) each live for one C block and shadow a local of their name. The // block's exit free and the shadowed local's were both keyed by the name, @@ -51,7 +52,7 @@ fresh(w: string) -> string? { // value is read after it. by_loop(w: string, stop: int) -> int { s = string.concat(w, "er") - for (s = "a"; string.length(s) < 4; s = string.concat(s, "b")) { + for (string s = "a"; string.length(s) < 4; s = string.concat(s, "b")) { if string.length(s) == stop { return 1 } @@ -64,7 +65,7 @@ by_loop(w: string, stop: int) -> int { // A parameter made the function's own before the loop shadows it. by_param(s: string) -> int { s = string.concat(s, "er") - for (s = "a"; string.length(s) < 4; s = string.concat(s, "b")) { + for (string s = "a"; string.length(s) < 4; s = string.concat(s, "b")) { if string.length(s) == 3 { return 1 } @@ -74,8 +75,8 @@ by_param(s: string) -> int { by_nested(w: string, stop: int) -> int { s = string.concat(w, "er") - for (s = "a"; string.length(s) < 4; s = string.concat(s, "b")) { - for (s = "c"; string.length(s) < 3; s = string.concat(s, "d")) { + for (string s = "a"; string.length(s) < 4; s = string.concat(s, "b")) { + for (string s = "c"; string.length(s) < 3; s = string.concat(s, "d")) { if string.length(s) == stop { return 7 } From 0082f2f82383414e5e0bb45bb53c4e113a26e9c8 Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Thu, 22 Oct 2026 12:57:52 -0300 Subject: [PATCH 34/50] A local declared without a value starts at its type's zero value (#3114) `long x`, `int[3] k` and `P p` were `int64_t x; int k[3]; P p;` in the generated C, so reading one before writing it read whatever its stack slot held, with no diagnostic. A declaration without a value now starts at its type's zero (`= 0`, `= NULL`, `= {0}`), spelled per kind by the helper the hoisted locals already used (renamed local_zero_init). In a loop body each pass declares the local afresh, as a declaration with a value rebinds it: the hoisted variable's owned parts are released (own_emit_part) and it is zeroed again, so `long acc` there no longer carries the last pass's value. Cost: none measurable. Only eight declarations in std are of this kind (the block functions of BLAKE2, ChaCha20, SM4, 3DES and GOST). BLAKE2b and ChaCha20 over 16 MiB, min of 21 interleaved runs against the previous commit: whole process 124.1 vs 125.0 ms (-0.7%) and 233.5 vs 231.2 ms (+1.0%); in a second run, less the input fill, 115.2 vs 113.9 ms (+1.1%) and 222.0 vs 222.6 ms (-0.3%), the noise of this machine. benchmarks/ and examples/ declare none. Test: tests/regression/test_declared_local_starts_at_zero.ae (the issue's function, every kind, a fill that misses an element, a closure's and an actor handler's locals, per-pass zero in a loop body, owning locals released each pass; 0 leaks under leaks --atExit). Before: "the issue's g(): 18499374720". --- compiler/codegen/codegen_stmt.c | 52 +++++-- docs/language-reference.md | 26 ++++ ...T154318Z-fixed-3114-declared-local-zero.md | 9 ++ .../test_declared_local_starts_at_zero.ae | 135 ++++++++++++++++++ 4 files changed, 209 insertions(+), 13 deletions(-) create mode 100644 new_changelogs/20261022T154318Z-fixed-3114-declared-local-zero.md create mode 100644 tests/regression/test_declared_local_starts_at_zero.ae diff --git a/compiler/codegen/codegen_stmt.c b/compiler/codegen/codegen_stmt.c index db65b6e67..749fee3e2 100644 --- a/compiler/codegen/codegen_stmt.c +++ b/compiler/codegen/codegen_stmt.c @@ -5539,18 +5539,20 @@ void push_heap_string_exit_free_defers(CodeGenerator* gen, ASTNode* body) { * emits `elem name[N];`. (Hit repeatedly by the Redis port's per-loop * scratch buffers; the first-statement-in-block decl path already does * this correctly, this fixes the not-first / hoisted path.) */ -/* The zero initializer for a hoisted local. A hoisted variable is - * declared at function scope and assigned inside the branch or loop - * body that binds it; on a path that skips the body its value is - * indeterminate, and reading an indeterminate value is undefined — not +/* The zero initializer for a local that starts with no value of its own. + * A hoisted variable is declared at function scope and assigned inside the + * branch or loop body that binds it; on a path that skips the body its + * value is indeterminate, and reading an indeterminate value is undefined — not * a garbage value, undefined: gcc's interprocedural constant * propagation may treat it as whatever the other call sites pass (a * literal 0), so a parameter that "genuinely varies" arrives as a * constant, with no sanitizer able to say so (#2128 is the shape of that - * report). Initialized, the variable is 0/NULL/{0} on such a path and - * every read is defined. Spelled per kind rather than as a universal - * `{0}`, which clang flags on scalars (-Wbraced-scalar-init). */ -static const char* hoisted_zero_init(Type* t, const char* c_type) { + * report). A declaration without a value (`long x`, `int[3] k`, `P p`) + * starts at its type's zero value the same way (#3114). Initialized, the + * variable is 0/NULL/{0} and every read is defined. Spelled per kind + * rather than as a universal `{0}`, which clang flags on scalars + * (-Wbraced-scalar-init). */ +static const char* local_zero_init(Type* t, const char* c_type) { if (t) { switch (t->kind) { case TYPE_INT: case TYPE_INT64: case TYPE_UINT64: case TYPE_UINT32: @@ -5560,7 +5562,7 @@ static const char* hoisted_zero_init(Type* t, const char* c_type) { case TYPE_BITSET: case TYPE_BITSTRUCT: /* backing integers */ return " = 0"; case TYPE_ISOLATED: - return hoisted_zero_init(t->element_type, c_type); + return local_zero_init(t->element_type, c_type); case TYPE_F32X4: case TYPE_F64X2: case TYPE_I32X4: case TYPE_I64X2: case TYPE_I16X8: case TYPE_F32X8: case TYPE_I32X8: return " = {0}"; /* #2146: a vector, not a scalar 0 */ @@ -5568,6 +5570,8 @@ static const char* hoisted_zero_init(Type* t, const char* c_type) { return " = NULL"; case TYPE_FUNCTION: return t->is_fnptr ? " = NULL" : " = {0}"; + case TYPE_ARRAY: + return " = {0}"; /* a sized array, or a slice's AetherSlice */ default: break; } @@ -5627,7 +5631,7 @@ static void emit_hoisted_local_decl(CodeGenerator* gen, Type* var_type, } const char* c_type = get_c_type(var_type); fprintf(gen->output, "%s %s%s;", volatile_decl_type(vq, c_type), name, - hoisted_zero_init(var_type, c_type)); + local_zero_init(var_type, c_type)); /* A hoisted local nothing reads (`_ = f()` in a loop) is unused. */ if (codegen_local_unread(gen, name)) fprintf(gen->output, " (void)%s;", name); fprintf(gen->output, "\n"); @@ -9943,6 +9947,25 @@ static void generate_statement_body(CodeGenerator* gen, ASTNode* stmt) { strcmp(stmt->children[0]->value, stmt->value) == 0) { break; } + /* #3114: a declaration without a value of a variable + * declared already (hoisted out of the loop or branch + * it is written in) starts it at its type's zero value + * again, as one with that value would: each pass of a + * loop gets a fresh `long acc`. What it holds is + * released first, as any rebind releases it. */ + if (stmt->child_count == 0) { + Type* vt = hoisted ? hoisted : stmt->node_type; + const char* lv = is_promoted_capture(gen, stmt->value) + ? cg_internf("(*%s)", stmt->value) : stmt->value; + own_emit_part(gen, vt, lv, OWN_DROP); + print_indent(gen); + const char* zero = local_zero_init(vt, get_c_type(vt)); + if (strcmp(zero, " = {0}") == 0) + fprintf(gen->output, "memset((void*)&%s, 0, sizeof(%s));\n", lv, lv); + else + fprintf(gen->output, "%s%s;\n", lv, zero); + break; + } /* #2289: an array literal bound to a fixed-size array * that is already declared -- hoisted out of the loop * or branch it is written in, or bound earlier -- is @@ -10471,9 +10494,12 @@ static void generate_statement_body(CodeGenerator* gen, ASTNode* stmt) { } } - /* #2618: an owning array starts empty, so its exit - * frees nothing it did not take. */ - if (decl_str_array && stmt->child_count == 0) fprintf(gen->output, " = {0}"); + /* #3114: a declaration without a value starts at its + * type's zero value (an owning array starts empty, so + * its exit frees nothing it did not take, #2618). */ + if (stmt->child_count == 0) + fputs(local_zero_init(stmt->node_type, get_c_type(stmt->node_type)), + gen->output); fprintf(gen->output, ";\n"); unwind_track_release(gen); if (decl_array_src) { diff --git a/docs/language-reference.md b/docs/language-reference.md index f9db9d91b..46f510464 100644 --- a/docs/language-reference.md +++ b/docs/language-reference.md @@ -507,6 +507,32 @@ float temperature = 98.6 Variables are inferred from their initialization or usage context. +**A local declared without a value starts at its type's zero value**: `0` for +a number, `false`, `null` for a string, a pointer or a closure, `none` for an +optional, the empty slice, an array of zeros, and a struct whose every field +is its zero. `long x`, `int[3] k` and `P p` are `x = 0`, three zeros and a +`P` of zeros, so a read before the first write is defined, and an element a +filling loop misses is 0. In a loop body each pass declares it afresh, as a +declaration with a value binds it afresh: what an owning one held is released +and it is zero again (#3114). + +```aether,run +struct P { + a: long + w: long[4] +} + +main() { + long x + int[3] k + P p + println("${x + k[1] + p.a + p.w[2]}") +} +``` +```output +0 +``` + **`let` / `var` are optional keywords**, accepted but not required. Bare Python-style assignment is the canonical form and is what most stdlib code uses. `let x = 10` and `var x = 10` parse identically to `x = 10`. There is no semantic distinction between `let` and `var` Aether is not Rust; mutability is a property of the binding's later use, not its declaration. There is no `mut`: `let mut x = 0` is an error, because a `let` must bind something and `mut` is not a keyword the language has. A typed declaration is written without `let` (`int n = 0`, `Pair p`), so two names after `let` is always a mistake. **Semicolons are optional.** Aether parses end-of-line as a statement terminator. The samples above use no semicolons; older samples in this doc may show `;` for clarity. Either is fine, after any statement: `if x { a } ; b` is `if x { a }` then `b`. diff --git a/new_changelogs/20261022T154318Z-fixed-3114-declared-local-zero.md b/new_changelogs/20261022T154318Z-fixed-3114-declared-local-zero.md new file mode 100644 index 000000000..87245776c --- /dev/null +++ b/new_changelogs/20261022T154318Z-fixed-3114-declared-local-zero.md @@ -0,0 +1,9 @@ +- **A local declared without a value starts at its type's zero value.** + `long x`, `int[3] k` and `P p` were `int64_t x; int k[3]; P p;` in C, so + reading one before writing it read whatever its stack slot held, with no + diagnostic. Each is now its zero value (0, false, null, none, the empty + slice, an array of zeros, a struct of zeros), in a function, a closure + and an actor's handler, and in a loop body each pass starts it at zero + again, releasing what an owning one held. Measured on BLAKE2b and + ChaCha20, whose block functions declare 16-element arrays this way: no + change outside the run-to-run noise (#3114). diff --git a/tests/regression/test_declared_local_starts_at_zero.ae b/tests/regression/test_declared_local_starts_at_zero.ae new file mode 100644 index 000000000..196f8d0a8 --- /dev/null +++ b/tests/regression/test_declared_local_starts_at_zero.ae @@ -0,0 +1,135 @@ +// Regression (#3114): a local declared without a value starts at its type's +// zero value: 0, false, null, an all-zero array, a struct with every field +// zero. +// +// The generated C was `int64_t x; int k[3]; P p;`, so reading one before +// writing it read whatever its stack slot held, with no diagnostic. Each +// shape below is read before it is written: a scalar, an array, a struct +// with an array field, in a function, a closure and an actor's handler. In +// a loop body each pass declares it afresh, as a declaration with a value +// binds it afresh, and what an owning one held is released first. +import std.string + +extern exit(code: int) + +var handler_sum = -1 + +fn check(cond: bool, msg: string) { + if !cond { + println("FAIL #3114: ${msg}") + exit(1) + } +} + +struct P { + a: long + w: long[4] +} + +struct Named { + name: string + n: int +} + +message Go {} + +actor Reader { + receive { + Go() -> { + long hx + int[3] hk + P hp + handler_sum = hx + hk[2] + hp.a + hp.w[3] + } + } +} + +// The issue's function: every value it reads is a declared local's. +g() -> long { + long x + int[3] k + P p + return x + k[1] + p.a + p.w[2] +} + +kinds() -> string { + float f + bool b + byte y + ptr q + string s + fn c + int? o + long[] sl + return "${f} ${b} ${y} ${q == null} ${s == null} ${c == null} ${o == none} ${sl.len}" +} + +// A fill that misses an element leaves it zero. +partial_fill() -> long { + long[8] v + i = 0 + while i < 8 { + if i != 5 { + v[i] = 9 + } + i = i + 1 + } + return v[5] +} + +per_pass() -> long { + long total = 0 + n = 0 + while n < 3 { + long acc + int[2] seen + P p + acc = acc + 5 + seen[0] = seen[0] + 1 + p.w[1] = p.w[1] + 2 + total = total + acc + seen[0] + p.w[1] + n = n + 1 + } + return total +} + +owning_per_pass(piece: string) -> int { + fresh = 0 + n = 0 + while n < 3 { + string s + Named nm + fn c + string[2] a + if s == null && nm.name == null && nm.n == 0 && c == null && a[1] == null { + fresh = fresh + 1 + } + s = string.concat(piece, "s") + nm = Named { name: string.concat(piece, "n"), n: n + 1 } + k = n + c = || { println("${k}") } + a[1] = string.concat(piece, "a") + n = n + 1 + } + return fresh +} + +main() { + check(g() == 0, "the issue's g(): ${g()}") + check(kinds() == "0 false 0 true true true true 0", "every kind: '${kinds()}'") + check(partial_fill() == 0, "an element a fill missed: ${partial_fill()}") + closure_sum = || { + long cx + int[2] ck + P cp + return cx + ck[1] + cp.w[0] + } + check(closure_sum() == 0, "a closure's locals: ${closure_sum()}") + r = spawn(Reader()) + r ! Go {} + wait_for_idle() + check(handler_sum == 0, "an actor handler's locals: ${handler_sum}") + check(per_pass() == 24, "a loop body's locals each pass: ${per_pass()}, want 24") + check(owning_per_pass(string.concat("p", "")) == 3, "owning locals each pass") + println("PASS #3114: a declared local starts at zero") +} From 0d9737fb57decc8701f348c1bb484374d6c4adee Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Thu, 22 Oct 2026 13:00:49 -0300 Subject: [PATCH 35/50] Tests ask the build which platform they run on, not getenv("OS") (#3119) Eleven tests took the Windows branch on `getenv("OS") == "Windows_NT"`, which cmd.exe and the desktop set. A shell without it (an MSYS2 login shell started with a clean environment, a CI step, a service) ran a Windows build down the POSIX branch, and the test failed. They ask `os.platform()`, which answers from the build. `make check-tests` runs tests/scripts/check_platform_env.py, which fails on a `getenv("OS")` outside a comment in tests, std or contrib, whichever spelling calls it, with unit tests of its own (test_check_platform_env.py). Reverting one of the eleven makes it fail, naming the line. --- Makefile | 8 ++- ...9Z-fixed-3119-tests-platform-from-build.md | 7 +++ std/path/test_path_clean_separator.ae | 2 +- tests/regression/test_fs_realpath.ae | 2 +- .../test_fs_stat_kind_socket_fifo.ae | 2 +- tests/regression/test_fs_symlink.ae | 2 +- tests/regression/test_glob_dir_prefix.ae | 2 +- tests/regression/test_os_cwd.ae | 2 +- tests/regression/test_os_run_full.ae | 2 +- tests/regression/test_run_capture_status.ae | 2 +- tests/scripts/check_platform_env.py | 58 +++++++++++++++++++ tests/scripts/test_check_platform_env.py | 40 +++++++++++++ tests/syntax/test_fs_stdlib_bundle.ae | 3 +- tests/syntax/test_os_run.ae | 2 +- tests/syntax/test_os_which.ae | 3 +- 15 files changed, 121 insertions(+), 16 deletions(-) create mode 100644 new_changelogs/20261022T160049Z-fixed-3119-tests-platform-from-build.md create mode 100644 tests/scripts/check_platform_env.py create mode 100644 tests/scripts/test_check_platform_env.py diff --git a/Makefile b/Makefile index 5935aa9d5..ce93e62b0 100644 --- a/Makefile +++ b/Makefile @@ -2804,7 +2804,7 @@ help: @echo " make test-all - Run both C and .ae tests" @echo " make check-standalone - Compile every standalone C main (benches, demos)" @echo " make check-docs - Compile the documentation's complete examples" - @echo " make check-tests - The test suite can fail: verdicts reach the exit code, prune list is consistent, every std module has tests, listeners bind loopback, shell tests end background programs through server_jobs.sh, test programs write temp files under names of their own" + @echo " make check-tests - The test suite can fail: verdicts reach the exit code, prune list is consistent, every std module has tests, listeners bind loopback, shell tests end background programs through server_jobs.sh, test programs write temp files under names of their own and ask the build for the platform" @echo " make check-extern-widths - Every extern bound to C in this tree agrees with the C definition on each width; no plain C long in a bound signature" @echo " make check-raw-calls - No std or contrib file calls a raw C binding another module declares" @echo " make check-contrib-modules - Type-check every non-host contrib module" @@ -3322,7 +3322,7 @@ test-optional: compiler ae stdlib check-tests: @echo "===================================" - @echo " test suite (verdicts, prune list, loopback listeners, shell kills, temp names)" + @echo " test suite (verdicts, prune list, loopback listeners, shell kills, temp names, platform)" @echo "===================================" @py=""; \ for cand in python3 python "py -3"; do \ @@ -3341,7 +3341,9 @@ check-tests: $$py tests/scripts/test_check_shell_tmp.py && \ $$py tests/scripts/check_shell_tmp.py && \ $$py tests/scripts/test_check_ae_tmp.py && \ - $$py tests/scripts/check_ae_tmp.py; \ + $$py tests/scripts/check_ae_tmp.py && \ + $$py tests/scripts/test_check_platform_env.py && \ + $$py tests/scripts/check_platform_env.py; \ else \ echo " [SKIP] test suite checks — no working Python found"; \ echo " (checked on the Linux/macOS legs, which run the same target)"; \ diff --git a/new_changelogs/20261022T160049Z-fixed-3119-tests-platform-from-build.md b/new_changelogs/20261022T160049Z-fixed-3119-tests-platform-from-build.md new file mode 100644 index 000000000..560a0a6ad --- /dev/null +++ b/new_changelogs/20261022T160049Z-fixed-3119-tests-platform-from-build.md @@ -0,0 +1,7 @@ +- **Tests ask the build which platform they run on.** Eleven tests decided + they ran on Windows from `getenv("OS") == "Windows_NT"`, a variable + cmd.exe and the desktop set, so from a shell without it (an MSYS2 login + shell with a clean environment, a CI step, a service) a Windows build + took the POSIX branch and failed. They ask `os.platform()`, and + `make check-tests` fails on a `getenv("OS")` in tests, std or contrib + (#3119). diff --git a/std/path/test_path_clean_separator.ae b/std/path/test_path_clean_separator.ae index beb37674f..a77fa1588 100644 --- a/std/path/test_path_clean_separator.ae +++ b/std/path/test_path_clean_separator.ae @@ -68,7 +68,7 @@ main() { // drive letters. These assertions only run on the Windows CI job, which is // the only place the branch is live; on POSIX a backslash is an ordinary // filename byte and must NOT be treated as a separator. - if os.getenv("OS") == "Windows_NT" { + if os.platform() == "windows" { if path.clean("C:\\a\\b\\..\\c") != "C:/a/c" { println("FAIL windows clean did not collapse .. in a backslash path") exit(1) diff --git a/tests/regression/test_fs_realpath.ae b/tests/regression/test_fs_realpath.ae index a4cbd2772..5cda33ca8 100644 --- a/tests/regression/test_fs_realpath.ae +++ b/tests/regression/test_fs_realpath.ae @@ -30,7 +30,7 @@ slashes(path: string) -> string { main() { println("=== fs.realpath regression ===") - windows = os_getenv("OS") == "Windows_NT" + windows = os.platform() == "windows" // A name of this process's own, so a second sweep running beside this // one does not delete the directory mid-test (#2771). name = "aether_fs_realpath_test_${os.getpid()}" diff --git a/tests/regression/test_fs_stat_kind_socket_fifo.ae b/tests/regression/test_fs_stat_kind_socket_fifo.ae index 26b639fcf..20f9481c4 100644 --- a/tests/regression/test_fs_stat_kind_socket_fifo.ae +++ b/tests/regression/test_fs_stat_kind_socket_fifo.ae @@ -26,7 +26,7 @@ main() { // POSIX-only: mkfifo, /tmp, and a numeric uid don't exist on Windows, // where sockets/FIFOs stay STAT_KIND_OTHER and user_id() returns -1 by // design. Skip gracefully rather than crash the Windows matrix. - if os.getenv("OS") == "Windows_NT" { + if os.platform() == "windows" { println("SKIP fs_stat_kind_socket_fifo: POSIX-only (socket/fifo kinds + uid)") return } diff --git a/tests/regression/test_fs_symlink.ae b/tests/regression/test_fs_symlink.ae index bf0507637..4cf834aba 100644 --- a/tests/regression/test_fs_symlink.ae +++ b/tests/regression/test_fs_symlink.ae @@ -33,7 +33,7 @@ check(label: string, ok: int) { main() { println("=== fs symlinks ===") - windows = os_getenv("OS") == "Windows_NT" + windows = os.platform() == "windows" base = "${os.temp_dir()}/aether_fs_symlink_test_${os.getpid()}" fs.remove_tree(base) fs.create_dir(base) diff --git a/tests/regression/test_glob_dir_prefix.ae b/tests/regression/test_glob_dir_prefix.ae index 880b40119..6065ee174 100644 --- a/tests/regression/test_glob_dir_prefix.ae +++ b/tests/regression/test_glob_dir_prefix.ae @@ -14,7 +14,7 @@ extern exit(code: int) extern system(cmd: string) -> int main() { - if os.getenv("OS") == "Windows_NT" { + if os.platform() == "windows" { // The bug is Windows-specific; the assertion here is the cross-platform // contract. On the Windows CI runner the same test binary would need a // Windows shell to set up the fixture, so skip and rely on the POSIX diff --git a/tests/regression/test_os_cwd.ae b/tests/regression/test_os_cwd.ae index abc0ebb7a..2728f4215 100644 --- a/tests/regression/test_os_cwd.ae +++ b/tests/regression/test_os_cwd.ae @@ -23,7 +23,7 @@ main() { // chdir to the root (no symlink indirection, stable everywhere) and // confirm getcwd reflects it. root = "/" - if os_getenv("OS") == "Windows_NT" { + if os.platform() == "windows" { root = "${string.substring(start, 0, 2)}\\" } err = os.chdir(root) diff --git a/tests/regression/test_os_run_full.ae b/tests/regression/test_os_run_full.ae index f4b9c1382..734e1afc5 100644 --- a/tests/regression/test_os_run_full.ae +++ b/tests/regression/test_os_run_full.ae @@ -172,7 +172,7 @@ main() { // platform's documented contract. av5 = list.new() out5, err5, code5, spawn5 = os.run_full("/no/such/binary", av5, null, "") - if os_getenv("OS") == "Windows_NT" { + if os.platform() == "windows" { if code5 != -1 || spawn5 != "program not found" { println(" FAIL: missing binary exit=${code5} spawn=${spawn5}, expected -1 / program not found") exit(1) diff --git a/tests/regression/test_run_capture_status.ae b/tests/regression/test_run_capture_status.ae index bd7569353..0ebe3301d 100644 --- a/tests/regression/test_run_capture_status.ae +++ b/tests/regression/test_run_capture_status.ae @@ -33,7 +33,7 @@ extern exit(code: int) main() { println("=== os.run_capture status ===") - windows = os_getenv("OS") == "Windows_NT" + windows = os.platform() == "windows" shell = "/bin/sh" flag = "-c" if windows { diff --git a/tests/scripts/check_platform_env.py b/tests/scripts/check_platform_env.py new file mode 100644 index 000000000..45aad5849 --- /dev/null +++ b/tests/scripts/check_platform_env.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Programs ask the build which platform they run on, not the environment (#3119). + +`getenv("OS") == "Windows_NT"` reads a variable cmd.exe and the Windows +desktop set. A shell that does not carry it (an MSYS2 login shell started +without the user's environment, a CI step or a service with a clean +environment) takes the POSIX branch of a program built for Windows, and the +test fails. `os.platform()` answers from the build. + +This gate reads every .ae file under tests/, std/ and contrib/ and fails on a +`getenv("OS")` outside a comment, whichever spelling calls it (`os.getenv`, +`os_getenv`, a bare `getenv`). + +Run from the repository root; exit 1 listing every offender. +""" +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from check_test_verdicts import ROOT, strip_comments # noqa: E402 + +# `os_getenv` too: `_` is a word character, so `\b` would not split it off. +OS_FROM_ENV = re.compile(r'(? Date: Thu, 22 Oct 2026 13:06:29 -0300 Subject: [PATCH 36/50] A no-threads stdlib built at -O0 links: http_park_resume is threaded only (#3121) http_park_resume was compiled in every build and calls http_pool_submit_conn, which aether_http_pool.c defines only with threads. Its one caller is under AETHER_HAS_THREADS, so at -O2 GCC dropped the unused static and its reference; at -O0 it kept both, and a no-threads stdlib's shared runtime did not link on Windows (an unresolved symbol in the Linux .so). It is compiled under the condition its caller is. Test: tests/integration/no_threads_refs compiles every libaether source (the Makefile's own lists and flags) at -O0 with and without AETHER_NO_THREADING and fails on a symbol a no-threads object needs that only the threaded objects define. Under GCC (ubuntu 24.04) on the old tree it fails naming http_pool_submit_conn; clang drops unused statics at any level, so on clang it passes either way. --- ...2T160629Z-fixed-3121-no-threads-O0-link.md | 9 +++ std/net/aether_http_server.c | 4 +- .../no_threads_refs/test_no_threads_refs.sh | 69 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 new_changelogs/20261022T160629Z-fixed-3121-no-threads-O0-link.md create mode 100644 tests/integration/no_threads_refs/test_no_threads_refs.sh diff --git a/new_changelogs/20261022T160629Z-fixed-3121-no-threads-O0-link.md b/new_changelogs/20261022T160629Z-fixed-3121-no-threads-O0-link.md new file mode 100644 index 000000000..5b3df04ea --- /dev/null +++ b/new_changelogs/20261022T160629Z-fixed-3121-no-threads-O0-link.md @@ -0,0 +1,9 @@ +- **A no-threads stdlib built at -O0 links.** `http_park_resume` was + compiled in every build and calls `http_pool_submit_conn`, which only a + threaded build defines. At -O2 GCC dropped the unused function and the + reference with it; at -O0 `make stdlib EXTRA_CFLAGS='-DAETHER_NO_THREADING + -O0'` failed to link the shared runtime on Windows and left an unresolved + symbol in the Linux one. It is compiled where its only caller is, and a + test compiles every libaether source at -O0 in both modes and fails on any + symbol a no-threads object needs that only threaded objects define + (#3121). diff --git a/std/net/aether_http_server.c b/std/net/aether_http_server.c index 65a34455c..2896c6a03 100644 --- a/std/net/aether_http_server.c +++ b/std/net/aether_http_server.c @@ -5484,12 +5484,14 @@ void http_server_drain_connection(HttpServer* server, int client_fd) { conn_serve(server, conn); } +#if AETHER_HAS_THREADS /* The parking lot woke a connection: put it back on a worker rather than * serving it on the poller thread, which must stay free for the other parked - * connections. */ + * connections. Threaded only, as its caller and the pool are (#3121). */ static void http_park_resume(HttpServer* server, HttpConn* conn) { http_pool_submit_conn((HttpConnectionPool*)server->conn_pool, conn); } +#endif static void handle_client_connection(HttpServer* server, int client_fd) { /* Thread-pool path — defers to the public drain helper so the diff --git a/tests/integration/no_threads_refs/test_no_threads_refs.sh b/tests/integration/no_threads_refs/test_no_threads_refs.sh new file mode 100644 index 000000000..6f2764dd5 --- /dev/null +++ b/tests/integration/no_threads_refs/test_no_threads_refs.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# Regression (#3121): a no-threads libaether references nothing that only a +# threaded one defines, whatever the optimizer keeps. +# +# std/net/aether_http_server.c compiled `http_park_resume` in every build, +# and it calls `http_pool_submit_conn`, which aether_http_pool.c defines only +# with threads. Its one caller is threaded, so at -O2 GCC dropped the unused +# static and its reference with it; at -O0 GCC keeps both, and a no-threads +# stdlib's shared runtime did not link on Windows (an unresolved symbol in a +# Linux .so). Each libaether source, the Makefile's own list and flags, is +# compiled at -O0 with and without AETHER_NO_THREADING, and no symbol a +# no-threads object needs may be one only the threaded objects define. Clang +# drops an unused static at any level, so under clang this passes either +# way; the GCC legs are the ones it guards. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +cd "$ROOT" || exit 1 +CC="${CC:-cc}" + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +make_var() { + printf 'ae-print-var:\n\t@echo $(%s)\n' "$1" | make -s -f Makefile -f - ae-print-var +} +SRCS="$(make_var RUNTIME_SRC) $(make_var STD_SRC) $(make_var STD_REACTOR_SRC) $(make_var COLLECTIONS_SRC)" +CFLAGS_REQ="$(make_var AETHER_REQUIRED_CFLAGS)" +if [ -z "$SRCS" ] || [ -z "$CFLAGS_REQ" ]; then + echo " [FAIL] no_threads_refs: could not read the libaether sources from the Makefile" + exit 1 +fi + +# $1 = mode (threads | coop), $2 = extra flag +compile_all() { + mkdir -p "$TMPDIR/$1" + for src in $SRCS; do echo "$src"; done | + xargs -P 4 -I{} sh -c 'o="$0/$(echo "$1" | tr / _).o"; $2 -c -O0 $3 $4 "$1" -o "$o" 2>>"$0.err" || echo "$1" >>"$0.failed"' \ + "$TMPDIR/$1" {} "$CC" "$2" "$CFLAGS_REQ" + if [ -s "$TMPDIR/$1.failed" ]; then + echo " [FAIL] no_threads_refs: $1 build did not compile:" + sed 's/^/ /' "$TMPDIR/$1.failed" | head -5 + grep -m 5 'error' "$TMPDIR/$1.err" | sed 's/^/ /' + exit 1 + fi +} + +# Defined (D) and undefined (U) external symbols of the objects in $1. +symbols() { + for o in "$TMPDIR/$1"/*.o; do nm -g "$o"; done | + awk '$1 == "U" { print "U " $2; next } NF == 3 && $2 != "U" { print "D " $3 }' | sort -u +} + +compile_all threads "" +compile_all coop "-DAETHER_NO_THREADING" + +symbols threads | sed -n 's/^D //p' | sort -u > "$TMPDIR/threads.defined" +symbols coop >"$TMPDIR/coop.syms" +sed -n 's/^D //p' "$TMPDIR/coop.syms" | sort -u > "$TMPDIR/coop.defined" +sed -n 's/^U //p' "$TMPDIR/coop.syms" | sort -u > "$TMPDIR/coop.needed" +comm -23 "$TMPDIR/threads.defined" "$TMPDIR/coop.defined" > "$TMPDIR/threads.only" +comm -12 "$TMPDIR/coop.needed" "$TMPDIR/threads.only" > "$TMPDIR/dangling" + +if [ -s "$TMPDIR/dangling" ]; then + echo " [FAIL] no_threads_refs: a no-threads build references symbols only a threaded build defines:" + sed 's/^/ /' "$TMPDIR/dangling" + exit 1 +fi +echo " [PASS] no_threads_refs: a no-threads libaether at -O0 needs nothing only threads define" From 74d9cccffc2262aab8ab22706c341bd38a9bb5a0 Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Thu, 22 Oct 2026 13:12:51 -0300 Subject: [PATCH 37/50] One Windows thread-type layout with and without AETHER_NO_THREADING (#3120) aether_thread.h took its no-threads branch first on every platform, and on Windows that branch's pthread.h test matched nothing MinGW defines, so a -DAETHER_NO_THREADING build got the bare-metal `typedef int` thread, mutex and condition while a threaded build has the Win32-shaped HANDLE, CRITICAL_SECTION and CONDITION_VARIABLE. A cooperative libaether is built with the flag and a program built against it by `ae` is not, so the two disagreed: the four ActorBase fields after `thread` sat 4 bytes apart (the rest agreed only through padding), ActorReplySlot was 40 bytes against 80, and Scheduler differed from `thread` on. The Windows types are defined once, ahead of both branches; the no-threads branch uses them on Windows, and its static initializers are `{0}` there. Test: tests/integration/thread_types_layout builds layout_probe.c (sizes and offsets of ActorBase, ActorReplySlot and Scheduler, the shared structs that hold thread types) with and without the flag and compares. Under MinGW (x86_64-w64-mingw32-gcc 13-win32, the values read from the generated assembly) 15 of the 35 values differ with the old header, none with the new one; every libaether source still compiles for Windows both ways. --- ...51Z-fixed-3120-windows-actorbase-layout.md | 9 +++ runtime/utils/aether_thread.h | 61 +++++++++++-------- .../thread_types_layout/layout_probe.c | 51 ++++++++++++++++ .../test_thread_types_layout.sh | 42 +++++++++++++ 4 files changed, 139 insertions(+), 24 deletions(-) create mode 100644 new_changelogs/20261022T161251Z-fixed-3120-windows-actorbase-layout.md create mode 100644 tests/integration/thread_types_layout/layout_probe.c create mode 100644 tests/integration/thread_types_layout/test_thread_types_layout.sh diff --git a/new_changelogs/20261022T161251Z-fixed-3120-windows-actorbase-layout.md b/new_changelogs/20261022T161251Z-fixed-3120-windows-actorbase-layout.md new file mode 100644 index 000000000..cd8da2ff9 --- /dev/null +++ b/new_changelogs/20261022T161251Z-fixed-3120-windows-actorbase-layout.md @@ -0,0 +1,9 @@ +- **A cooperative libaether and its programs agree on ActorBase on + Windows.** With `-DAETHER_NO_THREADING` (a cooperative libaether, `make + ci-coop`'s setup) aether_thread.h fell to bare-metal `int` thread types + on Windows, while a program built against it by `ae` has the Win32-shaped + ones. The four ActorBase fields after `thread` sat 4 bytes apart in the + two and the rest agreed only through padding, ActorReplySlot was 40 bytes + in one and 80 in the other, and Scheduler differed from `thread` on. The + Windows types are now one definition for both builds, and a test compares + those structs' layouts built both ways (#3120). diff --git a/runtime/utils/aether_thread.h b/runtime/utils/aether_thread.h index 7f3285a1d..0f6106899 100644 --- a/runtime/utils/aether_thread.h +++ b/runtime/utils/aether_thread.h @@ -21,6 +21,34 @@ #include "../config/aether_optimization_config.h" +#if defined(_WIN32) +// ---- Windows types -------------------------------------------------------- +// Spelled without , with the layout of the Win32 objects they +// stand for (checked against them in the threaded path, where windows.h is +// included). Threaded or not: a program and the libaether it links against +// share structs that hold a thread, a mutex and a condition (ActorBase), so +// their layout must not depend on AETHER_NO_THREADING, which a cooperative +// libaether is built with and a program is not (#3120). +#include // uintptr_t + +typedef void* pthread_t; // HANDLE +typedef struct { // CRITICAL_SECTION + void* debug_info; + long lock_count; + long recursion_count; + void* owning_thread; + void* lock_semaphore; + uintptr_t spin_count; +} pthread_mutex_t; +typedef struct { void* ptr; } pthread_cond_t; // CONDITION_VARIABLE +typedef unsigned long pthread_key_t; // DWORD: an FLS index + +// Attribute types — Windows has no equivalents; these are accepted but ignored +typedef int pthread_attr_t; +typedef int pthread_mutexattr_t; +typedef int pthread_condattr_t; +#endif + #if !AETHER_HAS_THREADS // ============================================================ @@ -37,7 +65,7 @@ // - Linux/macOS: (full POSIX) // - Emscripten: (stub types when threads disabled) // - ARM newlib: via (from ) -// - Windows: handled by the #elif _WIN32 path below (not here) +// - Windows: the Win32-shaped types above, a threaded build's (#3120) // // We only provide our own typedefs for truly bare-metal environments // where no system headers define pthread types at all. @@ -50,7 +78,7 @@ defined(__APPLE__) || defined(__EMSCRIPTEN__) || defined(__wasi__) || \ defined(_NEWLIB_VERSION) #include -#else +#elif !defined(_WIN32) // Bare-metal without newlib: define minimal types typedef int pthread_mutex_t; typedef int pthread_cond_t; @@ -127,8 +155,12 @@ static inline int aether_nop_join(pthread_t t, void** r) { (void)t; if (r) *r = // needs the macro to exist too — runtime/sandbox/aether_audit.c does exactly // that, and without this a threadless build fails with "use of undeclared // identifier 'PTHREAD_MUTEX_INITIALIZER'" (#1655, first hit on wasm32-wasi). -// pthread_mutex_t is an int here and every lock operation is a no-op, so any -// value is correct; 0 matches the "unlocked" reading of the typedef. +// Every lock operation is a no-op, so any value is correct: 0 for the int +// typedefs, a zeroed object for the Win32-shaped structs. +#if defined(_WIN32) +# define PTHREAD_MUTEX_INITIALIZER {0} +# define PTHREAD_COND_INITIALIZER {0} +#endif #ifndef PTHREAD_MUTEX_INITIALIZER # define PTHREAD_MUTEX_INITIALIZER 0 #endif @@ -207,9 +239,7 @@ static inline int aether_nop_join(pthread_t t, void** r) { (void)t; if (r) *r = # define EBUSY 16 #endif -// ---- Types --------------------------------------------------------------- -// Spelled without , with the layout of the Win32 objects they -// stand for (checked against them below, where windows.h is included). +// The types are the Win32-shaped ones defined at the top of this file. // A program's generated C includes this header for the types alone: the // actor runtime's structs carry a thread, a mutex and a condition. On MinGW // brings , and with it every AVX-512 and AVX10 @@ -217,23 +247,6 @@ static inline int aether_nop_join(pthread_t t, void** r) { (void)t; if (r) *r = // translation unit defines AETHER_THREAD_TYPES_ONLY and stops here. The // runtime, which calls the functions, includes the rest. -typedef void* pthread_t; // HANDLE -typedef struct { // CRITICAL_SECTION - void* debug_info; - long lock_count; - long recursion_count; - void* owning_thread; - void* lock_semaphore; - uintptr_t spin_count; -} pthread_mutex_t; -typedef struct { void* ptr; } pthread_cond_t; // CONDITION_VARIABLE -typedef unsigned long pthread_key_t; // DWORD: an FLS index - -// Attribute types — Windows has no equivalents; these are accepted but ignored -typedef int pthread_attr_t; -typedef int pthread_mutexattr_t; -typedef int pthread_condattr_t; - #ifndef AETHER_THREAD_TYPES_ONLY #ifndef WIN32_LEAN_AND_MEAN diff --git a/tests/integration/thread_types_layout/layout_probe.c b/tests/integration/thread_types_layout/layout_probe.c new file mode 100644 index 000000000..36e7826d7 --- /dev/null +++ b/tests/integration/thread_types_layout/layout_probe.c @@ -0,0 +1,51 @@ +/* Prints the layout of the runtime structs a program and libaether share + * that hold a thread, a mutex or a condition (#3120). Built as a program's + * C is: on Windows with AETHER_THREAD_TYPES_ONLY, the types alone. */ +#include +#include +#ifdef _WIN32 +#define AETHER_THREAD_TYPES_ONLY 1 +#endif +#include "multicore_scheduler.h" + +#define SIZE(S) printf("sizeof(" #S ") %lu\n", (unsigned long)sizeof(S)) +#define AT(S, f) printf(#S "." #f " %lu\n", (unsigned long)offsetof(S, f)) + +int main(void) { + SIZE(ActorBase); + AT(ActorBase, active); + AT(ActorBase, id); + AT(ActorBase, mailbox); + AT(ActorBase, step); + AT(ActorBase, thread); + AT(ActorBase, auto_process); + AT(ActorBase, assigned_core); + AT(ActorBase, migrate_to); + AT(ActorBase, main_thread_only); + AT(ActorBase, spsc_queue); + AT(ActorBase, reply_slot); + AT(ActorBase, step_lock); + AT(ActorBase, timeout_ns); + AT(ActorBase, last_activity_ns); + AT(ActorBase, dead); + AT(ActorBase, alloc_size); + AT(ActorBase, destroy_state); + AT(ActorBase, scheduler_owned); + AT(ActorBase, inbox); + AT(ActorBase, thread_parked); + AT(ActorBase, inflight); + SIZE(ActorReplySlot); + AT(ActorReplySlot, mutex); + AT(ActorReplySlot, cond); + AT(ActorReplySlot, refcount); + SIZE(Scheduler); + AT(Scheduler, thread); + AT(Scheduler, actor_table); + AT(Scheduler, foreign_lock); + AT(Scheduler, running); + AT(Scheduler, park_mutex); + AT(Scheduler, park_cond); + AT(Scheduler, parked); + AT(Scheduler, io_registered_count); + return 0; +} diff --git a/tests/integration/thread_types_layout/test_thread_types_layout.sh b/tests/integration/thread_types_layout/test_thread_types_layout.sh new file mode 100644 index 000000000..b865021ec --- /dev/null +++ b/tests/integration/thread_types_layout/test_thread_types_layout.sh @@ -0,0 +1,42 @@ +#!/bin/sh +# Regression (#3120): the runtime structs a program and libaether share have +# one layout with and without AETHER_NO_THREADING. +# +# A cooperative libaether is built with -DAETHER_NO_THREADING and a program +# built against it by `ae` is not. On Windows the no-threads branch of +# aether_thread.h fell to the bare-metal `int` thread types while the +# threaded one has the Win32-shaped ones, so every ActorBase field after +# `thread` sat 8 bytes apart in the two, and a cooperative program died at +# exit freeing `spsc_queue` from the wrong offset. layout_probe.c prints the +# sizes and offsets of those structs; built both ways, the two must agree. +# On Linux and macOS both ways include , so the Windows legs are +# the ones this guards. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +CC="${CC:-cc}" + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +INC="-I$ROOT/runtime -I$ROOT/runtime/scheduler -I$ROOT/runtime/actors -I$ROOT/runtime/utils -I$ROOT/runtime/memory -I$ROOT/runtime/config -I$ROOT/std" + +# $1 = name, $2 = extra flag +probe() { + if ! $CC -O2 $INC $2 "$SCRIPT_DIR/layout_probe.c" -o "$TMPDIR/$1" >"$TMPDIR/$1.build" 2>&1; then + echo " [FAIL] thread_types_layout: the probe did not build ($1)" + sed 's/^/ /' "$TMPDIR/$1.build" | head -8 + exit 1 + fi + "$TMPDIR/$1" >"$TMPDIR/$1.layout" +} + +probe threads "" +probe coop "-DAETHER_NO_THREADING" + +if ! cmp -s "$TMPDIR/threads.layout" "$TMPDIR/coop.layout"; then + echo " [FAIL] thread_types_layout: layouts differ (threads | AETHER_NO_THREADING):" + paste "$TMPDIR/threads.layout" "$TMPDIR/coop.layout" | awk -F'\t' '$1 != $2' | sed 's/^/ /' + exit 1 +fi +echo " [PASS] thread_types_layout: shared runtime structs agree with and without AETHER_NO_THREADING" From 0e1d826a407d7bd42396633023779789c7d12e17 Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Thu, 22 Oct 2026 13:38:44 -0300 Subject: [PATCH 38/50] Hoisting reads each name from one walk of its body, at any number of names (#3083) The checker and codegen asked, once per name bound in a function's branches, questions that each walked the whole body: the bindings' join (hoist_join_type, hoist_numeric_join), the type a binding writes (hoist_written_type), the names a branch hoists and their first binding (hoist_if_branch_candidates, hoist_common_decl_names), and whether the function reads the name (codegen_local_unread). Codegen's is_var_declared and declared_var_type scanned every declared local, and the checker's unused-variable pass compared each reference with each local, marking a local rebound n times n times per read. - hoist.c answers from per-body and per-statement indexes, each built by one walk and kept, by node, until hoist_cache_reset (at the start of the checker, and after codegen's renames) or until the node is freed, so a node allocated later at its address is asked afresh; a binding's type, which the checker fills in as it goes, is read when asked. HoistNames replaces the 64-entry name arrays: the names a branch hoists are any number, found by name. - Codegen indexes its declared locals by name (the earliest live slot, cleared as scope exits truncate them) and, per function, the names it reads. - The unused-variable pass finds a reference's locals by name and marks a name's entries once. Before, with 64 names hoisted at most, a 65th bound in an arm and read after it was "Undefined variable". Generated mains (each name bound in both arms of an if and read after it), min of 5 interleaved runs against the previous commit: names before after 1000 0.28 s 0.06 s 3000 2.57 s 0.13 s 6000 16.17 s 0.25 s The C every test, example and std test compiles to is unchanged (1063 programs compared). Test: tests/integration/hoist_scaling (300 names out of one-armed ifs, int/long, null/string and written-type joins, a name rebound 300 times, one unused local warned); before: "Undefined variable 'a16'", the 65th hoisted name. --- compiler/analysis/hoist.c | 554 +++++++++++------- compiler/analysis/hoist.h | 66 ++- compiler/analysis/typechecker.c | 93 +-- compiler/ast.c | 2 + compiler/codegen/codegen.c | 28 +- compiler/codegen/codegen.h | 7 + compiler/codegen/codegen_expr.c | 21 +- compiler/codegen/codegen_stmt.c | 49 +- compiler/codegen/optimizer.c | 3 + ...63844Z-performance-3083-hoisting-linear.md | 10 + .../hoist_scaling/test_hoist_scaling.sh | 79 +++ 11 files changed, 590 insertions(+), 322 deletions(-) create mode 100644 new_changelogs/20261022T163844Z-performance-3083-hoisting-linear.md create mode 100644 tests/integration/hoist_scaling/test_hoist_scaling.sh diff --git a/compiler/analysis/hoist.c b/compiler/analysis/hoist.c index 9cc4f7324..baf0cf3a9 100644 --- a/compiler/analysis/hoist.c +++ b/compiler/analysis/hoist.c @@ -6,51 +6,11 @@ #include "hoist.h" #include "../aether_strmap.h" +#include +#include #include #include -int hoist_direct_decl_names(ASTNode* block, const char** out, int count, int cap) { - if (!block || !out) return count; - for (int i = 0; i < block->child_count; i++) { - ASTNode* child = block->children[i]; - if (!child) continue; - if (child->type == AST_VARIABLE_DECLARATION && child->value && count < cap) { - int already = 0; - for (int j = 0; j < count; j++) { - if (strcmp(out[j], child->value) == 0) { already = 1; break; } - } - if (!already) out[count++] = child->value; - } - } - return count; -} - -ASTNode* hoist_find_decl(ASTNode* block, const char* name) { - if (!block || !name) return NULL; - for (int i = 0; i < block->child_count; i++) { - ASTNode* child = block->children[i]; - if (!child) continue; - if (child->type == AST_VARIABLE_DECLARATION && child->value - && strcmp(child->value, name) == 0) { - return child; - } - } - return NULL; -} - -int hoist_has_identifier_ref(ASTNode* node, const char* name) { - if (!node || !name) return 0; - if (node->type == AST_IDENTIFIER && node->value && - strcmp(node->value, name) == 0) return 1; - /* Any identifier counts as a use; declaration order within a subtree is - * not tracked. Over-eager, and safe: an extra hoisted declaration is a - * zero-initialised local. */ - for (int i = 0; i < node->child_count; i++) { - if (hoist_has_identifier_ref(node->children[i], name)) return 1; - } - return 0; -} - Type* hoist_decl_type(ASTNode* decl) { if (!decl) return NULL; Type* t = decl->node_type; @@ -148,56 +108,9 @@ static void fold_bind(JoinFold* f, const ASTNode* n) { fold_add(f, t, v, written); } -/* The bindings of `name` under `n` (closures excluded, each its own - * function), joined in the order they are written. */ -static void join_walk(ASTNode* n, const char* name, JoinFold* f) { - if (!n) return; - if (n->type == AST_CLOSURE || n->type == AST_QUOTE) return; /* its own function */ - if (n->type == AST_VARIABLE_DECLARATION && n->value && strcmp(n->value, name) == 0) - fold_bind(f, n); - for (int i = 0; i < n->child_count; i++) join_walk(n->children[i], name, f); -} - -static JoinFold fold_of(ASTNode* root, const char* name) { - JoinFold f; - memset(&f, 0, sizeof(f)); - for (int i = 0; root && name && i < root->child_count; i++) - join_walk(root->children[i], name, &f); - return f; -} - -Type* hoist_numeric_join(ASTNode* root, const char* name) { - JoinFold f = fold_of(root, name); - Type* r = fold_result(&f); - fold_free(&f); - return r; -} - -/* The first binding of `name` under `n` (closures excluded) that writes - * its type, of any kind. */ -static const ASTNode* first_written_decl(const ASTNode* n, const char* name) { - if (!n || n->type == AST_CLOSURE || n->type == AST_QUOTE) return NULL; - if (n->type == AST_VARIABLE_DECLARATION && n->value && !n->type_inferred && n->node_type && - n->node_type->kind != TYPE_UNKNOWN && n->node_type->kind != TYPE_VOID && - strcmp(n->value, name) == 0) - return n; - for (int i = 0; i < n->child_count; i++) { - const ASTNode* d = first_written_decl(n->children[i], name); - if (d) return d; - } - return NULL; -} - -Type* hoist_written_type(ASTNode* root, const char* name) { - for (int i = 0; root && name && i < root->child_count; i++) { - const ASTNode* d = first_written_decl(root->children[i], name); - if (d) return clone_type(d->node_type); - } - return NULL; -} - /* #3064: the bindings of one name under a HoistJoins' root, in the order - * join_walk meets them, and what the walk typing them has made of them. + * a walk of the root meets them, and what the walk typing them has made of + * them. * The walk types binds[0..typed) in that order; their parts are final for * it, folded in `head`. A binding not typed yet changes only when the walk * types it, so the parts of binds[typed..count) that `join` read (`rest`, @@ -347,14 +260,159 @@ void hoist_joins_free(HoistJoins* j) { free(j); } -/* Over the inferred bindings of `name` in `n` (closures excluded): sets - * `*strings` when one is a string, `*others` when one is neither a string - * nor `null`. */ -static void null_string_walk(ASTNode* n, const char* name, int* strings, int* others) { - if (!n) return; - if (n->type == AST_CLOSURE || n->type == AST_QUOTE) return; - if (n->type == AST_VARIABLE_DECLARATION && n->value && n->type_inferred && - strcmp(n->value, name) == 0) { +/* ---- #3083: one walk per body or statement ------------------------------ + * The questions below are asked once per name, and a generated body binds + * thousands of names: walking the body for each was quadratic in them. Each + * answer is read from what one walk of the body (or the statement) gathered, + * kept until the next checker or codegen run (hoist_cache_reset). A walk + * gathers the tree's shape, which neither pass changes after codegen's + * renames; a binding's type, which the checker fills in as it goes, is read + * when it is asked for. The answers are kept by node, and a node's go with + * it (hoist_node_freed): one allocated later at its address is a new node. */ + +struct HoistNodeCache { + HoistJoins* binds; /* as a body: its bindings by name */ + HoistNames* branch; /* as a body: hoist_if_branch_hoists */ + HoistNames* common; /* as a statement: hoist_common_hoists */ +}; + +/* Open addressing over the node's address; a removed slot is a tombstone + * until the table next grows. */ +typedef struct { + const ASTNode* node; + struct HoistNodeCache* cache; +} CacheSlot; + +#define CACHE_TOMBSTONE ((const ASTNode*)(uintptr_t)1) + +static CacheSlot* g_cache; +static size_t g_cache_cap; /* a power of two, or 0 */ +static size_t g_cache_used; /* live and tombstone slots */ + +static size_t cache_hash(const ASTNode* n) { + uint64_t h = (uint64_t)(uintptr_t)n; + h ^= h >> 33; + h *= 0xff51afd7ed558ccdULL; + h ^= h >> 33; + return (size_t)h; +} + +static CacheSlot* cache_slot(const ASTNode* n) { + if (!g_cache_cap) return NULL; + for (size_t i = cache_hash(n) & (g_cache_cap - 1);; i = (i + 1) & (g_cache_cap - 1)) { + if (g_cache[i].node == n) return &g_cache[i]; + if (!g_cache[i].node) return NULL; + } +} + +static void names_free(HoistNames* h) { + if (!h) return; + free((void*)h->names); + free(h->decls); + strmap_free(&h->at); + free(h); +} + +static void cache_free(struct HoistNodeCache* c) { + hoist_joins_free(c->binds); + names_free(c->branch); + names_free(c->common); + free(c); +} + +static struct HoistNodeCache* node_cache(ASTNode* n) { + CacheSlot* s = cache_slot(n); + if (s) return s->cache; + if ((g_cache_used + 1) * 2 > g_cache_cap) { + size_t cap = g_cache_cap ? g_cache_cap * 2 : 256; + CacheSlot* grown = (CacheSlot*)calloc(cap, sizeof(CacheSlot)); + if (!grown) { + fprintf(stderr, "Fatal: out of memory in the hoisting cache\n"); + exit(1); + } + size_t used = 0; + for (size_t i = 0; i < g_cache_cap; i++) { + const ASTNode* k = g_cache[i].node; + if (!k || k == CACHE_TOMBSTONE) continue; + size_t j = cache_hash(k) & (cap - 1); + while (grown[j].node) j = (j + 1) & (cap - 1); + grown[j] = g_cache[i]; + used++; + } + free(g_cache); + g_cache = grown; + g_cache_cap = cap; + g_cache_used = used; + } + struct HoistNodeCache* c = (struct HoistNodeCache*)calloc(1, sizeof(*c)); + if (!c) { + fprintf(stderr, "Fatal: out of memory in the hoisting cache\n"); + exit(1); + } + size_t j = cache_hash(n) & (g_cache_cap - 1); + while (g_cache[j].node && g_cache[j].node != CACHE_TOMBSTONE) j = (j + 1) & (g_cache_cap - 1); + if (!g_cache[j].node) g_cache_used++; + g_cache[j].node = n; + g_cache[j].cache = c; + return c; +} + +void hoist_node_freed(const ASTNode* n) { + CacheSlot* s = cache_slot(n); + if (!s) return; + cache_free(s->cache); + s->node = CACHE_TOMBSTONE; + s->cache = NULL; +} + +void hoist_cache_reset(void) { + for (size_t i = 0; i < g_cache_cap; i++) { + if (g_cache[i].node && g_cache[i].node != CACHE_TOMBSTONE) cache_free(g_cache[i].cache); + } + free(g_cache); + g_cache = NULL; + g_cache_cap = 0; + g_cache_used = 0; +} + +/* The bindings of `name` under `root` (closures excluded), in the order a + * walk of the body meets them; NULL for none. */ +static const HoistNameJoin* body_binds(ASTNode* root, const char* name) { + if (!root || !name) return NULL; + struct HoistNodeCache* c = node_cache(root); + if (!c->binds) c->binds = hoist_joins_new(root); + if (!c->binds) return NULL; + return (const HoistNameJoin*)strmap_get(&c->binds->names, name); +} + +Type* hoist_numeric_join(ASTNode* root, const char* name) { + const HoistNameJoin* nj = body_binds(root, name); + JoinFold f; + memset(&f, 0, sizeof(f)); + for (int i = 0; nj && i < nj->count; i++) fold_bind(&f, nj->binds[i]); + Type* r = fold_result(&f); + fold_free(&f); + return r; +} + +Type* hoist_written_type(ASTNode* root, const char* name) { + const HoistNameJoin* nj = body_binds(root, name); + for (int i = 0; nj && i < nj->count; i++) { + const ASTNode* n = nj->binds[i]; + if (!n->type_inferred && n->node_type && n->node_type->kind != TYPE_UNKNOWN && + n->node_type->kind != TYPE_VOID) + return clone_type(n->node_type); + } + return NULL; +} + +/* Over the inferred bindings of `name` under `root`: sets `*strings` when + * one is a string, `*others` when one is neither a string nor `null`. */ +static void null_string_binds(ASTNode* root, const char* name, int* strings, int* others) { + const HoistNameJoin* nj = body_binds(root, name); + for (int i = 0; nj && i < nj->count; i++) { + ASTNode* n = (ASTNode*)nj->binds[i]; + if (!n->type_inferred) continue; ASTNode* init = n->child_count > 0 ? n->children[0] : NULL; Type* t = hoist_decl_type(n); if (init && init->type == AST_NULL_LITERAL) { @@ -365,20 +423,18 @@ static void null_string_walk(ASTNode* n, const char* name, int* strings, int* ot *others = 1; } } - for (int i = 0; i < n->child_count; i++) null_string_walk(n->children[i], name, strings, others); } -/* #2980 review: over the inferred bindings of `name` in `n` (closures - * excluded), function values joined with `acc` by fn_type_mode_join. */ -static Type* fn_join_walk(ASTNode* n, const char* name, Type* acc) { - if (!n) return acc; - if (n->type == AST_CLOSURE || n->type == AST_QUOTE) return acc; - if (n->type == AST_VARIABLE_DECLARATION && n->value && n->type_inferred && - strcmp(n->value, name) == 0) { +/* #2980 review: the inferred bindings of `name` under `root` that are + * function values, joined with `acc` by fn_type_mode_join. */ +static Type* fn_join_binds(ASTNode* root, const char* name, Type* acc) { + const HoistNameJoin* nj = body_binds(root, name); + for (int i = 0; nj && i < nj->count; i++) { + ASTNode* n = (ASTNode*)nj->binds[i]; + if (!n->type_inferred) continue; Type* j = fn_type_mode_join(acc, hoist_decl_type(n)); if (j) { free_type(acc); acc = j; } } - for (int i = 0; i < n->child_count; i++) acc = fn_join_walk(n->children[i], name, acc); return acc; } @@ -399,7 +455,7 @@ Type* hoist_join_type(ASTNode* scope_body, const char* name, Type* seed) { * through the stronger mode at each parameter, where either may stand; * typed from the first binding alone, a `@keep` in the other was lost. */ if (seed->kind == TYPE_FUNCTION) { - Type* acc = fn_join_walk(scope_body, name, clone_type(seed)); + Type* acc = fn_join_binds(scope_body, name, clone_type(seed)); if (acc && types_equal(acc, seed)) { free_type(acc); return NULL; } return acc; } @@ -410,7 +466,7 @@ Type* hoist_join_type(ASTNode* scope_body, const char* name, Type* seed) { * which a `string` local could not take (E0202). */ if (seed->kind == TYPE_PTR && !seed->element_type) { int strings = 0, others = 0; - null_string_walk(scope_body, name, &strings, &others); + null_string_binds(scope_body, name, &strings, &others); if (strings && !others) return create_type(TYPE_STRING); } if (!is_number_type(seed)) return NULL; /* not a numeric kind: nothing to join */ @@ -426,8 +482,8 @@ Type* hoist_join_type(ASTNode* scope_body, const char* name, Type* seed) { /* The arms of a branching statement: an if's then and else blocks, a * switch's case statements, whose children (after a case's selector) are * its body, or a match's arm bodies. A case's selector is never a - * declaration, so the case node itself is the body hoist_direct_decl_names - * and hoist_find_decl read (#2732). A match arm that binds a name has a + * declaration, so the case node itself is the body whose direct + * declarations are read (#2732). A match arm that binds a name has a * block body (the parser makes `1 -> v = 10` the block `{ v = 10 }`); any * other arm body is a value or a statement that binds nothing, and counts * as an arm all the same (#2768). Returns the count written to `arms` (at @@ -459,15 +515,18 @@ static int is_branching(ASTNode* stmt) { stmt->type == AST_MATCH_STATEMENT); } -/* Does the arm pattern `p` bind `name`: `some(name)`, an element of a - * list pattern (`[name, y]`, `[name | t]`), or a field of a struct pattern - * (`Pt { x: name }`)? A bare name in a pattern is a - * value compared with, or a sum's variant, and binds nothing. */ -static int pattern_binds_name(const ASTNode* p, const char* name) { - if (!p || !name) return 0; +/* Each name the arm pattern `p` binds, to `visit` until it returns nonzero + * (which this returns): `some(name)`, an element of a list pattern (`[name, + * y]`, `[name | t]`), or a field of a struct pattern (`Pt { x: name }`). A + * bare name in a pattern is a value compared with, or a sum's variant, and + * binds nothing. */ +typedef int (*BoundNameVisit)(const char* name, void* user); + +static int pattern_each_bound(const ASTNode* p, BoundNameVisit visit, void* user) { + if (!p) return 0; if (p->type == AST_PATTERN_VARIABLE) - return p->annotation && strcmp(p->annotation, "some_pattern") == 0 && p->value && - strcmp(p->value, name) == 0; + return p->value && p->annotation && strcmp(p->annotation, "some_pattern") == 0 && + visit(p->value, user); /* #2992: a struct pattern binds the names its fields take, and those of * a struct pattern over a field. */ if (p->type == AST_PATTERN_STRUCT) { @@ -476,21 +535,26 @@ static int pattern_binds_name(const ASTNode* p, const char* name) { const ASTNode* sub = (fp && fp->type == AST_ASSIGNMENT && fp->child_count > 0) ? fp->children[0] : NULL; if (!sub) continue; - if (sub->type == AST_PATTERN_VARIABLE && sub->value && strcmp(sub->value, name) == 0) + if (sub->type == AST_PATTERN_VARIABLE && sub->value && visit(sub->value, user)) return 1; - if (sub->type == AST_PATTERN_STRUCT && pattern_binds_name(sub, name)) return 1; + if (sub->type == AST_PATTERN_STRUCT && pattern_each_bound(sub, visit, user)) return 1; } return 0; } if (p->type != AST_PATTERN_LIST && p->type != AST_PATTERN_CONS) return 0; for (int i = 0; i < p->child_count; i++) { const ASTNode* e = p->children[i]; - if (e && e->type == AST_PATTERN_VARIABLE && e->value && strcmp(e->value, name) == 0) - return 1; + if (e && e->type == AST_PATTERN_VARIABLE && e->value && visit(e->value, user)) return 1; } return 0; } +static int is_name(const char* name, void* want) { return strcmp(name, (const char*)want) == 0; } + +static int pattern_binds_name(const ASTNode* p, const char* name) { + return name && pattern_each_bound(p, is_name, (void*)name); +} + /* #2768: does an arm pattern of `stmt`, a match, bind `name`? Such a name * is the arm's own, and is not hoisted out of the match by any rule: a * binding of it in that arm rebinds the pattern's value, and one in another @@ -509,120 +573,194 @@ static int match_pattern_binds(const ASTNode* stmt, const char* name) { #define HOIST_MAX_ARMS 256 -int hoist_if_branch_candidates(ASTNode* body, const char** out, int cap) { - if (!body || !out) return 0; +static HoistNames* names_new(int per) { + HoistNames* h = (HoistNames*)calloc(1, sizeof(HoistNames)); + if (!h) return NULL; + h->per = per; + strmap_init(&h->at); + return h; +} + +/* Add `name` (one not in `h` yet), its bindings unset; returns its index. */ +static int names_push(HoistNames* h, const char* name) { + if (h->count == h->cap) { + int cap = h->cap ? h->cap * 2 : 8; + h->names = (const char**)aether_xrealloc((void*)h->names, (size_t)cap * sizeof(char*)); + h->decls = (ASTNode**)aether_xrealloc(h->decls, (size_t)cap * (size_t)h->per * + sizeof(ASTNode*)); + memset(h->decls + (size_t)h->cap * (size_t)h->per, 0, + (size_t)(cap - h->cap) * (size_t)h->per * sizeof(ASTNode*)); + h->cap = cap; + } + h->names[h->count] = name; + strmap_put(&h->at, name, (void*)(intptr_t)(h->count + 1)); + return h->count++; +} + +int hoist_names_find(const HoistNames* h, const char* name) { + void* at = h && name ? strmap_get(&h->at, name) : NULL; + return at ? (int)(intptr_t)at - 1 : -1; +} + +ASTNode* hoist_names_decl(const HoistNames* h, int i) { + if (!h || i < 0 || i >= h->count) return NULL; + ASTNode* any = NULL; + for (int k = 0; k < h->per; k++) { + ASTNode* d = h->decls[(size_t)i * (size_t)h->per + (size_t)k]; + if (!d) continue; + if (!any) any = d; + Type* t = hoist_decl_type(d); + if (t && t->kind != TYPE_VOID && t->kind != TYPE_UNKNOWN) return d; + } + return any; +} + +static int put_name(const char* name, void* out) { + strmap_put((StrMap*)out, name, (void*)1); + return 0; +} + +static void pattern_bound_names(const ASTNode* p, StrMap* out) { + pattern_each_bound(p, put_name, out); +} + +/* The names the arm patterns of `stmt`, a match, bind (match_pattern_binds + * for every name at once). */ +static void pattern_names(const ASTNode* stmt, StrMap* out) { + if (!stmt || stmt->type != AST_MATCH_STATEMENT) return; + for (int i = 1; i < stmt->child_count; i++) { + const ASTNode* a = stmt->children[i]; + if (a && a->type == AST_MATCH_ARM && a->child_count > 0) + pattern_bound_names(a->children[0], out); + } +} + +/* Every identifier name under `n` (closures included: any use counts). */ +static void identifier_names(const ASTNode* n, StrMap* out) { + if (!n) return; + if (n->type == AST_IDENTIFIER && n->value) strmap_put(out, n->value, (void*)1); + for (int i = 0; i < n->child_count; i++) identifier_names(n->children[i], out); +} + +static HoistNames* branch_hoists_build(ASTNode* body) { + HoistNames* all = names_new(1); + HoistNames* kept = names_new(1); + if (!all || !kept) { names_free(all); names_free(kept); return NULL; } /* Names a top-level statement declares get their own function-scope * declaration (and heap tracker) where they are; hoisting them from an - * arm as well would declare them twice. */ - const char* top_level_decls[HOIST_MAX_NAMES]; - int top_count = 0; - for (int i = 0; i < body->child_count && top_count < HOIST_MAX_NAMES; i++) { + * arm as well would declare them twice. Only a name read outside the + * arms -- by a top-level statement that does not branch, or by an if's + * condition or a switch's or a match's subject -- is hoisted. */ + StrMap top, outside; + strmap_init(&top); + strmap_init(&outside); + for (int i = 0; i < body->child_count; i++) { ASTNode* child = body->children[i]; if (!child) continue; - if (child->type == AST_VARIABLE_DECLARATION && child->value) { - top_level_decls[top_count++] = child->value; + if (child->type == AST_VARIABLE_DECLARATION && child->value) + strmap_put(&top, child->value, (void*)1); + if (is_branching(child)) { + if (child->child_count > 0) identifier_names(child->children[0], &outside); + } else { + identifier_names(child, &outside); } } - - const char* names[HOIST_MAX_NAMES]; - int count = 0; + /* Each name an arm binds, in the order the arms bind them, with its + * first binding in an arm of a statement whose patterns do not bind it + * (a name a match's pattern binds comes out of no arm of that match). */ for (int i = 0; i < body->child_count; i++) { + ASTNode* stmt = body->children[i]; ASTNode* arms[HOIST_MAX_ARMS]; - int na = branch_arms(body->children[i], arms, HOIST_MAX_ARMS); + int na = branch_arms(stmt, arms, HOIST_MAX_ARMS); + if (na == 0) continue; + StrMap bound; + strmap_init(&bound); + pattern_names(stmt, &bound); for (int j = 0; j < na; j++) { - count = hoist_direct_decl_names(arms[j], names, count, HOIST_MAX_NAMES); - } - } - /* A name a match's pattern binds comes out of no arm of that match; - * hoist_if_branch_first_decl, below, finds a binding of it elsewhere or - * none. */ - - int kept = 0; - for (int n = 0; n < count && kept < cap; n++) { - const char* name = names[n]; - int dup = 0; - for (int k = 0; k < top_count; k++) { - if (strcmp(name, top_level_decls[k]) == 0) { dup = 1; break; } - } - if (dup) continue; - /* Only a name read outside the arms -- by a top-level statement that - * does not branch, or by an if's condition or a switch's or a - * match's subject -- is hoisted. */ - int referenced_outside = 0; - for (int i = 0; i < body->child_count && !referenced_outside; i++) { - ASTNode* child = body->children[i]; - if (!child) continue; - if (is_branching(child)) { - if (child->child_count > 0 && - hoist_has_identifier_ref(child->children[0], name)) { - referenced_outside = 1; - } - continue; + for (int k = 0; arms[j] && k < arms[j]->child_count; k++) { + ASTNode* d = arms[j]->children[k]; + if (!d || d->type != AST_VARIABLE_DECLARATION || !d->value) continue; + int at = hoist_names_find(all, d->value); + if (at < 0) at = names_push(all, d->value); + if (!all->decls[at] && !strmap_has(&bound, d->value)) all->decls[at] = d; } - if (hoist_has_identifier_ref(child, name)) referenced_outside = 1; } - if (!referenced_outside) continue; - if (!hoist_if_branch_first_decl(body, name)) continue; - out[kept++] = name; + strmap_free(&bound); } + for (int n = 0; n < all->count; n++) { + const char* name = all->names[n]; + if (strmap_has(&top, name) || !strmap_has(&outside, name) || !all->decls[n]) continue; + int at = names_push(kept, name); + kept->decls[at] = all->decls[n]; + } + strmap_free(&top); + strmap_free(&outside); + names_free(all); return kept; } -ASTNode* hoist_if_branch_first_decl(ASTNode* body, const char* name) { +const HoistNames* hoist_if_branch_hoists(ASTNode* body) { if (!body) return NULL; - for (int i = 0; i < body->child_count; i++) { - if (match_pattern_binds(body->children[i], name)) continue; - ASTNode* arms[HOIST_MAX_ARMS]; - int na = branch_arms(body->children[i], arms, HOIST_MAX_ARMS); - for (int j = 0; j < na; j++) { - ASTNode* d = hoist_find_decl(arms[j], name); - if (d) return d; - } - } - return NULL; + struct HoistNodeCache* c = node_cache(body); + if (!c->branch) c->branch = branch_hoists_build(body); + return c->branch; } -int hoist_common_decl_names(ASTNode* stmt, const char** out, int cap) { - if (!stmt || !out) return 0; +static HoistNames* common_hoists_build(ASTNode* stmt) { ASTNode* arms[HOIST_MAX_ARMS]; int na = branch_arms(stmt, arms, HOIST_MAX_ARMS); + HoistNames* h = names_new(na > 0 ? na : 1); + if (!h) return NULL; /* Every path must pass through an arm: an if needs its else, a switch * its default, a match a `_` arm or arms that cover its type (#2768). */ - if (stmt->type == AST_IF_STATEMENT && na < 2) return 0; + if (na == 0) return h; + if (stmt->type == AST_IF_STATEMENT && na < 2) return h; if (stmt->type == AST_SWITCH_STATEMENT) { int has_default = 0; for (int j = 0; j < na; j++) { if (arms[j]->value && strcmp(arms[j]->value, "default") == 0) has_default = 1; } - if (!has_default || na < 2) return 0; - } - if (stmt->type == AST_MATCH_STATEMENT && !ast_match_covers(stmt)) return 0; - if (na == 0) return 0; - const char* first[HOIST_MAX_NAMES]; - int nfirst = hoist_direct_decl_names(arms[0], first, 0, HOIST_MAX_NAMES); - int kept = 0; - for (int i = 0; i < nfirst && kept < cap; i++) { - int everywhere = !match_pattern_binds(stmt, first[i]); - for (int j = 1; j < na && everywhere; j++) { - if (!hoist_find_decl(arms[j], first[i])) everywhere = 0; - } - if (everywhere) out[kept++] = first[i]; + if (!has_default || na < 2) return h; } - return kept; -} - -ASTNode* hoist_common_first_decl(ASTNode* stmt, const char* name) { - ASTNode* arms[HOIST_MAX_ARMS]; - int na = branch_arms(stmt, arms, HOIST_MAX_ARMS); - ASTNode* any = NULL; + if (stmt->type == AST_MATCH_STATEMENT && !ast_match_covers(stmt)) return h; + /* Each arm's first direct binding of each name it binds. */ + StrMap* firsts = (StrMap*)calloc((size_t)na, sizeof(StrMap)); + if (!firsts) return h; for (int j = 0; j < na; j++) { - ASTNode* d = hoist_find_decl(arms[j], name); - if (!d) continue; - if (!any) any = d; - Type* t = hoist_decl_type(d); - if (t && t->kind != TYPE_VOID && t->kind != TYPE_UNKNOWN) return d; + strmap_init(&firsts[j]); + for (int k = 0; arms[j] && k < arms[j]->child_count; k++) { + ASTNode* d = arms[j]->children[k]; + if (d && d->type == AST_VARIABLE_DECLARATION && d->value && + !strmap_has(&firsts[j], d->value)) + strmap_put(&firsts[j], d->value, d); + } } - return any; + StrMap bound; + strmap_init(&bound); + pattern_names(stmt, &bound); + /* The names the first arm binds that every other arm binds too. */ + for (int i = 0; i < strmap_count(&firsts[0]); i++) { + const char* name = strmap_key_at(&firsts[0], i); + int everywhere = !strmap_has(&bound, name); + for (int j = 1; j < na && everywhere; j++) + if (!strmap_has(&firsts[j], name)) everywhere = 0; + if (!everywhere) continue; + ASTNode* d0 = (ASTNode*)strmap_value_at(&firsts[0], i); + int at = names_push(h, d0->value); + for (int j = 0; j < na; j++) + h->decls[(size_t)at * (size_t)na + (size_t)j] = (ASTNode*)strmap_get(&firsts[j], name); + } + strmap_free(&bound); + for (int j = 0; j < na; j++) strmap_free(&firsts[j]); + free(firsts); + return h; +} + +const HoistNames* hoist_common_hoists(ASTNode* stmt) { + if (!stmt) return NULL; + struct HoistNodeCache* c = node_cache(stmt); + if (!c->common) c->common = common_hoists_build(stmt); + return c->common; } /* The matches whose arms a hoist_loop_decls walk is inside, innermost last: diff --git a/compiler/analysis/hoist.h b/compiler/analysis/hoist.h index aae526a49..682e05567 100644 --- a/compiler/analysis/hoist.h +++ b/compiler/analysis/hoist.h @@ -10,37 +10,26 @@ * then or else block, a case of a switch (#2732), or the body of a match * arm (#2768): * - * - hoist_if_branch_candidates: a name first bound in an arm of one of a + * - hoist_if_branch_hoists: a name first bound in an arm of one of a * function body's top-level ifs, switches or matches is declared at * function scope, when a top-level statement, an if condition or a * switch's or match's subject reads it and no top-level statement * declares it. * - the names every arm of an if/else, of a switch with a default, or of * a match with a `_` arm or arms that cover its type, declares - * (hoist_common_decl_names) are declared before that statement, at any + * (hoist_common_hoists) are declared before that statement, at any * depth. * - a while body's locals, and those of the ifs, switches, matches and * loops nested in it (hoist_loop_decls), are declared before the while. * * Whether a name is already declared, or is a module-level `var`, depends on * the caller's own state and is checked by the caller, and so is, for - * hoist_common_decl_names, whether a match is a statement: one whose value + * hoist_common_hoists, whether a match is a statement: one whose value * is used (bound, returned, or yielded by an arm of another) keeps its arms' * names in the arms. */ #include "../ast.h" - -#define HOIST_MAX_NAMES 64 - -/* Append the names `block`'s direct AST_VARIABLE_DECLARATION children bind to - * `out` (deduplicated, at most `cap`); returns the new count. */ -int hoist_direct_decl_names(ASTNode* block, const char** out, int count, int cap); - -/* The first direct declaration of `name` in `block`, or NULL. */ -ASTNode* hoist_find_decl(ASTNode* block, const char* name); - -/* Does `node`'s subtree contain an identifier `name`? */ -int hoist_has_identifier_ref(ASTNode* node, const char* name); +#include "../aether_strmap.h" /* The type a hoisted declaration of `decl` is given: its own node_type, else * its initializer's. Borrowed; NULL when neither is known. */ @@ -82,25 +71,44 @@ void hoist_joins_typed(HoistJoins* j, const ASTNode* decl); Type* hoist_joins_get(HoistJoins* j, const char* name); void hoist_joins_free(HoistJoins* j); -/* The names hoist_if_branch_vars declares at the top of function body - * `body` (before the caller's already-declared / module-global checks). - * Returns the count written to `out` (at most `cap`). */ -int hoist_if_branch_candidates(ASTNode* body, const char** out, int cap); +/* The names one of the rules hoists, in the order the arms bind them, each + * with its bindings (#3083: any number of names, each found by name). Kept + * by this file until hoist_cache_reset; the caller does not free it. */ +typedef struct { + int count; + int cap; + const char** names; + ASTNode** decls; /* names[i]'s bindings: decls[i * per] .. decls[i * per + per - 1] */ + int per; + StrMap at; /* name -> index + 1 */ +} HoistNames; + +/* The index of `name` in `h`, or -1. */ +int hoist_names_find(const HoistNames* h, const char* name); + +/* The binding of names[i] whose type the hoisted variable takes: the first + * whose hoist_decl_type is known, else the first. */ +ASTNode* hoist_names_decl(const HoistNames* h, int i); -/* The declaration of `name` in the first arm of a top-level if, switch or - * match of `body` that declares it (the one whose type the hoisted variable - * takes). */ -ASTNode* hoist_if_branch_first_decl(ASTNode* body, const char* name); +/* The names hoist_if_branch_vars declares at the top of function body + * `body` (before the caller's already-declared / module-global checks), + * each with its first binding: the one in the first arm of a top-level if, + * switch or match of `body` that declares it. */ +const HoistNames* hoist_if_branch_hoists(ASTNode* body); /* The names every arm of `stmt` declares directly, when `stmt` is an if with * an else, a switch with a default, or a match with a `_` arm or arms that - * cover its type (so every path binds them); 0 for any other statement. - * Returns the count written to `out` (at most `cap`). */ -int hoist_common_decl_names(ASTNode* stmt, const char** out, int cap); + * cover its type (so every path binds them), each with its binding in every + * arm; none for any other statement. */ +const HoistNames* hoist_common_hoists(ASTNode* stmt); + +/* Forget every answer kept for a body or a statement: at the start of each + * checker and codegen run, and after codegen renames names. */ +void hoist_cache_reset(void); -/* The declaration of `name` in an arm of `stmt` whose type the hoisted - * variable takes: the first whose hoist_decl_type is known, else the first. */ -ASTNode* hoist_common_first_decl(ASTNode* stmt, const char* name); +/* `n` is being freed: forget what was kept for it, so a node allocated + * later at its address is asked afresh. */ +void hoist_node_freed(const ASTNode* n); /* Visit, in codegen's order, the declarations hoist_loop_vars declares * before a while whose body is `body`: the body's direct declarations and, diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index 4cd7ad4ee..45bbfd20c 100644 --- a/compiler/analysis/typechecker.c +++ b/compiler/analysis/typechecker.c @@ -5079,41 +5079,44 @@ typedef struct { int used; } TrackedVar; +/* A function's tracked locals, found by name (#3083): `first` maps a name + * to its first entry + 1, and `next` chains the entries of one name, which a + * reference marks used together. Scanning them all for each reference was + * quadratic in a function's locals. */ +typedef struct { + TrackedVar* vars; + int count; + StrMap first; + int* next; +} TrackedVars; + +/* The entries of a name are marked together, so a marked first entry means + * every one is: a local rebound n times and read n times costs n, not n^2. */ +static void mark_used(TrackedVars* t, const char* name) { + intptr_t at = (intptr_t)strmap_get(&t->first, name); + if (!at || t->vars[at - 1].used) return; + for (; at; at = t->next[at - 1]) t->vars[at - 1].used = 1; +} + // Collect all AST_IDENTIFIER references in a subtree (excluding declarations) -static void collect_references(ASTNode* node, TrackedVar* vars, int var_count) { +static void collect_references(ASTNode* node, TrackedVars* t) { if (!node) return; // An identifier in expression position is a reference - if (node->type == AST_IDENTIFIER && node->value) { - for (int i = 0; i < var_count; i++) { - if (strcmp(vars[i].name, node->value) == 0) { - vars[i].used = 1; - } - } - } + if (node->type == AST_IDENTIFIER && node->value) mark_used(t, node->value); /* A lowered quote's body is the tree's now: each variable it captures * is read through the tree (docs/quote.md). */ if (node->type == AST_QUOTE) { QuoteInfo* q = quote_of(node); - for (int k = 0; q && k < q->ncaps; k++) { - for (int i = 0; i < var_count; i++) { - if (strcmp(vars[i].name, q->caps[k].name) == 0) vars[i].used = 1; - } - } + for (int k = 0; q && k < q->ncaps; k++) mark_used(t, q->caps[k].name); } // A call through a local is a use of that local: `g(p)` where `g` was // assigned via `f as fn(ptr) -> int` parses as AST_FUNCTION_CALL with // the callee name in node->value (no AST_IDENTIFIER child), so match // the call name against tracked variables too. - if (node->type == AST_FUNCTION_CALL && node->value) { - for (int i = 0; i < var_count; i++) { - if (strcmp(vars[i].name, node->value) == 0) { - vars[i].used = 1; - } - } - } + if (node->type == AST_FUNCTION_CALL && node->value) mark_used(t, node->value); // A call through a local's fn-pointer field, `f.get_text(p)`, uses `f` // too. The parser collapsed the callee to the dotted name `f.get_text` @@ -5123,12 +5126,10 @@ static void collect_references(ASTNode* node, TrackedVar* vars, int var_count) { if (node->type == AST_FUNCTION_CALL && node->value && node->annotation && strncmp(node->annotation, "fnfield_", 8) == 0) { const char* dot = strchr(node->value, '.'); - size_t rlen = dot ? (size_t)(dot - node->value) : 0; - for (int i = 0; i < var_count && rlen > 0; i++) { - if (strlen(vars[i].name) == rlen && - strncmp(vars[i].name, node->value, rlen) == 0) { - vars[i].used = 1; - } + char recv[256]; + if (dot && dot > node->value && dot - node->value < (ptrdiff_t)sizeof(recv)) { + snprintf(recv, sizeof(recv), "%.*s", (int)(dot - node->value), node->value); + mark_used(t, recv); } } @@ -5144,11 +5145,7 @@ static void collect_references(ASTNode* node, TrackedVar* vars, int var_count) { !(mt && mt->kind == TYPE_ARRAY) && !is_string_seq_ptr_type(mt)) { char len_name[256]; snprintf(len_name, sizeof(len_name), "%s_len", match_expr->value); - for (int i = 0; i < var_count; i++) { - if (strcmp(vars[i].name, len_name) == 0) { - vars[i].used = 1; - } - } + mark_used(t, len_name); } } @@ -5156,13 +5153,13 @@ static void collect_references(ASTNode* node, TrackedVar* vars, int var_count) { if (node->type == AST_VARIABLE_DECLARATION) { // Only walk children (RHS expression), not the declaration name for (int i = 0; i < node->child_count; i++) { - collect_references(node->children[i], vars, var_count); + collect_references(node->children[i], t); } return; } for (int i = 0; i < node->child_count; i++) { - collect_references(node->children[i], vars, var_count); + collect_references(node->children[i], t); } } @@ -5248,9 +5245,18 @@ static void check_unused_variables(ASTNode* body, const char** global_names, } // Collect references + TrackedVars t = { vars, var_count, {0}, (int*)calloc((size_t)var_count, sizeof(int)) }; + strmap_init(&t.first); + if (!t.next) { free(vars); return; } + for (int i = var_count - 1; i >= 0; i--) { + t.next[i] = (int)(intptr_t)strmap_get(&t.first, vars[i].name); + strmap_put(&t.first, vars[i].name, (void*)(intptr_t)(i + 1)); + } for (int i = 0; i < body->child_count; i++) { - collect_references(body->children[i], vars, var_count); + collect_references(body->children[i], &t); } + strmap_free(&t.first); + free(t.next); // Warn about unused for (int i = 0; i < var_count; i++) { @@ -6657,6 +6663,7 @@ int typecheck_program(ASTNode* program) { g_typecheck_program = program; g_tc_ptr_to_closure = 0; tc_clauses_reset(); /* #2647: a program's clause sets, indexed afresh */ + hoist_cache_reset(); /* #3083: the hoisting answers, gathered afresh */ /* #2631: a module file checked on its own (it has an `exports(...)` * list, the test `program_is_module` below makes too) knows the @@ -11755,24 +11762,18 @@ static int report_unheld_binding(ASTNode* stmt, Type* w, Type* t, ASTNode* init) /* Function (or main) entry: hoist_if_branch_vars's names. */ static void declare_if_branch_hoists(ASTNode* body, SymbolTable* table) { - const char* names[HOIST_MAX_NAMES]; - int n = hoist_if_branch_candidates(body, names, HOIST_MAX_NAMES); - for (int i = 0; i < n; i++) { - declare_hoisted_local(table, names[i], - hoist_decl_type(hoist_if_branch_first_decl(body, names[i]))); - } + const HoistNames* h = hoist_if_branch_hoists(body); + for (int i = 0; h && i < h->count; i++) + declare_hoisted_local(table, h->names[i], hoist_decl_type(hoist_names_decl(h, i))); } /* Before an if/else, or a switch with a default (#2732): hoist_common_vars's * names (every arm declares them), typed from the first arm that types * them. */ static void declare_common_hoists(ASTNode* stmt, SymbolTable* table) { - const char* names[HOIST_MAX_NAMES]; - int n = hoist_common_decl_names(stmt, names, HOIST_MAX_NAMES); - for (int i = 0; i < n; i++) { - declare_hoisted_local(table, names[i], - hoist_decl_type(hoist_common_first_decl(stmt, names[i]))); - } + const HoistNames* h = hoist_common_hoists(stmt); + for (int i = 0; h && i < h->count; i++) + declare_hoisted_local(table, h->names[i], hoist_decl_type(hoist_names_decl(h, i))); } /* Before a while: hoist_loop_vars's names. */ diff --git a/compiler/ast.c b/compiler/ast.c index 2fb2cdd9c..9727a7480 100644 --- a/compiler/ast.c +++ b/compiler/ast.c @@ -3,6 +3,7 @@ #include #include #include "ast.h" +#include "analysis/hoist.h" #include #include "../std/mem/aether_grow.h" @@ -1180,6 +1181,7 @@ void free_ast_node(ASTNode* node) { free(node->children); } + hoist_node_freed(node); free(node); } diff --git a/compiler/codegen/codegen.c b/compiler/codegen/codegen.c index aee12b3fb..e94162cf0 100644 --- a/compiler/codegen/codegen.c +++ b/compiler/codegen/codegen.c @@ -12,6 +12,7 @@ #include "../aether_error.h" #include "../analysis/actor_reply.h" #include "../analysis/sandbox_trust.h" +#include "../analysis/hoist.h" /* The AetherString header the emitted C inspects, from the definition the * runtime uses (std/string/aether_string_abi.h): its magic's bytes in @@ -960,6 +961,8 @@ void code_generator_release(CodeGenerator* gen) { } free(gen->declared_vars); } + strmap_free(&gen->declared_index); + strmap_free(&gen->reads_index); if (gen->module_global_vars) { for (int i = 0; i < gen->module_global_var_count; i++) { free(gen->module_global_vars[i]); @@ -1009,12 +1012,7 @@ void free_code_generator(CodeGenerator* gen) { // Helper: check if variable was already declared in current function int is_var_declared(CodeGenerator* gen, const char* var_name) { - for (int i = 0; i < gen->declared_var_count; i++) { - if (strcmp(gen->declared_vars[i], var_name) == 0) { - return 1; - } - } - return 0; + return strmap_get(&gen->declared_index, var_name) != NULL; } // #701: is `name` a module-level `var` global (a file-scope static)? @@ -1073,6 +1071,10 @@ void register_module_global_var(CodeGenerator* gen, const char* name, int is_pro * without bound. */ void truncate_declared_vars(CodeGenerator* gen, int saved_count) { for (int i = saved_count; i < gen->declared_var_count; i++) { + /* A later slot of the name goes with this one: the slots go as a + * suffix. */ + if ((intptr_t)strmap_get(&gen->declared_index, gen->declared_vars[i]) == i + 1) + strmap_put(&gen->declared_index, gen->declared_vars[i], NULL); free(gen->declared_vars[i]); if (gen->declared_var_types && gen->declared_var_types[i]) { free_type(gen->declared_var_types[i]); @@ -1098,16 +1100,14 @@ void mark_var_declared_typed(CodeGenerator* gen, const char* var_name, Type* hoi gen->declared_var_types = new_types; gen->declared_vars[gen->declared_var_count] = strdup(var_name); gen->declared_var_types[gen->declared_var_count] = hoisted_type ? clone_type(hoisted_type) : NULL; + if (!strmap_get(&gen->declared_index, var_name)) + strmap_put(&gen->declared_index, var_name, (void*)(intptr_t)(gen->declared_var_count + 1)); gen->declared_var_count++; } Type* declared_var_type(CodeGenerator* gen, const char* var_name) { - for (int i = 0; i < gen->declared_var_count; i++) { - if (strcmp(gen->declared_vars[i], var_name) == 0) { - return gen->declared_var_types ? gen->declared_var_types[i] : NULL; - } - } - return NULL; + intptr_t at = (intptr_t)strmap_get(&gen->declared_index, var_name); + return at && gen->declared_var_types ? gen->declared_var_types[at - 1] : NULL; } // Helper: clear declared vars (call at function start) @@ -1129,6 +1129,8 @@ void clear_declared_vars(CodeGenerator* gen) { gen->declared_var_types = NULL; gen->hoist_scope_body = NULL; gen->declared_var_count = 0; + strmap_free(&gen->declared_index); + strmap_init(&gen->declared_index); } // Helper: check if variable currently holds a heap-allocated string @@ -8082,6 +8084,8 @@ void generate_program(CodeGenerator* gen, ASTNode* program) { /* The rename changed definition names, which are the index's keys, without changing the child count it is keyed on. */ program_index_reset(); + /* So are the hoisting answers the checker kept (#3083). */ + hoist_cache_reset(); erase_string_retype_casts(program); /* #2586: the functions used as fn-pointer values, by name, before any * pass classifies a function or emits a return of one. */ diff --git a/compiler/codegen/codegen.h b/compiler/codegen/codegen.h index 0db003247..de83f8482 100644 --- a/compiler/codegen/codegen.h +++ b/compiler/codegen/codegen.h @@ -64,6 +64,13 @@ typedef struct { // cannot flow into that type is reported in the language's terms // instead of by the C compiler. Type** declared_var_types; + // #3083: name -> its first slot in declared_vars + 1 (NULL: not + // declared), so a lookup is one probe, not a scan of every local. + StrMap declared_index; + // #3083: every name `reads_root` (the function being emitted) reads, by + // tree_reads_name's rule, gathered by one walk of it. + const ASTNode* reads_root; + StrMap reads_index; // #2124: the body of the function (or main) being generated, walked // by the hoisters to find every binding of a name across sibling // branches and loop bodies, so a hoisted local is declared with the diff --git a/compiler/codegen/codegen_expr.c b/compiler/codegen/codegen_expr.c index f778f2610..dd2778994 100644 --- a/compiler/codegen/codegen_expr.c +++ b/compiler/codegen/codegen_expr.c @@ -2890,12 +2890,8 @@ static ASTNode* stmt_hoisted_decl(ASTNode* s, const char* var_name) { if (s->child_count > 1) hoist_loop_decls(s->children[1], hoist_name_hunt, &h); return h.decl; } - const char* names[HOIST_MAX_NAMES]; - int n = hoist_common_decl_names(s, names, HOIST_MAX_NAMES); - for (int i = 0; i < n; i++) - if (names[i] && strcmp(names[i], var_name) == 0) - return hoist_common_first_decl(s, var_name); - return NULL; + const HoistNames* h = hoist_common_hoists(s); + return hoist_names_decl(h, hoist_names_find(h, var_name)); } // The line of the first declaration of `var_name` that is VISIBLE from @@ -3015,13 +3011,9 @@ static int enclosing_decl_line(ASTNode* program, const char* func_name, * switch or match and read after it is declared at the top * of the function (hoist_if_branch_vars): visible from its * first binding on. */ - const char* names[HOIST_MAX_NAMES]; - int n = hoist_if_branch_candidates(body, names, HOIST_MAX_NAMES); - for (int c = 0; c < n; c++) { - if (!names[c] || strcmp(names[c], var_name) != 0) continue; - ASTNode* first = hoist_if_branch_first_decl(body, var_name); - if (first && !subtree_contains(first, viewer)) return first->line; - } + const HoistNames* h = hoist_if_branch_hoists(body); + ASTNode* first = hoist_names_decl(h, hoist_names_find(h, var_name)); + if (first && !subtree_contains(first, viewer)) return first->line; return INT_MAX; } } @@ -5008,9 +5000,11 @@ void emit_closure_definitions(CodeGenerator* gen) { char** prev_declared = gen->declared_vars; Type** prev_declared_types = gen->declared_var_types; int prev_declared_count = gen->declared_var_count; + StrMap prev_declared_index = gen->declared_index; gen->declared_vars = NULL; gen->declared_var_types = NULL; gen->declared_var_count = 0; + strmap_init(&gen->declared_index); /* #2124: the closure body is a C function of its own; its * hoisted locals join over ITS bindings, not the enclosing * function's. */ @@ -5227,6 +5221,7 @@ void emit_closure_definitions(CodeGenerator* gen) { gen->declared_vars = prev_declared; gen->declared_var_types = prev_declared_types; gen->declared_var_count = prev_declared_count; + gen->declared_index = prev_declared_index; gen->hoist_scope_body = prev_hoist_scope; // Free this closure body's heap-string set and restore the // enclosing scope's (see the matching reset above). diff --git a/compiler/codegen/codegen_stmt.c b/compiler/codegen/codegen_stmt.c index 749fee3e2..4085ea570 100644 --- a/compiler/codegen/codegen_stmt.c +++ b/compiler/codegen/codegen_stmt.c @@ -5643,14 +5643,13 @@ static void emit_hoisted_local_decl(CodeGenerator* gen, Type* var_type, } /* Declare, before an if/else or a switch with a default, the names every arm - * binds (hoist_common_decl_names, shared with the typechecker), so the + * binds (hoist_common_hoists, shared with the typechecker), so the * binding survives the arm (#2732 added the switch). */ static void hoist_common_vars(CodeGenerator* gen, ASTNode* stmt) { - const char* names[HOIST_MAX_NAMES]; - int count = hoist_common_decl_names(stmt, names, HOIST_MAX_NAMES); + const HoistNames* h = hoist_common_hoists(stmt); - for (int i = 0; i < count; i++) { - const char* n = names[i]; + for (int i = 0; h && i < h->count; i++) { + const char* n = h->names[i]; // Skip if already declared at outer scope. if (is_var_declared(gen, n)) continue; @@ -5662,7 +5661,7 @@ static void hoist_common_vars(CodeGenerator* gen, ASTNode* stmt) { if (is_actor_state_var(gen, n)) continue; // Recover a usable type from the first arm that has one. - ASTNode* decl = hoist_common_first_decl(stmt, n); + ASTNode* decl = hoist_names_decl(h, i); Type* var_type = hoist_decl_type(decl); Type* joined = sibling_join_type(gen, n, var_type); if (joined) var_type = joined; @@ -6235,12 +6234,11 @@ static int function_has_ensures(ASTNode* func) { void hoist_if_branch_vars(CodeGenerator* gen, ASTNode* body) { if (!body) return; - /* Which names: hoist_if_branch_candidates (shared with the typechecker, + /* Which names: hoist_if_branch_hoists (shared with the typechecker, * which must accept exactly the reads this makes compile). */ - const char* names[HOIST_MAX_NAMES]; - int count = hoist_if_branch_candidates(body, names, HOIST_MAX_NAMES); - for (int n = 0; n < count; n++) { - const char* name = names[n]; + const HoistNames* h = hoist_if_branch_hoists(body); + for (int n = 0; h && n < h->count; n++) { + const char* name = h->names[n]; if (is_var_declared(gen, name)) continue; /* #744: a module-level `var` global first assigned inside an * if-branch must NOT be hoisted as a fresh function local — @@ -6252,7 +6250,7 @@ void hoist_if_branch_vars(CodeGenerator* gen, ASTNode* body) { * skip it here exactly as that path does. */ if (is_module_global_var(gen, name)) continue; if (is_actor_state_var(gen, name)) continue; /* #2505: self->name */ - ASTNode* first_decl = hoist_if_branch_first_decl(body, name); + ASTNode* first_decl = hoist_names_decl(h, n); if (!first_decl) continue; Type* var_type = first_decl->node_type; if ((!var_type || var_type->kind == TYPE_VOID || var_type->kind == TYPE_UNKNOWN) @@ -8837,6 +8835,30 @@ static int tree_reads_name(const ASTNode* node, const char* name, size_t len) { return 0; } +/* The names `node` reads by tree_reads_name's rule, into `out`: each value, + * and each part of a dotted one up to a `.`. */ +static void reads_collect(const ASTNode* node, StrMap* out) { + if (!node) return; + if ((node->type == AST_IDENTIFIER || node->type == AST_FUNCTION_CALL) && node->value) { + strmap_put(out, node->value, (void*)1); + for (const char* dot = strchr(node->value, '.'); dot; dot = strchr(dot + 1, '.')) + strmap_put(out, cg_intern_n(node->value, (size_t)(dot - node->value)), (void*)1); + } + for (int i = 0; i < node->child_count; i++) reads_collect(node->children[i], out); +} + +/* #3083: does the function being emitted read `name`? One walk of it for + * every local asked about, not one per local. */ +static int current_function_reads(CodeGenerator* gen, const char* name) { + if (gen->reads_root != gen->current_function) { + strmap_free(&gen->reads_index); + strmap_init(&gen->reads_index); + reads_collect(gen->current_function, &gen->reads_index); + gen->reads_root = gen->current_function; + } + return strmap_has(&gen->reads_index, name); +} + /* Is a local a destructure declares one the program never reads? A name * spelled with a leading `_` says so (the type checker does not warn about * it), and any other is looked for in the function being emitted. Such a @@ -8848,8 +8870,7 @@ static int tree_reads_name(const ASTNode* node, const char* name, size_t len) { static int destructure_local_unread(CodeGenerator* gen, const char* name) { if (!name) return 0; if (name[0] == '_') return 1; - return !gen->current_function || - !tree_reads_name(gen->current_function, name, strlen(name)); + return !gen->current_function || !current_function_reads(gen, name); } int codegen_local_unread(CodeGenerator* gen, const char* name) { diff --git a/compiler/codegen/optimizer.c b/compiler/codegen/optimizer.c index b9970748d..3b0f619ae 100644 --- a/compiler/codegen/optimizer.c +++ b/compiler/codegen/optimizer.c @@ -1,5 +1,6 @@ #include "optimizer.h" #include "../aether_error.h" +#include "../analysis/hoist.h" #include #include #include @@ -845,6 +846,7 @@ ASTNode* optimize_dead_code(ASTNode* node) { free(node->value); free_type(node->node_type); free(node->children); + hoist_node_freed(node); free(node); return optimize_dead_code(then_branch); @@ -857,6 +859,7 @@ ASTNode* optimize_dead_code(ASTNode* node) { free(node->value); free_type(node->node_type); free(node->children); + hoist_node_freed(node); free(node); return optimize_dead_code(else_branch); diff --git a/new_changelogs/20261022T163844Z-performance-3083-hoisting-linear.md b/new_changelogs/20261022T163844Z-performance-3083-hoisting-linear.md new file mode 100644 index 000000000..fe2993cf2 --- /dev/null +++ b/new_changelogs/20261022T163844Z-performance-3083-hoisting-linear.md @@ -0,0 +1,10 @@ +- **Hoisting a function's branch-bound locals is linear in them.** The + checker and codegen walked the function body once per name bound in its + branches (the joins, the written type, the first binding, whether the + name is read), codegen scanned every declared local per lookup, and the + unused-variable pass compared every reference with every local. Each + question now reads what one walk of the body gathered: a generated main + with 3000 such names compiles in 0.13 s, from 2.57 s, and one with 6000 + in 0.25 s, from 16.17 s. A function also hoisted at most 64 names out of + its branches, so a 65th, bound in an arm and read after it, was refused + as "Undefined variable"; any number is hoisted now (#3083). diff --git a/tests/integration/hoist_scaling/test_hoist_scaling.sh b/tests/integration/hoist_scaling/test_hoist_scaling.sh new file mode 100644 index 000000000..14b60bfde --- /dev/null +++ b/tests/integration/hoist_scaling/test_hoist_scaling.sh @@ -0,0 +1,79 @@ +#!/bin/sh +# Regression (#3083): hoisting answers each name from one walk of its body, +# at any number of names. +# +# The checker and codegen walked the function body once per name bound in +# its branches (the joins, the written type, the first binding, whether a +# name is read), and the unused-variable pass compared every reference with +# every local: a function with 3000 such names took 2.6 s to compile. Each +# question now reads what one walk of the body gathered. A function could +# also hoist at most 64 names out of its branches: the 65th, bound in an arm +# and read after it, was "Undefined variable". +# +# This is the functional half: a generated main large enough that every +# indexed path is taken, checking the answers. The timing half is the table +# in #3083; a wall-clock assertion would be a flake on a loaded runner. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" + +if [ ! -x "$AE" ] && [ ! -x "$AE.exe" ]; then + echo " [SKIP] hoist_scaling: $AE not built" + exit 0 +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +N=300 + +awk -v n="$N" 'BEGIN { + print "import std.string" + print "" + print "main() {" + print " c = 1" + print " long total = 0" + for (i = 0; i < n; i++) { + # One arm binds it, a read after the if hoists it (past 64 names). + printf " if c > 0 {\n a%d = %d\n }\n", i, i + # Both arms bind it: an int beside a long joins to a long. + printf " if c > %d {\n b%d = 4000000000\n } else {\n b%d = %d\n }\n", i + 5, i, i, i + # `null` beside a string joins to a string. + printf " if c > 0 {\n s%d = \"s\"\n } else {\n s%d = null\n }\n", i, i + # A binding that writes its type is the type. + printf " if c > 0 {\n uint64 w%d = 7\n } else {\n w%d = 1\n }\n", i, i + printf " c = c + 1\n" + printf " total = total + a%d + b%d + string.length(s%d) + w%d\n", i, i, i, i + } + print " unused_one = 5" + print " println(\"total=${total} c=${c}\")" + print "}" +}' > "$tmp/big.ae" + +OUT="$(AETHER_HOME="$ROOT" "$AE" run "$tmp/big.ae" 2>&1)" +RC=$? +if [ $RC -ne 0 ]; then + echo " [FAIL] hoist_scaling: the $N-name program did not compile/run" + echo "$OUT" | grep -E "error|rror:" | head -6 + exit 1 +fi + +# c is i + 1 at the i-th round, so `c > i + 5` never holds and b_i = i (its +# other arm only makes it a long). total = sum(a_i) + sum(b_i) + n * len("s") +# + n * 7 = n(n-1) + 8n. +want_total=$((N * (N - 1) + 8 * N)) +want_c=$((N + 1)) +fail=0 +if ! echo "$OUT" | grep -Fxq "total=$want_total c=$want_c"; then + echo " [FAIL] hoist_scaling: expected total=$want_total c=$want_c" + echo "$OUT" | tail -4 + fail=1 +fi +warned="$(echo "$OUT" | grep -c "unused variable")" +if [ "$warned" -ne 1 ] || ! echo "$OUT" | grep -q "unused variable 'unused_one'"; then + echo " [FAIL] hoist_scaling: expected one unused-variable warning, for unused_one ($warned)" + echo "$OUT" | grep "unused variable" | head -4 + fail=1 +fi +[ "$fail" -eq 0 ] || exit 1 +echo " [PASS] hoist_scaling: $N names hoisted out of branches, joined and read through the indexes" From e3c7eeba8338769f5bc383e15eecb31b77ba3f72 Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Thu, 22 Oct 2026 13:50:53 -0300 Subject: [PATCH 39/50] tcp.write_all, the TLS records and std.http's server send a whole buffer (#3108) std.tcp's writes are one send(2) each and return its count, which can be short: every socket std.net opens has a 30 s send timeout, and a send that runs into it while the peer reads slowly returns the bytes that fit by then, as one a signal cuts short does. tls13_client and tls13_server sent each record with one write_n and took a short count for "failed to send record", with part of the record already at the peer. std.http's server had the same send under every plaintext response, WebSocket frame, SSE event and upgrade reply (conn_send): the rest of a response never went, and the client got it truncated. - tcp_send_all_raw sends until every byte is out, retrying an interrupted send and stopping at one that fails or times out having sent nothing; tcp.write_all(sock, data: byte[]) returns (data.len, "") or (sent, "send failed"). The TLS modules' record sends use it, and write_n's and write_slice's notes point to it. - conn_send sends until every byte is out, retrying an interrupted send, and returns the length, or -1 once a send fails. The server's TLS paths send through their own layers, unchanged. Tests: - tests/runtime/test_runtime_net.c (a 4 KiB send buffer and a 500 ms send timeout on a socketpair): one 256 KiB send to a peer that is not reading comes back short (4096 bytes on macOS); with a reader that pauses 750 ms after 64 KiB, tcp_send_all_raw delivers all 262144 bytes in order. Made to send once, it delivered 69632. - std/tcp/test_tcp_slices.ae: write_all over loopback, a 48 KiB payload read back in order, an empty slice, an unbounded one. - tests/runtime/test_http_server.c: the same socketpair and reader under http_server_drain_connection with a 256 KiB response: every byte arrives, in order. With one send(2), 69510 of 262144 body bytes did. --- docs/api/index.html | 3 +- docs/api/tcp.html | 11 ++- docs/stdlib-reference.md | 2 +- ...261022T165053Z-added-3108-tcp-write-all.md | 9 ++ ...900Z-fixed-3108-http-server-short-sends.md | 6 ++ std/cryptography/tls13_client/module.ae | 10 +-- std/cryptography/tls13_server/module.ae | 10 +-- std/net/aether_http_server.c | 20 ++++- std/net/aether_net.c | 26 ++++++ std/net/aether_net.h | 3 + std/tcp/README.md | 16 ++-- std/tcp/module.ae | 29 +++++-- std/tcp/test_tcp_slices.ae | 35 +++++++- tests/runtime/test_http_server.c | 86 ++++++++++++++++++ tests/runtime/test_runtime_net.c | 87 +++++++++++++++++++ 15 files changed, 322 insertions(+), 31 deletions(-) create mode 100644 new_changelogs/20261022T165053Z-added-3108-tcp-write-all.md create mode 100644 new_changelogs/20261022T180900Z-fixed-3108-http-server-short-sends.md diff --git a/docs/api/index.html b/docs/api/index.html index 5680430be..19fea2194 100644 --- a/docs/api/index.html +++ b/docs/api/index.html @@ -474,7 +474,7 @@

    tar

    tcp

    std.tcp - TCP Socket Operations (alias for std.net) Raw externs end in `_raw` and return NULL/int in C-style.

    - 35 exports + 36 exports

    time

    @@ -2417,6 +2417,7 @@

    Search Results

  • writetcp
  • write_ntcp
  • write_slicetcp
  • +
  • write_alltcp
  • readtcp
  • read_ntcp
  • read_intotcp
  • diff --git a/docs/api/tcp.html b/docs/api/tcp.html index 95ffb705c..dc5ffcab3 100644 --- a/docs/api/tcp.html +++ b/docs/api/tcp.html @@ -37,6 +37,7 @@

    Exports

  • write
  • write_n
  • write_slice
  • +
  • write_all
  • read
  • read_n
  • read_into
  • @@ -259,13 +260,19 @@

    write fn

    write_n fn

    write_n(sock, data)

    sock: ptr, data: byte[]

    -

    Length-aware socket write: sends the bytes of `data`, whose length travels with the slice (#2301), so embedded NUL bytes do not truncate the send. Returns the OS send(2) byte count; callers that require full-buffer delivery should loop, sending `data[n..]` until every byte has been accepted. An empty slice sends nothing and returns (0, ""). For a string, send its byte view: `tcp.write_n(sock, string.bytes(s))`, a prefix with `string.bytes(s)[0..n]`; for a `std.bytes` buffer, `bytes.view(buf)`.

    +

    Length-aware socket write: sends the bytes of `data`, whose length travels with the slice (#2301), so embedded NUL bytes do not truncate the send. Returns the OS send(2) byte count, which may be short: a caller that needs every byte delivered calls write_all. An empty slice sends nothing and returns (0, ""). For a string, send its byte view: `tcp.write_n(sock, string.bytes(s))`, a prefix with `string.bytes(s)[0..n]`; for a `std.bytes` buffer, `bytes.view(buf)`.

    write_slice fn

    write_slice(sock, s) -> (int, string)

    sock: ptr, s: byte[]

    -

    Write a `byte[]` slice's bytes, binary-safe: the length travels with the slice (#2301). Returns (bytes_sent, "") or (0, "send failed"). The count is one send(2) and may be SHORT, as with write_n: a caller that needs the whole slice delivered loops on the rest, `s[sent..]`. An empty slice sends nothing and returns (0, ""). A slice with no bound panics.

    +

    Write a `byte[]` slice's bytes, binary-safe: the length travels with the slice (#2301). Returns (bytes_sent, "") or (0, "send failed"). The count is one send(2) and may be SHORT, as with write_n: a caller that needs the whole slice delivered calls write_all. An empty slice sends nothing and returns (0, ""). A slice with no bound panics.

    +
    +
    +

    write_all fn

    +
    write_all(sock, data) -> (_0, string)
    +

    sock: ptr, data: byte[]

    +

    Write every byte of a `byte[]` slice (#3108), as many sends as it takes: one send can accept fewer bytes than asked, when the socket's send timeout (30 s) expires while the peer reads slowly, or a signal cuts it short. Returns (data.len, "") once every byte is out, or (sent, "send failed") with the bytes sent before a send failed or timed out having sent nothing. An empty slice sends nothing and returns (0, ""); a slice with no bound panics.

    read fn

    diff --git a/docs/stdlib-reference.md b/docs/stdlib-reference.md index 80804db78..0d2f3ee77 100644 --- a/docs/stdlib-reference.md +++ b/docs/stdlib-reference.md @@ -83,7 +83,7 @@ header comment is the authoritative description. | `std.string` | Managed strings: construction, search, slicing, case, split and join. | 100 | [full section](#strings-stdstring) | | `std.sync` | Atomic 64-bit integer cell (load, store, add, sub, compare-and-swap) for refcounts and lock-free reclamation. | 7 | [guide](../std/sync/README.md) · [source](../std/sync/module.ae) | | `std.tar` | Streaming POSIX ustar archives: reader and writer. | 24 | [full section](#posix-ustar-archives-stdtar) | -| `std.tcp` | TCP sockets, re-exported from `std.net`. | 35 | [guide](../std/tcp/README.md) · [source](../std/tcp/module.ae) | +| `std.tcp` | TCP sockets, re-exported from `std.net`. | 36 | [guide](../std/tcp/README.md) · [source](../std/tcp/module.ae) | | `std.udp` | Datagram sockets for game networking: non-blocking bind, send_to, recv_from, poll, and address values. | 40 | [guide](../std/udp/README.md) · [source](../std/udp/module.ae) | | `std.time` | Civil date and time over Unix epoch seconds (UTC). | 22 | [guide](../std/time/README.md) · [source](../std/time/module.ae) | | `std.tracking` | Leak-detecting allocator wrapper. | 5 | [guide](../std/tracking/README.md) · [source](../std/tracking/module.ae) | diff --git a/new_changelogs/20261022T165053Z-added-3108-tcp-write-all.md b/new_changelogs/20261022T165053Z-added-3108-tcp-write-all.md new file mode 100644 index 000000000..5beeaaee6 --- /dev/null +++ b/new_changelogs/20261022T165053Z-added-3108-tcp-write-all.md @@ -0,0 +1,9 @@ +- **`tcp.write_all(sock, data: byte[])` sends a whole buffer, and the TLS + modules send their records through it.** `write`, `write_n` and + `write_slice` each make one send and return its count, which can be + short when the socket's 30 s send timeout expires while a peer reads + slowly, or a signal cuts the send off. tls13_client and tls13_server sent + each record with one `write_n` and reported a short count as "failed to + send record", with part of the record already at the peer. `write_all` + sends until every byte is out or a send fails, and returns `(sent, err)` + (#3108). diff --git a/new_changelogs/20261022T180900Z-fixed-3108-http-server-short-sends.md b/new_changelogs/20261022T180900Z-fixed-3108-http-server-short-sends.md new file mode 100644 index 000000000..7752f2b69 --- /dev/null +++ b/new_changelogs/20261022T180900Z-fixed-3108-http-server-short-sends.md @@ -0,0 +1,6 @@ +- **std.http's server sends every byte of a response.** On a plaintext + connection each response, WebSocket frame and SSE event went out as one + send(2), which can take fewer bytes than asked when a signal or a send + timeout cuts it off once part of the buffer is out; the rest never went, + and the client got a truncated response. The server now sends until every + byte is out, retrying an interrupted send (#3108). diff --git a/std/cryptography/tls13_client/module.ae b/std/cryptography/tls13_client/module.ae index 19b3bd8b4..1154bc8f8 100644 --- a/std/cryptography/tls13_client/module.ae +++ b/std/cryptography/tls13_client/module.ae @@ -554,12 +554,12 @@ struct ClientConn { res_hash_len: int } -// Send the first `n` bytes of the std.bytes buffer `rec` on `sock` as a -// borrowed view of the buffer, with no string copy (#2301). Returns send(2)'s -// count, or -1 on failure: the contract the call sites had when they copied -// the record into a string for net.tcp_send_n_raw. +// Send the record `rec` on `sock`, a borrowed view of its buffer with no +// string copy (#2301), every byte of it however many sends that takes +// (#3108): a short send left the peer holding part of a record. Returns the +// record's length, or -1 on failure. fn cli_send_view(sock: ptr, rec: byte[]) -> int { - sent, err = tcp.write_n(sock, rec) + sent, err = tcp.write_all(sock, rec) if err != "" { return -1 } diff --git a/std/cryptography/tls13_server/module.ae b/std/cryptography/tls13_server/module.ae index 92c567e74..fe9ce04a4 100644 --- a/std/cryptography/tls13_server/module.ae +++ b/std/cryptography/tls13_server/module.ae @@ -153,12 +153,12 @@ fn recio_next_hs(r: *RecordIO) -> (ptr, int, string) { return rec, rlen, err } -// Send the first `n` bytes of the std.bytes buffer `rec` on `sock` as a -// borrowed view of the buffer, with no string copy (#2301). Returns send(2)'s -// count, or -1 on failure: the contract the call sites had when they copied -// the record into a string for net.tcp_send_n_raw. +// Send the record `rec` on `sock`, a borrowed view of its buffer with no +// string copy (#2301), every byte of it however many sends that takes +// (#3108): a short send left the peer holding part of a record. Returns the +// record's length, or -1 on failure. fn srv_send_view(sock: ptr, rec: byte[]) -> int { - sent, err = tcp.write_n(sock, rec) + sent, err = tcp.write_all(sock, rec) if err != "" { return -1 } diff --git a/std/net/aether_http_server.c b/std/net/aether_http_server.c index 2896c6a03..6380f5cda 100644 --- a/std/net/aether_http_server.c +++ b/std/net/aether_http_server.c @@ -379,6 +379,9 @@ static int conn_recv(HttpConn* c, void* buf, int len) { return (int)recv(c->fd, buf, len, 0); } +/* Every one of `len` bytes, or -1: a plain send(2) can take fewer than asked + * (a signal cuts it short), and every caller here sends a whole response, + * frame or event (#3108). */ static int conn_send(HttpConn* c, const void* buf, int len) { if (c->pure_tls) { return pt_server_send(c->pure_tls, buf, len); @@ -390,7 +393,22 @@ static int conn_send(HttpConn* c, const void* buf, int len) { return n; } #endif - return (int)send(c->fd, buf, len, 0); + const char* p = (const char*)buf; + int sent = 0; + while (sent < len) { + int n = (int)send(c->fd, p + sent, (size_t)(len - sent), 0); + if (n > 0) { + sent += n; + continue; + } +#ifdef _WIN32 + if (n < 0 && WSAGetLastError() == WSAEINTR) continue; +#else + if (n < 0 && errno == EINTR) continue; +#endif + return -1; + } + return sent; } /* Issue #383 zero-copy helpers. */ diff --git a/std/net/aether_net.c b/std/net/aether_net.c index ed3c05d9a..6e6e5f475 100644 --- a/std/net/aether_net.c +++ b/std/net/aether_net.c @@ -15,6 +15,7 @@ TcpReceiveResult tcp_receive_n_raw(TcpSocket* s, int m) { return out; } int tcp_send_bytes_raw(TcpSocket* s, const void* d, int n) { (void)s; (void)d; (void)n; return -1; } +int tcp_send_all_raw(TcpSocket* s, const void* d, int n) { (void)s; (void)d; (void)n; return 0; } int tcp_receive_into_raw(TcpSocket* s, void* b, int c) { (void)s; (void)b; (void)c; return TCP_RECV_CLOSED; } int tcp_close(TcpSocket* s) { (void)s; return 0; } TcpServer* tcp_listen_raw(int p) { (void)p; return NULL; } @@ -261,6 +262,31 @@ int tcp_send_bytes_raw(TcpSocket* sock, const void* data, int length) { return sent < 0 ? -1 : sent; } +/* #3108: every one of `length` bytes, as many send(2) calls as it takes: + * one can take fewer than asked (the send timeout expiring while the peer + * reads slowly, a signal). An interrupted send is retried; the first that + * fails, or times out having sent nothing, ends it. Returns the bytes sent: + * `length` once every one is out, fewer when a send failed. */ +int tcp_send_all_raw(TcpSocket* sock, const void* data, int length) { + if (!sock || !sock->connected || length <= 0 || !data) return 0; + const char* p = (const char*)data; + int sent = 0; + while (sent < length) { + int n = send(sock->fd, p + sent, (size_t)(length - sent), 0); + if (n > 0) { + sent += n; + continue; + } +#ifdef _WIN32 + if (n < 0 && WSAGetLastError() == WSAEINTR) continue; +#else + if (n < 0 && errno == EINTR) continue; +#endif + break; + } + return sent; +} + /* Receive into a caller-owned buffer of `cap` bytes (#2301): one recv(2) * of at most `cap` bytes, nothing allocated. Returns the count (> 0), * TCP_RECV_TIMEOUT for an idle peer (recv-timeout / would-block: the diff --git a/std/net/aether_net.h b/std/net/aether_net.h index 3be0b59a6..24f675194 100644 --- a/std/net/aether_net.h +++ b/std/net/aether_net.h @@ -29,6 +29,9 @@ TcpReceiveResult tcp_receive_n_raw(TcpSocket* sock, int max_bytes); #define TCP_RECV_CLOSED (-1) #define TCP_RECV_TIMEOUT (-2) int tcp_send_bytes_raw(TcpSocket* sock, const void* data, int length); +// tcp_send_all_raw: every one of `length` bytes from `data`, one send(2) +// after another (#3108); the bytes sent, `length` unless a send failed. +int tcp_send_all_raw(TcpSocket* sock, const void* data, int length); int tcp_receive_into_raw(TcpSocket* sock, void* buf, int cap); int tcp_close(TcpSocket* sock); diff --git a/std/tcp/README.md b/std/tcp/README.md index 52c9dc211..942ffb4fb 100644 --- a/std/tcp/README.md +++ b/std/tcp/README.md @@ -40,8 +40,11 @@ main() { Every write and read may transfer **fewer bytes than asked**, which is TCP working as designed rather than an error: each is one `send(2)` or `recv(2)`, -and returns the count it moved. A length-prefixed protocol loops on the -rest until the full count is transferred or the connection dies. +and returns the count it moved. A send comes back short when the socket's +30 s send timeout expires while the peer reads slowly, or a signal cuts it +short. `write_all(sock, s)` sends a whole `byte[]` slice, as many sends as it +takes, and returns `(s.len, "")` or the bytes sent with `"send failed"` (#3108); +a read loops on the rest until the full count is in or the connection dies. ## Binary data in `byte[]` slices @@ -56,12 +59,7 @@ reading, and a slice with no bound (`p as byte[]`, `.len` -1) panics. ```aether,fragment // Send all of `frame`, then read exactly `want` bytes into `buf`. -sent = 0 -while sent < frame.len { - n, err = tcp.write_slice(sock, frame[sent..]) - if err != "" { break } - sent = sent + n -} +sent, werr = tcp.write_all(sock, frame) got = 0 while got < want { n, err = tcp.read_into(sock, buf[got..want]) @@ -106,5 +104,5 @@ while !stopping { ## Exports `connect`, `listen`, `listen_on`, `accept`, `read`, `read_n`, `read_into`, -`write`, `write_n`, `write_slice`, `poll`, `poll2`, `server_poll`, `server_port`, `set_nodelay`, +`write`, `write_n`, `write_slice`, `write_all`, `poll`, `poll2`, `server_poll`, `server_port`, `set_nodelay`, `fd`, `server_fd`, `tcp_close`, `tcp_server_close`. diff --git a/std/tcp/module.ae b/std/tcp/module.ae index 5c6e42346..569e720a0 100644 --- a/std/tcp/module.ae +++ b/std/tcp/module.ae @@ -11,7 +11,7 @@ exports( tcp_server_port_raw, tcp_server_poll_raw, tcp_set_nodelay_raw, tcp_fd_raw, tcp_server_fd_raw, tcp_poll_raw, tcp_poll2_raw, tcp_send_bytes_raw, tcp_receive_into_raw, - connect, write, write_n, write_slice, read, read_n, read_into, + connect, write, write_n, write_slice, write_all, read, read_n, read_into, listen, listen_on, accept, fd, server_fd, server_port, server_poll, set_nodelay, poll, poll2 ) @@ -32,6 +32,7 @@ extern tcp_send_n_raw(sock: ptr, data: ptr, length: int) -> int extern tcp_receive_raw(sock: ptr, max_bytes: int) -> string @heap extern tcp_receive_n_raw(sock: ptr, max_bytes: int) -> (string @heap, int, string) extern tcp_send_bytes_raw(sock: ptr, data: ptr, length: int) -> int +extern tcp_send_all_raw(sock: ptr, data: ptr, length: int) -> int extern tcp_receive_into_raw(sock: ptr, buf: ptr, cap: int) -> int extern tcp_close(sock: ptr) -> int extern tcp_listen_raw(port: int) -> ptr @@ -72,9 +73,9 @@ write(sock: ptr, data: string) -> { // Length-aware socket write: sends the bytes of `data`, whose length // travels with the slice (#2301), so embedded NUL bytes do not truncate -// the send. Returns the OS send(2) byte count; callers that require -// full-buffer delivery should loop, sending `data[n..]` until every byte -// has been accepted. An empty slice sends nothing and returns (0, ""). +// the send. Returns the OS send(2) byte count, which may be short: a +// caller that needs every byte delivered calls write_all. An empty slice +// sends nothing and returns (0, ""). // // For a string, send its byte view: `tcp.write_n(sock, string.bytes(s))`, // a prefix with `string.bytes(s)[0..n]`; for a `std.bytes` buffer, @@ -131,8 +132,8 @@ fn need_bound(fname: string, s: byte[]) { // Write a `byte[]` slice's bytes, binary-safe: the length travels with // the slice (#2301). Returns (bytes_sent, "") or (0, "send failed"). The // count is one send(2) and may be SHORT, as with write_n: a caller that -// needs the whole slice delivered loops on the rest, `s[sent..]`. An empty -// slice sends nothing and returns (0, ""). A slice with no bound panics. +// needs the whole slice delivered calls write_all. An empty slice sends +// nothing and returns (0, ""). A slice with no bound panics. write_slice(sock: ptr, s: byte[]) -> { need_bound("write_slice", s) n = tcp_send_bytes_raw(sock, s, s.len) @@ -142,6 +143,22 @@ write_slice(sock: ptr, s: byte[]) -> { return n, "" } +// Write every byte of a `byte[]` slice (#3108), as many sends as it takes: +// one send can accept fewer bytes than asked, when the socket's send +// timeout (30 s) expires while the peer reads slowly, or a signal cuts it +// short. Returns (data.len, "") once every byte is out, or (sent, "send +// failed") with the bytes sent before a send failed or timed out having +// sent nothing. An empty slice sends nothing and returns (0, ""); a slice +// with no bound panics. +write_all(sock: ptr, data: byte[]) -> { + need_bound("write_all", data) + n = tcp_send_all_raw(sock, data, data.len) + if n < data.len { + return n, "send failed" + } + return n, "" +} + // Read into a caller-owned `byte[]` (#2301): one receive of at most // `buf.len` bytes, nothing allocated, never a byte past `buf.len`. Returns // (n, "") with the data in `buf[0..n]`, which may be fewer bytes than diff --git a/std/tcp/test_tcp_slices.ae b/std/tcp/test_tcp_slices.ae index 8211e1039..34ddabd0a 100644 --- a/std/tcp/test_tcp_slices.ae +++ b/std/tcp/test_tcp_slices.ae @@ -8,7 +8,10 @@ // Over loopback on an ephemeral port: a binary payload with zero bytes read // back however TCP splits it, a read slice smaller than what is queued (the // rest stays for the next read), guard bytes either side, empty and -// unbounded slices, and a closed peer. +// unbounded slices, and a closed peer. write_all (#3108) sends a 48 KiB +// payload whole, read back in order; the short sends it survives are +// forced in tests/runtime/test_runtime_net.c, which can shrink a socket's +// buffers and send timeout. import std.tcp import std.bytes @@ -51,6 +54,16 @@ fn tcp_write_panics(sock: ptr, s: byte[]) -> bool { return caught } +fn tcp_write_all_panics(sock: ptr, s: byte[]) -> bool { + caught = false + try { + _n, _e = tcp.write_all(sock, s) + } catch e { + caught = true + } + return caught +} + fn tcp_read_panics(sock: ptr, s: byte[]) -> bool { caught = false try { @@ -124,6 +137,26 @@ main() { check(tcp_read_full(peer, recv[10 + k..22]), "the rest is still queued") check(same(recv[10..22], payload[2..14]), "the whole sub-slice arrived in order") + // write_all: a payload larger than one segment, every byte, in order. + bigb = bytes.new(49152) + bytes.set_length(bigb, 49152) + big = bytes.view(bigb) + for i in 0..big.len { + big[i] = (i * 131 + 7) & 255 + } + backb = bytes.new(49152) + bytes.set_length(backb, 49152) + back = bytes.view(backb) + asent, aerr2 = tcp.write_all(conn, big) + check(aerr2 == "" && asent == big.len, "write_all sends every byte, got ${asent} '${aerr2}'") + check(tcp_read_full(peer, back), "the whole payload is read back") + check(same(back, big), "write_all's bytes arrive in order") + asent, aerr2 = tcp.write_all(conn, big[0..0]) + check(aerr2 == "" && asent == 0, "write_all of an empty slice sends nothing") + check(tcp_write_all_panics(conn, raw), "write_all refuses an unbounded slice") + bytes.free(bigb) + bytes.free(backb) + // Empty and unbounded slices. k, rerr = tcp.read_into(peer, recv[0..0]) check(rerr == "receive buffer is empty" && k == 0, "an empty read slice is refused, got '${rerr}'") diff --git a/tests/runtime/test_http_server.c b/tests/runtime/test_http_server.c index e827ffc63..9474f31d7 100644 --- a/tests/runtime/test_http_server.c +++ b/tests/runtime/test_http_server.c @@ -9,8 +9,11 @@ #include #else #include +#include #include #include +#include +#include #endif #include "../../std/net/aether_http_server.h" #include "../../std/http/proxy/aether_proxy.h" @@ -656,3 +659,86 @@ TEST(http_proxy_cache_keeps_a_long_ttl) { ASSERT_TRUE(found); ASSERT_TRUE(ttl_ms == 30LL * 24 * 3600 * 1000); } + +#if !defined(_WIN32) +/* #3108: the server's sends went out as one send(2) each, which can take + * fewer bytes than asked: a signal or a send timeout cuts it short once part + * of the buffer is out. A response written that way reached the client + * truncated. Here the server's end of a socketpair has a 4 KiB buffer and a + * 500 ms send timeout, and the client pauses 750 ms after 64 KiB of a + * 256 KiB response; the rest goes when it reads on. */ +enum { BIG_BODY = 256 * 1024 }; + +static void big_body_handler(HttpRequest* req, HttpServerResponse* res, void* ud) { + (void)req; + const char* body = (const char*)ud; + http_response_set_status(res, 200); + http_response_set_body_n(res, body, BIG_BODY); +} + +typedef struct { + int fd; + char* buf; + int cap; + int got; +} PausingReader; + +static void* pausing_reader(void* arg) { + PausingReader* r = (PausingReader*)arg; + int paused = 0; + while (r->got < r->cap) { + int n = (int)recv(r->fd, r->buf + r->got, (size_t)(r->cap - r->got), 0); + if (n <= 0) break; + r->got += n; + if (!paused && r->got >= 64 * 1024) { + paused = 1; + usleep(750000); + } + } + return NULL; +} + +TEST_CATEGORY(http_server_sends_a_whole_response_through_short_sends, TEST_CATEGORY_NETWORK) { + char* body = (char*)malloc(BIG_BODY); + ASSERT_NOT_NULL(body); + for (int i = 0; i < BIG_BODY; i++) body[i] = (char)('a' + i % 26); + + HttpServer* server = http_server_create(0); + ASSERT_NOT_NULL(server); + http_server_get(server, "/big", big_body_handler, body); + http_server_set_keepalive_raw(server, 0, 0, 0); + + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + int small = 4096; + struct timeval tv = { 0, 500000 }; + setsockopt(fds[0], SOL_SOCKET, SO_SNDBUF, &small, sizeof(small)); + setsockopt(fds[1], SOL_SOCKET, SO_RCVBUF, &small, sizeof(small)); + setsockopt(fds[0], SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + + const char* req = "GET /big HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"; + ASSERT_EQ((int)strlen(req), (int)send(fds[1], req, strlen(req), 0)); + + PausingReader r = { fds[1], (char*)malloc(BIG_BODY + 4096), BIG_BODY + 4096, 0 }; + ASSERT_NOT_NULL(r.buf); + pthread_t reader; + ASSERT_EQ(0, pthread_create(&reader, NULL, pausing_reader, &r)); + http_server_drain_connection(server, fds[0]); /* closes fds[0] */ + pthread_join(reader, NULL); + + char* head_end = NULL; + for (int i = 0; i + 4 <= r.got; i++) { + if (memcmp(r.buf + i, "\r\n\r\n", 4) == 0) { head_end = r.buf + i + 4; break; } + } + ASSERT_NOT_NULL(head_end); + int body_got = r.got - (int)(head_end - r.buf); + printf(" body received: %d of %d bytes\n", body_got, BIG_BODY); + ASSERT_EQ(BIG_BODY, body_got); + ASSERT_TRUE(memcmp(head_end, body, BIG_BODY) == 0); + + close(fds[1]); + free(r.buf); + http_server_free(server); + free(body); +} +#endif diff --git a/tests/runtime/test_runtime_net.c b/tests/runtime/test_runtime_net.c index 2560cac9c..5f22e93c7 100644 --- a/tests/runtime/test_runtime_net.c +++ b/tests/runtime/test_runtime_net.c @@ -5,8 +5,10 @@ #ifndef _WIN32 #include +#include #include #include +#include #endif TEST_CATEGORY(socket_null_handling, TEST_CATEGORY_NETWORK) { @@ -178,4 +180,89 @@ TEST_CATEGORY(tcp_poll_readiness, TEST_CATEGORY_NETWORK) { close(fds[0]); close(fds[1]); } +/* #3108: a send against a peer that reads slowly can take fewer bytes than + * asked. With a small send buffer, a 500 ms send timeout and nobody + * reading, one send of 256 KiB comes back short. A reader that takes 64 KiB + * and then pauses 750 ms runs a send into its timeout after part of the + * bytes went; tcp_send_all_raw sends the rest when the reader goes on (well + * inside the next send's timeout), and every byte arrives in order. */ +typedef struct { + int fd; + unsigned char* buf; + int len; + int got; +} SlowReader; + +static void* slow_reader(void* arg) { + SlowReader* r = (SlowReader*)arg; + int paused = 0; + while (r->got < r->len) { + int want = r->len - r->got < 4096 ? r->len - r->got : 4096; + int n = (int)recv(r->fd, r->buf + r->got, (size_t)want, 0); + if (n <= 0) break; + r->got += n; + if (!paused && r->got >= 64 * 1024) { + paused = 1; + usleep(750000); + } + } + return NULL; +} + +static void shrink_sender(int send_fd, int recv_fd) { + int small = 4096; + struct timeval tv = { 0, 500000 }; + setsockopt(send_fd, SOL_SOCKET, SO_SNDBUF, &small, sizeof(small)); + setsockopt(recv_fd, SOL_SOCKET, SO_RCVBUF, &small, sizeof(small)); + setsockopt(send_fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); +} + +TEST_CATEGORY(tcp_send_all_delivers_through_short_sends, TEST_CATEGORY_NETWORK) { + enum { LEN = 256 * 1024 }; + unsigned char* out = (unsigned char*)malloc(LEN); + unsigned char* in = (unsigned char*)calloc(1, LEN); + ASSERT_NOT_NULL(out); + ASSERT_NOT_NULL(in); + for (int i = 0; i < LEN; i++) out[i] = (unsigned char)(i * 131 + 7); + + int stalled[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, stalled)); + shrink_sender(stalled[0], stalled[1]); + TcpSocket stuck = { stalled[0], 1 }; + errno = 0; + int one = tcp_send_bytes_raw(&stuck, out, LEN); + if (one < 0 && errno == EPERM) { + printf(" SKIP tcp_send_all_delivers_through_short_sends: send denied by sandbox\n"); + close(stalled[0]); close(stalled[1]); free(out); free(in); + return; + } + printf(" one send to a peer that is not reading: %d of %d bytes\n", one, LEN); + ASSERT_TRUE(one < LEN); + close(stalled[0]); + close(stalled[1]); + + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + shrink_sender(fds[0], fds[1]); + TcpSocket left = { fds[0], 1 }; + SlowReader r = { fds[1], in, LEN, 0 }; + pthread_t reader; + ASSERT_EQ(0, pthread_create(&reader, NULL, slow_reader, &r)); + int all = tcp_send_all_raw(&left, out, LEN); + shutdown(fds[0], SHUT_WR); /* the reader's EOF, if not every byte went */ + pthread_join(reader, NULL); + ASSERT_EQ(LEN, all); + ASSERT_EQ(LEN, r.got); + ASSERT_EQ(0, memcmp(out, in, LEN)); + + /* Nothing to send is nothing sent, and no handle sends nothing. */ + ASSERT_EQ(0, tcp_send_all_raw(&left, out, 0)); + ASSERT_EQ(0, tcp_send_all_raw(NULL, out, 16)); + + close(fds[0]); + close(fds[1]); + free(out); + free(in); +} + #endif From f3f83235d59a1afbb7b5aee024f7caf707646b3e Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Thu, 22 Oct 2026 13:54:26 -0300 Subject: [PATCH 40/50] std.http's server clears the receive timeout once a WebSocket is upgraded (#3118) Every HTTP connection carries SO_RCVTIMEO set to the keep-alive idle timeout (30 s by default), which guards the wait for the next request. An upgraded WebSocket kept it, so the handler's blocking ws_recv timed out on a client quiet that long, ws_recv_exact took the failed recv for a close, and ws_recv returned -1: a chat tab in the background or a dashboard between updates was dropped. Once the 101 has gone out the server clears it (conn_set_recv_timeout, the setter conn_apply_recv_timeout now goes through, with the per-connection bookkeeping of #1719); ws_recv_timeout still bounds a wait a handler wants bounded. The client side never set one. Test: tests/integration/ws_idle_server (a 1 s idle timeout, a client quiet for 2.5 s between two messages, both echoed), in the sweep's prune list with the other client/server pairs. Before: the client's wait for the second echo returned -1, the server having closed the connection. --- docs/http-server.md | 6 +- ...61022T165426Z-fixed-3118-websocket-idle.md | 6 ++ std/net/aether_http_server.c | 38 ++++++---- tests/ae_sweep_prune.txt | 1 + .../ws_idle_server/test_ws_idle_server.sh | 70 +++++++++++++++++++ tests/integration/ws_idle_server/wsclient.ae | 39 +++++++++++ tests/integration/ws_idle_server/wsserver.ae | 55 +++++++++++++++ 7 files changed, 201 insertions(+), 14 deletions(-) create mode 100644 new_changelogs/20261022T165426Z-fixed-3118-websocket-idle.md create mode 100644 tests/integration/ws_idle_server/test_ws_idle_server.sh create mode 100644 tests/integration/ws_idle_server/wsclient.ae create mode 100644 tests/integration/ws_idle_server/wsserver.ae diff --git a/docs/http-server.md b/docs/http-server.md index 6d51c0674..8407868ff 100644 --- a/docs/http-server.md +++ b/docs/http-server.md @@ -655,7 +655,11 @@ Same handler-owns-the-connection shape as SSE. The server runs the upgrade handshake first (computes `Sec-WebSocket-Accept = Base64(SHA-1(client_key + magic-uuid))`), sends `101 Switching Protocols`, then hands an `HttpWsConn*` to the -handler. Inside the handler: +handler. The connection's receive timeout, the keep-alive idle one that +guards the wait for the next request, is cleared at the upgrade: a quiet +WebSocket is the protocol at rest, so `ws_recv` waits as long as the peer +stays connected, and `ws_recv_timeout(ws, ms)` bounds a wait when a handler +wants one (#3118). Inside the handler: | Call | Behaviour | |---|---| diff --git a/new_changelogs/20261022T165426Z-fixed-3118-websocket-idle.md b/new_changelogs/20261022T165426Z-fixed-3118-websocket-idle.md new file mode 100644 index 000000000..6a80e2a25 --- /dev/null +++ b/new_changelogs/20261022T165426Z-fixed-3118-websocket-idle.md @@ -0,0 +1,6 @@ +- **std.http's server keeps a quiet WebSocket open.** An upgraded + connection kept the receive timeout the server sets on every HTTP + connection (the keep-alive idle timeout, 30 s by default), so a handler's + blocking `ws_recv` took a client quiet that long for a close and returned + -1. The server clears it once the upgrade has gone out; `ws_recv_timeout` + still bounds a wait a handler wants bounded (#3118). diff --git a/std/net/aether_http_server.c b/std/net/aether_http_server.c index 6380f5cda..a942ca65a 100644 --- a/std/net/aether_http_server.c +++ b/std/net/aether_http_server.c @@ -4359,6 +4359,8 @@ static void conn_send_bad_request_line(HttpConn* conn) { conn->linger_on_close = 1; } +static void conn_set_recv_timeout(HttpConn* c, int ms); + static int handle_one_request(HttpServer* server, HttpConn* conn, int requests_served, int max_requests) { int64_t t_start = http_now_us(); @@ -4830,6 +4832,12 @@ static int handle_one_request(HttpServer* server, HttpConn* conn, http_server_response_free(res); return 0; /* close */ } + /* #3118: the idle timeout guards the wait for the next + * request; a WebSocket at rest between messages is the + * protocol working. Left on, a blocking ws_recv took + * the timeout for a close and dropped any client quiet + * that long. ws_recv_timeout bounds a wait of its own. */ + conn_set_recv_timeout(conn, 0); HttpWsConn ws_handle = { .conn = conn, .closed = 0, .msg_buf = NULL, .msg_cap = 0, @@ -5208,27 +5216,31 @@ static int64_t conn_now_ms(void) { return (int64_t)time(NULL) * 1000; } -/* Per-recv socket timeout, applied whenever a worker is about to block on - * this connection. Also the idle guard against a slow-loris client. */ -static void conn_apply_recv_timeout(HttpServer* server, HttpConn* c) { - int fd = c->fd; - int idle_ms = server->keep_alive_idle_ms > 0 - ? server->keep_alive_idle_ms : 30000; - if (c->applied_recv_timeout_ms == idle_ms) return; - c->applied_recv_timeout_ms = idle_ms; +/* The receive timeout on `c`'s socket: `ms` milliseconds, 0 for none (a + * recv then waits as long as the peer stays connected). */ +static void conn_set_recv_timeout(HttpConn* c, int ms) { + if (c->applied_recv_timeout_ms == ms) return; + c->applied_recv_timeout_ms = ms; #ifdef _WIN32 - DWORD rcv_timeout = (DWORD)idle_ms; - setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, + DWORD rcv_timeout = (DWORD)ms; + setsockopt(c->fd, SOL_SOCKET, SO_RCVTIMEO, (const char*)&rcv_timeout, sizeof(rcv_timeout)); #else struct timeval rcv_tv = { - .tv_sec = idle_ms / 1000, - .tv_usec = (idle_ms % 1000) * 1000 + .tv_sec = ms / 1000, + .tv_usec = (ms % 1000) * 1000 }; - setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &rcv_tv, sizeof(rcv_tv)); + setsockopt(c->fd, SOL_SOCKET, SO_RCVTIMEO, &rcv_tv, sizeof(rcv_tv)); #endif } +/* Per-recv socket timeout, applied whenever a worker is about to block on + * this connection. Also the idle guard against a slow-loris client. */ +static void conn_apply_recv_timeout(HttpServer* server, HttpConn* c) { + conn_set_recv_timeout(c, server->keep_alive_idle_ms > 0 + ? server->keep_alive_idle_ms : 30000); +} + /* Is the next request already here, or about to be? * * Bytes in the connection's own buffer (a pipelined request, or an over-read diff --git a/tests/ae_sweep_prune.txt b/tests/ae_sweep_prune.txt index d4c713215..981ed8c41 100644 --- a/tests/ae_sweep_prune.txt +++ b/tests/ae_sweep_prune.txt @@ -246,6 +246,7 @@ tests/integration/when_in_imported_module/ tests/integration/where_clause/ tests/integration/ws_client_loopback/ tests/integration/ws_client_nonblocking/ +tests/integration/ws_idle_server/ tests/integration/ws_client_conformance/ tests/integration/wss_client_tls/ tests/integration/wycheproof/ diff --git a/tests/integration/ws_idle_server/test_ws_idle_server.sh b/tests/integration/ws_idle_server/test_ws_idle_server.sh new file mode 100644 index 000000000..92f2841bf --- /dev/null +++ b/tests/integration/ws_idle_server/test_ws_idle_server.sh @@ -0,0 +1,70 @@ +#!/bin/sh +# Regression (#3118): std.http's server keeps an upgraded WebSocket open +# while it is quiet. +# +# Every HTTP connection carries a receive timeout, the keep-alive idle one +# (30 s by default), which guards the wait for the next request. After the +# upgrade the handler's blocking ws_recv waited with it still on, took the +# timeout for a close and returned -1: any client quiet that long was +# dropped. The server clears it once the handshake has gone out. Here the +# idle timeout is 1 s and the client is quiet for 2.5 s between messages. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +. "$ROOT/tests/lib/wait_port.sh" +. "$ROOT/tests/lib/server_jobs.sh" +AE="$ROOT/build/ae" + +[ -x "$AE" ] || { echo " [SKIP] ws_idle_server: ae not built"; exit 0; } + +TMP="$(mktemp -d)" +SRV_PID="" +cleanup() { + stop_server "$SRV_PID" + rm -rf "$TMP" || : + return 0 +} +trap cleanup EXIT +fail() { echo " [FAIL] ws_idle_server: $1"; exit 1; } + +if command -v timeout >/dev/null 2>&1; then + TIMEOUT="timeout 30" +elif command -v gtimeout >/dev/null 2>&1; then + TIMEOUT="gtimeout 30" +else + TIMEOUT="" +fi + +"$AE" build "$SCRIPT_DIR/wsserver.ae" -o "$TMP/wssrv" >"$TMP/b1.log" 2>&1 \ + || { sed -n '1,10p' "$TMP/b1.log"; fail "server did not build"; } +"$AE" build "$SCRIPT_DIR/wsclient.ae" -o "$TMP/wscli" >"$TMP/b2.log" 2>&1 \ + || { sed -n '1,10p' "$TMP/b2.log"; fail "client did not build"; } + +"$TMP/wssrv" > "$TMP/srv.log" 2>&1 & +SRV_PID=$! + +i=0 +while [ "$i" -lt 60 ]; do + grep -q READY "$TMP/srv.log" 2>/dev/null && break + sleep 0.1 + i=$((i + 1)) +done +grep -q READY "$TMP/srv.log" 2>/dev/null || { + sed -n '1,10p' "$TMP/srv.log" + fail "the echo server never became READY" +} +PORT=$(read_ready_port "$TMP/srv.log") || exit 1 + +OUT=$(AE_TEST_PORT="$PORT" $TIMEOUT "$TMP/wscli" 2>&1) || { + echo "$OUT" | sed 's/^/ /' + sed 's/^/ server: /' "$TMP/srv.log" + fail "the client exited non-zero" +} +case "$OUT" in + *"PASS: ws idle round-trip"*) ;; + *) echo "$OUT" | sed 's/^/ /'; fail "the round-trips did not report PASS" ;; +esac + +echo " [PASS] ws_idle_server: an upgraded WebSocket outlives the 1 s idle timeout" diff --git a/tests/integration/ws_idle_server/wsclient.ae b/tests/integration/ws_idle_server/wsclient.ae new file mode 100644 index 000000000..2c91609c8 --- /dev/null +++ b/tests/integration/ws_idle_server/wsclient.ae @@ -0,0 +1,39 @@ +// The client for ws_idle_server: a message, 2.5 s of quiet (past the +// server's 1 s idle timeout), then another, each echoed. +import std.http +import std.os +import std.string + +extern exit(code: int) + +fn round_trip(w: ptr, text: string) { + if http.ws_send_text(w, text) < 0 { + println("FAIL: ws_send_text '${text}'") + exit(1) + } + kind = http.ws_recv(w) + if kind != 1 { + println("FAIL: the echo of '${text}': ws_recv returned ${kind}, want 1 (text)") + exit(1) + } + got = http.ws_message(w) + if string.compare(got, "echo: ${text}") != 0 { + println("FAIL: got [${got}] want [echo: ${text}]") + exit(1) + } +} + +main() { + w = http.ws_connect("ws://127.0.0.1:${os.getenv("AE_TEST_PORT")}/echo") + if w == null { + println("FAIL: ws_connect returned null") + exit(1) + } + round_trip(w, "first") + sleep(2500) + round_trip(w, "after the quiet") + http.ws_close(w, 1000, "done") + http.ws_client_free(w) + println("PASS: ws idle round-trip") + exit(0) +} diff --git a/tests/integration/ws_idle_server/wsserver.ae b/tests/integration/ws_idle_server/wsserver.ae new file mode 100644 index 000000000..25ac70284 --- /dev/null +++ b/tests/integration/ws_idle_server/wsserver.ae @@ -0,0 +1,55 @@ +// The echo server for ws_idle_server: a 1 s idle timeout, which guards the +// wait for the next HTTP request and must not reach an upgraded WebSocket. + +import std.http + +extern exit(code: int) +extern http_server_start_raw(server: ptr) -> int +extern http_server_bind_raw(server: ptr, host: string, port: int) -> int +extern http_server_port(server: ptr) -> int + +@c_callback echo_handler(req: ptr, ws: ptr, ud: ptr) { + n = 0 + done = 0 + while n < 2 && done == 0 { + if http.ws_recv(ws) == -1 { + println("server: closed") + done = 1 + } else { + http.ws_send_text(ws, "echo: ${http.ws_message(ws)}") + n = n + 1 + } + } +} + +message StartSrv { raw: ptr } +message Noop {} +actor SrvActor { + state s = 0 + receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } +} +actor SchedHelper { + state x = 0 + receive { Noop() -> { x = 1 } } +} + +main() { + raw = http.server_create(0) + if raw == 0 { println("FAIL: server_create"); exit(1) } + http.server_set_host(raw, "127.0.0.1") + kerr = http.server_set_keepalive(raw, 1, 0, 1000ms) + if kerr != "" { println("FAIL: server_set_keepalive: ${kerr}"); exit(1) } + http.server_websocket(raw, "/echo", echo_handler, null) + if http_server_bind_raw(raw, "127.0.0.1", 0) < 0 { + println("FAIL: bind") + exit(1) + } + println("READY ${http_server_port(raw)}") + + spawn(SchedHelper()) + a = spawn(SrvActor()) + a ! StartSrv { raw: raw } + + sleep(30000) + exit(0) +} From bed800c8d3a8d6e6e178bd84f3c71cdc226249c3 Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Thu, 22 Oct 2026 14:38:37 -0300 Subject: [PATCH 41/50] A send is asynchronous whatever the actor count; send batches only wrap loops that cannot wait (#3112, #3117) #3112. With one actor the runtime ran the handler inside the sender's `!` (main-thread mode) until a second actor was spawned: a handler that blocked on something its sender did next deadlocked the program, output order depended on the actor count, a C host had to poll to keep the actor running, and the payload pointed at the sender's stack (g_skip_free). Tests and examples spawned a do-nothing actor to get the real behaviour. - Every send goes through the scheduler from the first actor on. Main-thread mode is gone: main_thread_only, the inline enqueue and step paths and their locks, g_sync_step_actor, g_skip_free, the deferred release for inline steps, scheduler_ensure_threads_running, and the AETHER_INLINE / AETHER_NO_INLINE switches. - aether_scheduler_poll returns 0 with threads. - The cooperative runtime queues a send instead of stepping the actor: into the mailbox, and past its 32 messages into the actor's inbox (the unbounded FIFO the actor threads use), refilled by aether_scheduler_poll, which wait_for_idle and shutdown run. - A message with no fields goes as a one-int one does, with no payload to allocate. - The 47 do-nothing actors in tests/ and contrib/ are removed. Unit tests that let a second spawn start the cores call scheduler_start(), and test_scheduler's two tests lose the poller thread whose walk went with the mode. foreign_thread_send keeps its scheduler-mode half. #3117. The compiler opened a send batch around every `while` loop of a non-actor function that sent. With #3112 every program shows the effects: - a loop that waited held its messages until it ended; - a payload message went around the batch and overtook the one-int ones (test_closure_actor_state_message_release, the one failure of the full .ae sweep on #3112 alone); - an inner loop's batch_start emptied the outer one's buffer. - The compiler batches only a loop that cannot wait between its sends: no call, print, ask, actor-state read, `return`, labeled `break` or `continue` (which can leave for an enclosing loop, past the flush) or closure in it. An inner loop joins the outer loop's batch, and a payload send in it joins too (aether_send_message_batch). - At run time, opening a batch keeps what it holds. A foreign send around a pending batch flushes it first, and so does wait_for_idle. A scheduler thread batches nothing. Measured, max of 11 interleaved runs against the previous commit (M msg/s): counting (1 actor, 10M) 30.70 -> 20.95 the inline handler is gone; 16.72 before the field-less message lost its payload ping_pong (2 actors, 1M) 11.43 -> 11.35 fork_join (1M) 13.72 -> 14.00 Tests: - tests/regression/test_send_async_one_actor.ae: a lone actor's handler waits for main to go on after the send. Before: it saw 0 after 2 s. - tests/regression/test_send_batches.ae: a waiting loop delivers as it goes, a mixed loop keeps its order, nested loops lose nothing, a loop left by `break outer` delivers without a flush after it. Before: "a payload message overtook a one-int one"; batching the labeled-break loop, "kept 5 of 5 messages". All 610 C unit tests pass; the .ae sweep on #3112 alone passed 1844 of 1847 (the batching failure above and two failures that happen only on this Mac). --- compiler/codegen/codegen.c | 31 ++ compiler/codegen/codegen_expr.c | 13 +- compiler/codegen/codegen_internal.h | 1 + compiler/codegen/codegen_stmt.c | 10 +- contrib/tinyweb/example_schema_api.ae | 6 - contrib/tinyweb/test_integration.ae | 7 - contrib/tinyweb/test_schema_api.ae | 6 - contrib/tinyweb/test_websocket.ae | 10 +- docs/actor-concurrency.md | 18 +- docs/architecture.md | 21 +- docs/c-embedding.md | 7 +- docs/runtime-config.md | 5 +- docs/runtime-optimizations.md | 71 +-- docs/scheduler-quick-reference.md | 27 +- docs/stdlib-reference.md | 4 - ...T171300Z-changed-3112-send-always-async.md | 13 + ...0261022T171534Z-fixed-3117-send-batches.md | 14 + runtime/actors/aether_send_message.c | 233 +--------- runtime/actors/aether_send_message.h | 3 + runtime/config/aether_optimization_config.c | 25 +- runtime/config/aether_optimization_config.h | 65 +-- runtime/scheduler/aether_scheduler_coop.c | 122 +++-- runtime/scheduler/multicore_scheduler.c | 419 ++---------------- runtime/scheduler/multicore_scheduler.h | 42 +- std/net/aether_actor_bridge.c | 26 +- .../integration/binary_import_actors/crowd.ae | 3 +- .../test_foreign_thread_send.sh | 70 +-- .../http_actor_mode_release/shim.c | 8 - tests/integration/http_auth/server.ae | 6 - .../http_client_custom_ca/server.ae | 6 - .../http_client_insecure_tls/server.ae | 6 - tests/integration/http_client_stream/main.ae | 7 - .../http_client_stream_chunked/main.ae | 7 - .../http_h2_concurrent_dispatch/server.ae | 6 - .../http_handler_user_data/server.ae | 6 - .../http_head_and_middleware/server.ae | 6 - .../integration/http_middleware_d1/server.ae | 6 - .../integration/http_middleware_d2/server.ae | 6 - .../http_park_idle_connections/server.ae | 6 - .../http_query_param_multi/server.ae | 6 - tests/integration/http_real_ip/server.ae | 6 - .../http_request_conn_accessors/server.ae | 6 - .../http_request_remote_addr/server.ae | 6 - .../integration/http_reverse_proxy/server.ae | 6 - .../http_reverse_proxy_pool/server.ae | 6 - tests/integration/http_sendfile/server.ae | 6 - .../http_serve_static_range/server.ae | 6 - .../http_server_actor_dispatch/server.ae | 6 - .../http_server_connect_tunnel/server.ae | 7 - tests/integration/http_server_h2/server.ae | 6 - .../http_server_h2_middleware/server.ae | 6 - .../integration/http_server_h2_tls/server.ae | 6 - .../http_server_keepalive/server.ae | 6 - .../http_server_observability/server.ae | 6 - tests/integration/http_server_ops/server.ae | 6 - tests/integration/http_server_sse/server.ae | 6 - tests/integration/http_server_tls/server.ae | 6 - .../http_server_websocket/server.ae | 6 - tests/integration/http_sse_upgrade/server.ae | 6 - .../integration/http_stream_upload/server.ae | 6 - .../https_client_pure_tls/server.ae | 6 - .../https_pure_tls_vertical/server.ae | 6 - .../integration/https_windows_trust/server.ae | 6 - .../panic_actor_step_drain/probe.ae | 4 +- .../test_panic_actor_step_drain.sh | 2 +- tests/integration/std_testing_arms/probe.ae | 7 - tests/integration/test_http_client_v2.ae | 16 +- .../thread_types_layout/layout_probe.c | 1 - .../ws_client_loopback/wsserver.ae | 6 - .../ws_client_nonblocking/wsserver.ae | 6 - tests/integration/ws_idle_server/wsserver.ae | 6 - tests/regression/test_send_async_one_actor.ae | 40 ++ tests/regression/test_send_batches.ae | 103 +++++ tests/runtime/test_scheduler.c | 107 +---- tests/runtime/test_worksteal_race.c | 1 - tests/syntax/test_actor_timeout_multiple.ae | 10 +- tests/syntax/test_await_io.ae | 12 - tests/syntax/test_http_request_body_length.ae | 7 - tests/syntax/test_http_response_accessors.ae | 6 - 79 files changed, 466 insertions(+), 1361 deletions(-) create mode 100644 new_changelogs/20261022T171300Z-changed-3112-send-always-async.md create mode 100644 new_changelogs/20261022T171534Z-fixed-3117-send-batches.md create mode 100644 tests/regression/test_send_async_one_actor.ae create mode 100644 tests/regression/test_send_batches.ae diff --git a/compiler/codegen/codegen.c b/compiler/codegen/codegen.c index e94162cf0..7965f09da 100644 --- a/compiler/codegen/codegen.c +++ b/compiler/codegen/codegen.c @@ -445,6 +445,37 @@ int contains_send_expression(ASTNode* node) { return 0; } +/* #3117: can `node`, a loop, keep its sends in a batch (delivered at the + * loop's end, or 256 at a time)? Only when nothing in it can wait between two + * sends or look at what a send did: it calls nothing, prints nothing, asks + * nothing and reads no actor's state. A send, arithmetic, an assignment, a + * branch and an inner loop are fine: the counting and fork-join shape the + * batch exists for. A `return`, or a labeled `break` or `continue` (which can + * leave for an enclosing loop), would leave the batch unflushed, and a + * closure made in the loop runs outside it, so each keeps the loop out too. */ +int loop_sends_without_waiting(ASTNode* node) { + if (!node) return 1; + switch (node->type) { + case AST_FUNCTION_CALL: case AST_PRINT_STATEMENT: case AST_SEND_ASK: + case AST_CLOSURE: case AST_RETURN_STATEMENT: + return 0; + case AST_BREAK_STATEMENT: case AST_CONTINUE_STATEMENT: + if (node->value) return 0; + break; + case AST_MEMBER_ACCESS: + if (node->child_count > 0 && node->children[0] && node->children[0]->node_type && + node->children[0]->node_type->kind == TYPE_ACTOR_REF) + return 0; + break; + default: + break; + } + for (int i = 0; i < node->child_count; i++) { + if (!loop_sends_without_waiting(node->children[i])) return 0; + } + return 1; +} + // Tree-shake of merged-but-unused stdlib functions runs around typecheck // (module_mark_unreachable before it, module_sweep_unreachable after it, // #2613); see aether_module.c. Codegen no longer needs a separate pass; diff --git a/compiler/codegen/codegen_expr.c b/compiler/codegen/codegen_expr.c index dd2778994..2fbe63466 100644 --- a/compiler/codegen/codegen_expr.c +++ b/compiler/codegen/codegen_expr.c @@ -11731,9 +11731,14 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { MessageDef* msg_def = lookup_message(gen->message_registry, message->value); if (msg_def) { const char* single_int = get_single_int_field(msg_def); - if (single_int) { + /* A message with no fields carries nothing: it goes + * as a one-int one does, with no payload to allocate + * (its handler reads none). */ + int no_fields = msg_def->fields == NULL; + if (single_int || no_fields) { // Single-field inline: value stored in payload_int (no malloc) fprintf(gen->output, "{ Message _imsg = {%d, 0, ", msg_def->message_id); + if (no_fields) fprintf(gen->output, "0"); for (int i = 0; i < message->child_count; i++) { ASTNode* field_init = message->children[i]; if (field_init && field_init->type == AST_FIELD_INIT && field_init->child_count > 0) { @@ -11811,7 +11816,11 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { } } } - fprintf(gen->output, "aether_send_message("); + /* #3117: in a batched loop every send joins the + * batch, so a payload message keeps its place + * among the loop's one-int ones. */ + fprintf(gen->output, gen->in_main_loop ? "aether_send_message_batch(" + : "aether_send_message("); emit_send_target(gen, target, "void*"); fprintf(gen->output, ", &_msg, sizeof(%s)); }", message->value); } diff --git a/compiler/codegen/codegen_internal.h b/compiler/codegen/codegen_internal.h index 5d8637b1b..8326d055c 100644 --- a/compiler/codegen/codegen_internal.h +++ b/compiler/codegen/codegen_internal.h @@ -749,6 +749,7 @@ void push_struct_destroy_defer(CodeGenerator* gen, const char* var_name, /* Internal helpers shared across files */ int contains_send_expression(ASTNode* node); +int loop_sends_without_waiting(ASTNode* node); const char* get_single_int_field(MessageDef* msg_def); /* The fields of a message in the order its C struct declares them, and the * C type each has there; the .c and the --emit-header file share them (#2517). */ diff --git a/compiler/codegen/codegen_stmt.c b/compiler/codegen/codegen_stmt.c index 4085ea570..3f1e1a144 100644 --- a/compiler/codegen/codegen_stmt.c +++ b/compiler/codegen/codegen_stmt.c @@ -11176,9 +11176,13 @@ static void generate_statement_body(CodeGenerator* gen, ASTNode* stmt) { break; // collapsed — done } - // Batch optimization: only in main() (not inside actors) + // Batch optimization: only outside actors, and only around a + // loop that cannot wait between its sends (#3117). A loop inside + // a batched one joins its batch rather than opening another. // Uses queue_enqueue_batch to reduce atomics from N to num_cores - if (has_sends && gen->current_actor == NULL) { + int batch = has_sends && gen->current_actor == NULL && !gen->in_main_loop && + loop_sends_without_waiting(stmt); + if (batch) { print_line(gen, "scheduler_send_batch_start();"); gen->in_main_loop = 1; } @@ -11288,7 +11292,7 @@ static void generate_statement_body(CodeGenerator* gen, ASTNode* stmt) { print_line(gen, "#endif"); } - if (has_sends && gen->current_actor == NULL) { + if (batch) { print_line(gen, "scheduler_send_batch_flush();"); gen->in_main_loop = 0; } diff --git a/contrib/tinyweb/example_schema_api.ae b/contrib/tinyweb/example_schema_api.ae index ac4dce5f5..4738c3e72 100644 --- a/contrib/tinyweb/example_schema_api.ae +++ b/contrib/tinyweb/example_schema_api.ae @@ -52,11 +52,6 @@ actor SrvActor { } } } -actor Sched { - state x = 0 - receive { Ping() -> { x = 1 } } -} -message Ping {} // The whole response through std.http.client: the showcase prints the // status (201, 422) next to the body. Null when no response came back at @@ -147,7 +142,6 @@ main() { schema_api.openapi_route("/openapi.json", registry) } - spawn(Sched()) a = spawn(SrvActor()) a ! StartSrv { srv: server } sleep(500) diff --git a/contrib/tinyweb/test_integration.ae b/contrib/tinyweb/test_integration.ae index f82678249..49a75e504 100644 --- a/contrib/tinyweb/test_integration.ae +++ b/contrib/tinyweb/test_integration.ae @@ -45,7 +45,6 @@ handle_param(req: ptr, res: ptr, ud: ptr) { // ---- Server actor ---- message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 @@ -59,11 +58,6 @@ actor SrvActor { } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} - // ---- Helpers ---- // The whole response through std.http.client, so a spec can assert on the @@ -115,7 +109,6 @@ main() { http.server_post(raw, "/echo", handle_echo, 0) http.server_get(raw, "/users/:id", handle_param, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } sleep(500) diff --git a/contrib/tinyweb/test_schema_api.ae b/contrib/tinyweb/test_schema_api.ae index bd2078882..a00a64be7 100644 --- a/contrib/tinyweb/test_schema_api.ae +++ b/contrib/tinyweb/test_schema_api.ae @@ -66,11 +66,6 @@ actor SrvActor { } } } -actor SchedHelper { - state x = 0 - receive { Ping() -> { x = 1 } } -} -message Ping {} // The whole response through std.http.client, so a spec can assert on the // status (422, 400, 204) as well as the body. Null when no response came @@ -141,7 +136,6 @@ main() { schema_api.openapi_route("/openapi.json", registry) } - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { srv: server } sleep(500) diff --git a/contrib/tinyweb/test_websocket.ae b/contrib/tinyweb/test_websocket.ae index 87c18a68d..866e8cab9 100644 --- a/contrib/tinyweb/test_websocket.ae +++ b/contrib/tinyweb/test_websocket.ae @@ -50,7 +50,6 @@ fn reply_for(msg: string) -> string { // ---- server, run from an actor so main can act as the client ---- message StartWs { srv: ptr } -message Noop {} actor WsActor { state s = 0 receive { StartWs(srv) -> { @@ -60,12 +59,6 @@ actor WsActor { tinyweb.ws_accept_loop(ws_tcp, handlers) } } } -// A second actor keeps the scheduler ticking while the server actor blocks in -// the accept loop (mirrors test_integration.ae's SchedHelper). -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} // One accept key and one unmasked payload; both die with this call. fn handshake_strings(key: string, masked: string, mask: string) -> int { @@ -166,7 +159,6 @@ main() { srv = map_new() map.put(srv, "tcp", ws_tcp) map.put(srv, "handlers", handlers) - spawn(SchedHelper()) a = spawn(WsActor()) a ! StartWs { srv: srv } sleep(500) // let the accept loop come up before we connect @@ -222,7 +214,7 @@ main() { _n = handshake_strings(key, masked, mask) } after = mem.heap_in_use() - spec.assert_true(mid - before == 0 || after -mid == 0, + spec.assert_true(mid - before == 0 || after - mid == 0, "500 rounds grew the heap by ${mid - before}, then ${after - mid} bytes") } } diff --git a/docs/actor-concurrency.md b/docs/actor-concurrency.md index da5627b27..a0d28a85c 100644 --- a/docs/actor-concurrency.md +++ b/docs/actor-concurrency.md @@ -30,12 +30,8 @@ The actor runtime includes several performance optimizations applied automatical ### Active Optimizations -**Main Thread Actor Mode:** -- Single-actor programs bypass the scheduler entirely -- Messages processed synchronously in the sender's context -- Zero-copy: caller's stack pointer passed directly -- Activated automatically when only one actor exists -- Disabled by `AETHER_NO_INLINE=1` environment variable +**Sends are asynchronous, whatever the actor count:** the sender goes on and +a scheduler thread runs the handler, from the first actor on (#3112). **Message Coalescing:** - Batch dequeue drains multiple messages in a single atomic operation @@ -84,7 +80,7 @@ typedef struct { } CounterActor; ``` -The fields, in order: `active`, `id`, `mailbox`, `step`, `thread`, `auto_process`, `assigned_core`, `migrate_to`, `main_thread_only`, `spsc_queue`, `reply_slot`, `step_lock`, `timeout_ns`, `last_activity_ns`, `dead`, `alloc_size`, `destroy_state`, `scheduler_owned`, `inbox`, `thread_parked`, `inflight`. The header comments each one. +The fields, in order: `active`, `id`, `mailbox`, `step`, `thread`, `auto_process`, `assigned_core`, `migrate_to`, `spsc_queue`, `reply_slot`, `step_lock`, `timeout_ns`, `last_activity_ns`, `dead`, `alloc_size`, `destroy_state`, `scheduler_owned`, `inbox`, `thread_parked`, `inflight`. The header comments each one. An actor the compiler generates holds the base as its first member instead, `ActorBase _aether_base;`, followed by its states. So a state can be named like any of these fields (`state id`, `state active`); only a state named `_aether_base` is refused (#3094). @@ -184,7 +180,7 @@ actor Monitor { } ``` -The timeout is one-shot: it is cancelled when any message is received. The countdown starts when the actor's mailbox becomes empty. Internally, the generated step function checks `_aether_clock_ns()` against a deadline before each `mailbox_receive()` call. With no message to step it for, the actor's core (or its own thread) steps it once the deadline passes, so the timeout fires within a park's timed wait of it (32 ms at most), and `wait_for_idle()` waits for every armed timeout to fire, as it does in main-thread mode (#3102). +The timeout is one-shot: it is cancelled when any message is received. The countdown starts when the actor's mailbox becomes empty. Internally, the generated step function checks `_aether_clock_ns()` against a deadline before each `mailbox_receive()` call. With no message to step it for, the actor's core (or its own thread) steps it once the deadline passes, so the timeout fires within a park's timed wait of it (32 ms at most), and `wait_for_idle()` waits for every armed timeout to fire (#3102). The timeout expression can reference actor state fields, not just integer literals, useful when the interval is configurable per-actor or computed: @@ -257,10 +253,6 @@ coverage: `tests/regression/test_panic_unwind_cleanup.ae` (the full matrix), `tests/integration/panic_unwind_no_leak/` (50000 caught panics, RSS flat), `tests/integration/panic_actor_step_drain/` (scheduler barrier). -Main-thread inline mode (single-actor programs) has no step barrier by -design, so a handler panic there still reaches the no-frame fallback -(reason + trace + abort), unchanged. - ## Cooperative Preemption By default, message handlers run to completion. A tight compute loop inside a handler will block that core's scheduler thread. For programs where this is a concern, cooperative preemption can be enabled: @@ -353,7 +345,7 @@ Message payloads are managed by thread-local pools. Payloads are returned to the ## Memory Model The actor runtime uses several allocation strategies for automatic cleanup: -- **Actor memory**: one NUMA-aware, 64-byte-aligned allocation per actor at spawn time (`aether_numa_alloc_aligned`), or the kept block of a released actor of the same size. `scheduler_release_actor` takes the actor out of its core's table and retires it; it is reclaimed once every core's scheduler thread and every per-actor thread has passed the top of its loop since, and no other thread is walking the tables. The scheduler threads, per-actor threads and `aether_scheduler_poll` read the tables without a lock, so the reclaim has to wait for them; a core blocked in a long step delays it. A reclaimed block is not returned to the allocator while the scheduler runs: it stays marked released, so a send to it is dropped rather than reading freed memory, and the next spawn of that size takes it. The blocks go back with the scheduler's tables. The mailbox is an inline struct member, not a separate allocation. +- **Actor memory**: one NUMA-aware, 64-byte-aligned allocation per actor at spawn time (`aether_numa_alloc_aligned`), or the kept block of a released actor of the same size. `scheduler_release_actor` takes the actor out of its core's table and retires it; it is reclaimed once every core's scheduler thread and every per-actor thread has passed the top of its loop since, and no other thread is walking the tables. The scheduler threads, per-actor threads and `wait_for_idle`'s scan for armed timeouts read the tables without a lock, so the reclaim has to wait for them; a core blocked in a long step delays it. A reclaimed block is not returned to the allocator while the scheduler runs: it stays marked released, so a send to it is dropped rather than reading freed memory, and the next spawn of that size takes it. The blocks go back with the scheduler's tables. The mailbox is an inline struct member, not a separate allocation. - **Message payloads**: thread-local pools with automatic return - **Arenas**: a general-purpose arena allocator provides bulk deallocation without per-object tracking for opt-in uses (`std.arena`, JSON parsing). The actor path does not use arenas. diff --git a/docs/architecture.md b/docs/architecture.md index 27c84a97b..559244858 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -219,7 +219,7 @@ rejects timestamp macros outright. int count; // the actor's own state follows } CounterActor; ``` - The fields, in order: `active`, `id`, `mailbox`, `step`, `thread`, `auto_process`, `assigned_core`, `migrate_to`, `main_thread_only`, `spsc_queue`, `reply_slot`, `step_lock`, `timeout_ns`, `last_activity_ns`, `dead`, `alloc_size`, `destroy_state`, `scheduler_owned`, `inbox`, `thread_parked`, `inflight`. The header comments each one. + The fields, in order: `active`, `id`, `mailbox`, `step`, `thread`, `auto_process`, `assigned_core`, `migrate_to`, `spsc_queue`, `reply_slot`, `step_lock`, `timeout_ns`, `last_activity_ns`, `dead`, `alloc_size`, `destroy_state`, `scheduler_owned`, `inbox`, `thread_parked`, `inflight`. The header comments each one. 2. **Message Structure** (`runtime/actors/actor_state_machine.h`) ```c @@ -313,12 +313,12 @@ For platforms without pthreads (WebAssembly, embedded, bare-metal), the cooperat ``` **Key Differences from Multi-Core:** -- `scheduler_init()` forces `num_cores = 1` and `main_thread_mode = true` +- `scheduler_init()` forces `num_cores = 1` - `scheduler_start()` / `scheduler_stop()` are no-ops (no threads to manage) +- A send queues: into the actor's mailbox, and past its 32 messages into its inbox (an unbounded FIFO); it never runs a handler (#3112) - `scheduler_wait()` drains via `aether_scheduler_poll()` loop - All sends are local (no cross-core queues, no migration) -- Ask/reply is synchronous (poll until reply_ready) -- `aether_send_message_sync()` heap-allocates message data (no zero-copy stack optimization, mailbox FIFO order means the sent message may not be the next one consumed) +- Ask/reply polls until reply_ready **Selection:** The Makefile selects the scheduler based on `PLATFORM` or auto-detects `AETHER_NO_THREADING` in `EXTRA_CFLAGS`. Only one scheduler .c file is compiled into the binary. Users can also use `ae build --target wasm` for Emscripten builds. @@ -381,13 +381,12 @@ design and its record in [design/ownership-model.md](design/ownership-model.md)) The runtime implements these optimization strategies in the active code paths: -1. **Main Thread Actor Mode** - Single-actor programs bypass scheduler entirely -2. **Thread-Local Message Pools** - Eliminate malloc/free overhead -3. **Batch Dequeue** - Reduce atomic operations from N to 1 per batch -4. **Adaptive Batching** - Scale batch size with load (64 to 1024) -5. **Direct Mailbox Delivery** - Same-core messages bypass the queue -6. **Computed Goto Dispatch** - Direct label jumps for message handlers -7. **Inline Single-Int Messages** - Bypass pool allocation for common messages +1. **Thread-Local Message Pools** - Eliminate malloc/free overhead +2. **Batch Dequeue** - Reduce atomic operations from N to 1 per batch +3. **Adaptive Batching** - Scale batch size with load (64 to 1024) +4. **Direct Mailbox Delivery** - Same-core messages bypass the queue +5. **Computed Goto Dispatch** - Direct label jumps for message handlers +6. **Inline Single-Int Messages** - Bypass pool allocation for common messages 8. **Message-Driven Migration** - Co-locate communicating actors 9. **Optimized Spinlock** - PAUSE/YIELD hints during spin-wait 10. **Cache Line Alignment** - Prevent false sharing on shared structures diff --git a/docs/c-embedding.md b/docs/c-embedding.md index 2f74c5b73..c4a38c8c0 100644 --- a/docs/c-embedding.md +++ b/docs/c-embedding.md @@ -287,7 +287,6 @@ The `ActorBase` contains: - `step`: Your message handler - `assigned_core`: Current core assignment (`atomic_int`, safe for cross-core reads) - `migrate_to`: Affinity hint for message-driven migration (`atomic_int`) -- `main_thread_only`: If set, scheduler threads skip this actor (`atomic_int`) - `spsc_queue`: Lock-free same-core messaging (pointer, lazily allocated) - `reply_slot`: Non-NULL during ask/reply (`_Atomic` pointer) - `step_lock`: Prevents concurrent `step()` during work-steal handoff (`atomic_flag`) @@ -437,9 +436,7 @@ Include the header in your C host application and send through the helpers, or b ## Polling from a C Event Loop -When a C event loop (raylib, SDL, game loop, etc.) holds the main thread, Aether actors in main-thread mode cannot process messages, the scheduler has no opportunity to run. - -Use `aether_scheduler_poll()` to drain pending messages from C code between frames: +With threads, actors run on the scheduler's threads from the first spawn on, so a C event loop (raylib, SDL, a game loop) that holds the main thread does not hold them up, and has nothing to poll: `aether_scheduler_poll` returns 0 there (#3112). In the cooperative (no-threads) runtime the actors run only when the host lets them, so a C loop calls `aether_scheduler_poll()` between frames: ```c #include "runtime/scheduler/multicore_scheduler.h" @@ -463,7 +460,7 @@ while (!window_should_close()) { int aether_scheduler_poll(int max_per_actor); ``` -`aether_scheduler_poll` is safe to call from the main thread at any time. It only processes actors that are in main-thread mode; scheduler worker threads are not affected. It is a no-op if no actors are registered. +In the cooperative runtime `aether_scheduler_poll` steps every actor that has messages, at most `max_per_actor` of them each; with threads it returns 0. It is a no-op if no actors are registered. ## See Also diff --git a/docs/runtime-config.md b/docs/runtime-config.md index 67083aa91..784a1b300 100644 --- a/docs/runtime-config.md +++ b/docs/runtime-config.md @@ -237,8 +237,7 @@ if (my_core >= 0 && my_core == atomic_load(&actor->assigned_core)) { } ``` -A single-actor program takes an earlier path still: main-thread mode runs -`step()` inline on the sender's thread rather than queueing at all. The SPSC +Every send takes this path, whatever the number of actors (#3112). The SPSC queue is allocated on first use (`ensure_spsc_queue` in [`runtime/scheduler/multicore_scheduler.c`](../runtime/scheduler/multicore_scheduler.c)), so an actor that never receives a same-core send does not pay for one. @@ -261,8 +260,6 @@ static inline Message* message_pool_alloc(void) { | Variable | Effect | |----------|--------| -| `AETHER_NO_INLINE` | Disables Main Thread Actor Mode (forces scheduler-based processing) | -| `AETHER_INLINE` | Forces Main Thread Actor Mode even with multiple actors | | `AETHER_SINGLE_CORE` | Pins all actors to core 0 (eliminates cross-core overhead) | | `AETHER_PROFILE` | Memory profile: `micro`, `small`, `medium` (default), `large` | | `AETHER_MSG_POOL_SIZE` | Override message pool size (default from profile) | diff --git a/docs/runtime-optimizations.md b/docs/runtime-optimizations.md index a3047cc23..e9cb44224 100644 --- a/docs/runtime-optimizations.md +++ b/docs/runtime-optimizations.md @@ -13,56 +13,19 @@ The optimizations are tiered by how they activate: ## Active Optimizations -### Main Thread Actor Mode +### Every send goes through the scheduler -**Implementation:** `runtime/actors/aether_send_message.c`, `runtime/scheduler/multicore_scheduler.c`, `runtime/config/aether_optimization_config.h` - -Single-actor programs bypass the scheduler entirely. When only one actor exists, message processing occurs synchronously on the main thread with zero-copy message delivery. - -**Activation:** -- Automatic: Enabled when the first actor spawns, disabled if a second actor spawns -- Manual disable: Set `AETHER_NO_INLINE=1` environment variable - -**Mechanism:** - -When main thread mode is active: -1. `aether_send_message` calls `aether_send_message_sync` instead of routing through scheduler -2. **Threaded mode:** The sync path passes the caller's stack pointer directly (no malloc, no memcpy). A TLS flag (`g_skip_free`) prevents the handler from freeing the stack pointer -3. **Cooperative mode:** The sync path heap-allocates a copy of the message data, because the mailbox may have other messages queued ahead, the immediate `step()` call consumes the oldest message (FIFO), not necessarily the one just sent -4. `actor->step()` is called synchronously in the sender's context - -```c -static inline void aether_send_message_sync(ActorBase* actor, void* message_data, size_t message_size) { - Message msg; - msg.type = *(int*)message_data; -#if AETHER_HAS_THREADS - msg.payload_ptr = message_data; // Direct pointer to caller's stack (zero-copy) -#else - msg.payload_ptr = malloc(message_size); // Cooperative: heap-copy (FIFO safety) - memcpy(msg.payload_ptr, message_data, message_size); -#endif - - mailbox_send(&actor->mailbox, msg); - -#if AETHER_HAS_THREADS - g_skip_free = 1; -#endif - actor->step(actor); -#if AETHER_HAS_THREADS - g_skip_free = 0; -#endif -} -``` - -**Per-actor flag:** - -The `main_thread_only` field on `ActorBase` signals scheduler threads to skip processing this actor. When a second actor spawns, the flag is cleared on the first actor so scheduler threads can process both normally. In cooperative mode, `main_thread_only` stays set for all actors, the cooperative scheduler always processes them directly. - -**Scheduler integration:** +**Implementation:** `runtime/actors/aether_send_message.c`, `runtime/scheduler/multicore_scheduler.c` -- `scheduler_start()` returns immediately if main thread mode is active (no-op in cooperative mode) -- `scheduler_wait()` returns immediately in threaded mode; in cooperative mode, drains via `aether_scheduler_poll()` loop -- Scheduler threads check `actor->main_thread_only` before processing (cooperative mode has no scheduler threads) +A send (`!`) is asynchronous whatever the number of actors: the payload is +copied to the heap and handed to the scheduler, and the sender goes on while a +scheduler thread runs the handler. The runtime once ran the first actor's +handler inside the sender's `!` until a second actor was spawned ("main-thread +mode"), so a handler that blocked, waiting on something its sender was about +to do, deadlocked a one-actor program, and the order of a program's output +depended on how many actors it had (#3112). The cooperative (no-threads) +runtime queues a send the same way and runs it at the next `wait_for_idle`, +`aether_scheduler_poll` or shutdown. ### Thread-Local Message Payload Pools @@ -113,7 +76,7 @@ The batch dequeue reads `head` and `tail` once, copies all available messages, t **Implementation:** `runtime/scheduler/multicore_scheduler.c`, `compiler/codegen/codegen.c` -For main thread fan-out patterns (fork-join), batch send reduces atomic operations from N to num_cores. The compiler detects while loops containing sends in `main()` and wraps them with batch start/flush calls. +For main thread fan-out patterns (fork-join), batch send reduces atomic operations from N to num_cores. The compiler wraps a while loop of a non-actor function in batch start/flush calls when the loop sends and cannot wait between its sends (#3117): it calls nothing, prints nothing, asks nothing and reads no actor's state, and has no `return`, labeled `break` or `continue` (which can leave for an enclosing loop) or closure in it. Sends, arithmetic, assignments, branches and inner loops are fine, the counting and fork-join shape. A loop that sleeps, reads a line or waits between sends is not batched, since its messages would wait for the loop's end. ```c // Generated code for fork-join pattern: @@ -129,9 +92,7 @@ The flush snapshots each message's target core, sorts by core using radix sort, **Partial batch enqueue:** `queue_enqueue_batch` enqueues as many messages as fit in the queue and returns the count, rather than failing the entire batch when the queue is near full. This lets the common case (queue has room) complete with a single atomic store per core. When the queue can't absorb the full batch, the flush falls back to `scheduler_send_remote` for each remaining message, which handles its own routing and counting. The fallback path is rare in practice because the cross-core queue holds 1024 items. -**Runtime auto-detection:** The batch send path automatically detects when Main Thread Actor Mode is active (single-actor programs) and uses the synchronous zero-copy path instead of batching. This ensures single-actor benchmarks like counting use the optimal path while multi-actor fan-out patterns like fork-join benefit from batch send. No manual configuration is required. - -**Pattern detection:** Only applied in `main()` function loops, not inside actor receive handlers. This preserves low-latency actor-to-actor messaging while optimizing main-to-actors fan-out. +**Pattern detection:** Only applied outside actor receive handlers, which preserves low-latency actor-to-actor messaging while optimizing main-to-actors fan-out. Inside a batch every send joins it, a message with a payload included (`aether_send_message_batch`), so the loop's sends to one actor arrive in the order they were made; an inner loop joins the outer loop's batch. A send that goes around a pending batch flushes it first, and so does `wait_for_idle()`; opening a batch never drops what one holds. On a scheduler thread nothing is batched: its sends keep their core's own channel and overflow order. ### Adaptive Batch Size @@ -483,11 +444,7 @@ The Aether compiler (`aetherc`) runs its own optimization passes on the AST befo ### Hot/Cold Path Fixes (scheduler-level) -**Problem A, `aether_main_thread_mode_active()` branch hint** (`runtime/config/aether_optimization_config.h`) - -The inline accessor previously carried `__builtin_expect(..., 0)`, marking the main-thread path as *unlikely*. For single-actor programs this path is always taken, so the hint put the fast code in the cold instruction-cache section. Removed the hint; the C compiler now places the branch neutrally. - -**Problem B, Dead branch in generated send code** (`compiler/codegen/codegen_expr.c`) +**Dead branch in generated send code** (`compiler/codegen/codegen_expr.c`) Generated code for `actor ! Msg` from the main thread previously emitted: ```c diff --git a/docs/scheduler-quick-reference.md b/docs/scheduler-quick-reference.md index 11fbb0c63..c172bd4ef 100644 --- a/docs/scheduler-quick-reference.md +++ b/docs/scheduler-quick-reference.md @@ -1,22 +1,10 @@ # Aether Scheduler - Quick Reference -## Main Thread Mode +## Sends -Single-actor programs bypass the scheduler entirely. When only one actor exists and all messages originate from the main thread, the runtime processes messages synchronously without spawning scheduler threads. - -**Activation:** -- Automatic: Enabled when the first actor spawns, disabled when a second actor spawns -- Manual disable: Set `AETHER_NO_INLINE=1` environment variable - -**Mechanism:** -- `scheduler_start()` returns immediately -- `scheduler_wait()` returns immediately (non-destructive; does not stop threads) -- `aether_send_message` processes synchronously via `aether_send_message_sync` -- **Threaded mode:** Zero-copy, message data passed directly from caller's stack -- **Cooperative mode:** Heap-copy, message data is `malloc`'d because mailbox FIFO order means the sent message may not be consumed by the immediate `step()` call - -**Per-actor flag:** -The `main_thread_only` field on `ActorBase` signals scheduler threads to skip processing this actor. When a second actor spawns, the flag is cleared on the first actor. In cooperative mode, this flag stays set for all actors. +A send is asynchronous whatever the number of actors: the payload is copied to +the heap and handed to the scheduler, and a scheduler thread runs the handler +while the sender goes on (#3112). --- @@ -25,11 +13,12 @@ The `main_thread_only` field on `ActorBase` signals scheduler threads to skip pr On platforms without pthreads (WebAssembly, embedded) or when threading is explicitly disabled (`-DAETHER_NO_THREADING`), the cooperative scheduler (`aether_scheduler_coop.c`) provides the same API as the multi-core scheduler but runs everything on a single thread. **Key differences:** -- `scheduler_init()` forces `num_cores = 1`, `main_thread_mode = true` +- `scheduler_init()` forces `num_cores = 1` - `scheduler_start()` / `scheduler_stop()` are no-ops +- A send queues (the mailbox, then the actor's inbox past 32 messages) and never runs a handler - `scheduler_wait()` polls all actors via `aether_scheduler_poll()` until no messages remain - All sends go to local mailbox (no cross-core routing) -- Ask/reply is synchronous (poll until `reply_ready`) +- Ask/reply polls until `reply_ready` **Selection:** Controlled by `PLATFORM=wasm|embedded` or auto-detected from `EXTRA_CFLAGS` containing `AETHER_NO_THREADING`. Only one scheduler .c file is compiled. @@ -216,7 +205,7 @@ Both migration and work stealing use ascending core-id lock ordering to prevent - Avoid blocking operations in actor step functions ### Crashes -- Verify every actor struct starts with `ActorBase`'s fields: expand `AETHER_ACTOR_BASE_FIELDS` first (or put an `ActorBase` member first) rather than copying the list, which drifts as fields are added. In order, they are `active`, `id`, `mailbox`, `step`, `thread`, `auto_process`, `assigned_core`, `migrate_to`, `main_thread_only`, `spsc_queue`, `reply_slot`, `step_lock`, `timeout_ns`, `last_activity_ns`, `dead`, `alloc_size`, `destroy_state`, `scheduler_owned`, `inbox`, `thread_parked`, `inflight`. +- Verify every actor struct starts with `ActorBase`'s fields: expand `AETHER_ACTOR_BASE_FIELDS` first (or put an `ActorBase` member first) rather than copying the list, which drifts as fields are added. In order, they are `active`, `id`, `mailbox`, `step`, `thread`, `auto_process`, `assigned_core`, `migrate_to`, `spsc_queue`, `reply_slot`, `step_lock`, `timeout_ns`, `last_activity_ns`, `dead`, `alloc_size`, `destroy_state`, `scheduler_owned`, `inbox`, `thread_parked`, `inflight`. - Initialize `migrate_to = -1` after actor creation - Use `scheduler_send_remote` instead of direct mailbox writes for cross-core messages - Call `scheduler_shutdown()` before process exit (waits for quiescence, stops and joins threads) diff --git a/docs/stdlib-reference.md b/docs/stdlib-reference.md index 0d2f3ee77..049e8b2ce 100644 --- a/docs/stdlib-reference.md +++ b/docs/stdlib-reference.md @@ -2715,10 +2715,6 @@ arm. - `await_io` must be called from inside an actor's `receive` handler (not from `main()`). The bridge reads the current actor from a TLS set at the top of every generated `_step()` function. -- Single-actor programs run in main-thread mode which bypasses the - scheduler loop, and therefore the I/O reactor. Spawn at least two - actors to force multi-threaded scheduler mode if you want `await_io` - to function. - The fd must outlive the `await_io` registration. If you close the fd before the `IoReady` fires, behavior depends on the backend (epoll reports EPOLLHUP; kqueue silently drops the one-shot). diff --git a/new_changelogs/20261022T171300Z-changed-3112-send-always-async.md b/new_changelogs/20261022T171300Z-changed-3112-send-always-async.md new file mode 100644 index 000000000..d38b4d0f4 --- /dev/null +++ b/new_changelogs/20261022T171300Z-changed-3112-send-always-async.md @@ -0,0 +1,13 @@ +- **A send is asynchronous whatever the number of actors.** With one actor + the runtime ran the handler inside the sender's `!` ("main-thread mode") + until a second actor was spawned, so a handler that blocked on something + its sender was about to do deadlocked the program, the order of a + program's output depended on its actor count, and tests and examples + spawned a do-nothing actor to get the real behaviour. Every send now goes + through the scheduler from the first actor on, and the cooperative + (no-threads) runtime queues it for the next `wait_for_idle`, + `aether_scheduler_poll` or shutdown. `AETHER_INLINE` and + `AETHER_NO_INLINE` are gone with the mode, and `aether_scheduler_poll` + returns 0 with threads: there is nothing left for a host to drain. A + program that printed from a handler and then from `main` and relied on + that order calls `wait_for_idle()` between them (#3112). diff --git a/new_changelogs/20261022T171534Z-fixed-3117-send-batches.md b/new_changelogs/20261022T171534Z-fixed-3117-send-batches.md new file mode 100644 index 000000000..a2fa14621 --- /dev/null +++ b/new_changelogs/20261022T171534Z-fixed-3117-send-batches.md @@ -0,0 +1,14 @@ +- **A loop that waits between sends delivers each send as it goes, and a + nested loop no longer loses the outer loop's messages.** The compiler + opened a send batch around every `while` loop of a non-actor function + that sent, so a heartbeat, a loop that slept or read a line between + sends, or one that called `wait_for_idle()` held its messages until the + loop ended or 256 had been sent; a message with a payload went around + the batch and overtook earlier one-int messages to the same actor; and an + inner loop's batch emptied the outer one's, losing its messages + (`a got 0 of 10`). A batch now wraps only a loop that cannot wait between + its sends (no call, print, ask, read of an actor's state, `return` or + labeled `break` or `continue`), every send in it joins it, an inner loop + joins the outer loop's batch, a send that goes around a pending batch and + `wait_for_idle()` flush it first, opening a batch never drops what one + holds, and a scheduler thread batches nothing (#3117). diff --git a/runtime/actors/aether_send_message.c b/runtime/actors/aether_send_message.c index a8e9789d4..fc595e183 100644 --- a/runtime/actors/aether_send_message.c +++ b/runtime/actors/aether_send_message.c @@ -83,18 +83,10 @@ static inline int payload_pool_release(void* ptr) { return 1; // Successfully returned to pool } -// TLS flag: when set, skip freeing (used for sync mode zero-copy) -extern AETHER_TLS int g_skip_free; - // Free message payload - try pool first, then fall back to free() void aether_free_message(void* msg_data) { if (!msg_data) return; - // SYNC MODE: Skip free when processing messages with zero-copy stack pointers - if (unlikely(g_skip_free)) { - return; // Caller's stack memory - don't free! - } - // Try to return to pool if (payload_pool_release(msg_data)) { return; // Returned to pool successfully @@ -104,205 +96,8 @@ void aether_free_message(void* msg_data) { free(msg_data); } -// ============================================================================== -// Message Sending with Pool Optimization -// ============================================================================== - -// ============================================================================== -// MAIN THREAD MODE: Synchronous Message Processing -// ============================================================================== -// When main thread mode is active: -// - Single actor, all sends from main(), no scheduler threads -// - Messages processed synchronously in sender's context -// - Zero queue overhead: 50M+ msg/sec possible -// -// This is the fastest path for single-actor programs like the counting benchmark. - -// TLS flag: when set, aether_free_message skips freeing (used for sync mode) -// Declared here, accessed by aether_free_message below -AETHER_TLS int g_skip_free = 0; - -// TLS pointer: the actor whose step() is currently being executed synchronously -// on the main thread (non-NULL only inside aether_send_message_sync). -// scheduler_spawn_actor checks this to defer main_thread_only=0 if the second -// actor is spawned while the first actor's step() is still on the call stack. -// Without the deferral a scheduler thread can enter the same actor's step() -// concurrently with the main thread → data race / crash. -AETHER_TLS ActorBase* g_sync_step_actor = NULL; - -// How many inline sends (below) are touching an actor on this thread, and -// whether an actor released meanwhile waits for the outermost to finish -// (#2509): the inline path still touches the actor after its step returns, -// and a step can release its own actor. scheduler_inline_step_done() runs -// those releases. -AETHER_TLS int g_inline_step_depth = 0; -AETHER_TLS int g_inline_release_pending = 0; - -/* Returns 1 when the message was delivered inline, 0 when main-thread - * mode ended before the payload was written and the caller must send - * through the scheduler instead. */ -static inline int AETHER_HOT aether_send_message_sync(ActorBase* actor, void* message_data, size_t message_size) { - g_inline_step_depth++; - Message msg; - msg.type = *(int*)message_data; - msg.sender_id = 0; - msg.payload_int = 0; - msg.zerocopy.data = NULL; - msg.zerocopy.size = 0; - msg.zerocopy.owned = 0; - msg._reply_slot = g_pending_reply_slot; - -#if AETHER_HAS_THREADS - // ULTRA-FAST PATH: Pass original message directly without copying - // Since processing is synchronous, caller's stack memory is still valid - // We mark g_skip_free so step() doesn't try to free the caller's stack - msg.payload_ptr = message_data; -#else - // COOPERATIVE MODE: heap-allocate the message data. - // The mailbox may have other messages queued ahead, so the immediate - // step() call below may consume a different message (FIFO order). - // The just-sent message will be processed later by scheduler_wait(), - // by which time the caller's stack frame may be gone. - // - // A dead actor takes no message: a released one's block is kept - // (#2517), so this read is defined, and the scheduler's send drops and - // counts the message before anything is allocated for it. - if (atomic_load_explicit(&actor->dead, memory_order_relaxed)) { - msg.payload_ptr = NULL; - scheduler_send_local(actor, msg); - g_inline_step_depth--; - return 1; - } - void* heap_copy = malloc(message_size); - if (!heap_copy) { - // Delivering a NULL payload would crash the handler (or, for a - // single-int message, silently run it on zeroed data). Match the - // threaded path below and fail loudly rather than corrupt state. - fprintf(stderr, "aether: malloc(%zu) failed for msg type %d to actor %d\n", - message_size, *(int*)message_data, actor->id); - abort(); - } - memcpy(heap_copy, message_data, message_size); - msg.payload_ptr = heap_copy; -#endif - -#if AETHER_HAS_THREADS - // Hold step_lock from before the mailbox write until the step has - // returned: another thread may switch the runtime out of main-thread - // mode meanwhile (a send from a std.worker or std.http thread, #2083), - // and the scheduler threads it starts take this lock before stepping, - // so they can neither step the actor while it is on this stack nor - // pick the stack-allocated payload out of the mailbox and free it. - // (The self-send transition inside step() used to set the lock - // itself; it finds it already held.) - int locked = aether_inline_lock_acquire(actor); - // The mode check and the enqueue are one step with respect to leaving - // the mode, so this message is the only one in the mailbox and the - // step below is the one that takes it (#2266). If the mode ended, the - // payload must be a heap copy: hand the send back to the caller's - // standard path. - if (!aether_main_mode_enqueue(actor, msg)) { - if (locked) aether_inline_lock_release(actor); - g_inline_step_depth--; - return 0; - } -#else - mailbox_send(&actor->mailbox, msg); -#endif - -#if AETHER_HAS_THREADS - // Tell aether_free_message to skip freeing the initial (stack-allocated) message. - // Self-sent messages from handlers are heap-allocated (see g_sync_step_actor check - // in aether_send_message), so they can be freed normally. - g_skip_free = 1; -#endif - // Guard: scheduler_spawn_actor defers main_thread_only=0 while this is set - g_sync_step_actor = actor; - actor->step(actor); -#if AETHER_HAS_THREADS - if (locked) aether_inline_lock_release(actor); -#endif -#if AETHER_HAS_THREADS - g_skip_free = 0; -#endif - - // Do NOT drain self-sent messages here. If a handler does self ! Msg {}, - // the message sits in the mailbox and will be processed on the NEXT call to - // aether_send_message (from main) or by the scheduler. Draining eagerly - // would turn self-continuation patterns (animation loops) into infinite - // blocking loops that starve main(). - g_sync_step_actor = NULL; - - // Deferred main_thread_only disable: if the second actor was spawned during - // step() above, scheduler_spawn_actor skipped clearing main_thread_only to - // avoid a concurrent-step race. Now that step() has returned it is safe. - if (!aether_main_thread_mode_active() && - atomic_load_explicit(&actor->main_thread_only, memory_order_relaxed)) { - atomic_store_explicit(&actor->main_thread_only, 0, memory_order_release); - } - - // Done with the actor: a release its step made can go ahead. - if (--g_inline_step_depth == 0 && unlikely(g_inline_release_pending)) { - scheduler_inline_step_done(); - } - - // Track stats - AETHER_STAT_INC(inline_sends); - return 1; -} - -// Send a typed message to an actor using optimized scheduler paths -// Uses SPSC queues, direct sends, and other optimizations automatically -void aether_send_message(void* actor_ptr, void* message_data, size_t message_size) { - ActorBase* actor = (ActorBase*)actor_ptr; - - // ============================================================================== - // FAST PATH: Main thread mode (single actor, synchronous processing) - // ============================================================================== - // For single-actor programs (like counting benchmark), process immediately. - // No scheduler threads, no queues, no atomics - pure function call. - if (aether_main_thread_mode_active()) { -#if AETHER_HAS_THREADS - // If we're already inside a step() (self-send from a handler), the actor - // needs to run independently from the main thread. Disable main-thread - // mode so the scheduler threads take over message processing. This - // enables patterns like animation loops (self ! AnimateStep {}) that - // require the actor to process messages concurrently with main(). - if (g_sync_step_actor != NULL) { - // Self-send from a handler: disable main-thread mode so the - // scheduler takes over message processing for this actor. - // Hold step_lock while the main thread's step() is still on the - // call stack. Without this, the scheduler threads (started by - // the switch below) could call step() concurrently — a data - // race on actor state. aether_send_message_sync releases the - // lock after step() returns. - atomic_flag_test_and_set_explicit(&actor->step_lock, memory_order_acquire); - // The switch every other path makes: under the lock an inline - // enqueue holds (#2266), and it starts the scheduler threads - // that main-thread mode never created. - aether_leave_main_thread_mode(); - // Fall through to the standard multi-actor send path below. - } else if (!aether_on_main_mode_thread()) { - // A thread that is not main (a std.worker / std.http pool - // thread, a C callback thread): it cannot step the actor - // inline — the main thread may be stepping it right now — - // so the runtime leaves main-thread mode, as a second spawn - // would, and the send goes through the scheduler (#2083). - aether_leave_main_thread_mode(); - // Fall through to the standard multi-actor send path below. - } else -#endif // AETHER_HAS_THREADS - { - if (aether_send_message_sync(actor, message_data, message_size)) return; - // Main-thread mode ended under us: fall through to the - // standard path, which heap-copies the payload. - } - } - -#if AETHER_HAS_THREADS - // ============================================================================== - // STANDARD PATH: Multi-actor scheduler-based processing - // ============================================================================== +// The heap copy of a typed message's payload, as the Message a send carries. +static Message payload_message(ActorBase* actor, void* message_data, size_t message_size) { // Always use heap allocation for type-safe messages. // TLS pools have thread affinity issues: a same-core actor may be migrated // to another core after the message is sent, causing the receiver to call @@ -324,7 +119,19 @@ void aether_send_message(void* actor_ptr, void* message_data, size_t message_siz msg.zerocopy.size = 0; msg.zerocopy.owned = 0; msg._reply_slot = g_pending_reply_slot; + return msg; +} +// Send a typed message to an actor. The payload is copied to the heap and +// handed to the scheduler, which delivers it whatever the actor count +// (#3112): the handler runs later, on a scheduler thread, and the sender +// goes on. The cooperative runtime queues it for the next wait_for_idle or +// aether_scheduler_poll. +void aether_send_message(void* actor_ptr, void* message_data, size_t message_size) { + ActorBase* actor = (ActorBase*)actor_ptr; + Message msg = payload_message(actor, message_data, message_size); + +#if AETHER_HAS_THREADS // Cache TLS once — avoids repeated tlv_get_addr calls on macOS const int my_core = current_core_id; @@ -334,7 +141,13 @@ void aether_send_message(void* actor_ptr, void* message_data, size_t message_siz scheduler_send_remote(actor, msg, my_core); } #else - // No threads: always use synchronous processing - aether_send_message_sync(actor, message_data, message_size); -#endif // AETHER_HAS_THREADS + scheduler_send_local(actor, msg); +#endif +} + +// A typed message sent inside a batched loop (#3117): it joins the batch, as +// the loop's one-int messages do, so the loop's sends keep their order. +void aether_send_message_batch(void* actor_ptr, void* message_data, size_t message_size) { + ActorBase* actor = (ActorBase*)actor_ptr; + scheduler_send_batch_add(actor, payload_message(actor, message_data, message_size)); } diff --git a/runtime/actors/aether_send_message.h b/runtime/actors/aether_send_message.h index a80624dd0..f63b1e360 100644 --- a/runtime/actors/aether_send_message.h +++ b/runtime/actors/aether_send_message.h @@ -6,6 +6,9 @@ // Send a typed message to an actor void aether_send_message(void* actor_ptr, void* message_data, size_t message_size); +// The same, inside a batched loop: the message joins the loop's send batch +// (#3117). +void aether_send_message_batch(void* actor_ptr, void* message_data, size_t message_size); // Free message payload (tries thread-local pool first, then falls back to free()) void aether_free_message(void* msg_data); diff --git a/runtime/config/aether_optimization_config.c b/runtime/config/aether_optimization_config.c index 267210938..8216a014c 100644 --- a/runtime/config/aether_optimization_config.c +++ b/runtime/config/aether_optimization_config.c @@ -14,11 +14,6 @@ AetherRuntimeConfig g_aether_config = { .use_lockfree_mailbox = false, // Opt-in (slower single-thread) .use_message_dedup = false, // Opt-in (changes semantics) .verbose = false, - .inline_mode_active = false, - .inline_mode_forced = false, - .inline_mode_disabled = false, - .main_thread_mode = false, // Sync processing on main thread - .main_actor = NULL, .actor_count = 0, .preempt_enabled = false, // Opt-in: AETHER_PREEMPT=1 .preempt_threshold_ns = 1000000, // 1ms default @@ -27,7 +22,6 @@ AetherRuntimeConfig g_aether_config = { .actors_malloced = 0, .direct_sends = 0, .queue_sends = 0, - .inline_sends = 0, .batches_adjusted = 0, .simd_batches = 0, .messages_deduped = 0 @@ -107,16 +101,6 @@ void aether_runtime_configure(AetherOptFlags flags) { // Called once at scheduler init before any actors spawn void aether_init_from_env(void) { #if AETHER_HAS_GETENV - // Check inline mode env overrides first - if (getenv("AETHER_INLINE")) { - atomic_store(&g_aether_config.inline_mode_forced, true); - atomic_store(&g_aether_config.inline_mode_active, true); - } - if (getenv("AETHER_NO_INLINE")) { - atomic_store(&g_aether_config.inline_mode_disabled, true); - atomic_store(&g_aether_config.inline_mode_active, false); - } - // Profile from env or default g_aether_config.profile = aether_profile_from_env(); @@ -173,17 +157,10 @@ void aether_print_config(void) { printf(" malloc: %s\n", AETHER_HAS_MALLOC ? "YES" : "NO"); printf(" shell: %s\n\n", AETHER_HAS_SHELL ? "YES" : "NO"); - printf("PROFILE: %s (msg_pool=%d)\n", + printf("PROFILE: %s (msg_pool=%d)\n\n", aether_profile_name(g_aether_config.profile), g_aether_config.msg_pool_size); - const char* inline_status = "auto"; - if (atomic_load(&g_aether_config.inline_mode_forced)) inline_status = "forced ON (AETHER_INLINE)"; - else if (atomic_load(&g_aether_config.inline_mode_disabled)) inline_status = "forced OFF (AETHER_NO_INLINE)"; - printf("INLINE MODE: %s (current: %s)\n\n", - inline_status, - atomic_load(&g_aether_config.inline_mode_active) ? "active" : "inactive"); - printf("TIER 1 - ALWAYS ON:\n"); printf(" [ON] Direct Send - Same-core bypass\n"); printf(" [ON] Adaptive Batching - Dynamic 4-64 batch size\n"); diff --git a/runtime/config/aether_optimization_config.h b/runtime/config/aether_optimization_config.h index a40e1af49..a3e2cfae4 100644 --- a/runtime/config/aether_optimization_config.h +++ b/runtime/config/aether_optimization_config.h @@ -406,18 +406,7 @@ typedef struct { atomic_bool use_message_dedup; atomic_bool verbose; - // Inline mode (auto-detected or env override) - // When active, single-actor programs bypass queues entirely - atomic_bool inline_mode_active; - atomic_bool inline_mode_forced; // Set by AETHER_INLINE=1 env var - atomic_bool inline_mode_disabled; // Set by AETHER_NO_INLINE=1 env var - - // Main thread actor mode: single actor, all sends from main(), no scheduler needed - // When active: messages processed synchronously in sender's context - atomic_bool main_thread_mode; - void* main_actor; // Pointer to the single actor (ActorBase*) - - // Actor tracking for auto-detection + // Live actors (spawned, not yet released) atomic_int actor_count; // Memory profile (auto-detected or env override) @@ -433,7 +422,6 @@ typedef struct { atomic_uint_fast64_t actors_malloced; atomic_uint_fast64_t direct_sends; atomic_uint_fast64_t queue_sends; - atomic_uint_fast64_t inline_sends; // Messages sent via inline mode atomic_uint_fast64_t batches_adjusted; atomic_uint_fast64_t simd_batches; atomic_uint_fast64_t messages_deduped; @@ -476,54 +464,9 @@ static inline bool aether_has_opt(AetherOptFlags flag) { #define AETHER_STAT_INC(field) ((void)0) #endif -// ============================================================================ -// INLINE MODE DETECTION (for single-actor sequential optimization) -// ============================================================================ - -// Check if inline mode is currently active (single branch, predicted false) -static inline bool aether_inline_mode_active(void) { - return unlikely(atomic_load_explicit(&g_aether_config.inline_mode_active, memory_order_relaxed)); -} - -// Check if main thread actor mode is active (synchronous processing) -// This is the fastest path: no scheduler, no queues, direct function calls. -// NOTE: No __builtin_expect here — in single-actor programs this is always true -// (hinting it as unlikely would put the fast inline path in cold instruction cache). -// The CPU branch predictor learns the correct bias quickly for both cases. -static inline bool aether_main_thread_mode_active(void) { - return atomic_load_explicit(&g_aether_config.main_thread_mode, memory_order_relaxed); -} - -// Enable main thread mode for an actor (call after spawn, before any sends) -static inline void aether_enable_main_thread_mode(void* actor) { - atomic_store_explicit(&g_aether_config.main_thread_mode, true, memory_order_relaxed); - g_aether_config.main_actor = actor; -} - -// Called at spawn time to update actor count and inline mode state +// Called at spawn time to count the actor static inline void aether_on_actor_spawn(void) { - int count = atomic_fetch_add_explicit(&g_aether_config.actor_count, 1, memory_order_relaxed); - - // Check env overrides - if (atomic_load_explicit(&g_aether_config.inline_mode_disabled, memory_order_relaxed)) { - return; // User forced inline off - } - - if (atomic_load_explicit(&g_aether_config.inline_mode_forced, memory_order_relaxed)) { - atomic_store_explicit(&g_aether_config.inline_mode_active, true, memory_order_relaxed); - return; // User forced inline on regardless of actor count - } - - // Auto-detect: inline mode only for single actor (count was 0 before increment) - bool single_actor = (count == 0); - atomic_store_explicit(&g_aether_config.inline_mode_active, single_actor, memory_order_relaxed); - - // MAIN THREAD MODE: Disable when second actor is spawned - // This ensures multi-actor programs use scheduler threads (not synchronous processing) - if (!single_actor) { - atomic_store_explicit(&g_aether_config.main_thread_mode, false, memory_order_relaxed); - g_aether_config.main_actor = NULL; - } + atomic_fetch_add_explicit(&g_aether_config.actor_count, 1, memory_order_relaxed); } // Called when an actor terminates @@ -531,7 +474,7 @@ static inline void aether_on_actor_terminate(void) { atomic_fetch_sub_explicit(&g_aether_config.actor_count, 1, memory_order_relaxed); } -// Initialize profile and inline mode from env vars +// Initialize the profile from env vars // Called once at runtime init void aether_init_from_env(void); diff --git a/runtime/scheduler/aether_scheduler_coop.c b/runtime/scheduler/aether_scheduler_coop.c index 7667e9db1..94f2c04c7 100644 --- a/runtime/scheduler/aether_scheduler_coop.c +++ b/runtime/scheduler/aether_scheduler_coop.c @@ -11,6 +11,7 @@ #include "../config/aether_optimization_config.h" #include "../aether_numa.h" #include "../actors/aether_send_message.h" +#include "../actors/aether_actor_inbox.h" #include #include #include @@ -32,18 +33,13 @@ AETHER_TLS void* g_pending_reply_slot = NULL; AETHER_TLS void* g_current_reply_slot = NULL; AETHER_TLS void* g_current_step_actor = NULL; -// Defined in aether_send_message.c -extern AETHER_TLS ActorBase* g_sync_step_actor; -extern AETHER_TLS int g_inline_step_depth; -extern AETHER_TLS int g_inline_release_pending; - // ============================================================================ // Released actors (#2509) // ============================================================================ // // One thread, but a step can still release its own actor, and whatever ran -// that step (aether_scheduler_poll, scheduler_wait, an inline send) touches -// the actor after it returns. So an actor released while any of them is in +// that step (aether_scheduler_poll, scheduler_wait) touches the actor after +// it returns. So an actor released while any of them is in // progress is kept here and freed when the last one is done. static int g_coop_walk_depth = 0; // aether_scheduler_poll / scheduler_wait walks in progress @@ -61,12 +57,25 @@ static int g_coop_kept_count = 0; static int g_coop_kept_capacity = 0; static void coop_free_actor(ActorBase* actor) { - // Anything still in the mailbox was sent against the release contract. + // Anything still in the mailbox or waiting behind it was sent against the + // release contract. Message discard; while (mailbox_receive(&actor->mailbox, &discard)) { if (discard.payload_ptr) aether_free_message(discard.payload_ptr); if (discard.zerocopy.owned && discard.zerocopy.data) free(discard.zerocopy.data); } + ActorInbox* ib = atomic_exchange_explicit(&actor->inbox, NULL, memory_order_relaxed); + if (ib) { + Message spill[AETHER_INBOX_RING]; + int got; + while ((got = actor_inbox_take(ib, spill, AETHER_INBOX_RING)) > 0) { + for (int k = 0; k < got; k++) { + if (spill[k].payload_ptr) aether_free_message(spill[k].payload_ptr); + if (spill[k].zerocopy.owned && spill[k].zerocopy.data) free(spill[k].zerocopy.data); + } + } + free(ib); + } if (actor->spsc_queue) { free(actor->spsc_queue); actor->spsc_queue = NULL; @@ -169,6 +178,50 @@ static int coop_send_to_dead_actor(ActorBase* actor, Message* msg) { // One thread: nothing but aether_scheduler_poll and scheduler_wait steps an // actor, and their walk depth covers them. + +/* A send queues (#3112): into the mailbox while it has room and nothing waits + * behind it, else into the actor's inbox (aether_actor_inbox.h, an unbounded + * FIFO), from which coop_refill moves the messages on, oldest first, as the + * mailbox empties. A send never runs a handler; aether_scheduler_poll and + * wait_for_idle do. */ +static void coop_deliver(ActorBase* actor, Message msg) { + if (coop_send_to_dead_actor(actor, &msg)) return; + ActorInbox* ib = atomic_load_explicit(&actor->inbox, memory_order_relaxed); + if ((!ib || atomic_load_explicit(&ib->count, memory_order_relaxed) == 0) && + mailbox_send(&actor->mailbox, msg)) + return; + if (!ib) { + ib = actor_inbox_new(); + if (!ib) { + fprintf(stderr, "aether: out of memory queueing message type %d for actor %d\n", + msg.type, actor->id); + abort(); + } + atomic_store_explicit(&actor->inbox, ib, memory_order_relaxed); + } + if (!actor_inbox_push(ib, msg)) { + fprintf(stderr, "aether: out of memory queueing message type %d for actor %d\n", + msg.type, actor->id); + abort(); + } +} + +// Moves what waits in the actor's inbox into its mailbox, as room allows. +static void coop_refill(ActorBase* actor) { + ActorInbox* ib = atomic_load_explicit(&actor->inbox, memory_order_relaxed); + if (!ib) return; + int room = MAILBOX_SIZE - atomic_load_explicit(&actor->mailbox.count, memory_order_relaxed); + if (room <= 0) return; + Message moved[MAILBOX_SIZE]; + int n = actor_inbox_take(ib, moved, room); + for (int k = 0; k < n; k++) mailbox_send(&actor->mailbox, moved[k]); +} + +static int coop_has_messages(ActorBase* actor) { + ActorInbox* ib = atomic_load_explicit(&actor->inbox, memory_order_relaxed); + return atomic_load_explicit(&actor->mailbox.count, memory_order_acquire) > 0 || + (ib && atomic_load_explicit(&ib->count, memory_order_relaxed) > 0); +} void scheduler_reader_online(void) {} void scheduler_reader_quiescent(void) {} void scheduler_reader_offline(void) {} @@ -178,16 +231,16 @@ void scheduler_actor_thread_deliver(ActorBase* actor, Message msg) { (void)actor void scheduler_set_actor_park_ms(int ms) { (void)ms; } void scheduler_set_core_park_ms(int ms) { (void)ms; } -// Frees the released actors once no walk and no inline send is running. +// Frees the released actors once no walk is running. static void coop_free_released(void) { - if (g_coop_walk_depth > 0 || g_inline_step_depth > 0) return; + if (g_coop_walk_depth > 0) return; while (g_coop_released_count > 0) { coop_free_actor(g_coop_released[--g_coop_released_count]); } } // One thread: the released actors are ended here and now (#2528), unless a -// walk or an inline send is on the stack, which ends them when it returns. +// walk is on the stack, which ends them when it returns. int scheduler_reclaim_released(void) { coop_free_released(); return g_coop_released_count; @@ -212,11 +265,6 @@ int scheduler_released_actors_pending(void) { return g_coop_released_count; } -void scheduler_inline_step_done(void) { - g_inline_release_pending = 0; - coop_free_released(); // waits for an enclosing walk, which frees them at its end -} - // ============================================================================ // Initialization // ============================================================================ @@ -255,9 +303,6 @@ void scheduler_init(int cores) { table->capacity = MAX_ACTORS_PER_CORE; atomic_store_explicit(&schedulers[0].actor_table, table, memory_order_relaxed); atomic_store_explicit(&schedulers[0].actor_count, 0, memory_order_relaxed); - - // Force main-thread mode — all processing is cooperative - atomic_store(&g_aether_config.main_thread_mode, true); } void scheduler_init_with_opts(int cores, AetherOptFlags opts) { @@ -273,10 +318,6 @@ void scheduler_start(void) { // No threads to start } -void scheduler_ensure_threads_running(void) { - // No threads in cooperative mode — this is a no-op -} - void scheduler_stop(void) { // No threads to stop } @@ -371,23 +412,13 @@ ActorBase* scheduler_spawn_actor(int preferred_core, void (*step)(void*), size_t actor->spsc_queue = NULL; atomic_store_explicit(&actor->assigned_core, 0, memory_order_relaxed); atomic_store_explicit(&actor->migrate_to, -1, memory_order_relaxed); - atomic_store_explicit(&actor->main_thread_only, 1, memory_order_relaxed); // Always main-thread atomic_store_explicit(&actor->reply_slot, NULL, memory_order_relaxed); atomic_flag_clear_explicit(&actor->step_lock, memory_order_relaxed); actor->timeout_ns = 0; actor->last_activity_ns = 0; - // Track actor count for inline mode - int prev_count = atomic_load_explicit(&g_aether_config.actor_count, memory_order_relaxed); aether_on_actor_spawn(); - if (prev_count == 0) { - aether_enable_main_thread_mode(actor); - } - // In cooperative mode, main_thread_mode stays on even with multiple actors. - // We force it back on because aether_on_actor_spawn() disables it for count > 1. - atomic_store_explicit(&g_aether_config.main_thread_mode, true, memory_order_relaxed); - actor->scheduler_owned = 1; register_actor(actor, 0); return actor; @@ -418,11 +449,8 @@ void scheduler_release_actor(ActorBase* actor) { // A step may be releasing its own actor: free it once whatever runs the // step is done with it (#2509). - if (g_coop_walk_depth > 0 || g_inline_step_depth > 0) { - if (coop_defer_release(actor)) { - if (g_inline_step_depth > 0) g_inline_release_pending = 1; - return; - } + if (g_coop_walk_depth > 0) { + if (coop_defer_release(actor)) return; // Out of memory to remember it: keep it rather than free it under // the step. fprintf(stderr, "aether: out of memory deferring the release of actor %d; " @@ -433,19 +461,17 @@ void scheduler_release_actor(ActorBase* actor) { } // ============================================================================ -// Message sending — cooperative: direct mailbox send, process via poll +// Message sending — cooperative: queued (coop_deliver), processed via poll // ============================================================================ void scheduler_send_local(ActorBase* actor, Message msg) { - if (coop_send_to_dead_actor(actor, &msg)) return; - mailbox_send(&actor->mailbox, msg); + coop_deliver(actor, msg); } void scheduler_send_remote(ActorBase* actor, Message msg, int from_core) { (void)from_core; // In cooperative mode there's only one core — all sends are local - if (coop_send_to_dead_actor(actor, &msg)) return; - mailbox_send(&actor->mailbox, msg); + coop_deliver(actor, msg); } // Batch send — trivial in single-threaded mode @@ -454,8 +480,7 @@ void scheduler_send_batch_start(void) { } void scheduler_send_batch_add(ActorBase* actor, Message msg) { - if (coop_send_to_dead_actor(actor, &msg)) return; - mailbox_send(&actor->mailbox, msg); + coop_deliver(actor, msg); } void scheduler_send_batch_flush(void) { @@ -520,11 +545,12 @@ int aether_scheduler_poll(int max_per_actor) { if (!actor || !actor->step) continue; int processed = 0; - int has_messages = atomic_load_explicit(&actor->mailbox.count, memory_order_acquire) > 0; + int has_messages = coop_has_messages(actor); int has_timeout = actor->timeout_ns > 0 && actor->last_activity_ns > 0; if (has_messages) { while (processed < limit) { + coop_refill(actor); if (atomic_load_explicit(&actor->mailbox.count, memory_order_acquire) == 0) break; actor->step(actor); processed++; @@ -559,7 +585,7 @@ void* scheduler_ask_message(ActorBase* target, void* msg_data, size_t msg_size, // Set pending reply slot g_pending_reply_slot = &slot; - // Send the message (will be processed synchronously if main_thread_mode) + // Queued as any send is, and processed by the polls below void* msg_copy = malloc(msg_size); if (!msg_copy) return NULL; memcpy(msg_copy, msg_data, msg_size); @@ -574,7 +600,7 @@ void* scheduler_ask_message(ActorBase* target, void* msg_data, size_t msg_size, msg.zerocopy.owned = 0; msg._reply_slot = &slot; - mailbox_send(&target->mailbox, msg); + coop_deliver(target, msg); g_pending_reply_slot = NULL; // Process messages until reply is ready diff --git a/runtime/scheduler/multicore_scheduler.c b/runtime/scheduler/multicore_scheduler.c index a83520aeb..ebfa8f4b3 100644 --- a/runtime/scheduler/multicore_scheduler.c +++ b/runtime/scheduler/multicore_scheduler.c @@ -36,15 +36,9 @@ extern void aether_send_message(void* actor_ptr, void* message_data, size_t mess // Forward declaration: I/O polling used in scheduler thread loop static int scheduler_io_poll(Scheduler* sched, int timeout_ms); - -// Forward declaration: TLS guard set by aether_send_message_sync while an -// actor's step() is executing synchronously on the main thread. Used to -// defer main_thread_only=0 in scheduler_spawn_actor and prevent a scheduler -// thread from entering the same step() concurrently. -extern AETHER_TLS ActorBase* g_sync_step_actor; -// Inline-send depth and pending releases, defined in aether_send_message.c. -extern AETHER_TLS int g_inline_step_depth; -extern AETHER_TLS int g_inline_release_pending; +// Forward declaration: a pending send batch goes before a send around it +// and before a wait (#3117), defined with the batch below. +static inline void batch_flush_pending(void); #ifdef __APPLE__ #include @@ -108,79 +102,6 @@ static void reply_slot_decref(ActorReplySlot* slot); // We use a function rather than a macro so the 8 step() sites stay // readable; `inline` lets the compiler devirtualize where it can. // -------------------------------------------------------------------------- -/* The thread that put the runtime into main-thread mode. */ -static aether_tid_t g_main_mode_thread; -static atomic_int g_main_mode_thread_set = 0; - -int aether_on_main_mode_thread(void) { - if (!atomic_load_explicit(&g_main_mode_thread_set, memory_order_acquire)) return 1; - return aether_tid_equal(aether_tid_self(), g_main_mode_thread); -} - -/* Leaving main-thread mode starts the scheduler threads, and from then on - * the actor's core delivers into its mailbox, which takes one producer at a - * time. An inline send checks that the mode is on and then writes that - * mailbox itself, so a switch landing between the two gave the mailbox two - * producers, and put scheduler deliveries ahead of the inline message: the - * one step that follows consumed a delivered message instead, and left the - * inline message -- its payload on the sender's stack -- for a scheduler - * thread to process and free() after that frame was gone (#2266). This - * lock makes "the mode is on, enqueue" and every switch out of the mode - * exclusive. It is held across the enqueue only, never across a step, so a - * thread leaving the mode never waits on a handler. */ -static atomic_flag g_main_mode_lock = ATOMIC_FLAG_INIT; - -static inline void main_mode_lock(void) { - while (atomic_flag_test_and_set_explicit(&g_main_mode_lock, memory_order_acquire)) AETHER_PAUSE(); -} - -static inline void main_mode_unlock(void) { - atomic_flag_clear_explicit(&g_main_mode_lock, memory_order_release); -} - -int aether_main_mode_enqueue(ActorBase* actor, Message msg) { - main_mode_lock(); - int on = aether_main_thread_mode_active(); - if (on) mailbox_send(&actor->mailbox, msg); - main_mode_unlock(); - return on; -} - -void aether_leave_main_thread_mode(void) { - main_mode_lock(); - ActorBase* main_actor = (ActorBase*)g_aether_config.main_actor; - atomic_store_explicit(&g_aether_config.main_thread_mode, false, memory_order_release); - g_aether_config.main_actor = NULL; - /* The inline step paths hold the actor's step_lock while they step, - * so clearing main_thread_only cannot let a scheduler thread step the - * actor concurrently with a step still on the main thread's stack. */ - if (main_actor) atomic_store_explicit(&main_actor->main_thread_only, 0, memory_order_release); - main_mode_unlock(); - scheduler_ensure_threads_running(); -} - -/* The actor whose step_lock THIS thread holds for an inline step. A - * handler that sends to its own actor (`self ! Tick {}`) steps again on - * the same thread, and step_lock is not recursive: the owner check is - * what keeps that from spinning on its own lock. */ -static AETHER_TLS ActorBase* g_inline_lock_owner = NULL; - -int aether_inline_lock_acquire(ActorBase* actor) { - if (g_inline_lock_owner == actor) return 0; - while (atomic_flag_test_and_set_explicit(&actor->step_lock, memory_order_acquire)) AETHER_PAUSE(); - g_inline_lock_owner = actor; - return 1; -} - -void aether_inline_lock_release(ActorBase* actor) { - g_inline_lock_owner = NULL; - atomic_flag_clear_explicit(&actor->step_lock, memory_order_release); -} - -/* Inline (main-thread-mode) step: hold step_lock across it so the switch - * out of the mode by another thread is safe. */ -static inline void aether_step_inline(ActorBase* actor); - static inline void aether_step_safe(ActorBase* actor) { if (!actor || !actor->step) return; if (atomic_load_explicit(&actor->dead, memory_order_acquire)) return; @@ -262,18 +183,6 @@ static inline void aether_step_safe(ActorBase* actor) { if (reason_release) reason_release(reason); } -static inline void aether_step_inline(ActorBase* actor) { - // As in aether_send_message_sync: a release made in the step waits until - // this is done touching the actor (#2509). - g_inline_step_depth++; - int locked = aether_inline_lock_acquire(actor); - aether_step_safe(actor); - if (locked) aether_inline_lock_release(actor); - if (--g_inline_step_depth == 0 && unlikely(g_inline_release_pending)) { - scheduler_inline_step_done(); - } -} - /* Layout assertions to catch struct padding/size mismatches between * translation units. A derived actor is cast to ActorBase*, and both mirror * AETHER_ACTOR_BASE_FIELDS, so any disagreement about Mailbox's layout @@ -333,10 +242,7 @@ static atomic_uint_fast64_t main_thread_sent = 0; // pthread_join already-joined threads — undefined behaviour (crash on Linux glibc). static atomic_int g_threads_joined = 0; -// Track whether scheduler threads have been created. -// scheduler_start() skips thread creation in main-thread mode; if main-thread -// mode is later disabled (e.g. self-send pattern), we need to know whether -// threads still need to be started. +// Whether scheduler_start() has created the threads this lifecycle. static atomic_int g_threads_started = 0; // Barrier: scheduler_start() spins here until every thread has finished its @@ -761,8 +667,7 @@ static void overflow_flush(int from_core) { if (!actor->auto_process) inflight_leave(actor, 1); scheduler_drop_dead_message(&schedulers[from_core], &msg); flushed++; - } else if (ac == from_core && !actor->auto_process && - !atomic_load_explicit(&actor->main_thread_only, memory_order_relaxed)) { + } else if (ac == from_core && !actor->auto_process) { if (mailbox_send(&actor->mailbox, msg)) { inflight_leave(actor, 1); atomic_store_explicit(&actor->active, 1, memory_order_relaxed); @@ -1118,10 +1023,6 @@ static int actor_table_grow_locked(Scheduler* sched, int numa_node) { #define AETHER_EXTERNAL_READER_SLOTS 32 -// Set once main-thread mode has been entered or a second actor has existed: -// the mode is not entered again in this scheduler lifecycle. -static atomic_int g_main_mode_ended = 0; - static _Atomic uint64_t g_reclaim_epoch = 1; // never 0: 0 means "not reading" static _Atomic uint64_t g_external_reader[AETHER_EXTERNAL_READER_SLOTS]; @@ -1201,25 +1102,6 @@ void scheduler_reader_offline(void) { atomic_store_explicit(&r->in_use, 0, memory_order_release); } -// Actors released while an inline send was stepping on this thread, released -// for real when the outermost one is done (scheduler_inline_step_done). -static AETHER_TLS ActorBase** t_inline_released = NULL; -static AETHER_TLS int t_inline_released_count = 0; -static AETHER_TLS int t_inline_released_capacity = 0; - -static int inline_release_defer(ActorBase* actor) { - if (t_inline_released_count == t_inline_released_capacity) { - int cap = t_inline_released_capacity ? t_inline_released_capacity * 2 : 4; - ActorBase** grown = realloc(t_inline_released, (size_t)cap * sizeof(ActorBase*)); - if (!grown) return 0; - t_inline_released = grown; - t_inline_released_capacity = cap; - } - t_inline_released[t_inline_released_count++] = actor; - g_inline_release_pending = 1; - return 1; -} - typedef struct { ActorBase* actor; uint64_t epoch; @@ -1683,7 +1565,6 @@ static void scheduler_drop_dead_message(Scheduler* sched, Message* msg) { // lock is taken or a message is in flight. static int move_actor(Scheduler* sched, ActorBase* actor, int dst, int slot_hint) { if (dst < 0 || dst >= num_cores || dst == sched->core_id || actor->auto_process || - atomic_load_explicit(&actor->main_thread_only, memory_order_acquire) || atomic_load_explicit(&actor->dead, memory_order_acquire)) return 0; Scheduler* to = &schedulers[dst]; @@ -1875,25 +1756,6 @@ void* AETHER_HOT scheduler_thread(void* arg) { continue; } - // For actors processed on main thread, just deliver to mailbox (don't process) - if (unlikely(atomic_load_explicit(&actor->main_thread_only, memory_order_acquire))) { - // Its place kept as below (#3093): the main thread drains the - // mailbox while the actor is its own, and once main-thread - // mode has let go of it this core makes room by stepping it. - int placed = 1; - while (!mailbox_send(&actor->mailbox, msg)) { - if (atomic_load_explicit(&actor->main_thread_only, memory_order_acquire)) { - aether_sched_yield(); - } else if (!make_room(sched, actor)) { - placed = 0; - break; - } - } - inflight_leave(actor, 1); - if (!placed) scheduler_drop_dead_message(sched, &msg); - work_done = 1; - continue; - } // From here the message is this core's to deliver, and so is // every message of the same actor that follows it in the batch: @@ -1996,8 +1858,6 @@ void* AETHER_HOT scheduler_thread(void* arg) { // A slot vacated since the snapshot reads NULL. if (unlikely(!actor)) continue; - // Skip actors processed on main thread - if (unlikely(atomic_load_explicit(&actor->main_thread_only, memory_order_acquire))) continue; // An actor with its own thread is stepped there. Counting it as // work, as this used to, kept the core from ever going idle and // parking for as long as such an actor existed (#2592). @@ -2280,8 +2140,6 @@ void scheduler_init(int cores) { atomic_store_explicit(&g_threads_ready, 0, memory_order_relaxed); // Reset global overflow counter between scheduler lifecycles. atomic_store_explicit(&g_overflow_total, 0, memory_order_relaxed); - // A new lifecycle's first actor may use main-thread mode again (#2509). - atomic_store_explicit(&g_main_mode_ended, 0, memory_order_relaxed); // Reset main-thread send counter between scheduler lifecycles. // Without this, count_pending_messages() sees a stale sent > processed // delta from the prior run and scheduler_wait() spins forever. @@ -2290,19 +2148,10 @@ void scheduler_init(int cores) { // TIER 2: Auto-detect hardware capabilities first aether_detect_hardware(); - // Initialize profile and inline mode from env vars + // Initialize the profile from env vars aether_init_from_env(); - // Reset main-thread-mode and actor count between scheduler lifecycles. - // Tests call scheduler_init/cleanup in sequence; a prior run may have left - // main_thread_mode=true (via scheduler_spawn_actor), causing scheduler_start() - // and scheduler_wait() to skip creating/joining threads on the next run. - // Only reset if the user hasn't force-enabled inline mode via AETHER_INLINE. - if (!atomic_load_explicit(&g_aether_config.inline_mode_forced, memory_order_relaxed)) { - atomic_store_explicit(&g_aether_config.main_thread_mode, false, memory_order_relaxed); - atomic_store_explicit(&g_aether_config.inline_mode_active, false, memory_order_relaxed); - g_aether_config.main_actor = NULL; - } + // Tests call scheduler_init/cleanup in sequence: the count starts afresh. atomic_store_explicit(&g_aether_config.actor_count, 0, memory_order_relaxed); // Initialize NUMA topology detection @@ -2386,12 +2235,6 @@ void scheduler_init_with_opts(int cores, AetherOptFlags opts) { } void scheduler_start(void) { - // MAIN THREAD MODE: Single-actor programs don't need scheduler threads - // All message processing happens synchronously on the main thread - if (aether_main_thread_mode_active()) { - return; // No threads to start - } - // Only start threads once (idempotent: safe to call multiple times) if (atomic_exchange_explicit(&g_threads_started, 1, memory_order_acq_rel) == 1) { return; // Threads already running @@ -2424,17 +2267,6 @@ void scheduler_start(void) { } } -// Ensure scheduler threads are running. Called when transitioning out of -// main-thread mode (e.g. self-send from an actor handler). In main-thread -// mode, scheduler_start() skips thread creation; this function starts them -// on demand so queued messages get processed. -void scheduler_ensure_threads_running(void) { - if (atomic_load_explicit(&g_threads_started, memory_order_acquire)) { - return; // Already running - } - scheduler_start(); -} - void scheduler_stop(void) { // Set all running flags to 0 with release semantics for (int i = 0; i < num_cores; i++) { @@ -2514,36 +2346,7 @@ static int has_pending_actor_timeout(void) { } void scheduler_wait(void) { - // MAIN THREAD MODE: All messages processed synchronously, nothing to wait for - // This is the fastest path for single-actor programs (counting benchmark) - if (aether_main_thread_mode_active()) { - // But if any actor has a pending timeout, poll until it fires - if (has_pending_actor_timeout()) { - int rounds = 0; - while (has_pending_actor_timeout() && rounds < 100000) { - int slot = reclaim_reader_enter(); // #2509 - for (int c = 0; c < num_cores; c++) { - int count; - AetherActorTable* table = actor_table_snapshot(&schedulers[c], &count); - for (int i = 0; i < count; i++) { - ActorBase* a = actor_table_read(table, i); - if (a && a->timeout_ns > 0 && a->step) { - a->step(a); - } - } - } - reclaim_reader_exit(slot); - #ifdef _WIN32 - Sleep(1); - #else - usleep(1000); - #endif - rounds++; - } - } - return; - } - + batch_flush_pending(); // Check if scheduler is still running int still_running = 0; for (int i = 0; i < num_cores; i++) { @@ -2574,7 +2377,7 @@ void scheduler_wait(void) { stable_count++; // Quiet, but a receive timeout is armed (#3102): its core fires // it once the deadline passes, and its arm may send more, so the - // wait goes on, as main-thread mode's does above (and as long). + // wait goes on, polling every millisecond (100000 times at most). if (stable_count == 5 && timeout_rounds < 100000 && has_pending_actor_timeout()) { stable_count = 0; timeout_rounds++; @@ -2624,7 +2427,7 @@ void scheduler_wait(void) { // Full shutdown: wait for quiescence, stop threads, join them. // Called once at program exit. Safe to call even if threads were never -// started (main-thread mode) or already shut down. +// started or already shut down. void scheduler_shutdown(void) { // Wait for any in-flight messages first scheduler_wait(); @@ -3101,27 +2904,11 @@ void scheduler_send_remote(ActorBase* actor, Message msg, int from_core) { scheduler_actor_thread_deliver(actor, msg); return; } - // INLINE MODE: For single-actor programs, process synchronously on the main thread. - // scheduler_send_batch_add has the same check; keep them in sync. A - // thread other than main leaves the mode instead and sends through - // the scheduler (#2083). - if (unlikely(aether_main_thread_mode_active())) { - if (aether_on_main_mode_thread()) { - if (aether_main_mode_enqueue(actor, msg)) { - aether_step_inline(actor); - AETHER_STAT_INC(inline_sends); - return; - } - // The mode ended before the enqueue: the scheduler takes it. - } else { - aether_leave_main_thread_mode(); - } - } - if (current_core_id >= 0) { send_through_core(actor, msg, from_core); return; } + batch_flush_pending(); if (!foreign_send_enter()) { send_while_stopped(actor, &msg); return; @@ -3264,13 +3051,21 @@ typedef struct { static AETHER_TLS BatchSendBuffer* g_batch_buffer = NULL; +/* #3117: a send from this thread that does not join its pending batch, or a + * wait for quiescence, sends the batch first: the batch's messages are older, + * and messages nobody has sent are not pending to the wait. */ +static inline void batch_flush_pending(void) { + if (unlikely(g_batch_buffer && g_batch_buffer->count > 0)) scheduler_send_batch_flush(); +} + +/* Opening a batch never drops what one holds (#3117): only a flush empties + * the buffer. An inner loop's start emptied it and lost the outer loop's + * messages. */ void scheduler_send_batch_start(void) { if (!g_batch_buffer) { g_batch_buffer = calloc(1, sizeof(BatchSendBuffer)); if (!g_batch_buffer) return; // OOM: batching stays off, callers fall back } - g_batch_buffer->count = 0; - memset(g_batch_buffer->by_core, 0, sizeof(g_batch_buffer->by_core)); } void scheduler_send_batch_add(ActorBase* actor, Message msg) { @@ -3280,33 +3075,19 @@ void scheduler_send_batch_add(ActorBase* actor, Message msg) { return; } // An actor with its own thread: straight to its inbox, as from - // scheduler_send_remote, never stepped inline (#2598). - if (unlikely(actor->auto_process)) { + // scheduler_send_remote, never stepped inline (#2598). And nothing is + // batched on a scheduler thread (#3117): its sends keep its own core's + // channel and overflow order. + if (unlikely(actor->auto_process) || current_core_id >= 0) { scheduler_send_remote(actor, msg, current_core_id); return; } - // FAST PATH: Single-actor programs bypass batching entirely - // Main Thread Mode = synchronous processing, zero queue overhead - if (aether_main_thread_mode_active()) { - if (aether_on_main_mode_thread()) { - if (aether_main_mode_enqueue(actor, msg)) { - aether_step_inline(actor); - AETHER_STAT_INC(inline_sends); - return; - } - // The mode ended before the enqueue: the batch path takes it. - } else { - aether_leave_main_thread_mode(); - } - } - // BATCH PATH: Multi-actor fan-out optimization if (!g_batch_buffer) { scheduler_send_batch_start(); if (!g_batch_buffer) { - // Batch buffer couldn't be allocated: deliver directly so the - // message is never dropped. Batching is only a throughput optimization. - mailbox_send(&actor->mailbox, msg); + // No buffer: the send goes out on its own, as an unbatched one. + scheduler_send_remote(actor, msg, current_core_id); return; } } @@ -3400,6 +3181,10 @@ void scheduler_send_batch_flush(void) { sorted_actors[pos] = g_batch_buffer->actors[i]; sorted_msgs[pos] = g_batch_buffer->messages[i]; } + // Copied out: the buffer is empty before the overflow fallback below + // sends, which flushes a pending batch first. + g_batch_buffer->count = 0; + memset(g_batch_buffer->by_core, 0, sizeof(g_batch_buffer->by_core)); // === PHASE 4: Batch enqueue to each core (ONE atomic per core!) === uint64_t total_sent = 0; @@ -3488,62 +3273,13 @@ ActorBase* scheduler_spawn_actor(int preferred_core, void (*step)(void*), size_t atomic_init(&actor->inflight, 0); atomic_init(&actor->assigned_core, preferred_core); atomic_init(&actor->migrate_to, -1); - atomic_init(&actor->main_thread_only, 0); atomic_init(&actor->reply_slot, NULL); atomic_flag_clear_explicit(&actor->step_lock, memory_order_relaxed); actor->timeout_ns = 0; actor->last_activity_ns = 0; atomic_init(&actor->dead, 0); - // Track actor count for inline mode auto-detection - // Get previous count and main_actor BEFORE aether_on_actor_spawn modifies them - int prev_count = atomic_load_explicit(&g_aether_config.actor_count, memory_order_relaxed); - ActorBase* prev_main_actor = (ActorBase*)g_aether_config.main_actor; - // A second actor ends main-thread mode: under the lock an inline - // enqueue holds, like every switch out of the mode (#2266). - main_mode_lock(); aether_on_actor_spawn(); - main_mode_unlock(); - - // Main-thread mode is for the first actor of a scheduler lifecycle and is - // entered at most once (#2509). Released actors bring the count back to - // 0, and a later spawn at 0 (the next per-request worker of an HTTP - // server, say) would otherwise be stepped inline on whichever thread - // spawned it, with the scheduler threads already running. - if (prev_count >= 1) { - atomic_store_explicit(&g_main_mode_ended, 1, memory_order_relaxed); - } - - // MAIN THREAD MODE handling - if (prev_count == 0 && !atomic_load(&g_aether_config.inline_mode_disabled) && - !atomic_exchange_explicit(&g_main_mode_ended, 1, memory_order_relaxed)) { - // First actor: enable main thread mode for synchronous processing - aether_enable_main_thread_mode(actor); - g_main_mode_thread = aether_tid_self(); - atomic_store_explicit(&g_main_mode_thread_set, 1, memory_order_release); - atomic_store_explicit(&actor->main_thread_only, 1, memory_order_release); - } else if (prev_count == 1 && prev_main_actor != NULL) { - // Second actor: disable main thread mode on the first actor so scheduler - // threads can process both actors normally. - // - // RACE GUARD: If the main thread is currently executing prev_main_actor's - // step() synchronously (g_sync_step_actor == prev_main_actor), we must NOT - // clear main_thread_only here. Doing so would allow a scheduler thread to - // call step() on the same actor concurrently — undefined behaviour. - // - // aether_send_message_sync() clears main_thread_only after step() returns, - // so deferred clearing is always safe. - if (g_sync_step_actor != prev_main_actor) { - atomic_store_explicit(&prev_main_actor->main_thread_only, 0, memory_order_release); - } - // else: aether_send_message_sync will clear it once step() unwinds. - - // aether_on_actor_spawn() (above) disabled main_thread_mode. If - // scheduler_start() was already called and returned early (because - // main-thread mode was active at the time), threads were never created. - // Start them now so both actors can be processed by the scheduler. - scheduler_ensure_threads_running(); - } actor->scheduler_owned = 1; register_actor(actor, preferred_core); @@ -3567,26 +3303,6 @@ static void actor_end(ActorBase* actor) { void scheduler_release_actor(ActorBase* actor) { if (!actor) return; - // Released while an inline send is stepping an actor on this thread (its - // own, typically): that send still touches the actor once the step - // returns, so the release waits for scheduler_inline_step_done(). - if (g_inline_step_depth > 0) { - if (inline_release_defer(actor)) return; - // Out of memory to remember it: keeping the actor is safe, freeing - // it is not. - fprintf(stderr, "aether: out of memory deferring the release of actor %d; " - "it is not freed\n", actor->id); - return; - } - - // The lone actor of main-thread mode is going: leave the mode (which - // starts the scheduler threads), or the next spawn would be stepped - // inline against a main actor that no longer exists. - if (aether_main_thread_mode_active() && - (ActorBase*)g_aether_config.main_actor == actor) { - aether_leave_main_thread_mode(); - } - // Read before the mark: once it is set, the actor's own thread may end // the actor at any moment, and nothing below touches it after that. int own_thread = actor->auto_process; @@ -3617,20 +3333,6 @@ void scheduler_actor_thread_exit(ActorBase* actor) { if (was & AETHER_ACTOR_RELEASED) actor_end(actor); } -void scheduler_inline_step_done(void) { - // Releases are taken one at a time from the end: releasing runs no step, - // so nothing is added meanwhile, but the list is not walked while it - // changes. - while (t_inline_released_count > 0) { - ActorBase* actor = t_inline_released[--t_inline_released_count]; - scheduler_release_actor(actor); - } - free(t_inline_released); - t_inline_released = NULL; - t_inline_released_capacity = 0; - g_inline_release_pending = 0; -} - // ============================================================================ // Ask/Reply support @@ -3729,62 +3431,11 @@ void scheduler_reply(ActorBase* self, void* data, size_t data_size) { scheduler_reply_owned(self, data, data_size, NULL); } -// --------------------------------------------------------------------------- -// aether_scheduler_poll — drain pending messages for main-thread-only actors. -// -// Call this from C-hosted event loops (e.g., inside a render callback) to -// keep Aether actors alive while the main thread is blocked in C code. -// -// Thread safety: only touches actors flagged as main_thread_only, which -// scheduler worker threads explicitly skip — so no concurrent access. -// -// max_per_actor: max messages to process per actor per call (0 = unlimited). -// Returns total number of messages processed across all actors. +// aether_scheduler_poll: the scheduler threads run every actor from the first +// spawn on (#3112), so a host thread has nothing to drain. See the header. int aether_scheduler_poll(int max_per_actor) { - // If main-thread inline mode is active, all sends are already handled - // synchronously — nothing to drain here. - if (aether_main_thread_mode_active()) return 0; - - int total = 0; - int limit = (max_per_actor <= 0) ? 1024 : max_per_actor; - - // A step run here may release its own actor, which the loop touches after - // the step: the reader slot keeps it allocated until the walk is over - // (#2509). - int slot = reclaim_reader_enter(); - for (int c = 0; c < num_cores; c++) { - Scheduler* sched = &schedulers[c]; - - // No lock: scheduler threads change the table meanwhile (#2486). - int actor_count; - AetherActorTable* table = actor_table_snapshot(sched, &actor_count); - - for (int i = 0; i < actor_count; i++) { - ActorBase* actor = actor_table_read(table, i); - if (!actor || !actor->step) continue; - - // Only process actors the scheduler threads are skipping. - // It is safe to call step() here because scheduler threads check - // main_thread_only and skip these actors entirely. - if (!atomic_load_explicit(&actor->main_thread_only, memory_order_acquire)) - continue; - - int processed = 0; - if (!atomic_flag_test_and_set_explicit(&actor->step_lock, memory_order_acquire)) { - while (atomic_load_explicit(&actor->mailbox.count, memory_order_relaxed) > 0 && processed < limit) { - aether_step_safe(actor); - processed++; - total++; - } - atomic_flag_clear_explicit(&actor->step_lock, memory_order_release); - } - if (atomic_load_explicit(&actor->mailbox.count, memory_order_relaxed) == 0) - atomic_store_explicit(&actor->active, 0, memory_order_relaxed); - } - } - reclaim_reader_exit(slot); - - return total; + (void)max_per_actor; + return 0; } diff --git a/runtime/scheduler/multicore_scheduler.h b/runtime/scheduler/multicore_scheduler.h index f1566b59b..3db751218 100644 --- a/runtime/scheduler/multicore_scheduler.h +++ b/runtime/scheduler/multicore_scheduler.h @@ -56,9 +56,8 @@ typedef struct { // TLS pointer to the actor whose step() is currently running on this // thread. Set by generated *_step() functions at entry; read by the // reactor bridge (std/net/aether_actor_bridge.c) so await_io() can -// identify the calling actor without a parameter. Unlike -// g_sync_step_actor which is specific to main-thread sync mode, this -// variable is set for every step call on every thread. +// identify the calling actor without a parameter. It is set for every +// step call on every thread. extern AETHER_TLS void* g_current_step_actor; /* Accessors for the three thread-local slots the generated program touches. @@ -129,8 +128,6 @@ struct ActorInbox; /* runtime/actors/aether_actor_inbox.h */ atomic_int assigned_core; \ /* Affinity hint: core to migrate to (-1 = none) */ \ atomic_int migrate_to; \ - /* If set, scheduler threads must not process this actor */ \ - atomic_int main_thread_only; \ /* Same-core batches from send_buffer_flush (lazy) */ \ SPSCQueue* spsc_queue; \ /* Non-NULL only while an ask/reply is in flight */ \ @@ -296,7 +293,6 @@ void scheduler_init(int cores); void scheduler_init_with_opts(int cores, AetherOptFlags opts); void scheduler_start(void); -void scheduler_ensure_threads_running(void); // Start threads if not already started (for main-thread mode transition) // The CPU scheduler thread `core_id` is placed on: the (core_id mod n)-th // of the n CPUs the process may run on, read when the threads last started @@ -304,26 +300,6 @@ void scheduler_ensure_threads_running(void); // Start threads if not already st // numbers to place by (macOS). int scheduler_core_cpu(int core_id); -/* Main-thread mode is "one actor, every send from the thread running - * main()": that thread steps the actor inline at each send. A send from - * any other thread cannot join in — stepping the same actor from two - * threads is the data race #2083's second symptom (heap corruption) — - * so the runtime leaves the mode exactly as spawning a second actor - * does, and the send goes through the scheduler. */ -int aether_on_main_mode_thread(void); -void aether_leave_main_thread_mode(void); -/* Put `msg` in `actor`'s mailbox for an inline step, if the runtime is still - * in main-thread mode: the check and the enqueue are one step with respect - * to leaving the mode, so no scheduler delivery can come between or ahead - * of them (#2266). Returns 1 when enqueued, 0 when the mode has ended and - * the caller must send through the scheduler instead. */ -int aether_main_mode_enqueue(ActorBase* actor, Message msg); -/* The inline (main-thread-mode) step holds the actor's step_lock, and a - * handler that sends to its own actor steps again on the same thread: - * acquire returns 0 when this thread already holds the lock (nothing to - * release), 1 when it took it. */ -int aether_inline_lock_acquire(ActorBase* actor); -void aether_inline_lock_release(ActorBase* actor); void scheduler_stop(void); void scheduler_wait(void); // Wait for quiescence (all pending messages processed). Non-destructive. void scheduler_shutdown(void); // Wait + stop + join threads. Call once at program exit. @@ -422,10 +398,6 @@ void scheduler_set_actor_park_ms(int ms); * steps it from 1 to 32 ms the longer the core is idle. For tests, as the * one above: set long, a core that is never woken stalls (#3087). */ void scheduler_set_core_park_ms(int ms); -/* Called by the inline (main-thread-mode) send once the step it ran has - * returned and it no longer touches the actor: an actor that released itself - * in that step is released now (#2509). */ -void scheduler_inline_step_done(void); // Ask/reply: send a message and block until a reply arrives or timeout. // Returns malloc'd reply payload on success (caller must free), NULL on timeout. @@ -441,11 +413,11 @@ void scheduler_reply(ActorBase* self, void* data, size_t data_size); void scheduler_reply_owned(ActorBase* self, void* data, size_t data_size, void (*release)(void*)); -// Drain pending messages for main-thread-only actors. -// Call this from C-hosted event loops (e.g. inside a render/event callback) -// to keep Aether actors alive while the main thread is occupied in C code. -// max_per_actor: max messages to process per actor per call (0 = unlimited). -// Returns total messages processed across all actors. +// The cooperative (no-threads) runtime's dispatch: step every actor with +// messages, at most max_per_actor messages each (0 = 1024), and return how +// many were processed. A C host's event loop calls it between frames to keep +// actors running. With threads the scheduler threads run every actor, from +// the first spawn on (#3112), so it has nothing to drain and returns 0. int aether_scheduler_poll(int max_per_actor); // I/O event integration — register/unregister fds for non-blocking I/O dispatch diff --git a/std/net/aether_actor_bridge.c b/std/net/aether_actor_bridge.c index 39056119d..59ace47b1 100644 --- a/std/net/aether_actor_bridge.c +++ b/std/net/aether_actor_bridge.c @@ -22,22 +22,20 @@ // here to identify the "current" actor and core at the point of call. // // g_current_step_actor is set by every generated *_step() function at -// entry (see compiler/codegen/codegen_actor.c), so it is valid in all -// scheduling modes: main-thread sync mode, scheduler-thread dispatch, -// and work-stealing. g_sync_step_actor is kept as a legacy fallback -// for the main-thread-only path. +// entry (see compiler/codegen/codegen_actor.c), so it is valid on every +// thread that steps an actor: a scheduler core, work-stealing, an actor's +// own thread. extern AETHER_TLS int current_core_id; extern AETHER_TLS void* g_current_step_actor; -extern AETHER_TLS ActorBase* g_sync_step_actor; // Suspend the current actor until `fd` is readable. Returns 0 on // success, -1 on failure (no active actor context, invalid fd, or the // scheduler refused the registration). // -// The caller must have received or spawned from within an actor's -// receive handler — `g_sync_step_actor` is only set while an actor's -// step() function is on the call stack. Calling await_io from main() -// with no active actor is a programming error and returns -1. +// The caller must be an actor's receive handler: `g_current_step_actor` +// is set only while an actor's step() function is on the call stack. +// Calling await_io from main() with no active actor is a programming error +// and returns -1. // // On success, control returns to the caller immediately; the runtime // does NOT block here. The actor resumes via a fresh step() invocation @@ -47,16 +45,12 @@ int ae_io_await(int fd) { if (fd < 0) return -1; ActorBase* actor = (ActorBase*)g_current_step_actor; - if (!actor) actor = g_sync_step_actor; if (!actor) return -1; + // A step on a thread that is no scheduler core (an actor's own thread) + // registers through core 0. int core = current_core_id; - if (core < 0) { - // Main-thread actor mode: step() runs on the main thread with - // current_core_id == -1, but the actor is still assigned to a - // scheduler core. Route the registration through that core. - core = 0; - } + if (core < 0) core = 0; return scheduler_io_register(core, fd, actor, AETHER_IO_READ); } diff --git a/tests/integration/binary_import_actors/crowd.ae b/tests/integration/binary_import_actors/crowd.ae index 7b059705c..30df0b4f9 100644 --- a/tests/integration/binary_import_actors/crowd.ae +++ b/tests/integration/binary_import_actors/crowd.ae @@ -24,8 +24,7 @@ actor Herald { } } -// Two actors: past the single-actor main-thread mode, so the scheduler's -// threads do the work. +// Two actors, the scheduler's threads doing the work. count_to(n: int) -> int { a = spawn(Counter()) b = spawn(Counter()) diff --git a/tests/integration/foreign_thread_send/test_foreign_thread_send.sh b/tests/integration/foreign_thread_send/test_foreign_thread_send.sh index 52f6ab67a..7b7b0a814 100644 --- a/tests/integration/foreign_thread_send/test_foreign_thread_send.sh +++ b/tests/integration/foreign_thread_send/test_foreign_thread_send.sh @@ -10,25 +10,12 @@ # silently lost: the receive arm never ran, no error anywhere. The channel # now has a lock that serializes its non-scheduler producers. # -# A second thing hid behind that: with ONE actor the runtime is in -# main-thread mode — the thread running main() steps the actor inline at -# each send — and a foreign thread's send stepped the same actor at the -# same time (heap corruption). A thread that is not main now leaves the -# mode, as spawning a second actor does, and sends through the scheduler; -# the inline step holds the actor's step_lock so the switch is safe. -# -# A third: an inline send checked that the mode was on and then wrote the -# mailbox, and a switch landing between the two let scheduler deliveries -# in ahead of the inline message, whose stack payload a scheduler thread -# then freed (#2266, free(): invalid pointer). The check and the enqueue -# are now one step with respect to every switch out of the mode. -# # Two worker threads and the main thread each send `PER` Bump messages to # one counter actor at the same time; the counter must end at 3 * PER. -# Run twice: with the counter as the only actor (main-thread mode on: -# crashed before the fix) and with a second actor spawned first (mode -# off: sends into the 1024-slot channel from three threads collided on -# nearly every run, thousands lost). +# Before the fix, sends into the 1024-slot channel from three threads +# collided on nearly every run and thousands were lost. (The one-actor +# main-thread mode this test also ran, and its races #2083 and #2266, went +# with the mode, #3112.) SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" @@ -68,7 +55,6 @@ actor Spy { } const PER = 20000 -const MODE = @MODE@ // Runs on a std.worker thread: a foreign producer. blast(job: ptr) -> ptr { @@ -94,10 +80,6 @@ read_count(target: actor_ref, timeout_ms: int) -> int { } main() { - if MODE == 1 { - // A second actor first: the runtime is not in main-thread mode. - _ = spawn(Spy()) - } a = spawn(Counter()) actors.register("counter", a) worker.run(|| { return blast(null) }, |r: ptr| { }) @@ -117,40 +99,26 @@ main() { println("counted ${got} of ${3 * PER}") } AE -# The switch out of main-thread mode happens once per process, at the -# first worker's send, and the race that corrupted the heap there (#2266) -# needed the main thread to be between its mode check and its mailbox -# write at that moment: about one CI run in thirty. So each mode is built -# once and its program run several times, the one-actor mode more. fail=0 -for mode in 0 1; do - sed "s/@MODE@/$mode/" "$tmp/main.ae" > "$tmp/run$mode.ae" - if ! out="$(AETHER_HOME="$ROOT" "$AE" build "$tmp/run$mode.ae" -o "$tmp/run$mode" 2>&1)"; then - echo " [FAIL] foreign_thread_send: the mode $mode program did not build" +if ! out="$(AETHER_HOME="$ROOT" "$AE" build "$tmp/main.ae" -o "$tmp/run" 2>&1)"; then + echo " [FAIL] foreign_thread_send: the program did not build" + printf '%s\n' "$out" | tail -4 | sed 's/^/ /' + exit 1 +fi +runs=10 +i=0 +while [ "$i" -lt "$runs" ]; do + i=$((i + 1)) + out="$("$tmp/run" 2>&1)" + got="$(printf '%s\n' "$out" | tail -1)" + if [ "$got" != "counted 60000 of 60000" ]; then + echo " [FAIL] foreign_thread_send: sends from worker threads were lost or crashed (run $i of $runs)" printf '%s\n' "$out" | tail -4 | sed 's/^/ /' fail=1 - continue + break fi - runs=5 - [ "$mode" = 0 ] && runs=20 - i=0 - while [ "$i" -lt "$runs" ]; do - i=$((i + 1)) - out="$("$tmp/run$mode" 2>&1)" - got="$(printf '%s\n' "$out" | tail -1)" - if [ "$got" != "counted 60000 of 60000" ]; then - if [ "$mode" = 0 ]; then - echo " [FAIL] foreign_thread_send: with one actor (main-thread mode) sends from worker threads were lost or crashed (run $i of $runs)" - else - echo " [FAIL] foreign_thread_send: with two actors (scheduler mode) sends from worker threads were lost or crashed (run $i of $runs)" - fi - printf '%s\n' "$out" | tail -4 | sed 's/^/ /' - fail=1 - break - fi - done done if [ "$fail" = 0 ]; then - echo " [PASS] foreign_thread_send: sends from worker threads and the main thread all arrive, in both runtime modes" + echo " [PASS] foreign_thread_send: sends from worker threads and the main thread all arrive" fi exit $fail diff --git a/tests/integration/http_actor_mode_release/shim.c b/tests/integration/http_actor_mode_release/shim.c index d45fff77d..8a88d61b6 100644 --- a/tests/integration/http_actor_mode_release/shim.c +++ b/tests/integration/http_actor_mode_release/shim.c @@ -56,8 +56,6 @@ static void worker_step(void* self) { if (fd >= 0) http_server_drain_connection(g_server, fd); } -static void noop_step(void* self) { (void)self; } - static int registered_actors(void) { int total = 0; for (int c = 0; c < num_cores; c++) total += atomic_load(&schedulers[c].actor_count); @@ -140,12 +138,6 @@ static void sleep_ms(int ms) { } int http_actor_release_probe(void) { - /* An actor of the program's own that lives throughout: the first actor - * takes main-thread mode, the first worker ends it and the scheduler - * threads run the workers. */ - ActorBase* keep = scheduler_spawn_actor(-1, noop_step, sizeof(ActorBase)); - if (!keep) { printf("FAIL: spawn\n"); return 1; } - g_server = http_server_create(0); if (!g_server) { printf("FAIL: server_create\n"); return 1; } http_server_get(g_server, "/ping", ping_handler, NULL); diff --git a/tests/integration/http_auth/server.ae b/tests/integration/http_auth/server.ae index dc9b5b702..9bf3a1712 100644 --- a/tests/integration/http_auth/server.ae +++ b/tests/integration/http_auth/server.ae @@ -40,19 +40,13 @@ handle_me(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} run_server(raw: @keep ptr) { - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } sleep(60000) diff --git a/tests/integration/http_client_custom_ca/server.ae b/tests/integration/http_client_custom_ca/server.ae index 4b146525b..2199b6bf3 100644 --- a/tests/integration/http_client_custom_ca/server.ae +++ b/tests/integration/http_client_custom_ca/server.ae @@ -20,16 +20,11 @@ handle_root(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { cert_path = os_getenv("CERT_PATH") @@ -63,7 +58,6 @@ main() { http.server_get(raw, "/", handle_root, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_client_insecure_tls/server.ae b/tests/integration/http_client_insecure_tls/server.ae index 6b3b2828c..f16256deb 100644 --- a/tests/integration/http_client_insecure_tls/server.ae +++ b/tests/integration/http_client_insecure_tls/server.ae @@ -21,16 +21,11 @@ handle_root(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { cert_path = os_getenv("CERT_PATH") @@ -65,7 +60,6 @@ main() { http.server_get(raw, "/", handle_root, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_client_stream/main.ae b/tests/integration/http_client_stream/main.ae index 4ac18c77a..83581eda4 100644 --- a/tests/integration/http_client_stream/main.ae +++ b/tests/integration/http_client_stream/main.ae @@ -37,7 +37,6 @@ handle_big(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 @@ -49,11 +48,6 @@ actor SrvActor { } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} - base_url() -> string { return "http://127.0.0.1:${PORT}" } main() { @@ -65,7 +59,6 @@ main() { } http.server_get(raw, "/big", handle_big, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_client_stream_chunked/main.ae b/tests/integration/http_client_stream_chunked/main.ae index 1af0ce04a..e82853505 100644 --- a/tests/integration/http_client_stream_chunked/main.ae +++ b/tests/integration/http_client_stream_chunked/main.ae @@ -21,12 +21,6 @@ extern tcp_close(sock: ptr) -> int const PORT = 18099 message StartRaw { srv: ptr } -message Noop {} - -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} // Persistent chunked server: accept a connection, drain the request, answer // with a chunked "hello world", close, repeat. Looping lets the client's @@ -59,7 +53,6 @@ main() { lst = tcp_listen_on_raw("127.0.0.1", PORT) if lst == null { println("FAIL: chunked listen failed"); exit(1) } - spawn(SchedHelper()) srv = spawn(RawChunkedSrv()) srv ! StartRaw { srv: lst } diff --git a/tests/integration/http_h2_concurrent_dispatch/server.ae b/tests/integration/http_h2_concurrent_dispatch/server.ae index 92f75af0a..b9fa617eb 100644 --- a/tests/integration/http_h2_concurrent_dispatch/server.ae +++ b/tests/integration/http_h2_concurrent_dispatch/server.ae @@ -45,16 +45,11 @@ handle_fast(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -95,7 +90,6 @@ main() { http.server_get(raw, "/slow", handle_slow, 0) http.server_get(raw, "/fast", handle_fast, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_handler_user_data/server.ae b/tests/integration/http_handler_user_data/server.ae index ab6ef1704..bb6b8654d 100644 --- a/tests/integration/http_handler_user_data/server.ae +++ b/tests/integration/http_handler_user_data/server.ae @@ -64,16 +64,11 @@ handle_no_ctx(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -109,7 +104,6 @@ main() { } println("READY ${http_server_port(raw)}") - spawn(SchedHelper()) srv_actor = spawn(SrvActor()) srv_actor ! StartSrv { raw: raw } diff --git a/tests/integration/http_head_and_middleware/server.ae b/tests/integration/http_head_and_middleware/server.ae index 83f1e0f50..39bdd5826 100644 --- a/tests/integration/http_head_and_middleware/server.ae +++ b/tests/integration/http_head_and_middleware/server.ae @@ -29,16 +29,11 @@ handle_root(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -59,7 +54,6 @@ main() { } println("READY ${http_server_port(raw)}") - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_middleware_d1/server.ae b/tests/integration/http_middleware_d1/server.ae index 0308250b3..a3c97c4a3 100644 --- a/tests/integration/http_middleware_d1/server.ae +++ b/tests/integration/http_middleware_d1/server.ae @@ -33,16 +33,11 @@ handle_root(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -74,7 +69,6 @@ main() { http.server_get(raw, "/", handle_root, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_middleware_d2/server.ae b/tests/integration/http_middleware_d2/server.ae index ab2df1c81..55f024667 100644 --- a/tests/integration/http_middleware_d2/server.ae +++ b/tests/integration/http_middleware_d2/server.ae @@ -37,15 +37,10 @@ handle_kaboom(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -91,7 +86,6 @@ main() { http.server_get(raw, "/api/data", handle_api, 0) http.server_get(raw, "/kaboom", handle_kaboom, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_park_idle_connections/server.ae b/tests/integration/http_park_idle_connections/server.ae index 090612560..8ac7072d7 100644 --- a/tests/integration/http_park_idle_connections/server.ae +++ b/tests/integration/http_park_idle_connections/server.ae @@ -17,16 +17,11 @@ handle(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -46,7 +41,6 @@ main() { } println("READY ${http_server_port(raw)}") - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_query_param_multi/server.ae b/tests/integration/http_query_param_multi/server.ae index d3d1623e5..01e1505ff 100644 --- a/tests/integration/http_query_param_multi/server.ae +++ b/tests/integration/http_query_param_multi/server.ae @@ -44,16 +44,11 @@ handle_missing(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -76,7 +71,6 @@ main() { } println("READY ${http_server_port(raw)}") - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_real_ip/server.ae b/tests/integration/http_real_ip/server.ae index b15b51c0a..c565f9f31 100644 --- a/tests/integration/http_real_ip/server.ae +++ b/tests/integration/http_real_ip/server.ae @@ -27,16 +27,11 @@ handle_whoami(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -65,7 +60,6 @@ main() { http.server_get(raw, "/whoami", handle_whoami, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_request_conn_accessors/server.ae b/tests/integration/http_request_conn_accessors/server.ae index 3e319cf30..fcd45813c 100644 --- a/tests/integration/http_request_conn_accessors/server.ae +++ b/tests/integration/http_request_conn_accessors/server.ae @@ -28,16 +28,11 @@ handle_whoami(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -69,7 +64,6 @@ main() { http.server_get(raw, "/whoami", handle_whoami, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_request_remote_addr/server.ae b/tests/integration/http_request_remote_addr/server.ae index 981493a01..db4d643ed 100644 --- a/tests/integration/http_request_remote_addr/server.ae +++ b/tests/integration/http_request_remote_addr/server.ae @@ -27,16 +27,11 @@ handle_whoami(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -57,7 +52,6 @@ main() { http.server_get(raw, "/whoami", handle_whoami, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_reverse_proxy/server.ae b/tests/integration/http_reverse_proxy/server.ae index ed13b8ad3..fceaac683 100644 --- a/tests/integration/http_reverse_proxy/server.ae +++ b/tests/integration/http_reverse_proxy/server.ae @@ -103,18 +103,12 @@ handle_upstream_slow(req: ptr, res: ptr, ud: ptr) { // Actors -------------------------------------------------------- message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} run_server(srv: @keep ptr) { - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: srv } sleep(60000) diff --git a/tests/integration/http_reverse_proxy_pool/server.ae b/tests/integration/http_reverse_proxy_pool/server.ae index 3bdcdff37..57172f3bd 100644 --- a/tests/integration/http_reverse_proxy_pool/server.ae +++ b/tests/integration/http_reverse_proxy_pool/server.ae @@ -178,18 +178,12 @@ handle_echo_cacheable_ttl(req: ptr, res: ptr, ud: ptr) { // ---- Actors ----------------------------------------------- message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} run_server(srv: @keep ptr) { - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: srv } // Block until the test harness SIGKILLs us. The earlier shape diff --git a/tests/integration/http_sendfile/server.ae b/tests/integration/http_sendfile/server.ae index cb97bd118..b1fd141fa 100644 --- a/tests/integration/http_sendfile/server.ae +++ b/tests/integration/http_sendfile/server.ae @@ -36,15 +36,10 @@ handle_static(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { n = os.args_count() @@ -69,7 +64,6 @@ main() { } println("READY ${http_server_port(s)}") - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: s } sleep(60000) diff --git a/tests/integration/http_serve_static_range/server.ae b/tests/integration/http_serve_static_range/server.ae index 18b9add21..01566b7be 100644 --- a/tests/integration/http_serve_static_range/server.ae +++ b/tests/integration/http_serve_static_range/server.ae @@ -17,15 +17,10 @@ handler(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { base = os_getenv("TEST_BASE") @@ -53,7 +48,6 @@ main() { exit(1) } println("READY ${http_server_port(raw)}") - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } sleep(60000) diff --git a/tests/integration/http_server_actor_dispatch/server.ae b/tests/integration/http_server_actor_dispatch/server.ae index f223c6448..9cefa3212 100644 --- a/tests/integration/http_server_actor_dispatch/server.ae +++ b/tests/integration/http_server_actor_dispatch/server.ae @@ -26,15 +26,10 @@ handle_echo(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -62,7 +57,6 @@ main() { http.server_get(raw, "/echo", handle_echo, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_server_connect_tunnel/server.ae b/tests/integration/http_server_connect_tunnel/server.ae index 3d410cdf7..673618e59 100644 --- a/tests/integration/http_server_connect_tunnel/server.ae +++ b/tests/integration/http_server_connect_tunnel/server.ae @@ -38,18 +38,12 @@ handle_connect(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} - main() { raw = http.server_create(0) http.server_set_host(raw, "127.0.0.1") @@ -69,7 +63,6 @@ main() { } println("READY ${http_server_port(raw)}") - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_server_h2/server.ae b/tests/integration/http_server_h2/server.ae index 8a7043813..0f679dc49 100644 --- a/tests/integration/http_server_h2/server.ae +++ b/tests/integration/http_server_h2/server.ae @@ -79,16 +79,11 @@ handle_n(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -127,7 +122,6 @@ main() { http.server_get(raw, "/big", handle_big, 0) http.server_get(raw, "/n", handle_n, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_server_h2_middleware/server.ae b/tests/integration/http_server_h2_middleware/server.ae index c70b1d269..dc2a9cf50 100644 --- a/tests/integration/http_server_h2_middleware/server.ae +++ b/tests/integration/http_server_h2_middleware/server.ae @@ -32,16 +32,11 @@ handle_text(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -90,7 +85,6 @@ main() { http.server_get(raw, "/text", handle_text, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_server_h2_tls/server.ae b/tests/integration/http_server_h2_tls/server.ae index b7425a616..9a261d6f4 100644 --- a/tests/integration/http_server_h2_tls/server.ae +++ b/tests/integration/http_server_h2_tls/server.ae @@ -22,16 +22,11 @@ handle_root(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { cert_path = os_getenv("CERT_PATH") @@ -73,7 +68,6 @@ main() { http.server_get(raw, "/", handle_root, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_server_keepalive/server.ae b/tests/integration/http_server_keepalive/server.ae index dbae5d0b4..39b4f146c 100644 --- a/tests/integration/http_server_keepalive/server.ae +++ b/tests/integration/http_server_keepalive/server.ae @@ -22,16 +22,11 @@ handle_count(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -59,7 +54,6 @@ main() { http.server_get(raw, "/", handle_count, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_server_observability/server.ae b/tests/integration/http_server_observability/server.ae index f281f3404..51e639688 100644 --- a/tests/integration/http_server_observability/server.ae +++ b/tests/integration/http_server_observability/server.ae @@ -27,15 +27,10 @@ handle_error(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -62,7 +57,6 @@ main() { http.server_get(raw, "/api/data", handle_data, 0) http.server_get(raw, "/api/error", handle_error, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_server_ops/server.ae b/tests/integration/http_server_ops/server.ae index 518247cb7..7fb5dd7d7 100644 --- a/tests/integration/http_server_ops/server.ae +++ b/tests/integration/http_server_ops/server.ae @@ -59,15 +59,10 @@ handle_work(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -91,7 +86,6 @@ main() { http.server_get(raw, "/work", handle_work, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_server_sse/server.ae b/tests/integration/http_server_sse/server.ae index 3d49f8119..68685c9ca 100644 --- a/tests/integration/http_server_sse/server.ae +++ b/tests/integration/http_server_sse/server.ae @@ -20,15 +20,10 @@ extern http_server_port(server: ptr) -> int } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -46,7 +41,6 @@ main() { } println("READY ${http_server_port(raw)}") - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_server_tls/server.ae b/tests/integration/http_server_tls/server.ae index 4db5c4a7a..9fa5f5522 100644 --- a/tests/integration/http_server_tls/server.ae +++ b/tests/integration/http_server_tls/server.ae @@ -21,16 +21,11 @@ handle_root(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { cert_path = os_getenv("CERT_PATH") @@ -65,7 +60,6 @@ main() { http.server_get(raw, "/", handle_root, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_server_websocket/server.ae b/tests/integration/http_server_websocket/server.ae index cae1d02a9..51f00b197 100644 --- a/tests/integration/http_server_websocket/server.ae +++ b/tests/integration/http_server_websocket/server.ae @@ -33,15 +33,10 @@ extern http_server_port(server: ptr) -> int } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -59,7 +54,6 @@ main() { } println("READY ${http_server_port(raw)}") - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/http_sse_upgrade/server.ae b/tests/integration/http_sse_upgrade/server.ae index c49caf899..8846fec56 100644 --- a/tests/integration/http_sse_upgrade/server.ae +++ b/tests/integration/http_sse_upgrade/server.ae @@ -46,15 +46,10 @@ extern http_server_port(server: ptr) -> int } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -70,7 +65,6 @@ main() { exit(1) } println("READY ${http_server_port(raw)}") - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } sleep(60000) diff --git a/tests/integration/http_stream_upload/server.ae b/tests/integration/http_stream_upload/server.ae index 48148d481..0e2ccc0e3 100644 --- a/tests/integration/http_stream_upload/server.ae +++ b/tests/integration/http_stream_upload/server.ae @@ -108,15 +108,10 @@ handle_ping(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { // The port is no longer an argument: the server binds 0 and reports the @@ -144,7 +139,6 @@ main() { } println("READY ${http_server_port(s)}") - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: s } sleep(60000) diff --git a/tests/integration/https_client_pure_tls/server.ae b/tests/integration/https_client_pure_tls/server.ae index 3ad31eb87..858aa5816 100644 --- a/tests/integration/https_client_pure_tls/server.ae +++ b/tests/integration/https_client_pure_tls/server.ae @@ -24,16 +24,11 @@ handle_root(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { cert_path = os_getenv("CERT_PATH") @@ -70,7 +65,6 @@ main() { http.server_get(raw, "/", handle_root, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/https_pure_tls_vertical/server.ae b/tests/integration/https_pure_tls_vertical/server.ae index e8017d506..a61b9a75b 100644 --- a/tests/integration/https_pure_tls_vertical/server.ae +++ b/tests/integration/https_pure_tls_vertical/server.ae @@ -24,16 +24,11 @@ handle_root(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { cert_path = os_getenv("CERT_PATH") @@ -72,7 +67,6 @@ main() { http.server_get(raw, "/", handle_root, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/https_windows_trust/server.ae b/tests/integration/https_windows_trust/server.ae index d8b7dcb8a..f493a818d 100644 --- a/tests/integration/https_windows_trust/server.ae +++ b/tests/integration/https_windows_trust/server.ae @@ -25,16 +25,11 @@ handle_root(req: ptr, res: ptr, ud: ptr) { } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { cert_path = os_getenv("CERT_PATH") @@ -71,7 +66,6 @@ main() { http.server_get(raw, "/", handle_root, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/panic_actor_step_drain/probe.ae b/tests/integration/panic_actor_step_drain/probe.ae index 6438f1aaa..2e9265c4c 100644 --- a/tests/integration/panic_actor_step_drain/probe.ae +++ b/tests/integration/panic_actor_step_drain/probe.ae @@ -1,9 +1,7 @@ // Regression: #1301, scheduler-barrier half. The per-step panic // barrier uses the same frame machinery as try/catch, so a panicking // actor's step-scoped allocations drain through the same journal -// before the actor is marked dead. Run with AETHER_NO_INLINE=1 (the -// driver sets it): main-thread inline mode has no step barrier by -// design, so the drain only exists on the scheduler-thread path. +// before the actor is marked dead. // // #2333: Shouter panics with a message built at run time. The barrier // owns it and releases it after the death hook; LeakSanitizer sees the diff --git a/tests/integration/panic_actor_step_drain/test_panic_actor_step_drain.sh b/tests/integration/panic_actor_step_drain/test_panic_actor_step_drain.sh index aeb49ce31..bd176bd1c 100755 --- a/tests/integration/panic_actor_step_drain/test_panic_actor_step_drain.sh +++ b/tests/integration/panic_actor_step_drain/test_panic_actor_step_drain.sh @@ -24,7 +24,7 @@ if ! "$AE" build probe.ae -o "$tmpdir/probe" >"$tmpdir/build.log" 2>&1; then exit 1 fi -if ! AETHER_NO_INLINE=1 "$tmpdir/probe" >"$tmpdir/run.log" 2>&1; then +if ! "$tmpdir/probe" >"$tmpdir/run.log" 2>&1; then echo " [FAIL] panic_actor_step_drain: probe exited non-zero" sed 's/^/ /' "$tmpdir/run.log" | head -20 exit 1 diff --git a/tests/integration/std_testing_arms/probe.ae b/tests/integration/std_testing_arms/probe.ae index 141483e40..17b1e21e2 100644 --- a/tests/integration/std_testing_arms/probe.ae +++ b/tests/integration/std_testing_arms/probe.ae @@ -63,18 +63,12 @@ const PORT = 18130 // GET /missing -> 404 (the non-200 status case) message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} - handle_info(req: ptr, res: ptr, ud: ptr) { http.response_set_status(res, 200) http.response_set_header(res, "Content-Type", "application/json") @@ -258,7 +252,6 @@ run_http_section(fw: ptr) { http.server_post(srv, "/created", handle_created, 0) http.server_post(srv, "/forbidden", handle_forbidden, 0) - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: srv } diff --git a/tests/integration/test_http_client_v2.ae b/tests/integration/test_http_client_v2.ae index c67aa760b..2033e2efb 100644 --- a/tests/integration/test_http_client_v2.ae +++ b/tests/integration/test_http_client_v2.ae @@ -179,16 +179,10 @@ handle_redirect_absolute(req: ptr, res: ptr, ud: ptr) { } // ------------------------------------------------------------------ -// Server actor — owns the blocking accept loop. The companion -// SchedHelper is empty-on-purpose: the multicore scheduler needs -// at least two registered actors before main() reliably yields back, -// so the helper is a no-op spawned before the server. (Same trick -// the broader tests in the tree use; tracking the underlying -// requirement is a separate scheduler-ergonomics question.) +// Server actor — owns the blocking accept loop. // ------------------------------------------------------------------ message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 @@ -200,11 +194,6 @@ actor SrvActor { } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} - // ------------------------------------------------------------------ // Test helpers // ------------------------------------------------------------------ @@ -260,9 +249,6 @@ main() { http.server_get(raw, "/redirect-absolute", handle_redirect_absolute, 0) // Hand the server to an actor that calls server_start (blocking). - // SchedHelper spawned first to satisfy the >=2-actor scheduler - // requirement (without it, main() never yields back from sleep). - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/thread_types_layout/layout_probe.c b/tests/integration/thread_types_layout/layout_probe.c index 36e7826d7..412735eac 100644 --- a/tests/integration/thread_types_layout/layout_probe.c +++ b/tests/integration/thread_types_layout/layout_probe.c @@ -21,7 +21,6 @@ int main(void) { AT(ActorBase, auto_process); AT(ActorBase, assigned_core); AT(ActorBase, migrate_to); - AT(ActorBase, main_thread_only); AT(ActorBase, spsc_queue); AT(ActorBase, reply_slot); AT(ActorBase, step_lock); diff --git a/tests/integration/ws_client_loopback/wsserver.ae b/tests/integration/ws_client_loopback/wsserver.ae index 057707cf1..6ca7a79bb 100644 --- a/tests/integration/ws_client_loopback/wsserver.ae +++ b/tests/integration/ws_client_loopback/wsserver.ae @@ -28,15 +28,10 @@ extern http_server_port(server: ptr) -> int } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -50,7 +45,6 @@ main() { } println("READY ${http_server_port(raw)}") - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/ws_client_nonblocking/wsserver.ae b/tests/integration/ws_client_nonblocking/wsserver.ae index 4e8d659f8..6f9ed0275 100644 --- a/tests/integration/ws_client_nonblocking/wsserver.ae +++ b/tests/integration/ws_client_nonblocking/wsserver.ae @@ -38,15 +38,10 @@ const PORT = 18251 } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(PORT) @@ -56,7 +51,6 @@ main() { http.server_websocket(raw, "/slow", slow_handler, null) println("READY") - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/integration/ws_idle_server/wsserver.ae b/tests/integration/ws_idle_server/wsserver.ae index 25ac70284..bb2410597 100644 --- a/tests/integration/ws_idle_server/wsserver.ae +++ b/tests/integration/ws_idle_server/wsserver.ae @@ -23,15 +23,10 @@ extern http_server_port(server: ptr) -> int } message StartSrv { raw: ptr } -message Noop {} actor SrvActor { state s = 0 receive { StartSrv(raw) -> { s = raw; http_server_start_raw(raw) } } } -actor SchedHelper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { raw = http.server_create(0) @@ -46,7 +41,6 @@ main() { } println("READY ${http_server_port(raw)}") - spawn(SchedHelper()) a = spawn(SrvActor()) a ! StartSrv { raw: raw } diff --git a/tests/regression/test_send_async_one_actor.ae b/tests/regression/test_send_async_one_actor.ae new file mode 100644 index 000000000..31d40e8db --- /dev/null +++ b/tests/regression/test_send_async_one_actor.ae @@ -0,0 +1,40 @@ +// Regression (#3112): a send is asynchronous with one actor too. +// +// With a single actor the runtime ran the handler inside the sender's `!` +// (main-thread mode) until a second actor was spawned, so a handler that +// waited for something its sender does next waited for nothing: here it gave +// up after two seconds and saw `ready` still 0, and with no timeout it would +// have deadlocked. Every send now goes through the scheduler, so the handler +// runs on a scheduler thread while main goes on and sets `ready`. + +extern exit(code: int) + +var ready = 0 +var saw_ready = -1 + +message Wait {} + +actor Waiter { + receive { + Wait() -> { + waited = 0 + while ready == 0 && waited < 2000 { + sleep(1) + waited = waited + 1 + } + saw_ready = ready + } + } +} + +main() { + w = spawn(Waiter()) + w ! Wait {} + ready = 1 + wait_for_idle() + if saw_ready != 1 { + println("FAIL #3112: the handler ran inside the send and never saw main go on (${saw_ready})") + exit(1) + } + println("PASS #3112: a send to a lone actor returns before its handler runs") +} diff --git a/tests/regression/test_send_batches.ae b/tests/regression/test_send_batches.ae new file mode 100644 index 000000000..09e112bbb --- /dev/null +++ b/tests/regression/test_send_batches.ae @@ -0,0 +1,103 @@ +// Regression (#3117): which loops batch their sends, and what a batch keeps. +// +// The compiler opened a send batch around every `while` loop that sent, so +// a loop that waited between sends held its messages until it ended, a +// message with a payload went around the batch and overtook the one-int ones +// sent before it, and an inner loop's batch emptied the outer one's, losing +// its messages. Now only a loop that cannot wait between its sends is +// batched, every send in it joins the batch, and an inner loop joins the +// outer loop's. A loop a labeled `break` can leave is not batched: the +// break jumps past the end where the batch is sent. + +extern exit(code: int) + +message Add { n: int } +message Note { n: int, tag: string } + +actor Sum { + state count = 0 + state last = -1 + state in_order = 1 + receive { + Add(n) -> { + count = count + 1 + if n != last + 1 { in_order = 0 } + last = n + } + Note(n, tag) -> { + count = count + 1 + if n != last + 1 { in_order = 0 } + last = n + } + } +} + +fn check(cond: bool, msg: string) { + if !cond { + println("FAIL #3117: ${msg}") + exit(1) + } +} + +main() { + // A loop that waits between sends: each one is delivered as it goes. + c = spawn(Sum()) + i = 0 + while i < 3 { + c ! Add { n: i } + wait_for_idle() + check(c.count == i + 1, "a waiting loop's send ${i} was held: ${c.count} handled") + i = i + 1 + } + + // A batched loop of one-int and payload messages keeps their order. + m = spawn(Sum()) + i = 0 + while i < 40 { + if i % 2 == 0 { + m ! Add { n: i } + } else { + m ! Note { n: i, tag: "odd" } + } + i = i + 1 + } + wait_for_idle() + check(m.count == 40, "the mixed loop delivered ${m.count} of 40") + check(m.in_order == 1, "a payload message overtook a one-int one") + + // An inner loop joins the outer loop's batch, which loses nothing. + a = spawn(Sum()) + b = spawn(Sum()) + i = 0 + while i < 10 { + a ! Add { n: i } + j = 0 + while j < 2 { + b ! Add { n: i * 2 + j } + j = j + 1 + } + i = i + 1 + } + wait_for_idle() + check(a.count == 10, "the outer loop's messages: ${a.count} of 10") + check(b.count == 20, "the inner loop's messages: ${b.count} of 20") + + // An inner loop that sends and leaves the outer one with `break outer`. + // Nothing flushes after it (wait_for_idle would), so the messages must + // have gone as they were sent. + d = spawn(Sum()) + k = 0 + outer: while k < 3 { + check(k == 0, "the labeled break did not leave the outer loop") + j = 0 + while j < 10 { + d ! Add { n: j } + if j == 4 { break outer } + j = j + 1 + } + k = k + 1 + } + sleep(500) + check(d.count == 5, "a loop left by a labeled break kept ${5 - d.count} of 5 messages") + println("PASS #3117: loops batch only what cannot wait, in order, nothing lost") +} diff --git a/tests/runtime/test_scheduler.c b/tests/runtime/test_scheduler.c index d6edfbe6c..1ca31d2d0 100644 --- a/tests/runtime/test_scheduler.c +++ b/tests/runtime/test_scheduler.c @@ -287,39 +287,22 @@ void test_scheduler_spawn_aligned(void) { } // #2486: a core's actor table grows past MAX_ACTORS_PER_CORE while its -// scheduler thread scans it without the lock and the main-thread reader -// (aether_scheduler_poll) walks it from another thread, with messages -// flowing. Afterwards every actor is in exactly one table, every slot at or -// past a table's count is NULL (the grown tail included), and the replaced -// tables are still chained for scheduler_cleanup() to free. -typedef struct { - atomic_int stop; - atomic_int polls; -} TablePollerArgs; - -static void* table_poller(void* arg) { - TablePollerArgs* p = (TablePollerArgs*)arg; - while (!atomic_load(&p->stop)) { - aether_scheduler_poll(1); - atomic_fetch_add(&p->polls, 1); - } - return NULL; -} +// scheduler thread scans it without the lock, with messages flowing. +// Afterwards every actor is in exactly one table, every slot at or past a +// table's count is NULL (the grown tail included), and the replaced tables +// are still chained for scheduler_cleanup() to free. (A second reader, +// aether_scheduler_poll walking the tables from another thread, went with +// main-thread mode, #3112: the cores run every actor.) void test_scheduler_actor_table_growth(void) { enum { N = 2 * MAX_ACTORS_PER_CORE + 100 }; scheduler_init(2); + scheduler_start(); CounterActor** actors = calloc(N, sizeof(CounterActor*)); int* seen = calloc(N, sizeof(int)); ASSERT_NOT_NULL(actors); ASSERT_NOT_NULL(seen); - TablePollerArgs poll_args; - atomic_init(&poll_args.stop, 0); - atomic_init(&poll_args.polls, 0); - pthread_t poller; - int poller_started = 0; - int spawned = 0, sent = 0; for (int i = 0; i < N; i++) { // Just before a grow, leave a freed block of the new table's size @@ -339,11 +322,6 @@ void test_scheduler_actor_table_growth(void) { atomic_init(&a->last_value, i); actors[i] = a; spawned++; - // Scheduler threads run from the second spawn on; start the second - // reader then. - if (i == 1 && pthread_create(&poller, NULL, table_poller, &poll_args) == 0) { - poller_started = 1; - } // A sparse trickle: core 0 keeps going idle, which is when it scans. if (i % 50 == 49) { int target = (i * 7919) % (i + 1); @@ -360,8 +338,6 @@ void test_scheduler_actor_table_growth(void) { if (processed >= sent) break; sleep_ms(5); } - atomic_store(&poll_args.stop, 1); - if (poller_started) pthread_join(poller, NULL); scheduler_shutdown(); // Threads are joined: the tables are quiescent. @@ -405,7 +381,6 @@ void test_scheduler_actor_table_growth(void) { ASSERT_EQ(N, spawned); ASSERT_EQ(sent, processed); - ASSERT_TRUE(poller_started); ASSERT_EQ(N, registered); ASSERT_EQ(0, bad_live); ASSERT_EQ(0, foreign); @@ -417,7 +392,7 @@ void test_scheduler_actor_table_growth(void) { } // #2509: actors are spawned and released in rounds while every core scans -// its table and another thread walks all of them with aether_scheduler_poll(). +// its table. // Half release themselves from their own step, the other half are released by // the main thread right after their step ran, while the core that ran it may // still be touching them. A release takes the actor out of its table at once @@ -451,23 +426,10 @@ static int churn_registered(void) { void test_scheduler_release_churn(void) { enum { ROUNDS = 40, PER_ROUND = 250 }; scheduler_init(4); - - // Two actors that live throughout: the first takes main-thread mode, the - // second ends it for good and starts the core threads. - ActorBase* keep[2]; - for (int i = 0; i < 2; i++) { - keep[i] = scheduler_spawn_actor(-1, (void (*)(void*))counter_step, sizeof(CounterActor)); - ASSERT_NOT_NULL(keep[i]); - } + scheduler_start(); int registered_before = churn_registered(); int live_before = atomic_load(&g_aether_config.actor_count); - TablePollerArgs poll_args; - atomic_init(&poll_args.stop, 0); - atomic_init(&poll_args.polls, 0); - pthread_t poller; - int poller_started = pthread_create(&poller, NULL, table_poller, &poll_args) == 0; - atomic_store(&g_churn_handled, 0); ChurnActor* batch[PER_ROUND]; int spawned = 0, handled_all = 1, max_registered = 0; @@ -510,13 +472,9 @@ void test_scheduler_release_churn(void) { int registered_after = churn_registered(); int live_after = atomic_load(&g_aether_config.actor_count); - atomic_store(&poll_args.stop, 1); - if (poller_started) pthread_join(poller, NULL); scheduler_shutdown(); - for (int i = 0; i < 2; i++) scheduler_release_actor(keep[i]); scheduler_cleanup(); - ASSERT_TRUE(poller_started); ASSERT_EQ(ROUNDS * PER_ROUND, spawned); ASSERT_TRUE(handled_all); ASSERT_EQ(ROUNDS * PER_ROUND, atomic_load(&g_churn_handled)); @@ -621,12 +579,7 @@ static int wait_no_pending(void) { void test_scheduler_actor_thread_holds_released_actor(void) { scheduler_init(1); // one core: every actor is the actor thread's core-mate - - ActorBase* keep[2]; - for (int i = 0; i < 2; i++) { - keep[i] = scheduler_spawn_actor(-1, (void (*)(void*))counter_step, sizeof(CounterActor)); - ASSERT_NOT_NULL(keep[i]); - } + scheduler_start(); ChurnActor* target = (ChurnActor*)scheduler_spawn_actor(-1, churn_step, sizeof(ChurnActor)); ASSERT_NOT_NULL(target); @@ -693,7 +646,6 @@ void test_scheduler_actor_thread_holds_released_actor(void) { scheduler_shutdown(); scheduler_release_actor((ActorBase*)echo); - for (int i = 0; i < 2; i++) scheduler_release_actor(keep[i]); scheduler_cleanup(); } @@ -704,12 +656,7 @@ void test_scheduler_actor_thread_holds_released_actor(void) { // entry is tried after the actor has been reclaimed, under the plain build. void test_scheduler_late_send_after_release(void) { scheduler_init(2); - - ActorBase* keep[2]; - for (int i = 0; i < 2; i++) { - keep[i] = scheduler_spawn_actor(-1, (void (*)(void*))counter_step, sizeof(CounterActor)); - ASSERT_NOT_NULL(keep[i]); - } + scheduler_start(); CounterActor* a = (CounterActor*)scheduler_spawn_actor(-1, (void (*)(void*))counter_step, sizeof(CounterActor)); @@ -757,7 +704,6 @@ void test_scheduler_late_send_after_release(void) { scheduler_shutdown(); scheduler_release_actor((ActorBase*)b); - for (int i = 0; i < 2; i++) scheduler_release_actor(keep[i]); scheduler_cleanup(); } @@ -770,10 +716,7 @@ void test_scheduler_late_send_after_release(void) { // count below zero, which reads as a move in progress. void test_scheduler_reuse_waits_for_messages_in_flight(void) { scheduler_init(2); - // No main-thread mode, which would run the sends below on this thread, - // and with it no spawn that starts the cores: they start when asked. - int inline_was = atomic_load(&g_aether_config.inline_mode_disabled); - atomic_store(&g_aether_config.inline_mode_disabled, 1); + // No spawn starts the cores: they start when asked. CounterActor* a = (CounterActor*)scheduler_spawn_actor(1, (void (*)(void*))counter_step, sizeof(CounterActor)); ASSERT_NOT_NULL(a); @@ -797,7 +740,6 @@ void test_scheduler_reuse_waits_for_messages_in_flight(void) { scheduler_shutdown(); if (b) scheduler_release_actor((ActorBase*)b); - atomic_store(&g_aether_config.inline_mode_disabled, inline_was); scheduler_cleanup(); ASSERT_EQ(10, counted); ASSERT_EQ(0, pending); @@ -1136,6 +1078,7 @@ static CounterActor g_caller_actor; static void teardown_cycle(void) { scheduler_init(2); + scheduler_start(); const size_t sizes[] = { sizeof(CounterActor), sizeof(ActorBase) + 1000, sizeof(OrderActor) }; for (int s = 0; s < 3; s++) { for (int k = 0; k < 4; k++) { @@ -1245,9 +1188,7 @@ static void yield_cpu(void) { static void contended_lock_cycle(void) { scheduler_init(1); - // The first spawn takes main-thread mode, the second ends it and starts - // the core thread; the message goes to the second. - ASSERT_NOT_NULL(scheduler_spawn_actor(-1, (void (*)(void*))counter_step, sizeof(CounterActor))); + scheduler_start(); LockContender c; atomic_init(&c.sending, 0); c.target = scheduler_spawn_actor(-1, (void (*)(void*))counter_step, sizeof(CounterActor)); @@ -1281,12 +1222,8 @@ void test_scheduler_teardown_destroys_core_locks(void) { static void live_actor_thread_cycle(void) { scheduler_init(1); // An actor thread runs while its core does, so the core threads must be - // up: the first actor of a lifecycle takes main-thread mode, the second - // ends it and starts them (as in the churn test). Both are spawned, live - // into teardown and are ended there with the actor under test. - for (int i = 0; i < 2; i++) { - ASSERT_NOT_NULL(scheduler_spawn_actor(-1, (void (*)(void*))counter_step, sizeof(CounterActor))); - } + // up. + scheduler_start(); ASSERT_TRUE(atomic_load(&schedulers[0].running)); CounterActor* a = (CounterActor*)scheduler_spawn_actor(-1, (void (*)(void*))counter_step, sizeof(CounterActor)); @@ -1349,10 +1286,7 @@ static int wait_count(atomic_int* count, int want) { void test_scheduler_actor_thread_parks_when_idle(void) { scheduler_init(1); scheduler_set_actor_park_ms(60000); - // Two actors first, so the core thread runs (as in the teardown cycle). - for (int i = 0; i < 2; i++) { - ASSERT_NOT_NULL(scheduler_spawn_actor(-1, (void (*)(void*))counter_step, sizeof(CounterActor))); - } + scheduler_start(); CounterActor* a = (CounterActor*)scheduler_spawn_actor(-1, (void (*)(void*))counter_step, sizeof(CounterActor)); ASSERT_NOT_NULL(a); @@ -1385,7 +1319,7 @@ void test_scheduler_actor_thread_parks_when_idle(void) { // #3102: a receive timeout armed on an actor that receives nothing fires, on // its core and on its own thread. Only a message stepped an actor before, so -// with more than one actor (no main-thread mode) the timeout never fired. +// on the scheduler threads the timeout never fired. // The step tests the deadline as a generated one does, and counts a firing. static void timeout_step(void* self) { CounterActor* a = (CounterActor*)self; @@ -1403,6 +1337,7 @@ static void timeout_step(void* self) { void test_scheduler_fires_receive_timeouts(void) { scheduler_init(2); + scheduler_start(); CounterActor* on_core = (CounterActor*)scheduler_spawn_actor(-1, timeout_step, sizeof(CounterActor)); CounterActor* on_thread = (CounterActor*)scheduler_spawn_actor(-1, timeout_step, sizeof(CounterActor)); ASSERT_NOT_NULL(on_core); @@ -1460,7 +1395,7 @@ void test_scheduler_places_threads_among_process_cpus(void) { int recommended = cpu_recommend_cores(); - // Two actors, one per core, so the threads start (no main-thread mode). + // Two actors, one per core. scheduler_init(2); CounterActor* a = (CounterActor*)scheduler_spawn_actor(0, (void (*)(void*))counter_step, sizeof(CounterActor)); @@ -1643,7 +1578,7 @@ static void* order_sender_thread(void* arg) { void test_scheduler_actor_thread_inbox_keeps_order(void) { scheduler_init(1); - ASSERT_NOT_NULL(scheduler_spawn_actor(-1, (void (*)(void*))counter_step, sizeof(CounterActor))); + scheduler_start(); BurstActor* burst = (BurstActor*)scheduler_spawn_actor(-1, burst_step, sizeof(BurstActor)); ASSERT_NOT_NULL(burst); atomic_init(&burst->done, 0); diff --git a/tests/runtime/test_worksteal_race.c b/tests/runtime/test_worksteal_race.c index 04e6b2f1e..79ccf183e 100644 --- a/tests/runtime/test_worksteal_race.c +++ b/tests/runtime/test_worksteal_race.c @@ -42,7 +42,6 @@ static WStealActor* make_wsteal_actor(int actor_id) { a->auto_process = 0; atomic_init(&a->assigned_core, -1); atomic_init(&a->migrate_to, -1); - atomic_init(&a->main_thread_only, 0); atomic_init(&a->reply_slot, NULL); atomic_flag_clear_explicit(&a->step_lock, memory_order_relaxed); atomic_init(&a->count, 0); diff --git a/tests/syntax/test_actor_timeout_multiple.ae b/tests/syntax/test_actor_timeout_multiple.ae index d99d772f5..29e9cf256 100644 --- a/tests/syntax/test_actor_timeout_multiple.ae +++ b/tests/syntax/test_actor_timeout_multiple.ae @@ -1,11 +1,9 @@ // Test: two actors with different timeouts, both of which fire. // -// With two actors the program runs on the scheduler's cores, not in -// main-thread mode. No core stepped an actor whose mailbox was empty, so -// neither timeout fired, and wait_for_idle() returned at once: this test -// printed its PASS line without either handler having run (#3102). Now the -// core fires each timeout once its deadline passes, and wait_for_idle() -// waits for them, as it does in main-thread mode. +// No core stepped an actor whose mailbox was empty, so neither timeout +// fired, and wait_for_idle() returned at once: this test printed its PASS +// line without either handler having run (#3102). Now the core fires each +// timeout once its deadline passes, and wait_for_idle() waits for them. message Ping {} diff --git a/tests/syntax/test_await_io.ae b/tests/syntax/test_await_io.ae index d1b76ca3e..0e165b594 100644 --- a/tests/syntax/test_await_io.ae +++ b/tests/syntax/test_await_io.ae @@ -9,15 +9,11 @@ // 5. the scheduler's I/O poller observes the fd is readable, delivers // IoReady{fd, events} to Reader's mailbox // 6. Reader's IoReady handler runs, prints success, exits 0 -// -// A Helper actor is spawned to force multi-threaded scheduler mode; -// single-actor main-thread mode does not poll the I/O reactor. import std.net message Start { r: int } message IoReady { fd: int, events: int } -message Noop {} actor Reader { state got = 0 @@ -43,13 +39,6 @@ actor Reader { } } -actor Helper { - state x = 0 - receive { - Noop() -> { x = 1 } - } -} - main() { read_fd = net.pipe_open() if read_fd < 0 { @@ -58,7 +47,6 @@ main() { } write_fd = net.pipe_write_fd() - spawn(Helper()) r = spawn(Reader()) r ! Start { r: read_fd } diff --git a/tests/syntax/test_http_request_body_length.ae b/tests/syntax/test_http_request_body_length.ae index a5fd0ba30..957b43f18 100644 --- a/tests/syntax/test_http_request_body_length.ae +++ b/tests/syntax/test_http_request_body_length.ae @@ -33,7 +33,6 @@ handle_post(req: ptr, res: ptr, ud: ptr) { } message Go { raw: ptr } -message Noop {} actor Srv { state s = 0 @@ -45,11 +44,6 @@ actor Srv { } } -actor Helper { - state x = 0 - receive { Noop() -> { x = 1 } } -} - main() { println("=== http.request_body_length accessor test ===") @@ -65,7 +59,6 @@ main() { // each new executable listening there opens a firewall prompt (#2639). http.server_set_host(raw, "127.0.0.1") http.server_post(raw, "/echo", handle_post, 0) - spawn(Helper()) a = spawn(Srv()) a ! Go { raw: raw } sleep(300) diff --git a/tests/syntax/test_http_response_accessors.ae b/tests/syntax/test_http_response_accessors.ae index aefaaac08..da584f40f 100644 --- a/tests/syntax/test_http_response_accessors.ae +++ b/tests/syntax/test_http_response_accessors.ae @@ -19,7 +19,6 @@ handle_test(req: ptr, res: ptr, ud: ptr) { } message Go { raw: ptr } -message Noop {} actor Srv { state s = 0 @@ -32,10 +31,6 @@ actor Srv { } // Force multi-threaded scheduler -actor Helper { - state x = 0 - receive { Noop() -> { x = 1 } } -} main() { print("=== HTTP Response Accessor Tests ===\n\n") @@ -46,7 +41,6 @@ main() { // each new executable listening there opens a firewall prompt (#2639). http.server_set_host(raw, "127.0.0.1") http.server_get(raw, "/test", handle_test, 0) - spawn(Helper()) a = spawn(Srv()) a ! Go { raw: raw } sleep(300) From 7a110b6d3f06cfe20250aa3af93e816f5dbaa4b8 Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Thu, 22 Oct 2026 15:15:35 -0300 Subject: [PATCH 42/50] tinyweb serves its WebSocket, SSE and chunked endpoints through std.http, on the server's port (#3109) tinyweb listened for WebSocket and SSE on ports of their own, through accept loops a program had to start beside tw_start, which blocks in the HTTP loop. Its frame reader took one read(2) for a whole payload (a message over what one read returns arrived cut), had no 64-bit length (no message of 64 KiB or more), took a ping for a message and answered none, and took an empty frame for a close. Its SSE writes returned nothing, so a handler streaming in a loop never learnt that its client had left, and chunked_start wanted a raw socket an endpoint does not have. with_timeout, with_keep_alive and on_stats were stored and never applied; with_backlog, with_ws_backlog and the four on_*error callbacks were stored and never read. std.http, which tinyweb's HTTP routes already ran on, has a conformance- tested WebSocket codec and an SSE stream on the server's own connection (TLS included). It gains what tinyweb needs and lacked: - http.response_begin_chunked(res), chunked_write and chunked_end: a body of unknown length on the connection, as response_upgrade_sse takes it over; refused on a response that has a body. - http.sse_comment(sse, text): a keep-alive line. - http.response_get_status(res). tinyweb: - web_socket and sse_endpoint routes are std.http's server_websocket and server_sse, on the HTTP port. A web_socket handler gets each message whole (binary ones zero bytes and all), `sender` is the std.http WebSocket, and `ctx` is a map the connection's messages share. - sse_send, sse_send_event and sse_comment return 0, or -1 once the client has gone. chunked_start(res, status) returns (stream, err). - tw_start applies with_timeout and with_keep_alive through server_set_keepalive. on_stats gets a record of each endpoint request (path, route, status, durations, the filters and their verdicts), its accessors typed. - Removed: its frame code, accept loops, web_server_with_ws, web_server_full, the backlog options, the error callbacks, ws_handshake.c and ws_client.ae (std.http's ws_connect is the client). The contrib build catalogue loses the tinyweb archive; the install test's always-built archive canary is host/factor's. - The examples and the liveview spike serve everything on one port; the spike keeps its count in `ctx`. The liveview roadmap's "structural blocker" (serving the page and the WebSocket at once) is cleared. Tests: - contrib/tinyweb/test_streams.ae (replaces test_websocket.ae): over loopback, a 70000-byte message echoed both ways, a binary message with zero bytes, a ping answered with its payload, `ctx` across two messages, a WebSocket quiet past a 1 s with_timeout, an idle HTTP connection closed after it, on_stats for a filtered and a stopped request, an SSE handler ending once its client leaves, a chunked body read back with std.http.client, a chunked start over a body refused. Without the server_websocket registration the six WebSocket cases fail; without the keep-alive call the idle close takes the 30 s default; without on_stats on the routes both stats cases fail; without server_sse the SSE case fails. - tests/regression/test_http_server_streams.ae: the chunked head (the handler's Content-Length dropped) and body on the wire, an empty write sending nothing, the refusal over a body, a status read back, and sse_comment's 0 and -1 for a line break. On the previous commit none of these functions exist. --- .github/scripts/contrib_check.sh | 8 +- Makefile | 2 - contrib/tinyweb/README.md | 55 +- contrib/tinyweb/example_sse.ae | 52 +- contrib/tinyweb/example_websocket.ae | 35 +- contrib/tinyweb/examples/liveview/README.md | 10 +- .../examples/liveview/example_counter_live.ae | 80 +-- contrib/tinyweb/module.ae | 674 +++++------------- contrib/tinyweb/test_streams.ae | 376 ++++++++++ contrib/tinyweb/test_websocket.ae | 266 ------- contrib/tinyweb/ws_client.ae | 132 ---- contrib/tinyweb/ws_handshake.c | 119 ---- docs/http-server.md | 26 +- docs/liveview-lite-roadmap.md | 263 +++---- docs/stdlib-reference.md | 3 +- ...0901Z-added-3109-http-chunked-responses.md | 7 + ...80902Z-changed-3109-tinyweb-on-std-http.md | 25 + std/http/module.ae | 57 +- std/net/aether_http_server.c | 88 +++ std/net/aether_http_server.h | 28 + .../test_install_contrib_resolves.sh | 6 +- .../test_release_contrib_c_modules.sh | 19 +- tests/regression/test_http_server_streams.ae | 145 ++++ tests/scripts/check_loopback_listeners.py | 2 +- tests/scripts/contrib_build.sh | 10 - .../scripts/test_check_loopback_listeners.py | 2 +- 26 files changed, 1126 insertions(+), 1364 deletions(-) create mode 100644 contrib/tinyweb/test_streams.ae delete mode 100644 contrib/tinyweb/test_websocket.ae delete mode 100644 contrib/tinyweb/ws_client.ae delete mode 100644 contrib/tinyweb/ws_handshake.c create mode 100644 new_changelogs/20261022T180901Z-added-3109-http-chunked-responses.md create mode 100644 new_changelogs/20261022T180902Z-changed-3109-tinyweb-on-std-http.md create mode 100644 tests/regression/test_http_server_streams.ae diff --git a/.github/scripts/contrib_check.sh b/.github/scripts/contrib_check.sh index 1a9c54642..641c97d3c 100755 --- a/.github/scripts/contrib_check.sh +++ b/.github/scripts/contrib_check.sh @@ -126,10 +126,10 @@ TESTS=( "templating/liquid/filters|$LQ/test_filters.ae||run|" "templating/liquid/inheritance|$LQ/test_inheritance.ae||run|" "tinyweb/spec|$TW/test_spec.ae||run|" - "tinyweb/inventory|$TW/test_inventory.ae|$TW/ws_handshake.c|run|" - "tinyweb/integration|$TW/test_integration.ae|$TW/ws_handshake.c|run|" - "tinyweb/schema_api|$TW/test_schema_api.ae|$TW/ws_handshake.c|run|" - "tinyweb/websocket|$TW/test_websocket.ae|$TW/ws_handshake.c|run|" + "tinyweb/inventory|$TW/test_inventory.ae||run|" + "tinyweb/integration|$TW/test_integration.ae||run|" + "tinyweb/schema_api|$TW/test_schema_api.ae||run|" + "tinyweb/streams|$TW/test_streams.ae||run|" "i18n/collate|$I18N/collate/test_collate.ae|$I18N/aether_i18n.c std/unicode/utf8proc/utf8proc.c $I18N/ducet/ducet_data.c|leak|" # png: pure Aether over std.zlib; its test decodes what it encoded. "png/encode|contrib/png/test_png.ae||leak|" diff --git a/Makefile b/Makefile index ce93e62b0..5278d30f4 100644 --- a/Makefile +++ b/Makefile @@ -2552,8 +2552,6 @@ install: $(VERSION_HEADER) release-build ae stdlib # runtime needs the factor-language fork) # - host/aether (fork+exec sandboxed child via LD_PRELOAD; # libc + in-tree sandbox runtime only) -# - tinyweb (libc only — SHA-1 + base64 for WebSocket -# handshake; no third-party dep) # Out of scope for v1: host/{java,go} (separate-process or JNI-style # bridges, different build shape — javac/jar and cgo c-archive don't fit # the cc→ar pipeline). The widget toolkit diff --git a/contrib/tinyweb/README.md b/contrib/tinyweb/README.md index 1318ced79..f4d3062f7 100644 --- a/contrib/tinyweb/README.md +++ b/contrib/tinyweb/README.md @@ -15,7 +15,7 @@ server = web_server_host("localhost", 8080) { response_write(res, "Hello, World!") } web_socket("/eee") |msg, sender, ctx| { - ws_send_frame(sender, "Echo: ${msg}") + http.ws_send_text(sender, "Echo: ${msg}") } } @@ -25,7 +25,7 @@ server = web_server_host("localhost", 8080) { response_write(res, "User: ${request_get_path_param(ctx, \"1\")}") } } -server_start(server) +err = tw_start(server) ``` ## Features @@ -34,15 +34,49 @@ server_start(server) - **Path nesting**: `path("/api") { path("/v1") { end_point(...) } }` - **Filters**: `filter(GET, "/.*") |req, res, ctx| { CONTINUE / STOP }` - **Composition**: `compose(server) { ... }` to add routes after construction -- **WebSocket**: server + client with RFC 6455 handshake +- **WebSocket**: `web_socket("/ws") |msg, sender, ctx| { ... }`, on the + server's own port - **SSE**: `sse_endpoint("/events") |stream| { sse_send(stream, data) }` - **Static files**: MIME detection, traversal protection, index.html fallback - **Chunked transfer**: `chunked_start`, `write_chunk`, `chunked_end` - **Cookies**: `request_get_cookie(req, "name")` - **Attributes**: `ctx_set_attribute` / `ctx_get_attribute` for filter-to-endpoint data -- **Error callbacks**: `on_error`, `on_server_error`, `on_ws_timeout`, `on_ws_error` - **Statistics**: `on_stats(server) |stats| { ... }` -- **Config**: `with_timeout`, `with_backlog`, `with_ws_backlog`, `with_keep_alive`, `with_tls` +- **Config**: `with_timeout`, `with_keep_alive`, `with_tls` + +### WebSocket, SSE and chunked responses + +All three are std.http's, served on the same port as the HTTP routes, TLS +included: + +- A `web_socket` handler runs once per message. `msg` is the whole message, + of any length, a binary one zero bytes and all; `sender` is the + connection's std.http WebSocket (reply with `http.ws_send_text` or + `http.ws_send_binary`); `ctx` is a map the connection's messages share. + Pings are answered, and a quiet connection stays open. +- An `sse_endpoint` handler gets the stream and runs until it returns. + `sse_send`, `sse_send_event` and `sse_comment` return 0, or -1 once the + client has gone, so a handler that streams in a loop knows when to stop. +- An endpoint streams a body of unknown length with + `stream, err = chunked_start(res, status)`, `write_chunk(stream, data)` + and `chunked_end(stream)`. The headers set on `res` before it go out with + it; a response that already has a body is refused. + +### Statistics + +`on_stats(server) |stats| { ... }` runs after each request to an +`end_point` with a record of it: `stats_get_path`, `stats_get_endpoint` (the +route's pattern), `stats_get_status`, `stats_get_duration` and +`stats_get_endpoint_duration` (milliseconds), and `stats_get_filters`, a +list of the filters that ran (`filter_stat_path`, `filter_stat_result`, +"CONTINUE" or "STOP", and `filter_stat_duration`). + +### Timeouts and keep-alive + +`with_timeout(server, ms)` is how long a connection may wait for its next +request (30000 by default); an upgraded WebSocket or an SSE stream is not +held to it. `with_keep_alive(server, 0)` closes each connection after its +response. ### TLS @@ -128,12 +162,9 @@ leaf: its block, if any, is a request-time handler, not more DSL. ## Files - `module.ae`: The library -- `ws_client.ae`: WebSocket client -- `ws_handshake.c` (SHA-1 + Base64 for the WebSocket accept key, plus - `ws_unmask` (RFC 6455 §5.3 frame unmasking)) C externs - `example_app.ae`: HTTP endpoints with filters and nested paths - `example_composition.ae`: Modular composition, deep nesting, auth filters -- `example_websocket.ae`: HTTP + WebSocket server with echo handler +- `example_websocket.ae`: HTTP + WebSocket routes with echo handlers - `example_static.ae`: Static file serving - `example_sse.ae`: Server-Sent Events + chunked transfer - `example_auth.ae`: Cookie parsing + filter-to-endpoint attributes @@ -143,7 +174,5 @@ leaf: its block, if any, is a request-time handler, not more DSL. - `test_schema_api.ae`: schema_api integration tests (validation + schema/OpenAPI endpoints) - `test_spec.ae`: DSL registration unit tests - `test_integration.ae`: HTTP round-trip integration tests -- `test_websocket.ae`: WebSocket codec round-trip (handshake, client-frame - unmasking, and extended-length server framing). Its C extern, - `ws_handshake.c`, is compiled in by the module's `@source`, so - `ae run test_websocket.ae` needs no `--extra` +- `test_streams.ae`: WebSocket, SSE and chunked endpoints and the server + options, over loopback diff --git a/contrib/tinyweb/example_sse.ae b/contrib/tinyweb/example_sse.ae index 36c0a73a5..291ed2fe3 100644 --- a/contrib/tinyweb/example_sse.ae +++ b/contrib/tinyweb/example_sse.ae @@ -1,19 +1,12 @@ -// Server-Sent Events (SSE) Example — streaming events to browser clients +// Server-Sent Events (SSE) and chunked Example — streaming to browser clients import contrib.tinyweb -import std.http -import std.string -import std.list -import std.map -import std.tcp - main() { println("=== TinyWeb SSE + Chunked Example ===\n") - server = tinyweb.web_server_full("127.0.0.1", 8080, 0, 8082) { - - tinyweb.end_point(tinyweb.GET, "/") |req: ptr, res: ptr, ctx: ptr| { + server = tinyweb.web_server_host("127.0.0.1", 8080) { + tinyweb.end_point(tinyweb.GET, "/") | req: ptr, res: ptr, ctx: ptr | { tinyweb.response_set_header(res, "Content-Type", "text/html") tinyweb.response_write(res, " @@ -22,7 +15,7 @@ main() {

    Server-Sent Events