Performance — Where Kolu Can Get Faster
A living tuning map of the Kolu monorepo, read through the N/C/U performance model — do fewer things (N), make each thing cheaper (C), use idle capacity (U). Built from a 77-agent survey and adversarial verification, it files every shipped win and open opportunity under the lever it pulls, so we keep Kolu nimble and fast, by measurement, over time.
This is the Atlas hub for keeping Kolu nimble and fast — a living map of the
monorepo’s performance surfaces, the wins already banked, and the opportunities
worth tuning next, all organized by one model: N/C/U. It was built by a survey
workflow with adversarial verification,10 investigators, one per
subsystem, each reading real source, then synthesis. Every finding was then
checked against the code — this repo’s history shows plausible, code-cited perf
diagnoses are often wrong (see
memory-learnings
and dock-and-eventloop-1308).
and re-files every survivor under the lever it pulls.Of 66 raw
findings, 35 survived as real or partial, 6 were confirmed already-shipped, and
25 were dropped as speculative or mechanically wrong. This pass also adds one
verified new item (the uncompressed bundle) and two low-confidence candidates a
second sweep surfaced.
The N/C/U model
Think of any workload as a loop. The time to finish it is roughly:
Time to outcome ≈ (N × C) / U
- N — how many times. Iterations, round-trips, re-derivations, retries. Lower it with a better algorithm, by removing dead work, by doing shared work once, or by not doing work until it’s actually asked for.
- C — how much each time costs. Dominated by distance — network hops, layers of indirection, bytes moved. Lower it by keeping hot data close, compacting what repeats, and shrinking what crosses the wire.
- U — how much of your capacity is working. Idle cores, idle links, unbalanced load. Raise it with parallelism, pipelining, and pre-computing in slack time.
The three levers are orthogonal, so they multiply: a 3× on each is 27×, not 9×.That is the aspirational ceiling, not Kolu’s ledger — this repo’s wins are mostly single-lever and single-digit-percent at today’s scale (3–20 terminals, small/medium docs). Here the model earns its keep as a search heuristic: it names the lever each fix pulls, so we don’t double-count a win, and points at the thin levers — C on the wire, and U almost everywhere — where the next structural headroom sits. Then you measure.
The backlog
The actionable shortlist, ranked by leverage, tagged with the lever each item pulls. Impact and effort are the verified estimates (post-adversarial correction), not the original claims. Row 1 is new this pass and the only item on the list that is not bounded at today’s scale — it fires on every cold load, now.
| # | Opportunity | Lever | Surface | Impact | Effort | The fix, in one line |
|---|---|---|---|---|---|---|
| 1 | Serve the client bundle compressed shipped | C·U | Bundle | med | low | Done #1643 — build-time .br/.gz + precompressed-sibling negotiation; the 2.56 MB main chunk → 571 kB brotli (4.6×) on every cold / remote / phone load. |
| 2 | Per-key collection deltas on the wire | C·N | Wire | med | med | Publish {added/changed/removed} keys, not the full key array, on every upsert/remove. |
| 3 | Heartbeat: hidden-tab probe interval | N | Mobile | med | med | Lengthen the background probe (30–60s), don’t stop it (that blinds the watchdog). Reconnect-on-resume already shipped #1598. |
| 4 | Workspace-level test parallelism | U | Dev-loop | med | low | Add --workspace-concurrency so ~44 packages don’t test one-at-a-time. |
| 5 | Lazy-load the Code tab — measured, deferred | N·U | Bundle | med | high | 171 kB gzip / 23% of the eager chunk, but activeTab defaults to code so it defers past first paint rather than skips — a faster-first-paint-vs-cold-flash trade. Deferred. |
| 6 | One-shot Nix pnpmDeps hash check |
N | Dev-loop | med | high | Compute the hash once instead of two sequential builds (2m45s on darwin). |
| 7 | Stabilize the terminalIds memo shipped |
N | Reactivity | med | med | Done #1425 — the memo keeps its prior array when the id order is unchanged, so terminalIds() stops notifying downstream on non-display metadata writes. |
| 8 | Markdown toggle keep-alive shipped | N | Markdown | med | med | Done #1446 — a Source⇄Rendered flip is a visibility change, not a remount + full re-sanitize (pipeline runs 0× per toggle, was 1×). |
Already banked
So this map isn’t re-litigated: the wins below are shipped and verified — do not re-report them as opportunities. Each is tagged with the lever it pulled; remaining slivers inside them are noted in the lever sections as “remaining within …”.
- Reactivity keystone N #1425 — the
terminalIdsmemo keeps a stable reference when the top-level id order is unchanged (sameTerminalIdOrderequalsgate), so the accessor stops notifying downstream on non-display metadata writes; proven by a re-run-count regression test.displayInfoskeeps its own field-level subscriptions togit/cwd/parentIdvia the surface store’sreconcilewrites, so PR / agent / foreground churn never reached it even before this gate; a realgit/cwd/parentIdchange still re-runs it, correctly — that path is left intact, by design. - Attention: class reads don’t wake on the byte tick N #2019 — the dock’s rank-and-paint memo (an O(n log n) sort plus a regroup) reads a terminal’s attention CLASS, which moves on agent transitions. The LIVE set beside it churns on kaval’s ~1 s byte-motion window — every time any terminal prints a line. Keeping those in separate per-host memos is what stops a
tail -ffrom re-sorting the whole dock once a second for no visible change.Proven the same way the keystone was: a re-run-count test (attentionMarks.test.ts) drives three consecutive live-set writes and asserts the class read stays at 1 run, then moves to 2 on a real class change — and it was verified to go RED ([2,3,4]) under a deliberate one-line coarsening before being kept. The mechanism underneath is Solid’s per-property store write: aliveIds-only merge never replaces thebyClassnode, so a third test pins that referential identity directly — the thing that would silently break ifwriteHostMarksever rebuilt the record wholesale. The tempting simplification — one memo over the whole frame — is exactly the regression, which is why the test exists rather than a comment. - Attention: the frame is indexed once, not rescanned per terminal N #2019 — three readers ran a per-terminal linear walk of a host’s four class lists (each element read a store-proxy
get, so it registered a dependency too), and the host-tab active count was an un-memoized fold read from five reactive computations per chip — doubled again by the hidden width-measuring row. Now a per-host classMap+ liveSet+ a count memo, built inside the root that already owns the subscriptions. The pure folds stay exported and unchanged as the definition the tests pin; the indexes are an index over the same answer, never a second rule, and a host with no root yet falls back to the fold rather than answering wrongly. - Parent→children resolved in one walk N #2019 —
getSubTerminalIdswaskeys().filter(…), a full scan of every terminal per call, andbuildTerminalDisplayInfoscalls it once per top-level terminal: O(T²) collection reads per display rebuild, on every metadata update. OnechildrenByParentmemo now serves both it and that display pass, whose own comment had flagged this exact shape for the relation.Complexity-by-construction, not a captured profile — the honest label. At 3–20 terminals it sits under the perception floor the callout below describes; the reason to take it anyway was that the fix removed a quadratic walk rather than adding a cache. - Markdown toggle keep-alive N #1446 —
FileViewkeeps both Source ⇄ Rendered modes alive, so toggling a.mdpreview is a visibility flip, not a remount + full re-sanitize (the marked→DOMPurify→Shiki→innerHTMLpipeline runs 0× per toggle, was 1×).A per-slotheldFilesnapshot keeps reload-on-edit intact with norender(file)API change. The companion “stabilize the markdown image resolver reference” claim was refuted as a measured no-op. - Per-attach scrollback share N #1573 — an already-aborted attach does zero
serialize(), and a burst of attaches to one PTY within a publish-epoch shares one memoized snapshot; the reconnect-storm transient dropped from a measured 2–3.2 GB of concurrent full serializes to O(live-terminal count).Bounding each snapshot to a viewport is the follow-up — kaval memory. - Client bundle compression C·U #1643 — the client build emits
.br/.gzfor the immutable/assets/*andfreshStaticLayernegotiates them; the 2.56 MB main chunk crosses the wire as 571 kB brotli (4.6×) on every cold / remote / phone load.Theno-storeshell stays uncompressed (its commit stamp is seded post-build); zero per-request CPU, compression time-shifted to build. - OpenCode-derived wins N·U —
@pierre/diffs1.2.10 + Shiki 4.2.0 #1360, off-thread diff highlighting #1363 (the lone banked U win), the canvas gesture-p99 harness + rAF-coalesced pan/zoom #1368. Full write-up: opencode-perf. - WebGL context cap U #1416 #1399 — admit the whole working set under a 12-context cap; killed the focus-churn VRAM leak on Chrome+AMD.
- Compositor paint storms C — canvas tile-aura + dock CSS animations moved to compositor-friendly properties #1354 #1308.
- Unfocused xterm write coalesce N·C — under Dock-scale multi-agent load every attached tile used to
term.writeat full rate (parse + rAF paint onCrRendererMainper chunk). Unfocused tiles now batch PTY chunks for ≤100 ms viacreateOutputCoalescein@kolu/xterm-kit/solid; focused stays real-time. Measured on a 16-tile dense flood (paired same-style post-fix pass): main-thread busy 3896 → 2777 ms / 6 s (−29%); a harder post-fix flood still 1541 ms (25.7%).Write-up + full series: multi-tile-xterm-cpu. CSS aura A/B was a faithfully-reproduced negative — compositor animations already fixed in #1354. Captures were session-local; re-run the note’s procedure for a fresh pair. - Off-screen work elimination N — covered tiles reuse the viewport box; no redundant
ResizeObserverfit()cycles on hidden terminals. - Memory U —
storesByKeyreleased on terminal deletion; per-terminal history-browser state reset on repo change #610. - Heartbeat reconnect-on-resume N #1598 — the watchdog compares elapsed wall vs monotonic time across each probe and voids-and-re-probes a window a suspension crossed, so a laptop sleep / tab freeze no longer forces a spurious reconnect over a healthy socket.
- Blocking
git rev-parseoff the event loop N #1615 — the Code-view watchers resolved each repo’s git dir with a synchronousexecSync('git rev-parse')inline on every watcher install, which could freeze the entire single-threaded event loop.One wedged call froze the event loop inwaitpidwith no timeout — a 25-minute browser-unresponsive wedge. Now async + bounded:execFile5s timeout,fs.promises.realpath. - Nix dev-shell eval C — 35× faster (
docs/nix-eval-perf-report.md).
N — Do fewer things
The fat lever, and the one Kolu has mostly already pulled. On a reactive client the enemy is redundant re-derivation, not distance or idle cores — so the banked wins above (reactivity, markdown, scrollback, canvas) all live here, and what’s left is more of the same: scans that could be indexed, timers that wake when nothing’s watching, and round-trips that could be shared or deferred.
Low Keystone — stabilize the terminalIds memo reference — ✓ shipped
terminalIds was a createMemo running meta.keys().filter(...)
(useTerminalMetadata.ts), returning a new array reference every run even when
the contents were identical. The dependent displayInfos memo tracked that
reference, so any single terminal’s metadata mutation re-ran
buildTerminalDisplayInfos for all terminals (terminalDisplay.ts) and
re-evaluated every tile’s Show gate.Each pass allocated 4–5
intermediate collections and re-checked getDisplayInfo per tile
(TerminalCanvas.tsx). Verification corrected the original “O(n³)” claim to
O(n log n) — the cost was wasted allocations + re-derivation, not algorithmic
blowup. Done #1425 — the memo now carries a
sameTerminalIdOrder equals gate, so it keeps the prior array whenever the
top-level id set is unchanged; the set-shaped re-run path now fires only on a
real add / remove / reorder, not on every metadata mutation. The accessor still
re-runs cheaply; what it no longer does is notify downstream when the set is
identical. Proven by a re-run-count regression test
(useTerminalMetadata.test.ts).displayInfos keeps a second,
field-level subscription to git / cwd / parentId inside its own scope;
because the surface store writes via reconcile, PR / agent / foreground churn
never reached it even before this gate, and a real display-identity change still
re-runs it, correctly. The gate closes the set-reference path; the field-level
path is already as narrow as it should be.
Low Corollary — the display-info snapshot must carry NO live record — ✓ fixed
The keystone win has a sharp edge worth stating on its own, so a future change
doesn’t re-introduce it: precisely because PR / agent / foreground churn
never re-runs displayInfos, anything live carried in its output silently
goes stale. TerminalDisplayInfo used to bundle the whole TerminalMetadata,
so a consumer reading getDisplayInfo(id).meta.pr off that snapshot saw a value
frozen at the last git / cwd / membership change — the canvas tile title bar
lagged the dock on PR resolution (the dock reads the live getMetadata(id)
proxy; the header read the snapshot). This is the reactively-correct shape: a
memo hands out only what it invalidates on. Fixed #1897 —
meta is removed from TerminalDisplayInfo entirely, so every live fact (pr / agent / foreground /
intent / git) now reads from getMetadata(id) — the fine-grained store proxy —
at each consumer’s own leaf, and the header tracks the same leaf the dock does. A
terminalDisplay.test.ts guard asserts the display info can never again carry a
record; the memo keeps only colors, the identity key, and sub-count.
getSubTerminalIdsO(n) scan, called per top-level terminal inside the display derivation → O(n²) per metadata update (useTerminalMetadata.ts:54-56,terminalDisplay.ts:80). AMap<ParentId, TerminalId[]>index built in the same memo replaces the repeated full scans with one O(n) pass — a better-algorithm N cut. lowterminalLabelO(n)indexOfper access (useTerminalMetadata.ts:94-96) — real, but only 2 call sites, both at event boundaries; a precomputed id→position map (derived alongside the group-by index above) collapses it to O(1). Bundle it with that index work, don’t chase it alone. low- Per-tile geometry arithmetic (
onScreen,tileTransformCSSinCanvasTile.tsx:114-130) recomputes per pan/zoom frame — but the big win (not mounting off-screen auras) already shipped; the residual ~4 ops/tile/rAF is the arithmetic pan/zoom genuinely changes each frame, likely below noise. Remaining within the canvas work. low
Medium Markdown preview render cost — the resolver 'fix' was a no-op; the toggle remount was real — ✓ shipped
The original claim — BrowseFileDispatcher passes resolveImageSrc as an inline
arrow, so Markdown’s memo re-runs — is a measured no-op (stabilizing the
reference eliminates zero sanitizeHtml runs; the inline-arrow prop is static
to the Solid compiler, never a reactive dependency). The real cost the
reproduction surfaced — now fixed: active() returned only the active branch,
so a Source⇄Rendered toggle remounted and re-sanitized the whole doc (a
50-image doc: ~50 image-resolutions + a full parse/sanitize/highlight/DOM-reparse
per toggle). Fixed #1446 — FileView now keeps both toggle
modes alive (the #818 RightPanel keep-alive pattern), so a flip is a visibility
change, not a remount; the pipeline runs 0× per toggle — a textbook
pre-compute + reuse. Proven by an e2e. Full write-up + reproduction:
markdown-image-resolver-and-toggle.
sanitizeHtmldoes 6 sequential full-tree walks per parse (sanitize.ts:359-410) — sixquerySelectorAllpasses (anchors, inputs, pre, img,[id],a[href^=#]) each re-traverse the whole DOM; fusing them into one traversal that dispatches per node visits each node once instead of six times (find common work). Memo-gated on content, so it only bites very large documents. low- File-search ancestor recompute per keystroke (
fileSearch.ts:50-62) —ancestorDirectoryPaths(and the per-path normalization) re-derives over a stable tree on every keystroke; a path-keyed module-level memo reuses it (pre-compute + reuse). Measured at 0.076 ms/200 calls, below perception — cheap insurance, not urgent. low
Medium Lazy-load the Code tab — measured (171 kB gzip), deferred — N (skip) + U (time-shift)
An A/B production build measured the Code-tab tree — @kolu/solid-pierre’s
FileTree, the @kolu/solid-markdown renderer, the diff/source view wrappers,
and the comment system — at 629 kB raw / 171 kB gzip (23%) of the eager
index chunk (a static import in RightPanel). Splitting it out skips
parsing+executing it entirely on mobile / collapsed-panel sessions (N: work
never done) and time-shifts it async past first paint on the default desktop
case (U: moved off the first-paint window) — not any byte shrink. Lazy-loading
works (built, review-clean, e2e 115/115) but is deferred: activeTab
defaults to code and the desktop panel opens by default, so on a typical
desktop session CodeTab loads anyway, just async — a faster-first-paint-vs-cold-
flash trade whose perceptual net is the untraced cold-start TTI. Two premises
it refuted: Shiki grammars are already lazy, and ImageAddon can’t
lazy-on-first-use (it must precede the image escape sequence). Full write-up + the
unblock path:
bundle-codetab-lazyload.
Note the C sibling below: before deferring this 171 kB slice, compressing the
whole 2.56 MB bundle is a strictly larger, unconditional win.
Medium Heartbeat probe: reconnect-on-resume fixed; hidden-tab battery still open
createHeartbeat() runs system.live / identity.info() every 15s while the
socket is OPEN. Two distinct costs hid behind one finding, both N:
- Spurious reconnect on resume — FIXED #1598 (eliminate retries). A laptop sleep / tab freeze paused the event loop; the probe’s 10s timeout fired overdue on resume and forced a reconnect over a still-healthy socket. The watchdog now compares elapsed wall time against elapsed monotonic time across each probe and voids-and-re-probes a window a suspension crossed, and a window-focus / tab-visible wake event re-probes at once.
- Hidden-tab radio wake — still open (reduce round-trips). The probe still
runs ≈240×/hour while backgrounded, forcing the mobile radio idle→active. The
tempting fix — stop the interval while
document.visibilityState === 'hidden'— is a coverage regression: a hidden tab is still running, so gating the probe blinds the watchdog to a genuine half-open during a long background. A battery fix must lengthen the hidden-tab interval (30–60s), not stop it. Measure: packet-capture probes/hour backgrounded on a real phone — target under ~10, vs ~240 today.
- Every-minute staleness ticker fires globally regardless of visibility (
terminal/staleness.ts:26-57) — gate the shared 60ssetNowtick onvisibilitychange(reuse therefitOnTabVisiblepattern); its re-bucketing is invisible while the tab is hidden (remove dead work). low - A second, ungated 1s clock tick drives the uptime / “Running for” / heap readouts (
time/clock.ts:24) with novisibilitychangegate — the same family as the 60s staleness ticker, firing 60× more often. Its comment assumes a hidden tab self-throttles to ~1/min, but kolu holds an always-open surface WebSocket, which can exempt the page from Chrome’s background-timer throttling — so backgrounded it may keep waking a phone. Gate it on visibility like the ticker (the readouts it feeds are invisible while hidden). New this pass; low, unmeasured — the same on-device caveat as the rest of this cluster. low - N per-terminal
visibilitychangelisteners for re-fit (refitOnTabVisible.ts) — collapse to one shared App-root listener fanning out to a Set ofdebouncedFitcallbacks (find common work — N redundant registrations on one document event). low useCollectionsubscribes to all keys even if one is consumed (useTerminalMetadata.ts:34) — bounded, since rendered terminals genuinely need metadata; lazily subscribing only visible terminals removes standing streams never read, worth it only in 50+ terminal workspaces with most invisible (lazy evaluation). low- Three parallel git-status subscriptions per Code tab (
CodeTab.tsx:314-349) — real duplication (localStatusandactiveStatuseven collide on identical{mode:'local'}input), but documented as load-bearing (the passive subs swallowBASE_BRANCH_NOT_FOUNDwhile the active one revives after fetch). Do not coalesce blindly. low - One-shot Nix
pnpmDepshash check —ci::pnpm-hash-freshruns two sequentialnix builds (the second--rebuild), sopnpm installruns fully twice — measured at 2m45s on darwin / 25s on linux (ci/mod.just:82-84,default.nix:154-159). Compute the hash once into a temp derivation, then compare in a pure eval step (remove the redundant double-fetch). med
C — Make each thing cheaper
C is dominated by distance — bytes over the wire, layers of indirection — and
its marquee item is now banked: the client bundle, which shipped uncompressed,
is now served .br/.gz — a 4.6× cut on every cold load #1643. The
rest is open wire work (payload shapes) plus one forced layout read.
Low Serve the client bundle compressed — ✓ shipped (2.56 MB → 571 kB)
The production server shipped the client build through a static file handler with
no compression in the pipeline, so
the ~2.56 MB eager index bundle went out with no Content-Encoding even
though every browser offers gzip, deflate, br. Caching was already right, so it
only bit the cold load — but that’s exactly the remote / Tailscale / phone
path kolu markets, where bytes over a slow radio dominate first
paint.Immutable hashed /assets/* with max-age a year, so the miss is
first visit, cache-miss, and every post-deploy hash change. Done
#1643 — the client Vite build now emits .br/.gz siblings for the
immutable /assets/* at build time (brotli q11), and the static layer negotiates
Accept-Encoding against those siblings, serving one at zero per-request CPU — the compression time-shifted to build, a U
“pre-compute in idle” move on top of the C byte cut.
Measured on the main chunk: 2.56 MB → 571 kB brotli (4.58×) / 726 kB gzip —
beating the ~700 kB the map first estimated.The no-store shell
(index.html) is deliberately left uncompressed: its commit stamp is seded
post-build (kolu#1319), so a compressed shell would strand a returning browser on a
stale stamp — the build emits siblings for /assets/* only. A per-request
compress middleware was the rejected alternative (per-request CPU vs build-time
zero). This was row 1 and the only item on the map not bounded at
today’s scale — it fires on every cold load — and it settled the Code-tab
lazy-load debate: compressing ships ~2 MB less on the whole eager load,
unconditionally, dwarfing that deferred 171 kB slice. Covered by a
freshStaticLayer negotiation test; the remaining trace is on-device cold-start
LCP over Tailscale (see coverage gaps).
Medium Publish per-key collection deltas, not the full key set — C (payload) + N (client work)
Every upsert/remove publishes the entire key array via
keysBus.publish(Array.from(...)) — a fresh object each time — which crosses the
wire and triggers client mapArray reconciliation (surface/server.ts:1218-1223,
useCollection.ts:60-65). Publishing discriminated {added:[k]}/{changed:[k]}/{removed:[k]}
deltas (full set only on init) shrinks the per-event payload/allocation (C)
and lets useCollection apply the change instead of re-reconciling the whole key
set (N). The batched machinery already exists (deltasBus + createTickCoalescer,
useCollectionDeltas) but the useTerminalMetadata call sites pass explicit
keys, which forces the per-key path regardless of the deltas verb — so
realizing this is a call-site change, not a one-line verb flip. Measure:
keysBus publish frequency and payload sizes during terminal spawn/metadata
churn.
- Full metadata object per live-field update (
terminalEndpoint/metadata.ts:96-136) —publishAuthoredupserts a full{...entry.meta}clone on every field change. Upstream dedup gates (prResultEqual,agentInfoEqual) already cap the number of publishes to PR-poll 30s / screen-scrape 1s, so the remaining lever is payload size (C): splitting live vs persisted deltas. Lower priority. low base64stdio framing adds ~33% (links/stdio-codec.ts:25-64) —encodeFramebase64-encodes every peer message, a fixed 4/3 byte inflation; framing is already swappable, so a length-prefixed binary frame is the upgrade (compact encoding), gated on measured large-payload ops (git diff,fsListAll). lowgetBoundingClientRectper terminal tap for link detection (Terminal.tsx:572-591) — guarded to genuine taps, but each forces a sync layout read; caching the rect against theResizeObserver(local caching — recompute only on real resize) removes the reflow. low- Eager per-terminal addons —
Search/Image/Serializeare instantiated per terminal (Terminal.tsx:490-510) though conditional; dynamic-importingSerialize/Searchwould defer bundle load (N) and stop per-mount allocation (C), butImageAddoncan’t defer and the survivors minify to ~10–15 kB gzip while adding async to the hot path. Low value. low
U — Use the capacity you have
The thinnest lever, because a single-user desktop client has little idle capacity to reclaim — which is exactly why the one banked U win (off-thread diff highlighting #1363) and most of what’s open live in the dev-loop and the backend, where cores and queues actually sit idle. The rule here is don’t waste, don’t bottleneck, pre-compute in slack time.
Medium Parallelize tests across the workspace
pnpm -r serializes package test runs (no --workspace-concurrency), so on a
multi-core machine the ~44 packages run roughly one-at-a-time even though each
vitest threads internally — workspace-level parallelism is unused
(package.json:7). Enabling workspace concurrency in the test:unit recipe fills
idle cores (parallelism / raise utilization) without changing the tests run;
consider vitest --shard for the slowest packages (git/index.test.ts).
Measure: just test-unit baseline vs --workspace-concurrency N.
- Server unit tests run single-file —
packages/server/package.jsonforcesvitest --fileParallelism=falsebecause all 16 server test files share oneKOLU_STATE_DIR(keyed off the shell PID$$, resolved once so every worker inherits the same path), and a module-levelConfsingleton would collide across parallel forks. Key the state dir perVITEST_WORKER_ID(each fork a private dir) and drop the flag to run the files across cores — a within-package U win distinct from the cross-package item above (that fans out whole packages; this unblocks the slowest one). New this pass; bounded by whether server is the unit-lane long pole (gitis named the slowest). low
Low What a port-scan pass actually spends, decomposed and measured
The architecture is “TypeScript calls an external process” on darwin and “TypeScript
reads /proc in-process” on linux. Both overheads are now measured rather than
argued about, so the decision to keep them is answerable.
Darwin — the ~6 ms pass, decomposed. Round trip measured from Node exactly as padi calls it; the helper’s own share read from its mach monotonic clock in the SAME invocation, so the parts cannot drift against the whole (an earlier attempt timed them in separate loops and the parts summed to more than the total). Instrumentation is a measure-only build; nothing ships with timers. zest, macOS 27.0, 718 rows / 20.9 KB of output, 15 runs:
| component | median | share |
|---|---|---|
| the C work itself (libproc walk + format) | 4.11 ms | 65% |
| process lifecycle — fork/exec + dyld + teardown | 1.96 ms | 31% |
pipe transfer + execFile plumbing |
0.30 ms | 5% |
| round trip, shipping build | 5.96 ms | — |
The lifecycle figure is measured, not inferred: it is a no-op C binary spawned from Node under identical conditions. The instrumented build costs 0.41 ms over the shipping one, which is why the shipping total is quoted separately.
TSV parsing is small enough to be a rounding error and is measured with the SHIPPED
parseHelperOutput over that same real output: 0.112 ms median for 696 process
rows + 21 listener rows (min 0.104, max 0.371, 200 runs). ~2% of the pass.
Linux — the ~14 ms in-process pass, decomposed. No subprocess at all. Instrumented copy of the real reader, 573-process box, 12 runs:
| stage | median | note |
|---|---|---|
pid table (readdir /proc + batched stat/cmdline) |
13.29 ms | ~96% of the pass |
socket table read (/proc/net/tcp{,6}) |
3.45 ms | runs CONCURRENTLY with the pid table, so it is hidden under it — not additive |
| fd walk (only the subtree’s pids) | 0.31 ms | |
| join | 0.07 ms | |
| socket table parse | 0.06 ms | |
total scanSubtreePorts |
13.91 ms |
That settles a claim the module header had been asserting: the dominant cost really is
the HOST pid table, and it scales with the host rather than with the question. It is also
the measurement that makes u-port-scan-subtree-descent the right next move — 96% of
the pass is the thing subtree descent would eliminate.
Steady state, both platforms. Worst case is the duty-cycle floor’s fast end, one pass
per second (nudgeFloorMs keeps a fast pass at the 1 s minimum):
| per pass | worst-case cadence | cost | |
|---|---|---|---|
| darwin | 5.96 ms | 1 / s | 0.60% of one core |
| linux | 13.91 ms | 1 / s | 1.39% of one core |
Darwin’s spawn peaks at 1.9 MB RSS, transient, once per pass.
The counterfactual, and why we pay it. An in-process darwin reader (an N-API addon over the same libproc calls) would save the lifecycle plus the plumbing — 2.26 ms per pass, taking ~5.96 ms to ~3.7 ms, or 0.23% of a core at the worst-case cadence. We decline it for reasons that outweigh 2 ms: a native addon couples padi’s build to a Node ABI and forces a rebuild per Node bump inside the nix closure; a crash in a separate process cannot take padi down, whereas a segfault in an addon takes the daemon with it; and the subprocess boundary is what lets the same binary serve drishti later. The cost is small in absolute terms and is stated here so the trade can be re-examined against a number rather than a feeling.
Low The port scan's nudge floor is duty-cycle bounded, so a slow platform cannot become a hot loop
Banked in #1982. The sampler’s nudge floor used to be a fixed 1 s, which quietly makes per-pass cost a property of the PLATFORM rather than of the pass: the darwin path measured 93 ms on a busy Mac, and 93 ms every second is ~9% of a core for as long as any terminal streams output. Since kaval throttles its activity edge to one per PTY per 200 ms, a single streaming agent delivers ~5 edges/s, so the floor — not the 5 s baseline — is the real period. That is the whole duty cycle, paid for the life of the daemon.
The floor is now clamp(lastPassMs * 20, 1 s, 5 s) (nudgeFloorMs), measured
around the whole pass in a finally so a pass that FAILED — a 5 s helper timeout is
the most expensive pass there is — still pays for its own cost. Effect: ≤5% of one
core by construction, on any platform, with no knob and no platform switch. Linux
(14-18 ms) and a quiet Mac (17 ms) sit at the 1 s minimum and never engage the bound;
50 ms is the largest pass that still fits under it.
Why bound rather than only optimise: a bound holds for costs nobody has measured yet. The darwin mechanism itself is under active review — a hand-rolled libproc helper vs adopting a maintained tool through nix — and whichever wins, the duty cycle is already capped.
Medium Scan a terminal's subtree, not the whole host process table
padi’s port sensor reads every /proc/<pid>/stat on the host to build a
pid→ppid table, because procfs has no children index — except it does:
/proc/<pid>/task/<tid>/children. So the pass costs what the HOST costs
(515 processes here) rather than what the question costs (a terminal’s subtree,
typically a handful). Measured on a 515-process box, through the shipped
module: 35-57 ms per pass before batching, 14-18 ms after (the pid table in
bounded groups, the fd walk concurrent per pid — #1982), and
<1 ms for a children-descent prototype over two subtrees.
That last order of magnitude is worth taking because the cadence is not what it looks like: kaval throttles its activity edge to one per PTY per 200 ms, so one streaming agent delivers ~5 edges/s and the sampler’s ≥1 s nudge floor — not the 5 s baseline — becomes the real period. ~16 ms every second for the life of a padi is ~1.6% of a core; the pre-batching ~40 ms was ~4%.
Why it is not done yet: descending children is a redesign of the walk (it
must iterate /proc/<pid>/task/* so a multithreaded parent’s children are not
missed), and proc(5) warns the list may be incomplete if the process list
changes mid-read. That is the same exit race the scanner already tolerates by
policy (procReadFailure → skip), and the current readdir("/proc")-then-stat
shape has the identical hazard — so this is a deliberate decision to write down,
not a blocker. Measure: a pass on a 500-process box with 1 vs 30 terminals,
table-walk vs children-descent.
Low One kaval activity subscription, fanned out in padi
kaval’s host-global activity stream now has three independent
resubscribeStream consumers — the finish fold, the live dots, and the port-scan
nudge (#1982) — so every activity edge is encoded, transported over
the padi↔kaval link and decoded 3× instead of 2×, with a third retry loop and
async iterator. One subscription publishing into an inMemoryChannel that all
three consume cuts frames on that link by a third.
Held back deliberately: the three are not three of one shape — the live-dots subscription is per-SUBSCRIBER by design while the other two are daemon-lifetime, so a shared receptacle would force the odd one in. The structural review’s own disposition was “extract at the consumer that actually matches”; this records the measured cost so that decision is made against a number.
Low Bound subscriber queues by bytes, not just item count
Each subscriber queue caps at maxQueue (10k items) with no byte bound
(kaval/channel.ts:54-132), so a stalled subscriber on a 1 KB/event PTY could
pin ~10 MB before being dropped. Tracking queue byte size at publish and dropping
when either item-count or a new maxQueueBytes is exceeded right-sizes the
drop threshold to actual heap. Note: the #1420
RCA rules this out as the production OOM source — this is a known-constant memory
cap, not the leak fix (which lives in scrollback/snapshot retention and needs a
dedicated heap snapshot).
- No backpressure / drop-visibility on
proc.onDatafan-out (ptyHost.ts:544-548) —publish()is fire-and-forget; wiring the unusedonOverflowhook into a dropped-subscriber counter surfaces when the fan-out sheds load, the measurement needed to balance producer/consumer capacity. (The original “O(N) push” claim was wrong —pushis O(1).) low - Exit-code tombstones FIFO-evicted, no TTL (
ptyHost.ts:39-42) — an intentional bounded reuse-cache (1024 entries, FIFO); a missing tombstone falls back harmlessly to0. Add a TTL only if measurement shows time-based eviction right-sizes retention better than count-based. low useCommentspersistedPrefhand-rolls a per-terminalIdsignal (useComments.ts:36-83) — consumers wrap it increateMemoso owners do auto-dispose (the leak claim was overstated), but moving tomakePersistedfrom@solid-primitiveswould make owner cleanup automatic (don’t waste retained capacity). Marginal. low- WebGL cap oversized for phones —
WEBGL_CONTEXT_CAP=12suits desktop; mobile shows 1–2 tiles. Largely mitigated (Terminal.tsx:185-196requiresvisible && holdsWebgl), but a layout-specific budget (1 on phone) would right-size held VRAM tighter. Remaining within #1399. low
From static reads to live traces
Every finding above is a static code read. Verification corrected several overstated claims (no “high” survived; “O(n³)” was O(n log n); a “100–400 KB” snapshot was ~4 MB, not ~4 KB) precisely because nobody had a number. The next round moves from reading to measuring — the gaps this map does not yet rest on:
- Compression’s byte cut is measured; the on-device time isn’t. The build cut is banked — the main chunk went 2.56 MB → 571 kB brotli (4.6×) #1643. What’s still untraced is the perceptual win: cold-start LCP/INP on a real phone over Tailscale, before/after.
- No live client trace. Capture LCP/INP/CLS and a flame chart of a real 20+ terminal session (chrome-devtools) to confirm which N-lever items actually surface — this gates the open reactivity/wire items (the keystone shipped, proven by a deterministic re-run-count test rather than a trace; the markdown toggle re-sanitize shipped, proven by an e2e).
- The two new candidates rest on mechanism, not measurement. The ungated 1s
clock.tstick leans on unverified WebSocket-exempts-throttling browser behavior; the server vitest parallelism win is bounded by whether server is the unit-lane long pole. Both are code-confirmed and correctly levered, both unmeasured. - The #1420 OOM root cause is still unidentified. The Channel-queue RCA ruled itself out; a dedicated kaval heap snapshot needs to find the real scrollback/snapshot retention path.
- Mobile rests on mechanism, not on-device traces. Battery wake-ups (15s heartbeat, 60s + 1s tickers), GPU memory across swipes, and keystroke-to-paint on low-end Android are all unmeasured.
- Wire payloads are uncounted. No captured byte sizes for full-key-set / full-object publishes or base64 framing across representative repos.
- Server CPU under load (git-status polling, 1s agent screen-scrape, PR polling) hasn’t been profiled in aggregate, only mechanism-by-mechanism.