Skip to content

Commit 7f5c8d7

Browse files
fix(interp): #555 interpreter residual — multi-shot resume loud-fail (#597)
## Summary The #555 **codegen** handler-arm-drop class was fixed by #566 (issue now closed). This addresses the live **interpreter** residual its closing comment left open — the tree-walking interpreter's shallow `resume`. `ExprHandle` models the resume continuation as the identity, correct only for single-shot **tail**-resume. Multi-shot resume (calling `resume` >1×) has no reified continuation, so each call just returned its argument and e.g. `resume(1) + resume(2)` silently evaluated to `3` instead of running the continuation twice — a silent miscompute. ## Fix `lib/interp.ml`: count resume invocations per dispatch and fail loud (`RuntimeError`) on the second call. A multi-shot handler can never again silently produce a wrong value. Single-shot tail-resume (the supported shape) is unaffected. **Documented residual (not closed here):** non-tail single-shot resume (`let x = op(); x + 100` resuming to 5 rather than 105) stays a shallow-resume incompleteness — the body has already unwound the bind chain when the arm runs, so the discarded continuation is undetectable without a CPS transform. It's pinned as an executable known-shallow fact that flips deliberately when delimited continuations land. ## Tests New `E2E Resume Soundness (#555)` group — the **first** interpreter tests that actually `apply` `main` and exercise `resume`: - `handle_resume_tail.affine` → `VInt 5` (single-shot tail-resume works) - `handle_resume_multishot.affine` → loud "multi-shot resume" error (negative) - `handle_resume_nontail.affine` → `VInt 5`, KNOWN shallow (flips to 105 when CPS lands) Full suite **454/454 green**. Refs #555 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 187f21a commit 7f5c8d7

5 files changed

Lines changed: 148 additions & 4 deletions

File tree

