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

@@ -767,55 +767,85 @@ akerr_ErrorContext *akbasic_runtime_user_function(akbasic_Runtime *obj, akbasic_
akbasic_ASTLeaf *argptr = NULL;
akbasic_Value *argvalue = NULL;
akbasic_Value *unused = NULL;
akbasic_Value *result = NULL;
akbasic_Value *out = NULL;
akbasic_Environment *callenv = NULL;
void *fnptr = NULL;
PASS(errctx, akbasic_environment_get_function(obj->environment, expr->identifier, &fnptr));
fndef = (akbasic_FunctionDef *)fnptr;
/*
* The function's environment is owned by the funcdef, not by the pool free
* list: it is reset on every call and outlives any single one. The reference
* holds it by value inside BasicFunctionDef for the same reason.
* **One environment per call, from the pool -- exactly as GOSUB does.**
*
* It used to be owned by the funcdef and reset on every call, which made a
* function not re-entrant and cost two silent defects:
*
* - Calling one twice in a single expression aliased one storage slot, so
* the second call overwrote the first result before the operator saw it.
* `DBL(10) + DBL(1)` was 4 rather than 22, and two different functions
* in one expression were fine, which is what made it so hard to see.
* - Recursion did not terminate. The recursive call re-initialised the
* environment the outer call was still using, so the loop below could
* never see control come back. No error, no bound, no diagnostic -- the
* one place in this interpreter that hung instead of raising.
*
* Taking it from the pool fixes both, and makes recursion depth answer to
* AKBASIC_MAX_ENVIRONMENTS like every other nesting: too deep is now
* "Environment pool exhausted", which is a diagnosis rather than a hang.
*/
if ( fndef->environment == NULL ) {
PASS(errctx, akbasic_runtime_new_environment(obj));
fndef->environment = obj->environment;
obj->environment = targetenv;
}
PASS(errctx, akbasic_environment_init(fndef->environment, obj, obj->environment));
PASS(errctx, akbasic_runtime_new_environment(obj));
callenv = obj->environment;
obj->environment = targetenv;
/* Bind arguments into the function's scope before entering it. */
/*
* Bind arguments into the call's scope, evaluating each one in the caller's.
* Passing is by value: assignment is what copies, so a structure argument
* deep-copies and a pointer argument copies its reference.
*/
leafptr = (expr->right != NULL ? expr->right->right : NULL);
argptr = (fndef->arglist != NULL ? fndef->arglist->right : NULL);
while ( leafptr != NULL && argptr != NULL ) {
akbasic_Environment *callerenv = obj->environment;
PASS(errctx, akbasic_runtime_evaluate(obj, leafptr, &argvalue));
obj->environment = fndef->environment;
PASS(errctx, akbasic_environment_assign(fndef->environment, argptr, argvalue, &unused));
obj->environment = callerenv;
obj->environment = callenv;
PASS(errctx, akbasic_environment_assign(callenv, argptr, argvalue, &unused));
obj->environment = targetenv;
leafptr = leafptr->next;
argptr = argptr->next;
}
obj->environment = fndef->environment;
obj->environment = callenv;
if ( fndef->expression != NULL ) {
PASS(errctx, akbasic_runtime_evaluate(obj, fndef->expression, dest));
obj->environment = obj->environment->parent;
PASS(errctx, akbasic_runtime_evaluate(obj, fndef->expression, &result));
/*
* Copied into the *caller's* scratch before the call's environment goes
* back to the pool. Handing back a pointer into the callee is what made
* two calls in one expression collide, and it would now be a pointer
* into a released slot as well.
*/
PASS(errctx, akbasic_runtime_prev_environment(obj));
PASS(errctx, akbasic_environment_new_value(targetenv, &out));
PASS(errctx, akbasic_value_clone(result, out));
*dest = out;
SUCCEED_RETURN(errctx);
}
/*
* A multi-line subroutine. Hand control to its environment and let the
* caller's step loop run it until RETURN pops back out. The result is the
* value RETURN parked in the child environment.
* caller's step loop run it until RETURN pops back out. RETURN parks its
* result on the *parent* -- this environment -- precisely so it outlives the
* pop, and it is copied into a fresh scratch here for the same reason the
* single-expression form does.
*/
obj->environment->gosubReturnLine = obj->environment->lineno + 1;
obj->environment->nextline = fndef->lineno;
callenv->gosubReturnLine = callenv->lineno + 1;
callenv->nextline = fndef->lineno;
while ( obj->environment != targetenv && obj->mode == AKBASIC_MODE_RUN ) {
PASS(errctx, akbasic_runtime_process_line_run(obj));
}
*dest = &fndef->environment->returnValue;
PASS(errctx, akbasic_environment_new_value(targetenv, &out));
PASS(errctx, akbasic_value_clone(&targetenv->returnValue, out));
*dest = out;
SUCCEED_RETURN(errctx);
}