From eb0095428dcbcae70be6b8936002810fa56e5c09 Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Fri, 14 Aug 2026 22:45:03 +0200 Subject: [PATCH] fix: Text is an owned value copied at every boundary (executable plan, Task 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on the workload's supervisor mode, eight seconds, clean SIGTERM exit: run 1 051 040 B in 24 allocations -> 2 112 B in 19; watch 128 B in 2 -> 64 B in 1. corpus 71/0, woc runtest 565/0, wovm unit gates green, just log-watcher 6/0. - owner.ml: `oclass_of` called `Text` a builtin scalar, so it was Copy and NO Text local was ever dropped — that, not the missing stdlib table, was the leak. Text is now Owned, which forces an answer for what it does at an ownership boundary, and the answer is uniform: it is COPIED. Into a container (push/set/`m[i] = v`, already true), into a field (SETF), out of a function (return), into a binding (`let s = other`), and into a loop cursor. The source keeps its value; a freshly built Text stays the caller's and is dropped at the site - owner.ml: resolve_callee answers for three shapes it never knew — reserved stdlib members, builtins, and a class's `static` members — so their results get a type, an owner and a drop - vm/builtin: WO_B_TEXT_COPY, the one new builtin the rule needs; SETF copies a TEXT field in; emit copies a Text read out of a container, bound from a place, returned from a place, or loaded into a cursor, and drops a freshly built one after a copying store - sysio.c: fs.read_all/net.read allocated their cap then relabelled the buffer with the short length — but wo_str_free sizes a block by its len (no size headers, obj.h), so a 1 MiB buffer wearing a 30-byte length went onto a 32-byte free list and never came back. They copy out at the true size now - two regressions the corpus caught, fixed in the same pass: a @gc value read out of a container is a plain borrow, not an rc-counted alias; and push's @gc escape is keyed on "push is not a user-declared fn" rather than "the callee did not resolve", which stopped being true once builtins resolved - docs: Task 1 closed in the executable plan with its before/after numbers, and the status board's item 1 records the deeper root cause Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 1 - compiler/src/emit.ml | 73 ++++++-- compiler/src/owner.ml | 171 +++++++++++++++++- compiler/src/types.ml | 13 ++ .../test/golden/owner/pricing-demo.expected | 1 + docs/00-status.md | 12 +- .../2026-08-14-logwatcher-executable.md | 37 +++- runtime/src/builtin.c | 16 ++ runtime/src/loader.c | 1 + runtime/src/sysio.c | 31 +++- runtime/src/vm.c | 20 +- runtime/src/wob.h | 7 +- 12 files changed, 336 insertions(+), 47 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e356865..749b50d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,6 @@ Always inspect crashsites. Always measure. Never assume. - caveman - context-mode - web-search -- superpowers --- diff --git a/compiler/src/emit.ml b/compiler/src/emit.ml index a9c64b9..2e37b3f 100644 --- a/compiler/src/emit.ml +++ b/compiler/src/emit.ml @@ -256,6 +256,7 @@ let b_map_key_at = 37 let b_map_val_at = 38 let b_multi_set = 39 let b_map_get_opt = 59 +let b_text_copy = 60 (* json (runtime/src/json.c): encode takes the value's static kind as its second argument, decode the class id to build as its second. *) @@ -855,21 +856,6 @@ let iface_method (p : pctx) (iname : string) (m : string) : (int * Types.method_ | None -> None | Some sg -> Some (slot, sg)))) -(* Types.typ -> this file's own Ast.field_ty view. Needed for the one table - that is stated in the typechecker's language and consumed here: the - systems stdlib's declared return shapes (Types.stdlib_members). Container - element types beyond one scalar level cannot be spelled as a field_ty - (`Multi of string`), so a nested container yields None — nothing in the - stdlib returns one. *) -let rec field_ty_of_typ (t : Types.typ) : Ast.field_ty option = - match t with - | Types.TScalar n -> Some (Scalar n) - | Types.TRef n -> Some (Ref n) - | Types.TMulti (Types.TScalar n) -> Some (Multi n) - | Types.TMap (Types.TScalar k, Types.TScalar v) -> Some (Map (k, v)) - | Types.TNullable inner -> ( match field_ty_of_typ inner with Some ft -> Some (Nullable ft) | None -> None) - | Types.TMulti _ | Types.TMap _ | Types.TVoid -> None - let builtin_ret (name : string) (argty : Ast.field_ty option) : Ast.field_ty option = match name with | "int_to_text" -> Some (Scalar "Text") @@ -1034,7 +1020,7 @@ let rec ty_of_expr (p : pctx) (f : fstate) (e : Ast.expr) : Ast.field_ty option stdlib_members) — the source of `st.size` resolving at all *) | Some u when u.Types.ue_is_stdlib -> ( match Types.stdlib_member alias mname with - | Some sm -> ( match sm.Types.sm_ret with Some t -> field_ty_of_typ t | None -> None) + | Some sm -> ( match sm.Types.sm_ret with Some t -> Types.field_ty_of_typ t | None -> None) | None -> None) | Some u -> ( let target_mid = Types.path_str u.Types.ue_segments in @@ -1366,6 +1352,17 @@ let container_imm (p : pctx) (expected : Ast.field_ty option) (map : bool) : int result, a concatenation, an interpolation — and left alone when it was read out of a place, whose owner still holds it. Types the emitter cannot resolve are left alone: a missed drop is a leak, a wrong drop is a crash. *) +(* The mirror of drop_fresh_text: a Text read out of a PLACE is copied when it + crosses an ownership boundary (a binding, a return), so the place keeps its + own value and the new owner gets its own. A freshly built Text is already + nobody else's and passes through untouched. *) +let copy_place_text (p : pctx) (f : fstate) (reg : int) (e : Ast.expr) : unit = + let is_place = match e.Ast.kind with Ast.Ident _ | Ast.Field _ | Ast.Index _ -> true | _ -> false in + let is_text = + match ty_of_expr p f e with Some t -> field_kind p t = 3 (* WO_K_TEXT *) | None -> false + in + if is_place && is_text then put f (ins_abc op_builtin reg reg b_text_copy) + let drop_fresh_text (p : pctx) (f : fstate) (reg : int) (e : Ast.expr) : unit = let is_place = match e.Ast.kind with Ast.Ident _ | Ast.Field _ | Ast.Index _ -> true | _ -> false in let is_text = @@ -1548,7 +1545,11 @@ let rec emit_expr (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e let w = alloc_temps p f e.pos 2 in emit_expr p f v ~dst:w base; emit_expr p f v ~dst:(w + 1) idx; - put f (ins_abc op_builtin dst w bid)) + put f (ins_abc op_builtin dst w bid); + (* a Text read out of a container is COPIED: the container keeps owning + its element, the reader owns the copy (see owner.ml's copies_out) *) + if (match ty_of_expr p f e with Some t -> field_kind p t = 3 | None -> false) then + put f (ins_abc op_builtin dst dst b_text_copy)) | Unary (Neg, o) -> let b = emit_operand p f v o in put f (ins_abc op_neg dst b 0) @@ -2178,6 +2179,9 @@ and emit_ctor (p : pctx) (f : fstate) (v : views) ~(dst : int) (e : Ast.expr) (c emit_expr p f v ~dst:t ~expected:fty fe; f.f_cur_line <- fe.pos.line; put f (ins_abc op_setf dst (check_field_idx p f e.pos idx) t); + (* SETF copies a TEXT field in, so a freshly built one is still this + frame's to drop — see drop_fresh_text *) + drop_fresh_text p f t fe; f.f_temp <- save) fields; (* haxe-parity Task 4: fields the literal omitted. A declared @@ -2844,7 +2848,12 @@ and emit_builtin (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : match args with | a :: _ -> ( match container_id a b_multi_get b_map_get with - | Some id -> fixed id + | Some id -> + fixed id; + if (match builtin_ret name (match args with x :: _ -> ty_of_expr p f x | [] -> None) with + | Some t -> field_kind p t = 3 + | None -> false) + then put f (ins_abc op_builtin dst dst b_text_copy) | None -> bad "builtin `get` needs a `multi` or a `map` as its first argument") | [] -> bad "builtin `get` takes 2 arguments, given 0") | "set" -> ( @@ -2880,6 +2889,8 @@ and emit_stmt (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) : unit = (match declared with | Some t -> emit_expr p f v ~dst:r ~expected:t value | None -> emit_expr p f v ~dst:r value); + (* a Text bound out of a place is this binding's own copy *) + copy_place_text p f r value; f.f_env <- (name, (r, vty)) :: f.f_env; Hashtbl.replace f.f_decl s.s_id r; f.f_declared <- s.s_id :: f.f_declared; @@ -3026,6 +3037,9 @@ and emit_assign (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (target : Ast acquire_guards f guards; put f (ins_abc op_setf b idx t); release_guards f guards; + (* SETF copies a TEXT field in, so a freshly built one stays this + frame's to drop — see drop_fresh_text *) + drop_fresh_text p f t value; match Hashtbl.find_opt v.v_move value.id with | Some place -> ( match lookup_local f place with Some (sr, _) -> mask_clear f sr | None -> ()) | None -> ())) @@ -3094,6 +3108,24 @@ and emit_return (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (opt : Ast.ex f.f_div <- true | Some e -> let t = emit_tail p f v e in + (* Returning a Text crosses an ownership boundary, like storing one into a + field or a container: when the value is a PLACE (a local, a field, a + loop cursor, a container read) the callee only borrows it, so the caller + must not become a second owner — copy. A freshly built Text is already + owned by nobody else and passes straight through. Without this, + `return lv` inside `for lv in [...]` is WO-E304 and the workload's own + level classifier cannot be written at all. *) + let t = + let is_place = match e.Ast.kind with Ast.Ident _ | Ast.Field _ | Ast.Index _ -> true | _ -> false in + let is_text = match ty_of_expr p f e with Some ty -> field_kind p ty = 3 | None -> false in + if is_place && is_text then begin + let w = alloc_temps p f e.pos 1 in + put f (ins_abc op_move w t 0); + put f (ins_abc op_builtin w w b_text_copy); + w + end + else t + in (match Hashtbl.find_opt v.v_move e.id with | Some place -> ( match lookup_local f place with Some (sr, _) -> mask_clear f sr | None -> ()) | None -> ()); @@ -3303,6 +3335,8 @@ and emit_for (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (var : string) put f (ins_abc op_move (w + 1) ri 0); put f (ins_abc op_builtin rk w b_map_key_at); put f (ins_abc op_builtin rv w b_map_val_at); + if field_kind p kt = 3 then put f (ins_abc op_builtin rk rk b_text_copy); + if field_kind p vt = 3 then put f (ins_abc op_builtin rv rv b_text_copy); let lf = { lf_node = s.s_id; lf_breaks = []; lf_continues = [] } in f.f_loops <- lf :: f.f_loops; List.iter (emit_stmt p f v) body; @@ -3358,6 +3392,9 @@ and emit_for (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (var : string) let jz = here f in put f (ins_asbx op_jz tc 0); put f (ins_abc op_builtin rv rc b_multi_get); + (* a Text cursor holds a copy — owner.ml declares it owned, and the + scope-end drop for the loop body releases it each iteration *) + if field_kind p elem = 3 then put f (ins_abc op_builtin rv rv b_text_copy); let lf = { lf_node = s.s_id; lf_breaks = []; lf_continues = [] } in f.f_loops <- lf :: f.f_loops; List.iter (emit_stmt p f v) body; diff --git a/compiler/src/owner.ml b/compiler/src/owner.ml index 1bd0e90..c268bf3 100644 --- a/compiler/src/owner.ml +++ b/compiler/src/owner.ml @@ -413,6 +413,18 @@ let rec unwrap_nullable (ft : Ast.field_ty) : Ast.field_ty = let oclass_of (ctx : ctx) (ft : Ast.field_ty) : oclass = match unwrap_nullable ft with + (* `Text` is a HEAP value (runtime/src/obj.h's wo_str), so a binding that + holds a fresh one owns it and must drop it at scope end. It was Copy + until 2026-08-14 — grouped with Int/Bool because types.ml calls it a + builtin scalar — and the consequence was that no Text local was ever + dropped: every concatenation, interpolation and stdlib read accumulated + in the arena for the life of the process. Invisible in the corpus (small + strings live in the arena, which is freed wholesale at exit) and fatal in + a daemon (the workload's supervisor leaked a 1 MiB `fs.read_all` result + per rescan, which is a plain malloc and so ASan-visible). A Text read out + of a PLACE is still a borrow — analyze_let's own place logic decides + that, exactly as it does for a record field. *) + | Scalar n when n = "Text" || n = Types.json_value_type -> Owned | Scalar n -> if Types.is_builtin_scalar n then Copy else if Types.is_gc_class ctx.syms n then Gc @@ -541,6 +553,11 @@ let rec expr_ty (ctx : ctx) (e : Ast.expr) : Ast.field_ty option = | Ident n -> variant_union_ty ctx n | _ -> None)) | Unary (_, o) -> expr_ty ctx o + (* `..` (CONCAT) always produces a FRESH Text, and interpolation desugars to + exactly such a chain — so this is what gives every interpolated or + concatenated binding an owner and a drop. Left as None before + 2026-08-14, which made those bindings Copy and leaked every one. *) + | Binary (Concat, _, _) -> Some (Scalar "Text") | Binary _ -> None (* arithmetic/comparison: Copy either way *) | Ctor (cn, _) -> Some (Scalar cn) | Interp _ -> Some (Scalar "Text") (* an interpolation always produces Text *) @@ -617,7 +634,21 @@ and resolve_callee (ctx : ctx) (callee : Ast.expr) : callee option = | Some (f : Types.free_fn_info) -> Some { ce_params = params_of f.Types.params; ce_ret = f.Types.ret; ce_recv_excl = false } - | None -> None) + (* A BUILTIN's return type, from the table types.ml and emit.ml already + read. `split`/`split_ws`/`slice` hand back a fresh `multi` and + `substr`/`trim`/`join`/… a fresh Text; without this they resolved to + nothing, the binding fell back to Copy, and every one of those + containers leaked (measured: the workload's supervisor mode). A + user-declared fn of the same name wins above, the shadowing rule the + builtin surface already states. *) + | None -> ( + let arg0 = None in + match Types.builtin_confident_ret name arg0 with + | Some t -> ( + match Types.field_ty_of_typ t with + | Some ft -> Some { ce_params = []; ce_ret = Some ft; ce_recv_excl = false } + | None -> None) + | None -> None)) | Field (base, mname) -> ( match expr_ty ctx base with | Some bt -> ( @@ -633,7 +664,41 @@ and resolve_callee (ctx : ctx) (callee : Ast.expr) : callee option = { ce_params = params_of m.Types.params; ce_ret = m.Types.ret; ce_recv_excl = body_writes_self m.Types.body })) | _ -> None) - | None -> None) + (* The base is not a value: it names a reserved stdlib module + (`fs.read_all(path, cap)`) or a class with a static member + (`Tools.needle(cmd)`). Both shapes were unresolved here until + 2026-08-14, and an unresolved callee is not a missing *type* — it is a + missing LIFETIME: analyze_let's None-fallback classifies the binding + `Scalar "Int"`, oclass_of calls that Copy, and the fresh Text or + `multi` the call returned is never dropped. That was the measured + >1 MB leak in eight seconds of the workload's supervisor mode (one + `fs.read_all` result per cron file). The tables read here are the same + ones types.ml and emit.ml already read; a local of the same name + shadows the module, exactly as it does everywhere else. *) + | None -> ( + match base.kind with + | Ident head when find_local ctx head = None -> ( + match Types.stdlib_member head mname with + | Some sm -> + Some + { ce_params = []; + ce_ret = ( match sm.Types.sm_ret with Some t -> Types.field_ty_of_typ t | None -> None); + ce_recv_excl = false } + | None -> ( + match Types.StringMap.find_opt head ctx.syms.Types.classes with + | None -> None + | Some (cls : Types.class_info) -> ( + match + List.find_opt + (fun (m : Types.method_info) -> m.Types.name = mname && m.Types.is_static) + cls.Types.methods + with + | None -> None + | Some m -> + Some + { ce_params = params_of m.Types.params; ce_ret = m.Types.ret; + ce_recv_excl = false }))) + | _ -> None)) | _ -> None (* ============================================================ @@ -655,9 +720,24 @@ let rec idx_text (e : Ast.expr) : string = let idx_proj (e : Ast.expr) : proj = match e.kind with IntLit n -> PConst n | _ -> PDyn (idx_text e) +(* Builtins that hand back a POINTER INTO their container rather than a fresh + value: binding one binds a borrow of that container, not a second owner. + `pop`/`shift` are deliberately absent — they remove the element, so the + caller really does take ownership. Now that Text is Owned (oclass_of), this + distinction is what keeps `let v = get(m, k)` from dropping a string the + map still holds. *) +let borrowing_builtin (name : string) : bool = + List.mem name [ "get"; "latest"; "key_at"; "val_at" ] + let rec place_of (e : Ast.expr) : place option = match e.kind with | Ident n -> Some { root = n; projs = []; ppos = e.pos; pnode = e.id } + | Call ({ kind = Ident bname; _ }, (container :: rest)) when borrowing_builtin bname -> ( + match place_of container with + | Some p -> + let proj = match rest with idx :: _ -> idx_proj idx | [] -> PDyn ("#" ^ string_of_int e.id) in + Some { p with projs = p.projs @ [ proj ]; pnode = e.id } + | None -> None) | Field (base, f) -> ( match place_of base with | Some p -> Some { p with projs = p.projs @ [ PField f ]; pnode = e.id } @@ -995,6 +1075,19 @@ let gc_escape (ctx : ctx) (p : place) : unit = an already-moved local) must be classified as a read, or the region's pairwise check reports a bogus move conflict on top of the escape error `transfer` is about to give. *) +(* A `Text` stored into a field or a record is COPIED by the VM (SETF's own + rule, the same one push/set follow) — so it is neither a move out of the + source nor a borrow escaping its scope. Without this, `fn rename(name: Text) + { self.name = name }` — the most ordinary line in the workload — is + WO-E304, and the only way to write it would be `take name: Text`. Copying + is what keeps a field's owner the object itself. *) +let stores_by_copy (ctx : ctx) (p : place) : bool = + match place_ty ctx p with + | Some t -> ( match unwrap_nullable t with + | Scalar n -> n = "Text" || n = Types.json_value_type + | _ -> false) + | None -> false + let is_real_transfer (ctx : ctx) (p : place) : bool = p.projs = [] && match root_local ctx p with Some l -> l.l_holds && l.l_state = Live | None -> false @@ -1098,7 +1191,8 @@ and analyze_ctor (ctx : ctx) (cn : string) (fields : (string * Ast.expr) list) : match place_of fe with | None -> () | Some p -> - if transfer ctx p ~what:(Printf.sprintf "cannot be stored in `%s.%s`" cn fname) then + if stores_by_copy ctx p then () (* the field gets its own copy *) + else if transfer ctx p ~what:(Printf.sprintf "cannot be stored in `%s.%s`" cn fname) then record_move ctx p (MvCtorField fname)) fields @@ -1230,8 +1324,16 @@ and analyze_call (ctx : ctx) (call_e : Ast.expr) (callee : Ast.expr) (args : Ast in. Narrow to `push`'s own value slot (index 1) and to Gc places only — an Owned element's move-on-push is a separate, pre-existing gap this task does not touch. *) + (* Keyed on "`push` is not a user-declared fn", NOT on "the callee did not + resolve": since 2026-08-14 resolve_callee answers for builtins too (their + return types are what give a `split`/`slice` binding its drop), and the + old `resolved = None` test silently stopped firing — the pushed @gc value + lost its RC_INC, the collector freed it while the container still held it, + and both `gc/` fixtures died with a use-after-free. *) let is_push_gc_value i = - resolved = None && i = 1 && match callee.kind with Ident "push" -> true | _ -> false + i = 1 + && Types.StringMap.find_opt "push" ctx.syms.Types.free_fns = None + && match callee.kind with Ident "push" -> true | _ -> false in List.iteri (fun i a -> @@ -1523,9 +1625,25 @@ and analyze_stmt (ctx : ctx) (s : Ast.stmt) : unit = | Some t, _ -> (None, ( match elem_ty t with Some e -> e | None -> t)) | None, _ -> (None, Scalar "Int") in + (* A cursor over Text elements holds a COPY, not a borrow: the emitter + copies each element as it loads it (the same boundary rule containers, + fields and returns follow), so the body owns its cursor and drops it per + iteration. That is also what lets `for k, v in m { v.field = … }` work — + a borrowing key cursor made every mutation through the value cursor a + WO-E303 against the container's own borrow. Any other element type is + still a borrow: records are not copied. *) let cursor (n : string) (t : Ast.field_ty) : local = - { l_name = n; l_ty = t; l_class = oclass_of ctx t; l_node = s.s_id; l_pos = s.s_pos; - l_holds = false; l_src = src; l_bkind = AShared; l_state = Borrowed s.s_pos } + let copied = + match unwrap_nullable t with + | Scalar cn -> cn = "Text" || cn = Types.json_value_type + | _ -> false + in + if copied then + { l_name = n; l_ty = t; l_class = Owned; l_node = s.s_id; l_pos = s.s_pos; l_holds = true; + l_src = None; l_bkind = AShared; l_state = Live } + else + { l_name = n; l_ty = t; l_class = oclass_of ctx t; l_node = s.s_id; l_pos = s.s_pos; + l_holds = false; l_src = src; l_bkind = AShared; l_state = Borrowed s.s_pos } in ctx.loop_stack <- s.s_id :: ctx.loop_stack; fixpoint ctx @@ -1641,9 +1759,37 @@ and analyze_let (ctx : ctx) (s : Ast.stmt) (name : string) (ty : Ast.field_ty op | None -> ( match expr_ty ctx value with Some t -> t | None -> Scalar "Int") in let cls = oclass_of ctx vty in - let vplace = place_of value in + (* A container read that yields a Text is COPIED by the emitter — the fourth + ownership boundary, and the one that keeps `let cl = headers["x"]` from + borrowing the map for the rest of the scope (which then forbade moving + that map into a record, WO-E302). For any other element type the read is + still a borrow of the container: records are not copied. *) + let reads_container = + match value.Ast.kind with + | Ast.Index _ -> true + | Ast.Call ({ Ast.kind = Ast.Ident bn; _ }, _) -> borrowing_builtin bn + | _ -> false + in + let copies_out = reads_container && (match unwrap_nullable vty with + | Scalar n -> n = "Text" || n = Types.json_value_type + | _ -> false) in + let vplace = if copies_out then None else place_of value in + (* A `@gc` value read out of a container is neither a copy nor a new + reference: the container holds the count, and the reader only looks. It + must NOT take the Gc-alias path below (which records an RC_INC/RC_DEC + pair) — doing so double-released the element and segfaulted both `gc/` + fixtures. Reading it as a plain borrow of the container is what the pass + did before container reads became places, now with the right type. *) + let gc_container_read = reads_container && cls = Gc in let holds, src, state = match (cls, vplace) with + | Gc, _ when gc_container_read -> (false, vplace, Borrowed s.s_pos) + (* Binding a Text from a PLACE copies it — the same boundary rule as a + field store, a container element, a loop cursor and a return. The + source stays live and keeps its own value; this binding owns the copy + and drops it at scope end. Without it `let range = part` MOVED `part`, + and the next line's `index_of(part, "/")` was a use-after-move. *) + | (Owned | Gc), Some p when stores_by_copy ctx p -> (true, None, Live) | Copy, _ -> (false, None, Live) | Owned, None -> (true, None, Live) (* fresh value: constructor or call result *) | Owned, Some p -> ( @@ -1740,7 +1886,8 @@ and analyze_assign (ctx : ctx) (s : Ast.stmt) (target : Ast.expr) (value : Ast.e (match tplace with Some tp -> place_text tp | None -> "a field") else "cannot be moved out" in - if transfer ctx vp ~what then record_move ctx vp MvAssign); + if into_field && stores_by_copy ctx vp then () (* the field gets its own copy *) + else if transfer ctx vp ~what then record_move ctx vp MvAssign); (* Whatever the value was — a fresh constructor, a call result, another local — assigning to a whole local re-initializes it: a local that had been moved out of is live again afterwards. *) @@ -1757,7 +1904,13 @@ and analyze_return (ctx : ctx) (s : Ast.stmt) (opt : Ast.expr option) : unit = match place_of e with | None -> () | Some p -> - if transfer ctx p ~what:(Printf.sprintf "escapes `%s`" ctx.fn_name) then + (* Returning a Text is the third ownership boundary that COPIES (the + other two are a container element and a field): the caller gets its + own string, the callee's borrow stays the borrow it was. emit.ml + emits that copy. Without this the workload's own level classifier — + `for lv in [...] { … return lv }` — cannot be written at all. *) + if stores_by_copy ctx p then () + else if transfer ctx p ~what:(Printf.sprintf "escapes `%s`" ctx.fn_name) then record_move ctx p MvReturn)); let live = live_holders ctx in record_drop ctx ~node:s.s_id ~pos:s.s_pos ~kind:DReturn ~items:(owned_items live); diff --git a/compiler/src/types.ml b/compiler/src/types.ml index 61da425..3a5395f 100644 --- a/compiler/src/types.ml +++ b/compiler/src/types.ml @@ -344,6 +344,19 @@ let suggest_gc_annotation ~file (cls : class_info) (collector : Diag.Collector.t 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; the check pass still calls it under its old local name. *) +(* The reverse of typ_of_field_ty: this pass states the stdlib's return shapes + as `typ`, and both the ownership pass and the emitter reason in + `Ast.field_ty`. A container of containers cannot be spelled as a field_ty + (`Multi of string`), so it answers None — nothing in the stdlib returns one. *) +let rec field_ty_of_typ (t : typ) : field_ty option = + match t with + | TScalar n -> Some (Scalar n) + | TRef n -> Some (Ref n) + | TMulti (TScalar n) -> Some (Multi n) + | TMap (TScalar k, TScalar v) -> Some (Map (k, v)) + | TNullable inner -> ( match field_ty_of_typ inner with Some ft -> Some (Nullable ft) | None -> None) + | TMulti _ | TMap _ | TVoid -> None + let rec typ_of_field_ty (ft : field_ty) : typ = match ft with | Scalar name -> TScalar name diff --git a/compiler/test/golden/owner/pricing-demo.expected b/compiler/test/golden/owner/pricing-demo.expected index 21f73a6..39fdcb1 100644 --- a/compiler/test/golden/owner/pricing-demo.expected +++ b/compiler/test/golden/owner/pricing-demo.expected @@ -1,6 +1,7 @@ == MOVES == 26:12 MOVE other RETURN == DROPS == +18:5 OVERWRITE self.name 22:5 OVERWRITE self.prices == RC == == RESIDUAL == diff --git a/docs/00-status.md b/docs/00-status.md index 6a86acb..e986b3d 100644 --- a/docs/00-status.md +++ b/docs/00-status.md @@ -29,10 +29,14 @@ gates it: 6 checks, 0 failures. What is left is the difference between "it runs" and "you can leave it running", and every item below came from a measurement on the sample itself: -1. **The ownership pass does not know what the stdlib returns** — so a binding - holding a fresh `fs.read_all`/`fs.list`/`net.read`/`json.encode` result is - classified Copy and never dropped. Measured: >1 MB leaked in eight seconds - of `run` mode, the largest single allocation being one `fs.read_all` result. +1. ~~The ownership pass does not know what the stdlib returns~~ — **done + 2026-08-14**. The root cause was deeper than the table: `Text` was + classified Copy, so no Text local was ever dropped. `Text` is now an owned + heap value that is **copied at every ownership boundary** (container, field, + return, binding, loop cursor), the ownership pass reads the stdlib, builtin + and static tables, and `fs.read_all`/`net.read` no longer mis-size a short + read's buffer. Measured: `run` **1 051 040 B → 2 112 B**, `watch` + **128 B → 64 B**; what remains is items 2 and 3 below, by stack. 2. **A projected temporary is never dropped** — `for e in parse_dir(d).entries` keeps the elements (correct) and leaks the record shell, once per rescan. 3. **The runtime leaks its own argv container** — 128 bytes in 2 allocations on diff --git a/docs/plan/compiler/2026-08-14-logwatcher-executable.md b/docs/plan/compiler/2026-08-14-logwatcher-executable.md index 3a18f96..6d4e483 100644 --- a/docs/plan/compiler/2026-08-14-logwatcher-executable.md +++ b/docs/plan/compiler/2026-08-14-logwatcher-executable.md @@ -60,7 +60,7 @@ docs/examples/log-watcher/ mcp.wo: close what accept opened (Task 5) scripts/log-watcher-accept.sh the soak check (Task 6) ``` -### Task 1: The owner pass must know what the stdlib returns +### Task 1 ✅: The owner pass must know what a callee returns — and what a `Text` is **Concept & reason:** `owner.ml`'s `expr_ty`/`resolve_callee` have no stdlib table — `types.ml` and `emit.ml` each got one, the ownership pass did not. So a @@ -72,13 +72,34 @@ fs.read_all(path, FILE_CAP) catch (e) nil` holds a fresh Text nobody frees. The fix is to read the same `Types.stdlib_members` table the other two passes read, including through a `try`'s arms, so the classification matches reality. -- [ ] Failing measurement first: record the current ASan totals for `watch` and - `run` (eight seconds each, clean exit via SIGTERM) so the drop is proven, - not assumed. -- [ ] Teach the ownership pass the stdlib return shapes; every stdlib member - that yields a fresh Text, `multi` or record is Owned at its binding. -- [ ] Re-measure: the `fs.read_all` and `fs.list` allocations disappear from - both modes' reports; `just oop-e2e` and `just woc-test` stay green. +- [x] Failing measurement first: `run` 1 051 040 B in 24 allocations, `watch` + 128 B in 2, both over eight seconds with a clean SIGTERM exit. +- [x] Teach the ownership pass what a callee returns — three tables it never + read: the stdlib members, the builtins, and a class's `static` members. +- [x] **`Text` is an owned heap value, not a scalar.** `oclass_of` grouped it + with Int/Bool, so no Text local was ever dropped; that, not the stdlib + table alone, was the leak. Making it Owned forces the language to answer + what a Text does at an ownership boundary, and the answer is uniform: it + is **copied** — into a container (push/set/`m[i] = v`), into a field + (SETF), out of a function (`return`), into a binding (`let s = other`), + and into a loop cursor. The source keeps its own value; a freshly built + Text stays the caller's and is dropped at the site. `WO_B_TEXT_COPY` is + the one new builtin this needed. +- [x] Runtime bug found by the same measurement: `fs.read_all`/`net.read` + allocate their cap and then relabel the buffer with the short length, but + `wo_str_free` sizes a block by its `len` (obj.h keeps no size headers) — + so a 1 MiB buffer wearing a 30-byte length went onto a 32-byte free list + and never came back. They now copy out at the true size and release the + buffer at the size it was taken. +- [x] Two regressions caught by the corpus and fixed in the same pass: a `@gc` + value read out of a container is a plain borrow (not an rc-counted + alias), and `push`'s `@gc` escape is keyed on "`push` is not a + user-declared fn" rather than on "the callee did not resolve" — which + stopped being true the moment builtins resolved. +- [x] Re-measured: **`run` 1 051 040 B → 2 112 B (24 → 19 allocations)**, + **`watch` 128 B → 64 B (2 → 1)**. Everything left is Task 2's projected + temporary and Task 3's argv container, by stack. `just oop-e2e` 71/0, + `just woc-test` 565/0, `wovm` unit gates green, `just log-watcher` 6/0. ### Task 2: A temporary whose field is projected must still be dropped diff --git a/runtime/src/builtin.c b/runtime/src/builtin.c index 44ea978..2e4fa9a 100644 --- a/runtime/src/builtin.c +++ b/runtime/src/builtin.c @@ -249,6 +249,22 @@ int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) { } return 0; } + case WO_B_TEXT_COPY: { /* nil copies to nil: a `?Text` crosses this boundary + * exactly like a Text does */ + if (!R[B]) { + R[A] = 0; + return 0; + } + const wo_str *src = native_check(R[B], WO_CLS_STR, msg); + if (!src) return WO_T_BOUNDS; + wo_str *cp = wo_str_new(rt, src->data, src->len); + if (!cp) { + *msg = "out of memory"; + return WO_T_OOM; + } + R[A] = (uint64_t)(uintptr_t)cp; + return 0; + } case WO_B_MAP_GET_OPT: { /* `m[k]`: a missing key is nil, not a trap */ wo_map *m = native_check(R[B], WO_CLS_MAP, msg); if (!m) return WO_T_BOUNDS; diff --git a/runtime/src/loader.c b/runtime/src/loader.c index 15d46e1..db1dc0c 100644 --- a/runtime/src/loader.c +++ b/runtime/src/loader.c @@ -62,6 +62,7 @@ static const uint8_t b_arity[WO_B_MAX + 1] = { /* json (json.c): encode takes the value's static kind, decode the class id to build */ [WO_B_JSON_ENCODE] = 2, [WO_B_JSON_DECODE] = 2, [WO_B_MAP_GET_OPT] = 2, + [WO_B_TEXT_COPY] = 1, }; static int vtab_cmp(const void *a, const void *b) { diff --git a/runtime/src/sysio.c b/runtime/src/sysio.c index 2ad9e20..745cc0e 100644 --- a/runtime/src/sysio.c +++ b/runtime/src/sysio.c @@ -117,8 +117,20 @@ static wo_str *read_range(wo_rt *rt, int fd, off_t off, size_t want, const char if (n == 0) break; /* EOF */ got += (size_t)n; } - s->len = (uint32_t)got; /* the allocation may be longer; length is truth */ - return s; + if (got == want) return s; + /* A short read means the buffer is bigger than the value. It cannot just + * be relabelled: wo_str_free sizes a block by its `len` (obj.h — no size + * headers anywhere), so a 1 MiB buffer wearing a 30-byte length is freed + * into a 32-byte size class and never returned to the allocator. Copy out + * at the true size and release the buffer at the size it was taken. */ + wo_str *exact = wo_str_new(rt, s->data, (uint32_t)got); + wo_str_free(rt, s); /* still labelled `want`: the size it was allocated at */ + if (!exact) { + *msg = "out of memory"; + *tcode = WO_T_OOM; + return NULL; + } + return exact; } int wo_builtin_sys(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) { @@ -374,8 +386,19 @@ int wo_builtin_sys(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) { *msg = strerror(errno); return WO_T_IO; } - s->len = (uint32_t)n; - R[A] = (uint64_t)(uintptr_t)s; + if ((size_t)n == (size_t)max) { + R[A] = (uint64_t)(uintptr_t)s; + return 0; + } + /* short read: copy out at the true size and free the buffer at the + size it was allocated (see read_range's own note) */ + wo_str *exact = wo_str_new(rt, s->data, (uint32_t)n); + wo_str_free(rt, s); + if (!exact) { + *msg = "out of memory"; + return WO_T_OOM; + } + R[A] = (uint64_t)(uintptr_t)exact; return 0; } case WO_B_NET_WRITE: { diff --git a/runtime/src/vm.c b/runtime/src/vm.c index e81beeb..b168a36 100644 --- a/runtime/src/vm.c +++ b/runtime/src/vm.c @@ -372,11 +372,27 @@ dispatch: CASE(SETF) : { /* overwriting a non-scalar field does NOT auto-drop the old value: - * the compiler emits the drop (format doc) */ + * the compiler emits the drop (format doc). + * + * A TEXT field is COPIED into (2026-08-14), the same rule push/set + * follow: the field's kind makes the object the owner of that string, + * so storing a pointer the caller still owns would give it two owners. + * It is also what lets `self.name = name` — a borrowed Text parameter + * stored in a field, the most ordinary line there is — stay legal + * without demanding `take`. A freshly built Text handed to a field is + * still the caller's, and the compiler drops it at the store site. */ const char *why; wo_hdr *o = recv_check(vm, R[wo_ins_a(ins)], wo_ins_b(ins), &why); if (!o) TRAPF(WO_T_BOUNDS, "%s", why); - wo_fields(o)[wo_ins_b(ins)] = R[wo_ins_c(ins)]; + uint64_t v = R[wo_ins_c(ins)]; + if (v && vm->mod->classes[o->class_id].kinds[wo_ins_b(ins)] == WO_K_TEXT) { + const wo_str *src = (const wo_str *)(uintptr_t)v; + if (src->h.class_id != WO_CLS_STR) TRAPF(WO_T_BOUNDS, "not a text value"); + wo_str *cp = wo_str_new(&vm->rt, src->data, src->len); + if (!cp) TRAPF(WO_T_OOM, "out of memory"); + v = (uint64_t)(uintptr_t)cp; + } + wo_fields(o)[wo_ins_b(ins)] = v; NEXT(); } diff --git a/runtime/src/wob.h b/runtime/src/wob.h index 4d9f351..587f93b 100644 --- a/runtime/src/wob.h +++ b/runtime/src/wob.h @@ -280,8 +280,13 @@ enum { * `get(m, k)` (WO_B_MAP_GET) stays the asserting form. Indexing a `multi` * out of range still traps — a bad index is a fault, not an absence. */ WO_B_MAP_GET_OPT = 59, /* (map, key) -> value or nil */ + /* (text) -> a fresh copy. Every ownership boundary in this language + * copies a Text — into a container (push/set), into a field (SETF), and + * out of a function (`return` of a borrowed place, which is what this id + * exists for: the callee's borrow must not become the caller's owner). */ + WO_B_TEXT_COPY = 60, }; -#define WO_B_MAX 59u +#define WO_B_MAX 60u /* ids at or above this one live in sysio.c, not builtin.c */ #define WO_B_SYS_FIRST WO_B_FS_EXISTS