The rebase's four repairs, and two pins that were not pinning anything

Rebased onto dev-loop. Three conflicts were additive and both sides are
kept: FIX.org's two appended sections, want_map's diagnostics argument
against the class_sync inserted beside it, and test_dev.ml's agent-socket
block against this lane's migration block, whose comment no longer says
"the block above" now that something sits between.

The fourth is the one the auto-merge hid. flan_dyn_class_def's argument
check was written against the pre-diagnostics trap1 and merged clean into
a tree where trap1 takes a location first, so the class name would have
been read as a length. dune build does not compile flan_dyn.c, so the
green build said nothing; caught by compiling a program.

Two pins in test_session.ml asserted "flan_dyn_class_def" against the IR
text, which every module contains because emit.ml declares every runtime
entry point in all of them. Both now assert the call and the packed slot
list. Checked by mutation: with the thunk suppressed the old needles pass
and the new ones fail, along with the daemon's slot count.

Also disclosed: say_render is the second raw reader beside render, and
neither syncs, so a stale instance shows its old slots in the inspector
until something touches it. That is the editor-facing consequence of
keeping the printers printers, and it is now in the runtime comment and in
FIX.org rather than left to be met. And the stale-caller walk says in as
many words that it is a tripwire, unreachable on purpose, not a filter to
be tidied away.
This commit is contained in:
Joseph Ferano 2026-09-20 19:54:53 +07:00
parent 63fb5c629c
commit 5cfefa586b
4 changed files with 85 additions and 20 deletions

24
FIX.org
View File

@ -2540,11 +2540,25 @@ Without that rule every save would migrate every instance in the program.
instance is read or written"; those are the three places that read or write
the slot *set*.
~render~ is deliberately not one of them. It runs inside trap reporting,
where the heap is whatever the trap left, and a printer that reallocates an
object's storage is not something to have on that path. So a stale instance
prints its old slots until something touches it. CLHS's
"implementation-dependent time" allows it and the printer stays a printer.
*Neither printer is one of them*, and that has a consequence somebody will
meet. ~render~ — which ~print~ goes through, and which the editor renders
every dyn value with — and ~say_render~ — the 96-byte sentence a trap
prints — both walk the entries raw and neither syncs. ~say_render~ runs
inside trap reporting, where the heap is whatever the trap left, and a
printer that frees an object's entry block and installs another is not
something to have on that path; ~render~ is its sibling and is reached from
it for nested values, so splitting them would put the mutation one recursion
below a trap anyway.
So: *a stale instance shows its old slots to the editor until something
touches it.* A watch expression, the value ~C-x C-e~ answers and the
inspector's render of a dyn all arrive through ~render~, so in the moment
after a ~defclass~ is redefined the inspector can show a slot the class no
longer has and omit one it has gained — while ~(get p :z)~ typed at the same
instant answers the new definition, migrates the instance, and makes the
inspector agree from then on. CLHS's "implementation-dependent time" permits
it; it is the price of the printer staying a printer; and it is disclosed
here rather than discovered.
The migration rebuilds the entry block rather than compacting it in place,
and writes the slots in the *class's* order. One ~malloc~ per instance per

View File