lib/interp.ml

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -280,11 +280,33 @@ let rec eval (env : env) (expr : expr) : value result =
280280
result of the entire `handle` expression. This is correct for
281281
the common single-shot, tail-resume pattern. Full multi-shot
282282
continuations require either OCaml 5 effects or a CPS transform. *)
283+
(* #555: the shallow continuation is sound only for single-shot
284+
tail-resume. Multi-shot resume (calling `resume` more than
285+
once) cannot be expressed without reified continuations and
286+
previously produced a silently-wrong value — each call merely
287+
returned its argument, so e.g. `resume(1) + resume(2)` yielded
288+
`3` instead of running the continuation twice. Count
289+
invocations and fail loudly on the second call so a multi-shot
290+
handler can never silently miscompute. (Non-tail single-shot
291+
resume — `let x = op(); x + 100` resuming to `5` rather than
292+
`105` — remains a documented shallow-resume incompleteness: the
293+
body has already unwound the bind chain, so the discarded
294+
continuation is undetectable here without a CPS transform.) *)
295+
let resume_count = ref 0 in
283296
let resume_fn = VBuiltin ("__resume__", fun resume_args ->
284-
match resume_args with
285-
| [v] -> Ok v
286-
| [] -> Ok VUnit
287-
| vs -> Ok (VTuple vs)
297+
incr resume_count;
298+
if !resume_count > 1 then
299+
Error (RuntimeError
300+
("multi-shot resume is not supported by the tree-walking \
301+
interpreter: `resume` was called more than once in the \
302+
handler for effect `" ^ op_name ^ "` (Refs #555). Only \
303+
single-shot tail-resume is implemented; multi-shot \
304+
handlers require reified delimited continuations."))
305+
else
306+
match resume_args with
307+
| [v] -> Ok v
308+
| [] -> Ok VUnit
309+
| vs -> Ok (VTuple vs)
288310
) in
289311
(* Bind effect argument values to handler patterns.
290312
Convention: all declared params first, then the continuation as
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 hyperpolymath
3+
//
4+
// Issue #555 regression fixture (NEGATIVE). The arm calls `resume` twice. The
5+
// tree-walking interpreter has no reified continuation, so each call
6+
// previously just returned its argument and `a + b` silently evaluated to 3
7+
// instead of running the continuation twice. The second `resume` must now fail
8+
// loudly rather than silently miscompute.
9+
effect Choose { fn pick() -> Int; }
10+
11+
fn main() -> Int {
12+
handle pick() {
13+
pick() => {
14+
let a = resume(1);
15+
let b = resume(2);
16+
a + b
17+
}
18+
}
19+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 hyperpolymath
3+
//
4+
// Issue #555 regression fixture (KNOWN INCOMPLETENESS). Non-tail single-shot
5+
// resume: `compute` performs `ask()` and then computes `x + 100`, so the
6+
// correct result is 105. The shallow tree-walking interpreter unwinds the body
7+
// at the perform and cannot replay the discarded continuation, so it returns
8+
// the resumed value 5 instead. This case is undetectable at runtime without a
9+
// CPS transform; the test pins the current shallow value (5) so a future
10+
// delimited-continuation implementation flips it to 105 deliberately.
11+
effect Ask { fn ask() -> Int; }
12+
13+
fn compute() -> Int / Ask {
14+
let x = ask();
15+
x + 100
16+
}
17+
18+
fn main() -> Int {
19+
handle compute() {
20+
ask() => resume(5)
21+
}
22+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 hyperpolymath
3+
//
4+
// Issue #555 regression fixture (POSITIVE). Single-shot tail-resume: the
5+
// handled computation `ask()` is itself the perform, so resuming with 5 makes
6+
// the whole `handle` evaluate to 5. This is the one resume shape the
7+
// tree-walking interpreter implements correctly; the multi-shot guard must not
8+
// disturb it. (First interpreter test to actually exercise `resume`.)
9+
effect Ask { fn ask() -> Int; }
10+
11+
fn main() -> Int {
12+
handle ask() {
13+
ask() => resume(5)
14+
}
15+
}

test/test_e2e.ml

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2312,6 +2312,71 @@ let async_fence_tests = [
23122312
Alcotest.test_case "wasm: async row without async call compiles" `Quick test_async_no_boundary_still_compiles;
23132313
]
23142314

2315+
(* ============================================================================
2316+
Issue #555: interpreter resume soundness (multi-shot loud-fail)
2317+
============================================================================
2318+
2319+
The tree-walking interpreter models `resume` with an identity continuation,
2320+
correct only for single-shot tail-resume. Multi-shot resume (>1 call)
2321+
previously produced a silently-wrong value (each call merely returned its
2322+
argument); it must now fail loudly. Non-tail single-shot resume stays a
2323+
documented shallow-resume incompleteness (undetectable without a CPS
2324+
transform), pinned here as an executable fact. Unlike the existing
2325+
handler-fence tests, these actually APPLY `main` so the handler runs. *)
2326+
2327+
let interp_main path =
2328+
let open Result in
2329+
let ( let* ) = bind in
2330+
let* (prog, _resolve_ctx) = run_frontend path in
2331+
let* env =
2332+
match Interp.eval_program prog with
2333+
| Ok env -> Ok env
2334+
| Error e -> Error (Value.show_eval_error e)
2335+
in
2336+
let* main_fn =
2337+
match Value.lookup_env "main" env with
2338+
| Ok f -> Ok f
2339+
| Error _ -> Error "no `main` binding"
2340+
in
2341+
match Interp.apply_function main_fn [] with
2342+
| Ok v -> Ok v
2343+
| Error e -> Error (Value.show_eval_error e)
2344+
2345+
let test_resume_single_shot_tail () =
2346+
match interp_main (fixture "handle_resume_tail.affine") with
2347+
| Ok (Value.VInt 5) -> ()
2348+
| Ok v -> Alcotest.failf "single-shot tail-resume: expected VInt 5, got %s" (Value.show_value v)
2349+
| Error msg -> Alcotest.failf "single-shot tail-resume should evaluate, got error: %s" msg
2350+
2351+
let test_resume_multishot_loud_fail () =
2352+
match interp_main (fixture "handle_resume_multishot.affine") with
2353+
| Ok v ->
2354+
Alcotest.failf
2355+
"expected a loud multi-shot resume error (Refs #555); got Ok %s — \
2356+
silent multi-shot miscompute has regressed" (Value.show_value v)
2357+
| Error msg ->
2358+
Alcotest.(check bool) "error names the multi-shot resume limit" true
2359+
(contains_str "multi-shot resume" msg)
2360+
2361+
let test_resume_nontail_known_shallow () =
2362+
(* KNOWN shallow-resume incompleteness: the correct result is 105; the
2363+
shallow interpreter returns the resumed value 5. Flip to VInt 105 when
2364+
delimited continuations land (Refs #555). *)
2365+
match interp_main (fixture "handle_resume_nontail.affine") with
2366+
| Ok (Value.VInt 5) -> ()
2367+
| Ok (Value.VInt 105) ->
2368+
Alcotest.fail
2369+
"non-tail resume now returns 105 — delimited continuations appear to \
2370+
have landed; update this pin and the #555 CAPABILITY-MATRIX note"
2371+
| Ok v -> Alcotest.failf "non-tail resume: expected VInt 5 (known shallow), got %s" (Value.show_value v)
2372+
| Error msg -> Alcotest.failf "non-tail resume should evaluate (shallow), got error: %s" msg
2373+
2374+
let resume_soundness_tests = [
2375+
Alcotest.test_case "interp: single-shot tail-resume evaluates to 5" `Quick test_resume_single_shot_tail;
2376+
Alcotest.test_case "interp: multi-shot resume → loud failure" `Quick test_resume_multishot_loud_fail;
2377+
Alcotest.test_case "interp: non-tail resume → 5 (KNOWN shallow #555)" `Quick test_resume_nontail_known_shallow;
2378+
]
2379+
23152380
(* ============================================================================
23162381
Section: Stage 7 — typed-wasm Ownership Verifier (Tw_verify)
23172382
============================================================================
@@ -5459,6 +5524,7 @@ let tests =
54595524
("E2E Try/Catch/Finally", try_catch_tests);
54605525
("E2E Handler Fence (#555)", handler_fence_tests);
54615526
("E2E Async Fence (#556)", async_fence_tests);
5527+
("E2E Resume Soundness (#555)", resume_soundness_tests);
54625528
("E2E Ownership Verify", tw_verify_tests);
54635529
("E2E Cmd Linearity", cmd_linear_tests);
54645530
("E2E Boundary Verify", tw_interface_tests);

0 commit comments

Comments
 (0)