From b6be1e44bfea07a139b56162401b8f65c9c4017f Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Wed, 29 Jul 2026 00:19:49 -0500 Subject: [PATCH] fix(runtime): one `exit of N` no longer disables try/catch for the process (#739 item 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `exit` is deliberately uncatchable: builtin_exit sets g_exit_requested and CHECK_ERROR consults it to refuse routing the unwind to a `try` handler. But the flag was a PROCESS GLOBAL that nothing ever reset — not eigs_close, not eigs_state_destroy, and main leaves it set on purpose. So once any script in any state called `exit`, every later eigs_eval_string in the process ran with exception handling silently disabled: a raise inside `try` went to vm_error_halt instead of the catch handler. Reproduced through the embed API (unreachable from the CLI, where `exit` ends the process — which is why this had never been seen): before exit -> CAUGHT after exit -> (null) <- catch handler never fires again For a long-lived host running untrusted snippets — the sandbox_run case, and the seam EigenOS M11 consumes — one line of script permanently corrupted the semantics. The request now lives on EigsThread beside the g_has_error / g_try_depth CHECK_ERROR reads it with (they were the only members of that flag family still process-global), and is cleared at host eval entry using the idiom repl.c already had for g_has_error. It applies to the eval that raised it and no later one. `exit` stays uncatchable. The exit CODE is additionally latched on EigsState. Per-thread alone silently dropped `exit of N` inside a SPAWNED WORKER to 0 while main returned success — a regression this change introduced and the suite had no coverage for. The thread flag drives the uncatchable unwind; the state latch decides the process's status. A side benefit: a worker's exit no longer erases a genuine main-thread error. Two reader-side fixes fall out of the move, both the same root cause — a bridge macro is not a process global: - main.c's REPL path read the flag AFTER eigs_thread_detach, so the REPL returned 0 instead of the requested code. It captures before teardown now, like the script path already did. Every other read was audited; all are on attached threads. - eigenscript.h's comment still said "Process-global (exit terminates the whole process)" directly above the new note contradicting it. Merged. Tests: tests/test_exit.sh gains the spawned-worker case (validated red: rc=0 want=9), and embed_smoke gains the try/catch-after-exit case with a before-exit control (validated red; the control passes in both runs, so the check isolates the right thing). Gates: release 3274/3274; asan+ubsan 3278/3278 and asan-http 3374/3374, leak tally 0 in both; freestanding stage 1+2 OK; embed smoke OK; exit suite 6/6. Verified unchanged: `exit of 7` -> 7, uncatchable in `try` -> 3, REPL `exit of 5` -> 5, worker `exit of 9` -> 9. Refs #739 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 23 +++++++++++++++++++++++ docs/BUILTINS.md | 2 +- docs/EMBEDDING.md | 9 +++++++++ src/builtins.c | 15 +++++++++------ src/eigenscript.h | 36 +++++++++++++++++++++++++++++------- src/eigs_embed.c | 9 +++++++++ src/embed_smoke.c | 25 +++++++++++++++++++++++++ src/main.c | 13 +++++++++++-- tests/test_exit.sh | 13 +++++++++++++ 9 files changed, 129 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10b0fbfa..92c147cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -171,6 +171,29 @@ All notable changes to EigenScript are documented here. ### Fixed +- **One `exit of N` permanently disabled `try`/`catch` for the rest of the + process (#739 item 2).** `exit` is deliberately uncatchable: `builtin_exit` + sets `g_exit_requested` and `CHECK_ERROR` consults it to refuse routing the + unwind to a `try` handler. But the flag was a **process global that nothing + ever reset** — not `eigs_close`, not `eigs_state_destroy`, and `main` leaves + it set on purpose. So once any script in any state called `exit`, every later + `eigs_eval_string` in the process ran with exception handling silently off: a + raise inside `try` went to `vm_error_halt` instead of the catch handler. For a + long-lived embedder running untrusted snippets — the `sandbox_run` case, and + the seam EigenOS M11 consumes — one line of script corrupted the semantics + permanently. Unreachable from the CLI, where `exit` ends the process, which is + why it had never been seen. `g_exit_requested`/`g_exit_code` now live on + `EigsThread` beside the `g_has_error`/`g_try_depth` they are read with, and + the request is cleared at host eval entry, so it applies to the eval that + raised it and no later one. `exit` stays uncatchable and still returns its + code from the CLI and the REPL. The exit **code** is additionally latched on + the `EigsState`: per-thread alone would have silently dropped `exit of N` + inside a *spawned worker* to 0 while `main` returned success, so the thread + flag drives the uncatchable unwind and the state latch decides the process's + status — which also means a worker's exit no longer erases a genuine + main-thread error. `tests/test_exit.sh` covers the worker case; the embed + smoke harness covers the try/catch recovery with a before-exit control. + - **Every HTTP connection worker tore down the process trace tape, so the tape died after the first request (#739).** `trace_shutdown()` is a process-wide teardown — it closes the one tape, drops an embedder's trace sink, and shuts diff --git a/docs/BUILTINS.md b/docs/BUILTINS.md index 9c94bb45..5d160be7 100644 --- a/docs/BUILTINS.md +++ b/docs/BUILTINS.md @@ -44,7 +44,7 @@ audio (`audio_open`, `audio_close`, `audio_pause`, `audio_play`, | `num` | `num of value` | Convert to number (parse string or coerce) | | `type` | `type of value` | Return type name: "num", "str", "list", "dict", "buffer", "text_builder", "fn", "builtin", "none" (the null value — SPEC.md is normative and its gated example prints `none`; the string `"null"` is never produced) | | `assert` | `assert of [cond, msg]` | Raise catchable error `"ASSERT FAIL: "` if condition is false | -| `exit` | `exit of N` | Terminate the program with exit code `N` (default 0). **Uncatchable** — a `try`/`catch` does not intercept it — and unwinds through normal teardown, so it is leak-clean even with live closures. Code after it does not run. | +| `exit` | `exit of N` | Terminate the program with exit code `N` (default 0). **Uncatchable** — a `try`/`catch` does not intercept it — and unwinds through normal teardown, so it is leak-clean even with live closures. Code after it does not run. The request is scoped to the evaluating thread and cleared at each host eval entry, so under the embedding API a script that calls `exit` does not disable `try`/`catch` for the host's *next* eval (#739). | | `coalesce` | `coalesce of [value, default]` | Return value unless empty/null, else default | | `eval` | `eval of code_string` | Execute EigenScript code, return result | | `throw` | `throw of message` | Raise catchable error | diff --git a/docs/EMBEDDING.md b/docs/EMBEDDING.md index d9671370..bc7a1645 100644 --- a/docs/EMBEDDING.md +++ b/docs/EMBEDDING.md @@ -254,6 +254,15 @@ act between evals. While a replay tape is set, nondet builtins return the recorded `N` values in order instead of consulting their live sources; when the tape runs out they fall back to live. +**A script's `exit` does not outlive its eval** (#739). `exit of N` is +deliberately uncatchable — `CHECK_ERROR` refuses to route it to a `try` +handler — but the request is per-thread and cleared at the top of +`eigs_eval_string`, so it applies to the eval that raised it and no +later one. Before that it was a process global nothing ever reset: one +untrusted snippet calling `exit` left every subsequent eval in the +process running with exception handling silently disabled, in any state. +A host that wants the exit code reads it from the eval that requested it. + **The sink and the tape are per-PROCESS, not per-state** (#739). With several states co-located, one sink serves them all, and the teardown that drops it — `trace_shutdown()`, which `eigs_close` calls — belongs diff --git a/src/builtins.c b/src/builtins.c index c3d67e59..c9fd9f6d 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -621,14 +621,15 @@ Value* builtin_get_observer_thresholds(Value *arg) { return result; } -/* Process-global exit request (declared in eigenscript.h). */ -int g_exit_requested = 0; -int g_exit_code = 0; - /* exit of N — request a clean process exit with code N (default 0). Sets the * unwind flag (g_has_error) so vm_run returns to main, plus g_exit_requested so * the unwind is UNCATCHABLE (a `try` must not swallow `exit`) and main exits - * with the code after its normal teardown — leak-clean, unlike a raw exit(). */ + * with the code after its normal teardown — leak-clean, unlike a raw exit(). + * #739: the request lives on EigsThread, beside the g_has_error / g_try_depth + * it is consulted with — as a process global nothing ever reset it, so one + * script's `exit` disabled try/catch for every later eval in the PROCESS, in + * any state. The exit CODE is additionally latched at the EigsState, so `exit` + * inside a spawned worker still decides the process's status. */ Value* builtin_exit(Value *arg) { int code = 0; if (arg && arg->type == VAL_NUM) { @@ -639,7 +640,9 @@ Value* builtin_exit(Value *arg) { code = (int)arg->data.list.items[0]->data.num; } g_exit_code = code; - g_exit_requested = 1; + g_exit_requested = 1; /* this thread's unwind: uncatchable */ + g_exit_latch_code = code; /* the state's: what main reports as the */ + g_exit_latched = 1; /* process exit code, incl. from a worker */ g_has_error = 1; /* triggers CHECK_ERROR -> unwind to main */ return make_null(); } diff --git a/src/eigenscript.h b/src/eigenscript.h index f48eeb87..4dfe0f89 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -550,6 +550,13 @@ struct EigsState { * threaded states (the common case — DMG, MiniSat, Tidepool, REPL) * keep it at 0 and skip the atomic ~20-cycle penalty on x86. */ int multithreaded; + /* #739: process-exit request, LATCHED at the state. The per-thread flag + * above drives CHECK_ERROR's uncatchable unwind and is cleared at host + * eval entry; this latch is what `main` reports as the process exit code, + * so `exit of N` inside a spawned worker still sets it — the per-thread + * flag alone would have silently dropped a worker's exit code to 0. */ + int exit_latched; + int exit_latch_code; /* Cycle-collector registry — the intrusive list of captured envs and its * live count. Per-STATE (not per-thread) so candidates created on any * thread survive that thread's death and stay collectable at exit; gc_lock @@ -606,6 +613,12 @@ struct EigsThread { int parse_errors; int has_error; int try_depth; + /* #739: `exit of N` request. Sits with has_error/try_depth because + * CHECK_ERROR reads all three together — an exit unwind is uncatchable. + * Cleared at host eval entry (eigs_eval_string) so a second eval on this + * thread is not stuck with the first one's exit. */ + int exit_requested; + int exit_code; int first_error_line; int first_error_col; /* 0-based column of the first error, or 0 */ int first_error_len; /* source length of the offending token @@ -738,6 +751,8 @@ extern __thread EigsThread *eigs_current; #define g_first_error_msg (eigs_current->first_error_msg) #define g_error_value (eigs_current->error_value) #define g_error_kind (eigs_current->error_kind) +#define g_exit_requested (eigs_current->exit_requested) +#define g_exit_code (eigs_current->exit_code) #define g_error_line (eigs_current->error_line) #define g_error_raw (eigs_current->error_raw) #define g_last_obs_slot_env (eigs_current->last_obs_slot_env) @@ -769,6 +784,8 @@ extern __thread EigsThread *eigs_current; #define g_compile_import_toplevel (eigs_current->compile_import_toplevel) #define g_import_resolve_dir (eigs_current->import_resolve_dir) #define g_vm_multithreaded (eigs_current->state->multithreaded) +#define g_exit_latched (eigs_current->state->exit_latched) +#define g_exit_latch_code (eigs_current->state->exit_latch_code) #define g_gc_envs (eigs_current->state->gc_envs) #define g_gc_captured_live (eigs_current->state->gc_captured_live) #define g_gc_val_buf (eigs_current->state->gc_val_buf) @@ -1055,13 +1072,18 @@ void env_reserve_slots(Env *env, int total); * leave it off so cross-chunk env lookups continue to work. */ extern int g_compile_module_slots; -/* `exit of N` requests a clean process exit with code N. Process-global (exit - * terminates the whole process). The builtin sets these + g_has_error to unwind - * vm_run to main via the existing error path; CHECK_ERROR treats the request as - * uncatchable (a `try` must not swallow `exit`), and main exits with the code - * after its normal teardown — so exit is leak-clean, unlike a raw exit(). */ -extern int g_exit_requested; -extern int g_exit_code; +/* `exit of N` requests a clean process exit with code N. The builtin sets + * g_exit_requested/g_exit_code + g_has_error to unwind vm_run to main via the + * existing error path; CHECK_ERROR treats the request as uncatchable (a `try` + * must not swallow `exit`), and main exits with the code after its normal + * teardown — so exit is leak-clean, unlike a raw exit(). + * + * #739: the request is per-THREAD (bridge macros above, beside the + * g_has_error / g_try_depth CHECK_ERROR reads it with) and cleared at host + * eval entry. It was process-global and never reset, so one `exit of N` in any + * state left every later eval in the process running with exception handling + * silently disabled. A reader of the request must take it BEFORE + * eigs_thread_detach — there is no thread to read it through afterwards. */ /* ---- Parser / Evaluator ---- */ diff --git a/src/eigs_embed.c b/src/eigs_embed.c index 9e9128ee..93beb5e9 100644 --- a/src/eigs_embed.c +++ b/src/eigs_embed.c @@ -73,6 +73,15 @@ EigsValue *eigs_eval_string(const char *src) { g_parse_errors = 0; g_has_error = 0; + /* #739: don't carry a prior eval's `exit` into this one. CHECK_ERROR makes + * an exit unwind uncatchable — correct — but the request was never + * cleared, so after any script called `exit of N` every later eval in this + * process ran with exception handling silently disabled: a raise inside + * `try` went to vm_error_halt instead of the catch handler. One line of + * script permanently corrupted the semantics for a long-lived host running + * untrusted snippets. g_exit_code needs no reset — builtin_exit always + * writes it before setting the flag, so it can never be read stale. */ + g_exit_requested = 0; TokenList tl = tokenize(src); if (g_parse_errors > 0) { diff --git a/src/embed_smoke.c b/src/embed_smoke.c index 428fdbac..fd37fd9e 100644 --- a/src/embed_smoke.c +++ b/src/embed_smoke.c @@ -391,6 +391,31 @@ int main(void) { eigs_value_release(r); eigs_set_abort_flag(NULL); + /* #739: `exit of N` must not permanently disable try/catch for the state. + * The exit request is deliberately uncatchable, but it was a process global + * that nothing ever reset — so after ANY script called exit, every later + * eval in the process ran with exception handling silently off: a raise + * inside `try` went to vm_error_halt instead of the catch handler. This is + * the shape a long-lived host running untrusted snippets hits, and it is + * only reachable through the embed API (on the CLI, exit ends the process). + * The first CHECK is the control: catching must work BEFORE the exit. */ + const char *catcher = "try:\n throw of \"boom\"\ncatch e:\n \"CAUGHT\""; + r = eigs_eval_string(catcher); + CHECK(r != NULL && eigs_value_as_string(r) && + strcmp(eigs_value_as_string(r), "CAUGHT") == 0, + "try/catch works before any exit (control)"); + if (r) eigs_value_release(r); + + r = eigs_eval_string("exit of 0"); + if (r) eigs_value_release(r); + eigs_clear_error(); + + r = eigs_eval_string(catcher); + CHECK(r != NULL && eigs_value_as_string(r) && + strcmp(eigs_value_as_string(r), "CAUGHT") == 0, + "try/catch still works after a script called exit (#739)"); + if (r) eigs_value_release(r); + /* Leave a recv-blocked, never-joined worker for eigs_close to reap — this * hangs (or leaks/UAFs) unless eigs_close drains (close+wake+join, #303). */ r = eigs_eval_string( diff --git a/src/main.c b/src/main.c index 5400a643..1708cdec 100644 --- a/src/main.c +++ b/src/main.c @@ -266,6 +266,11 @@ int main(int argc, char **argv) { register_store_builtins(global); eigenscript_repl(global); + /* #739: take the exit code BEFORE teardown. It is a bridge macro now + * (state latch, reached through eigs_current), so eigs_thread_detach + * below leaves nothing to read it through — the script path at the + * bottom of main already captures it first, for the same reason. */ + int repl_exit_code = g_exit_latched ? g_exit_latch_code : 0; trace_shutdown(); /* Drop the global scope's bindings (closures defined at top level * die here), then collect the env<->fn cycles those closures left @@ -277,7 +282,7 @@ int main(int argc, char **argv) { g_global_env = NULL; eigs_thread_detach(); eigs_state_destroy(eigs_st); - return g_exit_requested ? g_exit_code : 0; + return repl_exit_code; } /* Extract script directory for load_file resolution. g_script_dir @@ -360,7 +365,11 @@ int main(int argc, char **argv) { /* `exit of N` requests a specific code (and unwound via g_has_error); it * takes precedence over the generic uncaught-error code. Clear the unwind * flag so the teardown below sees a clean state. */ - int exit_code = g_exit_requested ? g_exit_code + /* #739: the CODE comes from the state latch, so `exit of N` inside a + * spawned worker still decides the process's exit status; the unwind flag + * is cleared off THIS thread's request, so a worker's exit never erases a + * genuine main-thread error. */ + int exit_code = g_exit_latched ? g_exit_latch_code : ((g_has_error || unobserved_task_error) ? 1 : 0); if (g_exit_requested) g_has_error = 0; /* An uncaught `throw` leaves its structured payload stashed; release diff --git a/tests/test_exit.sh b/tests/test_exit.sh index f57647d3..87f12d40 100644 --- a/tests/test_exit.sh +++ b/tests/test_exit.sh @@ -37,4 +37,17 @@ check_exit "exit unwinds out of a function and loop" 'define f() as: f of null print of "no"' 7 "" +# #739: `exit of N` inside a SPAWNED worker still decides the process's exit +# code. The request drives an uncatchable unwind on the raising thread, so it +# is per-thread — but the exit CODE is latched at the state, or a worker's exit +# silently becomes 0 while main returns success. (Main is not interrupted here: +# it runs to completion and then reports the worker's code, which is the +# behavior that has always shipped.) +check_exit "exit inside a spawned worker sets the process exit code" 'define worker(n) as: + exit of 9 + return 1 +w is spawn of [worker, 1] +r is join of w +print of "main continued"' 9 "main continued" + echo ""