fix(runtime): one exit of N no longer disables try/catch for the process (#739 item 2) - #762
Merged
Merged
Conversation
…ocess (#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 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) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes an embedding-specific runtime bug where calling exit once could permanently disable try/catch handling for subsequent evaluations in the same process, by moving exit request state off process globals and scoping/resetting it correctly per thread/eval while still preserving process exit status behavior.
Changes:
- Move
g_exit_requested/g_exit_codeontoEigsThreadand add anEigsStatelatch for the process exit code; clear per-thread exit request at embed eval entry. - Update CLI/REPL exit-code reporting to read the state latch before
eigs_thread_detach(). - Add regression coverage (spawned worker exit code; embed smoke for try/catch-after-exit) and update docs + changelog.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/test_exit.sh | Adds regression test ensuring exit of N inside a spawned worker still determines the process exit code. |
| src/main.c | Captures latched exit code before thread teardown in REPL path; switches script-mode exit status to state latch. |
| src/embed_smoke.c | Adds embed repro/regression check: try/catch must keep working after an eval that called exit. |
| src/eigs_embed.c | Clears per-thread exit request at eigs_eval_string entry to prevent cross-eval semantic corruption. |
| src/eigenscript.h | Adds per-thread exit fields + state latch fields and bridge macros; updates comments accordingly. |
| src/builtins.c | Updates builtin_exit to set per-thread request and state-level latch for process exit status. |
| docs/EMBEDDING.md | Documents that exit is per-eval/per-thread for embedding and no longer poisons later evals. |
| docs/BUILTINS.md | Clarifies exit behavior under embedding (doesn’t disable later try/catch). |
| CHANGELOG.md | Records the bug and fix details under “Fixed”. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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. |
This was referenced Jul 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Second of #739's items. Item 1 (the trace tape) landed in #761; this is the one flagged as most severe of the remainder.
The bug
exitis deliberately uncatchable:builtin_exitsetsg_exit_requested, andCHECK_ERRORconsults it to refuse routing the unwind to atryhandler. But the flag was a process global that nothing ever reset — noteigs_close, noteigs_state_destroy, andmainleaves it set on purpose. So once any script in any state calledexit, every latereigs_eval_stringin the process ran with exception handling silently disabled: a raise insidetrywent tovm_error_haltinstead of the catch handler.Reproduced through the embed API — unreachable from the CLI, where
exitends the process, which is why it had never been seen:For a long-lived host running untrusted snippets — the
sandbox_runcase, and the seam EigenOS M11 consumes — one line of script permanently corrupted the semantics.The fix
g_exit_requested/g_exit_codemove ontoEigsThread, beside theg_has_error/g_try_depththatCHECK_ERRORreads them with — they were the only members of that flag family still process-global. The request is cleared at host eval entry, using the idiomrepl.c:67already had forg_has_error("don't carry a prior line's error into this one"). It applies to the eval that raised it and no later one.exitstays uncatchable.The exit code is additionally latched on
EigsState. Per-thread alone silently droppedexit of Ninside a spawned worker to 0 whilemainreturned success — a regression this change introduced, which the suite had no coverage for. The thread flag drives the uncatchable unwind; the state latch decides the process's status. Side benefit: a worker's exit no longer erases a genuine main-thread error.Two reader-side fixes fall out, both the same root cause — a bridge macro is not a process global
main.c's REPL path read the flag aftereigs_thread_detach(), leaving nothing to read it through; the REPL returned 0 instead of the requested code. It captures before teardown now, as 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 into one block.Validation
Both new checks were validated red before being trusted:
test_exit.shspawned-worker caserc=0 want=9embed_smoketry/catch-after-exitFAIL … (#739)The embed check ships a before-exit control that passes in both runs, so the check isolates the right thing rather than the harness.
Behavior verified unchanged:
exit of 7→ 7,exitinsidetry→ 3 (still uncatchable), REPLexit of 5→ 5, workerexit of 9→ 9.Refs #739 — items 3 (
g_task_suspend_requestglobal while its scheduler is per-thread) and 4 (s_osr_threshold,g_sandbox_*,g_stream_file,g_model,g_db_conn) remain open.🤖 Generated with Claude Code