Skip to content

Commit 35c476d

Browse files
hyperpolymathclaude
andcommitted
fix(codegen): enum-in-match stack imbalance + param struct-field reads
Two codegen bugs discovered 2026-04-19 while writing the double-track-browser extension-lifecycle pilot (KNOWN-ISSUES.md). 1. Enum-in-match stack imbalance. gen_pattern's PatCon-with-args branch saved the tag-test boolean via LocalTee on a fresh __match_result local and then re-pushed it via LocalGet after stack-neutral binding code. The enclosing If expected ONE i32 on the stack but saw TWO, failing WASM validation: "expected 1 elements on the stack for fallthru, found 2". Drop the save/restore; leave I32Eq directly on the stack. Secondary: ExprVar now falls back to ctx.variant_tags when lookup_local misses, so bare `Initialised` (no parens) resolves as the variant's tag — matching the parser's acceptance. 2. Non-first struct-field read from function parameter returned 0. The per-variable field_layouts map was only populated by let-bound ExprRecord literals; every other binding path defaulted offsets to 0, so `.field_1_or_later` read the tag byte. Register struct layouts globally in ctx.struct_layouts from TopType(TyStruct) and propagate them to: function params (p_ty), call-result lets (fn_ret_structs seeded from fd_ret_ty), let-bindings with a type annotation (sl_ty), and let-from-let passthroughs. Full test suite: 177/177 green. Regression fixtures live in the sibling affinescript-deno-test harness; the double-track-browser extension_lifecycle_test.affine pilot now runs 10/10 without the tagged-struct workaround. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1d10951 commit 35c476d

2 files changed

Lines changed: 166 additions & 38 deletions

File tree

KNOWN-ISSUES.md

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ Minimal reproducers for codegen bugs discovered while using AffineScript
55
downstream. Please open a proper GitHub issue (or link an existing one)
66
before starting a fix so the fix can be attributed.
77

8+
> **Status 2026-04-19:** both issues below are FIXED in `lib/codegen.ml`.
9+
> Regression coverage lives in `developer-ecosystem/affinescript-ecosystem/
10+
> affinescript-deno-test/example/codegen_regression_test.affine` and the
11+
> double-track-browser `extension_lifecycle_test.affine` (10/10 green).
12+
> The reproducers are retained for history; workarounds below are no
13+
> longer needed.
14+
815
---
916

1017
## Issue 1 — `match` on enum with distinct zero-arity constructors per arm emits invalid WASM
@@ -46,8 +53,20 @@ struct State {
4653
}
4754
```
4855

49-
**Likely location in compiler:** `lib/codegen.ml` around match-arm
50-
lowering for enum construction sites.
56+
**Root cause:** `gen_pattern` for `PatCon` with args saved the tag-test
57+
boolean via `LocalTee` on a fresh `__match_result` local and then
58+
pushed the same value via `LocalGet` at the end of the pattern code,
59+
leaving the stack with TWO booleans where the enclosing `If` expected
60+
ONE. Bindings between save and restore are stack-neutral by
61+
construction, so the save/restore was redundant.
62+
63+
**Fix:** remove the LocalTee/LocalGet pair, leaving the `I32Eq` result
64+
directly on the stack. (`lib/codegen.ml`, `gen_pattern` / `PatCon` arm.)
65+
Secondary fix in the same commit: `ExprVar` falls back to
66+
`ctx.variant_tags` when `lookup_local` misses, so bare `Initialised`
67+
(parens omitted) is accepted as a zero-arity constructor expression —
68+
the parser accepts the form, but codegen previously failed with
69+
`UnboundVariable`.
5170

5271
---
5372

@@ -110,18 +129,30 @@ pub fn test_via_scalar_helper() -> Bool {
110129
}
111130
```
112131

113-
**Likely location in compiler:** `lib/codegen.ml` around struct-field
114-
access codegen for function-parameter locals (likely a wrong local
115-
index or wrong offset calculation when the struct pointer is a function
116-
parameter rather than a let-bound local).
132+
**Root cause:** the per-variable `field_layouts` map was populated
133+
only by `let`-bindings whose RHS was a record literal. Every other
134+
binding path — function parameters of struct type, let-bindings from
135+
function calls returning struct, type-annotated lets — fell through
136+
to a default offset of 0, so `.field_1_or_later` read the tag byte
137+
instead of the real field.
138+
139+
**Fix:** register struct field layouts globally in
140+
`ctx.struct_layouts` from `TopType(TyStruct)` decls, and propagate
141+
them to (a) function parameters via `p_ty`, (b) call-result lets via
142+
a new `fn_ret_structs` map populated from `fd_ret_ty`, (c) let-bindings
143+
with an explicit type annotation (`sl_ty`), (d) let-bindings whose RHS
144+
is another tracked variable. (`lib/codegen.ml`, `gen_function` /
145+
`StmtLet` / `gen_decl`.)
117146

118147
---
119148

120149
## Tracking
121150

122-
Both issues block scaling the `affinescript-deno-test` harness (sibling
123-
component at `developer-ecosystem/affinescript-ecosystem/
124-
affinescript-deno-test/`) from its current 10-test pilot to the
125-
estate-wide TypeScript-test migration in AI-WORK-todo.md §3c.
151+
Both issues were unblocking the `affinescript-deno-test` harness
152+
(sibling component at `developer-ecosystem/affinescript-ecosystem/
153+
affinescript-deno-test/`) for idiomatic enum + struct test idioms.
154+
With these fixes the harness now runs the double-track-browser
155+
extension-lifecycle pilot (10/10 green) and is ready to scale beyond
156+
the MVP shape into the estate-wide TypeScript-test migration.
126157

127158
Estate tracker: `~/Desktop/AI-WORK-todo.md §11`.

lib/codegen.ml

Lines changed: 125 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,15 @@ type context = {
3232
lambda_funcs : func list; (** lifted lambda functions *)
3333
next_lambda_id : int; (** next lambda function ID *)
3434
heap_ptr : int option; (** global index for heap pointer, if initialized *)
35-
field_layouts : (string * (string * int) list) list; (** type name -> [(field, offset)] *)
35+
field_layouts : (string * (string * int) list) list; (** variable name -> [(field, offset)] *)
36+
struct_layouts : (string * (string * int) list) list;
37+
(** Struct type name -> [(field, offset)]. Registered from TopType(TyStruct)
38+
at decl time so function-parameter and call-result field accesses can
39+
recover the field layout by type, not by let-binding shape. *)
40+
fn_ret_structs : (string * string) list;
41+
(** Function name -> struct type name it returns (if any). Lets a
42+
`let s = make()` call site register s's field layout in field_layouts
43+
when the callee's return type is a known struct. *)
3644
variant_tags : (string * int) list; (** constructor name -> tag (int) *)
3745
string_data : (string * int) list; (** string content -> memory offset *)
3846
next_string_offset : int; (** next available offset for string data *)
@@ -78,6 +86,8 @@ let create_context () : context = {
7886
next_lambda_id = 0;
7987
heap_ptr = None;
8088
field_layouts = [];
89+
struct_layouts = [];
90+
fn_ret_structs = [];
8191
variant_tags = [];
8292
string_data = [];
8393
next_string_offset = 2048; (* Start strings after heap at offset 2048 *)
@@ -99,6 +109,17 @@ let ownership_kind_of_param (p : param) : ownership_kind =
99109
| TyMut _ -> ExclBorrow
100110
| _ -> Unrestricted
101111

112+
(** If [ty] names a known struct (through any number of own/ref/mut wrappers),
113+
return that struct's name. Lets us recover a struct's field layout from
114+
parameter and return-type annotations so `.field_N` reads use the correct
115+
offset instead of defaulting to 0. *)
116+
let rec struct_name_of_ty (ty : type_expr) : string option =
117+
match ty with
118+
| TyCon id -> Some id.name
119+
| TyApp (id, _) -> Some id.name
120+
| TyOwn inner | TyRef inner | TyMut inner -> struct_name_of_ty inner
121+
| _ -> None
122+
102123
(** Extract ownership kind from an optional return type expression *)
103124
let ownership_kind_of_ret (ret : type_expr option) : ownership_kind =
104125
match ret with
@@ -397,8 +418,20 @@ let rec gen_expr (ctx : context) (expr : expr) : (context * instr list) result =
397418
Ok (ctx', [instr])
398419

399420
| ExprVar id ->
400-
let* idx = lookup_local ctx id.name in
401-
Ok (ctx, [LocalGet idx])
421+
begin match lookup_local ctx id.name with
422+
| Ok idx -> Ok (ctx, [LocalGet idx])
423+
| Error _ ->
424+
(* Fallback: bare identifier that names a zero-arity enum variant.
425+
Matches the ExprCall resolution at line 658 so that both
426+
`Initialised` and `Initialised()` work as expressions when the
427+
name is known as a variant constructor. Without this, a match
428+
arm body of the form `Uninitialised => Initialised` fails with
429+
UnboundVariable even though the parser accepts it. *)
430+
begin match List.assoc_opt id.name ctx.variant_tags with
431+
| Some tag -> Ok (ctx, [I32Const (Int32.of_int tag)])
432+
| None -> Error (UnboundVariable id.name)
433+
end
434+
end
402435

403436
| ExprBinary (left, op, right) ->
404437
let* (ctx', left_code) = gen_expr ctx left in
@@ -1215,20 +1248,24 @@ and gen_pattern (ctx : context) (scrutinee_local : int) (pat : pattern)
12151248
({ ctx with variant_tags = (con.name, new_tag) :: ctx.variant_tags }, new_tag)
12161249
in
12171250

1218-
(* Allocate temp for match result *)
1219-
let (ctx_with_temp, match_result_idx) = alloc_local ctx_with_tag "__match_result" in
1220-
1221-
(* Test: load tag from scrutinee and compare, save result *)
1251+
(* Test: load tag from scrutinee and compare. Leaves the boolean
1252+
on the stack. Binding code below is stack-neutral (see net-zero
1253+
analysis in bind_fields), so the boolean survives through to the
1254+
end of full_code. Prior implementation saved the bool via
1255+
LocalTee and re-pushed via LocalGet at the end, which left TWO
1256+
booleans on the stack and broke WASM validation in any match arm
1257+
whose body produced an i32 result — e.g. enum-in-match returning
1258+
distinct zero-arity or arg constructors across arms. *)
12221259
let tag_test = [
12231260
LocalGet scrutinee_local; (* variant pointer *)
12241261
I32Load (2, 0); (* load tag from offset 0 *)
12251262
I32Const (Int32.of_int tag);
1226-
I32Eq;
1227-
LocalTee match_result_idx; (* Save match result *)
1263+
I32Eq; (* bool on stack — one value *)
12281264
] in
12291265

1230-
(* Extract fields and bind variables *)
1231-
(* For now, only support PatVar sub-patterns *)
1266+
(* Extract fields and bind variables. Each bind is net-zero on the
1267+
stack (LocalGet +1, I32Load 0, LocalSet -1), so the tag-test
1268+
boolean above remains on top of the stack as the pattern result. *)
12321269
let rec bind_fields ctx_acc bindings_acc offset patterns =
12331270
match patterns with
12341271
| [] -> Ok (ctx_acc, bindings_acc)
@@ -1252,10 +1289,9 @@ and gen_pattern (ctx : context) (scrutinee_local : int) (pat : pattern)
12521289
end
12531290
in
12541291

1255-
let* (ctx_final, binding_code) = bind_fields ctx_with_temp [] 4 sub_patterns in
1292+
let* (ctx_final, binding_code) = bind_fields ctx_with_tag [] 4 sub_patterns in
12561293

1257-
(* Combine: test tag, bind fields, return test result *)
1258-
let full_code = tag_test @ binding_code @ [LocalGet match_result_idx] in
1294+
let full_code = tag_test @ binding_code in
12591295

12601296
Ok (ctx_final, full_code, [])
12611297

@@ -1378,15 +1414,46 @@ and gen_stmt (ctx : context) (stmt : stmt) : (context * instr list) result =
13781414
let* (ctx', rhs_code) = gen_expr ctx sl.sl_value in
13791415
begin match sl.sl_pat with
13801416
| PatVar id ->
1381-
let (ctx'', idx) = alloc_local ctx' id.name in
1382-
(* If RHS is a record, track its field layout *)
1383-
let ctx_with_layout = match sl.sl_value with
1417+
(* Track the bound variable's field layout so subsequent `.field_N`
1418+
reads pick the right offset. Three sources, in order:
1419+
1. Explicit `let s: State = ...` annotation → look up struct_layouts.
1420+
2. RHS is a record literal → layout from literal's field order.
1421+
3. RHS is `f(...)` where f's declared return type is a struct
1422+
→ look up fn_ret_structs then struct_layouts.
1423+
4. RHS is another bound variable `let t = s` where s has a
1424+
tracked layout → copy it.
1425+
Any source misses fall back to no tracking (existing behaviour). *)
1426+
let layout_from_ty_annot () =
1427+
match sl.sl_ty with
1428+
| Some ty ->
1429+
begin match struct_name_of_ty ty with
1430+
| Some sname -> List.assoc_opt sname ctx'.struct_layouts
1431+
| None -> None
1432+
end
1433+
| None -> None
1434+
in
1435+
let layout_from_rhs () =
1436+
match sl.sl_value with
13841437
| ExprRecord rec_expr ->
1385-
let field_layout = List.mapi (fun i (field_name, _) ->
1386-
(field_name.name, i * 4)
1387-
) rec_expr.er_fields in
1388-
{ ctx'' with field_layouts = (id.name, field_layout) :: ctx''.field_layouts }
1389-
| _ -> ctx''
1438+
Some (List.mapi (fun i (fn, _) -> (fn.name, i * 4)) rec_expr.er_fields)
1439+
| ExprApp (ExprVar fn_id, _) ->
1440+
begin match List.assoc_opt fn_id.name ctx'.fn_ret_structs with
1441+
| Some sname -> List.assoc_opt sname ctx'.struct_layouts
1442+
| None -> None
1443+
end
1444+
| ExprVar src_id -> List.assoc_opt src_id.name ctx'.field_layouts
1445+
| _ -> None
1446+
in
1447+
let layout_opt =
1448+
match layout_from_ty_annot () with
1449+
| Some _ as s -> s
1450+
| None -> layout_from_rhs ()
1451+
in
1452+
let (ctx'', idx) = alloc_local ctx' id.name in
1453+
let ctx_with_layout = match layout_opt with
1454+
| Some layout ->
1455+
{ ctx'' with field_layouts = (id.name, layout) :: ctx''.field_layouts }
1456+
| None -> ctx''
13901457
in
13911458
Ok (ctx_with_layout, rhs_code @ [LocalSet idx])
13921459
| _ ->
@@ -1659,9 +1726,23 @@ let gen_function (ctx : context) (fd : fn_decl) : (context * func) result =
16591726
(* Create fresh context for function scope, but preserve lambda_funcs and next_lambda_id *)
16601727
let fn_ctx = { ctx with locals = []; next_local = 0; loop_depth = 0 } in
16611728

1662-
(* Parameters become locals 0..n-1 *)
1729+
(* Parameters become locals 0..n-1. When a parameter's declared type
1730+
names a known struct, register its field layout under the parameter
1731+
name so body-side `.field_N` reads resolve to the correct offset
1732+
rather than defaulting to 0. *)
16631733
let (ctx_with_params, _) = List.fold_left (fun (c, _) param ->
1664-
alloc_local c param.p_name.name
1734+
let (c', idx) = alloc_local c param.p_name.name in
1735+
let c'' =
1736+
match struct_name_of_ty param.p_ty with
1737+
| Some sname ->
1738+
begin match List.assoc_opt sname c'.struct_layouts with
1739+
| Some layout ->
1740+
{ c' with field_layouts = (param.p_name.name, layout) :: c'.field_layouts }
1741+
| None -> c'
1742+
end
1743+
| None -> c'
1744+
in
1745+
(c'', idx)
16651746
) (fn_ctx, 0) fd.fd_params in
16661747

16671748
let param_count = List.length fd.fd_params in
@@ -1711,10 +1792,21 @@ let gen_decl (ctx : context) (decl : top_level) : context result =
17111792
let param_kinds = List.map ownership_kind_of_param fd.fd_params in
17121793
let ret_kind = ownership_kind_of_ret fd.fd_ret_ty in
17131794

1714-
(* Register function name to index mapping and record ownership annotations *)
1795+
(* Register function name to index mapping and record ownership annotations.
1796+
Also record the function's return struct name (if any) so call sites
1797+
`let s = f(...)` can register s's field layout. *)
1798+
let fn_ret_structs' = match fd.fd_ret_ty with
1799+
| Some ty ->
1800+
begin match struct_name_of_ty ty with
1801+
| Some sname -> (fd.fd_name.name, sname) :: ctx_with_type.fn_ret_structs
1802+
| None -> ctx_with_type.fn_ret_structs
1803+
end
1804+
| None -> ctx_with_type.fn_ret_structs
1805+
in
17151806
let ctx_with_func_idx = { ctx_with_type with
17161807
func_indices = ctx_with_type.func_indices @ [(fd.fd_name.name, func_idx)];
17171808
ownership_annots = ctx_with_type.ownership_annots @ [(func_idx, param_kinds, ret_kind)];
1809+
fn_ret_structs = fn_ret_structs';
17181810
} in
17191811

17201812
(* Generate function with correct type index *)
@@ -1756,7 +1848,6 @@ let gen_decl (ctx : context) (decl : top_level) : context result =
17561848
Ok ctx''
17571849

17581850
| TopType td ->
1759-
(* Register variant tags for enum types *)
17601851
begin match td.td_body with
17611852
| TyEnum variants ->
17621853
(* Assign sequential tags to each variant constructor *)
@@ -1765,8 +1856,14 @@ let gen_decl (ctx : context) (decl : top_level) : context result =
17651856
{ c_acc with variant_tags = (vd.vd_name.name, idx) :: c_acc.variant_tags }
17661857
) ctx (List.mapi (fun i v -> (i, v)) variants) in
17671858
Ok ctx_with_tags
1768-
| _ ->
1769-
(* Other type declarations (alias, struct) don't need codegen *)
1859+
| TyStruct fields ->
1860+
(* Build the struct's field layout so function parameters and call
1861+
results of this type can resolve `.field_N` to the correct offset.
1862+
Layout: fields packed sequentially at 4-byte offsets, matching the
1863+
ExprRecord store path which writes fields in declaration order. *)
1864+
let layout = List.mapi (fun i sf -> (sf.sf_name.name, i * 4)) fields in
1865+
Ok { ctx with struct_layouts = (td.td_name.name, layout) :: ctx.struct_layouts }
1866+
| TyAlias _ ->
17701867
Ok ctx
17711868
end
17721869

0 commit comments

Comments
 (0)