9 Commits

Author SHA1 Message Date
a9c0c3e9bf The listing buffers are malloc'd, and a followed pointer says why it stops
Three follow-ups to the registry reader.

The agent's "reg types" handler held its four row buffers as statics. The lock
argument for that was right — request_lock is held across the whole of
handle_line from both entry points — but the BSS was not: 8KB in every build
this package is linked into, including a release build of a game that imports
the agent and never writes a row. That is flan_dev.c's own argument against a
fixed table at a thirty-second of the size. Malloc'd and freed per request,
like the watch handler beside it.

A followed pointer parses as an opaque node, so the inspector refused it with
"no structure for this type" — false and unhelpful at once. It has structure,
it is drawn, and the reason you cannot step in is that the step would start
from the pointee. Said that way instead.

And the three new commands have elisp tests: the address root's wire, that a
path is refused rather than dropped, the listing's rows and totals, the
overflow warning, and a build with no registry refusing rather than showing an
empty table that reads like a program holding nothing.
2026-09-13 15:33:25 +07:00
fe858811cb An address answers with a type, and a killed program is asked rather than hooked
The allocation registry had a recording side and half a reader. This is the
rest of the reader: point at any heap address, a breakdown by type, what is
still held, and the test that stops dev-ptr.flan's header from being read by
hand.

The recorded name, back to a type. The table records a string and has to —
the note is built where the concrete type exists and what crosses into the
runtime is bytes. What closes it is that the string is Types.to_string, which
is the source spelling, so the round trip is the language's own reader,
Parse.texpr and Check.resolve. No table of spellings is written down, so
nothing can fall behind Types.to_string, and a name that is not a type —
"pool slots" — is refused with the name quoted rather than defaulted.

The address root renders a (Ptr T) and not the pointee, which puts it through
render.ml's pointer arm: permission is asked in one place in the compiler, and
an address root and a slot root reach the same two answers by the same code.
Flan has no integer-to-pointer cast, so flan_dev_reg_addr is an extern beside
flan_agent_frame_slot, for the same reason.

One walk and two questions: a leak report is a breakdown with the dead left
out, so flan_dev_reg_by_type is one function and the agent formats it.

"At exit" is not a hook. A program killed by a signal runs no handler, which
is how a game under the editor ends, so (:op "leaks") is the authoritative
reader and can be asked at any moment including the one before the kill. The
atexit hook is for the program that returns from main, is registered from
inside flan_dev_reg_enable rather than by a file-scope destructor so that a
release build does not grow a third not-free place, and is off unless
FLAN_DEV_LEAKS is set because the acceptance table reads stderr.

The memcheck half of item 6 is deliberately not here.
2026-09-13 15:27:31 +07:00
306fc88094 A union's fields have no accessor, so RET refuses them where none exists
The capability lists were written before the code held the line they claim.
Under an expression root, RET on a field of a union built `(.at s)' and sent
it, and the checker refused it — "a union's fields belong to a case ... they
are reached by (match ...)". A refusal from the far end of a socket is exactly
what this buffer's own comment says not to do: every refusal is by name, here,
with the reason, because RET working on some lines and erroring on others
teaches nothing about the language.

It is a refusal of the *parent* and not of the value at point, which is why it
is not in `flan-inspect-refusal': a struct field that merely holds a union is
an ordinary accessor and has to stay enterable. It is a field of the union
itself that cannot be written. The two cases are one test each.

The slot root steps into it by offset and is unaffected, which is the
difference the manual now claims and the tests now show.

`lib/dev.ml' cited DISCUSS.md item 1 as a hole; item 1 is the answer now, so
it cites BUILT.md instead. And the item 1 stub is two sentences and a pointer
— everything else in it is in BUILT.md verbatim, and DISCUSS.md's own header
says nothing in it is a decision.
2026-09-12 20:40:02 +07:00
4d29e52dbe The inspector's stack carries a root, so `i' names the frame it is looking at
`i' on a local sent the local's *name* to be evaluated, and an expression is
evaluated where the evaluator stands. That is the right frame only when the
frame is the innermost one; on any other it may resolve to a global, to
another binding of the same name, or to nothing, with the locals listing right
above it showing the frame's own storage and nothing saying the two disagree.

The daemon verb for the fix landed already. What was missing was the state
layer under it: `flan-inspect--expr' held a bare expression, so there was
nowhere to put a frame. It is `flan-inspect--root' and `flan-inspect--path'
now — `(:expr E)' or `(:slot FRAME SLOT NAME)', plus the steps walked from it
— and a stack entry is `(ROOT PATH . POINT)'. RET appends a step, `l' restores
a pair it pushed. Every step is still a fresh request, so the view is never
stale.

`l' cannot cross between the two roots, and that is structural rather than a
rule someone has to keep: RET only ever extends the path under the root the
buffer already has, and `flan-inspect' and `flan-inspect-slot' both start with
an empty stack, so a mixed stack cannot be built at all. It stays true if a
third rooting mode is added.

The break buffer hands over the frame and the slot *index*, which is the
fourth element `locals' now puts on each line. A name does not identify a
slot: two slots of one frame can share one, and a refused slot is not in the
listing, so its position is not an identifier either. A global still goes in
by name, because a global's name really is an expression that means the same
thing wherever it is evaluated — the loaded thunk binds to the program's own
storage through the dynamic linker.

Two smaller things the wire needed. A field step carries the type it was read
out of, because a union's payload is at an offset that depends on the case and
only the renderer knows which case the value is in — so `Union.case.field',
which is the head the renderer wrote with the field appended. And an empty
path is sent by omission: Emacs prints an empty list as `nil', which is a
symbol on the wire, so the daemon now reads that as no path rather than
refusing it as a step.
2026-09-12 20:31:18 +07:00
03d4460d72 WIP: the inspector's address root, half wired on the Emacs side
OCaml side is done and builds. Emacs side is mid-edit and INCOMPLETE — see
the handoff below. `dune build --root . @check` is green. `dune test --root .`
was NOT run. The .el files were not byte-compiled and flan-inspect.el will not
work as it stands: the state layer still speaks the old single-expression
shape while the helpers above it have been rewritten for roots and paths.

WHAT WORKS (daemon, lib/, in the parent commit and unchanged here)

- `(:op "inspect" :frame N :slot I :path (...))` renders one value rooted at a
  stopped frame's slot address. `Session.render_slot` is `render_locals` with a
  path applied to the root before the walk and one line out instead of one per
  slot; no second walk was written and no backend change was needed.
- A path step is a string for a struct field, an integer for an array or slice
  element, and the symbol `some` for an option's payload. A union case field is
  spelled `Union.case.field`, because the payload's offset depends on the case
  and only the renderer knows which case the value is in.
- Every step that does not fit the type in hand is refused by name with the
  reason: a field the type does not have, an index past a fixed array's end,
  `some` on something that is not an option, a union field without its case.
- The frame's identity IS checked, and not by a second copy: `Dev.stopped_frame`
  is one function now and `locals` and `inspect` both go through it — alive,
  stopped, frame exists, the frame is the program's and not a thunk's, the body
  is one this session holds, the slot count matches, and `Emit.slot_fingerprint`
  matches. `inspect` additionally refuses an unbound slot, for the listing's
  reason: a null address would fault on the stopped game thread.
- The slot travels by INDEX, not by name. Two slots can share a name
  (`fresh_slot` only allocates) and a refused slot is not in the listing, so
  neither the name nor the position identifies one. `locals` now puts the slot
  index as a fourth element on each `:locals` entry.
- `Dev.run_render_thunk` is one function; `locals`, `globals` and `inspect`
  share the build/deliver/wait/read tail.
- `layout`'s "union values are milestone 6" is corrected.

WHAT IS HALF-BUILT, AND EXACTLY WHERE IT STOPS

`emacs/flan-inspect.el`. Done: the header comment explaining the two roots;
`flan-inspect-step-expr` taking a 3-element `:field` step; `flan-inspect-wire-step`;
`flan-inspect--root-label`; `flan-inspect-refusal` taking an optional ROOT and
allowing an option's payload under a `:slot` root.

NOT done, and this is the whole of what is left:

1. `flan-inspect--expr` / `flan-inspect--stack` still hold a bare expression.
   They must become `flan-inspect--root` (`(:expr EXPR)` or
   `(:slot FRAME SLOT NAME)`) plus `flan-inspect--path`, with stack entries of
   `(ROOT PATH . POINT)`.
2. `flan-inspect--value` must branch on the root: `eval-expr` with
   `(flan-inspect--root-label root path)` for `:expr`; for `:slot`, send
   `(:op "inspect" :frame F :slot S :path P)` with P built by
   `flan-inspect-wire-step` over the path, and take `:value` from the reply.
3. `flan-inspect--show`, `-into`, `-pop`, `-refresh` rewired to (ROOT PATH).
   `-into` must build a `:some` step when the node's kind is `option`, and put
   the parent node's `:type` as the third element of a `:field` step.
4. New entry point `flan-inspect-slot (frame slot name)`, kept separate from
   `flan-inspect (expr)` — `emacs/flan-mode.el` autoloads and binds the latter
   and that file is out of this lane.
5. `emacs/flan-cnr.el`: the `flan-cnr-inspect` text property must carry
   `(:slot FRAME SLOT NAME)` on a local line — the slot index is `(nth 3 l)`
   now — and `(:expr NAME)` on a global line, with `flan-cnr-inspect`
   dispatching to the right entry point.
6. `emacs/test-flan-cider.el`: the fixture at "`i' on a local inspects it by
   name" asserts the old behaviour and must be rewritten; the locals fixtures
   need a fourth element.
