Skip to content

Commit b66c3e5

Browse files
leifericfclaude
andcommitted
mino_pcall: expose raw thrown value; STM propagates validator throws
Two coupled changes follow on from the previous pcall catch-arm fix: 1. mino_pcall's signature gains an out_ex parameter. When the call throws, *out_ex receives the raw thrown value (the cell passed to (throw ...) -- typically an ex-info map or similar payload). Callers like agent dispatch and STM validator handling that want to surface the user's exception unchanged read from out_ex directly. Breaking ABI change for existing pcall callers; mino is alpha so no compat shim. 2. The agent code's agent_try_call workaround is removed. agent.c now calls mino_pcall(..., &new_state, &thrown_ex) for actions, validators, and watches, getting the same exception capture in 1 line where agent_try_call took 30. The custom try frame is gone. 3. run_ref_validator in stm.c uses out_ex, threading the captured exception through tx_state_t's new validator_thrown_ex slot. tx_commit sets validator_rejected for both throws and falsy- rejects (both are hard failures, distinct from read-set- conflict retries) and parks the captured exception on tx. dosync_run consumes it: if validator_thrown_ex is set, it propagates the user's original payload via mino_throw; otherwise it raises the canonical MCT001 "Invalid reference state". Net behavior change: a validator that throws now aborts the transaction with the validator's own exception (matching JVM Clojure's "propagate the validator's exception" semantic). The previous code retried until the retry cap and then threw MST004 "transaction retry limit exceeded". GC: tx->validator_thrown_ex is traced in gc_mark_ctx_tx so the captured exception stays reachable across collections that may fire between commit and dosync_run's re-throw. tests/stm_test.clj covers both cases: - validator-throw-propagates-original-exception checks that (ex-data e) returns the original ex-info data. - validator-falsy-reject-throws-MCT001 pins the falsy-reject path. Internal suite 1514 / 7171 / 0. External baseline unchanged. The upstream add_watch.cljc / remove_watch.cljc agent arms still pass cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 06f2a5b commit b66c3e5

8 files changed

Lines changed: 173 additions & 108 deletions

File tree

CHANGELOG.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,56 @@ The agent code's `agent_try_call` workaround (added in E.4) is
394394
left in place by this commit; the follow-up replaces it with a
395395
direct `mino_pcall` call now that the catch arm is well-behaved.
396396

397+
### `mino_pcall` exposes the raw thrown value; STM validator throws propagate
398+
399+
Two coupled changes follow on from the catch-arm fix:
400+
401+
`mino_pcall`'s signature gains an `out_ex` parameter:
402+
403+
```c
404+
int mino_pcall(mino_state_t *S, mino_val_t *fn, mino_val_t *args,
405+
mino_env_t *env,
406+
mino_val_t **out, mino_val_t **out_ex);
407+
```
408+
409+
When the call throws, `*out_ex` receives the raw thrown value (the
410+
cell passed to `(throw ...)` -- typically an `ex-info` map or
411+
similar payload). Callers like agent dispatch and STM validator
412+
handling that want to surface the user's exception unchanged read
413+
from `out_ex` directly. **Breaking ABI change: existing pcall
414+
callers need to add the extra parameter (NULL is fine if the value
415+
isn't needed).** mino is alpha; no compat shim.
416+
417+
The agent code's `agent_try_call` workaround is removed:
418+
`src/prim/agent.c` now calls `mino_pcall(...&new_state, &thrown_ex)`
419+
for actions, validators, and watches, getting the same exception
420+
capture in 1 line where `agent_try_call` took 30. The custom try
421+
frame is gone.
422+
423+
`run_ref_validator` in `src/prim/stm.c` likewise uses `out_ex`,
424+
threading the captured exception through `tx_state_t`'s new
425+
`validator_thrown_ex` slot. `tx_commit` sets the
426+
`validator_rejected` flag for both throws and falsy-rejects (both
427+
are hard failures, distinct from read-set-conflict retries) and
428+
parks the captured exception on `tx`. `dosync_run` consumes it: if
429+
`validator_thrown_ex` is set, it propagates the user's original
430+
payload via `mino_throw`; otherwise it raises the canonical
431+
`MCT001 "Invalid reference state"`.
432+
433+
Net behavior: a validator that throws now aborts the transaction
434+
with the validator's own exception (matching JVM Clojure's
435+
"propagate the validator's exception" semantic), where the
436+
previous code retried until the cap and then threw `MST004
437+
"transaction retry limit exceeded"`. A validator that returns
438+
falsy without throwing still produces `MCT001`.
439+
440+
`tests/stm_test.clj` covers both cases:
441+
`validator-throw-propagates-original-exception` checks that
442+
`(ex-data e)` returns the original ex-info data; the new
443+
`validator-falsy-reject-throws-MCT001` pins the falsy-reject path.
444+
445+
Internal suite 1514 / 7171 / 0.
446+
397447
### Agents (MVP)
398448
399449
mino now ships agents: `agent`, `agent?`, `send`, `send-off`,

src/gc/roots.c

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ static void gc_mark_ctx_tx(mino_state_t *S, mino_thread_ctx_t *ctx)
315315
gc_mark_interior(S, rs->committed_old);
316316
gc_mark_interior(S, rs->committed_new);
317317
}
318+
gc_mark_interior(S, tx->validator_thrown_ex);
318319
}
319320

