2 Commits

Author SHA1 Message Date
69f5d8a05a A global Vec is borrowed, never moved, and outlives every entry to main
A program that wants to load its data once and keep it could not say so. Every
move-only global was refused where it was declared, on an argument about the
dead set being per function: two functions each freeing the same global would
be a double free nothing could see. The argument was sound and the conclusion
was too strong. It assumed a global has an owner. It does not.

Reading a move-only global is now always a borrow. Nothing may take ownership
of one, so nothing may free one, and with no owner to hand over there is no
double free left to catch. This is not a general ownership model for globals
and is not meant to grow into one: it is sound precisely because the lifetime
question that model would exist to answer has a constant answer here, the
process's. The refusal lands at the read, which is where a move would have been
recorded for a local -- passing the global to something that owns its
parameter, binding it to a local, returning it and freeing it all reach the
same place, and each is told to borrow instead, or to clone if it really wants
something of its own.

Such a global is mutable where it stands. push, put, reserve and set already
take their target through the borrow path, so a global (Vec u8) is filled and
grown in place, and the aliasing that raises is the one every Vec has:
spec-memory.md's explicit Zig/Odin contract, where a push that reallocates
invalidates a slice taken before it and the dev build's generation word traps
on the stale one. Globals get no borrow rule locals do not have, because the
hazard is not new and the trap lives on the Vec rather than on the binding.

What a move-only global may not do is carry a computed initialiser. A global's
initialiser is a link-time constant -- there is no init-at-startup path in the
LLVM backend by design, and the x86 backend that has one deliberately leaves it
out of a reload module, because re-running an initialiser wipes the live state
reloading exists to preserve. So the global starts zeroed, which for a Vec is
an empty Vec and therefore a value rather than a placeholder, and the load is
an ordinary assignment in whichever function loads it. That is also what makes
the data survive: nothing runs between one entry to main and the next, so a
re-entered main finds the global as it left it. A defconst cannot be one at
all, since a constant is not an assignable place and nothing could ever load
it; both refusals name the (defvar g (Vec u8)) that works.

The reload fixture gains a global Vec in the host and another that arrives at
run time, because that is where declaring instead of defining has teeth: a
module that defined the host's Vec would take a zeroed header of its own and
strand the block the process is still using, which a re-zeroed i64 cannot
demonstrate.
2026-09-17 18:42:01 +07:00
5aa6c16209 Ownership is not transitive yet, so refuse the three shapes that assume it is
spec-memory.md says ownership is structural: a struct containing a Vec is
itself move-only, free recurses into owning fields, and a field cannot be
freed on its own. None of that machinery exists — it is the recursive teardown
drop brings — and the move rule as written covered only the types Vec appears
in directly. Three ways past it, each of which hands out a second owner of one
buffer:

A struct field of Vec type. The struct copies its header on assignment and
nothing records a move.

A global of Vec type. The dead set is per function, so two functions each
freeing it is a double free nothing could see, and a global read does not go
through the move path at all — even the one-function case was accepted. Half a
rule is worse than none, so the type is refused where it is declared. A global
Allocator is not this and stays legal: an allocator is a copyable handle, and
it is what makes a handler that owns the arena expressible.

A Vec of a Vec. The runtime is type-erased and copies elements bytewise, so
clone would duplicate inner headers rather than copying what they own and free
would drop their buffers. Shipping the shallow answer under the deep name was
the alternative.

All three name drop as what they wait on.

Also: match arms shared one dead set, so `(match o (Some k) (free v) None
(free v))` reported the second arm as a use after the first arm's move — a
legal program refused, the same case that was already fixed for `if`. Arms are
alternatives, so each starts from the state before the match and the union
survives the join.

And a Vec reaching declare-c now says what to pass instead. It was already
refused, by the shim generator's catch-all for a type it does not know; the
reason it is refused is that handing a header that owns storage to C hands out
an owner, and that is worth saying at the declaration.
2026-09-12 11:12:49 +07:00