Skip to content

[Plan D Task 111b] Sync ABI threading — terminal_out as trailing param#90

Merged
boldfield merged 2 commits into
mainfrom
task-111b-sync-abi-threading
May 3, 2026
Merged

[Plan D Task 111b] Sync ABI threading — terminal_out as trailing param#90
boldfield merged 2 commits into
mainfrom
task-111b-sync-abi-threading

Conversation

@boldfield
Copy link
Copy Markdown
Owner

Summary

Second of 4 PRs implementing Task 111 (TLS → caller-owned terminal channel). Brian-authorized 4-PR breakdown 2026-05-03.

ABI change. Every Sync user fn signature gains a trailing terminal_out: *mut TerminalResult parameter:

Sync ABI: (closure_ptr, user_params..., terminal_out) -> ret_ty

The pointer is threaded end-to-end through Sync → Sync calls, into the main shim's stack-allocated TerminalResult, and forwarded into every sigil_run_loop call site that originates from a Sync user fn. TLS dual-write (added in 111a) remains in place at runtime; pointer-side becomes authoritative for Sync ABI propagation. 111c will thread terminal_out through the Cps + synth fn ABIs; 111d switches handle-exit reads to the caller-owned pointer and removes the TLS path.

Codegen sites updated

  • Sync ABI signature emit (UserFnAbi::Sync arm + Sync shim emit): +1 trailing pointer_ty AbiParam in both the declaration-time signature and the body-emit signature.
  • Lowerer struct: new terminal_out_param: Option<Value> field. Some(value) for Sync user fn body emit (block-param-bound at fn entry). None for Cps body emit, synth-arm-fn emit, synth-cont emit, post-arm-k synth fn emit, chained-let-bind synth-cont emit (8 sites total) — those contexts pass null until 111c.
  • Main shim: allocates a 16-byte stack slot (matching the runtime's repr(C) { value: u64, tag: u64 } layout) and passes its pointer to user_main as the trailing Sync ABI arg.
  • lower_call's Sync direct-call branch: threads self.terminal_out_param (or null) as the trailing arg.
  • lower_call's Cps interop wrapper: forwards self.terminal_out_param (or null) into sigil_run_loop's out slot.
  • Expr::ClosureRecord callee branch (IIFE / lambda-as-value): looks up callee ABI; threads terminal_out for Sync callees, keeps the 3-arg shape for Cps callees (111c plumbs Cps).
  • Indirect call (call_indirect via closure record's code_ptr): fn-typed values resolve to Sync ABI callees (hoisted user fn or Sync shim), so the indirect signature gets +1 pointer_ty AbiParam and the call args get +1 trailing pointer.
  • Per-Cps-fn Sync shim body emit: reads the trailing block param as terminal_out_v and forwards it into sigil_run_loop instead of null.
  • Five sigil_run_loop call sites inside Lowerer methods (handle body's nested run_loop, pre-Phase-4g return-arm dispatch, perform-side run_loop drive, branched k-call dispatch, Slice B fallback): switch from null_terminal_out to self.terminal_out_param-or-null.

Test plan

  • cargo build --workspace — clean.
  • cargo test --workspace — 274 passed; 3 failed (pre-existing perf timing tests fib_perf / fib_cps_perf / tree_example whose debug-build wall-clock under parallel test-runner contention exceeds the floor; all three fail identically on pre-111b state, verified via git stash + re-run).
  • cargo clippy --workspace --all-targets — clean.
  • cargo fmt --all -- --check — clean.

🤖 Generated with Claude Code

Second of 4 PRs implementing Task 111 (TLS → caller-owned terminal
channel). Brian-authorized 4-PR breakdown 2026-05-03.

**ABI change.** Every Sync user fn signature gains a trailing
`terminal_out: *mut TerminalResult` parameter:

    Sync ABI: (closure_ptr, user_params..., terminal_out) -> ret_ty

The pointer is threaded end-to-end through Sync → Sync calls, into
the main shim's stack-allocated `TerminalResult`, and forwarded into
every `sigil_run_loop` call site that originates from a Sync user fn.
TLS dual-write (added in 111a) remains in place at runtime; pointer-
side becomes authoritative for Sync ABI propagation. 111c will thread
terminal_out through the Cps + synth fn ABIs; 111d switches handle-
exit reads to the caller-owned pointer and removes the TLS path.

**Codegen sites updated.**

- Sync ABI signature emit (`UserFnAbi::Sync` arm + Sync shim emit):
  +1 trailing `pointer_ty` AbiParam in both the declaration-time
  signature and the body-emit signature.
- Lowerer struct: new `terminal_out_param: Option<Value>` field.
  `Some(value)` for Sync user fn body emit (block-param-bound at fn
  entry). `None` for Cps body emit, synth-arm-fn emit, synth-cont
  emit, post-arm-k synth fn emit, chained-let-bind synth-cont emit
  (8 sites total) — those contexts pass null until 111c.
- Main shim allocates a 16-byte stack slot (matching the runtime's
  `repr(C) { value: u64, tag: u64 }` layout) and passes its pointer
  to user_main as the trailing Sync ABI arg.
- `lower_call`'s Sync direct-call branch threads `self.terminal_out_-
  param` (or null) as the trailing arg.
- `lower_call`'s Cps interop wrapper forwards `self.terminal_out_-
  param` (or null) into `sigil_run_loop`'s `out` slot.
- `Expr::ClosureRecord` callee branch (IIFE / lambda-as-value): looks
  up callee ABI; threads terminal_out for Sync callees, keeps the
  3-arg shape for Cps callees (111c plumbs Cps).
- Indirect call (`call_indirect` via closure record's `code_ptr`):
  fn-typed values resolve to Sync ABI callees (hoisted user fn or
  Sync shim), so the indirect signature gets +1 pointer_ty AbiParam
  and the call args get +1 trailing pointer.
- Per-Cps-fn Sync shim body emit: reads the trailing block param as
  `terminal_out_v` and forwards it into `sigil_run_loop` instead of
  null.
- Five `sigil_run_loop` call sites inside Lowerer methods (handle
  body's nested run_loop, pre-Phase-4g return-arm dispatch, perform-
  side run_loop drive, branched k-call dispatch, Slice B fallback)
  switch from `null_terminal_out` to `self.terminal_out_param`-or-null.

**Verification.**

- `cargo build --workspace` — clean.
- `cargo test --workspace` — 274 passed; 3 failed (pre-existing perf
  timing tests fib_perf / fib_cps_perf / tree_example whose debug-
  build wall-clock under parallel test-runner contention exceeds the
  floor; all three fail identically on pre-111b state, verified via
  `git stash` + re-run).
- `cargo clippy --workspace --all-targets` — clean.
- `cargo fmt --all -- --check` — clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@boldfield
Copy link
Copy Markdown
Owner Author

Code Review — Plan D Task 111b

Reviewed @ HEAD (6143fd7). Net assessment: structurally sound, ABI threading is symmetric across all changed call sites, alignment/size for the main-shim slot is correct. Six concrete issues, ordered by severity.

1. Silent ABI fallback at Expr::ClosureRecord is a footgun (medium)

compiler/src/codegen.rs:18502-18506:

let callee_abi = self
    .user_fns
    .get(code_fn_name)
    .map(|e| e.abi)
    .unwrap_or(UserFnAbi::Sync);

The unwrap_or(UserFnAbi::Sync) silently defaults to Sync when code_fn_name is missing. Two lines up (18488), user_fn_refs.get(code_fn_name) uses unreachable!() on miss. user_fn_refs is built directly from user_fns (line 20760), so on the happy path the keys always agree and the fallback is dead. Today.

But if a future refactor ever puts a key in one map and not the other, the Sync default silently emits a (closure_ptr, args..., terminal_out) call against a Cps callee whose actual ABI is (closure_ptr, args_ptr, args_len). That is a stack-corrupting ABI mismatch with no debug-build trip-wire — it would surface as a hard-to-trace runtime crash or, worse, miscomputed results.

Fix: mirror the unreachable!() discipline used at the direct-Sync branch (compiler/src/codegen.rs:17613-17619):

let callee_abi = match self.user_fns.get(code_fn_name) {
    Some(e) => e.abi,
    None => unreachable!(
        "codegen: ClosureRecord code_fn_name `{code_fn_name}` registered in \
         user_fn_refs but missing from user_fns — pre-pass invariant violated"
    ),
};

The unreachable!() at 14245 documents the same map-pair invariant explicitly. Be consistent.

2. Sync shim body has implicit length precondition (low, trivial fix)

compiler/src/codegen.rs:14524-14530:

let block_params: Vec<Value> = builder.block_params(entry_block).to_vec();
let closure_ptr_v = block_params[0];
// block_params layout: [closure_ptr, user_params..., terminal_out].
// The Sync ABI signature emit guarantees length >= 2.
let terminal_out_idx = block_params.len() - 1;
let terminal_out_v = block_params[terminal_out_idx];
let user_args: Vec<Value> = block_params[1..terminal_out_idx].to_vec();

The comment is load-bearing. Add a debug_assert!(block_params.len() >= 2, "...") so the invariant is checked, not asserted via prose. Same for the analogous indexing at compiler/src/codegen.rs:9733:

let terminal_out_param = block_params[f.params.len() + 1];

This also assumes the trailing block param exists. A debug-mode bounds check is cheap insurance against a future refactor that drops the trailing param from one signature site but not the others.

3. No test exercises the new threading semantically (medium)

This is +187 lines of ABI-shape change spanning ~13 codegen sites with zero new tests. The existing suite (passing per PR description) verifies we did not break the runtime, but the runtime still treats TLS as authoritative — the pointer-side write in sigil_run_loop is a no-op for correctness right now. So a call site that passes garbage instead of terminal_out (e.g., uninitialized stack slot, wrong block param index, mis-arity'd indirect-call sig) would not produce an observable failure until 111d flips the load-bearing path.

That defers all ABI-threading bug discovery from this PR to 111d, where the failure mode will be: "111d is broken because of something 111b shipped." Hard to root-cause through the merged 111b → 111d delta.

Recommendation: add one targeted test that observes the threading. The cheapest version is a debug-only counter pair (non-null-out invocations, null-out invocations) in sigil_run_loop plus an assertion that for a known-shape program, the non-null/null ratio matches the predicted call graph. A heavier alternative: add a debug assert in sigil_run_loop that when both out is non-null and TLS write fires, the values being written agree (catches "wrong slot threaded"). Either reduces 111d's blast radius dramatically.

If you intentionally chose to defer all observable testing to 111d, please call that out in the PR description and the task tracker so future reviewers do not look for tests that aren't there.

4. Indirect-call signature unconditionally Sync — load-bearing comment, no enforcement (low)

compiler/src/codegen.rs:18597-18624:

The signature appends terminal_out always. The justification is that closure-record code_ptr slots only ever store Sync ABI entries (either a hoisted Sync user fn or a Sync shim wrapping a Cps user fn) — see lower_closure_record at compiler/src/codegen.rs:18901-18907.

That invariant is correct today, but it crosses two functions and is enforced only by reviewer attention. A future change that stores a Cps-ABI func_addr directly in a closure record's code_ptr (bypassing the shim) would silently break every indirect call site without any test or assertion catching it.

Fix: add a debug_assert! in lower_closure_record immediately before the store(code_ptr, closure_ptr, 8) that confirms the resolved code_fn_ref came from either sync_shim_refs or a Sync-ABI entry in user_fns. Costs nothing in release builds; saves the indirect-call site from being silently mis-typed.

5. Comment duplication across the 5 sigil_run_loop sites (low, ergonomic)

The same boilerplate (// Plan D Task 111b — forward caller's terminal_out (Sync) or null (Cps/synth, until 111c).) plus the identical unwrap_or_else snippet appears at five sites: 15211-15222, 15585-15596, 15983-15994, 16952-16963, 17328-17338, 17515-17522, 17749-17761. Plus three more in lower_call's Sync-direct (17634-17640) and Sync→Cps wrapper (17762-17768) and Expr::ClosureRecord (18510-18514) and indirect-call (18630-18632). That's ~10 instances of the same 5-line pattern.

A trivial method on Lowerer deduplicates it and centralizes the rationale:

/// Plan D Task 111b — yield `terminal_out` for forwarding into a
/// Sync-ABI call or `sigil_run_loop`. Returns the threaded pointer
/// when the surrounding fn is a Sync user fn, otherwise null (Cps /
/// synth-cont contexts; 111c threads them too).
fn terminal_out_or_null(&mut self) -> Value {
    self.terminal_out_param
        .unwrap_or_else(|| self.builder.ins().iconst(self.pointer_ty, 0))
}

Replaces ~30 lines with ~10. When 111c lands and the None branch goes away, you delete one method instead of editing ten sites.

6. Stack slot in main shim — correct, but uninitialized (informational)

compiler/src/codegen.rs:10020-10024:

let terminal_slot =
    builder.create_sized_stack_slot(StackSlotData::new(StackSlotKind::ExplicitSlot, 16, 3));
let terminal_out_v = builder.ins().stack_addr(pointer_ty, terminal_slot, 0);

Size 16 ✓ (matches repr(C) { value: u64, tag: u64 }). align_shift = 3 → 8-byte alignment ✓ (matches align_of::<TerminalResult>() == 8 and the runtime's debug_assert! at runtime/src/handlers.rs:1833-1837).

The slot is uninitialized when passed to user_main. That is fine only because sigil_run_loop always writes the full 16 bytes before any read, AND the shim never reads the slot back. Both conditions hold today. Worth a short comment in the shim noting "slot is write-only from main()'s perspective" so a future maintainer doesn't add a read-back path that picks up garbage.


Summary

Blocking: none. The PR is mergeable.

Should-fix before merge: issue 1 (silent ABI fallback) — replace with unreachable!() to match sibling code's discipline.

Should-fix this PR or 111c: issue 3 (no semantic test for threading). At minimum, add a sentence to the PR body acknowledging that ABI-threading verification is deferred to 111d.

Nice-to-have: issues 2, 4, 5, 6 — all defensive/ergonomic.

The terminal_out_param: None discipline at the 8 synth/lifted sites is correctly identified and consistent with the multi-PR rollout plan. The runtime's null-tolerance contract (per runtime/src/handlers.rs:1804-1814) makes the transitional state safe.

Addresses Brian's R1 review on PR #90 (codegen.rs:18483 silent fallback,
debug_assert gaps, no semantic test, comment duplication, main shim slot
ownership note). One commit covering all 6 issues.

**Issue 1 (medium) — silent ABI fallback at `Expr::ClosureRecord`.**
`unwrap_or(UserFnAbi::Sync)` replaced with `match`+`unreachable!()`,
mirroring the discipline at the direct-Sync branch (line 17613) and
the `user_fn_refs.get(code_fn_name)` lookup two lines below. A future
refactor that diverges `user_fn_refs` and `user_fns` keys now trips
a panic instead of silently emitting a Sync-ABI call against a Cps
callee (stack-corrupting ABI mismatch).

**Issue 2 (low) — implicit length precondition.** `debug_assert!`
added at the Sync shim body emit (block_params.len() >= 2) and at
the Sync user fn body emit (block_params.len() == f.params.len() + 2).
A future signature-emit refactor that drops the trailing terminal_out
trips here instead of producing OOB indexing later in the fn.

**Issue 3 (medium) — TLS-vs-pointer agreement debug_assert.**
`sigil_run_loop` now reads `*out` back after writing at both the
DONE and DISCHARGED terminal sites and asserts the read-back value
matches `LAST_TERMINAL_TAG` / `LAST_TERMINAL_VALUE` TLS. Tautological
under single-threaded sequential execution today (both written from
the same `(tag, v)` locals) but documents the load-bearing dual-write
invariant during the 111a→111d transition. Catches any future
reordering, aliasing, or write-failure that desyncs the two channels
before 111d removes TLS.

**Issue 4 (low) — indirect-call signature is unconditionally Sync.**
`debug_assert!` added in `lower_closure_record` immediately before
the code_ptr store. Confirms the resolved `code_fn_ref` came from
either `sync_shim_refs` or a Sync-ABI entry in `user_fns`. A future
change that stores a raw Cps-ABI func_addr directly in a closure
record now trips here instead of silently mis-typing every indirect
call site (whose signature builder unconditionally assumes Sync).

**Issue 5 (low) — comment duplication.** New
`Lowerer::terminal_out_or_null` helper method centralizes the
`unwrap_or_else` pattern + rationale at one site; the 10 call sites
collapse to single-line invocations. When 111c lands and every
Lowerer construction sets `terminal_out_param: Some(...)`, the
helper's `unwrap_or_else` branch becomes dead code and the helper
either collapses to `unwrap()` or the field type relaxes from
`Option<Value>` to `Value`.

**Issue 6 (informational) — main shim slot ownership.** Comment
added at the main shim's `terminal_slot` site documenting "slot is
write-only from main()'s perspective". A future maintainer adding
a read-back path (e.g., to forward terminal up to the C ABI exit
code) MUST initialize the slot first or it picks up stack garbage
on the pre-first-terminal codepath.

**Verification.**

- `cargo build --workspace` — clean.
- `cargo clippy --workspace --all-targets` — clean.
- `cargo fmt --all -- --check` — clean.
- `cargo test --workspace` — 274 passed; 3 failed (pre-existing perf
  timing tests under parallel contention; identical to PR #90's
  initial state and to pre-111b state, verified prior session).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@boldfield
Copy link
Copy Markdown
Owner Author

R1 review fixes pushed in 531f783. All 6 issues addressed in one commit.

# Severity Status Resolution
1 medium ✅ fixed unwrap_or(Sync)match+unreachable!() mirroring sibling code's discipline at the direct-Sync branch
2 low ✅ fixed debug_assert! added at both Sync shim body emit and Sync user fn body emit (length / f.params.len()+2 invariants)
3 medium ✅ fixed sigil_run_loop reads *out back after writing at DONE + DISCHARGED terminals and asserts equality with LAST_TERMINAL_* TLS — documents the dual-write invariant during the 111a→111d transition
4 low ✅ fixed debug_assert! in lower_closure_record confirms the resolved code_fn_ref is Sync ABI (sync_shim_refs entry OR UserFnAbi::Sync in user_fns)
5 low ✅ fixed New Lowerer::terminal_out_or_null helper; 10 call sites collapse to single-line invocations
6 info ✅ fixed Comment added documenting "slot is write-only from main()'s perspective"

Net diff: +149 / -71 across compiler/src/codegen.rs + runtime/src/handlers.rs.

Verification: cargo build --workspace, cargo clippy --workspace --all-targets, cargo fmt --all -- --check all clean. cargo test --workspace shows 274 pass / 3 fail (the 3 pre-existing perf timing tests fib_perf / fib_cps_perf / tree_example under parallel test-runner contention — identical failure shape pre- and post-111b). CI re-running on 531f783.

@boldfield boldfield merged commit c75bc61 into main May 3, 2026
4 checks passed
@boldfield boldfield deleted the task-111b-sync-abi-threading branch May 3, 2026 17:16
boldfield added a commit that referenced this pull request May 3, 2026
…sole channel (#92)

* [Plan D Task 111d] TLS removal — caller-owned TerminalResult slot is sole channel

Final of 4 PRs implementing Task 111. Brian-authorized 4-PR breakdown
2026-05-03. Closes the cross-fn discharge propagation gap that the
prior multi-return / out-pointer / per-fn-stack-slot attempts on PR
#50 could not address (see `[DEVIATION Task 111]`); the option-(C)
"thread `*mut TerminalResult` through every fn ABI" architectural
slice is now complete.

**Channel transition.** 111a–c ran TLS and the caller-owned
`TerminalResult` pointer in dual-write. 111d switches handle-exit
reads from TLS to the pointer and removes the TLS path entirely:

- `LAST_TERMINAL_TAG` / `LAST_TERMINAL_VALUE` thread-local statics
  removed from `runtime/src/handlers.rs`.
- 4 FFI helpers removed: `sigil_last_terminal_tag`,
  `sigil_reset_last_terminal_tag`, `sigil_last_terminal_value`,
  `sigil_reset_last_terminal_value`. 4 corresponding
  `module.declare_function` entries in codegen + the `PerFnRefsCtx`
  / `PerFnRefs` / `Lowerer` ref fields all removed; the destructure
  clauses at every Lowerer construction site are correspondingly
  cleaned up.
- TLS dual-write at `sigil_run_loop`'s DONE + DISCHARGED terminals
  removed. The PR #90 R1 issue 3 TLS-vs-pointer agreement
  debug_asserts removed (no TLS to compare against).
- The slot becomes the sole terminal channel; codegen always
  passes a non-null pointer (main shim + Sync/Cps/synth fn ABI
  threading guarantee). Null is still tolerated at run_loop for
  runtime tests that drive the trampoline directly without observing
  the terminal.

**Codegen sites switched to load/store on the slot.**

- 5 handle-exit tag / value queries (`Expr::Handle`'s outer
  return-arm / no-return-arm / k-pair-call branches): replace
  `call sigil_last_terminal_tag/value` with
  `load i64 [terminal_out_param + {0,8}]`. The `discharged_const`
  iconst widens from I32 to I64 to match the slot field width.
- 2 handle-entry resets (was `call sigil_reset_last_terminal_*`)
  become two `store i64` at offsets 0 and 8: `(value=0, tag=DONE)`
  before body lowering. Same role as the old TLS resets — keeps
  no-perform handles seeing a clean DONE state, and prevents a
  prior handle's terminal from leaking into the next handle in
  the same fn.
- 2 new `i32` constants `TERMINAL_RESULT_VALUE_OFF = 0` /
  `TERMINAL_RESULT_TAG_OFF = 8` mirror the runtime's `TerminalResult`
  `#[repr(C)]` layout.

**Field type relaxed.** `Lowerer.terminal_out_param: Option<Value>`
collapses to `Value` now that all 9 construction sites populate
`Some(...)`. The `terminal_out_or_null` helper (which existed only
to cover the dead `None` branch) is gone; call sites read
`self.terminal_out_param` directly. 13 call sites updated.

**End-to-end test added.** `task_111d_terminal_channel_propagation_-
through_nested_sync_calls` pins the new pointer-side path: a
3-deep Sync user-fn call chain (`a → b → c`) where `c` performs an
effect whose handler discharges. The (value=17, tag=DISCHARGED)
written by `sigil_run_loop` at `c`'s perform-site terminal must
propagate through `b`'s and `a`'s returns into the handle-exit's
load from the SAME caller-owned slot, route through the
discharge_block, and surface `17` to stdout. Closes the test
coverage gap Brian flagged in PR #91 R1 issue 4.

**Runtime / codegen comment refresh.** `TerminalResult` docstring,
`sigil_perform`'s "outer codegen logic" reference, and the
`sigil_run_loop` chain-routing note all update to reflect the
post-111d state. The transitional 111a/b/c notes are gone.

**Verification.**

- `cargo build --workspace --release` — clean. (Required for
  `libsigil_runtime.a` to be rebuilt; the compiler's `link.rs`
  prefers the release lib over debug.)
- `cargo clippy --workspace --all-targets` — clean.
- `cargo fmt --all -- --check` — clean.
- `bash scripts/check-no-interior-pointers.sh` — OK.
- `cargo test --workspace` — 275 passed (incl. new e2e); 3 failed
  (pre-existing perf timing tests fib_perf / fib_cps_perf /
  tree_example under parallel test-runner contention).

**Closure.** `[DEVIATION Task 111]` (deferred 2026-04-30) is now
fully closed. Plan B' Stage-6.8-followup carryover #1 (TLS → caller-
owned terminal channel) closes alongside. Plan D Task 119 closeout
audit's Task 111 line-item is unblocked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [Plan D Task 111d] PR #92 R1 review fixes — issues 1 (nested-handle leak), 2 (doc rot), quality nit

Addresses Brian's R1 review on PR #92.

**Issue 1 (medium, real correctness bug) — nested-handle slot leak.**
Confirmed via repro: when an outer handle's body contains an inner
handle whose op-arm DISCHARGES, the inner's `sigil_run_loop` writes
`(value, DISCHARGED)` to the fn-wide slot. The inner handle's exit
loads tag/value and routes through its discharge_block correctly,
but the slot RETAINS the inner's terminal state. The outer body
continues evaluating post-inner-handle expressions synchronously
(no further `sigil_run_loop` writes). When the outer handle's
exit-tag query loads from the slot, it reads the inner's leftover
DISCHARGED tag and incorrectly:

1. Skips the outer's return arm, AND
2. Loads the inner's leftover value as the handle's overall.

Pre-111d this leak existed identically in TLS form — the inner's
TLS write to `LAST_TERMINAL_TAG` clobbered the outer's expected
DONE state — but no test exercised the composition. Confirmed
pre-existing by repro on 111c (also outputs `99`); the slot is
load-bearing post-111d so the leak was newly the only source of
truth.

Fix: snapshot/restore at every handle entry/exit. At handle entry,
load the slot's pre-handle `(value, tag)` into local Cranelift
Values (`snap_value_v`, `snap_tag_v`). At every exit path (return-
arm and no-return-arm merge-blocks), restore the snapshot to the
slot before yielding the handle's overall. Pinned by new e2e
`task_111d_nested_handle_inner_discharge_does_not_leak_to_outer`
(invariant: `1090\n` post-fix; `99\n` pre-fix).

**Issue 2 (doc rot) — TLS/FFI references scattered through the
tree.** Brian flagged 13 hits across 5 files where stale references
to the now-removed `LAST_TERMINAL_*` thread-locals + `sigil_last_-
terminal_*` / `sigil_reset_last_terminal_*` FFI helpers persisted
in docstrings (most prominently `sigil_run_loop`'s contract docstring
which still described the dual-write transitional state). Updated
all to reflect the post-111d reality (caller-owned slot is sole
terminal channel) while preserving the historical "previously TLS"
provenance:

- `runtime/src/lib.rs:39-46` — module FFI list now notes the four
  TLS helpers were removed by 111d.
- `runtime/src/handlers.rs:1693-1701` — `sigil_run_loop`'s contract
  docstring rewritten: "slot is the sole terminal channel post-111d";
  null tolerance scoped to runtime unit tests.
- `compiler/src/codegen.rs:7189-7192, 10930-10934, 14704-14708,
  15375-15379, 16528-16532, 16729-16732, 16976-16983` — 7 codegen
  comments updated.
- `abi/src/effect.rs:43-48` — `NEXT_STEP_TAG_DISCHARGED` doc
  updated.
- `compiler/tests/e2e.rs:1001, 4600, 9550` — 3 test docstrings
  updated.

**Quality nit — `!out.is_null()` annotation.** Brian asked for a
SAFETY/NOTE line at the null check explaining "unreachable from
generated code post-111d; only runtime unit tests pass null." The
DONE branch already had a brief comment pointing at the DISCHARGED
bypass; expanded both to be self-contained and explicit about the
unreachability.

**Verification.**

- `cargo build --workspace --release` — clean.
- `cargo clippy --workspace --all-targets` — clean.
- `cargo fmt --all -- --check` — clean.
- `bash scripts/check-no-interior-pointers.sh` — OK.
- `cargo test --workspace` — 276 passed (incl. new nested-handle
  e2e); 3 failed (pre-existing perf timing tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant