- internal/test/e2e/: FrameCapture, Harness, ElementAssertions, helpers - internal/editor/logic.go: Add done channel and Done() method for graceful shutdown - internal/editor/mock_setup.go: Mock filesystem for tests - internal/browser/: Browser layout and search logic - internal/io/: Worker pool for async tasks - Update architecture docs and spec
506 lines
26 KiB
Markdown
506 lines
26 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 | Buffered to `workerCount × 4`. Workers post results; logic drains via `select`. Buffer prevents worker blocking when multiple tasks complete simultaneously.
|
||
| `highWorkChan` | Logic → Workers | UI-critical async work | Buffered to `workerCount × 2`. |
|
||
| `lowWorkChan` | Logic → Workers | Background async work | Buffered to `workerCount × 2`. |
|
||
|
||
### Why the Channel Design Works
|
||
|
||
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`**: Buffered to `workerCount × 4`. The worker pool is bounded. Results are posted as workers complete. The logic goroutine drains results in its `select` loop. The buffer prevents workers from blocking when multiple tasks complete simultaneously while logic is processing input.
|
||
|
||
4. **Work Channels (buffered to `workerCount × 2`)**: If all workers are busy, logic's dispatch blocks briefly until a worker frees up. Priority ensures critical tasks jump the queue.
|
||
|
||
## 4. Result Matching
|
||
|
||
When multiple tasks are dispatched concurrently, the logic goroutine must match results to their originating tasks. Each `Task` carries identifying context:
|
||
|
||
| Field | Purpose | Example |
|
||
|---|---|---|
|
||
| `TaskID()` | Unique identifier per dispatch | `"read_dir_/docs_42"` |
|
||
| `TaskType()` | Routing key for result handler | `TypeReadDir`, `TypeLoadPages` |
|
||
| `DirPath()` | Directory the task operates on | `"/user/documents"` |
|
||
|
||
The `Result` struct includes `TaskID`, `TaskType`, and `Data`. The logic goroutine routes results by `TaskType` (e.g., `IsBrowserResult()`, `IsFileResult()`) and uses `DirPath()` to apply results to the correct state (e.g., loading pages for the currently browsed directory).
|
||
|
||
```go
|
||
case res := <-wp.ResultChan():
|
||
if res.IsBrowserResult() {
|
||
switch res.TaskType {
|
||
case TypeBuildIndex:
|
||
applyDirectoryLoaded(res) // Uses res.Data + DirPath context
|
||
case TypeLoadPages:
|
||
applyPagesLoaded(res) // Matches pages to current directory
|
||
}
|
||
}
|
||
```
|
||
|
||
Tasks carry their context (directory path, file path, page indices) in their struct fields. When a task executes, it embeds this context in the `Result.Data` field, so the logic goroutine can apply the result to the correct state.
|
||
|
||
## 5. Error Handling Policy
|
||
|
||
Worker tasks may fail due to I/O errors, permission issues, or missing files. The error handling policy ensures the editor remains responsive:
|
||
|
||
| Task Category | On Error | Recovery |
|
||
|---|---|---|
|
||
| **Browser tasks** (read dir, build index, load pages) | Log warning; show placeholder in UI | Retry on next browse; user can navigate away and back |
|
||
| **File tasks** (read file, write file) | Log warning; show toast notification | Auto-save retries on next debounce cycle |
|
||
| **State persistence** (save state, save undo) | Log warning; continue editing | State is re-persisted on next edit |
|
||
| **Cache tasks** (read/write cache) | Log warning; fall back to rebuild | Cache is rebuilt on next access |
|
||
|
||
**Implementation**: Each task returns a `Result` with `Success: false` and `Error` set. The logic goroutine checks `result.IsError()` and handles accordingly. Errors are never re-queued automatically — the caller decides whether to retry based on the task type.
|
||
|
||
**User notification**: Critical errors (file read/write failures) show a brief toast. Non-critical errors (cache misses, index rebuilds) are logged silently.
|
||
|
||
## 6. Timeout and Cancellation
|
||
|
||
Worker tasks operate under time constraints to prevent the pool from stalling:
|
||
|
||
### Task Timeouts
|
||
|
||
Tasks have implicit timeouts enforced by the worker pool:
|
||
|
||
| Task Type | Timeout | Rationale |
|
||
|---|---|---|
|
||
| High priority (UI-critical) | 5 seconds | User is waiting; must complete or fail fast |
|
||
| Low priority (background) | 30 seconds | Can be retried; no user blockage |
|
||
|
||
If a task exceeds its timeout, the worker cancels the task's `context.Context` and posts a `Result` with `Error: ErrTimeout`.
|
||
|
||
### Task Cancellation
|
||
|
||
Tasks carry a `context.Context` that is cancelled when:
|
||
|
||
1. **User navigates away**: If the user leaves a directory, pending `LoadPages` tasks for that directory are cancelled.
|
||
2. **File is closed**: Pending chunk loads for a closed file are cancelled.
|
||
3. **Pool shutdown**: `Stop()` closes work channels; workers drain remaining tasks but new dispatches are rejected.
|
||
|
||
```go
|
||
// Task interface includes context for cancellation
|
||
func (t *ReadDirTask) Execute() Result {
|
||
ctx := t.Context() // Cancelled if user navigates away
|
||
entries, err := t.FS.ReadDirContext(ctx, t.Dir)
|
||
// ...
|
||
}
|
||
```
|
||
|
||
### Timeout Implementation
|
||
|
||
The `execute()` method wraps task execution with a timeout:
|
||
|
||
```go
|
||
func (wp *WorkerPool) execute(task Task) Result {
|
||
ctx, cancel := context.WithTimeout(context.Background(), task.Timeout())
|
||
defer cancel()
|
||
|
||
// Task receives context via Task.Context() or sets it before Execute()
|
||
result := task.Execute()
|
||
result.Timestamp = time.Now()
|
||
return result
|
||
}
|
||
```
|
||
|
||
## 7. 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 input→frame 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. Browser State Management
|
||
|
||
The browser package defines a `BrowserState` struct that holds all browser-specific state. This struct is **embedded** in the editor's `State` struct to maintain the single-owner pattern:
|
||
|
||
```go
|
||
type State struct {
|
||
// ... other fields ...
|
||
Browser BrowserState // Embedded, not a pointer
|
||
}
|
||
```
|
||
|
||
This ensures:
|
||
1. All browser state is owned by the editor package
|
||
2. The logic goroutine is the sole owner of browser state
|
||
3. No cross-package state access is required
|
||
|
||
The browser package's `BrowserLayout` function is called from the editor package, passing the embedded `BrowserState`:
|
||
|
||
```go
|
||
// In editor/state.go
|
||
func (s *State) BrowserLayout(screenW, screenH ui.Dp) []ui.Element {
|
||
return browser.BrowserLayout(screenW, screenH, &s.Browser)
|
||
}
|
||
```
|
||
|
||
This follows the architecture's principle that the editor package owns all application state, while the browser package provides pure functions for layout computation.
|
||
|
||
## 9. 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.
|