Skip to content

Commit d5120bf

Browse files
leifericfclaude
andcommitted
bc: suppress try_depth around speculative compile-time fold prim call
try_fold_call and try_fold_arg invoke pure prims speculatively at compile time to test whether a (head literal-args...) call can fold to a constant. The contract assumes prim_throw_classified will take the "set diag and return NULL" branch so the caller can clear_error and decline. That branch only runs when try_depth == 0; with an active try-frame in the surrounding eval context, the prim longjmps into the user's catch instead, escaping the compile entirely. Reproducer: (defn f [] (if (zero? 0) 43 (quot 1 0))) (f). The constant-condition fold doesn't fire because (zero? 0) is a cons (not self-evaluating), so both branches compile. The speculative fold of (quot 1 0) raises and longjmps; the user sees a runtime quot: division by zero error from an unreachable else-branch. Fix: save and zero try_depth around the speculative prim call in both try_fold_call and try_fold_arg. The prim sees a clean no-try context, takes the diag-return-NULL path, and the existing clear_error + decline logic works as documented. try_stack contents stay untouched; only the depth counter is temporarily masked. tests/bc_let_fold_test.clj gains if-else-fold-error-stays-unreachable covering the anchor reproducer plus mod / rem / when-with-quot / param-driven cond. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a918dae commit d5120bf

4 files changed

Lines changed: 83 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,43 @@
11
# Changelog
22

3+
## v0.255.6 — Fix: BC Speculative Fold Longjmps Through Active Try-Frame
4+
5+
Surfaced by mino-tests v0.7.0's `gen_program.clj`: a `defn` whose
6+
body contains an `(if cond then else)` where the else-branch holds
7+
a compile-time-foldable error (`(quot/mod/rem a 0)`, shift out of
8+
range, `LLONG_MIN/-1`) raised the error at runtime even when the
9+
cond was statically truthy and the else was unreachable. Top-level
10+
forms with the same shape worked. Reproducer:
11+
12+
```
13+
(defn f [] (if (zero? 0) 43 (quot 1 0)))
14+
(f) ; => quot: division by zero -- expected 43
15+
```
16+
17+
Root cause: `try_fold_call` and `try_fold_arg` in
18+
`src/eval/bc/compile.c` speculatively invoke a pure prim at compile
19+
time to test whether the call can fold to a constant. The contract
20+
assumes `prim_throw_classified` will take the "set diag and return
21+
NULL" branch on error so the caller can `clear_error` and decline.
22+
But that branch only runs when `try_depth == 0`; with an active
23+
try-frame in the surrounding eval context (compile-on-call from
24+
inside any `(try ...)`), `prim_throw_classified` `longjmp`s into
25+
the active frame instead. The longjmp escapes the compile, the
26+
exception lands on the user's catch, and the unreachable else
27+
surfaces as if it were the actual eval path.
28+
29+
Fix: save and zero `try_depth` around the speculative prim call in
30+
both `try_fold_call` and `try_fold_arg`. The prim sees a clean
31+
no-try context, takes the diag-return-NULL path, and the caller's
32+
existing `clear_error` + decline logic works as documented. The
33+
try-frame stack contents are untouched; only the depth counter is
34+
temporarily masked.
35+
36+
Regression: `tests/bc_let_fold_test.clj` gains
37+
`if-else-fold-error-stays-unreachable` with 6 sub-tests covering
38+
the anchor reproducer plus mod/rem, `when`-with-unreachable-then,
39+
and param-driven cond paths.
40+
341
## v0.255.5 — Fix: BC Bitwise Fast Path No Longer Promotes to Bigint
442

543
Surfaced by mino-tests's `gen.clj` while building a seeded RNG: an