7. `test/test_dev.ml`: no coverage of the new op yet. The discriminating test
   to write first is a stack whose OUTER frame has a local whose name is also a
   global with a different value, asserting `inspect` answers the frame's value.
   A new `test/programs/dev-inspect.flan` is picked up by the existing glob.
8. `BUILT.md`, `emacs/MANUAL.md`, and striking the item from `NEXT.md`'s
   "Decided in discussion" and `DISCUSS.md` item 1 — none done.

THE THREE ANSWERS THE TASK ASKED FOR

- Navigation in the new mode: the daemon supports it fully — RET extends the
  path, `l` shortens it, and both are a fresh request, so the view is never
  stale. The Emacs half of that is item 3 above and is not wired.
- `l` does not cross between the modes, and that is structural rather than a
  rule: a stack entry carries its own root, RET only ever extends the path
  under the root it already has, and every new root starts with an empty
  stack. A mixed stack cannot be constructed, so the question does not arise —
  and it stays answered if a third rooting mode is added.
- What each mode cannot do that the other can. The expression root works on a
  RUNNING program and roots at anything you can write, a call included; it
  cannot name a frame, so it is the bug. The slot root names one frame and one
  slot and is exact; it reaches an option's payload and a union case's fields,
  which have offsets but no accessor in the surface language; it needs a
  stopped program, it is refused when the frame's body was redefined since it
  was entered, and it cannot root at an expression at all.
2026-09-12 20:21:14 +07:00
0f3b633449 The printed struct moves to the dot, with the reader that parses it
render.ml's output and emacs/flan-inspect.el's parser are the two ends of one
wire format, which is why the printer was left on the colon when the rest of
the corpus moved: shifting it alone would have broken inspection in the dev
loop without breaking a test that said so. They move together here.

