From c43fa388d934646dc36971b68a064fd6a0ab8445 Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Fri, 14 Aug 2026 16:35:36 +0200 Subject: [PATCH] feat: try/catch over the trap system (haxe-parity plan 8, Task 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- compiler/bin/main.ml | 4 +- compiler/src/ast.ml | 14 ++++ compiler/src/dump.ml | 6 ++ compiler/src/emit.ml | 138 +++++++++++++++++++++++++++++++ compiler/src/lexer.ml | 2 + compiler/src/owner.ml | 49 +++++++++++ compiler/src/parser.ml | 37 ++++++++- compiler/src/token.ml | 6 ++ compiler/src/types.ml | 68 ++++++++++++++++ runtime/src/builtin.c | 38 +++++++++ runtime/src/loader.c | 16 +++- runtime/src/vm.c | 180 ++++++++++++++++++++++++++++++++--------- runtime/src/vm.h | 20 +++++ runtime/src/wob.h | 28 ++++++- 14 files changed, 561 insertions(+), 45 deletions(-) diff --git a/compiler/bin/main.ml b/compiler/bin/main.ml index 4ca9688..5ed34ed 100644 --- a/compiler/bin/main.ml +++ b/compiler/bin/main.ml @@ -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 diff --git a/compiler/src/ast.ml b/compiler/src/ast.ml index a164cd0..da4b8ff 100644 --- a/compiler/src/ast.ml +++ b/compiler/src/ast.ml @@ -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) --------------------------------------------- diff --git a/compiler/src/dump.ml b/compiler/src/dump.ml index 8ddaaa7..fd81e8a 100644 --- a/compiler/src/dump.ml +++ b/compiler/src/dump.ml @@ -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 diff --git a/compiler/src/emit.ml b/compiler/src/emit.ml index 790aeaf..ea9646d 100644 --- a/compiler/src/emit.ml +++ b/compiler/src/emit.ml @@ -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 + 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 + dst> + + 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 diff --git a/compiler/src/lexer.ml b/compiler/src/lexer.ml index 5b88edb..336914f 100644 --- a/compiler/src/lexer.ml +++ b/compiler/src/lexer.ml @@ -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 diff --git a/compiler/src/owner.ml b/compiler/src/owner.ml index 5b21ad8..c6e93b3 100644 --- a/compiler/src/owner.ml +++ b/compiler/src/owner.ml @@ -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; diff --git a/compiler/src/parser.ml b/compiler/src/parser.ml index 057cbc4..a4ab3ed 100644 --- a/compiler/src/parser.ml +++ b/compiler/src/parser.ml @@ -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 = diff --git a/compiler/src/token.ml b/compiler/src/token.ml index 7b3eda3..712d019 100644 --- a/compiler/src/token.ml +++ b/compiler/src/token.ml @@ -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 diff --git a/compiler/src/types.ml b/compiler/src/types.ml index 029aee5..a5776d5 100644 --- a/compiler/src/types.ml +++ b/compiler/src/types.ml @@ -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; diff --git a/runtime/src/builtin.c b/runtime/src/builtin.c index 06a4cff..fa9e8af 100644 --- a/runtime/src/builtin.c +++ b/runtime/src/builtin.c @@ -3,6 +3,7 @@ #include "builtin.h" #include +#include #include #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; diff --git a/runtime/src/loader.c b/runtime/src/loader.c index 2559ab0..2b3033c 100644 --- a/runtime/src/loader.c +++ b/runtime/src/loader.c @@ -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); diff --git a/runtime/src/vm.c b/runtime/src/vm.c index caeb5db..aa4405a 100644 --- a/runtime/src/vm.c +++ b/runtime/src/vm.c @@ -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; diff --git a/runtime/src/vm.h b/runtime/src/vm.h index 017a2b2..097a70e 100644 --- a/runtime/src/vm.h +++ b/runtime/src/vm.h @@ -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) */ diff --git a/runtime/src/wob.h b/runtime/src/wob.h index d99c918..db52caf 100644 --- a/runtime/src/wob.h +++ b/runtime/src/wob.h @@ -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) {