Skip to content

Multi-state boundary: trace.c is entirely process-global and ext_http's per-connection worker calls trace_shutdown(), destroying the process tape on every request #739

Description

@InauguralPhysicist

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions