1 Commits

Author SHA1 Message Date
Ishikawa
2cb68665d0 Fix generator teardown leaks, add RETURN-in-GEN and LOOP conditions on DO EACH
All checks were successful
akbasic CI Build / cmake_build (push) Successful in 3m26s
akbasic CI Build / coverage (push) Successful in 4m0s
akbasic CI Build / sanitizers (push) Successful in 6m33s
akbasic CI Build / akgl_build (push) Successful in 9m32s
akbasic CI Build / mutation_test (push) Successful in 18m47s
Review findings and follow-ups from PR #61 review:

- runtime_generator.c: akbasic_runtime_release_generator() now releases the
  forGeneratorEnv of every scope it walks through. Abandoning a generator
  that was itself suspended inside a FOR EACH over another generator
  stranded the inner generator's pool slot; a loop doing so exhausted the
  twelve-slot pool and died far from the cause.
- runtime.c/runtime.h: new akbasic_runtime_unwind_to_environment(), the
  shared teardown for the error unwinds in pump_generator() and
  call_function() -- both previously bare prev_environment() loops with the
  same suspended-generator blindness.
- runtime_commands.c: bare RETURN standing in a GEN's own frame ends the
  generator exactly as END GEN does -- a GEN is a function at heart. RETURN
  with a value there is refused (values leave a GEN only through EMIT). The
  no-frame error message now says "GOSUB, DEF, or GEN".
- runtime_structure.c: LOOP WHILE/UNTIL composes with DO EACH -- checked
  after each trip with the loop variable still holding that trip's value; a
  condition that stops the loop abandons the generator exactly as EXIT
  does. Previously the condition was silently ignored, while the verb
  reference documented it as working.
- parser_commands.c: trailing tokens after the generator call on a FOR
  EACH/DO EACH line are refused at parse. Previously they sat unparsed and
  blew up only after the loop completed, when the parent scope resumed the
  line mid-statement -- an error at the loop's end pointing at its start.
- tests/generators.c: pool-exhaustion tests for the nested-abandonment and
  LOOP-condition paths, RETURN semantics tests, and a direct test of the
  unwind primitive. Three new golden pairs cover RETURN, LOOP conditions
  and the misplaced-condition parse error.
- docs: RETURN and LOOP-condition semantics in 04-control-flow.md and
  11-verb-reference.md; corrected the self-recursion analogy (functions
  are re-entrant here). TODO.md 1.10 records the generator design
  decisions the code comments were already citing, plus the zero-arg
  parameter-list limitation. MAINTENANCE.md gains the abandoned-generators
  invariant those comments also cited.

Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 11:29:17 -04:00
17 changed files with 426 additions and 13 deletions

View File

