A type's fields over the wire, keyed by the qualified name that is already an identity

This commit is contained in:
Joseph Ferano 2026-09-12 10:43:20 +07:00
commit bd030c9e51
8 changed files with 394 additions and 14 deletions

View File

@ -1009,6 +1009,57 @@ state an editor has to cope with and the hardest one to arrange later. The Emacs
round, by installing a `step` that errors into a loop that calls it, fixes it while stopped, and then resumes: `C-x C-e`
answering while the program sits in the break loop is checked there against the real client, not only in OCaml.
### `layout` — a type's fields, with no program involved
```
(:op "layout" :type "sim/Cell") → (:status "ok" :type "sim/Cell"
:fields (("heat" "f32") ("next" "(Option sim/Cell)")))
→ (:status "error" :message "Missing is not a qualified name; …"
:candidates ("a/Missing" "b/Missing"))
```
The daemon can answer this with nothing running. A layout is a fact about the *build*, and the daemon owns the build —
`Tast.structs` is sitting in the session it compiled the process from. That is why the conditions buffer can name and
type a condition's fields while every one of their *values* stays refused: the shape is knowable and the contents are
not, and drawing them apart says more than drawing neither.
**The type is a name, and the name is the qualified one.** This was the open question — a class name is not an identity,
and two packages each declaring `Missing` would leave the daemon unable to pick. It turned out to need no new
machinery: `Load.qualify_decl` rewrites `Defstruct (n, …)` to `Defstruct (alias/n, …)` at import, so by the time
anything reaches `Tast.structs` the names are one flat namespace in which a collision cannot exist. The name *is* the
type id, with no table to keep in step across a reload, and the existing spelling of a type — `Types.to_string`
already prints it.
**And the break loop was already speaking it.** `Emit.struct_name_of` takes `Types.Named n` — the qualified name — and
passes it to `flan_error`; the agent holds it in `condition_name`; `break` answers it as `:condition`. So the string
the conditions buffer already had in hand resolves as `:type` by construction, and `test_dev.ml` round-trips exactly
that: the condition a stopped program reports, handed straight back, answers with that condition's fields. One caveat
worth writing down — `condition_name` is a `char[128]`, so the round trip holds for names up to 127 bytes and a longer
one is truncated and will not resolve.
**A bare name is refused, not resolved**, even when only one struct's last segment matches it. Resolving a unique
suffix would reintroduce the ambiguity the rule exists to remove, and a rule with an exception is one a client cannot
rely on. The refusal carries `:candidates`, so a person is one copy-paste from the answer and a client has its
completion list — the same shape as `package_of` refusing a directory imported under two aliases rather than picking
one. An enum is refused by *kind* (`X is an enum, not a struct`): its members are erased to `i32` before `Tast.program`
exists, which is the same fact that makes a `defenum` unreloadable. A union is refused the same way and for its own
reason — it is declared, and union *values* are milestone 6. Both are `Types.Named` at a use site, so falling through
to "no struct is named X" would say a type does not exist about one that plainly does.
**`render.ml` is not reused, and that is not a second walk.** It walks a *value* and emits the code that prints it;
this describes a *type* and emits text. What is shared is the spelling: field types go through `Types.to_string`, which
is what `defs` spells a signature with, so `(Option T)`, `[T]`, `[n T]` and `(Ptr T)` read the same in a layout, in a
signature and in the source. A field that is itself a struct shows its qualified name — which is a `:type` this op
accepts, so nesting is another request rather than a recursion, and nothing here can be made to walk forever. Prelude
structs are answered like any other, because `Render` resolves against the same list and an editor that could see a
`Split` printed but not ask about it would be the two disagreeing.
On the Emacs side `flan-cnr-layout` makes the request and `flan-cnr-show` passes the result into
`flan-cnr-state-from-reply`, which stays a function from data to data so the fixture-driven tests keep working without
a socket. A refusal is nil, not an error: the buffer already draws a section explaining why a section is empty, and
turning `C-c C-b` into an error would take away the restarts — the decision the buffer exists for — over a missing
annotation.
### Conditions — step 2: `restart-case` and `invoke-restart`
`spec-conditions.md` §3 to §6: the transfer. A handler runs where the signal was, decides, and control resumes at a

