diff --git a/.gitignore b/.gitignore index 762749c..7bad8a3 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,4 @@ prototypes/wo-db/ __pycache__/ *.pyc dist/ +docs/examples/*/target/ diff --git a/compiler/src/parser.ml b/compiler/src/parser.ml index ed64b95..bfcde81 100644 --- a/compiler/src/parser.ml +++ b/compiler/src/parser.ml @@ -126,6 +126,7 @@ let fail (st : state) (site : Ast.pos) (code : string) (message : string) : 'a = let syntax_code = Diag.parsing_prefix ^ "01" (* WO-E101: generic syntax error *) let table_code = Diag.parsing_prefix ^ "02" (* WO-E102: invalid @table(...) configuration *) +let gc_removed_code = Diag.parsing_prefix ^ "04" (* WO-E104: `@gc` — GC-ness is inferred *) (* haxe-parity Task 2: the haxe keyword verdict table's `inline` row — "adopt (values): const compile-time values; inline *functions* @@ -332,10 +333,19 @@ let parse_type_annotations (st : state) : type_annotations = let table = ref None in while peek st = Token.At do ignore (advance st); + let at_pos = peek_pos st in let name = expect_ident st "annotation name" in (match name with | "gc" -> - is_gc := true; + (* iteration 7b: `@gc` is not part of the language — GC-ness is inferred + (structural cycles + demand promotion; see `woc --dump-gc`). Reject it + rather than accept a meaningless annotation. `is_gc` stays false. *) + Diag.Collector.add st.collector + (Diag.error ~code:gc_removed_code ~file:st.file ~line:at_pos.Ast.line + ~col:at_pos.Ast.col + ~message: + "`@gc` is not a valid annotation: GC-ness is inferred by the \ + compiler (run `woc --dump-gc`). Remove it." ()); skip_paren_args st | "table" -> table := Some (parse_table_cfg st) | _ -> skip_paren_args st); diff --git a/compiler/src/types.ml b/compiler/src/types.ml index 0c4f421..438233d 100644 --- a/compiler/src/types.ml +++ b/compiler/src/types.ml @@ -302,34 +302,6 @@ let with_builtin_records (syms : symbols) : symbols = { acc with classes = StringMap.add name info acc.classes }) syms predeclared_records -let rec has_recursive_structure (cls : class_info) : bool = - List.exists (fun (_, ty, _, _) -> - match ty with - | Ast.Scalar name -> name = cls.name (* direct self-reference *) - | Ast.Ref name -> name = cls.name - | Ast.Multi name -> name = cls.name (* multi Self *) - | Ast.Map (k, v) -> k = cls.name || v = cls.name (* map<_, Self> / map *) - | Ast.Backlink _ -> false - | Ast.Nullable inner -> has_recursive_structure_type inner cls.name - ) cls.fields - -and has_recursive_structure_type (ty : Ast.field_ty) (cls_name : string) : bool = - match ty with - | Ast.Scalar name -> name = cls_name - | Ast.Ref name -> name = cls_name - | Ast.Multi name -> name = cls_name - | Ast.Map (k, v) -> k = cls_name || v = cls_name - | Ast.Backlink _ -> false (* a computed inverse holds no owned structure *) - | Ast.Nullable inner -> has_recursive_structure_type inner cls_name - -(* @unique field -> persistent identity (plan's "When NOT to emit": a - class with a @unique field should not get the @gc suggestion even if - it also has recursive/shared structure). Annotation *names* only, per - Ast.field's own doc comment -- "unique" is what parse_field stores for - a bare `@unique`. *) -let has_unique_field (cls : class_info) : bool = - List.exists (fun (_, _, _, anns) -> List.mem "unique" anns) cls.fields - (* iteration 7b: GC-ness is inference-first. A class is traced if the inference pass put it in `syms.traced` (structural cycle, or a Phase-2 demand promotion), OR — as a temporary bridge until demand promotion lands — it @@ -338,15 +310,6 @@ let is_gc_class (syms : symbols) name = StringSet.mem name syms.traced || (try (StringMap.find name syms.classes).is_gc with Not_found -> false) -let gc_suggestion_code = Diag.warning_prefix ^ "201" (* WO-W201 *) - -let suggest_gc_annotation ~file (cls : class_info) (collector : Diag.Collector.t) : unit = - if not cls.is_gc && Option.is_none cls.table && not (has_unique_field cls) - && has_recursive_structure cls then - Diag.Collector.add collector - (Diag.warning ~code:gc_suggestion_code ~file ~line:cls.pos.line ~col:cls.pos.col - ~message:(Printf.sprintf "%s has recursive/shared structure that borrow checker cannot prove. Consider adding @gc if this is an ephemeral in-memory cache. If this maps to a database table, keep owned (default)." cls.name) ()) - (* Ast.field_ty -> the internal resolved typ. Hoisted out of typecheck_program (where it was a local closure) so the .wob emitter can reach the same mapping instead of keeping a second copy of it; @@ -515,9 +478,7 @@ let collect_declarations ~file (prog : program) (collector : Diag.Collector.t) : | Some (existing : class_info) -> report_duplicate_decl collector ~file ~kind:"class" ~name:c.name ~pos:c.pos ~first_pos:existing.pos - | None -> - classes := StringMap.add c.name info !classes; - suggest_gc_annotation ~file info collector) + | None -> classes := StringMap.add c.name info !classes) | Ast.Interface i -> let methods = List.map (fun (m : Ast.method_sig) -> { name = m.name; diff --git a/compiler/test/golden/ast/pricing-demo.expected b/compiler/test/golden/ast/pricing-demo.expected index 23c8483..d6beb16 100644 --- a/compiler/test/golden/ast/pricing-demo.expected +++ b/compiler/test/golden/ast/pricing-demo.expected @@ -14,11 +14,11 @@ 22:5 ASSIGN self.prices = amount 25:3 METHOD adopt(take other: Product) -> Product 26:5 RETURN other -31:1 CLASS PriceCache @gc - 32:3 FIELD entries: map -35:1 TYPE Note - 36:3 FIELD id: Id - 37:3 FIELD body: Text - 38:3 FIELD created: Timestamp = now() -41:1 METHOD discount(mut amount: Int, take pct: Int) -> Int - 42:3 RETURN amount +30:1 CLASS PriceCache + 31:3 FIELD entries: map +34:1 TYPE Note + 35:3 FIELD id: Id + 36:3 FIELD body: Text + 37:3 FIELD created: Timestamp = now() +40:1 METHOD discount(mut amount: Int, take pct: Int) -> Int + 41:3 RETURN amount diff --git a/compiler/test/golden/ast/pricing-demo.wo b/compiler/test/golden/ast/pricing-demo.wo index e806b73..7ee2d8c 100644 --- a/compiler/test/golden/ast/pricing-demo.wo +++ b/compiler/test/golden/ast/pricing-demo.wo @@ -27,7 +27,6 @@ class Product { } } -@gc class PriceCache { entries: map } diff --git a/compiler/test/golden/bc/elision.expected b/compiler/test/golden/bc/elision.expected index eee02b1..ea84f55 100644 --- a/compiler/test/golden/bc/elision.expected +++ b/compiler/test/golden/bc/elision.expected @@ -2,14 +2,15 @@ k0 TEXT "Cache" k1 TEXT "Holder" k2 TEXT "hits" -k3 TEXT "cache" -k4 TEXT "read" -k5 TEXT "proven" -k6 TEXT "main" -k7 INT 41 -k8 INT 1 +k3 TEXT "peer" +k4 TEXT "cache" +k5 TEXT "read" +k6 TEXT "proven" +k7 TEXT "main" +k8 INT 41 +k9 INT 1 == CLASSES == -c0 Cache flags=gc fields=[hits:SCALAR] +c0 Cache flags=gc fields=[hits:SCALAR, peer:GCREF] c1 Holder flags=- fields=[cache:GCREF] == INTERFACES == == VTABLES == @@ -33,7 +34,7 @@ m2 main args=0 regs=6 [free fn] [ENTRY] drops: pc 14 owned={} gc={r0} drops: pc 15 owned={} gc={} 0000 NEW r0, c0 - 0001 LOADK r1, k7 + 0001 LOADK r1, k8 0002 SETF r0, f0, r1 0003 NEW r1, c1 0004 MOVE r2, r0 @@ -42,7 +43,7 @@ m2 main args=0 regs=6 [free fn] [ENTRY] 0007 MOVE r4, r1 0008 CALL r4, m1 0009 MOVE r3, r4 - 0010 LOADK r5, k8 + 0010 LOADK r5, k9 0011 ADD r2, r3, r5 0012 BUILTIN r2, r2, print_int 0013 DROP r1 diff --git a/compiler/test/golden/bc/elision.wo b/compiler/test/golden/bc/elision.wo index 1b9ae6c..1891661 100644 --- a/compiler/test/golden/bc/elision.wo +++ b/compiler/test/golden/bc/elision.wo @@ -7,9 +7,9 @@ -- the emitted body must contain NO borrow op and NO rc op at all — the -- disassembly below is the evidence. `main` is the contrast: an escape -- into a field is a KEPT acquire, so RC_INC does appear there. -@gc class Cache { hits: Int + peer: ?Cache } class Holder { diff --git a/compiler/test/golden/owner/pricing-demo.wo b/compiler/test/golden/owner/pricing-demo.wo index e806b73..7ee2d8c 100644 --- a/compiler/test/golden/owner/pricing-demo.wo +++ b/compiler/test/golden/owner/pricing-demo.wo @@ -27,7 +27,6 @@ class Product { } } -@gc class PriceCache { entries: map } diff --git a/compiler/test/golden/owner/rc.wo b/compiler/test/golden/owner/rc.wo index 1a1392e..8167080 100644 --- a/compiler/test/golden/owner/rc.wo +++ b/compiler/test/golden/owner/rc.wo @@ -1,4 +1,4 @@ -@gc +-- Cache is inferred `gc`: it escapes (returned out of `escaping`), so demand promotion traces it. class Cache { n: Int } diff --git a/compiler/test/runner.ml b/compiler/test/runner.ml index 0a1a971..4b16ead 100644 --- a/compiler/test/runner.ml +++ b/compiler/test/runner.ml @@ -1231,98 +1231,6 @@ let () = check "Float is not a builtin scalar" (not (Types.is_builtin_scalar "Float")); check "Timestamp is a builtin scalar" (Types.is_builtin_scalar "Timestamp") -let () = - (* class Node { next: Node } -- direct self-reference, no @gc, no - @table, no @unique field: WO-W201 must fire, at the class's own - (real) file/line/col, and a warning-only run must still exit 0 - (Diag.Collector's severity-keyed exit-code contract). *) - let path = "node.wo" in - let _, collector = typecheck_str ~file:path "class Node {\n next: Node\n}\n" in - let diags = Diag.Collector.diagnostics collector in - check_eq "gc-suggestion: exactly one diagnostic (WO-W201)" ~expected:1 - ~actual:(List.length diags) string_of_int; - (match diags with - | [ d ] -> - check "gc-suggestion: code is WO-W201" (d.Diag.code = "WO-W201"); - check "gc-suggestion: severity is Warning" (d.Diag.severity = Diag.Warning); - check "gc-suggestion: real file/line/col (node.wo:1:1, the `class` token)" - (d.Diag.site.Diag.file = path && d.Diag.site.Diag.line = 1 && d.Diag.site.Diag.col = 1) - | _ -> check "gc-suggestion: exactly one diagnostic" false); - check_eq "gc-suggestion: a warning-only run exits 0, not 1" ~expected:0 - ~actual:(Diag.Collector.exit_code collector) string_of_int - -let () = - (* @gc class Cache { next: Cache } -- same recursive shape as above, - but already @gc: WO-W201 must NOT fire. *) - let _, collector = typecheck_str ~file:"cache.wo" "@gc\nclass Cache {\n next: Cache\n}\n" in - check_eq "gc-suggestion: @gc class reports nothing" ~expected:0 - ~actual:(List.length (Diag.Collector.diagnostics collector)) string_of_int - -let () = - (* @table(...) class Node2 { next: Node2 } -- recursive, but DB-backed - via @table: WO-W201 must NOT fire (plan: "@table -> must be owned"). *) - let _, collector = - typecheck_str ~file:"node2.wo" - "@table(name: \"nodes\")\nclass Node2 {\n next: Node2\n}\n" - in - check_eq "gc-suggestion: @table class reports nothing" ~expected:0 - ~actual:(List.length (Diag.Collector.diagnostics collector)) string_of_int - -let () = - (* class Node3 { id: Id @unique; next: Node3 } -- recursive, but has a - @unique field (persistent identity): WO-W201 must NOT fire (plan's - "When NOT to emit" list, second bullet). *) - let _, collector = - typecheck_str ~file:"node3.wo" - "class Node3 {\n id: Id @unique\n next: Node3\n}\n" - in - check_eq "gc-suggestion: class with a @unique field reports nothing" ~expected:0 - ~actual:(List.length (Diag.Collector.diagnostics collector)) string_of_int - -let () = - (* class Point { x: Int; y: Int } -- a plain data struct, no - recursive/shared fields: WO-W201 must NOT fire either. *) - let _, collector = - typecheck_str ~file:"point.wo" "class Point {\n x: Int\n y: Int\n}\n" - in - check_eq "gc-suggestion: simple data struct reports nothing" ~expected:0 - ~actual:(List.length (Diag.Collector.diagnostics collector)) string_of_int - -let () = - (* class Calc { items: multi Item } (golden/ast/body-statements.wo's own - shape) -- a `multi` field of an UNRELATED type, not `multi Self`. - has_recursive_structure must key off self-reference, not "any multi - field": a bare `Ast.Multi _ -> true` would spuriously fire WO-W201 - on every plain data class that merely holds a collection. *) - let _, collector = - typecheck_str ~file:"calc.wo" "class Calc {\n items: multi Item\n}\n" - in - check_eq "gc-suggestion: unrelated `multi Item` field reports nothing" ~expected:0 - ~actual:(List.length (Diag.Collector.diagnostics collector)) string_of_int - -let () = - (* class Bucket { entries: map } -- same over-trigger risk - for `map`, neither side self-referential. *) - let _, collector = - typecheck_str ~file:"bucket.wo" "class Bucket {\n entries: map\n}\n" - in - check_eq "gc-suggestion: unrelated `map` field reports nothing" ~expected:0 - ~actual:(List.length (Diag.Collector.diagnostics collector)) string_of_int - -let () = - (* class Tree { children: multi Tree } -- `multi Self` must still fire - (the plan's own literal example of the heuristic). *) - let path = "tree.wo" in - let _, collector = - typecheck_str ~file:path "class Tree {\n children: multi Tree\n}\n" - in - let diags = Diag.Collector.diagnostics collector in - check_eq "gc-suggestion: `multi Self` still fires WO-W201" ~expected:1 - ~actual:(List.length diags) string_of_int; - match diags with - | [ d ] -> check "gc-suggestion: `multi Self` diagnostic is WO-W201" (d.Diag.code = "WO-W201") - | _ -> check "gc-suggestion: `multi Self` exactly one diagnostic" false - let () = (* class BadExample { code: INVALID_TYPE } -- INVALID_TYPE is not a builtin, class, or interface: WO-E225 must fire, at @@ -1955,13 +1863,15 @@ let () = (Option.is_some (find_substring ~needle:"previous loop iteration" d.Diag.message))) let () = - (* @gc is exempt from all of it (spec rule 5): the exact shape that is - WO-E301 above is silent here, and produces no move-table entries - either — a @gc transfer is an rc site, not a move. *) + (* A traced (gc) class is exempt from all of it (spec rule 5): the exact + shape that is WO-E301 above is silent here, and produces no move-table + entries either — a gc transfer is an rc site, not a move. `Cache` is + self-referential (`peer: ?Cache`), so inference classifies it gc without + any annotation (iteration 7b). *) let src = - "@gc\n\ - class Cache {\n\ + "class Cache {\n\ \ n: Int\n\ + \ peer: ?Cache\n\ }\n\ \n\ fn keep(take c: Cache) -> Int {\n\ diff --git a/docs/examples/gc-cycle/README.md b/docs/examples/gc-cycle/README.md index be0ea68..0de6cac 100644 --- a/docs/examples/gc-cycle/README.md +++ b/docs/examples/gc-cycle/README.md @@ -209,13 +209,14 @@ opcode-27/28 retirement, and the sweep list. **Phase 2b (landed).** Demand promotion: the ownership pass, run in collect mode, promotes any class whose value *must escape* (returned, stored where it -outlives its scope) — the acyclic-but-aliased case (`PriceCache`) inference by -structure cannot see. So GC-ness is now fully inferred (cycles by structure + -aliasing by demand), and `@gc` is redundant everywhere. What remains is -*removing the `@gc` keyword itself* — the parser rejecting it, and the RC/`@gc` -golden + `test_diag` assertions being rewritten — which is coupled to Phase 3 -(the RC machinery those tests cover is deleted there), so the keyword removal -lands with Phase 3. +outlives its scope) — the acyclic-but-aliased case inference by structure cannot +see, targeting the escaping projection's type precisely. So GC-ness is fully +inferred: cycles by structure + aliasing by demand. + +**`@gc` removed from the language (landed).** The keyword is now a hard error +(`WO-E104`): a developer never writes or mentions it; `woc --dump-gc` shows what +inference decided. WO-W201 (which suggested `@gc`) is retired. No `.wo` in the +repo carries `@gc`. This is the front-end half of iteration 7b, complete. When 7b lands, the acceptance is: - `woc --dump-gc docs/examples/gc-cycle` classifies `Node gc (cycle …)` / diff --git a/docs/plan/oop-vm/01-error-catalog.md b/docs/plan/oop-vm/01-error-catalog.md index e0274fa..b4e7f4a 100644 --- a/docs/plan/oop-vm/01-error-catalog.md +++ b/docs/plan/oop-vm/01-error-catalog.md @@ -34,6 +34,7 @@ half of the story ("moved here" / "borrowed here" / etc.). | 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: `name` given twice, an `index` with no columns, or an argument key other than `name`/`index`. | `@table(name: ...) given twice` | | 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-E104 | iteration 7b. `@gc` on a class — the annotation is gone: GC-ness is inferred (structural cycles + demand promotion; `woc --dump-gc`). The annotation is skipped for recovery and the class classifies by inference. | `` `@gc` is not a valid annotation: GC-ness is inferred by the compiler (run `woc --dump-gc`). Remove it. `` | ## WO-E2xx / WO-W2xx — types (Task 6, `compiler/src/types.ml`; WO-E214 Task 8, `compiler/bin/main.ml`; WO-E215 plan 3 Task 2, `compiler/src/types.ml`; WO-E210/E216–E218/W202 haxe-parity Task 1, `compiler/src/types.ml`; WO-E201 haxe-parity Task 2, `compiler/src/types.ml`; WO-E208 haxe-parity Task 3, `compiler/src/types.ml`; WO-E203 haxe-parity Task 4, `compiler/src/types.ml`) @@ -41,7 +42,7 @@ half of the story ("moved here" / "borrowed here" / etc.). | --- | --- | --- | | WO-E201 | haxe-parity Task 2. An `and`/`or` operand whose type is confidently known (the same narrow, "stay silent when underivable" deriver WO-E209 uses — `confident_typ`) and is not `Bool` — this language has no truthiness. Reserved since Task 6, its first real emission site. Haxe-parity Task 3 gave it two more sites: a `switch` expression's arms disagree on their yielded type (the switch's own type is fixed by the first arm — types.ml's "first wins" convention — every later arm is checked against it, via the regular `.typ` inference, not `confident_typ`; since haxe-parity Task 4 the comparison is *structural* — two typedef records with the same shape are the same type, `typ_equal`); and (review fix, Critical 2) a `case` value whose representation (`WO_K_TEXT` vs. `WO_K_SCALAR`) doesn't match the switch subject's — a real VM segfault if unchecked (a `Text` subject picks EQS, and EQS's `str_check` dereferences whatever sits in a mismatched `Int` case value's register), checked via `confident_typ`, silent when either side is unresolved. Haxe-parity Task 4 added the union-subject site: a `case` value over a confidently union-typed subject that does not name one of that union's variants (a misspelled variant, a variant of some other union, or a plain literal — union arms match variants, never values). Task 4's fix round 1 added three inverse/porosity sites, each a reviewer-reproduced silent-wrong-behavior hole: a variant-named `case` over a confidently **`?Union`** subject (it can never match — `switch` does not narrow `?T`; the message points at handling nil first, since forced handling is Task 6's), a variant-named `case` over a confidently **non-union** subject (`switch n { case Lo: }` over `n: Int` silently ordinal-matched), and a **cross-union `==`/`!=`** (`X == P` from two different bare unions was silently true whenever the ordinals matched; same-union comparison stays legal). Lexical scope wins at every one of these sites — a local sharing a variant's name is never misread as one. | `` `Wat` is not a variant of union `Kind` `` | | WO-E203 | haxe-parity Task 4 (`typedef` records + enum payload variants). A payload variant's argument count doesn't match its declaration, at either of the two places payload fields are positional: a construction (`Failed("a", "b")` against `Failed(reason: Text)`) or a `switch` pattern (`case Failed(a, b):`). The pattern site also rejects a non-name argument (`case Failed("x"):` — payload fields are bound positionally, never matched by value) and a payload-binding pattern sharing its arm with other values (`case Failed(r), Pending:` — the binding would be meaningless on the other match). Reserved since plan 2 Task 6; these are its first real emission sites, scoped to variant payloads only — user `fn`/method call arity is still the emitter's WO-E403, unchanged (see "Reserved, not yet emitted" below for the history of that gap). | `` variant `Failed` of `Status` takes 1 payload argument(s), given 2 `` | -| WO-W201 *(warning)* | a class has recursive/shared structure (a field, directly or through `ref`/`multi`/`map`/`?`, refers back to its own class) that the ownership pass cannot prove disjoint, has no `@table`, and has no `@unique` field — suggests `@gc`. | `Node has recursive/shared structure that borrow checker cannot prove. Consider adding @gc if this is an ephemeral in-memory cache. If this maps to a database table, keep owned (default).` | +| WO-W201 *(retired, iteration 7b)* | Suggested `@gc` for a recursive/shared class. Retired: `@gc` no longer exists (WO-E104) and inference classifies exactly these classes as traced automatically, so the suggestion is obsolete. | *(no longer emitted)* | | WO-E202 | a `.field` access names a field that the base's class (a *declared* class — an unresolved/placeholder expression type never triggers this) doesn't have. | `unknown field \`price\` on \`Product\`` | | WO-E206 | a constructor literal (`ClassName { ... }`) omits a field the class declares that is neither defaulted nor nullable. Haxe-parity Task 4 narrowed it from "omits any field with no default was already the rule, but defaults were unenforceable" to the real omittability rule: a field with a declared default is filled by the emitter (`TailState {}` — the sample's defaults-fill-in pattern), and a `?`-typed field omitted is nil (the zero word `NEW` already leaves), for classes and typedef records alike. | `missing field \`sku\` in constructor of \`Product\`` | | WO-E207 | a constructor literal names a class that isn't declared anywhere in the (possibly multi-file) program. `typedef` records (haxe-parity Task 4) are classes to this check — a record name resolves here like any declared class. | `unknown type \`Widget\` in constructor` | diff --git a/tests/corpus/gc/abandoned-cycle/fixture.wo b/tests/corpus/gc/abandoned-cycle/fixture.wo index 757874c..1138ed9 100644 --- a/tests/corpus/gc/abandoned-cycle/fixture.wo +++ b/tests/corpus/gc/abandoned-cycle/fixture.wo @@ -4,7 +4,6 @@ -- same trick runtime/test/test_cycle.c uses (BIG=130) so ASan can prove -- a Node is actually freed, not just recycled inside the arena's own -- freelist where a sanitizer can never see it. -@gc class Node { peers: multi Node p1: Int diff --git a/tests/corpus/gc/budget-steps/fixture.wo b/tests/corpus/gc/budget-steps/fixture.wo index b7cbe4a..536c50a 100644 --- a/tests/corpus/gc/budget-steps/fixture.wo +++ b/tests/corpus/gc/budget-steps/fixture.wo @@ -4,7 +4,6 @@ -- same trick runtime/test/test_cycle.c uses (BIG=130) so ASan can prove -- a Node is actually freed, not just recycled inside the arena's own -- freelist where a sanitizer can never see it. -@gc class Node { peers: multi Node p1: Int