Give each DEF call its own environment, so recursion returns

The function's environment was owned by the funcdef and re-initialised on every
call, which made a function not re-entrant and cost two silent defects:

    DEF DBL(N#) = N# * 2
    PRINT DBL(10) + DBL(1)      was 4, should be 22

The result was a pointer into the funcdef's own environment, so the second call
overwrote the first before the operator saw it -- both operands became the last
call's answer. Two *different* functions in one expression were fine, which is
most of why it was invisible.

    DEF FACT(N#)
    IF N# <= 1 THEN RETURN 1
    RETURN N# * FACT(N# - 1)
    PRINT FACT(5)               never returned

The recursive call re-initialised the environment the outer call was still
using, so the loop waiting for control to come back could not see it. No error,
no bound, no diagnostic -- the one place in this interpreter that looped forever
rather than raising.

A call takes an environment from the pool now, exactly as GOSUB does. The result
is copied into a caller-scope scratch before that environment goes back, because
handing back a pointer into the callee is what made two calls collide and would
now be a pointer into a released slot as well. RETURN parks its result on the
*parent* rather than on the environment it is about to release, so nothing reads
a freed slot to find it.

Recursion depth answers to AKBASIC_MAX_ENVIRONMENTS like every other nesting, so
too deep is "Environment pool exhausted" -- a diagnosis where there was none.

akbasic_FunctionDef.environment goes with it, as dead state.

One thing this exposed but did not cause, measured against a stashed build and
recorded rather than fixed: a statement containing a failed multi-line DEF call
still completes and prints a junk value. It is visible more often now only
because runaway recursion reaches it where it used to hang.
tests/language/functions/recursion.bas deliberately does not pin that answer.

Chapter 16 loses its "walk a list with a loop, not a recursive DEF" caveat and
gains the one that is still true: a function cannot take a structure parameter
yet, so it reaches a record by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 12:29:35 -04:00
parent 6bac929901
commit 00daa17a47
10 changed files with 332 additions and 31 deletions

View File

@@ -0,0 +1,23 @@
10 REM A DEF call takes its environment from the pool, one per call, exactly as
20 REM GOSUB does. It used to be owned by the funcdef and reset on every call,
30 REM which cost two silent defects: two calls in one expression shared a slot,
40 REM and recursion never came back at all.
50 DEF FACT(N#)
60 IF N# <= 1 THEN RETURN 1
70 RETURN N# * FACT(N# - 1)
80 PRINT FACT(5)
90 REM Each frame keeps its own argument, which is what makes the unwind work.
100 DEF SUMTO(N#)
110 IF N# <= 0 THEN RETURN 0
120 RETURN N# + SUMTO(N# - 1)
130 PRINT SUMTO(4)
140 REM And two calls to one function in one expression no longer collide.
150 DEF DBL(N#) = N# * 2
160 PRINT DBL(10) + DBL(1)
170 PRINT DBL(1) + DBL(10) + DBL(100)
180 REM Depth answers to the environment pool now, so runaway recursion reports
190 REM "Environment pool exhausted" rather than hanging. It is not exercised here
200 REM because the statement containing a failed call still prints a junk value
210 REM afterwards -- a separate, pre-existing defect recorded in TODO.md, and one
220 REM this golden file would pin if it went in. tests/user_functions.c asserts
230 REM the message instead.

View File

@@ -0,0 +1,4 @@
120
10
22
222

176
tests/user_functions.c Normal file
View File

@@ -0,0 +1,176 @@
/**
* @file user_functions.c
* @brief Tests that a `DEF` call is re-entrant.
*
* Both of the defects here came from one cause: the function's environment used
* to be owned by the funcdef and reset on every call, so a second call trampled
* the first. A call takes one from the pool now, exactly as `GOSUB` does.
*
* Neither failure was visible in an ordinary program. The aliasing one gave a
* wrong number rather than an error, and *only* when the same function appeared
* twice in one expression -- two different functions were fine, which is what
* made it hard to see at all. The recursion one produced no output whatsoever.
* So both are pinned here against the language rather than left to a listing
* somebody might not write.
*/
#include <string.h>
#include <akbasic/error.h>
#include <akbasic/runtime.h>
#include "harness.h"
#include "testutil.h"
/** @brief Run a program to completion, bounded so a hang fails rather than waits. */
static akerr_ErrorContext AKERR_NOIGNORE *run_program(const char *source)
{
PREPARE_ERROR(errctx);
PASS(errctx, harness_start(NULL));
PASS(errctx, akbasic_runtime_load(&HARNESS_RUNTIME, source));
PASS(errctx, akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 20000));
SUCCEED_RETURN(errctx);
}
/**
* @brief Two calls to one function in one expression do not share a slot.
*
* The result used to be a pointer into the funcdef's own environment, so the
* second call overwrote the first before the operator saw it: `DBL(10) + DBL(1)`
* came out as 4 rather than 22, both operands having become the last call's
* answer. A silent wrong number from a two-line program.
*/
static void test_two_calls_in_one_expression(void)
{
TEST_REQUIRE_OK(run_program("10 DEF DBL(N#) = N# * 2\n"
"20 DEF TPL(N#) = N# * 3\n"
"30 PRINT DBL(10) + DBL(1)\n"
"40 PRINT DBL(1) + DBL(10) + DBL(100)\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "22\n222\n");
harness_stop();
/*
* Two *different* functions were always correct, because each funcdef owned
* its own environment. Asserted so a future fix that reintroduces sharing
* cannot pass by getting this case right.
*/
TEST_REQUIRE_OK(run_program("10 DEF DBL(N#) = N# * 2\n"
"20 DEF TPL(N#) = N# * 3\n"
"30 PRINT DBL(1) + TPL(10)\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "32\n");
harness_stop();
}
/**
* @brief A recursive multi-line `DEF` returns.
*
* It used to hang: the recursive call re-initialised the environment the outer
* call was still using, so the loop waiting for control to come back never saw
* it. No error, no bound, no diagnostic -- the one place in this interpreter
* that looped forever rather than raising.
*/
static void test_recursion_returns(void)
{
TEST_REQUIRE_OK(run_program("10 DEF FACT(N#)\n"
"20 IF N# <= 1 THEN RETURN 1\n"
"30 RETURN N# * FACT(N# - 1)\n"
"40 PRINT FACT(5)\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "120\n");
harness_stop();
}
/**
* @brief Each call gets its own arguments, which is what recursion needs.
*
* Distinct from the test above: a function could return the right answer for
* `FACT` by luck if the argument happened to be re-read before being clobbered.
* This one unwinds and uses the argument *after* the inner call has returned, so
* a shared parameter slot gives the wrong answer rather than no answer.
*/
static void test_arguments_are_per_call(void)
{
TEST_REQUIRE_OK(run_program("10 DEF SUMTO(N#)\n"
"20 IF N# <= 0 THEN RETURN 0\n"
"30 RETURN N# + SUMTO(N# - 1)\n"
"40 PRINT SUMTO(4)\n"));
/* 4+3+2+1 = 10, and only if each frame kept its own N#. */
TEST_REQUIRE_STR(HARNESS_OUTPUT, "10\n");
harness_stop();
}
/**
* @brief Runaway recursion is reported, not hung.
*
* Depth now answers to the environment pool like every other nesting, so too
* deep is the same diagnosis `GOSUB` already gave. That is the whole change in
* one sentence: a bound where there was none.
*/
static void test_runaway_recursion_is_diagnosed(void)
{
TEST_REQUIRE_OK(run_program("10 DEF INF(N#)\n"
"20 RETURN INF(N# + 1)\n"
"30 PRINT INF(1)\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "Environment pool exhausted") != NULL,
"unbounded recursion should report the pool bound, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief A single-expression function still works, and still nests. */
static void test_single_expression_form(void)
{
TEST_REQUIRE_OK(run_program("10 DEF SQ(N#) = N# * N#\n"
"20 PRINT SQ(4)\n"
"30 PRINT SQ(SQ(2))\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "16\n16\n");
harness_stop();
}
/**
* @brief A function reaches a structure through the caller's scope.
*
* **Not through a parameter**: `DEF F(B@ AS CRATE)` is not implemented, and a
* bare `DEF F(B@)` is refused because `@` alone does not say *which* type. So
* the way a function works with a record today is by name, which the dynamic
* scoping already allows -- a call's environment has the caller's as its parent.
*
* Pinned rather than left implicit, because it is the only route there is and a
* reader would otherwise reasonably assume the parameter form works.
*/
static void test_structures_reached_through_scope(void)
{
TEST_REQUIRE_OK(run_program("10 TYPE CRATE\n"
"20 W#\n"
"30 END TYPE\n"
"40 DIM A@ AS CRATE\n"
"50 A@.W# = 5\n"
"60 DEF SCALED(N#) = A@.W# * N#\n"
"70 PRINT SCALED(3)\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "15\n");
harness_stop();
/* And a structure parameter is refused rather than silently misbehaving. */
TEST_REQUIRE_OK(run_program("10 TYPE CRATE\n"
"20 W#\n"
"30 END TYPE\n"
"40 DEF WID(B@) = B@.W#\n"
"50 PRINT 1\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "ERROR") != NULL,
"a structure parameter should be refused for now, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
int main(void)
{
TEST_REQUIRE_OK(akbasic_error_register());
test_two_calls_in_one_expression();
test_recursion_returns();
test_arguments_are_per_call();
test_runaway_recursion_is_diagnosed();
test_single_expression_form();
test_structures_reached_through_scope();
return akbasic_test_failures;
}