The Map's tests join the suite, and the epoch trap covers its half

maps.flan and map-exhausted.flan as fixed-output cases, the six refusals
by name, and map-stale-region.flan beside stale-region.flan.

The last one is not a line in the Vec's program because the two reach
the check by different routes. A Vec's operations check on the way in
and stop there. A map's get goes on to call a hash and an equality
function through pointers into the block, so a missing check there is
not a wrong number — it is a probe loop walking released memory. It
traps naming the site and exits 134, as the Vec's does.

{K V} resolves now, so the test that asserted it was milestone 6 is
replaced by the one that still holds: the arity, refused for the reason
Vec's arity is refused, because a near-miss would otherwise resolve to a
type variable and come back as generics.
This commit is contained in:
Joseph Ferano 2026-09-12 16:09:27 +07:00
parent 1bc5161ee2
commit c45447a6d4
3 changed files with 105 additions and 2 deletions

View File

@ -0,0 +1,25 @@
;;;; spec-memory.md, "Dev builds detect a released region" — the Map's half.
;;;;
;;;; A Map records the epoch of the allocator it was made with, exactly as a
;;;; Vec does, and free-all bumps that counter. Any operation on a container
;;;; whose recorded epoch has moved traps, naming the site.
;;;;
;;;; This is worth its own program rather than a line in stale-region.flan
;;;; because the two containers reach the check by different routes: a Vec's
;;;; every operation takes the Vec's address and checks on the way in, while a
;;;; map's get goes on to call a hash and an equality function through pointers
;;;; into a block that is no longer there. If the check were missing here, the
;;;; failure would not be a wrong number — it would be a probe loop walking
;;;; released memory.
(defn main [] i32
(let [a (arena-new 65536)]
(let [m (map-new i32 i32 a)]
(put m 1 10)
(put m 2 20)
(match (get m 2) (Some v) (println v) None (println "missing"))
;; The region goes. m is still in scope and still looks fine — nothing is
;; released at scope exit and nothing marked m — which is exactly the
;; case a static rule cannot see.
(free-all a)
(match (get m 2) (Some v) (println v) None (println "missing"))))
0)

View File

@ -581,6 +581,25 @@ let () =
end;
(try Sys.remove exe with Sys_error _ -> ());
(* The same trap on the Map's side, and it is not the same code path: a
Vec's operations check on the way in and stop there, while a map's get
goes on to call a hash and an equality function through pointers into
the block. A missing check here is not a wrong number, it is a probe
loop walking released memory. *)
let exe = compile "programs/map-stale-region.flan" in
let code, text = run exe None in
if code <> 134 || not (contains text "programs/map-stale-region.flan:")
|| not (contains text "allocator was released")
|| not (contains text "20")
then begin
incr failures;
Printf.printf
"FAIL a map used after its region was released\n\
\ got: %S (exit %d)\n wanted: exit 134, naming the site\n"
text code
end;
(try Sys.remove exe with Sys_error _ -> ());
(* §2's other half, which cannot be an [outputs] case because it does not
exit 0: a handler runs, returns normally, and has still not answered the
error, so the program stops and names the condition. *)
@ -1531,6 +1550,60 @@ ERR@7 unexpected token: not the kind the caller was reading
refuses_src "defer in a let inside a branch"
"(defn g [] 0)\n(defn f [] (if true (let [x 1] (defer (g))) 0))"
"not allowed inside a branch";
(* ── (Map K V), spec-memory.md step 4 ──────────────────────────
Odin's map: open-addressed Robin Hood hashing at a 75% load factor with
cache-line cell packing. maps.flan is seven claims, each one a plausible
wrong version gets wrong, and the numbers differ per failure.
The two worth naming, because nothing else in the suite would catch
them. A struct key is hashed *field by field*, never bytewise, because a
struct's padding bytes are indeterminate hashing them makes two equal
keys hash differently and the entry unfindable, which shows up here as a
wrong count rather than a crash. And a grow rehashes against a fresh
seed, because the seed is derived from the block address; carrying the
old hashes across a grow puts every entry in a slot nothing will probe.
2000 entries is eight grows and then every one of them read back. *)
let maps_out =
"2000\n0\n1600\n709\ntrue\nfalse\n3\n20\nfalse\n2\n3\nfalse\n\
100\n999\n2\n1\n3\n500\n998\n"
in
outputs "maps" "programs/maps.flan" maps_out;
outputs ~opt:"-O0" "maps, -O0" "programs/maps.flan" maps_out;
(* The allocation-failure rule is one rule over every allocating operation,
so it has to hold for map-new, put, reserve and clone as it does for the
Vec's four. A map is the harder case: its growth allocates a new block,
rehashes into it and only then releases the old one, so a failure
partway must leave the map exactly as it was or the retry re-attempts
against a half-moved map. *)
let map_exhausted_out = "300\n0\ntrue\ntrue\ntrue\n0\n2\n2\ntrue\n" in
outputs "map StorageExhausted and retry" "programs/map-exhausted.flan"
map_exhausted_out;
(* The refusals, each by name. A float key is not a milestone question —
NaN is not equal to itself and 0.0 and -0.0 are equal while differing
bytewise, so there is no equality for a map to hash. A move-only value
is the refusal (Vec (Vec T)) already carries, for the identical reason.
Unit as a value is refused rather than dividing a cache line by zero,
and it is named because it is the natural spelling of a set. *)
refuses_src "a float is not a map key"
"(defn f [m (Map f32 i32)] 0)" "is not a map key";
refuses_src "a Ptr is not a map key"
"(defn f [m (Map (Ptr i32) i32)] 0)" "hash an address";
refuses_src "a map value may not own storage"
"(defn f [m (Map i32 (Vec i32))] 0)" "holds a move-only value";
refuses_src "a map value may not be Unit"
"(defn f [m (Map i32 Unit)] 0)" "cannot be Unit";
refuses_src "map-new with nothing to say what it maps"
"(defn main [] i32 (let [m (map-new)] (free m)) 0)"
"nothing here says what (map-new) maps";
(* A map is move-only like a Vec, and the refusal names the type that was
moved rather than saying "a Vec" whatever it was. *)
refuses_src "a map used after it was moved"
"(defn main [] i32 (let [m (map-new i32 i32)] (free m) (put m 1 2)) 0)"
"cannot be used again";
let signed_out = "-4\n-1\nbig is not small\nbig is large\n1\n" in
outputs "signedness" "programs/signedness.flan" signed_out;
outputs ~opt:"-O0" "signedness, -O0" "programs/signedness.flan" signed_out;

View File

@ -685,8 +685,13 @@ let () =
back as generics. *)
rejects_check "Vec takes one type" "(defn f [x (Vec i32 i32)])"
~needle:"exactly one type";
rejects_check "Map is milestone 6" "(defn f [x {string i32}])"
~needle:"milestone 6";
(* {K V} resolves now — it is the Map type spelling, and the only one, since
a bare map form in expression position is a struct literal's field list.
What is still refused is the arity, for the same reason Vec's is: a
near-miss would otherwise resolve to a type variable and come back as
generics. *)
rejects_check "Map takes two types" "(defn f [x (Map i32)])"
~needle:"exactly two types";
rejects_check "Result is milestone 6" "(defn f [] (Result i32 i32) None)"
~needle:"milestone 6";
rejects_check "try is milestone 6" "(defn f [] i32 (try 1))"