fix: Text is an owned value copied at every boundary (executable plan, Task 1)

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) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-14 22:45:03 +02:00
parent 7c10df67a3
commit eb0095428d
12 changed files with 336 additions and 47 deletions

View file

@ -85,7 +85,6 @@ Always inspect crashsites. Always measure. Never assume.
- caveman - caveman
- context-mode - context-mode
- web-search - web-search
- superpowers
--- ---

View file

@ -256,6 +256,7 @@ let b_map_key_at = 37
let b_map_val_at = 38 let b_map_val_at = 38
let b_multi_set = 39 let b_multi_set = 39
let b_map_get_opt = 59 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 (* 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. *) 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 | None -> None
| Some sg -> Some (slot, sg)))) | 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 = let builtin_ret (name : string) (argty : Ast.field_ty option) : Ast.field_ty option =
match name with match name with
| "int_to_text" -> Some (Scalar "Text") | "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 *) stdlib_members) — the source of `st.size` resolving at all *)
| Some u when u.Types.ue_is_stdlib -> ( | Some u when u.Types.ue_is_stdlib -> (
match Types.stdlib_member alias mname with 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) | None -> None)
| Some u -> ( | Some u -> (
let target_mid = Types.path_str u.Types.ue_segments in 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 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 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. *) 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 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_place = match e.Ast.kind with Ast.Ident _ | Ast.Field _ | Ast.Index _ -> true | _ -> false in
let is_text = 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 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 base;
emit_expr p f v ~dst:(w + 1) idx; 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) -> | Unary (Neg, o) ->
let b = emit_operand p f v o in let b = emit_operand p f v o in
put f (ins_abc op_neg dst b 0) 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; emit_expr p f v ~dst:t ~expected:fty fe;
f.f_cur_line <- fe.pos.line; f.f_cur_line <- fe.pos.line;
put f (ins_abc op_setf dst (check_field_idx p f e.pos idx) t); 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) f.f_temp <- save)
fields; fields;
(* haxe-parity Task 4: fields the literal omitted. A declared (* 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 match args with
| a :: _ -> ( | a :: _ -> (
match container_id a b_multi_get b_map_get with 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") | None -> bad "builtin `get` needs a `multi` or a `map` as its first argument")
| [] -> bad "builtin `get` takes 2 arguments, given 0") | [] -> bad "builtin `get` takes 2 arguments, given 0")
| "set" -> ( | "set" -> (
@ -2880,6 +2889,8 @@ and emit_stmt (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) : unit =
(match declared with (match declared with
| Some t -> emit_expr p f v ~dst:r ~expected:t value | Some t -> emit_expr p f v ~dst:r ~expected:t value
| None -> emit_expr p f v ~dst:r 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; f.f_env <- (name, (r, vty)) :: f.f_env;
Hashtbl.replace f.f_decl s.s_id r; Hashtbl.replace f.f_decl s.s_id r;
f.f_declared <- s.s_id :: f.f_declared; 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; acquire_guards f guards;
put f (ins_abc op_setf b idx t); put f (ins_abc op_setf b idx t);
release_guards f guards; 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 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 -> ()) | Some place -> ( match lookup_local f place with Some (sr, _) -> mask_clear f sr | None -> ())
| 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 f.f_div <- true
| Some e -> | Some e ->
let t = emit_tail p f v e in 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 (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 -> ()) | Some place -> ( match lookup_local f place with Some (sr, _) -> mask_clear f sr | None -> ())
| 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_move (w + 1) ri 0);
put f (ins_abc op_builtin rk w b_map_key_at); put f (ins_abc op_builtin rk w b_map_key_at);
put f (ins_abc op_builtin rv w b_map_val_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 let lf = { lf_node = s.s_id; lf_breaks = []; lf_continues = [] } in
f.f_loops <- lf :: f.f_loops; f.f_loops <- lf :: f.f_loops;
List.iter (emit_stmt p f v) body; 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 let jz = here f in
put f (ins_asbx op_jz tc 0); put f (ins_asbx op_jz tc 0);
put f (ins_abc op_builtin rv rc b_multi_get); 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 let lf = { lf_node = s.s_id; lf_breaks = []; lf_continues = [] } in
f.f_loops <- lf :: f.f_loops; f.f_loops <- lf :: f.f_loops;
List.iter (emit_stmt p f v) body; List.iter (emit_stmt p f v) body;

View file

@ -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 = let oclass_of (ctx : ctx) (ft : Ast.field_ty) : oclass =
match unwrap_nullable ft with 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 -> | Scalar n ->
if Types.is_builtin_scalar n then Copy if Types.is_builtin_scalar n then Copy
else if Types.is_gc_class ctx.syms n then Gc 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 | Ident n -> variant_union_ty ctx n
| _ -> None)) | _ -> None))
| Unary (_, o) -> expr_ty ctx o | 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 *) | Binary _ -> None (* arithmetic/comparison: Copy either way *)
| Ctor (cn, _) -> Some (Scalar cn) | Ctor (cn, _) -> Some (Scalar cn)
| Interp _ -> Some (Scalar "Text") (* an interpolation always produces Text *) | 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 (f : Types.free_fn_info) ->
Some Some
{ ce_params = params_of f.Types.params; ce_ret = f.Types.ret; ce_recv_excl = false } { 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) -> ( | Field (base, mname) -> (
match expr_ty ctx base with match expr_ty ctx base with
| Some bt -> ( | 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_params = params_of m.Types.params; ce_ret = m.Types.ret;
ce_recv_excl = body_writes_self m.Types.body })) ce_recv_excl = body_writes_self m.Types.body }))
| _ -> None) | _ -> 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 | _ -> None
(* ============================================================ (* ============================================================
@ -655,9 +720,24 @@ let rec idx_text (e : Ast.expr) : string =
let idx_proj (e : Ast.expr) : proj = let idx_proj (e : Ast.expr) : proj =
match e.kind with IntLit n -> PConst n | _ -> PDyn (idx_text e) 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 = let rec place_of (e : Ast.expr) : place option =
match e.kind with match e.kind with
| Ident n -> Some { root = n; projs = []; ppos = e.pos; pnode = e.id } | 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) -> ( | Field (base, f) -> (
match place_of base with match place_of base with
| Some p -> Some { p with projs = p.projs @ [ PField f ]; pnode = e.id } | 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 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 pairwise check reports a bogus move conflict on top of the escape error
`transfer` is about to give. *) `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 = let is_real_transfer (ctx : ctx) (p : place) : bool =
p.projs = [] p.projs = []
&& match root_local ctx p with Some l -> l.l_holds && l.l_state = Live | None -> false && 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 match place_of fe with
| None -> () | None -> ()
| Some p -> | 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)) record_move ctx p (MvCtorField fname))
fields 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 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 — an Owned element's move-on-push is a separate, pre-existing gap
this task does not touch. *) 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 = 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 in
List.iteri List.iteri
(fun i a -> (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)) | Some t, _ -> (None, ( match elem_ty t with Some e -> e | None -> t))
| None, _ -> (None, Scalar "Int") | None, _ -> (None, Scalar "Int")
in 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 = 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; let copied =
l_holds = false; l_src = src; l_bkind = AShared; l_state = Borrowed s.s_pos } 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 in
ctx.loop_stack <- s.s_id :: ctx.loop_stack; ctx.loop_stack <- s.s_id :: ctx.loop_stack;
fixpoint ctx 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") | None -> ( match expr_ty ctx value with Some t -> t | None -> Scalar "Int")
in in
let cls = oclass_of ctx vty 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 = let holds, src, state =
match (cls, vplace) with 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) | Copy, _ -> (false, None, Live)
| Owned, None -> (true, None, Live) (* fresh value: constructor or call result *) | Owned, None -> (true, None, Live) (* fresh value: constructor or call result *)
| Owned, Some p -> ( | 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") (match tplace with Some tp -> place_text tp | None -> "a field")
else "cannot be moved out" else "cannot be moved out"
in 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 (* Whatever the value was — a fresh constructor, a call result, another
local — assigning to a whole local re-initializes it: a local that had local — assigning to a whole local re-initializes it: a local that had
been moved out of is live again afterwards. *) 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 match place_of e with
| None -> () | None -> ()
| Some p -> | 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)); record_move ctx p MvReturn));
let live = live_holders ctx in let live = live_holders ctx in
record_drop ctx ~node:s.s_id ~pos:s.s_pos ~kind:DReturn ~items:(owned_items live); record_drop ctx ~node:s.s_id ~pos:s.s_pos ~kind:DReturn ~items:(owned_items live);

View file

@ -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 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; 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 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 = let rec typ_of_field_ty (ft : field_ty) : typ =
match ft with match ft with
| Scalar name -> TScalar name | Scalar name -> TScalar name

View file

@ -1,6 +1,7 @@
== MOVES == == MOVES ==
26:12 MOVE other RETURN 26:12 MOVE other RETURN
== DROPS == == DROPS ==
18:5 OVERWRITE self.name
22:5 OVERWRITE self.prices 22:5 OVERWRITE self.prices
== RC == == RC ==
== RESIDUAL == == RESIDUAL ==

View file

@ -29,10 +29,14 @@ gates it: 6 checks, 0 failures.
What is left is the difference between "it runs" and "you can leave it 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: 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 1. ~~The ownership pass does not know what the stdlib returns~~ — **done
holding a fresh `fs.read_all`/`fs.list`/`net.read`/`json.encode` result is 2026-08-14**. The root cause was deeper than the table: `Text` was
classified Copy and never dropped. Measured: >1 MB leaked in eight seconds classified Copy, so no Text local was ever dropped. `Text` is now an owned
of `run` mode, the largest single allocation being one `fs.read_all` result. 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` 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. 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 3. **The runtime leaks its own argv container** — 128 bytes in 2 allocations on

View file

@ -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) 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 **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 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 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. read, including through a `try`'s arms, so the classification matches reality.
- [ ] Failing measurement first: record the current ASan totals for `watch` and - [x] Failing measurement first: `run` 1 051 040 B in 24 allocations, `watch`
`run` (eight seconds each, clean exit via SIGTERM) so the drop is proven, 128 B in 2, both over eight seconds with a clean SIGTERM exit.
not assumed. - [x] Teach the ownership pass what a callee returns — three tables it never
- [ ] Teach the ownership pass the stdlib return shapes; every stdlib member read: the stdlib members, the builtins, and a class's `static` members.
that yields a fresh Text, `multi` or record is Owned at its binding. - [x] **`Text` is an owned heap value, not a scalar.** `oclass_of` grouped it
- [ ] Re-measure: the `fs.read_all` and `fs.list` allocations disappear from with Int/Bool, so no Text local was ever dropped; that, not the stdlib
both modes' reports; `just oop-e2e` and `just woc-test` stay green. 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 ### Task 2: A temporary whose field is projected must still be dropped

View file

@ -249,6 +249,22 @@ int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
} }
return 0; 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 */ 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); wo_map *m = native_check(R[B], WO_CLS_MAP, msg);
if (!m) return WO_T_BOUNDS; if (!m) return WO_T_BOUNDS;

