Skip to content

Commit 282d67f

Browse files
Merge branch 'main' into fix/558-refinement-honest-reject
2 parents 12f896d + 7f5c8d7 commit 282d67f

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
@@ -2332,6 +2332,71 @@ let async_fence_tests = [
23322332
Alcotest.test_case "wasm: async row without async call compiles" `Quick test_async_no_boundary_still_compiles;
23332333
]
23342334

2335+
(* ============================================================================
2336+
Issue #555: interpreter resume soundness (multi-shot loud-fail)
2337+
============================================================================
2338+
2339+
The tree-walking interpreter models `resume` with an identity continuation,
2340+
correct only for single-shot tail-resume. Multi-shot resume (>1 call)
2341+
previously produced a silently-wrong value (each call merely returned its
2342+
argument); it must now fail loudly. Non-tail single-shot resume stays a
2343+
documented shallow-resume incompleteness (undetectable without a CPS
2344+
transform), pinned here as an executable fact. Unlike the existing
2345+
handler-fence tests, these actually APPLY `main` so the handler runs. *)
2346+
2347+
let interp_main path =
2348+
let open Result in
2349+
let ( let* ) = bind in
2350+
let* (prog, _resolve_ctx) = run_frontend path in
2351+
let* env =
2352+
match Interp.eval_program prog with
2353+
| Ok env -> Ok env
2354+
| Error e -> Error (Value.show_eval_error e)
2355+
in
2356+
let* main_fn =
2357+
match Value.lookup_env "main" env with
2358+
| Ok f -> Ok f
2359+
| Error _ -> Error "no `main` binding"
2360+
in
2361+
match Interp.apply_function main_fn [] with
2362+
| Ok v -> Ok v
2363+
| Error e -> Error (Value.show_eval_error e)
2364+
2365+
let test_resume_single_shot_tail () =
2366+
match interp_main (fixture "handle_resume_tail.affine") with
2367+
| Ok (Value.VInt 5) -> ()
2368+
| Ok v -> Alcotest.failf "single-shot tail-resume: expected VInt 5, got %s" (Value.show_value v)
2369+
| Error msg -> Alcotest.failf "single-shot tail-resume should evaluate, got error: %s" msg
2370+
2371+
let test_resume_multishot_loud_fail () =
2372+
match interp_main (fixture "handle_resume_multishot.affine") with
2373+
| Ok v ->
2374+
Alcotest.failf
2375+
"expected a loud multi-shot resume error (Refs #555); got Ok %s — \
2376+
silent multi-shot miscompute has regressed" (Value.show_value v)
2377+
| Error msg ->
2378+
Alcotest.(check bool) "error names the multi-shot resume limit" true
2379+
(contains_str "multi-shot resume" msg)
2380+
2381+
let test_resume_nontail_known_shallow () =
2382+
(* KNOWN shallow-resume incompleteness: the correct result is 105; the
2383+
shallow interpreter returns the resumed value 5. Flip to VInt 105 when
2384+
delimited continuations land (Refs #555). *)
2385+
match interp_main (fixture "handle_resume_nontail.affine") with
2386+
| Ok (Value.VInt 5) -> ()
2387+
| Ok (Value.VInt 105) ->
2388+
Alcotest.fail
2389+
"non-tail resume now returns 105 — delimited continuations appear to \
2390+
have landed; update this pin and the #555 CAPABILITY-MATRIX note"
2391+
| Ok v -> Alcotest.failf "non-tail resume: expected VInt 5 (known shallow), got %s" (Value.show_value v)
2392+
| Error msg -> Alcotest.failf "non-tail resume should evaluate (shallow), got error: %s" msg
2393+
2394+
let resume_soundness_tests = [
2395+
Alcotest.test_case "interp: single-shot tail-resume evaluates to 5" `Quick test_resume_single_shot_tail;
2396+
Alcotest.test_case "interp: multi-shot resume → loud failure" `Quick test_resume_multishot_loud_fail;
2397+
Alcotest.test_case "interp: non-tail resume → 5 (KNOWN shallow #555)" `Quick test_resume_nontail_known_shallow;
2398+
]
2399+
23352400
(* ============================================================================
23362401
Section: Stage 7 — typed-wasm Ownership Verifier (Tw_verify)
23372402
============================================================================
@@ -5479,6 +5544,7 @@ let tests =
54795544
("E2E Try/Catch/Finally", try_catch_tests);
54805545
("E2E Handler Fence (#555)", handler_fence_tests);
54815546
("E2E Async Fence (#556)", async_fence_tests);
5547+
("E2E Resume Soundness (#555)", resume_soundness_tests);
54825548
("E2E Ownership Verify", tw_verify_tests);
54835549
("E2E Cmd Linearity", cmd_linear_tests);
54845550
("E2E Boundary Verify", tw_interface_tests);

0 commit comments

Comments
 (0)