Skip to content

Fix a double-free: a heap-string field taken from a borrowed local was freed - #1927

Draft
paul-hammant wants to merge 1 commit into
mainfrom
fix/heap-field-borrowed-var-double-free
Draft

Fix a double-free: a heap-string field taken from a borrowed local was freed#1927
paul-hammant wants to merge 1 commit into
mainfrom
fix/heap-field-borrowed-var-double-free

Conversation

@paul-hammant

Copy link
Copy Markdown
Collaborator

Fixes a memory-corruption regression a downstream HTML sanitizer hit on 0.640→0.645: a callback returns a literal string, the engine stores it into a heap-boxed struct field, and teardown aborts with invalid pointer / SIGSEGV — after printing correct output.

Root cause

When a heap-string struct field is assigned from a bare heap-tracked local
(a.value = v), the field's _heap_<field> 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 the local's own _heap_<var> 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).

The generated store read attr->_heap_value = 1 even though the source's
_heap_... was 0 — the static "is a heap var" classification overrode the
value's actual runtime ownership.

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 instead of 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, applied to the
direct-pointer, value-struct, nested-path, and untrusted-box store paths.
Regression follow-up to #1866/#1879.

Verification

  • 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 an 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.
  • Recompiled the real downstream engine: all four attr->value = … store sites
    now emit the runtime-tracker move.
  • make test 409/409; make test-ae 1081/1083 (the 2 are the pre-existing
    windows_crt_symbols + h2 50-stream env flakes).

🤖 Generated with Claude Code

…s freed

When a heap-string struct field is assigned from a bare heap-tracked local
(`a.value = v`), the field's `_heap_<field>` 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_<var>` 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 <noreply@anthropic.com>
@paul-hammant
paul-hammant marked this pull request as draft September 6, 2026 16:32
@paul-hammant

Copy link
Copy Markdown
Collaborator Author

Do not merge — converting to draft. This branch's commit holds the first (store-site) attempt at the fix, which is not safe: it trades the double-free for a leak in the escape case (s = strbuilder.finish(b); n.text = s; return n), which the macOS leaks gate correctly caught (test_message, verified a real regression against an origin/main baseline on the same machine — the other 18 gate failures there are pre-existing baseline noise / non-determinism).

Full diagnosis, the two indistinguishable cases, both reproducers, and the sound fix (per-variable provenance dataflow rather than a store-site heuristic) are in #1928. The correct fix needs to mark a string var "borrowed" when assigned from a non-heap-owned RHS and consult that at the field store — that is dataflow, not a local guard, so it wants a fresh pass rather than another store-site patch.

Leaving this open as draft for reference; the work should land from #1928.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant