Found in a modularity review of main @ 8e8970a. The EigsState / EigsThread seam is genuinely good and most of the runtime obeys it — these are the subsystems that were never migrated and are now contradicted by the runtime's own in-tree use of multiple states.
Premise correction first, so this isn't read as broader than it is: g_vm, g_try_depth, and g_arena are not globals — they are bridge macros onto struct EigsThread (eigenscript.h:716-784), exactly as docs/EMBEDDING.md:59-61 describes. The seam works. The defects below are a small number of specific opt-outs.
1. trace.c has 24 file-scope globals and no EigsState reference at all
The tape (trace.c:386), the embed sink (:406-410), the replay reader (:536-547), the dedup state (:392-393, 452), the recording flags (:50-54), and the temporal history table (:118-120) are all process-wide.
trace_shutdown() (trace.c:1013-1044) tears all of it down — and it is called from every HTTP connection worker (ext_http.c:1292), each of which owns its own EigsState (ext_http.c:1269-1281: eigs_state_new + eigs_thread_attach per connection).
So with EIGS_TRACE set, the first HTTP request served closes the process's tape, unregisters any sink installed via eigs_set_trace_sink, and frees the entire prev-table — running slot_decref on entries recorded by other, still-live states. Separately, trace_assign (:1102) writes the table unlocked from concurrent workers, and prev_lookup_slot keys purely on the name string (:139, 163-169), so prev of x in one state can read another's history.
This is the same subsystem flagged in the correctness review for being process-global while the embedding contract is multi-state; the HTTP call site is what makes it concrete rather than theoretical.
Minimal fix: make trace_shutdown() take an EigsState* and tear down only that state's tape and table, so ext_http.c:1292 stops destroying the main state's. Full fix: move the globals into a TraceState owned by EigsState, reached by the same bridge macro as everything else.
2. g_exit_requested is never reset, permanently disabling try/catch
builtins.c:625-626 declares it; builtin_exit sets it (:641-642); CHECK_ERROR consults it to make the unwind uncatchable (vm.c:2505, 2515). The only writes in the tree are those two. Neither eigs_close (eigs_embed.c:44-65) nor eigs_state_destroy (state.c:35-61) clears it, and main.c:365 deliberately leaves it set.
Making exit uncatchable is correct. Never resetting it is not: once any script in any state calls exit of N, every subsequent eigs_eval_string in the process — same state or another — has exception handling silently disabled, and a raise inside try goes to vm_error_halt instead of the catch handler. For a long-lived embedder running untrusted snippets (the sandbox_run case), one line of script permanently corrupts the semantics.
I could not build a CLI repro because exit ends the process there; this is an embedding/HTTP-worker path. Fix: clear it at eval entry beside the existing g_parse_errors = 0; g_has_error = 0;, or move it onto EigsThread.
3. g_task_suspend_request is global while its scheduler is per-thread
The cooperative scheduler lives on EigsThread (eigenscript.h:705, freed per-thread at state.c:147), but its suspend request is a plain int (vm.c:5513, extern at vm.h:542), set at four sites and polled by every vm_run on every thread (vm.c:3435). A task_yield on thread A can drive thread B into vm_suspend_halt and hand B's live stack slice to A's task. Mechanical fix: move it into the per-thread TaskScheduler.
4. Smaller opt-outs, same shape
s_osr_threshold (vm.c:3228-3229) is a plain function static with no __thread, so the first state to cross a back edge freezes the OSR threshold process-wide — defeating the per-state JIT tuning eigenscript.h:570-576 explicitly advertises ("so two co-located embedded states can tune independently"). Entry and iter thresholds are correctly per-state; only OSR opts out.
g_sandbox_* (vm.c:711, 755-757) is save/restored around one call on the documented premise that "sandbox_run is synchronous / single-threaded" (vm.c:709-711, 752-754) — true per-thread, false the moment two states run concurrently, which ext_http.c:1269 does per connection.
g_stream_file (builtins.c:2023) is one FILE* per process, and stream_open unconditionally closes whatever is open.
g_model (model_io.c:8) and g_db_conn (ext_db.c:10) are one-per-process but registered per-state, and nothing frees them at state teardown.
The pattern worth naming
ext_store.c has zero file-scope globals and routes every Store through the per-state handle table (:597, 714). That is the correct seam and it already exists in-tree — the model and DB extensions simply predate it. Likewise g_vm_abort_flag (vm.c:734-735) is a documented, reasoned opt-out (async-signal-safety needs a flat address). The contrast between that explicit opt-out and trace.c's silent one is the whole finding.
Found in a modularity review of
main@8e8970a. TheEigsState/EigsThreadseam is genuinely good and most of the runtime obeys it — these are the subsystems that were never migrated and are now contradicted by the runtime's own in-tree use of multiple states.Premise correction first, so this isn't read as broader than it is:
g_vm,g_try_depth, andg_arenaare not globals — they are bridge macros ontostruct EigsThread(eigenscript.h:716-784), exactly asdocs/EMBEDDING.md:59-61describes. The seam works. The defects below are a small number of specific opt-outs.1.
trace.chas 24 file-scope globals and noEigsStatereference at allThe tape (
trace.c:386), the embed sink (:406-410), the replay reader (:536-547), the dedup state (:392-393, 452), the recording flags (:50-54), and the temporal history table (:118-120) are all process-wide.trace_shutdown()(trace.c:1013-1044) tears all of it down — and it is called from every HTTP connection worker (ext_http.c:1292), each of which owns its ownEigsState(ext_http.c:1269-1281:eigs_state_new+eigs_thread_attachper connection).So with
EIGS_TRACEset, the first HTTP request served closes the process's tape, unregisters any sink installed viaeigs_set_trace_sink, and frees the entire prev-table — runningslot_decrefon entries recorded by other, still-live states. Separately,trace_assign(:1102) writes the table unlocked from concurrent workers, andprev_lookup_slotkeys purely on the name string (:139, 163-169), soprev of xin one state can read another's history.This is the same subsystem flagged in the correctness review for being process-global while the embedding contract is multi-state; the HTTP call site is what makes it concrete rather than theoretical.
Minimal fix: make
trace_shutdown()take anEigsState*and tear down only that state's tape and table, soext_http.c:1292stops destroying the main state's. Full fix: move the globals into aTraceStateowned byEigsState, reached by the same bridge macro as everything else.2.
g_exit_requestedis never reset, permanently disabling try/catchbuiltins.c:625-626declares it;builtin_exitsets it (:641-642);CHECK_ERRORconsults it to make the unwind uncatchable (vm.c:2505, 2515). The only writes in the tree are those two. Neithereigs_close(eigs_embed.c:44-65) noreigs_state_destroy(state.c:35-61) clears it, andmain.c:365deliberately leaves it set.Making
exituncatchable is correct. Never resetting it is not: once any script in any state callsexit of N, every subsequenteigs_eval_stringin the process — same state or another — has exception handling silently disabled, and a raise insidetrygoes tovm_error_haltinstead of the catch handler. For a long-lived embedder running untrusted snippets (thesandbox_runcase), one line of script permanently corrupts the semantics.I could not build a CLI repro because
exitends the process there; this is an embedding/HTTP-worker path. Fix: clear it at eval entry beside the existingg_parse_errors = 0; g_has_error = 0;, or move it ontoEigsThread.3.
g_task_suspend_requestis global while its scheduler is per-threadThe cooperative scheduler lives on
EigsThread(eigenscript.h:705, freed per-thread atstate.c:147), but its suspend request is a plainint(vm.c:5513,externatvm.h:542), set at four sites and polled by everyvm_runon every thread (vm.c:3435). Atask_yieldon thread A can drive thread B intovm_suspend_haltand hand B's live stack slice to A's task. Mechanical fix: move it into the per-threadTaskScheduler.4. Smaller opt-outs, same shape
s_osr_threshold(vm.c:3228-3229) is a plain function static with no__thread, so the first state to cross a back edge freezes the OSR threshold process-wide — defeating the per-state JIT tuningeigenscript.h:570-576explicitly advertises ("so two co-located embedded states can tune independently"). Entry and iter thresholds are correctly per-state; only OSR opts out.g_sandbox_*(vm.c:711, 755-757) is save/restored around one call on the documented premise that "sandbox_run is synchronous / single-threaded" (vm.c:709-711, 752-754) — true per-thread, false the moment two states run concurrently, whichext_http.c:1269does per connection.g_stream_file(builtins.c:2023) is oneFILE*per process, andstream_openunconditionally closes whatever is open.g_model(model_io.c:8) andg_db_conn(ext_db.c:10) are one-per-process but registered per-state, and nothing frees them at state teardown.The pattern worth naming
ext_store.chas zero file-scope globals and routes everyStorethrough the per-state handle table (:597, 714). That is the correct seam and it already exists in-tree — the model and DB extensions simply predate it. Likewiseg_vm_abort_flag(vm.c:734-735) is a documented, reasoned opt-out (async-signal-safety needs a flat address). The contrast between that explicit opt-out andtrace.c's silent one is the whole finding.