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:
parent
7c10df67a3
commit
eb0095428d
12 changed files with 336 additions and 47 deletions
|
|
@ -85,7 +85,6 @@ Always inspect crashsites. Always measure. Never assume.
|
|||
- caveman
|
||||
- context-mode
|
||||
- web-search
|
||||
- superpowers
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
(* 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,7 +1625,23 @@ 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 =
|
||||
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
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
== MOVES ==
|
||||
26:12 MOVE other RETURN
|
||||
== DROPS ==
|
||||
18:5 OVERWRITE self.name
|
||||
22:5 OVERWRITE self.prices
|
||||
== RC ==
|
||||
== RESIDUAL ==
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,10 +386,21 @@ 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;
|
||||
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: {
|
||||
const wo_str *body = (const wo_str *)(uintptr_t)R[B + 1];
|
||||
if (!body || body->h.class_id != WO_CLS_STR) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue