diff --git a/CHANGELOG.md b/CHANGELOG.md index e87e5d2..0c5578c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,19 @@ All notable changes to EigenScript are documented here. atomic. Still open under #488: a debug-mode *assertion* that a delimited region issued no scheduler yield (needs VM support). +### Fixed +- **Circular `import` / `load_file` no longer crashes (#496).** A mutual or + self-referential `import` (a→b→a) or `load_file` used to recurse through + `vm_execute` until the C stack overflowed — SIGSEGV, `rc=139`, uncatchable. + The module cache is only populated *after* a load completes, so the + re-entrant load missed the cache and recursed. A per-`EigsState` in-flight + load stack now records paths whose load is on the current C stack; the + loader raises a catchable `io` error (`import: circular dependency — '…' is + already being loaded`) instead of recursing. Shared by `import` and + `load_file`, so a cycle that crosses the two is caught too. Repeated + *sequential* loads of the same file stay legal — only active re-entrancy is + a cycle. Regression: suite section **[115]**. + ## [0.28.0] - 2026-07-08 ### Added diff --git a/src/builtins.c b/src/builtins.c index f2ffb5d..b19eca8 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -2523,6 +2523,24 @@ Value* builtin_load_file(Value *arg) { fprintf(stderr, "load_file: cannot read '%s'\n", arg->data.str); return make_null(); } + + /* #496: circular-load guard. load_file has no module cache — it + * re-executes every call — so a mutual load (a loads b, b loads a) + * recurses through vm_execute to a C-stack SIGSEGV. Key on the + * canonical path (shared with import's cycle stack) and raise if this + * path's load is already on the stack. Sequential re-loads of the same + * file stay legal: the entry pops when each load completes. */ + char abs_key[8192]; + if (!realpath(path, abs_key)) + snprintf(abs_key, sizeof(abs_key), "%s", path); + if (eigs_loading_active(abs_key)) { + free(source); + rt_error(EK_IO, 0, + "load_file: circular dependency — '%s' is already being loaded", + arg->data.str); + return make_null(); + } + fprintf(stderr, "[load_file] Loading %s (%ld bytes)\n", path, size); /* A parse error in the loaded file must surface, not be silently run as a @@ -2564,7 +2582,9 @@ Value* builtin_load_file(Value *arg) { return make_null(); } g_parse_errors = saved_errors; + eigs_loading_enter(abs_key); /* #496 */ Value *result = vm_execute(lf_chunk, target); + eigs_loading_leave(abs_key); /* #496 */ chunk_free(lf_chunk); /* creator ref; loaded fns hold their own */ free_ast(ast); free(source); diff --git a/src/eigenscript.c b/src/eigenscript.c index 119048e..3dc1820 100644 --- a/src/eigenscript.c +++ b/src/eigenscript.c @@ -2275,6 +2275,51 @@ void eigs_module_cache_clear(void) { * is cleared at gc_collect_at_exit; the state outlives that call. */ } +/* ---- In-flight load guard (#496) ------------------------------------ + * A circular import/load_file re-enters the loader for a path whose load + * hasn't finished. The module cache is populated only *after* a load + * completes (eigs_module_cache_put below the vm_execute), so the re-entry + * misses the cache and recurses through vm_execute until the C stack + * overflows — SIGSEGV, rc=139, uncatchable. This stack records paths whose + * load is currently on the C stack; the loader checks it on entry and + * raises a catchable error instead of recursing. Same-thread startup use, + * so unguarded like the module cache above. */ +int eigs_loading_active(const char *abs_path) { + if (!abs_path || !eigs_current) return 0; + EigsState *st = eigs_current->state; + for (size_t i = 0; i < st->loading_count; i++) + if (strcmp(st->loading_stack[i], abs_path) == 0) return 1; + return 0; +} + +void eigs_loading_enter(const char *abs_path) { + if (!abs_path || !eigs_current) return; + EigsState *st = eigs_current->state; + if (st->loading_count == st->loading_cap) { + size_t newcap = st->loading_cap ? st->loading_cap * 2 : 8; + st->loading_stack = xrealloc_array(st->loading_stack, newcap, + sizeof(char *)); + st->loading_cap = newcap; + } + st->loading_stack[st->loading_count++] = strdup(abs_path); +} + +void eigs_loading_leave(const char *abs_path) { + if (!abs_path || !eigs_current) return; + EigsState *st = eigs_current->state; + /* LIFO in practice; scan from the top and remove the match so an + * unexpected out-of-order leave can't strand the wrong entry. */ + for (size_t i = st->loading_count; i-- > 0; ) { + if (strcmp(st->loading_stack[i], abs_path) == 0) { + free(st->loading_stack[i]); + memmove(&st->loading_stack[i], &st->loading_stack[i + 1], + (st->loading_count - i - 1) * sizeof(char *)); + st->loading_count--; + return; + } + } +} + /* Exit-time collection. Pure value->value cycles bound at global scope * (e.g. a list appended to itself) are unreachable from the captured-env * registry, so snapshot the global scope's container values first (one diff --git a/src/eigenscript.h b/src/eigenscript.h index 9cc2e9e..9d1be7a 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -483,6 +483,18 @@ struct EigsState { EigsModuleCacheEntry *module_cache; size_t module_cache_count; size_t module_cache_cap; + /* In-flight load stack (#496): paths currently executing via import / + * load_file. The module cache is only populated *after* a load + * completes, so a re-entrant load of a still-loading path misses the + * cache and recurses through vm_execute until the C stack is exhausted + * (SIGSEGV, rc=139). This detects the cycle so the loader raises a + * catchable error instead. Not a cache: repeated *sequential* loads + * stay legal (each entry pops on completion); only active + * re-entrancy — a path importing itself, directly or transitively — + * is a cycle. */ + char **loading_stack; + size_t loading_count; + size_t loading_cap; /* Opaque-pointer handle table (Store/Thread/Channel ids). Locked * via handle_mutex since spawn workers can release handles too. */ EigsHandleSlot handle_table[HANDLE_TABLE_SIZE]; @@ -1044,6 +1056,14 @@ void eigs_module_cache_put(const char *abs_path, Value *dict, Env *env); * usual snapshot collection. */ void eigs_module_cache_clear(void); +/* In-flight load guard (#496). eigs_loading_active is true while `abs_path` + * is between enter and leave — i.e. its load is on the current C stack. + * import and load_file share this so a cycle that crosses the two (import + * a → load_file b → import a) is still caught. LIFO in practice. */ +int eigs_loading_active(const char *abs_path); +void eigs_loading_enter(const char *abs_path); +void eigs_loading_leave(const char *abs_path); + /* Observer thresholds are EigsState fields — set via set_observer_thresholds; * read through g_obs_dh_zero / g_obs_dh_small / g_obs_h_low (macros above). */ diff --git a/src/state.c b/src/state.c index d43ec31..5a3d7be 100644 --- a/src/state.c +++ b/src/state.c @@ -46,6 +46,11 @@ void eigs_state_destroy(EigsState *st) { /* Module-cache refs were dropped at gc_collect_at_exit; the array * itself may still be allocated (capacity bumped past zero). */ free(st->module_cache); + /* #496: any load still on the in-flight stack at destroy is a leak of + * a strdup'd path (shouldn't happen in a clean run — every enter is + * paired with a leave — but free defensively). */ + for (size_t i = 0; i < st->loading_count; i++) free(st->loading_stack[i]); + free(st->loading_stack); /* #307: value-candidate buffer pins were drained at gc_collect_at_exit; * free the (now-empty) backing array. NULL if no cycle ever parked. */ free(st->gc_val_buf); diff --git a/src/vm.c b/src/vm.c index 460e907..cc8cd45 100644 --- a/src/vm.c +++ b/src/vm.c @@ -4714,6 +4714,22 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { } #endif /* !EIGENSCRIPT_FREESTANDING */ + /* #496: circular-import guard. `abs_path` is a confirmed cache + * miss here (both source branches above return on a hit), so if + * it is already on the in-flight load stack this import re-enters + * a module whose load hasn't finished — a cycle that would + * otherwise recurse to a C-stack SIGSEGV. Raise a catchable error + * instead. The enter/leave brackets vm_execute below, where any + * nested import runs. */ + if (eigs_loading_active(abs_path)) { + free(source); + rt_error(EK_IO, current_line, + "import: circular dependency — '%s' is already being loaded", + name); + vm_push(make_null()); + DISPATCH(); + } + Env *mod_env = env_new(g_global_env); int saved_errors = g_parse_errors; g_parse_errors = 0; @@ -4772,7 +4788,9 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { DISPATCH(); } g_parse_errors = saved_errors; + eigs_loading_enter(abs_path); /* #496 */ Value *mod_result = vm_execute(mod_chunk, mod_env); + eigs_loading_leave(abs_path); /* #496 */ if (mod_result) val_decref(mod_result); chunk_free(mod_chunk); /* creator ref; module fns hold their own */ g_load_env = saved_load; diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 4f55b1f..15dfd31 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -2636,6 +2636,47 @@ else fi echo "" +echo "[115] Circular Import/Load Guard (#496, 3 checks)" +# A mutual import (a→b→a) or load_file (a↔b) used to recurse through +# vm_execute until the C stack overflowed — SIGSEGV, rc=139, uncatchable. +# The in-flight load stack now detects the cycle and raises a catchable +# EK_IO error. Generated in a temp dir (multi-file, not worth committing). +# Checks: (1) mutual import raises, no segfault; (2) it's try/catch-able; +# (3) mutual load_file raises, no segfault. +CIRC_DIR=$(mktemp -d /tmp/eigs_circ_XXXX) +printf 'import b\n' > "$CIRC_DIR/a.eigs" +printf 'import a\n' > "$CIRC_DIR/b.eigs" +printf 'import a\nprint of "ok"\n' > "$CIRC_DIR/main.eigs" +printf 'try:\n import a\ncatch e:\n print of e.kind\n print of "CAUGHT"\n' > "$CIRC_DIR/catch.eigs" +printf 'load_file of "lb.eigs"\n' > "$CIRC_DIR/la.eigs" +printf 'load_file of "la.eigs"\n' > "$CIRC_DIR/lb.eigs" +BIN_ABS="$PWD/eigenscript" +CI_IMP=$( cd "$CIRC_DIR" && "$BIN_ABS" main.eigs &1 ); CI_IMP_RC=$? +CI_CAT=$( cd "$CIRC_DIR" && "$BIN_ABS" catch.eigs &1 ); CI_CAT_RC=$? +# Capture rc directly off the substitution (a trailing `| grep` would make +# $? grep's exit, not eigenscript's); strip the [load_file] debug lines after. +CI_LF=$( cd "$CIRC_DIR" && "$BIN_ABS" la.eigs &1 ); CI_LF_RC=$? +CI_LF=$(echo "$CI_LF" | grep -v '^\[load_file\]') +rm -rf "$CIRC_DIR" +TOTAL=$((TOTAL + 3)) +# rc=1 (raised, uncaught) and NOT 139 (segfault); message present. +if [ "$CI_IMP_RC" = "1" ] && echo "$CI_IMP" | grep -q "circular dependency"; then + echo " PASS: mutual import raises (no SIGSEGV)"; PASS=$((PASS + 1)) +else + echo " FAIL: mutual import (rc=$CI_IMP_RC out='$CI_IMP')"; FAIL=$((FAIL + 1)) +fi +if [ "$CI_CAT_RC" = "0" ] && echo "$CI_CAT" | grep -q "^CAUGHT$"; then + echo " PASS: circular import is try/catch-able"; PASS=$((PASS + 1)) +else + echo " FAIL: circular import not catchable (rc=$CI_CAT_RC out='$CI_CAT')"; FAIL=$((FAIL + 1)) +fi +if [ "$CI_LF_RC" = "1" ] && echo "$CI_LF" | grep -q "circular dependency"; then + echo " PASS: mutual load_file raises (no SIGSEGV)"; PASS=$((PASS + 1)) +else + echo " FAIL: mutual load_file (rc=$CI_LF_RC out='$CI_LF')"; FAIL=$((FAIL + 1)) +fi +echo "" + echo "[92] Module Resolve Base (1 check)" # Phase 0b: an `import` inside a module resolves relative to *that # module's* directory, not the main script's. Shell-driven because