The field list in the inspector is labelled with the dot too, which is the
spelling flan-inspect-step-expr already used to build `(.x b)' — the label and
the expression it stands for now read the same.

One case needed a guard the colon never did: `...' also begins with a dot and
is the renderer saying it stopped, not a field called `..'. A field name never
starts with a second dot, so one character of lookahead separates them.

The colon is not gone from the rendered grammar. An enum member is `:green' and
is a *value*, so the two are now told apart by the character alone, which is
the only thing that distinguishes them.

Also font lock, handed over with the same change: `:name' was the rule that
drew field labels, and with the colon belonging to keywords every label in the
corpus was left unfontified. `.name' is drawn as a constant, in both the places
it appears — the label in `{.x 1.0}' and the accessor in `(.x v)', which are
the same name.
2026-09-12 16:19:55 +07:00
a0610cecd5 A number in the inspector reads in the two bases it was written in
Decimal is what the value is and stays first; hex and binary go beside it. It
is the wrong base for about half the numbers anyone opens this buffer for — a
colour is 0x303030FF, a gesture is an OR of flags, a mask is read a bit at a
time — and reading those out of a decimal is arithmetic done by hand.

In two places: under the header of a value opened on its own, and on each
numeric row of a field list. The second is the one that matters, because a leaf
cannot be stepped into, so the field list is the only place most numbers are
ever seen.

Nothing is asked of the program. It is arithmetic on text the renderer already
wrote, so it works on a stopped program and costs no round trip. Binary is
grouped in nibbles because a mask is read in nibbles. A negative is shown as
the 64-bit two's complement it is in memory and says the width out loud: the
rendered value carries none, and every Flan integer comes back through i64.

A float is left alone rather than answered wrongly — its bits are an IEEE
layout, reinterpreting them is a different question, and the rendered text does
not carry the width to answer it.

The pointer half of this is not done and the refusal now says why. Render.render
writes the bare word <ptr> for every pointer on purpose: it is the same renderer
print uses, an address is not stable across runs, and test_acceptance pins the
current text for that reason. Showing one is a decision about the language's
printer, not about this buffer.
2026-09-12 16:19:39 +07:00
b661cd788b The shape of a condition is not the same refusal as its contents
Saying "the fields are not available" was under-claiming. A condition is a
struct, and Tast.structs holds every struct's field names and types in the
daemon, which owns the build — no running program is involved in answering
what a Missing is made of. Only the values need the pointer the break loop
was handed, and break_loop currently discards that pointer, so they are two
different gaps with two different fixes on two different sides of the socket.
The buffer now draws a field named and typed with its value refused, which is
what tells you whether the field you were about to blame is even a field of
this condition.

Navigation backwards was not the same list walked the other way. Forward
wrapped and backward stopped, and from the middle of a line the two disagreed
about where a field begins — a field line carries the property on all of it,
so a property-change walk from mid-line finds the end of the field you are
already in. Both now go through one list of field starts. The mid-line case
went red on the first try and the expectation was the thing that was wrong:
landing on the current field's start is what CIDER does and is the less
surprising of the two.
2026-09-12 04:14:47 +07:00
94b78a1a83 A value you can walk into, because the walk has a bound
C-x C-e renders once and stops at depth 4 and span 8. A field past either
comes back as "..." and nothing recovers it from the echo area. Re-rooting
the walk at that field renders it from depth 0, so the bound moves with you
— that, and not tidiness, is why an inspector is worth having beside the
expression evaluator.

CIDER keeps its inspector stack on the server because a JVM value can be
retained. Nothing here can: a Flan value has no header and the thunk that
rendered it is dlclosed the moment it returns. So the stack is a stack of
expressions on this side, and going into a field means sending a different
one — (.pos b) where the last one was b. It costs a re-evaluation per step,
which buys a view that is never stale and is why refresh is a key someone
presses rather than a timer.

Driven from fixtures, which is also the only way the cases a live program
will not hold still for get tested at all.
2026-09-12 04:08:13 +07:00