@@ -698,6 +698,26 @@ as a commit co-author, and this repository follows the same rule.
Each dependency carries its own `AGENTS.md` with authoritative per-repo rules. Read the Each dependency carries its own `AGENTS.md` with authoritative per-repo rules. Read the
relevant one before editing a submodule. relevant one before editing a submodule.
### Abandoned generators
The one invariant generators (`GEN`/`EMIT`, `FOR EACH`/`DO EACH`) add to the environment
pool: **a suspended generator hangs off its loop scope's `forGeneratorEnv` as a *child*,
off the parent chain, so no walk up `->parent` ever finds it.** Any path that discards a
loop scope — `EXIT`, a mismatched `NEXT`, a `LOOP` condition saying stop, an error unwind
through `akbasic_runtime_pump_generator()` or `akbasic_runtime_call_function()` — must
release that generator too, or its pool slot is stranded until the next `RUN`; with
twelve slots, a loop that abandons a dozen of them kills the program with "Environment
pool exhausted" far from the cause.
Two functions own the invariant, and every discard path goes through one of them:
`akbasic_runtime_release_generator()` (walks a detached generator chain up through its
call frame, recursing into any `forGeneratorEnv` it passes) and
`akbasic_runtime_unwind_to_environment()` (pops the *active* chain down to a target,
releasing each popped scope's suspended generator on the way). If you add a new path
that pops or discards environments, use one of these — a bare
`akbasic_runtime_prev_environment()` loop reintroduces the leak, and
`tests/generators.c` holds the pool-exhaustion tests that will say so.
--- ---
## Editing the documentation ## Editing the documentation

30
TODO.md
View File

@@ -366,6 +366,36 @@ One caveat survives the upgrade unchanged: `aksl_strhash_djb2` still sign-extend
high-bit byte hashes differently from the `unsigned char` answer. BASIC identifiers are 7-bit high-bit byte hashes differently from the `unsigned char` answer. BASIC identifiers are 7-bit
ASCII so the symbol tables cannot reach it — see §1.3, which is still accurate. ASCII so the symbol tables cannot reach it — see §1.3, which is still accurate.
### 1.10 Generators share the `DEF` namespace, and the rest of their v1 semantics
Decided with issue #57 and its review. `GEN` and `DEF` live in the same functions table —
one lookup, one "unknown function" error path, a name cannot be both. What follows from
that and from the review of PR #61, all settled:
- A `GEN` called like a function (`X# = COUNTUP(3)`) is refused at the call, before
anything is pushed — `akbasic_FunctionDef.isGenerator` exists for exactly this check.
- Bare `RETURN` standing in a `GEN`'s own frame ends the generator exactly as `END GEN`
does; `RETURN expr` there is an error, because values leave a `GEN` only through `EMIT`.
Both inherit the interpreter-wide restriction that `RETURN` does not unwind nested
`FOR`/`DO` scopes — lifting that everywhere (the C64 reference *does* unwind) is filed
separately.
- `DO EACH ... LOOP WHILE c | UNTIL c` composes: the condition is checked after each trip,
with the loop variable still holding that trip's value, and stopping abandons the
generator exactly as `EXIT` does. A condition on the `DO EACH` line itself is a parse
error.
- Self-recursion — a `GEN` reached again down its own parent chain — is refused; sibling
and nested invocations of the same `GEN` are each a fresh pool environment and are fine.
- Every path that discards a `FOR EACH`/`DO EACH` scope must release the generator
suspended off it; see MAINTENANCE.md's "Abandoned generators" note for the invariant
and `akbasic_runtime_unwind_to_environment()` for the one primitive that enforces it.
One known limitation, pre-existing and shared with `DEF`: `parse_def_parameters()` does
not accept an empty parameter list, so `GEN NAME()` cannot be written — every generator
takes at least one parameter whether it wants one or not. Location:
`src/parser_commands.c`, `parse_def_parameters()`. Consequence: pointless parameters in
programs. Blast radius: cosmetic, both `DEF` and `GEN` headers. Closure: teach the shared
helper to accept `()`, one test each for `DEF` and `GEN`; filed as its own issue.
--- ---
## 2. What exists — **the core port is complete and green** ## 2. What exists — **the core port is complete and green**

View File

@@ -197,8 +197,9 @@ A `GEN`'s body is ordinary BASIC: it may hold its own `FOR`, `DO`, `IF` or `GOSU
around the `EMIT`s, and even invoke another `GEN` with its own `FOR EACH`/`DO EACH` -- around the `EMIT`s, and even invoke another `GEN` with its own `FOR EACH`/`DO EACH` --
the same generator invoked with different arguments, nested or side by side, is not the same generator invoked with different arguments, nested or side by side, is not
recursion. A `GEN` invoking *itself* from within its own currently-running body is recursion. A `GEN` invoking *itself* from within its own currently-running body is
refused, the same way it would be an error to call a function that has not returned yet refused: unlike a function call, which runs to completion and returns, the outer
by name from inside its own body without meaning to recurse. invocation is suspended mid-body waiting on the same loop, and there is no answer to
"which EMIT feeds which loop" that is not a surprise.
`EXIT` leaves a `FOR EACH`/`DO EACH` loop early, exactly as it does a plain `FOR` or `EXIT` leaves a `FOR EACH`/`DO EACH` loop early, exactly as it does a plain `FOR` or
`DO`, and the generator it was consuming stops there -- nothing forces the rest of it to `DO`, and the generator it was consuming stops there -- nothing forces the rest of it to
@@ -224,6 +225,59 @@ run just because the loop started it:
STOPPED STOPPED
``` ```
`RETURN` ends a `GEN` from the inside, exactly as it ends a multi-line `DEF` or a
`GOSUB`: the generator is done, the loop consuming it ends, and the program carries on
after the loop. What a generator's `RETURN` cannot do is carry a value -- values leave
a `GEN` one at a time, through `EMIT`, and `RETURN 99` inside one is an error. Like a
`GOSUB`'s or a `DEF`'s, the `RETURN` must stand in the `GEN`'s own scope: from inside
a `FOR` or `DO` the body opened, it is an error, though `IF ... THEN RETURN` is fine
because `IF` opens no scope of its own.
```basic
10 GEN FIRSTFEW(N#)
20 EMIT 1
30 IF N# < 2 THEN RETURN
40 EMIT 2
50 IF N# < 3 THEN RETURN
60 EMIT 3
70 END GEN
80 FOR EACH V# IN FIRSTFEW(2)
90 PRINT V#
100 NEXT V#
110 PRINT "DONE"
```
```output
1
2
DONE
```
A `DO EACH`'s `LOOP` may carry a `WHILE` or `UNTIL`, and the two compose: the condition
is checked after each trip through the body, with the loop variable still holding that
trip's value, and a condition that says stop abandons the rest of the generator exactly
as `EXIT` does. The condition belongs on the `LOOP` -- putting it on the `DO` line is
an error, since the `DO` line already says what the loop consumes.
```basic
10 GEN COUNTUP(N#)
20 FOR I# = 1 TO N#
30 EMIT I#
40 NEXT I#
50 END GEN
60 DO EACH V# IN COUNTUP(10)
70 PRINT V#
80 LOOP UNTIL V# = 3
90 PRINT "STOPPED"
```
```output
1
2
3
STOPPED
```
A `GEN` shares its namespace with `DEF` -- the same name cannot be both -- but it is not A `GEN` shares its namespace with `DEF` -- the same name cannot be both -- but it is not
a function and cannot be called like one: `X# = COUNTUP(3)` is refused, because nothing a function and cannot be called like one: `X# = COUNTUP(3)` is refused, because nothing
about an ordinary call means "resume where the last `EMIT` left off." `FOR EACH`/`DO about an ordinary call means "resume where the last `EMIT` left off." `FOR EACH`/`DO

View File

@@ -37,7 +37,7 @@ for the reasoning in each case.
| `DIM``AS` | `DIM S@ AS T`, `DIM P@ AS PTR TO T` | Make a structure, or a strict pointer to one. See Chapter 16. | | `DIM``AS` | `DIM S@ AS T`, `DIM P@ AS PTR TO T` | Make a structure, or a strict pointer to one. See Chapter 16. |
| `DIRECTORY` | `DIRECTORY` | **Refused.** Not written yet; the standard-library wrapper it waited on has landed. | | `DIRECTORY` | `DIRECTORY` | **Refused.** Not written yet; the standard-library wrapper it waited on has landed. |
| `DLOAD` | `DLOAD "name"` | Load a program from a file. | | `DLOAD` | `DLOAD "name"` | Load a program from a file. |
| `DO` | `DO [WHILE c | UNTIL c]`, `DO EACH V IN gen(args)` | Start a loop. The condition may be here, on the `LOOP`, or neither. `EACH` consumes a `GEN` instead; see Chapter 4. | | `DO` | `DO [WHILE c | UNTIL c]`, `DO EACH V IN gen(args)` | Start a loop. The condition may be here, on the `LOOP`, or neither. `EACH` consumes a `GEN` instead, and takes its condition only on the `LOOP`; see Chapter 4. |
| `DOPEN` | `DOPEN n, "name" [,W]` | Open a file on channel `n`. `W` opens it for writing. | | `DOPEN` | `DOPEN n, "name" [,W]` | Open a file on channel `n`. `W` opens it for writing. |
| `DRAW` | `DRAW src, x, y [TO x, y ...]` | Plot a point or draw a polyline. | | `DRAW` | `DRAW src, x, y [TO x, y ...]` | Plot a point or draw a polyline. |
| `DSAVE` | `DSAVE "name"` | Save the program to a file. | | `DSAVE` | `DSAVE "name"` | Save the program to a file. |
@@ -90,7 +90,7 @@ for the reasoning in each case.
| `RENUMBER` | `RENUMBER [start [,step [,from]]]` | Renumber lines, rewriting every branch to match. | | `RENUMBER` | `RENUMBER [start [,step [,from]]]` | Renumber lines, rewriting every branch to match. |
| `RESTORE` | `RESTORE [line]` | Reset the `READ` cursor, optionally to a line. | | `RESTORE` | `RESTORE [line]` | Reset the `READ` cursor, optionally to a line. |
| `RESUME` | `RESUME [NEXT | line]` | Return from a `TRAP` handler. | | `RESUME` | `RESUME [NEXT | line]` | Return from a `TRAP` handler. |
| `RETURN` | `RETURN [expr]` | Return from a `GOSUB` or a multi-line `DEF`. | | `RETURN` | `RETURN [expr]` | Return from a `GOSUB` or a multi-line `DEF`. Inside a `GEN`, a bare `RETURN` ends the generator early; `RETURN expr` there is an error. |
| `RUN` | `RUN [line]` | Run the program, optionally from a line. | | `RUN` | `RUN [line]` | Run the program, optionally from a line. |
| `SAVE` | `SAVE "name"` | The other name for `DSAVE`. | | `SAVE` | `SAVE "name"` | The other name for `DSAVE`. |
| `SCALE` | `SCALE on [,xmax, ymax]` | Turn user coordinates on or off. | | `SCALE` | `SCALE on [,xmax, ymax]` | Turn user coordinates on or off. |

View File

@@ -635,6 +635,28 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_detach_environment(akbasic_Ru
*/ */
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_release_environment(akbasic_Runtime *obj, akbasic_Environment *env); akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_release_environment(akbasic_Runtime *obj, akbasic_Environment *env);
/**
* @brief Pop and release every scope from the active one up to @p target.
*
* The shared teardown for every path that abandons part of the environment
* chain at once instead of popping it a verb at a time: the error unwinds in
* akbasic_runtime_pump_generator() and akbasic_runtime_call_function(). Each
* popped scope's suspended generator (`forGeneratorEnv`), if it still holds
* one, is released through akbasic_runtime_release_generator() -- a suspended
* generator is a *child* of its loop scope, so no walk up the parent chain
* would ever reach it.
*
* Stops without error at the root if @p target is not on the chain: callers
* are already cleaning up after a failure, and releasing everything is the
* least-wrong answer to a target that has gone missing.
*
* @param obj Object to initialize, inspect, or modify.
* @param target The scope to stop at; it is left active and untouched.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When @p target is NULL.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_unwind_to_environment(akbasic_Runtime *obj, akbasic_Environment *target);
/** /**
* @brief Push a GEN's environment, bind its parameters and run it to its first EMIT. * @brief Push a GEN's environment, bind its parameters and run it to its first EMIT.
* *

View File

@@ -337,6 +337,19 @@ akerr_ErrorContext *akbasic_parse_do(akbasic_Parser *parser, akbasic_ASTLeaf **d
PASS(errctx, parse_each_clause(parser, &var, &callexpr)); PASS(errctx, parse_each_clause(parser, &var, &callexpr));
/*
* Same guard as akbasic_parse_for()'s EACH branch, with the likely
* mistake named: a condition belongs on the LOOP, where it is
* checked against each emitted value, not here on the DO.
*/
peeked = akbasic_parser_peek(parser);
FAIL_NONZERO_RETURN(errctx,
(peeked != NULL &&
peeked->tokentype != AKBASIC_TOK_UNDEFINED &&
peeked->tokentype != AKBASIC_TOK_COLON),
AKBASIC_ERR_SYNTAX,
"DO EACH takes its WHILE/UNTIL on the LOOP, and nothing else here");
newenv->isDoLoop = true; newenv->isDoLoop = true;
newenv->isEachLoop = true; newenv->isEachLoop = true;
newenv->loopFirstLine = firstline; newenv->loopFirstLine = firstline;
@@ -1098,6 +1111,21 @@ akerr_ErrorContext *akbasic_parse_for(akbasic_Parser *parser, akbasic_ASTLeaf **
PASS(errctx, parse_each_clause(parser, &var, &callexpr)); PASS(errctx, parse_each_clause(parser, &var, &callexpr));
/*
* Nothing may follow the generator call but another statement.
* Without this, a stray clause sits unparsed on the line and only
* blows up after the whole loop has run, when the parent scope
* resumes the line mid-statement -- an error at the loop's end
* pointing at its beginning.
*/
peeked = akbasic_parser_peek(parser);
FAIL_NONZERO_RETURN(errctx,
(peeked != NULL &&
peeked->tokentype != AKBASIC_TOK_UNDEFINED &&
peeked->tokentype != AKBASIC_TOK_COLON),
AKBASIC_ERR_SYNTAX,
"FOR EACH takes nothing after the generator call");
newenv->isEachLoop = true; newenv->isEachLoop = true;
newenv->loopFirstLine = firstline; newenv->loopFirstLine = firstline;
/* /*

View File

@@ -208,6 +208,38 @@ akerr_ErrorContext *akbasic_runtime_prev_environment(akbasic_Runtime *obj)
SUCCEED_RETURN(errctx); SUCCEED_RETURN(errctx);
} }
akerr_ErrorContext *akbasic_runtime_unwind_to_environment(akbasic_Runtime *obj, akbasic_Environment *target)
{
PREPARE_ERROR(errctx);
akbasic_Environment *popped = NULL;
FAIL_ZERO_RETURN(errctx, (obj != NULL && target != NULL), AKERR_NULLPOINTER,
"NULL argument in unwind_to_environment");
/*
* Stops early at the root rather than failing on it: every caller is an
* error-unwind path, where "release whatever there is" beats raising a
* second failure on top of the one being cleaned up after.
*/
while ( obj->environment != target && obj->environment->parent != NULL ) {
popped = obj->environment;
obj->environment = popped->parent;
/*
* An EACH loop scope on its way out takes its suspended generator with
* it -- the generator is a *child* of the scope, off the parent chain,
* and this walk is the only thing that will ever see it again. Guarded
* on `used` because a generator that was being pumped when the failure
* hit is *on* the chain being unwound, already released by the time
* the walk reaches the loop scope that references it.
*/
if ( popped->forGeneratorEnv != NULL && popped->forGeneratorEnv->used ) {
PASS(errctx, akbasic_runtime_release_generator(obj, popped->forGeneratorEnv));
}
popped->forGeneratorEnv = NULL;
PASS(errctx, akbasic_runtime_release_environment(obj, popped));
}
SUCCEED_RETURN(errctx);
}
/* ------------------------------------------------------------- lifecycle -- */ /* ------------------------------------------------------------- lifecycle -- */
akerr_ErrorContext *akbasic_runtime_zero(akbasic_Runtime *obj) akerr_ErrorContext *akbasic_runtime_zero(akbasic_Runtime *obj)
@@ -1166,10 +1198,11 @@ akerr_ErrorContext *akbasic_runtime_call_function(akbasic_Runtime *obj, const ch
* Give them back, or a host absorbing script errors drains the * Give them back, or a host absorbing script errors drains the
* twelve-slot environment pool after twelve dead calls and every * twelve-slot environment pool after twelve dead calls and every
* call after that fails for a reason nobody can see in the script. * call after that fails for a reason nobody can see in the script.
* The unwind, not a bare prev_environment() loop, because a body that
* died inside a FOR EACH leaves a suspended generator hanging off the
* loop scope, and only the unwind knows to take it down too.
*/ */
while ( obj->environment != targetenv && obj->environment->parent != NULL ) { IGNORE(akbasic_runtime_unwind_to_environment(obj, targetenv));
IGNORE(akbasic_runtime_prev_environment(obj));
}
} PROCESS(errctx) { } PROCESS(errctx) {
} FINISH(errctx, true); } FINISH(errctx, true);
PASS(errctx, akbasic_environment_new_value(targetenv, &out)); PASS(errctx, akbasic_environment_new_value(targetenv, &out));

View File

@@ -161,8 +161,22 @@ akerr_ErrorContext *akbasic_cmd_return(akbasic_Runtime *obj, akbasic_ASTLeaf *ex
SUCCEED_TRUE(obj, dest); SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx); SUCCEED_RETURN(errctx);
} }
/*
* A GEN is a function at heart, and RETURN ends it the way it ends a DEF
* or a GOSUB: early, cleanly, from its own frame. What a generator's
* RETURN cannot do is carry a value -- values leave a GEN one at a time,
* through EMIT, and there is no caller waiting on a return slot.
*/
if ( obj->environment->isGenerator ) {
FAIL_NONZERO_RETURN(errctx, (expr != NULL && expr->right != NULL), AKBASIC_ERR_STATE,
"A GEN yields values through EMIT; RETURN here takes none");
PASS(errctx, akbasic_runtime_prev_environment(obj));
obj->environment->forGeneratorEnv = NULL;
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
FAIL_ZERO_RETURN(errctx, (obj->environment->gosubReturnLine != 0), AKBASIC_ERR_STATE, FAIL_ZERO_RETURN(errctx, (obj->environment->gosubReturnLine != 0), AKBASIC_ERR_STATE,
"RETURN outside the context of GOSUB"); "RETURN outside the context of GOSUB, DEF, or GEN");
if ( expr != NULL && expr->right != NULL ) { if ( expr != NULL && expr->right != NULL ) {
PASS(errctx, akbasic_runtime_evaluate(obj, expr->right, &result)); PASS(errctx, akbasic_runtime_evaluate(obj, expr->right, &result));

View File

@@ -68,9 +68,7 @@ akerr_ErrorContext *akbasic_runtime_pump_generator(akbasic_Runtime *obj, akbasic
* among the scopes just released. * among the scopes just released.
*/ */
if ( obj->environment != loopenv ) { if ( obj->environment != loopenv ) {
while ( obj->environment != loopenv && obj->environment->parent != NULL ) { IGNORE(akbasic_runtime_unwind_to_environment(obj, loopenv));
IGNORE(akbasic_runtime_prev_environment(obj));
}
loopenv->forGeneratorEnv = NULL; loopenv->forGeneratorEnv = NULL;
} }
} PROCESS(errctx) { } PROCESS(errctx) {
@@ -103,6 +101,18 @@ akerr_ErrorContext *akbasic_runtime_release_generator(akbasic_Runtime *obj, akba
while ( walk != NULL ) { while ( walk != NULL ) {
isgen = walk->isGenerator; isgen = walk->isGenerator;
next = walk->parent; next = walk->parent;
/*
* A scope between the resume point and the call frame may be an EACH
* loop with its *own* generator suspended off to the side. Releasing
* the loop scope without releasing that generator strands it in the
* pool -- the walk goes through parents and a suspended generator is a
* child. Guarded on `used` so a generator already released as part of
* some enclosing teardown is not released twice.
*/
if ( walk->forGeneratorEnv != NULL && walk->forGeneratorEnv->used ) {
PASS(errctx, akbasic_runtime_release_generator(obj, walk->forGeneratorEnv));
}
walk->forGeneratorEnv = NULL;
PASS(errctx, akbasic_runtime_release_environment(obj, walk)); PASS(errctx, akbasic_runtime_release_environment(obj, walk));
if ( isgen ) { if ( isgen ) {
break; break;

View File

@@ -149,11 +149,27 @@ akerr_ErrorContext *akbasic_cmd_loop(akbasic_Runtime *obj, akbasic_ASTLeaf *expr
akbasic_Environment *loopenv = obj->environment; akbasic_Environment *loopenv = obj->environment;
PASS(errctx, akbasic_environment_stop_waiting(obj->environment, "LOOP")); PASS(errctx, akbasic_environment_stop_waiting(obj->environment, "LOOP"));
if ( loopenv->forGeneratorEnv != NULL ) { /*
* A condition on the LOOP composes with EACH: it is checked after each
* trip through the body, with the loop variable still holding that
* trip's value, before the generator is pumped for the next one. A
* condition that says stop abandons the generator exactly as EXIT does.
*/
again = true;
arg = (expr != NULL ? expr->right : NULL);
if ( arg != NULL ) {
kind = (int)arg->literal_int;
PASS(errctx, loop_continues(obj, arg->left, kind, &again));
}
if ( !again && loopenv->forGeneratorEnv != NULL ) {
PASS(errctx, akbasic_runtime_release_generator(obj, loopenv->forGeneratorEnv));
loopenv->forGeneratorEnv = NULL;
}
if ( again && loopenv->forGeneratorEnv != NULL ) {
obj->environment = loopenv->forGeneratorEnv; obj->environment = loopenv->forGeneratorEnv;
PASS(errctx, akbasic_runtime_pump_generator(obj, loopenv)); PASS(errctx, akbasic_runtime_pump_generator(obj, loopenv));
} }
again = (loopenv->forGeneratorEnv != NULL); again = (again && loopenv->forGeneratorEnv != NULL);
} else { } else {
PASS(errctx, akbasic_environment_stop_waiting(obj->environment, "LOOP")); PASS(errctx, akbasic_environment_stop_waiting(obj->environment, "LOOP"));
/* /*

View File

@@ -119,6 +119,137 @@ static void test_exit_partway_through(void)
harness_stop(); harness_stop();
} }
/**
* @brief Abandoning a generator that is itself suspended inside a FOR EACH
* over another generator releases the inner generator too.
*
* The inner generator's environment hangs off the *loop* scope inside OUTER's
* body as a child, off the parent chain -- the one place a bare parent walk
* never looks. Before akbasic_runtime_release_generator() recursed into
* `forGeneratorEnv`, every trip through this loop stranded one pool slot and
* the 13th trip died with "Environment pool exhausted".
*/
static void test_exit_releases_nested_generators(void)
{
TEST_REQUIRE_OK(run_program("10 GEN INNER(N#)\n"
"20 EMIT 1\n"
"30 EMIT 2\n"
"40 END GEN\n"
"50 GEN OUTER(N#)\n"
"60 FOR EACH I# IN INNER(0)\n"
"70 EMIT I#\n"
"80 NEXT I#\n"
"90 END GEN\n"
"100 FOR K# = 1 TO 40\n"
"110 FOR EACH V# IN OUTER(0)\n"
"120 EXIT\n"
"130 NEXT V#\n"
"140 NEXT K#\n"
"150 PRINT \"DONE\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "DONE\n");
harness_stop();
}
/**
* @brief A LOOP UNTIL that stops a DO EACH early releases the generator it
* abandons, every time.
*/
static void test_loop_condition_releases_generator(void)
{
TEST_REQUIRE_OK(run_program("10 GEN COUNTUP(N#)\n"
"20 FOR I# = 1 TO N#\n"
"30 EMIT I#\n"
"40 NEXT I#\n"
"50 END GEN\n"
"60 FOR K# = 1 TO 40\n"
"70 DO EACH V# IN COUNTUP(10)\n"
"80 LOOP UNTIL V# = 2\n"
"90 NEXT K#\n"
"100 PRINT \"DONE\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "DONE\n");
harness_stop();
}
/**
* @brief RETURN standing in a GEN's own frame ends the generator early,
* exactly as END GEN would -- a GEN is a function at heart.
*/
static void test_return_ends_generator(void)
{
TEST_REQUIRE_OK(run_program("10 GEN G(N#)\n"
"20 EMIT 1\n"
"30 RETURN\n"
"40 EMIT 2\n"
"50 END GEN\n"
"60 FOR EACH V# IN G(0)\n"
"70 PRINT V#\n"
"80 NEXT V#\n"
"90 PRINT \"DONE\"\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "1\nDONE\n");
harness_stop();
}
/**
* @brief RETURN with a value inside a GEN is refused: values leave a GEN one
* at a time, through EMIT, and there is no return slot waiting.
*/
static void test_return_value_in_generator_refused(void)
{
TEST_REQUIRE_OK(run_program_bounded("10 GEN G(N#)\n"
"20 EMIT 1\n"
"30 RETURN 99\n"
"40 END GEN\n"
"50 FOR EACH V# IN G(0)\n"
"60 PRINT V#\n"
"70 NEXT V#\n"
"80 PRINT \"UNREACHABLE\"\n", 2000));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "1\n") != NULL,
"expected the first EMIT in \"%s\"", HARNESS_OUTPUT);
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "UNREACHABLE") == NULL,
"RETURN with a value must stop the run, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/**
* @brief The unwind primitive releases a popped scope's suspended generator.
*
* Built by hand rather than through BASIC because the paths that need this --
* the error unwinds in pump_generator() and call_function() -- only trigger
* on C-level failures a program cannot politely ask for. The shape is the
* one EMIT leaves behind: a loop scope holding a detached generator child,
* with a further scope active above it.
*/
static void test_unwind_releases_suspended_generators(void)
{
akbasic_Environment *root = NULL;
akbasic_Environment *loopenv = NULL;
akbasic_Environment *genenv = NULL;
akbasic_Environment *forenv = NULL;
TEST_REQUIRE_OK(harness_start(NULL));
root = HARNESS_RUNTIME.environment;
TEST_REQUIRE_OK(akbasic_runtime_new_environment(&HARNESS_RUNTIME));
loopenv = HARNESS_RUNTIME.environment;
loopenv->isEachLoop = true;
TEST_REQUIRE_OK(akbasic_runtime_new_environment(&HARNESS_RUNTIME));
genenv = HARNESS_RUNTIME.environment;
genenv->isGenerator = true;
TEST_REQUIRE_OK(akbasic_runtime_detach_environment(&HARNESS_RUNTIME));
loopenv->forGeneratorEnv = genenv;
TEST_REQUIRE_OK(akbasic_runtime_new_environment(&HARNESS_RUNTIME));
forenv = HARNESS_RUNTIME.environment;
TEST_REQUIRE_OK(akbasic_runtime_unwind_to_environment(&HARNESS_RUNTIME, root));
TEST_REQUIRE(HARNESS_RUNTIME.environment == root,
"unwind must land on the target scope");
TEST_REQUIRE(!forenv->used && !loopenv->used && !genenv->used,
"unwind must release the chain and the suspended generator");
harness_stop();
}
/** /**
* @brief A GEN invoked like an ordinary function, rather than through FOR * @brief A GEN invoked like an ordinary function, rather than through FOR
* EACH/DO EACH, fails cleanly. * EACH/DO EACH, fails cleanly.
@@ -216,6 +347,11 @@ int main(void)
test_exit_releases_generator_for_each(); test_exit_releases_generator_for_each();
test_exit_releases_generator_do_each(); test_exit_releases_generator_do_each();
test_exit_partway_through(); test_exit_partway_through();
test_exit_releases_nested_generators();
test_loop_condition_releases_generator();
test_return_ends_generator();
test_return_value_in_generator_refused();
test_unwind_releases_suspended_generators();
test_called_like_a_function(); test_called_like_a_function();
test_emit_outside_gen(); test_emit_outside_gen();
test_self_recursion_refused(); test_self_recursion_refused();

View File

@@ -0,0 +1,8 @@
10 REM A DO EACH takes its condition on the LOOP, not on the DO line.
20 GEN ONE(N#)
30 EMIT N#
40 END GEN
50 DO EACH V# IN ONE(1) WHILE V# < 9
60 PRINT V#
70 LOOP
80 PRINT "UNREACHABLE"

View File

@@ -0,0 +1,2 @@
? 50 : PARSE ERROR DO EACH takes its WHILE/UNTIL on the LOOP, and nothing else here

View File

@@ -0,0 +1,15 @@
10 REM A WHILE or UNTIL on the LOOP composes with DO EACH: it is checked
20 REM after each trip through the body, with the loop variable still
30 REM holding that trip's value. Stopping abandons the generator cleanly.
40 GEN COUNTUP(N#)
50 FOR I# = 1 TO N#
60 EMIT I#
70 NEXT I#
80 END GEN
90 DO EACH V# IN COUNTUP(10)
100 PRINT V#
110 LOOP UNTIL V# = 3
120 DO EACH W# IN COUNTUP(4)
130 PRINT W# * 10
140 LOOP WHILE W# < 3
150 PRINT "DONE"

View File

@@ -0,0 +1,7 @@
1
2
3
10
20
30
DONE

View File

@@ -0,0 +1,15 @@
10 REM RETURN ends a GEN early, exactly as END GEN would: a GEN is a
20 REM function at heart, and only EMIT is different about it. Like a
30 REM GOSUB's or DEF's RETURN, it must stand in the GEN's own scope,
40 REM not inside a FOR or DO the body opened.
50 GEN FIRSTFEW(N#)
60 EMIT 1
70 IF N# < 2 THEN RETURN
80 EMIT 2
90 IF N# < 3 THEN RETURN
100 EMIT 3
110 END GEN
120 FOR EACH V# IN FIRSTFEW(2)
130 PRINT V#
140 NEXT V#
150 PRINT "DONE"

View File

@@ -0,0 +1,3 @@
1
2
DONE