From 651b17796e9666dfdce0c6f226478c0e260cffa2 Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sun, 6 Sep 2026 15:15:53 +0100 Subject: [PATCH 1/3] fix a double-free: a heap-string field taken from a borrowed local was freed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a heap-string struct field is assigned from a bare heap-tracked local (`a.value = v`), the field's `_heap_` tracker was hard-coded to 1. But a local classified as a heap-string var can hold a BORROWED value at runtime — e.g. one returned by a function that passes a parameter or a literal straight through, which leaves the local's own `_heap_` at 0. The struct destructor then freed a pointer the program never owned: a string literal (an invalid free of read-only data) or a value still owned elsewhere (a double free). It reached a downstream HTML sanitizer as a callback/FFI crash: an `on_filter_url` hook returns a literal `string`, the engine threads it through several `-> string` helpers and stores it into a heap-boxed attribute's string field; the program printed correct output, then aborted with `invalid pointer` / SIGSEGV when the node tree was freed. The generated store read `attr->_heap_value = 1` even though the source local's `_heap_...` was 0. Fix: when the RHS is a bare heap-tracked-var identifier, the field store now MOVES the source's runtime ownership into the field tracker rather than asserting 1: a->value = v; a->_heap_value = _heap_v; _heap_v = 0; A borrowed value (`_heap_v == 0`) is then not freed by the destructor; a genuinely-owned one (`_heap_v == 1`) is still freed exactly once, and the source is disowned so it is not also freed at scope exit. This is the field- store analogue of the existing `_heap_dest = _heap_src` alias move, and applies to the direct-pointer, value-struct, nested-path, and untrusted-box store paths. Regression follow-up to #1866/#1879. Regression test (tests/integration/heap_field_borrowed_var_no_double_free) asserts the generated store moves the runtime tracker (not `= 1`), the program runs to completion (the bug aborted at teardown), and — via the `owned` half — that a genuinely-heap value is still reclaimed. Confirmed to FAIL on the unfixed compiler (emits `_heap_value = 1`) and pass here. Valgrind clean on both the borrowed (no double free) and owned (freed once, no leak) cases. make test 409/409; make test-ae 1081/1083 (the 2 are the pre-existing windows_crt_symbols + h2 50-stream env flakes). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 18 ++++++ compiler/codegen/codegen_stmt.c | 58 ++++++++++++++--- .../prog.ae | 52 +++++++++++++++ ..._heap_field_borrowed_var_no_double_free.sh | 63 +++++++++++++++++++ 4 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 tests/integration/heap_field_borrowed_var_no_double_free/prog.ae create mode 100755 tests/integration/heap_field_borrowed_var_no_double_free/test_heap_field_borrowed_var_no_double_free.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b45b833..576e0704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,24 @@ version number before tagging the release. ## [current] +### Fixed + +- **A heap-string struct field assigned from a borrowed value was freed at + teardown — double free / free of rodata.** When a heap-string field is + assigned from a bare heap-tracked local (`a.value = v`), the field's + `_heap_` tracker was hard-coded to 1. But a local classified as a + heap-string var can hold a *borrowed* value at runtime — e.g. one returned by + a function that passes a parameter or a literal straight through, leaving its + `_heap_` at 0. The struct destructor then freed a pointer the program + never owned: a literal (free of read-only data) or a value still owned + elsewhere. It surfaced downstream as a callback/FFI crash — a hook returns a + literal `string`, the engine threads it through `-> string` helpers into a + heap-boxed struct field, and teardown aborted with `invalid pointer` / + SIGSEGV. The field store now *moves* the source var's runtime ownership + (`_heap_ = _heap_; _heap_ = 0`) instead of asserting 1, so a + borrowed value is not freed and a genuinely-owned one is still freed exactly + once. Regression follow-up to #1866/#1879. + ## [0.645.0] ### Added diff --git a/compiler/codegen/codegen_stmt.c b/compiler/codegen/codegen_stmt.c index 66447497..8ddbb490 100644 --- a/compiler/codegen/codegen_stmt.c +++ b/compiler/codegen/codegen_stmt.c @@ -1491,6 +1491,9 @@ static int is_ptr_struct_param(CodeGenerator* gen, const char* name) { * is safe regardless and is what stops the leak; a repeated assignment * through a nested path can still drop the earlier value, which is strictly * better than a segfault and matches what a pointer parameter already does. */ +static int emit_field_tracker_from_rhs(CodeGenerator* gen, ASTNode* rhs, + const char* tracker_lvalue); + static int emit_nested_field_heap_assign(CodeGenerator* gen, ASTNode* lhs, ASTNode* rhs, ASTNode* obj) { Type* obj_type = obj->node_type; @@ -1525,14 +1528,43 @@ static int emit_nested_field_heap_assign(CodeGenerator* gen, ASTNode* lhs, char tgt[32]; snprintf(tgt, sizeof(tgt), "_ae_ntgt%d", nested_tgt_seq++); + char tracker_lv[256]; + snprintf(tracker_lv, sizeof(tracker_lv), "%s->_heap_%s", tgt, lhs->value); print_indent(gen); fprintf(gen->output, "{ %s* %s = ", obj_type->element_type->struct_name, tgt); generate_expression(gen, obj); fprintf(gen->output, "; %s->%s = ", tgt, lhs->value); generate_expression(gen, rhs); - fprintf(gen->output, "; %s->_heap_%s = %d; }\n", - tgt, lhs->value, rhs_is_heap ? 1 : 0); + /* Move the source var's runtime ownership when the RHS is a heap-var + * identifier (it may hold a borrow); otherwise the static classification. */ + if (!emit_field_tracker_from_rhs(gen, rhs, tracker_lv)) { + fprintf(gen->output, "; %s = %d;", tracker_lv, rhs_is_heap ? 1 : 0); + } + fprintf(gen->output, " }\n"); + return 1; +} + +/* When a heap-string struct field is assigned from a bare heap-tracked-var + * identifier, the field's `_heap_` tracker must take that variable's + * RUNTIME ownership, not a static 1. A local classified as a heap-string var + * can still hold a BORROWED value at runtime (e.g. it was assigned from a + * function that returns a borrowed/literal pass-through, leaving `_heap_ + * == 0`). Hard-coding the field tracker to 1 then makes the struct destructor + * free a pointer the program never owned — a literal (free of rodata) or a + * value still owned elsewhere (double free). This is the field-store analogue + * of the `_heap_dest = _heap_src` ownership move used for `dest = src` aliases. + * + * Writes ` = _heap_;` then disowns the source + * (`_heap_ = 0;`) so the freeing duty transfers to the field and the + * source is not also freed at scope exit. Returns 1 if it handled the RHS + * (a bare heap-var identifier); 0 to fall back to the static rhs_is_heap. */ +static int emit_field_tracker_from_rhs(CodeGenerator* gen, ASTNode* rhs, + const char* tracker_lvalue) { + if (!gen || !rhs || rhs->type != AST_IDENTIFIER || !rhs->value) return 0; + if (!is_heap_string_var(gen, rhs->value)) return 0; + fprintf(gen->output, "; %s = _heap_%s; _heap_%s = 0;", + tracker_lvalue, rhs->value, rhs->value); return 1; } @@ -1610,6 +1642,9 @@ static int emit_struct_field_heap_assign(CodeGenerator* gen, ASTNode* lhs, ASTNo matching_field->node_type->kind != TYPE_STRING) return 0; int rhs_is_heap = is_heap_string_expr(gen, rhs); + char tracker_lv[256]; + snprintf(tracker_lv, sizeof(tracker_lv), "%s%s_heap_%s", + obj->value, acc, lhs->value); print_indent(gen); if (!tracker_is_trustworthy) { /* #1873: store and SET the tracker (so the destructor still reclaims @@ -1621,8 +1656,12 @@ static int emit_struct_field_heap_assign(CodeGenerator* gen, ASTNode* lhs, ASTNo * the caller can use heap.new to get the releasing behaviour. */ fprintf(gen->output, "{ %s%s%s = ", obj->value, acc, lhs->value); generate_expression(gen, rhs); - fprintf(gen->output, "; %s%s_heap_%s = %d; }\n", - obj->value, acc, lhs->value, rhs_is_heap ? 1 : 0); + /* Move the source var's runtime ownership when the RHS is a heap-var + * identifier (it may hold a borrow); otherwise the static class. */ + if (!emit_field_tracker_from_rhs(gen, rhs, tracker_lv)) { + fprintf(gen->output, "; %s = %d;", tracker_lv, rhs_is_heap ? 1 : 0); + } + fprintf(gen->output, " }\n"); return 1; } fprintf(gen->output, @@ -1630,12 +1669,11 @@ static int emit_struct_field_heap_assign(CodeGenerator* gen, ASTNode* lhs, ASTNo obj->value, acc, lhs->value, obj->value, acc, lhs->value); generate_expression(gen, rhs); - fprintf(gen->output, - "; if (%s%s_heap_%s) aether_heap_str_free(_tmp_old); " - "%s%s_heap_%s = %d; }\n", - obj->value, acc, lhs->value, - obj->value, acc, lhs->value, - rhs_is_heap ? 1 : 0); + fprintf(gen->output, "; if (%s) aether_heap_str_free(_tmp_old);", tracker_lv); + if (!emit_field_tracker_from_rhs(gen, rhs, tracker_lv)) { + fprintf(gen->output, " %s = %d;", tracker_lv, rhs_is_heap ? 1 : 0); + } + fprintf(gen->output, " }\n"); return 1; } diff --git a/tests/integration/heap_field_borrowed_var_no_double_free/prog.ae b/tests/integration/heap_field_borrowed_var_no_double_free/prog.ae new file mode 100644 index 00000000..3c1db4ef --- /dev/null +++ b/tests/integration/heap_field_borrowed_var_no_double_free/prog.ae @@ -0,0 +1,52 @@ +// A heap-string struct field assigned from a heap-tracked LOCAL that at +// runtime holds a BORROWED value must not be freed by the struct destructor. +// +// `passthru` returns its parameter straight through, so it is classified +// non-heap: `borrowed` is a heap-string var (it is reassignable to owned +// strings elsewhere in a real program) but its runtime `_heap_borrowed` is 0. +// Before the fix, `a.value = borrowed` hard-coded `_heap_value = 1`, so +// `heap.free(a)` freed a value the program never owned — a literal string +// (free of rodata) — aborting with SIGSEGV / "invalid pointer" at teardown. +// The store must instead MOVE the source's runtime ownership: +// a->value = borrowed; a->_heap_value = _heap_borrowed; _heap_borrowed = 0; +// +// This is the FFI/closure-callback shape a downstream sanitizer hit: a hook +// returns a literal, the engine threads it through `-> string` helpers into a +// heap-boxed attr's string field, and teardown double-freed it. +// +// The `owned` half proves the fix does not regress the leak it protects: +// a genuinely-heap value (string.concat) is still freed exactly once. + +import std.io +import std.string + +struct Attr { + name: string + value: string +} + +fn passthru(u: string) -> string { + return u +} + +main() { + // Borrowed: value the program does not own. Must NOT be freed at teardown. + a = heap.new(Attr) + a.name = "src" + borrowed = passthru("https://cdn.example/logo.png") + ap = a as *Attr + ap.value = borrowed + println("borrowed: ${a.value}") + heap.free(a) + + // Owned: genuinely-heap value. Must be freed exactly once (no leak). + b = heap.new(Attr) + b.name = "href" + owned = string.concat("https://", "cdn.example/style.css") + bp = b as *Attr + bp.value = owned + println("owned: ${b.value}") + heap.free(b) + + println("done") +} diff --git a/tests/integration/heap_field_borrowed_var_no_double_free/test_heap_field_borrowed_var_no_double_free.sh b/tests/integration/heap_field_borrowed_var_no_double_free/test_heap_field_borrowed_var_no_double_free.sh new file mode 100755 index 00000000..47f25cef --- /dev/null +++ b/tests/integration/heap_field_borrowed_var_no_double_free/test_heap_field_borrowed_var_no_double_free.sh @@ -0,0 +1,63 @@ +#!/bin/sh +# A heap-string struct field assigned from a heap-tracked local that holds a +# BORROWED value at runtime must move the source's runtime ownership into the +# field tracker, not hard-code it to 1. Hard-coding 1 made the destructor free +# a value the program never owned (a literal -> free of rodata), aborting at +# teardown. This is the closure/FFI-callback shape a downstream HTML sanitizer +# hit: a hook returns a literal, threaded through `-> string` helpers into a +# heap-boxed attr field, double-freed on free. +# +# Running to completion is half the assertion (a double free / rodata free +# aborts with SIGSEGV or SIGABRT). The emitted ownership move is checked +# directly too, so this means something on CI platforms with no leak checker. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +AETHERC="$ROOT/build/aetherc" +[ -x "$AE" ] || { echo " [SKIP] heap_field_borrowed_var_no_double_free: build/ae missing"; exit 0; } + +TMP="$(mktemp -d)" +cleanup() { rm -rf "$TMP"; } +trap cleanup EXIT + +# 1. Generated C moves the source var's runtime tracker into the field, rather +# than writing a literal 1. The borrowed store must NOT read `= 1`. +if ! "$AETHERC" "$SCRIPT_DIR/prog.ae" "$TMP/out.c" > "$TMP/gen.log" 2>&1; then + echo " [FAIL] heap_field_borrowed_var_no_double_free: codegen failed" + sed 's/^/ /' "$TMP/gen.log" | head -8 + exit 1 +fi +if ! grep -q "_heap_value = _heap_borrowed" "$TMP/out.c"; then + echo " [FAIL] heap_field_borrowed_var_no_double_free: borrowed store did not move the runtime tracker" + echo " expected '..._heap_value = _heap_borrowed; _heap_borrowed = 0;'" + grep -n "value = borrowed\|_heap_value" "$TMP/out.c" | sed 's/^/ /' | head -6 + exit 1 +fi +if grep -q "value = borrowed; ap->_heap_value = 1" "$TMP/out.c"; then + echo " [FAIL] heap_field_borrowed_var_no_double_free: borrowed store still hard-codes _heap_value = 1" + exit 1 +fi + +# 2. It runs to completion — the actual bug was a teardown abort. Build a real +# binary and run it; a double free / rodata free crashes here. +if ! "$AE" build "$SCRIPT_DIR/prog.ae" -o "$TMP/prog" > "$TMP/build.log" 2>&1; then + echo " [FAIL] heap_field_borrowed_var_no_double_free: build failed" + sed 's/^/ /' "$TMP/build.log" | head -8 + exit 1 +fi +if ! "$TMP/prog" > "$TMP/run.out" 2>&1; then + echo " [FAIL] heap_field_borrowed_var_no_double_free: program aborted at runtime (rc $?)" + sed 's/^/ /' "$TMP/run.out" | head -8 + exit 1 +fi +if ! grep -q "^done$" "$TMP/run.out"; then + echo " [FAIL] heap_field_borrowed_var_no_double_free: did not reach 'done'" + sed 's/^/ /' "$TMP/run.out" | head -8 + exit 1 +fi + +echo " [PASS] heap_field_borrowed_var_no_double_free: borrowed field-store moves runtime ownership; no teardown double-free" +exit 0 From d6e6f658deb3ce86e3ea7a403e0ec6315f400374 Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Tue, 8 Sep 2026 02:15:07 -0300 Subject: [PATCH 2/3] fix(codegen): keep the ownership flag true when the value escapes Reading a var's runtime ownership only works if that value is maintained, and for one shape it was not. When a heap-string var's value escapes into a container or a struct field its own frees are suppressed, because the recipient may have kept the pointer, and on that path the assignment left `_heap_` at its stale 0 on the reasoning that nothing would read it. The field store added here reads it. So a genuinely owned value moved a 0 into the field tracker and the destructor never reclaimed the buffer: the borrowed double-free was traded for a leak. std.message leaked 60 allocations of 2880 bytes through `s = strbuilder.finish(b)` followed by `n.text = s`, which the macOS leaks gate caught and this branch's CI reported. The flag is the ownership token, so record it whether or not this scope is the one that acts on it. Nothing new is freed as a result: the scope-exit free skips escaped vars outright (push_heap_string_exit_free_defers), and the reassignment wrapper on this path still emits a plain store with no free. The flag now only feeds the two consumers that want the truth, the field-store move and the alias transfer. The owned half of the regression test could not have caught this: the constant folder reduces `string.concat("https://", "cdn.example/style.css")` to a literal, so it ran with `_heap_owned == 0` and exercised the borrowed path a second time. Added a case built through a call the folder cannot see through, asserted on the generated C so platforms with no leak checker fail too, and verified freed exactly once under leaks(1). Verified in both directions: reverting the tracker fix restores 60 leaks in test_message and 1 in the new case, and turns the extended regression test red. Also normalised the emitted separators, which were producing `;;` in the generated C. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 12 +++++++ compiler/codegen/codegen_stmt.c | 28 +++++++++++++--- .../prog.ae | 33 ++++++++++++++++++- ..._heap_field_borrowed_var_no_double_free.sh | 31 +++++++++++++++++ 4 files changed, 98 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8945d6f2..0b0e8408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,18 @@ version number before tagging the release. borrowed value is not freed and a genuinely-owned one is still freed exactly once. Regression follow-up to #1866/#1879. + Reading the tracker only works if the tracker is maintained, and for one + shape it was not. When a heap-string var's value escapes into a container or + a struct field, its own frees are suppressed (the recipient may have kept the + pointer), and on that path the assignment left `_heap_` at its stale 0 + on the reasoning that nothing would read it. The field store now reads it, so + a genuinely owned value (`s = strbuilder.finish(b)` then `n.text = s`) moved + a 0 into the field tracker and the destructor never reclaimed the buffer. + `std.message` leaked 60 allocations exactly this way, caught by the macOS + leaks gate. The flag is the ownership token, so it is now recorded whether or + not this scope is the one that acts on it; the escape-suppressed frees are + unchanged. + ## [0.647.0] ### Fixed diff --git a/compiler/codegen/codegen_stmt.c b/compiler/codegen/codegen_stmt.c index 8ddbb490..61e5af00 100644 --- a/compiler/codegen/codegen_stmt.c +++ b/compiler/codegen/codegen_stmt.c @@ -1536,10 +1536,11 @@ static int emit_nested_field_heap_assign(CodeGenerator* gen, ASTNode* lhs, generate_expression(gen, obj); fprintf(gen->output, "; %s->%s = ", tgt, lhs->value); generate_expression(gen, rhs); + fprintf(gen->output, ";"); /* Move the source var's runtime ownership when the RHS is a heap-var * identifier (it may hold a borrow); otherwise the static classification. */ if (!emit_field_tracker_from_rhs(gen, rhs, tracker_lv)) { - fprintf(gen->output, "; %s = %d;", tracker_lv, rhs_is_heap ? 1 : 0); + fprintf(gen->output, " %s = %d;", tracker_lv, rhs_is_heap ? 1 : 0); } fprintf(gen->output, " }\n"); return 1; @@ -1563,7 +1564,7 @@ static int emit_field_tracker_from_rhs(CodeGenerator* gen, ASTNode* rhs, const char* tracker_lvalue) { if (!gen || !rhs || rhs->type != AST_IDENTIFIER || !rhs->value) return 0; if (!is_heap_string_var(gen, rhs->value)) return 0; - fprintf(gen->output, "; %s = _heap_%s; _heap_%s = 0;", + fprintf(gen->output, " %s = _heap_%s; _heap_%s = 0;", tracker_lvalue, rhs->value, rhs->value); return 1; } @@ -1656,10 +1657,11 @@ static int emit_struct_field_heap_assign(CodeGenerator* gen, ASTNode* lhs, ASTNo * the caller can use heap.new to get the releasing behaviour. */ fprintf(gen->output, "{ %s%s%s = ", obj->value, acc, lhs->value); generate_expression(gen, rhs); + fprintf(gen->output, ";"); /* Move the source var's runtime ownership when the RHS is a heap-var * identifier (it may hold a borrow); otherwise the static class. */ if (!emit_field_tracker_from_rhs(gen, rhs, tracker_lv)) { - fprintf(gen->output, "; %s = %d;", tracker_lv, rhs_is_heap ? 1 : 0); + fprintf(gen->output, " %s = %d;", tracker_lv, rhs_is_heap ? 1 : 0); } fprintf(gen->output, " }\n"); return 1; @@ -5025,9 +5027,25 @@ void generate_statement(CodeGenerator* gen, ASTNode* stmt) { emit_unwind_track_local(gen, stmt->value); fprintf(gen->output, " }\n"); } else { - fprintf(gen->output, "%s = ", stmt->value); + /* Record the value's heapness even though this + * var's own frees are escape-suppressed. The + * tracker used to be left at its stale value here + * on the reasoning that nothing would read it: the + * defer-free and the reassignment wrapper are both + * suppressed for an escaped var, so the flag was + * dead weight. It is not dead any more. A struct + * field store now MOVES this flag into the field's + * own tracker to decide whether the destructor + * frees, so a stale 0 on a genuinely owned value + * (`s = strbuilder.finish(b)` then `n.text = s`) + * tells the destructor to leave it alone and the + * buffer leaks. The flag is the ownership token; + * it has to be true whether or not this scope is + * the one that acts on it. */ + fprintf(gen->output, "{ %s = ", stmt->value); generate_expression(gen, stmt->children[0]); - fprintf(gen->output, ";\n"); + fprintf(gen->output, "; _heap_%s = %d; }\n", + stmt->value, rhs_is_heap ? 1 : 0); } } else if (var_is_string && stmt->child_count > 0) { // Defensive: if the hoist somehow missed this diff --git a/tests/integration/heap_field_borrowed_var_no_double_free/prog.ae b/tests/integration/heap_field_borrowed_var_no_double_free/prog.ae index 3c1db4ef..92e3c5e6 100644 --- a/tests/integration/heap_field_borrowed_var_no_double_free/prog.ae +++ b/tests/integration/heap_field_borrowed_var_no_double_free/prog.ae @@ -15,10 +15,21 @@ // heap-boxed attr's string field, and teardown double-freed it. // // The `owned` half proves the fix does not regress the leak it protects: -// a genuinely-heap value (string.concat) is still freed exactly once. +// a genuinely-heap value is still freed exactly once. +// +// `owned` alone is not enough, and the reason is worth stating: the compiler +// constant-folds `string.concat("https://", "cdn.example/style.css")` into a +// literal, so that half runs with `_heap_owned == 0` and exercises the +// borrowed path a second time rather than the owned one. `runtime_owned` is +// built through a call the folder cannot see through, so it is genuinely heap +// at runtime -- and it is the shape that leaked: for a var whose value escapes +// into a struct field, the assignment used to leave `_heap_` at its stale +// 0, and moving that 0 into the field tracker told the destructor the buffer +// was borrowed. std.message leaked 60 allocations exactly this way. import std.io import std.string +import std.strbuilder struct Attr { name: string @@ -29,6 +40,15 @@ fn passthru(u: string) -> string { return u } +// Built through a call, so the constant folder cannot reduce it to a literal: +// the result is genuinely heap-allocated at runtime. +fn build_owned(n: int) -> string { + b = strbuilder.new(16) + strbuilder.append(b, "https://cdn.example/") + strbuilder.append_int(b, n) + return strbuilder.finish(b) +} + main() { // Borrowed: value the program does not own. Must NOT be freed at teardown. a = heap.new(Attr) @@ -48,5 +68,16 @@ main() { println("owned: ${b.value}") heap.free(b) + // Genuinely heap at runtime, and escaping into a struct field. The field + // tracker must receive 1 so the destructor reclaims it exactly once: + // freeing nothing here is the leak, freeing twice is the crash above. + c = heap.new(Attr) + c.name = "src" + runtime_owned = build_owned(7) + cp = c as *Attr + cp.value = runtime_owned + println("runtime_owned: ${c.value}") + heap.free(c) + println("done") } diff --git a/tests/integration/heap_field_borrowed_var_no_double_free/test_heap_field_borrowed_var_no_double_free.sh b/tests/integration/heap_field_borrowed_var_no_double_free/test_heap_field_borrowed_var_no_double_free.sh index 47f25cef..741a7934 100755 --- a/tests/integration/heap_field_borrowed_var_no_double_free/test_heap_field_borrowed_var_no_double_free.sh +++ b/tests/integration/heap_field_borrowed_var_no_double_free/test_heap_field_borrowed_var_no_double_free.sh @@ -41,6 +41,25 @@ if grep -q "value = borrowed; ap->_heap_value = 1" "$TMP/out.c"; then exit 1 fi +# 1b. The mirror image: a var holding a GENUINELY owned value must reach the +# field tracker as 1, or the destructor frees nothing and the buffer leaks. +# For a var whose value escapes into a struct field the assignment used to +# leave `_heap_` at its stale 0, so the move carried a 0 and the leak +# was silent on every platform without a leak checker. Asserted on the +# generated C so it fails on those platforms too. +if ! grep -q "runtime_owned = build_owned(7); _heap_runtime_owned = 1" "$TMP/out.c"; then + echo " [FAIL] heap_field_borrowed_var_no_double_free: an owned value that escapes" + echo " into a field left its tracker stale, so the field tracker takes 0" + echo " and the destructor never frees it" + grep -n "runtime_owned" "$TMP/out.c" | sed 's/^/ /' | head -6 + exit 1 +fi +if ! grep -q "_heap_value = _heap_runtime_owned" "$TMP/out.c"; then + echo " [FAIL] heap_field_borrowed_var_no_double_free: owned store did not move the runtime tracker" + grep -n "value = runtime_owned\|_heap_value" "$TMP/out.c" | sed 's/^/ /' | head -6 + exit 1 +fi + # 2. It runs to completion — the actual bug was a teardown abort. Build a real # binary and run it; a double free / rodata free crashes here. if ! "$AE" build "$SCRIPT_DIR/prog.ae" -o "$TMP/prog" > "$TMP/build.log" 2>&1; then @@ -59,5 +78,17 @@ if ! grep -q "^done$" "$TMP/run.out"; then exit 1 fi +# 3. Freed exactly once, where a leak checker is available. leaks(1) is the one +# that works on macOS; Linux CI covers the same program under Valgrind. +if [ "$(uname -s)" = Darwin ] && command -v leaks >/dev/null 2>&1; then + if ! MallocStackLogging=1 leaks --atExit -- "$TMP/prog" > "$TMP/leaks.out" 2>&1; then + if grep -qE "[1-9][0-9]* leak(s)? for" "$TMP/leaks.out"; then + echo " [FAIL] heap_field_borrowed_var_no_double_free: the owned value leaked" + grep -E "leaks? for" "$TMP/leaks.out" | sed 's/^/ /' | head -3 + exit 1 + fi + fi +fi + echo " [PASS] heap_field_borrowed_var_no_double_free: borrowed field-store moves runtime ownership; no teardown double-free" exit 0 From 45ef1618b5a2da6828389ca9794b679fe8e338ca Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Tue, 8 Sep 2026 08:45:25 -0300 Subject: [PATCH 3/3] docs(changelog): move the entry back under [current] after 0.648.0 Main cut 0.648.0 while this branch was open, so merging it folded this entry under the tagged heading with no conflict. Restore [0.648.0] as main has it and put the entry under a fresh [current]. --- CHANGELOG.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70711acd..4ac48904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,19 +11,17 @@ version number before tagging the release. ## [current] -## [0.648.0] - ### Fixed - **A heap-string struct field assigned from a borrowed value was freed at - teardown — double free / free of rodata.** When a heap-string field is + teardown, a double free / free of rodata.** When a heap-string field is assigned from a bare heap-tracked local (`a.value = v`), the field's `_heap_` tracker was hard-coded to 1. But a local classified as a - heap-string var can hold a *borrowed* value at runtime — e.g. one returned by + heap-string var can hold a *borrowed* value at runtime, e.g. one returned by a function that passes a parameter or a literal straight through, leaving its `_heap_` at 0. The struct destructor then freed a pointer the program never owned: a literal (free of read-only data) or a value still owned - elsewhere. It surfaced downstream as a callback/FFI crash — a hook returns a + elsewhere. It surfaced downstream as a callback/FFI crash: a hook returns a literal `string`, the engine threads it through `-> string` helpers into a heap-boxed struct field, and teardown aborted with `invalid pointer` / SIGSEGV. The field store now *moves* the source var's runtime ownership @@ -42,6 +40,11 @@ version number before tagging the release. leaks gate. The flag is the ownership token, so it is now recorded whether or not this scope is the one that acts on it; the escape-suppressed frees are unchanged. + +## [0.648.0] + +### Fixed + - **Three more server fixtures bind an ephemeral port** (part of #1920): `http_auth`, which runs two servers from one binary, and the two websocket fixtures, including the python peer `ws_client_conformance` dials. Their