Enter a TRAP handler when the variable table is full

Two separable faults, both on the path a program takes when it is already in
trouble.

`akbasic_trap_set_error_variables()` reached `ER#` and `EL#` through
`akbasic_runtime_global()`, which *creates* a name the program never used -- and
creating one takes a variable slot. So a program that had filled the 128-slot
table could not have its handler entered at all, and because the failure
happened inside the error path rather than raising, nothing was reported and the
program carried on with the failing statement's effect quietly missing. A wrong
answer delivered as a right one, which is worse than an abort.

`akbasic_runtime_reserve_globals()` now creates both at runtime init, where
there is always room, and `clear_variables()` puts them back after `CLR` and
`NEW` empty the table.

Second: `report_and_reraise()` used a plain `PASS` around the report, so a
failure while reporting *replaced* the error the program had actually made --
"Maximum runtime variables reached" in place of the subscript that was out of
range. The secondary failure is now logged and the original is re-raised, which
is what a user needs to hear.

**The reduction in TODO.md no longer reproduces, and not because of this.** The
value-pool fix in the previous commit made a scalar free, so the pool can no
longer be emptied by creating names. The defect was still live through the
variable table: 124 names and a TRAP armed, and the handler was silently
skipped. That is what the new test in tests/trap_verbs.c pins, together with the
invariant -- both globals present before a program runs.

Verified by reverting the fix against the new tests: both fail, and the second
prints the log line the swallow used to eat, "could not report a BASIC error 515
(Out Of Bounds): Maximum runtime variables reached".

TODO.md section 6 item 33, struck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 23:48:56 -04:00
parent 05f241aca1
commit f5a35b4af6
5 changed files with 159 additions and 3 deletions

View File

@@ -67,6 +67,32 @@ akerr_ErrorContext *akbasic_runtime_global(akbasic_Runtime *obj, const char *nam
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_runtime_reserve_globals(akbasic_Runtime *obj)
{
PREPARE_ERROR(errctx);
akbasic_Variable *variable = NULL;
FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL runtime in reserve_globals");
/*
* ER# and EL# exist from the start, because the moment they are needed is
* the moment the interpreter is least able to make them.
*
* akbasic_trap_set_error_variables() reaches them through
* akbasic_runtime_global(), which *creates* a name the program never used --
* and creating one costs a variable slot. So a program that filled the
* variable table and then raised could not have its handler entered at all,
* and because the failure happened inside the error path rather than raising,
* the program carried on with the failing statement's effect quietly
* missing. A wrong answer delivered as a right one. TODO.md section 6 item
* 33 has the reduction.
*
* Called again from CLR and NEW, which empty the table this fills.
*/
PASS(errctx, akbasic_runtime_global(obj, "ER#", &variable));
PASS(errctx, akbasic_runtime_global(obj, "EL#", &variable));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_runtime_new_function(akbasic_Runtime *obj, akbasic_FunctionDef **dest)
{
PREPARE_ERROR(errctx);
@@ -215,6 +241,7 @@ akerr_ErrorContext *akbasic_runtime_init(akbasic_Runtime *obj, akbasic_TextSink
PASS(errctx, akbasic_runtime_new_environment(obj));
PASS(errctx, akbasic_runtime_zero(obj));
PASS(errctx, akbasic_scanner_zero(obj));
PASS(errctx, akbasic_runtime_reserve_globals(obj));
SUCCEED_RETURN(errctx);
}
@@ -450,6 +477,7 @@ akerr_ErrorContext *akbasic_runtime_set_mode(akbasic_Runtime *obj, int mode)
static akerr_ErrorContext *report_and_reraise(akbasic_Runtime *obj, akerr_ErrorContext *cause)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *reporting = NULL;
char message[AKERR_MAX_ERROR_CONTEXT_STRING_LENGTH];
int status = cause->status;
@@ -458,7 +486,28 @@ static akerr_ErrorContext *report_and_reraise(akbasic_Runtime *obj, akerr_ErrorC
obj->lasterrorstatus = status;
cause->handled = true;
IGNORE(akerr_release_error(cause));
PASS(errctx, akbasic_runtime_error(obj, AKBASIC_ERRCLASS_RUNTIME, message));
/*
* **Reporting can itself fail, and the program's own error still wins.**
*
* The way it fails is the `TRAP` dispatch inside akbasic_runtime_error():
* entering a handler takes a scope, and a program deep enough to have run
* out of them raises for that reason while already raising for another. A
* plain PASS here would return the *dispatch's* failure and lose the one the
* program needs to hear about -- "Environment pool exhausted" in place of
* the subscript that was actually out of range.
*
* So the secondary failure is logged where a developer will see it and the
* original is re-raised. TODO.md section 6 item 33 is where this was found;
* item 33's other half stops the dispatch needing to allocate at all, which
* is what makes this path rare rather than routine.
*/
reporting = akbasic_runtime_error(obj, AKBASIC_ERRCLASS_RUNTIME, message);
if ( reporting != NULL ) {
LOG_ERROR_WITH_MESSAGE(reporting, "could not report a BASIC error");
reporting->handled = true;
IGNORE(akerr_release_error(reporting));
}
FAIL_RETURN(errctx, status, "%s", message);
}

View File

@@ -72,6 +72,12 @@ static akerr_ErrorContext AKERR_NOIGNORE *clear_variables(akbasic_Runtime *obj)
FAIL_ZERO_RETURN(errctx, (root != NULL), AKERR_NULLPOINTER, "Runtime has no root environment");
PASS(errctx, akbasic_symtab_init(&root->variables, AKBASIC_MAX_VARIABLES));
PASS(errctx, akbasic_symtab_init(&root->functions, AKBASIC_MAX_FUNCTIONS));
/*
* Put the reserved globals back. They were in the table this just emptied,
* and the `TRAP` dispatch relies on never having to create them -- so a CLR
* that left them gone would reopen item 33 for the rest of the session.
*/
PASS(errctx, akbasic_runtime_reserve_globals(obj));
SUCCEED_RETURN(errctx);
}