Summary
A closure returned out of an imported module carries an address into the module's chunk, and nothing marks it. Calling it runs the caller's instructions at that address.
This is the mirror of #691. #691 was a caller's closure passed into a module; that direction is fixed in #695. This is a module's closure handed out, and it is untouched by that fix — the fix resolves a closure's origin from contexts that are recorded somewhere reachable (a _ClosureProxy, a cross-module frame, a caller VM), and nothing records a module's context on the way out. VM._foreign_closure_origin correctly returns None here, which is its documented "leave the call as it was" case.
The worst case is silent, and it does not need a workflow. Plain top-level code, one module, one returned closure.
Reproduction
m.nd:
fn plain_maker() { return fn() { return 99i } }
fn make_adder(n) { return fn(x) { return x + n } }
fn pad_a() { return 1i }
fn pad_b() { return 2i }
1. Silent truncation at top level — the worst case
import "./m.nd" as m
fn main() {
let f = m.plain_maker()
print("no-upvalue at top level: \(f())")
}
No output. No error. Exit 0. The print never ran.
2. Stack underflow in a step body
import "./m.nd" as m
workflow w {
step a { let f = m.plain_maker(); print("in step: \(f())"); return "ok" }
}
fn main() { let r = run_workflow(w); print("failed: \(r["failed"])") }
Runtime error at r_step.nd:3:72: Stack underflow
at __anon_3 (r_step.nd:3:72)
called from __anon_1 (r_step.nd:3:58)
3. A returned closure with an upvalue
import "./m.nd" as m
fn main() {
let g = m.make_adder(3i)
print("upvalue at top level: \(g(10i))")
}
Type error at r_upv.nd:3:26: Method calls are only supported on records
at __anon_1 (r_upv.nd:3:26)
called from main (r_upv.nd:4:38)
As in #691, the three symptoms are not three defects — the symptom is whatever instruction happens to sit at the returned closure's address in the caller's chunk. Vary the padding functions in m.nd and the symptom changes.
Expected behavior
m.plain_maker()() should return 99. A factory function is an ordinary thing for a module to export, and nodus-flow-style builder APIs are exactly this shape.
Whatever else, the silent case has to become either correct or an error. A print that does not run, with exit 0, is the failure mode #691 was filed for.
Not a regression
Confirmed identical on main before #695 and after it. The construct is not new, so this is likely present in every release.
Fix direction
The two directions are one question — "which chunk was this closure compiled against?" — and #695 answers it for values crossing in. What is missing is a record for values crossing out.
Two ways in, so probably two ways out to cover:
NodusModule.invoke_function's return value — the detached-VM path.
_op_return popping a frame that carries a cross_module_ctx — the in-VM path.
Both should mark a returned Closure local to the chunk being left, so VM._foreign_closure_origin can find it. _ClosureProxy already does exactly this in the other direction (it captures origin_ctx at the boundary it crosses), so the shape is established; the open question is whether to reuse it or add a lighter-weight carrier, and whether closures nested inside a returned list/map/record must be walked too — #339 says they must, since that is the case the argument direction missed the first time.
Check the reverse before calling it done. #476 is the cautionary one: the asymmetry ran in both directions and fixing one left the other. Here the pair is "into a module" (#691, fixed) and "out of a module" (this), and a third case worth probing is a closure that goes in, is stored, and comes back out.
Affected versions
Confirmed on main at 5.8.0+. Almost certainly every earlier release.
Summary
A closure returned out of an imported module carries an address into the module's chunk, and nothing marks it. Calling it runs the caller's instructions at that address.
This is the mirror of #691. #691 was a caller's closure passed into a module; that direction is fixed in #695. This is a module's closure handed out, and it is untouched by that fix — the fix resolves a closure's origin from contexts that are recorded somewhere reachable (a
_ClosureProxy, a cross-module frame, a caller VM), and nothing records a module's context on the way out.VM._foreign_closure_origincorrectly returnsNonehere, which is its documented "leave the call as it was" case.The worst case is silent, and it does not need a workflow. Plain top-level code, one module, one returned closure.
Reproduction
m.nd:1. Silent truncation at top level — the worst case
No output. No error. Exit 0. The
printnever ran.2. Stack underflow in a step body
3. A returned closure with an upvalue
As in #691, the three symptoms are not three defects — the symptom is whatever instruction happens to sit at the returned closure's address in the caller's chunk. Vary the padding functions in
m.ndand the symptom changes.Expected behavior
m.plain_maker()()should return99. A factory function is an ordinary thing for a module to export, andnodus-flow-style builder APIs are exactly this shape.Whatever else, the silent case has to become either correct or an error. A
printthat does not run, with exit 0, is the failure mode #691 was filed for.Not a regression
Confirmed identical on
mainbefore #695 and after it. The construct is not new, so this is likely present in every release.Fix direction
The two directions are one question — "which chunk was this closure compiled against?" — and #695 answers it for values crossing in. What is missing is a record for values crossing out.
Two ways in, so probably two ways out to cover:
NodusModule.invoke_function's return value — the detached-VM path._op_returnpopping a frame that carries across_module_ctx— the in-VM path.Both should mark a returned
Closurelocal to the chunk being left, soVM._foreign_closure_origincan find it._ClosureProxyalready does exactly this in the other direction (it capturesorigin_ctxat the boundary it crosses), so the shape is established; the open question is whether to reuse it or add a lighter-weight carrier, and whether closures nested inside a returned list/map/record must be walked too — #339 says they must, since that is the case the argument direction missed the first time.Check the reverse before calling it done. #476 is the cautionary one: the asymmetry ran in both directions and fixing one left the other. Here the pair is "into a module" (#691, fixed) and "out of a module" (this), and a third case worth probing is a closure that goes in, is stored, and comes back out.
Affected versions
Confirmed on
mainat 5.8.0+. Almost certainly every earlier release.