Two gaps nothing in the suite reached.
A dev build, because the hash and equality pair emitted for a struct key
is a function nobody wrote, and the only other inhabitant of the lifted
list — a handler-bind clause — carries a parent this one cannot: the
pair is shared by every function that maps that key type, so it has no
single parent. A dev build puts every body behind an indirection cell
and is the build that would notice. It does not; maps.flan answers the
same nineteen ways at --dev as it does at -O2 and -O0.
And a map crossing a function boundary in both directions. Everything
else in the file lives and dies inside one let, so nothing would have
noticed if the 48-byte header travelled wrongly by value while every
runtime operation takes its address. Returning one and passing one are
both moves, which is the rule a Vec already follows — verified against a
Vec rather than assumed, since a refusal that fired for the wrong reason
would look the same.
has-key? is flagged in BUILT.md as what it is: an addition, not
something spec-memory.md names.
BUILT.md gets the Map and the defer relaxation; NEXT.md strikes step 4
and item 3, and records four things that are genuinely open rather than
finished.
The one worth reading is that the Map is slower than CPython's dict at a
million entries while being six times quicker cache-resident. Both are
memory-bound at that size and this layout waits longer: keys, values and
hashes are three separate runs, so a lookup that misses everything costs
three cache misses where a compact dict costs two, and the hash run is a
full eight bytes a slot. Cell packing buys probe locality, which is a
win while the hash run is resident and a loss once nothing is. One byte
of metadata a slot is the known answer and is not built, and the
crossover between the two results is somewhere nobody has looked.
Also recorded: the defer change amends a frozen spec-memory.md, which
said a defer for a let-bound value was not expressible; and the Map is
narrower than the spec on one point, a fixed array being a key only when
its elements compare bytewise.
A global is program state a frame happened to touch, not part of it, so
nesting it under one implies an ownership that is not there and repeats the
name once per frame that reads it. One section instead, holding the union of
the globals every frame on the stack references — the compiler does the
choosing, since Reach.expr_refs already answers a body's reference set, and
listing every global a program has would bury the one that matters under the
prelude's PRNG state.
Each entry says which frames touch it, by the index the stack section already
numbers them with, which recovers what per-frame nesting would have told you
at no cost in duplication. Ordered by the innermost frame that touches it:
a deep stack makes the union large and proximity to the error is what puts
the likely culprit on top.
Simpler than locals, because a global is reached by name rather than by
address. Emit.redefinition writes a global the host has as external, so the
thunk binds to the program's own storage and nothing is asked of the stopped
thread — no dev-slot round trip and no not-yet-bound case to refuse.
A frame that cannot be attributed contributes nothing and is named in
:skipped; the union being incomplete and the union being complete are
different answers. The hole in that is stated rather than papered over:
slot_fingerprint hashes a body's slots, which is the right cut for locals and
not for this, so a body that names different globals while binding the same
locals is not caught. The test drives the case that is.
MANUAL.md also loses a stale paragraph claiming the fingerprint check never
fires with a failing test pinned to it. It fires, and test_dev covers it.
BUILT.md gains "The header is read now", directly under the section whose last
paragraph promised that reading a header was what would convert the trusted
half into a checked one and that it was not built. That sentence is replaced by
a pointer to the one below it, in BUILT.md and in shim.ml's docstring both.
It records the things worth not re-deriving: why the dump and not libclang (and
that Zig left libclang too, which strengthens the argument rather than weakening
it), why the import is bounded by the package's own defstructs, why generating
defstructs would make the check circular in exactly the way a _Static_assert
was rejected for, refusal-by-demotion from Zig's failDecl, the naming rule and
what it must actually guarantee, and both const-vs-non-const char * and the
target-varying widths.
The diff and the costs are stated as measurements, with the table: 16 of 16
defstructs and 172 of 172 declare-c agree against 5.5, ten real differences
against 5.1-dev, release +4ms warm, redefinition 31.0 -> 46.5ms.
DISCUSS.md item 6 is rewritten rather than removed. The mechanism question is
settled and is now in BUILT.md; what is left is narrower and is two decisions
that are the author's — whether the header stays a build-time read or becomes a
committed generator, and whether the 172 hand-written lines migrate. Both have
the argument on each side written out, including what migration would lose:
key-pressed? is a better name than is-key-pressed, and an enum parameter
imports as i32 because nothing tells the importer the package calls KeyboardKey
"Key".
check.ml's prose carried struct literals in the old spelling in two
comments the form-level scan does not see, OCaml comments not being forms.
The Emacs handoff said MANUAL.md and flan-mode.el's font-lock still show
the colon. MANUAL.md does not mention a struct literal at all. font-lock
does have something, but it is the opposite of what was written: it colours
:name as a constant and has no rule for .name, so a field label is now
unfontified rather than wrongly coloured. Said accurately, with the line.
runtime/flan_rt.c:256 also shows {:name ...} and is left alone on purpose --
it describes the *printed* form, which still uses colons and is correct.
web/index.html's Flan blocks convert and its output blocks do not, which
is the same split render.ml makes: the printed form keeps the colon until
the Emacs inspector that reads it moves too. Same in BUILT.md.
plan.org, spec-conditions.md and spec-memory.md carried struct literals in
the old spelling and now do not.
NEXT.md decision 6 is struck, and batch item 2 with it, naming what to run
at merge. BUILT.md says why the colon belongs to keys -- mostly that a map
literal wants {:key value}, and two literals sharing one syntax would have
left the reader asking the checker which it was looking at.
The sweep was not idempotent and is now: {.k :hi} -- a field already
converted, holding an enum member -- read as a destructuring pair on a
second run and ate the member. A re-run over a lane's files would have
corrupted them silently, which is exactly what the tool exists to do
safely.
BUILT.md's locals section said the second whole-frame refusal was a slot count
mismatch. It is a fingerprint, and the paragraph now says why a count could not
have done the job: the case it exists for is a rename, which changes neither
the count nor the types. It also states the bound honestly — a 30-bit hash can
collide, and a collision would reproduce exactly the wrong answer this catches,
but only between two differing bodies of a function whose name already matched.
NEXT.md's item 1 is struck, and the handoff paragraph that diagnosed this is
marked wrong rather than deleted. It claimed every piece was written and one of
five hand-offs was dropping the number; four were never written. The step it
recommended first could not have found that, and a lane stopping mid-repair
should say which pieces it ran rather than which it believes it wrote.
BUILT.md gains the section: why the path had to become an embed, the rule for
a target-tagged .c file, why the agent is a no-op there and why that is not the
barf decision reversed, the exact commands including the .html output name and
the server a wasm module needs, and the four things only a human opening it can
settle.
NEXT.md strikes web blocker 1 and rewrites blocker 3 — nothing has been opened
in a browser is still true, and is now the only thing left.
The half the shadow stack was built for. A slot's entry in the frame is its
address, null until the binding that fills it has run, so "not bound yet at
this point" is a null and needs no liveness analysis. The daemon compiles a
thunk that renders the types it already knows -- Tast.fn.slots, with snames
beside them -- at the addresses the stopped program supplies, and reads the
text back the way C-x C-e does. Nothing is copied out, because a value with
no header is bytes with no meaning anywhere but in the program that holds
it.
That is render.ml's walk with its root changed, which is the pointer-rooted
thunk NEXT.md said this needed, and one new arm in the backend: a cast from
one pointer type to another, which emits nothing.
Only named slots are recorded. A recorded slot escapes and stops being
promotable, and the slots that would cost most are the ones with nothing to
show -- dotimes' bound, the temporaries min and max use, the walk's own
scratch. They are refused by name rather than shown under an invented one.
Recording every slot was built and timed and is inside the noise, so the
rule stands on what it shows.
Four refusals, each by name and with its reason: a slot nobody named, a
slot the program has not reached, a type the printer has no arm for, and
two whole frames -- an evaluation's thunk, and a frame running a body that
has been redefined since, where every slot index would be a guess.
Measured, minimum of nine runs: +61% on call-heavy code over globals
against +33% for the frames alone, 0.06% of a frame at 60fps.
On Linux open_in_bin on a directory succeeds and in_channel_length
answers a number; the read is where EISDIR arrives. Guarding only the
open turned (embed "assets") — someone who meant embed-dir — into an
uncaught OCaml exception out of the checker, which is the one way a user
could make the compiler crash rather than refuse. It now says it is a
directory and names the form that embeds one.
Same class, same function family: read_embed_dir tested is_directory
before file_exists, and Sys.is_directory raises on a path that does not
resolve, so a dangling symlink inside an embedded directory crashed
before the existence test ran. The conjuncts are swapped.
slurp.flan gets its dev build, and the compiler-emitted use-value gets
the same unarmed-restart assertion the hand-written one has. It is the
first clause the compiler emits with a parameter — alloc_guard's retry
takes none — so it is worth saying it rides emit.ml's existing path
rather than sitting beside it.
flan_file_read loses its declare: nothing Flan emits calls it, only
flan_slurp_into does, from C. That takes the edit to emit.ml down to
four declare lines and a comment.
plan.org has specified a shadow stack in the dev column since the beginning
and nothing had ever built it. A frame is four words on the calling
function's own stack: the one it displaced, a pointer to a static
description of the function, and two words reserved for its locals. The
name and the location travel on the frame, so a backtrace needs no debug
information, no symbol table, and nothing from the platform unwinder that
plan.org deliberately does not use.
The pop is at every ret, the landing block a transfer leaves through
included. That is the half that is easy to get wrong: a pop written only on
the normal path leaves a dead frame behind every handled error, and the
test takes five breaks and resumes all of them by transfer before asking
for two frames.
(:op "backtrace") answers from a snapshot the stopped thread takes, beside
the restarts and for the same reason, and marks which frames belong to the
program and which to the evaluation the break is inside. It is refused
while the program runs.
Measured, interleaved, three pairs of binaries: 29% on 600 frames of sand,
7.6% on a benchmark that is nothing but calls -- 32us per frame of sand, a
fifth of a percent of a frame at 60fps. An array with a stack pointer was
built and timed as the alternative and is worse on both.
NEXT.md strikes decisions 1, 2 and 5, and the web target's "assets are
two questions" item, which the embed answered with a third option
neither half of it considered: make it a compiler feature and neither
question arises. That item's diagnosis was right — the file that needs
the asset is structurally the one file that cannot declare it — and its
conclusion, that the fix must be a link channel or a new declaration,
was wrong.
BUILT.md gets the two sections. The embed one records the choice a
reader would otherwise have to reverse-engineer: the bytes are a Str
node typed [u8] rather than a Bytes prim over a string, because the
prim is identity but makes the node non-constant, and an embed-dir in a
defconst then cannot be an LLVM constant. It also states the .rodata
write hole loudly, because an embedded asset is precisely what someone
will try to decode in place.
The slurp/barf one writes down what the host ABI grew by and why that
much: three POSIX-shaped calls and one reason reader, Vec-ignorant, with
the Vec-aware half as runtime glue rather than a fourth call. And it
records the gap the feature revealed without fixing — a handler that
wants "try to save, carry on if you cannot" has nowhere to go, because
error is diverging and neither restart means give up.
NEXT.md is what is left, so the three steps go, the four questions that were
unsettled under them are answered or replaced, and the reasoning that outlives
the change moves to BUILT.md: why Allocator being a builtin opaque type made
milestone 5 unnecessary, why with-allocator is its own IR node, why the move
rule unions at a join, what ownership is not transitive through yet and what
each refusal waits on, and why StorageExhausted had to land with Vec.
Four things replace the old open list. The use-after-release question is still
open but now has evidence available for the first time, because there is a Vec
to write arena programs with. The release layout is blocked on the reload path
carrying build flags. The generation word has no reader and will not have one
until a slice can carry a Vec's identity. And the budget is an addition to the
spec that wants folding in or replacing with a growable arena.
The thunk calls flan_dev_result_begin before it evaluates anything, so an
expression that signals is stopped inside the seqlock's window — and a restart
taken from that break transfers past the thunk, so the matching end never runs.
An unpaired begin cost nothing while the counter only moved at the end. It
costs everything now: incrementing would leave the count odd for the life of
the process, every later read reporting a write in progress, and C-x C-e dead
until the program restarts.
So begin sets the low bit rather than incrementing, and end clears it by
setting rather than adding. The ordinary sequence is unchanged — 2k, 2k+1,
2k+2 — and an abandoned write is over as soon as the next evaluation starts.
What that does not fix, because one buffer cannot: an evaluation running while
another is stopped mid-render shares the buffer, so the inner value is the one
that survives. That was true before the counter was a seqlock and is not a
regression.
Also noted in NEXT.md: rt_die in flan_rt.c has the same exit-with-the-loader-
lock-held shape the break loop just lost. Not fixed with it, because rt_die is
the non-dev path too, where there is no listener to deadlock against — whether
it should be _exit always or only under --dev is a decision.
And the 4K-cap assertions clamp their own String.sub, so a short body prints a
failure instead of raising out of the test.
§4 meets §3, and the answer a reader will assume is the other one. An
inner (use-value [s string] ...) shadows an outer (use-value [v i32] ...),
so an i32 is refused there and the outer clause that would have taken it
is never consulted. Searching outward for a frame whose signature fits
would make which restart runs depend on the arguments, which is overload
resolution on a dynamic stack.
Also: neither of the new guards is a bounds check, so --no-bounds-checks
does not remove them. A wrong index is a wrong answer; a transfer into a
clause whose parameters were written to a different layout is not.
NEXT.md: the ring, the seqlock, the break loop's exit and the leaked handle are
struck with what each was fixed to rather than only that it was. The snapshot
generation stays open — it wants a hook a test can drive, which is a design
decision and not a fix. The four-buffer paragraph is now two and two.
BUILT.md carries the reasoning that outlives the change. "Nothing is ever
dlclosed" is restated as "nothing that published anything is ever dlclosed",
because that is what the rule was always about — being pointed into — and the
two modules that are closed are the ones nothing can point into. Stating it the
weaker way is what made a dropped handle look like obedience.
The agent section gains why a full ring refuses rather than drops or blocks,
and why the break loop leaves with _exit. The renderer section gains why the
result counter had to become a real seqlock and why marking it odd needs a
release fence rather than a release store — a release store orders what
precedes it, so the buffer writes could still be hoisted over it, which is the
original bug with more ceremony.
One correction: the release-build story named flan_dev_result_get as the symbol
that came up undefined. That symbol no longer exists.
dune test green; dune build @sanitize clean.
The language half of §3's parameters is in; the half that makes it worth
having is not. A break loop chooses by position and has nothing to fill a
clause's parameters in with, and that is now the top item in NEXT.md,
spelled out end to end — the accessors the frame can already answer, the
signature on the wire, and the one store that has to happen before the
channel is aimed.
§3 asks for a clause's report string to be settled before parameters and
it was not. The field is cheap and so is the accessor; the only thing
that would read either is the break loop's listing, which lives in the
agent and the daemon, so it would have shipped as a field nothing read.
It belongs with the editor half, which is changing that listing anyway.
BUILT.md gains the section on the third target and corrects the claim it
already carried: emscripten_set_main_loop had the browser fact right and drew
the wrong conclusion, because asyncify answers the same fact without cutting
main in half.
NEXT.md gets the four holes. sand.flan has no web build, and the proximate
cause is that vendor/agent/flan_agent.c:426 uses struct timeval without
pulling in sys/time.h, which glibc gives it transitively and emscripten does
not; sand's main calls agent/start unconditionally so Reach cannot prune it.
Beneath the include is the decision worth making rather than patching around:
the agent is a socket server and the browser has no sockets, so the honest fix
is to refuse vendor:agent on a web target the way --dev is refused.
Assets are two questions and only the easy one is about emscripten.
--embed-file is a linker argument and so already expressible as an @web line.
The hard one is that the file doing (rl/load-texture "brush.png") is
structurally the one file that cannot say so: Load hands out lflags only for a
directory package, and main is not exported, so a program can never be one. No
flag was invented for it.
And nothing has been opened in a browser, asyncify's cost is quoted rather
than measured, and audio and threads on web are untried.
layout searched only Tast.structs, so a declared union came back as "no struct
is named X" — which reads as "that type does not exist" about a type the
checker knows. Refused by kind beside the enum, and both refusals now have a
test: a new enum and a new union, evaluated into the session.
(:op "layout" :type T) needs no running program: the daemon owns the build, so
Tast.structs is already in the session it compiled the process from. The open
question was what T is, and it needs no new machinery — Load qualifies every
declaration at import, so two packages' Missing are a/Missing and b/Missing and
the name is the type id. Emit already writes that same qualified name into
flan_error, so the string break reports as :condition resolves as :type by
construction, which is the round trip the test makes.
A bare name is refused with the candidates rather than resolved to a unique
suffix: resolving it would put back the ambiguity the rule exists to remove.
ASan was instrumenting none of the Flan half: it is an LLVM pass that
only touches functions carrying sanitize_address, which clang's C
frontend adds and hand-written IR does not. Globals get redzones either
way, which is why it looked right. emit.ml puts the attribute on every
define now, and a control asserts the report.
UBSan reaches no Flan code and no flag changes that -- its checks are
frontend-emitted branches, not a pass -- so shift UB and the NaN cast are
not answerable this way. Left as a compiler question, pinned by a control
that must not report.
A negative index into a global is silent in bounds.flan, which is
measured. "Because a global has no left redzone" was the explanation
put on it, and it does not survive the obvious test: declare another
defvar in front of arr and arr[-1] is caught, landing in that global's
right redzone. Underflow detection is a question about what the linker
put in front of the object, not about the access. Corrected in
test_sanitize, BUILT.md and NEXT.md.
NEXT.md's entry also goes back to its stated size. It had grown to 78
lines saying what BUILT.md says in the same commit range -- the
attribute, the -O0 decision, the bounds.flan table -- which is the
half-build-log the file's own header warns about. What stays here is
what is next: the UBSan gap as an undecided compiler question, the four
daemon-path buffers the corpus never reaches, and Valgrind.
NEXT.md's queued section becomes a landed one. The headline is not the
flag: ASan reaches Flan code only because Emit now attributes every
define, and UBSan reaches none of it and has no lever that would, so the
shift-UB and float-cast items that section listed are still open and are
a compiler feature rather than a flag.
The clean result is written with its reach. println.flan pushes a
1100-character string through escaped[1024] on purpose, so that buffer
is genuinely covered; scratch[64] never sees more than 20 characters;
and the 4K result cap, the dev registry guard, SNAP_MAX/SNAP_NAMES and
condition_name[128] are on the daemon path and not in the corpus at all
-- read, not tested. Two defects fixed, both found by reading. Three of
bounds.flan's six out-of-bounds cases caught with the checks off, with
the other three tabulated and explained, and the caveat that ASan sees
out-of-object and not out-of-subobject access, so three of six is a
ceiling and not a measurement.
BUILT.md gets the durable half: the attribute, the absent UBSan lever,
why --sanitize does not force -O0 when --debug does, and the -O0/-O2
divergence that earned it.
The .out file and the two places that quote it in prose had the banner
from before restarts were numbered, so check.sh had been red on
breakdemo since that commit. The .out is regenerated from the same
build --dev and timeout run check.sh does, rather than typed: the leading
blank line and the three spaces before each number are part of what is
compared.
The page gets a sentence it was missing. A number in front of a restart
is not decoration -- a restart is taken by position, because an inner one
can shadow an outer one of the same name -- and the banner showed the
numbers without the page ever saying what they were for.
print and println are the whole printing surface now. About 500 call
sites across 47 files, and the site documents either of them for the
first time.
Two pinned outputs moved and both are corrections. sand-headless hashes
to 15595743031174623232 rather than -2851001042534928384 -- the same 64
bits, printed unsigned now that hash-grid's u64 no longer goes through an
(i64 ...) cast, which is the bug the family's explicit widening invited.
And a trap column shifted because the call it names got shorter.
NEXT.md was 1,738 lines and about half of it narrated work that was
finished. BUILT.md takes that half — the reload primitive, cells, the
agent, the session, the daemon, the Emacs client, conditions, the FFI
shim, the layout, and the order it was all built in. Not deleted,
because the reasons in it are load-bearing and would have to be derived
again.
NEXT.md keeps what is left, and says at the top what the two files are
for, so it does not become a log a second time. The struck-through
milestone checklist goes with the log; its one live item, the milestone
4 loose ends, stays. Start here was stale in three places and is
rewritten: conditions are three steps of four, a restart is taken by
position, and find-restart is blocked on a type rather than on effort.
The raylib gap list and the reasons break was declined are written down
where the next session will look for them, rather than living in a
commit message.