View file

@ -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 /* json (json.c): encode takes the value's static kind, decode the class
id to build */ id to build */
[WO_B_JSON_ENCODE] = 2, [WO_B_JSON_DECODE] = 2, [WO_B_MAP_GET_OPT] = 2, [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) { static int vtab_cmp(const void *a, const void *b) {

View file

@ -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 */ if (n == 0) break; /* EOF */
got += (size_t)n; got += (size_t)n;
} }
s->len = (uint32_t)got; /* the allocation may be longer; length is truth */ if (got == want) return s;
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) { 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); *msg = strerror(errno);
return WO_T_IO; return WO_T_IO;
} }
s->len = (uint32_t)n; if ((size_t)n == (size_t)max) {
R[A] = (uint64_t)(uintptr_t)s; 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; return 0;
} }
case WO_B_NET_WRITE: { case WO_B_NET_WRITE: {

View file

@ -372,11 +372,27 @@ dispatch:
CASE(SETF) : { CASE(SETF) : {
/* overwriting a non-scalar field does NOT auto-drop the old value: /* 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; const char *why;
wo_hdr *o = recv_check(vm, R[wo_ins_a(ins)], wo_ins_b(ins), &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); 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(); NEXT();
} }

View file

@ -280,8 +280,13 @@ enum {
* `get(m, k)` (WO_B_MAP_GET) stays the asserting form. Indexing a `multi` * `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. */ * 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 */ 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 */ /* ids at or above this one live in sysio.c, not builtin.c */
#define WO_B_SYS_FIRST WO_B_FS_EXISTS #define WO_B_SYS_FIRST WO_B_FS_EXISTS