diff --git a/CMakeLists.txt b/CMakeLists.txt index a3743cd..5adb870 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -157,11 +157,13 @@ akbasic_instrument(basic) # 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. if(AKBASIC_BUILD_EXAMPLES) - add_executable(akbasic_example_embed examples/embed.c) - target_compile_options(akbasic_example_embed PRIVATE -Wall -Wextra) - target_link_libraries(akbasic_example_embed PRIVATE akbasic) - akbasic_instrument(akbasic_example_embed) - _add_test(NAME example_embed COMMAND akbasic_example_embed) + foreach(_example IN ITEMS embed hostvars) + add_executable(akbasic_example_${_example} examples/${_example}.c) + target_compile_options(akbasic_example_${_example} PRIVATE -Wall -Wextra) + target_link_libraries(akbasic_example_${_example} PRIVATE akbasic) + akbasic_instrument(akbasic_example_${_example}) + _add_test(NAME example_${_example} COMMAND akbasic_example_${_example}) + endforeach() endif() # --------------------------------------------------------------------------- diff --git a/README.md b/README.md index 2d65487..e3954e8 100644 --- a/README.md +++ b/README.md @@ -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`: diff --git a/TODO.md b/TODO.md index 94f6c3e..3a48942 100644 --- a/TODO.md +++ b/TODO.md @@ -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` | | Text sink | `src/sink_stdio.c` | `basicruntime_graphics.go`'s stdout mirror | | 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 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 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` @@ -579,9 +614,9 @@ entire test corpus and passes clean under ASan and UBSan. | 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 | -| ASan + UBSan | 60/60 | +| ASan + UBSan | 61/61 | | Line coverage | 92.3% (2823/3058) — above the 90% gate | | Function coverage | 96.9% (221/228) | | Warnings | none under `-Wall -Wextra` | diff --git a/examples/hostvars.c b/examples/hostvars.c new file mode 100644 index 0000000..fe46b2e --- /dev/null +++ b/examples/hostvars.c @@ -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 +#include + +#include + +#include +#include +#include + +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; +}