12
NEXT.md
View File

@ -521,15 +521,15 @@ Sixty mutations, nineteen left the whole suite green. The severe cluster is clos
### Asked for by the editor lanes
- **`(:op "layout" :type T)` → the struct's fields and their types.** `Tast.structs` is held by the daemon at all
times because it owns the build, and **no running program is involved** — this is the cheapest real win on the
list, and the C&R buffer already draws its result.
- **`(:op "condition")` → the stopped program's condition, rendered.** Two steps: `break_loop` currently does
`(void)condition;` and *discards the pointer*, so stash it beside `condition_name`; then the daemon builds a render
thunk aimed at that address, which is `Session.render` rooted at a `Ptr` instead of an expression.
- **One thing to get right for both:** the type must be an identity the daemon can resolve to a `Tast` type, not a
bare class name. The hook is handed a string, and two packages both declaring `Missing` leave the daemon unable to
pick a layout. A qualified name or a type id. The same wrinkle bites locals later, because DWARF also gives a name.
- **The type identity is settled, and it is the qualified name**`layout` is in, see BUILT.md. `Load` qualifies
every declaration at import, so the names in `Tast.structs` are a flat namespace where two packages' `Missing` are
`a/Missing` and `b/Missing`; a bare name is refused with the candidates rather than resolved. `condition` inherits
it for free: the string the break loop already reports *is* that name, because `Emit.struct_name_of` writes
`Types.Named` into `flan_error`. It is still open for **locals**, where DWARF gives a name and the name a debugger
reads is not qualified by anything.
- **`(:op "backtrace")` is blocked** on frame metadata — unlocked by the DWARF work, then a new agent verb. Locals are
blocked twice: DWARF for the frame layout, *and* the pointer-rooted render thunk. Restart source locations and
arity are blocked too — `flan_restart` carries `prev`, `name_id`, `name` and `namelen`, so both need a new field in

View File

@ -160,6 +160,20 @@ silently omitted.
**`C-c C-M-b`** is the same choice as a quick one-key prompt, when you already
know which restart you want and do not need the buffer.
**The condition's fields are named and typed, and have no values.** Under the
condition you get the struct it is — `:path string`, `:tried i32` — because the
daemon compiled the program and knows what that type looks like without asking
the program anything. What is beside each field is a note saying the value is
not available, not a blank: a value lives in the stopped frame, and nothing yet
hands the break loop's condition pointer back. Knowing the shape is still worth
having — it tells you whether the field you were about to blame is a field of
this condition at all.
If that section says it could not resolve the name, read it: a package
qualifies what it declares, so two packages' `Missing` are `a/Missing` and
`b/Missing`. The daemon refuses a bare name and says what it could have meant
rather than picking one.
After you choose, the program carries on from the restart. It never unwound, so
everything it had is still there.

View File