src/eval/bc/compile.c

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2855,7 +2855,18 @@ static int try_fold_arg(compiler_t *c, mino_val_t *v, mino_val_t **out)
28552855
args = mino_cons(c->S, stack[i], args);
28562856
if (args == NULL) return 0;
28572857
}
2858+
/* The speculative fold contract requires prim_throw_classified
2859+
* to take the "set diag + return NULL" branch on error, not the
2860+
* "longjmp to active try-frame" branch. If the compile is
2861+
* happening underneath a live try (compile-on-call from inside
2862+
* a user (try ...) block, for instance), longjmp would escape
2863+
* the compile and surface a stale exception. Suppress try_depth
2864+
* for the duration of the speculative prim call; the saved
2865+
* value is restored regardless of outcome. */
2866+
int saved_td = mino_current_ctx(c->S)->try_depth;
2867+
mino_current_ctx(c->S)->try_depth = 0;
28582868
folded = pp->prim(c->S, args, c->env);
2869+
mino_current_ctx(c->S)->try_depth = saved_td;
28592870
if (folded == NULL) { clear_error(c->S); return 0; }
28602871
if (!fold_result_constable(folded)) return 0;
28612872
*out = folded;
@@ -2893,7 +2904,13 @@ static int try_fold_call(compiler_t *c, mino_val_t *form, int dst,
28932904
if (args == NULL) return 1;
28942905
}
28952906

2907+
/* Same try_depth suppression as try_fold_arg: the speculative
2908+
* fold needs prim_throw_classified to take the diag-return-NULL
2909+
* branch, not longjmp. */
2910+
int saved_td = mino_current_ctx(c->S)->try_depth;
2911+
mino_current_ctx(c->S)->try_depth = 0;
28962912
mino_val_t *folded = pp->prim(c->S, args, c->env);
2913+
mino_current_ctx(c->S)->try_depth = saved_td;
28972914
if (folded == NULL) {
28982915
/* The prim raised at fold time (e.g., division by zero).
28992916
* Clear the diagnostic and decline -- the runtime path will

src/mino.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
*/
2929
#define MINO_VERSION_MAJOR 0
3030
#define MINO_VERSION_MINOR 255
31-
#define MINO_VERSION_PATCH 5
31+
#define MINO_VERSION_PATCH 6
3232

3333
/*
3434
* Human-readable version string of the *linked* runtime, e.g. "0.48.0".

tests/bc_let_fold_test.clj

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,30 @@
9595
(finally (def + old-plus))))
9696
;; After restore, original semantics return.
9797
(is (= 30 (rd1)))))
98+
99+
(deftest if-else-fold-error-stays-unreachable
100+
;; When an if-form's cond is statically truthy (or a compile-time
101+
;; resolvable shape) and the else-branch contains a call that would
102+
;; fold to a runtime error (division by zero, shift out of range,
103+
;; LLONG_MIN/-1 overflow), the compiler's speculative fold attempt
104+
;; on the else expression must NOT escape into the surrounding
105+
;; eval context. The unreachable else stays unreachable; the fn
106+
;; returns the then-branch value normally.
107+
(testing "(zero? 0) cond + (quot 1 0) unreachable else"
108+
(defn iea1 [] (if (zero? 0) 43 (quot 1 0)))
109+
(is (= 43 (iea1))))
110+
(testing "let-bound zero divisor under (zero? d) cond"
111+
(defn iea2 [] (let [d 0] (if (zero? d) 43 (mod 43 d))))
112+
(is (= 43 (iea2))))
113+
(testing "rem under (zero? d) cond"
114+
(defn iea3 [] (let [d 0] (if (zero? d) 99 (rem 100 d))))
115+
(is (= 99 (iea3))))
116+
(testing "when guarding an unreachable quot"
117+
(defn iea4 [] (when (zero? 0) (quot 100 5)))
118+
(is (= 20 (iea4))))
119+
(testing "param-based cond doesn't pre-eval else"
120+
(defn iea5 [c] (if c 43 (quot 1 0)))
121+
(is (= 43 (iea5 true))))
122+
(testing "param-false cond hits the runtime quot error"
123+
(defn iea6 [c] (if c 43 (quot 1 0)))
124+
(is (thrown? (iea6 false)))))

0 commit comments

Comments
 (0)