From 39277a0583f01a56fd3a7d41904b5f5992f8722b Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Sun, 1 Nov 2026 13:28:51 -0300 Subject: [PATCH 01/17] fix: compile time linear in the program again (#3499) The release's macOS x86_64 leg failed twice on order_effects_scaling (8000 chained functions in 22-25 s against a 20 s bound). Sampled with gdb, the compile was quadratic through four lookups by name that scanned the program per call or per top-level node: - typechecker effect_find_func and call_extern_decl (every call's declaration, from the check_frees and @keep walks): now answered from the checker's clause index, which also indexes externs; - codegen lookup_fnptr_global (every call): now in the program index; - codegen rename_locals_shadowing_functions: its NameSet was a list scanned per reference; now a StrMap, and externs by name; - module merge rewrite_import_prefixes: the program's imports were collected again for each top-level node; now once. 8000 functions: 957 -> 325 ms here; 16000: 3691 -> 651 ms; 32000: about 20 s -> 1.3 s. The test now compares 4000 and 16000 functions on the same runner (linear is 4x; main's compile was 13x, which fails it). --- compiler/aether_module.c | 53 +++++--- compiler/analysis/typechecker.c | 107 +++++++++------ compiler/codegen/codegen.c | 77 +++++------ compiler/codegen/codegen_internal.h | 2 + ...T120000Z-fixed-3499-compile-time-linear.md | 12 ++ .../test_order_effects_scaling.sh | 126 +++++++++++------- 6 files changed, 229 insertions(+), 148 deletions(-) create mode 100644 new_changelogs/20261101T120000Z-fixed-3499-compile-time-linear.md diff --git a/compiler/aether_module.c b/compiler/aether_module.c index 897e1f550..6efa2aa9a 100644 --- a/compiler/aether_module.c +++ b/compiler/aether_module.c @@ -3662,16 +3662,20 @@ static const char* import_written_prefix(ASTNode* imp) { * and `vg.region`, both renamed off "region" by module_assign_namespaces) * doesn't have the first one's rewrite blindly consume every "region.name" * in the body before the second's ever runs — see - * rewrite_qualified_prefix_ambiguous. */ -static void rewrite_import_prefixes(ASTNode* body, ASTNode* scope_ast, - const char* self_name) { - if (!body || !scope_ast) return; - + * rewrite_qualified_prefix_ambiguous. The imports are collected once per + * scope (#3499): collected again for each of a program's top-level nodes, + * the merge grew with the square of the program. */ #define REWRITE_MAX_IMPORTS 64 +typedef struct { const char* writtens[REWRITE_MAX_IMPORTS]; AetherModule* mods[REWRITE_MAX_IMPORTS]; - int n = 0; - for (int i = 0; i < scope_ast->child_count && n < REWRITE_MAX_IMPORTS; i++) { + int n; +} ImportPrefixes; + +static void import_prefixes_collect(ImportPrefixes* ip, ASTNode* scope_ast, + const char* self_name) { + ip->n = 0; + for (int i = 0; scope_ast && i < scope_ast->child_count && ip->n < REWRITE_MAX_IMPORTS; i++) { ASTNode* imp = scope_ast->children[i]; if (!imp || imp->type != AST_IMPORT_STATEMENT || !imp->value) continue; if (imp->annotation && strcmp(imp->annotation, "synthetic") == 0) continue; @@ -3679,31 +3683,42 @@ static void rewrite_import_prefixes(ASTNode* body, ASTNode* scope_ast, const char* ns = module_namespace_of(imp->value); if (strcmp(written, ns) == 0) continue; if (self_name && strcmp(written, module_last_segment(self_name)) == 0) continue; - writtens[n] = written; - mods[n] = module_find(imp->value); - n++; + ip->writtens[ip->n] = written; + ip->mods[ip->n] = module_find(imp->value); + ip->n++; } +} +static void import_prefixes_rewrite(const ImportPrefixes* ip, ASTNode* body) { + if (!body) return; int done[REWRITE_MAX_IMPORTS] = {0}; - for (int i = 0; i < n; i++) { + for (int i = 0; i < ip->n; i++) { if (done[i]) continue; AetherModule* candidates[REWRITE_MAX_IMPORTS]; int ncand = 0; - candidates[ncand++] = mods[i]; + candidates[ncand++] = ip->mods[i]; done[i] = 1; - for (int j = i + 1; j < n; j++) { - if (!done[j] && strcmp(writtens[i], writtens[j]) == 0) { - candidates[ncand++] = mods[j]; + for (int j = i + 1; j < ip->n; j++) { + if (!done[j] && strcmp(ip->writtens[i], ip->writtens[j]) == 0) { + candidates[ncand++] = ip->mods[j]; done[j] = 1; } } if (ncand == 1) { - if (mods[i]) rewrite_qualified_prefix(body, writtens[i], module_namespace_of(mods[i]->name), NULL, 0); + if (ip->mods[i]) rewrite_qualified_prefix(body, ip->writtens[i], module_namespace_of(ip->mods[i]->name), NULL, 0); } else { - rewrite_qualified_prefix_ambiguous(body, writtens[i], candidates, ncand, NULL, 0); + rewrite_qualified_prefix_ambiguous(body, ip->writtens[i], candidates, ncand, NULL, 0); } } +} #undef REWRITE_MAX_IMPORTS + +static void rewrite_import_prefixes(ASTNode* body, ASTNode* scope_ast, + const char* self_name) { + if (!body || !scope_ast) return; + ImportPrefixes ip; + import_prefixes_collect(&ip, scope_ast, self_name); + import_prefixes_rewrite(&ip, body); } /* The registered module whose parsed AST is `mod_ast`, or NULL. */ @@ -4421,10 +4436,12 @@ void module_merge_into_program(ASTNode* program) { /* #2209: the program's own code names its imports by alias or last * segment; point each at the imported module's namespace before any * module body is cloned in beside it. */ + ImportPrefixes prog_imports; + import_prefixes_collect(&prog_imports, program, NULL); for (int i = 0; i < program->child_count; i++) { ASTNode* child = program->children[i]; if (!child || child->type == AST_IMPORT_STATEMENT) continue; - rewrite_import_prefixes(child, program, NULL); + import_prefixes_rewrite(&prog_imports, child); } // Find insertion point: just before AST_MAIN_FUNCTION diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index d159dbe3b..f751e3640 100644 --- a/compiler/analysis/typechecker.c +++ b/compiler/analysis/typechecker.c @@ -6367,6 +6367,8 @@ static void order_const_declarations(ASTNode* program) { } static void tc_clauses_reset(void); +static ASTNode* tc_first_def(ASTNode* program, const char* name); +static ASTNode* tc_first_extern(ASTNode* program, const char* name, const char* merged); /* ---- qualified type names: `mod.T`, `*mod.T` --------------------------- * @@ -9855,13 +9857,7 @@ static ASTNode* call_extern_decl(ASTNode* program, const char* name) { for (size_t i = 0; i < n; i++) merged[i] = name[i] == '.' ? '_' : name[i]; merged[n] = '\0'; } - for (int c = 0; c < program->child_count; c++) { - ASTNode* e = program->children[c]; - if (!e || e->type != AST_EXTERN_FUNCTION || !e->value) continue; - if (!strcmp(e->value, name)) return e; - if (dotted && (e->is_imported || e->origin_module) && !strcmp(e->value, merged)) return e; - } - return NULL; + return tc_first_extern(program, name, dotted ? merged : NULL); } /* The declaration a call is checked against: an Aether function, or an @@ -11811,15 +11807,12 @@ static const char* effect_call_capability(const char* ns) { return NULL; } +/* The first definition named `name`, from the checker's index (#3499): a + * scan of the program per call made checking grow with the square of its + * size. */ static ASTNode* effect_find_func(ASTNode* program, const char* name) { if (!program || !name) return NULL; - for (int i = 0; i < program->child_count; i++) { - ASTNode* c = program->children[i]; - if ((c->type == AST_FUNCTION_DEFINITION || c->type == AST_BUILDER_FUNCTION) && - c->value && strcmp(c->value, name) == 0) - return c; - } - return NULL; + return tc_first_def(program, name); } /* The definition a call names, or NULL when the walk cannot see one (a @@ -19361,6 +19354,10 @@ typedef struct { } TcClauses; static StrMap g_tc_clauses; +/* #3499: the externs of the program by name, the first in program order, + * and of those a module brought, each as its position + 1. */ +static StrMap g_tc_externs; +static StrMap g_tc_imported_externs; static ASTNode* g_tc_clauses_program = NULL; static int g_tc_clauses_child_count = -1; @@ -19370,38 +19367,72 @@ static void tc_clauses_reset(void) { if (tc) { free(tc->nodes); free(tc); } } strmap_free(&g_tc_clauses); + strmap_free(&g_tc_externs); + strmap_free(&g_tc_imported_externs); g_tc_clauses_program = NULL; g_tc_clauses_child_count = -1; } +/* Index `program`'s definitions and externs, once per program shape: the + * checker adds top-level nodes (derived functions, a clause set's join) but + * renames and frees none, and an addition changes the count. */ +static void tc_decls_index(ASTNode* program) { + if (g_tc_clauses_program == program && g_tc_clauses_child_count == program->child_count) + return; + tc_clauses_reset(); + for (int i = 0; i < program->child_count; i++) { + ASTNode* c = program->children[i]; + if (c && c->value && c->type == AST_EXTERN_FUNCTION) { + void* at = (void*)(intptr_t)(i + 1); + if (!strmap_has(&g_tc_externs, c->value)) strmap_put(&g_tc_externs, c->value, at); + if ((c->is_imported || c->origin_module) && + !strmap_has(&g_tc_imported_externs, c->value)) + strmap_put(&g_tc_imported_externs, c->value, at); + continue; + } + if (!c || !c->value || + (c->type != AST_FUNCTION_DEFINITION && c->type != AST_BUILDER_FUNCTION)) continue; + TcClauses* tc = strmap_get(&g_tc_clauses, c->value); + if (!tc) { + tc = calloc(1, sizeof(TcClauses)); + if (!tc) continue; + strmap_put(&g_tc_clauses, c->value, tc); + } + if (tc->count == tc->cap) { + int cap = tc->cap ? tc->cap * 2 : 2; + ASTNode** nn = realloc(tc->nodes, sizeof(ASTNode*) * (size_t)cap); + if (!nn) continue; + tc->nodes = nn; + tc->cap = cap; + } + tc->nodes[tc->count++] = c; + } + g_tc_clauses_program = program; + g_tc_clauses_child_count = program->child_count; +} + +/* #3499: the first function or builder named `name` in `program`. */ +static ASTNode* tc_first_def(ASTNode* program, const char* name) { + tc_decls_index(program); + const TcClauses* tc = strmap_get(&g_tc_clauses, name); + return (tc && tc->count > 0) ? tc->nodes[0] : NULL; +} + +/* #3499: the first extern in `program` named `name`, or, when `merged` is + * given, a module's extern named `merged`, whichever comes first. */ +static ASTNode* tc_first_extern(ASTNode* program, const char* name, const char* merged) { + tc_decls_index(program); + intptr_t a = (intptr_t)strmap_get(&g_tc_externs, name); + intptr_t b = merged ? (intptr_t)strmap_get(&g_tc_imported_externs, merged) : 0; + intptr_t at = (a && b) ? (a < b ? a : b) : (a ? a : b); + return at ? program->children[at - 1] : NULL; +} + /* The clauses of the set `fn` is one of, or NULL for a single definition. */ static const TcClauses* tc_clauses_of(ASTNode* fn) { ASTNode* program = g_typecheck_program; if (!program || !fn || !fn->value) return NULL; - if (g_tc_clauses_program != program || g_tc_clauses_child_count != program->child_count) { - tc_clauses_reset(); - for (int i = 0; i < program->child_count; i++) { - ASTNode* c = program->children[i]; - if (!c || !c->value || - (c->type != AST_FUNCTION_DEFINITION && c->type != AST_BUILDER_FUNCTION)) continue; - TcClauses* tc = strmap_get(&g_tc_clauses, c->value); - if (!tc) { - tc = calloc(1, sizeof(TcClauses)); - if (!tc) continue; - strmap_put(&g_tc_clauses, c->value, tc); - } - if (tc->count == tc->cap) { - int cap = tc->cap ? tc->cap * 2 : 2; - ASTNode** nn = realloc(tc->nodes, sizeof(ASTNode*) * (size_t)cap); - if (!nn) continue; - tc->nodes = nn; - tc->cap = cap; - } - tc->nodes[tc->count++] = c; - } - g_tc_clauses_program = program; - g_tc_clauses_child_count = program->child_count; - } + tc_decls_index(program); const TcClauses* tc = strmap_get(&g_tc_clauses, fn->value); if (!tc || tc->count < 2) return NULL; for (int i = 0; i < tc->count; i++) diff --git a/compiler/codegen/codegen.c b/compiler/codegen/codegen.c index b87a90661..35deae9d6 100644 --- a/compiler/codegen/codegen.c +++ b/compiler/codegen/codegen.c @@ -833,16 +833,11 @@ Type* lookup_fnptr_local(CodeGenerator* gen, const char* name) { * on a `void*`), which C rejects. NULL when `name` is not such a global. */ Type* lookup_fnptr_global(CodeGenerator* gen, const char* name) { if (!gen || !gen->program || !name) return NULL; - for (int i = 0; i < gen->program->child_count; i++) { - ASTNode* c = gen->program->children[i]; - if (c && c->type == AST_EXPORT_STATEMENT && c->child_count > 0) c = c->children[0]; - if (!c || c->type != AST_CONST_DECLARATION || !c->value) continue; - if (!(c->annotation && strcmp(c->annotation, "global_var") == 0) && - !is_fnptr_const_decl(c)) continue; - if (strcmp(c->value, name) != 0) continue; - return is_fnptr_type(c->node_type) ? c->node_type : NULL; - } - return NULL; + /* From the program index (#3499): asked for every call, a scan of the + * program made codegen grow with the square of its size. */ + ProgramIndex* ix = program_index(gen->program); + ASTNode* c = ix ? strmap_get(&ix->fn_globals, name) : NULL; + return (c && is_fnptr_type(c->node_type)) ? c->node_type : NULL; } /* #2648: a module-level `const` of a typed fn-pointer type. It holds a @@ -1308,6 +1303,13 @@ static void program_index_build(ProgramIndex* ix, ASTNode* program) { for (int i = 0; i < program->child_count; i++) { ASTNode* c = program->children[i]; if (!c) continue; + /* #3499: the module `var`s and fn-pointer `const`s a call may go + * through (lookup_fnptr_global), the first of each name. */ + ASTNode* g = (c->type == AST_EXPORT_STATEMENT && c->child_count > 0) ? c->children[0] : c; + if (g && g->type == AST_CONST_DECLARATION && g->value && + ((g->annotation && strcmp(g->annotation, "global_var") == 0) || is_fnptr_const_decl(g)) && + !strmap_has(&ix->fn_globals, g->value)) + strmap_put(&ix->fn_globals, g->value, g); if ((c->type == AST_FUNCTION_DEFINITION || c->type == AST_BUILDER_FUNCTION) && c->value) { DefClauses* dc = strmap_get(&ix->defs, c->value); if (!dc) { @@ -1391,6 +1393,7 @@ void program_index_reset(void) { strmap_free(&ix->clause_scopes); strmap_free(&ix->def_children); strmap_free(&ix->def_sets); + strmap_free(&ix->fn_globals); ix->program = NULL; ix->child_count = 0; ix->main_fn = NULL; @@ -6374,30 +6377,22 @@ static void mangle_keyword_value_idents(ASTNode* program) { * a program can see. A write to a module variable or an actor's state is * not a local, and keeps its name. Both walks visit a node's children before * the node, so a binding's initializer comes before the binding. */ -typedef struct { - const char** names; /* interned (cg_intern), never freed one by one */ - int count, cap; -} NameSet; +/* A set of names in insertion order, hashed (#3499): a list scanned per + * reference made this pass grow with the square of the program, since + * every reference is checked against every function. */ +typedef StrMap NameSet; static int nameset_has(const NameSet* s, const char* name) { - for (int i = 0; name && i < s->count; i++) - if (strcmp(s->names[i], name) == 0) return 1; - return 0; + return name && strmap_has(s, name); } static void nameset_add(NameSet* s, const char* name) { - if (!name || !name[0] || nameset_has(s, name)) return; - if (s->count == s->cap) { - s->cap = s->cap ? s->cap * 2 : 16; - s->names = (const char**)aether_xrealloc((void*)s->names, (size_t)s->cap * sizeof(*s->names)); - } - s->names[s->count++] = cg_intern(name); + if (!name || !name[0] || strmap_has(s, name)) return; + strmap_put(s, name, NULL); } static void nameset_free(NameSet* s) { - free((void*)s->names); - s->names = NULL; - s->count = s->cap = 0; + strmap_free(s); } static int is_value_name_node(const ASTNode* n) { @@ -6416,33 +6411,23 @@ static int is_binding_node(const ASTNode* n) { * as the extern declarations below emit them). */ typedef struct { NameSet names; /* every function and extern, by its Aether name */ - ASTNode** externs; - int extern_count, extern_cap; + StrMap externs; /* the first extern of each name */ } ShadowFns; static void shadow_fns_add_extern(ShadowFns* f, ASTNode* ext) { if (!ext || !ext->value) return; nameset_add(&f->names, ext->value); - if (f->extern_count == f->extern_cap) { - f->extern_cap = f->extern_cap ? f->extern_cap * 2 : 32; - f->externs = (ASTNode**)aether_xrealloc((void*)f->externs, - (size_t)f->extern_cap * sizeof(*f->externs)); - } - f->externs[f->extern_count++] = ext; + if (!strmap_has(&f->externs, ext->value)) strmap_put(&f->externs, ext->value, ext); } static void shadow_fns_free(ShadowFns* f) { nameset_free(&f->names); - free((void*)f->externs); - f->externs = NULL; - f->extern_count = f->extern_cap = 0; + strmap_free(&f->externs); } /* The extern named `name`, or NULL. */ static ASTNode* shadow_fns_extern(const ShadowFns* f, const char* name) { - for (int i = 0; name && i < f->extern_count; i++) - if (strcmp(f->externs[i]->value, name) == 0) return f->externs[i]; - return NULL; + return name ? (ASTNode*)strmap_get(&f->externs, name) : NULL; } /* Every C spelling a reference to `v` could have that names one of `fns`, @@ -6512,8 +6497,8 @@ static void rename_locals_shadowing_functions_in(ASTNode* scope, const ShadowFns const NameSet* fixed) { LocalShadowScan s = {fns, fixed, {0}, {0}, {0}}; scan_local_shadows(scope, &s); - for (int i = 0; i < s.renamed.count; i++) { - const char* from = s.renamed.names[i]; + for (int i = 0; i < strmap_count(&s.renamed); i++) { + const char* from = strmap_key_at(&s.renamed, i); char* to = prefixed_name("ae_local_", from); int active = 0; rename_local_after_binding(scope, from, to, &active); @@ -6527,7 +6512,7 @@ static void rename_locals_shadowing_functions_in(ASTNode* scope, const ShadowFns static void rename_locals_shadowing_functions(ASTNode* program) { if (!program) return; NameSet globals = {0}; - ShadowFns fns = {{0}, NULL, 0, 0}; + ShadowFns fns = {{0}, {0}}; for (int i = 0; i < program->child_count; i++) { ASTNode* c = program->children[i]; if (!c || !c->value) continue; @@ -6545,7 +6530,7 @@ static void rename_locals_shadowing_functions(ASTNode* program) { } } } - for (int i = 0; fns.names.count > 0 && i < program->child_count; i++) { + for (int i = 0; strmap_count(&fns.names) > 0 && i < program->child_count; i++) { ASTNode* c = program->children[i]; if (!c) continue; if (c->type == AST_FUNCTION_DEFINITION || c->type == AST_MAIN_FUNCTION || @@ -6553,7 +6538,7 @@ static void rename_locals_shadowing_functions(ASTNode* program) { rename_locals_shadowing_functions_in(c, &fns, &globals); } else if (c->type == AST_ACTOR_DEFINITION) { NameSet fixed = {0}; - for (int g = 0; g < globals.count; g++) nameset_add(&fixed, globals.names[g]); + for (int g = 0; g < strmap_count(&globals); g++) nameset_add(&fixed, strmap_key_at(&globals, g)); for (int k = 0; k < c->child_count; k++) { ASTNode* st = c->children[k]; if (st && st->type == AST_STATE_DECLARATION && st->value) @@ -6689,7 +6674,7 @@ static void rename_scoped_bindings(ASTNode* program) { } else if (c->type == AST_ACTOR_DEFINITION) { /* An actor's state is `self->name`, no local. */ NameSet fixed = {0}; - for (int g = 0; g < globals.count; g++) nameset_add(&fixed, globals.names[g]); + for (int g = 0; g < strmap_count(&globals); g++) nameset_add(&fixed, strmap_key_at(&globals, g)); for (int k = 0; k < c->child_count; k++) { ASTNode* st = c->children[k]; if (st && st->type == AST_STATE_DECLARATION && st->value) diff --git a/compiler/codegen/codegen_internal.h b/compiler/codegen/codegen_internal.h index 0f7fe7c9a..7659a0402 100644 --- a/compiler/codegen/codegen_internal.h +++ b/compiler/codegen/codegen_internal.h @@ -820,6 +820,8 @@ typedef struct { * parameter) -> the definition (#2897) */ StrMap def_sets; /* `` of a definition -> the DefClauses it * is one of (#2897, fn_def_clause_set) */ + StrMap fn_globals; /* name -> the first module `var`/`const` that may + * hold a fn pointer (#3499, lookup_fnptr_global) */ } ProgramIndex; ProgramIndex* program_index(ASTNode* program); diff --git a/new_changelogs/20261101T120000Z-fixed-3499-compile-time-linear.md b/new_changelogs/20261101T120000Z-fixed-3499-compile-time-linear.md new file mode 100644 index 000000000..f0592ba08 --- /dev/null +++ b/new_changelogs/20261101T120000Z-fixed-3499-compile-time-linear.md @@ -0,0 +1,12 @@ +- **Compile time grows linearly with a program's size again (#3499).** + Three lookups by name scanned the whole program for each call or each + top-level node: the checker's callee and extern lookups, codegen's + function-pointer globals and its set of function names that a local may + shadow, and the merge's import prefixes. A chain of 8000 functions took + 1 s here and 22 to 25 s on the release's macOS x86_64 runner, which + failed the release twice. Each lookup is now answered from an index + (the checker's clause index, codegen's program index, a hashed name set), + and the same chain compiles in a third of a second; at 32000 functions, + 1.3 s instead of about 20. `order_effects_scaling` now compares 4000 and + 16000 functions on the same runner, so it no longer depends on the + runner's speed. diff --git a/tests/integration/order_effects_scaling/test_order_effects_scaling.sh b/tests/integration/order_effects_scaling/test_order_effects_scaling.sh index 3d43d01f1..c48454b7e 100755 --- a/tests/integration/order_effects_scaling/test_order_effects_scaling.sh +++ b/tests/integration/order_effects_scaling/test_order_effects_scaling.sh @@ -3,12 +3,20 @@ # evaluation-order rule of #2478) is solved over the call graph in time # linear in its size. # -# A chain of 8000 functions, each calling the next and only the last -# writing through its pointer parameter, asked about once -# (`show(q.n, f0(p, 3))`): one fixed point over every reached function took -# a pass per level, 90 s for this chain, and the recursive solve before it -# ran out of stack. Solved by strongly connected component it takes well -# under a second; the bound here is generous for slow runners. +# A chain of functions, each calling the next and only the last writing +# through its pointer parameter, asked about once (`show(q.n, f0(p, 3))`): +# one fixed point over every reached function took a pass per level, 90 s +# for 8000 of them, and the recursive solve before it ran out of stack. +# +# #3499: the whole compile is linear too. Three lookups by name scanned the +# program once per call or per top-level node (the checker's callee and +# extern lookups, codegen's fn-pointer globals and shadowed names, the +# merge's import prefixes), so the compile still grew with the square of the +# chain: 8000 functions took 22 to 25 s on the release's macOS x86_64 +# runner. The chain is compiled at 4000 and at 16000 functions on the same +# machine: four times the functions must cost less than eight times the time +# (linear is four, the quadratic compile was thirteen), whatever the +# runner's speed. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" @@ -19,62 +27,88 @@ if [ ! -x "$AETHERC" ]; then echo " [SKIP] order_effects_scaling: aetherc not built" exit 0 fi +if ! perl -MTime::HiRes -e 1 2>/dev/null; then + echo " [SKIP] order_effects_scaling: no perl Time::HiRes to time the compiles" + exit 0 +fi TMPDIR="$(mktemp -d)" trap 'rm -rf "$TMPDIR"' EXIT -N=8000 -LIMIT=20 +# A sanity ceiling on the larger compile, far above what a linear one takes +# on any runner. +LIMIT_MS=60000 -awk -v n="$N" 'BEGIN { - print "extern malloc(size: int) -> ptr" - print "" - print "struct Box {" - print " n: int" - print "}" - print "" - print "show(a: int, b: int) -> int {" - print " return a * 1000 + b" - print "}" - for (i = 0; i < n; i++) { +now_ms() { perl -MTime::HiRes=time -e 'printf "%d\n", time * 1000'; } + +# chain : write the chain of n functions to $TMPDIR/chain.ae. +chain() { + awk -v n="$1" 'BEGIN { + print "extern malloc(size: int) -> ptr" print "" - print "f" i "(b: *Box, d: int) -> int {" - if (i + 1 < n) { - print " return f" (i + 1) "(b, d + 1)" - } else { - print " b.n = 5" - print " return 2" + print "struct Box {" + print " n: int" + print "}" + print "" + print "show(a: int, b: int) -> int {" + print " return a * 1000 + b" + print "}" + for (i = 0; i < n; i++) { + print "" + print "f" i "(b: *Box, d: int) -> int {" + if (i + 1 < n) { + print " return f" (i + 1) "(b, d + 1)" + } else { + print " b.n = 5" + print " return 2" + } + print "}" } + print "" + print "main() {" + print " p = malloc(sizeof(Box)) as *Box" + print " q = p" + print " p.n = 1" + print " r = show(q.n, f0(p, 3))" + print " println(\"${r}\")" print "}" - } - print "" - print "main() {" - print " p = malloc(sizeof(Box)) as *Box" - print " q = p" - print " p.n = 1" - print " r = show(q.n, f0(p, 3))" - print " println(\"${r}\")" - print "}" -}' > "$TMPDIR/chain.ae" + }' > "$TMPDIR/chain$1.ae" +} + +# compile_ms : compile the chain of n functions; its time in ms in $MS. +compile_ms() { + start=$(now_ms) + "$AETHERC" "$TMPDIR/chain$1.ae" "$TMPDIR/chain$1.c" > "$TMPDIR/aetherc$1.log" 2>&1 + rc=$? + MS=$(( $(now_ms) - start )) + if [ "$rc" -ne 0 ]; then + echo " [FAIL] order_effects_scaling: aetherc failed (exit $rc) on a chain of $1 calls" + tail -5 "$TMPDIR/aetherc$1.log" | sed 's/^/ /' + exit 1 + fi +} -start=$(date +%s) -"$AETHERC" "$TMPDIR/chain.ae" "$TMPDIR/chain.c" > "$TMPDIR/aetherc.log" 2>&1 -rc=$? -elapsed=$(( $(date +%s) - start )) +chain 4000 +chain 16000 +compile_ms 4000 +small=$MS +compile_ms 16000 +large=$MS -if [ "$rc" -ne 0 ]; then - echo " [FAIL] order_effects_scaling: aetherc failed (exit $rc) on a chain of $N calls" - tail -5 "$TMPDIR/aetherc.log" | sed 's/^/ /' +if [ "$large" -gt "$LIMIT_MS" ]; then + echo " [FAIL] order_effects_scaling: a chain of 16000 calls took ${large} ms to compile (ceiling ${LIMIT_MS} ms)" exit 1 fi -if [ "$elapsed" -gt "$LIMIT" ]; then - echo " [FAIL] order_effects_scaling: a chain of $N calls took ${elapsed}s to compile (limit ${LIMIT}s)" +# Below 50 ms the timer's grain and the process start decide; both are fast. +[ "$small" -lt 50 ] && small=50 +if [ "$large" -ge $(( small * 8 )) ]; then + echo " [FAIL] order_effects_scaling: 4000 calls compiled in ${small} ms, 16000 in ${large} ms: four times the functions cost $(( large / small ))x the time (linear is 4x)" exit 1 fi # The read of q.n comes first, ahead of the call that writes it. -if ! grep -q "_eo[0-9]* = \|_AE_SEQ_LET" "$TMPDIR/chain.c"; then +if ! grep -q "_eo[0-9]* = \|_AE_SEQ_LET" "$TMPDIR/chain16000.c"; then echo " [FAIL] order_effects_scaling: q.n is not evaluated ahead of f0(p, 3)" exit 1 fi -echo " [PASS] order_effects_scaling: a chain of $N calls solved in ${elapsed}s" +echo " [PASS] order_effects_scaling: 4000 calls in ${small} ms, 16000 in ${large} ms" exit 0 From 243e9031378cb1d92879907497417f7f699b1cce Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Sun, 1 Nov 2026 13:34:04 -0300 Subject: [PATCH 02/17] ci: every ubuntu-latest job runs on ubuntu-26.04; actions off Node 20 (#3500) GitHub moves ubuntu-latest from 24.04 to 26.04 between 2026-10-19 and 2026-11-19, so until then a job lands on either image from one run to the next (contrib.sqlite's cross build failed on a 26.04 runner and passed on the next run; Paul's 4861daaa fixed its package). The jobs that used ubuntu-latest (ci.yml six, memory-check.yml, windows.yml two, release.yml six, changelog-collect.yml) now name ubuntu-26.04, so each run gets the image this PR checks them on. The release's Linux build stays on 22.04 for its glibc floor. The actions GitHub flags as Node 20 move to the releases that declare node24: checkout v5, cache restore/save v5, upload-artifact v6, download-artifact v7, setup-emsdk v15, action-gh-release v3, and in the benchmarks setup-java v5 and setup-go v6. mlugg/setup-zig and ilammy/msvc-dev-cmd have no node24 release yet and stay (GitHub runs them on Node 24). --- .github/workflows/benchmarks.yml | 8 +-- .github/workflows/changelog-collect.yml | 4 +- .github/workflows/ci.yml | 86 ++++++++++++------------- .github/workflows/memory-check.yml | 30 ++++----- .github/workflows/release.yml | 38 +++++------ .github/workflows/ubsan.yml | 4 +- .github/workflows/windows.yml | 34 +++++----- 7 files changed, 102 insertions(+), 102 deletions(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index b1ed009c2..ec70ba1dd 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -58,7 +58,7 @@ jobs: timeout-minutes: ${{ github.event_name == 'workflow_dispatch' && 120 || 30 }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 # gcc and g++ for C, C++ and Aether; GNU time for CPU time and memory. - name: Install C, C++ and GNU time @@ -66,12 +66,12 @@ jobs: sudo rm -f /etc/apt/sources.list.d/google-chrome.list; sudo apt-get update sudo apt-get install -y gcc g++ make time - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version: stable cache: false - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v5 with: distribution: temurin java-version: '21' @@ -129,7 +129,7 @@ jobs: - name: Upload results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: benchmark-results path: | diff --git a/.github/workflows/changelog-collect.yml b/.github/workflows/changelog-collect.yml index b5b85e437..192782234 100644 --- a/.github/workflows/changelog-collect.yml +++ b/.github/workflows/changelog-collect.yml @@ -42,9 +42,9 @@ concurrency: jobs: collect: name: Fold settled fragments - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: # The collector reads each fragment's commit time to decide whether # it has settled, so it needs the history, not a shallow clone. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f6a5ad5d..564d1b03a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,7 +136,7 @@ jobs: target: ci-sweep steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install toolchain if: matrix.install != '' @@ -159,7 +159,7 @@ jobs: # correctness never depends on a cache hit. - name: Restore miniaudio object cache id: audio_cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: .ci-cache key: audio-obj-${{ matrix.os }}-${{ matrix.cc }}-h${{ matrix.harden }}-${{ hashFiles('std/audio/miniaudio.h', 'std/audio/stb_vorbis.c', 'std/audio/aether_audio.c', 'std/audio/aether_audio.h') }} @@ -241,7 +241,7 @@ jobs: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.audio_cache.outputs.cache-hit != 'true' && (matrix.target != 'ci-sweep' || matrix.unit == '1') - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: .ci-cache key: audio-obj-${{ matrix.os }}-${{ matrix.cc }}-h${{ matrix.harden }}-${{ hashFiles('std/audio/miniaudio.h', 'std/audio/stb_vorbis.c', 'std/audio/aether_audio.c', 'std/audio/aether_audio.h') }} @@ -273,12 +273,12 @@ jobs: shard: ["1/2", "2/2"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 # The ARM64 leg's miniaudio object (same OS, compiler and build # variant, so the same key); restore only, the leg saves it. - name: Restore miniaudio object cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: .ci-cache key: audio-obj-macos-latest-clang-h0-${{ hashFiles('std/audio/miniaudio.h', 'std/audio/stb_vorbis.c', 'std/audio/aether_audio.c', 'std/audio/aether_audio.h') }} @@ -319,11 +319,11 @@ jobs: # ============================================================ ci-changelog: name: CHANGELOG entry present - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 if: github.event_name == 'pull_request' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 @@ -412,11 +412,11 @@ jobs: ci-freebsd-cross: name: FreeBSD / cross-compile (x86_64) - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install toolchain run: sudo rm -f /etc/apt/sources.list.d/google-chrome.list; sudo apt-get update && sudo apt-get install -y gcc make bc curl xz-utils @@ -424,7 +424,7 @@ jobs: # zig cc bundles no FreeBSD libc, so the base sysroot is what makes the # FreeBSD headers visible. Same source as the release leg. - name: Checkout aether-crossbuild - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: repository: aether-lang-dev/aether-crossbuild path: crossbuild @@ -434,7 +434,7 @@ jobs: # dir every run. deps.lock pins both URLs and checksums. - name: Restore cross-toolchain download cache id: cross_cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: | .zig @@ -459,7 +459,7 @@ jobs: if: >- github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.cross_cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: | .zig @@ -484,10 +484,10 @@ jobs: # (no shm_open, no pthread_setaffinity_np, ...) on the PR, not at release. ci-android-cross: name: Android / cross-compile (aarch64) - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Cross-build for Android (ANDROID=1) run: | NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-}}" @@ -534,18 +534,18 @@ jobs: # ============================================================ ci-sqlite-cross: name: contrib.sqlite / self-contained cross builds - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install toolchain run: sudo rm -f /etc/apt/sources.list.d/google-chrome.list; sudo apt-get update && sudo apt-get install -y gcc make bc curl xz-utils file qemu-user - name: Restore zig + SQLite cross object cache id: sqlite_xcache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: | .zig @@ -577,7 +577,7 @@ jobs: if: >- github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.sqlite_xcache.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: | .zig @@ -586,11 +586,11 @@ jobs: ci-freebsd-native: name: FreeBSD / native build + unit tests - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 # vmactions/freebsd-vm installs qemu with apt, under apt's default # connect timeout. When the runner's Azure mirror stopped answering, its @@ -633,7 +633,7 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install host language dev libraries run: | @@ -688,7 +688,7 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install build deps run: | @@ -737,7 +737,7 @@ jobs: # only ever supply the right one. Split restore/save as above. - name: Restore SQLite amalgamation object cache id: sqlite_obj_cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: .ci-cache/sqlite key: sqlite-obj-ubuntu-22.04-${{ hashFiles('contrib/sqlite/amalgamation.lock', 'tests/scripts/contrib_build.sh') }} @@ -759,7 +759,7 @@ jobs: if: >- github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.sqlite_obj_cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: .ci-cache/sqlite key: sqlite-obj-ubuntu-22.04-${{ hashFiles('contrib/sqlite/amalgamation.lock', 'tests/scripts/contrib_build.sh') }} @@ -883,7 +883,7 @@ jobs: runs-on: ${{ matrix.runner }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install build deps, lavapipe and gdb run: | @@ -1001,7 +1001,7 @@ jobs: shell: bash steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 # Metal ships with macOS. Vulkan does not: the headers build # contrib.vulkan, the loader is what it opens at runtime, and MoltenVK is @@ -1077,7 +1077,7 @@ jobs: shell: msys2 {0} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: msys2/setup-msys2@v2 with: @@ -1179,7 +1179,7 @@ jobs: shell: msys2 {0} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: msys2/setup-msys2@v2 with: @@ -1246,7 +1246,7 @@ jobs: # (#1372); keep its object between runs, as on the Linux contrib leg. - name: Restore SQLite amalgamation object cache id: sqlite_obj_cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: .ci-cache/sqlite key: sqlite-obj-windows-${{ hashFiles('contrib/sqlite/amalgamation.lock', 'tests/scripts/contrib_build.sh') }} @@ -1295,7 +1295,7 @@ jobs: if: >- github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.sqlite_obj_cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: .ci-cache/sqlite key: sqlite-obj-windows-${{ hashFiles('contrib/sqlite/amalgamation.lock', 'tests/scripts/contrib_build.sh') }} @@ -1354,7 +1354,7 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install toolchain run: sudo rm -f /etc/apt/sources.list.d/google-chrome.list; sudo apt-get update && sudo apt-get install -y gcc make bc @@ -1374,7 +1374,7 @@ jobs: # could rot to a stub and stay green everywhere. OPENSSL=0 forces the # detection off regardless of what is installed on the runner. steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install toolchain run: sudo rm -f /etc/apt/sources.list.d/google-chrome.list; sudo apt-get update && sudo apt-get install -y gcc make bc @@ -1418,11 +1418,11 @@ jobs: # case that is skipped or does not agree, with the reason. ci-crypto-ct: name: Crypto constant-time (Valgrind) and conformance corpora - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install toolchain run: sudo rm -f /etc/apt/sources.list.d/google-chrome.list; sudo apt-get update && sudo apt-get install -y gcc make bc valgrind @@ -1439,7 +1439,7 @@ jobs: - name: Restore the conformance corpora id: conformance_cache if: success() || failure() - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: ~/.cache/aether-conformance key: conformance-${{ hashFiles('tests/conformance/*/run.sh') }} @@ -1457,7 +1457,7 @@ jobs: (success() || failure()) && github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.conformance_cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: ~/.cache/aether-conformance key: conformance-${{ hashFiles('tests/conformance/*/run.sh') }} @@ -1467,10 +1467,10 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Emscripten - uses: mymindstorm/setup-emsdk@v14 + uses: mymindstorm/setup-emsdk@v15 with: version: 3.1.51 @@ -1551,7 +1551,7 @@ jobs: timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 # --no-install-recommends: Recommends pulls libstdc++-arm-none-eabi-newlib, # 347 MB of C++ library this C-only build never links, and a slow Azure @@ -1577,7 +1577,7 @@ jobs: timeout-minutes: 45 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Free disk space for the ESP-IDF image run: | @@ -1610,7 +1610,7 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install RISC-V cross-toolchain + qemu run: | @@ -1634,7 +1634,7 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install tools run: | @@ -1680,7 +1680,7 @@ jobs: - name: Upload memory reports if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: memory-reports path: | diff --git a/.github/workflows/memory-check.yml b/.github/workflows/memory-check.yml index 6c0e71aea..29ec888c4 100644 --- a/.github/workflows/memory-check.yml +++ b/.github/workflows/memory-check.yml @@ -41,7 +41,7 @@ concurrency: jobs: valgrind-test: name: Valgrind Memory Leak Check - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 # A healthy run takes about a minute: 66 s on PR #2601, 25 s of it the # Valgrind run itself. It used to take an hour, while idle actor threads # spun (#2592) and Valgrind's default lock starved the threads they waited @@ -51,7 +51,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install dependencies run: | @@ -133,7 +133,7 @@ jobs: - name: Upload Valgrind report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: valgrind-report path: | @@ -147,14 +147,14 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-26.04, macos-latest] steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup GCC - if: matrix.os == 'ubuntu-latest' + if: matrix.os == 'ubuntu-26.04' run: | sudo rm -f /etc/apt/sources.list.d/google-chrome.list; sudo apt-get update sudo apt-get install -y gcc make @@ -167,7 +167,7 @@ jobs: - name: Build with AddressSanitizer run: | make clean - if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then + if [ "${{ matrix.os }}" = "ubuntu-26.04" ]; then # Linux supports LeakSanitizer make compiler CFLAGS="-fsanitize=address -fsanitize=leak -fno-omit-frame-pointer -O1 -g" \ LDFLAGS="-fsanitize=address -fsanitize=leak -pthread -lm" @@ -183,7 +183,7 @@ jobs: - name: Run tests with ASAN run: | - if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then + if [ "${{ matrix.os }}" = "ubuntu-26.04" ]; then export ASAN_OPTIONS="detect_leaks=1:halt_on_error=1:abort_on_error=1" export LSAN_OPTIONS="report_objects=1" else @@ -196,7 +196,7 @@ jobs: # nothing checked that, and every program leaked. Linux only: LeakSanitizer # is the whole point and macOS has no LSan. - name: Compiler leak gate - if: matrix.os == 'ubuntu-latest' + if: matrix.os == 'ubuntu-26.04' run: bash tests/scripts/check_compiler_leaks.sh # The C unit tests under UBSan, on macOS (Apple clang). On Linux they run @@ -222,11 +222,11 @@ jobs: memory-profiling: name: Memory Usage Profiling - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install dependencies run: | @@ -269,7 +269,7 @@ jobs: echo "SUCCESS: Peak memory usage acceptable" - name: Upload profiling report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: memory-profile path: | @@ -282,11 +282,11 @@ jobs: strategy: matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-26.04, macos-latest] steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 # Linux only, as the setup steps above. The macOS runner already has # clang and make; the brew fallback that ran there installed nothing the @@ -294,7 +294,7 @@ jobs: # `brew update` failed the job whenever a tap preinstalled on the runner # could not be fetched (#2895). - name: Setup GCC - if: matrix.os == 'ubuntu-latest' + if: matrix.os == 'ubuntu-26.04' run: | sudo rm -f /etc/apt/sources.list.d/google-chrome.list; sudo apt-get update sudo apt-get install -y gcc make diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 508c075e0..00fb682eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,7 @@ jobs: # ================================================================== # prepare: name: Prepare release - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 if: github.ref == 'refs/heads/main' # `pull-requests: read` lets the decide step query PR state via gh, @@ -57,7 +57,7 @@ jobs: tag: ${{ steps.decide.outputs.tag }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 fetch-tags: true @@ -166,14 +166,14 @@ jobs: name: Auto-bump version needs: prepare if: needs.prepare.outputs.action == 'bump' || needs.prepare.outputs.action == 'refresh' - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 permissions: contents: write pull-requests: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 fetch-tags: true @@ -383,7 +383,7 @@ jobs: name: Tag release needs: prepare if: needs.prepare.outputs.action == 'tag' - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 permissions: contents: write @@ -393,7 +393,7 @@ jobs: version: ${{ steps.t.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 fetch-tags: true @@ -494,7 +494,7 @@ jobs: timeout: 80 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ needs.tag.outputs.tag || github.ref }} # Full history + all tags. A shallow checkout of a tag does not @@ -678,7 +678,7 @@ jobs: cd release && TZ=UTC+12 zip -r ../aether-${VERSION}-${{ matrix.target }}.zip * - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: aether-${{ matrix.target }} path: | @@ -711,11 +711,11 @@ jobs: (needs.tag.result == 'success' && needs.tag.outputs.tag != '') || startsWith(github.ref, 'refs/tags/') ) - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ needs.tag.outputs.tag || github.ref }} # Full history + all tags. A shallow checkout of a tag does not @@ -733,7 +733,7 @@ jobs: # libc) comes from the sibling aether-crossbuild repo, which fetches # FreeBSD's published base.txz (BSD-licensed, freely redistributable). - name: Checkout aether-crossbuild - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: repository: aether-lang-dev/aether-crossbuild path: crossbuild @@ -908,7 +908,7 @@ jobs: || { echo "::error::the sysroot probe did not produce a FreeBSD binary"; exit 1; } - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: aether-freebsd-x86_64 path: | @@ -938,11 +938,11 @@ jobs: (needs.tag.result == 'success' && needs.tag.outputs.tag != '') || startsWith(github.ref, 'refs/tags/') ) - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ needs.tag.outputs.tag || github.ref }} fetch-depth: 0 @@ -1008,7 +1008,7 @@ jobs: ls -la "aether-${VERSION}-android-aarch64-sysroot.tar.xz" - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: aether-android-aarch64 path: | @@ -1021,7 +1021,7 @@ jobs: # quietly drops a platform because its build broke is worse than no # release, and dropping it silently is what hid the last FreeBSD break. needs: [tag, build, build-freebsd, build-android] - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 if: | always() && needs.build.result == 'success' && needs.build-freebsd.result == 'success' @@ -1034,12 +1034,12 @@ jobs: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ needs.tag.outputs.tag || github.ref }} - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: merge-multiple: true @@ -1058,7 +1058,7 @@ jobs: run: python3 .github/scripts/build_release_body.py ${{ steps.tag.outputs.version }} - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.tag.outputs.tag }} name: Aether ${{ steps.tag.outputs.version }} diff --git a/.github/workflows/ubsan.yml b/.github/workflows/ubsan.yml index d33d421c3..a73cfd8b4 100644 --- a/.github/workflows/ubsan.yml +++ b/.github/workflows/ubsan.yml @@ -56,7 +56,7 @@ jobs: timeout-minutes: 35 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install toolchain run: sudo rm -f /etc/apt/sources.list.d/google-chrome.list; sudo apt-get update && sudo apt-get install -y gcc make bc pkg-config libgtk-4-dev; pip install --retries 10 --timeout 120 --break-system-packages websockets 2>/dev/null || pip install --retries 10 --timeout 120 websockets @@ -66,7 +66,7 @@ jobs: - name: Upload the UBSan reports if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: ubsan-reports path: build/ubsan/reports/ diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 4712e833b..430eef3ae 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -92,11 +92,11 @@ jobs: # ============================================================ windows-cross: name: Windows / cross-build (x86_64) - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 timeout-minutes: 25 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install toolchain run: sudo rm -f /etc/apt/sources.list.d/google-chrome.list; sudo apt-get update && sudo apt-get install -y gcc make bc curl xz-utils @@ -105,7 +105,7 @@ jobs: # mingw-w64 headers + CRT sources, so there is no sysroot to fetch. - name: Restore zig toolchain cache id: zig_cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: | .zig @@ -124,7 +124,7 @@ jobs: if: >- github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.zig_cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: | .zig @@ -159,7 +159,7 @@ jobs: - name: Upload cross-built binaries if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: aether-windows-cross-x86_64 path: | @@ -181,7 +181,7 @@ jobs: # ============================================================ windows-wine: name: Windows / runtime under Wine (x86_64) - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 # Dependency installation can take 20 minutes on hosted runners; leave # time for the complete cross-build and runtime sweep after setup. timeout-minutes: 60 @@ -189,7 +189,7 @@ jobs: # a gate, so it must not add latency to an otherwise green run. steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install toolchain run: | @@ -263,7 +263,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up MSYS2 / MinGW-w64 uses: msys2/setup-msys2@v2 @@ -293,7 +293,7 @@ jobs: # key already embeds the source hash). - name: Restore miniaudio object cache id: audio_cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: .ci-cache key: audio-obj-windows-${{ matrix.root }}-${{ hashFiles('std/audio/miniaudio.h', 'std/audio/stb_vorbis.c', 'std/audio/aether_audio.c', 'std/audio/aether_audio.h') }} @@ -322,14 +322,14 @@ jobs: if: >- github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.audio_cache.outputs.cache-hit != 'true' && matrix.target == 'ci' - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: .ci-cache key: audio-obj-windows-${{ matrix.root }}-${{ hashFiles('std/audio/miniaudio.h', 'std/audio/stb_vorbis.c', 'std/audio/aether_audio.c', 'std/audio/aether_audio.h') }} - name: Upload Windows binaries if: matrix.target == 'ci' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: aether-windows-${{ matrix.root }} path: | @@ -383,7 +383,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 # MINGW64 (msvcrt), the C runtime the baseline was measured on: a heap # count is the C runtime's own. @@ -420,7 +420,7 @@ jobs: # failure is read from. - name: Upload the grid's report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: ownership-grid-${{ matrix.slot }} path: | @@ -446,7 +446,7 @@ jobs: shell: msys2 {0} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up MSYS2 / MinGW-w64 uses: msys2/setup-msys2@v2 with: @@ -470,7 +470,7 @@ jobs: run: make ownership-grid-check OWNERSHIP_GRID_ARGS="--mode both --update-baseline" - name: Upload the baseline if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: ownership-grid-baseline path: | @@ -491,7 +491,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Visual Studio developer environment uses: ilammy/msvc-dev-cmd@v1 @@ -533,7 +533,7 @@ jobs: # is the one the windows-cross job built; the script runs under Git Bash, # with cl.exe on PATH from msvc-dev-cmd. - name: Download the cross-built aetherc - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: aether-windows-cross-x86_64 path: build/cross From 56c9261e6281725221d58f400a924a7c1923524b Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Sun, 1 Nov 2026 13:42:40 -0300 Subject: [PATCH 03/17] fix: an if used as a value parses when its condition ends in a call (#3478) The expression form of `if` parsed its condition without the in_condition guard the if statement, while and match use, so a call at its end took the then arm's `{` as a trailing block ("Expected LEFT_BRACE, got ELSE"). The condition is parsed under the guard and the arms without it, so a call inside an arm still takes a trailing block. Regression test covers a lone call, a call ending an &&, an else-if chain of calls, and a trailing closure in an arm. --- compiler/parser/parser.c | 39 ++++++++++++------- ...fixed-3478-if-expression-call-condition.md | 5 +++ .../test_if_expression_call_condition.ae | 37 ++++++++++++++++++ 3 files changed, 66 insertions(+), 15 deletions(-) create mode 100644 new_changelogs/20261101T130000Z-fixed-3478-if-expression-call-condition.md create mode 100644 tests/regression/test_if_expression_call_condition.ae diff --git a/compiler/parser/parser.c b/compiler/parser/parser.c index 051a3cfff..640ab4ef0 100644 --- a/compiler/parser/parser.c +++ b/compiler/parser/parser.c @@ -1872,24 +1872,33 @@ static ASTNode* parse_primary_expression_inner(Parser* parser) { // chain of any length is nested two-armed if-expressions and // types as they do, the last `else` still required. Token* t = advance_token(parser); // consume 'if' + /* #3478: the `{` after the condition opens the then arm, so a + * call that ends the condition takes no trailing block, as in an + * `if` statement or a `match` (the in_condition guard). The arms + * sit inside braces, where a call may take one again. */ + int saved_in_condition = parser->in_condition; + parser->in_condition = 1; ASTNode* cond = parse_expression(parser); - if (!cond) return NULL; - if (!expect_token(parser, TOKEN_LEFT_BRACE)) return NULL; - ASTNode* then_expr = parse_expression(parser); - if (!then_expr) return NULL; - if (!expect_token(parser, TOKEN_RIGHT_BRACE)) return NULL; - if (!expect_token(parser, TOKEN_ELSE)) return NULL; + parser->in_condition = 0; + ASTNode* then_expr = NULL; ASTNode* else_expr = NULL; - Token* after_else = peek_token(parser); - if (after_else && after_else->type == TOKEN_IF) { - else_expr = parse_primary_expression(parser); - if (!else_expr) return NULL; - } else { - if (!expect_token(parser, TOKEN_LEFT_BRACE)) return NULL; - else_expr = parse_expression(parser); - if (!else_expr) return NULL; - if (!expect_token(parser, TOKEN_RIGHT_BRACE)) return NULL; + int ok = cond && expect_token(parser, TOKEN_LEFT_BRACE) && + (then_expr = parse_expression(parser)) != NULL && + expect_token(parser, TOKEN_RIGHT_BRACE) && + expect_token(parser, TOKEN_ELSE); + if (ok) { + Token* after_else = peek_token(parser); + if (after_else && after_else->type == TOKEN_IF) { + else_expr = parse_primary_expression(parser); + ok = else_expr != NULL; + } else { + ok = expect_token(parser, TOKEN_LEFT_BRACE) && + (else_expr = parse_expression(parser)) != NULL && + expect_token(parser, TOKEN_RIGHT_BRACE); + } } + parser->in_condition = saved_in_condition; + if (!ok) return NULL; ASTNode* if_expr = create_ast_node(AST_IF_EXPRESSION, NULL, t->line, t->column); if_expr->node_type = create_type(TYPE_UNKNOWN); diff --git a/new_changelogs/20261101T130000Z-fixed-3478-if-expression-call-condition.md b/new_changelogs/20261101T130000Z-fixed-3478-if-expression-call-condition.md new file mode 100644 index 000000000..72201c1ee --- /dev/null +++ b/new_changelogs/20261101T130000Z-fixed-3478-if-expression-call-condition.md @@ -0,0 +1,5 @@ +- **An `if` used as a value parses when its condition ends in a call + (#3478).** `s = if leap(year) { "leap" } else { "common" }` failed with + "Expected LEFT_BRACE, got ELSE": the expression form parsed its condition + without the guard the `if` statement uses, so the call took the then + arm's `{` as a trailing block. A call inside an arm still takes one. diff --git a/tests/regression/test_if_expression_call_condition.ae b/tests/regression/test_if_expression_call_condition.ae new file mode 100644 index 000000000..eaad3bdcf --- /dev/null +++ b/tests/regression/test_if_expression_call_condition.ae @@ -0,0 +1,37 @@ +// Regression (#3478): an `if` used as a value parses when its condition +// ends in a call. +// +// The expression form parsed its condition without the guard the `if` +// statement, `while` and `match` use, so the call at its end took the then +// arm's `{` as a trailing block, and the `else` after it was "Expected +// LEFT_BRACE, got ELSE". The condition is now parsed under that guard, and +// the arms inside their braces without it, so a call there may still take +// a block. + +extern exit(code: int) + +yes(x: int) -> bool -> x > 0 + +twice(f: fn) -> int { + return call(f) * 2 +} + +check(got: int, want: int, what: string) { + if got != want { + println("FAIL: ${what}: ${got}, want ${want}") + exit(1) + } +} + +main() { + s = if yes(1) { "yes" } else { "no" } + if s != "yes" { + println("FAIL: a call alone as the condition: ${s}") + exit(1) + } + check(if 1 < 2 && yes(1) { 1 } else { 2 }, 1, "a call that ends the condition") + check(if yes(-1) { 1 } else if yes(2) { 2 } else { 3 }, 2, "an else-if chain of calls") + // A call inside an arm still takes a trailing block. + check(if yes(1) { twice() || -> 21 } else { 0 }, 42, "a trailing block in the then arm") + println("ok") +} From 723eafde3b9f6c9643edd2fbd1428221781b48a4 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Sun, 1 Nov 2026 13:42:40 -0300 Subject: [PATCH 04/17] fix: s[i] on a string is a checked byte (#3479) The checker gave a string index no type, so a local bound to it warned "unresolved type in codegen, defaulting to int". The generated C read a plain char (signed on x86: a UTF-8 byte past 127 read negative there and positive on ARM), and nothing checked the index: s[100000] on "Az" read past the string. The read is now typed byte in the inference pass and the checker, the value string.bytes(s)[i] has, and codegen reads it through _aether_str_at, which checks it against the string's length and panics "string index i out of range for length n" (aether_string_fail_index), as a slice index does. The language reference says so. --- compiler/analysis/type_inference.c | 4 ++ compiler/analysis/typechecker.c | 8 +++ compiler/codegen/codegen.c | 8 +++ compiler/codegen/codegen_expr.c | 12 ++++ docs/language-reference.md | 2 +- ...01T130100Z-fixed-3479-string-index-byte.md | 7 +++ runtime/aether_slice.h | 8 +++ tests/regression/test_string_index_byte.ae | 55 +++++++++++++++++++ 8 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 new_changelogs/20261101T130100Z-fixed-3479-string-index-byte.md create mode 100644 tests/regression/test_string_index_byte.ae diff --git a/compiler/analysis/type_inference.c b/compiler/analysis/type_inference.c index e90031a10..1295c53b5 100644 --- a/compiler/analysis/type_inference.c +++ b/compiler/analysis/type_inference.c @@ -1091,6 +1091,10 @@ void collect_expression_constraints(ASTNode* node, InferenceContext* ctx) { if (array_type && array_type->kind == TYPE_ARRAY && array_type->element_type) { free_type(node->node_type); node->node_type = clone_type(array_type->element_type); + } else if (array_type && array_type->kind == TYPE_STRING) { + /* #3479: `s[i]` reads one byte of the string. */ + free_type(node->node_type); + node->node_type = create_type(TYPE_BYTE); } } break; diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index f751e3640..80e9db4ae 100644 --- a/compiler/analysis/typechecker.c +++ b/compiler/analysis/typechecker.c @@ -17835,6 +17835,14 @@ int typecheck_expression(ASTNode* expr, SymbolTable* table) { if (expr->node_type) free_type(expr->node_type); expr->node_type = clone_type(arr_type->element_type); } + /* #3479: `s[i]` on a string reads one byte, the type + * `string.bytes(s)[i]` has; a local bound to it was left to + * codegen's `int` default. */ + if (arr_type && arr_type->kind == TYPE_STRING && + (!expr->node_type || expr->node_type->kind != TYPE_BYTE)) { + if (expr->node_type) free_type(expr->node_type); + expr->node_type = create_type(TYPE_BYTE); + } // Indexing a bare `ptr` (void*, element_type == NULL) has no // element type: it lowers to a C index on `void*` and leaks diff --git a/compiler/codegen/codegen.c b/compiler/codegen/codegen.c index 35deae9d6..690383cb8 100644 --- a/compiler/codegen/codegen.c +++ b/compiler/codegen/codegen.c @@ -9815,6 +9815,14 @@ void generate_program(CodeGenerator* gen, ASTNode* program) { print_line(gen, " if (!s) return \"\";"); print_line(gen, " return aether_string_data(s);"); print_line(gen, "}"); + /* #3479: `s[i]` is the string's byte i, read unsigned (a plain char is + * signed on some targets), and checked against the string's length as a + * slice index is against the slice's; past the end it read past it. */ + print_line(gen, "static inline uint8_t _aether_str_at(const void* s, int64_t i, const char* file, int line) {"); + print_line(gen, " uint64_t n = s ? (uint64_t)aether_string_length(s) : 0;"); + print_line(gen, " if ((uint64_t)i >= n) aether_string_fail_index(i, n, file, line);"); + print_line(gen, " return (uint8_t)_aether_safe_str(s)[i];"); + print_line(gen, "}"); /* Owned-print helpers: a heap-producing CALL in bare print/println * argument position (`println(string.concat(a, b))`) is a temporary * nothing else owns; these print it and free it in one composable diff --git a/compiler/codegen/codegen_expr.c b/compiler/codegen/codegen_expr.c index 7c39f3d9c..5e10e4678 100644 --- a/compiler/codegen/codegen_expr.c +++ b/compiler/codegen/codegen_expr.c @@ -11981,6 +11981,18 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { } int str_base = base && base->node_type && base->node_type->kind == TYPE_STRING; + /* #3479: a read of a string's byte, checked and unsigned + * (a store through `s[i]` is refused by the checker). */ + if (str_base && !gen->generating_lvalue) { + fprintf(gen->output, "_aether_str_at("); + generate_expression(gen, base); + fprintf(gen->output, ", (int64_t)("); + generate_read(gen, expr->children[1]); + fprintf(gen->output, "), "); + emit_slice_diag_file(gen, expr); + fprintf(gen->output, ", %d)", expr->line); + break; + } /* #3113: an index into a fixed-size `T[N]` is checked * against N, as a slice's is against its length: a local, * a field, a parameter, an actor's state or a `const` diff --git a/docs/language-reference.md b/docs/language-reference.md index 8cb8447ad..9241c8883 100644 --- a/docs/language-reference.md +++ b/docs/language-reference.md @@ -413,7 +413,7 @@ main() { - **`s[i]` is bounds-checked.** An index outside `0 ..< s.len` is a runtime panic naming the source line, the index and the length (`slice index 5 out of range for length 3`). It is an ordinary panic: `try` catches it, and an actor step that trips it dies cleanly. A negative index is out of range, never a read before the buffer. An index into a fixed-size `T[N]` array is checked the same way, against `N` ([Array Types](#array-types)). - **`s[lo..hi]`** is the half-open sub-slice `[lo, hi)`, checked the same way (`lo <= hi <= s.len`). `s[lo..]` runs to the end, `s[..hi]` from the start, `s[..]` is the whole thing. The base may be a slice or a `T[N]` array; the result is always a `T[]` over the *same* elements, so a write through it is visible through the original. - **A `T[N]` array converts to `T[]` implicitly** wherever a slice is expected: a parameter, a `T[]`-typed binding or field, a `return` from a `-> T[]` function. The length travels with it. `null`, and the empty literal `[]`, convert to the empty slice: `.len` is `0`, it views no storage, and it compares equal to `null`. So `s == []` is `s == null` (it asks whether `s` views any storage; test `s.len == 0` for emptiness), a C function's `T[]` parameter takes `[]` as the null pointer, and an if-expression or a `match` given to a `T[]` converts each arm on its own. A module-level `var` or `const` of type `T[]` takes either as its initial value. -- **A string is not a slice.** Passing one where a `T[]` is expected (a parameter, a binding, a `return`) is a type error; `string.bytes(s)` is the bounded, read-only `byte[]` view of a string's bytes, and it decays to their pointer at a C extern like any slice. Reading it costs what any slice's read does; a store through it (an element store, `+=`, `++`), or through any view made from it (a sub-slice, a pattern's tail), in this function or one it is given to, panics with "a write through a read-only view of a string", and one the checker can see through a local bound to it is a compile error (E0200) (#3025). A string's own bytes are read-only too: `s[i]` reads one, and a write (`s[i] = v`, `s[i] += v`, `s[i]++`) is a compile error (E0200). Build the new text as a new string, or work on the bytes in a `byte[N]` array of your own. +- **A string is not a slice.** Passing one where a `T[]` is expected (a parameter, a binding, a `return`) is a type error; `string.bytes(s)` is the bounded, read-only `byte[]` view of a string's bytes, and it decays to their pointer at a C extern like any slice. Reading it costs what any slice's read does; a store through it (an element store, `+=`, `++`), or through any view made from it (a sub-slice, a pattern's tail), in this function or one it is given to, panics with "a write through a read-only view of a string", and one the checker can see through a local bound to it is a compile error (E0200) (#3025). A string's own bytes are read-only too: `s[i]` reads one, a `byte` (the value `string.bytes(s)[i]` has, 0 to 255), checked against the string's length as a slice index is against its own: an index outside `0 ..< length` is the panic `string index 5 out of range for length 3`. A write (`s[i] = v`, `s[i] += v`, `s[i]++`) is a compile error (E0200). Build the new text as a new string, or work on the bytes in a `byte[N]` array of your own. - **A slice decays to its pointer** wherever a `ptr` is expected, exactly as a `T[N]` array does: a `ptr` parameter, a C extern's parameter (including one declared `T[]`), a comparison against `null`, and pointer arithmetic (`s + n` is a `ptr` to element `n`). `s as ptr` spells the same conversion explicitly, for a `ptr` return or binding. The length is dropped at that boundary; nothing else about the extern ABI changes. - **`make([]T, n)`** allocates `n` zeroed elements and returns them as a bounded slice. The local that holds it releases it when it goes out of scope ([Slice ownership](#slice-ownership)), and `free(s)` releases it early, and first what the elements hold: each element is a place a store into it takes a reference for (a string, a closure, a struct that holds one), so the free gives each back, as a struct's destroy does its fields (#3159). The elements of C memory viewed as a slice hold no reference, and its free releases only the memory. Only the slice that holds such a buffer, or a view of a raw pointer the program owns from its start (`(p as T[])[0..n]`), may be freed: a view owns nothing. `free` of a sub-slice (`b[2..4]`, `b[1..]`, a list pattern's tail), of an array viewed as a slice, of `string.bytes(s)`, or of an array `T[N]`, is a compile error where the checker sees it (a local bound once to one, or the expression itself), and a named panic at run time anywhere else (`free of a view of elements another slice or an array holds, which owns nothing`). Before, the allocator was given a pointer it never handed out. In a module that defines a `free` of its own (`bignum.free`, `json.free`), `free(s)` of a slice is still this release, unless the module's `free` itself takes a slice: the module's function would only see the slice's pointer (#3116). - **`T[][]` is a slice of slices**, and `T[][][]` one deeper: each further `[]` wraps the type once more, in any position a type is written (a parameter, a return type, a field, a typed local, a function type). `make([]int[], n)` builds one; `g[i] = row` stores a row, `g[i][j]` reads an element and `g[i].len` is a row's length. The outer slice owns its rows: a store into `g[i]` releases the row it replaces, and releasing the outer slice (its owner's scope exit, or `free(g)`) releases every row first; `free(g[i])` releases a row early and leaves it empty. A row read out (`r = g[i]`) is a view of it. Only the empty `[]` repeats; there is no `T[][3]`. diff --git a/new_changelogs/20261101T130100Z-fixed-3479-string-index-byte.md b/new_changelogs/20261101T130100Z-fixed-3479-string-index-byte.md new file mode 100644 index 000000000..1acff45fa --- /dev/null +++ b/new_changelogs/20261101T130100Z-fixed-3479-string-index-byte.md @@ -0,0 +1,7 @@ +- **`s[i]` on a string is a checked `byte` (#3479).** A local bound to it + warned that its type was left to codegen; the read was a plain `char`, so + a byte past 127 (any non-ASCII UTF-8 byte) was negative on x86 and + positive on ARM; and an index past the end read past the string. It now + has the type and the value `string.bytes(s)[i]` has, 0 to 255, and an + index outside `0 ..< length` is the panic `string index 5 out of range for + length 3`, which a `try` catches, as a slice index's is. diff --git a/runtime/aether_slice.h b/runtime/aether_slice.h index 03bedf955..f505f2762 100644 --- a/runtime/aether_slice.h +++ b/runtime/aether_slice.h @@ -164,6 +164,14 @@ static inline void aether_array_fail_index(int64_t idx, uint64_t len, file, line, (long long)idx, (unsigned long long)len); } +/* #3479: `s[i]` on a string outside `0 ..< length`, the slice's panic with + * the string's length. */ +static inline void aether_string_fail_index(int64_t idx, uint64_t len, + const char* file, int line) { + aether_slice_panicf("%s:%d: string index %lld out of range for length %llu", + file, line, (long long)idx, (unsigned long long)len); +} + /* #3113: `a[i]` on a `T[N]`: `i`, checked against N, for the generated C * to index with (`a[aether_array_index(i, N, ...)]`), so the element stays * an lvalue and `i` is evaluated once. A negative index is out of range. diff --git a/tests/regression/test_string_index_byte.ae b/tests/regression/test_string_index_byte.ae new file mode 100644 index 000000000..47d715c3c --- /dev/null +++ b/tests/regression/test_string_index_byte.ae @@ -0,0 +1,55 @@ +// Regression (#3479): `s[i]` on a string is its byte i, typed `byte`, +// read unsigned and checked against the string's length. +// +// The checker gave the read no type, so a local bound to it warned +// "unresolved type in codegen, defaulting to int"; the generated C read a +// plain `char`, which is signed on x86, so a byte past 127 (any non-ASCII +// UTF-8 byte) read negative there and positive on ARM; and an index past +// the end read past the string. The read now has the type +// `string.bytes(s)[i]` has, and the value it has, and an index outside +// `0 ..< length` panics, as a slice index does, where a `try` catches it. + +import std.string + +extern exit(code: int) + +check(got: int, want: int, what: string) { + if got != want { + println("FAIL: ${what}: ${got}, want ${want}") + exit(1) + } +} + +first(text: string) -> int { + c = text[0] + return c +} + +main() { + s = "é" + c = s[0] + check(c, 195, "a lead byte bound to a local") + check(s[1], 169, "a continuation byte") + check(s[0], string.bytes(s)[0], "the byte string.bytes reads") + check(first("A"), 65, "a parameter's byte") + t = "Az" + check(t[1] - t[0], 57, "arithmetic on two bytes") + caught = 0 + i = 2 + while i < 5 { + try { + x = t[i] + println("FAIL: no panic at ${i} (${x})") + exit(1) + } catch e { + if string.ends_with(e, "string index ${i} out of range for length 2") == 0 { + println("FAIL: message: ${e}") + exit(1) + } + caught = caught + 1 + } + i = i + 1 + } + check(caught, 3, "indexes past the end caught") + println("ok") +} From f52e0e756ea51341e398e4f1e06f7734a9d72f6e Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Sun, 1 Nov 2026 13:45:23 -0300 Subject: [PATCH 05/17] fix: a refused binary import removes its stub directory (#3485) The refusals (ae_lib_check_compat, ae_refuse_split_ownership) called exit(1) from inside ae_generate_binimport_stub, with the stub's FILE still open. C runs atexit handlers before it closes streams, so the cleanup that removes the stub directory ran against an open file, which Windows will not delete: every refused build left ae-binimport-- behind (1,168 on one machine). The refusals now return 2; the caller closes the stub, removes it and exits, and the atexit cleanup finds nothing open. The ownership test runs each refused build in a temp directory of its own and checks it is empty after (failed before, on Windows). Also removes ae_source_module_exists, which nothing calls (#3486): clang's -Wall -Werror refused the file for it. --- ...00Z-fixed-3485-refused-import-cleans-up.md | 6 ++++ .../test_binary_import_ownership.sh | 11 ++++++- tools/ae.c | 30 ++++++++++--------- 3 files changed, 32 insertions(+), 15 deletions(-) create mode 100644 new_changelogs/20261101T140000Z-fixed-3485-refused-import-cleans-up.md diff --git a/new_changelogs/20261101T140000Z-fixed-3485-refused-import-cleans-up.md b/new_changelogs/20261101T140000Z-fixed-3485-refused-import-cleans-up.md new file mode 100644 index 000000000..2915fc5d0 --- /dev/null +++ b/new_changelogs/20261101T140000Z-fixed-3485-refused-import-cleans-up.md @@ -0,0 +1,6 @@ +- **A refused binary import leaves nothing in the temp directory (#3485).** + `ae` refused it from inside the stub it was writing, so on Windows the + open file kept its `ae-binimport--` directory from being + removed: one more for every refused build, over a thousand on one + development machine. The stub is now closed and removed before `ae` + exits. diff --git a/tests/integration/binary_import_ownership/test_binary_import_ownership.sh b/tests/integration/binary_import_ownership/test_binary_import_ownership.sh index d3253f35c..5ad24b6e8 100644 --- a/tests/integration/binary_import_ownership/test_binary_import_ownership.sh +++ b/tests/integration/binary_import_ownership/test_binary_import_ownership.sh @@ -62,11 +62,20 @@ build_app() { # refused