Document host/script variable exchange, and file the scope hazard it exposes

The host can expose variables to a BASIC program, in both directions and for
every type, with no marshalling layer -- akbasic_environment_get() finds or
creates by name, the type comes from the suffix as it does for BASIC code, and
host and script share the same akbasic_Value. Nothing new was needed to make
that work.

Testing it before writing it up turned up a hazard worth stating plainly, so the
README documents the pattern and section 6 item 17 records the gap.

Seeding before akbasic_runtime_start() and reading after the script stops is
safe, and so is updating an existing global at any time, because the parent
chain is searched. Creating one mid-run is not, in two ways, and both are
silent. A script suspended part-way through a bounded run() is usually inside a
FOR or GOSUB scope, so a variable created through obj->environment lands in that
scope and dies when it pops -- the script reads it correctly inside the loop and
gets 0 immediately after. And reaching for the root explicitly does not help:
akbasic_environment_get only auto-creates when the environment it is given is
the active one, so with a child active it returns NULL through dest without
raising, and an unchecked host dereferences it.

This one is ours rather than inherited. It falls out of the environment pool
meeting the bounded run(), a combination the reference never had because its
run() never returned.

examples/hostvars.c demonstrates both hazards rather than describing them, and
prints what it observes, so the day this is fixed that output changes and the
example needs revisiting. Like examples/embed.c it is built and run by every
build. Both README snippets were extracted and compiled as a check.

Not fixed here: akbasic_runtime_global() would close it in about fifteen lines,
but what it should do when the script is suspended inside a user function's
scope is a design question worth settling deliberately rather than discovering.
Filed with the proposed signature and the tests it wants.

ctest 61/61; ASan+UBSan 61/61; no warnings under -Wall -Wextra.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 06:50:55 -04:00
parent cde6fa8f59
commit e51b9a1694
4 changed files with 313 additions and 8 deletions

View File

@@ -203,6 +203,89 @@ akerr_ErrorContext AKERR_NOIGNORE *script_tick(void)
}
```
## Exchanging variables with the host
Yes — in both directions, for integers, floats and strings, using the same variable pool the
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
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.
```c
/* host -> script, before the script starts */
akerr_ErrorContext AKERR_NOIGNORE *host_set_int(akbasic_Runtime *obj, const char *name, int64_t value)
{
PREPARE_ERROR(errctx);
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_variable_set_integer(variable, value, subscript, 1));
SUCCEED_RETURN(errctx);
}
/* script -> host, after it stops */
akerr_ErrorContext AKERR_NOIGNORE *host_get_int(akbasic_Runtime *obj, const char *name, int64_t *dest)
{
PREPARE_ERROR(errctx);
akbasic_Variable *variable = NULL;
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_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);
*dest = value->intval;
SUCCEED_RETURN(errctx);
}
```
Used like this, with `akbasic_variable_set_string` and `set_float` as the other two:
```c
CATCH(errctx, akbasic_runtime_load(&SCRIPT, PROGRAM));
CATCH(errctx, host_set_int(&SCRIPT, "HP#", 100)); /* seed */
CATCH(errctx, host_set_int(&SCRIPT, "LEVEL#", 7));
CATCH(errctx, host_set_string(&SCRIPT, "NAME$", "LINK"));
CATCH(errctx, akbasic_runtime_start(&SCRIPT, AKBASIC_MODE_RUN));
CATCH(errctx, akbasic_runtime_run(&SCRIPT, 0));
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
**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:
* 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.
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.
`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.
## Where the output goes
`PRINT` writes through an `akbasic_TextSink`, which is a record of function pointers plus whatever state you hang off `self`: