Skip to content

Commit f959cf7

Browse files
committed
fix: bind an if let payload and a for variable inside a lambda body (#994)
The capture walk grew its bound set for `bind` statements and a lambda's own parameters and for nothing else, so a name any other construct introduces read as free and went into the closure's environment. The lambda then emitted a load of a name the enclosing frame has no local for: f := fn() -> Int: if let v = maybe(): return v return 0 unknown load source 'v' in main `for x in xs` inside a lambda failed the same way on `x`, which the block comment above `ir_capture_find_free_vars` already named alongside the `if let` payload. `if_let`, `while_let` and `for_stmt` are now walked the way `lambda` already was: the subject and the iterable under the outer bound set, the body under a set that carries the pattern's names (or the loop variable and index). An `if let`'s else block stays under the outer set, since the payload is not in scope there. That exposed the second half. `ir_emit_let_bindings` decided the release-of-previous on an rc payload by asking whether the name is in `ir_untracked_rc_binds`, which is the ENCLOSING function's list — the payload name was in the enclosing frame's tracked set, so the release was emitted, and now that the payload no longer comes from a capture slot the lambda had no `v` to load. It asks the positive question `ir_emit_bind_stmt` already asks instead: emit the release only for a name this frame tracks. The two agree everywhere the prepass saw the name, and where it did not the new answer is the safe one. Verified by output, against the same body written as a named function: tests/cases/test_if_let_in_lambda.pith prints five lambda arms beside five named ones over four rounds, and every pair agrees. All of it failed to compile before the change. tests/leaks/leak_lambda_if_let.pith gates the shell: four lambda `if let` arms over call subjects with by-value payloads, flat at 2.70 mb -> 2.70 mb across 200k and 800k rounds. Still open, and separate: a lambda body never runs the rc prologue, so it tracks no locals and releases none of them — a heap payload the binding takes a count on is stranded, and so is a bound optional local's shell. That is not specific to `if let`; a plain `s := "x"` in a lambda leaks the same way. tests/pending/if_let_frame_ownership.pith measures it beside the generic-body twin and the named-function controls that already pass.
1 parent 2aa6457 commit f959cf7

8 files changed

Lines changed: 23762 additions & 22825 deletions

File tree

self-host/bootstrap/ir_driver.ir

Lines changed: 23396 additions & 22815 deletions
Large diffs are not rendered by default.

self-host/ir_capture.pith

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,33 @@ fn ir_find_free_vars_walk(idx: Int, bound: List[String], free: List[String], blo
5454
if cn.kind == "body" or cn.kind == "block":
5555
updated_free = ir_find_free_vars_walk(child, inner_bound, updated_free, blocked_names, locals)
5656
return updated_free
57+
# an `if let` / `while let` payload is bound by the construct itself, for
58+
# its body only — the same relationship a lambda's parameters have to the
59+
# lambda body above. the subject is walked under the OUTER set, since the
60+
# payload name means nothing yet while it is evaluated, and so is an
61+
# `if let`'s else block, where the payload is not in scope at all.
62+
if node.kind == "if_let" or node.kind == "while_let":
63+
if node.children.len() < 3:
64+
return ir_walk_children(idx, bound, free, blocked_names, locals)
65+
body_bound := ir_copy_names(bound)
66+
ir_collect_pattern_names(node.children[0], body_bound)
67+
mut let_free := ir_find_free_vars_walk(node.children[1], bound, free, blocked_names, locals)
68+
let_free = ir_find_free_vars_walk(node.children[2], body_bound, let_free, blocked_names, locals)
69+
if node.children.len() > 3:
70+
let_free = ir_find_free_vars_walk(node.children[3], bound, let_free, blocked_names, locals)
71+
return let_free
72+
# a `for` loop binds its variable (and its optional index) for the body on
73+
# the same terms. the loop variable lives in the loop's own slot rather
74+
# than a frame local, so capturing it emitted a load of a name neither
75+
# frame has.
76+
if node.kind == "for_stmt" and node.children.len() >= 2:
77+
loop_bound := ir_copy_names(bound)
78+
for child in node.children:
79+
cn := get_node(child)
80+
if cn.kind == "loop_var" or cn.kind == "loop_index":
81+
loop_bound.push(cn.value)
82+
mut for_free := ir_find_free_vars_walk(node.children[0], bound, free, blocked_names, locals)
83+
return ir_find_free_vars_walk(node.children[1], loop_bound, for_free, blocked_names, locals)
5784
if node.kind == "body" or node.kind == "block":
5885
mut scoped_bound := ir_copy_names(bound)
5986
mut updated_free := ir_copy_names(free)
@@ -65,11 +92,26 @@ fn ir_find_free_vars_walk(idx: Int, bound: List[String], free: List[String], blo
6592
if bind_name.len() > 0 and not ir_list_contains(scoped_bound, bind_name):
6693
scoped_bound.push(bind_name)
6794
return updated_free
95+
return ir_walk_children(idx, bound, free, blocked_names, locals)
96+
97+
fn ir_walk_children(idx: Int, bound: List[String], free: List[String], blocked_names: Map[String, Bool], locals: Map[String, Int]) -> List[String]:
6898
mut updated_free := ir_copy_names(free)
69-
for child in node.children:
99+
for child in get_node(idx).children:
70100
updated_free = ir_find_free_vars_walk(child, bound, updated_free, blocked_names, locals)
71101
return updated_free
72102

103+
# every name a pattern binds, appended to `names`: the bare `pat_binding`
104+
# an `if let` unwraps an optional with, and the ones nested inside a tuple
105+
# or variant pattern. the literal and wildcard patterns bind nothing.
106+
fn ir_collect_pattern_names(idx: Int, names: List[String]):
107+
node := get_node(idx)
108+
if node.kind == "pat_binding":
109+
if not ir_list_contains(names, node.value):
110+
names.push(node.value)
111+
return
112+
for child in node.children:
113+
ir_collect_pattern_names(child, names)
114+
73115
# the names a lambda body has to capture from the scope around it: every
74116
# identifier under `idx` that nothing inside it declares. the emitter
75117
# calls this with the lambda's parameters as `bound` and uses the result
@@ -84,12 +126,14 @@ fn ir_find_free_vars_walk(idx: Int, bound: List[String], free: List[String], blo
84126
# an unknown name that is not blocked and is not a known function is
85127
# captured on the assumption that it is a local.
86128
#
87-
# only `bind` statements and lambda parameters add to the bound set as
88-
# the walk descends, and a bind counts only after its own initializer is
89-
# walked (`x := x` captures the outer x). names introduced any other way
90-
# are NOT bound: a `for` loop variable and an `if let` payload are seen
91-
# as free and captured, which emits a load of a name the enclosing frame
92-
# does not have and the ir consumer rejects with "unknown load source".
129+
# the bound set grows as the walk descends, for each name the construct
130+
# that introduces it puts in scope: a `bind` statement for the rest of its
131+
# block (and only after its own initializer is walked, so `x := x` captures
132+
# the outer x), a lambda's parameters for the lambda body, an `if let` or
133+
# `while let` payload for that body alone, and a `for` loop's variable and
134+
# index for the loop body. a name introduced by one of the last three used
135+
# to read as free and be captured, which emitted a load of a name neither
136+
# frame has and the ir consumer rejected with "unknown load source".
93137
# `self` is captured under that name like an ordinary variable.
94138
pub fn ir_capture_find_free_vars(idx: Int, bound: List[String], blocked_names: Map[String, Bool], locals: Map[String, Int]) -> List[String]:
95139
return ir_find_free_vars_walk(idx, bound, [], blocked_names, locals)

self-host/ir_emitter_core.pith

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8781,14 +8781,19 @@ fn ir_emit_let_bindings(pat_idx: Int, subj_r: Int, subj_idx: Int):
87818781
# transfer) releases what the binding holds. the
87828782
# release-of-previous mirrors ir_bind_owned_rc_payload — zero
87838783
# on first reach, a stale payload when a loop rebinds the
8784-
# name — and an untracked name skips it, keeping the retain
8785-
# as a bounded leak in the safe direction.
8784+
# name — and a name this frame does not track skips it,
8785+
# keeping the retain as a bounded leak in the safe direction.
8786+
# the tracked test is the positive one ir_emit_bind_stmt asks,
8787+
# not the untracked list: that list is the ENCLOSING
8788+
# function's, and a lambda body — which is its own frame and
8789+
# tracks nothing — read it and emitted a load of a name the
8790+
# lambda has no slot for.
87868791
# a tuple payload (an inner optional shell) is skipped on
87878792
# the same terms the prepass poisons it: its count belongs to
87888793
# its owner and the binding is an uncounted view.
87898794
rc_kind := ir_rc_kind(inner_kind)
87908795
if rc_kind.len() > 0 and rc_kind != "tuple":
8791-
if not ir_untracked_rc_binds.contains(pat.value):
8796+
if ir_rc_local_kind(pat.value).len() > 0:
87928797
old_r := ir_reg()
87938798
ir_emit("load " + old_r.to_string() + " " + pat.value)
87948799
ir_rc_release_reg(old_r, rc_kind)
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# an `if let` inside a lambda body did not compile: the capture walk treated
2+
# the payload name as a free variable, so the closure loaded it out of an
3+
# environment slot the enclosing frame has no local for and the ir consumer
4+
# rejected it with "unknown load source". a `for` loop variable inside a
5+
# lambda failed the same way, and for the same reason — the walk bound only
6+
# `bind` statements and the lambda's own parameters.
7+
#
8+
# every lambda arm here prints beside the same body written as a named
9+
# function. the pair is the test: a payload pulled from the wrong storage
10+
# cannot answer the same as the named twin.
11+
12+
fn maybe_int(i: Int) -> Int?:
13+
if i % 3 == 0:
14+
return none
15+
return i + 1
16+
17+
fn maybe_str(i: Int) -> String?:
18+
if i % 3 == 0:
19+
return none
20+
return "s" + i.to_string()
21+
22+
fn maybe_pair(i: Int) -> (Int, String)?:
23+
if i % 3 == 0:
24+
return none
25+
return (i, "p" + i.to_string())
26+
27+
fn named_int(i: Int) -> Int:
28+
if let v = maybe_int(i):
29+
return v
30+
return 0
31+
32+
fn named_str(i: Int) -> String:
33+
if let v = maybe_str(i):
34+
return "some:" + v
35+
return "none"
36+
37+
fn named_pair(i: Int) -> String:
38+
if let v = maybe_pair(i):
39+
return "some:" + v.1
40+
return "none"
41+
42+
fn named_sum(limit: Int) -> Int:
43+
mut total := 0
44+
for x in 0..limit:
45+
total = total + x
46+
return total
47+
48+
fn named_drain(start: Int) -> String:
49+
mut k: Int? := start
50+
mut acc := ""
51+
while let v = k:
52+
acc = acc + v.to_string()
53+
if v <= 1:
54+
k = none
55+
else:
56+
k = v - 1
57+
return acc
58+
59+
fn main():
60+
lam_int := fn(i: Int) -> Int:
61+
if let v = maybe_int(i):
62+
return v
63+
return 0
64+
65+
lam_str := fn(i: Int) -> String:
66+
if let v = maybe_str(i):
67+
return "some:" + v
68+
return "none"
69+
70+
lam_pair := fn(i: Int) -> String:
71+
if let v = maybe_pair(i):
72+
return "some:" + v.1
73+
return "none"
74+
75+
lam_sum := fn(limit: Int) -> Int:
76+
mut total := 0
77+
for x in 0..limit:
78+
total = total + x
79+
return total
80+
81+
lam_drain := fn(start: Int) -> String:
82+
mut k: Int? := start
83+
mut acc := ""
84+
while let v = k:
85+
acc = acc + v.to_string()
86+
if v <= 1:
87+
k = none
88+
else:
89+
k = v - 1
90+
return acc
91+
92+
# the payload name shadows a live local of the enclosing frame: the
93+
# lambda binds its own storage and leaves the outer name alone
94+
v := "outer"
95+
lam_shadow := fn(i: Int) -> String:
96+
if let v = maybe_str(i):
97+
return "inner:" + v
98+
return "inner:none"
99+
100+
mut i := 0
101+
while i < 4:
102+
print("int " + named_int(i).to_string() + " " + lam_int(i).to_string())
103+
print("str " + named_str(i) + " " + lam_str(i))
104+
print("pair " + named_pair(i) + " " + lam_pair(i))
105+
print("shad " + lam_shadow(i) + " " + v)
106+
i = i + 1
107+
print("sum " + named_sum(5).to_string() + " " + lam_sum(5).to_string())
108+
print("drain " + named_drain(3) + " " + lam_drain(3))
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
int 0 0
2+
str none none
3+
pair none none
4+
shad inner:none outer
5+
int 2 2
6+
str some:s1 some:s1
7+
pair some:p1 some:p1
8+
shad inner:s1 outer
9+
int 3 3
10+
str some:s2 some:s2
11+
pair some:p2 some:p2
12+
shad inner:s2 outer
13+
int 0 0
14+
str none none
15+
pair none none
16+
shad inner:none outer
17+
sum 10 10
18+
drain 321 321
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# an `if let` inside a lambda body did not compile at all: the capture walk
2+
# read the payload name as free, so the lambda loaded it from an environment
3+
# slot the enclosing frame has no local for. the payload is bound by the
4+
# `if let` itself now and stays inside the lambda's own frame.
5+
#
6+
# what this measures is the shell, not the payload. every subject is a call,
7+
# which mints a shell the consumer owns and releases at the merge, and every
8+
# payload is a by-value kind, which strands nothing — so anything this grows
9+
# by is the two-slot shell itself, the half of the ownership a heap-payload
10+
# arm would hide.
11+
#
12+
# the arms that cannot be here, and why: a heap payload strands its count,
13+
# because a lambda body runs no rc prologue at all and so tracks and releases
14+
# no locals; a bound-local subject strands its shell for the same reason; and
15+
# a `return` out of the then-block jumps past the merge where the owned shell
16+
# is released. tests/pending/if_let_frame_ownership.pith measures those three
17+
# and the generic-body twin of the middle one.
18+
import leakprobe as probe
19+
20+
fn count_of(i: Int) -> Int?:
21+
if i % 7 == 6:
22+
return none
23+
return i
24+
25+
fn flag_of(i: Int) -> Bool?:
26+
if i % 3 == 2:
27+
return none
28+
return i % 2 == 0
29+
30+
fn main():
31+
n := probe.rounds()
32+
33+
# the taken path and the none path both reach the merge, so the shell is
34+
# released exactly once on either
35+
taken := fn(i: Int) -> Int:
36+
mut m := 0
37+
if let v = count_of(i):
38+
m = v - i
39+
return m
40+
41+
# the else arm releases its own shell on the path that skipped the body
42+
with_else := fn(i: Int) -> Int:
43+
mut m := 0
44+
if let v = count_of(i):
45+
m = v - i
46+
else:
47+
m = 1
48+
return m
49+
50+
# two shells live at once; the inner one is released before the outer
51+
nested := fn(i: Int) -> Int:
52+
mut m := 0
53+
if let a = count_of(i):
54+
if let b = flag_of(i):
55+
if b:
56+
m = a - i
57+
return m
58+
59+
# a payload name that shadows a live local of the enclosing frame binds
60+
# the lambda's own storage and must not touch the outer name's count
61+
outer := 41
62+
shadow := fn(i: Int) -> Int:
63+
mut m := 0
64+
if let outer = count_of(i):
65+
m = outer - i
66+
return m
67+
68+
mut i := 0
69+
mut total := 0
70+
while i < n:
71+
total = total + taken(i) + with_else(i) + nested(i) + shadow(i)
72+
i = i + 1
73+
kb := probe.peak_kb()
74+
if total < 0 or outer < 0:
75+
print("unreachable")
76+
print("{kb}")

0 commit comments

Comments
 (0)