Epoch Synchronization
EPOCH is MonoTerm’s solution for preventing resize race conditions between frontend and backend.The Resize Problem
When a terminal is resized, multiple components must be updated synchronously.Copy
╔════════════════════════════════════════════════════════════════════════╗
║ ║
║ TERMINAL RESIZE INVOLVES MULTIPLE COMPONENTS ║
║ ║
║ ║
║ When user resizes the window: ║
║ ║
║ ║
║ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ ║
║ │ │ │ │ │ │ ║
║ │ xterm.js │ │ Rust │ │ PTY │ ║
║ │ (Frontend) │ │ (Backend) │ │ (Daemon) │ ║
║ │ │ │ │ │ │ ║
║ │ 120 x 40 │ │ 120 x 40 │ │ 120 x 40 │ ║
║ │ │ │ │ │ │ ║
║ └───────────────┘ └───────────────┘ └───────────────┘ ║
║ ║
║ ║
║ All THREE must have the SAME dimensions. ║
║ If they disagree, rendering will be corrupted. ║
║ ║
╚════════════════════════════════════════════════════════════════════════╝
The Race Condition
Resize notifications travel at different speeds through the system.Copy
╔════════════════════════════════════════════════════════════════════════╗
║ ║
║ THE RACE CONDITION ║
║ ║
║ ║
║ Timeline of a resize from 80x24 to 120x40: ║
║ ║
║ ║
║ Time xterm.js Rust Backend PTY/Shell ║
║ ──── ───────────── ───────────── ────────── ║
║ ║
║ T0 80 x 24 80 x 24 80 x 24 ║
║ │ │ │ ║
║ │ User resizes │ │ ║
║ v window │ │ ║
║ T1 120 x 40 │ │ ║
║ │ │ │ ║
║ │ Send resize │ │ ║
║ │ request ──────▶ │ │ ║
║ │ v │ ║
║ T2 120 x 40 80 x 24 │ ║
║ │ │ Processing... │ ║
║ │ │ │ ║
║ │ │ │ ║
║ T3 │ │ GridUpdate │ ║
║ │ │ (80x24) sent! │ ║
║ │ ◀────────────── │ │ ║
║ │ │ │ ║
║ │ !!! SIZE │ │ ║
║ │ MISMATCH! │ │ ║
║ │ 120x40 != │ │ ║
║ │ 80x24 v │ ║
║ T4 │ 120 x 40 │ ║
║ │ │ Forward to PTY │ ║
║ │ │ ───────────────▶ │ ║
║ │ │ v ║
║ T5 │ │ 120 x 40 ║
║ │ │ │ ║
║ │ │ │ ║
║ T6 │ │ GridUpdate │ ║
║ │ ◀────────────── │ (120x40) sent │ ║
║ │ │ │ ║
║ │ Sizes match OK │ │ ║
║ v v v ║
║ ║
║ ║
║ The Problem: ║
║ ║
║ At T3, xterm.js receives a GridUpdate with dimensions ║
║ 80x24, but xterm.js is already 120x40. ║
║ ║
║ If we inject this update, text will wrap incorrectly, ║
║ cursor will be at wrong position, display will be corrupt. ║
║ ║
╚════════════════════════════════════════════════════════════════════════╝
EPOCH: The Solution
EPOCH is a version number that increments on each resize.Copy
╔═════════════════════════════════════════════════════════════════════════╗
║ ║
║ EPOCH VERSIONING ║
║ ║
║ ║
║ Concept: ║
║ ║
║ - Each resize increments a counter called "EPOCH" ║
║ - EPOCH is included in every GridUpdate ║
║ - Frontend rejects updates with old EPOCH ║
║ ║
║ ║
║ ┌─────────────────────────────────────────────────────────────┐ ║
║ │ │ ║
║ │ Frontend Backend │ ║
║ │ │ ║
║ │ currentEpoch: 5 │ ║
║ │ │ ║
║ │ │ │ │ ║
║ │ │ User resizes window │ │ ║
║ │ │ │ │ ║
║ │ │ currentEpoch++ (now 6) │ │ ║
║ │ │ │ │ ║
║ │ │ resize_session(epoch: 6) │ │ ║
║ │ │ ────────────────────────────▶ │ │ ║
║ │ │ │ │ ║
║ │ │ │ Store epoch = 6 │ ║
║ │ │ │ │ ║
║ │ │ GridUpdate (epoch: 5) │ │ ║
║ │ │ ◀─────────────────────────── │ │ ║
║ │ │ │ │ ║
║ │ │ 5 < 6 ──▶ DISCARD │ │ ║
║ │ │ (stale update) │ │ ║
║ │ │ │ │ ║
║ │ │ GridUpdate (epoch: 6) │ │ ║
║ │ │ ◀─────────────────────────── │ │ ║
║ │ │ │ │ ║
║ │ │ 6 == 6 ──▶ ACCEPT │ │ ║
║ │ │ (current update) │ │ ║
║ │ │ │ │ ║
║ │ v v │ ║
║ │ │ ║
║ └─────────────────────────────────────────────────────────────┘ ║
║ ║
║ ║
║ Rule: ║
║ ║
║ if (update.epoch < currentEpoch) { ║
║ // This update was generated before the resize ║
║ // DISCARD IT - dimensions are wrong ║
║ } ║
║ ║
╚═════════════════════════════════════════════════════════════════════════╝
Frontend-Initiated Pattern
The epoch is managed by the frontend, not the backend.Copy
╔════════════════════════════════════════════════════════════════════════╗
║ ║
║ FRONTEND-INITIATED EPOCH PATTERN ║
║ ║
║ ║
║ Why Frontend Controls Epoch: ║
║ ║
║ The frontend knows FIRST when a resize happens (xterm.js event). ║
║ It must increment epoch BEFORE sending resize to backend. ║
║ This ensures any in-flight GridUpdates are marked as stale. ║
║ ║
║ ║
║ ┌──────────────────────────────────────────────────────────┐ ║
║ │ │ ║
║ │ Frontend (xterm.js) Backend (Rust) │ ║
║ │ │ ║
║ │ 1. Window resize event │ │ ║
║ │ detected │ │ ║
║ │ │ │ ║
║ │ 2. prepareResize() │ │ ║
║ │ currentEpoch++ │ │ ║
║ │ (now epoch=6) │ │ ║
║ │ │ │ ║
║ │ 3. requestResize(cols, rows, │ │ ║
║ │ epoch=6) │ │ ║
║ │ ───────────────────────────▶ resize(c, r, epoch=6) │ ║
║ │ │ epoch = 6 │ ║
║ │ │ │ ║
║ │ 4. Any GridUpdate with │ │ ║
║ │ epoch < 6 is REJECTED │ │ ║
║ │ │ │ ║
║ └──────────────────────────────────────────────────────────┘ ║
║ ║
╚════════════════════════════════════════════════════════════════════════╝
Complete EPOCH Flow
Step-by-step walkthrough of a resize with EPOCH.Copy
╔════════════════════════════════════════════════════════════════════════╗
║ ║
║ EPOCH FLOW: RESIZE FROM 80x24 TO 120x40 ║
║ ║
║ ║
║ Frontend Backend PTY ║
║ (epoch=5) (epoch=5) (80x24) ║
║ │ │ │ ║
║ │ │ │ ║
║ ─────┼───────────────────────┼───────────────────────┼───── ║
║ 1 │ Window resize event │ │ ║
║ │ xterm now 120x40 │ │ ║
║ │ │ │ ║
║ ─────┼───────────────────────┼───────────────────────┼───── ║
║ 2 │ prepareResize() │ │ ║
║ │ epoch++ (now 6) │ │ ║
║ │ │ │ ║
║ │ requestResize({ │ │ ║
║ │ cols: 120, │ │ ║
║ │ rows: 40, │ │ ║
║ │ epoch: 6 │ │ ║
║ │ }) ───────────────────▶ │ ║
║ │ │ │ ║
║ ─────┼───────────────────────┼───────────────────────┼───── ║
║ 3 │ │ Meanwhile, shell │ ║
║ │ │ outputs data... │ ║
║ │ │ │ ║
║ │ │ GridUpdate (epoch=5) │ ║
║ │◀──────────────────────│ │ ║
║ │ │ │ ║
║ │ DISCARD (5 < 6) │ │ ║
║ │ │ │ ║
║ ─────┼───────────────────────┼───────────────────────┼───── ║
║ 4 │ │ Process resize │ ║
║ │ │ epoch = 6 │ ║
║ │ │ │ ║
║ │ │ resize_pty(120,40) ───┼────▶ ║
║ │ │ │ ║
║ ─────┼───────────────────────┼───────────────────────┼───── ║
║ 5 │ │ │ 120x40 ║
║ │ │ │ ║
║ │ │ Shell re-renders... │ ║
║ │ │◀──────────────────────│ ║
║ │ │ │ ║
║ │ │ GridUpdate (epoch=6) │ ║
║ │◀──────────────────────│ │ ║
║ │ │ │ ║
║ │ ACCEPT (6 >= 6) │ │ ║
║ │ Inject to display │ │ ║
║ │ │ │ ║
║ ─────┼───────────────────────┼───────────────────────┼───── ║
║ 6 │ Display correct │ │ ║
║ │ 120x40 content │ │ ║
║ │ │ │ ║
║ v v v ║
║ ║
╚════════════════════════════════════════════════════════════════════════╝
Double Verification
Even with EPOCH, we verify actual dimensions match.Copy
╔════════════════════════════════════════════════════════════════════════╗
║ ║
║ DOUBLE VERIFICATION ║
║ ║
║ ║
║ EPOCH alone is not enough. We also check dimensions: ║
║ ║
║ ║
║ Validation Steps: ║
║ ║
║ ┌─────────────────────────────────────────────┐ ║
║ │ │ ║
║ │ Step 1: EPOCH Check │ ║
║ │ │ ║
║ │ update.epoch < currentEpoch ? │ ║
║ │ │ ║
║ │ YES ──▶ DISCARD (stale) │ ║
║ │ NO ──▶ Continue to Step 2 │ ║
║ │ │ ║
║ └─────────────────────────────────────────────┘ ║
║ │ ║
║ v ║
║ ┌─────────────────────────────────────────────┐ ║
║ │ │ ║
║ │ Step 2: Dimension Check │ ║
║ │ │ ║
║ │ update.cols == xterm.cols AND │ ║
║ │ update.rows == xterm.rows ? │ ║
║ │ │ ║
║ │ NO ──▶ Request backend resize │ ║
║ │ Skip injection │ ║
║ │ YES ──▶ Continue to Step 3 │ ║
║ │ │ ║
║ └─────────────────────────────────────────────┘ ║
║ │ ║
║ v ║
║ ┌─────────────────────────────────────────────┐ ║
║ │ │ ║
║ │ Step 3: Inject to Display │ ║
║ │ │ ║
║ │ Safe to inject - all checks passed │ ║
║ │ │ ║
║ └─────────────────────────────────────────────┘ ║
║ ║
║ ║
║ Why Both Checks? ║
║ ║
║ EPOCH: Catches updates generated before resize ║
║ Dimension: Catches unexpected size differences ║
║ ║
║ Belt AND suspenders. Both are needed for safety. ║
║ ║
╚════════════════════════════════════════════════════════════════════════╝
Why Not Just Use Dimensions?
Why do we need EPOCH when we could just compare dimensions?Copy
╔════════════════════════════════════════════════════════════════════════╗
║ ║
║ WHY EPOCH IS NECESSARY ║
║ ║
║ ║
║ Scenario: Rapid Resize ║
║ ║
║ User quickly resizes: 80x24 ──▶ 120x40 ──▶ 80x24 ║
║ ║
║ ║
║ Time Action Dimensions ║
║ ──── ────── ────────── ║
║ T1 Start 80 x 24 ║
║ T2 Resize to 120x40 120 x 40 ║
║ T3 GridUpdate (80x24) in flight ... ║
║ T4 Resize back to 80x24 80 x 24 ║
║ T5 GridUpdate (80x24) arrives ... ║
║ ║
║ ║
║ Without EPOCH: ║
║ ║
║ At T5, dimensions match (80x24 == 80x24). ║
║ But this update was generated at T1, before T2's resize! ║
║ The content is STALE - cursor position is wrong. ║
║ ║
║ Dimension check alone: PASS (80 == 80, 24 == 24) ║
║ But update is stale: WRONG CONTENT! ║
║ ║
║ ║
║ With EPOCH: ║
║ ║
║ T1: epoch = 5, generate update (epoch=5, 80x24) ║
║ T2: epoch++ = 6, resize to 120x40 ║
║ T4: epoch++ = 7, resize to 80x24 ║
║ T5: update (epoch=5) arrives, currentEpoch=7 ║
║ ║
║ EPOCH check: 5 < 7 ──▶ DISCARD ║
║ ║
║ We correctly reject the stale update! ║
║ ║
╚════════════════════════════════════════════════════════════════════════╝
Summary
Copy
╔════════════════════════════════════════════════════════════════════════╗
║ ║
║ EPOCH SYNCHRONIZATION IN ONE DIAGRAM ║
║ ║
║ ║
║ ║
║ FRONTEND BACKEND ║
║ ┌────────────────┐ ┌────────────────┐ ║
║ │ │ │ │ ║
║ │ currentEpoch │ │ Grid State │ ║
║ │ ┌───┐ │ │ .epoch │ ║
║ │ │ 5 │ │ │ ┌───┐ │ ║
║ │ └───┘ │ │ │ 5 │ │ ║
║ │ │ │ │ └───┘ │ ║
║ │ │ │ │ │ │ ║
║ │ On resize: │ │ │ │ ║
║ │ prepareResize()│ │ │ │ ║
║ │ epoch++ (now 6)│ requestResize() │ │ │ ║
║ │ │─────────────────▶│ epoch = 6 │ ║
║ │ │ │ │ ║
║ │ │ GridUpdate │ │ │ ║
║ │ │ (epoch: 5) │ │ │ ║
║ │ │◀──────┼──────────────────│ │ │ ║
║ │ │ │ │ │ │ ║
║ │ 5 < 6 ? │ │ │ │ ║
║ │ YES ──▶ DROP │ │ │ │ ║
║ │ │ GridUpdate │ │ │ ║
║ │ │ (epoch: 6) │ │ │ ║
║ │ │◀──────┼──────────────────│ │ │ ║
║ │ │ │ │ │ │ ║
║ │ 6 < 6 ? │ │ │ │ ║
║ │ NO ──▶ ACCEPT│ │ │ │ ║
║ │ │ │ │ │ │ ║
║ │ Inject to │ │ │ │ ║
║ │ xterm display│ │ │ │ ║
║ │ │ │ │ ║
║ └────────────────┘ └────────────────┘ ║
║ ║
║ ║
║ EPOCH = Monotonically increasing version number ║
║ ║
║ - Frontend increments on resize ║
║ - Frontend passes to backend ║
║ - Backend stores epoch in GridUpdate ║
║ - Frontend validates before injection ║
║ ║
╚════════════════════════════════════════════════════════════════════════╝