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

68
TODO.md
View File

@@ -895,38 +895,35 @@ question.
### 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.
17. ~~**A host cannot reliably create a script variable while a script is suspended.**~~
**Fixed.** `akbasic_runtime_global(obj, name, dest)` walks `obj->environment` to the root
and finds or creates there unconditionally. `README.md` and `examples/hostvars.c` both point
at it now, and `tests/hostvars.c` asserts it: a global created from inside a `FOR` body and
from inside a `GOSUB`, still readable by the script after the body pops, plus the
seed-before / read-after path that always worked.
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:
This one was not inherited — it fell out of §1.4's environment pool meeting §1.6's bounded
`run()`, a combination the reference never had because its `run()` never returned. Both
failure modes were silent: a variable created through `akbasic_environment_get()` landed in
the loop's scope and died with it, so the script read it correctly inside the loop and got
`0` immediately after; and reaching for the root explicitly returned `NULL` through `dest`
**without raising**, because that function only auto-creates when
`obj->runtime->environment == obj`.
- 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.
Three things settled while doing it, each worth keeping:
`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.
- **The design question §6 named has an answer.** Suspended inside a *user function's*
scope, the root is still right and the walk still gets there: a funcdef's environment is
initialized with the caller's as its parent (`akbasic_runtime_user_function`), so the
chain terminates at the same root as any other.
- **`akbasic_environment_create()` is the new half**, and it searches *only* the scope it
is given — no walk in either direction. Walking up would find an outer variable and put
the value somewhere other than where the caller asked for it.
- **`akbasic_environment_get()` is unchanged.** Declining to create in a non-active scope is
the right behaviour for what the interpreter uses it for; it is only wrong as a *host*
API, which is why the answer was a new entry point rather than a change to that one.
`tests/hostvars.c` asserts the old behaviour too, so a later "tidy-up" has to argue with a
test.
---
@@ -1019,10 +1016,10 @@ entire test corpus and passes clean under ASan and UBSan.
| Gate | Result |
|---|---|
| `ctest` | 74/74 — 41 upstream golden cases, 8 local ones, 22 unit tests, 2 embedding examples, 1 known-failing |
| `ctest` with `-DAKBASIC_WITH_AKGL=ON` | 73/73 — the same, minus the three `no_device` cases the SDL driver contradicts, plus `akgl_backends` and `akgl_frontend`; the `akgl_build` CI job |
| `ctest` | 75/75 — 41 upstream golden cases, 8 local ones, 23 unit tests, 2 embedding examples, 1 known-failing |
| `ctest` with `-DAKBASIC_WITH_AKGL=ON` | 74/74 — the same, minus the three `no_device` cases the SDL driver contradicts, plus `akgl_backends` and `akgl_frontend`; the `akgl_build` CI job |
| Golden corpus | 41/41 byte-exact, driven in place from the submodule — **and 41/41 again through the SDL binary**, which is most of what proves the frontend changes no output |
| ASan + UBSan | 74/74 |
| ASan + UBSan | 75/75 |
| Line coverage | 93.6% (3618/3867) — above the 90% gate |
| Function coverage | 97.8% (267/273) |
| Warnings | none under `-Wall -Wextra` |
@@ -1137,14 +1134,13 @@ What remains, in priority order:
deliberately does not clone. Two apt packages against two more submodules is an easy trade.
Every step was verified from a clean clone before being written down, not inferred.
5. **§6 items 16 and 17.** Items 10 and 1215 are fixed, each with a regression test in the
5. **§6 item 16.** Items 10, 1215 and 17 are fixed, each with a regression test in the
suite that pinned it. What is left is not more of the same:
- **Item 16** is a *decision*, not a fix. Making the "Reserved word in variable name" check
fire costs an upstream golden case, because the reference's own `strreverse.bas` names a
variable `INPUT$`. §6 states the trade; somebody has to make it.
- **Item 17** is ours: a host cannot reliably create a script variable while a script is
suspended. The fix is `akbasic_runtime_global()` and roughly fifteen lines. §6 describes
it and names the one design question to settle first.
- ~~**Item 17**~~ **Done**`akbasic_runtime_global()`, `tests/hostvars.c`, and the README
and example both pointed at it.
6. **Mutation survivors in `src/value.c`**~~closed~~, with one correction to what was
written here before. `tests/value_arithmetic.c` grew two functions,
`test_maximum_length_string()` and `test_pool_starts_empty()`, and each was verified by