Repository navigation
Batch 9, second half: the rest of the ten and every bug batch 9 found (draft until all land) - #3334
Merged
Merged
Conversation
…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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
u1*G + u2*Qverifyserver_set_tls_pem: a TLS server takes its chain and key as PEM textbytes.wipe, verify-before-send on server and client, hedged nonces,std.cryptography.shieldfor the server keystd.stream: credit-windowed streams between actorsBugs this PR closes
Every open bug, by area:
make check-crypto-wipeinmake ci), a TLS keep-alive connection held by a KeyUpdate on both backends (std.http: the pure TLS pending check counts a KeyUpdate as data, so a keep-alive connection is not parked #3405), and a pure TLS program that linked the HTTP server and client (A program importing the pure TLS stack links the HTTP server and client (does not link on macOS) #3426).replyand field access on actor refs, message emission order, stores into another actor's state (std.supervise: the supervisor's children are byte copies of the spec's, released when the spec is freed (use after free) #3386) and through pointers (codegen: stores through a pointer take references but do not publish them (#2919) #3398), and generated helpers clang warned of (Generated allocation helpers fail clang -Wall -Werror when unused #3425).make test-aeand an installed std,make compiler-fast,make install(make install cannot link: the release compiler's runtime list drifted from the dev compiler's #3418), the wasm runtime list and a wasm build that needed the native one (ae exits on a missing MANIFEST for commands that never read it (ae build --target=wasm, ae check) #3420), the embedded build (aether_http_server.c includes sys/ioctl.h outside the networking build (embedded fails) #3427), and racy, host-timed, OpenSSL-version-bound or leaking tests (accounts_active_time_once_when_nested leans on sleeps taking the time asked (fails on macOS) #3422, TLS tests assume OpenSSL 3.2+ or GNU openssl (fail on Ubuntu's 3.0 and macOS LibreSSL) #3429, Regression tests leak refs, closure boxes and a bignum (macOS leaks gate) #3430). http_proxy_send_timeout: the worker-path case intermittently receives every byte (macOS, Linux) #3338's cause was the macOS kernel absorbing about 100 MB for a stalled reader about 2 s in; the test now cuts before that (80 of 80 on macOS, 40 of 40 on Linux).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:
wait_for_idle()could still return while an arm that ended inside its quiet window had sent more, so the wait starts again when one ends there; an arm's ownwait_for_idle()waited for itself (new test, seen failing first: without the fix it hung past 90 s); and the count needed a fence before the disarm, a plain store, on AArch64 without LSE.net.fd_writeto a socket whose peer closed still raised SIGPIPE on Linux (the new check in test_tcp_write_to_closed_peer; the oldae_fd_writedied with SIGPIPE under a POSIX build).extern free, freeing a borrowed slice parameter or a view is now a compile error, as without it; the changelog says so, and slice_own and free_view check both.A sixth round, two fresh reviewers on the fifth round's fixes and on #3445, found more, fixed here:
wait_for_idle()inside an actor never returned (forever in a handler; round five's self-discount left arms waiting for each other). It now panics there on both schedulers, ending the actor with a reason that says to call it outside the actors (test seen failing first: hung until killed at 60 s).A seventh round, two more fresh reviewers on the sixth round's fixes, found more, fixed here:
wait_for_idle()returned with messages in flight between cores (std.stream on the UBSan leg): each core's counts were read in turn. Handled counts are read before sent counts, and a batch send counts before it enqueues.An eighth and last round, a review of the seventh round's fixes, a diagnostic build, and CI on b3b4616, found more, fixed here:
tcp.close_drainedin std.tcp (the pattern std.cryptography: pure TLS client closes with unread bytes after a failed handshake, so its alert is lost to a TCP reset #3368 used for a failed handshake). The peer test now fails every run without it, and every TLS test passes with it.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_chainalready 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 ciwithout its clean:-Werror; the C test runner, rebuilt and run bare: 649 of 649;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 whichextern free(p: ptr)madefreeof 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.tcpwrites 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 --libsis) 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_accountsearlyandin_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_drainedmade 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):
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-takenpanic()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
tryand 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