320321
/* Pin lexical environments published as GC roots and the symbol/keyword

src/mino.h

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1025,12 +1025,19 @@ mino_val_t *mino_call(mino_state_t *S, mino_val_t *fn, mino_val_t *args,
10251025
mino_env_t *env);
10261026

10271027
/*
1028-
* Protected call: same as mino_call but returns 0 on success (writing the
1029-
* result to *out) or -1 on error. The error message is available via
1030-
* mino_last_error(). *out is set to NULL on error.
1028+
* Protected call: same as mino_call but returns 0 on success (writing
1029+
* the result to *out) or -1 on error. The error message is available
1030+
* via mino_last_error(). *out is set to NULL on error.
1031+
*
1032+
* If out_ex is non-NULL, on error *out_ex is set to the raw thrown
1033+
* exception value (the cell passed to (throw ...) by the inner code).
1034+
* This is the original payload, not a diagnostic map -- useful for
1035+
* captures-and-stores callers like agent dispatch and STM validator
1036+
* handling that want to surface the user's ex-info / map / etc.
1037+
* unchanged. *out_ex is set to NULL on success or if out_ex is NULL.
10311038
*/
10321039
int mino_pcall(mino_state_t *S, mino_val_t *fn, mino_val_t *args,
1033-
mino_env_t *env, mino_val_t **out);
1040+
mino_env_t *env, mino_val_t **out, mino_val_t **out_ex);
10341041

10351042
/* ------------------------------------------------------------------------- */
10361043
/* Exceptions */

src/prim/agent.c

Lines changed: 6 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
* thread. JVM Clojure dispatches the action on a worker thread pool
66
* and returns the agent immediately; mino runs the action eagerly,
77
* validates against the agent's validator, captures any throw into
8-
* the agent's error slot, dispatches watches, and returns the agent.
8+
* the agent's error slot via mino_pcall's out_ex parameter, dispatches
9+
* watches, and returns the agent.
910
*
1011
* Why sync: mino's eval loop is serialized under a per-state mutex
1112
* (mino_state_lock_acquire). A future-driven worker would acquire
@@ -35,56 +36,8 @@
3536
#include "prim/internal.h"
3637
#include "eval/internal.h"
3738

38-
#include <setjmp.h>
3939
#include <string.h>
4040

41-
/* Run fn synchronously and capture any thrown exception into *out_ex.
42-
* Returns 0 on success (with result in *out_result), -1 on throw.
43-
*
44-
* mino_pcall does not work for our needs because its catch arm calls
45-
* set_eval_diag, which itself longjmps to the next-outer try frame
46-
* when one is present (mino's diagnostic mechanism converts errors
47-
* to catchable exceptions outside try blocks). The pcall behavior is
48-
* "intercept then re-throw if any outer try exists"; we want
49-
* "intercept and swallow without re-throwing" so a thrown action /
50-
* watch is captured into the agent's err slot. We push our own try
51-
* frame and skip the diagnostic conversion. */
52-
static int agent_try_call(mino_state_t *S, mino_val_t *fn, mino_val_t *args,
53-
mino_env_t *env, mino_val_t **out_result,
54-
mino_val_t **out_ex)
55-
{
56-
int saved_try = mino_current_ctx(S)->try_depth;
57-
mino_val_t *result;
58-
59-
if (saved_try >= MAX_TRY_DEPTH) {
60-
if (out_result != NULL) *out_result = NULL;
61-
if (out_ex != NULL) *out_ex = NULL;
62-
return -1;
63-
}
64-
mino_current_ctx(S)->try_stack[saved_try].exception = NULL;
65-
mino_current_ctx(S)->try_stack[saved_try].saved_ns = S->current_ns;
66-
mino_current_ctx(S)->try_stack[saved_try].saved_ambient = S->fn_ambient_ns;
67-
mino_current_ctx(S)->try_stack[saved_try].saved_load_len = S->load_stack_len;
68-
if (setjmp(mino_current_ctx(S)->try_stack[saved_try].buf) != 0) {
69-
/* Caught a throw. Capture the exception, restore state, and
70-
* return -1 WITHOUT calling set_eval_diag (which would
71-
* re-throw to any enclosing try). */
72-
mino_val_t *ex = mino_current_ctx(S)->try_stack[saved_try].exception;
73-
S->current_ns = mino_current_ctx(S)->try_stack[saved_try].saved_ns;
74-
S->fn_ambient_ns = mino_current_ctx(S)->try_stack[saved_try].saved_ambient;
75-
mino_current_ctx(S)->try_depth = saved_try;
76-
if (out_result != NULL) *out_result = NULL;
77-
if (out_ex != NULL) *out_ex = ex;
78-
return -1;
79-
}
80-
mino_current_ctx(S)->try_depth++;
81-
result = mino_call(S, fn, args, env);
82-
mino_current_ctx(S)->try_depth = saved_try;
83-
if (out_result != NULL) *out_result = result;
84-
if (out_ex != NULL) *out_ex = NULL;
85-
return result == NULL ? -1 : 0;
86-
}
87-
8841
/* --- public-API constructor + predicate ----------------------------------- */
8942

