Add akbasic_runtime_global: a host variable lands in the script's root scope

A host creating a variable while a script was suspended got it in whatever
scope was active -- usually a FOR or GOSUB body -- and it died when the body
popped, silently. Reaching for the root by hand returned NULL without raising,
because environment_get only auto-creates in the active environment.

Both are still true of environment_get, which is correct for what the
interpreter uses it for. The README and the example now point somewhere else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 11:53:05 -04:00
parent a31058cf37
commit 5b7b7d2ed9
9 changed files with 425 additions and 87 deletions

View File

@@ -254,7 +254,7 @@ Yes — in both directions, for integers, floats and strings, using the same var
script itself uses. There is no marshalling layer and no copy: the host and the script are
looking at the same `akbasic_Value`.
`akbasic_environment_get()` finds a variable by name and creates it if it does not exist. The
`akbasic_runtime_global()` finds a variable by name and creates it if it does not exist. The
type comes from the name's suffix, exactly as it does for BASIC code, so `HP#` is an integer
and `NAME$` is a string. Every scalar is really a one-element array, which is why the subscript
list is `{0}` with a count of 1.
@@ -267,9 +267,7 @@ akerr_ErrorContext AKERR_NOIGNORE *host_set_int(akbasic_Runtime *obj, const char
akbasic_Variable *variable = NULL;
int64_t subscript[1] = { 0 };
PASS(errctx, akbasic_environment_get(obj->environment, name, &variable));
FAIL_ZERO_RETURN(errctx, (variable != NULL), AKERR_KEY,
"could not reach variable %s from the active scope", name);
PASS(errctx, akbasic_runtime_global(obj, name, &variable));
PASS(errctx, akbasic_variable_set_integer(variable, value, subscript, 1));
SUCCEED_RETURN(errctx);
}
@@ -282,8 +280,7 @@ akerr_ErrorContext AKERR_NOIGNORE *host_get_int(akbasic_Runtime *obj, const char
akbasic_Value *value = NULL;
int64_t subscript[1] = { 0 };
PASS(errctx, akbasic_environment_get(obj->environment, name, &variable));
FAIL_ZERO_RETURN(errctx, (variable != NULL), AKERR_KEY, "no variable %s", name);
PASS(errctx, akbasic_runtime_global(obj, name, &variable));
PASS(errctx, akbasic_variable_get_subscript(variable, subscript, 1, &value));
FAIL_NONZERO_RETURN(errctx, (value->valuetype != AKBASIC_TYPE_INTEGER), AKBASIC_ERR_TYPE,
"%s is not an integer", name);
@@ -310,26 +307,29 @@ CATCH(errctx, host_get_int(&SCRIPT, "SCORE#", &score)); /* collect */
The full thing, including the string and float forms, is in
[`examples/hostvars.c`](examples/hostvars.c). It is built and run by every build.
### The one rule: seed before, read after
### Which scope it lands in, and why you should not care
**Do this between runs, not during one.** Seed before `akbasic_runtime_start()` and read after
the script has stopped. Poking a variable while a script is suspended part-way through a
bounded `akbasic_runtime_run()` is sharp in two ways, and both are silent:
`akbasic_runtime_global()` always resolves against the script's outermost scope, so seeding
before `akbasic_runtime_start()`, reading after the script stops, and creating a variable while
a script is suspended part-way through a bounded `akbasic_runtime_run()` all do the same,
obvious thing.
* A suspended script is usually inside a `FOR` or `GOSUB` scope, and `obj->environment` is that
scope rather than the global one. A variable *created* there dies when the scope pops — the
script reads it correctly inside the loop and gets `0` the moment the loop ends.
* Reaching for the global scope explicitly does not help. Only the currently active environment
auto-creates, so `akbasic_environment_get(root, "NEW#", &var)` with a child active hands back
`NULL` and no error, and an unchecked host dereferences it.
That is worth one paragraph of history, because the obvious-looking alternative is a trap.
`akbasic_environment_get()` resolves against `obj->environment` — whatever scope is *active*
and a suspended script is usually inside a `FOR` or `GOSUB` body. A variable created through it
lands in that body and dies when the body pops: the script reads it correctly inside the loop
and gets `0` the moment the loop ends, with nothing raised anywhere. Reaching for the root
explicitly does not help either, because only the active environment auto-creates, so
`akbasic_environment_get(root, "NEW#", &var)` with a child active hands back `NULL` and no
error and an unchecked host dereferences it.
Reading and *updating an existing* global is safe at any time — the parent chain is searched,
so a variable that already exists in the global scope is found from anywhere. It is only
*creation* that lands in the wrong place.
Both of those are still true of `akbasic_environment_get()`, which is the right behaviour for
what the *interpreter* uses it for. They are simply not your problem: use
`akbasic_runtime_global()`.
`examples/hostvars.c` demonstrates both hazards rather than describing them. This is filed as
[`TODO.md`](TODO.md) section 6 item 17; the fix is a small `akbasic_runtime_global()` that
resolves against the root scope regardless of what is active.
[`examples/hostvars.c`](examples/hostvars.c) shows the difference rather than describing it,
and `tests/hostvars.c` asserts it — a global created from inside a `FOR` body, and from inside
a `GOSUB`, and still readable by the script afterwards.
## Where the output goes