RETURN should unwind nested scopes to the nearest call frame, as the C64 reference does #62

Open
opened 2026-08-06 11:34:16 -04:00 by andrew · 0 comments
Owner

Found during the PR #61 (generators) review. Filed by Ishikawa via Andrew's account; discussed and agreed with Andrew there.

The problem

RETURN in this interpreter only works when standing directly in the environment that carries gosubReturnLine (or, since #61, a GEN's own frame). From inside any nested scope it errors:

10 GOSUB 100
20 END
100 FOR I# = 1 TO 3
110   RETURN
120 NEXT I#
? 110 : RUNTIME ERROR RETURN outside the context of GOSUB, DEF, or GEN

The C64 reference does not behave this way. MS-family BASICs keep FOR and GOSUB entries interleaved on one runtime stack, and RETURN discards FOR entries above the topmost GOSUB entry. Our behavior is the deviation. The same restriction limits #61's RETURN-in-GEN (it must not stand inside a FOR/DO the body opened) and is called out in docs/04-control-flow.md and TODO.md §1.10.

Semantics (settled during review — do not relitigate, but do record in TODO.md §1)

  1. RETURN walks the parent chain from the current environment to the nearest frame with gosubReturnLine != 0 || isGenerator. It can never pass through such a frame — the walk stops at the first one, period. This invariant is load-bearing (see "Why the driving loops survive" below).
  2. Every scope between the RETURN and that frame, and the frame itself, is released — including each released scope's suspended forGeneratorEnv (see MAINTENANCE.md "Abandoned generators"). akbasic_runtime_unwind_to_environment() from #61 is the model; this change likely generalizes it or adds a sibling that stops-at-frame instead of stops-at-target.
  3. If the nearest frame is a GEN invocation: behave exactly as akbasic_cmd_end_gen()'s real-exhaustion path (release, pop to loop env, clear forGeneratorEnv). RETURN expr into a GEN frame stays an error.
  4. Otherwise: behave exactly as today's akbasic_cmd_return() tail — park the value (cloned) on the target frame's parent returnValue, set that parent's nextline = target->gosubReturnLine, release everything through the target.
  5. Interrupt re-arm: the existing obj->environment == obj->handlerenv identity check must be applied to the target frame, not the environment RETURN was standing in.
  6. No frame anywhere up the chain → the existing error, and nothing may have been released: walk first, check, then release.

Implementation steps

  1. src/runtime_commands.c akbasic_cmd_return(): after the is-waiting check, walk the parent chain per rule 1. Keep the current fast path (already standing in the frame) intact for readability if that reads better.
  2. Add the release walk (rules 2/6). Reuse akbasic_runtime_release_environment() + akbasic_runtime_release_generator(); look at akbasic_runtime_unwind_to_environment() in src/runtime.c first — the shape is nearly identical and one shared primitive is the goal, not a fourth hand-rolled loop.
  3. Fold #61's GEN branch into the same walk (rule 3) — it becomes "nearest frame is a generator" instead of a separate up-front check.
  4. Move the handlerenv check onto the target (rule 5).
  5. Update docs/04-control-flow.md (the GOSUB section and the generators section both state the current restriction — both must change) and docs/11-verb-reference.md's RETURN row. Remove the now-wrong "must stand in the GEN's own scope" sentences, and fix tests/language/flowcontrol/generators_return.bas's leading REM which states the same.
  6. Update TODO.md §1.10's bullet about the restriction (it says "filed separately" — point it here, mark done when done).

Why the driving loops survive (verified during review — trust but re-verify with the tests below)

  • akbasic_runtime_pump_generator() spins while obj->environment != loopenv; its anchor loopenv sits strictly above the GEN frame where any interior RETURN's walk stops. A generator-frame RETURN lands on loopenv exactly as END GEN does.
  • akbasic_runtime_call_function()'s loop anchors on targetenv (the caller), strictly above the DEF's callenv where the walk stops; the value parks on targetenv->returnValue, exactly where the loop reads it.
  • A stray NEXT still cannot pop a call frame (forNextVariable guard in cmd_next), so frame integrity is preserved from that side too.

Test matrix (add to tests/generators.c and a new tests/return_unwind.c; golden pairs under tests/language/flowcontrol/)

  • RETURN from inside FOR / DO / FOR EACH / DO EACH × from inside GOSUB / ON..GOSUB / multi-line DEF / GEN. The EACH cases must have a generator suspended at RETURN time, and pool-exhaustion loops (>12 iterations, AKBASIC_MAX_ENVIRONMENTS) proving release.
  • RETURN with a value through nested FOR into a DEF → value arrives in the caller's expression.
  • RETURN inside a nested loop inside a TRAP/COLLISION handler → interrupts re-arm (handler can fire again).
  • Same-line resumption: GOSUB with trailing statements on its line, subroutine RETURNs from inside a FOR → trailing statements still run (per-environment token cursors make this work; prove it stays true).
  • Top-level RETURN inside a FOR with no frame anywhere → error, and a subsequent GOSUB still works (nothing was released).
  • The reference behavior itself: the C64 snippet from "The problem" above, as a golden pair.

Architecture docs: thorough treatment of the interpreter's stack — REQUIRED, not optional

docs/14-architecture.md gained an environments-and-generators section in #61, but nothing documents the machine as a whole. This issue must leave behind a full treatment covering, at minimum:

  1. The parent chain is the runtime stack. There is no other stack: akbasic_Environment.parent links a fixed pool (AKBASIC_MAX_ENVIRONMENTS = 12) into the active chain; obj->environment is the top.
  2. Two kinds of link: scopes vs call frames. FOR/DO/FOR EACH/DO EACH push scopes; GOSUB/ON..GOSUB/multi-line DEF calls/interrupt handlers/GEN invocations push frames (gosubReturnLine != 0 or isGenerator). Enumerate every push site by file/function. RETURN targets frames; loop verbs pop scopes; this distinction is what the RETURN walk runs on.
  3. Per-environment token cursors and mid-line resumption. Each environment owns its scanned token array and cursor (environment.htokens/nexttoken/curtoken); a parent suspended mid-line resumes exactly where it stopped when its child pops. Walk through 10 GOSUB 100 : PRINT "TAIL" step by step — this is the least-obvious mechanism in the interpreter and it took tracing to rediscover during review.
  4. nextline/lineno per environment, advanced before dispatch (process_line_run()), and how verbs redirect them (GOTO, EMIT, RETURN).
  5. Waits (wait_for_command) as per-environment skip state, and how EXIT rides them.
  6. Teardown: prev_environment vs release_generator vs unwind_to_environment (and this issue's RETURN walk); the abandoned-generators invariant, cross-referencing MAINTENANCE.md.
  7. Off-chain children: forGeneratorEnv as the one place an environment is referenced from outside the parent chain.

Every fenced BASIC example in it runs under the docs checker, so write them as real programs.

Found during the PR #61 (generators) review. Filed by Ishikawa via Andrew's account; discussed and agreed with Andrew there. ## The problem `RETURN` in this interpreter only works when standing directly in the environment that carries `gosubReturnLine` (or, since #61, a GEN's own frame). From inside any nested scope it errors: ```basic 10 GOSUB 100 20 END 100 FOR I# = 1 TO 3 110 RETURN 120 NEXT I# ``` ``` ? 110 : RUNTIME ERROR RETURN outside the context of GOSUB, DEF, or GEN ``` **The C64 reference does not behave this way.** MS-family BASICs keep FOR and GOSUB entries interleaved on one runtime stack, and RETURN discards FOR entries above the topmost GOSUB entry. Our behavior is the deviation. The same restriction limits #61's `RETURN`-in-`GEN` (it must not stand inside a `FOR`/`DO` the body opened) and is called out in `docs/04-control-flow.md` and TODO.md §1.10. ## Semantics (settled during review — do not relitigate, but do record in TODO.md §1) 1. `RETURN` walks the parent chain from the current environment to the **nearest** frame with `gosubReturnLine != 0 || isGenerator`. It can never pass through such a frame — the walk stops at the first one, period. This invariant is load-bearing (see "Why the driving loops survive" below). 2. Every scope between the RETURN and that frame, and the frame itself, is released — **including each released scope's suspended `forGeneratorEnv`** (see MAINTENANCE.md "Abandoned generators"). `akbasic_runtime_unwind_to_environment()` from #61 is the model; this change likely generalizes it or adds a sibling that stops-at-frame instead of stops-at-target. 3. If the nearest frame is a GEN invocation: behave exactly as `akbasic_cmd_end_gen()`'s real-exhaustion path (release, pop to loop env, clear `forGeneratorEnv`). `RETURN expr` into a GEN frame stays an error. 4. Otherwise: behave exactly as today's `akbasic_cmd_return()` tail — park the value (cloned) on the **target frame's parent** `returnValue`, set that parent's `nextline = target->gosubReturnLine`, release everything through the target. 5. Interrupt re-arm: the existing `obj->environment == obj->handlerenv` identity check must be applied to the **target frame**, not the environment RETURN was standing in. 6. No frame anywhere up the chain → the existing error, and **nothing may have been released**: walk first, check, then release. ## Implementation steps 1. `src/runtime_commands.c` `akbasic_cmd_return()`: after the is-waiting check, walk the parent chain per rule 1. Keep the current fast path (already standing in the frame) intact for readability if that reads better. 2. Add the release walk (rules 2/6). Reuse `akbasic_runtime_release_environment()` + `akbasic_runtime_release_generator()`; look at `akbasic_runtime_unwind_to_environment()` in `src/runtime.c` first — the shape is nearly identical and one shared primitive is the goal, not a fourth hand-rolled loop. 3. Fold #61's GEN branch into the same walk (rule 3) — it becomes "nearest frame is a generator" instead of a separate up-front check. 4. Move the `handlerenv` check onto the target (rule 5). 5. Update `docs/04-control-flow.md` (the GOSUB section and the generators section both state the current restriction — both must change) and `docs/11-verb-reference.md`'s RETURN row. Remove the now-wrong "must stand in the GEN's own scope" sentences, and fix `tests/language/flowcontrol/generators_return.bas`'s leading REM which states the same. 6. Update TODO.md §1.10's bullet about the restriction (it says "filed separately" — point it here, mark done when done). ## Why the driving loops survive (verified during review — trust but re-verify with the tests below) - `akbasic_runtime_pump_generator()` spins `while obj->environment != loopenv`; its anchor `loopenv` sits strictly *above* the GEN frame where any interior RETURN's walk stops. A generator-frame RETURN lands on `loopenv` exactly as `END GEN` does. - `akbasic_runtime_call_function()`'s loop anchors on `targetenv` (the caller), strictly above the DEF's `callenv` where the walk stops; the value parks on `targetenv->returnValue`, exactly where the loop reads it. - A stray `NEXT` still cannot pop a call frame (`forNextVariable` guard in `cmd_next`), so frame integrity is preserved from that side too. ## Test matrix (add to `tests/generators.c` and a new `tests/return_unwind.c`; golden pairs under `tests/language/flowcontrol/`) - RETURN from inside FOR / DO / FOR EACH / DO EACH × from inside GOSUB / ON..GOSUB / multi-line DEF / GEN. The EACH cases must have a generator *suspended* at RETURN time, and pool-exhaustion loops (>12 iterations, `AKBASIC_MAX_ENVIRONMENTS`) proving release. - RETURN with a value through nested FOR into a DEF → value arrives in the caller's expression. - RETURN inside a nested loop inside a TRAP/COLLISION handler → interrupts re-arm (handler can fire again). - Same-line resumption: `GOSUB` with trailing statements on its line, subroutine RETURNs from inside a FOR → trailing statements still run (per-environment token cursors make this work; prove it stays true). - Top-level RETURN inside a FOR with no frame anywhere → error, and a subsequent GOSUB still works (nothing was released). - The reference behavior itself: the C64 snippet from "The problem" above, as a golden pair. ## Architecture docs: thorough treatment of the interpreter's stack — REQUIRED, not optional `docs/14-architecture.md` gained an environments-and-generators section in #61, but nothing documents the machine as a whole. This issue must leave behind a full treatment covering, at minimum: 1. **The parent chain is the runtime stack.** There is no other stack: `akbasic_Environment.parent` links a fixed pool (`AKBASIC_MAX_ENVIRONMENTS` = 12) into the active chain; `obj->environment` is the top. 2. **Two kinds of link: scopes vs call frames.** FOR/DO/FOR EACH/DO EACH push *scopes*; GOSUB/ON..GOSUB/multi-line DEF calls/interrupt handlers/GEN invocations push *frames* (`gosubReturnLine != 0` or `isGenerator`). Enumerate every push site by file/function. RETURN targets frames; loop verbs pop scopes; this distinction is what the RETURN walk runs on. 3. **Per-environment token cursors and mid-line resumption.** Each environment owns its scanned token array and cursor (`environment.h` — `tokens`/`nexttoken`/`curtoken`); a parent suspended mid-line resumes exactly where it stopped when its child pops. Walk through `10 GOSUB 100 : PRINT "TAIL"` step by step — this is the least-obvious mechanism in the interpreter and it took tracing to rediscover during review. 4. **`nextline`/`lineno` per environment**, advanced before dispatch (`process_line_run()`), and how verbs redirect them (GOTO, EMIT, RETURN). 5. **Waits** (`wait_for_command`) as per-environment skip state, and how EXIT rides them. 6. **Teardown**: `prev_environment` vs `release_generator` vs `unwind_to_environment` (and this issue's RETURN walk); the abandoned-generators invariant, cross-referencing MAINTENANCE.md. 7. Off-chain children: `forGeneratorEnv` as the one place an environment is referenced from outside the parent chain. Every fenced BASIC example in it runs under the docs checker, so write them as real programs.
Sign in to join this conversation.