- New doc/architecture.md: concurrency model, goroutine responsibilities, channel topology, deadlock analysis, sync/async partitioning - Update SPEC.md §3: split into code organization + runtime architecture, cross-reference to architecture.md Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
311 lines
19 KiB
Markdown
311 lines
19 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 │
|
|
│ - Receives frames from frame receiver goroutine │
|
|
│ - Locks mutex, draws frame, calls e.Frame(), unlocks │
|
|
│ - Sends user input to logic goroutine │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
↑ frameChan (read) ↑ inputChan (send)
|
|
│ │
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ Frame receiver goroutine │
|
|
│ - Tight loop: read frameChan → lock → store → unlock │
|
|
│ - Calls w.Invalidate() after storing each frame │
|
|
│ - Ensures logic's send on frameChan never blocks │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
↓ frameChan (send) ↓ inputChan (receive)
|
|
│ │
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ Logic goroutine │
|
|
│ - select on: inputChan, worker results, async I/O │
|
|
│ - Processes input: state mutation + layout computation │
|
|
│ - Dispatches long-running tasks to workers │
|
|
│ - Sends computed frames on frameChan │
|
|
│ - Guarantees: process + layout < 16 ms │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
↓ workChan (dispatch) ↑ resultChan (receive)
|
|
│ │
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ Worker pool (fixed goroutines) │
|
|
│ - 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, releases the mutex, and calls `w.Invalidate()`. This ensures the logic goroutine's send on `frameChan` never blocks unless the receiver is actively storing a frame (which is fast — a pointer assignment).
|
|
3. **The main goroutine holds the mutex only during draw + `e.Frame()`.** After releasing the mutex, it sends user 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. This ensures `inputChan` never fills up and the main goroutine's send to logic never blocks.
|
|
|
|
## 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.WindowEvent`
|
|
- **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 collection**: extracts user input (taps, key events, scroll) from the Gioui context after rendering
|
|
- **Input delivery**: sends collected input to the logic goroutine on `inputChan`
|
|
|
|
### 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, releases the mutex
|
|
- **Frame invalidation**: calls `w.Invalidate()` to request Gioui to invoke a `FrameEvent`
|
|
|
|
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 (store a slice header).
|
|
|
|
### 2.3 Logic Goroutine
|
|
|
|
Owns all application state. Runs a `select` loop over multiple channels. Responsibilities:
|
|
|
|
- **Input processing**: receives user input from `inputChan`, mutates state, computes new layout
|
|
- **Result handling**: receives results from workers on `resultChan`, applies completed async tasks to state
|
|
- **Work dispatch**: identifies long-running tasks (file I/O, diff, undo replay) and dispatches them to the worker pool
|
|
- **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.
|
|
|
|
### 2.4 Worker Pool
|
|
|
|
A fixed set of goroutines (e.g., 4) that execute long-running tasks. Responsibilities:
|
|
|
|
- **File I/O**: reading file chunks, writing auto-saves, persisting state files
|
|
- **Diff computation**: computing diffs for conflict resolution, external update detection
|
|
- **Undo replay on open**: replaying undo chains when restoring state across sessions
|
|
- **Line index building**: streaming pass over a file to build the line-offset index
|
|
|
|
Workers post results on `resultChan`. The channel is sized to the worker pool size so sends never block.
|
|
|
|
## 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 | User input events (taps, keys, scroll) | Unbuffered. Logic drains via `select`, main sends after releasing mutex. Logic's 16ms guarantee prevents main from blocking. |
|
|
| `resultChan` | Workers → Logic | Completed async task results | Unbuffered. Workers post and return to pool. Logic drains via `select`. Pool is bounded, so backlog is bounded. |
|
|
| `workChan` | Logic → Workers | New async work items | Buffered to pool size. Logic dispatches and continues; workers drain at their own pace. |
|
|
|
|
### Why Unbuffered Channels Work
|
|
|
|
Buffered channels were initially considered for deadlock avoidance. They are not necessary because:
|
|
|
|
1. **`frameChan`**: The frame receiver goroutine runs a tight loop. It only holds the mutex during the store operation (pointer assignment + unlock). The logic goroutine's send blocks only during this brief window, which is sub-millisecond.
|
|
|
|
2. **`inputChan`**: The main goroutine sends input after releasing the mutex. The logic goroutine processes input in < 16ms. The main goroutine produces at most one input event per frame (Gioui event loop pace). The logic goroutine's processing pace exceeds the main goroutine's production pace, so the channel never backs up.
|
|
|
|
3. **`resultChan`**: The worker pool is bounded (fixed goroutine count). Results are posted as workers complete. The logic goroutine drains results in its `select` loop before processing the next input event. The bounded pool ensures the result rate is bounded.
|
|
|
|
4. **`workChan`**: 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 — logic can send a frame before dispatching.
|
|
|
|
## 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. Retreat cursor, 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 → unlock → w.Invalidate() // fast, sub-ms
|
|
```
|
|
|
|
**Resolution**: The frame receiver runs a tight loop. It only holds the mutex during the store (pointer assignment). 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 <- input // blocks if logic is not reading
|
|
Logic: input := <-inputChan // select loop, drains before next frame
|
|
```
|
|
|
|
**Resolution**: Logic's 16ms guarantee means it processes input faster than main produces it (one per frame event). 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 (fixed goroutine count). 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 (input process + frame send — < 16ms). The worker is not holding any lock during this send. No deadlock.
|
|
|
|
### Scenario: Logic dispatches work, all workers busy
|
|
|
|
```
|
|
Logic: workChan <- workItem // blocks if workChan is full
|
|
Workers: <-workChan // drain at their own pace
|
|
```
|
|
|
|
**Resolution**: `workChan` is buffered to pool size. If all workers are busy, logic's dispatch blocks until a worker frees up. This is acceptable because dispatching work is not on the critical input→frame path — logic sends a frame before dispatching, so the user sees the loading state immediately. 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`, `resultChan` | Synchronous handoff ensures no message is lost; timing guarantees prevent blocking |
|
|
| **Buffered channel** | `workChan` (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, results, and async I/O 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 → unlock`
|
|
4. **Receiver invalidates**: `w.Invalidate()` queues a `FrameEvent`
|
|
5. **Main receives `FrameEvent`**: `lock → renderer.Draw(gtx, currentElems) → e.Frame(&ops) → unlock`
|
|
6. **Frame is on screen**: Gioui has submitted the frame to the display
|
|
7. **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.
|