Skip to content

Every open bug, batch 5: clause sets, branch locals, closures, module scope, and a fair benchmark suite - #3085

Merged
nicolas-maman merged 19 commits into
mainfrom
fix/oct21-batch5
Oct 21, 2026
Merged

nicolas-maman merged 19 commits into
mainfrom
fix/oct21-batch5

Conversation

@nicolas-maman

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

Copy link
Copy Markdown
Collaborator

Every open bug, batch 5, after #3066 and v0.838.0, and a fairness overhaul of the cross-language benchmark suite. One commit per issue, each with a test that fails before it, the docs it makes stale updated in the same commit, and a changelog fragment; every memory and ownership fix follows the ownership model's R1 to R4. Draft until every issue below is on this branch and a review round comes back clean.

Every open bug, and where it stands

Issue What Status
#3064 (follow-up) the early pass joined a branch-bound local once per binding: 6000 re-bindings took 6.6 s on the branch: the join is kept as the walk goes, 0.12 s; checked against a full walk at 391,375 bindings in 1,472 files
#3070 a written integer type in one branch was dropped when a sibling branch bound the name (x = -1 beside uint64 x = 7 was an int) on the branch: a written type is the variable's in either order, and a value it does not hold is refused at its binding. Follow-up on the branch: a string beside a written int compiled and crashed; a written type of any kind is now the variable's
#3063 (follow-ups) a none, a null and a string clause together were refused with a misleading message; a set whose only value clause recurses never settled on the branch (two commits); a refused set was also reported twice
#3084 since 0.837 a program's top-level function shadows a module's own import of the same name inside that module (breaks ae3d) on the branch: a function beside a module's namespace no longer hides it (the namespace check took any symbol of the name, functions included; 0.837's bare-ptr rule only made that an error)
#3077 null in a fn? slot, fn? == fn? and a null tuple part pass the checker and fail in C (follow-up of #3067) on the branch: null is the empty closure in every closure slot, an optional's included; fn? == fn? refused like two closures. Follow-up on the branch: an unannotated tuple local that owns nothing ((null, 1), (1.5, true)) had its C type declared after its use; every tuple type is now declared ahead
#3078 a fn? returned by a call leaks its closure's environment when the caller only tests it on the branch: a fn? a call returns is given back when nothing binds it (six shapes, 1600 bytes per 100 calls to 0)
#3079 @keep fn is decided by function names (a qualified collections.list_add missed, a program's own list_add refused) queued (batch A)
#3082 a literal clause beside a written narrower result type is refused queued (batch A)
#3080 a program function named like a std C symbol compiles and fails in C queued (batch A)
#3081 ae fmt rewrites make([]fn, 2) as make([] fn, 2) queued (batch A)
#3083 hoisting is quadratic in the number of names in the checker and codegen queued (batch A)
#3076 make benchmark could not run: the Aether entries never linked, the median was the middle run, Linux memory read 0, Windows could not run it on the branch, with the fairness overhaul below
#3087 Aether's fork-join, thread ring and skynet are bimodal run to run on a 24-thread hybrid CPU, 10x apart (found by the benchmark work) on the branch: a parked core is woken for every batched and overflow send, a seq_cst fence pair closes the park and wake race, and Windows keeps scheduler threads on their CPUs by the ideal-processor hint; fork-join's spread went from 98.7% to 5-7%. Its timing test is now a C test of the wake itself (the wall-clock one failed on CI runners); the first push's macOS leaks timeout was a loaded runner (programs without actors slowed alike)
#3088 a module's body sees the program's functions and types it never imported (the root cause of #3084), and a module's own function hides its import of the same name on the branch: a module's body resolves its own declarations and imports, never the program's; its own function no longer renames the base of ns.member when it imports ns
#3090 the scheduler's migration never converges a thread ring (the docs say it does): all 100 nodes on one core ran about 7x faster in one trial in progress (batch C): place a woken actor on its sender's core at send time, with a per-actor in-flight count so no message is reordered; a per-pair ordering stress test first
#3091 calling an empty closure (a null fn, or a fn? holding null) crashes with no message in progress (batch A): a named panic at the call, catchable
#3092 arr.length passes the checker and fails in C queued (batch A)
#3095 on Windows a scheduler park deadline over 2147 ms overflowed a 32-bit long (found writing the #3087 test) on the branch: computed in 64 bits; two more such overflows in std.net and the HTTP middleware in progress (batch C)
#3096 on Windows the HTTP server, proxy, middleware and event loop kept time and sums in a 32-bit long: the proxy clock went negative after 24.8 days (timeouts stopped), a cache TTL over 24.8 days expired at once, metric sums overflowed in minutes on a busy server, a Content-Length over 2 GiB read short on the branch: 64-bit throughout
#3097 C functions bound as Aether long (64-bit) defined with a 32-bit Windows long (Lua integers truncated) queued (batch C), with a check that compares each extern with its C definition
#3098 a call through a typed function pointer holding null crashes queued (batch A)
#3099 fs[1]() is refused as "Undefined function '?'" queued (batch A)
#3094 an actor state named like a runtime actor field fails in C queued (batch C)
#3093 messages from one sender arrived out of order when the target migrated (31k-48k of 3.2M) in progress (batch C), with #3090: 0 out of order; the ring 8.2x faster on 1/71 of the CPU, fork-join 31% faster, ping-pong and counting unchanged
#3089 the hard CPU pinning on Linux should be measured under background load open: needs Linux hardware; not in this batch
#3086 design: should a borrowed ptr put into a container be E0902 (std declaring its item parameters @keep)? owner's decision; not in this batch (#3079 keeps the holders-only rule and resolves containers by callee)
#2371, #2410, #2562 driver bugs (Khronos validation layer, lavapipe) Aether side done in #3019; stay open until the upstream reports are filed

