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>
2026-08-01 12:29:35 -04:00
|
|
|
/**
|
|
|
|
|
* @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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
Let DEF take structure parameters, and give call scopes back
DEF AREA(S@ AS RECT) = S@.W# * S@.H#
DEF POKEIT(P@ AS PTR TO RECT)
A parameter names its type, exactly as DIM does. A bare `DEF F(S@)` is refused:
@ says "a structure" without saying which, so it does not state a contract the
way S$ does, and accepting it would mean checking fields at the call rather than
at the declaration -- which is the hole naming the type closes. The cost is that
there are no generic functions, and that is a real loss rather than an oversight.
Passing is by value, because a parameter is bound by assignment and assignment
copies; a pointer parameter copies its reference and lets a function change its
caller's record on purpose. Neither is a special rule. What a structure
parameter does need is its storage prepared before the copy, since a structure
variable is a run of slots and there is nothing to copy into until the run
exists.
A DEF parameter list is no longer parsed as an argument list, because a
parameter is a declaration rather than an expression: `S@ AS RECT` stopped that
parser dead with "Unbalanced parenthesis".
akbasic_value_is_truthy() learned that a pointer is true when it points at
something, which had to come with this. Without it there is no way to test for
the end of a list at all -- comparing a pointer to 0 reads a numeric field it
does not carry and answers whatever that field held. A structure is deliberately
given no truth value: it always exists, so the question has no answer worth
guessing at.
And a regression I introduced last commit, plus the older one underneath it.
prev_environment() released a scope but not the variables the scope created, so
a call leaked one slot per parameter and two hundred calls exhausted the
128-slot pool. Giving each DEF call its own scope made that reachable; it was
there for GOSUB all along, measured on a stashed build -- a subroutine with a
local of its own failed after about 128 calls before any of this work. The
release is safe because a scope's table holds only what it created, and it is
the variable slot that comes back rather than its storage, so a pointer into a
record DIMmed in that scope stays sound.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:05:32 -04:00
|
|
|
* @brief A structure parameter is passed by value, and a pointer one is not.
|
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>
2026-08-01 12:29:35 -04:00
|
|
|
*
|
Let DEF take structure parameters, and give call scopes back
DEF AREA(S@ AS RECT) = S@.W# * S@.H#
DEF POKEIT(P@ AS PTR TO RECT)
A parameter names its type, exactly as DIM does. A bare `DEF F(S@)` is refused:
@ says "a structure" without saying which, so it does not state a contract the
way S$ does, and accepting it would mean checking fields at the call rather than
at the declaration -- which is the hole naming the type closes. The cost is that
there are no generic functions, and that is a real loss rather than an oversight.
Passing is by value, because a parameter is bound by assignment and assignment
copies; a pointer parameter copies its reference and lets a function change its
caller's record on purpose. Neither is a special rule. What a structure
parameter does need is its storage prepared before the copy, since a structure
variable is a run of slots and there is nothing to copy into until the run
exists.
A DEF parameter list is no longer parsed as an argument list, because a
parameter is a declaration rather than an expression: `S@ AS RECT` stopped that
parser dead with "Unbalanced parenthesis".
akbasic_value_is_truthy() learned that a pointer is true when it points at
something, which had to come with this. Without it there is no way to test for
the end of a list at all -- comparing a pointer to 0 reads a numeric field it
does not carry and answers whatever that field held. A structure is deliberately
given no truth value: it always exists, so the question has no answer worth
guessing at.
And a regression I introduced last commit, plus the older one underneath it.
prev_environment() released a scope but not the variables the scope created, so
a call leaked one slot per parameter and two hundred calls exhausted the
128-slot pool. Giving each DEF call its own scope made that reachable; it was
there for GOSUB all along, measured on a stashed build -- a subroutine with a
local of its own failed after about 128 calls before any of this work. The
release is safe because a scope's table holds only what it created, and it is
the variable slot that comes back rather than its storage, so a pointer into a
record DIMmed in that scope stays sound.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:05:32 -04:00
|
|
|
* Neither is a special rule: a parameter is bound by assignment like any other
|
|
|
|
|
* variable, and assignment copies a structure and copies a pointer's reference.
|
|
|
|
|
* The two halves of the feature seen from a function's side.
|
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>
2026-08-01 12:29:35 -04:00
|
|
|
*/
|
Let DEF take structure parameters, and give call scopes back
DEF AREA(S@ AS RECT) = S@.W# * S@.H#
DEF POKEIT(P@ AS PTR TO RECT)
A parameter names its type, exactly as DIM does. A bare `DEF F(S@)` is refused:
@ says "a structure" without saying which, so it does not state a contract the
way S$ does, and accepting it would mean checking fields at the call rather than
at the declaration -- which is the hole naming the type closes. The cost is that
there are no generic functions, and that is a real loss rather than an oversight.
Passing is by value, because a parameter is bound by assignment and assignment
copies; a pointer parameter copies its reference and lets a function change its
caller's record on purpose. Neither is a special rule. What a structure
parameter does need is its storage prepared before the copy, since a structure
variable is a run of slots and there is nothing to copy into until the run
exists.
A DEF parameter list is no longer parsed as an argument list, because a
parameter is a declaration rather than an expression: `S@ AS RECT` stopped that
parser dead with "Unbalanced parenthesis".
akbasic_value_is_truthy() learned that a pointer is true when it points at
something, which had to come with this. Without it there is no way to test for
the end of a list at all -- comparing a pointer to 0 reads a numeric field it
does not carry and answers whatever that field held. A structure is deliberately
given no truth value: it always exists, so the question has no answer worth
guessing at.
And a regression I introduced last commit, plus the older one underneath it.
prev_environment() released a scope but not the variables the scope created, so
a call leaked one slot per parameter and two hundred calls exhausted the
128-slot pool. Giving each DEF call its own scope made that reachable; it was
there for GOSUB all along, measured on a stashed build -- a subroutine with a
local of its own failed after about 128 calls before any of this work. The
release is safe because a scope's table holds only what it created, and it is
the variable slot that comes back rather than its storage, so a pointer into a
record DIMmed in that scope stays sound.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:05:32 -04:00
|
|
|
static void test_structure_parameters(void)
|
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>
2026-08-01 12:29:35 -04:00
|
|
|
{
|
|
|
|
|
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"
|
Let DEF take structure parameters, and give call scopes back
DEF AREA(S@ AS RECT) = S@.W# * S@.H#
DEF POKEIT(P@ AS PTR TO RECT)
A parameter names its type, exactly as DIM does. A bare `DEF F(S@)` is refused:
@ says "a structure" without saying which, so it does not state a contract the
way S$ does, and accepting it would mean checking fields at the call rather than
at the declaration -- which is the hole naming the type closes. The cost is that
there are no generic functions, and that is a real loss rather than an oversight.
Passing is by value, because a parameter is bound by assignment and assignment
copies; a pointer parameter copies its reference and lets a function change its
caller's record on purpose. Neither is a special rule. What a structure
parameter does need is its storage prepared before the copy, since a structure
variable is a run of slots and there is nothing to copy into until the run
exists.
A DEF parameter list is no longer parsed as an argument list, because a
parameter is a declaration rather than an expression: `S@ AS RECT` stopped that
parser dead with "Unbalanced parenthesis".
akbasic_value_is_truthy() learned that a pointer is true when it points at
something, which had to come with this. Without it there is no way to test for
the end of a list at all -- comparing a pointer to 0 reads a numeric field it
does not carry and answers whatever that field held. A structure is deliberately
given no truth value: it always exists, so the question has no answer worth
guessing at.
And a regression I introduced last commit, plus the older one underneath it.
prev_environment() released a scope but not the variables the scope created, so
a call leaked one slot per parameter and two hundred calls exhausted the
128-slot pool. Giving each DEF call its own scope made that reachable; it was
there for GOSUB all along, measured on a stashed build -- a subroutine with a
local of its own failed after about 128 calls before any of this work. The
release is safe because a scope's table holds only what it created, and it is
the variable slot that comes back rather than its storage, so a pointer into a
record DIMmed in that scope stays sound.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:05:32 -04:00
|
|
|
"60 DEF WIDEN(B@ AS CRATE)\n"
|
|
|
|
|
"70 B@.W# = 99\n"
|
|
|
|
|
"80 RETURN B@.W#\n"
|
|
|
|
|
"90 PRINT WIDEN(A@)\n"
|
|
|
|
|
"100 PRINT A@.W#\n"));
|
|
|
|
|
/* The callee saw 99; the caller still has 5, because it was a copy. */
|
|
|
|
|
TEST_REQUIRE_STR(HARNESS_OUTPUT, "99\n5\n");
|
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>
2026-08-01 12:29:35 -04:00
|
|
|
harness_stop();
|
|
|
|
|
|
|
|
|
|
TEST_REQUIRE_OK(run_program("10 TYPE CRATE\n"
|
|
|
|
|
"20 W#\n"
|
|
|
|
|
"30 END TYPE\n"
|
Let DEF take structure parameters, and give call scopes back
DEF AREA(S@ AS RECT) = S@.W# * S@.H#
DEF POKEIT(P@ AS PTR TO RECT)
A parameter names its type, exactly as DIM does. A bare `DEF F(S@)` is refused:
@ says "a structure" without saying which, so it does not state a contract the
way S$ does, and accepting it would mean checking fields at the call rather than
at the declaration -- which is the hole naming the type closes. The cost is that
there are no generic functions, and that is a real loss rather than an oversight.
Passing is by value, because a parameter is bound by assignment and assignment
copies; a pointer parameter copies its reference and lets a function change its
caller's record on purpose. Neither is a special rule. What a structure
parameter does need is its storage prepared before the copy, since a structure
variable is a run of slots and there is nothing to copy into until the run
exists.
A DEF parameter list is no longer parsed as an argument list, because a
parameter is a declaration rather than an expression: `S@ AS RECT` stopped that
parser dead with "Unbalanced parenthesis".
akbasic_value_is_truthy() learned that a pointer is true when it points at
something, which had to come with this. Without it there is no way to test for
the end of a list at all -- comparing a pointer to 0 reads a numeric field it
does not carry and answers whatever that field held. A structure is deliberately
given no truth value: it always exists, so the question has no answer worth
guessing at.
And a regression I introduced last commit, plus the older one underneath it.
prev_environment() released a scope but not the variables the scope created, so
a call leaked one slot per parameter and two hundred calls exhausted the
128-slot pool. Giving each DEF call its own scope made that reachable; it was
there for GOSUB all along, measured on a stashed build -- a subroutine with a
local of its own failed after about 128 calls before any of this work. The
release is safe because a scope's table holds only what it created, and it is
the variable slot that comes back rather than its storage, so a pointer into a
record DIMmed in that scope stays sound.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:05:32 -04:00
|
|
|
"40 DIM A@ AS CRATE\n"
|
|
|
|
|
"50 A@.W# = 5\n"
|
|
|
|
|
"60 DIM Q@ AS PTR TO CRATE\n"
|
|
|
|
|
"70 POINT Q@ AT A@\n"
|
|
|
|
|
"80 DEF POKEIT(P@ AS PTR TO CRATE)\n"
|
|
|
|
|
"90 P@->W# = 42\n"
|
|
|
|
|
"100 RETURN P@->W#\n"
|
|
|
|
|
"110 PRINT POKEIT(Q@)\n"
|
|
|
|
|
"120 PRINT A@.W#\n"));
|
|
|
|
|
/* And through a pointer the caller's record does change. */
|
|
|
|
|
TEST_REQUIRE_STR(HARNESS_OUTPUT, "42\n42\n");
|
|
|
|
|
harness_stop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief A structure parameter must name its type, and the type is checked.
|
|
|
|
|
*
|
|
|
|
|
* `@` says "a structure" without saying which, so `X@` does not state a contract
|
|
|
|
|
* the way `X$` does. Naming the type is what closes that, and refusing a bare
|
|
|
|
|
* `X@` rather than treating it as "any structure" is what keeps the rule to one
|
|
|
|
|
* -- duck typing there would put the hole straight back.
|
|
|
|
|
*/
|
|
|
|
|
static void test_structure_parameter_types_are_checked(void)
|
|
|
|
|
{
|
|
|
|
|
static const struct { const char *tail; const char *expect; } CASES[] = {
|
|
|
|
|
{ "90 DEF F(B@) = B@.W#\n100 PRINT 1\n", "must name its type" },
|
|
|
|
|
{ "90 DEF F(B@ AS CRATE) = B@.W#\n100 PRINT F(O@)\n", "cannot take a OTHERT" },
|
|
|
|
|
{ "90 DEF F(B@ AS NOSUCH) = B@.W#\n100 PRINT F(A@)\n", "which is not a type" },
|
|
|
|
|
{ "90 DEF F(B@ AS PTR TO CRATE) = B@->W#\n100 PRINT F(A@)\n", "expects a pointer to" },
|
|
|
|
|
{ "90 DEF F(B@ AS) = 1\n100 PRINT 1\n", "Expected a type name" }
|
|
|
|
|
};
|
|
|
|
|
char source[2048];
|
|
|
|
|
size_t i = 0;
|
|
|
|
|
|
|
|
|
|
for ( i = 0; i < sizeof(CASES) / sizeof(CASES[0]); i++ ) {
|
|
|
|
|
snprintf(source, sizeof(source),
|
|
|
|
|
"10 TYPE CRATE\n20 W#\n30 END TYPE\n"
|
|
|
|
|
"40 TYPE OTHERT\n50 N#\n60 END TYPE\n"
|
|
|
|
|
"70 DIM A@ AS CRATE\n80 DIM O@ AS OTHERT\n%s", CASES[i].tail);
|
|
|
|
|
TEST_REQUIRE_OK(run_program(source));
|
|
|
|
|
TEST_REQUIRE(strstr(HARNESS_OUTPUT, CASES[i].expect) != NULL,
|
|
|
|
|
"case %zu should say \"%s\", got \"%s\"",
|
|
|
|
|
i, CASES[i].expect, HARNESS_OUTPUT);
|
|
|
|
|
harness_stop();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief A call gives its parameter variables back when it returns.
|
|
|
|
|
*
|
|
|
|
|
* One variable leaked per call before this, so a function called two hundred
|
|
|
|
|
* times exhausted the 128-slot pool and reported "Maximum runtime variables
|
|
|
|
|
* reached" on a four-line program. It was there for `GOSUB` all along --
|
|
|
|
|
* measured on a stashed build -- and giving each `DEF` call its own scope simply
|
|
|
|
|
* made it reachable a second way.
|
|
|
|
|
*/
|
|
|
|
|
static void test_call_scopes_are_reclaimed(void)
|
|
|
|
|
{
|
|
|
|
|
TEST_REQUIRE_OK(run_program("10 DEF DBL(N#) = N# * 2\n"
|
|
|
|
|
"20 T# = 0\n"
|
|
|
|
|
"30 FOR I# = 1 TO 200\n"
|
|
|
|
|
"40 T# = T# + DBL(I#)\n"
|
|
|
|
|
"50 NEXT I#\n"
|
|
|
|
|
"60 PRINT T#\n"));
|
|
|
|
|
TEST_REQUIRE_STR(HARNESS_OUTPUT, "40200\n");
|
|
|
|
|
harness_stop();
|
|
|
|
|
|
|
|
|
|
/* The GOSUB half, which is where the leak actually started. */
|
|
|
|
|
TEST_REQUIRE_OK(run_program("10 FOR I# = 1 TO 300\n"
|
|
|
|
|
"20 GOSUB 100\n"
|
|
|
|
|
"30 NEXT I#\n"
|
|
|
|
|
"40 PRINT \"OK\"\n"
|
|
|
|
|
"50 END\n"
|
|
|
|
|
"100 LOCALV# = I# * 2\n"
|
|
|
|
|
"110 RETURN\n"));
|
|
|
|
|
TEST_REQUIRE_STR(HARNESS_OUTPUT, "OK\n");
|
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>
2026-08-01 12:29:35 -04:00
|
|
|
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();
|
Let DEF take structure parameters, and give call scopes back
DEF AREA(S@ AS RECT) = S@.W# * S@.H#
DEF POKEIT(P@ AS PTR TO RECT)
A parameter names its type, exactly as DIM does. A bare `DEF F(S@)` is refused:
@ says "a structure" without saying which, so it does not state a contract the
way S$ does, and accepting it would mean checking fields at the call rather than
at the declaration -- which is the hole naming the type closes. The cost is that
there are no generic functions, and that is a real loss rather than an oversight.
Passing is by value, because a parameter is bound by assignment and assignment
copies; a pointer parameter copies its reference and lets a function change its
caller's record on purpose. Neither is a special rule. What a structure
parameter does need is its storage prepared before the copy, since a structure
variable is a run of slots and there is nothing to copy into until the run
exists.
A DEF parameter list is no longer parsed as an argument list, because a
parameter is a declaration rather than an expression: `S@ AS RECT` stopped that
parser dead with "Unbalanced parenthesis".
akbasic_value_is_truthy() learned that a pointer is true when it points at
something, which had to come with this. Without it there is no way to test for
the end of a list at all -- comparing a pointer to 0 reads a numeric field it
does not carry and answers whatever that field held. A structure is deliberately
given no truth value: it always exists, so the question has no answer worth
guessing at.
And a regression I introduced last commit, plus the older one underneath it.
prev_environment() released a scope but not the variables the scope created, so
a call leaked one slot per parameter and two hundred calls exhausted the
128-slot pool. Giving each DEF call its own scope made that reachable; it was
there for GOSUB all along, measured on a stashed build -- a subroutine with a
local of its own failed after about 128 calls before any of this work. The
release is safe because a scope's table holds only what it created, and it is
the variable slot that comes back rather than its storage, so a pointer into a
record DIMmed in that scope stays sound.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:05:32 -04:00
|
|
|
test_structure_parameters();
|
|
|
|
|
test_structure_parameter_types_are_checked();
|
|
|
|
|
test_call_scopes_are_reclaimed();
|
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>
2026-08-01 12:29:35 -04:00
|
|
|
|
|
|
|
|
return akbasic_test_failures;
|
|
|
|
|
}
|