diff --git a/docs/v4.0.0/DECOMPOSITION.md b/docs/v4.0.0/DECOMPOSITION.md index c3bf251a..ef32eddd 100644 --- a/docs/v4.0.0/DECOMPOSITION.md +++ b/docs/v4.0.0/DECOMPOSITION.md @@ -175,6 +175,7 @@ definition below depends on one, it says so. | **D-11** | `Q./` semantics: division by zero, rounding, overflow (ruled 2026-10-02). | **Saturate and flag; round toward zero; saturate on overflow.** The quotient of `a * 2^16 / b` is rounded toward zero, like `SM/REM`. When it does not fit a Q value it is clamped to Q max or Q min by its sign. Division by zero returns Q max or Q min by the sign of the dividend (`0 / 0` gives 0) and sets the node's `NODE-ERROR` register (§7), which `VM-ERROR?` reads. v3 returned 0 on division by zero and saturated whenever the dividend was 2^48 or more, even when the quotient would have fit. | | **D-12** | Q approximations outside their domain (ruled 2026-10-02). | **Return 0 and set `NODE-ERROR`.** `Q.SQRT` of a negative value and `Q.LOG` of zero or a negative value return 0 and set `NODE-ERROR` (§7), as `Q./` does on division by zero (D-11). v3 returned 0 for `ln(0)` without a flag and read negative arguments as large unsigned values. | | **D-13** | Pictured-output hold buffer: size and errors (ruled 2026-10-03). | **63 characters, as v3; on error set `NODE-ERROR` and drop the character.** The buffer holds 63 characters at either cell width. `HOLD` of a value outside 0–255, or into a full buffer, stores nothing and sets `NODE-ERROR` (§7), which is v3's behaviour (it set its error flag and dropped the character). A full double in base 2 therefore does not fit, as in v3. | +| **D-14** | An address outside the node's memory (ruled 2026-10-04). | **Guarded: an address fault.** Every address a running programme uses is checked before it is used — `P` when an instruction word or an `@p` literal is fetched or `!p` stores, `A` for `@ @+ ! !+`, `B` for `@b !b`. Outside `0 … memory size − 1` the opcode does nothing (no fetch, no store, stacks and `A`, `B` untouched), the rest of its instruction word is not executed, and `P` becomes the node's **fault handler**. Nothing is pushed: the handler does not return to the programme. On the host node the handler is `(FAULT)` (`v4/capsule/quit.v4`): it prints `Address out of range`, ends any definition that was open, prints ` ERROR` and returns to the prompt, from however deep the fault was. A node with no handler stops. v3 printed ` ERROR` alone. What a mesh node's handler does — it has no console — comes with the mesh (step 2). Executed on the golden model (2026-10-04): `tests/test_exec.c` for every memory opcode and for `P`, `tests/test_host_quit.c` from the prompt. | **Consequences of D-2 that every definition must respect.** The data stack holds 10 items and the return stack 9, and every `call`, `FOR`, `DO` loop frame and `push` uses return-stack slots. Nesting @@ -649,7 +650,7 @@ width and print a flood of spaces. | `EMIT` | DEV | Console service: one-character message. Until the mesh exists it is a store to the `CONSOLE-TX` register (§7): `CONSOLE-TX b! !b`. As in v3, the low byte of the cell is the character. Executed on the golden model (2026-10-03). Clobbers `B`. | | `KEY` | DEV | Console service: blocking receive. Until the mesh exists it reads the `CONSOLE-STATUS` and `CONSOLE-RX` registers (§7): `L: CONSOLE-STATUS b! @b if WAIT drop CONSOLE-RX b! @b ; WAIT: drop jump L` — it asks whether a character is pending until one is, then takes it. The character is 0–255. v3's `KEY` returned −1 at the end of its input; here there is no end of input, and `KEY` waits. Executed on the golden model (2026-10-03), including a transcript of the v3 binary, and shown still waiting after 5000 instruction words with nothing pending. Leaves its caller 9 data cells and 8 return entries. Clobbers `B`. | | `?TERMINAL` | DEV | Console service: non-blocking status. Until the mesh exists: `CONSOLE-STATUS b! @b` — −1 when a character is pending, 0 when not, as v3; it takes nothing. Executed on the golden model (2026-10-03). Clobbers `B`. | -| `TYPE` | CAP | `( baddr u -- )`. Loop of `C@ EMIT`, or one string message to the console node: `-if OK drop drop NODE-ERROR b! -1 !b ; OK: if DONE over C@ EMIT push 1 + pop -1 + jump OK DONE: drop drop ;` — nothing for `u = 0`; for `u < 0` nothing is printed and `NODE-ERROR` is set, where v3 printed nothing and raised its error flag. The address range is not checked (out-of-range addressing is still open, `node.h`). Executed on the golden model (2026-10-03). Leaves its caller 6 data cells and 6 return entries. Clobbers `A` and `B`. | +| `TYPE` | CAP | `( baddr u -- )`. Loop of `C@ EMIT`, or one string message to the console node: `-if OK drop drop NODE-ERROR b! -1 !b ; OK: if DONE over C@ EMIT push 1 + pop -1 + jump OK DONE: drop drop ;` — nothing for `u = 0`; for `u < 0` nothing is printed and `NODE-ERROR` is set, where v3 printed nothing and raised its error flag. An address outside the node's memory is an address fault (D-14). Executed on the golden model (2026-10-03). Leaves its caller 6 data cells and 6 return entries. Clobbers `A` and `B`. | | `CR` | CAP | `10 EMIT`, as `10 jump EMIT` — executed on the golden model (2026-10-03). Character 10, as v3; the console turns it into a new line. | | `SPACE` | CAP | `BL EMIT`, as `32 jump EMIT` — executed on the golden model (2026-10-03). | | `SPACES` | CAP | `( n -- )`: `-if L drop ; L: if DONE SPACE -1 + jump L DONE: drop ;` — `n` spaces, none for `n <= 0`, as v3. Executed on the golden model (2026-10-03), including a transcript of the v3 binary. Leaves its caller 7 data cells and 7 return entries. Clobbers `B`. | diff --git a/v4/capsule/quit.v4 b/v4/capsule/quit.v4 index 590121e6..93958afa 100644 --- a/v4/capsule/quit.v4 +++ b/v4/capsule/quit.v4 @@ -11,6 +11,9 @@ \ ok> NOSUCH \ UNKNOWN WORD: 'NOSUCH' \ ERROR any line that sets NODE-ERROR ends so +\ ok> -1 @ +\ Address out of range an address fault (D-14), from any depth +\ ERROR \ The line itself is not sent back: the terminal shows what is typed. \ \ THE RETURN STACK is circular (D-2): it has no bottom to be reset to, and @@ -52,6 +55,15 @@ header QUIT header ABORT : ABORT (RESET) 1 jump (REPL) +\ ( -- ) where the node goes when a programme uses an address outside its +\ memory (D-14): the loader makes this word the node's fault handler. The +\ opcode that faulted did nothing; whatever was running is abandoned, as by +\ ABORT, and the line ends with ERROR. +: (FAULT) + $72646441 (EMIT4) $20737365 (EMIT4) $2074756F (EMIT4) \ "Address out " + $7220666F (EMIT4) $65676E61 (EMIT4) CR \ "of range" + 2 jump (REPL) + \ ---- text in the source --------------------------------------------------------- \ ." and ABORT" take the text up to the next " , which may be none at all \ (input.v4's (PARSE)); with no closing " it is the rest of the line. Inside a definition they lay diff --git a/v4/include/v4/exec.h b/v4/include/v4/exec.h index e238ab4a..bb656a45 100644 --- a/v4/include/v4/exec.h +++ b/v4/include/v4/exec.h @@ -43,7 +43,12 @@ void v4_exec_reset(v4_exec_state *es); /* Execute one instruction word: fetch at P, advance P, run the slots left to * right until the word ends. A unext loop runs to completion inside one call, * so the call does not return while R is nonzero at a `unext`. Returns the - * number of instructions retired. */ + * number of instructions retired. + * + * An address outside node memory -- P at the fetch, or the address an opcode + * uses -- is a fault (node.h, D-14): that opcode and the rest of the word do + * not execute, and P is the node's fault handler on return. With no handler + * the node stops, and this does nothing and returns 0 from then on. */ unsigned v4_exec_step_word(v4_node *n, v4_exec_state *es, v4_heat *h); /* Execute a single opcode. The branch opcodes read their target from `w` and diff --git a/v4/include/v4/node.h b/v4/include/v4/node.h index 1b0336cf..45cbb4c2 100644 --- a/v4/include/v4/node.h +++ b/v4/include/v4/node.h @@ -80,6 +80,12 @@ typedef struct { unsigned input_len; /* characters fed */ unsigned input_pos; /* characters taken; input_len - input_pos are pending */ unsigned char input[V4_CONSOLE_CAP]; + + /* Address faults (D-14). See v4_node_fault_attach below. */ + v4_cell fault_vector; /* word address of the handler, or -1: none */ + v4_cell fault_addr; /* the address of the latest fault */ + unsigned faults; /* how many there have been */ + int stopped; /* non-zero: faulted with no handler */ } v4_node; /* Zero P, A and B, empty the stacks, and clear memory. Installs every @@ -97,37 +103,16 @@ int v4_node_guards_intact(const v4_node *n); * and a narrower parameter would put a conversion between the register and the * access on every one of the eight memory opcodes. * - * PRECONDITION: 0 <= addr < V4_NODE_WORDS. + * An address outside 0 .. V4_NODE_WORDS-1 is never used to index memory + * (D-14, ruled 2026-10-04): v4_node_load gives 0 for it and v4_node_store + * does nothing. That is all these two do. They are what C code uses -- the + * assemblers, the tests -- and they do not count a fault; the executor does, + * for the addresses a running programme uses (see v4_node_fault_attach). * - * Out-of-range addressing is not defined by DECOMPOSITION.md. D-1 fixes word - * addressing and says nothing about an address outside the node's memory; a - * F18 has no such case because its address space is the memory. Whether a - * conforming model should wrap, saturate, or fault is a real open question and - * is raised as a gap rather than decided here, because each answer changes the - * ISA's observable behaviour and inventing one silently would bake a guess into - * every later test. - * - * WHAT THE BOUNDARY HERE DOES AND DOES NOT COVER, since it is easy to - * overclaim and the distinction decides whether out-of-range addresses are - * actually protected: - * - * - It DOES cover a linear index error. An access to mem[-1] or - * mem[V4_NODE_WORDS] lands in the 5% band, because the band is immediately - * adjacent (tests/test_node.c asserts the adjacency with offsetof rather - * than assuming it). That access is inside the struct, so AddressSanitizer - * stays silent about it and v4_node_guards_intact() is what reports it. - * - * - It does NOT cover an out-of-range *word address*. A v4_cell address of - * -1 casts to unsigned 0xFFFFFFFF, which is 16 GB past mem at 32-bit cells - * -- far outside the struct and outside the band. So a P, A or B that runs - * off the end of memory is caught by neither the band nor, in a production - * build without sanitizers, by anything else here. It is a precondition - * violation and the range check that would catch it is the open question - * above, not the band. - * - * The band is worth its 10.2% for the first case regardless. The second case - * is a hole, and it is closed by ruling on out-of-range addressing, not by - * widening the band. */ + * The 5% band on each side of mem is a different protection: it is where a + * linear index error in the model's own C -- mem[-1], mem[V4_NODE_WORDS] -- + * would land, inside the struct where AddressSanitizer cannot see it, and + * v4_node_guards_intact() reports it. */ v4_cell v4_node_load(const v4_node *n, v4_cell addr); void v4_node_store(v4_node *n, v4_cell addr, v4_cell value); @@ -182,7 +167,37 @@ void v4_node_console_input_attach(v4_node *n, v4_cell rx, v4_cell status); unsigned v4_node_console_feed(v4_node *n, const void *chars, unsigned len); /* A data fetch: what @, @+ and @b read at `addr`. The two receive registers - * above when attached, memory otherwise. Same precondition as v4_node_load. */ + * above when attached, memory otherwise; 0 outside memory, as v4_node_load. */ v4_cell v4_node_fetch(v4_node *n, v4_cell addr); +/* 1 if `addr` is a word of node memory. */ +int v4_node_addr_ok(v4_cell addr); + +/* ADDRESS FAULTS (DECOMPOSITION.md D-14, ruled 2026-10-04: an address outside + * the node's memory is guarded, an error is shown, and the node returns to + * its prompt). + * + * The executor checks every address a programme uses before using it: P when + * an instruction word or an `@p` literal is fetched or `!p` stores, A for + * `@ @+ ! !+`, B for `@b !b`. If it is outside memory there is a fault: + * - the opcode does nothing: no fetch, no store, the stacks and A and B + * untouched, nothing retired; the rest of its instruction word is not + * executed; + * - fault_addr is the address and faults is one more; + * - P becomes fault_vector, the address of the node's handler. Nothing is + * pushed: the handler does not return to the programme, it reports and + * goes back to the prompt (capsule/quit.v4, (FAULT)). + * With no handler attached (fault_vector -1) the node stops instead: + * `stopped` is set and v4_exec_step_word does nothing more until the node is + * reset or a handler is attached. + * + * v4_node_reset detaches the handler and clears the count. Attaching clears + * `stopped` and the count; `handler` must be a word of memory, or -1 to + * detach. */ +void v4_node_fault_attach(v4_node *n, v4_cell handler); + +/* For the executor: record a fault at `addr` and redirect or stop the node, + * as above. */ +void v4_node_fault(v4_node *n, v4_cell addr); + #endif /* V4_NODE_H */ diff --git a/v4/include/v4/testcode.h b/v4/include/v4/testcode.h index 1a94a330..4e177ffb 100644 --- a/v4/include/v4/testcode.h +++ b/v4/include/v4/testcode.h @@ -12,7 +12,7 @@ * and step instruction words until the word returns. The stacks and A, B are * left as the caller set them, so arguments go on the data stack first. * Returns the number of instruction words stepped, or -1 if the word had not - * returned after `max_words`. */ + * returned after `max_words` or the node stopped on an address fault (D-14). */ long v4_test_call(v4_node *n, v4_exec_state *es, v4_heat *h, v4_cell entry, long max_words); diff --git a/v4/src/exec.c b/v4/src/exec.c index 6f1b383d..26f051cf 100644 --- a/v4/src/exec.c +++ b/v4/src/exec.c @@ -35,6 +35,14 @@ static int ends_word(unsigned op) return v4_op_is_branch(op) || op == V4_OP_SEMI || op == V4_OP_EX; } +/* D-14: an address outside memory is a fault, and the opcode does nothing. */ +static int faulted(v4_node *n, v4_cell addr) +{ + if (v4_node_addr_ok(addr)) return 0; + v4_node_fault(n, addr); + return 1; +} + void v4_exec_reset(v4_exec_state *es) { es->anticlock = 0; @@ -87,31 +95,39 @@ void v4_exec_op(v4_node *n, v4_exec_state *es, v4_heat *h, break; case V4_OP_FETCH_P: + if (faulted(n, n->p)) return; v4_dstack_push(ds, v4_node_load(n, n->p)); n->p = add_wrap(n->p, 1); break; case V4_OP_FETCH_INC: + if (faulted(n, n->a)) return; v4_dstack_push(ds, v4_node_fetch(n, n->a)); n->a = add_wrap(n->a, 1); break; case V4_OP_FETCH_B: + if (faulted(n, n->b)) return; v4_dstack_push(ds, v4_node_fetch(n, n->b)); break; case V4_OP_FETCH_A: + if (faulted(n, n->a)) return; v4_dstack_push(ds, v4_node_fetch(n, n->a)); break; case V4_OP_STORE_P: + if (faulted(n, n->p)) return; v4_node_store(n, n->p, v4_dstack_pop(ds)); n->p = add_wrap(n->p, 1); break; case V4_OP_STORE_INC: + if (faulted(n, n->a)) return; v4_node_store(n, n->a, v4_dstack_pop(ds)); n->a = add_wrap(n->a, 1); break; case V4_OP_STORE_B: + if (faulted(n, n->b)) return; v4_node_store(n, n->b, v4_dstack_pop(ds)); break; case V4_OP_STORE_A: + if (faulted(n, n->a)) return; v4_node_store(n, n->a, v4_dstack_pop(ds)); break; @@ -181,18 +197,24 @@ void v4_exec_op(v4_node *n, v4_exec_state *es, v4_heat *h, unsigned v4_exec_step_word(v4_node *n, v4_exec_state *es, v4_heat *h) { - v4_iword iw = fetch_iword(n, n->p); + v4_iword iw; unsigned executed = 0; unsigned slot = 0; + /* D-14: a node that faulted with no handler has stopped; and P itself + * may be what is outside memory. */ + if (n->stopped || faulted(n, n->p)) return 0; + iw = fetch_iword(n, n->p); n->p = add_wrap(n->p, 1); while (slot < V4_SLOT_COUNT) { unsigned op = v4_iword_op(iw, slot); /* Decided before the opcode runs, because running it changes R. */ int again = (op == V4_OP_UNEXT) && (n->rs.r != 0); + unsigned faults = n->faults; v4_exec_op(n, es, h, op, iw, slot); + if (n->faults != faults) break; /* P is the handler now, or the node has stopped */ executed++; if (again) { diff --git a/v4/src/node.c b/v4/src/node.c index 24d0f4ff..fdec5cf0 100644 --- a/v4/src/node.c +++ b/v4/src/node.c @@ -13,6 +13,30 @@ void v4_node_reset(v4_node *n) v4_rstack_reset(&n->rs); v4_node_console_attach(n, -1); v4_node_console_input_attach(n, -1, -1); + v4_node_fault_attach(n, -1); +} + +int v4_node_addr_ok(v4_cell addr) +{ + /* One unsigned compare covers both ends: a negative address is a very + * large unsigned one. */ + return (v4_ucell)addr < (v4_ucell)V4_NODE_WORDS; +} + +void v4_node_fault_attach(v4_node *n, v4_cell handler) +{ + n->fault_vector = v4_node_addr_ok(handler) ? handler : (v4_cell)-1; + n->fault_addr = 0; + n->faults = 0; + n->stopped = 0; +} + +void v4_node_fault(v4_node *n, v4_cell addr) +{ + n->fault_addr = addr; + n->faults++; + if (n->fault_vector >= 0) n->p = n->fault_vector; + else n->stopped = 1; } int v4_node_guards_intact(const v4_node *n) @@ -25,9 +49,7 @@ int v4_node_guards_intact(const v4_node *n) v4_cell v4_node_load(const v4_node *n, v4_cell addr) { - /* Precondition checked by the caller; see node.h. The unsigned compare is - * deliberate: `addr` is signed, and a negative address must not wrap into - * a large positive one and read the top of memory instead. */ + if (!v4_node_addr_ok(addr)) return 0; return n->mem[(unsigned)addr]; } @@ -42,6 +64,7 @@ void v4_node_store(v4_node *n, v4_cell addr, v4_cell value) n->console_dropped++; return; } + if (!v4_node_addr_ok(addr)) return; n->mem[(unsigned)addr] = value; } diff --git a/v4/src/testcode.c b/v4/src/testcode.c index 90bda8f7..7cbc67b7 100644 --- a/v4/src/testcode.c +++ b/v4/src/testcode.c @@ -10,10 +10,8 @@ long v4_test_call(v4_node *n, v4_exec_state *es, v4_heat *h, n->p = entry; while (n->p != V4_TEST_HALT) { if (words >= max_words) return -1; - /* A golden model must not index outside its own memory on a runaway - * P; node.h leaves out-of-range addressing undefined. */ - if (n->p < 0 || (v4_ucell)n->p >= V4_NODE_WORDS) return -1; (void)v4_exec_step_word(n, es, h); + if (n->stopped) return -1; /* an address fault with no handler (D-14) */ words++; } return words; diff --git a/v4/tests/test_exec.c b/v4/tests/test_exec.c index 850670f1..605a2b6b 100644 --- a/v4/tests/test_exec.c +++ b/v4/tests/test_exec.c @@ -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); diff --git a/v4/tests/test_host_quit.c b/v4/tests/test_host_quit.c index b1579b78..4a3fbd09 100644 --- a/v4/tests/test_host_quit.c +++ b/v4/tests/test_host_quit.c @@ -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 */ diff --git a/v4/tests/test_node.c b/v4/tests/test_node.c index 6a750856..72f70d58 100644 --- a/v4/tests/test_node.c +++ b/v4/tests/test_node.c @@ -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();