diff --git a/compiler/src/disasm.ml b/compiler/src/disasm.ml index 91a151f..af5bd55 100644 --- a/compiler/src/disasm.ml +++ b/compiler/src/disasm.ml @@ -183,7 +183,8 @@ let dump (img : string) : string = set; iteration 19's v5 added the Float constant tag, kinds 6/7 and opcodes 34-41). The disassembler tracks the emitter, not a range: an old image is a different format and reading it as this one would misrender. *) - if ver <> 6 then raise (Bad (Printf.sprintf "unsupported version %d" ver)); + (* tracks emit.ml's wob_version and wob.h's WOB_VERSION *) + if ver <> 7 then raise (Bad (Printf.sprintf "unsupported version %d" ver)); let coff = u32 img 8 and ccnt = u32 img 12 in let koff = u32 img 16 and kcnt = u32 img 20 in let ioff = u32 img 24 and icnt = u32 img 28 in @@ -259,7 +260,12 @@ let dump (img : string) : string = in line (Printf.sprintf "c%-3d %s flags=%s fields=[%s]" i (kname nm) - (if flags land 1 <> 0 then "gc" else "-") + (let parts = + (if flags land 1 <> 0 then [ "gc" ] else []) + @ (if flags land 2 <> 0 then [ "volatile" ] else []) + @ (if flags land 4 <> 0 then [ "resident=keys" ] else []) + in + if parts = [] then "-" else String.concat "+" parts) (String.concat ", " fields)) done; (* interfaces + vtable rows *) diff --git a/compiler/src/emit.ml b/compiler/src/emit.ml index 01cc8d3..c3b3847 100644 --- a/compiler/src/emit.ml +++ b/compiler/src/emit.ml @@ -154,7 +154,10 @@ let wob_magic = 0x31424F57 (* "WOB1" read as an LE u32 *) (* v5 (iteration 19): the Float constant tag, field kinds 6/7, opcodes 34-41, builtins 70-83. v4 (iteration 7b): RC opcodes retired; gc mask = GC roots *) -let wob_version = 6 +(* MUST track runtime/src/wob.h's WOB_VERSION — the loader is an exact-match + check, so a drift here is not a warning, it is every image refused. + v7 (databasev2 2): two class flag bits, no layout change. *) +let wob_version = 7 let wob_hdr_size = 44 let wob_none = 0xFFFFFFFF @@ -167,6 +170,9 @@ let k_text = 1 let k_float = 2 let max_regs = 64 let classf_gc = 0x01 +(* databasev2 2: spare bits of the same flags word — see runtime/src/wob.h *) +let classf_volatile = 0x02 +let classf_resident_keys = 0x04 let op_nop = 0 let op_loadk = 1 @@ -393,6 +399,10 @@ let code_push (c : code) (v : int) : unit = type clsrec = { cr_name : string; cr_gc : bool; + (* databasev2 2: storage properties, spelled as the DEFAULT here so a + non-table class (union payload records below) trivially gets flags 0 *) + cr_durable : bool; + cr_resident_keys : bool; cr_fields : (string * Ast.field_ty) array; cr_methods : string list; (* method names, declaration order *) (* iteration 9 Task 4: (unique, column indices) per secondary index — @@ -4765,6 +4775,14 @@ let emit ?(entry_ok : string -> bool = fun _ -> true) ~(syms : Types.symbols) cfg.Ast.indexes | None -> ()); { cr_name = c.name; cr_gc = Types.is_gc_class syms c.name; + cr_durable = + (match c.Ast.table with + | Some cfg -> cfg.Ast.durable + | None -> true); + cr_resident_keys = + (match c.Ast.table with + | Some cfg -> cfg.Ast.resident = Ast.ResKeys + | None -> false); cr_fields = Array.of_list (List.filter_map @@ -4802,7 +4820,8 @@ let emit ?(entry_ok : string -> bool = fun _ -> true) ~(syms : Types.symbols) class_id := SM.add key cid !class_id; incr nclasses; classes := - { cr_name = key; cr_gc = false; cr_indexes = []; cr_is_table = false; + { cr_name = key; cr_gc = false; cr_durable = true; + cr_resident_keys = false; cr_indexes = []; cr_is_table = false; cr_backlinks = []; cr_fields = Array.of_list vd.Ast.v_fields; cr_methods = [] } @@ -4846,7 +4865,9 @@ let emit ?(entry_ok : string -> bool = fun _ -> true) ~(syms : Types.symbols) class_id := SM.add name cid !class_id; incr nclasses; classes := - { cr_name = name; cr_gc = false; cr_fields = Array.of_list fields; cr_methods = []; + (* not a @table (a predeclared record), so storage flags stay 0 *) + { cr_name = name; cr_gc = false; cr_durable = true; cr_resident_keys = false; + cr_fields = Array.of_list fields; cr_methods = []; cr_indexes = []; cr_is_table = false; cr_backlinks = [] } :: !classes end) @@ -5023,7 +5044,10 @@ let emit ?(entry_ok : string -> bool = fun _ -> true) ~(syms : Types.symbols) Array.iteri (fun cid (c : clsrec) -> Buf.u32 cls class_name_k.(cid); - Buf.u32 cls (if c.cr_gc then classf_gc else 0); + Buf.u32 cls + ((if c.cr_gc then classf_gc else 0) + lor (if c.cr_durable then 0 else classf_volatile) + lor (if c.cr_resident_keys then classf_resident_keys else 0)); Buf.u32 cls (Array.length c.cr_fields); Array.iter (fun (_, ty) -> Buf.u8 cls (field_kind p ty)) c.cr_fields; let pad = (4 - (Array.length c.cr_fields mod 4)) mod 4 in diff --git a/compiler/src/parser.ml b/compiler/src/parser.ml index e190b47..27e3181 100644 --- a/compiler/src/parser.ml +++ b/compiler/src/parser.ml @@ -394,6 +394,16 @@ let parse_table_cfg (st : state) : Ast.table_cfg = end done end; + (* databasev2 2: rows that are neither logged nor resident have nowhere to + live. Checked here, after the whole argument list is known, because it is + a property of the COMBINATION rather than of either argument. The loader + refuses it again (runtime/src/wob.h, loader.c) on the principle that what + the loader accepts the interpreter trusts — but a compile error is the one + a developer can act on. *) + if (not !cfg.Ast.durable) && !cfg.Ast.resident = Ast.ResKeys then + fail st (peek_pos st) table_code + "@table(durable: false, resident: keys): rows would be neither logged \ + nor resident, so there is nowhere to read them from — pick one"; !cfg type type_annotations = { diff --git a/compiler/test/runner.ml b/compiler/test/runner.ml index b000dcc..305a510 100644 --- a/compiler/test/runner.ml +++ b/compiler/test/runner.ml @@ -2402,7 +2402,7 @@ let validate_image (img : string) : string list = let u64 o = if ok 8 o then String.get_int64_le img o else 0L in let none = 0xFFFFFFFF in if u32 0 <> 0x31424F57 then fail "bad magic"; - if u32 4 <> 6 then fail "unsupported version"; (* v6: iteration 36 *) + if u32 4 <> 7 then fail "unsupported version"; (* v7: databasev2 2 *) let coff = u32 8 and ccnt = u32 12 in let koff = u32 16 and kcnt = u32 20 in let ioff = u32 24 and icnt = u32 28 in @@ -2439,7 +2439,13 @@ let validate_image (img : string) : string list = let nm = u32 !o and flags = u32 (!o + 4) and fcnt = u32 (!o + 8) in o := !o + 12; if not (text_const nm) then fail (Printf.sprintf "class %d: bad name constant" i); - if flags land lnot 0x01 <> 0 then fail (Printf.sprintf "class %d: unknown flags" i); + (* v7 (databasev2 2): bit1 VOLATILE, bit2 RESIDENT_KEYS. This battery is a + deliberately independent reimplementation of runtime/src/loader.c's + validation, so it tracks the same contract — including refusing the pair + that would leave rows neither logged nor resident. *) + if flags land lnot 0x07 <> 0 then fail (Printf.sprintf "class %d: unknown flags" i); + if flags land 0x02 <> 0 && flags land 0x04 <> 0 then + fail (Printf.sprintf "class %d: durable:false with resident:keys" i); if fcnt > 65535 then fail (Printf.sprintf "class %d: too many fields" i); class_fields.(i) <- fcnt; let kco = !o in (* the kind bytes' offset: the v3 index walk re-reads them *) diff --git a/docs/plan/oop-vm/00-wob-format.md b/docs/plan/oop-vm/00-wob-format.md index c94dacc..1e1f557 100644 --- a/docs/plan/oop-vm/00-wob-format.md +++ b/docs/plan/oop-vm/00-wob-format.md @@ -11,11 +11,11 @@ All integers little-endian; offsets are absolute file offsets. -**Header (44 bytes):** magic `"WOB1"`, version 6 (iteration 36; see "v6: the Int bitwise set" below — v5 was iteration 19's "v5: Float and Bytes"), then offset/count u32 pairs for the constant pool, class table, interface section, and method table, then a u32 entry-method index (all-ones = none). +**Header (44 bytes):** magic `"WOB1"`, version 7 (databasev2 2; see "v7: table storage flags" below — v6 was iteration 36's "v6: the Int bitwise set", v5 iteration 19's "v5: Float and Bytes"), then offset/count u32 pairs for the constant pool, class table, interface section, and method table, then a u32 entry-method index (all-ones = none). **Constant pool** — sequential entries: one tag byte; tag 0 = i64 follows; tag 1 = text (u32 length + bytes, no NUL); tag 2 = f64 as its IEEE 754 bit pattern in an LE u64 (v5). There is no Bytes tag: Bytes has no literal form. -**Class table** — per class: name constant index, flags u32 (bit0 = instances are `@gc`), field count, then one kind byte per field padded to a 4-byte boundary, then **three u32 arrays of per-field metadata** (v2), one entry per field each, in declaration order: +**Class table** — per class: name constant index, flags u32 (bit0 = instances are `@gc`; **bit1 = `@table(durable: false)`, bit2 = `@table(resident: keys)`** — v7, and 0 in both means the pre-v7 behaviour of durable-and-fully-resident), field count, then one kind byte per field padded to a 4-byte boundary, then **three u32 arrays of per-field metadata** (v2), one entry per field each, in declaration order: 1. `field_names[i]` — constant index of the field's name, or all-ones for "not recorded" (what a hand-built test image writes). 2. `field_class[i]` — the class id the field refers to: its own class for an OWNED/GCREF field, its *element's* class for a container of records; `0xFFFFFFFE` marks a `json.Value` field, whose Text holds a raw JSON slice; `0xFFFFFFFD` a nullable scalar (`WO_NIL_SCALAR` nil); `0xFFFFFFFC` a plain `Bool` (json encodes `true`/`false`); `0xFFFFFFFB` a `?Bool` (both); `0xFFFFFFFA` a `?Float` (v5 — nil is `WO_NIL_FLOAT`, not `WO_NIL_SCALAR`); all-ones for none. @@ -307,3 +307,34 @@ left to fall out accidentally): magic, or offsets that don't exactly account for every trailing byte) is left untouched and copied as-is — the safe default when it's not certain. + +## v7: table storage flags (databasev2 2) + +The smallest version bump in the format's history: **no layout change at all.** +Both properties ride spare bits of the class descriptor's existing `flags` +u32, so a v7 class record is byte-identical in shape to a v6 one. + +**What v7 adds** + +- `flags` bit1 — `WO_CLASSF_VOLATILE`: the class is a `@table(durable: false)`. + Its writes are never staged to the WAL and replay skips its records. +- `flags` bit2 — `WO_CLASSF_RESIDENT_KEYS`: the class is a + `@table(resident: keys)`. Its id map, secondary indexes and unique shadows + are resident; its rows are read back from the log by offset. +- Both are spelled as the **non-default**, so a zero flags word means exactly + what every pre-v7 image meant: durable, every row resident. A class that is + not a `@table` must have both clear. + +**Why bump at all, given nothing moved?** To stop an older runtime reading a +v7 image and silently treating a volatile table as durable — the one failure +mode where the program keeps running and quietly disagrees with its own source. +The loader would in fact also reject it, because the flags mask check +(`loader.c`) rejects unknown bits and has since v1; but a version refusal names +the real problem instead of blaming the flags. + +**Refused by the loader, independently of the compiler:** bit1 and bit2 set +together. Rows that are neither logged nor resident have nowhere to live. `woc` +refuses this at compile time (WO-E102), and the loader refuses it again on the +standing principle that what the loader accepts, the interpreter trusts. Both +paths are gate-verified — the loader's by forging the flags word in an +otherwise valid image, since `woc` will not emit one. diff --git a/docs/plan/oop-vm/01-error-catalog.md b/docs/plan/oop-vm/01-error-catalog.md index 804192a..3140c13 100644 --- a/docs/plan/oop-vm/01-error-catalog.md +++ b/docs/plan/oop-vm/01-error-catalog.md @@ -40,7 +40,7 @@ half of the story ("moved here" / "borrowed here" / etc.). | code | meaning | example message | | --- | --- | --- | | WO-E101 | generic syntax error: an unexpected token where the grammar expected something else, including running off the end of the file inside an unclosed block/type/interface body. Declaration-level recovery syncs to the next top-level keyword so one bad declaration yields one diagnostic, not a cascade. | `expected ')' or ',', got NEWLINE` | -| WO-E102 | an invalid `@table(...)` configuration. Original causes: `name` given twice, an `index` with no columns, or an unknown argument key. **databasev2 2 (2026-08-26) added the storage arguments and five more causes under the same code:** `durable` given twice; `resident` given twice; a `resident` value other than `all`/`keys`; `resident: index`, which is the pre-review spelling and gets a message naming its replacement; and a *retired* argument word (`mode`, `store`, `ram`, `cold`, `tiered`, `paged`, `mmap`, `buffer` — vocabulary the design brainstorm explored and rejected), which gets a message stating the two real keys rather than a generic "unknown argument". A non-boolean after `durable:` is WO-E101 from the generic token expectation, not this code. | `` `cold` is not a @table argument — storage is declared with two keys: `durable: true\|false` and `resident: all\|keys` `` | +| WO-E102 | an invalid `@table(...)` configuration. Original causes: `name` given twice, an `index` with no columns, or an unknown argument key. **databasev2 2 (2026-08-26) added the storage arguments and five more causes under the same code:** `durable` given twice; `resident` given twice; a `resident` value other than `all`/`keys`; `resident: index`, which is the pre-review spelling and gets a message naming its replacement; and a *retired* argument word (`mode`, `store`, `ram`, `cold`, `tiered`, `paged`, `mmap`, `buffer` — vocabulary the design brainstorm explored and rejected), which gets a message stating the two real keys rather than a generic "unknown argument". A non-boolean after `durable:` is WO-E101 from the generic token expectation, not this code. Also `durable: false` together with `resident: keys` — rows would be neither logged nor resident, checked after the whole argument list is known because it is a property of the combination; the loader refuses the same pair independently (`.wob` v7). | `` `cold` is not a @table argument — storage is declared with two keys: `durable: true\|false` and `resident: all\|keys` `` | | WO-E103 | haxe-parity Task 2. `inline fn ...` — the haxe keyword verdict table's own reject half of the `inline` row (`const` values are the adopted half). The whole declaration is discarded by the usual top-level recovery, same as any other bad declaration. | `` `inline fn` is rejected — optimization is the compiler's job `` | | WO-E106 | iteration 15 (deps, 2026-08-18). A dependency fetch/shape failure, driver-level: missing `git` binary, clone/checkout failure, a locked commit missing from the remote, cache/lock drift (a moved `rev` — the message names both SHAs and points at `woc --update-deps`), a fetched dep that is not a writeonce project, or a dep declaring its own `[deps]` (transitive — refused flat-only). One code; the message names the dependency and the failing step. | `` dependency `niceframework`: lock drift — wo.lock pins but .wo-deps has (a moved `rev`?); run `woc --update-deps` or remove .wo-deps/niceframework `` | | WO-E107 | iteration 15 (deps). A `[deps]` name collides with a local top-level module directory of the same name — `use ` would be ambiguous, so the build refuses instead of silently picking one. | `` dependency `niceframework` collides with the local module directory `niceframework/` `` | diff --git a/runtime/src/loader.c b/runtime/src/loader.c index 0ab48d3..84cdcf7 100644 --- a/runtime/src/loader.c +++ b/runtime/src/loader.c @@ -177,8 +177,15 @@ int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err, BAIL("class %u: truncated", (unsigned)i); if (name >= m->const_cnt || m->consts[name].tag != WOB_K_TEXT) BAIL("class %u: bad name constant", (unsigned)i); - if (flags & ~WO_CLASSF_GC) + if (flags & ~WO_CLASSF_ALL) BAIL("class %u: unknown flags", (unsigned)i); + /* databasev2 2: rows that are neither logged nor resident would have + * nowhere to live. woc refuses this at compile time; the loader + * refuses it again because what the loader accepts, the interpreter + * trusts — this combination must never reach the engine. */ + if ((flags & WO_CLASSF_VOLATILE) && (flags & WO_CLASSF_RESIDENT_KEYS)) + BAIL("class %u: durable:false with resident:keys — rows would have " + "nowhere to be read from", (unsigned)i); if (fcnt > 65535) BAIL("class %u: too many fields", (unsigned)i); if (fcnt > k.len - k.off) BAIL("class %u: truncated kinds", (unsigned)i); for (uint32_t j = 0; j < fcnt; j++) diff --git a/runtime/src/wob.h b/runtime/src/wob.h index 506d59a..8b67938 100644 --- a/runtime/src/wob.h +++ b/runtime/src/wob.h @@ -13,7 +13,17 @@ /* ---- file header (44 bytes, absolute offsets) ---- */ #define WOB_MAGIC 0x31424F57u /* "WOB1" read as LE u32 */ -#define WOB_VERSION 6u /* v6 (iteration 36): opcodes 42-46 (the Int +#define WOB_VERSION 7u /* v7 (databasev2 2): two class flag bits — + * WO_CLASSF_VOLATILE (@table durable: false) and WO_CLASSF_RESIDENT_KEYS + * (@table resident: keys). No layout change: both ride spare bits of the + * class descriptor's existing `flags` u32, so the serialized shape is + * byte-identical to v6. The bump exists to stop an OLDER runtime reading a + * v7 image and silently treating a volatile table as durable — the loader + * would also reject the unknown flag bits, but a version refusal names the + * real problem. A v6 image is refused by the exact-match check rather than + * read with the bits clear, matching how v4-v6 each invalidated their + * predecessors. + * v6 (iteration 36): opcodes 42-46 (the Int * bitwise set BAND/BOR/BXOR/SHL/SHR), trap kind WO_T_SHIFT. * v5 (iteration 19): the two missing scalars. New * constant tag WOB_K_FLOAT, field kinds WO_K_FLOAT/WO_K_BYTES (WO_K_MAX 5->7), @@ -562,6 +572,12 @@ typedef struct wo_classdesc { const uint32_t *idx_meta; } wo_classdesc; #define WO_CLASSF_GC 0x01u +/* databasev2 2. Both describe STORAGE, so both are meaningless on a class that + is not a @table and must be 0 there. Spelled as the non-default so a zero + flags word means today's behaviour: durable, every row resident. */ +#define WO_CLASSF_VOLATILE 0x02u /* @table(durable: false) — never logged */ +#define WO_CLASSF_RESIDENT_KEYS 0x04u /* @table(resident: keys) — rows read from the log */ +#define WO_CLASSF_ALL 0x07u /* runtime object layout: 16-byte header then one 8-byte slot per field */ static inline size_t wo_obj_size(const wo_classdesc *c) {