From b2da9b3d6d33422ddf76c451311192175b8908a7 Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Thu, 20 Aug 2026 03:04:43 +0200 Subject: [PATCH] fix(compiler): Text interp of a place crossed let/assign uncopied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - copy_place_text matched only bare Ident/Field/Index, so a Text-typed single-segment interpolation ("${r.method}", r a loop borrow) passed the place's own register through a binding/assignment boundary — the local aliased the row's field and its overwrite freed it - release-build crash; invisible to ASan (in-arena free, no redzones) - now asks is_borrowed_value_t && not is_container_read — exactly drop_fresh_text's place test; Int segments (fresh int_to_text) and container reads (already copies) stay uncopied as before - pinned by tests/corpus/run/interp-borrowed-field (crashed both runtimes before the fix, 50 iterations now exact) - gates: woc-test 540/0, oop-e2e 89/0 (ASan stage included) Co-Authored-By: Claude Opus 5 (1M context) --- compiler/src/CODE-LOGIC.md | 10 ++++++ compiler/src/emit.ml | 10 +++--- .../run/interp-borrowed-field/fixture.out | 1 + .../run/interp-borrowed-field/fixture.wo | 33 +++++++++++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 tests/corpus/run/interp-borrowed-field/fixture.out create mode 100644 tests/corpus/run/interp-borrowed-field/fixture.wo diff --git a/compiler/src/CODE-LOGIC.md b/compiler/src/CODE-LOGIC.md index c1fbd9c..2cfc085 100644 --- a/compiler/src/CODE-LOGIC.md +++ b/compiler/src/CODE-LOGIC.md @@ -133,6 +133,16 @@ REMOVES the element — the caller owns what it then ignores). The finding tool was an arena size-class census plus a pointer trace, not ASan: an in-arena leak is invisible to LeakSanitizer, because the arena is one allocation. +The framework-v1 slice (2026-08-20) found the copy-side mirror of the +Int-segment lesson: `copy_place_text` matched only bare `Ident/Field/Index`, +so a Text-typed SINGLE-SEGMENT interpolation of a place +(`allow = "${r.method}"` with `r` a loop borrow) passed the place's own +register through a `let`/assignment boundary uncopied — the binding aliased +the row's field and its overwrite freed it (release-build crash the arena +hid from ASan). It now asks `is_borrowed_value_t && not is_container_read`, +exactly `drop_fresh_text`'s place test. Pinned by +`tests/corpus/run/interp-borrowed-field`. + Two rules the measurements imposed, both easy to get backwards: - **Never drop an argument register after a `CALL`.** The callee's frame diff --git a/compiler/src/emit.ml b/compiler/src/emit.ml index 7fda8c4..58971d8 100644 --- a/compiler/src/emit.ml +++ b/compiler/src/emit.ml @@ -1487,10 +1487,12 @@ let rec is_container_read (e : Ast.expr) : bool = own value and the new owner gets its own. A freshly built Text is already nobody else's and passes through untouched. *) let copy_place_text (p : pctx) (f : fstate) (reg : int) (e : Ast.expr) : unit = - let is_place = - (match e.Ast.kind with Ast.Ident _ | Ast.Field _ | Ast.Index _ -> true | _ -> false) - && not (is_container_read e) - in + (* seen through an interpolation exactly as drop_fresh_text sees it: a + single Text segment passes the place's own register through untouched + (`out = "${r.method}"` aliased the row's field and its overwrite freed + it — the 405 Allow-header crash), while an Int segment is already a + fresh int_to_text that must not be re-copied *) + let is_place = is_borrowed_value_t p f e && not (is_container_read e) in let is_text = match ty_of_expr p f e with Some t -> field_kind p t = 3 (* WO_K_TEXT *) | None -> false in diff --git a/tests/corpus/run/interp-borrowed-field/fixture.out b/tests/corpus/run/interp-borrowed-field/fixture.out new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/tests/corpus/run/interp-borrowed-field/fixture.out @@ -0,0 +1 @@ +ok diff --git a/tests/corpus/run/interp-borrowed-field/fixture.wo b/tests/corpus/run/interp-borrowed-field/fixture.wo new file mode 100644 index 0000000..33779ef --- /dev/null +++ b/tests/corpus/run/interp-borrowed-field/fixture.wo @@ -0,0 +1,33 @@ +-- probe: Text interpolation of a borrowed field inside a loop. +-- If the interpolation segment drops the field it only borrows, repeated +-- calls corrupt the arena and the third-ish call crashes in release. + +class Row { + method: Text +} + +class Tab { + rows: multi Row + + fn scan() -> Text { + let out = ""; + for r in self.rows { + if out == "" { out = "${r.method}"; } else { out = "${out}, ${r.method}"; } + } + return out; + } +} + +fn main() -> Int { + let t = Tab { rows: [] }; + push(t.rows, Row { method: "GET" }); + push(t.rows, Row { method: "POST" }); + let i = 0; + while i < 50 { + let s = t.scan(); + if s != "GET, POST" { print("bad: ${s} at ${i}"); return 1; } + i = i + 1; + } + print("ok"); + return 0; +}