From 9a78fcd4ddee0976593c7584d36d4dd8ccabff3e Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Thu, 9 Jul 2026 01:02:12 -0500 Subject: [PATCH] fix: for-in snapshots iteration length at loop entry (#491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `for x in xs` re-read the sequence's *live* length on every ITER_NEXT, so a body that appended to xs looped forever (unbounded memory, OOM) and one that removed from the front skipped elements past the shrink. SPEC left mutation-during-iteration undefined. make_iter_state now records the length at loop entry as a third element of the iterator state; ITER_NEXT (interpreter) and jit_helper_iter_next (JIT) bound the iteration by min(snapshot, live length). Appending can no longer extend the loop; removing stops at the live length instead of reading a freed slot. `for` over a mutated sequence is now well-defined: it visits exactly the indices 0..N-1 that existed at entry, read live. Applies to both tiers and to list comprehensions (same ITER_SETUP/ITER_NEXT path). SPEC updated. Regression: suite section [117] (test_for_in_mutation.eigs, 9 checks — append-terminates, remove-is-OOB-safe, JIT-tier snapshot, buffer, empty, listcomp). Release suite green (2655/2655). Closes #491 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 ++++++ docs/SPEC.md | 10 ++++-- src/vm.c | 32 +++++++++++++++++- tests/run_all_tests.sh | 8 +++++ tests/test_for_in_mutation.eigs | 60 +++++++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 tests/test_for_in_mutation.eigs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1493412..afec96a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,16 @@ All notable changes to EigenScript are documented here. `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]**. +- **`for x in xs` snapshots the iteration length at loop entry (#491).** + ITER_NEXT used to re-read the sequence's *live* length every step, so a + body that appended to `xs` looped forever (unbounded memory, OOM) and one + that removed from the front skipped elements. The length is now fixed at + entry and bounded by `min(snapshot, live length)`: appending can't extend + the loop, and removing stops at the live length instead of reading a freed + slot. `for` over a sequence mutated in its body is now well-defined — it + visits exactly the indices 0..N-1 that existed at entry, read live (SPEC + updated). Applies to both the interpreter and JIT tiers and to list + comprehensions. Regression: suite section **[117]**. ## [0.28.0] - 2026-07-08 diff --git a/docs/SPEC.md b/docs/SPEC.md index 3080a69..0194c24 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -439,9 +439,13 @@ medium ## Loops `loop while cond:` repeats while the condition is truthy. `for v in -seq:` iterates a list, buffer, or `range of n` (0 to n-1). `break` and -`continue` behave conventionally and do not escape function-call -boundaries. A `break` or `continue` with no enclosing loop — including +seq:` iterates a list, buffer, or `range of n` (0 to n-1). The iteration +length is **fixed at loop entry**: mutating `seq` inside the body is +well-defined — the loop visits the indices that existed when it started, +reading each element live, so appending does not extend the loop and +removing stops it early (rather than looping forever or reading past the +end). `break` and `continue` behave conventionally and do not escape +function-call boundaries. A `break` or `continue` with no enclosing loop — including inside a function body that has no loop of its own — is a **compile error** (`'break' outside a loop`), not a silent no-op. diff --git a/src/vm.c b/src/vm.c index cc8cd45..2079afd 100644 --- a/src/vm.c +++ b/src/vm.c @@ -500,11 +500,24 @@ static inline uint16_t read_u16(uint8_t *ip) { /* Iterator state: stored as a list [iterable, index] */ static Value *make_iter_state(Value *iterable) { - Value *state = make_list(2); + Value *state = make_list(3); list_append(state, iterable); Value *idx = make_num(0); list_append(state, idx); val_decref(idx); + /* #491: snapshot the length at loop entry (items[2]). ITER_NEXT bounds + * the iteration by min(snapshot, live length), so appending to the + * iterable in the body can no longer extend the loop (it used to read + * the live count every step — an unbounded loop / OOM), while a shrink + * (remove) still stops at the live length instead of reading a freed + * slot. `for` over a list mutated in its body is thus well-defined: + * exactly the indices 0..N-1 that existed at entry, read live. */ + int snap = 0; + if (iterable && iterable->type == VAL_LIST) snap = iterable->data.list.count; + else if (iterable && iterable->type == VAL_BUFFER) snap = iterable->data.buffer.count; + Value *slen = make_num((double)snap); + list_append(state, slen); + val_decref(slen); return state; } @@ -1130,6 +1143,15 @@ int jit_helper_iter_next(void) { if (iterable->type == VAL_LIST) len = iterable->data.list.count; else if (iterable->type == VAL_BUFFER) len = iterable->data.buffer.count; else return 1; + /* #491: cap by the entry-time snapshot (items[2]) — see make_iter_state. + * Mirrors CASE(ITER_NEXT). */ + if (state->data.list.count >= 3) { + Value *snap_v = state->data.list.items[2]; + if (snap_v && snap_v->type == VAL_NUM) { + int snap = (int)snap_v->data.num; + if (snap < len) len = snap; + } + } if (idx >= len) return 1; Value *elem; if (iterable->type == VAL_BUFFER) { @@ -3909,6 +3931,14 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { int len = 0; if (iterable->type == VAL_LIST) len = iterable->data.list.count; else if (iterable->type == VAL_BUFFER) len = iterable->data.buffer.count; + /* #491: cap by the entry-time snapshot (items[2]) so a body that + * appends can't extend the loop; min() with the live length keeps a + * remove from reading past the end. See make_iter_state. */ + if (state->data.list.count >= 3 && + state->data.list.items[2]->type == VAL_NUM) { + int snap = (int)state->data.list.items[2]->data.num; + if (snap < len) len = snap; + } if (idx >= len) { ip += exit_offset; diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index e6f3969..fdf2229 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -1036,6 +1036,14 @@ echo "[116] Silent-Tolerance Batch-2: bad input raises (13 issues)" check_eigs_suite "invalid input raises instead of silent null/0/empty" \ test_raise_on_bad_input.eigs "ALL_RAISE_TESTS_DONE" 35 +# [117] for-in snapshots the iteration length at loop entry (#491). Mutating +# the sequence in the body is well-defined: appending no longer loops forever +# (was an unbounded loop / OOM), removing stops at the live length instead of +# reading a freed slot. Covers interpreter + JIT tiers, buffer, empty, listcomp. +echo "[117] for-in Length Snapshot (#491, 9 checks)" +check_eigs_suite "for-in snapshots length; body mutation is bounded + safe" \ + test_for_in_mutation.eigs "FOR_IN_MUTATION_DONE" 9 + # [23] Named parameters echo "[23/27] Named Parameters (9 checks)" NP_OUTPUT=$(./eigenscript ../tests/test_named_params.eigs 2>&1); NP_OUTPUT_RC=$? diff --git a/tests/test_for_in_mutation.eigs b/tests/test_for_in_mutation.eigs new file mode 100644 index 0000000..a516618 --- /dev/null +++ b/tests/test_for_in_mutation.eigs @@ -0,0 +1,60 @@ +# test_for_in_mutation.eigs — #491: `for x in xs` snapshots the length at +# loop entry. Mutating xs in the body is well-defined: the loop visits +# exactly the indices 0..N-1 that existed at entry, read live. Appending can +# no longer extend the loop forever (was an unbounded loop / OOM); removing +# stops at the live length instead of reading a freed slot. +load_file of "lib/test.eigs" + +# ---- append during iteration terminates (was infinite) ---- +xs is [1, 2, 3] +seen is 0 +for x in xs: + append of [xs, x * 10] + seen += 1 +assert_eq of [seen, 3, "append-during-iter runs exactly the 3 entry-time elements"] +assert_eq of [len of xs, 6, "the 3 appended elements are present after the loop"] +assert_true of [xs[0] == 1 and xs[3] == 10 and xs[5] == 30, "appended values are correct and in order"] + +# ---- remove from the front is OOB-safe and terminates ---- +ys is [1, 2, 3, 4, 5] +visited is [] +for y in ys: + list_remove_at of [ys, 0] + append of [visited, y] +# bounded by min(snapshot=5, shrinking live length): visits indices 0..2 of +# the shrinking list, then idx passes the live length and the loop stops. +assert_true of [len of visited > 0 and len of visited <= 5, "remove-front loop terminates, no OOB"] + +# ---- a big loop crosses the JIT threshold and still snapshots ---- +big is [] +for i in range of 500: + append of [big, i] +jit_count is 0 +for b in big: + append of [big, 0] + jit_count += 1 +assert_eq of [jit_count, 500, "JIT-tier for-in snapshots the entry-time length"] + +# ---- normal iteration is unchanged ---- +sum is 0 +for n in [10, 20, 30]: + sum += n +assert_eq of [sum, 60, "plain for-in over a literal list still works"] + +buf_sum is 0 +bf is buf_from_list of [7, 8, 9] +for v in bf: + buf_sum += v +assert_eq of [buf_sum, 24, "for-in over a buffer still works"] + +empty_hits is 0 +for e in []: + empty_hits += 1 +assert_eq of [empty_hits, 0, "for-in over an empty list runs zero times"] + +# ---- a list comprehension over a list is unaffected ---- +doubled is [k * 2 for k in [1, 2, 3, 4]] +assert_true of [len of doubled == 4 and doubled[3] == 8, "list comprehension still works"] + +print of "FOR_IN_MUTATION_DONE" +test_summary of null