Skip to content

Batch 9, second half: the rest of the ten and every bug batch 9 found (draft until all land) - #3334

Merged
nicolas-maman merged 198 commits into
mainfrom
feat/oct28-batch9b
Oct 29, 2026
Merged

nicolas-maman merged 198 commits into
mainfrom
feat/oct28-batch9b

Conversation

@nicolas-maman

@nicolas-maman nicolas-maman commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Batch 9, second half: the rest of the ten, every bug batch 9 found, every bug the reviews of this batch found, and the enhancements they asked for.

The target, from the owner: when this merges, no issue labelled bug is open, new ones included, and the open non-bug issues are at least 10 fewer than when this PR was opened (46, so at most 36). This PR closes every open bug and 13 non-bug issues, so 36 non-bug issues remain open (#3432, filed since, among them).

Non-bug issues this PR closes

Issue What
#2974 Actor monitors, links, supervisors, mailbox bounds
#1298 Pure-Aether TLS 1.3: constant-time signing, backend choice, conformance corpora (BoGo, x509-limbo, bettertls, Wycheproof)
#2600 Fixed-width field arithmetic for the curves, one-pass u1*G + u2*Q verify
#2955 Embedding: the concurrency and host re-entry contract
#2956 Embedding: execution frames, deadlines and cancellation at safe points, allocation failure that ends the call
#2954 Embedding: execution accounts, a budget that belongs to a script across its calls (deadline modes, a parent chain, two runtime copies)
#2957 Embedding: the compatibility policy for shipped libraries (docs/abi-policy.md), its checks, and a cross-version matrix against the previous release in CI and before every release
#3311 An explicit partial-chain option on both TLS backends
#3326 The pure TLS server signs with RSA-PSS, P-384, P-521 and Ed25519
#3362 server_set_tls_pem: a TLS server takes its chain and key as PEM text
#3376 Keys in memory: bytes.wipe, verify-before-send on server and client, hedged nonces, std.cryptography.shield for the server key
#426 std.stream: credit-windowed streams between actors
#476 docs/aether-vs-hcl.md

Bugs this PR closes

Every open bug, by area:

Reviews

Fresh reviews ran on this batch in eight rounds: crypto and TLS; the compiler, runtime and the rest of std; the fixes for those; the fixes for that; the fixes CI's runs on Linux and macOS led to; those fixes with #3445; the fixes for those; and, the last, the seventh round's fixes. Each finding was filed and is fixed here with a test seen failing first, except the races the fifth found, which are named below.

The fifth review's findings are fixed under the issues they reopen:

A sixth round, two fresh reviewers on the fifth round's fixes and on #3445, found more, fixed here:

A seventh round, two more fresh reviewers on the sixth round's fixes, found more, fixed here:

An eighth and last round, a review of the seventh round's fixes, a diagnostic build, and CI on b3b4616, found more, fixed here:

Outside contribution

#3340 (Adil Alperen Ciftci, the CA check for #3303) merged first; this branch builds on it. So did his #3423 (pathLenConstraint, intermediate validity and issuer backtracking): this branch's validate_chain already makes those checks and the rest of #3303, so the merge keeps it, and his test cases run against it, all passing (the merge commit says how each maps).

Checks

Local, on d9ed500 (Windows x86_64, MSYS2 MINGW64), the stages of make ci without its clean:

  • the build with -Werror; the C test runner, rebuilt and run bare: 649 of 649;
  • check-standalone, check-docs, check-api-docs, check-tests, check-changelog, check-extern-widths (0 mismatches), check-raw-calls, the new check-crypto-wipe, check-warnings (0) and check-examples (64, 0 problems);
  • make test-ae: 1953 passed and 94 skipped of 2048. The one failure, a test file not in canonical format, is fixed in d9ed500, and the format gate passes.

CI on d9ed500 found three more, filed and fixed in the last three commits: a wasm build that stopped on the native runtime list it never reads (#3420, make ci-wasm), a death notice's entries lost to a panic (#3421, ASan and Valgrind), and a test that leaned on sleeps (#3422, macOS). On the fixed head: the build with -Werror, the C runner 649 of 649, check-changelog (129 fragments), check-tests, and the integration tests the runtime-list change reaches (the wasm, trace, install and contrib ones). The installed-prefix wasm test was seen failing before its fix.

CI on 3cbdc64, the first full run of the batch on Linux and macOS, found more, each filed and fixed here: generated helpers clang warned of (#3425); a pure TLS program that linked the HTTP layer and on macOS nghttp2 (#3426); an include the embedded build lacks (#3427); TLS tests bound to OpenSSL 3.2+ or GNU openssl (#3429); tests that leaked under the macOS leaks gate (#3430). A read of the Linux and macOS-only failures then found: fatal errors that lost buffered stdout on glibc (#3435); runtime-copy tests that assumed Windows' binding (#3437); a copy stepping plainly inside another copy's sandbox levels (#3438); gateway scripts that no longer loaded (#3439); copies that could disagree on who owns the levels and accounts (#3442); and wait_for_idle() returning while a timeout's arm ran (#3443, the macOS ARM failure, older than the batch). Merging main brought slice ownership, under which extern free(p: ptr) made free of a slice a double free (#3450; it broke crypto_tls13_peer). The next run found: libraries whose runtime is left to the host that did not load without one (#3455), std.tcp writes to a closed peer that ended the process with SIGPIPE (#3457, the pure TLS stack on macOS), and two TLS tests at fault (#3459), with more OpenSSL 3.0 and LibreSSL names (#3429). On the head after those: the build with -Werror, the C runner 650 of 650, check-changelog, check-tests, check-extern-widths, check-raw-calls, check-crypto-wipe, check-warnings, check-standalone, check-api-docs and the format gate, and every integration test those fixes reach; each new test was seen failing first where the platform shows it, and the rest are named in their commits as CI's to show.

#3445 (security) is fixed here too, in the design its issue recommended: runtime copies found each other only by exports, so a program whose executable exports nothing (Windows, or Linux without -rdynamic, as a C host linked with ae cflags --libs is) and whose copy looked before it loaded a library kept levels and accounts the library's copy never found; the library's code inside the program's deny-all block was allowed everything. Each copy now carries a record in its image (a PE section, an ELF note, a Mach-O section) that every copy reads in load order, the program first (the only lookup: no release had these rendezvous). Making the executable export the rendezvous was not taken: it would turn off MinGW's auto-export in every DLL that links the runtime without --export-all-symbols, the shared runtime's among them. Tests, each seen failing first with the records switched off: sandbox_nested_libraries 2c (inner 1 granted 1 without them), emit_lib_accounts early and in_use (not charged without them), and test_abi_layout (the runner finding its own copy).

CI on 5883be2 failed check-docs on every leg: the stdlib index still gave std.tcp 42 exports, and close_drained made 43 (b8a8fb9 fixes it, and the std.tcp guide now names it). check-docs, check-api-docs, check-tests and check-changelog pass locally on b8a8fb9.

The cooperative-scheduler set, zig cross-compiles of the runtime for Linux, macOS, FreeBSD and wasm32-wasi, make test-install, and the ABI matrix against 0.856.0 also pass. The matrix runs in CI on the Linux and macOS legs for the first time with this push.

Benchmarks of the final head against main 356bfd8 (built at 9364d6b, the same code; the actor set, interleaved, median of 21 rounds, thread_ring and skynet again over 31):

  • ping_pong -1.8%, counting -0.6%, fork_join -1.5%.
  • skynet: within its spread (single runs from 0.9 to 1.5 M msg/s on both).
  • thread_ring: 9 to 10% slower at 1M, 5M and 20M hops (about 4.6 ns a hop), with no work added on its path. Bisected to fb6b3e8, wait_for_idle() inside an actor never returns: a handler's own step stays counted #3463's refusal in wait_for_idle(). Its only effect on the binary is that GCC (MinGW, -O3 -march=native -flto) no longer inlines the cold, never-taken panic body into the ring's message handler, and allocates that handler's registers differently: the same instructions and calls, with the thread-local base reloaded from %gs instead of the stack. Removing the refusal restores the speed. Moving it into a cold, never-inlined function, one LTO partition, and 64-byte alignment of functions, loops and jumps do not; a never-taken panic() in main's own thread_ring does not slow it. It is the compiler's choice for this one handler, not a cost of the change, and it is left as measured.

Measured earlier on this batch: the no-account allocation fast path is faster (27.0 ns against 29.5 ns). The known cost is #3406's journaling of owned values while a frame is live: about 3% in a try and 5% in a handler that makes an owned value per message, about 3 to 5 ns each, documented in docs/memory-management.md.

Closes #426, closes #476, closes #1298, closes #2600, closes #2954, closes #2955, closes #2956, closes #2957, closes #2974, closes #3291
Closes #3293, closes #3298, closes #3303, closes #3307, closes #3308, closes #3309, closes #3310, closes #3311, closes #3312, closes #3313
Closes #3314, closes #3315, closes #3316, closes #3317, closes #3318, closes #3319, closes #3320, closes #3321, closes #3322, closes #3323
Closes #3324, closes #3325, closes #3326, closes #3328, closes #3329, closes #3330, closes #3331, closes #3332, closes #3333, closes #3335
Closes #3336, closes #3337, closes #3338, closes #3339, closes #3341, closes #3342, closes #3343, closes #3344, closes #3346, closes #3347
Closes #3348, closes #3349, closes #3350, closes #3351, closes #3352, closes #3353, closes #3354, closes #3355, closes #3356, closes #3357
Closes #3358, closes #3359, closes #3360, closes #3361, closes #3362, closes #3363, closes #3364, closes #3365, closes #3366, closes #3367
Closes #3368, closes #3369, closes #3370, closes #3371, closes #3372, closes #3373, closes #3374, closes #3375, closes #3376, closes #3377
Closes #3378, closes #3379, closes #3380, closes #3381, closes #3382, closes #3383, closes #3384, closes #3385, closes #3386, closes #3387
Closes #3388, closes #3389, closes #3390, closes #3391, closes #3392, closes #3393, closes #3394, closes #3395, closes #3396, closes #3397
Closes #3398, closes #3399, closes #3400, closes #3401, closes #3402, closes #3403, closes #3404, closes #3405, closes #3406, closes #3407
Closes #3408, closes #3409, closes #3410, closes #3411, closes #3412, closes #3413, closes #3414, closes #3415, closes #3416, closes #3417
Closes #3418, closes #3419, closes #3420, closes #3421, closes #3422
Closes #3425, closes #3426, closes #3427, closes #3429, closes #3430
Closes #3435, closes #3437, closes #3438, closes #3439, closes #3442, closes #3443, closes #3450
Closes #3445, closes #3455, closes #3457, closes #3459, closes #3463, closes #3464, closes #3465

…panic gives up its inbox (#2974, #3298)

Commit 1 of the #2974 track (design_2974.md, approved). An actor ends
once, by the first of its death bits: a panic (PANICKED), actors.kill(ref)
from anywhere (KILLED), actors.stop() from its own handler (STOPPED), or a
C release; the first keeps its reason ("normal", "killed", the panic's).
Its death is finalized on the thread that owns its mailbox while it steps
nothing (actor_finalize): the mailbox, the overflow and an actor thread's
inbox are dropped through aether_message_drop, credited and counted
(scheduler_dead_sends, actors.dead_sends), and its watchers are told. A
panic is finalized in the step barrier, a stop or a kill that lands during
a step when the step returns, a kill of an actor not stepping by its core
from a request (Scheduler.control, run at the loop's top and from the
relief, counted by scheduler_wait), an actor thread's at its loop's end.
So a Down never comes while the handler runs, and comes after the last
messages the actor sent. A killed actor waiting in an ask has it end at
once (AETHER_ASK_KILLED, "the asking actor was killed"; the core's
innermost asker is woken through its slot).

actors.monitor(ref) / actors.demonitor(id) (runtime/actors/
aether_supervision.c, shared by both schedulers): per-actor watch lists,
one locked at a time; a monitor of an ended actor is answered at once; a
Down already on its way when demonitor runs is dropped by the watcher's
generated step (aether_down_accept, the id rides in payload_int). Down is
declared by the compiler for programs that name it (reserved id 254, a
_Static_assert ties it to AetherDownMsg), a program's own `message Down`
is refused, and aether_message_drop releases a Down's reason itself. A
counted send reads its target's death again after counting itself, the
reorder the rebirth of commit 3 rests on. On wasi actors.monitor is
refused at compile time (an unavailable attribute). The new ActorBase
fields are prefixed (death_watch, death_reason, exit_flags): a C actor
that expands AETHER_ACTOR_BASE_FIELDS and has a `watch` of its own
(test_ask_relief.c does) no longer compiles otherwise.

uncredited and the shutdown waited forever; the inbox is now drained with
the mailbox, and a sender that pushes after that drain drops what it
pushed (fences on both sides).

Tests: tests/runtime/test_actor_death.c (a kill mid-handler: a probe sent
after the kill sees no Down, one "killed" Down after release, 5 queued
counted; a killed asker's ask ends at once; a Down after the last
messages; #3298), each shown failing by mutation (inbox not drained:
hang, as on main; Down at kill time: 1 Down while running; no ask check:
the ask waited 3 s); tests/regression/test_actor_down_notices.ae (reasons,
ref, dead_sends, monitor of the dead, demonitor, no Down for the live, a
watcher with no Down arm); tests/integration/down_message_reserved. The
scheduler C tests (86), compiler C tests (80), actor .ae and shell tests
(87), thread_types_layout, check-tests and check-api-docs pass. The
coverage-only flush test of #3292 is labelled as such.
Commit 2 of the #2974 track. actors.link(ref), from a handler, adds a LINK
entry to both actors' watch lists. When either ends, its death is told as
its watchers are (aether_watch_notify): the link goes both ways, and the
other actor gets a Down (monitor id 0, never dropped by demonitor) if it
traps exits (actors.trap_exits(true), AETHER_EXIT_TRAP), else it ends too
by KILLED with the same reason, unless the reason is "normal", which ends
nothing. Its own watchers and links then take its death in turn, so a
failure climbs a tree of links to the first actor that traps exits.
actors.unlink(ref) removes both entries; a link to an actor that has
ended already takes its death at once (its stored reason; "normal" for a
released one). link and trap_exits are refused on wasi with monitor.

tests/regression/test_actor_links.ae: a panic crosses a link with its
reason and the linked actor handles nothing after; a trapping actor gets
a Down and lives; "normal" does not cross; unlinked first, nothing
crosses; linked to the dead, its death is taken at once. It fails without
the change (no actors.link). The scheduler C tests (86), the actor .ae and
shell tests (88), check-tests and check-api-docs pass.
…nic is a death (#2974)

The lead's decision 3 on design_2974.md. The cooperative scheduler
(Emscripten, esp32, -DAETHER_NO_THREADING) stepped actors with no panic
barrier, so a handler's panic ended the program. The barrier is now one
static inline function both schedulers call (runtime/actors/
aether_step_barrier.h), each passing its own parts as static functions
the compiler inlines: its death finalize, its reply slot's failure, and
the multicore scheduler's after-step refill (#3241). A panic there now
ends the actor: the message and the ask it held are dropped and failed,
its death is finalized and its watchers get a Down. On wasi, which has
no setjmp, AETHER_SIGSETJMP takes the first arm and a panic still ends
the program; watching a death is refused there at compile time.

ci-coop now runs tests/regression/test_actor_down_notices.ae and
test_actor_links.ae, which panic in a handler: on the cooperative
scheduler before this they ended at the first panic. Run here on a
stdlib built with -DAETHER_NO_THREADING: the ci-coop list (11) and both
pass. The #2163 invariant test reads the barrier where it now lives.

Threaded: the scheduler C tests (86), the actor .ae and shell tests (88)
and check-tests pass; against origin/main, 7 runs, medians: ping_pong
11.98/12.14, counting 20.90/20.98, thread_ring 21.59/21.72, fork_join
38.14/37.96, skynet 1.27/1.24 M msg/s, ask 682/655 and 661/658 ns.
…2974)

Commit 3 of the #2974 track. A supervisor reruns a child's factory and
the factory's spawn of the child's actor type takes the child's own
block, so siblings and clients keep working refs and the factory's
configuration reaches the new incarnation:

- actors.rebirth_begin(child) claims the block, on this thread, when no
  message for it can still arrive: ended, its death finalized, its step
  lock taken, nothing counted in flight (0 "not yet": the supervisor
  retries from a message to itself; -1: alive, released, C-registered);
- scheduler_spawn_actor finds the claim (aether_rebirth_take) and resets
  the block: old state destroyed and zeroed, mailbox, timeouts, watch
  lists and reason cleared, incarnation + 1; the generated state
  initializers then run;
- the generated spawn's new last line, scheduler_spawn_ready, clears the
  death bits with a release store and the step lock (one compare for a
  fresh spawn; the spawn no longer stores dead = 0 near its top);
- actors.rebirth_end(child) says whether it is alive, or undoes a
  rebirth the factory did not finish ("the restart failed"); a panic of
  the supervisor's handler undoes it too (the barrier).

No message sent before the restart reaches the new incarnation: a
counted send reads the death after counting itself (commit 1), a local
send reads it on the child's core, and the core touches a dead child
only through counted messages and under the step lock. A core's finalize
request reads the bits again under the step lock, since a rebirth may
have brought the actor alive. actors.incarnation(ref) counts restarts.

Found and fixed on the way: make_room returned 1 after its steps killed
the actor (a panic, a stop), and the loop put the rest of the run into
the dead actor's mailbox, never handled nor credited (from #3093); dead
drops were counted by a fresh read of the bits, which a rebirth could
clear first (now by the caller's finding); a stopping thread's leftovers
are not counted as dead sends.

tests/runtime/test_actor_rebirth.c: a target that panics every 150
messages restarted by a supervisor on its own core while three sender
actors and the main thread send to it: handled + dropped = sent, each
sender's order, nothing handled half-made, nothing a sender had finished
sending at the claim handled by the new incarnation, one restart per
failure (40 of 40 runs); and the claim step by step (not before the
death is finalized, not with a message in flight: the mutation without
that check fails it 5 of 5, sends while dead counted, the state reset,
sends after reaching incarnation 1). The scheduler C tests (88),
compiler C tests (80), actor .ae and shell tests (88),
thread_types_layout, check-tests and check-api-docs pass.
supervise.start takes a block of children, each a name and a factory
closure (supervise.child, or supervise.child_restart with "permanent",
"transient" or "temporary"), starts them in order before it returns, and
links them from a Supervisor actor that traps exits. A child's Down
restarts it in place under the ref its siblings and clients hold
(actors.rebirth_begin, the factory, actors.rebirth_end), by the strategy
(one_for_one, one_for_all, rest_for_one). Siblings a strategy restarts are
killed newest first and restarted in start order once their own Downs
have come; a claim that says "not yet" is retried from a message to
itself, so the supervisor never blocks its core. Past max_restarts within
within_secs, or at a failed restart, it kills its children and panics with
a reason that says why, so its watchers and a supervisor above it take the
failure. supervise.which(sup, name) and supervise.restarts(sup) read it.

The runtime's spawn capture (aether_actor_capture_begin/end, noted in
scheduler_spawn_ready) tells which actor a factory started; captures nest.

A compiler bug found on the way, fixed here: the compiler's Down message
was taken for the program's own declaration, so a module's Down arm saw
untyped fields. (The typed actor ref in a prototype it also needed is

Tests: tests/regression/test_supervise.ae (one_for_one with the factory's
configuration rerun and the dead sends counted, one_for_all, rest_for_one,
restart kinds, the budget and the Down that reports it; 30 runs clean;
fails 2 cases with the Down fix reverted). The stdlib index counts
std.actors' 17 exports (stale since the death-notice commit) and lists
std.supervise.
Mailboxes stay unbounded by default: a send never blocks its sender, which
can be a core's thread that the receiver's core waits on. `@mailbox(bound:
N)` before an actor takes at most N messages its step has not taken yet,
wherever they wait (a core's queue, a sender's deferred sends, the
overflow, the mailbox). The generated spawn sets ActorBase.mailbox_bound;
mailbox_pending counts exactly:

- a send takes its place first (aether_mailbox_admit, a CAS loop) in
  scheduler_send_local and scheduler_send_remote, whose bodies are now
  send_local_admitted and send_remote_admitted; send_many and the main
  thread's batch send a bounded actor's messages through them one by one;
- the generated step of a bounded actor gives the place back as it takes
  a message (aether_mailbox_took), and every drop of one not yet taken
  does (release_undelivered, the cooperative scheduler's dead drop and
  finalize drain, the rebirth discard);
- Down and IoReady are neither counted nor refused.

Refused, a send is released at once: a plain `!` is counted
(ActorBase.full_drops, scheduler_full_drops(), actors.full_drops(ref)) and
reported once on stderr; an ask fails with AETHER_ASK_FULL, "the mailbox
is full"; `try_send(ref, Msg { ... })`, a new builtin, returns "",
"the mailbox is full" or "the actor is dead" (scheduler_try_send,
aether_try_send_message) and counts nothing. try_send is the send node
marked "try_send", so its message is built, owned and released as a
send's; it runs ahead of its statement as a control value, its answer a
temporary. An unbounded actor's send pays one load, on the line `dead` is
on, and a branch; its step pays nothing.

Tests: tests/runtime/test_mailbox_bound.c (the #3241 setup: exactly the
bound plus the held message accepted across core 0's queue, the relief's
overflow and the move to core 1; E's plain sends all counted; the ask
refused; all accepted handled in order; nothing left pending; and a
death's drops give their places back; 25 runs clean; fails with the drop
give-back or the remote admission removed), tests/regression/
test_mailbox_bound.ae (try_send, `!` and `?` at the bound, a Down past
it, room again after; fails with the step's give-back removed), and
tests/integration/mailbox_bound_syntax (the refusals of a bad
@mailbox and a try_send to a non-actor).
… keeps its gate off its stack (#3314)

test_scheduler_actor_thread_holds_released_actor took the target as stepped
inline, on the relay's actor thread, by the time the relay reported its
send. Since #3246 an actor thread sets current_core_id to -1, so its sends
step nothing inline: core 0 steps the target, after the report, and the
check read 0 when it came first (the ASan job, run 40602182581, line 610).
The relay now sends as an actor thread does (through the core's queue),
and the test waits for the core's step, and then for the target's release
(the step counts itself handled just before it releases itself: the old
line 611 read 0 there too). What the test checks is unchanged: the target,
released in that step, is held by the relay thread's epoch while its step
waits at the gate, and so is every release after it.

The failed assert returned while the relay's thread still polled `gate`
on the test's stack (the stack-use-after-return in relay_step). The gate
is static now, and every assertion comes after both actor threads are
joined, so no path out leaves a thread reading the stack.

Runs (a driver with this test alone; "loaded" = every hardware thread busy
with `yes`, which delays core 0 as ASan does):
- Windows, MinGW: before 0/500 idle, 109/300 and 53/200 loaded; after
  0/500 idle, 0/500 loaded.
- MSYS2 POSIX build of the runtime: before 0/500 idle, 3/700 loaded;
  after 0/500 idle, 0/500 loaded.
- #3241 does not change the odds: the old test, loaded, on origin/main's
  runtime 99/300, on 0140a091's 130/300. deliver_owned runs in the send,
  before the relay's report; the race is the main thread's check against
  core 0's step.
…eps the death notice list (#2974)

Measured against origin/main after 224838a2 (medians of 15 to 25 runs):
skynet had lost 11% and thread_ring 4 to 6%; ping_pong, counting and
fork_join were within 2%. Two causes, both fixed here:

- A spawn read three thread-locals for the restart claim and the spawn
  capture (aether_rebirth_take, scheduler_spawn_ready), each a call under
  emutls. They are read only while a claim or a capture is live on some
  thread: one process-wide count (g_spawn_watch), a relaxed load per spawn.
- #2974's ActorBase fields took 40 bytes past `inflight`, so an actor's
  state started a cache line later and the step of a small actor touched
  one more line. They take 16 now and no earlier field moves: exit_flags
  (a byte) and mailbox_bound sit in the padding after step_lock,
  incarnation in the padding before inbox, full_drops is 32 bits, and the
  death reason moved into the watch list (aether_actor_death_reason).

The main thread's batch now admits a bounded actor's message as it joins
the batch instead of sending it unbatched (bounded counting 6.6 to 11.5 M
msg/s; unbounded 21). A forced inlining of the admitted send bodies was
tried and dropped (thread_ring 21.36 inlined, 21.56 not).

After: skynet 1.20 base / 1.23 new; the bound checks themselves cost
nothing (thread_ring 21.15 with them, 21.16 compiled out). thread_ring
stays about 5% under origin/main; a same-session bisect (this tree's
generated C on each commit's runtime) puts the largest step at #3241's
overflow checks (22.24 to 21.46), the rest within run-to-run noise.

Fixed on the way, in restart in place (22b8d742): the rebirth freed the
child's watch list while another actor's monitor or link of the dead child
could hold it, to read the death it told under its lock (a use after
free). The list is emptied in place under its lock (watch_reset), and a
monitor or link of a dead actor copies the reason under the lock. Test:
"Rebirth: the death notice list survives a restart" (fails with the
freeing code: the list is gone).

Runtime C tests 91/91, near actor tests 91/0, the cooperative list and the
layout probe pass.
…ple (#2974)

docs/actor-concurrency.md gets "The delivery contract": per sender to
receiver FIFO, at most once with every unhandled message released and
counted, unbounded by default as a decision (a blocked sender can be a
core's thread the receiver's core waits on), and the opt-in bound.
docs/structured-concurrency.md records Phase 1 (death notices, links,
restart in place, std.supervise, the bound) as shipped, with where it
differs from the proposal (std.actors functions rather than bare
builtins, Down built into the compiler, wasi refusing monitor and link);
Phase 2 stays a proposal. The language reference says what a send
promises; docs/isolated.md no longer waits on #2974 for dead-actor drops.

examples/02-actors/12-supervisor.ae restarts a crashed calculator under
its ref and shows the supervisor giving up past its budget, with a
monitor's Down saying why; 10-crash-isolation.ae no longer says monitors
and supervisors are not built; examples/README.md links the new one.
Found by 500-run loops of the tests this track added:

- "Mailbox bound: exact across the queues and the overflow" failed 1 in
  500 (E never emitted): on four cores the relief moved D to the parked
  core it prefers, which was E's when core 1 happened to be awake, and D's
  held step kept E from running. Three cores now, as test_ask_relief.c
  has (core 2 is in B's long step, so core 1 is the only place D can go),
  and E is an actor with a thread of its own, which no core's placement
  can hold up.
- "Rebirth: the death notice list survives a restart" failed 75 in 500
  (the claim said "not yet"): it claimed as soon as the death was
  finalized, while the core that finalized it still held its step lock.
  It waits for the scheduler first, as the in-flight test does (the core
  counts the kill's request done only after it lets go of the lock).

After: 0/500 each (Windows); the other groups' 500-run loops were clean:
death 0, send buffer 0, ask relief 0, and the actor .ae tests (bound,
supervise, Down notices, links) 0 each.
…ef has no fields (#3331, #3333)

#3331: inside a trailing block a builder function's hidden _ctx does not
count toward arity, but a call that left out a defaulted parameter after it
was refused (too few arguments) or had the context taken as the first
argument. A call that gives the parameters after _ctx now fills the
defaults it leaves out; one that passes the context first, or every
parameter, passes it explicitly as before.

#3333: a field access through a bare actor_ref compiled to a read of
whatever lay at that offset of the actor. A bare reference names no actor,
so the access is an error that says to type the reference; a typed one
still reaches the actor's state, and an unknown state name on a typed one
is reported by name.

Closes #3331. Closes #3333.
`reply w`, where `w` is an actor_ref, was carried as an int: the pointer
cut to its low half, and the asker could not use the result as a
reference. The ask's result slot is a void* now, in both the expression
and the statement forms of the ask, bare or as a reply message's field.

Closes #3332.
docs/aether-vs-hcl.md: what HCL does well, where it stops, one gateway
configuration written both ways, std.schema beside HCL's validated
variables, review before a configuration runs, and when to pick which.
Linked from config-is-code.md, aether-embedded-in-host-applications.md and
containment-sandbox.md.

Closes #476.
A message's struct and its id were emitted when the loop over the program
reached the message. An actor declared before a message it handled
dropped it without a word, a function that sent a later message did not
compile, and a program's own actor could not handle a message an imported
module exports, since the merge puts module declarations before main.
emit_message_definition now runs for every message before that loop, as
the structs are hoisted already; the message ids keep their order.

Closes #3341.
With AETHER_HOME set to an install, a test that ran build/aetherc without
pinning it compiled against the installed std, and six tests failed on a
machine with an older install. Both sweep runners drop an inherited
AETHER_HOME; a test that wants an install names one itself. The new test
runs each runner with AETHER_HOME set over a probe that fails if it sees it.

Closes #3342.
A send never blocks, so a producer that outran its consumer grew the
consumer's mailbox, then the scheduler's overflow, without bound. A stream
bounds it: stream.connect(producer, consumer, window) gives both actors a
stream whose producer holds `window` credits. Each chunk it sends costs one
(stream.offer sends one chunk, stream.write cuts a string into chunks and
sends as many as the window allows, returning where it got to); the
consumer returns one per chunk it handled (stream.consumed). Running out of
credit is a return value, never a block. stream.finish sends a last chunk;
the consumer's end comes back behind its last credit, and stream.credit
frees the stream there, so nothing of it is in flight when it goes.

Shaped by the issue's narrowed scope: chunks are the unit of credit, the
consumer's mailbox is the buffer, a chunk is a copy (any bytes), the read is
the StreamChunk arm, and sequencing waits for a transport that can reorder.
A library over sends: no runtime change.

Tests (std/stream/test_stream.ae): binary data, zero bytes included, arrives
whole and in order chunk by chunk; a producer with more to send fills the
window, stops, and resumes on each credit, never past the window; every
credit is back at the end; an empty stream and a window of 1; misuse; and,
where the heap count is exact, whole streams with actors spawned and
released each round leak nothing (40 of 40 after #3346). README with a
runnable producer and consumer; the stdlib reference index and a
Concurrency section; docs/api regenerated.

Closes #426.
InitializeCriticalSection attaches a 48-byte debug record to a critical
section the first time a thread waits on it, at an arbitrary later point.
heap_in_use counts every block on Windows, so an exact leak check read a
lock first contended inside its window as growth: two actors exchanging
messages and released grew 48 or 96 bytes in 3 runs of 30, and the
std.stream test's check failed 8 of 30. pthread_mutex_init now makes the
critical section with CRITICAL_SECTION_NO_DEBUG_INFO; 0 of 40 grew after.

std.os's Windows process table made its critical section on first use
behind an unguarded flag, so two threads spawning at once could both
initialize it; it is a statically initialized SRW lock now (never taken
while held).

New runtime test: a contended mutex's DebugInfo is still the "none" marker
afterwards. Seen failing against the old pthread_mutex_init.

Closes #3346.
The monolithic compiler build kept a list of its own of the runtime pieces
aetherc links and had lost aether_spawn.c and aether_sandbox.c, so it failed
at the link; it also did not wait for the generated headers, and compiled
the vendored lzf and miniaudio sources without the warnings their own rules
relax. AETHERC_RUNTIME_SRC is now the one list for both rules, the headers
are prerequisites, and the sources with flags of their own come from their
own rules. The manifest rule makes the build directory it writes into.

Checked by building compiler-fast in a fresh build directory: it links,
with no warnings, and the compiler compiles a program.

Closes #3351.
…efore macOS absorbs (#3338)

http_proxy_send_timeout's worker-path case received every byte now and
then. Diagnostic CI runs (eleven, of 40 and 80 tries each, the server's
sends logged by connection, TCP_CONNECTION_INFO, a capture of lo0) found
two causes, both the test's; the server's send timeout was right each time.

- Linux: the client set its receive buffer after connecting, so the window
  the connection opened with still stood; it took that much through the
  stall and drained it for 16.5 s after the cut. Set before connecting, the
  close comes 3 ms after the stall.
- macOS: the client's kernel takes about 300 KB over the first second (for
  any SO_RCVBUF). About two seconds in, in some runs, the sender's kernel
  reports tens of megabytes sent and acknowledged (txbytes up, snd_sbbytes
  near zero) while the client sends nothing and the capture shows the bytes
  crossing only after it wakes. No sender can tell that from a client that
  reads, so a server is right not to cut it. The worker path's cut, at its
  first full buffer (about one second) plus the one second timeout, met that
  at two seconds: 21 runs in 480.

New tcp.connect_buffered(host, port, read_buffer) sets SO_RCVBUF before the
connection is made (aether_net_connect_addr and dial_addrs take the size),
and tcp.available(sock) says how many bytes wait in the receive buffer
(FIONREAD). The test connects with the first and uses a 300 ms idle
timeout, so every cut comes well before macOS's absorption; what it asserts
is unchanged. The set_read_buffer docs no longer promise what the kernels do
not keep.

Test std/tcp/test_tcp_buffered.ae: a window under 1 refused, a buffered
connection carrying data both ways, available before and after a read, and
on no socket.

Closes #3338.
…im is live (#2974)

fork_join, measured against main e6b27b8 in rotation (main, the branch
before this, the branch after; the first run of each round rotating, 45
rounds): main 38.51 M msg/s, before -2.27% per round, after -0.68%. An
A/A of main against itself spreads about 1% (fork_join) and 5% (skynet)
between the two places of a pair, which is why the pairs alternate order.

The cost was the death re-read #2974 added to scheduler_send_batch_flush:
`dead` is on the cache line the target's core writes at every step
(step_lock, step_payload, step_type), and the flush reads it a whole batch
after the add read it, so every message of a fan-out took a miss. The
re-read is there for one race only: a message counted after a restart
claim read the child's inflight count must not reach the new incarnation.
The claim now counts itself in g_aether_spawn_watch (seq_cst) before it
reads inflight, and inflight_enter is seq_cst (the same lock xadd on x86,
ldaddal on arm64), so a flush that counted its messages and then reads 0
there cannot race a claim, which will see the messages and wait for them.
Only while a claim is live does the flush read the death again.

Test: "Rebirth: a batch flushed during a claim drops what it sends the
child" (a message added while the child lived, the child killed and
claimed, its core held, the batch flushed, the child restarted): dropped
and counted, never handled by the new incarnation. It fails with the
re-read removed. Runtime driver 92/92, rebirth group 500 runs 0 failures,
near actor tests 96/0, coop list passes.
…ing it is (#3329)

test_caps_fs_read_denied_past_cap (POSIX only) put file_read_all_raw's
AetherString* in a char*: GCC 15 makes incompatible pointer types an error,
and MSYS2's gcc 15.3 refused the file (1 error; 0 after). The result is a
struct AetherString* now, released if the cap let the read through, and
the cap and the file are put back before anything is asserted, so a
failing assertion no longer leaves the cap set for the tests after it.
…sage (#3330)

With no threads an ask ignored timeout_ms: a handler slower than the
timeout still had its reply taken, and an ask behind more than 100 000
queued messages ended "timed out" early, its polls counted instead of
timed. The polls now stop at the deadline and only there (an empty poll
with no countdown armed ends at once), and a reply past the deadline is
discarded as late.

An ask that ended with its message still queued (the old poll count, now
a deadline behind a slower handler) left the message pointing at the
slot on the asker's stack; its handler later wrote the reply there, so
the next ask from the same place took it (a crash when the types
differ). The ask now clears its slot from the queued message, which is
handled as a plain send.

test_ask_failure_reasons gains the queued and stalled cases and joins
the ci-coop list. On a cooperative stdlib before the fix: the queued
case and "too late" fail; with the deadline but without the forget the
program crashes in the next ask. After: the ci-coop list 17/17.

Fixes #3343, fixes #3344.
D1: every enforce installed one process-wide checker pointer and any
thread whose own depth reached 0 cleared it, so a deny-all block on one
thread ran unchecked while another thread churned an allow-all block, or
called the pointer just cleared (access violation within 500 ms).
D2: the levels lived in each program's or library's generated code. An
export of library L2 called inside L1's block (through a host service)
checked against L2's empty stack; on one runtime image L2's own enforce
replaced L1's checker (an inner grant_all widened the block) and cleared
it on the way out (the rest of L1's block ran unchecked).

The levels are now per thread in runtime/aether_sandbox.c, reached by
generated code only through functions (push, pop, tag_site and
trust_enter with an image identity, since site numbers are per program,
restore). Nothing is installed: the check reads the calling thread's
levels behind a count of threads that have any (0.31 ns against 0.21 ns
on main, before each checked operation). Copies of the runtime find each
other once (the first loaded image exporting aether_sandbox_rendezvous:
module enumeration on Windows, dl_iterate_phdr with RTLD_NOLOAD on ELF,
the dyld image list on macOS; 40 us, once per copy) and count each
thread's levels in one place, so a copy that sees the thread inside
levels it cannot read refuses, naming --shared-runtime.

Found and fixed on the way:
- a panic caught by an actor's barrier (or any catch in code that never
  enforces) left the block's level on the thread: the next handler on
  that scheduler thread was held to grants its unwind freed. aether_panic
  now ends the levels pushed inside the catching frame;
- a 65th level was dropped and its pop took an outer level off (a
  widening both ways); it is now refused with a panic;
- the contrib host bridges swapped the process-wide checker for their
  own, dropping the enclosing block's grants and racing other threads;
  they push their grants as levels of the thread;
- the LD_PRELOAD library used the in-process checker for every thread
  while any thread had one installed, so other threads skipped the file
  grants; it now reads the calling thread's levels.

sandbox_install() and sandbox_uninstall() compile to nothing.

Tests: test_sandbox_thread_levels.ae (main crashes with 0xC0000005;
without its race part the barrier and 65-level cases fail on main);
sandbox_nested_libraries (main: one runtime "granted 1 outer 1",
a runtime per library "inner 1 granted 1" with no message, shared
runtime "granted 1 outer 1"). 500 runs of the thread test: 0 failures.

Fixes #3347, fixes #3348, fixes #3349, fixes #3350.
…2600, #1298, #3291, #3293)

std.cryptography.p256 signed and did ECDH over the variable-time
std.bignum: a branch on every bit of the private key and the nonce, and
ecdsa_sign took the nonce from its caller. The pure TLS 1.3 server and
the mTLS client signed with it.

New std.cryptography.ecc: Montgomery field arithmetic on fixed 32-bit
limb arrays (no allocation; carries and conditional subtractions are
masks), Fermat inversion with the public exponent p-2, the complete
projective formulas of Renes-Costello-Batina (a = -3 and a = 0, so no
exceptional case and no branch on a point), a fixed 4-bit window scalar
multiplication that reads all 16 table entries per digit, scalar
arithmetic mod n on the same engine, and ECDSA with RFC 6979 nonces
(HMAC-DRBG over the SHA-2 function of the digest's length). Verify
computes u1*G + u2*Q in one interleaved pass.

p256 is a thin wrapper now. ecdsa_sign(priv, hash) has no nonce
parameter (migration note in the changelog fragment); tls13_cert's two
CertificateVerify signers lose theirs too, and the server and client stop
drawing one. ecdh, scalar_mult and ecdsa_verify refuse a point off the
curve or with a coordinate >= p (the invalid-curve attack, #3291), and
sign/ecdh refuse a key outside [1, n-1]; public_key_valid and
private_key_valid are new. The TLS client's rand_into returned zeros on
a CSPRNG failure (its key shares would have been predictable); it
returns null and every caller fails the handshake, and the client's
P-256 key share is drawn until it is a valid scalar (#3293).

New std.cryptography.ct: masked eq/lt/select/bytes_eq and declassify,
the one place a secret-derived value may decide a branch, with a hook
for the harness.

Constant-time check: tests/integration/crypto_ct_valgrind marks the
secret inputs undefined for Valgrind's memcheck (ct_shim.c) and fails on
any branch or memory index computed from them; CI job "Crypto
constant-time (Valgrind)" in ci.yml. Run against main's p256 (run
40583266438) it reported 68 such sites (bignum and p256_jac_mul).

Tests: crypto_p256 has RFC 6979 A.2.5 for SHA-224/256/384/512 on both
messages, (n-1)G, nG, the key-range and off-curve refusals; all 355
Wycheproof ecdh_secp256r1_ecpoint cases run (16 invalid-curve cases were
accepted on main); the P-256 ECDSA Wycheproof drivers sweep every vector
(746 cases, 4 s at -O0); std/cryptography/{ecc,ct} have unit tests.

Per operation, default ae build, this machine: sign 415 us (was 4050),
verify 470 us (was 7850), ecdh 365 us (was 3900).
The constant-time harness's first run on the new P-256 (run 40585185265)
reported two secret-dependent branches, both in aether_string_data under
aether_bytes_copy_from_string under sha2.final_bytes, reached from
hmac.final_bytes in RFC 6979's HMAC-DRBG. final_bytes turned the digest
buffer into a string (bytes.finish) and copied it back
(bytes.copy_from_string). The extern gets the string's characters, and
its C side asks again whether that pointer is a string by reading its
first four bytes against the header magic: a branch on HMAC's secret
state, and a misread for a digest that starts like a string header.

sha2.final_bytes now returns the buffer the digest was serialized into,
trimmed to digest_len (SHA-512/224 writes four whole words).

The same route carried key material: the TLS client and server drew
their key shares and randoms, and ML-KEM its seeds, as
cryptography.random_bytes' string copied into a buffer. New
cryptography.random_buffer(n) -> (bytes, err) fills a std.bytes buffer
from the OS CSPRNG directly (null and an error on failure or a short
read, never zeros); rand_into in both TLS modules and mlkem.rand32 use
it.

Tests: crypto_random_buffer (length, two draws differ, n = 0, n < 0);
the kdf, tls13 kdf/ks/client/cert/server, mlkem, sha, digest-stream,
Wycheproof hmac/hkdf/mlkem suites and the pure TLS vertical pass. The
constant-time probe's second CI run is the check for the HMAC path.
The three curves still did their arithmetic on std.bignum, with a
caller-supplied nonce. They are wrappers over std.cryptography.ecc now,
like p256: constant-time signing, ECDH and scalar multiplication,
RFC 6979 nonces, complete formulas (a = 0 for secp256k1), one-pass
verify, on-curve and key-range checks, public_key_valid and
private_key_valid.

ecdsa_sign(priv, hash) loses its nonce parameter (migration note in the
changelog fragment). P-521 reads the digest as FIPS 186-5 says, its
leftmost 521 bits: a SHA-512 digest goes in as its 64 bytes, and the
Wycheproof P-521 drivers stop zero-padding it to 66 (which now means a
528-bit digest, truncated).

Tests: crypto_nist_curves has RFC 6979 A.2.6 and A.2.7 for SHA-224/256/
384/512 on both messages plus the refusals; crypto_curves2 has
bitcoinjs-lib v3.3.2's seven deterministic secp256k1 signatures (r, and s
in low-S form) plus ECDH both ways and the off-curve refusal. Wycheproof:
every ecdh_secp384r1_ecpoint (790), ecdh_secp521r1_ecpoint (661) and
ecdh_secp256k1_webcrypto (496) case runs (#3291), and the P-384, P-521
and secp256k1 ECDSA drivers sweep every vector (784, 860 and 728 cases:
8 s, 18 s and 4 s at -O0, about 30 s for all of ECDH). The constant-time
probe signs and does ECDH on each curve.

Per operation, default ae build, this machine (bignum before):
P-384 sign/verify/ECDH 1230/1410/1110 us (7700/15500/7400),
P-521 3040/3400/2920 us (15000/28600/14800),
secp256k1 340/370/285 us (3800/7850/3750).
raw_decrypt (and decrypt_pkcs1, decrypt_oaep, sign_pkcs1, sign_pss on
top of it) raised the input to the private exponent with std.bignum's
mod_pow: variable-time arithmetic and a bit pattern of d in its timing.

The private-key operation is now Montgomery arithmetic (CIOS, masked
final subtraction) on little-endian 32-bit limb vectors of the modulus's
width, allocated once per operation and indexed through unbounded views,
with a fixed 4-bit window exponentiation over all k bytes of d that reads
the 16 table entries for every window. The base is blinded: c * r^e for
a fresh random r, the result times r^-1, where r^-1 is std.bignum's
mod_inverse of r*b (b random) times b, so that variable-time inversion
only sees a uniformly random value. The key keeps d as k big-endian bytes
(dbe), converted once when the key is built.

PKCS#1 v1.5 and OAEP decryption scan the whole block with masks and
declassify only the verdict and the message length (no Bleichenbacher or
Manger timing oracle from an early exit). encrypt_pkcs1 draws its
padding from cryptography.random_buffer, and draws again instead of
reusing bytes when the nonzero ones run out.

New rsa.key_from_bytes(n, e, d) builds a key from big-endian byte
strings (JWK and PKCS#11 carry them so) and copies d byte by byte,
never through std.bignum: it is how the constant-time probe marks d
secret. raw_decrypt and the signers give null (or an error) for a
public-only key instead of dereferencing a null d.

RSA-2048 private-key operation, default ae build: 11 ms (27 ms before).
Wycheproof rsa decrypt families (104 cases): 13 s at -O0 (21 s before).
Tests: crypto_rsa_oaep_pss (RFC vectors, key_from_bytes, public-key and
long-d refusals), Wycheproof RSA decrypt/sign/verify, the CT probe signs
and decrypts with d secret (PSS, PKCS#1 v1.5 sign, OAEP, PKCS#1 v1.5).
EdDSA key derivation and signing and the X448 ladder ran on the
variable-time std.bignum (a double-and-add branch on every bit of the
secret scalar and nonce).

std.cryptography.ecc gains the Edwards curves: edwards25519 (a = -1) and
edwards448 (a = 1), d not a square, so the projective add-2008-bbjlp
addition and dbl-2008-bbjlp doubling are complete. The secret scalar
multiplication is the same fixed 4-bit window with all-entries masked
selection, scalars are reduced mod L on fixed-width limbs (bit-serial,
constant time, any input length), S = r + k a mod L likewise, RFC 8032
encoding is constant time, and decoding (public) checks y < p, the
square root and the x = 0 sign rule. Verify computes [S]B - [k]A in one
interleaved pass and compares its encoding with R, so a non-canonical R
fails without being decoded. ed25519 and ed448 keep their hashing,
clamping and dom4, now on ed_scalar_mult_base / ed_reduce / ed_sign_s /
ed_verify.

x448 is a Montgomery ladder on the fixed-width field with masked
swaps. New x448.agree refuses an all-zero shared secret, as x25519.agree
does (#1298's API asymmetry); x448.x448 stays the raw RFC 7748 function
and returns null for buffers that are not 56 bytes. x25519.agree's zero
check now goes through ct.declassify.

Per operation, default ae build (bignum before): Ed25519 sign/verify
665/410 us (7850/8350), Ed448 2.3/1.6 ms (23.2/25.5), X448 1.0 ms (6.6).
Wycheproof: Ed25519 every case (was stride 5), X448 every case through
agree (was stride 25), Ed448 unchanged (full); the asym script takes 6 s
at -O0. Tests: crypto_curves2 checks x448.agree and x25519.agree refuse
an all-zero secret where the raw functions return zeros; ecc's unit test
covers scalars mod L and the identity / base encodings; the CT probe
signs with Ed25519 and Ed448 and agrees with X25519 and X448.
…gelog says so (#3450)

Under a program's own `extern free(p: ptr)`, free of a slice is now the
builtin release, so the ownership checks apply there as everywhere: freeing a
slice parameter the function only borrows (E0901), or a view of another slice
(E0200), is a compile error where it used to be C's free. The fragment names
them, and slice_own and free_view check both under extern free.

Found by the fifth review.
…ot by exports (#3445)

The sandbox levels and the execution accounts live in one runtime copy per
process, the first loaded image that has them, and an image said so only by
its exports. A Windows executable exports nothing, nor does one linked
without -rdynamic on Linux (a C host linked with `ae cflags --libs`), so a
program whose copy looked first, before it loaded a library with a runtime
of its own, kept levels and accounts the library's copy never found: the
library's code the program called inside a deny-all block was allowed
everything, and an account the program had put in use was charged nothing
the library allocated.

Each copy now carries a record in its image (runtime/aether_rv_mark.h): a
.aerv section on PE, an ELF note, a __DATA,__aether_rv section on Mach-O,
laid out as an ELF note on every format, naming the copy's rendezvous
function. aether_rendezvous_find reads the images in load order, the
program first, and takes an image's record before its export; an image of
an earlier release has none and is found by its export, as before. A
record's function counts only when it lies inside its image, and on ELF
and Mach-O only once the image is loaded whole (dlopen RTLD_NOLOAD), so a
library another thread is still loading is never read unrelocated. The
finder is each copy's own (hidden), since copies may be of different
releases. Each copy passes its own record to the finder, which keeps it
under --gc-sections.

Making the executable export the rendezvous (dllexport) was not taken: it
turns off MinGW's auto-export for any DLL that links the runtime without
--export-all-symbols, the shared runtime's among them.

Tests, each seen failing first with the records switched off:
- sandbox_nested_libraries 2c: the outer library linked into a program
  that runs the block before it loads the inner one: inner 1 granted 1
  without the records, inner 0 granted 0 with them;
- emit_lib_accounts: `early` must now share the account (it was refused),
  and the new `in_use` must charge the library's allocations to the account
  the program put in use (grow 64000 outcome 0 without the records);
- test_abi_layout: the record's layout, and the runner, an executable that
  exports nothing on Windows, finding its own copy.
The record links into PT_NOTE in executables and shared objects (x86_64 and
x86, with and without --gc-sections, checked with readelf); the finder
compiles with -Werror for Linux glibc and musl, macOS, FreeBSD and x86.
…copies find each other (#3445)

A review of 269c84f found the record a hardening regression on ELF and
missing where it was claimed:
- its initializer was a function address, so under -fPIC the note section
  was writable ("aw", needing a dynamic relocation): GNU ld places notes
  after .interp, and a writable one there starts an RW segment that takes
  .dynsym, .dynstr, the relocations and the ELF headers out of RELRO (RWX
  with .text where ld defaults to -z noseparate-code), and gas warns on
  every GCC build;
- musl and FreeBSD name the program in dl_iterate_phdr, and a dlopen of
  it without loading fails there, so no copy took the program's record
  (the FreeBSD leg: abi_rv_mark_finds_the_program failed);
- clang's ASan put redzones between the C records of a section (the macOS
  ASan leg: a global-buffer-overflow in aether_rendezvous_find);
- other images' headers were read with no reference held.

Now:
- ELF records are notes emitted by assembly in a read-only section, whose
  desc is the function's offset from it, fixed at link time against a
  hidden function: no relocation, nothing writable, nothing to interpose,
  nothing ASan instruments. Checked with readelf on lld-linked x86_64 and
  aarch64 objects: .note.aether.rv flags A, SHT_NOTE, 28 bytes, no dynamic
  relocation, and the offset lands on the function, --gc-sections too.
- PE and Mach-O keep C records (.aerv; __DATA_CONST,__aether_rv), outside
  ASan's instrumentation; each module or image is held while it is read
  (GetModuleHandleExW; a dlopen without loading, verified still to be the
  image listed), and a record counts only when its function lies inside
  its image, Mach-O included.
- The program is the object whose headers the kernel gave the loader
  (AT_PHDR, getauxval or elf_aux_info), else the unnamed or first one.
- The records are the only lookup wherever the images are read: no
  release ever had these rendezvous (#2954 and #2955 are this batch), so
  the export lookup served only builds of this branch. The rendezvous
  functions are each copy's own; the foreign-layout fixtures carry records,
  as a copy of another Aether would. Cygwin keeps its global-scope lookup.
- The docs no longer describe earlier releases found by their exports.
…out (#3435)

A single ftrylockfile skipped the flush whenever another thread was in the
middle of a println, so a multi-threaded program that printed lost its last
lines again, the failure #3435 fixed (found by review). The flush now tries
for a quarter of a second, by the monotonic clock, then gives up: a
momentary holder is waited for, a writer blocked on a full pipe still
cannot hold the abort. The comment says that macOS's and FreeBSD's abort()
flush the streams themselves.

held.c, a second thread holding stdout while the program fails a bounds
check, seen failing first under a POSIX (MSYS2) build: held 50 ms, the
single try lost both lines; held for ever, a plain fflush never aborted.
With the change both lines are kept and the abort comes after 300 ms.
POSIX only: Windows' C runtime has no try, and flushes.
… is read with acquire (#3443)

wait_for_idle() waits for every actor, the calling one too, whose step
cannot end while it waits: in a handler it hung forever (its message stays
counted), and in an arm the per-thread discount of round five left two arms
waiting for each other, and an arm waiting for the actors of its own core,
until the bound or forever (found by review). On both schedulers it now
panics inside a step, as the actor's code: the actor ends with a reason that
says to call it outside the actors, its watchers are told. The discount is
gone.

The quiet window's check read the firing count relaxed and the done count
after it: on weak memory (AArch64, POWER) the done load could be satisfied
first, so an arm that ended between them was missed. The firing count is
read with acquire.

test_wait_for_idle_inside_actor (a handler and an arm, each watched) was seen
failing first: on the old runtime it hung until killed at 60 s; now it
passes 10 of 10. test_actor_timeout_arm_waits_idle, which expected an arm's
wait to return, is removed with the discount. docs/actor-concurrency.md says
both.
SEQUENCE { TRUE, INTEGER 0, INTEGER 5 } read as pathLen 5, the last one
winning, and an INTEGER before the BOOLEAN was taken too (found by review);
OpenSSL refuses both. The cA comes first and once, the pathLenConstraint
after it and once, or the extension is malformed. Two cases in
crypto_tls13_cert, certificates made with OpenSSL's raw DER extension
syntax; both failed before (300).
…ram's panic (#3438)

Case 2d of sandbox_nested_libraries: a program with the panic of a runtime
of its own and no sandbox, linked -rdynamic on Linux as `ae build` links
one, loads a library with a runtime of its own whose enforce block catches
a panic raised in a nested block. Under ELF interposition the library runs
the program's panic and try frames, and its sandbox must reach them through
the program's pointers. With the pointers each copy's own (round five's
AETHER_COPY_LOCAL on all four), the catch leaves the nested level in force
and the block's end the outer one; a review found it. Pushed ahead of the
fix to see it fail on Linux, where interposition happens; it passes on
Windows and macOS, which do not interpose.
Round five made all four pointers between the sandbox and the step barrier
each copy's own (hidden). The join pointer must be: a constructor sets it,
and a constructor runs in every image whatever the interposition, so under
ELF interposition a later library's reached the first copy's. The unwind,
hide and show pointers must not: they are read by the panic and the step
barrier, and under interposition a library with a runtime of its own runs
the program's; its sandbox, the only one where the program has none, then
set its own hidden copies, which nobody read. A panic caught inside the
library's enforce block left the nested level in force, and the block's
end removed the wrong level (found by review).

Case 2d of sandbox_nested_libraries, pushed ahead of this, failed on the
Linux / Clang leg (e3de216): "after the catch PATH 0 probe 0; after the
block probe 0", want "PATH 1 probe 0; after the block probe 1". The three
are back to default visibility, binding as their readers do.
A review of ad4a2d8 found the ELF walk used an object's name after the
loader's lock was released (a use-after-free when another thread closed it),
copies that could disagree because the others' by-name dlopen check could
fail where a copy trusted its own record (musl and FreeBSD reopen a relative
name from the current directory: #3445's escape back), records unreadable in
an 8-aligned PT_NOTE (gold groups notes regardless of alignment), and a
malformed note that could stop or fault the walk.

The walk stops at the first record, and the asking copy's own image has one,
so it never reads an image loaded after its own; those before it are loaded
whole, since the loader loads one library at a time, adds each at the end of
the list and relocates what a load brings before any of its constructors
runs. So the loaded-whole checks, the by-name dlopen and the search for the
program are gone, and every copy answers alike at any moment. The ELF record
is now 32 bytes aligned to 8 (an 8-byte desc: the offset, then 4 zeros), so
it parses with notes aligned to 4 or 8; readelf on lld links, 64- and 32-bit:
flags A, the offset lands on the function. A note counts only inside the
object's loaded segments, sizes past what is left end the walk, the Windows
pin must be the module listed, and the rendezvous stay exported where no
records are read (Cygwin, systems with no dl_iterate_phdr), whose global
lookup needs them.
#3464)

count_pending_messages read each core's sent and handled counts in turn, so
a message sent on a core already read and handled on one read after
cancelled the reply it sent, still pending, and the count read 0: on the
Linux / GCC under UBSan leg std.stream's test saw the wait return mid-stream
("the consumer got 3136 bytes of 5000"). Every core's handled count is now
read before every sent count: a message is counted sent before its receiver
can take it, so one counted handled is counted sent too, and one pending
through the read counts. scheduler_send_many (the send buffer's flush)
counted its batch after it enqueued it; it counts before, as a single send
does. No cost on the send path.

A race: the stream test (10 of 10 here) and the UBSan leg are its witness.
A drop or a death hook a core runs comes after the step, with the in-step
flag off and the step's message not yet counted handled, so a wait there
waited for that core, forever (found by review); so did one on an actor's
own thread. The threaded scheduler's wait now panics on a core's thread and
an actor's own too, where the drop's or the hook's frame takes it. The
cooperative scheduler, whose wait ran the other actors nested and returned,
refuses inside a step as before, now said in the docs and the changelog.

test_wait_inside_actor (a death hook that waits, on a core) was seen failing
first: without the thread check the runner hung there until killed at 120 s;
with it, 653 of 653.
Nothing checked that the extension's value ended with the SEQUENCE, so
trailing bytes were read past (found by review). A certificate made with
OpenSSL's raw DER syntax, SEQUENCE { TRUE } then 00, read as a CA with no
limit before; it is refused now.
Case 2e of sandbox_nested_libraries: the program with the panic and no
sandbox (-rdynamic on Linux and FreeBSD, where 2d now links it so too) loads
caught.ae's library and inner.ae's, each with a runtime of its own; the
first runs its block, the second enforces after it, and the first runs its
block again. Both run the program's panic, whose one slot of hooks the last
copy to look set: the first's caught panic leaves its level in force (found
by review). Pushed ahead of the fix to see it fail on Linux.
…egistered with them (#3438)

Under ELF interposition a library with a runtime of its own runs the
program's panic and step barrier, and its sandbox, where the program has
none, set their one slot of hooks; two such libraries overwrote each other,
and the last to look was served alone, so a panic caught inside the other's
enforce block left its level in force and a step inside its levels was not
put aside (found by review). A copy whose own scheduler ran its static
sandbox code also wrote its hooks there, and its step's show called the
exported restore, which interposition sent to another copy, truncating that
copy's levels.

Each copy of the sandbox now adds its hooks to a list (aether_panic.h,
AetherSandboxHooks, at most eight copies; one past that keeps its levels in
force through a panic and a step, refused more, never less, and says so),
and gives its place up when it is unloaded. The panic unwinds every copy's
levels pushed inside the frame it leaves; a step puts aside every copy's
that has some and gives them back in the reverse order. A copy's hooks act
on its own levels through static functions, never an exported name another
copy could interpose.

Case 2e of sandbox_nested_libraries, pushed ahead of this, tests two
libraries; it passes on Windows and macOS either way, which do not
interpose.
…re it can be taken (#3464)

Reading the handled counts first did not end the stream test's early
returns (Linux / GCC, 7739d84: "the consumer got 4288 bytes of 5000"):
the handled count went up by one for each step a core made, whether the
step took a message or not. A step of a dead actor takes nothing
(aether_step_safe returns at once; the finalize credits what waits), and a
core's drain loops, the own-core overflow flush, the inline send and an
ask's relief stepped and counted it anyway: an actor killed mid-drain, or
one another core took between a caller's look and its lock, put the handled
count past the sent count for good, and every later wait could return with
that many messages pending (found by review and by a diagnostic build: a
second wait after the early return found the stream finished). An actor
thread counted the same way.

aether_step_safe and scheduler_step_actor return 1 when the step took a
message (the callers hold the step lock, so a non-empty mailbox at the step
gives it one), and every caller counts that; a drain stops at a step that
took nothing. The timeout path counts what its step took, which a message
that came first is. scheduler_send_batch_flush counted its batch after it
enqueued it, as send_many did; it counts before. The handled credits that
were relaxed are release, as the wait's reads are acquire.

The stream test under stress here (three loops of 1000): 10 early returns in
3000 runs at b3b4616, 0 with this.
…ording says what happens (#3463)

runtime/examples/multicore_bench.c and workstealing_bench.c pose main as
core 0 and called scheduler_wait() after scheduler_stop(): now a refusal
with no frame, so an abort (found by review; CI only compiles them). Main
leaves the core role first. The comments, the docs and the changelog now
say that a drop run outside any frame ends the process with the reason,
and that on the cooperative scheduler a drop or a death hook after a step
still waits nested and returns. test_wait_inside_actor reads whether the
hook ran after the wait returns, which it does only after the hook, not
against a six-second clock.
…blocks (#3438)

A copy past the registry's eight places kept its levels through a panic,
and a trusted call's barrier a panic left let the rest of a block skip that
block's grants: a widening (found by review), and a copy that failed never
tried again. The registry holds 64 copies now; one that finds no place
refuses every check while this thread has a level, until a push finds a
place (each push tries). A step puts aside the levels of at most eight
copies at once (AETHER_STEP_SANDBOX_COPIES), so the step's stack holds what
it did; a ninth copy with levels on the thread at once keeps them for the
step, refused more, never less.
…ound by their head (#3445)

A review of 65cec34 found:
- the walk stopped at the asking copy's own image only if it read its own
  record; a record it could not read (below) let it go on into images a
  load in progress on another thread had mapped and not relocated. The
  walk now ends at the image that holds the copy's record, read or not, on
  every format;
- records were found by walking the notes by the segment's alignment, which
  gold (notes of both alignments in one PT_NOTE) or a PT_NOTE starting 4
  past an 8-aligned place (after .interp) put out of step; a record is now
  found by its 24 exact head bytes at any 4-aligned place, and what it
  names must still lie inside its image;
- Windows read the module list with K32EnumProcessModules, which takes no
  lock and fails or skips while another thread loads or unloads a module,
  and a failure was taken as "this copy owns the state" (the #3445 escape),
  with a silent cap of 512 modules; the list is now a Toolhelp snapshot,
  taken under the loader's lock, retried while it says the list changed,
  with no cap;
- macOS reads dyld's list by index without its lock, so an image unloaded
  during the walk shifted the rest past the owner; the walk is made again
  while the image count changes under it.
The docs name where records are not read (Cygwin, illumos, DragonFly) and
that a dlmopen namespace finds no copy outside it.
The check failed about once in 1500 runs ("streams grew the heap by 3726
bytes in every run", one run's two actor blocks): a parked core holds the
reclaim of released actors back until it wakes, so a run's spawns could
take new blocks, and the high-water mark that sets could rise in every one
of the five windows steady_growth compares. Bounded, a leak it is not. Each
run now reclaims its released actors before the next spawns, so a block is
reused every time and a window settles; a block that never became reusable
would still grow every run. Under stress here: 2 such failures in 3000 runs
at b3b4616, 0 in 3000 with this and #3464's fix.
…he alert is not lost to a reset (#3465)

The close after a failure closed the socket with what the peer sent after
(the rest of a record too long to read) still unread. That close ends with
a TCP reset, and a peer that got the reset before it read could discard
the alert just sent: Windows UCRT64 CI saw a bare close in
crypto_tls13_peer. The client and the server now drain first, with
std.tcp's close_drained (the bounded pattern #3368 used for a failed
handshake, moved into std.tcp so both sides share it); the server's
library entry for a failed handshake drains too.

The peer test sleeps after sending the oversized record, so without the
drain it fails every run, not by timing; with it every TLS test passes.
@nicolas-maman
nicolas-maman marked this pull request as ready for review October 29, 2026 16:25
check-docs failed on every leg of 5883be2: the stdlib index still gave
std.tcp 42 exports, and close_drained made 43. The guide now says what
close_drained does and lists it with the other exports.
@nicolas-maman
nicolas-maman merged commit d331474 into main Oct 29, 2026
45 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment