Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/BUILTINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <msg>"` 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 |
Expand Down
9 changes: 9 additions & 0 deletions docs/EMBEDDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 9 additions & 6 deletions src/builtins.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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();
}
Expand Down
36 changes: 29 additions & 7 deletions src/eigenscript.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 ---- */

Expand Down
9 changes: 9 additions & 0 deletions src/eigs_embed.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
25 changes: 25 additions & 0 deletions src/embed_smoke.c
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
13 changes: 11 additions & 2 deletions src/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions tests/test_exit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Loading