| Commit message (Collapse) | Author | Age |
| ... | |
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
A review of what this program does when the environment says no. The finding
that reframes it: there were almost NO panics on ordinary paths — the rule
already held — but there was a great deal of silence, and one case worse than
any panic.
SILENT DATA LOSS ON SAVE. `saveFile` marked the pane saved the moment it
QUEUED the effect, before any host had tried; `host_io.writeFd` returned void,
so a short or failed write was indistinguishable from a complete one; and
`writeFileBytes` returned true regardless. A save to a read-only file, or into
a directory removed under the pane, therefore cleared the tag's ` *` and posted
nothing — and `Del` makes no dirty check, so the next click threw the edits
away with the screen saying they were safe. On a full disk it was worse: the
file is already `O_TRUNC`'d when `write` fails, so the message row said `saved`
over a file that had just been emptied.
Now: `writeFd` reports, `writeFileBytes` returns WHY (`PermissionDenied`,
`NoSpaceLeft`, `ReadOnlyFilesystem`, …) including a failed `close`, which is
where write-back filesystems report at all; the core marks the pane saved
around `perform` rather than at emit, which is also where the bytes are read;
and a host that could not write calls `Pardes.saveFailed`, which puts the
reason on the message row and takes the clean mark back. That is a CALL and
not a return value because host.zig enforces, at comptime, that a `push_`
method reaching every host in a fan-out cannot have one answer — the first
attempt at this changed the signature and the compiler was right to refuse it.
TWO PANICS ON AN ORDINARY KEYSTROKE, in look.zig's number scans. `v = v * 10 +
d` over caller-supplied digits, reached from `parsePathLine` and the `@pN` scan
— which every Look, every right-click and every n/N motion runs on whatever
word is under the pointer. A hash in a log, a CSV column, any output shaped
`foo:99999999999999999999`, and the editor died with "integer overflow". Both
saturate now, the same way acmefs.zig's address parser already did; a saturated
line is refused by `file_pane.open`'s `line <= total` and a saturated pane id
by `focusPaneLine`'s `id < MAX_PANES`, so nothing addressable changes.
A BOOT FILE THAT WILL NOT OPEN joins the missing-name case in the `+Errors`
pane instead of taking the launch down: `pardes /root` resolves as a `.file`,
could not be read, and left `error: PermissionDenied` and a return trace.
`look.readFile` now says which errno it was, so the pane can say "permission
denied" rather than a word from the source code.
The tag-marker test drained no effects and passed anyway, which is exactly the
defect; it drains now.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
stays one line
Two defects in the +Errors boot, both found by adversarial re-review.
The pane was opened with `dir = ""` — copied from the board's boot buffer,
which can afford it because that platform has no filesystem — so its path came
out `/+Errors` and `paneDir` answered `/`. An output pane's directory is where
a `Grep` from it walks, where its `Newtty` spawns a shell and what its `Save`
prefills, so the boot screen rooted all three at the filesystem root, and the
one word the pane prints resolved against `/` and could never be clicked. The
launch directory rides in `Options.missing` beside the word now, and the test
asserts the pane's path rather than only its contents.
A typo INSIDE pardes stacked a second full-screen UI. The hand-off block above
resolves the word and sends it to the outer instance; `.none` sent nothing and
fell through, which was harmless while the classification below refused it and
became the one input that stacks the UI that block exists to prevent — with no
shell pane in it, so the only way out is `Del`. Its own comment said as much
and was falsified by the +Errors boot. `.none` is refused in that shell now, in
one line and without a stack trace, and the outer session is not told: `Look`
on a word naming nothing is not something to do to somebody else's session.
Also recorded, not fixed: the commonest permission case never reaches the
`.dir` arm this arm's comment defends. `look.isDir` probes with O_DIRECTORY|
O_RDONLY, so a directory you cannot read resolves as `.file` and dies in
`file_pane.open` with `error.OpenFailed` out of `main` — still a trace at a
human, and a different fault than the one fixed here.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
Adversarial re-review, confirmed against std's source and then measured.
`captureCurrentStackTrace` is not the safe half of `writeCurrentStackTrace`.
`StackIterator.init` picks the `.di` strategy whenever `SelfInfo` can unwind,
`stratOk` accepts `.di` regardless of `allow_unsafe_unwind`, and `.di` takes
`SelfInfo`'s rwlock EXCLUSIVELY on its first call — the only kind of call a
panic record makes — across `dl_iterate_phdr`, a DWARF CFI machine and an
allocation. A panic in there (a smashed stack is a leading reason to be in a
panic handler at all) leaves the lock held, because the unlock is a `defer` in
a frame that never returns, and `defaultPanic` then waits on it for the life of
the process. A crash becomes a hang, which is worse than what this file was
added to improve on. The frames stay on stderr, where defaultPanic prints them
under the staging that makes them safe; the record keeps what can be gathered
without asking the process any questions.
ONE record per process, never released. With the guard released on the way out,
one panic wrote two records: the real message, then "reached unreachable code"
under it. That second panic is this handler's own `vaxis.recover()` running a
second time — it closes the vaxis tty and never clears the global saying there
is one, so the double close is `recoverableOsBugDetected` and an `unreachable`
in a Debug build. Guarded now in both the panic and the segfault handler; that
half is a fix older than the crash file.
`clock_gettime`'s return is checked, unlike dump.zig's, because a failure here
leaves `ts` undefined and an undefined large-positive `sec` walks
`calculateYearDay`'s u16 year past 65535 and overflow-panics inside the panic
handler. Debug fills it with 0xaa and lands in 1970, which is why it reads as
harmless.
Verified end to end with a temporary probe: one panic, one record.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
`pardes nosuchfile` returned error.BadArgs out of nativeMain, which std prints
as `error: BadArgs` with a return trace under it — indistinguishable from a
crash, for a typo, and it left the human with no editor at all. A launch that
names something look.resolve cannot make a target of now boots one +Errors pane
filling the window, saying `file or directory not found` and the argument AS
TYPED: acme's own vocabulary for output that came from the program rather than
from a word somebody clicked, and the word rather than a resolved path because
`pardes ~/notes/tdoo.md` wants to see its own typo back.
A chdir that fails on a directory that really is one stays BadArgs. That is a
permission problem rather than a typo, and the two want different answers.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
Two defects, and either fix alone makes the other one worse.
No frontend ever clamped its window to the protocol's grid ceiling — the hello
carried it raw — so a 4K display at a small font, already past max_rows 128,
had its geometry refused by the session's decoder as BadValue. That path
answers with close(.protocol) and no refuse behind it, so the frontend was told
only that the session "hung up on the connect": at a session with all 32 slots
free. client.zig now asks for the largest grid the wire carries, which is what
its own GEOMETRY note already promises a frontend gets — the session is drawn
at its own size in the corner of a bigger window, exactly as when another
frontend is the smaller one. A ZERO geometry is dropped rather than clamped,
because the session grid is the smallest common one and a frontend reporting 1
would collapse everybody else; TIOCGWINSZ answers 0x0 during a teardown and the
tty shell forwarded it, which was the same mute hangup by another route.
That clamp alone would have replaced one bug with a worse one. max_cols *
max_rows is 65536 and a run's length prefix is a u16, so the single grid legal
at both bounds is the one grid whose full frame — and an attach always produces
a full frame — cannot be described by one run. encodeFrame's @intCast panicked
in a safe build and was illegal behaviour in a fast one. The encoder splits the
run instead, bounding the CURSOR rather than the run because the gap lookahead
runs ahead of it, and frameBound had already paid for the extra header.
wire.version 1 -> 2 for the same reason: the geometry a v2 frontend now asks
for is one a v1 daemon panics encoding, and `zig build` replacing the binary
under a running session is exactly what that field exists for. A v1 daemon
answers Refusal.version instead of dying with every pane shell it owns.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
|
| |/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
lose it
Every crash this program has ever had went to stderr and nowhere else, and
stderr is the one place it cannot keep anything. In the tty shell stderr IS the
screen, so the trace lands on the grid the terminal is being reset out of; the
SDL and AppKit shells have no terminal at all; a --detach session's goes
wherever its launcher left it. src/crash.zig appends a record to
<config dir>/crashes first: one line naming the build (version, commit, UTC,
os-arch, pid) and under it the panic message and the frames behind it.
RETURN ADDRESSES and not the symbolised trace, which is measured rather than
chosen. `std.debug.writeCurrentStackTrace` called from a panic handler BEFORE
defaultPanic wedges the process at 0% CPU: symbolising reads DWARF, that read
can itself panic, and the staging which turns a nested panic into "aborting due
to recursive panic" is defaultPanic's own and private. Reproduced in a
standalone build with this program's std_options_debug_io and inside a test
binary. `captureCurrentStackTrace` only walks frames, so the addresses go in
the file and `addr2line -e` finishes the job; stderr still gets the symbolised
trace from defaultPanic, unchanged.
The AppKit shell gets a panic handler of its own here too: the macOS build
roots at macos.zig, so main.zig's had never run there — in the shell with the
least useful stderr of the four. The config directory is COPIED rather than
borrowed, because that host's lives in an arena its own errdefer frees. One
record at a time, so two panicking threads cannot interleave into one buffer.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
bugs go with them
Nine read-only scouts compared every host-side concern across `src/macos.zig`,
`src/tty/tty.zig`, `src/gui/gui.zig` and `src/detached/server.zig`. What they
found was not a style problem: each duplicated body had drifted, and in every
case the drift WAS a bug the users of that shell could see. So the fixes and
the deduplication are the same change.
**One PATH, adopted before the first fork.** LaunchServices hands a bundle
launchd's environment, whose `PATH` is `/usr/bin:/bin:/usr/sbin:/sbin`. Every
pty shell, `|` filter and language server the app forked inherited it, so
`yazi` in `/opt/homebrew/bin` was absent from a Dock launch and present in the
identical binary run from a terminal — the "it worked briefly" window was
simply the sessions started from a shell. `shell_bin.adoptSystemPath` composes
`/etc/paths` then `/etc/paths.d/*` in the order `path_helper` reads them,
deduplicating on first occurrence, and runs once at startup in all four native
hosts. It APPENDS: an entry already present keeps its position, so running it
over a real session cannot demote a mise shim behind `/usr/bin` and silently
change which `node` runs. A `PATH` that was configured is left byte-for-byte
alone; only one nobody configured is repaired. `prepareForFork` folds that
adoption together with the prompt-rc staging and the `BASH_SILENCE_DEPRECATION_WARNING`
setenv the five hand-copied prefork sites had between them — `server.zig` had
none of it, which is why every detached pane opened with Apple's zsh banner.
**The LSP protocol client never worked on macOS.** It opened its control
socket with `libc.SOCK.CLOEXEC`; Zig defines that constant for Linux and
Darwin answers `socketpair` with `EPROTONOSUPPORT`, so the call failed before
any fork, `ensure` returned `error.NoServer`, and every row in the spec table
— rust-analyzer, clangd, gopls — was unreachable in every macOS build. The
in-process ZLS backend kept answering, which is what made it read as "only Zig
is supported". It is a plain socket plus `fcntl(FD_CLOEXEC)` now, the route
`fuse.zig:943` and `nested.zig:95` already took for the same reason. The
snapshot suite that covered this path had never run natively on a Mac: the
harness targets defaulted to x86_64-linux.
**One LSP host worker.** `src/lsp_host.zig` is the snapshot, the worker body
and the job lifetime that `tty.zig` and `gui.zig` carried verbatim — `gui.zig`
said so in a comment — and that `macos.zig` did not carry at all: `lsp` and
`pipe` were absent from its `Host.VTable`, so the core answered its own empty
answer, `SPC l i` rendered a blank panel and a `|` filter silently did
nothing. All three shells share the module, and the AppKit host implements
both effects. Its status sink is now REGISTERED as well as defined, so
unsolicited server news reaches the message row instead of nowhere.
**The animation clock measures time.** `pardes_animation_tick` advanced one
scene frame per callback and published `frame_count / 60`, so scene time was a
count of callbacks rather than elapsed seconds — and `AppDelegate` re-armed
`asyncAfter(.now() + 0.016)` only after the previous frame's work had
finished, making the true period 16 ms plus all of it. Motion ran at about
three quarters of wall clock and unevenly. The tick now spends measured
monotonic time in whole `frame_ns` steps and banks the remainder, so a late
callback advances two frames instead of stretching one; `spendTickTime` is
that arithmetic as a pure function with its own tests and no display attached.
On macOS 14+ the animating run is one `CADisplayLink` phase-locked to vsync
rather than a chain rebuilt after every frame; macOS 13 keeps the old chain.
**Three more single definitions.** `panel_animation.paintOrder` is the
moving-then-opening-then-closing composite order as a rule the core applies
once in `Pardes.render` — `macos.zig` was re-sorting an already-sorted list.
`selection_pipe.Tasks` is the bounded in-flight pipe table `tty.zig` and
`gui.zig` each declared. `boxContains` was a fourth copy of the half-open cell
test and is now an alias of `Box.contains`.
**A filtered terminal stops asking libm per cell.** `Filter`'s legibility
stage called `RGB.contrast` for every painted cell, and that ends in
`std.math.pow` up to six times, re-deriving a ratio against a background that
had not moved; the existing memo cache covered the palette reduction beside it
and never this. The indexed path's input is a `u8`, so all 256 answers are
enumerated once per pass — after the default roles are fixed, before the first
cell is read — and what a cell names becomes an array index. Only truecolour
still reduces. ReleaseFast, 190x56, Tracy: recolour 3.09 ms -> 0.130 ms,
frame 3.37 ms -> 0.299 ms. The comptime luminance table is pinned to
`RGB.luminance` and `RGB.contrast` by exact-equality test over every channel
value and all 65 536 palette pairs, because the decision is a threshold
comparison where one ULP is a different colour. A `filterInit` Tracy zone
records the part that is still per-pass: 2.9 us warm against a 117 us pass,
which is the measurement that says not to cache it across frames.
Released as 0.0.2. `build.zig.zon` carries the version into `pardes --version`
and into the `Changelog` pane through `@embedFile`, so the entries above open a
`## 0.0.2` section and `## 0.0.1` closes with the tagline work of the parent
commit.
Two bugs here were mine, caught by review rather than by me: a double free in
the macOS pipe drain arm (`Msg.free` already owns the response) that segfaulted
the app on the first `|`, and a proposed `getRowAndCell` optimisation that
targeted 2 of 43 draw samples while the contrast math beside it took 12 — and
would not have compiled. The profile that justified it was a Debug build, which
`build.zig:1160` already documents as ~5x slower than release.
Native and -Dplatform=macos suites: 0 failures. All targets build with Tracy on
and off; the shipped release binary contains no `___tracy_emit_zone_begin`.
App reinstalled, signature verified, dmg regenerated, launched with 0 crash
reports; installed binaries verified byte-identical to a fresh build.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
effects that compile
Three things this shell had its own copy of, and in each case the fix is that
it stops having one.
**The tagline band.** A pane tag draws at `gui_tagline_font_percent` of the
body face and the band it sits on shrinks with it, while the grid row stays
body-sized — so something has to decide where the shorter band sits in the
taller row. This shell decided by centring, always, which is precisely the case
`config.gui_topbar_pane_border_px` exists to prevent: the topbar's unused
half-band meets the first pane tag's unused half-band and the window background
shows through the seam. The strip is as wide as the bands are short — on a
20-pixel cell, 4 physical pixels at the default 82%, 10 at 50%, 14 at 30% — so
it grew as the tagline face shrank and read as "the tagline is wrong on the mac"
rather than as one missing rule. The rule is `pardes.taglineBandOffset` in the
core now and both pixel hosts call it: row zero bottom-aligned, the first
pane-tag row top-aligned, the two joined by `gui_topbar_pane_border_px` in the
theme's scrollbar-track colour, every row between centred, and a `Tagbottom`
band on the final row flush with the window edge — with the sub-cell strip
beneath it painted in that band's own colour, because the core grid holds only
whole cells and a window is any height it likes. `pardes_tagline_band_offset`,
`pardes_topbar_pane_border_px` and `pardes_topbar_pane_border_rgb` carry it over
the C ABI as PHYSICAL pixels: the host multiplies its points by the backing
scale going in and divides coming out, which is the snapping `Metrics` already
does for the cell, and is what keeps a one-pixel rule one pixel instead of a
two-pixel smear.
**The watch.** `file_watch.zig` was one mark/reconcile transaction over
`inotify`, so the tty shell, the SDL window and the detached daemon all watched
nothing off Linux: an edit made outside pardes never reached the pane, and a PDF
replaced on disk kept rendering the old inode. It is the same transaction over
two kernels now — `init`, `wait`, `stop`, `drain`, `markDir` and `unmarkDir` are
still the whole of it, and the hosts wait on a kqueue and poll it exactly as
they did the old descriptor. A macOS mark is TWO filters, because a kqueue
directory filter reports its entries changing and never a write to a file
already inside it: the parent mark follows rename-over saves, `markFile` catches
in-place writes, and `remarkFile` re-arms the file filter once a rename has moved
the inode. That is the same pair the AppKit host's DispatchSources already used
for the same reason. Directory marks are deduplicated here by device and inode,
because each `EVFILT_VNODE` filter needs a descriptor of its own and inotify did
that deduplication itself; `stop` and `drain` wake through the one `EVFILT_USER`
filter, since a kqueue cannot simply be read the way an inotify descriptor can.
**The effects.** The three `crt.ci.metal` entry points are
`extern "C" [[stitchable]]`. `CIKernel.kernels(withMetalString:)` compiles that
source at runtime, looks for stitchable functions, and rejects the WHOLE source
with "cannot find a valid stitchable Metal function in the source" when it finds
none — so `ScenePostprocessor.init?` returned nil and every scene effect and
panel transition silently degraded to the plain CoreText draw. The
`effect_sources.zig` test pins the exact spelling of all three, and
`draw-effect` in the e2e suite catches the degradation rather than the spelling.
Beside them, the offscreen harness owes the core a PRESENTATION. Its window is
borderless and never ordered front, so AppKit runs no display cycle and
`pardes_frame_presented` — whose only caller is `draw(_:)` — never fired. The
core holds pointer gestures inert while a layout mutation has not reached a
backend, which for an unpresenting harness is the rest of the script: the first
pane a script opened silently killed every later click, drag and Look. So
`readFrame` presents what it just rendered, into a bitmap nobody reads.
`PARDES_CHROME` also looks under `/Applications`, where a browser's executable
lives inside an application bundle and never on `PATH`. The macOS goldens are
regenerated; docs/macos.md, config.md, detached.md, web.md and the design PDF
follow.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The seam grows a second backend: src/lsp/lsp_client.zig speaks JSON-RPC to
child language servers — rust-analyzer, clangd, gopls, tsserver, pyright are
rows in a spec table — while the in-process ZLS analyser keeps .zig. One
reader thread per server owns the socket, routes responses to a mailbox
under the conn mutex (monotonic condvar), answers server-to-client requests,
feeds the diagnostics store, and narrates $/progress and state changes
through a status sink both native shells post to the transient message row:
"rust-analyzer: cargo check 88% 955/1083" lands where a save narrates, with
the same clock. Chatty progress is throttled and deduplicated; settled
states always land, which is also what makes the goldens deterministic.
Nothing wedges and nothing healthy dies: waits are deadline-bounded, a
timeout cancels and returns no rows, three consecutive timeouts restart the
server ONLY while it is idle (an indexing server is narrating its own
excuse), spawn and handshake failures back off 10s to 2min, a crash shortly
after ready counts as a failure, and only a missing binary disables a spec.
PARDES_LSP_{RS,C,GO,TS,PY} override binaries; empty disables; the snapshot
harness pins RS to test/lspmock.zig and empties the rest.
Mutating answers really mutate now: the @put record beside rename @edit
carries per-range text, so = applies the formatter (both backends) and a
same-file WorkspaceEdit rename applies atomically, one undo step, narrated
("renamed 2 range(s)"); a multi-file rename previews as rows instead of
half-applying. Malformed responses fail closed: coordinates validated not
clamped, one bad TextEdit poisons the whole edit set, poison frames kill
the connection instead of buffering forever, decoded control bytes reject a
uri, hierarchy items too deep to reserialize are skipped.
Four kinds helix does not have, on SPC l: c/C incoming/outgoing calls (rows
are call sites), t/T super/subtypes. Pull diagnostics (3.17) preferred when
advertised. Help gains a language-keys footer for the motions no builtin
row could carry; lsp.rel and look.grep now share one path-shortening rule.
zig build lspprobe drives the seam from the CLI (comma-separated kinds share
one server); measured against a 1083-crate workspace warm: gd 26ms, gr 213
rows 165ms, incoming calls 212 sites 197ms, document symbols 670 rows 347ms.
docs/lsp.md tells the whole story; lsp-evaluation.md gets an addendum.
|
| | |
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`detached session: input from a frontend reaches the core and comes back as a
diff` failed about three runs in five, always the same way: cell 122, the core
holding `g` and the frontend a space. Cell 122 on a 60-wide grid is row 2
column 2, and row 2 is the pane shell's first line — the `g` is the front of
`goblin@pardes ...`, the prompt bash printed into the pty at whatever moment it
felt like. Nothing in the transport was wrong. The frame carrying that prompt
was sent, arrived, and was sitting unread in the client's socket: instrumenting
the mismatch printed `drained 1 more queued messages` and then
`same after drain: true`.
`pumpUntil` returns the instant it decodes the message it was asked for and
abandons the rest of the burst, while every `pump` presents one frame. One
`c.wait(5)` that times out — and under a loaded test binary one does — buys a
second pump before the first frame is read, and from there the client is one
frame behind for the rest of the test. Harmless while the only thing in that
frame is nothing; a cell that differs the moment a forked shell writes its
prompt.
So the assertion was comparing the core's NOW against the frontend's THEN, and
the fix is the one this file already made for two frontends: converge.
`pumpUntilShowsCore` is `pumpUntilSameScreen` with one client instead of two —
pump, wait, drain EVERYTHING, compare — checked before the first pump so a test
already in sync spends nothing, and handing its last comparison to
`expectSameScreen` so a transport that genuinely drops a cell still fails by
naming it rather than by timing out.
Both single-frontend screen assertions take it; the two-frontend one already
had its own. 8/8 clean gui runs against 3-failures-in-5 before, and the tty
suite's 457 unchanged.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`Shell.host()` hands the headless `PARDES_TEST_GRID` harness `grid_vtable`, which
inherited `push_set_clipboard` and `pull_read_clipboard` from the windowed
vtable. Both of those functions opened with `if (s.gui == null) return;` — and
in grid mode `s.gui` is null by definition, so the pair were methods that could
never answer. host.zig says what a NULL method means in as many words: "Null
answers immediately from the in-process clipboard instead, so a request never
goes unanswered." A method present and mute is the one case that contract does
not cover: `SPC y` went nowhere and `SPC p` waited for a reply nobody was going
to send, so paste was dead in the only mode of this shell a test can drive —
which is also why the SDL shell's clipboard had no coverage at all.
Null them, and the grid harness runs on the same in-process clipboard
`pardes-isolate` does. The two SDL functions then have no reachable
`gui == null` path left, so the dead guards go with them.
The regression is the round trip through the real seam: build the Shell the
harness builds, take the host off `Shell.host()`, assert it picked
`grid_vtable`, then `SPC y` and `SPC p` and check the bytes came back. It fails
on the old vtable at `clip_pending == null` — the paste that never arrived.
Verified end to end as well: `PARDES_TEST_GRID` with `x`, `SPC y`, `SPC p`
duplicates the line and marks the buffer dirty; the shipped binary does
nothing. The windowed shell is unchanged and still reads the desktop
clipboard (checked on wayland and x11, keys injected at evdev level, against
a real focused window).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
1297 lines to 498. The tutor had grown to the point where the things a
newcomer needs first were buried in the middle of a helix reference, and
two subsystems it never mentioned at all.
REORDERED by what is most different from editors people arrive from, and
each part now earns its place:
1 THE MOUSE acme's three buttons and the chords, trimmed
2 PANES NEW. Moving between them, in one place
3 THE TTY Ctrl-b, Shift-Esc, and what plain Esc does
4 DETACHED NEW. The core outliving the terminal showing it
5 THE KEYS the helix half, cut hard, practice blocks kept
6 THE REST PDFs, images, the language backend, scripting
PANES was scattered across 3.12, part 2 and the summary; it is one
section now, because "how do I get out of this pane" is the question
that actually gets asked. Ctrl-w hjkl, Esc, Shift-Esc, Ctrl-o/Ctrl-i,
Alt-n, Alt-c, and the three panes that have their own claim on Escape.
THE TTY gains the rule the old text got wrong. It said Esc "goes to the
program" in raw tty, full stop. It has not for a while: at a shell
PROMPT plain Esc hops away like Shift-Esc, and only a program that has
TAKEN the tty (vim, a pager) keeps it. That is `takesCommandLine` in
pardes.zig, and it is the difference between the toggle feeling
obvious and feeling arbitrary.
DETACHED did not exist in the tutor at all. `--detach`, `--attach`, the
Attach/Detach words and their chords, why the pane shells belong to the
session and never stop, why N frontends share ONE screen at the smallest
common grid, and that `--fs`/`--fs9` work in a daemon now.
THE KEYS lost the most: thirteen subsections became six, roughly thirty
practice blocks became six. What went is the enumeration a reference
does better; what stayed is the handful of blocks that teach the one
idea helix users do not arrive with -- motions SELECT, so `wd` is what
`dw` was -- plus the count rule, which is the other thing that surprises.
Part 6 documents `pty/`, `--fs9` and the `9p` word, none of which the
tutor knew about.
The first seven lines are byte-identical on purpose: test/snapshots/
tutor.golden pins them, and it regenerated unchanged.
ALSO REGENERATED: test/snapshots/builtins.golden, which had been stale
since the `9p` word was added a few changes ago -- one more builtin
shifts every row of the `SPC ?` listing below it. Nothing was wrong with
the code; the golden had simply not been updated with the feature. All
95 snapshot scripts pass.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`build.zig.zon` named `../05-zig-p4` as a path dependency, and `build.zig`
`@import`ed it inside `if (-Desp32p4-firmware)`. But `@import` in a build script
is resolved when the SCRIPT is compiled, not when the branch that needs it is
taken -- so naming the package at all meant anyone without that sibling checkout
could not build pardes AT ALL. Not the firmware: the terminal shell, the SDL
shell, the tests. `zig build` failed with
build.zig:1073: error: no module named 'zig_p4' available within module 'root.@build'
from a line inside an `if` that was false.
Neither escape hatch works for a PATH dependency, and both were tried rather
than assumed. `.lazy = true` is about FETCHING; a path dep whose directory is
absent is generated as a package with no `build.zig` rather than one marked
unavailable, so `b.lazyImport` -- which exists for exactly this and is what the
standard library says is to `@import` what `lazyDependency` is to `dependency`
-- reaches a `@compileError` instead of returning null. Making it a fetched
dependency instead is not available either: the toolchain has no remote.
So the duplicate goes. That build tree's firmware block linked an image the
toolchain repository already knows how to link -- its own build.zig has
`-Dpardes`, `-Dapp=<root>` and `-Dpardes-obj=<path>`, and its comments record
having learned this same lesson from the other direction, where nesting pardes's
~30-package graph under it broke every build there. The object is the seam: it
crosses by PATH and never by package, and each repository builds what it owns
the pieces of.
zig build -Dplatform=esp32p4 # here, no toolchain needed
zig build -Dpardes # there, the console image
zig build -Dpardes -Dapp=<pardes>/src/esp32p4_9p.zig # there, the 9P image
For the second and third to work with no module map, `src/board9p.zig` and the
9P firmware root now reach the codec by PATH instead of through a named `ninep`
module that only pardes's own build.zig knew to inject -- which is also why the
root moved from `src/esp32p4/nine.zig` up to `src/esp32p4_9p.zig`, beside
`src/esp32p4.zig`: a path import may not escape its module's own directory. Both
files are now self-contained, and `zig test src/board9p.zig` works with no flags.
`-Desp32p4-port`, `-Desp32p4-prof` and `-Desp32p4-cpu-mhz` go with the block. An
option this build cannot honour is worse than no option, because it accepts the
flag and then ignores it; all three are spelled the same way in the toolchain.
Verified by moving ../05-zig-p4 out of the way: `zig build`, `zig build
-Dplatform=esp32p4` and `zig build unit-test` all pass without it. With it back,
the toolchain still links both images -- console 812,720 B, 9P 88,096 B.
next-steps.txt gains the six features the 9P chain shipped.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Step 5 of the 9P chain (docs/9p.typ 12.5, docs/registry.typ 9P-22, 9P-11, BOARD-1).
THE CLIENT. `Client` in src/9p.zig is the mirror of `Server` and the same shape:
sans-io, no allocator, no threads, no descriptor, caller-owned buffers, and it
builds freestanding. 152 bytes of struct against the server's 9,488, because a
client owns neither a fid table nor a park table -- the far end does.
The API is submit / push+output+wrote / take. Completion is a PULL: a callback
would fire inside push, inside the transport's read, inside the host's poll
dispatch, which is exactly where fs9_service says filesystem work must not
happen. `take()` returns the next completed operation or null, which is
`Server.next()`'s loop-until-null contract read from the other side. Tags are a
fixed 16-entry table indexed BY the tag, so an out-of-order reply -- which 9P
allows and both reference clients rely on -- costs one bounds check. The reply's
TYPE is checked against the request's op, because a tag is only as good as the
table behind it. A `Done` borrows the input buffer and is valid until the next
call; `take()` releases the previous frame on entry, so the rule is mechanical
rather than remembered, and read data and error strings are zero-copy.
And one real caller, so this is not a library with no user: the `9p` word takes
a dial and a path, walks another instance's tree, and opens the bytes in a pane
like any other `Look`.
THE BOARD. A SECOND image, not a second role: the console runtime keeps UART0
bidirectionally and is behaviourally untouched. On the new one the UART carries
9P AND NOTHING ELSE -- no ANSI, no vaxis, no allocator, no heap module. The loop
is uart.read -> push / retry+next -> handle -> reply / output -> writeSome ->
wrote. `writeSome` is new and additive: `write`'s bounded spin DROPS bytes on a
stalled transmitter, which on a protocol stream truncates a reply mid-message
and desynchronises for good, where a short count cannot. BOARD-1's one divider
write raises the line to 921600.
88,000 B text, 49,424 B bss, an 88,080-byte image -- 5.7% of the 1,536,000 B
partition, against the console image's 809,536 B.
THE COMPTIME BRIDGE, which is the part worth reading. `board9p.caps` is the ONLY
place the GPIO tree is described; node ids, parents, names, permissions,
handlers, buffer size and the per-pin directories are all derived from it, and
`fan.dirs` makes `gpio/<n>/value` one table entry serving eleven pins. Modes are
derived from which handlers a file has rather than declared. A second capability
is a table entry, not new tree code.
JP1 became a real table in the new leaf `src/board_pins.zig`, with the ASCII
drawing RENDERED from it at comptime and the pin list COLLECTED from it -- the
9P image links no core and so cannot import board_memory.zig, and copying the
table was not acceptable. A golden test pins the drawing byte for byte, the
console's own shape test still passes, and the identical bytes are present in
all three artifacts.
PROVED. Two daemons: B read A's `/1/body` through the `9p` word into a pane,
byte-identical to plan9port's `9p read` of the same path. Both board images
build. No hardware was attached, so nothing about the board is claimed beyond
what builds and what the host tests cover.
zig build unit-test 585/585. fs-bench unchanged and still zero allocations on
every read row.
---
REVIEW FIXES FOLDED IN. Steps 3, 4 and 5 were verified on the happy path and
then adversarially reviewed by three agents; eight defects, six fixed here, five
of them reproduced with measurements before and after. Full writeup in
docs/registry.typ `9P-27`. In brief:
* a remote crash of the WHOLE daemon: one `size[4]` of zero plus one byte hit
`unreachable` in `fs9_service.fill`. Also 99.7% of a core when the stuck
buffer made `room == 0` return without reading. Now `srv.dead` is a hangup,
checked before the room guard.
* the editor froze 177 s on a dial: `connect(2)` ran on a still-BLOCKING
socket before the deadline existed, and a full accept backlog waits forever.
Now non-blocking with the wait spent against the budget. After: 2.03 s.
* a 64 KiB pty read is exactly `queue_cap` and wiped every unread byte AND
dropped itself. `notePtyOutput` splits at half the cap. Deterministic.
* four silent sockets denied `--fs9` forever; connections now expire on the
same five-second rule the frontend transport already had.
* EMFILE spun a core; the listener pauses and leaves the poll set, as the
frontend listener does.
* `max_fids = 32` made `find` over `9pfuse` fail with 57 consecutive
`Rerror`s -- refuting this step's own acceptance clause. 256 for a host,
`board_fids` 32 for the microcontroller.
Found clean and worth recording: `sig` reaches the foreground process group; the
two-namespace pty lookup is right over both transports; `PaneFile`'s u4 wall is
guarded; reader counts release on every abrupt-death path; `fs_origin` routing
and the reply arithmetic hold under probing.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Step 4 of the 9P chain (docs/9p.typ 12.4, docs/registry.typ 9P-15/16/17/4/5).
src/9p.zig is a base 9P2000 codec and a SANS-IO server: it never touches a
descriptor, takes no allocator, starts no thread, and builds for
wasm32-freestanding and riscv32-freestanding. That is what lets the same code
serve a unix socket here and a UART on the board later.
Server(comptime fs: type) duck-typed on fs.Req/fs.Reply/fs.Reply.Attr, so it
never imports acmefs and acmefs never learns 9P
init{ in, out, root } the caller owns the buffers; msize is derived
retry/next/reply the three fs_service.Transport ops, by name
push/output/wrote/hangup bytes in, bytes out, partial writes supported
next() is a PUMP, not one-message-one-request: a 3-element Twalk is three
lookups, Topen|OTRUNC is a setattr then an open, Tversion is none at all.
Decisions that were open and are now taken, each recorded in the file:
* qid.version is ALWAYS 0, which makes Linux set P9L_DIRECT and skip its
cache -- the 9P equivalent of the FOPEN_DIRECT_IO fuse.zig relies on.
* Every Rread is clamped to the client's count. An over-long one is a hard
-EIO in Linux, not a truncation.
* Rerror carries Linux's exact strerror text (registry 9P-4 option A), so a
mount recovers the errno instead of ESERVERFAULT. Asserted as literals,
because a typo there is 'Unknown error 526' on every mount.
* `.` and `..` are resolved BY THE SERVER. Under FUSE the kernel does it
and acmefs says so; 9P has no kernel, and forwarding `..` as a lookup
would break every client that normalises a path.
* Topen checks the perm bits itself. Under FUSE the kernel enforced them;
over 9P nobody is above the server, and `errors` would have been readable.
* Tcreate and Tremove are Rerror: `new/` creates a pane on WALK, so the
capability exists and is not spelled Tcreate.
THE INTEGRATION BUG, which was not in the protocol: the daemon's push_fs_reply
sent every reply to the FUSE mount, whose park table has no 9P tag, so it
dropped it -- Tversion worked (no core involved) and Tattach hung forever. That
is exactly the 'no routing origin for the 9P descriptor' cell in the layering
table of docs/9p.typ. Session.fs_origin now carries the transport that asked.
Proved with plan9port against a live daemon serving BOTH transports at once:
9p ls / and /1, read index/ctl/tag, write /1/body, stat, a walk through
/1/../index, pane creation through `new/body`, and the two refusals arriving as
strings -- 'permission denied' and 'No such file or directory' -- confirmed on
the raw wire as Rerror text rather than numbers. A write over 9P reads back
through FUSE and a write through FUSE reads back over 9P.
msize 8192, 34,072 bytes per connection (Server 9,488 + in 8,192 + out 16,384,
out being two msize so that every reply is infallible), four connections.
zig build unit-test: 468 tests before, 503 after.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Review fixes to steps 1 and 2, found by an adversarial pass over the committed
chain. One is a real bug and the rest are comments that were false.
THE BUG. `waitInput` put the FUSE descriptor in the poll set whenever the mount
existed, and the `.fuse` arm ignored every revent. Linux's `fuse_dev_poll`
answers EPOLLERR once the connection is gone, and POSIX reports POLLERR whatever
the events mask asked for -- so an external `fusermount3 -u`, a sysfs abort, or
systemd taking /run/user/$UID away at final logout (exactly when a detached
session is supposed to keep running) made poll(2) return instantly, forever. The
daemon then spun the whole pump at 100% of a core for the rest of its life, and
re-offered every parked slot to the core at that rate.
Measured on the unfixed commit: 0 CPU ticks over 10 s idle, then 1000 ticks over
the next 10 s after unmounting its own mount point. Measured after the fix: 0
ticks over 8 s in the same scenario, process alive and in state S.
fuse.zig's own poll thread has carried the equivalent guard all along, which is
why the desktop shells never showed this and the daemon did.
THE COMMENTS, each checkable and each wrong:
* `Source.fuse` said the drain is not done in `dispatch` to avoid re-entering
the core. Both `pull_wait_input` and `push_poll_frame` are called from
inside `pump`, so either re-enters. The real reason is that `dispatch` is
mid-iteration over a SNAPSHOT of the descriptors, and one `push_spawn`
replaces a pane's master under it.
* `fuse.zig`'s new import said "no cycle". There is a cycle: fs_service
imports fuse.zig back and still names `*Fs` in three signatures.
* `Transport`'s rationale said tty.zig and gui.zig store one in a struct
field. Neither does; all four call sites build it inline.
* `deinit` justified its ordering against "as long as the harvest takes".
`harvest` is waitpid(WNOHANG) and blocks for nothing. The real reason to go
first is that aborting the connection wakes a parked reader while its own
shell is still alive to run its exit path.
* `harvest` claimed pane shells are the only children this process forks.
Since step 1 it also forks `fusermount3`, three times over -- reaped by its
own spawner, so the conclusion holds and the premise did not.
* docs/detached.md, docs/lsp.md and docs/design.typ still said the daemon
implements sixteen of twenty-one methods and mounts no /dev/fuse.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Step 3 of the 9P chain (docs/9p.typ 12.3, docs/registry.typ 9P-8). Nothing here
is about 9P: it lands in the FUSE-served tree and any later transport inherits it.
A script could write into a terminal that already existed and read its rendered
scrollback. It could not START one, RESIZE one or SIGNAL one. Two of those were
already effects the core emits, so `exec` and `winsize` are existing
capabilities acquiring a name; only `sig` is new, and it brings the one new
host method, `push_pty_signal`.
pty/ctl winsize <cols> <rows> | sig INT|TERM|HUP|QUIT|KILL | exec
one verb per line, validate-all then apply-all, EINVAL applies
nothing -- `writeCtl`'s shape and `writeCtl`'s reason
pty/status cols, rows, tty-taken as three %11d fields
pty/data write is input to the process; read is the RAW output stream,
gated on a reader count so a pane nobody reads costs one branch
A pane that is not a terminal has no pty/ at all: the lookup is ENOENT and
readdir does not list it.
`PaneFile` is an enum(u4) and this takes it from 11 values to 15. ONE REMAINS.
That is also why pty/ is a DIRECTORY and not three more flat names -- a
subdirectory costs one value and buys its own namespace, so `ctl` and `data`
did not have to be renamed.
Two things the core does not know, and which are therefore not invented: a
child's EXIT STATUS (a shell's death is `Event.eof`, which removes the pane,
so there is no directory left to read it in) and RAW/COOKED (the core never
sets a termios; the mode belongs to the program on the far side).
Verified live against a daemon: pty/ appears only on the terminal pane; a
`winsize 0 24` and a `sig SIGINT` are refused; a bad verb beside a good one
applies neither; `echo pty-works` written to pty/data runs in the shell and its
output reaches the body; and a blocking read of pty/data returns the raw stream,
OSC 133 marks and all. fs-bench unchanged and still zero allocations.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Step 2 of the 9P chain (docs/9p.typ 12.2, docs/registry.typ 9P-2).
`drain` and `step` never asked a `*fuse.Fs` for anything but `retry()`,
`next()` and `reply()`, so the concrete pointer was a coupling that bought
nothing and forbade a second answer. `Transport` names the three; `Fs.transport()`
is the first implementor and the thunks are the entire cost.
No behaviour change. The order contract -- retry() to null, then next() to null --
moves into `drain`'s doc comment, where it belongs: it is the caller's rule and
every implementor inherits it, rather than a fact about FUSE.
`start` and `wake` keep their `*fuse.Fs`: they are about a MOUNT, which is a
FUSE thing, and a 9P listener will bring its own.
Measured unchanged against zig build fs-bench -Doptimize=ReleaseFast: getattr 19 ns,
lookup 40, read body 4K/1M 25/25, read ctl 385, read index 633, readdir 38,
read event (empty) 22, all at zero allocations.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
with no thread
Step 1 of the 9P chain (docs/9p.typ 12.1, docs/registry.typ 9P-14).
A detached session was the one configuration no script could drive. The core,
the panes and the undo history outlive every frontend that attaches -- and the
filesystem that would let a program read or change any of it was never mounted,
because `push_fs_reply` was one of the host methods this process left null.
Nothing prevented it; the call was simply not there.
It costs less here than in the desktop shells. They start a thread that blocks
on poll() and pokes a loop it does not otherwise share (`fs_service.wake`);
this process already runs ONE poll over its listener, its frontends, its pane
shells and inotify, so /dev/fuse is one more descriptor in the same syscall and
there is no thread at all. `Source.fuse`'s arm does nothing on purpose: being
in the set is the whole point, because the wake must end the sleep so that
`pollFrame` -- which runs after `pull_wait_input` returns, where re-entering
the core is legal -- reaches the drain.
`main.zig` refused `--detach --fs` outright, with a comment saying that
serving it would mean mounting FUSE in the detached core and that this was a
feature rather than a fix. It was right, and this is the feature. `--attach`
is still refused: a frontend has no core to serve.
Verified against the project's own clients: examples/acmefs/pardesctl panes,
new, send, body and del all drive a daemon, and the pane shells it forks now
inherit PARDES_FS/PARDES_PANE like every other host's.
|
| | |
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Esc stops recentring
## A terminal row's ANSI colours survive being edited
The loudest colour bug this editor had: one keystroke anywhere in a coloured shell row turned EVERY
column of it grey. `EditAnchors` anchored a buffer line only when it was BYTE-IDENTICAL to the shell
row it stood over, so a single differing byte dropped the whole row's colour projection. Worst shape
is invisible: append past the pane's right edge, where the text is clipped, and the row looks the
same and only its colour goes.
Anchoring is byte-level now. An edit leaves the row's own bytes at both ends, and being the same
bytes they keep the same colours; only what was typed has no cell under it, so only that takes none.
Live, on real `fastfetch`: a 32-column blue run split into 6 + 26 around one typed character.
Three defects underneath it, all found by machinery rather than by reading:
* A JOIN removes a buffer line while the buffer's covered span grows, so `lines == covered` and both
aligned guesses — Nth line over the Nth covered row, and the same counted from the bottom —
resolved to the SAME wrong row. Every untouched row below a join went plain. Anchoring is now a
streaming monotone matching: one shell-row cursor that only ever moves forward, advanced once per
buffer line, linear in the buffer where the version before it was quadratic.
* An EMPTY line is not evidence. Splitting a row makes one, it equals every blank row in the span,
and left free to look ahead it claimed the blank row below the last output and took every coloured
row in between out of reach of the lines that owned them.
* Reflow under a scrolled viewport. `PageList.getTopLeft(.viewport)` returns the viewport pin
verbatim, x and all, while `PageList.pin` forces x to 0 — so after a reflow remapped a tracked pin
into the middle of a row, the text pass dumped row 0 from that column while the colour pass paired
the fragment with the row's FIRST cells. Row 0 wore its left half's colours until the pane snapped
back to live output. `bodyText` dumps from column zero now, which is also what ghostty's own
renderer draws.
Also here: DECSCNM (reverse video) was silently dropped whenever `tty_filter` was off, because the
raw path resolved a `.none` colour by role and never consulted the mode.
The test that found the first two is the one worth keeping: random editing against an ABSOLUTE
oracle — every row's own text names the colour it must have — because the differential oracle it
replaced was blind by construction. It skipped the edited row, which is the row the user is
complaining about.
## Esc returns to a pane without moving its view
Esc in body normal mode runs `Last`, "the pane you were in before this one", and that went through
`focusPaneLine`, which recentred a file on the target line unconditionally. So returning to a buffer
repainted the whole screen to show a line that was already on it.
`focusPaneLine` takes a landing now: `.center` for the three callers going somewhere you have not
been (a look target, a path a pane already holds, `@pN:LINE:COL`), `.keep` for Esc. `.keep` leaves
the view alone and lets `ensureCursorVisible` — which already existed and already scrolls by the
minimum into the `scroll_off` band — be the only thing that may move anything.
Not `line = 0`, which `focusPaneLine` already understands as "focus and touch nothing": a background
pane's view can move while you are away, because the wheel scrolls the pane under the POINTER and a
resize reveals no cursor, so the recorded cursor plus a minimal nudge is what actually gets you back.
Ctrl-o and Ctrl-i keep centring, and the asymmetry is structural rather than arbitrary: `Last` only
ever CROSSES panes, so the pane it lands on already holds the view you left it with, while `jumpBy`
can land in the SAME pane, where a long in-file jump would arrive on the very top or bottom row with
`scroll_off` lines of context on one side. Helix splits the same pair the same way — its jumplist
centres, its buffer switch does not.
One deliberate consequence: under `.keep` a PDF's page is not restored AT ALL, because a page reveal
IS that pane's view and a reveal of the page you are already on still snaps `document_scroll_y` to
that page's start, discarding where you had read to. When something moved the pane while you were
away — the wheel again — Esc leaves it where the wheel left it, and Ctrl-o is how you reach the
recorded page.
## host_io.zig: the machine-local half of a host, once
`host.zig` is the seam. The part of the answer that is identical on every host with an operating
system under it — fork a pane's shell, put bytes on a disk — was written FOUR times: in tty.zig,
gui.zig, macos.zig and detached/server.zig. What those copies had in common says what they were for:
all four were missing FD_CLOEXEC on the pty master, so in every shell pardes has shipped, a program
in one pane could read another pane's terminal.
One copy now, and the wire got smaller for it: `ServerMsg.spawn` is gone. A frontend never asked the
server to fork anything — the server has an operating system under it and forks through `host_io`
like every other host — and `decodeClient` lost the scratch buffer that message needed.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
board cap on one screen
## The wire is the effect stream, not a new protocol
`pardes --detach` leaves a core running with no terminal; `pardes --attach` is a frontend that owns
a terminal and a socket and nothing else. N frontends on one core all look at the same screen —
`screen -x`, not N sessions.
The codec (`src/detached/wire.zig`) carries exactly one `Event` or one `Host.VTable` call per
message. That is not a coincidence and it is why there is no third vocabulary to keep in step: the
core's IO seam was already a struct of function pointers with plain-data arguments, so a socket is
a legal implementation of it. `nested.zig`'s socket could not be reused — it carries a builtin
command line, and a command line cannot carry a frame.
ARCHITECTURE-NEUTRAL on purpose, not as decoration. The frontend on the far end may be
riscv32-freestanding on the ESP32-P4 while the core is x86_64 Linux, so every field is an explicit
little-endian fixed width and no message is a blit of a native struct. A protocol that only works
between two builds of the same compiler would have thrown away the one frontend that motivated it.
## The board comes in; its toolchain stays out
`src/p4.zig` becomes `src/esp32p4.zig`, and the pardes half of `../05-zig-p4` — the vaxis-over-
serial runner, the UART editor terminal, the keystroke rescue ring, the on-die test suite — moves
into `src/esp32p4/`. `build.zig.zon` gains `.zig_p4 = .{ .path = "../05-zig-p4" }`, so
`zig build -Dplatform=esp32p4 -Desp32p4-firmware` builds, flashes, monitors and self-tests the
board from this repo's `build.zig`.
The DIVISION is the point. What moved is what only pardes wants: the runner that drives a pardes
core over a serial line. What stayed is everything a second project would also want — the HAL, the
register/radio/oracle layers, the linker script, `_start`. `zig_p4` declares no dependencies of its
own and its `build()` early-returns when it is not the root package, so this costs the package
graph exactly zero packages and the editor's own builds nothing at all.
## limits.zig: nine forgettable places become one budget
Nine `platform == .esp32p4` capacity tests lived in nine files. They were never nine decisions —
they are ONE decision, how much memory this build may spend, taken nine times where no reader could
see the total. `src/limits.zig` puts the whole budget on one screen with every cap named against
what it is measured against, derived from two booleans.
The payoff is testability on a machine that is not the board: the caps are ordinary comptime values,
so a host build can be compiled against the board's numbers and the parking, eviction and clamping
paths a 240 KiB core takes get exercised by the normal test suite instead of only over a UART.
## A bare `zig build`
`zig build` with no arguments now builds the tty and GUI binaries and installs them into
`~/.local/bin`, and says so once on stdout with the flag that overrides it. The old default built
one binary into `zig-out` — a path nothing on a `PATH` ever looks at, which made "build it" and
"use it" two different commands for no reason.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
the P4
## Gpio
`Gpio 33` flips one pad and answers on the message row with what it did:
GPIO 33: 0->1
GPIO 33: 1->0
Bare `Gpio` draws the header instead, because the first question about a header is which pins it
has. The pin number is DECIMAL and it is the only literal in board_memory.zig that is - every
other one is an address, and addresses come off datasheets and linker maps that print hex, which
is why that file made everything hex two commits ago. A GPIO number is not an address, it is part
of a NAME: the schematic says GPIO47, the datasheet's pin table says 47, and `Gpio 20` meaning pin
32 would be a trap laid for the one argument anybody types from memory.
## The toggle is the host's, not the editor's
New `Host.VTable.pull_gpio_toggle`, and a `GpioFn` in the p4 ABI (hence version 2), rather than
board_memory reaching for GPIO_OUT the way `Poke` two functions above it would happily do.
Writing that register is not the job. A pad has to be pointed at the GPIO function in the IO MUX,
routed in the GPIO matrix, given drive strength and an input buffer with its pulls cleared, and
only then driven - four register files behind a per-pin table. That code already exists in
`05-zig-p4/src/hal/gpio.zig`, it is the same `configureOutput` the blink demo has always used, and
its register numbers are checked against ESP-IDF's own headers on the die by `zig build diff`. A
second copy inside the editor object would be a second copy under no test, and getting it wrong on
a pin that boots as something else is how you lose the console you are typing on.
Reported levels are the OUTPUT bits, before and after, because that is what a toggle means: the
level this board is driving. A pad's input buffer on an unconnected header pin reads the air.
## JP1, read off the schematic rather than remembered
The diagram is the vendor's own wiring, from sheet 2 "Expand IO" of
`01-esp32p4-m3/docs/JC-ESP32P4-M3_schematic.pdf` - the only document that carries this mapping. The
specification PDF's "Interface Description" page turned out to be a marketing render, and there is
no board user guide; the chip datasheet has a package pinout, which is not a header.
That sheet is a 872x1168 raster (`pdfimages -list` - the PDF embeds no vectors, so rendering it
larger adds nothing), and at that size the rows around pin 14 are genuinely ambiguous by eye. So
the mapping came from the drawing's geometry instead: thirteen wires leave each side of the symbol,
a net wire runs ~100 px to its label and a power stub ~21 px. Pin 8's wire is 21 px, which is what
identifies it as unconnected rather than as the first of the GPIO4x labels - the reading that had
GPIO47 one row higher and shorted GPIO45 to the ground bracket.
Cross-checked against a second source that has been in the tree all along: `05-zig-p4/build.zig`
documents `-Dled=20` as "JP1 pin 17", and GPIO20 lands on pin 17 here. Both facts are asserted in
the test, so the diagram cannot drift from either.
## Peek, Poke, Hexdump and Gpio are now the P4 build's alone
`board_memory.enabled` was `os.tag == .freestanding and !isWasm()`, on the argument that these
words are a property of having no operating system rather than a product configuration, and that a
predicate spelled out of `builtin` cannot drift the way a hand-maintained enum can.
Tidy, and it answered the wrong question. A word only exists if some shell offers it, and the
shells are the platforms. `Gpio` settles it beyond argument: its whole content is one board's
header, and a second freestanding port would need its own pinout rather than inheriting this one.
"Bare metal" was never the requirement, "this board" was, and the two only looked identical
because there is currently one of them. The old predicate's real work was excluding wasm -
`freestanding` too, where an address is an offset into a linear memory the engine owns - and naming
`p4` excludes it by construction instead of by a term somebody has to keep remembering. The target
is now the witness rather than the gate.
Absent means not compiled: the tty binary contains no `+Gpio`, no `+Hexdump`, no `ES_I2C_SDA` and
no `MisalignedAddress`.
## The boot buffer's lines are checked, not eyeballed
Three times now a line in that tour has been one or two characters too long for a 56-column grid,
and every time it was found by reading the die's screen - the expensive way to measure a string
literal. The text is a named `boot_buffer` with a test over it, six lines came down to fit with
margin, and the tour gained `Gpio`.
Tests: the pinout's width, its thirteen aligned pin rows, GPIO20-on-17 and pin-8-unconnected; the
decimal-versus-hex distinction; every boot-buffer line. Full suite green - unit-test, snap 95/95,
hxdiff 481/0, hxparity 561/0, image-harness, pdf-harness, mupdf-check - and tty, p4, gui,
p4 at 80x24, p4 with the fade forced on. On the die `p4-bench --check` is 5/5, the fifth being a
new one: three `Gpio 33` runs must report 0->1, 1->0, 0->1, because the alternation is the only
oracle a hardcoded string could not fake.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A theme change moves the anchored chrome palette - taglines, boxes, line numbers, scroll
bars - from the old colors to the new ones over ten display frames. On a screen that
repaints in microseconds that is a short legible transition, and it is why the code exists:
a palette that teleports reads as a glitch.
On a 115200 serial line it is not a fade. Each of the ten steps recolors every anchored
cell, so the diff finds the whole chrome dirty and spends a frame's worth of wire on it, ten
times over, with nothing else on screen to look at. Measured on the die, one `NextColor`:
fade on 12,593 bytes 1,097 ms of saturated wire
fade off 2,425 bytes 215 ms
A second of the editor talking to itself about a color, on the one transport where a second
is noticeable, for a gradient nobody can watch arrive at 11.5 KB/s.
## Comptime, so the code is not there
`ChromeAnimation` now selects between `animation.Transition` and a new `animation.Immediate`
- the same interface with the animation taken out, a value that is only ever what it was
last set to. That is what makes `ChromeTheme.interpolate` unreachable, and unreachable is
what makes it absent: the flashed image drops 2,336 bytes, and the object 13,180.
A bool tested at runtime would have kept every one of those bytes and still paid the
branch. It also would have needed a second meaning bolted onto `animate_theme_changes`,
whose job is the startup window and nothing else; that field is untouched here.
The option is `-Dtheme-animation`, defaulting to off for `p4` and on everywhere else, and it
is an option rather than a platform test because "is a frame expensive" is a property of the
transport: a P4 driven over something faster than a UART would want the fade back, and
`-Dtheme-animation=true` gives it to them.
## What was checked
`Immediate` is new code with one contract worth pinning, and it is the one a caller could
get wrong: it must arrive at the SAME palette a completed fade arrives at. An endpoint that
differed by a rounding step would make the option a change of colors rather than a change of
how long they take. Tested against a fully advanced `Transition` in `animation.zig`.
Full suite: unit-test, snap 95/95, hxdiff 481/0, hxparity 561/0, image-harness, pdf-harness,
mupdf-check. Builds: tty, p4, gui, and tty/gui with the fade forced off. On the die the
canonical verifier reports the screen IDENTICAL across both arms - the workload contains no
theme change, so this is the check that ordinary rendering was not perturbed - and
`p4-bench --check` stays 4/4.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two columns back, and a better reason than the two columns.
Every number these words read is hex - there is no other kind, and they refuse a decimal
one - so a prefix on the output restates what the whole file already says. Dropping it buys
something worth more than the width: an address in a dump can be typed straight back into a
Peek without editing it, because bare hex is exactly what the parser now wants. Output that
is valid input beats output that is decorated.
No platform question to answer either: `enabled` is freestanding-and-not-wasm, so these
three words exist only on bare metal. There is no host format to stay consistent with.
On the die, 44 columns of a 48-column body:
40000020 32 54 cd ab 00 00 00 00 |2T......|
40000030 30 2e 31 00 00 00 00 00 |0.1.....|
5011002c: wrote deadbeef, reads deadbeef
5011002c: deadbeef
501101a4: fc48777d
501101a4: 4b4ae238
The last two are the same command twice - LP_SYSTEM_REG_RNG_DATA, which is what makes it
the honest demonstration that a register is not memory.
unit-test, and `p4-bench --check` 4/4 on the board.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
## Eight bytes a row on the P4
`hexdump -C`'s sixteen needs 79 columns: ten for the address, forty-eight of hex, a gap,
and eighteen of ASCII gutter. The board drives 56 columns of which seven go to the line
numbers, so every row wrapped onto a second display line and the columns stopped lining
up - which is the entire value of the layout. Eight fits in 46 and keeps every property
that matters, including a gap at the halfway mark, because the eye counts in fours and
eights rather than in sixteens.
Verified on the die:
0x40000020 32 54 cd ab 00 00 00 00 |2T......|
0x40000030 30 2e 31 00 00 00 00 00 |0.1.....|
That is the app descriptor: 0xABCD5432 and the version string, read out of flash by a
command typed with no 0x on either argument.
## The boot buffer is shorter, and its addresses are named
The first draft opened with four lines of prose explaining that there is no operating
system. True, unhelpful, and it cost a third of a fourteen-row window before the first
command. One header line earns its place; the rest of the screen is addresses.
The two LP registers at the end are now named, because they are named in ESP-IDF's own
headers and the names are the interesting part: 0x5011002c is LP_SYSTEM_REG_LP_STORE0, a
general-purpose retention register that holds what you put in it, and 0x501101a4 is
LP_SYSTEM_REG_RNG_DATA, the hardware random generator. Between them they demonstrate the
whole point of a volatile read - one address gives back what was written, the other never
gives the same answer twice:
Poke 5011002c deadbeef -> 0x5011002c: wrote 0xdeadbeef, reads 0xdeadbeef
Peek 5011002c -> 0x5011002c: 0xdeadbeef
Peek 501101a4 -> 0x501101a4: 0x0b099791
Peek 501101a4 -> 0x501101a4: 0xfc97f3b7
All four run on the die, all with bare hex. Peek and Poke had not been tested there before
this - only Hexdump had, which I had let stand as though it covered all three.
snap 95/95, hxdiff 481/0, hxparity 561/0, unit-test, tty/p4/gui.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
the bus
## Hex, always
Base-0 parsing accepted `0x4ff40000` and `1341390848` and refused a bare `4ff40000`, on
the grounds that guessing between hex and decimal would let one typo address somewhere
else entirely. The reasoning was sound and the conclusion was still wrong: the ambiguity
it guarded against is not a real one. Every address anybody has ever typed at these three
words is hex - it came off a datasheet, a linker map, or a previous dump's own output, all
of which print hex - so the base was never in doubt, and demanding `0x` on every one of
them was a toll on the common case to protect a case that does not arise.
The COUNTS go with them, and that is the part worth saying out loud rather than leaving as
a surprise: `Hexdump 4ff40000 100` shows 0x100 bytes, which is 256, not one hundred. One
rule for every literal beats two rules that each fit their own argument better, because
the second kind has to be remembered at the moment you are concentrating on something
else. What these words PRINT is hex too now, clamp notes included, so a number can go back
in where it came out.
## And the board boots into somewhere worth looking
The empty output buffer was honest and useless. The three words that make this port
interesting all take an address, and a board's address space is precisely the thing you
cannot guess - so the boot buffer is now a tour of it: the image's own rodata and code in
flash, the firmware's data and the editor's heap in L2MEM, the mask ROM, UART0, the
systimer, GPIO_OUT and an IO_MUX pad, and one harmless Poke.
Every address comes from this repository rather than from memory, which is what makes them
worth trusting: the flash and RAM figures are the linker script's own ORIGINs in
`05-zig-p4/build.zig`, and the peripheral bases are the `DR_REG_*` values `05-zig-p4/src/hal`
uses. Each command sits alone on its line because an argument list ends at the last
argument - a trailing comment would be `ExtraArgument` - so the notes go above the lines
they describe. Lines are kept inside 48 columns because the first draft wrapped every one
of them at the 56-column grid, which reads like a bug.
Verified on the die: the buffer renders one line per line, and putting the cursor on
`Hexdump 40000020 60`, selecting with `x` and pressing Tab opens a dump whose first bytes
are `32 54 cd ab` - 0xABCD5432, the ESP app-descriptor magic - with the version string
right behind it. Bare hex, no prefix, reading real flash.
snap 95/95, hxdiff 481/0, hxparity 561/0, unit-test, tty/p4/gui.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The 40x12 ceiling was never about the screen. It was about memory, and the comment above
`max_cols` said so: "every cell is paid for four times over: vaxis keeps a Screen and an
InternalScreen, pardes keeps its own Surface and previous_cells". Two of those four are now
dead weight - with `direct_emit` the emitter diffs the Surface against its own shadow and
writes the escapes itself, so vaxis's two grids are allocated, never read, and were the
largest single claim on a 384 KiB heap. `init` sizes them to ONE CELL. vaxis still does the
work only it can do: the alternate screen, the capability queries, and parsing everything
that comes back.
That removes the memory ceiling entirely - the heap now reports 336 KB free at every
geometry tried, including ones that used to fail - and leaves latency as the only limit,
which is the honest one: every frame walks the whole grid.
## Measured on the die, 0.87 us per cell
geometry cells round trip
40x12 480 3,628 us the old default
56x14 784 3,930 us the new one
56x16 896 3,965 us
60x18 1,080 4,114 us
64x20 1,280 4,281 us
80x24 1,920 4,809 us
100x30 3,000 5,743 us
120x36 4,320 6,923 us
140x42 5,880 8,310 us the largest that runs
160x48 7,680 links, then traps
200x60 12,000 does not link
56x14 is 63% more area and 40% more width than 40x12 and still holds the 4 ms this port was
built to. 56x16 was tried first: 3,965 us on the bench instrument but 4,029 on the
phase-randomised one, which is over, and the two instruments differ by about 50 us
systematically - so the wider grid went and two rows stayed behind. Width is worth more than
height for reading code.
The two failures at the top are worth naming precisely because they are different failures.
200x60 does not link: `.bss will not fit in region l2mem, overflowed by 76036 bytes`, that
`.bss` being the shell's shadow copy of the grid, sized at comptime. 160x48 links and then
TRAPS at boot - the same region pressure arriving at runtime as a collision rather than as a
diagnostic. Neither is a heap problem any more, which is the interesting part: the heap has
336 KB spare while `.bss` runs out.
`-Dp4-cols` / `-Dp4-rows` because none of the above is a constant. 80x24 is one flag away for
anyone who would rather have the classic terminal than the millisecond.
Verified at the new geometry rather than assumed: the A/B against the reference path - vaxis
rendering, full repaint, `shadow_grid` and `direct_emit` both off - is identical in every
cell, characters and resolved style. That matters more here than usual because the emitter's
column arithmetic has a special case at the last column, and 40 was the only width it had
ever been asked about. snap 95/95, hxdiff 481/0, hxparity 561/0, unit-test, both A/B arms,
tty/p4/gui, and the board's own `p4-bench --check`.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The mouse did not work. Chasing that found something much larger: NO escape sequence
worked on this transport, and had not since the port began.
`vaxis.Parser` resolves a buffer containing nothing but 0x1b as the Escape KEY. That is
deliberate and correct for a terminal, where the kernel hands over a whole escape sequence
in a single read, so a solitary ESC really does mean somebody pressed Escape. A 115200
serial line hands over ONE BYTE AT A TIME - 87 us apart, an eternity to a loop running at
360 MHz - so the first byte of every sequence arrived alone and was resolved as Escape,
and the remaining bytes arrived as ordinary keys.
A mouse click therefore came through as TEN key presses: Escape, `[`, `<`, `0`, `;`, `1`,
`8`, `;`, `3`, `M`. The `0` among them is "go to column zero" in normal mode, which is
exactly where the cursor kept landing, and why the first attempt at this looked like a
coordinate bug. Arrow keys, function keys, and the host bridge's in-band resize reports
were all being taken apart the same way.
Longer partial sequences were never affected: the CSI scanner returns `n == 0` for "no
final byte yet" and the shell already keeps those bytes. Only the one-byte case needed an
answer, because it is the only one the parser answers WRONGLY instead of declining. So the
shell holds a buffer that is exactly one ESC and lets `pardes_p4_tick` release it after
10 ms - two orders of magnitude longer than the 87 us until the next byte of a real
sequence, and imperceptible to a person pressing Escape. The same trade every terminal
editor makes, for the same reason.
Finding it took instrumenting the ABI: printing `@tagName` of every event the shell
applied. Ten `key_press` where one `mouse` belonged is not a thing any amount of reading
the coordinate arithmetic would have shown, and I had already read it twice.
## Mouse reporting, and the 1003 that is not requested
With the sequences intact, `apply` already handled `.mouse` - it mirrors the tty shell - so
enabling reporting was the only missing piece. Spelled out here rather than taken from
`vx.setMouseMode`, which asks for `1002;1003;1004;1006`: 1003 is ANY-MOTION tracking, a
report per cell the pointer crosses with no button held. On a 115200 line that is dozens of
15-byte reports for one sweep, arriving as input the editor must parse while it paints, and
arriving whether or not anyone wants it - moving the mouse over the window would starve
typing. 1002 reports presses, releases and motion while a button is held, which is exactly
what a click and a drag-select need.
Verified on the die: a click at column 12 puts the cursor at column 12 and one at column 22
puts it at column 22, a drag paints a selection, and the wheel scrolls. A press alone paints
the new position and then reverts - the caret does not move until the gesture ends - so the
release is what commits it, which cost an hour of believing a working click was broken.
Screen byte-identical to the vaxis reference, round trip median 3682 us against 3682, snap
95/95, hxdiff 481/0, hxparity 561/0, unit-test, tty/p4/gui all build.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
bridge on every frame
Two findings, both in the P4 shell's own `present`.
## std.mem.eql was the largest read in the firmware, one byte at a time
The shadow-grid diff compares each row against the previous frame: two 13 KB streams,
every frame, and by far the biggest memory access the firmware makes. It measured 3.2
cycles per byte, which is about four times what word-wide loads need - the shape of a
byte-at-a-time loop, and `std.mem.eql` is what it was.
`sameBytes` compares a `u32` at a time and falls back to the byte loop when the spans are
not aligned for it. The alignment test has to be a RUNTIME one because `Cell` is all `u8`
fields and therefore has alignment 1: whether a row begins on a word boundary is a
property of whoever allocated the Surface, not of the type. A row is 40 cells of 26 bytes,
divisible by four, so an aligned base makes every row aligned.
The answer is bit-for-bit the same - this is still exact byte equality - so it keeps the
property the whole diff rests on: byte equality implies visual equality, so the diff can
never claim two different cells are the same.
Measured on the die: the grid walk 223 -> 66 us, 0.95 cycles per byte. 157 us off every
keystroke at every document length, and the single largest win since the clock raise.
## A frame that only hides the cursor still has to fill a USB packet
The padding added for the bridge's 32-byte bulk-IN packet covered the branch that
positions the cursor and not the branch that hides it. A frame that only hid the cursor
was six bytes and waited out the bridge's timer. Hiding an already-hidden cursor is as
idempotent as positioning it twice, so it pads the same way.
The packet size is no longer inferred from an experiment either: 32 is `wMaxPacketSize` of
endpoint 0x82 as the device reports it, and the sweep over pad targets confirms what it
implies - 0 and 16 sit at 4.7-5.1 ms, while 32, 48 and 64 all sit at 3.6-3.8 ms. Crossing
the boundary is worth about 950 us; going past it buys nothing.
## Result
length 0 20 40 80 160 320 640 chars
RTT 3602 3624 3638 3790 3868 4026 4192 us
Fixed cost 3652 us against a 4 ms target, from 16.99 ms where this started. A
phase-randomised instrument agrees over 80 trials: median 3687 us, minimum 3571, maximum
3912 - every trial under 4 ms.
The two lengths still above 4 ms are the ones where the line has outgrown the viewport, so
the cursor is off screen and the keystroke changes NOTHING: the frame is 36 bytes of
cursor-hide and padding, zero cells changed, while pardes still rebuilds all 480 cells of
the Surface for 858-985 us. That is the one architectural item left and it is not a micro
-optimisation: nothing in this repository can avoid work pardes has already done.
Verified: screen byte-identical to the vaxis reference on the 18-step workload, with
canonical style decoding rather than escape history. snap 95/95, hxdiff 481/0, hxparity
561/0, unit-test, both A/B arms build, tty, p4 and gui all build.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`fitEnd` decides where a wrapped row breaks, and it asked two function calls per
character to learn what arithmetic knows. `modal.nextGrapheme` and
`graphemeDisplayWidth` each already answer ASCII in constant time - that was earlier
work - but they answer once per character, and a 640-column line asks 640 times.
A printable ASCII byte whose successor is also ASCII is a complete grapheme cluster one
column wide. That is the same guard, for the same reason, as the three fast paths already
in `Surface.print`, `modal.nextGrapheme` and `graphemeDisplayWidth`: every rule that
could join an ASCII base into a longer cluster - Extend, ZWJ, SpacingMark, Prepend,
Regional_Indicator - is spelled with non-ASCII scalars. Tabs and the C0 controls are
excluded by the range test and keep the general path, as does anything wide.
Measured on the die: 21 us of a 640-character keystroke, 5 us at 160. Small, and reported
as small - the interesting part is that it is small, because it says the per-character
grapheme walk was NOT where a long line's cost lives.
The test pins the fast path to the general walk it replaces rather than to transcribed
expectations: same inputs through both routes, every start offset, every width from zero
to past the end, over strings chosen to land the boundary inside a combining sequence, a
wide glyph, a regional-indicator pair, a tab and a CR. A break that moved by one column
would move text on screen, so this is the invariant worth holding.
|
| |
|
|
|
|
|
|
|
|
| |
Five digits is every u16, so the `v >= 10000` branch could never be taken and it was
dragging `std.fmt.printInt` into a firmware whose whole reason for hand-rolling this
was to keep the format machinery out of the hottest sequence it emits.
Behaviour is identical, and re-verified rather than assumed: screen byte-identical to
the vaxis reference on the 18-step workload, round trip median 3830 us over 60 trials
(3829 before), snap 95/95, hxdiff 481/0, hxparity 561/0, unit-test.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`present` already knows exactly which cells moved - that is what the shadow grid is
for - and then handed every one of them to vaxis so that vaxis could work it out again
against its own copy. That second diff measured 631 us of a 4.37 ms keystroke, all of
it redundant. This emits the escapes itself and skips it.
The emitter is small because it is allowed to be: one absolute CUP per run of changed
cells rather than per cell, absolute SGR rather than a delta from whatever is currently
on, and a hand-rolled two-digit formatter instead of `std.fmt` for the sequence it
writes most. Absolute SGR is the interesting choice - it costs a few bytes on a style
change and buys the property that no cell can inherit an earlier cell's colour if a
frame is cut short. Cursor column tracking gives up after anything that is not a single
printable ASCII byte, and at the last column, because deferred wrap makes the answer
terminal-dependent and wrong by a whole row.
Board cost: `render` 1362 -> 779 us. Bytes per keystroke: 81 -> 21. Image 26.6 KB
smaller, since vaxis's renderer is now unreachable.
## And it measured SLOWER
4.72 ms against 4.37. Fewer bytes, less compute, worse round trip - which is the sort of
result that means the model is wrong, so I stopped optimising and went looking.
It is the USB bridge. The board talks to the host through a CH340, a full-speed part
whose bulk IN endpoint carries 32-byte packets, and it forwards a packet when the packet
is FULL. A 21-byte frame does not fill one, so it sits in the bridge until an internal
timer gives up waiting for more - about a millisecond, a quarter of the whole budget.
Routing through vaxis only looked competitive because its frames are 81 bytes and fill a
packet by accident.
The evidence, all at identical board cost and with a byte-identical screen:
frame min median
21 B 3843 us 4817 us never fills a packet
49 B 3719 us 3814 us padded past the boundary
81 B 4373 us 4475 us vaxis, fills one by accident
Note the minimum: the 21-byte frame's floor is already 530 us below vaxis's, exactly the
compute that was saved. Only the median was hostage to the timer.
So the frame has a minimum size and it belongs to the transport, not the terminal. Pad
to it, with repeated absolute cursor positioning: idempotent, already the sequence the
frame ends on, cannot alter a cell. Every emitted byte goes through one counting helper
so the epilogue knows how much is owed. This is an Ethernet runt frame - the medium has
a minimum and the sender pays it - and it is a real trade rather than free, since the
filler is wire time that delays a later frame. It only applies when the frame is small,
which is when there is wire to spare.
## Result: 3.74 ms, and the goal was 4.00
step fixed per char at 160 chars
ReleaseSmall 16.99 ms 54.3 us 25.56 ms
ReleaseFast 14.85 ms 34.7 us 20.30 ms 0.79x
+ ASCII grapheme 14.56 ms 12.0 us 16.46 ms 0.64x
+ ASCII print 14.27 ms 6.9 us 15.36 ms 0.60x
+ shadow grid 8.87 ms 7.3 us 10.02 ms 0.39x
+ byte compare 8.37 ms 7.1 us 9.48 ms 0.37x
+ 360 MHz 4.37 ms 1.9 us 4.67 ms 0.18x
+ direct emit 3.74 ms 2.0 us 4.06 ms 0.16x
35 bytes per keystroke, down from 81. A phase-randomised instrument agrees: 60 trials,
median 3829 us, min 3722, p90 3930.
That second instrument exists because of this commit. The original bench sends keystrokes
on a fixed cadence, which locks the send phase to the host's 1 ms USB frame clock and
makes the round trip a staircase in board time - a real saving can measure as a
regression. Sleeping a uniform random 0-2 ms before each keystroke decorrelates the two.
It was not what was happening here, but it had to be excluded before the CH340 could be
believed, and it is the right default for anything measured across this link.
## Verification
`direct_emit = false` routes every cell back through vaxis and is the reference. Both
arms, same 18-step workload, same clock: identical characters and identical resolved
style in every cell - resolved, not raw SGR, because two emitters reaching the same
colour by different escapes are the same screen. A from-scratch ANSI emitter is exactly
the change that can be right about latency and wrong about the screen, and until the
verifier compared canonical style rather than escape history it could not have told the
difference.
snap 95/95, hxdiff 481 cases 0 mismatches, hxparity 561 cases 0 mismatches, unit-test,
both A/B arms build, tty, p4 and gui all build.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`Surface.cells` is contiguous and row-major, so a row is a single `memcmp` against the
shadow grid - and on a keystroke eleven of twelve rows are untouched. The per-cell
loop was ~40 branchy comparisons per row where this is one call over 1,120 bytes.
Byte equality implies visual equality, which is what makes the shortcut sound: a row
that compares equal cannot be hiding a changed cell, and a row that differs only in
padding falls through to the per-cell path, which is correct and merely slower.
Measured on the die at 360 MHz: the grid walk 246 -> 226 us. That is a small win and
the reason is worth recording - at 27 KB read per frame and about 6 cycles per byte,
this stage is now bounded by L2MEM bandwidth rather than by comparison work, so there
is little left in it. It is also why board compute scaled 2.6x rather than 4x when the
core clock went up 4x.
Verified with a canonical-style A/B: reference path (`shadow_grid = false`) and
incremental path, same 18-step workload, same clock - identical characters and
identical resolved style in every cell. snap 95/95, hxdiff 481/0, hxparity 561/0,
unit-test, tty and p4 both build.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`Cell.visuallyEqual` is the semantically exact answer and too slow to ask 480 times a
frame: `std.meta.eql` on a `CellStyle` recurses through a colour union and eight
booleans, and the walk measured 1.45 ms on the die - about 270 cycles to compare a
28-byte struct.
`sameCell` in src/p4.zig does it as bytes. That is safe in the direction that
matters: byte equality IMPLIES visual equality, so it can never claim two different
cells are the same. It can miss an equality - scratch bytes past `len`, or padding -
and the only cost of that is one redundant `writeCell` which vaxis then diffs away.
Defaults are still compared by meaning, because an unpainted cell's text and style are
whatever the previous frame left in them.
Measured: the grid walk 1.45 -> 0.98 ms, a keystroke 8.87 -> 8.37 ms fixed.
Verified the way a rendering change has to be. The A/B harness now hashes the SGR
state of every cell as well as its character, because the first version compared text
only and would have passed a colour regression in silence. Reference path
(`shadow_grid = false`, clear and write everything) and incremental path were each run
against the same 19-step workload on the die and the reconstructed screens are
identical in both text and per-row style hash.
snap 95/95, hxdiff 481 cases 0 mismatches, hxparity 561 cases 0 mismatches, unit-test,
and tty / p4 / gui all build.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A keystroke on the ESP32-P4 cost 17.0 ms and the goal is 4. Profiling the core in
that board's exact configuration - 40x12, tree-sitter disabled, via `zig build perf
-Dtree-sitter=disabled -- --cols 40 --rows 12 --only small` - named the cost, and it
was Unicode machinery answering questions about the letter `y`.
Four changes, each a fast path guarded so that non-ASCII text takes exactly the road
it took before.
`modal.graphemeStart` was 21.5% of a keystroke, the single largest item. It iterates
graphemes FROM THE START of the text with the full UAX #29 break state machine until
it passes the offset, and the render path calls it once per visible row with a column
offset - so the cost followed the cursor's distance along its line. That is the shape
measured on the die, where inserting at column 320 of a fixed 320-character line cost
7.8 ms more than inserting at column 0 of the same line. In UAX #29 every ASCII
scalar is its own cluster with ONE exception, GB3 (CR joined to LF); every other rule
that could extend a cluster - Extend, ZWJ, SpacingMark, Prepend, Regional_Indicator -
is spelled with non-ASCII scalars. So an ASCII byte whose predecessor is also ASCII,
and not that CR-LF pair, IS a boundary. O(1), and sound rather than approximate.
`Surface.print` then became the largest at 26.2%: per character it took a UTF-8
length, a decode, a FRESHLY CONSTRUCTED grapheme iterator, a slice validation and a
width lookup, to conclude that `y` is one cell. Printable ASCII followed by ASCII
takes none of that now. Same guard, same reason.
`file_pane.graphemeDisplayWidth` was 6.9%, essentially all of it asking `gwidth`
about ASCII. Bounded to 0x20..0x7e on purpose: DEL and the C0 controls are not one
printable cell and `gwidth` stays the authority on them.
`modal.lineSlice` searched for "\n" with the generic substring search where a memchr
does; it is called once per visible row per frame.
Measured at the P4's geometry and configuration, on the host: render 55 -> 12 us,
key-down 483 -> 24 us, key-right 327 -> 13 us, edit-char 205 -> 46 us. On the die,
the per-character cost of a keystroke fell from 54.3 to 6.9 us - 7.9x - and a
keystroke at a 160-character line from 25.56 ms to 15.36 ms.
## The shadow grid, and why it is static
`src/p4.zig`'s `present` copied all 480 cells into vaxis every frame, which measured
6.75 ms on the die - 57% of a keystroke - and was paid whether or not anything
changed: a second render with nothing new cost the same as the first. vaxis diffs its
own grid, but only after being told every cell, and being told is the expensive part.
So `present` now keeps the previous Surface and tells vaxis only what moved.
`Cell.visuallyEqual` is the right comparison and already existed. Copy: 6.75 -> 1.45 ms.
The grid lives in `.bss`, sized by `max_cols` x `max_rows` at comptime, and that is
not a micro-optimisation. The first version allocated it from the editor's heap; on a
board whose 384 KiB is nearly spoken for, that is exactly the kind of change that
works and then breaks something else three steps away.
`shadow_grid` is a comptime A/B switch, kept deliberately. With it false, `present`
behaves as it did before - clear and write every cell - which is the reference any
measurement should be compared against, and the way to tell a rendering bug from a
rendering difference. It earned its keep immediately: the two paths were run against
the same 19-step workload on the die - inserts, deletes, motions that move the
modified-marker, a line outgrowing the viewport, backspaces that shrink it - and the
reconstructed screens are byte-identical.
## Verification
`snap` 95/95 scripts, `hxdiff` 481 cases 0 mismatches, `hxparity` 561 cases 0
mismatches, `unit-test`, `image-harness`, `pdf-harness`, `mupdf-check`, and tty / p4 /
gui all build. The rendering changes are exactly the sort that pass a latency
benchmark while corrupting a screen, so the snapshot parity suite is the one that
matters here and it is unchanged.
`test/perf.zig` gains `--cols`/`--rows`/`--only`. The screen's shape is one of the
things that table exists to hold constant, and 40x12 is not a scaled guess at the
board - it is the board. `--only` exists because under `perf record` one 63 ms cell on
the largest fixture swamps every sample from the case being asked about.
## Found, not fixed
`vx.resize` fails on this board: a runtime geometry change hits its allocation
failure path, restores the previous size and returns, so 80 bytes go out where 1,392
should. Verified independent of everything above - it reproduces with `shadow_grid`
false. The board therefore has one geometry for the life of a session, which is why
the staleness test above compares two firmwares rather than resizing one.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`-Doptimize` defaults to Debug, so the naive `zig build -Dplatform=p4` produced an
object that CANNOT RUN. Debug wraps every tier in `allocators.zig` in a
`DebugAllocator`, whose metadata is page-granular, and one 4 KiB page per size class
does not fit in the 384 KiB the board hands the editor: the image links, flashes, and
then dies in `Pardes.init`. Nothing said so, because every build in this session
happened to pass `-Doptimize=` explicitly.
The p4 target now falls back to ReleaseFast, and that mode was measured rather than
preferred. On the die, against ReleaseSmall over 5 document lengths x 7 trials:
configuration fixed per char at 160 chars
ReleaseSmall 16.99 ms 54.3 us 25.56 ms
ReleaseFast 14.85 ms 34.7 us 20.30 ms 0.79x
13% off the fixed per-keystroke cost, 36% off the per-character cost, for 35% more
flash on a partition that is 39% used. An explicit `-Doptimize=` still wins, so
ReleaseSmall stays one flag away when flash matters more than latency - which is why
this is a fallback and not a hard override.
Following the file's own convention: the web target has pinned ReleaseSmall
unconditionally for the same kind of reason (size is its budget) since before this.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The measurement said a keystroke costs 54 us per character already in the line. The
source said why: `insertAt` called `lineCount` - `std.mem.count` over every byte -
TWICE merely to clamp a row, and then `spliceAlloc` allocated and copied the whole
document. Three whole-document passes before one character can be inserted, which
fits a linear slope exactly.
So it was fixed. `modal.lineSpan` finds a row's byte span in ONE scan that stops at
that row, and `insertAt` uses it; a full count is paid only on the rare clamping
path where the cursor is past the end. Two tests pin the equivalence, including the
edges that make line counting awkward - an empty document, a trailing newline (its
own empty last line), and a row past the end. The first version of `lineSpan`
disagreed with `lineCount` about an empty document and the test caught it.
On the host harness this is a real win, reproduced over three independent runs at
matched sample counts:
edit-char 1k lines 50k lines 300k lines
before 730 us 2996 us 15030 us
after 721 us 2414 us 10955 us
ratio 0.98x 0.79-0.82x 0.78-0.83x
Every operation I did not touch stayed at 1.00x, which is better evidence than any
single cell.
On the board it changed NOTHING. The slope was 54.3 us/char before and 54.0 after,
a ratio of 1.00 over 5 conditions x 7 trials. Not a contradiction - the same fact
seen twice. The removed passes are O(document), and this board's document is a few
hundred BYTES, so two scans of it cost nothing worth measuring.
## Where the time actually goes
`-Dprof` times the two phases on the die with the cycle counter around
`pardes_p4_input` and `pardes_p4_render`:
chars in line input (parse+edit) render
1 220 us 14804 us
80 212 us 17114 us
240 250 us 24615 us
Input is FLAT at ~220 us - 1.5% of a keystroke - and does not grow with the document
at all. The ~15 ms floor and every microsecond of the slope are inside `render`. The
edit path could be made free and nobody would notice.
`soc.flushFlashCache`'s home in soc.zig is what let the profiling build exist at all
alongside the responder; `-Dprof` defaults off because it puts a line on the wire per
frame, which is the resource being measured.
## The report
`experiments/report.typ` gains Experiment 3 and, more importantly, a correction:
Experiment 2's mechanism claim was wrong and now says so, with the disproof next to
it. The ranked recommendations are reordered - the renderer is now #1 and the change
this commit makes is listed unranked, because on this target it buys nothing, which
is exactly why it is worth recording.
The position table earns its place there too: in a fixed 320-character line, an
insert at column 320 costs 33.9 ms and emits 28 bytes, while one at column 0 costs
26.0 ms and emits 81. Output size and latency are not merely uncorrelated on this
board, they are inverted - which is the signature of a walk from the start of a
line, and the next thing to go looking for.
The lesson is the one the instrument exists to enforce. A plausible mechanism, read
off the source and consistent with the shape of the data, was wrong about where the
time went, and only a measurement inside the firmware could say so.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`-Dplatform=p4 -Dtarget=riscv32-freestanding` emits a single freestanding OBJECT
exporting a seven-function C ABI, not an executable. The board's toolchain
(../05-zig-p4) owns `_start`, the linker script and the UART driver and links this
in. The seam is bytes rather than types, so neither side can accidentally depend
on the other's internals, and a signature that drifts fails at link time.
The serial line is the whole of the I/O. `src/p4.zig` drives vaxis unchanged over
it: the renderer is a byte writer and `queryTerminalSend` is a byte writer, so the
terminal emulator on the host answers the capability handshake and the firmware
sees a real terminal. Measured going out over the wire on attach: alt screen,
in-band resize, cursor report, kitty keyboard, kitty graphics, DA1.
THREE WORDS EXIST ONLY HERE. `src/board_memory.zig` implements `Peek`, `Poke` and
`Hexdump`, gated on `builtin.os.tag == .freestanding and !isWasm()` - derived from
the TARGET, because they are a property of running with no OS under you rather
than a product option, and because wasm is freestanding too and is exactly what
must be excluded: in a browser an address is an offset into the linear memory this
editor's own heap lives in. Every access goes through `*allowzero volatile`: a
peripheral register is not memory, and address 0 is an ordinary unmapped address
on this bus. One 4 KiB cap per command, set by the console rather than the memory -
an unbounded dump would wedge the only console the board has for eleven hours.
Measured on ESP32-P4 rev v1.3 silicon, driven from a host terminal:
Peek 0x501101a4 0x0e63ce71, then 0xaeaa6919 on a second read - the
RNG register, so the volatile loads are not folded
Poke 0x5011002c 0xdeadbeef LP_STORE0; a later Peek returned 0xdeadbeef
Hexdump 0x5011002c 32 16 bytes a row, hex columns and an ASCII gutter
Peek 0x50110001 `peek: MisalignedAddress` on the message row
That last line is the one that matters. A misaligned 32-bit access traps, and a
trap in firmware is a watchdog reset that takes the session with it, so the check
that turns it into a message is the reason the file is hand-written rather than a
generic reader.
BARE METAL BOOTS AN EMPTY OUTPUT BUFFER. Every other boot layout in `init` makes a
shell, and on this platform that is not a preference but an impossibility: nothing
to fork, no pty to give a terminal pane. Booting one anyway produced precisely what
that describes - a pane whose tag ends in `Filter`, no gutter, no buffer, and every
keystroke vanishing into the Fallback's silent pty. An output buffer is also what
the platform's own words want, since Peek, Poke and Hexdump each fill one.
Sized for the board rather than for a desktop:
* `allocators.zig` gains a p4 tier that is ALL fallback - every capacity is zero,
so each arena spills immediately to the 384 KiB heap the firmware hands over,
and no megabyte-shaped static reservation lands in `.bss`.
* `source_manifest.zig`'s allowlist is EMPTY on p4. The table is ~0.95 MiB of
rodata against a 1.5 MiB flash partition; the firmware's filesystem is the
serial host's, through the Host vtable.
* The grid is clamped and the clamp is measured, not guessed: every cell is paid
for four times (vaxis Screen + InternalScreen, pardes Surface + previous_cells),
so 40x12 fits and 80x24 exhausts the heap during `Pardes.init`.
* `Vaxis.resize` deinits both screens before allocating replacements, so a failed
resize leaves vaxis rendering nothing. The p4 shell keeps the previous geometry
on failure instead of leaving a half-applied one.
Also here: `output_pane_integration_test.zig` had an exhaustive switch over
`Platform` that adding `.p4` left unhandled, which broke `zig build unit-test`
outright - the native test binary is the one consumer no platform build compiles.
346 tests pass again.
|
| |
|
|
| |
stops rewriting the suite
|
| | |
|
| |
|
|
| |
takes a path argument
|
| |
|
|
| |
walk wrapped rows
|
| |
|
|
| |
optional methods
|
| |
|
|
| |
backends agree
|
| | |
|
| | |
|
| | |
|
| |
|
|
| |
docs
|