9043
mino_val_t *mino_agent(mino_state_t *S, mino_val_t *initial)
@@ -136,7 +89,7 @@ static void agent_apply_action(mino_state_t *S, mino_val_t *agent,
13689
mino_val_t *thrown_ex = NULL;
13790
int pc;
13891

139-
pc = agent_try_call(S, fn, call_args, env, &new_state, &thrown_ex);
92+
pc = mino_pcall(S, fn, call_args, env, &new_state, &thrown_ex);
14093
if (pc != 0 || new_state == NULL) {
14194
gc_write_barrier(S, agent, agent->as.agent.err, thrown_ex);
14295
agent->as.agent.err = thrown_ex;
@@ -147,8 +100,8 @@ static void agent_apply_action(mino_state_t *S, mino_val_t *agent,
147100
if (agent->as.agent.validator != NULL) {
148101
mino_val_t *vargs = mino_cons(S, new_state, mino_nil(S));
149102
mino_val_t *vresult = NULL;
150-
pc = agent_try_call(S, agent->as.agent.validator, vargs, env,
151-
&vresult, &thrown_ex);
103+
pc = mino_pcall(S, agent->as.agent.validator, vargs, env,
104+
&vresult, &thrown_ex);
152105
if (pc != 0 || vresult == NULL || !mino_is_truthy(vresult)) {
153106
mino_val_t *ex = thrown_ex;
154107
if (ex == NULL) {
@@ -187,7 +140,7 @@ static void agent_apply_action(mino_state_t *S, mino_val_t *agent,
187140
mino_cons(S, agent,
188141
mino_cons(S, old_state,
189142
mino_cons(S, new_state, mino_nil(S)))));
190-
pc = agent_try_call(S, wfn, wargs, env, &wresult, &wthrown);
143+
pc = mino_pcall(S, wfn, wargs, env, &wresult, &wthrown);
191144
if (pc != 0 && wthrown != NULL) {
192145
/* Capture the watch's thrown payload into agent.err.
193146
* Continue dispatching remaining watches: a thrown

src/prim/stm.c

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -745,22 +745,29 @@ mino_val_t *mino_tx_ensure(mino_state_t *S, mino_val_t *ref,
745745
* the same ref do not conflict.
746746
*/
747747
/* Run a ref's validator (if any) against the proposed new value via
748-
* mino_pcall so a thrown validator does not longjmp out while we still
749-
* hold the commit lock. Returns 1 on success, 0 on validator throw,
750-
* -1 on validator returning falsy (which is also a rejection but
751-
* surfaces a different error). */
748+
* mino_pcall so a thrown validator does not longjmp out while we
749+
* still hold the commit lock. Returns:
750+
* 1 -- validator returned truthy
751+
* 0 -- validator threw; *out_ex set to the thrown value
752+
* -1 -- validator returned falsy (no throw); *out_ex unchanged.
753+
* vfn==NULL is treated as "no validator" and returns 1. */
752754
static int run_ref_validator(mino_state_t *S, mino_val_t *ref,
753-
mino_val_t *new_val, mino_env_t *env)
755+
mino_val_t *new_val, mino_env_t *env,
756+
mino_val_t **out_ex)
754757
{
755758
mino_val_t *vfn = ref->as.tx_ref.validator;
756759
mino_val_t *vargs;
757760
mino_val_t *result = NULL;
761+
mino_val_t *thrown = NULL;
758762
int pc;
759763
if (vfn == NULL) return 1;
760764
vargs = mino_cons(S, new_val, mino_nil(S));
761-
pc = mino_pcall(S, vfn, vargs, env, &result);
762-
if (pc != 0) return 0;
763-
if (result == NULL) return 0;
765+
pc = mino_pcall(S, vfn, vargs, env, &result, &thrown);
766+
if (pc != 0) {
767+
if (out_ex != NULL) *out_ex = thrown;
768+
return 0;
769+
}
770+
if (result == NULL) return 0; /* defensive; shouldn't happen */
764771
if (!mino_is_truthy(result)) return -1;
765772
return 1;
766773
}
@@ -770,6 +777,7 @@ static int tx_commit(mino_state_t *S, tx_state_t *tx, mino_env_t *env,
770777
{
771778
tx_ref_state_t *rs;
772779
if (out_validator_rejected != NULL) *out_validator_rejected = 0;
780+
tx->validator_thrown_ex = NULL;
773781
stm_lock(S);
774782
/* Validate read set: every ref that the tx read must still be at
775783
* its snapshot version. */
@@ -797,12 +805,20 @@ static int tx_commit(mino_state_t *S, tx_state_t *tx, mino_env_t *env,
797805
}
798806
}
799807
if (new_val != NULL) {
800-
int vc = run_ref_validator(S, rs->ref, new_val, env);
808+
mino_val_t *vex = NULL;
809+
int vc = run_ref_validator(S, rs->ref, new_val, env, &vex);
801810
if (vc != 1) {
802811
stm_unlock(S);
803-
if (out_validator_rejected != NULL && vc == -1) {
812+
/* Both throws and falsy-rejects are validator
813+
* rejections (hard failures), distinct from read-set
814+
* conflicts that should retry. dosync_run inspects
815+
* tx->validator_thrown_ex to decide whether to
816+
* propagate the user's exception or throw the generic
817+
* "Invalid reference state" message. */
818+
if (out_validator_rejected != NULL) {
804819
*out_validator_rejected = 1;
805820
}
821+
tx->validator_thrown_ex = vex;
806822
return 0;
807823
}
808824
rs->committed_old = rs->ref->as.tx_ref.val;
@@ -912,9 +928,19 @@ static mino_val_t *tx_run_loop(mino_state_t *S,
912928
return r;
913929
}
914930
if (validator_rejected) {
931+
mino_val_t *vex = tx->validator_thrown_ex;
915932
tx_clear_ref_states(tx);
916-
prim_throw_classified(S, "eval/contract", "MCT001",
917-
"Invalid reference state");
933+
tx->validator_thrown_ex = NULL;
934+
if (vex != NULL) {
935+
/* Validator threw -- propagate the user's original
936+
* exception value. JVM Clojure surfaces a validator
937+
* throw to the dosync caller as that exception, not
938+
* as a generic IllegalStateException. */
939+
mino_throw(S, vex);
940+
} else {
941+
prim_throw_classified(S, "eval/contract", "MCT001",
942+
"Invalid reference state");
943+
}
918944
return NULL; /* unreachable */
919945
}
920946
/* Conflict: free per-ref state and retry. */
@@ -948,11 +974,12 @@ static mino_val_t *tx_outer_run(mino_state_t *S,
948974

949975
ctx_v = mino_current_ctx(S);
950976
saved_try = ctx_v->try_depth;
951-
tx.depth = 1;
952-
tx.refs_head = NULL;
953-
tx.retry_count = 0;
954-
tx.try_depth_at_start = saved_try;
955-
tx.retry_signal = 0;
977+
tx.depth = 1;
978+
tx.refs_head = NULL;
979+
tx.retry_count = 0;
980+
tx.try_depth_at_start = saved_try;
981+
tx.retry_signal = 0;
982+
tx.validator_thrown_ex = NULL;
956983

957984
if (ctx_v->try_depth >= MAX_TRY_DEPTH) {
958985
return prim_throw_classified(S, "eval/state", "MST006",

src/runtime/internal.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,11 @@ typedef struct tx_state {
217217
int retry_count;
218218
int try_depth_at_start; /* try-stack snapshot for retry */
219219
int retry_signal; /* set by retry-trigger; consumed by loop */
220+
/* Set by tx_commit to the validator's thrown exception (if any)
221+
* so dosync_run can re-throw the original payload instead of a
222+
* generic MCT001 message. NULL when the validator returned falsy
223+
* without throwing or when no validator ran. */
224+
mino_val_t *validator_thrown_ex;
220225
} tx_state_t;
221226

222227
/* ------------------------------------------------------------------------- */

src/runtime/state.c

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -743,48 +743,51 @@ mino_val_t *mino_call(mino_state_t *S, mino_val_t *fn, mino_val_t *args, mino_en
743743
}
744744

745745
int mino_pcall(mino_state_t *S, mino_val_t *fn, mino_val_t *args, mino_env_t *env,
746-
mino_val_t **out)
746+
mino_val_t **out, mino_val_t **out_ex)
747747
{
748748
int saved_try = mino_current_ctx(S)->try_depth;
749749
mino_val_t *result;
750750

751-
if (mino_current_ctx(S)->try_depth >= MAX_TRY_DEPTH) {
752-
if (out != NULL) {
753-
*out = NULL;
754-
}
751+
if (out_ex != NULL) *out_ex = NULL;
752+
753+
if (saved_try >= MAX_TRY_DEPTH) {
754+
if (out != NULL) *out = NULL;
755755
return -1;
756756
}
757757

758-
mino_current_ctx(S)->try_stack[mino_current_ctx(S)->try_depth].exception = NULL;
759-
mino_current_ctx(S)->try_stack[mino_current_ctx(S)->try_depth].saved_ns = S->current_ns;
760-
mino_current_ctx(S)->try_stack[mino_current_ctx(S)->try_depth].saved_ambient = S->fn_ambient_ns;
761-
mino_current_ctx(S)->try_stack[mino_current_ctx(S)->try_depth].saved_load_len = S->load_stack_len;
762-
if (setjmp(mino_current_ctx(S)->try_stack[mino_current_ctx(S)->try_depth].buf) != 0) {
763-
/* Landed here from longjmp -- error was thrown. */
758+
mino_current_ctx(S)->try_stack[saved_try].exception = NULL;
759+
mino_current_ctx(S)->try_stack[saved_try].saved_ns = S->current_ns;
760+
mino_current_ctx(S)->try_stack[saved_try].saved_ambient = S->fn_ambient_ns;
761+
mino_current_ctx(S)->try_stack[saved_try].saved_load_len = S->load_stack_len;
762+
if (setjmp(mino_current_ctx(S)->try_stack[saved_try].buf) != 0) {
763+
/* Landed here from longjmp -- error was thrown. Restore the
764+
* eval bookkeeping that was active at pcall entry, then
765+
* surface the raw thrown value via out_ex without touching
766+
* last_error / last_diag.
767+
*
768+
* pcall does NOT publish to last_error on purpose: the eval
769+
* loop has read sites that treat mino_last_error as "an error
770+
* happened during my call" (see eval_impl's "evaled == NULL
771+
* && mino_last_error != NULL" gate), so leaving a stale diag
772+
* from a successfully-caught pcall would mislead the next
773+
* failing call into thinking its own error had already been
774+
* reported. Callers that want a diag published after pcall
775+
* returns -1 call set_eval_diag explicitly. */
776+
mino_val_t *ex = mino_current_ctx(S)->try_stack[saved_try].exception;
764777
S->current_ns = mino_current_ctx(S)->try_stack[saved_try].saved_ns;
765778
S->fn_ambient_ns = mino_current_ctx(S)->try_stack[saved_try].saved_ambient;
766779
load_stack_truncate(S, mino_current_ctx(S)->try_stack[saved_try].saved_load_len);
767780
mino_current_ctx(S)->try_depth = saved_try;
768-
/* pcall does NOT publish to last_error / last_diag on a caught
769-
* throw. The eval loop has read sites that treat
770-
* mino_last_error as "an error happened during my call" (see
771-
* eval_impl's "evaled == NULL && mino_last_error != NULL"
772-
* gate), so leaving a stale diag from a successfully-caught
773-
* pcall would mislead the next failing call into thinking
774-
* its own error had already been reported. Callers that
775-
* want a diag published after pcall returns -1 call
776-
* set_eval_diag explicitly. */
777781
if (out != NULL) *out = NULL;
782+
if (out_ex != NULL) *out_ex = ex;
778783
return -1;
779784
}
780785
mino_current_ctx(S)->try_depth++;
781786

782787
result = mino_call(S, fn, args, env);
783788
mino_current_ctx(S)->try_depth = saved_try;
784789

785-
if (out != NULL) {
786-
*out = result;
787-
}
790+
if (out != NULL) *out = result;
788791
return result == NULL ? -1 : 0;
789792
}
790793

0 commit comments

Comments
 (0)