Benchmark fairness (#3076)

The goal: each language measured at what its own standard library provides, written with the techniques an expert would use, no frameworks, so the suite adds no unfairness of its own.

  • Methodology (on the branch): true median with min, max and CV; CPU time per run and throughput per CPU-second beside wall throughput, so a runtime that spins is not credited for it; the same warmup for every language; every language built only through its own build file; every result's checksum verified, a wrong one rejected; toolchain versions and flags recorded; memory shown as n/a where it cannot be measured. Checksums exposed unequal work: Scala's fork-join sent every value to all 8 workers, Rust and Pony sent N rather than 8 x (N/8), Scala's thread ring passed the token past 0; all now do the same work.
  • Build parity (on the branch): -O3, native CPU and whole-program optimisation everywhere the toolchain has them (C++ had no LTO, Rust no native CPU).
  • Technique review, all 11 languages x 5 patterns (on the branch): each language's technique is fixed by the shape of the work (units against cores, one sender per mailbox), the same in every pattern, never picked per pattern from results. C, C++, Rust and Zig share one mailbox, a ring of sequence-numbered slots on their standard atomics, spinning then sleeping where the units fit the cores and sleeping at once where they do not; Java and Scala use platform threads with LinkedTransferQueue, virtual threads for the ring. Ping-pong went from 0.20 to 7.50 M msg/s for C, fork-join from 0.81 to 33.62; Java's ping-pong from 0.19 to 3.37. Every alternative measured is listed. Zig ported to 0.16; Java and Scala printed rates in the machine's locale and the runner read none.
  • FAIRNESS.md (on the branch): rules 7 to 11 (own build files, checked results, the same warmup, technique by shape, wait strategy with CPU time), a techniques table, and the October 2026 audit with every finding and its measured effect. The README, config.md and docs/performance-benchmarks.md were stale (Akka, a Python step, a config file nothing read) and are rewritten.
  • CI (on the branch): a smoke job that builds and checks all eleven languages when the suite changes and every Monday (a break from compiler or runtime changes shows within a week), and a full run by hand that uploads the results.

Then one review round of the whole branch and a full local gate (build, make test, strict C11 sweep, check-docs, full sweep, ownership grid) on the final head.

Closes #3070
Closes #3084
Closes #3076
Closes #3077
Closes #3078
Closes #3088
Closes #3087
Closes #3095
Closes #3096

…y, and serves its files

Found running `make benchmark` on Windows (MSYS2 MINGW64) at df5540e:

- Every platform: the Aether benchmarks did not link. The generated C
  calls std (string_retain, string_release, aether_write_bytes) and the
  bench Makefile compiled the runtime sources alone, so every Aether
  entry was "[SKIP] Build failed" and the runner stopped. It links the
  rest through `ae cflags --libs`, as it takes its includes from
  `ae cflags --cflags`; the runtime is still compiled from source with
  the benchmark's flags, so only std members come from the archive.
- Every platform: parse_mem matched "maximum resident set size",
  macOS's spelling; GNU time -v writes "Maximum ...", so every Linux
  run recorded 0 MB.
- Windows: os_system and os_exec_raw hand a command to cmd.exe, and
  every command here is POSIX sh. On Windows they run through sh -c,
  argv-style through os.run_full (sh_system, sh_exec), chosen by
  select(windows: ...). The PATH prefix, `:`-separated POSIX paths, is
  not added there (it corrupted the first entry of a `;` PATH), and the
  CPU name comes from /proc/cpuinfo, which Linux and MSYS both have
  (MSYS has no lscpu).
- Windows: the visualization server read its files under `pwd`, an
  MSYS path (/d/...) no file read opens, so the page was a 404 and every
  result "No results yet". It reads them from its working directory,
  where `make benchmark` starts it.

Measured: make benchmark on an i7-13700K runs Aether, C, C++, Go and
Rust through all five patterns and serves the page; Java, Zig 0.16 (the
sources need an older Zig), Elixir, Erlang, Pony and Scala are skipped
for missing or mismatched toolchains.
…3064)

From C's review of bc4703b. The early inference pass gives a name bound
again in the same body the join of all its bindings (hoist_numeric_join),
and asked for it at every numeric re-binding with a walk of the whole
body, so a body bound n times cost n walks of it. A main with x = x + i
6000 times took 4.9 s to compile, 12000 times 24.7 s.

The answer is not the same at every binding, though: a declaration's type
is set once, the first time the walk types it, and a binding the walk has
not reached yet has no type, so the join at a binding is that of the
bindings typed so far (and of those written with a type). A join taken
once per name and kept gives an earlier, narrower answer for the rest:

    g(c: int, u: uint32) {
        if c > 1 { x = 0 } else { x = 1 }
        if c > 0 { x = -1 } else { x = u }
        return x       // a long; taken at the second binding, an int
    }

and g's result settles as an int, which the checker then refuses
("narrowing return from 'g'").

HoistJoins (hoist.c) gathers a body's bindings, by name in the order
join_walk meets them, with one walk of the body the first time the pass
types a declaration in it. The pass reports each declaration it types
(hoist_joins_typed), and a name's join is kept as the join of the
bindings typed so far, in order, with those not reached yet that already
have a number type (a written one), whose parts stand until the walk
reaches them. So each answer is the one the walk of the body gave, and is
taken again only when a binding it read is typed differently. A binding
typed out of that order, or twice, makes every answer for its name a fold
of that name's bindings alone. join_part and join_add are join_walk's
step, shared.

Measured, aetherc on one file under the memory cap, three runs each:
6000 re-bindings 4.9 s before, 0.12 s after; 3000 1.25 s and 0.08 s;
12000 24.7 s and 0.31 s. A main binding 3000 names in both branches of an
if each went from 5.0 s to 1.6 s; what remains there grows with the
square of the names too, and is the checker's and codegen's hoisting,
which walk the body per hoisted name (hoist_if_branch_candidates,
hoist_join_type), older than bc4703b.

Checked: with every kept join compared against a walk of the body
(hoist_numeric_join) at each ask, over every .ae file in std, contrib,
examples and tests (2269, one at a time under the memory cap, AETHER_HOME
the worktree), the 391375 answers in 1472 files are all the same.

Test: tests/regression/test_issue3064_join_typed_late.ae, on both C
paths: the program above and a local bound from it, each branch taken, a
uint32 past an int's range. It passes on 0.838.0 too; a join taken once
per name fails it, as above. The branch value, branch local, hoisting and
clause set regression tests (17, both C paths), the integration tests
hoisted_local_rebind, hoisted_local_scope, hoisted_local_zero_init,
order_hoist_call_statement, sibling_binding_type and
signed_unsigned_join, and make test (591) pass.

The walk per binding came with bc4703b and shipped in 0.838.0; the
fragment says so. On ed24f0b (0.838.0), with this machine shared, the
6000 re-bindings took 6.6 s before and 0.12 s after, three runs each.
With the two commits after it, every .ae file in std, contrib, examples
and tests and every ownership grid cell (2419, dumped from gen.ae) gives
aetherc's exit status, diagnostics and C as on 0.838.0, but the new
regression test of the third.
From C's review of bc4703b. A name first bound inside a branch or loop
body is one variable for the whole function, with the join of its
bindings, and a binding that wrote its type was joined like the others:

    if k > 0 { uint64 x = 7 } else { x = n }   // refused
    if k > 0 { x = n } else { uint64 x = 7 }   // an int; n = -1 printed -1
    if k > 0 { x = -1 } else { uint64 x = 7 }  // an int, -1
    if k > 0 { x = 1.5 } else { int x = 0 }    // a float
    if k > 0 { uint8 x = 7 } else { x = 300 }  // an int, 300

hoist_numeric_join (hoist.c) joined the written type with the others and,
for two no type holds, left the first binding's type, and the checker's
sibling check (typechecker.c, the binding of a hoisted local) looked only
at inferred bindings, joining a written local's type with each, so the
written type was kept or dropped by the order of the two.

A written type is the local's type now, and a value it does not hold is
the error:

