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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 7 additions & 3 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
32 changes: 31 additions & 1 deletion src/vm.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions tests/run_all_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=$?
Expand Down
60 changes: 60 additions & 0 deletions tests/test_for_in_mutation.eigs
Original file line number Diff line number Diff line change
@@ -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
Loading