Add FENCE word (SDK v1.9.0 scoping); fix severe pre-existing FORGET use-after-free
FENCE ( -- ) exposes the dict_fence_latest/dict_fence_here state FORGET already honored internally, letting callers (e.g. a future SDK capsule) raise the boundary after loading their own content -- no new VM fields, no policy logic beyond exposing existing state. Writing a direct test for it surfaced a real, severe, pre-existing bug in FORGET's relink logic, unrelated to FENCE itself and reproducible with the original boot-time fence alone: - Forgetting the single newest word incorrectly destroyed every other word back to the fence too, not just the target. - Forgetting an older word (correctly cascading to remove newer words too, per FORTH-79 semantics) crashed with SIGSEGV. Root cause: the relink code's target_prev pointer was, by construction, always inside the range the preceding loop had just freed whenever target wasn't vm->latest -- so writing through it was a use-after-free every time that branch executed. Fixed by removing the target_prev tracking and both branches entirely; vm->latest unconditionally becomes target_next (target's own captured, still-valid link) after the free loop, correct in every case. Added a FENCE test suite to dictionary_manipulation_words_test.c (Module 14) including the exact regression case (forgetting the newest word must not disturb an older one). Verified zero warnings and identical POST/dict_hash results across all three kernel architectures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
6d8b48f100
commit
4e7dcdf889
+50
@@ -1752,3 +1752,53 @@ Block range for `capsules/sdk.4th`, whenever it's written: `5200+` (clear of `tu
|
||||
once scoped: three-arch boot to `zuse)ok>`, POST green, `mkcapsule --lint` clean, HOWTOs
|
||||
present for everything shipped — matching every other acceptance bar in this document, nothing
|
||||
new invented for this specific release.
|
||||
|
||||
**Decisions made 2026-08-19 (user's calls, per the open questions above):** SDK vocabulary
|
||||
re-exports the cookbook (`turtle.4th` + `doe.4th`) plus `FENCE`; load model is REPL-only
|
||||
(`S" sdk.4th" EXEC`, matching `turtle.4th`'s own precedent, not wired into `init.4th`);
|
||||
kernel-only is explicitly accepted (the SDK is a kernel-side developer surface, hosted builds
|
||||
remain compile-sanity-only). Version string and `turtle.4th`'s unconfirmed rendering remain
|
||||
open, not decided here.
|
||||
|
||||
**`FENCE ( -- )` implemented, and a real, severe pre-existing bug in `FORGET` found and fixed
|
||||
in the process — not a design bug in the new word, a bug in code this session didn't touch
|
||||
until now.** Added `dictionary_word_fence()` right next to `FORGET` in `defining_words.c`
|
||||
(registered alongside it) — it does exactly what the scoping above proposed: raises
|
||||
`dict_fence_latest`/`dict_fence_here` to the current dictionary top, nothing more. While
|
||||
writing a direct reproduction test for it (three plain `CREATE`d words, forget the newest,
|
||||
confirm the older one survives), the older one did *not* survive — `UNKNOWN WORD` — with no
|
||||
`FENCE` call involved at all, using only the original boot-time fence. Traced to
|
||||
`defining_words.c`'s `FORGET` relink logic (`~653-660`, pre-fix): when the target being
|
||||
forgotten is `vm->latest` itself (the single newest word), the code branches on
|
||||
`target_prev == NULL` and — reading that as "nothing exists between `vm->latest` and the
|
||||
fence" — resets `vm->latest = vm->dict_fence_latest` directly, discarding *everything* back to
|
||||
the fence instead of just the one targeted word. That reasoning was simply wrong:
|
||||
`target_prev == NULL` only means target is newest, not that it's adjacent to the fence.
|
||||
|
||||
**Escalated on a second reproduction, past the first (narrower) framing — this is a
|
||||
use-after-free, not just an off-by-one.** Testing the *other* direction (forget the *oldest*
|
||||
of three words, which per FORTH-79 "target and newer" semantics should legitimately remove all
|
||||
three) produced a **SIGSEGV**, not wrong output. Root cause: the preceding free loop already
|
||||
frees every entry from `vm->latest` down through target inclusive — which includes
|
||||
`target_prev` (by construction, the search loop's `prev` pointer is always the entry
|
||||
immediately newer than target, and thus always inside that just-freed range whenever target
|
||||
isn't `vm->latest` itself). The `if (target_prev) { target_prev->link = ...; vm->latest =
|
||||
target_prev; }` branch was therefore *always* operating on already-freed memory when it ran at
|
||||
all — the `target_prev == NULL` branch (discussed above) merely hid this by taking the *other*
|
||||
path instead, in the one case where it happened not to matter. Fix: deleted the `target_prev`
|
||||
tracking and both branches entirely; `vm->latest` unconditionally becomes `target_next`
|
||||
(target's own captured `->link`, valid and outside the freed range) after the free loop —
|
||||
correct whether target was latest, oldest, or in the middle, and correct whether it was the
|
||||
only survivor-adjacent entry or the fence itself. Verified this collapses cleanly to the
|
||||
already-correct fence-boundary-rejection case too (`FORGET DUP` against the original boot
|
||||
fence still correctly refuses, no crash, dictionary intact afterward).
|
||||
|
||||
Added a `FENCE` `WordTestSuite` entry to `dictionary_manipulation_words_test.c` right after
|
||||
`FORGET`'s own (Module 14) — three cases: forgetting a post-`FENCE` word succeeds, forgetting a
|
||||
pre-`FENCE` word is refused, and `forget_latest_keeps_predecessor` — the exact regression this
|
||||
fix addresses, encoded as a permanent POST case so it can't silently regress again. Verified:
|
||||
zero build warnings hosted and on all three kernel architectures; hosted POST 1009→1012 (+3,
|
||||
matching the new test count exactly), 0 failed, 0 errors; three-arch kernel boot identical
|
||||
(1012/0/0 on amd64/aarch64/riscv64), `dict_hash` matches exactly across all three (changed from
|
||||
the pre-`FENCE` baseline as expected — a new C word legitimately changes the dictionary hash;
|
||||
cross-arch agreement is what's being checked, not stability against the prior baseline).
|
||||
|
||||
Reference in New Issue
Block a user