Pad/doc/architecture.md

21 KiB

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:

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:

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
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. 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.