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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions src/builtins.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
45 changes: 45 additions & 0 deletions src/eigenscript.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions src/eigenscript.h
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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). */

Expand Down
5 changes: 5 additions & 0 deletions src/state.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
18 changes: 18 additions & 0 deletions src/vm.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
41 changes: 41 additions & 0 deletions tests/run_all_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 </dev/null 2>&1 ); CI_IMP_RC=$?
CI_CAT=$( cd "$CIRC_DIR" && "$BIN_ABS" catch.eigs </dev/null 2>&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 </dev/null 2>&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
Expand Down
Loading