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.
This commit is contained in:
Joseph Ferano 2026-09-12 16:57:56 +07:00
parent ee15745718
commit 6c026cb32e
2 changed files with 44 additions and 1 deletions

View File

@ -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 }

View File

@ -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