Pad/doc/architecture.md

390 lines
21 KiB
Markdown

# Runtime Architecture
This document specifies the concurrency model, goroutine responsibilities, inter-goroutine communication, and the synchronous/asynchronous partitioning of logic operations.
## 1. Concurrency Model
Pad runs three concurrent components coordinated by channels and a single mutex:
```
┌─────────────────────────────────────────────────────────────┐
│ Main goroutine (Gioui event loop) │
│ - Waits for app events (w.Event()) │
│ - On FrameEvent: Lock → Read Frame → Draw → e.Frame → Unlock│
│ - Batches user input into []InputEvent │
│ - Sends batch to logic goroutine via inputChan │
│ - Sends window changes via configChan │
└─────────────────────────────────────────────────────────────┘
↑ w.Invalidate() │ inputChan / configChan
│ (via Frame Receiver) │ (send)
│ ↓
┌─────────────────────────────────────────────────────────────┐
│ Frame receiver goroutine │
│ - Tight loop: read frameChan │
│ - Lock → Store Frame → w.Invalidate() → Unlock │
└─────────────────────────────────────────────────────────────┘
↑ frameChan (read)
┌─────────────────────────────────────────────────────────────┐
│ Logic goroutine │
│ - select on: inputChan, configChan, resultChan │
│ - Processes input batches: loops through []InputEvent │
│ - Dispatches work to priority-aware worker pool │
│ - Sends computed frames on frameChan │
│ - Guarantees: process + layout < 16 ms │
└─────────────────────────────────────────────────────────────┘
↓ highWorkChan / lowWorkChan (dispatch)
┌─────────────────────────────────────────────────────────────┐
│ Worker pool (fixed goroutines) │
│ - Priority-aware: highWorkChan > lowWorkChan │
│ - File I/O, diff computation, undo replay on open │
│ - Results posted on resultChan │
└─────────────────────────────────────────────────────────────┘
```
### Key Invariants
1. **The mutex is never held while touching a channel.** All channel sends and receives happen outside the critical section.
2. **The frame receiver goroutine runs a tight loop.** It reads from `frameChan`, acquires the mutex, stores the frame, calls `w.Invalidate()`, and releases the mutex. This ensures the logic goroutine's send on `frameChan` never blocks unless the receiver is actively storing a frame.
3. **The main goroutine holds the mutex only during draw + `e.Frame()`.** After releasing the mutex, it sends the batched input to the logic goroutine. This send happens outside the lock.
4. **The logic goroutine guarantees sub-16ms processing.** It processes input, mutates state, computes layout, and sends a frame — all within the display refresh budget.
## 2. Goroutine Responsibilities
### 2.1 Main Goroutine (Gioui Event Loop)
Owns the `*app.Window` and runs the event loop. Responsibilities:
- **Event dispatch**: calls `w.Event()` to receive `app.FrameEvent`, `app.DestroyEvent`, `app.ConfigEvent`
- **Frame rendering**: on `app.FrameEvent`, acquires the mutex, reads the current frame, draws it via the renderer, calls `e.Frame(&ops)`, releases the mutex
- **Input batching**: collects all user input (taps, key events, scroll) received during the event cycle into a `[]InputEvent`
- **Input delivery**: sends the batch to the logic goroutine on `inputChan`
- **Config delivery**: sends window size or metric changes (`app.ConfigEvent`) to the logic goroutine on `configChan`
### 2.2 Frame Receiver Goroutine
A dedicated goroutine that bridges the logic goroutine and the main goroutine. Responsibilities:
- **Frame consumption**: reads from `frameChan` in a tight loop
- **Frame storage**: acquires the mutex, stores the received frame
- **Frame invalidation**: calls `w.Invalidate()` while holding the mutex to request Gio to invoke a `FrameEvent`
- **Lock release**: releases the mutex after invalidation
This goroutine exists so that the logic goroutine can send frames without blocking. The receiver is always ready to consume, and the mutex hold is minimal.
### 2.3 Logic Goroutine
Owns all application state. Runs a `select` loop over multiple channels. Responsibilities:
- **Input processing**: receives a batch of input events from `inputChan`. It loops through the `[]InputEvent` and updates state accordingly.
- **Config processing**: receives window/metric changes from `configChan` and updates layout parameters.
- **Result handling**: receives results from workers on `resultChan`, applies completed async tasks to state.
- **Work dispatch**: identifies long-running tasks and dispatches them to the worker pool via the appropriate priority channel (`highWorkChan` or `lowWorkChan`).
- **Frame production**: computes `[]Element` from current state, sends on `frameChan`.
- **State persistence**: persists state to disk (sync or async, runtime decision — see §5).
The logic goroutine is the sole owner of mutable state. No other goroutine reads or writes state directly.
#### Logic Loop Implementation
The logic goroutine uses a `select` to wait for work:
```go
for {
select {
case inputs := <-inputChan:
for _, in := range inputs {
// handle input (e.g., update buffer, move cursor)
}
sendFrame()
case cfg := <-configChan:
// update window size / metrics
sendFrame()
case res := <-resultChan:
// handle worker result
sendFrame()
}
}
```
### 2.4 Worker Pool
A fixed set of goroutines (e.g., 4) that execute long-running tasks with two-tier priority.
#### Priority Tiers
1. **High Priority (`highWorkChan`)**: UI-critical operations that block user progress.
- Loading a file chunk for the cursor/viewport.
- Loading a directory page in the browser.
- Computing a diff for an active merge session.
2. **Low Priority (`lowWorkChan`)**: Background operations that can be delayed without affecting responsiveness.
- Auto-saving file content.
- Persisting undo stacks.
- Building/updating the line index.
#### Implementation
Workers prioritize high-priority tasks using a nested `select`:
```go
for {
select {
case work := <-highWorkChan:
work.Execute()
default:
select {
case work := <-highWorkChan:
work.Execute()
case work := <-lowWorkChan:
work.Execute()
}
}
}
```
Workers post results on `resultChan`.
## 3. Channel Topology
| Channel | Direction | Purpose | Blocking behavior |
|---|---|---|---|
| `frameChan` | Logic → Frame receiver | Computed frames (`[]Element`) | Unbuffered. Receiver runs tight loop, so logic's send only blocks while receiver holds mutex (fast). |
| `inputChan` | Main → Logic | Batched user input (`[]InputEvent`) | Unbuffered. Logic drains via `select`, main sends after releasing mutex. |
| `configChan` | Main → Logic | Window config changes (`app.ConfigEvent`) | Unbuffered. Logic drains via `select`, main sends after releasing mutex. |
| `resultChan` | Workers → Logic | Completed async task results | Unbuffered. Workers post and return to pool. Logic drains via `select`. |
| `highWorkChan` | Logic → Workers | UI-critical async work | Buffered to pool size. |
| `lowWorkChan` | Logic → Workers | Background async work | Buffered to pool size. |
### Why Unbuffered Channels Work
1. **`frameChan`**: The frame receiver goroutine runs a tight loop. It only holds the mutex during the store + invalidate operation. The logic goroutine's send blocks only during this brief window.
2. **`inputChan` / `configChan`**: The main goroutine sends after releasing the mutex. The logic goroutine processes input in < 16ms. The main goroutine produces at most one batch per frame event. The logic's processing pace exceeds production, so the channel never backs up.
3. **`resultChan`**: The worker pool is bounded. Results are posted as workers complete. The logic goroutine drains results in its `select` loop.
4. **Work Channels**: Buffered to pool size. If all workers are busy, logic's dispatch blocks briefly until a worker frees up. Priority ensures critical tasks jump the queue.
## 4. Synchronous/Asynchronous Partitioning
Every user-facing action has a **synchronous state decision** (what the UI shows) and an optional **asynchronous persistence** (writing to disk). The synchronous path must complete in < 16ms.
### 4.1 Text Editing
| Step | Path | Operation |
|---|---|---|
| User types 'a' | Sync | Insert into in-memory buffer, append to undo chain, advance cursor, compute visible lines, produce frame |
| After frame | Async | Debounce timer (1s) triggers file write via worker |
| After frame | Async | Undo stack persistence (sync or async, runtime decision §5) |
### 4.2 Backspace
| Step | Path | Operation |
|---|---|---|
| User presses backspace | Sync | Check if last undo entry matches; if so pop it, else start delete chain. Move cursor backward, recompute visible lines, produce frame |
| After frame | Async | Same as text editing (debounced file write, undo persist) |
### 4.3 Undo
| Step | Path | Operation |
|---|---|---|
| User triggers undo | Sync | Pop top chain from in-memory stack, re-anchor using context search in in-memory buffer, replay in reverse, update cursor, produce frame |
| After frame | Async | Persist modified buffer (debounced), persist trimmed undo stack |
### 4.4 Open File
| Step | Path | Operation |
|---|---|---|
| User taps file in browser | Sync | Switch page to editor, set `active_file`, show loading state, produce frame |
| Async | Worker | Read file metadata (size, mtime), check line index cache, compute diff against last known state |
| Async result | Logic | If diff detected trigger conflict resolution flow. Otherwise load chunk around cursor |
| Async result | Logic | Place cursor, update scroll, produce frame with editor content |
### 4.5 Conflict Resolution
| Step | Path | Operation |
|---|---|---|
| File watcher detects change | Async | Worker reads file metadata, classifies as external update or sync conflict |
| Classification result | Logic | If sync conflict set conflict icon in status bar, produce frame |
| User taps conflict icon | Sync | Switch to merge page, set `loading` state, produce frame |
| Async | Worker | Compute line-based diff between ours and theirs |
| Async result | Logic | Hunks computed display first hunk, produce frame |
| User resolves hunk | Sync | Record choice, advance to next hunk, produce frame |
| User applies merge | Async | Worker writes merged file, deletes conflict file |
| Async result | Logic | Switch to editor page, load merged content, produce frame |
### 4.6 Search
| Step | Path | Operation |
|---|---|---|
| User activates search | Sync | Show search bar element, set focus, produce frame |
| User types in search | Sync | Scan loaded chunks for matches, jump cursor to first match, produce frame |
| User taps up/down | Sync | Jump cursor to previous/next match, produce frame |
### 4.7 Navigation
| Step | Path | Operation |
|---|---|---|
| User scrolls | Sync | Update scroll offset, recompute visible lines, produce frame |
| User taps to place cursor | Sync | Convert tap position line/col via line index, move cursor, produce frame |
| User jumps to line | Sync | Binary search on line index, compute scroll offset, produce frame |
### 4.8 Directory Browser
| Step | Path | Operation |
|---|---|---|
| User scrolls browser | Sync | Update scroll offset, virtualize visible entries, produce frame |
| User types in browser search | Sync | Filter entries by query, produce frame |
| User taps letter in alpha index | Sync | Jump to corresponding section, produce frame |
| Async | Worker | Load directory entries for newly visible region (lazy loading) |
## 5. State Persistence Strategy
State persistence (writing `state.json`, `cursors.json`, undo stacks) has a **runtime-adaptive strategy**:
### Policy
```
Strategy: Sync | Async
Default: Sync
Monitor: rolling average of persistence duration (exponential moving average)
Threshold: 2 ms average
If average > threshold → switch to Async (log transition)
If average < threshold/2 while Async → switch back to Sync
```
### Rationale
On modern mobile devices with UFS storage, writing small JSON files (< 200 KB for typical undo stacks) completes in single-digit milliseconds. Synchronous persistence is simpler (no crash window between in-memory and on-disk state) and provides stronger crash recovery guarantees.
As state grows (large undo stacks, many cursor entries), persistence may exceed the threshold. The runtime switches to async persistence, accepting the bounded crash window (in-memory state ahead of on-disk state, bounded by the 1s debounce).
### Implementation
The persistence function checks an atomic boolean flag (`sync/atomic.Bool`). If `Sync`, it blocks on the file write. If `Async`, it dispatches the write to a worker and returns immediately. The flag is updated by a background profiler goroutine that monitors the rolling average.
### What is Persisted
| Data | Trigger | Debounce |
|---|---|---|
| `state.json` | Every navigation, edit, cursor movement | 500 ms |
| `cursors.json` | Every cursor movement (in any file) | 500 ms |
| `undo/<hash>.json` | Every edit (insert, delete, undo) | 1000 ms (separate from file save) |
| File content (auto-save) | Every edit | 1000 ms |
| Line index | On file close, or after bulk edit | Immediate |
## 6. Deadlock Analysis
### Scenario: Logic sends frame, main holds mutex
```
Logic: frameChan <- elems // blocks if receiver is not reading
Receiver: lock → store → w.Invalidate() → unlock // fast, sub-ms
```
**Resolution**: The frame receiver runs a tight loop. It only holds the mutex during the store + invalidate. Logic's send blocks for at most the duration of this store sub-millisecond. No deadlock.
### Scenario: Main sends input, logic is processing previous input
```
Main: inputChan <- inputs // blocks if logic is not reading
Logic: inputs := <-inputChan // select loop, drains before next frame
```
**Resolution**: Logic's 16ms guarantee means it processes input faster than main produces it. Main sends input after releasing the mutex, so it never holds the lock while blocked on a channel send. No deadlock.
### Scenario: Worker posts result, logic is busy
```
Worker: resultChan <- result // blocks if logic is not reading
Logic: select { case r := <-resultChan: ... }
```
**Resolution**: Worker pool is bounded. Workers post results and return to the pool. If logic is briefly busy processing input, the worker's send blocks for at most the duration of logic's current iteration. The worker is not holding any lock during this send. No deadlock.
### Scenario: Logic dispatches work, all workers busy
```
Logic: highWorkChan <- workItem // blocks if highWorkChan is full
Workers: <-highWorkChan // drain at their own pace
```
**Resolution**: Work channels are buffered to pool size. If all workers are busy, logic's dispatch blocks briefly until a worker frees up. This is acceptable because dispatching work is not on the critical inputframe path. No deadlock.
## 7. Go Concurrency Features Used
| Feature | Where | Why |
|---|---|---|
| **Goroutines** | Main, frame receiver, logic, workers | Natural fit for concurrent, channel-coordinated components |
| **Unbuffered channels** | `frameChan`, `inputChan`, `configChan`, `resultChan` | Synchronous handoff ensures no message is lost; timing guarantees prevent blocking |
| **Buffered channels** | `highWorkChan`, `lowWorkChan` (size = pool size) | Allows logic to dispatch without waiting for a free worker |
| **`sync.Mutex`** | Protects current frame | Single critical section (store or read frame), sub-millisecond hold time |
| **`sync/atomic.Bool`** | Persistence strategy flag | Lock-free read in logic goroutine, written by profiler goroutine |
| **`context.Context`** | Worker task cancellation | Cancel in-flight loads if user navigates away or file is closed |
| **`select`** | Logic goroutine's main loop | Multiplex over input, config, and results without polling |
### Features Deliberately Avoided
| Feature | Why avoided |
|---|---|
| **`sync.Mutex` on state** | Single-owner pattern: only logic goroutine mutates state |
| **`sync.RWMutex`** | No readers other than logic; frame access goes through mutex + frame receiver |
| **`sync.Cond`** | Channels provide the same signaling with less boilerplate |
| **`errgroup`** | Workers are long-lived pool goroutines, not task-scoped |
| **`sync.Once`** | No one-time initialization that needs coordination |
| **`atomic.Value`** | Frame is a slice header (two words); mutex is simpler and safer |
## 8. Frame Lifecycle
A frame (computed `[]Element`) follows this lifecycle:
1. **Logic computes**: state mutation + layout `[]Element`
2. **Logic sends**: `frameChan <- elems` (blocks only while receiver holds mutex)
3. **Receiver stores**: `lock → currentElems = elems → w.Invalidate() → unlock`
4. **Main receives `FrameEvent`**: `lock → renderer.Draw(gtx, currentElems) → e.Frame(&ops) → unlock`
5. **Frame is on screen**: Gioui has submitted the frame to the display
6. **Next frame**: logic computes a new `[]Element`, repeats from step 2
At no point are two frames rendered simultaneously. The mutex ensures the main goroutine never reads a frame while the receiver is writing it. The frame receiver calls `w.Invalidate()` only after storing, so Gioui never requests a draw for a frame that hasn't been stored yet.
## 9. Process Death and Recovery
The concurrency model has no shared mutable state between goroutines (state is owned by the logic goroutine, frame is protected by mutex). On process death:
- **In-memory state is lost**: restored from `.pad/state.json`, `.pad/cursors.json`, `.pad/undo/`
- **In-flight worker tasks are lost**: workers are short-lived; tasks are idempotent (file reads, diff computation)
- **In-flight persistence is lost**: bounded by the debounce window; last persisted state is the recovery point
- **Merge state does not survive**: if the app dies during conflict resolution, the merge session is re-computed on relaunch (diff is idempotent)
The recovery flow (SPEC §8) reads persisted state, restores the active file, cursor, and undo stack. The concurrency model restarts fresh: new goroutines, new channels, same invariants.
## 10. Memory Monitoring and Management
Pad implements a monitoring system for heap usage and latency, with hooks for future global memory management.
### 10.1 Monitoring
A background profiler goroutine periodically collects system metrics:
- **Heap Usage**: Collected via `runtime.ReadMemStats`. Tracks total allocated memory and heap size.
- **Persistence Latency**: Tracks the EMA of state persistence duration (see §5).
- **Logic Latency**: Tracks the time taken for the Logic goroutine to process input and compute layout.
These metrics are reported on a **Debug Page** within the application, allowing for real-time performance analysis during development and by power users.
### 10.2 Memory Pressure (Future Enhancement)
While initial versions use per-component eviction (e.g., LRU for chunk cache and line indices), a future **Global Memory Governor** is planned:
- **Centralized Eviction**: Instead of components deciding when to evict independently, the Governor monitors total heap pressure.
- **Pressure Tiers**:
- **Normal**: No proactive eviction.
- **Warning**: Proactively evict oldest line indices and non-visible file chunks.
- **Critical**: Evict all non-visible data; force garbage collection.
- **Implementation**: Components register "evictable" resources with the Governor, which calls back to release memory when thresholds are exceeded.
This ensures that the "No Practical Limits" philosophy does not lead to Out-Of-Memory (OOM) crashes on devices with limited RAM.