Skip to content

Commit 6336115

Browse files
hyperpolymathclaude
andcommitted
fix(interp): close #555 — interpreter multi-shot resume loud-fail
The #555 codegen handler-arm-drop class was fixed by #566; the live residual was 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` more than once) 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. Count resume invocations per dispatch and fail loud (RuntimeError) on the second call, so a multi-shot handler can never silently produce a wrong value. Single-shot tail-resume (the one shape the interpreter implements correctly) is unaffected. Non-tail single-shot resume (`let x = op(); x + 100` resuming to 5 rather than 105) remains a documented shallow-resume incompleteness: the body has already unwound the bind chain when the arm runs, so the discarded continuation is undetectable here without a CPS transform. Regression tests (test/test_e2e.ml, "E2E Resume Soundness (#555)") — 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 delimited continuations land) Full suite 454/454 green. Refs #555 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9bf8d57 commit 6336115

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
@@ -2268,6 +2268,71 @@ let handler_fence_tests = [
22682268
Alcotest.test_case "deno-esm: handle → loud failure" `Quick test_handle_deno_loud_fail;
22692269
]
22702270

2271+
(* ============================================================================
2272+
Issue #555: interpreter resume soundness (multi-shot loud-fail)
2273+
============================================================================
2274+
2275+
The tree-walking interpreter models `resume` with an identity continuation,
2276+
correct only for single-shot tail-resume. Multi-shot resume (>1 call)
2277+
previously produced a silently-wrong value (each call merely returned its
2278+
argument); it must now fail loudly. Non-tail single-shot resume stays a
2279+
documented shallow-resume incompleteness (undetectable without a CPS
2280+
transform), pinned here as an executable fact. Unlike the existing
2281+
handler-fence tests, these actually APPLY `main` so the handler runs. *)
2282+
2283+
let interp_main path =
2284+
let open Result in
2285+
let ( let* ) = bind in
2286+
let* (prog, _resolve_ctx) = run_frontend path in
2287+
let* env =
2288+
match Interp.eval_program prog with
2289+
| Ok env -> Ok env
2290+
| Error e -> Error (Value.show_eval_error e)
2291+
in
2292+
let* main_fn =
2293+
match Value.lookup_env "main" env with
2294+
| Ok f -> Ok f
2295+
| Error _ -> Error "no `main` binding"
2296+
in
2297+
match Interp.apply_function main_fn [] with
2298+
| Ok v -> Ok v
2299+
| Error e -> Error (Value.show_eval_error e)
2300+
2301+
let test_resume_single_shot_tail () =
2302+
match interp_main (fixture "handle_resume_tail.affine") with
2303+
| Ok (Value.VInt 5) -> ()
2304+
| Ok v -> Alcotest.failf "single-shot tail-resume: expected VInt 5, got %s" (Value.show_value v)
2305+
| Error msg -> Alcotest.failf "single-shot tail-resume should evaluate, got error: %s" msg
2306+
2307+
let test_resume_multishot_loud_fail () =
2308+
match interp_main (fixture "handle_resume_multishot.affine") with
2309+
| Ok v ->
2310+
Alcotest.failf
2311+
"expected a loud multi-shot resume error (Refs #555); got Ok %s — \
2312+
silent multi-shot miscompute has regressed" (Value.show_value v)
2313+
| Error msg ->
2314+
Alcotest.(check bool) "error names the multi-shot resume limit" true
2315+
(contains_str "multi-shot resume" msg)
2316+
2317+
let test_resume_nontail_known_shallow () =
2318+
(* KNOWN shallow-resume incompleteness: the correct result is 105; the
2319+
shallow interpreter returns the resumed value 5. Flip to VInt 105 when
2320+
delimited continuations land (Refs #555). *)
2321+
match interp_main (fixture "handle_resume_nontail.affine") with
2322+
| Ok (Value.VInt 5) -> ()
2323+
| Ok (Value.VInt 105) ->
2324+
Alcotest.fail
2325+
"non-tail resume now returns 105 — delimited continuations appear to \
2326+
have landed; update this pin and the #555 CAPABILITY-MATRIX note"
2327+
| Ok v -> Alcotest.failf "non-tail resume: expected VInt 5 (known shallow), got %s" (Value.show_value v)
2328+
| Error msg -> Alcotest.failf "non-tail resume should evaluate (shallow), got error: %s" msg
2329+
2330+
let resume_soundness_tests = [
2331+
Alcotest.test_case "interp: single-shot tail-resume evaluates to 5" `Quick test_resume_single_shot_tail;
2332+
Alcotest.test_case "interp: multi-shot resume → loud failure" `Quick test_resume_multishot_loud_fail;
2333+
Alcotest.test_case "interp: non-tail resume → 5 (KNOWN shallow #555)" `Quick test_resume_nontail_known_shallow;
2334+
]
2335+
22712336
(* ============================================================================
22722337
Section: Stage 7 — typed-wasm Ownership Verifier (Tw_verify)
22732338
============================================================================
@@ -5414,6 +5479,7 @@ let tests =
54145479
("E2E LSP Phase D", lsp_phase_d_tests);
54155480
("E2E Try/Catch/Finally", try_catch_tests);
54165481
("E2E Handler Fence (#555)", handler_fence_tests);
5482+
("E2E Resume Soundness (#555)", resume_soundness_tests);
54175483
("E2E Ownership Verify", tw_verify_tests);
54185484
("E2E Cmd Linearity", cmd_linear_tests);
54195485
("E2E Boundary Verify", tw_interface_tests);

0 commit comments

Comments
 (0)