Skip to content

Commit 49161db

Browse files
samuelcolvinclaude
andcommitted
Fix lambda default capture missing enclosing cell
The cell-var pre-pass merged a lambda's default expressions into its body reference set and then filtered by the lambda's own parameter names. A name referenced inside a default that coincided with a lambda param (e.g. `lambda x=(lambda: x): x()`) was dropped, so the enclosing variable was never promoted to a cell — the owner compiled it as a plain local while the nested closure expected a cell, crashing with "MakeClosure: expected cell reference on stack". Defaults are evaluated in the enclosing scope, so a capture inside them must resolve against the enclosing variable regardless of the lambda's params. Scan lambda defaults separately and unconditionally, mirroring the FunctionDef branch; keep param filtering only for body references. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 954a6a8 commit 49161db

2 files changed

Lines changed: 55 additions & 10 deletions

File tree

crates/monty/src/prepare.rs

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2685,23 +2685,34 @@ fn collect_cell_vars_from_expr(
26852685
use crate::expressions::Expr;
26862686
match &expr.expr {
26872687
Expr::LambdaRaw { signature, body, .. } => {
2688-
// This lambda captures variables from our scope
2688+
// This lambda's *default* expressions are evaluated in OUR scope at
2689+
// definition time, not inside the lambda — so any name they
2690+
// reference that is one of our locals is captured by us, regardless
2691+
// of the lambda's own params. Crucially the default must NOT be
2692+
// filtered by the lambda's params: in `lambda x=(lambda: x): x()`
2693+
// the inner lambda captures the enclosing `x`, not the param `x`,
2694+
// so filtering would drop the required outer cell. Body references
2695+
// are filtered below; defaults are not.
2696+
for default in signature.default_exprs() {
2697+
let mut default_referenced = AHashSet::new();
2698+
collect_referenced_names_from_expr(default, &mut default_referenced, interner);
2699+
for name in &default_referenced {
2700+
if our_locals.contains(name) {
2701+
cell_vars.insert(name.clone());
2702+
}
2703+
}
2704+
}
2705+
26892706
// Find what names are referenced in the lambda body
26902707
let mut referenced = AHashSet::new();
26912708
collect_referenced_names_from_expr(body, &mut referenced, interner);
2692-
// Also collect from default expressions (evaluated in our scope).
2693-
for default in signature.default_exprs() {
2694-
collect_referenced_names_from_expr(default, &mut referenced, interner);
2695-
}
26962709

26972710
// Extract param names from signature
26982711
let param_names: Vec<StringId> = signature.param_names().collect();
26992712

2700-
// Any name that is:
2701-
// - Referenced by the lambda
2702-
// - Not a param of the lambda
2703-
// - In our locals
2704-
// becomes a cell_var
2713+
// A body reference becomes a cell_var if it is not one of the
2714+
// lambda's own params (which the lambda binds itself) and is one of
2715+
// our locals.
27052716
for name in &referenced {
27062717
if !param_names.iter().any(|p| interner.get_str(*p) == name) && our_locals.contains(name) {
27072718
cell_vars.insert(name.clone());

crates/monty/test_cases/closure__transitive.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,3 +358,37 @@ def inner():
358358

359359

360360
assert outer_with_global_mid() == 100, 'global in mid routes inner past the outer local'
361+
362+
363+
# === Lambda whose default is a capturing lambda ===
364+
# The default `(lambda: x)` is evaluated in the enclosing scope, so its `x`
365+
# captures the enclosing `x` — NOT the outer lambda's same-named param. The
366+
# cell-var pre-pass must therefore scan lambda defaults without filtering by the
367+
# lambda's own params, else the enclosing cell is missed (the closure build then
368+
# fails). `y` reads `x` first to pin that the owner stays consistent with the
369+
# late-promoted cell.
370+
def lambda_default_capture():
371+
x = 10
372+
y = x + 1
373+
g = lambda x=(lambda: x): x()
374+
return y, g()
375+
376+
377+
assert lambda_default_capture() == (11, 10), 'lambda default captures enclosing same-named var'
378+
379+
380+
# The same capture works two levels up and survives owner mutation after the
381+
# inner closure is built (a shared cell, not a snapshot).
382+
def lambda_default_two_level():
383+
v = 1
384+
385+
def mid():
386+
g = lambda x=(lambda: v): x
387+
return g()
388+
389+
inner = mid()
390+
v = 42 # mutated after the default closure was created
391+
return inner()
392+
393+
394+
assert lambda_default_two_level() == 42, 'lambda default capture two levels up sees later mutation'

0 commit comments

Comments
 (0)