Pad/doc/architecture.md
Greg Pomerantz 6c6a0c1a27 Fix write-concurrency race: per-file write protocol + unique temps
The review-identified race: saves are async (owner snapshots content,
worker pool writes), nothing serialized per file, and every write of a
file used the SAME deterministic temp ('.<name>.tmp'). Two overlapping
writes (autosave x autosave, retry x autosave, or the synchronous
FlushAll on GoToBrowser/Shutdown x a worker write) interleaved on the
shared temp and could rename a byte-mixture into place; even without
interleaving, last-rename-wins could promote a STALE snapshot.

Owner-side protocol (logic.go, requestSave + result handler):
- at most one write in flight per file (writeInFlight maps filename ->
  the file version whose content the in-flight write carries);
- a save requested while one is in flight is deferred (savePending) and
  re-issued by the write's result handler with a FRESH snapshot, so
  'last rename wins' coincides with 'newest snapshot wins';
- on success the SNAPSHOT version (not the current one) is recorded as
  written, so an edit that arrived during the write leaves the file
  dirty and triggers the re-issue;
- FlushAll (GoToBrowser, Shutdown) defers via the same protocol instead
  of writing concurrently on the shared temp;
- shutdown drain: on done the owner waits (bounded 5 s) for in-flight
  writes and armed retries to settle before exiting, so the post-exit
  synchronous FlushAll and workerPool.Stop cannot race a straggling
  worker write;
- retry timer now sends a non-blocking token (no timer-goroutine stall
  on a full channel); emitFrame no longer blocks on a slow/gone main
  (frames are snapshots; the next emission wins) - also required so the
  drain can never deadlock on frame delivery.

Mechanism (real/filesystem.go):
- WriteFileAtomic uses a unique per-call temp ('.<name>.tmp.<pid>.<seq>'),
  making same-file staging-file interleaving structurally impossible even
  if the serialization regressed (defense in depth);
- each successful write best-effort removes stale temps of the same file
  (crash leftovers, plus the legacy deterministic name for upgraded
  installs); a failed write removes its own temp.

Tests:
- write_serialization_test.go (e2e): a counting FS wrapper proves the
  peak concurrent same-file saves is 1 across two deliberately
  overlapping autosaves (2 s saves; the second edit lands inside the
  first save's window and its token is deferred, then re-issued with the
  newer content), and that a Flush during an in-flight save adds no
  concurrent writer and the newest snapshot still wins. Mutation-verified:
  disabling the deferral fails it with peak = 2. (The pool's WriteFileTask
  calls FS.WriteFile, not WriteFileAtomic - the real FS is atomic only
  because WriteFile delegates to WriteFileAtomic; the wrapper mirrors that
  delegation or the overlap window does not exist.)
- filesystem_test.go: stale-temp test updated to the new pattern, also
  covering the legacy name and asserting a different file's temp is
  untouched.
- real_file_fuzz_test.go: stray-temp check matches both patterns.

Docs: architecture.md 6.5 rewritten (protocol invariants), spec.md
autosave line, development_plan.md v11 + Phase 12.

On-device smoke: open, type, autosave lands exact content on disk, no
temp files left, clean relaunch. Full suite green under -race.

Residuals (documented): no fsync before rename (power-loss window only);
external-change detection absent; a drain-deadline exit with a straggling
write can only lose freshness (unique temps keep every rename a complete
snapshot).
2026-08-17 16:29:47 -04:00

