Stage 6.8 followup: canonical run_state runs end-to-end (Bug 1 + Bug 2 + Layers 2 + 3a + 3b + 3c)#39
Conversation
…ge values Phase 4g (PR #29 `eabef59` activation + `dd10379` test fixup) shipped uniform return arm dispatch in `Expr::Handle`'s `lower_expr`: after body lowering, if `return_arm.is_some()`, codegen unconditionally builds `NextStep::Call(return_closure, return_fn, [body_val_widened, ...])` and drives `sigil_run_loop`. The `dd10379` commit message defended this with "the return clause runs over whatever value flows out of the body, including non-resuming op-arm tail values." That interpretation is incorrect per algebraic-effects type theory: - Body's type is B; op arm bodies have type R (handle's overall). - Return clause `return(v: B) => body_R` wraps body's normal value (type B) into R. - When an op arm fires and discards `k`, its value already has type R and IS the handle's final value; passing it through the return clause as `v: B` is type-unsound when B ≠ R. The bug was masked when B = R (PR #29's tests) and surfaces when B ≠ R (the canonical `run_state` shape from PR #38's reverted Task 109 first- cycle attempt). Symptom: heap-pointer-shaped output values varying across runs (the discharged arm's lambda closure_ptr at type R interpreted as `v: B = Int` and fed through the return arm's pointer arithmetic). **The fix — distinguish at runtime, conditional dispatch at codegen.** Runtime (`abi/src/effect.rs` + `runtime/src/handlers.rs`): - New `NEXT_STEP_TAG_DISCHARGED = 2` discriminant. - New `sigil_next_step_discharged(value)` constructor. - New thread-local `LAST_TERMINAL_TAG: Cell<u32>` set by `sigil_run_loop` immediately before returning the terminal value. - New `sigil_last_terminal_tag()` query + `sigil_reset_last_terminal_tag()` reset for codegen. - The trampoline routes both DONE and DISCHARGED through the existing outer post_arm_k stack uniformly; the distinction matters only at the top-level run_loop terminal. Codegen (`compiler/src/codegen.rs`): - Op arm fn body's discard-`k` catchall now emits `sigil_next_step_discharged` instead of `sigil_next_step_done`. Synth- cont chains (resume-`k`) keep emitting `sigil_next_step_done` — body completion via continuation IS body-normal completion. - `Expr::Handle`'s `lower_expr` emits `sigil_reset_last_terminal_tag()` before body lowering when `return_arm.is_some()`. - After body lowering, query the tag and conditionally branch: DISCHARGED → skip return arm dispatch, convert body_val to handler_overall_ty's Cranelift type, use directly as handle's overall. DONE → existing return arm dispatch path. - Both branches converge on a merge block. **Width-aware discharge conversion.** When body's Cranelift type B and handler_overall_ty R coincide (run_state-shape: both i64), body_val flows directly. For mismatched widths the discharge branch performs uextend/ireduce/bitcast, or emits a safe placeholder when the mismatch indicates the runtime path is structurally dead. Codegen still must emit valid IR for both branches. **Test inversions.** Two PR #29 tests pinned the buggy semantics: - `handle_with_return_arm_fires_on_op_arm_discharge_value` (asserted "9900") → renamed to `handle_with_op_arm_discharge_skips_return_arm`, asserts "99". - `handle_with_constant_return_arm_overrides_op_arm_yield` (asserted "999") → renamed to `handle_with_op_arm_discharge_skips_constant_return_arm`, asserts "7". **New positive test:** `handle_returning_fn_typed_value_with_op_arm_- discharge_runs` exercises the load-bearing B ≠ R case (B = Int, R = (Int) → Int). Pre-fix: heap-pointer-shaped value varying across runs. Post-fix: "107\n" (arm's lambda invoked with arg = 7). **What this fix does NOT close.** Canonical `run_state` from PR #38's reverted attempt remains broken — Bug 2 is one of multiple layered bugs. Verified manually: a literal canonical run_state still produces heap-pointer-shaped output. Other layers (Bug 1's body-tail-after- perform under sync lowering, the canonical's k-capturing-lambda- invocation chain, multi-arm dispatch composition) are documented in the deviation entry as follow-up work. **Verification:** - 539 compiler unit tests pass - 73 runtime tests pass - 127 e2e tests pass; 3 macOS timing flakes (pass individually) - rs_a (run_state's load-bearing B ≠ R shape): pre-fix returned heap pointer; post-fix returns 107 - choose / multishot_perf / multishot_stress: all pass — outer post_arm_k stack works correctly with new DISCHARGED tag Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…raw arg Empirical bisect of run_state canonical bugs identifies and bounds Layer 2: captured-k invocation from lifted lambda's body returns raw arg instead of return-arm-wrapped R-typed value, causing SIGSEGV when the next call site tries to dereference the Int as a closure pointer. Probes: rs_b1 (eager k(7) tail) WORKS returns 20; rs_b (lambda + k(s)(s)) SEGFAULTS exit 139. Root cause is lower_k_pair_call narrowing run_loop's u64 to handler_overall_ty without applying the return arm, combined with k_fn= sigil_continuation_identity which echoes arg unchanged. Bug 2's fix is load-bearing and correct; Layer 2 is independent. Proposed fix path (Option A): extend trailing-pair to trailing-triple (k_closure, k_fn, return_arm_fn) so lower_k_pair_call self-applies the return arm after run_loop. Estimated ~70 LOC across closure_convert and codegen; no runtime ABI changes. Analysis only in this commit; no compiler or runtime changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…return arm Captured-k from a lifted lambda that escapes its handle now produces the handler-overall R-typed value instead of the raw B-typed run_loop result. Closes the SIGSEGV the rs_b probe shape and the canonical run_state arm body pattern (k(s)(s) chain) hit pre-fix. Approach: thread the originating Expr::Handle's span through ArmKPair Capture, then look up handler_return_arm_indices / handler_return_arm _refs_per_handle in lower_k_pair_call to invoke the return-arm synth fn on the run_loop result before narrowing to handler_overall_ty. Mirrors the Phase 4g handle-discharge dispatch shape; no runtime ABI changes. Limitations (deferred): return arms with outer captures fall back to pre-fix raw-arg path. The canonical run_state's return arm has no outer captures so this fix unblocks it for tail-perform-body single-arm cases. Layers 1 (non-tail-perform body) and 3 (multi-arm composition) still block the full canonical run_state. Test: handle_returning_k_capturing_lambda_invoked_outside_handle. Asserts f(7) = 14 for the canonical k(s)(s) chain. Pre-fix: SIGSEGV. Post-fix: 14. 539 compiler unit + 73 runtime + 128/131 e2e (3 perf flakes pre-existing) all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…il-perform body
Closes Bug 1 from `[DEVIATION Stage-6.8-followup Bug 2]`'s residual list.
Synchronous body lowering's IR-locally-computed body_val reflects body's
tail expression's natural value, NOT the discharged arm's value, when
the body has post-perform code (`{ let _ = perform; tail }` shape). The
runtime now preserves the trampoline's terminal value in a TLS so
codegen recovers it at handle discharge time.
Runtime: new LAST_TERMINAL_VALUE: Cell<u64> TLS in handlers.rs, set
alongside LAST_TERMINAL_TAG by sigil_run_loop's terminal. New FFI
exports sigil_last_terminal_value() and sigil_reset_last_terminal_value().
Codegen: plumb the new FFI fns through PerFnRefsCtx / PerFnRefs /
prepare_per_fn_refs / Lowerer / 7 Lowerer construction sites. Reset
both TLS slots at every handle's body-lowering entry (return-arm-
bearing AND no-return-arm). The return-arm path's discharge_block now
reads sigil_last_terminal_value() instead of body_val_widened. The
no-return-arm path gains an analogous tag-conditional structure
(discharge_block_nra / normal_block_nra / merge_block_nra).
Test: handle_with_post_perform_body_code_uses_arm_discharge_value
asserts f(7) = 107 for the dbg_a probe shape (body `{ let _ = perform
Trigger.fire(); fn (x) => x }`, arm `fn (x) => x + 100`, no return
arm). Pre-fix returned 7 (body's tail lambda); post-fix returns 107.
129/132 e2e + 539 compiler unit + 73 runtime tests green; 3 pre-
existing perf flakes unchanged. Layer 3 (multi-arm composition with
body resume across multiple performs) still blocks canonical run_state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…turn-arm self-apply Closes Layer 3a from the canonical run_state residual list. lower_k_pair_call's self-apply step now branches on LAST_TERMINAL_TAG: skip the return arm when DISCHARGED (raw value IS R-typed already), apply when DONE (body completed naturally, value is B-typed and needs wrapping). Pre-fix unconditionally applied — for k-pair-bearing lambdas captured at non-tail-perform body shapes (synth-cont k_fn), driving k(arg) through synth-cont can discharge from a nested arm; double-wrapping that R-typed value produced nonsensical closure-of-closure pointer output. Documents Layer 3b (fn-as-value Sync vs Cps ABI ambiguity at indirect call site) and Layer 3c (captured-continuation invoked outside handle hits empty handler stack) as the two architectural gaps still blocking canonical run_state. Both gaps verified empirically: an experimental 3b fix causes canonical to hit a clean "unhandled effect_id; handler stack empty" abort exposing 3c. Recommended path forward: Option 2 from 3b (Sync shims for Cps fns at fn-as-value materialization) + 3c (frame_ptr in trailing-triple). Probes: rs_b, rs_b1, dbg_a, rs_l3a all green (14/20/107/14). 129/132 e2e + 539 compiler unit + 73 runtime tests green; 3 perf flakes pre-existing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-value materialization Closes Layer 3b. fn-as-value materialization for Cps-ABI top-level fns was previously dispatched via native call_indirect — for Cps-ABI fns whose actual code_ptr is `(closure_ptr, args_ptr, args_len) -> *NextStep`, the indirect call returned a NextStep pointer interpreted as the declared ret type, producing heap-pointer-shaped junk. Implementation: Option 2 from the prior analysis (recommended path). For every Cps-ABI top-level fn, codegen's pre-pass declares a parallel Sync-ABI shim with linker name `<mangled>__sync_shim` and signature `(closure_ptr, params...) -> ret_ty`. Shim body packs args + trailing (null, identity) into a stack slot, calls the underlying Cps fn, drives sigil_run_loop, narrows back. lower_closure_record checks sync_shim_refs first when materializing a fn-as-value: if the entry exists, the closure record's code_ptr gets the shim's func_addr. Direct-call sites (lower_call's Ident path) untouched. Plumbing: sync_shim_fn_ids in PerFnRefsCtx → sync_shim_refs in PerFnRefs → Lowerer::sync_shim_refs. 13 destructure / Lowerer- construction sites updated. Tests added: - cps_effected_fn_typed_parameter_indirect_call_returns_correct_value - handle_with_eager_resume_arms_chains_let_yield_correctly Probes: rs_l3b 6, rs_l3c 42 (both heap pointers pre-fix). Canonical run_state now hits a clean Layer 3c abort instead of heap-pointer output. 131/134 e2e + 539 + 73 tests green; 3 perf flakes pre-existing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n_state runs end-to-end Closes the canonical run_state(initial, comp) higher-order helper from PR #38's reverted Task 109. Three coordinated fixes: 3c-1: Trailing-triple (k_closure, k_fn, frame_ptr) convention. Lifted lambda's closure record gains a third trailing slot for the originating handler's frame pointer. closure_convert::ArmKPairCapture gains frame_ptr_idx; alloc_arm_closure_record extended with frame_ptr_v parameter; arm fn body emit reads frame_ptr from arm closure record; lower_closure_record writes it to lifted lambda's third trailing slot; lower_k_pair_call calls sigil_handle_push(frame_ptr) before run_loop and sigil_handle_pop after — re-installing the handler frame so synth-cont chains inside k(arg) find the originating effect via sigil_perform's handler-stack walk. 3c-2: Trampoline preserves DISCHARGED through outer_post_arm_k routing. Pre-fix uniformly converted DISCHARGED → DONE at the outermost terminal (routing through identity which returns Done). For lower_k_pair_call driving a synth-cont chain that discharges via inner arm, this lost the DISCHARGED signal Layer 3a needs. Algebraic semantics: when any arm discharges, the handle terminates; subsequent outer-chain computations are abandoned. Trampoline now bypasses outer_post_arm_k routing when tag == DISCHARGED. 3c-3: closure_convert k-index two-pass. Pre-fix set k_closure_idx at the moment k was encountered in raw_caps. When k appeared before other captures (e.g., fn (s) => k(arg)(arg) where free-var traversal sees k first as callee), k_closure_idx collided with env slot 0 (the first regular capture). Two-pass: filter out k first, then assign indices based on final filtered.len(). Order-independent. Latent pre-Layer-3c. 3c-4: lower_k_pair_call writes trailing-pair (null, identity) at args[1..3] (count=3). When k_fn is a synth-cont (chained-let-yield step), it expects [arg, post_arm_k_closure, post_arm_k_fn] per the Phase 4e captures+ Slice A convention; pre-fix wrote only args[0], producing dispatched Calls with null fn_ptrs from garbage args[1..3]. Test: run_state_canonical_higher_order_helper_returns_threaded_value asserts run_state(5, comp) = 11 for comp doing set(10); v=get(); v+1. Composes Bug 2 + Layer 2 + Bug 1 + Layer 3a + Layer 3b + Layer 3c + closure_convert k-index fix end-to-end. 132/135 e2e + 539 compiler unit + 73 runtime tests green; 3 perf flakes pre-existing. Plan B' Stage 6.8's canonical run_state criterion is now met. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ain leak, Layer 3d, debug-doc removal Closes the four non-canonical residuals identified after the Layer 3c canonical run_state fix. 1. examples/state.sigil rewritten to canonical run_state(initial, comp) shape. Replaces the dual-handle Plan B Task 59 workaround (now architecturally unnecessary). e2e renamed: state_example_dual_handle _returns_6_then_99 → state_example_canonical_run_state_returns_11. Plan B' Stage 6.8 completion criterion now concretely met by the example file. 2. DEBUG_RUN_STATE.md deleted. Bisect debug-prep doc from e912315 is stale now that all three layers are closed. 3. sigil_run_loop drains OUTER_POST_ARM_K_DEPTH back to entry-time on the Layer 3c DISCHARGED bypass. Prevents synth-cont Middle pushes from leaking across run_loop boundaries. Pre-fix leaks were benign for canonical (entries are (null, identity), routing is identity- passthrough) but architecturally questionable for adversarial nesting and a 32-entry capacity-overflow risk. 4. Layer 3d — return arms with outer captures. lower_k_pair_call's self-apply path now loads return_closure from the handler frame at HANDLER_FRAME_RETURN_CLOSURE_OFF instead of hardcoding null. Handle expression's codegen writes it via sigil_handler_frame_set_return at handle codegen time; both empty-captures (null) and non-empty (allocated record) cases unify through the frame load. The pre-fix `if synth.captures.is_empty() ... else widened_result` gate is removed. Test: handle_return_arm_with_outer_captures_in_k_pair_dispatch_path asserts caller(3) → f(7) = 28 (= 7*3+7) for return arm `(v) => fn (s) => v * factor + s` capturing factor=3. 133/136 e2e + 539 compiler unit + 73 runtime tests green; 3 perf flakes pre-existing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apply rustfmt across compiler/src/codegen.rs and compiler/tests/e2e.rs for the Layer 3 + Bug 1/2 + cleanups commits. No behavior change; CI's rustfmt --check lane passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
boldfield
left a comment
There was a problem hiding this comment.
Final review — overall
Closes a long-standing canonical run_state runtime gap by stacking six layered fixes (Bug 1, Bug 2, Layers 2 / 3a / 3b / 3c). The end-to-end test (run_state_canonical_higher_order_helper_returns_threaded_value) passes, and the surrounding probe matrix (rs_a / rs_b / rs_b1 / rs_l3a/b/c/d / dbg_a) is exercised. Test coverage and documentation are unusually thorough for a PR this size — every layer's deviation note in PLAN_B_PRIME_DEVIATIONS.md carries enough context that the bisect is reproducible from cold.
That said, there are a handful of real issues — one obvious bug, one stale comment that contradicts the code, and several architectural concerns worth flagging before this merges.
Bugs / must-fix
1. Duplicate #[no_mangle] on sigil_next_step_call
runtime/src/handlers.rs:855-857:
#[no_mangle]
#[no_mangle]
pub unsafe extern "C" fn sigil_next_step_call(Almost certainly an accidental commit. Compiles (rustc tolerates it) but it's editor-paste residue, not intentional.
2. Stale comment in Expr::Handle no-return-arm path contradicts the code
compiler/src/codegen.rs:10254-10270 claims:
Note: the reset must happen BEFORE body lowering, not here. … the codegen-emitted reset above (in the return_arm.is_some() branch) doesn't fire for no-return-arm handles, but
sigil_run_loop's terminal always overwrites LAST_TERMINAL_TAG / LAST_TERMINAL_VALUE before returning. So the only observable stale-state path is: prior run_loop produced DISCHARGED, this handle's body never invokes run_loop (no perform), and the post-body tag-check sees stale DISCHARGED. Pinned as known limitation; v2 either pre-resets unconditionally or hoists the reset emit before body lowering.
But the unconditional pre-body reset was hoisted in this same PR — see codegen.rs:~9883 (reset_last_terminal_tag + reset_last_terminal_value are emitted before lower_expr(body) for all handles, not gated on return_arm.is_some()). The stale "known limitation" prose will mislead future readers into thinking there's an extant bug here. Either drop the paragraph or rewrite it to describe what actually happens now ("reset is unconditional; this branch's discharge path is correct on first principles").
Architectural concerns
3. Sync-shim emission is unconditional for every Cps-ABI fn
compiler/src/codegen.rs:5078-5101 declares a Sync shim for every fn whose body shape selects UserFnAbi::Cps. Code-bloat is bounded by the count of CPS fns in the program, but the materialization site (lower_closure_record) is the only consumer. For programs that never bind a CPS fn as a value, every shim is dead code in the emitted object. Worth a follow-up to gate emission on "appears in Ident → ClosureRecord rewrites" if module size starts mattering.
The linear scan in the shim emitter (codegen.rs:8421 — checked.program.items.iter().find_map(...) per shim) is O(n·m). Trivial today, but if you do gate it as above, please replace with the side-table lookup at the same time.
4. LAST_TERMINAL_VALUE is a u64 TLS that may carry heap pointers — Boehm-conservative-only rooting
The doc on the TLS (runtime/src/handlers.rs:206) is honest about this: u64, no GC root, "codegen must consume the value before any GC-triggering operation." Today's discharge_block does consume it immediately into a Cranelift block param. Two concrete risks:
- Any future codegen path that reads
LAST_TERMINAL_VALUEand does work between the read and any subsequent allocation (e.g., adding logging, instrumentation, or a stackmap push between the call and the use) needs to thread it through a value/slot before the alloc. The discipline is invisible at the call site. - A precise-GC port (the doc names this) breaks silently — Boehm's conservative scan happens to find the value on the stack today, but a precise scanner won't.
Recommend leaving a // SAFETY: comment at the codegen consumer site that calls out the rooting requirement, not just at the TLS declaration.
5. OUTER_POST_ARM_K_DEPTH drain on DISCHARGED is asymmetric
runtime/src/handlers.rs:1294-1311 (the new bypass branch in sigil_run_loop): on DISCHARGED the trampoline drains the stack back to outer_post_arm_k_entry_depth and returns. On DONE there's no equivalent drain — entries that didn't get consumed via the routing-through-chain path leak across run_loop boundaries.
The comment acknowledges this and says the existing canonical-shape pushes are always (null, identity) so subsequent run_loops route through them benignly. Two failure modes the comment names but doesn't close: adversarial nesting and deep-chain capacity overflow. Worth either (a) draining unconditionally on terminal (DONE or DISCHARGED) — the routing-through-chain path consumes via try_pop anyway, so a final drain is a no-op when the chain matched — or (b) asserting OUTER_POST_ARM_K_DEPTH == outer_post_arm_k_entry_depth at DONE terminal in debug builds to flush out leak bugs early. Asymmetry between the two paths is the kind of thing that bites later when the next layer of fix reads the depth as load-bearing.
6. arm_body_has_k_pair_lambda returns false for Expr::Lambda
compiler/src/codegen.rs:~12382. The walker is run post-closure-conversion, where Expr::Lambda should have been replaced with Expr::ClosureRecord — so in well-formed input the false is unreachable. But it silently misclassifies if a Lambda survives CC. Either a debug_assert!(false, "Lambda should not survive closure_convert") or descend into the body would guard the invariant. Same applies to the implicit assumption that walking only into ClosureRecord.env_exprs covers k-pair-bearing structure — it does, given current CC output, but the contract is implicit.
7. Layered architectural debt — six commits to land one feature
Standing back: the canonical run_state shape required Bug 1, Bug 2, and Layers 2 / 3a / 3b / 3c, each fixing a distinct invariant violation in a different subsystem (handle expression lowering, op-arm fn emit, lifted-lambda dispatch, closure-record layout, fn-as-value materialization, handler-frame lifetime). The deviation notes are excellent at documenting what each layer does, but the cumulative complexity — three new TLS slots, a new NextStep tag, a new shim ABI, trailing-pair → trailing-triple, two-pass k-index assignment, conditional handler-frame re-push — suggests the underlying design is brittle.
Specifically: the identity-as-k_fn Phase 4d MVP simplification (which Layer 2's analysis correctly identifies as "an architectural debt") is what forces every captured-k call site to apply the return arm itself. A future cycle should consider whether k_fn should always be the return-arm-applied continuation, eliminating the case-1 vs case-2 split and collapsing Layers 2/3a back into the trampoline. That would be a bigger refactor than this PR's scope; calling it out as a Plan-C-or-later candidate.
Style / minor
compiler/src/codegen.rs:10222placeholdericonst(handler_overall_ty, 0)for "future float / 32-bit-target port" — emits a 0 of the wrong domain to satisfy Cranelift's verifier on a structurally-dead branch. Fragile; if 32-bit lands, this silently produces incorrect IR rather than failing loud. Aunimplemented!()-equivalent (panic during compilation) would be safer than a silent placeholder.lower_k_pair_call's tag-conditional self-apply (codegen.rs:~10581+) andExpr::Handle's discharge_block are now structurally identical (build NextStep::Call with [val, null, identity] at fixed offsets, drive run_loop, narrow). Worth extracting once you're past this freeze — three sites of the same pattern is the cue to factor.closure_convert.rs:~600: the two-pass k-index fix is correct. The body comment ("the body references k first as callee then arg as call args") is the kind of bug-archaeology that belongs in the deviation note, not in the source — the source should just state the invariant. Minor.- Doc comments in this PR are unusually long. Useful for the deviation notes (which are an audit trail). In the source, paragraphs that say "pre-fix this happened, post-fix this happens" rot — the diff is the source of truth for "what changed." Consider trimming source-comment archaeology to one-line invariants in a follow-up cleanup pass.
Test coverage
The new e2e tests are well-targeted — each closes a specific layer with a minimal probe shape, and the canonical test composes everything. One gap: no test for a handle whose body is a literal value (no perform), to pin that the unconditional pre-body reset doesn't break the trivial case. Add handle_with_no_perform_body_uses_body_val_directly or similar — should be ~5 lines.
Verdict
Approve with the duplicate #[no_mangle] and stale-comment drift fixed before merge. Architectural concerns are follow-up material; landing this unblocks the canonical state.sigil and that's the critical milestone.
boldfield
left a comment
There was a problem hiding this comment.
Update: re-categorizing items as merge-blocking
Following up: the architectural concerns I parked as "follow-up" should instead be addressed before this lands. Re-stating the punch list with that lens, and noting which are mechanically fixable in this PR vs. which are observations.
Must-fix (mechanical)
-
Duplicate
#[no_mangle]—runtime/src/handlers.rs:855-857. Drop the duplicate. -
Stale comment in no-return-arm path —
compiler/src/codegen.rs:10254-10270. The "Note: the reset must happen BEFORE body lowering, not here … Pinned as known limitation" paragraph contradicts the unconditional pre-body reset that was hoisted in this same PR. Either delete the paragraph or rewrite it to describe the actual current behavior. -
Sync-shim unconditional emission —
compiler/src/codegen.rs:5078-5101. Gate shim declaration on "fn appears in someIdent → ClosureRecordrewrite." closure_convert already knows this set; surface it to codegen as a side-table and skip declaration when the fn isn't materialized as a value. Eliminates dead shims and the O(n·m) linear scan in the shim-body emitter. -
OUTER_POST_ARM_K_DEPTHdrain asymmetry —runtime/src/handlers.rs:~1294. Either drain unconditionally on terminal (DONE and DISCHARGED) so leaked entries can't span run_loop boundaries, ordebug_assert!(OUTER_POST_ARM_K_DEPTH.with(|c| c.get()) == outer_post_arm_k_entry_depth)on the DONE terminal so the leak fails loud in debug builds. The current "happens to be benign for(null, identity)" justification is fragile. -
arm_body_has_k_pair_lambdaLambda case —compiler/src/codegen.rs:~12382. ReplaceExpr::Lambda { .. } => falsewithExpr::Lambda { .. } => unreachable!("Lambda should not survive closure_convert")(or a debug_assert + false). The current silent false misclassifies if a Lambda ever leaks past CC. -
Float / 32-bit placeholder
iconst(0)—compiler/src/codegen.rs:~10222. Replace the silent placeholder with apanic!/unreachable!so a future port that hits this path fails at compile time rather than producing wrong-domain IR. -
LAST_TERMINAL_VALUEconsumer SAFETY comment — at the discharge_block read site inExpr::Handle's lowering, add a SAFETY comment naming the rooting requirement (must consume into a Cranelift value before any GC-triggering op). The TLS-declaration doc mentions it; the consumer site doesn't, and that's where the discipline gets violated when someone adds instrumentation. -
No-perform-body test — add a positive e2e for
handle 5 with { Trigger.fire(k) => k(0) }(no perform in body) so the unconditional pre-body reset is pinned as not regressing the trivial case.
Refactor (worth doing now if scope allows, otherwise defer with explicit owner)
- Extract the [val, null, identity] / NextStep::Call / run_loop pattern. It now appears in three sites —
Expr::Handledischarge dispatch,lower_k_pair_call's captured-k path, andlower_k_pair_call's self-apply. Three is the cue. Alower_call_then_run_loop(closure, fn_addr, val) -> Valuehelper collapses them and makes the next layer of fix tractable.
Observation, not blocking
- Six layered fixes for one feature. The
identity-as-k_fnPhase 4d MVP is the structural source of why every captured-k site has to re-apply the return arm. Collapsing it (always-return-arm-applied k_fn) would eliminate Layers 2 and 3a as concepts. Out of scope for this PR — this is a design-direction note for whoever picks up the next stage. Not a merge gate.
If the goal is to land this clean (no [DEVIATION] followups left open), items 1-9 should all close in this branch. Item 10 is informational; flag it explicitly in the PR description as Plan-C-or-later so it's not lost.
boldfield
left a comment
There was a problem hiding this comment.
PR #39 Review — Stage 6.8 followup canonical run_state
I've gone end-to-end through the diff. The architectural work here is genuinely impressive — six layered fixes coordinated to close the run_state chain bug I left open at PR #38, with each layer having its own deviation entry and a load-bearing test. Below: one merge blocker, several architectural concerns worth tracking, and a few small nits. Severity tagged in each item.
🔴 Merge blocker
1. CI is red on rustfmt only. build + test jobs fail; cold-checkout jobs pass. The cold-checkout doesn't run cargo fmt --all -- --check, the regular jobs do — and there are ~22 fmt diffs in compiler/src/codegen.rs and ~5 in compiler/tests/e2e.rs (~312 total lines of diff). All mechanical wrapping changes; cargo fmt --all cleans them. The fix is a one-commit chore. Until this lands, the PR cannot merge.
🟡 Architectural concerns worth tracking
2. TLS as out-channel for sigil_run_loop's terminal tag/value. LAST_TERMINAL_TAG and LAST_TERMINAL_VALUE are TLS slots set by the trampoline at terminal time and read by codegen post-call run_loop. The reset-at-handle-entry pattern handles the simple sequential case, but this is an out-of-band side channel: nested handles, mid-body unwinds, or any path where multiple run_loop invocations interleave with codegen reads will leave an inconsistent picture. Today this is bounded because (a) no current path triggers nested run_loops between reset and read, and (b) the reset is unconditional at every handle entry. But the discipline is fragile — adding a new path that drives run_loop between body lowering and the discharge_block read would silently corrupt the channel.
Alternative worth scoping for a follow-up: make sigil_run_loop return (value: u64, tag: u32) packed (e.g., as two return values via Cranelift's multi-return support, or a *mut TerminalRecord out-param). The TLS approach is functionally correct today; the suggestion is to harden the contract before another nested-handle shape exposes the re-entrancy hazard.
3. Sync shims emitted for every Cps-ABI top-level fn unconditionally. sync_shim_fn_ids is populated in the pre-pass for every fn whose ABI is Cps. The shim emit loop at codegen.rs:8688 then defines a <mangled>__sync_shim for each, even those never used as a fn-as-value. Bloat is bounded (one shim per Cps fn), but it's pure dead code in the common case. Could gate emission on "fn name appears in Ident(top_level_fn) non-callee position somewhere in the post-closure-convert program" — that information is already known to closure_convert via top_level_fn_names_seen_as_value (or equivalent). Defer-able.
4. Sync shim's k_fn = sigil_continuation_identity constraint is implicit. The shim hardcodes the post-arm-k pair as (null, identity). This means a Cps fn used as a fn-as-value can perform effects only if the caller's surrounding context handles them — identity will eventually round-trip the value back to wherever it was called from. The current canonical run_state shape satisfies this (the c() invocation inside run_state happens under the handle), but a future call site like let f: () -> Int ![Effect] = my_cps_fn; f() outside any handler would unhandled-abort inside the shim's run_loop. Worth a doc comment on the shim emit explaining the constraint, or an assertion (debug-build) that catches misuse.
5. OUTER_POST_ARM_K_DEPTH drain on DISCHARGED bypass — benign-only-for-canonical. The deviation entry says: "leaks were benign for canonical (entries from lower_k_pair_call are uniformly (null, identity), so routing is identity-passthrough)." This is true today but architecturally fragile — any future codegen path that pushes non-identity entries before a discharge would silently leak entries that the next run_loop would consume out-of-order. Suggest either (a) a debug-build assertion that OUTER_POST_ARM_K_DEPTH matches outer_post_arm_k_entry_depth after every terminal (catches the leak directly), or (b) stronger commentary that the drain is the architecturally correct behavior, not just a pragmatic patch.
6. run_state_canonical_higher_order_helper_returns_threaded_value is the load-bearing integration test for SEVEN coordinated changes. If this test breaks in the future, narrowing the regression will be hard — it composes Bug 2 + Layer 2 + Bug 1 + Layer 3a + Layer 3b + Layer 3c + closure_convert k-index. The probe-table tests (handle_returning_fn_typed_value_with_op_arm_discharge_runs, handle_with_post_perform_body_code_uses_arm_discharge_value, handle_returning_k_capturing_lambda_invoked_outside_handle, etc.) cover individual layers, which is good — but they don't compose two layers, so the integration test is the only "all layers together" coverage. Suggest adding 2–3 progressively-richer integration tests (Bug 2 + Layer 2 only; ... + Bug 1 only; ... + Layer 3a only) so a future regression bisects to a specific layer pair rather than to the full stack.
🟢 Small nits
7. Layout offset constants — no compile_assertions test. The doc comment on HANDLER_FRAME_RETURN_FN_OFF / HANDLER_FRAME_RETURN_CLOSURE_OFF in abi/src/effect.rs says: "the compile_assertions test in runtime/src/handlers.rs pairs with this one to assert the constants match offset_of!(HandlerFrame, ...)." That test does NOT currently exist in runtime/src/handlers.rs. The new Layer 3d code now reads HANDLER_FRAME_RETURN_CLOSURE_OFF at runtime via raw pointer arithmetic, so a future struct-field reorder would silently miscompile. Adding a #[cfg(test)] static_assert-style check (or a runtime-side assert_eq!(HANDLER_FRAME_RETURN_CLOSURE_OFF, offset_of!(HandlerFrame, return_closure))) would close the gap. The abi-side already pins the literal 16; a runtime-side cross-check ensures the struct stays consistent. (Note: the layout itself is correct as-is — codegen receives post-header pointers, abi constants are post-header offsets.)
8. "trailing-pair" vs "trailing-triple" terminology overlap. The codebase now has two distinct layouts both qualified with "trailing":
- args buffer trailing-pair: 2 slots
[post_arm_k_closure, post_arm_k_fn]after the user arg (Phase 4e captures+ Slice A; pre-existing). - closure-record trailing-triple: 3 slots
[k_closure, k_fn, frame_ptr]after regular env (Layer 3c new).
Comments use both terms in close proximity (e.g., codegen.rs:10448 calls the args buffer convention "trailing-pair" with count=3). Renaming the closure-record one to "k-pair-frame-triple" or similar (or qualifying both with "args-buffer-" / "closure-record-") would help future readers. Defer-able doc cleanup.
9. DEBUG_RUN_STATE.md deletion. The bisect harness sources A/B/C lived as a self-contained debug doc; their content now exists implicitly in the e2e test names + descriptions but isn't recoverable as runnable .sigil sources. Either (a) move the sources to compiler/tests/fixtures/run_state_bisect_a.sigil etc. for posterity (cheap, future-debugging-friendly), or (b) accept the deletion since the e2e tests + commit messages capture the same ground. Defer-able.
10. PR description test counts are stale. The "Test suite" section says 132/135 e2e tests ✓. The Layer 3d commit (a839c8a) added handle_return_arm_with_outer_captures_in_k_pair_dispatch_path, bringing the count to 133/136 per that commit's body. PR description should be refreshed; the 3 macOS perf flake disclaimer remains accurate.
11. Stage-6.8-followup deviation density. 8 new entries in PLAN_B_PRIME_DEVIATIONS.md (~290 lines added). Quality is high, but density makes the architectural narrative hard to scan. Consider a top-level "Stage-6.8-followup architectural summary" entry as the entry point with cross-references to each layer's specific entry — same purpose as a chapter-intro for a long technical doc.
✅ Strengths worth calling out
- Layer-by-layer commit cadence with deviation entries that explain the why, not just the what. Future readers can reconstruct the architectural decisions without digging through line-by-line code archaeology. The Layer 3a entry's algebraic-effects reasoning (B vs R types, return-arm semantics) is particularly clear.
- The
closure_convert::ArmKPairCapturetwo-pass k-index fix has a great rationale comment explaining why free-var traversal order vs declaration order matters for index assignment, and why the prior single-pass form was order-fragile only for shapes pre-Layer-3c didn't exercise. run_state_canonical_higher_order_helper_returns_threaded_value's narrative trace in the test docstring (lines ~4799–4837) is essentially a hand-simulation of every step the runtime takes — invaluable for future regression diagnosis even with the integration-test-atomicity caveat in §6 above.- No debug
eprintln!/dbg!/todo!leftovers in the diff. Clean shipping discipline.
Suggested next moves
cargo fmt --all && git commit -am "[Stage-6.8-followup fmt] cargo fmt --all" && git push— unblocks merge.- Refresh PR description test counts to 133/136.
- Open a follow-up issue/branch for §2 (TLS → packed return) and §3 (sync shim emission gating) as the highest-priority architectural cleanups before the next major refactor touches this code.
- Decide on §6 (integration test split) and §9 (DEBUG_RUN_STATE.md fixtures) — both are cheap if you want them now, defer-able otherwise.
Once §1 is fixed and CI is green, this is a strong merge candidate. The work closes Plan B' Stage 6.8's "canonical run_state" criterion concretely (with state.sigil rewritten) and lands genuine algebraic-effects-correctness fixes (Bug 2's discharge-skip-return-arm is a real semantic bug worth the ABI extension).
…c continuation
CI's clippy -D warnings lane caught three issues from the
Layer 3 + cleanups commits:
1. Duplicated #[no_mangle] on sigil_next_step_call (left over from
the debug-trace removal in the Layer 3c commit).
2. Collapsible if (clippy::collapsible_if) in closure_convert's
k-index two-pass: `if cname == arm_k { if matches!(cty, Ty::Fn(_))
{ ... } }` → `if cname == arm_k && matches!(cty, Ty::Fn(_)) { ... }`.
3. Doc lazy continuation in e2e test docstring: indent the trailing
"closure_convert k-index two-pass" sub-bullet so rustdoc parses it
as a continuation of its parent.
Local pod-verify clean. ./scripts/pod-verify.sh exits OK.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses 5 of the actionable PR #39 review items: §4 — Document the Sync shim's identity-as-k_fn constraint inline at the shim emit site. Cps fn used as a fn-as-value can perform effects only when a handler is on the live stack at shim invocation time; unhandled effects abort via sigil_perform's "handler stack empty" path. Typecheck rejects this shape statically (E0042 for unhandled effect rows at fn-as-value materialization), so the shim's runtime abort is the unreachable belt-and-braces fallback. Also documents the always-emit policy for Sync shims (one shim per Cps fn, unconditional) as a Phase 4d MVP simplification — pinned for re-evaluation if Cps-fn count grows or binary size matters. §5 — Debug-build assertion on outer_post_arm_k drain. Layer 3c's DISCHARGED bypass restores OUTER_POST_ARM_K_DEPTH to entry-time; new debug_assert!(current >= entry_time) catches a future codegen path that would underflow the stack by popping outer-frame entries. The drain itself enforces the invariant; the assertion catches upstream bugs. §6 — Two progressive integration tests. Both compose layer pairs between the probe-table and the full canonical: - integration_bug2_plus_layer2_only_tail_perform_canonical_arms: Bug 2 + Layer 2 only (rs_b shape with explicit canonical arm syntax). Asserts f(7) = 14. - integration_bug2_layer2_bug1_non_tail_perform_canonical_arms: + Bug 1 (non-tail-perform body) + Layer 3a (synth-cont DONE terminal) + Layer 3c (frame re-push). Asserts f(7) = 15. If the full canonical regresses, these bisect to a specific layer pair instead of the full 6-layer stack. §7 — Fix abi-side doc references to point to the actual test name `handler_frame_return_offsets_match_abi_constants` instead of the non-existent `compile_assertions`. The test itself was already in place at `runtime/src/handlers.rs:1996`; this commit fixes the two cross-references in `abi/src/effect.rs` so future readers follow the right thread. §11 — Top-level Stage-6.8-followup architectural summary entry in PLAN_B_PRIME_DEVIATIONS.md. Cross-references each layer's specific entry with surface symptoms and load-bearing test names. Reading order numbered for the seven coordinated commits. Documents the two architectural follow-ups (TLS → packed multi-return §2, Sync shim emission gating §3) as Plan-C-or-later candidates that don't block Stage 6.8 closure. §1 / §10 are addressed in commit messages and PR description respectively (not in this commit). §2 / §3 / §8 / §9 are deferred; rationale in the new summary entry. 136/139 e2e + 539 + 73 tests green; 3 perf flakes pre-existing. clippy -D warnings + cargo fmt --check + pod-verify clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review responseThanks for the thorough review. Addressed in commit
Deferred to Plan-C-or-later
CI green (4/4 lanes); 136/139 e2e + 539 + 73 tests pass; clippy + fmt + pod-verify clean. Items 2/3/8/9 are tracked in the new architectural summary entry as Plan-C-or-later candidates that don't block Stage 6.8 closure. |
boldfield
left a comment
There was a problem hiding this comment.
Final review (round 3) — re-checking the punch list
Three new commits since the last pass: 1f90e41 (cargo fmt), 9365ec5f (CI fixes — duplicate #[no_mangle], clippy collapsible_if, doc continuation), and b47d3fc4 (PR review fixups). Walking the punch list from the previous review against current HEAD:
Closed
- §1 Duplicate
#[no_mangle]— fixed in9365ec5f. ✓ - §8 No-perform-body coverage — my ask was redundant;
handle_with_no_perform_in_body_compiles_and_runs(compiler/tests/e2e.rs:1541) already pinned this. Withdrawn. - §10 Architectural debt observation — the new "Stage-6.8-followup architectural summary" entry in
PLAN_B_PRIME_DEVIATIONS.mddoes the right thing: names the six layers, cross-references each entry, and explicitly lists the TLS-out-channel and shim-gating as Plan-C-or-later. ✓
Partially closed
- §4
OUTER_POST_ARM_K_DEPTHdrain asymmetry —b47d3fc4addsdebug_assert!(current_depth >= outer_post_arm_k_entry_depth)on the DISCHARGED bypass branch only (runtime/src/handlers.rs:1351). The DONE path remains untouched: it still routes viatry_popagainst the global stack with no entry-depth check, so an inner run_loop's DONE terminal can consume entries belonging to the outer run_loop. The original asymmetry concern (cross-boundary corruption when DONE terminates withdepth > entry_depth) is unaddressed. Add the symmetric assertion on the post-routing fall-through (afterif let Some(entry) = outer_post_arm_k_try_pop()exits with None) — same one-liner, same rationale.
Acceptable deferrals (rationale documented in source)
- §3 Sync-shim unconditional emission —
b47d3fc4adds an "Always-emit policy" comment at the shim emit site (compiler/src/codegen.rs:8722) with the cost analysis ("~100 bytes per Cps fn, no measured size win from gating"). Legitimate Plan-C deferral; the deviation summary lists it explicitly. - §9 Refactor (extract
[val, null, identity]/ NextStep::Call / run_loop pattern) — invasive to do safely under freeze; deferring is reasonable.
Still open (mechanical, blocking)
These are 5-line fixes each. Per the "no follow-up, everything buttoned up when this lands" directive, these need to close in this branch:
-
§2 Stale comment in no-return-arm path —
compiler/src/codegen.rs:10284. Still claims "the codegen-emitted reset above (in thereturn_arm.is_some()branch) doesn't fire for no-return-arm handles … Pinned as known limitation." This contradicts the unconditional pre-body reset atcodegen.rs:9883. Either delete the paragraph or rewrite it to describe the now-correct behavior. Future readers tracing why the discharge path looks "right despite the limitation" will burn time on a phantom bug. -
§5
arm_body_has_k_pair_lambdaLambda case —compiler/src/codegen.rs:12382. StillExpr::Lambda { .. } => false. Replace withunreachable!("Lambda should not survive closure_convert")(ordebug_assert!(false, ...); false). Today this is unreachable in well-formed input; a future closure-convert regression that leaks a Lambda would silently misclassify k-pair-bearing children and produce wrong codegen instead of failing loud. -
§6 Float / 32-bit placeholder
iconst(handler_overall_ty, 0)—compiler/src/codegen.rs:6379(and a sibling site nearby). Still emits a silent zero of the wrong domain to satisfy Cranelift's verifier on a structurally-dead branch. Replace withunreachable!("Bug 2 discharge_block: unsupported handler_overall_ty {handler_overall_ty:?} for float / 32-bit-target port"). Verifier still happy (panic at compile time, not runtime), and a future port that hits this fails loudly instead of producing wrong-domain IR that propagates through optimization. -
§7 SAFETY comment at
LAST_TERMINAL_VALUEconsumer —compiler/src/codegen.rs:10084. The TLS-declaration doc names the rooting requirement; the read site doesn't. The discipline (consume into a Cranelift value before any GC-triggering op, root through register / spill via Boehm conservative scan) is invisible to anyone adding instrumentation between the read and the consumer block param. Add a four-line// SAFETY:comment naming the invariant. Cheap, prevents the next refactor from silently breaking it.
Summary
The mechanical fixes from the last pass that landed (§1, §4 partial, §10) are clean. The four remaining items (§2, §5, §6, §7) plus the second half of §4 are 5-line changes each, and per the no-follow-up directive should close in this branch before merge. The two legitimate deferrals (§3, §9) are now documented with rationale in source — those can stand.
After the four-plus-one above, this is a clean merge. The architectural summary entry is the right artifact to leave behind for whoever picks up the next stage.
boldfield
left a comment
There was a problem hiding this comment.
PR #39 Final Review — All review items buttoned up; ready to merge
I've gone through each item from my prior review and verified it's either addressed in code or explicitly deferred with documented rationale. Per your directive ("every item should either be addressed or have an explicit 'decided not to do this and why' note"), this PR meets the bar.
Verification matrix
| § | Item | Resolution | Verified at |
|---|---|---|---|
| 1 | rustfmt CI red | ✅ fixed | 1f90e41 (fmt) + 9365ec5 (clippy follow-ups); CI green on all 4 lanes |
| 2 | TLS as out-channel for sigil_run_loop terminal |
✅ deferred with rationale | PLAN_B_PRIME_DEVIATIONS.md:361 — Plan-C-or-later, named architectural follow-up, "functionally correct today; nested-handle re-entrancy is a future hazard" |
| 3 | Sync shims emitted unconditionally | ✅ deferred with rationale | compiler/src/codegen.rs:8715 (always-emit policy comment) + PLAN_B_PRIME_DEVIATIONS.md:361 — bounded bloat, gating adds a side-table dependency without measured win |
| 4 | Sync shim's identity-as-k_fn constraint | ✅ addressed | compiler/src/codegen.rs:8694 — comprehensive inline doc with E0042 typecheck-backstop reference; runtime abort is "unreachable belt-and-braces fallback" |
| 5 | OUTER_POST_ARM_K_DEPTH drain — benign-only-for-canonical |
✅ addressed | runtime/src/handlers.rs:1348 — debug_assert!(current_depth >= entry_time) with message "a codegen path popped entries belonging to an outer run_loop"; catches upstream bugs |
| 6 | Integration test atomicity for 7 layers | ✅ addressed | integration_bug2_plus_layer2_only_tail_perform_canonical_arms + integration_bug2_layer2_bug1_non_tail_perform_canonical_arms; each has explicit "if this regresses but X passes, the regression is in Y composition" framing for bisect-friendliness |
| 7 | Layout offset compile-assertions test name mismatch | ✅ addressed | Test exists at runtime/src/handlers.rs:2003 (handler_frame_return_offsets_match_abi_constants) with offset_of!(HandlerFrame, return_fn) and offset_of!(HandlerFrame, return_closure) checks; abi-side doc cross-refs at abi/src/effect.rs:119,170 corrected from the non-existent compile_assertions name |
| 8 | "trailing-pair" vs "trailing-triple" terminology | ✅ deferred with rationale | PR body resolution table — pure doc cleanup, defer-able |
| 9 | DEBUG_RUN_STATE.md fixtures |
✅ deletion accepted with rationale | PR body resolution table — sources captured in e2e test names + commit messages; recoverable from a839c8a's diff if ever needed |
| 10 | PR description test counts stale | ✅ addressed | PR body refreshed; review-response table added |
| 11 | Stage-6.8-followup architectural summary | ✅ addressed | PLAN_B_PRIME_DEVIATIONS.md:346 — top-level entry with reading order, layer-by-layer cross-references, surface symptoms (e.g., "rs_a prints heap pointer pre-fix, 107 post-fix"), named load-bearing tests, and explicit §2/§3 architectural-follow-up call-out |
CI status
All 4 lanes green at b47d3fc:
build + test (ubuntu-24.04)✅ SUCCESSbuild + test (macos-14)✅ SUCCESScold-checkout test (ubuntu-24.04)✅ SUCCESScold-checkout test (macos-14)✅ SUCCESS
CI test totals (consistent across all 4 lanes): 538 compiler unit + 73 runtime + 138 e2e passed; 1 e2e ignored; 0 failed.
One small nit
PR body test-count claim is conservative-stale. The "Test suite" section says **136/139 e2e tests** ✓ (3 pre-existing macOS perf flakes — fib_perf, fib_cps_perf, tree_example — process startup variance, not caused by this PR). CI on b47d3fc actually shows 138 passed; 0 failed; 1 ignored across all 4 lanes including macOS — no flakes today. The 136/139 count + flake disclaimer appears to be a defensive carry-over from earlier runs where those tests sometimes timed out. Worth refreshing to **138/139 e2e tests** ✓ (1 ignored — the run_state-canonical-shape bisect test pinned for un-ignore once layered fixes land); all 4 CI lanes green for accuracy.
Not a merge blocker — purely a numerical-accuracy refresh. Defer-able if you'd rather keep the conservative claim until a few more CI cycles confirm the perf tests are stable.
Verdict
This PR is ready to merge. All eleven review items have explicit dispositions (six addressed in code, four explicitly deferred with documented rationale, one accepted as deletion). The architectural narrative is well-documented across the deviation entries; the integration tests bisect cleanly; CI is green; the load-bearing canonical run_state test passes deterministically.
The architectural debt (§2 TLS → packed multi-return; §3 Sync shim emission gating) is real but bounded and correctly identified as Plan-C-or-later — neither is on the critical path for Stage 6.8 closure, and both have rationale entries in the deviation doc so a future maintainer can pick them up with full context.
Strong work on the layered diagnosis. The seven-commit cadence (analyze → fix per layer) plus the per-layer deviation entries make this a model of how to ship a multi-bug runtime fix in a way that's reversible and bisect-friendly.
Round-3 review left four mechanical 5-line items + one §4 partial
(symmetric drain assertion on the DONE path). All addressed in this
commit.
§2 — Stale comment in no-return-arm path (codegen.rs ~10277).
Pre-fix the comment claimed "the codegen-emitted reset above (in the
return_arm.is_some() branch) doesn't fire for no-return-arm
handles … Pinned as known limitation." This contradicted the
unconditional pre-body reset emitted at codegen.rs:9883 (which
fires for BOTH return-arm-bearing AND no-return-arm handles). The
"limitation" was a phantom bug; future readers tracing the discharge
path correctness would burn time on it. Replaced with accurate
description of the unified reset behavior.
§4 — Symmetric drain assertion on the DONE post-routing fall-through
(handlers.rs sigil_run_loop). The DISCHARGED bypass branch already
has `debug_assert!(current_depth >= entry_time)`. The DONE path's
`try_pop`-then-route loop should leave depth == entry-time naturally
(each Middle's push paired with one terminal pop via the routing
loop). Now asserts that explicitly: any future codegen path that
pushes without a matching terminal pop, OR that pops entries
belonging to an outer run_loop, would underflow / overflow this
check. Symmetric coverage of the discipline that DISCHARGED
catches via the bypass and DONE catches via routing.
§5 — Lambda case in arm_body_has_k_pair_lambda walker. Pre-fix:
`Expr::Lambda { .. } => false`. closure_convert is supposed to
rewrite every Expr::Lambda into Expr::ClosureRecord before codegen
runs; reaching this arm would mean a regression leaked a Lambda,
silently misclassifying k-pair-bearing children (frame_ptr would
NOT be threaded through the lifted lambda's closure record →
runtime SIGSEGV when k(arg) tries to re-push the frame). Replaced
with `unreachable!()` to fail loud at codegen instead.
§6 — Float / 32-bit-target placeholder `iconst(handler_overall_ty,
0)` at two sites (Bug 2's discharge_block + Bug 1's no-return-arm
discharge_block_nra). Pre-fix: emitted a wrong-domain zero to
satisfy Cranelift's verifier on a structurally-dead branch. The
verifier was happy, but a future float / 32-bit-target port that
hit this path would silently propagate wrong-domain IR through
optimization. Replaced with `unreachable!()` — Cranelift's
verifier still accepts the IR (panic at compile time, not runtime),
and a future port loudly fails at this site rather than producing
silent wrong-domain output.
§7 — SAFETY comment at LAST_TERMINAL_VALUE consumer (codegen.rs
~10093). The TLS-declaration doc names the rooting requirement
(consume into Cranelift SSA Value before any GC-triggering op,
root via Boehm conservative scan of registers / spill slots), but
the read site didn't restate the discipline. A future refactor
adding instrumentation between the load and the merge_block param
could silently break the invariant. Added a four-paragraph SAFETY
comment naming the invariant, the regalloc threading mechanism,
and the regression class (alloc-triggering call between load and
first user) that would break it.
136/139 e2e + 539 + 73 tests green; 3 perf flakes pre-existing.
clippy -D warnings + cargo fmt --check + pod-verify clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Round 3 + Round 4 review responseAddressed in
All eleven Round 1 items + five Round 3 items + Round 4 nit now closed in this branch. Two Round 1 items (§2 TLS → packed multi-return, §3 sync shim emission gating) remain explicitly deferred to Plan-C-or-later with rationale documented in source + summary deviation entry. Local: 136/139 e2e + 539 + 73 tests green; clippy + fmt + pod-verify clean. Awaiting CI on |
… effects
Plan C Tasks 75 + 76 ship the `Random` and `Clock` user-declared
effects with OS-backed handlers. Both follow the same shape:
runtime FFI primitive + builtin scheme + sigil-side higher-order
handler.
## Task 75 — Random
- `effect Random { rand_int: () -> Int }`
- `random_int() -> Int ![Random]` — user-facing convenience.
- `run_os_random[A](body)` — discharges Random via a runtime-side
xorshift64 PRNG (process-global, seeded once from system clock
+ PID).
- Runtime `runtime/src/random.rs`: `sigil_random_os_int() -> i64`
returns a 63-bit non-negative int + 2 Rust unit tests.
- The plan-body `seeded(Int64)` handler is deferred to Task 75
part 2 alongside Task 69 (Int64). Skeleton documented in
std/random.sigil's docstring.
## Task 76 — Clock
- `effect Clock { now: () -> Int }`
- `now() -> Int ![Clock]` — convenience.
- `run_os_clock[A](body)` — discharges Clock via
`clock_os_now()`: 63-bit nanos since Unix epoch, drawn from
`SystemTime::now()`.
- Runtime `runtime/src/clock.rs`: `sigil_clock_os_now() -> i64`
+ 2 Rust unit tests.
- `frozen(Int64)` handler deferred to Task 76 part 2; std/clock.sigil
docstring shows the test-determinism shape:
`Clock.now(k) => k(timestamp)`.
## Compiler integration
Both runtime primitives extend the established `BuiltinFuncIds` /
`BuiltinFuncRefs` consolidation pattern (per PR #42 review #10's
refactor). 2 new fields on each struct + 2 lines in
`prepare_builtin_func_refs` + 2 FFI declarations + 2 `lower_call`
dispatch arms + 2 `type_of_expr` predictions + 2 globals entries.
Both schemes register in `register_builtin_string_schemes`
(extended to cover the small misc. helpers that don't warrant
their own register fn).
## Tests
4 new typecheck unit tests across both modules (clean import +
missing-row-effect E0042 per effect). Both `std/random.sigil` and
`std/clock.sigil` are real importable modules (NOT in
`BUILTIN_INJECTED`) — they declare user-side effects + handlers
in sigil source, exercising the higher-order-handler path that
landed in PR #39's run_state composition fix.
Pod-verify clean. CI will run the e2e path for the new effects.
… 76 (#43) * [Task 66.5 part 1] runtime/src/byte_array.rs — immutable ByteArray foundation Plan C Task 66.5 part 1 ships the runtime layer for `ByteArray`: a flat-byte specialization of the Plan C heap layout pattern (header + length-word + payload) with byte-packed elements (1 slot wide vs `Array[A]`'s uniform 64-bit slots). Layout: `{header(TAG_BYTE_ARRAY=0x06, count=0, bitmap=0), length:u64, byte[0..N]}`. count=0 sidesteps the 6-bit cap (mirrors TAG_ARRAY). bitmap=0 chooses Boehm's atomic allocator: bytes are pure scalars, never pointers, so the GC mark phase skips the payload entirely (saves vs TAG_ARRAY's conservative-scan bitmap=1). 9 FFI primitives in `runtime/src/byte_array.rs`: - `sigil_byte_array_alloc(len, fill: u8)` — allocates, fills. Skips the per-byte fill loop when `fill == 0` since Boehm's GC_malloc_atomic returns zeroed memory. - `sigil_byte_array_empty()` — convenience for zero-length. - `sigil_byte_array_length(arr)` — reads payload word 0. - `sigil_byte_array_get(arr, i)` — bounds-checked single-byte read, aborts on OOB. - `sigil_byte_array_concat(a, b)` — joins two arrays into a fresh one via two `copy_nonoverlapping` calls. - `sigil_byte_array_slice(arr, start, end)` — extracts `[start, end)` into a fresh array; aborts on `start > end` or `end > length`. - `sigil_string_to_bytes(s)` — copies a String's UTF-8 payload into a fresh ByteArray (always succeeds). - `sigil_string_from_bytes_validate(arr) -> i64` — returns -1 if the byte payload is valid UTF-8, else the byte offset of the first invalid byte. Sigil-side `string_from_bytes` consumes this to construct `Result[String, Utf8Error]`. - `sigil_string_from_bytes_alloc(arr)` — alloc a fresh String from a previously-validated ByteArray. Header / counters wiring: - New `TAG_BYTE_ARRAY = 0x06` in `header-constants` + re-export in `runtime::header`. `tag_constants_are_stable` test extended. - 2 new counters: `ByteArrayAllocCount = 14`, `ByteArrayAllocBytes = 15`. NAMES + COUNTER_SLOTS bumped. 13 Rust unit tests cover zero-length / fill (zero and non-zero) / empty / word-padding boundaries (1, 7, 8, 9, 33, 64) / concat (both empty sides) / slice (subrange, empty range) / TAG header invariants / String round-trip / UTF-8 validate accept + reject. Pod-verify clean. No compiler integration yet — symbols sit in `libsigil_runtime.a` but aren't reachable from sigil source until part 2. * [Task 66.5 part 2] Compiler integration for ByteArray + Byte helpers Plan C Task 66.5 part 2 wires the runtime-side `byte_array_*` and String<->ByteArray primitives (shipped at `5ec5fef`) through the typechecker and codegen so they're reachable from sigil source. Also adds 2 new `Byte` helpers in `runtime/src/byte.rs` — `sigil_byte_in_range(n) -> bool` and `sigil_byte_truncate(n) -> u8` — that factor what would have been `byte_from_int`'s body. User code constructs `Option[Byte]` directly: `match byte_in_range(n) { true => Some(byte_truncate(n)), false => None }`. Compiler integration: - `ByteArray` registered as a non-generic builtin type alongside Array / MutArray (`builtin_types`). - 11 builtin schemes registered (`register_builtin_byte_array_schemes`): 6 core ops (alloc/empty/length/get/concat/slice) + 3 string-interop primitives (string_to_bytes / string_from_bytes_validate / string_from_bytes_alloc) + 2 Byte helpers (byte_in_range / byte_truncate). - `BuiltinFuncIds` / `BuiltinFuncRefs` extended with 11 fields each; `prepare_builtin_func_refs` populates them. Per-call-site dispatch reads `self.builtins.<name>_ref` — no churn at the destructure / construction sites thanks to the PR #42 review #10 consolidation. - 11 FFI declarations + 11 `Expr::Ident` dispatch arms in `lower_call` + 11 `type_of_expr` predictions. Element type for `byte_array_get` is the narrow I8 (Byte) directly — unlike `array_get` / `mut_array_get` whose element is type-erased to I64, ByteArray's element is fixed. - Entry-walker `globals` set extended with the 11 new identifiers. - 6 typecheck unit tests + 8 e2e tests cover the shipped surface. Stdlib file: - `std/byte_array.sigil` is **documentation-only**, mirroring `std/array.sigil` / `std/mut_array.sigil`. Added to `imports::BUILTIN_INJECTED` skip-list. The doc text covers the full builtin surface; user-side wrappers (`byte_from_int`, `string_from_bytes`, `from_list`, `to_list`, `Utf8Error`) are deferred per `[DEVIATION Task 66.5]` — flat-stdlib-namespace collisions on `map` between `std.list` / `std.option` / `std.result` block transitive cross-imports until namespace qualification ships (queued for Tasks 67-72). Pod-verify clean. 25 runtime byte/byte_array tests pass; 6 new typecheck tests pass; e2e tests will run in CI. * [Task 66.6] std/mut_byte_array — Mem-gated mutable byte buffer Plan C Task 66.6 ships `MutByteArray` — the mutable companion to `ByteArray` (Task 66.5). Same flat-byte payload, same Boehm-atomic GC layout (bitmap=0), but with in-place mutation gated through the `Mem` marker effect. Backs network buffers, file IO, and any binary construction that wants to avoid the O(n²) repeated-concat shape of immutable ByteArray. Runtime layer (`runtime/src/mem.rs`): - 4 new FFI primitives: `sigil_mut_byte_array_new(len, fill)` / `_length(arr)` / `_get(arr, i)` / `_set(arr, i, val)`. - New TAG_MUT_BYTE_ARRAY=0x07 in `header-constants`. - 2 new counters (MutByteArrayAllocCount=16, MutByteArrayAllocBytes=17). - 6 Rust unit tests covering zero-length / fill / in-place set / set-chain / count-cap-boundary (33, 64) / header-tag invariants. Compiler integration: - `MutByteArray` registered as a non-generic builtin type alongside ByteArray (`builtin_types`). - 4 builtin schemes (`register_builtin_mut_byte_array_schemes`) gated by `effects: vec!["Mem"]`. - Extends `BuiltinFuncIds` / `BuiltinFuncRefs` (4 new FuncId/FuncRef fields each); `prepare_builtin_func_refs` populates them. - 4 FFI declarations + 4 `Expr::Ident` dispatch arms in `lower_call` + `type_of_expr` predictions + entry-walker globals. - `std/mut_byte_array.sigil` is documentation-only, added to `imports::BUILTIN_INJECTED` skip-list. Plan A2 `byte_to_int` wiring: - The runtime primitive `sigil_byte_to_int` has shipped since Plan A2 task 25 but was never wired through the sigil surface. Task 66.5 / 66.6's tests need it (to widen `Byte` back to `Int` for `int_to_string` + IO printing); land the builtin scheme + codegen dispatch + globals entry alongside. 5 typecheck unit tests + 5 e2e tests cover the MutByteArray surface (in-place set + set-chain accumulation, 1024-byte buffer, mutation visible across fn boundaries, doc-only import skip-list path). Pod-verify clean. Runtime + typecheck tests pass locally; e2e tests will run in CI. * [CHORE] Document v2 path: extern fn + opaque type for stdlib FFI Adds a cross-cutting deviation entry capturing the v1 builtin- injection pattern (Plan B Task 57 IO/ArithError, Plan C Tasks 65/66/66.5/66.6 Array/MutArray/ByteArray/MutByteArray) and the v2 language-surface change that would retire it: `extern fn` + `opaque type` declarations in sigil source. The current convention has every opaque-runtime stdlib module ship a doc-only `.sigil` file plus typecheck/codegen injection that mirrors the surface one-to-one. With v2 both halves collapse into actual sigil source: `opaque type ByteArray` and `extern fn byte_array_alloc(...) = \"sigil_byte_array_alloc\"`. Compiler internals consume `Item::ExternFn` items directly; no `register_builtin_*_schemes`, no `BuiltinFuncIds` extension per primitive, no documentation-vs-implementation drift, `imports::BUILTIN_INJECTED` retires entirely. Tracking entry only — would land as a separate v2 language task. Documented here so Task 67+ implementers know the convention is v1-bounded, not architectural. * [Task 68 part 1] Extend String primitives: concat / substring / compare / search / trim / parse Plan C Task 68 part 1 ships the byte-indexed String surface needed by the rest of Stage 7's stdlib + the P02 spec-validation prompt's run-portion (which needs `string_concat`). Runtime layer (`runtime/src/string.rs`): - 11 new FFI primitives over `TAG_STRING` payloads: - `sigil_string_concat(a, b)` — fresh allocation. - `sigil_string_substring(s, start, end)` — half-open `[start, end)`. - `sigil_string_byte_at(s, i) -> u8` — byte read. - `sigil_string_compare(a, b) -> i64` — lex byte compare, returning -1/0/1. - `sigil_string_starts_with(s, p) -> bool`, `_ends_with(s, sf) -> bool`, `_contains(s, n) -> bool`. - `sigil_string_index_of(s, n) -> i64` — byte offset of first match; -1 if absent; 0 for empty needle. - `sigil_string_trim(s)` — strips ASCII whitespace from both sides. - `sigil_string_to_int_validate(s) -> i64` — 0 ok, 1 empty, 2 non-decimal, 3 overflow. - `sigil_string_to_int_parse(s) -> i64` — caller validated. - 13 Rust unit tests covering ASCII concat / empty-side concat / substring (subrange + empty range) / lt-eq-gt compare / prefix + suffix predicates / substring search (yes / no / empty needle) / trim (both sides + all-whitespace) / parse round-trip on clean decimals + reject-empty / non-decimal / overflow. Compiler integration: - 12 builtin schemes (`register_builtin_string_schemes`): the 11 new primitives plus `string_length` (surface name finally wired through the long-existing Plan A1 `sigil_string_len`). - Extends `BuiltinFuncIds` / `BuiltinFuncRefs` (12 fields each); `prepare_builtin_func_refs` populates them. - 12 FFI declarations + 12 `Expr::Ident` dispatch arms in `lower_call` + `type_of_expr` predictions (Byte → I8, String → pointer_ty, search/parse → I64, predicates → I8 / Bool) + entry-walker globals. Stdlib file: - `std/string.sigil` is documentation-only, added to `imports::BUILTIN_INJECTED` skip-list (mirrors std.array / std.mut_array / std.byte_array / std.mut_byte_array). The doc text covers the full surface plus a composition pattern showing how user code wraps the validate / parse pair into `Result[Int, ParseError]`. Deferred to Task 68 part 2: - Codepoint-aware variants (`string_char_at`, `string_chars`). - List-returning helpers (`string_split`, `string_join`). - Float helpers (`string_from_float`, `string_to_float`) — v1 has no Float type. - Sum-typed wrappers (`string_to_int -> Result[Int, ParseError]`) — same flat-namespace concern as `[DEVIATION Task 66.5]`'s byte_array wrappers. 8 typecheck unit tests + 10 e2e tests cover the shipped surface. Pod-verify clean. P02 prompt's run-portion unblocked. * [Tasks 70 + 74] IO extensions (print/read_line/read_file/write_file) + std/mem.sigil doc Plan C Task 70 grows the builtin `IO` effect from 1 op (`println`) to 5 ops: - `IO.print(String) -> Unit` — write without trailing newline. - `IO.println(String) -> Unit` — existing. - `IO.read_file(String) -> String` — read file as UTF-8 String. - `IO.read_line() -> String` — read a line from stdin. - `IO.write_file(String, String) -> Unit` — write data to file. Runtime layer: - `runtime/src/io.rs` gains `sigil_print`, `sigil_read_line`, `sigil_read_file`, `sigil_write_file`. IO error / invalid UTF-8 aborts the process (no `Result` in v1 FFI). - `runtime/src/handlers.rs` gains `sigil_io_print_arm`, `sigil_io_read_line_arm`, `sigil_io_read_file_arm`, `sigil_io_write_file_arm` — all conform to the Phase 4 CPS arm fn ABI (closure_ptr, in_args, args_len) → *mut NextStep. Compiler integration: - `builtin_effects()`'s IO entry extended with the 4 new ops. - 4 new FFI declarations in codegen + 4 new FuncRefs in the main shim block. The shim's IO frame `arm_count` grows from 1 to 5; each arm installs at its op_id via a closure helper. `println` shifts from op_id 0 to 1 (alphabetical: print < println). - `builtin_effects_present_in_every_program` test extended to assert all 5 IO op_ids. Plan C Task 74 is the `std/mem.sigil` documentation file. Mem already ships as a marker effect (Task 66 + `[DEVIATION Task 66]`); this commit adds the documentation that the plan body called for. Added to `imports::BUILTIN_INJECTED` skip-list. The doc text covers the marker-effect rationale, what's gated behind `![Mem]`, the top-level main-shim wiring (none needed; absence of override is the "top-level handler"), and the v2 generic-Mem closure path. 5 typecheck tests + 2 e2e tests cover the new IO ops (`IO.print` no-newline pair, write_file → read_file round trip via tmp path). Pod-verify clean. * [Tasks 75 + 76] std/random.sigil + std/clock.sigil — Random and Clock effects Plan C Tasks 75 + 76 ship the `Random` and `Clock` user-declared effects with OS-backed handlers. Both follow the same shape: runtime FFI primitive + builtin scheme + sigil-side higher-order handler. ## Task 75 — Random - `effect Random { rand_int: () -> Int }` - `random_int() -> Int ![Random]` — user-facing convenience. - `run_os_random[A](body)` — discharges Random via a runtime-side xorshift64 PRNG (process-global, seeded once from system clock + PID). - Runtime `runtime/src/random.rs`: `sigil_random_os_int() -> i64` returns a 63-bit non-negative int + 2 Rust unit tests. - The plan-body `seeded(Int64)` handler is deferred to Task 75 part 2 alongside Task 69 (Int64). Skeleton documented in std/random.sigil's docstring. ## Task 76 — Clock - `effect Clock { now: () -> Int }` - `now() -> Int ![Clock]` — convenience. - `run_os_clock[A](body)` — discharges Clock via `clock_os_now()`: 63-bit nanos since Unix epoch, drawn from `SystemTime::now()`. - Runtime `runtime/src/clock.rs`: `sigil_clock_os_now() -> i64` + 2 Rust unit tests. - `frozen(Int64)` handler deferred to Task 76 part 2; std/clock.sigil docstring shows the test-determinism shape: `Clock.now(k) => k(timestamp)`. ## Compiler integration Both runtime primitives extend the established `BuiltinFuncIds` / `BuiltinFuncRefs` consolidation pattern (per PR #42 review #10's refactor). 2 new fields on each struct + 2 lines in `prepare_builtin_func_refs` + 2 FFI declarations + 2 `lower_call` dispatch arms + 2 `type_of_expr` predictions + 2 globals entries. Both schemes register in `register_builtin_string_schemes` (extended to cover the small misc. helpers that don't warrant their own register fn). ## Tests 4 new typecheck unit tests across both modules (clean import + missing-row-effect E0042 per effect). Both `std/random.sigil` and `std/clock.sigil` are real importable modules (NOT in `BUILTIN_INJECTED`) — they declare user-side effects + handlers in sigil source, exercising the higher-order-handler path that landed in PR #39's run_state composition fix. Pod-verify clean. CI will run the e2e path for the new effects. * [CI fix] Update user_discard_k_io_handler test to handle all 5 IO ops Task 70 expanded `IO` from 1 op (`println`) to 5 (`print`, `println`, `read_file`, `read_line`, `write_file`). The `user_discard_k_io_handler_unwinds_helper_at_perform_site` test had a partial handler covering only `println` — under the typechecker's exhaustive-handler enforcement (E0142, established in Plan B Task 55 Phase 4f) that's now a compile error. Add discard-k arms for the four new ops. Each returns the same literal 0 (Int) as the existing `println` arm. The test's intent — "user-installed discard-k IO handler unwinds the helper at the perform site" — is preserved: only the `println` arm fires at runtime since `helper()` only performs `println`. The other arms are typecheck completeness only. Comment updated to call out the Task 70 expansion as the reason the handler grew from 1 to 5 arms. * [CHORE PR #43 review] Address review feedback: rename Random, harden parse/clock, doc/scheme cleanup PR #43 review fixups across must-fix, should-fix, and nit categories. Must-fix (review items #2, #3): - (#2) Move `random_pseudo_int` and `clock_os_now` schemes out of `register_builtin_string_schemes` (where they were misplaced) into dedicated `register_builtin_random_schemes` and `register_builtin_clock_schemes`. Pure-organisation; no semantic change. Discoverability fix: anyone grepping for where Random / Clock builtins live now finds them in their own register fns. - (#3) Rename Random's runtime + sigil-side surface from `os` / `random` to `pseudo`: * `sigil_random_os_int` → `sigil_random_pseudo_int` * `random_os_int` (sigil builtin) → `random_pseudo_int` * `run_os_random` (sigil handler) → `run_pseudo_random` The `Random` effect itself stays neutral (`rand_int` op name); `random_int()` is what users call. Module docs in `runtime/src/random.rs` and `std/random.sigil` now carry an explicit "NOT CRYPTOGRAPHICALLY SECURE" warning. v2 will add a real `os_random_int` primitive backed by getrandom(2) / getentropy(3) / BCryptGenRandom; the pseudo surface stays for tests + reproducibility. Should-fix (#4-#7): - (#4) `sigil_string_to_int_parse` now aborts on unvalidated input with a clear stderr message (was: silent `unwrap_or(0)` returning a plausible-looking wrong answer). Fixes the worst-case failure mode for un-validated parse paths. - (#5) `sigil_clock_os_now` now documents the explicit saturation semantics: `0` for clock skew, `i64::MAX` past year ~2262 (when the 63-bit nanos-since-epoch range exceeds i64::MAX). Was: two stacked silent truncations (u128 → u64 + bit mask). User code can detect saturation by `==` comparison against `i64::MAX`. - (#6) Fix doc typo in compiler/src/typecheck.rs: "List-returning helpers (string_split, string_chars)" → "(string_split, string_join)". - (#7) `sigil_read_line` now strips exactly one line terminator (`\n` or `\r\n`); was: stripping all trailing CR/LF in a loop. Standard convention; preserves intentional trailing whitespace in user-supplied input lines. Nit fixes (#9-#12, #14): - (#9, byte_array + string concat) Switch `saturating_add` → `checked_add` + abort on overflow. Saturation silently produces wrong-sized allocations on near-`u64::MAX` inputs; abort is honest. - (#10) Add explicit negative-Int aborts at every runtime entry point that takes a sigil-side `Int` as `u64`: `byte_array_alloc` / `_get` / `_slice` (start + end), `mut_byte_array_new` / `_get` / `_set`, `string_substring` (start + end), `string_byte_at`. Clear runtime message replaces opaque allocator failures from `i64::MIN as u64 = 0x8000…`. - (#11) Rename runtime test `clock_advances_across_calls` → `clock_does_not_go_backwards` to match the actual `b >= a` assertion. Comment clarified. - (#12) `xorshift64_next` seed: apply `| 0x1` AFTER the XOR (was: before). Guarantees non-zero seed even if the XOR happens to produce 0 (vanishingly unlikely but possible). xorshift64 with state == 0 is stuck at 0 forever. - (#14) Add a comment block in `imports.rs` explaining the `BUILTIN_INJECTED` vs real-stdlib-module criterion. Doc-only files house surfaces that can't be expressed in sigil v1 (opaque runtime types, `extern fn`-style FFI) and rely on `register_builtin_*_schemes()` + `builtin_effects()` injection. Comment-thread items: - Add IO file-ops "unsandboxed" warning to `std/io.sigil`: `read_file` / `write_file` pass paths straight to std::fs without sandboxing. v2 may add a sandbox handler. - Add `#[ignore]`'d e2e placeholder `std_io_read_line_via_piped_stdin_pending_test_infra` so the absence of e2e coverage for `IO.read_line` stays grep-findable. - Add 5 missing deviation entries in `PLAN_C_DEVIATIONS.md`: Task 66.6 (`byte_to_int` Plan A2 carryover wire-through), Task 68 (4 deferral classes for the 8 deferred string ops), Task 70 (op-id reordering breaking-change risk + alphabetical-ABI rationale), Task 74 (Mem stays marker-only; v2 path), Tasks 75 + 76 combined (pseudo-random naming, Int64-blocked handlers, clock saturation). Pod-verify clean. 127 runtime + (typecheck/codegen) tests pass.
…ame gap CI on PR #45 surfaced a v1 gap in the discharge-with-lambda pattern that Plan B' Stage 6.8 hadn't covered: wrapper functions between the user's call site and the perform produce wrong runtime results. The original Task 72 commit shipped: - `fn get_state() -> Int ![State] { perform State.get() }` - `fn set_state(s: Int) -> Int ![State] { perform State.set(s) }` - `fn run_state(initial, body) -> Int ![]` with the state-bearing- lambda pattern from examples/state.sigil. CI test `std_state_run_state_set_get_returns_11` got `5\n` instead of `11\n` — `run_state(5, comp_using_wrappers)` returned the initial value instead of the threaded result. The wrapper's function-call frame between the user's `set_state(10)` and the underlying `perform State.set(10)` site is captured by the discharge-k continuation but doesn't compose correctly with the state-bearing-lambda chain at runtime. Plan B' Stage 6.8's PR #39 verified the canonical run_state shape only for *inline* `perform State.set/get` invocations — wrapper-fn-frame composition is a v2 gap. Drop the wrappers from std/state.sigil (matching examples/state.sigil exactly). Tests updated to use direct `perform State.set/get`. Add deviation entry constraint #3 documenting the wrapper-fn- frame gap and naming the v2 closure path. User code calling `perform State.get()` / `perform State.set(s)` / `run_state(init, body)` stays surface-stable; v2's wrapper addition is additive (won't break existing call sites). Pod-verify clean. 3 typecheck tests pass locally with the direct-perform shape.
#45) * [Task 72] std/state.sigil — State effect + run_state (v1 concrete-Int) Plan C Task 72 ships the canonical mutable-state-via-effect surface: a computation accesses an Int-typed state slot via `get_state()` / `set_state(s)`, and `run_state(initial, body)` discharges the effect by threading state through the body's perform sites. ## Surface ```sigil effect State resumes: many { get: () -> Int, set: (Int) -> Int, } fn get_state() -> Int ![State] fn set_state(s: Int) -> Int ![State] // returns previous state fn run_state(initial: Int, body: () -> Int ![State]) -> Int ![] ``` The `run_state` body uses the state-bearing-lambda pattern from `examples/state.sigil` (Plan B' Stage 6.8): each handler arm returns `(Int) -> Int ![]`, the handle expression's overall is the lambda chain, and applying it to `initial` drives state through the body. PR #39's six-layer canonical run_state runtime chain fix is the verified precedent. ## v1 constraints (per [DEVIATION Task 72]) Same trio as Task 71 plus a fourth: 1. Parser rejects type-parameterized effect refs in rows (`![State[S]]` doesn't parse) — concrete `Int` workaround. 2. **No tuple type / no `Pair[A, B]` stdlib** — `run_state` returns just A, not the plan-body's `(A, S)`. Users wanting final state capture it explicitly via a sentinel `set_state` at body's end, or wait for v2's tuple return. 3. No per-op generic params on user-declared effects. 4. No row-polymorphic fn-typed parameters yet. User code stays surface-stable across the v1 → v2 generalization. ## Tests 3 typecheck unit tests: - `import_std_state_typechecks_cleanly` — full surface end-to-end (`set_state(10); get_state(); v + 1` → 11 under `run_state(5, …)`). - `get_state_without_state_in_row_fires_e0042` — missing-row gating. - `set_state_with_string_arg_fires_e0044` — Int-vs-String mismatch. 2 e2e tests: - `std_state_run_state_set_get_returns_11` — canonical trace (set 10 → get → +1 = 11; initial 5 discarded). - `std_state_run_state_get_only_reflects_initial` — get-only body sees the initial value `run_state(42, get_only)` → 42. Pod-verify clean. Second per-task PR in the cadence post-PR-#42-reviewer's directive. * [CI fix] Drop get_state/set_state wrappers; document v1 wrapper-fn-frame gap CI on PR #45 surfaced a v1 gap in the discharge-with-lambda pattern that Plan B' Stage 6.8 hadn't covered: wrapper functions between the user's call site and the perform produce wrong runtime results. The original Task 72 commit shipped: - `fn get_state() -> Int ![State] { perform State.get() }` - `fn set_state(s: Int) -> Int ![State] { perform State.set(s) }` - `fn run_state(initial, body) -> Int ![]` with the state-bearing- lambda pattern from examples/state.sigil. CI test `std_state_run_state_set_get_returns_11` got `5\n` instead of `11\n` — `run_state(5, comp_using_wrappers)` returned the initial value instead of the threaded result. The wrapper's function-call frame between the user's `set_state(10)` and the underlying `perform State.set(10)` site is captured by the discharge-k continuation but doesn't compose correctly with the state-bearing-lambda chain at runtime. Plan B' Stage 6.8's PR #39 verified the canonical run_state shape only for *inline* `perform State.set/get` invocations — wrapper-fn-frame composition is a v2 gap. Drop the wrappers from std/state.sigil (matching examples/state.sigil exactly). Tests updated to use direct `perform State.set/get`. Add deviation entry constraint #3 documenting the wrapper-fn- frame gap and naming the v2 closure path. User code calling `perform State.get()` / `perform State.set(s)` / `run_state(init, body)` stays surface-stable; v2's wrapper addition is additive (won't break existing call sites). Pod-verify clean. 3 typecheck tests pass locally with the direct-perform shape. * [PR #45 review] State.set return contract, deviation count, e2e placement, wrapper-fn-frame pin, resumes:many gloss PR #45 review fixups. Five items, no behavior changes: - `std/state.sigil`: Document `State.set`'s return-value contract (perform site evaluates to the new value, not previous state), with a workaround example for callers wanting prev-state. Refine the `resumes: many` justification gloss to make the single-shot vs. forward-compatibility framing explicit. - `PLAN_C_DEVIATIONS.md` Task 72: Fix prose count "Three v1 surface gaps... plus a fourth" → "Five v1 surface gaps" to match the enumerated list (parser / tuple / wrapper-fn / per-op generics / row-poly Fn). - `compiler/tests/e2e.rs`: Move the Task 72 section to land after `std_raise_catch_conditional_branch` (the last Task 71 test) so per-task grouping stays contiguous instead of bracketing. - `compiler/tests/e2e.rs`: Add `#[ignore]`'d regression-pin `std_state_run_state_via_wrappers_pending_v2_wrapper_fn_frame_fix` asserting the future-correct `"11\n"` for the wrapper-fn-frame shape. Un-ignore when v2 closes constraint #3. - `PLAN_C_PROGRESS.md`: Add wrapper-fn-frame composition fix as a third architectural carryover under "Plan B' Stage-6.8-followup architectural carryovers", alongside TLS multi-return and Sync shim gating. Pod-verify clean.
* [Task 81] Fix DONE-path try_pop entry_depth respect; pin pure-tail multi-perform composition Bug: nested sigil_run_loop (inside sigil_continuation_invoke's Phase 1) consumed outer_post_arm_k entries belonging to the OUTER run_loop. The DONE-path's `outer_post_arm_k_try_pop` at handlers.rs:1869 didn't gate on `outer_post_arm_k_entry_depth`, so the first inner DONE pop succeeded (removed outer's entry, leaving the slot null), and the next inner invoke's DONE pop dereferenced a null fn_ptr → SIGSEGV. Fix: cap try_pop at entry_depth so each run_loop only consumes entries it owns. Mirrors the symmetric DISCHARGED-path drain discipline (Layer 3c from PR #39 review §5). Closes the chained-let-yield + pure-tail multi-perform composition gap under runtime-N dischargers (`std/choose.sigil`'s `all_choices` / `first_choice` over a body like `let a = perform; let b = perform; a + b`). 2 new e2e tests pin: (1) inline single-shot k(0) baseline (#[test]), (2) `all_choices` over chained-let-yield + pure tail (#[test]). Two architectural gaps remain v2 work (#[ignore]'d e2e tests document the closure path): - Nested-if branched tail not classified by is_let_yield_prefix_then_branched_cps_tail_body (only Pure/CpsCall/ Perform leaves, not nested If/Match) - Pure let-bindings between perform let-yields get ANF-lifted, breaking the chain classifier's "every stmt is Perform/Call" rule Both are needed for natural Sudoku-shape bodies; PLAN_C_PROGRESS Task 81 records the architectural finding and v2 follow-up scope. 156 runtime + 288/289 e2e green (1 pre-existing macOS perf-floor failure unrelated). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [Task 81] ANF-tolerant chain classifier: accept pure trailing let-yields Body shapes like: let a = perform A; let b = perform B; if a == 1 && b == 1 { 99 } else { perform Choose.fail() } ANF-elaborate to: let a = perform A; let b = perform B; let _0 = a == 1; // ANF-lifted intermediate let _1 = b == 1; // ANF-lifted intermediate if _0 && _1 { 99 } else { perform Choose.fail() } The chain classifiers (`is_simple_chained_let_yield_then_pure_tail_body` and `is_let_yield_prefix_then_branched_cps_tail_body`) previously rejected the lifted body because their per-stmt match required every `Stmt::Let` value to be `Expr::Perform` or `Expr::Call`. Falling through to Sync ABI silently broke `first_choice` / `all_choices` composition: the second perform never made it through the runtime-N discharger machinery. Fix: - Both classifiers now walk stmts and accept `Stmt::Let` with pure RHS interspersed AFTER the last perform/call yield. Yield count is returned (= chain_length); pure trailing intermediates are the closure path for ANF-lifted compound subexpressions. - New `TailPrefixLet` struct + `tail_prefix_lets: Vec<TailPrefixLet>` field on `ChainStepRole::Final`. Pre-pass partitions stmts into yield-bearing (`Perform` / `Call`) chain steps and pure trailing intermediates; FINAL synth-cont's emit lowers each pure intermediate in order (binding into env via `lower_expr` + `env.insert`) before the existing pattern-c-detect / standard-tail dispatch. - `collect_chained_synth_cont_captures` extended to walk `tail_prefix_lets` values BEFORE adding their names to the bound set, so helper params referenced only in lifted intermediates (e.g. `let _0 = x + threshold` where `threshold` is a helper param) flow through the closure record. - Both `compute_user_fn_abi` chain-classification arms updated to tolerate pure stmts (the chain-step push loop now skips them; the cap check `captures.len() + chain_length + 1 < MAX_CLOSURE_ENV_SLOTS` remains correct against `chain_length` as yield count). - Test-only `collect_chained_synth_cont_captures` callers (7 sites) updated to pass empty `&[]` for `tail_prefix_lets` (no behavioral change for those tests). New e2e test pins the ANF case end-to-end: - `std_choose_first_choice_two_sequential_performs_anf_intermediates` — body uses `if a == 1 && b == 1` shape; ANF lifts both compares into trailing pure lets; chain classifier accepts; FINAL emit evaluates them; first_choice short-circuits on `a=1, b=1 → 99`. 288/289 e2e green (1 pre-existing macOS perf-floor failure unrelated); 156 runtime + 0 regressions. Remaining Plan C Task 81 work: nested-If branched-tail classifier+emit (recursive walk for body shapes like `if a == 1 { if b == 1 { ... } else { perform fail } } else { perform fail }`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [Task 81] Nested-If branched-tail classifier+emit: BranchedCpsLeaf::Nested Body shapes like: let a = perform A; let b = perform B; if a == 1 { if b == 1 { 99 } else { perform Choose.fail() } } else { perform Choose.fail() } previously rejected by `is_let_yield_prefix_then_branched_cps_tail_body` because `classify_branched_cps_tail_branch_expr` only accepted Pure / CpsCall / Perform leaves at one level. Nested If/Match leaves fell to Sync ABI; first_choice / all_choices then saw only the outer perform's continuation. Fix: - New `BranchedCpsLeaf::Nested` variant. classify_branched_cps_tail_- branch_expr recursively classifies nested If (and the if-desugar Match-on-Bool 2-arm shape) when both sub-branches classify. At least one sub-branch must be Cps-eligible (CpsCall / Perform / Nested) per a new `leaf_is_cps_eligible` helper; otherwise the whole tail is wholly pure and the existing Pure path handles it (lower_expr lowers nested If as a value via internal phi). - classify_branched_cps_tail_branch (block variant) refactored to delegate to the bare-expr classifier, sharing the nested recognition. - is_let_yield_prefix_then_branched_cps_tail_body's has_cps_call check uses leaf_is_cps_eligible (covers Nested transitively). - FINAL synth-cont's branched-tail emit refactored from a 2-element `for` loop to a `while let Some(work.pop())` loop. When the popped leaf_kind is Nested, the loop calls detect_pattern_c_dispatch on the nested tail, lowers the nested cond, brifs into 2 fresh blocks, and enqueues each sub-branch's (block, expr, kind) tuple. Pure / CpsCall / Perform leaves dispatch as before. Termination: each iteration either emits a leaf (return_/jump terminates the block) or pushes 2 strictly-smaller sub-trees; finite tail expression bounds the iteration. Two new e2e tests pin the nested case end-to-end: - `std_choose_first_choice_two_sequential_performs_nested_if_tail` — body uses `if a == 1 { if b == 1 { 99 } else { fail } } else { fail }`; first_choice short-circuits on a=1, b=1 → 99. - (also kept `..._anf_intermediates` from the prior commit which exercises the && form via ANF-lifted intermediates.) 290/291 e2e green (1 pre-existing macOS perf-floor failure unrelated); 156 runtime + 0 regressions. Remaining Plan C Task 81 work: cross-fn perform composition through Cps-call branched-tail leaves. The natural Sudoku `solve` body's recursive `solve(set(...), cell+1)` Cps-call leaf composes via the existing chain-step machinery (CpsCall leaf forwards caller_k_pair), but the cross-fn end-to-end behaviour under first_choice needs verification — a focused e2e test (`pick_outer` calls `pick_inner` across a branched-tail CpsCall leaf) currently returns -1 (None); investigating whether the issue is the Cps-call leaf's caller_k_pair forward arithmetic or the helper-fn's chain caller_k_pair load. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [Task 81] Arm-body Block stmts threading: cross-fn perform composition Arm bodies in branched tails frequently contain ANF-lifted pure intermediates as `Stmt::Let` stmts inside an `Expr::Block`: if x == 2 { p + x * 10 // ANF lifts to: { let _0 = x * 10; p + _0 } } else { perform Choose.fail() } The pre-extension `classify_branched_cps_tail_branch` rejected any non-empty Block stmts, falling through to Sync ABI. Cross-fn composition (`pick_outer` → `pick_inner(p)` via Cps-call branched- tail leaf) silently broke under `first_choice` — both fns dropped to Sync, the perform-site continuations weren't captured through the runtime-N discharger machinery, and the discharger saw only the outer perform's continuation. Fix: - `classify_branched_cps_tail_branch` accepts arm-body `Block`s whose stmts are all `Stmt::Let` with pure RHS. Each pure stmt is an ANF-lifted intermediate; the leaf-emit walker lowers them before the leaf-emit dispatch. - `detect_pattern_c_dispatch` returns 7-tuple: `(cond, then_stmts, then_leaf, else_stmts, else_leaf, then_kind, else_kind)`. Stmts are `&[Stmt]` slices into the arm body's Block (or `&[]` for bare-Expr arm bodies). Per-branch `extract_leaf` returns both stmts and leaf for both arm-body shapes (Block-wrapped, bare). - Work-stack item structure extended to carry `&[Stmt]` per branch. The work-stack iteration lowers each arm-body stmt's RHS via `lower_expr` and binds the result into env via `lowerer.env.insert` before dispatching the leaf-emit (Pure / CpsCall / Perform / Nested). Nested re-dispatch threads its own arm-body stmts through the recursive `detect_pattern_c_dispatch` call. - `leaf_is_cps_eligible` (Plan C Task 81 helper) replaces inline `matches!(.., CpsCall | Perform)` checks at both classifier and detect-dispatch sites for consistency. New e2e test pins cross-fn end-to-end: - `std_choose_first_choice_multi_perform_site_recursive_branched` — `pick_outer` performs `Choose.choose(2)`, branches on `p == 1` to either `pick_inner(p)` (Cps-call leaf) or `perform fail`. `pick_inner` performs `Choose.choose(3)`, branches on `x == 2` to either `p + x * 10` (Pure leaf with ANF-lifted `_0 = x * 10`) or `perform fail`. `first_choice` short-circuits at (p=1, x=2) → 21. 290/291 e2e green (1 pre-existing macOS perf-floor failure unrelated); 156 runtime + 0 regressions. The 9×9 std.choose-based Sudoku demo is now expressible end-to-end via the natural `solve(board, cell)` recursive shape (classifier accepts; emit composes through first_choice). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [CHORE] cargo fmt --all Fixes rustfmt diffs introduced by the Plan C Task 81 commits: - collect_chained_synth_cont_captures call sites at lines 324, 403: multi-line argument layout per rustfmt's width rules - chained-let-yield pre-pass binding_kinds.push at lines 8119, 8137: single-line method-chain - Tail-prefix-let lower at lines 13014, 13022: single-line builder.func.dfg.value_type / builder.ins().uextend - classify_branched_cps_tail_branch_expr nested Match arm at line 22977: tuple-let formatting - e2e.rs trailing whitespace at line 8890 No behavioral changes; cargo fmt --all -- --check now clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [Task 81] Address PR #96 review: pure-tail test, doc rot, env_value rename, breadcrumb 5 review items addressed: 1. **Coverage gap (item #1)** — added `std_choose_all_choices_- two_perform_then_two_pure_lets_pure_tail` e2e test pinning the pure-tail variant's `seen_pure_after_yield` path with hand-written multi-prefix lets (`let s = a + b; let s2 = s + 1; s2`). The symmetric branched-tail tests don't exercise this independently. 2. **Doc rot (item #2)** — `detect_pattern_c_dispatch` docstring at codegen.rs updated to reflect the 7-tuple return shape (added `then_stmts` / `else_stmts` describing arm-body Block stmts and how the work-stack emit consumes them). 3. **`tail_prefix_lets` widening misnaming (item #3)** — local renamed from `widened` to `env_value`. For non-Int slot kinds the path is no longer disguised as widening; instead each non-Int arm asserts the lower_expr contract (`debug_assert_eq!` against the slot kind's natural Cranelift width: I8 for Bool/ Byte/Unit, I32 for Char, pointer_ty for String/Closure/User). A future lower_expr change that breaks the contract now fails loudly instead of silently bit-mismatching at use sites. 5. **Pre-yield pure-let breadcrumb (item #5)** — branched-tail variant's catch-all `_ => return None` arm now carries the same one-line note the pure-tail variant has, calling out that pure lets BEFORE the first yield require a pre-yield env-prep emit phase that the current Plan C Task 81 extension doesn't ship. (Item #4 — PR description out of sync — addressed via `gh pr edit` in a separate operation since it doesn't touch the diff.) cargo fmt --all clean. 292/293 e2e green (1 pre-existing macOS perf- floor failure unrelated; up from 290 via the new pure-tail test). 156 runtime + 0 regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Closes the canonical
run_state(initial, comp)higher-order helper from PR #38's reverted Task 109 first-cycle attempt. Six coordinated semantic fixes layered atop each other plus four cleanups; canonical output is now the expected11forrun_state(5, comp)with comp doingset(10); v = get(); v + 1.examples/state.sigilis rewritten to use the canonical shape, so Plan B' Stage 6.8's "examples/state.sigil uses literalrun_statehigher-order helper" criterion is concretely met.28bd281485f08814bdf4fdfc998000d35d9d38b7ce9b7cd8da839c8acargo fmt1f90e41#[no_mangle], collapsible-if, doc continuation9365ec5b47d3fcProbes
rs_a(B≠R single discharge)rs_b1(eager k(7) tail)rs_b(k(s)(s) lambda chain, tail-perform)dbg_a(non-tail-perform body)rs_l3a(multi-arm-defined-but-single-fires)rs_l3b(chained-let-yield with eager arms)rs_l3c(CPS-effected fn-typed parameter)rs_l3d(single-perform non-tail body + lambda)rs_l3e(return arm with outer captures)run_state_canonicalTest suite
partial_handler_of_multi_op_effect_aborts_at_runtime_pending_resolution, pre-existing); CI consistently shows all 4 lanes green oncde7afbwith no perf flakescargo fmt --check✓ ·cargo clippy --workspace -- -D warnings✓ ·./scripts/pod-verify.sh✓ · 4/4 CI lanes greenNew e2e tests:
handle_returning_fn_typed_value_with_op_arm_discharge_runs(Bug 2)handle_returning_k_capturing_lambda_invoked_outside_handle(Layer 2)handle_with_post_perform_body_code_uses_arm_discharge_value(Bug 1)cps_effected_fn_typed_parameter_indirect_call_returns_correct_value(Layer 3b)handle_with_eager_resume_arms_chains_let_yield_correctly(Layer 3b)handle_return_arm_with_outer_captures_in_k_pair_dispatch_path(Layer 3d)integration_bug2_plus_layer2_only_tail_perform_canonical_arms(review §6 progressive integration Plan A1 code-review fixes: 6 issues from the post-review audit #1)integration_bug2_layer2_bug1_non_tail_perform_canonical_arms(review §6 progressive integration Plan A2 Stage 1.5: scaffolding + pod-verify + cold-checkout fix + debug_assert #2)run_state_canonical_higher_order_helper_returns_threaded_value(full canonical)state_example_canonical_run_state_returns_11(canonical state.sigil)Architectural surface
Runtime ABI additions (
abi/src/effect.rs+runtime/src/handlers.rs):NEXT_STEP_TAG_DISCHARGED = 2discriminant +sigil_next_step_dischargedconstructorLAST_TERMINAL_TAG+sigil_last_terminal_tag/sigil_reset_last_terminal_tagLAST_TERMINAL_VALUE+sigil_last_terminal_value/sigil_reset_last_terminal_valuesigil_run_loop's terminal: bypass outer_post_arm_k routing when DISCHARGED (drain to entry-time depth); for DONE, route through chain (existing) and set TLS slots; for DISCHARGED, return value directly preserving tagCodegen additions (
compiler/src/codegen.rs,compiler/src/closure_convert.rs):kcatchall emitssigil_next_step_dischargedExpr::Handlelowerings reset both TLS slots before body lowering and tag-conditionally branch on the post-body terminal taglower_k_pair_call(synth lambda fn's k-pair dispatch) loads frame_ptr from closure record, re-pushes the handler frame before run_loop, pops after, then conditionally self-applies the return arm (loading return_closure from frame atHANDLER_FRAME_RETURN_CLOSURE_OFF) gated on tag == DONEclosure_convert::ArmKPairCaptureextends withhandle_span(Layer 2) andframe_ptr_idx(Layer 3c); k-index assignment is two-pass (post-filter)sigil_user_<name>__sync_shimSync-ABI shim;lower_closure_recorduses the shim's func_addr at fn-as-value materializationalloc_arm_closure_recordextended withframe_ptr_v: Option<Value>; arm closure records gain a frame_ptr slot when the arm body has any nested k-pair-bearing ClosureRecordarm_body_has_k_pair_lambda+block_has_k_pair_lambdawalkerReview response (PR #39)
1f90e41+9365ec5b47d3fc(inline doc comment + always-emit policy)b47d3fc(debug_assert on drain depth invariant)b47d3fc(2 progressive integration tests; bisect to layer pair)b47d3fc(fix abi-side doc to point to actual test name; the test itself already exists)a839c8a's diff if ever needed)b47d3fc(top-level summary entry in deviation doc with cross-references)Test plan
integration_bug2_plus_layer2_only,integration_bug2_layer2_bug1) bisect cleanly if a regression is introduced🤖 Generated with Claude Code