From a29c7f34fef4225eb8136712895c9d5e54845b4f Mon Sep 17 00:00:00 2001 From: Ishikawa Date: Thu, 6 Aug 2026 11:29:17 -0400 Subject: [PATCH] Fix generator teardown leaks, add RETURN-in-GEN and LOOP conditions on DO EACH 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 Co-Authored-By: Claude Fable 5 --- MAINTENANCE.md | 20 +++ TODO.md | 30 ++++ docs/04-control-flow.md | 58 +++++++- docs/11-verb-reference.md | 4 +- include/akbasic/runtime.h | 22 +++ src/parser_commands.c | 28 ++++ src/runtime.c | 39 ++++- src/runtime_commands.c | 16 ++- src/runtime_generator.c | 16 ++- src/runtime_structure.c | 20 ++- tests/generators.c | 136 ++++++++++++++++++ .../generators_do_condition_misplaced.bas | 8 ++ .../generators_do_condition_misplaced.txt | 2 + .../flowcontrol/generators_loop_condition.bas | 15 ++ .../flowcontrol/generators_loop_condition.txt | 7 + .../flowcontrol/generators_return.bas | 15 ++ .../flowcontrol/generators_return.txt | 3 + 17 files changed, 426 insertions(+), 13 deletions(-) create mode 100644 tests/language/flowcontrol/generators_do_condition_misplaced.bas create mode 100644 tests/language/flowcontrol/generators_do_condition_misplaced.txt create mode 100644 tests/language/flowcontrol/generators_loop_condition.bas create mode 100644 tests/language/flowcontrol/generators_loop_condition.txt create mode 100644 tests/language/flowcontrol/generators_return.bas create mode 100644 tests/language/flowcontrol/generators_return.txt diff --git a/MAINTENANCE.md b/MAINTENANCE.md index 3ccf604..3a7ea64 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -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 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 diff --git a/TODO.md b/TODO.md index fb96df9..018b00e 100644 --- a/TODO.md +++ b/TODO.md @@ -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 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** diff --git a/docs/04-control-flow.md b/docs/04-control-flow.md index e6bfa48..190f428 100644 --- a/docs/04-control-flow.md +++ b/docs/04-control-flow.md @@ -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` -- 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 -refused, the same way it would be an error to call a function that has not returned yet -by name from inside its own body without meaning to recurse. +refused: unlike a function call, which runs to completion and returns, the outer +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 `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 ``` +`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 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 diff --git a/docs/11-verb-reference.md b/docs/11-verb-reference.md index e749913..ea7db72 100644 --- a/docs/11-verb-reference.md +++ b/docs/11-verb-reference.md @@ -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. | | `DIRECTORY` | `DIRECTORY` | **Refused.** Not written yet; the standard-library wrapper it waited on has landed. | | `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. | | `DRAW` | `DRAW src, x, y [TO x, y ...]` | Plot a point or draw a polyline. | | `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. | | `RESTORE` | `RESTORE [line]` | Reset the `READ` cursor, optionally to a line. | | `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. | | `SAVE` | `SAVE "name"` | The other name for `DSAVE`. | | `SCALE` | `SCALE on [,xmax, ymax]` | Turn user coordinates on or off. | diff --git a/include/akbasic/runtime.h b/include/akbasic/runtime.h index 72db4a5..27f3562 100644 --- a/include/akbasic/runtime.h +++ b/include/akbasic/runtime.h @@ -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); +/** + * @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. * diff --git a/src/parser_commands.c b/src/parser_commands.c index 0667577..44ee9ff 100644 --- a/src/parser_commands.c +++ b/src/parser_commands.c @@ -337,6 +337,19 @@ akerr_ErrorContext *akbasic_parse_do(akbasic_Parser *parser, akbasic_ASTLeaf **d 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->isEachLoop = true; 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)); + /* + * 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->loopFirstLine = firstline; /* diff --git a/src/runtime.c b/src/runtime.c index 58cb604..78e2645 100644 --- a/src/runtime.c +++ b/src/runtime.c @@ -208,6 +208,38 @@ akerr_ErrorContext *akbasic_runtime_prev_environment(akbasic_Runtime *obj) 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 -- */ 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 * twelve-slot environment pool after twelve dead calls and every * 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_prev_environment(obj)); - } + IGNORE(akbasic_runtime_unwind_to_environment(obj, targetenv)); } PROCESS(errctx) { } FINISH(errctx, true); PASS(errctx, akbasic_environment_new_value(targetenv, &out)); diff --git a/src/runtime_commands.c b/src/runtime_commands.c index 86c185e..fa3f892 100644 --- a/src/runtime_commands.c +++ b/src/runtime_commands.c @@ -161,8 +161,22 @@ akerr_ErrorContext *akbasic_cmd_return(akbasic_Runtime *obj, akbasic_ASTLeaf *ex SUCCEED_TRUE(obj, dest); 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, - "RETURN outside the context of GOSUB"); + "RETURN outside the context of GOSUB, DEF, or GEN"); if ( expr != NULL && expr->right != NULL ) { PASS(errctx, akbasic_runtime_evaluate(obj, expr->right, &result)); diff --git a/src/runtime_generator.c b/src/runtime_generator.c index 4dceb36..da49f5b 100644 --- a/src/runtime_generator.c +++ b/src/runtime_generator.c @@ -68,9 +68,7 @@ akerr_ErrorContext *akbasic_runtime_pump_generator(akbasic_Runtime *obj, akbasic * among the scopes just released. */ if ( obj->environment != loopenv ) { - while ( obj->environment != loopenv && obj->environment->parent != NULL ) { - IGNORE(akbasic_runtime_prev_environment(obj)); - } + IGNORE(akbasic_runtime_unwind_to_environment(obj, loopenv)); loopenv->forGeneratorEnv = NULL; } } PROCESS(errctx) { @@ -103,6 +101,18 @@ akerr_ErrorContext *akbasic_runtime_release_generator(akbasic_Runtime *obj, akba while ( walk != NULL ) { isgen = walk->isGenerator; 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)); if ( isgen ) { break; diff --git a/src/runtime_structure.c b/src/runtime_structure.c index 68113bc..2d2a7f2 100644 --- a/src/runtime_structure.c +++ b/src/runtime_structure.c @@ -149,11 +149,27 @@ akerr_ErrorContext *akbasic_cmd_loop(akbasic_Runtime *obj, akbasic_ASTLeaf *expr akbasic_Environment *loopenv = obj->environment; 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; PASS(errctx, akbasic_runtime_pump_generator(obj, loopenv)); } - again = (loopenv->forGeneratorEnv != NULL); + again = (again && loopenv->forGeneratorEnv != NULL); } else { PASS(errctx, akbasic_environment_stop_waiting(obj->environment, "LOOP")); /* diff --git a/tests/generators.c b/tests/generators.c index be9824f..fc3db06 100644 --- a/tests/generators.c +++ b/tests/generators.c @@ -119,6 +119,137 @@ static void test_exit_partway_through(void) 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 * EACH/DO EACH, fails cleanly. @@ -216,6 +347,11 @@ int main(void) test_exit_releases_generator_for_each(); test_exit_releases_generator_do_each(); 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_emit_outside_gen(); test_self_recursion_refused(); diff --git a/tests/language/flowcontrol/generators_do_condition_misplaced.bas b/tests/language/flowcontrol/generators_do_condition_misplaced.bas new file mode 100644 index 0000000..5d338c3 --- /dev/null +++ b/tests/language/flowcontrol/generators_do_condition_misplaced.bas @@ -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" diff --git a/tests/language/flowcontrol/generators_do_condition_misplaced.txt b/tests/language/flowcontrol/generators_do_condition_misplaced.txt new file mode 100644 index 0000000..6f0d6ee --- /dev/null +++ b/tests/language/flowcontrol/generators_do_condition_misplaced.txt @@ -0,0 +1,2 @@ +? 50 : PARSE ERROR DO EACH takes its WHILE/UNTIL on the LOOP, and nothing else here + diff --git a/tests/language/flowcontrol/generators_loop_condition.bas b/tests/language/flowcontrol/generators_loop_condition.bas new file mode 100644 index 0000000..9f21e95 --- /dev/null +++ b/tests/language/flowcontrol/generators_loop_condition.bas @@ -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" diff --git a/tests/language/flowcontrol/generators_loop_condition.txt b/tests/language/flowcontrol/generators_loop_condition.txt new file mode 100644 index 0000000..7dddada --- /dev/null +++ b/tests/language/flowcontrol/generators_loop_condition.txt @@ -0,0 +1,7 @@ +1 +2 +3 +10 +20 +30 +DONE diff --git a/tests/language/flowcontrol/generators_return.bas b/tests/language/flowcontrol/generators_return.bas new file mode 100644 index 0000000..cf65a2d --- /dev/null +++ b/tests/language/flowcontrol/generators_return.bas @@ -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" diff --git a/tests/language/flowcontrol/generators_return.txt b/tests/language/flowcontrol/generators_return.txt new file mode 100644 index 0000000..9b29285 --- /dev/null +++ b/tests/language/flowcontrol/generators_return.txt @@ -0,0 +1,3 @@ +1 +2 +DONE