A for loop that only executes once is a particularly cruel kind of bug. It does not crash. It does not throw an error. It runs, produces output, and stops -- giving you just enough evidence to think it is working while withholding the rest. The first iteration succeeds, creating a false sense of correctness. Only when you examine the results carefully do you notice that a loop over three items produced only one result.
This was the state of FLIN's for loops on January 6, 2026. It took two dedicated sessions -- Session 061 and Session 062 -- to untangle what turned out to be two layered bugs, each hiding behind the other.
Bug One: The Stack Underflow
The first symptom was not subtle at all. Running any for loop produced a stack underflow crash:
for n in [1, 2, 3] {
print(n)
}
print("done")Instead of printing 1, 2, 3, done, this crashed with a stack underflow error during scope cleanup. To understand why, you need to understand how FLIN manages loop variables versus regular variables.
The Storage Mismatch
In FLIN's virtual machine, regular variables follow a simple lifecycle. When declared, their value is pushed onto the stack. When they go out of scope, the compiler emits Pop instructions to remove them. This symmetry -- push on creation, pop on destruction -- keeps the stack balanced.
Loop variables break this symmetry. The NextFor opcode stores the current iteration value directly into a local variable slot:
// vm.rs:2356 -- NextFor opcode
frame.locals[slot] = item; // Stored to locals, NOT pushed to stackBut the end_scope() function in the compiler did not know about this distinction. It emitted Pop instructions for every local variable, including loop variables that were never on the stack:
1. [push iterable] -> Stack: [list]
2. StartFor(end_offset) -> Stack: [iterator]
3. NextFor(slot) -> Stack: [iterator], Locals: [item]
4. [loop body] -> Stack: [iterator]
5. Loop (jump back) -> (iterations...)
6. EndFor -> Stack: [] (iterator popped)
7. end_scope() -> Pop -> CRASH: nothing left to pop!At step 7, end_scope() tried to pop the loop variable n from the stack. But n was never on the stack -- it lived exclusively in the locals array. Stack underflow.
The is_loop_var Fix
The fix required teaching the compiler to distinguish loop variables from regular variables. We added an is_loop_var flag to the Local struct:
struct Local {
name: String,
depth: usize,
slot: u8,
is_captured: bool,
is_loop_var: bool, // NEW: Skip Pop for these
}Then we modified end_scope() to skip loop variables during cleanup:
fn end_scope(&mut self) {
while !self.locals.is_empty() && /* scope check */ {
let local = self.locals.pop().unwrap();if local.is_loop_var { continue; // Loop variables are NOT on the stack }
// Regular variables: emit Pop as usual self.emit(OpCode::Pop); } } ```
Every call to add_local() now specifies whether the variable is a loop variable:
// Loop variables (managed by iterator opcodes)
let item_slot = self.add_local(item, true);
let _index_slot = index.map(|idx| self.add_local(idx, true));// Regular variables (pushed to stack normally) let slot = self.add_local(name, false); // variable declarations let slot = self.add_local(¶m.name, false); // function parameters ```
With this fix, for loops no longer crashed. Tests went from 968 to 970 passing. But a new problem immediately revealed itself.
Bug Two: The Single-Iteration Problem
The for loop no longer crashed, but it only executed once:
sum = 0
for n in [1, 2, 3] {
sum = sum + n
}
print(sum)
// Output: 1 (expected: 6)The loop body ran once with n = 1, then the loop exited as if the iterator were exhausted. No error. No crash. Just premature termination.
The Investigation
Session 062 began with systematic instrumentation. We added debug output to every opcode involved in the for loop:
StartFor: Log iterator ID, index, length, exhaustion statusNextFor: Log slot, item, new indexJump: Log IP transitionPop: Log removed value
The first iteration trace looked perfect:
StartFor: iter_id=24, index=0, length=3
NextFor: slot=0, item=Int(1), new_index=1
Pop: removed None
Jump: from IP=21 to target=8Iterator created, first item extracted, body executed, jump back to loop start. Everything correct. But the second iteration told a different story:
StartFor: iter_id=24, index=0 (should be 1!)
NextFor: slot=0, item=Int(1) (same item again!)The iterator index had reset to zero. The mutation from NextFor -- which set new_index=1 -- was not persisting between iterations.
Deeper Stack Analysis
The index reset was suspicious, but we needed to understand the mechanism. We added stack size tracing at every opcode:
IP=13, LoadGlobal, Stack=1 -> pushes print function
IP=16, LoadLocal, Stack=2 -> pushes argument n
IP=18, Call, Stack=3 -> Stack=3 AFTER call (should be 2!)
IP=20, Pop, Stack=3 -> pops None result
IP=21, Jump, Stack=2 -> Stack=2 (should be 1!)
IP=8, StartFor, Stack=2 -> gets 2 items instead of 1!There it was. After the Call instruction executed the print function, the stack had three items instead of two. The Pop instruction correctly removed the function's return value (None), but an extra value remained: the function object itself.
The Root Cause: Native Function Stack Cleanup
FLIN's VM handles two kinds of function calls differently. For user-defined functions, the execute_call method calculates a base pointer that includes the function object, and stack.truncate(base_pointer) cleans up everything -- arguments, locals, and the function object itself.
For native functions (built-ins like print), the cleanup was incomplete:
CallInfo::Native { arity: native_arity, index } => {
// Execute the native function
// It pops its arguments and pushes its result
self.execute_native_call(index)?;
// BUG: The function object is still on the stack!
}Native functions popped their arguments and pushed their result, but the function object that was loaded onto the stack before the call was never removed. This meant that every call to a native function inside a for loop leaked one stack slot.
After the first iteration, the stack looked like this:
[iterator, print_function_object]When StartFor executed at the top of the next iteration, it popped the top of the stack expecting to find the iterator. Instead, it found the print function object. Since this object was not an iterator, StartFor either created a new empty iterator or misinterpreted the data -- either way, the loop appeared exhausted.
The Three-Line Fix
CallInfo::Native { arity: native_arity, index } => {
if native_arity as usize != arity {
return Err(RuntimeError::ArityMismatch {
expected: native_arity,
got: arity,
});
}self.execute_native_call(index)?;
// Remove the function from stack // Stack before: [..., function, result] // Stack after: [..., result] let result = self.pop()?; // Pop result self.pop()?; // Pop function object self.push(result)?; // Push result back } ```
Three lines of actual logic: pop the result, pop the function, push the result back. The fix was trivial once identified. The investigation to find it was not.
Verification
After the fix, all iteration patterns worked correctly:
// Basic iteration
for n in [1, 2, 3] {
print(n)
}
// Output: 1, 2, 3// Range iteration for i in range(1, 6) { print(i) } // Output: 1, 2, 3, 4, 5
// Nested loops for i in [1, 2] { for j in [10, 20] { print(i * j) } } // Output: 10, 20, 20, 40 ```
All 970 tests continued to pass with no regressions.
A Third Bug Hiding Underneath
Even after fixing iteration, we discovered that variable assignments inside for loops caused a separate compilation error:
for n in [1, 2, 3] {
x = n // error: Stack underflow during compilation
}We verified this was a pre-existing bug unrelated to our changes by reverting the fix and confirming the same error occurred. This bug would be addressed in a later session, but its discovery illustrates a pattern common in language development: fixing one bug reveals another, which reveals another, like geological strata exposed by erosion.
The Layered Bug Pattern
The for-loop saga is a textbook example of layered bugs -- multiple independent issues that interact to produce a single visible symptom.
Layer 1: end_scope() assumed all local variables were on the stack, causing stack underflow when loop variables (which live only in locals) went out of scope. Symptom: crash.
Layer 2: Native function calls did not clean up the function object from the stack, causing stack pollution that corrupted the iterator on subsequent loop iterations. Symptom: loop executes once.
Layer 3: Variable assignment in for loops had a separate compilation error. Symptom: compilation failure.
Layer 1 masked Layer 2 (could not observe single-iteration because the loop crashed first). Layer 2 masked Layer 3 (could not observe assignment errors because the loop never iterated to exercise them). Each fix peeled back a layer, revealing the next problem.
Debugging Lessons
This two-session investigation reinforced several debugging principles.
Instrument before hypothesizing. We could have spent hours theorizing about why the loop stopped. Instead, we added tracing to every relevant opcode and let the data tell the story. The stack size mismatch after Call was immediately visible in the trace output.
Track stack discipline rigorously. In a stack-based VM, every instruction that pushes must have a corresponding instruction that pops (or a mechanism like truncate that handles cleanup in bulk). The native function path violated this discipline, and the violation was invisible until it interacted with a loop.
Fix one thing at a time. Session 061 fixed the stack underflow. Session 062 fixed the iteration. Each session had a single, clear objective. Attempting to fix both simultaneously would have made it impossible to isolate which change resolved which symptom.
Verify with increasingly complex tests. We tested basic iteration first, then ranges, then nested loops, then accumulation, then variable assignment. Each test added one dimension of complexity, making it easy to identify exactly where the remaining boundary of failure lay.
The three-line fix that resolved the iteration bug is perhaps the best illustration of a universal truth in software engineering: the difficulty of a bug is not proportional to the size of its fix, but to the size of the search space you must navigate to find it.
---
This is Part 157 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.
Series Navigation: - [156] The CreateEntity Opcode That Went Missing - [157] The For-Loop Iteration Bug (you are here) - [158] The None Handling Bug