summaryrefslogtreecommitdiff
path: root/docs
Commit message (Collapse)AuthorAge
* Reuse bounded source analysis and speed up result context traversalGabriel Schneider39 hours
|
* Default location results to stacked addresses and source previewsGabriel Schneider39 hours
|
* Bound preceding result context by its outermost tree-sitter declarationGabriel Schneider39 hours
|
* Highlight exact source occurrences in LSP references and goto resultsGabriel Schneider39 hours
|
* Show result declaration locations only on their first header lineGabriel Schneider39 hours
|
* Mark omitted source after result declaration context with ellipsesGabriel Schneider39 hours
|
* Align search and LSP results in groups of eight matchesGabriel Schneider39 hours
|
* Center GUI pane indicators using cached font ink boundsGabriel Schneider2026-09-15
|
* Separate pane and column grip hit areas from tagline text with a small gapGabriel Schneider2026-09-15
|
* Keep column grips blank and muted in every focus stateGabriel Schneider2026-09-15
|
* Cycle pane input modes with Mode and retain terminal tag accessGabriel Schneider2026-09-15
|
* Reorder columns using aligned and distinctly colored tag gripsGabriel Schneider2026-09-15
|
* Make location highlighting an explicit output producer choiceGabriel Schneider2026-09-15
|
* Separate tag text geometry from physical pane gripsGabriel Schneider2026-09-15
|
* Separate discontinuous pinned source context with thin bordersGabriel Schneider2026-09-15
|
* Hide redundant locations on neighboring result contextGabriel Schneider2026-09-15
|
* Align search and LSP context locations flush leftGabriel Schneider2026-09-15
|
* Match filename tint lightness in every canonical theme and focus stateGabriel Schneider2026-09-15
|
* Mute result declaration context and show nesting after locationsGabriel Schneider2026-09-15
|
* Add optional compact tagline styling for source contextGabriel Schneider2026-09-15
|
* Lead terminal tags with a tinted Tty commandGabriel Schneider2026-09-15
|
* Distinguish filename hues while preserving tag brightnessGabriel Schneider2026-09-15
|
* Expose LocationsConfig in location result pane tagsGabriel Schneider2026-09-15
|
* Follow embedded PDF links through LookGabriel Schneider2026-09-15
|
* Remember tag cursors and reserve uppercase pane navigationGabriel Schneider2026-09-15
|
* Tint pane tag filenames with an optional theme colorGabriel Schneider2026-09-15
|
* Align location results and configure source contextGabriel Schneider2026-09-15
|
* Add optional tree-sitter declaration contextGabriel Schneider2026-09-15
|
* Mirror raw terminal mouse selections for text transferGabriel Schneider2026-09-15
|
* Align file clicks and insertion cursorsGabriel Schneider2026-09-15
|
* Route mouse thumb buttons to jump historyGabriel Schneider2026-09-15
|
* Preserve selections when navigating jump historyGabriel Schneider2026-09-15
|
* Add Linux Tty9p mounted terminals and forward raw TTY keysGabriel Schneider2026-09-15
|
* 9p: use cloud9 protocol sessions and transportsGabriel Schneider2026-09-15
|
* trunk: resume before the Reload experimentGabriel Schneider2026-09-15
| | | | Empty marker on the last pre-Reload change. Keep the Reload experiment on reload (3801914), its first change on reload-start (200a1fc), and the unfinished performance investigation on reload-perf-wip.
* Resolve relative restores in the dump directory and propagate LSP probe errorsGabriel Schneider2026-09-15
|
* macos: merge local trackpad gestures with current upstreamGabriel Schneider2026-09-07
|\
| * macos: use two-finger click for Exec and deep press for LookGabriel Schneider2026-09-07
| |
* | Refactor panes and filesystem; replace FUSE with 9PGabriel Schneider2026-09-07
| | | | | | | | | | | | Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
* | pipe: helix's other three shell commands, and its newline ruleGabriel Schneider2026-09-03
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `|` was the only one of helix's five. The other four differ in exactly two things — whether the selection is stdin, and where the output lands — so they are one action carrying a `PipeBehavior` rather than four code paths: | stdin is the selection, output REPLACES it (had this) A-| stdin is the selection, output discarded shell_pipe_to ! no stdin, output inserted BEFORE each selection shell_insert_output A-! no stdin, output appended AFTER each selection shell_append_output Each arms the same visible tag-tail prompt with its own marker (`|`, `|-`, `!`, `!+`) so the prompt says which one you are in — they take the same command line and do very different things to the buffer. Two helix rules came with them. A behaviour that sends no stdin runs the command ONCE and every cursor gets that one answer (helix's `shell_output` cache): ten cursors and `date` give ten identical stamps rather than ten forks racing to produce one. And a command that put a trailing newline on a selection which did not have one has it taken back off — that is what keeps a one-line `| tr a-z A-Z` from becoming two lines. The existing multi-range test moved with that rule and now pins it deliberately. In all three writing behaviours the OUTPUT is what ends up selected, keeping the original range's direction, so an operator can follow straight on from what the command just produced. `$` (`shell_keep_pipe` — drop the selections whose command exited nonzero) is still missing: it needs a per-selection verdict and the runner's answer is atomic. Noted in docs/helix-keys.md beside the `$` divergence already there. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
* | crash: a panic record must not be able to hang the process it is recordingGabriel Schneider2026-09-03
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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
* | detached: a big screen can attach, and the frame it asks for fits the wireGabriel Schneider2026-09-03
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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
* | crash: a panic writes itself down beside the init file, where stderr cannot ↵Gabriel Schneider2026-09-03
|/ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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
* hosts: the effects three shells kept a copy of become one, and the mac's own ↵Gabriel Schneider2026-09-02
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* macos: one tagline rule for both hosts, a kqueue beside the inotify, and ↵Gabriel Schneider2026-09-01
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* lsp: a protocol client for every other language, narrated on the message rowGabriel Schneider2026-09-01
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* build: pardes builds without the ESP32-P4 toolchain checkout beside itGabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `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.
* 9p: the client half, and a board that serves its own tree over the UARTGabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* 9p: serve the acme tree over 9P2000 on a unix socket, beside the mountGabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* detached: drop /dev/fuse from the poll set once the connection diesGabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.