518 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Runtime Architecture
This document describes how Pad actually works: the concurrency model, goroutine
responsibilities, the frame handoff contract, ownership rules, and the internal
design of the editor and browser. It is written at the level of *invariants and
contracts*, not line-by-line code, so it stays true as implementation details
evolve. If a detail below conflicts with the code, the code wins — and this
document should be fixed.
## 1. Concurrency model: single owner, no locks on state
The **logic goroutine is the sole owner (reader and writer) of mutable
`State`**. There is no `sync.Mutex`/`sync.RWMutex` on application state. Every
other goroutine talks to the owner through channels.
```
┌────────────────────────────────────────────────────────────────────┐
│ Main goroutine (Gio event loop) │
│ - w.Event() loop: Config / Frame / Destroy │
│ - On FrameEvent: lock(handoff) → read Frame snapshot → │
│ Renderer.Draw → collect gestures + key events → │
│ e.Frame → unlock(handoff) → send inputs/config/query/layout │
│ to logic channels (sends happen OUTSIDE the lock) │
└───────────────▲──────────────────────────────┬─────────────────────┘
│ handoff lock (frame storage) │ inputChan, configChan,
│ + w.Invalidate() │ layoutChan, searchQueryChan
┌───────────────┴───────────┐ ▼
│ frameReceiver goroutine │ ┌──────────────────────────────────────┐
│ tight loop: │ │ Logic goroutine (SOLE OWNER of State)│
│ f := <-frameChan │ │ - select over all input channels │
│ lock; *frame = f; │ │ - mutates State, builds []Element │
│ w.Invalidate(); unlock │ │ - sends Frame on frameChan │
└───────────────────────────┘ │ - dispatches tasks to worker pool │
└───────────────┬──────────────────────┘
│ task dispatch / results
┌───────────────▼──────────────────────┐
│ Worker pool (8 goroutines) │
│ - priority: high > medium > low │
│ - file I/O, directory index, line │
│ index build, autosave writes │
│ - results posted on ResultChan │
└──────────────────────────────────────┘
```
Key invariants:
1. **No locks on `State`.** The only mutex is the *handoff* mutex in
`cmd/pad/main.go`, which guards the one-frame storage slot between
frameReceiver and the main goroutine. It is a message-passing handoff, not
a state lock; it is never held while touching a channel.
2. **The main goroutine never reads logic `State` directly.** It reads only
the latest `Frame` snapshot (see §4) and writes only via channels.
3. **Non-owner goroutines never mutate `State`.** They send a request
(channel) or run a callback on the owner (`Inspect`, test-only).
4. **The autosave timer goroutine reads nothing.** `time.AfterFunc` only sends
a token on `autosaveChan`; the owner does all state reads and dispatches
the write.
5. **Gio-mutable widget state lives in the main-goroutine-owned `Renderer`**,
never in logic-owned `State` (see §5).
6. **Logic work is synchronous and fast.** Processing input, mutating state,
building the element tree, and sending a frame must stay well under one
display refresh (~16 ms). File I/O and index building always go to the
worker pool.
## 2. Goroutine responsibilities
### 2.1 Main goroutine (`cmd/pad/main.go`)
- Runs the Gio event loop.
- On `app.ConfigEvent`: sends `ConfigEvent{PixelWidth, PixelHeight}` to
`logic.ConfigChan()`.
- On `app.FrameEvent`:
1. Reads `newScale = gtx.Metric.PxPerDp`; if it differs from
`frame.Scale`, sends `ScaleEvent` (after the draw).
2. Under the handoff lock: reads the `Frame` snapshot, calls
`renderer.Draw(gtx, frame.Elems, scale)`, then `renderer.CheckGestures`
and the focused element's key/edit events, then `e.Frame(&ops)`.
Key events are queried with a catch-all `key.Filter{Focus: id}` **plus
one named filter per arrow key**. This is required on Android: the
window layer wraps plain arrow-key *presses* in `input.SystemEvent`
(it wants them for focus navigation), and system events match only
filters that name the key explicitly. Matching a named filter both makes
the press deliverable and suppresses the focus-move side effect (a
matched event makes `WakeupTime` report handled, skipping the window's
`moveFocus`). The **shift key is tracked here** (`shiftDown`): Gio's
Android JNI bridge never reads `KeyEvent.getMetaState`, so
`key.Event.Modifiers` is always 0 and shift+arrow is otherwise
indistinguishable from a plain arrow. `NameShift` press/release do
arrive as plain events; the tracked state is attached to the
`ui.KeyEvent{Shift: ...}` forwarded to the logic (OR-ed with the
Modifiers field so desktop behavior is unchanged). Shift state is
reset when no element is focused.
3. Outside the lock: sends `[]ui.InputEvent` (if any) to `InputChan`,
the search text (if it differs from `frame.Query`) to `SearchQueryChan`,
and `renderer.GlyphLayout()` to `LayoutChan`.
- Owns `frameReceiver` (started in `run`).
### 2.2 Frame receiver
Tight loop: read `logic.FrameChan()`, store into the shared `Frame` under the
handoff lock, call `w.Invalidate()`. The logic goroutine's send on `frameChan`
(bufsize 1) never blocks for more than one frame cycle.
### 2.3 Logic goroutine (`internal/editor/logic.go`)
Sole owner of `*State`. `Run()` selects on:
| Channel | From | Payload | Action |
|---|---|---|---|
| `configChan` | main | `ConfigUpdate` (pixels or scale) | store scale/pixels; recompute layout |
| `inputChan` | main | `[]ui.InputEvent` | run each `Handler(evt.Data)` on the owner |
| `searchQueryChan` | main | `string` | store `Browser.Query`, re-filter, new frame |
| `openFileChan` | main (tap) | `string` path | open file in editor (chunked buffer + async index) |
| `layoutChan` | main | `ui.GlyphLayout` | store on editor state; derive `LastLineY` for scroll clamping |
| `retryChan` | logic | `string` filename | autosave retry |
| `autosaveChan` | timer | `struct{}` token | reconstruct content, dispatch `WriteFile` task |
| `workerPool.ResultChan()` | pool | task `Result` | apply async results (stat, read, index, write ack) |
| `inspectChan` | tests | `inspectReq` | run `fn(*State)` **on the owner** and reply (test-only) |
| `resultChan` | legacy | `ResultEvent` | drained (legacy channel; the live result path is the pool's) |
| `done` | main | — | drain pool, stop timer, exit |
After every state change the logic goroutine rebuilds the element tree and
sends a `Frame` on `frameChan`.
### 2.4 Worker pool (`internal/io/pool`)
Fixed 8 workers, two priority lanes (high > low; dispatch routes
`HighPriority` tasks to the high lane, everything else to low). Tasks are small
structs with `Execute() Result`; the `FileSystem` interface (`pool.FileSystem`)
has a real implementation (`io/pool/real`, rooted at `/`) and a mock
(`io/pool/mock`) for tests.
Task types in active use: `StatFile`, `ReadFile`, `BuildLineIndex`,
`WriteFile` (editor); `BuildIndex`, `LoadPages` (browser). Dormant/dead task
types — `ReadChunk` (no-op for fully-loaded in-range files), `ReadDir`,
`StatDir`, `ReadCache`, `WriteCache`, `Invalidate`, `SaveState`, `SaveUndo`
are candidates for removal in a cleanup round.
## 3. Channel topology summary
- **main → logic:** `InputChan`, `ConfigChan`, `LayoutChan`, `SearchQueryChan`,
`OpenFileChan`.
- **timer → logic:** `autosaveChan` (token only).
- **logic → logic:** `retryChan` (self-piped).
- **logic → frameReceiver:** `FrameChan` (the only outbound state carrier).
- **pool → logic:** `WorkerPool.ResultChan()`.
- **tests → logic:** `Inspect` (owner-executed callback; the production
equivalent is "send a request channel message").
## 4. The Frame handoff contract
`editor.Frame` is the **only** data that crosses from the logic side to the
main side:
```go
type Frame struct {
Elems []ui.Element // element tree for the next draw
Scale float32 // current px-per-Dp
FocusedElementID string // which registered element gets key/edit events
Query string // search text logic is filtering with
}
```
- `Elems` is the computed tree; the renderer draws it (it is a snapshot, safe
to read from main).
- `Scale` lets main detect a density change and pass scale to `Renderer.Draw`.
- `FocusedElementID` drives which registered `key.Filter`/`key.FocusFilter`
main uses to harvest keyboard/IME events.
- `Query` lets main compare against the (main-owned) search `widget.Editor`
text and forward changes — the browser search box is a Gio widget, so its
text lives on the main side, not in `State`.
## 5. Ownership rules
| Owned by | What |
|---|---|
| **Logic goroutine** | `State` (browser + editor + chunked buffers + line indexes), worker pool, autosave timer handle |
| **Main goroutine** | `*app.Window`, `op.Ops`, text shaper, `ui.Renderer` (gesture state, IME dedup state, glyph layout cache), the search-bar `widget.Editor` |
Rules:
1. **Gio mutates widgets during draw, so anything Gio mutates must be
main-owned.** The search bar is a `widget.Editor` registered with the
renderer by ID (`"search_bar"`); its text reaches logic only through
`SearchQueryChan`.
2. **The editor content is NOT a `widget.Editor`.** It is the custom
`ui.TextField` element; all editor state (buffer, cursor, scroll) is
logic-owned. This is why the editor does not hit `widget.Editor`'s
O(n²)-ish cost on large files.
3. **Per-frame element values are rebuilt by logic** (`[]ui.Element` in the
`Frame`); any cross-frame persistent draw-side state (IME dedup, gesture
tracking) belongs in the persistent, main-owned `Renderer`.
## 6. Editor internals (`internal/editor`)
### 6.1 Chunked buffer
`ChunkedBuffer` (`chunked_buffer.go`) is the edit buffer for open files.
- **Full load on open** for in-range files: the whole file is read into an
ordered slice of chunks (64 KB) plus **prefix-sum byte offsets** over actual
chunk lengths. There is no lazy loading and no eviction (both existed as
plans and were removed).
- **Byte-indexed** throughout: `CursorPosition`, chunk offsets, and glyph
`ByteOffsets` are byte offsets. All edit primitives are **rune-granular**:
`HandleBackspace`/`HandleDelete` compute the UTF-8 rune width at the cursor
(a byte-granular delete corrupts multi-byte characters, e.g. a two-byte
character straddling a chunk boundary); IME edits arrive as rune ranges;
tap-to-position lands on rune starts.
- Edits splice the affected chunk(s) only; chunks are not rebalanced.
- `LineIndex` (per-line byte offsets, `int32`) is built asynchronously by
`BuildLineIndexTask` and stored on `cb.LineIndex` — the single source of
truth (the old parallel `EditorState.LineIndex` field was removed). A file
ending in `'\n'` has a trailing empty line (one offset past the last `\n`,
equal to the file length).
- **Incremental line-index maintenance.** Every buffer edit updates the index
in place instead of rebuilding it: `UpdateLineIndexAfterInsert(pos, text)`
(shifts starts above `pos` right, keeps a start at `pos`, adds one start per
inserted `\n`) and `UpdateLineIndexAfterDelete(start, end)` (drops starts in
`[start, end)`, shifts the rest left, re-inserts `start` iff it is a line
start in the new content). A start exactly at `end` always drops: it was
created by the `'\n'` at `end-1`, which the deletion removes (line merge).
These must be called in the same order as the buffer splice, and an IME
replace is `Delete` then `Insert` at the same position.
- **Size guard:** files larger than `MaxEditableFileSize` (**50 MB**,
measured on-device) open into a `TooLarge` state: the editor shows a notice
and edit handlers are no-ops; the browser still lists the file.
### 6.2 Virtualized viewport
Only the visible byte range is shaped and drawn each frame:
- `VisibleByteRange` maps scroll offset + viewport height → `[startLine,
endLine]` via the `LineIndex`, then to a byte range. The range is always
bounded by real lines of the document.
- **Scroll decomposition invariant.** The scroll offset s is split into a
content line k and sub-line remainder r (k·lh ≤ s < (k+1)·lh) by a single
float64 floor decomposition, and the three consumers of that split MUST
stay in lockstep: the window start line (VisibleByteRange), the renderer's
sub-line shift (the windowed layout is drawn shifted up by r), and the tap
mapping (`tapLocalY` adds r). With them consistent, a tap a dp below the
region top always maps to content line k + ⌊(a+r)/lh⌋ =
⌊(a+s)/lh⌋ — the line actually under the finger — for every s ≥ 0. The
decomposition must be computed in float64: a raw `int(s/lh)` in the Dp
float32 domain can round the quotient UP across an integer boundary while
a float64 mod still reflects the line below, so the window start and the
remainder disagree by one line in a sub-pixel-wide band of offsets and the
whole rendered window (hence every tapped line) shifts by one.
- **Font-scale axis.** The shaper draws baselines in sp, so on Android the
rendered line pitch in density-dp is `EditorLineHeight()*fontScale`
(`fontScale = Metric.PxPerSp/PxPerDp`, the user font-size setting).
Every consumer of `lh` above therefore uses `EffectiveLineHeight()` (the
font-scale-applied value, tracked in `State` via `ScaleEvent.FontScale`),
and the renderer's `GlyphLayout.LineHeight`, caret, handles, and selection
highlight use the same scaled pitch. With the raw line height, a
non-default font setting would misplace taps by up to (fontScale-1)
viewportfuls of lines and make scroll clamping stop short of (or run past)
the file ends. Density itself (pure hi-DPI) is a separate axis: all
geometry bookkeeping is in density-dp and scale enters only at the px↔dp
conversion (single `r.scale`/`State.scale`), so the invariant above is
scale-free and holds at any display density.
- **Never shape the whole file.** Lesson learned (Phase 3): the shaper's
internal `document` retains the backing array of its largest layout forever
(`reset()` keeps the cap), so one whole-file layout permanently inflated
memory to ~1 GB for a 10 MB file. Shaping ~50 lines keeps it small and flat.
- The renderer reports `GlyphLayout` back via `LayoutChan`; logic derives
`LastLineY` for scroll clamping and the max scroll offset.
- **`GlyphLayout` offsets are window-relative.** The shaper only sees the
visible window, so `GlyphLayout.ByteOffsets` (and `VisualLineStarts`, `Y`)
are relative to `IMEWindowStartByte`, not the file start. Any logic-goroutine
code that maps a glyph offset to the absolute `CursorPosition` (tap-to-position,
Home/End, vertical cursor move) must add the window base
(`IMEWindowStartByte`) before storing the cursor and subtract it before
searching the offsets. For a whole-file window the base is 0 (a no-op).
Getting this wrong snaps the cursor to the window top on scrolled large files.
### 6.3 Selection and caret
- Key presses arrive as `ui.KeyEvent{Name, Shift}` (main.go), not bare
`key.Name`, so handlers can distinguish shift+arrow from plain arrow.
On Android the `Shift` bit comes from main.go's own shift tracking, not
from Gio's Modifiers (see §2.1); arrow-key presses reach the handler only
because main.go registers the explicit named key filters.
- **Shift+arrow extends a selection**, plain arrow moves the caret and clears
it. `SelectionAnchor` is the fixed end, `CursorPosition` the active end; both
are **absolute file byte offsets**. No selection ⇔ `SelectionAnchor == -1`.
A zero-length shift selection keeps the anchor (`SelectionStart/End == -1`)
so the next shift-move extends from the original spot.
- Insert / backspace / delete with a live selection delete the whole selection
first (`deleteRange`), then insert; the selection is cleared afterwards.
- IME: with an active selection, `HandleReplaceRange` unions the IME-reported
range with the selection before splicing, so replacement is deterministic
whether the IME reports the caret (empty range) or the full range.
- Rendering: the `TextField` element carries **window-relative** selection
start/end into the visible `Value` (1 = none) for the highlight, drawn
before the glyphs; the IME `SelectionCmd` push dedups on the
(selectionStart, caret) pair.
### 6.3a Touch selection (v1)
- **Division of labor: the renderer reports finger positions, the logic owns
all geometry.** Touch input is delivered to the logic goroutine as
`ui.Point` (tap), `ui.DoubleTapPoint`, `ui.LongPressPoint`, and selection
drag events (which handle + app-local Dp position). The logic converts them
to text coordinates using the `EditorRegion` stored on `State` (set each
layout frame) plus the scroll offset, hit-tests the floating-menu items
itself, and owns the menu rect, highlight range, and handle positions.
The renderer only draws what the `Frame` snapshot says (menu, handles,
highlight) and registers the input regions.
- **Menu taps are logic-decided.** The menu panel is one `Tap` interaction
over its whole rect; the editor's tap handler ignores any tap inside a
visible menu (`pointInMenu` guard) so both handlers can coexist regardless
of dispatch order. Item identity comes from the tap's X within the panel.
- **Long press needs frames to elapse.** Gio renders on demand; a stationary
finger produces no pointer events and therefore no frames, so the 400 ms
threshold could never be checked. The main loop polls
`Renderer.PendingLongPress()` and keeps invalidating the window while a
press is held still on the editor. A non-grabbing raw pointer probe
(plain `event.Op` tag on the editor region) observes the press's motion and
cancels the pending long press on movement; a scroll or handle drag grabs
the pointer (`pointer.GrabCmd`), which cancels it via `pointer.Cancel`.
- **A pointer filter with zero `Kinds` matches nothing.**
`pointer.Filter.Matches` tests `e.Kind & f.Kinds == e.Kind`, so a query
without `Kinds` silently receives no pointer events — probe queries must
name the kinds they want.
- **One event per Update is a trap on Android.** A tap's down+up routinely
arrive in a *single* frame, and `gesture.Click`/`gesture.Drag` return at
most one event per `Update` call. If the renderer processed only one event
per gesture per frame, the release would sit in the queue until the next
redraw — which on an idle window may never come — and the tap is swallowed
(this is why menu taps initially required a second tap to "rescue" the
first). The renderer therefore drains each click/drag gesture's queue to
exhaustion every frame; `gesture.Scroll` already drains internally.
- **Per-frame gesture bookkeeping must survive the frame.** Click-registry
state (press time/position, long-press-fired flag) is kept in structs held
**by pointer** in a map; `range` over a value-type map yields copies and
silently discards the mutations.
- **Clipboard crosses the goroutine boundary via channels; the ops run on
the main/Gio frame path.** Logic→main: `clipboardSetChan` (string to write)
and `pasteReqChan` (token). Main→logic: `pasteChan` (string). All three are
buffered (16) so a harness with no main loop can never block the logic
goroutine. Main executes `clipboard.WriteCmd` / `clipboard.ReadCmd` during
a frame and forwards read results back on `pasteChan` from the
`transfer.DataEvent`.
- **Android clipboard reads need an explicit invalidation.** Gio v0.10 on
Android answers `ReadCmd` synchronously during the op flush by queueing a
`transfer.DataEvent`; a queued DataEvent schedules **no** frame wakeup of
its own. Main therefore invalidates the window after each read so a
follow-up frame exists in which the DataEvent is consumed.
- **The menu closes on any item tap.** Copy keeps the selection (only the
menu disappears); cut removes it (via `ClearSelection`, which also hides
the menu and cancels drag bookkeeping); paste closes the menu immediately
even though the actual insert happens on a later frame when the clipboard
content arrives.
### 6.4 IME (Android soft keyboard)
- The editor exposes to the IME a **windowed snippet**: `IMEWindowText` is the
visible viewport text, `IMEWindowStartByte` its absolute start. While the
`TextField` is focused, `drawElement` emits `key.SnippetCmd` (the window)
and `key.SelectionCmd` (caret as a rune index into the window).
- **Dedup in the Renderer** (main-owned state): the snippet/selection are
re-emitted only when they actually change, and a fresh push is forced when
the field (re)gains focus. This mirrors `widget.Editor`'s behavior and
prevents per-frame re-push from resetting IME composition on rapid commits.
- Incoming `key.EditEvent{Range, Text}` has **window-relative rune
indices**; `HandleReplaceRange` converts them to absolute byte offsets
(`RuneIndexToByte`) and splices the chunked buffer. Insertion,
`deleteSurroundingText` (backspace/autocorrect replacement), and composition
all arrive through this one path.
### 6.5 Autosave and the per-file write protocol
- Any edit calls `markDirty()`: a 1 s debounce timer; each keystroke restarts
it. On expiry the timer goroutine sends a token on `autosaveChan`; the owner
snapshots the full content from the chunked buffer and requests a save.
- **Per-file write protocol (corruption guard).** The worker pool is shared
and the on-disk staging path is per-file, so two concurrent writes of the
same file would race on the staging file. The owner therefore guarantees:
- at most **one write in flight per file** (`writeInFlight`, keyed by
filename, recording the file version whose content the write carries);
- a save requested while one is in flight is **deferred** (`savePending`)
and re-issued by the write's result handler — so the rename that lands
last always carries the **newest** content ("latest state wins");
- on success the recorded written version is the **snapshot's** version,
so any edit that arrived during the write leaves the file dirty and
triggers the re-issue.
- `FlushAll` (page switch, shutdown) obeys the same protocol: if a write is
in flight it defers instead of writing concurrently.
- **Shutdown drain:** on the `done` signal the owner waits (bounded, 5 s) for
in-flight writes and armed retries to settle before exiting, so the
post-exit `FlushAll` and `workerPool.Stop` cannot race a straggling worker
write.
- **Staging file:** `WriteFileAtomic` uses a **unique per-call temp name**
("`.<name>.tmp.<pid>.<seq>`") in the target directory and renames it into
place, so readers and crash recovery only ever see a complete file. Each
successful write also best-effort removes stale temps of the same file
(crash leftovers, plus the legacy deterministic "`.<name>.tmp`" name).
Unique temp names make same-file interleaving structurally impossible even
if the serialization regressed.
- Write failures are tracked per file (`writeFailed`, `retryAttempts`) and
retried via `retryChan` (exponential backoff 1 s … 30 s; the timer sends a
non-blocking token and the owner re-snapshots at fire time). There is no
save button; autosave is the only persistence.
- Known residual (out of scope): no `fsync` before the rename — a **power
loss** inside the rename window can lose the last save (process death
cannot: the page cache survives).
### 6.6 Opening a file
- A browser tap sends the path on `OpenFileChan`. The logic goroutine creates
the `ChunkedBuffer`, dispatches `StatFile` (size guard) and the read +
`BuildLineIndex` tasks, switches `page` to the editor, and sets
`justOpenedAt`.
- **Opening-tap swallow:** the tap that opens the file is also delivered as an
editor tap in the same frame. A short time window (`justOpenedAt`) swallows
it so the viewport does not jump to the tapped (often EOF) position.
## 7. Browser internals (`internal/browser`)
- `BrowserState` is **embedded by value** in `State` (single owner; no
pointer indirection).
- `BrowserManager` drives async directory loading through the worker pool:
`ReadDir` + `BuildIndex` on navigation, `LoadPages` for pagination.
- Features: 4 sort modes (name/date × asc/desc; default newest-first),
incremental case-insensitive search over names, directory navigation with
back, single-tap file open.
- The browser renders via `ListView`/`ListItem` elements; rows carry
`Interaction` handlers that send channel requests (tap) or mutate state
directly when the handler runs on the owner (scroll).
## 8. Render pipeline (`internal/ui`)
- **Logic** builds a `[]ui.Element` tree. Elements are value types; interactive
ones carry `Interaction{Gesture, Handler}` entries. `Handler` is a static
function that mutates `TheState` — handlers run on the logic goroutine only.
- **Main** draws via `Renderer.Draw(gtx, elems, scale)`:
1. `drawElement` recursively walks the tree, applying transforms/clips.
2. Interactive elements register hit regions in the *current clip context*
(`gesture.Click.Add` / `pointer.InputOp`), so hit-testing always matches
the drawn geometry.
3. Text elements shape through the shared shaper — **only the visible
window**, never the whole buffer.
4. `e.Frame(&ops)` flushes ops.
- After draw, `CheckGestures` turns raw pointer/gesture state into
`[]ui.InputEvent` (each carrying its own handler) for the logic channel.
Tap/double-tap/long-press/selection-drag event positions are **app-local
Dp** (window px ÷ `PxPerDp` — the same space as element `Region`s, and
`MenuRect`); the logic side converts them to text coordinates with the
stored `EditorRegion` + scroll (README.md §Screen coordinates, pipeline
hop 2).
- **Units:** `ui.Dp`/`ui.Px` convert via the current `PxPerDp` scale
(`ToDp`/`ToPx`). The window is 390×844 dp; the real pixel size arrives via
`ConfigEvent`.
- **Live element catalog:** `Container`, `Label`, `Icon`, `TextField` (editor
window), `ListView`/`Line`/`ListItem` (browser rows), `GioEditor` (search
bar), `Cursor`. Types `Button`, `Toast`, `Spacer`, `AlphaIndex`,
`Selection`, `MergeHunk` exist in `element.go` but are not used by any page
— future work, not current behavior.
- **Theme:** `ui.Theme{FontSize: 14}` + palette in `element.go`.
## 9. Testing hooks
- `Logic.Inspect(fn)` (test-only): runs `fn(*State)` **on the owner** and
returns its result — the sanctioned way for tests to read state without
breaking single ownership. It must never be called from production code.
- The e2e harness (`internal/test/e2e`) drives the **real** `Logic` + worker
pool + mock filesystem through the real channels, and asserts via
`Inspect`. It is logic-only: it does not exercise `Renderer.Draw`, so
draw-path behavior (IME dedup, gesture routing) is validated on-device with
adb (see `development_plan.md` §Phase 2/3 observation loop).
- CI gate: `go build ./... && go vet ./... && go test -race ./...`.
## 10. Known loose ends (code, not spec)
- Globals `TheState` / `TheLogic` / `ui.OpenFile` still exist (handlers are
static funcs); replacing them with explicit state is a planned cleanup
(`development_plan.md` Phase 4).
- Dead task types (`ReadChunk`, `SaveState`, `SaveUndo`, …) and unused element
types are candidates for removal in the same round.
- The Android arrow-key/shift workaround in main.go (named `key.Filter`s +
app-side shift tracking, §2.1) compensates for two Gio v0.10 behaviors: the
JNI bridge dropping modifier state, and mobile arrow presses being wrapped
in `input.SystemEvent` for focus navigation. If Gio changes either
behavior (e.g. starts passing meta state), the shift tracking and the named
filters must be re-examined — the workaround would become redundant or
wrong. Re-verify on-device with `adb shell input keyevent 22` (cursor must
move) and `input keycombination 59 22` (selection must extend).
## 11. Performance profiler (default-off, `internal/perf`)
Pad ships a built-in profiler that is **off by default** and costs nothing when
off. It is the in-app frame-timing tool (see `development_plan.md` §9: `gfxinfo`
cannot measure this app because it renders into a `SurfaceView`).
- **Enable** by creating the marker file
`/storage/emulated/0/PadPerf/enable` before launch. `main.go` then creates a
`perf.Profiler` that writes `logic_frames.csv` (one row per logic frame: seq,
ms-since-start, frame delta, page, scroll Dp, max-scroll Dp, total lines,
visible byte range) and logs a rolling ~1 s `PERF` summary. `DestroyEvent`
stops it (final flush + `PERF stopped` summary with p50/p90/p99/max).
- **Hook**: `internal/editor.PerfRecord` is a package-level func, set by
`main.go` only when enabled. `Logic.emitFrame` calls it on the owner
goroutine with a `ProbeRecord` **before** sending the frame. When disabled it
is `nil` and the per-frame cost is a single nil check.
- **Debug scroll jumps** (for testing, off by default): with the profiler on,
`main.go` also polls `/storage/emulated/0/PadPerf/cmd` (a one-shot file
consumed on read). `top`, `bottom`, `frac <0..1>`, and `dp <int>` jump the
editor's `ScrollOffset` (clamped to `[0, MaxScroll]`) and emit a frame. This
lets a test drive large-offset scrolls deterministically without pixel taps.
- The profiler is owned by the goroutine that creates it and is single-goroutine
(no locks). It does **not** `Sync()` the CSV per flush (only per row batch) to
avoid periodic fsync hitches in the logic path.
- **Measured** (emulator, 10 MB file, 2026-08): logic-frame cadence is flat
across scroll offsets 0.02→1.0 (no large-offset degradation); the visible byte
range stays ≤ ~4.3 KB (0.04% of the file); PSS plateaus ~250 MB (bounded
high-water mark, no leak). See `development_plan.md` Phase 6.