Skip to content

Commit e2bb4ca

Browse files
committed
fix(d5): resolve forward-returns iteratively, not recursively
The SCC pass and the param fixpoint are iterative, but resolve_return was left recursive — one Python frame per forward-return hop. With the depth cap gone, a long but acyclic chain of `ReturnSkeleton("forward")` wrappers recurses to Python's default limit and raises RecursionError. The bridge catches a solve() exception by dropping to an EMPTY MOS, so one deep wrapper chain would disable interprocedural ownership summaries for the entire input rather than just that one return chain. (Codex P2.) A return has at most one forward target, so resolution is a linked-list walk: chase the forward edges to a terminal / extern / cycle, then propagate the value back up applying the aliasOf-through-forward degrade at each hop. Behaviour is identical to the recursive version (same memo, same cycle -> unknown, same aliasOf rule); it just no longer grows the call stack. Adds deep-chain (N=3000, past the recursion limit) regression tests for both the forward-return and forward-param chains. ownership 42/42, ownir bridge 194/194, mypy --strict, ruff — all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019846YSZ35c7CdkWQ1qX5gm
1 parent 3260062 commit e2bb4ca

2 files changed

Lines changed: 64 additions & 29 deletions

File tree

‎ownlang/ownership.py‎

Lines changed: 49 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -318,39 +318,59 @@ def transfer_of(key: str, index: int,
318318
v = cur[m]
319319
param_val[m] = v if v is not None else Transfer.NO
320320

321-
# --- returns: a memoized, cycle-safe chase along forward-return edges --------
321+
# --- returns: an iterative, memoized, cycle-safe chase along forward edges ----
322+
# A return has at most ONE forward target, so resolution is a linked-list walk,
323+
# not a tree. Done iteratively (not recursively) so a deep but acyclic wrapper
324+
# chain cannot blow Python's recursion limit — which, since the bridge catches a
325+
# solve() exception by dropping to an EMPTY MOS, would otherwise disable every
326+
# method's summary over one long chain rather than just that chain (Codex P2).
322327
ret_val: dict[str, str] = {}
323328

324-
def resolve_return(key: str, visiting: frozenset[str]) -> str:
325-
if key in ret_val:
326-
return ret_val[key] # context-insensitive: a return has one forward target
327-
r = sk[key].ret
329+
def _terminal(r: ReturnSkeleton) -> str:
328330
if r.kind == "fresh":
329-
v = "fresh"
330-
elif r.kind == "aliasOf":
331-
v = f"aliasOf:{r.arg}"
332-
elif r.kind == "aliased":
333-
v = "aliased"
334-
elif r.kind == "none":
335-
v = "none" # explicit no-owned-return
336-
elif r.kind == "forward":
331+
return "fresh"
332+
if r.kind == "aliasOf":
333+
return f"aliasOf:{r.arg}"
334+
if r.kind == "aliased":
335+
return "aliased"
336+
if r.kind == "none":
337+
return "none" # explicit no-owned-return
338+
return "unknown" # an unrecognised kind fails closed, never silently "none"
339+
340+
def resolve_return(start: str) -> str:
341+
if start in ret_val:
342+
return ret_val[start] # context-insensitive: one forward target per return
343+
path: list[str] = [] # forward nodes above the stop node (shallow -> deep)
344+
on_path: set[str] = set()
345+
key = start
346+
while True:
347+
if key in ret_val:
348+
val = ret_val[key]
349+
break
350+
r = sk[key].ret
351+
if r.kind != "forward":
352+
val = ret_val[key] = _terminal(r)
353+
break
337354
if r.callee not in sk:
338355
unresolved.add(f"return {r.callee} (extern, no summary)")
339-
v = "unknown"
340-
elif r.callee in visiting:
341-
v = "unknown" # return-forward cycle: no ground to stand on
342-
else:
343-
inner = resolve_return(r.callee, visiting | {key})
344-
# `inner` aliasOf:<i> aliases one of the *callee's* params; remapping it
345-
# to OUR args needs the call's argument mapping, which the skeleton does
346-
# not carry yet (the obligation-identity model, D5.4). Never propagate a
347-
# wrong index — degrade to unknown (precision-safe: nothing aliased at
348-
# lowering). fresh / aliased / none / unknown propagate as-is.
349-
v = "unknown" if inner.startswith("aliasOf:") else inner
350-
else:
351-
v = "unknown" # an unrecognised kind fails closed, never silently "none"
352-
ret_val[key] = v
353-
return v
356+
val = ret_val[key] = "unknown"
357+
break
358+
if r.callee == key or r.callee in on_path:
359+
# forward-return cycle: this node (and all that feed it) have no ground
360+
val = ret_val[key] = "unknown"
361+
break
362+
path.append(key)
363+
on_path.add(key)
364+
key = r.callee
365+
# Propagate the stop node's value up the forward chain. At each hop an
366+
# aliasOf:<i> aliases one of the *callee's* params; remapping it to OUR args
367+
# needs the call's argument mapping the skeleton does not carry yet (the
368+
# obligation-identity model, D5.4) — so degrade to unknown rather than
369+
# propagate a wrong index. fresh / aliased / none / unknown propagate as-is.
370+
for k in reversed(path):
371+
val = "unknown" if val.startswith("aliasOf:") else val
372+
ret_val[k] = val
373+
return ret_val[start]
354374

355375
out: dict[str, MethodSummary] = {}
356376
for key, skel in sk.items():
@@ -362,7 +382,7 @@ def resolve_return(key: str, visiting: frozenset[str]) -> str:
362382
)
363383
for p in skel.params
364384
)
365-
returns = resolve_return(key, frozenset())
385+
returns = resolve_return(key)
366386
out[key] = MethodSummary(key, params, returns, skel.file, skel.line)
367387
return out, sorted(unresolved)
368388

‎tests/test_ownership.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,21 @@ def expect(cond, msg):
139139
_, log2 = solve_with_log([_m("Caller", _p(0, PathAction("forward", "Extern", 0)))])
140140
expect(any("Extern#0" in e for e in log2), "extern forward is logged, not silent")
141141

142+
# A deep but acyclic chain must not blow Python's recursion limit (the solver is
143+
# iterative end to end; a RecursionError here would, via the bridge's catch-all,
144+
# drop the WHOLE input's MOS to empty over one long chain). 3000 > default limit.
145+
N = 3000
146+
param_chain = [
147+
_m(f"L{i}", _p(0, PathAction("forward", f"L{i + 1}", 0))) for i in range(N)
148+
] + [_m(f"L{N}", _p(0, PathAction("dispose")))]
149+
expect(_t(solve(param_chain), "L0", 0) == Transfer.MUST,
150+
"deep param forward chain resolves without RecursionError")
151+
ret_chain = [
152+
_m(f"R{i}", ret=ReturnSkeleton("forward", callee=f"R{i + 1}")) for i in range(N)
153+
] + [_m(f"R{N}", ret=ReturnSkeleton("fresh"))]
154+
expect(solve(ret_chain)["R0"].returns == "fresh",
155+
"deep forward-return chain resolves without RecursionError")
156+
142157
# --- recursion / SCC convergence ----------------------------------------
143158
# Mutual recursion that never disposes -> no (the cycle seeds at bottom and the
144159
# fixpoint settles at `no`; crucially, it terminates).

0 commit comments

Comments
 (0)