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

@@ -157,11 +157,13 @@ akbasic_instrument(basic)
# The embedding example. It is built by default and registered as a test so the # The embedding example. It is built by default and registered as a test so the
# code README.md quotes cannot rot: a signature change breaks the build. # code README.md quotes cannot rot: a signature change breaks the build.
if(AKBASIC_BUILD_EXAMPLES) if(AKBASIC_BUILD_EXAMPLES)
add_executable(akbasic_example_embed examples/embed.c) foreach(_example IN ITEMS embed hostvars)
target_compile_options(akbasic_example_embed PRIVATE -Wall -Wextra) add_executable(akbasic_example_${_example} examples/${_example}.c)
target_link_libraries(akbasic_example_embed PRIVATE akbasic) target_compile_options(akbasic_example_${_example} PRIVATE -Wall -Wextra)
akbasic_instrument(akbasic_example_embed) target_link_libraries(akbasic_example_${_example} PRIVATE akbasic)
_add_test(NAME example_embed COMMAND akbasic_example_embed) akbasic_instrument(akbasic_example_${_example})
_add_test(NAME example_${_example} COMMAND akbasic_example_${_example})
endforeach()
endif() endif()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

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 ## Where the output goes
`PRINT` writes through an `akbasic_TextSink`, which is a record of function pointers plus whatever state you hang off `self`: `PRINT` writes through an `akbasic_TextSink`, which is a record of function pointers plus whatever state you hang off `self`:

41
TODO.md
View File

@@ -342,7 +342,7 @@ cmake -S . -B build-cov -DAKBASIC_COVERAGE=ON # 92.3% line, 96.9% function
| Verbs and functions | `src/runtime_commands.c`, `src/runtime_functions.c` | `basicruntime_{commands,functions}.go` | | Verbs and functions | `src/runtime_commands.c`, `src/runtime_functions.c` | `basicruntime_{commands,functions}.go` |
| Text sink | `src/sink_stdio.c` | `basicruntime_graphics.go`'s stdout mirror | | Text sink | `src/sink_stdio.c` | `basicruntime_graphics.go`'s stdout mirror |
| Driver | `src/main.c` | `main.go` | | Driver | `src/main.c` | `main.go` |
| Embedding example | `examples/embed.c` | *(new)* | | Embedding examples | `examples/embed.c`, `examples/hostvars.c` | *(new)* |
`akbasic_runtime_load(rt, source)` was added while writing the README's embedding `akbasic_runtime_load(rt, source)` was added while writing the README's embedding
section: a host usually holds its script as a string and wants the sink reserved for output, section: a host usually holds its script as a string and wants the sink reserved for output,
@@ -551,6 +551,41 @@ pinned by an assertion in the relevant unit test, and asserted *correctly* in
is dead code and `PRINT$ = 1` is accepted. Fix: strip the suffix before the lookup. Pinned is dead code and `PRINT$ = 1` is accepted. Fix: strip the suffix before the lookup. Pinned
in `tests/scanner_tokens.c`. in `tests/scanner_tokens.c`.
### Ours, not the reference's
17. **A host cannot reliably create a script variable while a script is suspended.** This one
is not inherited — it falls out of §1.4's environment pool meeting §1.6's bounded `run()`,
a combination the reference never had because its `run()` never returned.
Exchanging variables with an embedding host works and is documented in `README.md`: the
host calls `akbasic_environment_get()` to find or create a variable and
`akbasic_variable_set_*` / `_get_subscript` to move values across. 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 `akbasic_runtime_run()` is usually inside a
`FOR` or `GOSUB` scope, so `obj->environment` is that scope. A variable created through
it lands in the loop's scope and dies when the environment pops — the script reads the
value correctly inside the loop and gets `0` immediately after it.
- Reaching for the root explicitly does not help. `akbasic_environment_get` only
auto-creates when `obj->runtime->environment == obj` (`src/environment.c:242`), so with a
child active it returns `NULL` through `dest` **without raising**, and an unchecked host
dereferences it.
`examples/hostvars.c` demonstrates both rather than describing them, and prints what it
observes, so the day this is fixed that output changes and the example needs revisiting.
Fix: add `akbasic_runtime_global(akbasic_Runtime *obj, const char *name, akbasic_Variable **dest)`
that walks `obj->environment` to the root and creates there unconditionally, and point the
README at it instead of at `akbasic_environment_get`. Roughly fifteen lines. The one design
question worth settling first is what it should do when the script is suspended inside a
*user function's* scope — that environment is owned by the funcdef rather than the pool
(§1.4), and "root" is still the right answer there, but it is worth being deliberate about
it rather than discovering it. Tests: create a global while suspended in a `FOR` body and
assert the script still sees it after `NEXT`; the same from inside a `GOSUB`; and the
NULL-without-error path, which should become an impossible state.
--- ---
## 7. Filing gaps against `libakgl` ## 7. Filing gaps against `libakgl`
@@ -579,9 +614,9 @@ entire test corpus and passes clean under ASan and UBSan.
| Gate | Result | | Gate | Result |
|---|---| |---|---|
| `ctest` | 60/60 — 41 golden cases, 17 unit tests, 1 embedding example, 1 known-failing | | `ctest` | 61/61 — 41 golden cases, 17 unit tests, 2 embedding examples, 1 known-failing |
| Golden corpus | 41/41 byte-exact, driven in place from the submodule | | Golden corpus | 41/41 byte-exact, driven in place from the submodule |
| ASan + UBSan | 60/60 | | ASan + UBSan | 61/61 |
| Line coverage | 92.3% (2823/3058) — above the 90% gate | | Line coverage | 92.3% (2823/3058) — above the 90% gate |
| Function coverage | 96.9% (221/228) | | Function coverage | 96.9% (221/228) |
| Warnings | none under `-Wall -Wextra` | | Warnings | none under `-Wall -Wextra` |

