feat: try/catch over the trap system (haxe-parity plan 8, Task 5)

VM catch frames + expression-form try/catch in the compiler. Uncaught traps
keep byte-for-byte today's surface. log-watcher parse errors 18 -> 7;
corpus 71/0, woc runtest 565/0, wovm unit gates green (both dispatch flavors).

- wob.h: WOP_TRY (A sBx: push catch frame, handler at pc+sBx) / WOP_ENDTRY;
  WO_B_ERR_FILL builtin (fills the catch record: 0 code, 1 line, 2 method,
  3 msg — the field-order contract with the compiler)
- vm.h/vm.c: catch stack (depth, handler pc, error reg) + the caught error;
  vm_unwind takes a stop depth, so a caught trap kills every frame above the
  catching one exactly as an uncaught trap would, then releases only what the
  try region owned in the catching frame (drop-entry diff against the handler
  pc) and resumes at the handler; RET/RET0 drop the catch frames of the frame
  they leave; TRAPF resumes instead of returning when the trap was caught
- builtin.c: err_fill allocates the method/msg Texts into the record the
  compiler owns, so the pending error never has to outlive the landing
- loader.c: TRY's handler target validated like a jump, error register like
  any register operand; err_fill arity
- lexer/token/ast/parser: `try`/`catch` keywords; `try expr catch (e) expr`
  and `catch (e) { block }`, newline allowed before `catch`; try binds looser
  than every operator, so `try a / b catch (e) 0` catches the division
- types.ml: predeclared `Error` record (merged table only), catch binding,
  arm-type agreement reported only when both arms are confidently typed
- owner.ml: analyze_try — the catch arm is an alternate flow join off the
  entry state, the error record is an owned handler-scope local
- emit.ml: TRY/body/ENDTRY/JMP + handler prologue (NEW Error, err_fill),
  join drops on both arms, `Error` class entry only for programs that catch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-14 16:35:36 +02:00
parent 2a98186259
commit 2bb39d6b6b
14 changed files with 561 additions and 45 deletions

View file

