fix: drop the values nobody names (executable plan, Task 2)
- the drop tables track bindings only, so six shapes had no owner: a comparison operand (`if parse_expr(s) == nil` abandoned a schedule record and its five containers per cron line), a borrowed call argument (a 1 KB string per MCP request), a container read's copy, a loop's iterable, a projected record, and any of those escaped by a `return` from inside the statement that built them - `c[i]` is the one place expression whose register holds a COPY: no second copy at a boundary (`let u = tokens[0]` copied twice and abandoned the first), and a drop where every other place is left be - never drop an argument register after a CALL — the callee's frame overlaps it; the reap moved into call_window's pre-call stash - a statement-owned temporary is parked in a LOCAL slot: a loop reclaims every temp for its body, and the end-of-statement DROP was releasing the loop counter instead of the record - reader builtins (get/latest/key_at/val_at) keep arg0 alive — their result points into it — but their key argument is ordinary - measured: run 2 112 B -> 64 B, flat 8 s to 20 s; MCP mix 21 312 B / 63 -> 64 B / 1; every handler flat from 2 to 6 requests; the 64 B left is Task 3's argv container - gates: oop-e2e 71/0, woc-test 565/0, wovm-test green, log-watcher 6/0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c4c0880880
commit
662f541c88
4 changed files with 258 additions and 23 deletions
|
|
@ -109,6 +109,29 @@ reserve `dst` before allocating more temps, or an arm-local `let` can be handed
|
|||
the same register and clobber a live value before its drop runs. `emit_switch`
|
||||
carries the comment explaining the ASan-confirmed leak that taught this.
|
||||
|
||||
## Who owns a value nobody named
|
||||
|
||||
The drop tables (`owner.ml`) track **bindings**. Everything a statement builds
|
||||
and never binds is the emitter's problem, and the workload found six of them:
|
||||
an operand of a comparison (`if parse_expr(s) == nil`), an argument a callee
|
||||
only borrows, a container read's copy (`c[i]` is the one place expression whose
|
||||
register holds a **copy**, so it needs no second copy at a boundary and does
|
||||
need a drop), a loop's iterable, the record a projection reads a field of, and
|
||||
any of those escaped by a `return` from inside the statement that built them.
|
||||
|
||||
Two rules the measurements imposed, both easy to get backwards:
|
||||
|
||||
- **Never drop an argument register after a `CALL`.** The callee's frame
|
||||
overlaps those registers (vm.c's window overlap), so after it returns they
|
||||
hold the callee's leftovers. Copy the value into a stash slot allocated
|
||||
*below* the call window before the call — `call_window`'s `temp_idx` — and
|
||||
drop the stash.
|
||||
- **A statement-owned temporary must live in a local slot, not a temp.** A
|
||||
statement that opens a scope resets `f_temp` to `f_nlocals` for its body, so
|
||||
a loop reuses the register; the end-of-statement `DROP` then releases a loop
|
||||
counter and the value leaks. `f_stmt_drops` holds locals; `f_esc_drops` is
|
||||
the same registers seen from a `return`.
|
||||
|
||||
## Verifying a change
|
||||
|
||||
- `just woc-test` — unit assertions plus the golden suite (token/AST/owner/bc
|
||||
|
|
|
|||
|
|
@ -474,6 +474,20 @@ type fstate = {
|
|||
value in tail position its type (`return []` / `return nil` /
|
||||
`return {}`), the same role a `let`'s annotation plays *)
|
||||
f_ret : Ast.field_ty option;
|
||||
(* Registers holding a value this STATEMENT created and nobody took
|
||||
ownership of — today only the base of a projection (`parse_dir(d).entries`
|
||||
evaluates a whole record to read one field of it). They are dropped at the
|
||||
end of the statement, not at the projection: `for e in f().entries`
|
||||
borrows that field for the whole loop, so the record has to outlive it. *)
|
||||
mutable f_stmt_drops : int list;
|
||||
(* The same registers, seen from a `return`: a statement-end drop is skipped
|
||||
by a return taken from INSIDE the statement, and a loop's iterable drop by
|
||||
a return taken from inside the loop (`check_path` returns out of `for p in
|
||||
self.allowed_paths()` — one container leaked per MCP request). This list
|
||||
is what a return has to release on its way out; emit_stmt restores it to
|
||||
the enclosing statement's, so a register is only ever in it while it is
|
||||
live. *)
|
||||
mutable f_esc_drops : int list;
|
||||
f_code : code;
|
||||
mutable f_cur_line : int; (* line of the construct being lowered *)
|
||||
mutable f_line : int; (* last line written to the table *)
|
||||
|
|
@ -1352,23 +1366,77 @@ 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. *)
|
||||
(* Is this expression a value someone ELSE owns? Places are, and so is an
|
||||
interpolation segment wrapping one: `"${path}"`'s Text-typed inner value is
|
||||
passed through untouched (emit's Interp case only converts an Int), so the
|
||||
register holds the place's own string — dropping it frees a live local, an
|
||||
ASan-confirmed use-after-free in the MCP mode. *)
|
||||
let rec is_borrowed_value (e : Ast.expr) : bool =
|
||||
match e.Ast.kind with
|
||||
| Ast.Ident _ | Ast.Field _ | Ast.Index _ -> true
|
||||
| Ast.Interp inner -> is_borrowed_value inner
|
||||
| _ -> false
|
||||
|
||||
(* A value the emitter materialised into a temporary and nobody took ownership
|
||||
of: a fresh container or record used as an expression rather than bound to a
|
||||
name — `for raw in split(content, "\n")`, `join(slice(tokens, 0, 5), " ")`,
|
||||
`parse_dir(dir).entries`. The ownership tables only track BINDINGS, so these
|
||||
had no owner and no drop at all (measured: the workload's supervisor mode
|
||||
leaked every split, slice and projected record it evaluated). A value read
|
||||
out of a place is never dropped here — its owner still holds it — and
|
||||
neither is one the callee takes ownership of; both are the caller's to keep
|
||||
straight, which is why this is applied at the specific sites that borrow.
|
||||
Kinds 1/4/5 are OWNED/MULTI/MAP; Text has its own copy rule above. *)
|
||||
let is_fresh_owned_temp (p : pctx) (f : fstate) (e : Ast.expr) : bool =
|
||||
(not (is_borrowed_value e))
|
||||
&& (match ty_of_expr p f e with
|
||||
| Some t -> ( match field_kind p t with 1 | 4 | 5 -> true | _ -> false)
|
||||
| None -> false)
|
||||
|
||||
(* ~keep names the register the RESULT lives in: an operand or argument temp can
|
||||
be that same register (emit_operand allocates from f_temp, which in tail
|
||||
position is exactly where dst sits), and dropping it would free the value
|
||||
just produced — an ASan-confirmed use-after-free in the MCP mode's
|
||||
interpolation chains. *)
|
||||
let drop_fresh_owned ?keep (p : pctx) (f : fstate) (reg : int) (e : Ast.expr) : unit =
|
||||
if (match keep with Some k -> k <> reg | None -> true) && is_fresh_owned_temp p f e then
|
||||
put f (ins_abc op_drop reg 0 0)
|
||||
|
||||
(* `c[i]` is the one PLACE whose register does NOT hold the place's own value:
|
||||
emit_expr's Index case appends a text_copy, so a container read already
|
||||
hands back a copy the reader owns. Two consequences, both measured in the
|
||||
workload: it needs no SECOND copy at an ownership boundary (`let u =
|
||||
tokens[0]` was copying twice and abandoning the first), and it must be
|
||||
DROPPED at a boundary that takes its own copy — a ctor field, a push, a
|
||||
builtin argument — where every other place is left alone. Seen through an
|
||||
interpolation for the same reason is_borrowed_value is. *)
|
||||
let rec is_container_read (e : Ast.expr) : bool =
|
||||
match e.Ast.kind with
|
||||
| Ast.Index _ -> true
|
||||
| Ast.Interp inner -> is_container_read inner
|
||||
| _ -> false
|
||||
|
||||
(* 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_place =
|
||||
(match e.Ast.kind with Ast.Ident _ | Ast.Field _ | Ast.Index _ -> true | _ -> false)
|
||||
&& not (is_container_read e)
|
||||
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 drop_fresh_text ?keep (p : pctx) (f : fstate) (reg : int) (e : Ast.expr) : unit =
|
||||
let is_place = is_borrowed_value e && not (is_container_read e) 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 (not is_place) && is_text then put f (ins_abc op_drop reg 0 0)
|
||||
if (match keep with Some k -> k <> reg | None -> true) && (not is_place) && is_text then
|
||||
put f (ins_abc op_drop reg 0 0)
|
||||
|
||||
(* `nil` written literally on either side of a comparison — see emit_binary's
|
||||
Eq/Ne cases for why the distinction matters. *)
|
||||
|
|
@ -1507,6 +1575,19 @@ let rec emit_expr (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e
|
|||
match field_of p cid fname with
|
||||
| Some (idx, _) ->
|
||||
let b = emit_operand p f v base in
|
||||
(* reading a field of a value this expression just built — the
|
||||
record is nobody else's, so the statement owns it (f_stmt_drops).
|
||||
It is parked in a LOCAL slot, not left in the operand temp: a
|
||||
statement that opens a scope reclaims every temp for its body
|
||||
(`for e in parse_dir(d).entries` reused the register as its loop
|
||||
condition), and the statement-end DROP would then release a
|
||||
scalar and leak the record — measured, once per rescan. *)
|
||||
if is_fresh_owned_temp p f base then begin
|
||||
let g = alloc_local p f e.pos in
|
||||
put f (ins_abc op_move g b 0);
|
||||
f.f_stmt_drops <- g :: f.f_stmt_drops;
|
||||
f.f_esc_drops <- g :: f.f_esc_drops
|
||||
end;
|
||||
put f (ins_abc op_getf dst b (check_field_idx p f e.pos idx))
|
||||
| None ->
|
||||
err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos
|
||||
|
|
@ -1546,6 +1627,9 @@ let rec emit_expr (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e
|
|||
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);
|
||||
(* a freshly built KEY (`m["${a}/${b}"]`) is this read's to release; the
|
||||
container in `w` is not, and the result may point into it *)
|
||||
drop_fresh_text ~keep:dst p f (w + 1) idx;
|
||||
(* 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
|
||||
|
|
@ -1632,15 +1716,28 @@ and emit_tail (p : pctx) (f : fstate) (v : views) (e : Ast.expr) : int =
|
|||
and emit_binary (p : pctx) (f : fstate) (v : views) ~(dst : int) (op : Ast.binop) (l : Ast.expr)
|
||||
(r : Ast.expr) : unit =
|
||||
let pos = l.pos in
|
||||
(* An operand nobody owns dies with the instruction that read it — the same
|
||||
rule CONCAT states below, applied to the comparisons: `if parse_expr(s)
|
||||
== nil` abandoned a whole CronFields record (five containers) per cron
|
||||
line, and `tokens[0] == "@reboot"` abandoned the container read's copy.
|
||||
Scalar operands make both helpers no-ops (they are typed, not owned). *)
|
||||
let reap a b =
|
||||
drop_fresh_owned ~keep:dst p f a l;
|
||||
drop_fresh_text ~keep:dst p f a l;
|
||||
drop_fresh_owned ~keep:dst p f b r;
|
||||
drop_fresh_text ~keep:dst p f b r
|
||||
in
|
||||
let simple o =
|
||||
let a = emit_operand p f v l in
|
||||
let b = emit_operand p f v r in
|
||||
put f (ins_abc o dst a b)
|
||||
put f (ins_abc o dst a b);
|
||||
reap a b
|
||||
in
|
||||
let swapped o =
|
||||
let a = emit_operand p f v l in
|
||||
let b = emit_operand p f v r in
|
||||
put f (ins_abc o dst b a)
|
||||
put f (ins_abc o dst b a);
|
||||
reap a b
|
||||
in
|
||||
(* `x == nil` / `nil == x`: the literal takes ITS destination type from the
|
||||
other operand, so the sentinel-vs-zero choice matches what x actually
|
||||
|
|
@ -1656,6 +1753,8 @@ and emit_binary (p : pctx) (f : fstate) (v : views) ~(dst : int) (op : Ast.binop
|
|||
| Some t -> emit_expr p f v ~dst:b ~expected:t nil_e
|
||||
| None -> emit_expr p f v ~dst:b nil_e);
|
||||
put f (ins_abc o dst a b);
|
||||
drop_fresh_owned ~keep:dst p f a other;
|
||||
drop_fresh_text ~keep:dst p f a other;
|
||||
f.f_temp <- save
|
||||
in
|
||||
let nil_compare o = nil_compare_into p f v ~dst o l r in
|
||||
|
|
@ -1664,7 +1763,19 @@ and emit_binary (p : pctx) (f : fstate) (v : views) ~(dst : int) (op : Ast.binop
|
|||
| Sub -> simple op_sub
|
||||
| Mul -> simple op_mul
|
||||
| Div -> simple op_div
|
||||
| Concat -> simple op_concat
|
||||
| Concat ->
|
||||
(* CONCAT allocates a new Text and leaves its operands untouched, so an
|
||||
operand that was itself freshly built — the partial result of a longer
|
||||
chain, an interpolation segment's `int_to_text`, a nested call — has no
|
||||
owner once this instruction has read it. A three-segment interpolation
|
||||
allocates three strings and abandons two; that was the largest single
|
||||
leak class in the workload's MCP mode. An operand read out of a place
|
||||
keeps its owner, and a constant's drop is a no-op (WO_F_CONST). *)
|
||||
let a = emit_operand p f v l in
|
||||
let b = emit_operand p f v r in
|
||||
put f (ins_abc op_concat dst a b);
|
||||
drop_fresh_text ~keep:dst p f a l;
|
||||
drop_fresh_text ~keep:dst p f b r
|
||||
| Lt -> simple op_lt
|
||||
| Le -> simple op_le
|
||||
| Gt -> swapped op_lt
|
||||
|
|
@ -2528,7 +2639,15 @@ and emit_call (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : As
|
|||
| None -> ());
|
||||
sync_mask p f v e.id;
|
||||
f.f_cur_line <- e.pos.line;
|
||||
put f (ins_abc op_builtin dst base sm.Types.sm_builtin)
|
||||
put f (ins_abc op_builtin dst base sm.Types.sm_builtin);
|
||||
(* every stdlib member only READS its arguments, so one that was
|
||||
freshly built here (`net.write(c, head .. resp.body)`) has no
|
||||
other owner and dies with the call *)
|
||||
List.iteri
|
||||
(fun i (a : Ast.expr) ->
|
||||
drop_fresh_owned ~keep:dst p f (base + i) a;
|
||||
drop_fresh_text ~keep:dst p f (base + i) a)
|
||||
args
|
||||
end)
|
||||
| Some u -> (
|
||||
let target_mid = Types.path_str u.Types.ue_segments in
|
||||
|
|
@ -2617,6 +2736,20 @@ and call_window (p : pctx) (f : fstate) (v : views) (e : Ast.expr) ~(recv : Ast.
|
|||
and unions can't, WO-E304 polices those borrows); interface-typed
|
||||
call results (ty_of_expr names the interface, not the concrete
|
||||
class — still leak, disclosed). *)
|
||||
(* Task 2 widened the same rule to Text and to containers. Text qualifies now
|
||||
that every store COPIES it (Task 1), so a callee cannot retain the
|
||||
caller's: `rpc_result(id, "…${TOOL_SCHEMAS}…")` built a 1 KB string per
|
||||
MCP request and `parse_file("${dir}/${name}", res)` one per cron file,
|
||||
neither with an owner. Containers qualify for the same reason a projected
|
||||
record does — nothing else holds them. Both are reaped through the stash
|
||||
below, never by reading the argument register back after the CALL: the
|
||||
callee's frame OVERLAPS those registers (vm.c's window overlap), so after
|
||||
it returns they hold the callee's leftovers, not the arguments. *)
|
||||
let fresh_borrowed_value (a : Ast.expr) : bool =
|
||||
is_fresh_owned_temp p f a
|
||||
|| ((not (is_borrowed_value a))
|
||||
&& match ty_of_expr p f a with Some t -> field_kind p t = 3 | None -> false)
|
||||
in
|
||||
let owned_heap_temp (a : Ast.expr) : bool =
|
||||
(match a.kind with
|
||||
| Ident n -> lookup_local f n = None (* a local is a place, never a temp *)
|
||||
|
|
@ -2639,7 +2772,8 @@ and call_window (p : pctx) (f : fstate) (v : views) (e : Ast.expr) ~(recv : Ast.
|
|||
List.mapi
|
||||
(fun i a ->
|
||||
let conv = match List.nth_opt params i with Some (_, _, c) -> c | None -> Ast.Borrow in
|
||||
if conv = Ast.Borrow && owned_heap_temp a then Some i else None)
|
||||
if conv = Ast.Borrow && (owned_heap_temp a || fresh_borrowed_value a) then Some i
|
||||
else None)
|
||||
args
|
||||
|> List.filter_map Fun.id
|
||||
in
|
||||
|
|
@ -2769,6 +2903,24 @@ and emit_builtin (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e :
|
|||
sync_mask p f v e.id;
|
||||
f.f_cur_line <- e.pos.line;
|
||||
put f (ins_abc op_builtin dst base id);
|
||||
(* Arguments the builtin only READ: a freshly built one (`join(slice(t, 0,
|
||||
5), " ")`, `len(split(s, ","))`) has no owner but this expression, so
|
||||
it dies here. Excluded: the readers whose RESULT points into the
|
||||
argument (get/latest/key_at/val_at — dropping the container would
|
||||
dangle the value just read) and the stores, which either copy (Text,
|
||||
handled by copied_container_call) or take ownership (OWNED/GCREF). *)
|
||||
let reader = List.mem name [ "get"; "latest"; "key_at"; "val_at" ] in
|
||||
(if not (List.mem name [ "push"; "set" ]) then
|
||||
List.iteri
|
||||
(fun i (a : Ast.expr) ->
|
||||
(* a reader's result points into arg0 (the container) — dropping
|
||||
that would dangle the value just read. Its KEY argument is an
|
||||
ordinary borrowed argument. *)
|
||||
if not (reader && i = 0) then begin
|
||||
drop_fresh_owned ~keep:dst p f (base + i) a;
|
||||
drop_fresh_text ~keep:dst p f (base + i) a
|
||||
end)
|
||||
args);
|
||||
Some base
|
||||
end
|
||||
in
|
||||
|
|
@ -2876,7 +3028,21 @@ and emit_builtin (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e :
|
|||
|
||||
and emit_stmt (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) : unit =
|
||||
stmt_reset f;
|
||||
(* Statements NEST — a `for`'s own registration is made while its iterable is
|
||||
emitted, and every statement in its body runs this same function. Saving
|
||||
and restoring is what keeps the inner ones from wiping the outer one's
|
||||
list (the first version did, so the projected record never got its drop). *)
|
||||
let outer = f.f_stmt_drops in
|
||||
let outer_esc = f.f_esc_drops in
|
||||
f.f_stmt_drops <- [];
|
||||
f.f_cur_line <- s.s_pos.line;
|
||||
emit_stmt_body p f v s;
|
||||
(* whatever this statement built and nobody took: see fstate.f_stmt_drops *)
|
||||
List.iter (fun r -> put f (ins_abc op_drop r 0 0)) f.f_stmt_drops;
|
||||
f.f_stmt_drops <- outer;
|
||||
f.f_esc_drops <- outer_esc
|
||||
|
||||
and emit_stmt_body (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) : unit =
|
||||
match s.s_kind with
|
||||
| Let { name; ty; value } ->
|
||||
let declared = ty in
|
||||
|
|
@ -3102,6 +3268,7 @@ and emit_return (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (opt : Ast.ex
|
|||
| None ->
|
||||
emit_rc p f v ~node:s.s_id ~acquire:true ();
|
||||
(match Hashtbl.find_opt v.v_return s.s_id with Some items -> emit_drops p f items | None -> ());
|
||||
List.iter (fun r -> put f (ins_abc op_drop r 0 0)) f.f_esc_drops;
|
||||
emit_rc p f v ~node:s.s_id ~acquire:false ();
|
||||
f.f_cur_line <- s.s_pos.line;
|
||||
put f (ins_abc op_ret0 0 0 0);
|
||||
|
|
@ -3134,6 +3301,7 @@ and emit_return (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (opt : Ast.ex
|
|||
releases below — a balanced pair must never reach rc 0 in between *)
|
||||
emit_rc p f v ~node:s.s_id ~acquire:true ();
|
||||
(match Hashtbl.find_opt v.v_return s.s_id with Some items -> emit_drops p f items | None -> ());
|
||||
List.iter (fun r -> if r <> t then put f (ins_abc op_drop r 0 0)) f.f_esc_drops;
|
||||
emit_rc p f v ~node:s.s_id ~acquire:false ();
|
||||
f.f_cur_line <- s.s_pos.line;
|
||||
put f (ins_abc op_ret t 0 0);
|
||||
|
|
@ -3316,6 +3484,8 @@ and emit_for (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (var : string)
|
|||
let rv = alloc_local p f s.s_pos in
|
||||
f.f_temp <- f.f_nlocals;
|
||||
emit_expr p f v ~dst:rc iter;
|
||||
(* live until the loop ends — or until a `return` leaves from inside it *)
|
||||
if is_fresh_owned_temp p f iter then f.f_esc_drops <- rc :: f.f_esc_drops;
|
||||
f.f_cur_line <- s.s_pos.line;
|
||||
put f (ins_abc op_builtin rn rc b_len);
|
||||
put f (ins_abx op_loadk ri (check_bx p f s.s_pos "constant" (const_int p 0)));
|
||||
|
|
@ -3362,6 +3532,9 @@ and emit_for (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (var : string)
|
|||
end
|
||||
else mask_meet f entry_owned entry_gc;
|
||||
f.f_div <- div0;
|
||||
(* a freshly built map iterated in place belongs to this loop: drop it
|
||||
once the loop is done with it (see drop_fresh_owned) *)
|
||||
drop_fresh_owned p f rc iter;
|
||||
f.f_nlocals <- saved_locals;
|
||||
f.f_env <- saved_env;
|
||||
f.f_declared <- saved_decls;
|
||||
|
|
@ -3378,6 +3551,8 @@ and emit_for (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (var : string)
|
|||
let rv = alloc_local p f s.s_pos in
|
||||
f.f_temp <- f.f_nlocals;
|
||||
emit_expr p f v ~dst:rc iter;
|
||||
(* live until the loop ends — or until a `return` leaves from inside it *)
|
||||
if is_fresh_owned_temp p f iter then f.f_esc_drops <- rc :: f.f_esc_drops;
|
||||
f.f_cur_line <- s.s_pos.line;
|
||||
put f (ins_abc op_builtin rn rc b_count);
|
||||
put f (ins_abx op_loadk ri (check_bx p f s.s_pos "constant" (const_int p 0)));
|
||||
|
|
@ -3428,6 +3603,9 @@ and emit_for (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (var : string)
|
|||
end
|
||||
else mask_meet f entry_owned entry_gc;
|
||||
f.f_div <- div0;
|
||||
(* `for raw in split(content, "\n")` — the list exists only for this loop,
|
||||
so this is where it dies *)
|
||||
drop_fresh_owned p f rc iter;
|
||||
f.f_nlocals <- saved_locals;
|
||||
f.f_env <- saved_env;
|
||||
f.f_declared <- saved_decls;
|
||||
|
|
@ -3489,7 +3667,9 @@ and emit_do_while (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (body : Ast
|
|||
let emit_method (p : pctx) (v : views) ~(file : string) ~(self_class : (int * string) option)
|
||||
(m : Ast.method_decl) (rec_ : methrec) : unit =
|
||||
let f =
|
||||
{ f_file = file; f_fn = m.name; f_ret = m.ret; f_code = code_create (); f_cur_line = m.pos.line;
|
||||
{ f_file = file; f_fn = m.name; f_ret = m.ret; f_stmt_drops = []; f_esc_drops = [];
|
||||
f_code = code_create ();
|
||||
f_cur_line = m.pos.line;
|
||||
f_line = -1; f_lines = []; f_owned = 0L; f_gc = 0L; f_last_owned = 0L; f_last_gc = 0L;
|
||||
f_drops = []; f_nlocals = 0; f_temp = 0; f_max = 0; f_env = []; f_decl = Hashtbl.create 16;
|
||||
f_node = Hashtbl.create 64; f_kind = Hashtbl.create 16; f_declared = []; f_div = false; f_maxjmp = 0;
|
||||
|
|
|
|||
|
|
@ -37,10 +37,17 @@ running", and every item below came from a measurement on the sample itself:
|
|||
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
|
||||
every run, `main.c`'s `multi Text` of arguments.
|
||||
2. ~~A projected temporary is never dropped~~ — **done 2026-08-14**. The
|
||||
projection was one of six shapes with no owner: a call result compared
|
||||
against `nil`, an argument the callee only borrows, a container read's
|
||||
copy, a loop's iterable, a projected record, and any of those escaped by a
|
||||
`return` from inside the statement that built them. Measured: `run`
|
||||
**2 112 B → 64 B** and flat from 8 s to 20 s, the full MCP mix
|
||||
**21 312 B / 63 → 64 B / 1**, every handler flat from 2 to 6 requests. The
|
||||
64 bytes left are item 3, on every path.
|
||||
3. **The runtime leaks its own argv container** — 64 bytes in 1 allocation on
|
||||
every run, `main.c`'s `multi Text` of arguments. It is now the ONLY leak the
|
||||
sample reports in any mode.
|
||||
4. **A stopping program does not stop** — `env.stopping()` sets a flag, but
|
||||
`net.accept`/`net.read` restart on `EINTR`, so a server parked in `accept`
|
||||
ignores SIGTERM and needs `kill -9`.
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ read, including through a `try`'s arms, so the classification matches reality.
|
|||
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
|
||||
|
||||
**Concept & reason:** `for e in parse_dir(self.cron_dir).entries` compiles to
|
||||
"call, keep the record in a register, read its field, iterate" — and the record
|
||||
|
|
@ -112,19 +112,44 @@ call result is projected without a `let`. The temporary must be owned by the
|
|||
statement that created it and dropped at that statement's end, after every use
|
||||
of the projection.
|
||||
|
||||
- [ ] Failing measurement: the `run` mode's per-rescan growth over ~60 seconds,
|
||||
with the rescan interval shortened, as the number to beat.
|
||||
- [ ] Give a projected temporary a real owner and a drop at the end of its
|
||||
statement, including when the projection feeds a loop that outlives the
|
||||
expression.
|
||||
- [ ] Re-measure: rescan no longer grows the process; corpus and unit gates
|
||||
stay green.
|
||||
- [x] Failing measurement: `run` 2 112 B in 19 allocations; the MCP mix
|
||||
21 312 B in 63; per handler, `tools/list` 2 144 B, `get_running_crons`
|
||||
2 624 B, `list_logs` 5 184 B, `tail_log` 2 752 B — and 2 requests to 6
|
||||
grew `list_logs` from 5 to 13 allocations, so this was growth, not
|
||||
residue.
|
||||
- [x] The projection was one shape of six. Every one of them is the same
|
||||
sentence — *a value this expression built, that nothing else owns* — and
|
||||
each needed its own site because the drop tables only track bindings:
|
||||
a call result compared against `nil` (`if parse_expr(s) == nil` abandoned
|
||||
a whole schedule record and its five containers per cron line); an
|
||||
argument a callee only **borrows** (`rpc_result(id, "…")`, a 1 KB string
|
||||
per MCP request); a container read's copy (`tokens[0]` copies by rule,
|
||||
and `let u = tokens[0]` was copying twice and abandoning the first); a
|
||||
loop's iterable; the projected record itself; and any of those left
|
||||
behind by a `return` taken from inside the statement that built them
|
||||
(`check_path` returns out of `for p in self.allowed_paths()`).
|
||||
- [x] Two lowering bugs found while measuring, both silent: an argument
|
||||
register cannot be dropped **after** a `CALL` (the callee's frame
|
||||
overlaps it — the value read back is the callee's leftovers), so the
|
||||
reap moved into the stash slot `call_window` already emits before the
|
||||
call; and a statement-owned temporary cannot live in a temp register (a
|
||||
loop reclaims every temp for its body and the end-of-statement `DROP`
|
||||
then released a loop counter), so it is parked in a local slot.
|
||||
- [x] Re-measured: **`run` 2 112 B → 64 B**, identical at 8 s and 20 s;
|
||||
**MCP mix 21 312 B / 63 → 64 B / 1**; every handler flat from 2 to 6
|
||||
requests (`list_logs` 5 184 → 64, `tail_log` 2 752 → 64,
|
||||
`get_running_crons` 2 624 → 64, `tools/list` 2 144 → 64); `watch` 64 B.
|
||||
The remaining 64 bytes are Task 3's argv container, on every path. Gates:
|
||||
`just oop-e2e` 71/0, `just woc-test` 565/0, `just wovm-test` green,
|
||||
`just log-watcher` 6/0. The image grew 35 893 → 46 137 bytes — the drops
|
||||
themselves.
|
||||
|
||||
### Task 3: The runtime's argv container has no owner
|
||||
|
||||
**Concept & reason:** program mode builds the `multi Text` of arguments in
|
||||
`runtime/src/main.c` and hands it to the entry method, which borrows it. Nobody
|
||||
frees it — ASan reports it on every run (128 bytes in 2 allocations). It is
|
||||
frees it — ASan reports it on every run (64 bytes in 1 allocation, and since Task 2 it is the only leak the
|
||||
sample reports in any mode). It is
|
||||
bounded, so it is not the reason a daemon grows, but it is the runtime leaking
|
||||
its own allocation, and it pollutes every future ASan reading of the sample.
|
||||
The runtime owns that container and must release it after the entry returns,
|
||||
|
|
|
|||
Loading…
Reference in a new issue