Skip to content

An object's closure field keeps its captures through an interface - #617

Merged
ASDAlexander77 merged 3 commits into
mainfrom
fix-object-closure-interface-fn-field
Oct 19, 2026
Merged

ASDAlexander77 merged 3 commits into
mainfrom
fix-object-closure-interface-fn-field

Conversation

@ASDAlexander77

@ASDAlexander77 ASDAlexander77 commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

Problem

interface HasFn { fn: () => int; }
let k = 5;
const cl = () => k + 1;
const o = { fn: cl };
const h: HasFn = o;   // warning: Cloned object is used
print(h.fn());        // printed garbage (-1962833079), expected 6

The literal stores cl as a bound function (its this is the capture tuple); the interface slot is read as a function taking the object as its this (func<opaque, si32>). The cast cloned the object with the interface's field type, and bound_func -> func<opaque> dropped the captures, so the call bound the object where the captures belonged.

Probing turned up related cases with the same cause:

  • a type-literal annotated field (const o: { add: (a: int) => int } = { add: (a) => a + k }) holds a hybrid function: it worked only while it captured nothing, and crashed once it did
  • an interface method (fn(): int) filled by the closure field: garbage
  • a plain function with parameters ({ add: plusOne }): got the object as its first argument
  • a literal built for the interface (return { fn: cl } from a function returning HasFn) cast the closure while building the literal

Fix

A field holding a function the interface can't call with the object as this (bound, hybrid, or a plain function without a this parameter: MLIRTypeHelper::isObjectFieldFunctionWithoutThis):

  • matches the interface's function-typed member as it is (getInterfaceVirtualTableForObject), and keeps its own type in a clone (getInterfaceCloneFields) and in a literal built for the interface (addObjectFieldInfo)
  • is called through a thunk <vtable>.<field>.thunk(this, ...) { return this.<field>(...) } (generateSynthMethodToCallField, which now takes the this type and is shared with the class path from A class method can fill an interface's function-typed field #612). A method's slot holds the thunk, and a function-typed field's slot holds a method cell tagged with INTERFACE_METHOD_CELL_TAG, as A class method can fill an interface's function-typed field #612 does for classes. The per-cast method patch skips these slots.

Object-literal methods (fn() {...}, or arrows written inside the literal) take the object, so they keep the direct path.

The object's cell uses the {method, flag offset} layout from #615, with -1 (always present). Writing through the interface stays an error ("saving to constant object"), as #615 made it.

Tests

00interface_function_typed_field_object_closure.ts (AOT and JIT, plus rc/none corpus) covers: a capturing closure in a field and a method, a live capture (k = 10 is seen), a closure with a parameter, a hybrid annotated field, plain functions, a clone forced by another field, a literal typed by the interface, an escaping closure returned from a function and used in an array, an optional field, and an object-literal method. It failed before the fix (garbage, then an assert).

Full ctest -C Release -j 16 --timeout 300: 4736/4736 passed on the first push (2 skipped, 1 disabled, as on main), and 4742/4742 after merging main (#615).

🤖 Generated with Claude Code

ASDAlexander77 and others added 3 commits October 19, 2026 15:56
`interface HasFn { fn: () => int }` with `const o = { fn: cl }`, `cl` a
capturing closure: `const h: HasFn = o; h.fn()` printed garbage. The
literal stores the closure as a bound function (its `this` is the capture
tuple), the interface slot is read as a function taking the object as its
`this`. The cast cloned the object with the interface's field type, and
bound_func -> func<opaque> dropped the captures, so the call bound the
object where the captures belonged. A hybrid field (a type-literal
annotated one) crashed the same way once it captured, an interface method
(`fn(): int`) did too, and a plain function (`{ add: plusOne }`) received
the object as its first argument.

Such a field - bound, hybrid, or a plain function without a `this`
parameter (MLIRTypeHelper::isObjectFieldFunctionWithoutThis) - now matches
the interface's function-typed member as it is, and keeps its own type in
a clone and in a literal built for the interface. The object's vtable
calls it through a thunk, `<vtable>.<field>.thunk(this, ...)`, that reads
the field and calls it (generateSynthMethodToCallField, now taking the
`this` type, shared with classes): a method's slot holds the thunk, a
function-typed field's slot a tagged method cell holding it, as for a
class since #612. As there, a write through the interface changes the
shared cell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#615 made a method cell {method, flag offset}: an object's thunk cell
now has that layout too, with -1 (always present), so a read of an
optional interface field finds its offset where it looks for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CMakeLists.txt: both sides added a test at the same place; both kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ASDAlexander77
ASDAlexander77 merged commit 3f014ea into main Oct 19, 2026
2 checks passed
@ASDAlexander77
ASDAlexander77 deleted the fix-object-closure-interface-fn-field branch October 19, 2026 17:30
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