@ -70,7 +70,7 @@ from fixtures, and so `flan-dev.el' is named in one place.")
(defconst flan-cnr-unavailable
'((layout
. "which struct the class name refers to [needs a daemon op: `layout'. The field names and types are in `Tast.structs', which the daemon holds because it owns the build — no running program is involved. It is the one piece of this buffer that is a day's work on one side of the socket]")
. "the daemon could not resolve this condition's name to a struct [the `layout' op answers out of `Tast.structs' and needs a *qualified* name. A package qualifies what it declares, so two packages' `Missing' are two names and neither is `Missing'; the daemon refuses a bare one and lists what it could have meant rather than picking. A name it cannot place at all is a program built from source this daemon did not compile]")
(value
. "the break loop is handed the condition as an opaque pointer, and `break_loop' currently discards it; nothing at run time can render a value whose type it does not know [needs an agent verb: `condition', to stash and hand back the pointer, and a daemon-built render thunk aimed at it]")
(site
@ -382,20 +382,51 @@ list already says it."
"What a stopped Flan program is offering."
(setq buffer-read-only t))
(defun flan-cnr-state-from-reply (reply)
(defun flan-cnr-state-from-reply (reply &optional fields)
"The buffer's state, out of a `break' REPLY.
FIELDS is the condition's layout, if it was asked for and answered a list of
(NAME TYPE VALUE), where VALUE is nil because no running program was consulted
to get it.
Everything this cannot fill in is left nil deliberately: the renderer draws a
section saying why rather than leaving one out, and a section that is absent
cannot be told from one that is empty."
cannot be told from one that is empty.
Takes the layout rather than fetching it, so this stays a function from data to
data and the fixture-driven tests can drive it without a socket."
(list :condition (plist-get reply :condition)
:restarts (plist-get reply :restarts)
;; Both nil deliberately: `break' answers a class name and a list of
;; restart names, and nothing else exists to put here yet. The renderer
;; draws a section saying why rather than leaving one out.
:fields nil
:fields fields
;; Still nil deliberately: nothing here is attached to the stopped
;; program's frames, and the renderer says so by name.
:stack nil
:locals nil))
(defun flan-cnr-layout (type)
"The fields of TYPE, as the renderer wants them, or nil.
`layout' is answered out of the daemon's own `Tast.structs' it owns the
build so this costs no round trip to the program and works while it is
stopped, which is the only moment this buffer exists in.
TYPE is the string `break' reported as `:condition', and that string is the
qualified struct name the compiler put into the error: `Emit' takes it from
`Types.Named', the agent holds it in `condition_name', and the daemon looks it
up by the same name. So it resolves without anything here knowing what package
it came from.
A refusal is not an error here: nil means the layout section draws the reason
it is empty, which is the degradation the buffer is already built for."
(when (and type (not (string-empty-p type)))
(let ((r (funcall flan-cnr-request-function (list :op "layout" :type type))))
(when (equal (plist-get r :status) "ok")
(mapcar (lambda (f)
;; The value is nil, and that is the honest half: the shape
;; of a condition is a fact about the build, its contents
;; are a fact about the stopped frame, and only the first is
;; knowable today.
(list (nth 0 f) (nth 1 f) nil))
(plist-get r :fields))))))
;;;###autoload
(defun flan-cnr-show ()
"Show what the stopped program is offering, in a buffer.
@ -410,7 +441,8 @@ walk from a running program."
(let ((buf (get-buffer-create flan-cnr-buffer)))
(with-current-buffer buf
(unless (derived-mode-p 'flan-cnr-mode) (flan-cnr-mode))
(flan-cnr--render (flan-cnr-state-from-reply r)))
(flan-cnr--render
(flan-cnr-state-from-reply r (flan-cnr-layout (plist-get r :condition)))))
(pop-to-buffer buf)
buf)))

View File

@ -465,6 +465,50 @@
(test-flan--check "and n goes back to abort"
(get-text-property (point) 'flan-cnr-abort)))
;; The `layout' op, from this side: the condition's own name goes out as
;; `:type' and comes back as fields with no values. Two requests are made for
;; one `C-c C-b' — `break' then `layout' — so the stub records both.
(let ((asked nil))
(let ((flan-cnr-request-function
(lambda (form)
(push form asked)
(pcase (plist-get form :op)
("break" '(:status "ok" :stopped t :condition "sim/Missing"
:restarts ("retry")))
("layout" '(:status "ok" :type "sim/Missing"
:fields (("path" "string") ("tried" "i32"))))))))
(let ((text (with-current-buffer (save-window-excursion (flan-cnr-show))
(buffer-string))))
(test-flan--check "the condition's name is what `layout' is asked for"
(equal (plist-get (car (last asked)) :op) "break"))
(test-flan--check "and it is sent back verbatim, qualified as it came"
(equal (plist-get (car asked) :type) "sim/Missing"))
(test-flan--check "the fields are drawn, named and typed"
(string-match-p ":path *string" text))
;; Shape and contents are two different questions, and only the first is
;; answerable without the pointer the break loop discards.
(test-flan--check "and every value still says why it is missing"
(string-match-p ":tried *i32 *value not available" text)))))
;; A layout the daemon refuses — a bare name it will not guess between two
;; packages, or a type it cannot place — leaves the section drawing its reason
;; rather than turning `C-c C-b' into an error. The restarts are the decision
;; in front of you and they are still there.
(let ((flan-cnr-request-function
(lambda (form)
(pcase (plist-get form :op)
("break" '(:status "ok" :stopped t :condition "Missing"
:restarts ("retry")))
("layout" '(:status "error" :message "Missing is not a qualified name"
:candidates ("a/Missing" "b/Missing")))))))
(let ((text (with-current-buffer (save-window-excursion (flan-cnr-show))
(buffer-string))))
(test-flan--check "a refused layout is a section that says so"
(string-match-p "not available.*needs a \\*qualified\\* name" text))
(test-flan--check "and the restarts are drawn anyway"
(string-match-p "\\[retry\\]" text))))
(let ((flan-cnr-request-function
(lambda (_) '(:status "ok" :restarts nil :stopped nil))))
(test-flan--check "a running program is refused, by name"

View File

@ -105,6 +105,17 @@ is written instead — the real `message' call the real command makes."
(test-flan--check "describe lists the program's globals"
(member "ticks" (plist-get r :globals))))
;; `layout' against the real daemon, through `flan-cnr-layout', which is how
;; the conditions buffer gets it. The reply is the first one with a list of
;; lists in it, so `read' on this side is doing something it does nowhere
;; else — and the daemon answers it without asking the program anything.
(require 'flan-cnr)
(let ((flan-cnr-request-function #'flan-dev--request))
(test-flan--check "a struct's fields come back named and typed"
(equal (flan-cnr-layout "Missing") '(("id" "i32" nil))))
(test-flan--check "and a type the daemon cannot place is nil, not an error"
(null (flan-cnr-layout "Nonesuch"))))
;; C-c C-c on the form at point: put point inside `step' and send it. The
;; text comes from the buffer, so this exercises `beginning-of-defun' against
;; Flan's own syntax table as much as it does the wire.

View File

@ -478,6 +478,92 @@ let defs t =
in
ok [ ":defs " ^ Wire.list (fns @ globals @ externs) ]
(* [(:op "layout" :type T)] — a struct's fields and their types.
The daemon can answer this with no running program at all: [Tast.structs] is
what it built the process from, and a layout is a fact about the build. That
is why it is the one thing the conditions buffer can fill in while the
condition's *values* stay refused.
**The type is a name, and the name is the qualified one.** [Load] qualifies
every declaration as it imports it [Defstruct (qualify alias n, ...)] so
the names in [Tast.structs] are a flat namespace in which two packages each
declaring [Missing] are [a/Missing] and [b/Missing] and no collision is
possible. That makes the name a type identity rather than a class name, with
no id table to keep in step, and it is the same string on both ends of the
wire already: [Emit.struct_name_of] puts [Types.Named n] into [flan_error],
the agent holds it in [condition_name], and [break] answers it as
[:condition]. Handing that string straight back as [:type] therefore
resolves, by construction.
A bare name is **refused, not resolved**, even when only one struct's last
segment matches: resolving it is exactly the ambiguity that made this op
need a rule, and a rule with an exception cannot be relied on by a client.
The refusal lists the qualified names it could have meant, so a person who
typed [Missing] is one copy-paste from the answer and a client can offer
them as completions.
Field types are spelled by [Types.to_string], which is what [defs] spells a
signature with so [(Option T)], [[T]], [[n T]] and [(Ptr T)] read here
exactly as they read in a signature and in the source. A field that is
itself a struct shows its qualified name, which is a [:type] this op
accepts: nesting is another request rather than a second walk, and nothing
here can recurse forever. [Render] is the other walk over a type and is not
reused, because it walks a *value* and emits code that prints it; this emits
text about the type and never touches the program. *)
let layout t ~ty =
let structs = t.session.Session.program.Tast.structs in
match
List.find_opt (fun (s : Tast.structure) -> String.equal s.Tast.sname ty)
structs
with
| Some s ->
ok
[ ":type " ^ Wire.quote s.Tast.sname;
":fields "
^ Wire.list
(List.map
(fun (f : Tast.field) ->
Wire.list
[ Wire.quote f.Tast.fname;
Wire.quote (Types.to_string f.Tast.fty) ])
s.Tast.fields) ]
| None ->
(* Two types the checker knows and this op cannot describe. An enum's
members are erased to i32 before [Tast.program] exists, which is the
same fact that makes a defenum unreloadable; a union is declared and
has no values yet. Either way, saying which kind it is beats "no such
type" for a name that plainly exists. *)
if Hashtbl.mem t.session.Session.env.Check.enums ty then
error (ty ^ " is an enum, not a struct; its members are erased to i32")
else if
List.exists (fun (u : Tast.union) -> String.equal u.Tast.uname ty)
t.session.Session.program.Tast.unions
then
error (ty ^ " is a union, not a struct; union values are milestone 6")
else
let suffix = "/" ^ ty in
let candidates =
List.filter_map
(fun (s : Tast.structure) ->
let n = s.Tast.sname in
let k = String.length n - String.length suffix in
if k >= 0 && String.equal (String.sub n k (String.length suffix)) suffix
then Some n else None)
structs
in
(match candidates with
| [] -> error ("no struct is named " ^ ty)
| cs ->
(* Resolved on the client's side, deliberately: two packages can each
declare [Missing], and picking one of them here would answer a
layout for a type the asker did not mean. *)
"(:status \"error\" :message "
^ Wire.quote
(ty ^ " is not a qualified name; a package qualifies its \
declarations, so say which one")
^ " :candidates " ^ Wire.strings cs ^ ")")
(* What is on offer where the program stopped. [:stopped] and [:condition] are
not here: the annotation puts them on this reply as it puts them on every
other, so an editor reads the same two keys whatever it asked. What this op
@ -896,6 +982,10 @@ let handle t req =
| Some "describe" -> describe t
| Some "defs" -> defs t
| Some "break" -> break t
| Some "layout" ->
(match Wire.string_field req "type" with
| Some ty -> layout t ~ty
| None -> error "layout needs :type")
| Some "restart" ->
(match Wire.string_field req "name" with
| Some name -> choose t ~name

View File

@ -126,6 +126,123 @@ let () =
| None -> fail "defs did not mention an imported extern")
| _ -> fail "defs did not answer with a list");
(* [layout]: a struct's fields and their types, out of [Tast.structs],
with no running program involved at all. *)
let strings_of f =
match f with
| Some { Form.v = Form.List xs; _ } ->
List.filter_map
(fun (x : Form.t) ->
match x.Form.v with Form.Str s -> Some s | _ -> None)
xs
| _ -> []
in
let fields r =
match Wire.field r "fields" with
| Some { Form.v = Form.List fs; _ } ->
List.filter_map
(fun (f : Form.t) ->
match f.Form.v with
| Form.List
[ { Form.v = Form.Str n; _ }; { Form.v = Form.Str t; _ } ] ->
Some (n ^ " " ^ t)
| _ -> None)
fs
| _ -> []
in
let r = request c "(:op \"layout\" :type \"Missing\")" in
if status r <> "ok" then
fail "layout Missing: %s"
(Option.value ~default:"" (Wire.string_field r "message"))
else begin
if Wire.string_field r "type" <> Some "Missing" then
fail "layout answered a different type than it was asked for";
if fields r <> [ "id i32" ] then
fail "Missing's fields: %s" (String.concat ", " (fields r))
end;
(* The prelude's structs are in [Tast.structs] because [Check.program]
prepends the prelude, and they are answered for the same reason the
REPL's renderer resolves against the same list: an editor that could
see a type printed and not ask about it would be the two disagreeing.
[Rune] also pins the spelling the types read exactly as [defs]
spells a signature, because both go through [Types.to_string]. *)
let r = request c "(:op \"layout\" :type \"Split\")" in
if fields r <> [ "rest [u8]"; "sep u8"; "more bool" ] then
fail "Split's fields: %s" (String.concat ", " (fields r));
let r = request c "(:op \"layout\" :type \"Nonesuch\")" in
if status r <> "error" then fail "a type that does not exist got a layout";
(* A name that plainly exists and is not a struct is refused by *kind*.
Both of these are types the checker knows and this op cannot
describe, and "no struct is named X" would read as "X does not
exist". *)
let refusal r =
Option.value ~default:(status r) (Wire.string_field r "message")
in
let contains hay needle =
let n = String.length needle in
let rec go i =
i + n <= String.length hay
&& (String.equal (String.sub hay i n) needle || go (i + 1))
in
go 0
in
let r =
request c
"(:op \"eval\" :code \"(defenum Colour [red 0 green 1])\" :file \"/tmp/buf.flan\")"
in
if status r <> "ok" then fail "a new enum: %s" (refusal r)
else begin
let r = request c "(:op \"layout\" :type \"Colour\")" in
if status r <> "error" then fail "an enum answered a struct layout"
else if not (contains (refusal r) "is an enum") then
fail "an enum is refused as: %s" (refusal r)
end;
let r =
request c
"(:op \"eval\" :code \"(defunion Shape [(Circle [r f32])])\" :file \"/tmp/buf.flan\")"
in
if status r <> "ok" then fail "a new union: %s" (refusal r)
else begin
let r = request c "(:op \"layout\" :type \"Shape\")" in
if status r <> "error" then fail "a union answered a struct layout"
else if not (contains (refusal r) "is a union") then
fail "a union is refused as: %s" (refusal r)
end;
(* The identity rule, and the case NEXT.md named: a second [Blob] typed
into a package is [agent/Blob], the qualified name resolves, and the
bare one is refused with the names it could have meant rather than
resolved to either. The daemon derives the package from the path, so
the file this is sent with is the one the import qualified. *)
let agent_file =
let p = "../vendor/agent/agent.flan" in
try Unix.realpath p with Unix.Unix_error _ -> p
in
let r =
request c
(Printf.sprintf
"(:op \"eval\" :code \"(defstruct Blob [id i32])\" :file %s)"
(Wire.quote agent_file))
in
if status r <> "ok" then
fail "a struct typed into a package: %s"
(Option.value ~default:"" (Wire.string_field r "message"))
else begin
let r = request c "(:op \"layout\" :type \"agent/Blob\")" in
if status r <> "ok" || fields r <> [ "id i32" ] then
fail "a qualified name did not resolve: %s"
(Option.value ~default:(status r) (Wire.string_field r "message"));
let r = request c "(:op \"layout\" :type \"Blob\")" in
if status r <> "error" then
fail "a bare package-qualified name was resolved rather than refused"
else if strings_of (Wire.field r "candidates") <> [ "agent/Blob" ] then
fail "the refusal did not name what it could have meant: %s"
(String.concat ", " (strings_of (Wire.field r "candidates")))
end;
(* A form that does not check comes back as an error with a location,
and must not disturb the session. *)
let r = request c "(:op \"eval\" :code \"(defn step [] i64 nonsense)\" :file \"/tmp/buf.flan\")" in
@ -257,6 +374,27 @@ let () =
fail "the condition is reported as %S, wanted %S" (condition !last)
"Missing";
(* The identity claim, round-tripped: the string [break] reports is the
qualified struct name [Emit] put into [flan_error] and the agent
held in [condition_name], so handing it straight back as [:type]
has to resolve. This is the conditions buffer's whole path it has
the condition's name and nothing else, and asks for the fields with
it. A layout that only answered a name typed by hand would leave
that path guessing. *)
let r =
ask
(Printf.sprintf "(:op \"layout\" :type %s)"
(Wire.quote (condition !last)))
in
if status r <> "ok" then
fail "the condition's own name did not resolve to a layout: %s"
(Option.value ~default:"" (Wire.string_field r "message"))
else
(match Wire.field r "fields" with
| Some { Form.v = Form.List [ { Form.v = Form.List
[ { Form.v = Form.Str "id"; _ }; { Form.v = Form.Str "i32"; _ } ]; _ } ]; _ } -> ()
| _ -> fail "the stopped program's condition has the wrong layout");
(* What is on offer, innermost first. [break] carries the names and
nothing else the state is the annotation's business, so there is
one place in the daemon that decides it. *)