185
examples/hostvars.c Normal file
View File

@@ -0,0 +1,185 @@
/**
* @file hostvars.c
* @brief Exchanging variables between a host C program and a BASIC script.
*
* This is the code in README.md's "Exchanging variables with the host" section,
* kept here so it is compiled and run by every build rather than rotting in a
* document.
*
* The safe pattern is: seed variables *before* akbasic_runtime_start(), read
* results *after* the script stops. Poking a variable while the script is
* suspended part-way through is sharp in two ways that this file demonstrates at
* the bottom -- see TODO.md section 6 item 17.
*/
#include <stdio.h>
#include <string.h>
#include <akerror.h>
#include <akbasic/error.h>
#include <akbasic/runtime.h>
#include <akbasic/sink.h>
static akbasic_Runtime SCRIPT;
static akbasic_TextSink SINK;
static akbasic_StdioSink SINKSTATE;
static const char *PROGRAM =
"10 PRINT \"HERO \" + NAME$ + \" HAS \" + HP# + \" HP\"\n"
"20 SCORE# = HP# * LEVEL#\n"
"30 STATUS$ = \"OK\"\n";
/* ------------------------------------------------------- host -> script -- */
/*
* Seed an integer the script can read. akbasic_environment_get() creates the
* variable if it does not exist, and the type comes from the name's suffix, so
* "HP#" is an integer and "HP$" would be a string.
*
* The subscript list is {0} with a count of 1 because every scalar is really a
* one-element array -- the same shape the interpreter itself uses.
*/
static 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);
}
static akerr_ErrorContext AKERR_NOIGNORE *host_set_string(akbasic_Runtime *obj, const char *name, const char *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_string(variable, value, subscript, 1));
SUCCEED_RETURN(errctx);
}
/* ------------------------------------------------------- script -> host -- */
static 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);
}
static akerr_ErrorContext AKERR_NOIGNORE *host_get_string(akbasic_Runtime *obj, const char *name, char *dest, size_t len)
{
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_STRING), AKBASIC_ERR_TYPE,
"%s is not a string", name);
FAIL_ZERO_RETURN(errctx, (strlen(value->stringval) < len), AKBASIC_ERR_BOUNDS,
"%s does not fit the caller's buffer", name);
snprintf(dest, len, "%s", value->stringval);
SUCCEED_RETURN(errctx);
}
/* ------------------------------------------------------------- the demo -- */
/*
* The hazard, demonstrated rather than described. A script suspended part-way
* through is usually inside a FOR or GOSUB scope, and a variable created there
* dies when that scope pops.
*/
static const char *LOOPING_PROGRAM =
"10 FOR I# = 1 TO 3\n"
"20 PRINT \" in loop, GIFT# = \" + GIFT#\n"
"30 NEXT I#\n"
"40 PRINT \" after loop, GIFT# = \" + GIFT#\n";
static akerr_ErrorContext AKERR_NOIGNORE *demonstrate_scope_hazard(void)
{
PREPARE_ERROR(errctx);
akbasic_Environment *root = NULL;
akbasic_Variable *variable = NULL;
PASS(errctx, akbasic_runtime_init(&SCRIPT, &SINK));
root = SCRIPT.environment;
PASS(errctx, akbasic_runtime_load(&SCRIPT, LOOPING_PROGRAM));
PASS(errctx, akbasic_runtime_start(&SCRIPT, AKBASIC_MODE_RUN));
/* Run far enough to be inside the loop body. */
PASS(errctx, akbasic_runtime_run(&SCRIPT, 12));
printf("suspended inside the loop? %s\n",
(SCRIPT.environment != root ? "yes" : "no"));
/* Poking through the *active* scope creates the variable in the loop. */
PASS(errctx, host_set_int(&SCRIPT, "GIFT#", 42));
/* Reaching for the root scope while a child is active simply misses. */
PASS(errctx, akbasic_environment_get(root, "UNREACHABLE#", &variable));
printf("can the host create in the root scope mid-run? %s\n",
(variable != NULL ? "yes" : "no -- get() returns NULL, with no error"));
PASS(errctx, akbasic_runtime_run(&SCRIPT, 0));
SUCCEED_RETURN(errctx);
}
int main(void)
{
PREPARE_ERROR(errctx);
char status[64];
int64_t score = 0;
int rc = 0;
ATTEMPT {
CATCH(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, stdin));
CATCH(errctx, akbasic_runtime_init(&SCRIPT, &SINK));
CATCH(errctx, akbasic_runtime_load(&SCRIPT, PROGRAM));
/*
* Seed before starting. At this point the root environment is the active
* one, so anything created here is global to the script.
*/
CATCH(errctx, host_set_int(&SCRIPT, "HP#", 100));
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));
/* Read the results back once it has stopped. */
CATCH(errctx, host_get_int(&SCRIPT, "SCORE#", &score));
CATCH(errctx, host_get_string(&SCRIPT, "STATUS$", status, sizeof(status)));
printf("host read back: SCORE# = %lld, STATUS$ = \"%s\"\n",
(long long)score, status);
printf("\n--- the mid-run scope hazard (TODO.md 6.17) ---\n");
CATCH(errctx, demonstrate_scope_hazard());
} CLEANUP {
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "host variable exchange failed");
rc = 1;
} FINISH_NORETURN(errctx);
return rc;
}