feat(v4.0.0): address faults -- an address outside memory is guarded (D-14)

Ruled 2026-10-04: guarded, an error shown, back to the prompt.

- The executor checks every address a programme uses (P, A, B) before
  using it.  Outside memory the opcode does nothing, the rest of its word
  is not executed, and P becomes the node's fault handler; a node with no
  handler stops.  v4_node_load/store never index outside memory.
- capsule/quit.v4: (FAULT), the host node's handler, prints
  "Address out of range", ends an open definition, prints ERROR and
  returns to the prompt.
- tests: every memory opcode and P in test_exec.c; from the prompt, from
  inside nested words and loops, in test_host_quit.c.

This closes the hole node.h described: a wild address used to index the
model's own memory gigabytes out of bounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rajames
2026-10-04 12:04:59 -04:00
co-authored by Claude Opus 5.5
parent b5d644d498
commit b3d2d56d11
11 changed files with 277 additions and 74 deletions
+105
View File
@@ -316,6 +316,110 @@ static void test_soak(void)
CHECK(v4_node_guards_intact(&n), "soak left the guards intact");
}
/* D-14: an address outside node memory is a fault. The opcode does nothing,
* the rest of its word is not executed, and P becomes the handler; with no
* handler the node stops. */
#define HANDLER ((v4_cell)40)
static void test_address_faults(void)
{
static const v4_cell bad[] = { -1, (v4_cell)V4_NODE_WORDS, (v4_cell)V4_NODE_WORDS + 1, MINC, MAXC, (v4_cell)(V4_NODE_WORDS * 4u) };
static const struct { unsigned op; int reg; const char *name; } m[] = {
{ V4_OP_FETCH_A, 'a', "@" }, { V4_OP_FETCH_INC, 'a', "@+" }, { V4_OP_FETCH_B, 'b', "@b" },
{ V4_OP_STORE_A, 'a', "!" }, { V4_OP_STORE_INC, 'a', "!+" }, { V4_OP_STORE_B, 'b', "!b" },
};
unsigned i, k;
for (i = 0; i < sizeof bad / sizeof bad[0]; i++)
for (k = 0; k < sizeof m / sizeof m[0]; k++) {
unsigned retired;
fresh();
v4_node_fault_attach(&n, HANDLER);
dpush(11); dpush(22);
n.a = m[k].reg == 'a' ? bad[i] : 7;
n.b = m[k].reg == 'b' ? bad[i] : 7;
n.mem[0] = word6(V4_OP_DUP, m[k].op, V4_OP_DROP, V4_OP_DROP, NOP, NOP);
n.p = 0;
retired = v4_exec_step_word(&n, &es, &h);
CHECK(n.faults == 1 && n.fault_addr == bad[i] && !n.stopped, "%s at %lld is a fault", m[k].name, (long long)bad[i]);
CHECK(n.p == HANDLER, "%s: P is the handler", m[k].name);
CHECK(retired == 1, "%s: only the dup before it retired (%u)", m[k].name, retired);
CHECK(n.ds.t == 22 && n.ds.s == 22, "%s: it neither pushed nor popped, and the drops after it did not run", m[k].name);
CHECK(n.a == (m[k].reg == 'a' ? bad[i] : 7) && n.b == (m[k].reg == 'b' ? bad[i] : 7), "%s: A and B untouched", m[k].name);
CHECK(v4_node_guards_intact(&n), "%s: guards intact", m[k].name);
}
/* the last word of memory is not a fault, and the first is not */
fresh(); v4_node_fault_attach(&n, HANDLER);
n.mem[V4_NODE_WORDS - 1u] = 77; n.a = (v4_cell)(V4_NODE_WORDS - 1u); run1(V4_OP_FETCH_A);
CHECK(n.faults == 0 && n.ds.t == 77, "the last word of memory can be fetched");
fresh(); v4_node_fault_attach(&n, HANDLER);
dpush(5); n.b = (v4_cell)(V4_NODE_WORDS - 1u); run1(V4_OP_STORE_B);
CHECK(n.faults == 0 && n.mem[V4_NODE_WORDS - 1u] == 5, "and stored");
/* P outside memory: nothing is fetched or executed */
for (i = 0; i < sizeof bad / sizeof bad[0]; i++) {
fresh(); v4_node_fault_attach(&n, HANDLER);
dpush(1);
n.p = bad[i];
CHECK(v4_exec_step_word(&n, &es, &h) == 0 && n.faults == 1 && n.fault_addr == bad[i] && n.p == HANDLER && n.ds.t == 1,
"P at %lld is a fault", (long long)bad[i]);
}
/* @p in the last word of memory: its literal would be past the end */
fresh(); v4_node_fault_attach(&n, HANDLER);
dpush(1);
n.mem[V4_NODE_WORDS - 1u] = word6(V4_OP_FETCH_P, NOP, NOP, NOP, NOP, NOP);
n.p = (v4_cell)(V4_NODE_WORDS - 1u);
CHECK(v4_exec_step_word(&n, &es, &h) == 0 && n.faults == 1 && n.fault_addr == (v4_cell)V4_NODE_WORDS && n.p == HANDLER && n.ds.t == 1,
"a literal past the end of memory is a fault");
fresh(); v4_node_fault_attach(&n, HANDLER);
dpush(1);
n.mem[V4_NODE_WORDS - 1u] = word6(V4_OP_STORE_P, NOP, NOP, NOP, NOP, NOP);
n.p = (v4_cell)(V4_NODE_WORDS - 1u);
CHECK(v4_exec_step_word(&n, &es, &h) == 0 && n.faults == 1 && n.p == HANDLER && n.ds.t == 1, "and so is !p there");
/* a jump out of memory faults at the next fetch */
fresh(); v4_node_fault_attach(&n, HANDLER);
rpush(-5);
n.mem[0] = word6(V4_OP_SEMI, NOP, NOP, NOP, NOP, NOP);
n.p = 0;
CHECK(v4_exec_step_word(&n, &es, &h) == 1 && n.p == -5 && n.faults == 0, "; to an address outside memory is not yet a fault");
CHECK(v4_exec_step_word(&n, &es, &h) == 0 && n.p == HANDLER && n.faults == 1 && n.fault_addr == -5, "the fetch there is");
/* the handler runs, and faults are counted */
fresh(); v4_node_fault_attach(&n, HANDLER);
n.mem[HANDLER] = word6(V4_OP_DUP, V4_OP_ADD, NOP, NOP, NOP, NOP);
dpush(21); n.a = -1; run1(V4_OP_FETCH_A);
CHECK(v4_exec_step_word(&n, &es, &h) == 6 && n.ds.t == 42, "the handler's code runs next");
n.a = -1; run1(V4_OP_FETCH_A); n.a = -1; run1(V4_OP_STORE_A);
CHECK(n.faults == 3, "each fault is counted");
v4_node_fault_attach(&n, HANDLER);
CHECK(n.faults == 0 && n.fault_vector == HANDLER, "attaching clears the count");
/* with no handler the node stops */
fresh();
CHECK(n.fault_vector == -1 && n.faults == 0 && !n.stopped, "a fresh node has no handler and has not stopped");
dpush(9); n.a = -1;
CHECK(run1(V4_OP_FETCH_A) == 0 && n.stopped && n.faults == 1 && n.ds.t == 9, "with no handler a fault stops the node");
n.mem[0] = word6(V4_OP_DUP, V4_OP_ADD, NOP, NOP, NOP, NOP); n.p = 0;
CHECK(v4_exec_step_word(&n, &es, &h) == 0 && n.p == 0 && n.ds.t == 9, "a stopped node executes nothing");
v4_node_fault_attach(&n, HANDLER);
CHECK(!n.stopped && v4_exec_step_word(&n, &es, &h) == 6 && n.ds.t == 18, "attaching a handler lets it run again");
v4_node_fault_attach(&n, (v4_cell)V4_NODE_WORDS);
CHECK(n.fault_vector == -1, "a handler address outside memory detaches");
v4_node_reset(&n);
CHECK(n.fault_vector == -1 && !n.stopped && n.faults == 0, "reset detaches the handler");
/* the C accessors never index outside memory */
fresh();
for (i = 0; i < sizeof bad / sizeof bad[0]; i++) {
v4_node_store(&n, bad[i], 123);
CHECK(v4_node_load(&n, bad[i]) == 0 && v4_node_fetch(&n, bad[i]) == 0 && !v4_node_addr_ok(bad[i]),
"load, fetch and store at %lld touch nothing", (long long)bad[i]);
}
CHECK(n.faults == 0 && v4_node_guards_intact(&n) && v4_node_addr_ok(0) && v4_node_addr_ok((v4_cell)(V4_NODE_WORDS - 1u)),
"and they are not faults: only a running programme faults");
}
int main(void)
{
printf("v4 exec tests: V4_CELL_BITS=%d\n", V4_CELL_BITS);
@@ -332,6 +436,7 @@ int main(void)
#if V4_CELL_BITS == 64
test_iword_in_wide_cell();
#endif
test_address_faults();
test_soak();
printf(" %d checks, %d failures\n", checks, failures);
+27 -1
View File
@@ -20,6 +20,7 @@
* the text and carries on. Compiled into a word, v3's crashes (SIGSEGV).
* - control words that do not match give ERROR alone; v3 names the word.
* - QUERY takes 80 characters (FORTH-79); v3's line is 255.
* - an address outside memory says so (D-14); v3 gives ERROR alone.
*/
#include "v4/text.h"
#include "v4/testcode.h"
@@ -38,7 +39,7 @@ static v4_node n;
static v4_exec_state es;
static v4_heat h;
static v4_text tx;
static v4_cell w_quit, w_key, w_key_end, capsule_latest;
static v4_cell w_quit, w_key, w_key_end, w_fault, capsule_latest;
static char out[V4_CONSOLE_CAP + 1];
static long last_steps;
@@ -71,6 +72,7 @@ static void boot_with(unsigned depth)
n.mem[NODE_ERROR] = 0;
v4_node_console_attach(&n, CONSOLE_TX);
v4_node_console_input_attach(&n, CONSOLE_RX, CONSOLE_ST);
v4_node_fault_attach(&n, w_fault);
v4_dstack_reset(&n.ds);
v4_rstack_reset(&n.rs);
v4_exec_reset(&es);
@@ -180,6 +182,7 @@ int main(void)
w_quit = v4_text_word(&tx, "QUIT");
w_key = v4_text_word(&tx, "KEY");
w_key_end = v4_text_word(&tx, "CR"); /* the word after KEY in core.v4 */
w_fault = v4_text_word(&tx, "(FAULT)");
capsule_latest = v4_text_latest(&tx);
CHECK(w_key_end > w_key && w_key_end - w_key <= 12, "KEY is the few words before CR");
@@ -230,6 +233,29 @@ int main(void)
CHECK(is(say("67 EMIT\n"), "C ok\nok> ") && is(say("H3\n"), "UNKNOWN WORD: 'H3'\n ERROR\nok> "), "and the definition is gone");
CHECK(is(say(": H4 68 EMIT ; H4\n"), "D ok\nok> "), "the next definition compiles and runs");
/* ---- an address outside memory (D-14): a message, ERROR, and the prompt ---- */
{
static const char *const lines[] = {
"65 EMIT -1 @ 66 EMIT\n", "65 EMIT 5 -1 ! 66 EMIT\n", "65 EMIT 16384 @ 66 EMIT\n", "65 EMIT 5 16384 ! 66 EMIT\n",
"65 EMIT 99999999 C@ 66 EMIT\n", "65 EMIT 7 -4 C! 66 EMIT\n", "65 EMIT -1 EXECUTE 66 EMIT\n", "65 EMIT 1 -1 +! 66 EMIT\n",
"65 EMIT PAD -8 4 CMOVE 66 EMIT\n", "65 EMIT -9 COUNT 66 EMIT\n", "65 EMIT -9 3 TYPE 66 EMIT\n",
": F0 -1 @ ; : F1 F0 ; : F2 F1 ; : F3 65 EMIT F2 66 EMIT ; F3 67 EMIT\n",
": G0 9 0 DO I 3 = IF 0 -1 ! THEN LOOP ; : G1 65 EMIT G0 66 EMIT ; G1\n",
};
boot();
for (i = 0; i < sizeof lines / sizeof lines[0]; i++) {
unsigned before = n.faults;
CHECK(is(say(lines[i]), "AAddress out of range\n ERROR\nok> "), "fault %u", i);
CHECK(n.faults == before + 1 && !n.stopped && n.mem[STATE] == 0, "fault %u: one fault, and the node is at its prompt", i);
CHECK(is(say("1 2 + 48 + EMIT\n"), "3 ok\nok> "), "and the next line runs, after fault %u", i);
}
CHECK(is(say("16383 @ DROP 0 @ DROP 67 EMIT\n"), "C ok\nok> "), "the first and last words of memory are not faults");
CHECK(is(say(": H5 1 IF [ -1 @ ]\n"), "Address out of range\n ERROR\nok> ") && n.mem[STATE] == 0 && n.mem[CFP] == CFS_W,
"a fault while a definition is open ends it");
CHECK(is(say("H5\n"), "UNKNOWN WORD: 'H5'\n ERROR\nok> ") && is(say(": H6 68 EMIT ; H6\n"), "D ok\nok> "), "and the next definition compiles and runs");
CHECK(v4_node_guards_intact(&n), "guards intact after the faults");
}
/* ---- the text is in the definition, a counted string after the call ---- */
boot();
for (v4_cell k = n.mem[DP] / 4; k < DICT_END_W; k++) n.mem[k] = (v4_cell)-1; /* memory that was not zero */
+28 -32
View File
@@ -4,12 +4,10 @@
* place for a *linear* index error -- mem[-1], mem[V4_NODE_WORDS] -- which is
* inside the struct and therefore invisible to AddressSanitizer.
*
* It explicitly does NOT cover an out-of-range *word address*, where P, A or B
* has left the address space. That lands gigabytes away, outside any band.
* DECOMPOSITION.md does not say what such an access should do, so no policy is
* invented here; instead the limitation is measured and asserted, so the claim
* in node.h cannot rot. Closing that hole is a ruling on out-of-range
* addressing, not a wider band.
* It does not cover an out-of-range *word address*, where P, A or B has left
* the address space: as an index that would land gigabytes away, outside any
* band. D-14 guards that with a range check, tested here for the C accessors
* and in test_exec.c for a running programme.
*/
#include "v4/node.h"
@@ -171,32 +169,30 @@ static void test_linear_overrun_high_is_caught(void)
CHECK(v4_node_guards_intact(&n), "intact after re-reset");
}
static void test_out_of_range_word_address_is_not_covered_by_the_band(void)
static void test_out_of_range_word_address_touches_nothing(void)
{
/* What the band does NOT catch, asserted so the limitation stays visible
* and cannot be quietly forgotten.
*
* An out-of-range *word address* is a different animal from a linear index
* error. A v4_cell address of -1 becomes unsigned 0xFFFFFFFF, which is
* 2^32 words past mem -- 16 GB at 32-bit cells. That is far outside the
* struct, so no boundary can see it. The band was briefly documented as
* catching this; it does not, and the protection is a range check whose
* policy DECOMPOSITION.md does not define. This test records the gap by
* measuring it, so the number in node.h stays true. */
v4_cell neg = (v4_cell)-1;
uint64_t words_out = (uint64_t)(v4_ucell)neg;
uint64_t far = words_out * (uint64_t)sizeof(v4_cell);
printf(" out-of-range word address -1 lands %llu bytes past mem "
"(%.1f GB at this width): outside the band, as documented\n",
(unsigned long long)far, (double)far / 1073741824.0);
CHECK(far > (uint64_t)sizeof(v4_node),
"an out-of-range address should land outside the node");
/* The band is only V4_MEM_BOUND words, so even a modest overrun is caught
* only while it is within the band. Past that it is not, which is why the
* open question is a range check and not a wider band. */
CHECK(V4_MEM_BOUND < V4_NODE_WORDS / 2u,
"if the band were most of memory, this reasoning would be wrong");
/* A word address outside memory is a different thing from a linear index
* error: -1 as an index would be 2^32 words past mem, far outside the
* struct and any band. D-14 (2026-10-04) rules it guarded: load gives 0
* and store does nothing. (That a running programme faults there is the
* executor's business: test_exec.c.) */
static const v4_cell bad[] = { -1, -2, (v4_cell)V4_NODE_WORDS, (v4_cell)V4_NODE_WORDS + (v4_cell)V4_MEM_BOUND,
(v4_cell)V4_MSB, (v4_cell)(V4_MSB - 1u) };
v4_node *n = malloc(sizeof *n);
unsigned i;
if (!n) { CHECK(0, "malloc"); return; }
v4_node_reset(n);
for (i = 0; i < V4_NODE_WORDS; i++) n->mem[i] = (v4_cell)(i + 1000u);
for (i = 0; i < sizeof bad / sizeof bad[0]; i++) {
CHECK(!v4_node_addr_ok(bad[i]), "%lld is not an address", (long long)bad[i]);
CHECK(v4_node_load(n, bad[i]) == 0, "a load at %lld gives 0", (long long)bad[i]);
v4_node_store(n, bad[i], 0x5A5A);
}
for (i = 0; i < V4_NODE_WORDS; i++)
if (n->mem[i] != (v4_cell)(i + 1000u)) { CHECK(0, "a store outside memory changed word %u", i); break; }
CHECK(v4_node_guards_intact(n), "and the boundaries are intact");
CHECK(v4_node_addr_ok(0) && v4_node_addr_ok((v4_cell)(V4_NODE_WORDS - 1u)), "the first and last words are addresses");
free(n);
}
static void test_ends_are_distinguishable(void)
@@ -257,7 +253,7 @@ int main(void)
test_boundary_is_adjacent_to_memory();
test_linear_overrun_low_is_caught();
test_linear_overrun_high_is_caught();
test_out_of_range_word_address_is_not_covered_by_the_band();
test_out_of_range_word_address_touches_nothing();
test_ends_are_distinguishable();
test_workload_never_false_alarms();