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.
locals compares the frame on the stack against the body the session holds:
installing while stopped is allowed, so the two can be different bodies of
one function, and a rename that keeps the slot count pairs every name with
the wrong value. Emit.slot_fingerprint hashes each slot's name and type,
emit_fn puts it in the frame's static description, the agent reports it on
the backtrace line and Dev.locals compares it.
It does not fire. The test that drives it -- a redefinition that renames
every local of a function that is on the stack -- fails, and is committed
failing rather than deleted, because it is the only record of what is
wrong. Everything else in the suite is green; this one check is red.
It builds. See NEXT.md's handoff for where to look first.
Three things stood between the flagship program and the web target, and each
is answered here rather than worked around.
The brush was a path. (rl/load-texture "brush.png") hands raylib a filename to
open, and a bare relative path means nothing on a target with no filesystem.
It is (embed "brush.png") now, decoded through a new binding —
LoadImageFromMemory, declared (Ptr u8) plus an explicit count because the shim
generator refuses a slice parameter and says so, with a Flan wrapper taking
the slice apart exactly as collision-point-poly? and load-font-ex already do.
One decode now serves both textures: the unflipped upload first, then
ImageFlipHorizontal in place, then the mirrored one. load-texture and
load-image lose their only call site in this repository; that is deliberate,
because a path-based load is the thing that cannot work here.
A package's C may now be addressed to one target, the way a link line already
could. A .c file may carry a tag before its extension — flan_agent.web.c — and
on that target it is compiled and *replaces* the untagged file of the same
base name. Replacement rather than plain tagging, so that teaching a package
about a new target is additive: the file that was right on three targets is
not renamed to say so. Selection is in Build and not in Load, for the reason
select_lflags gives.
The dev agent on the web is a no-op, and the reasoning is written at length in
vendor/agent/flan_agent.web.c. Short version: the agent is a socket server and
a browser has no sockets, so the missing <sys/time.h> was the surface and not
the cause. Refusing vendor:agent on a web target was the other candidate and
is ruled out by arithmetic — Flan has no conditional compilation, sand.flan
calls agent/start unconditionally, Reach cannot prune a package something
reachable calls into, so a refusal means the program does not build for the
browser at all. This does not contradict the `barf` decision made earlier
today. `barf` is asked to make something durable, and a no-op returns success
to a program that now believes bytes are on disk. The agent is asked to accept
redefinitions, and on the web there is no editor, no socket and no session —
--dev is refused by name on every wasm target — so there is nothing to lose.
sand.flan already says the same of a native release build at the call site.
test/test_web.ml builds sand.flan for the browser and reads the module for
brush.png's own bytes, whole. Not "IHDR": stb_image carries that string itself,
linked in from raylib, so it would pass on a build where the embed emitted
nothing. It is not run — node has no DOM, so main reaches InitWindow and dies
inside glfwInit on `window is not defined`, which says the module is live and
nothing about whether the canvas paints.
test/dune gains brush.png, because an embed is read by the checker and the
headless case reaches sand.flan through ../../ from a sandboxed _build.
test_session's C-c C-k case now passes ~origin, which is what both editor
paths already send; omitting it was testing a request nobody makes.
dune test is green. Docs follow in the next commit.
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.
They were committed by a git add -A taken after running the program by
hand from the source tree. The suite runs it out of _build and cleans up
after itself; this is only for a run done directly.
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.
A computed path, a file that is not there, and a second argument that is
not `string`. The type argument is now settled before the file is
opened: a program asking for a type embed cannot read a file as was
otherwise told the file was missing, and got the real complaint only
after fixing the wrong thing.
A missing asset is a compile error naming it rather than an empty embed,
because an asset silently absent is the class of quiet wrongness the
whole feature exists to remove. An empty *directory* is not that: it
embeds cleanly as [0 EmbedFile] and len answers 0.
programs/web-files.flan is built for both targets from the same text and
neither build reads the target anywhere in parse.ml or check.ml. On the
desktop it writes the file and says so; in the browser barf signals a
FileError the program handles, naming the file and reason 4,
file-unsupported. The whole of the difference is one #ifdef in
flan_rt.c, which is where the host ABI is already implemented twice.
The web case is run under node rather than inspected. An artifact-shape
assertion would say nothing about what decision 2 actually bought —
that a program on the web is told its write did not happen instead of
quietly losing it — so the test asserts the refusal is printed and that
the desktop's success line is absent. A silent no-op would have taken
that branch, which is the outcome the decision rules out by name.
The same program embeds a file and prints it, because that is the half
needing no filesystem and no host ABI: the line is identical on both
targets and is the answer for assets a web build has to carry.
Decisions 2 and 5. slurp allocates, which is why it waited for Vec, and
it follows spec-memory.md's rule exactly: no allocating operation
returns an error, so there is no Result here and no out-parameter. A
failure to allocate is StorageExhausted under retry; a failure to read
is FileError under retry and use-value. The two guards nest rather than
merge, because they are two different failures with two different
answerable questions — the handler that grows an arena is not the
handler that supplies another path.
The restarts are the pair Common Lisp establishes for a file-error.
use-value is a typed restart, the other thing that landed this session,
and this is the first one the compiler itself emits with a parameter.
Its parameter *is* the path slot the attempt reads, so the clause body
is empty: emit.ml's bind_params stores the invoker's argument into the
slot, the clause falls through, and the loop re-attempts against the new
path. Everything is inside that loop, so a use-value naming a different
file re-measures it and re-allocates for its size; the Vec is freed at
the top of each turn, which is why a retry does not leak.
The host ABI grows by three calls and one reason reader: flan_file_size,
flan_file_read, flan_file_write, flan_file_fail_reason. They are
POSIX-shaped and Vec-ignorant — no handle crosses the boundary and
nothing is held between calls — so a second target implements three
functions. flan_slurp_into is runtime glue on this side of the ABI
rather than a fourth call. These do touch paths, which is the widening
plan.org names as the #1 portability risk and which decision 2 took
knowingly; embed is the answer that does not touch them at all.
Decision 1. Odin's #load and #load_directory are the model, spelled as
ordinary named calls — an s-expression language already has a head
position and does not need Odin's `#`. (embed "p") is a [u8], (embed "p"
string) is a string, and (embed-dir "d") is a [n EmbedFile] sorted by
name.
Two spellings rather than one that changes type with its context. Odin
threads a type_hint everywhere and can afford it; with structural
equality and no implicit widening, the same text meaning two types here
would be a wart. The path is a literal and resolves relative to the file
the form is written in, both of which are Odin's rules and for Odin's
reasons: the bytes must be in hand before any value exists, and a
package's assets must not depend on where flan was invoked from.
The bytes reach the program as a [Str] node typed [u8], not as a [Bytes]
prim over a string. [Bytes] is identity — emit.ml lowers String and
Slice _ to the same %slice — and wrapping the literal in a prim would
make the node non-constant, so an (embed-dir) bound with defconst could
not be an LLVM constant. Both string emitters take the bytes and ignore
the node's type, so it is the same constant either way and one a global
can hold. emit.ml's escape is byte-exact, so a PNG survives the .ll.
The directory lookup is a linear scan in the prelude over a slice of
EmbedFile. A directory embed is tens of entries out of cache-warm
.rodata, and a compile-time perfect hash would be a build-time map with
its own failure modes that nothing has asked for. Sorted because readdir
order is filesystem-dependent and an unsorted embed would make two
builds of identical sources emit different .ll.
The slice points into .rodata, so a store through it segfaults at -O0
and is deleted at -O2 — the same measured trap the prelude's ASCII-case
note describes for (bytes "Hi"). Inherited, not widened; clone into a
Vec for a mutable copy.
The debug-info arm and the structural printer are each a separate path from
everything the suite was exercising: `outputs ~dev:true` goes through the cells,
not through DWARF, and no program printed a Vec or an allocator. That is
NEXT.md's landed item 2 exactly — field_addr took only Types.Named, so the
printer's Option arm had never run and would have died on the first (Option T)
pointed at it. Both arms work; both are now reached, and the DWARF row asserts
the composite's size as well as its name, because an element count that
disagreed with `lay` would print plausible values for the wrong fields.
Printing a Vec did not work: `println` checked its argument as an ordinary read,
so it moved, and every printing of a Vec would have been its last. Printing is a
borrow — the walk goes over the value and keeps nothing.
And `vec-new` with an explicitly named null allocator no longer substitutes the
heap for it. Adopting the context for a *zeroed* Vec is the documented rule;
quietly substituting for an allocator the program named is the same "released
the region / never made one" collapse free-all already traps for, except silent
and found later as a leak. The no-allocator-named case never arrives as null —
the checker passes flan_context_allocator(), which always answers one.
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.
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.
Two element types, one runtime, and the element type appears nowhere below
the call site: size_of and align_of are produced where the concrete type is
known, which without generics is simply the concrete call site. That is
Odin's arrangement and it is what spec-memory.md specifies. `at` and `len`
were already the names for a fixed array and a slice, so a Vec extends them
rather than adding a parallel pair — the asymmetry `nth` was removed for —
and the value form and the place form go through one helper so they cannot
drift apart.
StorageExhausted lands with step 2 rather than after it, because the
signatures depend on it: `push` and `reserve` are Unit, `clone` is the
container, and nothing grows a Result. It is built out of nodes that already
existed — a while, a restart-case and an error — so the backend learned
nothing about allocation. The restart is established at the failing
allocation, which spec-memory.md names as the exception to "restarts go at
the resync point, once", and the element a push was given is bound to a slot
before the loop so a retry re-attempts the allocation and not the expression.
Move-only is a dead set on the checker context, and it is flow-sensitive at
an `if`: both arms start from the same set and the union survives the join,
so `(if c (free v) (free v))` is legal and a one-armed free still kills the
binding. The case a dead set cannot answer is a move inside a loop — merged
once at the end of the body it counts one move, not two — so that is a rule,
refused with its reason.
Four decisions the spec did not settle:
The Vec header is six words in every build, not four in release. A layout
that changes with a build flag can disagree across the reload boundary
silently: a redefinition module is built by llc and ld against a host built
separately, and nothing makes the two agree on a struct size. The 32-byte
release layout is deferred on that.
A zeroed Vec has a null allocator, and the first operation needing storage
adopts the context allocator. Odin's behaviour. The alternative was refusing a
Vec-typed struct field until drop lands; shipping the null was a null deref on
the first push.
A Vec's length and index are i32, like every other length here. Widening
indices is one change across all the containers, not a Vec question.
`let` has no type annotation, so a local Vec has nowhere to say what it holds
and the element type is written at the call: `(vec-new i32)`. This is not the
explicit instantiation syntax the generics section rules out — nothing here is
generic and the name resolves as an ordinary type. Where the context says, it
may be left out.
The allocator grew a budget: a ceiling on live bytes, 0 for none. The retry
restart is only answerable by a handler that can make the *same* request
succeed, and for a fixed backing store the handler that works is the one that
raises the ceiling — releasing the region a container lives in invalidates
the container, which is what the epoch check catches. The spec's "grows the
arena and then invokes retry" needed something to grow.
The generation word is bumped on every reallocation and read by nothing. The
stale-slice trap it is for needs a slice that can carry the Vec's identity,
and a slice is ptr+len. Said plainly rather than implied by the word's
presence.
It calls exit and not _exit, and the reason is the opposite of what the
comment said: the rows that did pass are still in stdout's buffer, and a
watchdog that threw them away would tell you less than the hang did.
Also observed firing from test_dev, which is blocked on a daemon rather
than spinning — the case the reader hang does not cover.
spec-memory.md defines an allocator as a procedure plus an opaque data
pointer, which reads as a function value, which check.ml refuses four ways.
None of the four is anywhere near this: `Allocator` is a `Types.t` case with
no user-writable constructor, the way `string` is a builtin ptr+len, its
procedure is a C symbol the emitter names, and every operation is an ordinary
named call that `check_call` already routes through `named_call`. The one
thing that really does need milestone 5 is a *user-written* allocator — it
wants a defn's name in value position — and that is refused by name with that
reason rather than left to come back as an unknown function.
An `Allocator` value is a pointer to the runtime's struct and never a copy of
one. That is forced, not chosen: the capability set has to be readable from
wherever a container landed, and `free-all` bumps an epoch every container
made from the allocator has to observe. A copy would give each its own epoch
and the dev trap would never fire.
Two decisions the spec left to be made here, both announced in BUILT.md:
`free-all` is retain-capacity — offset = 0, the pages stay — and handing the
pages back is `arena-destroy`, a separate operation. Zig's reset takes a mode;
Odin's arena_free_all is already retain-capacity in effect. Taking the mode
would have grown the operation table the spec froze at four. The epoch is
bumped either way, because the pages being the same does not make a container
made before the reset valid.
`context/allocator` and `context/temp` are dynamic variables with save and
restore, not extra parameters. The spec calls the allocator part of the
calling convention; the literal reading touches every signature, the FFI shim,
the dev trampolines and the reload ABI for the same observable behaviour.
`with-allocator` is its own IR node rather than a let and two calls, because
the restore has to happen on the transfer path too. A body that errors leaves
through the landing pad, and a context allocator left pointing into a region
nobody outside the body has heard of would be wrong in the break loop, which
is exactly where something is about to allocate to render a condition. The
acceptance program asserts that path by taking a restart out of a body.
The backend grew one prim, `Rt of string`: a call into the runtime's C named
by symbol, with argument and result types read off the expression nodes. The
container runtime is type-erased and therefore *is* a list of C entry points,
so one arm covers all of them rather than one arm each.
Says what closed each one and how, and that two of the four named
buffers now have evidence rather than a reading. The count of nineteen
stands as the old one: the mutation pass has not been re-run.
Neither limit had any coverage: a renderer that emits more than 4K and
a program that introduces more than 4096 run-time names are both past
anything the corpus does, so the truncation and the abort were code
that had never executed. dev_limits.c drives them directly — they are C
entry points with no Flan spelling, and flan_dev.c is compiled into
every build — one process per mode, because the name table never
shrinks and the overflow case aborts.
The cap case pins the length, the ellipsis, a byte from before the cut,
the generation moving exactly once, and the flag being cleared so a
short value after a truncated one does not inherit its ellipsis. The
registry case pins that 4096 fit and the next one stops the process
with its reason. Dropping result_full and moving the slot check by one
were both planted and watched fail.
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.
flan_dev_global hands back the allocation it made the first time a name
was asked for, and compares the size it recorded against the size it is
asked for. Nothing exercised the comparison: v5 is v4 with extra as an
i32, loaded on top of v3, and what it does is abort the process — so it
gets a host run of its own. The message is asserted alongside the exit
status, because a process that died for some other reason is not this
guard firing and the status alone cannot tell them apart.
§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.
The mutation pass turned up one defect that did not make the suite go
red: a reader branch that forgets to advance reads the same character
for ever, and dune test waits as long as it is left to. In CI that is a
job killed by the runner with nothing named and no output to read.
watchdog.ml puts an alarm on every test binary — generous, because an
alarm that fires on a slow machine is a flake — and a five-second one
around each read in test_flan, where the budget really is small. The
first read that does not return wedges the rest, so a looping reader
costs five seconds and names the row instead of costing eight minutes
or never finishing. Both were watched: the string-escape loop now fails
in five seconds with the case named, and the per-binary backstop was
armed short and observed to fire.
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.
The 4K result cap and condition_name[128] are on the agent's socket path, which
is why the sanitizer corpus cannot reach them: a program in the sweep has no
socket and nobody on the other end of it. test_agent has both.
A 5000-byte string literal evaluated into the running program comes back as
exactly 4096 bytes ending in the ellipsis result_end puts there to say it
clamped — and it comes back through the seqlock's copy, so the cap and the new
reader are pinned by the same case. The header also shows the generation as 1,
which is the count of complete values rather than the raw counter.
A condition class of 198 characters comes back from `status` as 127 and a
terminator. Aborting out of that break is what pins the exit status at 134 now
that the loop leaves with _exit rather than exit.
SNAP_MAX, SNAP_NAMES and the dev registry's overflow guard are still read
rather than tested. Sixty-five nested restart-cases and four thousand interned
names are a lot of program to write for a clamp each, and neither is on a path
this session changed.
flan_dev_result_cap() exists so the size is asked for rather than written down
in two files: "the copy is never truncated" is only true while the agent's
buffer and the runtime's bound agree, and the agent checks that where the copy
happens.
The pipe the queue program blocks on is close-on-exec, or the child inherits
the write end and its own stdin never reaches end of file — it sat in its last
read waiting for a byte only it could send.