The Temper Rust backend does not re-initialize for loop variables when
an enclosing loop iterates, if the for body contains an await.
The inner loop runs correctly on the first outer iteration, then silently
never executes again.
I discovered this when adding #377 for fun to make multiplayer snake after implementing sleep and readline IO ops in #376
It appeared in the websocket game loop ticking through the state machine for the game, but I have a pretty minimal example that can express this built off main here.
Affects: Temper main branch (tested at commit cb8c3d5)
Backend: Rust only. JS is correct.
Requires: No external dependencies. Pure Temper core primitives.
Minimal Reproduction
export let name = "loop-bug";
let resolved(): Promise<Empty> {
let pb = new PromiseBuilder<Empty>();
pb.complete(empty());
pb.promise
}
var outerCount = 0;
var totalLoopBodyRuns = 0;
async { (): GeneratorResult<Empty> extends GeneratorFn =>
do {
let items: List<Int> = [10, 20, 30];
while (outerCount < 3) {
outerCount = outerCount + 1;
for (var i = 0; i < items.length; ++i) {
totalLoopBodyRuns = totalLoopBodyRuns + 1;
await resolved();
}
}
console.log("total loop body runs: ${totalLoopBodyRuns.toString()}");
} orelse void;
}
Build and run:
temper build -b js -b rust
JS output: total loop body runs: 9 (3 outer × 3 inner = correct)
Rust output: total loop body runs: 3 (inner loop runs only on
the first outer iteration)
Root Cause
The Rust backend compiles async blocks into state machines via the
TranslateToRegularFunction coroutine strategy (in
be-rust/src/commonMain/kotlin/lang/temper/be/rust/RustSupportNetwork.kt,
coroutineStrategy = CoroutineStrategy.TranslateToRegularFunction).
Each await creates a yield point that splits the function into numbered
cases in a match caseIndex { 0 => ..., 1 => ..., ... } state machine.
Local variables that cross yield points are hoisted to
Arc<RwLock<T>> fields on the generator's closure struct.
For a for (var i = 0; ...) loop, the compiler:
-
Creates i as a struct field with initial value 0:
let mut i: Arc<RwLock<i32>> = Arc::new(RwLock::new(0));
-
Generates a state machine case for the loop condition check:
N => {
if !(i < items.length) {
caseIndex = exit_case;
} else {
caseIndex = body_case;
}
}
-
Generates a case for the increment:
M => {
i = i + 1;
caseIndex = N; // back to condition check
}
The initialization i = 0 from var i = 0 is only present in step 1
(the struct field default). There is no i = 0 assignment in any state
machine case. When the enclosing while loop iterates and re-enters the
for loop, the state machine jumps to case N (the condition check). At
this point i still holds its value from the previous outer iteration
(e.g., 3). The condition 3 < 3 is false, so the inner loop body is
skipped entirely.
The JS backend does not have this bug because it uses
CoroutineStrategy.TranslateToGenerator which compiles async blocks to
JavaScript generator functions. JavaScript generators re-execute the
full function body on each next() call up to the next yield, so
var i = 0 runs as a normal assignment each time the loop entry is
reached.
Scope
The bug affects any code where:
- A
for (var x = init; ...) loop is nested inside another loop
- The
for body contains an await expression
It does not depend on:
- Whether the Promise resolves synchronously or asynchronously
- Whether the
await is wrapped in do { ... } orelse void
- What types are involved
- Whether
@connected functions are used
The same issue likely affects any var declaration with an initializer
inside a loop body that also contains an await. The initializer runs
once at generator creation and is never re-executed.
Workarounds
-
Use for (let x of list) — compiles to list_for_each() with a
callback, avoiding the state machine entirely. Cannot await inside
the callback, but works for synchronous operations.
-
Avoid await inside for loops — keep the loop body synchronous
and await after the loop completes.
-
Reset the variable explicitly before the loop:
i = 0; // explicit reset
for (var i = 0; i < items.length; ++i) { ... }
(Untested — the compiler may or may not emit this as a state machine
assignment vs. a hoisted default.)
Fix
The coroutine-to-state-machine transformation in the Rust backend
(RustTranslator) should emit explicit variable initialization
assignments at loop entry points in the state machine, rather than
relying solely on struct field defaults. Each for (var x = init; ...)
should produce a case like:
loop_entry_case => {
x = init; // ← missing today
caseIndex = condition_check_case;
}
The relevant code is in the Temper compiler's Rust backend translation
pipeline, likely in the state machine generation within RustTranslator.kt
and related files under be-rust/src/commonMain/kotlin/lang/temper/be/rust/.
The Temper Rust backend does not re-initialize
forloop variables whenan enclosing loop iterates, if the
forbody contains anawait.The inner loop runs correctly on the first outer iteration, then silently
never executes again.
I discovered this when adding #377 for fun to make multiplayer snake after implementing
sleepandreadlineIO ops in #376It appeared in the websocket game loop ticking through the state machine for the game, but I have a pretty minimal example that can express this built off
mainhere.Affects: Temper
mainbranch (tested at commitcb8c3d5)Backend: Rust only. JS is correct.
Requires: No external dependencies. Pure Temper core primitives.
Minimal Reproduction
Build and run:
JS output:
total loop body runs: 9(3 outer × 3 inner = correct)Rust output:
total loop body runs: 3(inner loop runs only onthe first outer iteration)
Root Cause
The Rust backend compiles async blocks into state machines via the
TranslateToRegularFunctioncoroutine strategy (inbe-rust/src/commonMain/kotlin/lang/temper/be/rust/RustSupportNetwork.kt,coroutineStrategy = CoroutineStrategy.TranslateToRegularFunction).Each
awaitcreates a yield point that splits the function into numberedcases in a
match caseIndex { 0 => ..., 1 => ..., ... }state machine.Local variables that cross yield points are hoisted to
Arc<RwLock<T>>fields on the generator's closure struct.For a
for (var i = 0; ...)loop, the compiler:Creates
ias a struct field with initial value 0:Generates a state machine case for the loop condition check:
Generates a case for the increment:
The initialization
i = 0fromvar i = 0is only present in step 1(the struct field default). There is no
i = 0assignment in any statemachine case. When the enclosing
whileloop iterates and re-enters theforloop, the state machine jumps to case N (the condition check). Atthis point
istill holds its value from the previous outer iteration(e.g., 3). The condition
3 < 3is false, so the inner loop body isskipped entirely.
The JS backend does not have this bug because it uses
CoroutineStrategy.TranslateToGeneratorwhich compiles async blocks toJavaScript generator functions. JavaScript generators re-execute the
full function body on each
next()call up to the nextyield, sovar i = 0runs as a normal assignment each time the loop entry isreached.
Scope
The bug affects any code where:
for (var x = init; ...)loop is nested inside another loopforbody contains anawaitexpressionIt does not depend on:
awaitis wrapped indo { ... } orelse void@connectedfunctions are usedThe same issue likely affects any
vardeclaration with an initializerinside a loop body that also contains an
await. The initializer runsonce at generator creation and is never re-executed.
Workarounds
Use
for (let x of list)— compiles tolist_for_each()with acallback, avoiding the state machine entirely. Cannot
awaitinsidethe callback, but works for synchronous operations.
Avoid
awaitinsideforloops — keep the loop body synchronousand
awaitafter the loop completes.Reset the variable explicitly before the loop:
(Untested — the compiler may or may not emit this as a state machine
assignment vs. a hoisted default.)
Fix
The coroutine-to-state-machine transformation in the Rust backend
(
RustTranslator) should emit explicit variable initializationassignments at loop entry points in the state machine, rather than
relying solely on struct field defaults. Each
for (var x = init; ...)should produce a case like:
The relevant code is in the Temper compiler's Rust backend translation
pipeline, likely in the state machine generation within
RustTranslator.ktand related files under
be-rust/src/commonMain/kotlin/lang/temper/be/rust/.