Merge branch 'worktree-agent-a8eecdace6e85881f' into dev-loop
This commit is contained in:
commit
06810e3906
13
BUILT.md
13
BUILT.md
@ -4872,6 +4872,19 @@ invariant the whole of `flan_dev.c`'s watch section rests on. The cost is that a
|
|||||||
next runs rather than at the instant of the reset — and for a frame loop that is the only moment it could sensibly
|
next runs rather than at the instant of the reset — and for a frame loop that is the only moment it could sensibly
|
||||||
begin. The test waits for it rather than reading once, and says why.
|
begin. The test waits for it rather than reading once, and says why.
|
||||||
|
|
||||||
|
**The lazy clear stays lazy, and a stopped program is the case that decides it.** The obvious tidy-up is to make the
|
||||||
|
reader epoch-aware — compare the slot's epoch with the global one under the seqlock and report an untouched slot as
|
||||||
|
empty — which would make a reset visible in the very next tick instead of in the tick after the program's next
|
||||||
|
sample. It is the wrong trade, because **a stopped program does not sample**. An epoch-aware reader would blank the
|
||||||
|
watch for as long as the program sat in a break loop, and reading the numbers from the moment you stopped is the
|
||||||
|
entire point of stopping. The lazy clear gives exactly the right answer there. What was actually wrong was narrower
|
||||||
|
and lives in the editor: `flan-watch--tick` was sending `:reset t` five times a second at a program that could not
|
||||||
|
answer it. So the reset is now guarded on `flan-dev--stopped` — the read still goes out every tick, only the reset
|
||||||
|
field drops — and the runtime is untouched. That keeps the policy where the rest of this section already put it:
|
||||||
|
"since you last looked" is the editor's idea, not the table's. `watch_render_num`'s unreachable `n=0` arm is deleted
|
||||||
|
rather than commented, since the only way to reach it is the epoch check that was just rejected, and dead code is an
|
||||||
|
invitation to add one.
|
||||||
|
|
||||||
**An `i64` accumulates as a double**, so a magnitude past 2^53 loses precision in the sum and in the ends of the
|
**An `i64` accumulates as a double**, so a magnitude past 2^53 loses precision in the sum and in the ends of the
|
||||||
range. Recorded rather than designed around: a count, a coordinate and a tile index are what this is pointed at, and
|
range. Recorded rather than designed around: a count, a coordinate and a tile index are what this is pointed at, and
|
||||||
a second integer accumulator for a case nobody has would be two code paths for one tool. `watch-num-i64` and
|
a second integer accumulator for a case nobody has would be two code paths for one tool. `watch-num-i64` and
|
||||||
|
|||||||
252
HANDOFF-f3.md
252
HANDOFF-f3.md
@ -1,252 +0,0 @@
|
|||||||
# Handoff — a watch for a running program (`PORTING.md` Tier 1, item 5)
|
|
||||||
|
|
||||||
Written because the session was wound down for budget. **The work is finished and `dune test` is green**, run twice.
|
|
||||||
This file exists so the reasoning is not re-derived, and so the two things I would have done next are named.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The finding that reframes the task
|
|
||||||
|
|
||||||
**Most of item 5 was already built before this session.** The brief reads as though a watch had to be built from
|
|
||||||
nothing; it did not. What existed at `344e571`:
|
|
||||||
|
|
||||||
- `runtime/flan_dev.c` — the watch table: 64 slots, a name and rendered text per slot, a per-slot seqlock, an
|
|
||||||
`on`/`off` flag so a watch call costs a load and a not-taken branch when nobody is looking, and four scalar entry
|
|
||||||
points (`flan_dev_watch_i64`/`_u64`/`_f64`/`_str`).
|
|
||||||
- `vendor/agent/flan_agent.c` — `watch`, `watch on`, `watch off` on the agent socket.
|
|
||||||
- `lib/dev.ml` — `watch_read`, `watch_enable`, and the `:op "watch"` / `:op "watch-enable"` arms.
|
|
||||||
- `emacs/flan-watch.el` — the watch buffer *and* the inline ghost text, both fed from one reply.
|
|
||||||
- `test/test_dev.ml` — a block driving a real daemon and a real running program.
|
|
||||||
|
|
||||||
That is `watch.clj`'s `spy`, end to end. **What was missing was `spy-num`** — the numeric accumulator for hot loops,
|
|
||||||
which is precisely the part `PORTING.md` calls "least obvious and most valuable". That is what this session built.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The accumulator decision
|
|
||||||
|
|
||||||
This is the expensive part to re-derive, so it is stated in full. The long-form version is in `BUILT.md`, "A hot loop
|
|
||||||
keeps five numbers, and the window is the editor's"; `runtime/flan_dev.c` carries it at the code.
|
|
||||||
|
|
||||||
### What a slot keeps
|
|
||||||
|
|
||||||
**Count, min, max, last, mean.** Five numbers per label.
|
|
||||||
|
|
||||||
The scalar watch keeps one value per name. Sampled from a hot inner loop that is nearly useless — you see whichever
|
|
||||||
of the 91,200 cells happened to run last. `watch.clj`'s own docstring says so and is why `spy-num` exists there.
|
|
||||||
|
|
||||||
Each of the five answers a question you can ask *without building a query*:
|
|
||||||
|
|
||||||
- **`n`** — how many times the expression actually ran. This is the first thing that is wrong when a loop is wrong. A
|
|
||||||
count that tracks the frame counter rather than the cell count is a loop that is not running.
|
|
||||||
- **`min` / `max`** — the range. This is the thing a single sample can never show you, and it is what you are looking
|
|
||||||
for when you suspect an index or a velocity is leaving the region it should stay in.
|
|
||||||
- **`last`** — the one sample. Kept because it is what the scalar watch would have given you, and losing it on the
|
|
||||||
way to something richer would be a regression.
|
|
||||||
- **`mean`** — carried as a running `sum` and divided at read time. A mean accumulated as a mean drifts; a sum does
|
|
||||||
not.
|
|
||||||
|
|
||||||
### What it deliberately does not keep
|
|
||||||
|
|
||||||
**A ring, or a history.** A small ring of the last N samples was the serious alternative. It loses on the only ground
|
|
||||||
that matters here: N samples out of 91,200 is a sample of the *tail* of the loop, not of the loop. It answers "what
|
|
||||||
did the last few cells do" when the question is "what did the cells do". Past five numbers, every richer answer is a
|
|
||||||
UI for building a query — and a query builder is the one thing this whole design exists not to be. The ghost text
|
|
||||||
section of `BUILT.md` already settled the same question the same way for the scalar case ("a watch inside a loop
|
|
||||||
shows the last value written... every better answer is a UI for building a query. Settled, not open.").
|
|
||||||
|
|
||||||
### The write path does no formatting
|
|
||||||
|
|
||||||
This is the feature, not an optimisation. An `snprintf` per sample at thousands of samples a frame is a HUD that
|
|
||||||
costs more than the game. A sample is: one relaxed load of the epoch, five compares and stores, and the slot's
|
|
||||||
seqlock. The **reader** — the agent's listener thread, once per editor tick — turns the five numbers into text.
|
|
||||||
`watch.clj` reaches the same place with a `double-array` per label and a `render` function Emacs calls.
|
|
||||||
|
|
||||||
This is also why the slot grew a `num` flag rather than getting a second table: the read path already copies under a
|
|
||||||
seqlock, so it copies five doubles instead of 192 bytes and renders them out of locals *after* the counter check.
|
|
||||||
|
|
||||||
### The window is since the last reset — a deliberate divergence from `watch.clj`
|
|
||||||
|
|
||||||
`watch.clj` is cumulative until `reset-spies!` is called by hand. **This is not**, and the disagreement is on purpose.
|
|
||||||
|
|
||||||
Cumulative is the wrong default for a frame loop. A `min` and a `max` over a whole session reach the session's
|
|
||||||
extremes within a few seconds of play and then never move again — so the two most useful of the five numbers go dead
|
|
||||||
*exactly* when you start interacting with the thing you are debugging. This tool exists to show you a number while
|
|
||||||
you drag the mouse. So `flan-watch--tick` sends `:reset t` beside its read, and what you see is "since you last
|
|
||||||
looked": a fifth of a second, about a dozen frames, which keeps the range tracking the present.
|
|
||||||
|
|
||||||
A caller who wants cumulative numbers gets them by not resetting. The setting lives in the editor, not the runtime.
|
|
||||||
|
|
||||||
### Reset is its own message, never a side effect of reading
|
|
||||||
|
|
||||||
A destructive read was the tempting shape and is wrong: it makes *looking* change what is there. Anything that polls
|
|
||||||
— a test's `await`, a second editor, a person reading twice — would silently shorten the window and come back with a
|
|
||||||
count that is noise. So `watch reset` is its own agent command, `:reset t` is a field on the read op, and `dev.ml`
|
|
||||||
sends the reset *after* the read so a tick reports the window it just closed.
|
|
||||||
|
|
||||||
This was settled before the test was written rather than after, which is the only reason the test can assert anything
|
|
||||||
about `n` at all.
|
|
||||||
|
|
||||||
### The reader never writes the table
|
|
||||||
|
|
||||||
Reset bumps one global epoch counter and touches no slot. A slot clears itself on its **next sample**, when it
|
|
||||||
notices the epoch moved, and does so *inside* its own odd-generation window so a reader can never catch a
|
|
||||||
half-cleared slot. The game thread stays the only writer of the table — the invariant the whole watch section of
|
|
||||||
`flan_dev.c` rests on.
|
|
||||||
|
|
||||||
The cost: a new window begins when the program next runs, not at the instant of the reset. For a frame loop that is
|
|
||||||
the only moment it could sensibly begin. **This bit me in the test** (see "What did not work" below) and the test now
|
|
||||||
waits for it and says why.
|
|
||||||
|
|
||||||
### `i64` accumulates as a double
|
|
||||||
|
|
||||||
A magnitude past 2^53 loses precision in the sum and in the ends of the range. Recorded rather than designed around: a
|
|
||||||
count, a coordinate and a tile index are what this is pointed at, and a second integer accumulator for a case nobody
|
|
||||||
has would be two code paths for one tool. `watch-num-i64` and `watch-num-f64` are two entry points only so a program
|
|
||||||
need not cast at the call site.
|
|
||||||
|
|
||||||
### A whole number prints as one
|
|
||||||
|
|
||||||
`watch.clj`'s `fmt-num` does this and the reason survives the port: a watch on an array index that reads `66.0000`
|
|
||||||
sends you looking for a rounding bug that is not there.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What was built, file by file
|
|
||||||
|
|
||||||
All of it is **working** — built, run against a real daemon and a real running program, and covered by the test.
|
|
||||||
Nothing is stubbed and nothing is half-written.
|
|
||||||
|
|
||||||
| File | Change | State |
|
|
||||||
|---|---|---|
|
|
||||||
| `runtime/flan_dev.c` | `watch_slot` grew `num`, `epoch` and five doubles; `watch_begin` clears `num`; new section "A number sampled thousands of times a frame" with `watch_record`, `flan_dev_watch_reset`, `flan_dev_watch_num_i64`, `flan_dev_watch_num_f64`, `watch_num_str`, `watch_render_num`; `flan_dev_watch_read` renders a num slot instead of copying its (unused) text | working |
|
|
||||||
| `vendor/agent/flan_agent.c` | `flan_dev_watch_reset` declaration; a `watch reset` command beside `watch on`/`watch off` | working |
|
|
||||||
| `lib/dev.ml` | `watch_read` takes `~reset` and sends `watch reset` *after* the read; the `:op "watch"` arm reads a `:reset` field the same way `watch-enable` reads `:on` | working |
|
|
||||||
| `emacs/flan-watch.el` | `flan-watch-ghost-call-regexp` `?` → `*`; docstring updated; `flan-watch--tick` sends `(:op "watch" :reset t)` | working |
|
|
||||||
| `test/programs/dev-watch.flan` | `declare-c watch-num-i64`; a `loop-cells` hot loop sampling `"cell"` eight times a step at 0,3,…,21 | working |
|
|
||||||
| `test/test_dev.ml` | assertions inside the existing watch block: the row exists, `n > 8` (per-sample not per-step), `min < max` (a range, not just `last`), a plain read does not reset, `:reset t` reopens the window | working |
|
|
||||||
| `BUILT.md` | new section "A hot loop keeps five numbers, and the window is the editor's", after the ghost text one | working |
|
|
||||||
| `PORTING.md` | item 5 struck in house style with the accumulator decision | working |
|
|
||||||
| `NEXT.md` | "What is left on `PORTING.md`'s list" corrected: item 5 done, item 6 is next | working |
|
|
||||||
|
|
||||||
### Verified end to end, not just compiled
|
|
||||||
|
|
||||||
Against a real `flan dev` daemon and the real running program, the `"cell"` row read back:
|
|
||||||
|
|
||||||
```
|
|
||||||
n=184 min=0 max=21 last=21 mean=10.5000 (first read)
|
|
||||||
n=368 min=0 max=21 last=21 mean=10.5000 (second read — a plain read does not reset)
|
|
||||||
n=16 min=0 max=21 last=21 mean=10.5000 (after :reset t — the window reopened)
|
|
||||||
```
|
|
||||||
|
|
||||||
The loop is 0,3,…,21, so `min`, `max`, `mean` and the per-step count of 8 are each pinned by a different part of it.
|
|
||||||
|
|
||||||
### Test result
|
|
||||||
|
|
||||||
`dune test` is **green**, run twice in full. `test_dev.ml`'s first block — the separately-known-flaky socket bind race
|
|
||||||
— failed on one earlier run with "the daemon never listened" and passed on both final runs. That is exactly the
|
|
||||||
documented flake and another agent is on it; it is not related to anything here.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The ghost text path — the brief's assumption, checked
|
|
||||||
|
|
||||||
The brief said to reuse the ghost text path rather than invent a second one. **It fits, and it needed one character.**
|
|
||||||
|
|
||||||
The important property is in `BUILT.md`: ghost text anchors on the **string literal in the source**, not on a source
|
|
||||||
location carried in the table. `flan-watch--ghost-sites` scans buffers shown in a window for
|
|
||||||
`(<head> "<name>"` and matches the name against the rows. So a new *kind* of watch needs nothing new in the daemon,
|
|
||||||
nothing new in the wire, and no new display path — the accumulator's rendered value is a string like
|
|
||||||
`n=368 min=0 max=21 last=21 mean=10.5` and flows through the existing `:watch` rows untouched.
|
|
||||||
|
|
||||||
**Is it pull-based?** No, and the brief's phrasing is worth correcting for whoever reads this next. The watch is
|
|
||||||
**push-based** and that is load-bearing: `watch.clj` is pull (Emacs polls a render function), and `flan_dev.c`'s
|
|
||||||
comment explains at length why that does not port — an evaluation here compiles a module and `dlopen`s it, so a 5Hz
|
|
||||||
poll would be hundreds of `.so` files a minute. So the program pushes into a table and the editor reads *memory*.
|
|
||||||
Ghost text is not a poller at all: it is painted from `flan-watch--absorb`, off the same single reply that paints the
|
|
||||||
buffer, which is what keeps the two from disagreeing and preserves the one-request-in-flight invariant. The
|
|
||||||
accumulator sits inside that unchanged.
|
|
||||||
|
|
||||||
**The one thing that did not fit.** `flan-watch-ghost-call-regexp` was `watch\(?:-[[:alnum:]]+\)?` — *one* optional
|
|
||||||
hyphenated segment. `[[:alnum:]]` does not match a hyphen, so on `(watch-num-i64 "cell" ...` the regexp matched
|
|
||||||
`watch-num`, then required whitespace, found `-i64`, and backtracked to failure. A numeric watch therefore appeared
|
|
||||||
normally in the watch buffer and got **no inline value at all** — a silent half-failure. `*` for `?` is the whole
|
|
||||||
fix, verified in `emacs --batch` against all four head shapes.
|
|
||||||
|
|
||||||
That is worth recording as the predictable cost of the decision the ghost text section defends. Anchoring on the name
|
|
||||||
rather than on the head is what makes the head a `defcustom`, and a `defcustom` with an enumerated default is a
|
|
||||||
default that needs widening whenever the set of heads grows. Cheaper than the alternative, but not free.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What remains
|
|
||||||
|
|
||||||
Item 5 is complete, and so is the fixture the previous list asked for: `emacs/test-flan-watch.el` now has a block
|
|
||||||
"Every head the default regexp claims to cover" pinning `watch-num-i64` and `watch-num-f64` beside the one-segment
|
|
||||||
heads. It was verified by mutation — restoring the old `?\=` in `flan-watch-ghost-call-regexp` makes exactly those
|
|
||||||
two checks fail and nothing else — so a regression in that regexp is now caught.
|
|
||||||
|
|
||||||
One item is left, and it turned into a finding rather than a test.
|
|
||||||
|
|
||||||
1. **`watch_render_num`'s `n=0` branch is unreachable, so the test that was asked for cannot be written.**
|
|
||||||
`runtime/flan_dev.c:764` renders `n=0 last=<v>` for a window with no samples. Nothing can present that state to
|
|
||||||
the reader:
|
|
||||||
|
|
||||||
- `s->num` is set to 1 in exactly one place, `watch_record` (flan_dev.c:701), and that function always falls
|
|
||||||
through to `s->n += 1`. The clear (`s->n = 0` on an epoch change) and the increment are both inside one
|
|
||||||
odd-generation window, so a reader cannot see between them.
|
|
||||||
- `flan_dev_watch_begin` sets `s->num = 0` and never touches `s->n`, so the scalar path cannot leave a num slot
|
|
||||||
at zero either.
|
|
||||||
- `flan_dev_watch_read` never compares `s->epoch` to `watch_epoch`. A reset therefore does not make the slot read
|
|
||||||
as empty.
|
|
||||||
|
|
||||||
The user-visible consequence is worth stating on its own, because it is the thing the branch was presumably
|
|
||||||
written for: **after `:reset t`, a read taken before the program's next sample reports the *previous* window's
|
|
||||||
five numbers, not an empty one.** That is why `test_dev.ml`'s reset assertion has to `await` the count dropping
|
|
||||||
rather than read once — the test is already working around this.
|
|
||||||
|
|
||||||
Two ways out, and both are decisions rather than test work, which is why neither was taken here (the lane was
|
|
||||||
scoped to tests):
|
|
||||||
|
|
||||||
- **Delete the branch.** Honest about what the design says: a window begins when the program next runs, and until
|
|
||||||
then the last window is what there is to show.
|
|
||||||
- **Make the read epoch-aware** — compare `s->epoch` with `watch_epoch` under the seqlock and render `n=0` when
|
|
||||||
they differ. This makes a reset visible immediately without the reader writing the table, and would let the
|
|
||||||
existing `await` in the test become a single read. It is a behaviour change to what an editor sees in the tick
|
|
||||||
after a reset, so it belongs to whoever owns `runtime/flan_dev.c`, not to a test pass.
|
|
||||||
|
|
||||||
Item 6 (frame rollback as a worked example) is the next real piece of work and `NEXT.md` points at it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What I tried that did not work
|
|
||||||
|
|
||||||
- **Two Flan type errors in `dev-watch.flan`.** `(* i 3)` over a `let`-bound `0` is `i32`, and `watch-num-i64` wants
|
|
||||||
`i64` — fixed with an explicit `(i64 …)`. Then `loop-cells` was declared `i64` and returned the `i32` counter;
|
|
||||||
declared `i32`. Both are ordinary, but they cost two daemon round trips to find because a build error goes to the
|
|
||||||
daemon's stdout, not to the client — worth knowing if you are driving `flan dev` by hand.
|
|
||||||
|
|
||||||
- **My first probe script spoke the wrong protocol.** `Wire.send` is **length-prefixed** (`"%d\n%s"`), not
|
|
||||||
newline-delimited. A newline-delimited client gets the connection closed with no error text. If you write a
|
|
||||||
throwaway client, read `lib/wire.ml:32` first.
|
|
||||||
|
|
||||||
- **Two attempts at the reset assertion failed before the third stuck**, and both failures were informative rather
|
|
||||||
than noise:
|
|
||||||
1. *"the window did not reopen: 8 after 8"* — one step writes eight samples, so two reads back to back leave no
|
|
||||||
room *under* the count for a reset to show in. The test now runs the accumulator up past 32 first (which is
|
|
||||||
itself the other half of the claim: every one of those polls is a plain read, and a plain read must not reset,
|
|
||||||
or the count could never climb).
|
|
||||||
2. *"the window did not reopen: 40 after 40"* — reading immediately after `:reset t` still sees the old count,
|
|
||||||
because the reset bumps an epoch and the **slot clears on its next sample**. That is the design and not a bug;
|
|
||||||
the test now `await`s the drop and the comment explains why the laziness is what keeps the reader out of the
|
|
||||||
table.
|
|
||||||
|
|
||||||
- **Not attempted, deliberately:** the `(watch "hp" hp)` arm in `check.ml`. Both `PORTING.md` and `flan_dev.c` say
|
|
||||||
it is only wanted for *composites* (a struct, a slice, a union), which need `Render.render` pointed at the
|
|
||||||
`flan_dev_watch_emit_*` emitters. A scalar and an accumulator both reach the runtime by plain `declare-c` and need
|
|
||||||
no compiler change, which is the property that kept this change out of `check.ml` entirely.
|
|
||||||
|
|
||||||
- **One worktree note.** This worktree was checked out at `2c232dd`, an ancient commit, rather than at `dev-loop`'s
|
|
||||||
tip — none of `PORTING.md`, `NEXT.md`, `lib/dev.ml` or `runtime/` existed in it. `git reset --hard dev-loop` on a
|
|
||||||
clean tree fixed it. Also, `dune` finds the parent repo's root from inside a worktree, so every build here is
|
|
||||||
`dune build --root .` / `dune test --root .`.
|
|
||||||
14
NEXT.md
14
NEXT.md
@ -279,7 +279,7 @@ wait a minute now, bounded by the watchdog, with a named `listening` helper in `
|
|||||||
re-derive from `HANDOFF-f1.md` is its "lengthening any timeout" bullet — that applies to the `agent.sock` check
|
re-derive from `HANDOFF-f1.md` is its "lengthening any timeout" bullet — that applies to the `agent.sock` check
|
||||||
only, and reading it as a rule is what kept this open.
|
only, and reading it as a rule is what kept this open.
|
||||||
|
|
||||||
### From `HANDOFF-f3.md` — the watch is built; two tests are not
|
### ~~From `HANDOFF-f3.md` — the watch is built; two tests are not~~
|
||||||
|
|
||||||
`spy-num` landed and `PORTING.md` Tier 1 item 5 is closed. The `spy` half already existed — the finding that
|
`spy-num` landed and `PORTING.md` Tier 1 item 5 is closed. The `spy` half already existed — the finding that
|
||||||
reframed the task is that the table, the agent commands, the buffer and the ghost text were all there at `344e571`,
|
reframed the task is that the table, the agent commands, the buffer and the ghost text were all there at `344e571`,
|
||||||
@ -291,12 +291,12 @@ writes the table.
|
|||||||
The fixture is in: `emacs/test-flan-watch.el` pins both accumulator heads beside the one-segment ones, verified by
|
The fixture is in: `emacs/test-flan-watch.el` pins both accumulator heads beside the one-segment ones, verified by
|
||||||
putting the old `?` back in `flan-watch-ghost-call-regexp` and watching exactly those two checks fail.
|
putting the old `?` back in `flan-watch-ghost-call-regexp` and watching exactly those two checks fail.
|
||||||
|
|
||||||
1. **`watch_render_num`'s `n=0` branch is dead, and the choice is whose it is to remove.** No test can reach it:
|
**Closed.** The dead `n=0` branch in `watch_render_num` is deleted and the reader stays epoch-blind on purpose: a
|
||||||
`s->num` is set only in `watch_record`, which always falls through to `s->n += 1` inside one odd-generation
|
stopped program does not sample, so an epoch-aware reader would blank the watch for the whole of a break loop, which
|
||||||
window, and the reader never compares the slot's epoch to the global one. The live consequence is that a read
|
is exactly when the numbers are being read. What was actually wrong was in the editor — `flan-watch--tick` sent
|
||||||
after `:reset t` and before the program's next sample reports the *previous* window — which is why the test waits
|
`:reset t` five times a second at a program that could not answer it — and it is now guarded on `flan-dev--stopped`,
|
||||||
for the count to drop instead of reading once. Either delete the branch or make the read epoch-aware; both are
|
with the read still going out every tick. `emacs/test-flan-watch.el` pins both halves. `HANDOFF-f3.md` is deleted;
|
||||||
`runtime/flan_dev.c` decisions. `HANDOFF-f3.md` has the argument.
|
the reasoning is in `BUILT.md`, "A hot loop keeps five numbers, and the window is the editor's".
|
||||||
|
|
||||||
### Not started: five more raylib core examples
|
### Not started: five more raylib core examples
|
||||||
|
|
||||||
|
|||||||
@ -395,7 +395,23 @@ sends the next question, leaving at most one request in flight — the invariant
|
|||||||
;; show a number while you drag the mouse. The reset happens after
|
;; show a number while you drag the mouse. The reset happens after
|
||||||
;; the read, on the daemon's side, so this tick's numbers are the
|
;; the read, on the daemon's side, so this tick's numbers are the
|
||||||
;; last tick's window and nothing is lost between the two.
|
;; last tick's window and nothing is lost between the two.
|
||||||
(progn (flan-dev--send flan-dev--connection '(:op "watch" :reset t))
|
;;
|
||||||
|
;; Except while the program is stopped, when the read still happens
|
||||||
|
;; and the reset does not. A stopped program takes no samples, so
|
||||||
|
;; there is no window for a reset to close and none for it to open:
|
||||||
|
;; the runtime clears a slot lazily, on its next sample, which is
|
||||||
|
;; what keeps a paused program showing the numbers from the moment
|
||||||
|
;; you paused it — the whole reason to pause. Resetting anyway
|
||||||
|
;; would ask for a new window five times a second that nothing can
|
||||||
|
;; fill. The guard lives here rather than in the runtime because
|
||||||
|
;; "since you last looked" is the editor's policy, not the table's.
|
||||||
|
;; `flan-dev--stopped' is the one place that state is tracked, and
|
||||||
|
;; `flan-dev.el''s background poll keeps it current whether or not
|
||||||
|
;; anyone is evaluating.
|
||||||
|
(progn (flan-dev--send flan-dev--connection
|
||||||
|
(if flan-dev--stopped
|
||||||
|
'(:op "watch")
|
||||||
|
'(:op "watch" :reset t)))
|
||||||
(setq flan-watch--pending t))
|
(setq flan-watch--pending t))
|
||||||
(error (flan-watch-stop)))))))
|
(error (flan-watch-stop)))))))
|
||||||
|
|
||||||
|
|||||||
@ -210,5 +210,54 @@ what bounds its cost; a `with-temp-buffer' would be scanned by nothing."
|
|||||||
(test-flan--check "and the last consumer out disarms the table"
|
(test-flan--check "and the last consumer out disarms the table"
|
||||||
(and (null flan-watch--consumers) torn))))
|
(and (null flan-watch--consumers) torn))))
|
||||||
|
|
||||||
|
;; --- The reset is guarded on the stop ------------------------------------
|
||||||
|
;;
|
||||||
|
;; `flan-watch--tick' asks for "since you last looked" by sending `:reset t'
|
||||||
|
;; beside the read. A stopped program takes no samples, so there is no window
|
||||||
|
;; for a reset to close and none for it to open — and the runtime's lazy clear
|
||||||
|
;; is what keeps a paused program showing the numbers from the moment you
|
||||||
|
;; paused it. So the tick must drop `:reset' while stopped and keep reading.
|
||||||
|
;;
|
||||||
|
;; Asserted at the level the rest of this file works at: no daemon, no socket.
|
||||||
|
;; The tick is a function from `flan-dev--stopped' to the form it puts on the
|
||||||
|
;; wire, and that is the whole claim, so `flan-dev--send' and `process-live-p'
|
||||||
|
;; are stubs. What this cannot reach is the daemon actually honouring the
|
||||||
|
;; absent field; `test/test_dev.ml' drives a real program for that.
|
||||||
|
|
||||||
|
(defun test-flan-watch--tick-form (stopped)
|
||||||
|
"The form `flan-watch--tick' sends with the program STOPPED or not."
|
||||||
|
(let ((sent nil)
|
||||||
|
(flan-dev--stopped stopped)
|
||||||
|
(flan-dev--connection 'a-process)
|
||||||
|
(flan-dev--busy nil)
|
||||||
|
(flan-watch--pending nil)
|
||||||
|
;; Not `buffer': that consumer checks for a live watch buffer first and
|
||||||
|
;; would drop the subscription instead of ticking.
|
||||||
|
(flan-watch--consumers '(ghost)))
|
||||||
|
(cl-letf (((symbol-function 'process-live-p) (lambda (_) t))
|
||||||
|
((symbol-function 'flan-dev--take-reply) (lambda (_) nil))
|
||||||
|
((symbol-function 'flan-dev--send)
|
||||||
|
(lambda (_proc form) (setq sent form))))
|
||||||
|
(flan-watch--tick)
|
||||||
|
(list sent flan-watch--pending))))
|
||||||
|
|
||||||
|
(let ((running (test-flan-watch--tick-form nil))
|
||||||
|
(stopped (test-flan-watch--tick-form "BoundsError")))
|
||||||
|
(test-flan--check
|
||||||
|
"a tick while the program runs asks for the window it is closing"
|
||||||
|
(equal (nth 0 running) '(:op "watch" :reset t)))
|
||||||
|
(test-flan--check
|
||||||
|
"a tick while the program is stopped sends no reset"
|
||||||
|
(null (plist-get (nth 0 stopped) :reset)))
|
||||||
|
;; Skipping the tick outright would be worse than resetting: the watch would
|
||||||
|
;; freeze at whatever it held when the program stopped, and a break loop is
|
||||||
|
;; exactly when the numbers are being read.
|
||||||
|
(test-flan--check
|
||||||
|
"but it still reads the table"
|
||||||
|
(equal (plist-get (nth 0 stopped) :op) "watch"))
|
||||||
|
(test-flan--check
|
||||||
|
"and still has a reply in flight, so the cycle survives the pause"
|
||||||
|
(and (nth 1 running) (nth 1 stopped))))
|
||||||
|
|
||||||
(provide 'test-flan-watch)
|
(provide 'test-flan-watch)
|
||||||
;;; test-flan-watch.el ends here
|
;;; test-flan-watch.el ends here
|
||||||
|
|||||||
@ -755,26 +755,35 @@ static void watch_num_str(char *out, size_t cap, double d) {
|
|||||||
* slot, so nothing here races and nothing here runs per sample. Comfortably
|
* slot, so nothing here races and nothing here runs per sample. Comfortably
|
||||||
* inside [WATCH_VAL]: five numbers at %.4f and their labels is well under 192
|
* inside [WATCH_VAL]: five numbers at %.4f and their labels is well under 192
|
||||||
* bytes, and [snprintf] truncates rather than overruns if a caller's buffer is
|
* bytes, and [snprintf] truncates rather than overruns if a caller's buffer is
|
||||||
* smaller than [flan_dev_watch_val_cap]. */
|
* smaller than [flan_dev_watch_val_cap].
|
||||||
|
*
|
||||||
|
* **[n] is at least 1 whenever this runs**, so there is no empty-window arm
|
||||||
|
* and [sum / n] cannot divide by zero. A slot only becomes a num slot inside
|
||||||
|
* [watch_record], which always falls through to [s->n += 1], and the clear on
|
||||||
|
* an epoch change sits in the same odd-generation window as that increment, so
|
||||||
|
* no reader can catch a num slot at zero.
|
||||||
|
*
|
||||||
|
* There used to be an "n=0 last=..." arm here, for the window a reset opens
|
||||||
|
* before the program's next sample. It is deleted rather than kept, because
|
||||||
|
* the only way to reach it is to compare [s->epoch] with [watch_epoch] in
|
||||||
|
* [flan_dev_watch_read] — and that is the one thing the reader must not do. A
|
||||||
|
* stopped program does not sample. An epoch-aware reader would blank the watch
|
||||||
|
* for as long as the program stayed stopped, and looking at the numbers from
|
||||||
|
* the moment you stopped is the whole point of stopping. The lazy clear is
|
||||||
|
* right there; its cost is one stale tick in a *running* program, and the
|
||||||
|
* editor keeps even that honest by not sending [:reset] while the program is
|
||||||
|
* stopped. See [flan-watch--tick]. */
|
||||||
static uint64_t watch_render_num(double n, double lo, double hi, double last,
|
static uint64_t watch_render_num(double n, double lo, double hi, double last,
|
||||||
double sum, char *vd, uint64_t vcap) {
|
double sum, char *vd, uint64_t vcap) {
|
||||||
if (vcap == 0) return 0;
|
if (vcap == 0) return 0;
|
||||||
char b[4][40];
|
char b[4][40];
|
||||||
int k;
|
int k;
|
||||||
if (n == 0) {
|
watch_num_str(b[0], sizeof b[0], lo);
|
||||||
/* No sample since the window opened. The range and the mean have nothing
|
watch_num_str(b[1], sizeof b[1], hi);
|
||||||
* behind them and showing the previous window's would be a lie, but [last]
|
watch_num_str(b[2], sizeof b[2], last);
|
||||||
* is still the last value this name ever had, so it is kept. */
|
watch_num_str(b[3], sizeof b[3], sum / n);
|
||||||
watch_num_str(b[0], sizeof b[0], last);
|
k = snprintf(vd, (size_t)vcap, "n=%lld min=%s max=%s last=%s mean=%s",
|
||||||
k = snprintf(vd, (size_t)vcap, "n=0 last=%s", b[0]);
|
(long long)n, b[0], b[1], b[2], b[3]);
|
||||||
} else {
|
|
||||||
watch_num_str(b[0], sizeof b[0], lo);
|
|
||||||
watch_num_str(b[1], sizeof b[1], hi);
|
|
||||||
watch_num_str(b[2], sizeof b[2], last);
|
|
||||||
watch_num_str(b[3], sizeof b[3], sum / n);
|
|
||||||
k = snprintf(vd, (size_t)vcap, "n=%lld min=%s max=%s last=%s mean=%s",
|
|
||||||
(long long)n, b[0], b[1], b[2], b[3]);
|
|
||||||
}
|
|
||||||
if (k < 0) return 0;
|
if (k < 0) return 0;
|
||||||
if ((uint64_t)k > vcap - 1) return vcap - 1;
|
if ((uint64_t)k > vcap - 1) return vcap - 1;
|
||||||
return (uint64_t)k;
|
return (uint64_t)k;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user