@ -330,7 +330,9 @@ let typecheck_all (collector : Woc_lib.Diag.Collector.t) ~(root : string)
let module_of = module_of_file ~root in
Woc_lib.Types.check_modules collector ~module_of per_file_syms parsed;
let module_syms = Woc_lib.Types.module_symbols ~module_of per_file_syms in
let syms = merge_symbols (List.map snd per_file_syms) in
(* haxe-parity Task 5: the predeclared `Error` record joins the merged
table only — see Types.with_builtin_records for why not per-file. *)
let syms = Woc_lib.Types.with_builtin_records (merge_symbols (List.map snd per_file_syms)) in
(* `~file_syms` (hotfix, multi-file double-report): `per_file_syms` and
`parsed` are both `List.map`s over the same original file list, in
the same order, so pairing them positionally is exact -- each

View file

@ -247,6 +247,20 @@ and expr_kind =
literal without lookahead nothing else needs. *)
| ListLit of expr list
| MapLit
(* haxe-parity Task 5: `try body catch (ename) handler` — an expression,
like `switch`. `body` is an expression (the workload's only form);
`handler` is a `stmt list` so both arm spellings share one shape,
exactly as a switch arm does: `catch (e) nil` parses as a single
ExprStmt, `catch (e) { ... }` as its statements, and the arm's value
is its trailing ExprStmt (an arm with no trailing expression yields
nothing, which is legal in statement position). The error record the
handler binds is the structured trap error {code, line, method, msg}
— the `Error` record type, predeclared by types.ml. *)
| Try of {
body : expr;
ename : string;
handler : stmt list;
}
(* ---- statements (Task 5) ---------------------------------------------

View file

@ -63,6 +63,8 @@ let kind_label (k : Token.kind) : string =
| Token.KwCase -> "KW_CASE"
| Token.KwDefault -> "KW_DEFAULT"
| Token.KwTypedef -> "KW_TYPEDEF"
| Token.KwTry -> "KW_TRY"
| Token.KwCatch -> "KW_CATCH"
| Token.LBrace -> "LBRACE"
| Token.RBrace -> "RBRACE"
| Token.LParen -> "LPAREN"
@ -222,6 +224,10 @@ let rec expr_str (e : Ast.expr) : string =
| Ast.Interp inner -> Printf.sprintf "INTERP(%s)" (expr_str inner)
| Ast.ListLit items -> Printf.sprintf "[%s]" (String.concat ", " (List.map expr_str items))
| Ast.MapLit -> "{}"
(* Like SWITCH above: a one-line summary, not a full unparse of the
catch arm's statements. *)
| Ast.Try { body; ename; handler } ->
Printf.sprintf "TRY %s CATCH (%s) { %d stmt }" (expr_str body) ename (List.length handler)
(* haxe-parity Task 3: arm bodies are `stmt list`, not one `expr` — no
golden AST/bc fixture pins a switch (direct assertions instead, see
runner.ml, same convention haxe-parity Task 2 used), so this is a

View file

@ -192,6 +192,10 @@ let op_rc_dec = 28
let op_builtin = 29
let op_db_stub = 30
(* haxe-parity Task 5: try/catch (runtime/src/wob.h's WOP_TRY/WOP_ENDTRY) *)
let op_try = 32
let op_endtry = 33
let b_now = 0
let b_print = 1
let b_print_int = 2
@ -219,6 +223,13 @@ let b_int_to_text = 13
types.ml's builtin_signatures — 08-builtin-surface.md is unchanged). *)
let b_variant_tag = 14
(* haxe-parity Task 5: fills the catch arm's freshly allocated `Error`
record from the trap the VM landed with (field order 0 code, 1 line,
2 method, 3 msg — Types.error_record_fields). runtime/src/wob.h
WO_B_ERR_FILL = 15. Compiler-internal, like b_variant_tag: never a
source-callable name. *)
let b_err_fill = 15
let ins_abc op a b c = op lor (a lsl 8) lor (b lsl 16) lor (c lsl 24)
let ins_abx op a bx = op lor (a lsl 8) lor (bx lsl 16)
let ins_asbx op a sbx = ins_abx op a (sbx + 32768)
@ -867,6 +878,9 @@ let rec ty_of_expr (p : pctx) (f : fstate) (e : Ast.expr) : Ast.field_ty option
| ListLit (first :: _) -> (
match ty_of_expr p f first with Some (Scalar n) -> Some (Multi n) | _ -> None)
| ListLit [] | MapLit -> None
(* haxe-parity Task 5: a `try` yields its try arm's type — types.ml has
already required the catch arm to agree. *)
| Try t -> ty_of_expr p f t.body
| Ident n -> (
match List.assoc_opt n f.f_env with
| Some (_, t) -> Some t
@ -1270,6 +1284,7 @@ let rec emit_expr (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e
| Some imm ->
sync_mask p f v e.id;
put f (ins_abc op_builtin dst imm b_map_new))
| Try t -> emit_try p f v ~dst ?expected e t.body t.ename t.handler
| Ident n -> (
match lookup_local f n with
| Some (r, _) -> if r <> dst then put f (ins_abc op_move dst r 0)
@ -1802,6 +1817,100 @@ and emit_switch ?(want_value = true) (p : pctx) (f : fstate) (v : views) (e : As
List.iter (fun (o, g, _) -> mask_meet f o g) rest;
f.f_div <- div0
(* haxe-parity Task 5: `try body catch (e) handler`.
TRY ereg, ->handler register the region; ereg is where the error
<body -> dst> record lands if it fires
ENDTRY the region completed: pop it
JMP ->exit
handler:
NEW ereg, Error the record is the compiler's allocation, so
BUILTIN ereg, err_fill its drop is the ordinary scope-end one
<handler -> dst>
<scope drops, join drops>
exit:
The two arms are joined exactly like a switch's: each arm's ending
owned/gc masks meet, and each drops what the other moved
(emit_join_drops with the labels owner.ml's analyze_try recorded). The
VM releases whatever the body itself owned before landing — the live
mask at the try site is what it reads — so the handler starts from the
entry state, which is what the owner pass assumed. *)
and emit_try (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : Ast.expr)
(body : Ast.expr) (ename : string) (handler : Ast.stmt list) : unit =
(* dst is reserved for the whole construct — same reason emit_switch
does it: a handler-local `let` must never be handed dst's register *)
let saved_nlocals = f.f_nlocals in
if f.f_nlocals <= dst then f.f_nlocals <- dst + 1;
if f.f_temp <= dst then f.f_temp <- dst + 1;
bump f dst;
let ereg = alloc_local p f e.pos in
f.f_cur_line <- e.pos.line;
let try_pc = here f in
put f (ins_asbx op_try ereg 0);
let entry_owned = f.f_owned and entry_gc = f.f_gc in
let div0 = f.f_div in
(match expected with
| Some t -> emit_expr p f v ~dst ~expected:t body
| None -> emit_expr p f v ~dst body);
f.f_cur_line <- e.pos.line;
put f (ins_abc op_endtry 0 0 0);
emit_join_drops p f v ~node:e.id ~label:"TRYBODY";
let body_owned = f.f_owned and body_gc = f.f_gc and body_div = f.f_div in
let skip_pc = here f in
put f (ins_asbx op_jmp 0 0);
patch_jump p f ~file:f.f_file ~pos:e.pos try_pc (here f);
f.f_owned <- entry_owned;
f.f_gc <- entry_gc;
f.f_div <- div0;
let saved_env = f.f_env and saved_decls = f.f_declared in
let saved_locals = f.f_nlocals in
(match class_of_name p Types.error_record_name with
| Some ecid ->
f.f_cur_line <- e.pos.line;
put f (ins_abx op_new ereg (check_bx p f e.pos "class" ecid));
put f (ins_abc op_builtin ereg ereg b_err_fill)
| None ->
err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos
~message:"no class-table entry for the `Error` record — a `try` cannot bind its error";
put f (ins_abx op_loadk ereg (const_int p 0)));
f.f_env <- (ename, (ereg, Ast.Scalar Types.error_record_name)) :: f.f_env;
Hashtbl.replace f.f_decl e.id ereg;
f.f_declared <- e.id :: f.f_declared;
mask_set f (match Hashtbl.find_opt v.v_holder e.id with Some k -> k | None -> Owner.LOwned) ereg;
(match List.rev handler with
| [] -> ()
| last :: rev_init -> (
List.iter (emit_stmt p f v) (List.rev rev_init);
match last.Ast.s_kind with
| Ast.ExprStmt ve ->
stmt_reset f;
f.f_cur_line <- last.Ast.s_pos.line;
(match expected with
| Some t -> emit_expr p f v ~dst ~expected:t ve
| None -> emit_expr p f v ~dst ve)
| _ -> emit_stmt p f v last));
emit_scope_drops p f v ~node:e.id ~label:"CATCH";
emit_rc p f v ~node:e.id ~acquire:false ~groups:(declared_since f saved_decls) ();
f.f_nlocals <- saved_locals;
f.f_env <- saved_env;
f.f_declared <- saved_decls;
f.f_temp <- saved_locals;
emit_join_drops p f v ~node:e.id ~label:"CATCHJOIN";
patch_jump p f ~file:f.f_file ~pos:e.pos skip_pc (here f);
f.f_nlocals <- saved_nlocals;
(* the state after the try is what both arms agree on *)
if body_div then ()
else if f.f_div then begin
f.f_owned <- body_owned;
f.f_gc <- body_gc;
f.f_div <- div0
end
else begin
mask_meet f body_owned body_gc;
f.f_div <- div0
end
and emit_ctor (p : pctx) (f : fstate) (v : views) ~(dst : int) (e : Ast.expr) (cn : string)
(fields : (string * Ast.expr) list) : unit =
match class_of_name p cn with
@ -2921,6 +3030,16 @@ let emit_method (p : pctx) (v : views) ~(file : string) ~(self_class : (int * st
Program assembly
============================================================ *)
(* haxe-parity Task 5: does this program catch anywhere? Only then does the
`Error` record earn a class-table entry — so no image that never writes
`try` gains a class it does not use. *)
let program_uses_try (prog : Ast.program) : bool =
let found = ref false in
Types.walk_program
(fun _ (e : Ast.expr) -> match e.Ast.kind with Ast.Try _ -> found := true | _ -> ())
prog;
!found
(* Structural satisfaction, Go-style (spec section 2): a class satisfies
an interface when it has a method of the same name and parameter count
for every method the interface declares. There is no `implements`
@ -3056,6 +3175,25 @@ let emit ~(syms : Types.symbols) ~(module_of : string -> string)
| Ast.Const _ -> ())
u.prog.decls)
units;
(* haxe-parity Task 5: the record `catch (e)` binds needs a real
class-table entry (it is an ordinary heap object with two owned Texts,
so the drop plan is the ordinary one). Added only for a program that
actually catches — every existing image keeps its exact class table —
and only when nothing already claims the name. Its field order is
Types.error_record_fields, which is the same order the VM's
WO_B_ERR_FILL builtin writes. *)
if
(not (SM.mem Types.error_record_name !class_id))
&& List.exists (fun u -> program_uses_try u.prog) units
then begin
let cid = !nclasses in
class_id := SM.add Types.error_record_name cid !class_id;
incr nclasses;
classes :=
{ cr_name = Types.error_record_name; cr_gc = false;
cr_fields = Array.of_list Types.error_record_fields; cr_methods = [] }
:: !classes
end;
let class_id = !class_id in
let p_classes = Array.of_list (List.rev !classes) in
let p_ifaces = Array.of_list (List.rev !ifaces) in

View file

@ -136,6 +136,8 @@ let keyword_kind = function
| "case" -> Some Token.KwCase
| "default" -> Some Token.KwDefault
| "typedef" -> Some Token.KwTypedef
| "try" -> Some Token.KwTry
| "catch" -> Some Token.KwCatch
| "INSERT" -> Some Token.KwInsert
| "SELECT" -> Some Token.KwSelect
| _ -> None

View file

@ -511,6 +511,9 @@ let rec expr_ty (ctx : ctx) (e : Ast.expr) : Ast.field_ty option =
| ListLit (first :: _) -> (
match expr_ty ctx first with Some (Scalar n) -> Some (Multi n) | _ -> None)
| ListLit [] | MapLit -> None
(* haxe-parity Task 5: a `try` yields its try arm's type (types.ml has
already required the catch arm to agree). *)
| Try t -> expr_ty ctx t.body
| Ident n -> (
match find_local ctx n with
| Some l -> Some l.l_ty
@ -1063,6 +1066,7 @@ let rec read_expr (ctx : ctx) (e : Ast.expr) : unit =
literal-specific one. *)
| ListLit items -> List.iter (read_expr ctx) items
| MapLit -> ()
| Try t -> analyze_try ctx e t.body t.ename t.handler
| DbStub _ ->
(* trap-capable: the frame needs its drop map here *)
record_drop ctx ~node:e.id ~pos:e.pos ~kind:DLiveMask ~items:(mask_items (live_holders ctx))
@ -1556,6 +1560,51 @@ and fixpoint (ctx : ctx) (run : unit -> unit) : unit =
else today. Borrows only (l_holds = false), so pop_scope's drop
recording never sees them; every pre-existing call site passes
nothing and is byte-identical. *)
(* haxe-parity Task 5: `try body catch (e) handler`. The two arms are
alternate flows joining at one point — the same shape `if`/`switch`
already have, so the same join machinery applies: whatever one arm
moved out, the arm that still holds it drops at its own end
(branch_join_drops), and the state after the whole expression is the
join of both.
What is genuinely different from a branch is WHERE the catch arm starts
from: a trap can be raised anywhere inside the body, so the handler may
run after *any prefix* of it. Taking the entry state as the handler's
starting point is the conservative reading — it never claims the body's
moves happened — and the join then makes the surviving path responsible
for the drop. The frame also needs a live mask at the try itself (like
every other trap-capable site): that mask is what the VM's unwind uses
to release the body's own values before landing in the handler.
`e` is a local of the predeclared `Error` record, owned by the handler
scope (the record and its two Texts are freshly allocated at landing),
so pop_scope records its drop like any other owned local's. *)
and analyze_try (ctx : ctx) (e : Ast.expr) (body : Ast.expr) (ename : string)
(handler : Ast.stmt list) : unit =
record_drop ctx ~node:e.id ~pos:e.pos ~kind:DLiveMask ~items:(mask_items (live_holders ctx));
let entry = snapshot ctx in
let div0 = ctx.diverged in
read_expr ctx body;
let body_sn = snapshot ctx in
let body_div = ctx.diverged in
restore entry;
ctx.diverged <- div0;
let ety = Ast.Scalar Types.error_record_name in
let ebind =
{ l_name = ename; l_ty = ety; l_class = oclass_of ctx ety; l_node = e.id; l_pos = e.pos;
l_holds = (match oclass_of ctx ety with Copy -> false | _ -> true); l_src = None;
l_bkind = AShared; l_state = Live }
in
analyze_block ctx ~pre:[ ebind ] ~node:e.id ~pos:e.pos ~label:"CATCH" handler;
let catch_sn = snapshot ctx in
let catch_div = ctx.diverged in
if (not body_div) && not catch_div then begin
branch_join_drops ctx ~node:e.id ~label:"TRYBODY" ~pos:e.pos ~moving:catch_sn ~other:body_sn;
branch_join_drops ctx ~node:e.id ~label:"CATCHJOIN" ~pos:e.pos ~moving:body_sn ~other:catch_sn
end;
restore (if body_div then catch_sn else if catch_div then body_sn else join body_sn catch_sn);
ctx.diverged <- body_div && catch_div
and analyze_block (ctx : ctx) ?(pre = []) ~node ~pos ~label (body : Ast.stmt list) : unit =
push_scope ctx ~node ~pos ~label;
List.iter (declare ctx) pre;

View file

@ -719,7 +719,35 @@ let with_no_brace (st : state) (value : bool) (f : unit -> 'a) : 'a =
(* ---- expression parsing -------------------------------------------------- *)
let rec parse_expr (st : state) : Ast.expr = parse_or st
let rec parse_expr (st : state) : Ast.expr =
match peek st with Token.KwTry -> parse_try st | _ -> parse_or st
(* haxe-parity Task 5: `try body catch (e) arm`. `try` binds looser than
every operator, so the body is a full operator expression and `catch`
is what ends it (`try a / b catch (e) 0` catches the division, not just
`a`). A newline before `catch` is insignificant — the workload wraps
long try bodies (mcp.wo's `try self.dispatch(...)` / `catch (e)
err(...)`). The arm is either a braced block or one expression; both
become a `stmt list`, so `{}` right after the catch variable is always
the empty block, never the empty-map literal. *)
and parse_try (st : state) : Ast.expr =
let pos = peek_pos st in
let id = fresh_id st in
ignore (advance st);
(* 'try' *)
let body = parse_or st in
skip_newlines st;
expect st Token.KwCatch "`catch` after a `try` expression";
expect st Token.LParen "'(' before the catch variable";
let ename = expect_ident st "catch variable name" in
expect st Token.RParen "')' after the catch variable";
let handler =
if peek st = Token.LBrace then with_no_brace st false (fun () -> parse_block st)
else
let e = parse_expr st in
[ { Ast.s_id = fresh_id st; s_pos = e.pos; s_kind = Ast.ExprStmt e } ]
in
{ Ast.id; pos; kind = Ast.Try { body; ename; handler } }
and parse_or (st : state) : Ast.expr =
let lhs = ref (parse_and st) in
@ -1730,6 +1758,13 @@ let rec subst_expr (consts : Ast.expr StringMap.t) (bound : StringSet.t) (e : As
| Ast.Interp inner -> { e with Ast.kind = Ast.Interp (subst_expr consts bound inner) }
| Ast.ListLit items -> { e with Ast.kind = Ast.ListLit (List.map (subst_expr consts bound) items) }
| Ast.MapLit -> e
| Ast.Try { body; ename; handler } ->
{ e with
Ast.kind =
Ast.Try
{ body = subst_expr consts bound body; ename;
handler = subst_block consts (StringSet.add ename bound) handler }
}
| Ast.Switch (subject, arms) ->
{ e with
Ast.kind =

View file

@ -83,6 +83,12 @@ type kind =
| KwSwitch
| KwCase
| KwDefault
(* haxe-parity Task 5: `try expr catch (e) arm` — real keywords, and
neither appears as an identifier anywhere in the corpus or the
driving workload (grepped, the same discipline every keyword above
followed). *)
| KwTry
| KwCatch
(* haxe-parity Task 4: `typedef Name = { ... }` structural records. A
real keyword (grepped the corpus/sample first, same discipline as
every keyword above — `typedef` appears only as this declaration's

View file

@ -173,6 +173,33 @@ let stdlib_modules = [ "fs"; "proc"; "net"; "time"; "json"; "env" ]
let is_stdlib_module (name : string) : bool = List.mem name stdlib_modules
(* haxe-parity Task 5: the record `catch (e)` binds — the VM's structured
trap error, one shape forever (spec §6). Predeclared rather than
written: no source declares it, every program that catches gets it, and
the field ORDER here is the contract with the VM's WO_B_ERR_FILL
builtin (runtime/src/wob.h), which writes fields 0..3 by index. *)
let error_record_name = "Error"
let error_record_fields : (string * field_ty) list =
[ ("code", Scalar "Int"); ("line", Scalar "Int"); ("method", Scalar "Text");
("msg", Scalar "Text") ]
(* Adds the predeclared records to a symbol table. Applied to the MERGED
table only (bin/main.ml), never to a per-file one: one entry per file
would read as a cross-file duplicate declaration (WO-E214). A source
that declares its own `Error` keeps it — its own fields are then the
ones `catch (e)` binds, which is either what it wanted or a type error
it will hear about at the use site. *)
let with_builtin_records (syms : symbols) : symbols =
if StringMap.mem error_record_name syms.classes then syms
else
let info =
{ name = error_record_name; is_class = false; is_record = true; is_gc = false; table = None;
fields = List.map (fun (n, t) -> (n, t, None, [])) error_record_fields; methods = [];
id = -1; pos = { line = 0; col = 0 }; pub = true }
in
{ syms with classes = StringMap.add error_record_name info syms.classes }
let rec has_recursive_structure (cls : class_info) : bool =
List.exists (fun (_, ty, _, _) ->
match ty with
@ -844,6 +871,10 @@ let typecheck_program ~file ~(module_of : string -> string)
| ListLit (first :: _) -> (
match confident_typ cenv first with Some t -> Some (TMulti t) | None -> None)
| ListLit [] | MapLit -> None
(* haxe-parity Task 5: a `try` expression's type is its try arm's — the
handler is checked to agree (typecheck_expr below), so either arm
would answer, and the try arm is the one that always has a value. *)
| Try { body; _ } -> confident_typ cenv body
| Ident name -> StringMap.find_opt name cenv
| Field (base, field_name) -> (
match confident_typ cenv base with
@ -1169,6 +1200,40 @@ let typecheck_program ~file ~(module_of : string -> string)
| t :: _ -> { typ = TMulti t; is_nil = false }
| [] -> { typ = TScalar "Int"; is_nil = false })
| MapLit -> { typ = TScalar "Int"; is_nil = false }
| Try { body; ename; handler } ->
let body_res = typecheck_expr env cenv body in
(* The catch arm sees exactly one new name: the error record. *)
let herr = TScalar error_record_name in
let benv = StringMap.add ename herr env in
let bcenv = StringMap.add ename herr cenv in
let handler_res =
match List.rev handler with
| [] -> None
| last :: rev_init -> (
let env', cenv' = List.fold_left typecheck_stmt (benv, bcenv) (List.rev rev_init) in
match last.s_kind with
| ExprStmt e -> Some (confident_typ cenv' e, e.pos)
| _ ->
let _ = typecheck_stmt (env', cenv') last in
None)
in
(* Both arms must yield one type where the value is used. Reported
only when BOTH types are confident — the same "stay silent when
underivable" contract every other confident-type consumer here
follows, which also keeps a `catch (e) nil` arm quiet until
optionals land. *)
(match (handler_res, confident_typ cenv body) with
| Some (Some ht, hpos), Some bt when not (typ_equal syms ht bt) ->
Diag.Collector.add collector
(Diag.error ~code:type_mismatch_code ~file ~line:hpos.line ~col:hpos.col
~message:
(Printf.sprintf
"the `catch` arm yields `%s`, but the `try` arm yields `%s` — both arms of \
a `try` expression must have one type"
(typ_label ht) (typ_label bt))
())
| _ -> ());
{ typ = body_res.typ; is_nil = false }
(* haxe-parity Task 3: the one deriver behind both `Switch` call sites
-- `typecheck_expr`'s own case above (every "the value is used"
@ -1798,6 +1863,9 @@ and walk_expr (bound : StringSet.t) (visit : StringSet.t -> expr -> unit) (e : e
| Interp inner -> walk_expr bound visit inner
| ListLit items -> List.iter (walk_expr bound visit) items
| MapLit -> ()
| Try { body; ename; handler } ->
walk_expr bound visit body;
walk_block (StringSet.add ename bound) visit handler
| DbStub _ -> ()
| Switch (subject, arms) ->
walk_expr bound visit subject;

View file

@ -3,6 +3,7 @@
#include "builtin.h"
#include <stdio.h>
#include <string.h>
#include <time.h>
#include "cont.h"
@ -178,6 +179,43 @@ int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
R[A] = o->class_id;
return 0;
}
case WO_B_ERR_FILL: { /* haxe-parity compiler Task 5: try/catch */
/* Fills the catch arm's record from the error the VM landed with.
* Field order is this builtin's contract with the compiler
* (docs/plan/oop-vm/00-wob-format.md): 0 code, 1 line, 2 method,
* 3 msg. The record is the compiler's own allocation, so its drop
* is the ordinary one and the two fresh Texts belong to it. */
if (!R[B]) {
*msg = "null error record";
return WO_T_BOUNDS;
}
wo_hdr *o = (wo_hdr *)(uintptr_t)R[B];
if (o->class_id >= vm->mod->class_cnt ||
vm->mod->classes[o->class_id].field_cnt < 4) {
*msg = "error record is not a 4-field class";
return WO_T_BOUNDS;
}
wo_str *meth = wo_str_new(rt, vm->caught.method,
(uint32_t)strlen(vm->caught.method));
if (!meth) {
*msg = "out of memory";
return WO_T_OOM;
}
wo_str *text = wo_str_new(rt, vm->caught.msg,
(uint32_t)strlen(vm->caught.msg));
if (!text) {
wo_str_free(rt, meth);
*msg = "out of memory";
return WO_T_OOM;
}
uint64_t *fs = wo_fields(o);
fs[0] = vm->caught.code;
fs[1] = vm->caught.line;
fs[2] = (uint64_t)(uintptr_t)meth;
fs[3] = (uint64_t)(uintptr_t)text;
R[A] = R[B];
return 0;
}
default: /* unreachable: loader validated the id */
*msg = "unknown builtin";
return WO_T_EXPLICIT;

View file

@ -41,7 +41,7 @@ static const uint8_t b_arity[WO_B_MAX + 1] = {
[WO_B_MULTI_GET] = 2, [WO_B_COUNT] = 1, [WO_B_LATEST] = 1,
[WO_B_MAP_NEW] = 0, [WO_B_MAP_SET] = 3, [WO_B_MAP_GET] = 2,
[WO_B_MAP_HAS] = 2, [WO_B_INT_TO_TEXT] = 1,
[WO_B_VARIANT_TAG] = 1,
[WO_B_VARIANT_TAG] = 1, [WO_B_ERR_FILL] = 1,
};
static int vtab_cmp(const void *a, const void *b) {
@ -389,6 +389,20 @@ int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err,
case WOP_DB_STUB:
case WOP_TRAP:
break;
/* haxe-parity compiler Task 5: the handler target is validated
exactly like a jump (it IS a jump the VM takes on a trap),
and A is the register the catch arm's error record lands
in, so it has to be inside the frame. */
case WOP_TRY: {
RCHK(A);
int64_t tgt = (int64_t)pc + 1 + wo_ins_sbx(ins);
if (tgt < 0 || tgt >= (int64_t)mm->ninstr)
BAIL("method %u pc %u: catch handler out of code", (unsigned)i,
(unsigned)pc);
break;
}
case WOP_ENDTRY:
break;
default:
BAIL("method %u pc %u: unknown opcode %u", (unsigned)i,
(unsigned)pc, (unsigned)op);

View file

@ -28,30 +28,60 @@ void wo_vm_destroy(wo_vm *vm) { wo_rt_destroy(&vm->rt); }
* register does not block its drop — the borrower IS the dying frame.
* Window overlap is safe: a slot dropped by the callee frame is nulled, so
* an outer mask covering the same physical slot sees 0 and skips. */
static void vm_unwind(wo_vm *vm) {
const wo_module *mod = vm->mod;
for (uint32_t d = vm->depth; d > 0; d--) {
const wo_frame *f = &vm->frames[d - 1];
const wo_methodrec *me = &mod->methods[f->method];
uint32_t fpc = (d == vm->depth) ? f->pc : f->pc - 1;
const wo_dropent *ent = NULL; /* last entry with pc <= fpc */
for (uint32_t i = 0; i < me->drop_cnt && me->drops[i].pc <= fpc; i++)
ent = &me->drops[i];
if (!ent) continue; /* no entry: nothing live in this frame */
uint64_t *R = vm->regs + f->base;
for (uint32_t r = 0; r < me->reg_cnt; r++) {
uint64_t bit = 1ull << r;
if ((ent->owned & bit) && R[r]) {
wo_drop_obj(&vm->rt, (wo_hdr *)(uintptr_t)R[r]);
R[r] = 0;
}
if ((ent->gc & bit) && R[r]) {
wo_rc_dec(&vm->rt, (wo_hdr *)(uintptr_t)R[r]);
R[r] = 0;
}
/* The drop-table entry governing instruction [pc]: the last one recorded
* at or before it. NULL = nothing live there. */
static const wo_dropent *vm_dropent(const wo_methodrec *me, uint32_t pc) {
const wo_dropent *ent = NULL;
for (uint32_t i = 0; i < me->drop_cnt && me->drops[i].pc <= pc; i++) ent = &me->drops[i];
return ent;
}
/* Release what frame [d-1] owns at [pc] but no longer owns at [keep_pc] —
* the values the abandoned region of that frame created. [keep_pc] =
* UINT32_MAX means "keep nothing", which is the dying-frame case every
* uncaught trap uses. A borrow held by a dying register does not block
* its drop — the borrower IS the dying region. */
static void vm_release_frame(wo_vm *vm, uint32_t d, uint32_t pc, uint32_t keep_pc) {
const wo_frame *f = &vm->frames[d - 1];
const wo_methodrec *me = &vm->mod->methods[f->method];
const wo_dropent *ent = vm_dropent(me, pc);
if (!ent) return; /* no entry: nothing live in this frame */
uint64_t keep_owned = 0, keep_gc = 0;
if (keep_pc != UINT32_MAX) {
const wo_dropent *k = vm_dropent(me, keep_pc);
if (k) {
keep_owned = k->owned;
keep_gc = k->gc;
}
}
vm->depth = 0;
uint64_t *R = vm->regs + f->base;
for (uint32_t r = 0; r < me->reg_cnt; r++) {
uint64_t bit = 1ull << r;
if ((ent->owned & bit) && !(keep_owned & bit) && R[r]) {
wo_drop_obj(&vm->rt, (wo_hdr *)(uintptr_t)R[r]);
R[r] = 0;
}
if ((ent->gc & bit) && !(keep_gc & bit) && R[r]) {
wo_rc_dec(&vm->rt, (wo_hdr *)(uintptr_t)R[r]);
R[r] = 0;
}
}
}
/* Trap unwinding — the spec's "traps never leak" promise (spec §6). Walk
* frames innermost to outermost down to (not including) [stop_depth];
* in each, the governing instruction is the trap pc for the innermost
* frame and the instruction before the saved resume pc — i.e. the CALL —
* for every outer frame. Window overlap is safe: a slot dropped by the
* callee frame is nulled, so an outer mask covering the same physical
* slot sees 0 and skips. stop_depth is 0 for an uncaught trap (the whole
* stack dies) and the catching frame's depth for a caught one. */
static void vm_unwind(wo_vm *vm, uint32_t stop_depth) {
for (uint32_t d = vm->depth; d > stop_depth; d--) {
const wo_frame *f = &vm->frames[d - 1];
vm_release_frame(vm, d, (d == vm->depth) ? f->pc : f->pc - 1, UINT32_MAX);
}
vm->depth = stop_depth;
}
/* Residual runtime checks the loader cannot do statically (registers are
@ -89,25 +119,58 @@ static wo_str *str_check(uint64_t v, const char **why) {
return s;
}
/* Fills [out] with the trap's structured error (spec §6): the code, the
* source line of the trapping pc, the trapping method's name, and the
* message. One shape forever — the CLI prints it, and the catch arm of a
* `try` binds exactly the same four fields. */
static void vm_fill_err(wo_vm *vm, wo_err *out, uint32_t tcode, const char *fmt, va_list ap) {
const wo_module *mod = vm->mod;
const wo_frame *f = &vm->frames[vm->depth - 1];
const wo_methodrec *me = &mod->methods[f->method];
out->code = tcode;
out->line = 0; /* last line-table entry with pc <= trapping pc */
for (uint32_t i = 0; i < me->line_cnt && me->lines[i].pc <= f->pc; i++)
out->line = me->lines[i].line;
const wo_str *nm = mod->consts[me->name].s;
int nlen = nm->len < 63 ? (int)nm->len : 63;
snprintf(out->method, sizeof(out->method), "%.*s", nlen, nm->data);
vsnprintf(out->msg, sizeof(out->msg), fmt, ap);
}
/* 0 = the trap was caught: the stack is unwound down to the catching
* frame, that frame's pc now points at the handler, and the caller must
* reload and keep interpreting. -1 = uncaught: *err is filled and the
* stack is fully unwound (depth 0), exactly as before Task 5. */
static int vm_trap(wo_vm *vm, wo_err *err, uint32_t tcode, const char *fmt,
...) {
if (err) {
const wo_module *mod = vm->mod;
const wo_frame *f = &vm->frames[vm->depth - 1];
const wo_methodrec *me = &mod->methods[f->method];
err->code = tcode;
err->line = 0; /* last line-table entry with pc <= trapping pc */
for (uint32_t i = 0; i < me->line_cnt && me->lines[i].pc <= f->pc; i++)
err->line = me->lines[i].line;
const wo_str *nm = mod->consts[me->name].s;
int nlen = nm->len < 63 ? (int)nm->len : 63;
snprintf(err->method, sizeof(err->method), "%.*s", nlen, nm->data);
va_list ap;
va_start(ap, fmt);
vsnprintf(err->msg, sizeof(err->msg), fmt, ap);
va_end(ap);
/* The record the catch arm reads is always filled, even when the
* caller passed no err: it is the value `catch (e)` binds. */
va_list ap;
va_start(ap, fmt);
vm_fill_err(vm, &vm->caught, tcode, fmt, ap);
va_end(ap);
if (vm->ncatch) {
const wo_catch *c = &vm->catches[vm->ncatch - 1];
uint32_t cdepth = c->depth;
uint32_t hpc = c->pc;
/* Which instruction governs the catching frame's own live set has
* to be decided before unwinding moves the depth: the trapping
* instruction when the trap was raised in this very frame, the
* CALL (pc - 1, the saved pc is the resume point) when it came
* from deeper. */
int trapped_here = (cdepth == vm->depth);
vm->ncatch--;
/* frames above the catching one die whole */
vm_unwind(vm, cdepth);
/* in the catching frame only the try region's own values die: the
* handler's drop entry names what survives into the catch arm */
wo_frame *cf = &vm->frames[cdepth - 1];
vm_release_frame(vm, cdepth, trapped_here ? cf->pc : cf->pc - 1, hpc);
cf->pc = hpc;
return 0;
}
vm_unwind(vm);
if (err) *err = vm->caught;
vm_unwind(vm, 0);
return -1;
}
@ -127,11 +190,18 @@ static int vm_run(wo_vm *vm, uint64_t *ret, wo_err *err) {
R = vm->regs + vm->frames[vm->depth - 1].base; \
} while (0)
/* pc is post-incremented at dispatch: the trapping instruction is pc-1 */
/* pc is post-incremented at dispatch: the trapping instruction is pc-1.
* A caught trap (vm_trap == 0) has already unwound to the handler's frame
* and pointed it at the handler, so the interpreter just reloads and
* keeps going — the same macro serves both surfaces. */
#define TRAPF(tcode, ...) \
do { \
vm->frames[vm->depth - 1].pc = pc - 1; \
return vm_trap(vm, err, tcode, __VA_ARGS__); \
if (vm_trap(vm, err, tcode, __VA_ARGS__) == 0) { \
RELOAD(); \
NEXT(); \
} \
return -1; \
} while (0)
RELOAD();
@ -157,6 +227,7 @@ static int vm_run(wo_vm *vm, uint64_t *ret, wo_err *err) {
[WOP_RELEASE_X] = &&L_RELEASE_X, [WOP_RC_INC] = &&L_RC_INC,
[WOP_RC_DEC] = &&L_RC_DEC, [WOP_BUILTIN] = &&L_BUILTIN,
[WOP_DB_STUB] = &&L_DB_STUB, [WOP_TRAP] = &&L_TRAP,
[WOP_TRY] = &&L_TRY, [WOP_ENDTRY] = &&L_ENDTRY,
};
#define CASE(name) L_##name
#define NEXT() \
@ -262,10 +333,18 @@ dispatch:
NEXT();
}
/* A frame leaving takes its still-open try regions with it: a `return`
* out of a try region never runs its ENDTRY, and a handler pc in a frame
* that no longer exists would land the next trap on a dead window. */
#define DROP_CATCHES() \
while (vm->ncatch && vm->catches[vm->ncatch - 1].depth > vm->depth) \
vm->ncatch--
CASE(RET) : {
uint64_t rv = R[wo_ins_a(ins)];
vm->regs[vm->frames[vm->depth - 1].base] = rv;
vm->depth--;
DROP_CATCHES();
if (vm->depth == 0) {
*ret = rv;
return 0;
@ -276,6 +355,7 @@ dispatch:
CASE(RET0) : {
vm->regs[vm->frames[vm->depth - 1].base] = 0;
vm->depth--;
DROP_CATCHES();
if (vm->depth == 0) {
*ret = 0;
return 0;
@ -434,6 +514,24 @@ dispatch:
CASE(TRAP) : { TRAPF(wo_ins_bx(ins), "explicit trap"); }
CASE(TRY) : {
if (vm->ncatch >= WO_MAX_CATCH)
TRAPF(WO_T_STACK, "catch stack overflow (%u regions)",
(unsigned)WO_MAX_CATCH);
vm->catches[vm->ncatch].depth = vm->depth;
vm->catches[vm->ncatch].pc = (uint32_t)((int64_t)pc + wo_ins_sbx(ins));
vm->catches[vm->ncatch].reg = wo_ins_a(ins);
vm->ncatch++;
NEXT();
}
CASE(ENDTRY) : {
/* the try region completed without trapping. Defensive on an
* unpaired ENDTRY (a miscompile the loader cannot see): pop
* nothing rather than corrupt the stack. */
if (vm->ncatch) vm->ncatch--;
NEXT();
}
#ifdef WO_ISO_C
default:
TRAPF(WO_T_EXPLICIT, "unknown opcode"); /* unreachable: loader */
@ -444,6 +542,7 @@ dispatch:
#undef NEXT
#undef RELOAD
#undef TRAPF
#undef DROP_CATCHES
}
int wo_vm_call(wo_vm *vm, uint32_t method_idx, const uint64_t *args,
@ -465,6 +564,7 @@ int wo_vm_call(wo_vm *vm, uint32_t method_idx, const uint64_t *args,
return -1;
}
vm->depth = 1;
vm->ncatch = 0; /* catch regions never survive a call boundary */
vm->frames[0].method = method_idx;
vm->frames[0].pc = 0;
vm->frames[0].base = 0;

View file

@ -21,12 +21,32 @@ typedef struct wo_frame {
uint32_t base; /* register-window base in the value stack */
} wo_frame;
/* One live `try` region (haxe-parity compiler Task 5, WOP_TRY). `depth`
* is the frame depth that registered it, so a trap raised deeper unwinds
* every frame above that one and lands here; `pc` is the handler's
* instruction in that frame's method; `reg` is the window-relative
* register the error record is built into. */
typedef struct wo_catch {
uint32_t depth;
uint32_t pc;
uint32_t reg;
} wo_catch;
#define WO_MAX_CATCH 64u
typedef struct wo_vm {
const wo_module *mod;
wo_rt rt;
uint64_t regs[WO_STACK_SLOTS];
wo_frame frames[WO_MAX_FRAMES];
uint32_t depth;
/* the catch stack, innermost last; ncatch = 0 means every trap is
* the uncaught kind and behaves exactly as it did before Task 5 */
wo_catch catches[WO_MAX_CATCH];
uint32_t ncatch;
/* the error a caught trap landed with, read by WO_B_ERR_FILL while
* the catch arm builds its record */
wo_err caught;
} wo_vm;
/* heap_cap = arena byte capacity (the CLI's WO_HEAP_MB feeds this) */

View file

@ -120,8 +120,20 @@ enum {
WOP_BUILTIN = 29, /* A B C: r[A] = builtin C, args from r[B] */
WOP_DB_STUB = 30, /* traps WO_T_DB "engine not linked" */
WOP_TRAP = 31, /* Bx: explicit trap */
/* haxe-parity compiler Task 5: try/catch over the trap system.
* TRY pushes a catch frame {this frame, this window, handler pc =
* pc + sBx, error register A}; a trap raised while it is the
* innermost one unwinds every frame above this one exactly as an
* uncaught trap does (drop maps run, registers null), releases what
* the try region itself owned in this frame, and resumes at the
* handler instead of leaving the VM. ENDTRY pops it — the try
* region completed without trapping. Uncaught behavior is
* unchanged: with no catch frame live, a trap is byte-for-byte
* today's surface. */
WOP_TRY = 32, /* A sBx: push catch frame, handler at pc + sBx */
WOP_ENDTRY = 33, /* pop the innermost catch frame */
};
#define WOP_MAX 31u
#define WOP_MAX 33u
/* ---- builtin ids (WOP_BUILTIN operand C) ---- */
enum {
@ -152,8 +164,20 @@ enum {
* WO_T_BOUNDS on a null receiver or a native class id — the same
* defense ICALL keeps for a miscompiled receiver. */
WO_B_VARIANT_TAG = 14,
/* haxe-parity compiler Task 5: materialize the caught error. The
* catch arm's error record is an ordinary compiler-generated class
* whose field order this builtin is the contract for — (record
* object) -> the same object, with field 0 = code (i64), 1 = line
* (i64), 2 = method (fresh owned Text), 3 = msg (fresh owned Text),
* read from the error the VM landed here with. The compiler
* allocates and owns the record (so its drop is the ordinary one);
* the VM only fills it, which is why the pending error never has to
* outlive the landing. Traps WO_T_BOUNDS on a receiver that is not
* a 4-field class object, WO_T_OOM if either Text cannot be
* allocated. */
WO_B_ERR_FILL = 15,
};
#define WO_B_MAX 14u
#define WO_B_MAX 15u
/* ---- instruction encode/decode: op:8 A:8 then B:8 C:8 or Bx:16 ---- */
static inline uint32_t wo_ins_abc(uint8_t op, uint8_t a, uint8_t b, uint8_t c) {