@ -701,7 +701,20 @@ let eval ?(origin = "<eval>") ?pause t src : change =
constructor or takes its address, minus the ones this
evaluation is recompiling. [Tast.walk] rather than a match on
the body's head: a constructor call can be anywhere in an
expression, and a missed one is the wild-pointer case above. *)
expression, and a missed one is the wild-pointer case above.
**Known to be dead today, and deliberately not tightened.**
The checker refuses every caller before this runs, so [stale]
is empty on every path anyone has found; a global's [ginit] is
not walked here for the same reason it need not be a global
initialised by calling a constructor is re-checked with
everything else, and a mismatch there is the checker's
refusal too. What this branch is for is the day that stops
being true. It is a tripwire, not a filter: if it ever fires,
the answer is a refusal naming the callers rather than a
module that loads and then reads a register as a pointer. Do
not delete it because it is unreachable unreachable is the
property being asserted. *)
let stale =
List.filter_map
(fun (f : Tast.fn) ->

View File

@ -1159,12 +1159,29 @@ flan_dyn flan_dyn_map_new(void) {
* later than the next time a slot is read or written" and those are the
* three places that read or write the slot *set*.
*
* [render] is deliberately not one of them. It runs inside trap reporting,
* where the heap is whatever the trap left, and a printer that reallocates
* an object's storage is not something to have on that path. A stale
* instance therefore prints its old slots until something touches it, which
* CLHS's "implementation-dependent time" allows and which is worth the
* printer staying a printer. */
* **The two printers are deliberately not among them**, and the consequence
* is visible to whoever is sitting in front of the editor, so it is written
* out rather than left as a footnote. [render] which [print] and every
* value the editor renders go through and [say_render] the 96-byte
* sentence a trap prints both walk [items] raw and neither syncs.
*
* The reason is the same for both: [say_render] runs inside trap reporting,
* where the heap is whatever the trap left, and a printer that frees an
* object's entry block and installs another is not something to have on
* that path; [render] is the same function's sibling and is called from it
* for nested values, so splitting them would put a mutation one recursion
* below a trap anyway.
*
* What that costs: **a stale instance shows its OLD slots to the editor
* until something touches it.** A watch expression, the value [C-x C-e]
* answers, and the inspector's render of a dyn all reach a class instance
* through [render], so immediately after a [defclass] is redefined the
* inspector can show a slot the class no longer has, and not show one it
* has gained while [(get p :z)] typed at the same instant answers the new
* definition and migrates it, after which the inspector agrees. CLHS's
* "implementation-dependent time" permits it and it is the price of the
* printer staying a printer; it is not a bug report waiting to happen only
* because it is written down here, in FIX.org, and nowhere else. */
/* Interning, which is under "Keywords" further down. This file includes no
* header of its own every entry point is written out in flan_dyn.h and

View File

@ -1083,13 +1083,22 @@ let () =
(String.concat " " c.Session.fns);
(* The registration, and the thunk that runs it. Without the first the
runtime never hears that the class changed; without the second the
module defines a function nothing calls. *)
if not (has c.Session.ir "flan_dyn_class_def") then
module defines a function nothing calls.
[call void @] and not the bare symbol, which is the difference
between a pin and a decoration: [emit.ml]'s declare block names
every runtime entry point in every module it writes, so
"flan_dyn_class_def" on its own is in the text of a module that
registers nothing. Checked by mutation the bare needle passes with
the thunk deleted. *)
if not (has c.Session.ir "call void @flan_dyn_class_def") then
fail "a redefined class did not register its slots";
if not (has c.Session.ir "flan_reload_call") then
if not (has c.Session.ir "define void @flan_reload_call") then
fail "the class registration had nothing to run it";
(* And the slot names, in the packed form the runtime splits. *)
if not (has c.Session.ir "x\\0Ay\\0Az") then
(* And the slot names, in the packed form the runtime splits — which is
what says the call carries *this* class's new list and not some
other module's leftovers. *)
if not (has c.Session.ir "c\"x\\0Ay\\0Az\"") then
fail "the registration did not carry the new slot list"
| exception Loc.Error { Loc.dmsg = m; _ } ->
fail "adding a slot to a class was refused: %s" m);
@ -1106,12 +1115,24 @@ let () =
fail "removing a slot from a class was refused: %s" m);
(* A class that did not change registers anyway — the runtime ignores a
re-registration of the same list, and something has to tell it the list
in the first place. *)
in the first place. This is the C-c C-k shape: every class in the file
arrives, whether or not any of them moved.
The needles are the same two discriminating ones, and the slot list is
the *old* one, which is what says the registration is of this class as
it currently stands rather than a leftover from the case above. That
the runtime then declines to bump the generation is flan_dyn.c's half
and is pinned where it happens: dyn_ops.c's [classes] mode writes a
value and re-registers the same list under it, and test_dev.ml does the
same against a live program. Neither is visible in IR text, which is
why neither is asserted here. *)
(let t, _ = Session.create ~file:"programs/dev-class.flan" () in
match Session.eval t "(defclass point [x y])" with
| c ->
if not (has c.Session.ir "flan_dyn_class_def") then
fail "an unchanged class definition registered nothing"
if not (has c.Session.ir "call void @flan_dyn_class_def") then
fail "an unchanged class definition registered nothing";
if not (has c.Session.ir "c\"x\\0Ay\"") then
fail "an unchanged class registered some other slot list"
| exception Loc.Error { Loc.dmsg = m; _ } ->
fail "re-evaluating an unchanged class was refused: %s" m);
(* Now the refusal that stands, which is the whole reason the relaxation