- The join (JoinFold in hoist.c, for hoist_numeric_join, the early pass's
  HoistJoins and so codegen's hoisted declaration) is the first written
  number type when a binding writes one, whatever the others are.
- declare_hoisted_local marks such a local (Symbol.hoist_written, from
  hoist_numeric_written). An inferred binding of it in another branch or
  loop body is not joined into it; a value it does not hold is refused
  there (report_unheld_binding): "cannot bind 'x' to this int value: it
  is written as uint64 in another branch or loop body of this function,
  which does not hold the value". ast_number_holds (ast.c) says what a
  written number type holds: an integer literal in its range (5 in a
  uint8, not 300 or -1 in a uint64), any other value that joins it to
  itself (an int in a long, a uint32 in a uint64, not an int in a uint64,
  a long in an int or a float in an int).
- A binding that writes another type is refused: "cannot write 'x' as
  long: it is written as int in another branch or loop body".

Both orders are reported at the binding the written type does not hold,
once. The reference's paragraph on locals bound in branches says so.

Test: tests/integration/written_type_sibling_branch: the five programs
above, two written types, a match's arms and a loop body, each refused
at its binding with the message, once; and a uint64 past a long's range,
a long past an int's and a uint8, beside a literal or an int in either
order, run whole. On 0.838.0 the first is refused with the old message
and the others compile. The branch value, branch local, hoisting and
clause set regression tests (17, both C paths), and the integration tests
hoisted_local_rebind, hoisted_local_scope, hoisted_local_zero_init,
order_hoist_call_statement, sibling_binding_type and
signed_unsigned_join pass.
From C's review. A clause set with a clause that returns only `none`, one
that returns only `null` and one that returns a string was refused:

    label(0) -> none
    label(1) -> null
    label(k) -> "k${k}"     // the clauses of 'label' return string?
                            // (line 1) and `null`, which string? does not
                            // take

The early inference pass joins the clauses while the `none` one has no
type: `null` takes the string beside it and `none` makes that a
`string?`, and every clause that gives the set's result is then typed
`string?` (settle_clause_sets). The checker joined the same clauses again
from those types: clause_gives_only_none (ast.c) asked for a clause with
no type, so the `none` clause was a `string?` clause; and the string
clause, typed `string?` too, made the join a `string?` even with the
`none` clause known, beside which `null` was refused. So the two joins
of one set disagreed.

- A `none` clause is known by its returns, an inferred result every value
  `return` of which gives `none`, as a `null` clause is, whatever type the
  early pass gave it (clause_set_join skips it among the value clauses).
- `null` takes the optional of a pointer, string or closure type beside
  it, as `return null` from a `-> string?` function gives the string `null`
  is (some, not `none`): the checker's join of the settled clauses is the
  early pass's again, `string?`, in any order of the clauses.

On the way: a set the join refuses was reported twice, once when the
checker settles the sets and again after a clause is checked
(typecheck_pending settles it again). TcClauses keeps that it was said.
That shipped in 0.837.0, and the refusal of the set above in 0.838.0, so
the fragment says both.

A `fn?` set takes the join too, but `return null` from a `-> fn?`
function is still refused ("'pick' returns closure?, but this value is
ptr"), the #3067 gap the review of 1954b8a reports; such a set is
refused there instead of at the join.

The reference's paragraph on clause sets says so.

Test: tests/regression/test_issue3063_clause_none_null.ae, on both C
paths: a string set with the `none` and `null` clauses first, one with
`null` first and `none` the catch-all, and a pointer set, each clause
called. On 0.838.0 each set is refused (3 errors).
tests/integration/clause_set_result_join gains none_null_int: `none`,
`null` and an int, refused naming the int's clause, once; on 0.838.0 it
is said twice. The clause set, #3056, #3061, #3063, #3064 and #3068
regression tests (17, both C paths), the integration tests
clause_set_import, clause_set_result_join, clause_set_scaling,
emit_lib_clause_sets, function_clause_string_pattern,
match_value_void_arm, missing_return, where_clause and
signed_unsigned_join, and make test (591) pass.
From C's review. A clause set of a `none` clause and one that only calls
the set again was never settled:

    last(0) -> none
    last(k) -> last(k - 1)

    main() { println("${last(3) ?? -1}") }   // `??` requires an optional

clause_set_join (ast.c) took `last(k)`, whose result is still unknown, for
a clause to be inferred and waited (CLAUSE_JOIN_WAIT). The early pass
gives such a set, waiting on itself, the join of the clauses known so far
(recursion_waits), and here there is none: the `none` clause has no type.
So the set was never settled, its calls kept the default int, and the
only error was the `??`, about no optional.

A clause whose result is inferred and every one of whose value `return`s
calls its own set (clause_only_recurses) gives the set's result and no
type of its own, as a `none` clause gives none: the join skips it, and it
takes the set's result (clause_takes_set_result). This set is then the
error a set of only `none` clauses is, CLAUSE_JOIN_NONE_ONLY, naming the
clause that calls the set: "every clause of 'last' that returns a value
returns `none`, an optional of no type, or calls 'last' again (line 2):
the set's result `T?` needs its `T`". Beside a clause that gives a type
(`count(1) -> 7`), the set is its optional, as it was through the
recursion's wait, and `gcd(a, 0) -> a` beside `gcd(a, b) -> gcd(b, a %
b)` is the first's type, now without waiting.

The reference's paragraph on clause sets says so.

Test: tests/integration/clause_set_result_join gains none_and_recursion
(the set above, refused with that message at line 1; on 0.838.0 only the
`??` is) and value_and_recursion (`none`, 7 and the recursion, run). The
clause set, #3056, #3061, #3063, #3064 and #3068 regression tests (17,
both C paths), the integration tests clause_set_import,
clause_set_scaling, emit_lib_clause_sets, function_clause_string_pattern,
match_value_void_arm, missing_return and where_clause, and make test
(591) pass.

Measured: every .ae file in std, contrib, examples and tests and every
ownership grid cell (2419, dumped from gen.ae), one at a time under the
memory cap with AETHER_HOME the worktree: aetherc's exit status,
diagnostics and C are those of 0.838.0 but for the new regression test
of the commit before.
…, and verified checksums

First of the cross-language suite's fairness changes (#3076). The runner
in benchmarks/cross-language/run_benchmarks.ae, rewritten in Aether as a
table of the eleven languages:

- The median is the median: the middle of the sorted runs (the mean of
  the two middle ones for an even count). It kept the run at index
  runs / 2 in the order the runs happened, so Aether ping-pong's 12.34,
  12.29, 12.58, 12.09, 12.62 M msg/s reported 12.58. Min, max and the
  coefficient of variation (the sample standard deviation over the mean;
  it was range / mean * 50) come with it.
- CPU time: the user + system time /usr/bin/time -v (or -l on macOS)
  reports for the whole process, per run, and a throughput per
  CPU-second beside the wall-clock throughput, in the JSON and on the
  page. A runtime that spins while it waits gets its wall-clock rate
  from cores it keeps busy; this column shows what each message cost.
- The same warmup for every language (1 by default, BENCH_WARMUP). Only
  the JVM and BEAM languages had one.
- Every language is built only through its own build file: `make
  <pattern>` in its directory, and the Makefiles call make, go build,
  cargo (the Cargo profile), zig build (build.zig), javac, erlc, ponyc
  or sbt. The runner passes no compiler flags; C++ was built with the
  runner's own g++ line, without the Makefile's -flto. New Makefiles for
  Go, Rust, Java, Elixir, Pony and Scala; Erlang's now builds all five
  patterns, and skynet runs without +P, which 1,111 processes do not
  need. Scala runs with `java -cp` and the classpath sbt exports, so its
  measurement no longer holds sbt's own JVM.
- Every run's result is verified. Each implementation prints
  `Checksum:` (ping-pong: the sum of the values ping got back; counting:
  the count; thread ring: the receipts summed over the 100 nodes;
  fork-join: the sum of the values the workers received; skynet: the
  sum), and skynet its `concurrency units:`. A run whose checksum or
  unit count is wrong, that exits non-zero or prints no rate is [FAIL],
  listed under "failed" in the JSON and on the page, and not measured;
  a wrong Aether stops the run as a missing one did.
- The toolchain version and the build flags (from each build file's
  `flags` target) are recorded in the JSON and shown on the page.
- Memory and CPU time are n/a (null) where they cannot be measured: on
  Windows, MSYS's time sees only itself (0 s and about 5 MB for every
  native program), and anywhere /usr/bin/time is missing.
- BENCH_LANGS and BENCH_PATTERNS select a subset.

Making every result checkable changed some implementations:

- The fork-join workers in C, C++, Go, Rust, Java, Elixir, Pony and
  Scala now add up the values they receive, as Aether's and Erlang's
  did: one addition per message everywhere (rule 3). Scala sent value i
  to every worker for round i; it sends the round-robin index, as the
  others do. Rust and Pony sent all N messages, not the equal share
  8 * (N / 8) every other language sends.
- The thread rings report every node's receipts. Scala's token went on
  round the ring after reaching 0, up to 99 messages more than its rate
  divided by; it stops at 0 now, as the others do, and divides by
  hops + 1.
- A rate is always printed: skynet and Aether's patterns printed none
  when the run took under a microsecond, which a small smoke run does.

Tests: benchmarks/cross-language/lib/benchstats holds the statistics,
the parsing of a run's output and of GNU and BSD time, and the expected
checksums; test_benchstats.ae checks them (the median of the #3076 runs,
an even count, stddev and cv, fixed2, missing values as -1, the
checksums and units), and tests/integration/bench_runner_stats runs it
and type-checks the runner. It replaces tests/regression/
test_bench_cv_overflow.ae, which copied the old formula rather than
testing the runner's. A pass with BENCHMARK_MESSAGES=10000 and two runs
on Windows: Aether, C, C++, Go and Rust verify every checksum on all five
patterns; Java, Erlang, Elixir, Pony and Scala are not installed here and
are skipped (CI builds them), and Zig does not build with Zig 0.16 yet
(the port is a later commit). make test: 591 passed.
…U, whole-program

Second of the cross-language suite's fairness changes (#3076): build
parity. With the runner building only through each language's build file
(the previous commit), these are the settings each one uses.

- C, C++ and Aether: -O3 -march=native -flto -fno-plt, in their
  Makefiles. C++ was built with the runner's own g++ line, without
  -flto; it now gets its Makefile's.
- Rust: Cargo.toml's release profile already had lto = "fat" and
  codegen-units = 1; rust/.cargo/config.toml adds -C target-cpu=native,
  the -march=native the C column had and Rust did not.
- Zig: build.zig's target defaults to the native CPU and the Makefile
  builds ReleaseFast; Zig compiles a program as one unit, so there is no
  LTO switch to add. Said in build.zig and the Makefile.
- Go: `go build` is its release build (optimised, inlined, whole
  program). GOAMD64, Go's instruction-set level, is its nearest
  equivalent of -march=native and stays at the default; the timing
  commit records what raising it measures.
- Java, Scala, Erlang and Elixir compile to bytecode that the JIT
  compiles for the host CPU, and every language now gets the same warmup
  run; Pony's ponyc builds a release for the host CPU by default. Their
  Makefiles say so, and each build file's `flags` target is what the
  results record.
ae3d is blocked on it. A program that defines a function of the name a
module it uses imports failed in that module since 0.837.0:

    // main.ae                    // lib/pkg/user/module.ae
    import pkg.user               import pkg.frame
    frame() -> ptr { ... }        sets() -> int { return frame.SETS }

    lib/pkg/user/module.ae:8:17: Type 'ptr' has no field 'SETS': a bare
    `ptr` points at nothing the checker knows

The member access takes `ns.name` for a module's constant only when no
symbol of the name is in scope, so that a value of the name (a local, a
parameter, a global) is read as the value (module_name_local_shadow).
lookup_symbol finds functions too, and a module's merged code sees the
program's functions, so in pkg.user `frame` was the program's `frame()`
and `frame.SETS` a field of its `ptr` result. That was always so; what
changed in 0.837.0 is 979d6bc (#3048), which refuses a field of a bare
`ptr`, where before the access went through unchecked. The program's own
`frame.SETS` beside its `frame()` failed the same way.

A function has no fields, so it hides no namespace: value_hides_namespace
(typechecker.c) asks for a symbol that is no function and no module alias,
at the three places that ask whether a name hides a namespace (the
constant access in typecheck_expression, the export check in infer_type,
and the constant read in the value-type helper). A module's code reads its
own import's `frame.SETS`, and the program's does too; a local of the name
still hides the namespace.

The reference's section on local module imports says so.

Test: tests/integration/module_namespace_beside_program_fn, the issue's
layout: pkg.user reads frame.SETS and calls frame.make_eye and frame.width
beside the program's `frame()`, and main.ae does the same, then calls
`frame()`. On ca6b1f7 it fails with three of the errors above (two in
pkg.user, one in main.ae). The module scoping integration tests
(module_name_local_shadow, program_global_not_in_module_scope,
local_shadows_module_extern, builder_local_shadows_module_fn,
closure_param_shadows_module_fn, fn_param_shadows_consumer_fn,
self_shadowing_import, module_import_not_transitive, namespace_basic,
selective_import_shadow, module_leaf_collision, module_scoped_structs,
module_reexport, many_namespaces_qualified_call and
transitive_module_import) and make test (591) pass.
…e work

Third of the cross-language suite's fairness changes (#3076): a review of
all eleven languages times five patterns, Aether's own included. The rule,
fixed per language from the shape of each pattern before measuring, and
the same code in every pattern of a language:

- C, C++, Rust and Zig: every mailbox here has one sender and one receiver,
  so each uses one ring for that shape, built on its standard library's
  atomics: a sequence number in each slot (Vyukov's bounded queue for one
  sender and one receiver), so neither side reads the other's index and a
  message moves one cache line. Where the units fit the cores (ping-pong,
  counting, fork-join) a receiver spins up to 1000 pauses, looking again
  after 1, 2, 4, ... 64 of them, then sleeps; where they outnumber the cores
  (the 100-thread ring) it sleeps at once, and skynet's 1,111 threads join
  their children, which blocks at once. C and C++ waited on a mutex and a
  condition variable for every message; Rust used std's mpsc channels; Zig
  did not build with Zig 0.16 at all (std.Thread.Mutex, std.posix.getenv
  and GeneralPurposeAllocator are gone) and is ported to std.Io.
- Java and Scala: platform threads with LinkedTransferQueue, which spins
  briefly before it parks, where the units fit the cores; virtual threads
  with ArrayBlockingQueue, which parks at once, for the ring; ForkJoinPool
  tasks for skynet, as before. Every queue was ArrayBlockingQueue on a
  platform thread.
- Go, Erlang, Elixir, Pony and Aether: the runtime's own units and
  mailboxes, which have no such choice; each runtime spins before it parks.

Measured on Windows (i7-13700K, 24 logical cores, the editor's indexer
keeping about one busy), 1M messages, median of 5 runs and range, M msg/s:

           ping-pong       counting        thread ring    fork-join
C          0.20 ->  7.50   15.09 -> 57.23  0.38 -> 0.38   0.81 -> 33.62
C++        0.20 ->  7.21   17.90 -> 94.11  0.36 -> 0.38   0.79 -> 32.37
Rust       2.02 ->  7.77   23.00 -> 54.65  0.34 -> 0.34   1.24 -> 35.52
Zig        none ->  7.34    none -> 58.09  none -> 0.42   none -> 34.57
Java       0.19 ->  3.37    9.31 ->  9.51  0.36 -> 1.74   0.82 -> 10.60

(Before: the first batch of runs; after: the third, with the old Java and
Rust's and Zig's std channels in the same rounds as anchors. C's CPU time
per ping-pong run fell from 3.73 s to 0.27 s and per fork-join run from
2.31 s to 0.28 s: the old code paid a sleep and a wake-up per message.)

Every alternative measured is listed in FAIRNESS.md: the spin bounds (100,
1000, 10000), C++20 atomic wait (slower than the condition variable in every
pattern), the index ring with and without backoff, Rust's channel and
sync_channel (sync_channel leads counting at 92, and falls to 2.3 in
fork-join), Zig's std.Io.Queue (0.21 in ping-pong), Java's
ArrayBlockingQueue, LinkedBlockingQueue and virtual threads everywhere, Go
unbuffered and with 16k-slot buffers.

Also, found in the review:

- The clock starts before the units are created in every pattern (rule 5):
  counting, thread ring and fork-join created them first in Aether, C,
  C++, Go, Java, Zig, Erlang, Elixir and Pony.
- Java and Scala printed their rates in the default locale: "2,38" where a
  locale uses a decimal comma, which the runner reads as no rate. They
  print with Locale.ROOT.
- Aether's fork-join picked the worker with a division and eight
  comparisons per message; it indexes an array with i % 8 like the others.
  Aether's skynet sent every unit a Setup message with its own ref before
  its work, two messages per unit where the others send one; a node passes
  `self` as the parent now.
- Elixir's counting sent with Enum.each, an anonymous function call per
  message; it is a recursive loop, as in Erlang and in its own fork-join.
- Rust's skynet gathered its children's sums over an mpsc channel; it joins
  them, as C, C++ and Zig do (0.04 M units/s either way).
- Zig's skynet joined an undefined thread when a spawn failed; a failed
  spawn is a failed run now. Zig prints to stdout.
- Java's and Scala's skynet printed no rate under a microsecond. Java's
  thread ring had 8-message mailboxes for its one token; 1 now, as in
  Scala and everywhere else.
- Go keeps buffered channels of 1 and 1024 slots (unbuffered and 16k
  measured the same).

Java's and Scala's choices were measured with a portable Temurin 21; Scala
follows Java's, which it calls through the same java.util.concurrent.
Erlang, Elixir and Pony are not installed here: their changes are the
timed region and Elixir's send loop, reviewed by reading, and the CI job
of a later commit builds and runs them. Aether's thread ring and fork-join
measured bimodal on this machine (fork-join 1.53 to 21.51 M msg/s within
five runs), whatever the benchmark code: aether#3087. make test: 591
passed.
…native measured

Fourth of the cross-language suite's fairness changes (#3076), the
written record of the first three.

FAIRNESS.md keeps its six rules and adds five: each language built by its
own build file at its release level for the host CPU (7), every result
checked (8), the same warmup for every language (9), the technique an
expert would use, fixed by the shape of the work and the same in every
pattern of a language, never picked pattern by pattern from the results
(10), and each implementation's wait strategy written down beside the CPU
time it costs (11). A techniques table gives each language's units,
mailbox and way of waiting, and the rule behind its choice.

"Audit, October 2026" lists each finding with its measured effect: the
median that was the middle run in time order (Aether ping-pong's runs
reported 12.58 where the median is 12.34); the build flags that differed,
which made no difference five runs can see (C++ without -flto, Rust
without target-cpu=native, Go's GOAMD64=v3: all inside each other's
ranges); the warmup only the JVM and BEAM got; Scala measured under sbt;
the errors the checksums found (Scala's fork-join values and its ring
running past 0, Rust and Pony sending N fork-join messages); the wait
strategies, with a table per language of every alternative measured
(C and C++ went from 0.20 to 7.50 and 7.21 M round trips a second in
ping-pong and from 0.81 to 33.62 and 32.37 M msg/s in fork-join); what
reading the code found (the timed regions, Aether's fork-join and skynet
doing work no other language did, Elixir's send loop, Java's and Scala's
locale-dependent rates); Zig not building with 0.16; and Aether's thread
ring and fork-join running bimodal on this machine (aether#3087), which
leaves its own before and after there unreadable. It ends with where the
numbers stand, with each run's CPU time. The measurements: an
i7-13700K with 24 logical cores, the editor's indexer keeping about one
busy, 1M messages, one warmup and five interleaved runs.

The suite's README describes the methodology as it is now (median with
min, max and CV, the same warmup for all, CPU time per run and messages
per CPU-second, build files, checksums, the technique rule) and each
language's concurrency model; docs/performance-benchmarks.md did not
(Scala on Akka, "total tree nodes", a warmup for JIT languages only, a CV
of range * 50), and follows it. config.md described a Python step and a
benchmark_config.json that nothing has read since the runner moved to
Aether; it lists each language's toolchain and build file and the
runner's environment settings now, and the unused JSON file is gone.
…every week

Last of the cross-language suite's fairness changes (#3076). Nothing built
the suite in CI, so a language could break unseen: Zig had not compiled
since Zig 0.16, and a compiler or runtime change once turned every Aether
entry into a skip while the other ten kept reporting.

.github/workflows/benchmarks.yml, one job on ubuntu-24.04, kept cheap:

- Smoke, on a pull request or push that changes benchmarks/cross-language/
  (or the workflow), and every Monday: installs the eleven toolchains
  (apt for C, C++ and GNU time; setup-go, setup-java with Temurin 21,
  setup-sbt, erlef/setup-beam for Erlang and Elixir, setup-zig for 0.16,
  ponyup for Pony; Rust is on the image), builds Aether, and runs every
  language on every pattern once at 10,000 messages. The runner checks
  each checksum and skynet's unit count. The weekly run catches what no
  path filter cheaply does: breaks from outside the directory.
- Full, by hand (workflow_dispatch): the whole suite at 1,000,000
  messages and five runs (both inputs), the results JSON uploaded.
- When a run fails, the job summary names each language and pattern that
  broke and why; the results, build logs and that list are uploaded.

The runner gets BENCH_STRICT=1: a selected language that does not build,
or whose result is wrong, makes it exit 1 (without it a missing toolchain
stays a skip, as on a developer's machine). Every run writes what broke,
one "<language> <pattern>: <reason>" per line, to build/problems.txt, which
the job's summary step reads. `make benchmark-results` runs the suite
without the dashboard, and `make benchmark` is it plus the dashboard.

FAIRNESS.md and config.md describe the job and BENCH_STRICT.

Checked here: BENCH_STRICT=1 with Pony selected and not installed exits 1
and lists "Pony ping_pong: did not build (build/pony_ping_pong.log)", the
same run without it exits 0; a run of every pattern at 10,000 messages
verifies Aether, C, C++, Go, Rust, Java and Zig.
tests/integration/bench_runner_stats passes; make test: 591 passed.
From the review of #3067. `null` is the empty closure where a value goes
into a closure slot (#3062, #3067), but these slots never gave it the
closure's type, so it reached C as `NULL` or the checker refused it:

    let o: fn? = null            // _aether_closure_retain(NULL)
    o = null; s.cb = null        // a fn? local and field, re-bound
    Slot { cb: null }; take(null)    // a fn? field and parameter
    nothing() -> fn? { return null } // returns closure?, but this is ptr
    [held, null]                 // held: fn?, "cannot hold"
    let two: fn[2] = [null, null]    // all null: an array of ptr
    let t: (fn, int) = (null, 1); first((null, 2))
    hook = null                  // var hook: fn?, in a function
    o ?? null                    // type mismatch in conditional
    o == null                    // `.val == NULL` on the closure

and `o == q` between two `fn?`, or a `fn?` and a closure, passed the
checker and reached C as `==` on the closures they hold.

- slice_coerce_slot (slice_coerce.c) gives `null` the empty closure in an
  optional of a closure, as a `ptr?` given `null` holds a null pointer,
  and goes into an array literal of closures or `fn?` element by element
  and into a tuple literal part by part, each by its own slot's rule.
- ast_type_takes_null (ast.c) takes a `fn?`, so `return null` from `-> fn?`
  and a `null` element beside `fn?` ones fit, as they do beside closures.
- The `==` refusal (typecheck_binary_expression) takes a `fn?` operand for
  the closure it holds; against `null` and `none` it is allowed.
- `hook = null`, the re-bind of a module-level closure or `fn?` var from a
  function, and the default of `??`, a slot of the value type, go through
  slice_coerce_slot.
- Codegen: `o == null` on a `fn?` is `o == some(null)`: present, holding
  the empty closure (`has && val.fn == 0`).

A `fn?` given `null` is not `none`, and `o == null` is true. The
reference's closure section and its paragraph on what a `return`
converts say so.

Test: tests/regression/test_issue3077_closure_null_slots.ae, on both C
paths: every slot above, the multi-value return and the module var
initializers beside them; on ca6b1f7 it is refused (2 errors) and,
without those two shapes, fails in C. tests/integration/
closure_compare_null gains two `fn?`, a `fn?` and a closure (refused),
and a `fn?` against `none` and `null` (run); on ca6b1f7 the first is
accepted and the last fails in C. 20 regression tests on closures,
optionals, tuples and `??` (both C paths), the integration tests
array_literal_element_type, closure_local_env_free,
closure_store_through_fn_coercion_no_uaf, fn_type_modes,
fn_typed_local_call, optional_compare_value, optional_field_access,
optional_owning_fields, keeps_borrowed, into_param and
closure_args_borrowed, and make test (591) pass.

Measured: every .ae file in std, contrib, examples and tests and every
ownership grid cell (2419, dumped from gen.ae), one at a time under the
memory cap with AETHER_HOME the worktree, against 4811e95e: aetherc's exit
status, diagnostics and C are the same for every file but the new
regression test.
A function returning `fn?` hands its caller the closure in it (R3). Bound
to a local, the local owned it and gave it back at its scope's end;
anywhere nothing bound the optional, nothing did:

    maybe(k) != none          // (maybe(k)).has
    maybe(k) == null          // _ae_ol = maybe(k), ...val.fn == 0
    match maybe(k) { some(c) -> ... none -> ... }
    c = maybe(k)!             // _aether_closure_retain(... .val)
    c = maybe(k) ?? null      // the same
    maybe(k)                  // as a statement

each leaked the closure's environment at every call, 16 bytes for one that
captures an int (1600 per 100 calls on a5db8413, each shape).

call_returns_owned_opt_closure (codegen_stmt.c) is the `fn?` counterpart
of call_returns_owned_closure: a call of anything but a C function, whose
result is a `fn?`, hands its closure over. The expression that reads such
a temporary gives the closure back:

- `== none`, `!= none` and `== null` read the flag (and the function
  pointer) from a temporary, then release the environment it holds, as an
  owned `string?` call's string is freed there (#2750);
- a `match` on the call: the `some` binding, or a hidden one under `_`,
  owns the closure for the arm, a composite local whose every exit drops
  it (push_own_drop), as a local bound to a fresh closure; an arm that
  returns or stores the binding hands it on as from such a local;
- `call!` and `call ?? d` give a fresh closure (is_owned_closure_unwrap,
  closure_value_is_fresh), so the place that takes it adopts it instead of
  retaining; the `??` default is taken as one of its own (adopted when
  fresh, retained otherwise, emit_closure_take);
- the call as a statement releases it, as a discarded closure is (#2507).

Test: tests/regression/test_issue3078_opt_closure_temp_release.ae, on both
C paths: each shape in a loop, an arm that returns from the function
through its binding, `??` with a closure default, checked by value and by
mem.steady_growth over rounds; on a5db8413 it fails (2240 bytes per
round). A match arm that returns its binding, and one that stores it in a
module var, run right with no growth (2550, 0 bytes; a5db8413 grows 1600).
The closure and optional regression tests (89, both C paths), the
integration tests closure_local_env_free, closure_args_borrowed,
closure_store_through_fn_coercion_no_uaf, keeps_borrowed,
optional_owning_fields, optional_compare_value, closure_compare_null,
array_literal_element_type, fn_type_modes and into_param, and make test
(591) pass.

Measured: every .ae file in std, contrib, examples and tests and every
ownership grid cell (2419), one at a time under the memory cap with
AETHER_HOME the worktree, against a5db8413: aetherc's exit status,
diagnostics and C are the same for every file but the new regression
test, so no grid cell changes.
…m's (#3088)

The root cause under #3084, from C's review of it. A module's merged code
resolved names the program declared:

    // main.ae                      // lib/pkg/user/module.ae
    import pkg.user                 leak() -> int { return helper() }
    helper() -> int { return 42 }   // printed 42: never imported

and a program's `struct frame { w: int }` broke pkg.user's `frame.SETS`
as its `frame()` did (#3084). lookup_symbol_in (typechecker.c) kept a
module's body out of the program's top-level `var`s and of the names its
imports bind, but not out of its functions and types.

- A merged body (merged_from) no longer resolves a file-scope symbol that
  is the program's own declaration (is_program_own_decl): a function, a
  builder, a struct, a sum, an enum, a bitstruct, an actor, a message or a
  constant written in the program's files, not merged from a module. Its
  own declarations are merged under its prefix, its imports' names were
  renamed to theirs, and builtins have no declaration, so what it sees is
  its own scope; a call of a program's function is "Undefined function".
  Beneath a program's declaration it finds what is its own: an older
  symbol of the name that is no program declaration, or else its own
  extern (std.io's `string_concat` beside a program function of that
  name, libaether_symbol_collision). A name the module declares itself
  stays its own: a struct identical to the program's is merged as that
  one type (#2129), and the module names it as its own (tls13_client's
  RecordIO when tls13_server is checked on its own).
- A function @derive makes for a module's struct is declared in that
  module (origin_module), so the module's body reaches it
  (derive_schema_cross_module's `Health_schema()`).
- The older shape the issue names: a module's own function named like its
  import was renamed into the base of `frame.SETS` by the merger (#235,
  rename_intra_module_refs, aether_module.c), `user_frame.SETS`. The base
  of `ns.member` is left alone when `ns` is a namespace the module imports
  and a function of the module's own, not a constant, a var or a local:
  a function has no fields, as the checker reads it.

value_hides_namespace (#3084) stays: within the program itself, a
function of an import's name (`frame()` beside `import pkg.frame`) still
must not hide the namespace in `frame.SETS`.

The reference's section on local module imports says so.

Test: tests/integration/module_body_own_scope: a program's struct of the
import's name beside pkg.user's `frame.SETS` and `frame.twice(1)`
(prints "5 4"), pkg.user's own `frame()` beside its import (prints "13"),
and pkg.user calling a program's `helper()`, refused at the call. On
ca6b1f7 all three fail. The module, namespace, import, shadowing, derive
and symbol collision integration tests (83, 2 skip with no java and ruby
here) and make test (591) pass.

Measured: every .ae file in std, contrib, examples and tests and every
ownership grid cell (2419), one at a time under the memory cap with
AETHER_HOME the worktree, against 6d1eb706: aetherc's exit status,
diagnostics and C are the same for every file (one line that differed was
a run cut short by a pause, the same three times over on its own).
…3070)

From C's review of 999acab (#3070). A local bound in several branches
took its written type only when that type was a number:

    if k > 0 { x = "s" } else { int x = 1 }   // crashed when k <= 0

compiled with `x` the string's `const char*`, and the int branch stored
`aether_uniform_heap_str((const char*)(1))`; the other order was refused.
join_part and fold_add (hoist.c) leave out a part that is no number, so
the written int never made the join, never made the local
hoist_written, and hoist_join_type kept the first binding's type.

- hoist_written_type (hoist.c, in place of hoist_numeric_written) gives
  the type the first binding that writes one writes, of any kind, and
  hoist_join_type returns it whatever the others are: a written type is
  the local's type, for codegen's hoisted declaration and the checker's.
- report_unheld_binding (typechecker.c) refuses a value the written type
  does not hold, a number by ast_number_holds, any other value when it is
  not assignable to it: "cannot bind 'x' to this string value: it is
  written as int in another branch or loop body of this function", in
  either order.

The reference's paragraph on locals bound in branches says so.

Test: tests/integration/written_type_sibling_branch gains the program
above in both orders, refused at the string binding. On b76c298a it runs
and crashes (access violation) in the first order. The written type
integration tests, the branch, hoist and clause regression tests (14,
both C paths), hoisted_local_rebind, hoisted_local_scope,
hoisted_local_zero_init, sibling_binding_type, signed_unsigned_join and
order_hoist_call_statement, and make test (591) pass.

Measured: every .ae file in std, contrib, examples and tests and every
ownership grid cell (2419), one at a time under the memory cap with
AETHER_HOME the worktree, against 5b55aa94: aetherc's exit status,
diagnostics and C are the same for every file (a grid cell that differed
once compiled the same three times over on its own).
…kept

The first smoke run of the benchmarks workflow (on 1fdfcf9) failed Scala
on all five patterns: "run 1 exited with status 1". Its Makefile wrote
classpath.txt as the last line of `sbt -batch -error "export Runtime /
fullClasspath"`. On CI sbt ends its output with a terminal escape (ESC[0J)
and no newline (the build log shows it after `compile`), so that last line
was the escape, and `java -cp "$(cat classpath.txt)"` found no class.

- scala/build.sbt: a writeClasspath task writes the runtime classpath (the
  compiled classes and the Scala library) to classpath.txt with IO.write,
  joined by the platform's path separator. The Makefile runs it once
  (`sbt -batch -error writeClasspath`, which compiles first), so nothing
  sbt prints is parsed.
- run_benchmarks.ae: a failed run's output goes to
  build/<language>_<pattern>_run.log, which the workflow already uploads
  (build/*.log), and the reason names it. This failure said only that the
  run exited 1; the next one will say why.

sbt is not installed here, so the Scala side is checked by the workflow
on this push. The runner change was built and run with a forced failure:
"run 1 exited with status 3 (build/c_ping_pong_run.log)", the log holding
what the command printed.
… threads on their CPUs by a hint (#3087, #3095)

Fork-join, thread ring and skynet ran bimodal on Windows (i7-13700K, 24
logical cores): fork-join 1.53 to 21.51 M msg/s in five runs. Counters
put in the scheduler for the diagnosis (parks, park timeouts, timeouts
that found work waiting, wake signals, the CPU main ran on) found two
causes.

Lost wakeups. A core that has spun idle parks on a condition variable
with a timed wait, and every enqueue onto it calls sched_wake to signal
it. Two enqueue paths did not:

- scheduler_send_batch_flush, which every `while` loop of sends in
  main() goes through (the codegen batches them): it enqueued onto each
  core and never told a parked one. A fork-join's workers, parked
  between two batches, slept until their timed waits ran out: in five
  runs, 8, 16, 131, 134 and 8 timeouts found work waiting, the slow runs
  the ones with main on a pinned worker's CPU, and no signal was sent in
  any of them.
- overflow_flush's queue drain onto another core.

Both wake the core now. And the wake itself could be missed: the
sleeper stores `parked` then reads its queues, the sender stores its
message then reads `parked`, each a store and a load the CPU may reorder
(x86 from its store buffer) under release and acquire alone. A
sequentially consistent fence on each side, as the actor-thread park
already has, means one of them sees the other. A missed wake cost up to
PARK_MS_MAX (32 ms), and on Windows, whose timed waits end on its 15.6 ms
tick, at least that.

Hard pinning on Windows. pin_to_core bound scheduler thread i to logical
CPU i with SetThreadAffinityMask (16 threads on CPUs 0 to 15, the P-cores'
hyperthreads). A thread bound to one CPU cannot leave it when another
program's thread runs there (the editor's indexer, a browser) and shares
it a time slice at a time, so a ring whose path crossed such a CPU ran at
a fraction of its speed, and a run fell into a fast or a slow mode by
where the other load landed: at 200,000 hops, pinned 0.52 to 0.80 M msg/s
and unpinned 2.24 to 2.38, with parking ruled out (no parks at all
measured the same). A thread now gets an ideal processor
(SetThreadIdealProcessor), a hint: Windows keeps it there and moves it
when the CPU is busy, as macOS's affinity tag lets it. Linux keeps its
hard binding; whether it has the same trouble under background load is
its own issue (#3089).

Measured, 1,000,000 messages, 1 warmup and 10 interleaved runs, the
editor's indexer and a browser keeping about 1.5 cores of 24 busy;
M msg/s, median (range), coefficient of variation:

                base                     wake fixes, pinned       wake fixes + hint
  fork-join     5.04 (1.05-21.81) 98.7%  27.06 (24.60-28.29) 4.6% 30.38 (24.83-31.89) 6.8%
  thread ring   3.02 (2.08-3.16) 14.9%   3.08 (2.48-3.35) 9.2%    2.85 (2.53-3.06) 6.4%
  skynet        1.66 (0.31-2.10) 47.7%   1.60 (0.89-1.95) 23.0%   1.78 (1.22-1.96) 14.8%
  ping-pong     11.83 (8.79-12.07) 9.2%  11.95 (10.21-12.34) 5.1% 12.29 (12.00-12.55) 1.6%
  counting      42.54 (41.88-42.74) 0.7% 42.47 (40.93-42.86) 1.4% 42.67 (42.19-43.01) 0.6%

Unpinned measured the same as the hint (fork-join 29.65, ring 2.97,
skynet 1.60, ping-pong 12.39). The ring stayed in its fast mode in all of
this window's runs, base included: its slow mode needs other load on a
pinned CPU of its path, which the 200,000-hop runs above caught. Skynet at
a million leaves is about half a millisecond of Aether's time, where a
single wake or time slice moves the rate; its spread narrows, not to
ping-pong's. The fences cost nothing measurable: ping-pong, all
cross-core sends, is at least as fast. Counting runs in main-thread mode
and is the control.

Test: tests/runtime/test_scheduler.c, "Scheduler batch send wakes parked
cores", a test of the mechanism, not of wall time. A first version timed
ten rounds of park-then-batch in an .ae program against 60 ms; CI runners
missed that bound with the fix in (63 ms on MSYS2, 205 on UCRT64, 1973 on
macOS x86_64), as a busy machine misses any bound a missed wake would
cost. Now two cores park with a timed wait of a minute
(scheduler_set_core_park_ms, a test setting beside
scheduler_set_actor_park_ms), and each of three rounds waits until both
are parked, then sends one message from the batch path to each of four
actors, two a core, and waits up to 10 s for each to be stepped. Fixed,
the test takes about 60 ms; with the flush's wake removed it fails every
time (3 of 3), no actor stepped until the minute ran out.

Writing it found #3095, fixed here: aether_park_deadline computed the
deadline as (long)ms * 1000000L, and `long` is 32 bits on Windows, so a
wait past 2147 ms overflowed. A minute came out 0.13 s in the past (the
wait returned at once) or, with a negative tv_nsec, weeks ahead: the
test passed with the wake removed in four runs of five and hung in the
fifth, and the actor-thread park test's minute had never been one on
Windows. It is computed in 64 bits now. The default parks, 1 to 32 ms,
were never affected.

Shutdown on macOS. 1fdfcf9's macOS leaks gate [1/2] hit its 25-minute
cap with a program and a leaks process left behind, which read as a
program that never exited. No program hung:

- The one left was test_std_mem_bulk_endian, which has no actors: the
  compiler emits scheduler_init and the rest only for a program that
  has them, so no scheduler thread ever ran in it. It had been started
  2.6 s before the cap (test_std_bytes_get passed at 21:20:07.6, the
  step was stopped at 21:20:10.2).
- Every program runs under a 120 s bound that kills its process group
  and reports a timeout; neither shard reported one, and all 272
  programs the shard reached passed.
- The shard was slow, not stuck: from 66 s into the gate to about
  1320 s, every program took 6 to 18 s instead of 3 to 4, those without
  actors as much as those with them (test_int64_type_name 2.7 s on
  54147c6, 13.8 s here), and the last two minutes ran at the old pace
  again. The [2/2] shard of the same run had such a window from 600 to
  900 s; the six leaks jobs of the three runs before it had none. The
  step that builds the compiler took the same time as on 54147c6 (59.5
  and 58.8 s). In the same run the macOS ARM64 test job never got a
  runner and was cancelled after 30 minutes. So the macOS ARM64 machines
  were loaded that hour.

Nor can the park and wake changes hang a shutdown. Every park is a timed
wait, at most 32 ms. scheduler_stop stores running = 0, then broadcasts
on each core's park_cond under park_mutex; a core parks only after
reading running under that mutex, so it either sees 0 and does not wait,
or is already waiting when the broadcast comes. sched_wake takes one
lock, the target's park_mutex, and no caller holds a lock the parked
side wants (it holds park_mutex alone and only reads its queues), so the
new calls from the batch flush and overflow_flush add no lock order. The
fences change only ordering.

The 34 actor, ask, scheduler and cooperative tests in tests/regression
and tests/syntax and make test (592) pass. The POSIX side compiles with
-Wall -Wextra and no warning (-fsyntax-only, MSYS2's gcc).
…3077)

`t = (null, 1)` then `a, b = t` failed in the C compiler with "unknown
type name '_tuple_ptr_int'", and so did `(p, 2)` with `p` a `ptr` and
`(1.5, true)`. A tuple type no signature names was given its typedef where
its destructure was generated, which is after the local's declaration
names it. A tuple that owns a string was spared only because the
composite pass (emit_own_ops) had declared its type already.

collect_tuple_typedefs walks the program before the function bodies, as
collect_optional_typedefs does for optionals, and declares every tuple
type a node has whose parts C can spell (tuple_parts_known: none unknown
or void, nested tuples included). ensure_tuple_typedef keeps each one
declared once, so the later calls find it.

Corpus and ownership grid sweep (every .ae file and the 2419 grid cells,
aetherc before and after): no status or diagnostic changes; the C changes
for three tests whose tuple typedefs moved up (test_closure_tuple_result,
test_issue3077_closure_null_slots, test_sum_owns_variants), each of which
passes on both C paths.

Test: tests/regression/test_issue3077_untyped_tuple_local.ae fails before
("unknown type name" and "invalid initializer") and passes after, on the
default and the AETHER_GCC_COMPAT=0 C paths.
…vent loop and test clocks (#3096)

A `long` is 32 bits on Windows (and on 32-bit targets), and clock
readings, stored times, duration sums and counters were kept in one. Found
while fixing #3095, the same overflow in the scheduler's park deadline.
The whole tree was searched for the pattern ((long) times 1000 or
1000000, long-typed ms/us/ns values and sums, GetTickCount64 or tv_sec
products into a long). Every instance is fixed here.

What broke, and what did not. The clocks are CLOCK_MONOTONIC, which on
Windows is time since boot. `(long)tv_sec * 1000...` overflows once
uptime passes the limit: undefined behaviour that GCC wraps, so the
difference of two readings mostly still came out right. The visible
failures are comparisons of absolute readings, durations longer than a
long holds, and sums:

- std/http/proxy: aether_proxy_now_ms returned (long)http_clock_ms(), so
  the reading went negative after 24.8 days of uptime and stayed so for
  as long again. In that time the event loop read every request deadline
  as none (`deadline_ms <= 0`), and upstream timeouts stopped firing. The
  cache compared expires_at_ms <= now across the wrap. It returns int64_t
  now, as do the breaker's open time, the per-upstream rate limit's
  refill time, the cache entry's stored and expiry times, the event
  loop's deadlines, and the elapsed times passed in.
- aether_proxy_cache.c: expires_at_ms = stored_at_ms + (long)ttl_sec *
  1000L overflowed for a TTL past 24.8 days, and the entry expired as it
  was stored.
- aether_http_server.c: the metrics' request-duration sum overflowed
  after 35.8 minutes of summed request time (minutes of traffic on a
  busy server) and the counters after 2^31. The proxy's latency sum
  overflowed after 24.8 days of summed latency (hours on a busy proxy),
  and its counters after 2^31. All are _Atomic int64_t now, printed with
  %lld.
- http_now_us: a request longer than 35.8 minutes (a long stream) got a
  wrapped duration. HttpRequestHook takes int64_t duration_us. It is a
  C-only extension point, not callable from Aether (docs/http-server.md
  now gives its signature). The access log's byte count is long long.
- aether_middleware.c monotonic_ms (rate limiter): a bucket idle for more
  than 24.8 days read a negative elapsed time and did not refill.

Same root cause, not time:

- aether_http_evloop.c parsed Content-Length with strtol into a long. A
  length past 2 GiB saturated to 2147483647 and the body was read short
  of what the client declared. It is strtoll now, with an ERANGE check,
  as the client side's parse.
- aether_proxy_direct_take compared (long)body_len against the cap.

Tests and tools: test_harness.c's get_time_ms returned GetTickCount64()
as a long, and the watchdog's start time was an atomic_long. The clocks
in test_scheduler.c, test_scheduler_stress.c and bench_zerocopy.c also
read into a long. contrib/avcodec's pts_ms was a (long) of a double. Its
API still returns an int.

Test: tests/runtime/test_http_server.c, http_proxy_cache_keeps_a_long_ttl
stores a response under a 30-day default TTL and looks it up. Fixed, the
entry is found with its full 30 days ahead. With the old expiry math put
back, it fails on Windows ("found"). The clock and sum fixes have no
practical test: they need weeks of uptime or minutes of summed traffic.

Checked: make test (593) passes. The POSIX side compiles with -Wall
-Wextra and no warning (MSYS2 gcc -fsyntax-only, 16 files). The
http_server_observability (access log JSON and Prometheus metrics) and
http_middleware_d1 (rate limit) integration tests were run on Windows
with their Windows skip removed, and both pass. http_reverse_proxy_pool
and _extra (cache, retry, rate limit, trace, metrics) pass in their
Windows-reduced form. contrib/avcodec is not built here (no FFmpeg).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment