From 6c026cb32ea48fe970ce8f5f44541af59bd24b8d Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 16:57:56 +0700 Subject: [PATCH] uninit on a union, and .field on one, are both refused Everywhere else uninit is an opt-out from ZII and the bytes are whatever they were: a garbage f64 is a garbage number. A union is the one type where that is qualitatively worse. Its tag steers control flow, a tag no case names falls past every comparison in a match, and the block after those comparisons is unreachable -- which LLVM is entitled to assume cannot happen. So the one place where garbage becomes "the optimiser may do anything" is refused by name, with the zeroed form, which is a real case, named beside it. (.x u) on a union said "Shape is not a struct, so it has no fields", which is true and unhelpful. A union's fields belong to a case and which case is being held is what the tag says, so they are reached by match, whose arms bind the fields of the case they matched. The message says that. --- lib/check.ml | 33 ++++++++++++++++++++++++++++++++- test/test_acceptance.ml | 12 ++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/lib/check.ml b/lib/check.ml index 6e5be45..9a39427 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -1735,6 +1735,17 @@ and struct_target ctx (target : Ast.expr) : Tast.expr * string = | Types.Named n when Hashtbl.mem ctx.env.structs n -> t, n | Types.Ptr (Types.Named n) when Hashtbl.mem ctx.env.structs n -> mk t.Tast.loc (Types.Named n) (Tast.Deref t), n + (* A union's fields belong to one case, and which case it is holding is only + known after the tag has been read. [.field] would have to be a read that + might be reading something else, so it is not one: [match] is how a union + is opened, and it binds the fields it has proved are there. *) + | (Types.Named n | Types.Ptr (Types.Named n)) + when Hashtbl.mem ctx.env.unions n -> + fail target.Ast.loc + "%s is a union, and a union's fields belong to a case — which one it is \ + holding is what the tag says, so they are reached by (match ...), \ + whose arms bind the fields of the case they matched" + n | other -> fail target.Ast.loc "%s is not a struct, so it has no fields" (Types.to_string other) @@ -3665,7 +3676,27 @@ let check_global env (d : Ast.decl) : Tast.global option = let ginit = match init with | Ast.Zeroed -> { Tast.e = Tast.Zero ty; ty; loc = d.Ast.dloc } - | Ast.Uninit -> { Tast.e = Tast.Uninit ty; ty; loc = d.Ast.dloc } + | Ast.Uninit -> + (* Everywhere else [uninit] is an opt-out from ZII and the bytes are + whatever they were: a garbage f64 is a garbage number. A union is + the one type where that is qualitatively worse — the tag steers + control flow, a tag no case names falls past every comparison in a + [match], and the block after them is [unreachable], which LLVM is + entitled to assume cannot happen. So the one place where garbage + becomes "the optimiser may do anything" is refused by name, and the + zeroed form, which is the first declared case, is named beside it. *) + (match ty with + | Types.Named un when Hashtbl.mem env.unions un -> + fail d.Ast.dloc + "%s is a union, and uninit on one is refused: its tag steers \ + every match, and a tag no case names has no arm to reach. Drop \ + the uninit — a zeroed %s is %s, which is a real case" + (Types.to_string ty) un + (match Hashtbl.find_opt env.unions un with + | Some { Tast.cases = c :: _; _ } -> un ^ "." ^ c.Tast.vname + | _ -> "its first case") + | _ -> ()); + { Tast.e = Tast.Uninit ty; ty; loc = d.Ast.dloc } | Ast.Init v -> check (ctx ()) ~want:ty v in Some { Tast.gname = n; gty = ty; ginit; gconst = false; gfolded = false } diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 00450a6..2e4f81a 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -1740,6 +1740,18 @@ ERR@7 unexpected token: not the kind the caller was reading incr failures; Printf.printf "FAIL %s\n said: %S\n" name m end); + (* uninit is an opt-out from ZII everywhere else and the bytes are just + bytes. On a union they steer control flow: a tag no case names falls + past every comparison in a match into the block LLVM is entitled to + assume cannot be reached. *) + refuses_src "uninit on a union global" + "(defunion U [A B])\n(defvar g U uninit)\n(defn main [] i32 0)" + "its tag steers every match"; + (* A union's fields belong to a case, so .field is not a read anyone can + do without having read the tag first. match is how one is opened. *) + refuses_src "reading a field of a union directly" + "(defunion U [(A [x i32])])\n(defn f [u U] i32 (.x u))" + "reached by (match ...)"; (* And a zeroed one is fine, which is the other half of the same rule: it is the first declared case, all bytes zero, and needs no encoder. *) (let name = "a zeroed union global" in