notes

Log | Files | Refs | README

websocket_request_trackers.md (24484B)


      1 # WebSocket request trackers
      2 
      3 ### What createWsRequestTracker Does
      4 
      5 ```ts
      6 export function createWsRequestTracker<Epoch extends boolean = false>(
      7   options: WsRequestTrackerOptions<Epoch>,
      8 ): WsRequestTracker<Epoch> {
      9   // Creates a tracker that bridges WebSocket request/response pairs into Promises.
     10   // Handles timeouts and provides a clean API for awaiting responses.
     11   const { timeoutMs, enableEpochs = false } = options;
     12   const pending = new Map<string, PendingEntry>();
     13   // pending map to store in-flight WS requests, keyed by contractId
     14   let epoch = 0;
     15   // current epoch used for stale request handling (epoch mode)
     16 
     17   return {
     18     track<T = void>(
     19       contractId: string,
     20     ): Promise<Epoch extends true ? T | null : T> {
     21       // Start tracking a WS request by its contract ID
     22       if (enableEpochs) {
     23         epoch++;
     24       }
     25 
     26       const requestEpoch = epoch;
     27 
     28       return new Promise<any>((resolve, reject) => {
     29         const timeoutId = setTimeout(() => {
     30           const entry = pending.get(contractId);
     31           if (!entry) return; // already resolved/rejected
     32 
     33           pending.delete(contractId);
     34 
     35           if (enableEpochs && entry.epoch !== epoch) {
     36             entry.resolve(null);
     37             return;
     38           }
     39 
     40           let errorMessage =
     41             `WS request ${contractId} timed out after ${timeoutMs}ms — server did not respond`;
     42           reject(new WebError(errorMessage, "WsTimeoutError"));
     43         }, timeoutMs);
     44 
     45         pending.set(contractId, {
     46           resolve: resolve as (value: unknown) => void,
     47           reject,
     48           timeoutId,
     49           epoch: requestEpoch,
     50         });
     51       });
     52     },
     53 
     54     resolve(contractId: string, data?: unknown): void {
     55       // Resolve pending request for contractId
     56       const entry = pending.get(contractId);
     57       if (!entry) return;
     58 
     59       if (enableEpochs && entry.epoch !== epoch) {
     60         clearTimeout(entry.timeoutId);
     61         pending.delete(contractId);
     62         entry.resolve(null);
     63         return;
     64       }
     65 
     66       clearTimeout(entry.timeoutId);
     67       pending.delete(contractId);
     68       entry.resolve(data);
     69     },
     70 
     71     // Reject a pending request
     72     reject(contractId: string, error: WebError): void {
     73       const entry = pending.get(contractId);
     74       if (!entry) return;
     75 
     76       clearTimeout(entry.timeoutId);
     77       pending.delete(contractId);
     78       entry.reject(error);
     79     },
     80 
     81     // Bump epoch to stale-discard all in-flight resolves.
     82     invalidate(): void {
     83       if (!enableEpochs) return;
     84       epoch++;
     85     },
     86 
     87     // Reject all pending and clear all timeouts.
     88     dispose(): void {
     89       for (const [contractId, entry] of pending) {
     90         clearTimeout(entry.timeoutId);
     91 
     92         // Reject with disposed error
     93         let errMessage =
     94           `WS tracker disposed while request ${contractId} was still pending`;
     95         entry.reject(
     96           new WebError(errMessage, "WsTrackerDisposedError"),
     97         );
     98       }
     99       pending.clear();
    100     },
    101 
    102     // Check if request is pending
    103     hasPending(contractId: string): boolean {
    104       return pending.has(contractId);
    105     },
    106 
    107     // Get number of pending requests
    108     pendingCount(): number {
    109       return pending.size;
    110     },
    111   };
    112 ```
    113 
    114 `createWsRequestTracker` is a Promise-WS bridge that converts WebSocket
    115 request/response pairs into synchronous-looking async operations. In a WebSocket
    116 protocol where you send a packet and receive a response later via a callback
    117 listener, this tracker provides a clean API to await the response as a Promise.
    118 
    119 ### Core Problem It Solves
    120 
    121 Without this tracker:
    122 
    123 1. You send a WS packet (fire-and-forget)
    124 2. Later, a listener callback receives the response
    125 3. How do you connect step 2 back to step 1? How do you handle timeouts? How do
    126    you ensure stale responses don't hang your app?
    127 
    128 The tracker solves this by:
    129 
    130 • Creating a Promise when you call track(contractId) after sending a packet
    131 
    132 • Storing it keyed by the contract ID (packet UUID)
    133 
    134 • Resolving/rejecting it when the listener receives the response
    135 
    136 • Handling timeouts with automatic rejection
    137 
    138 ---
    139 
    140 ### Workflow (Complete Flow)
    141 
    142 ```text
    143 ┌────────────────────────────────────────────────────────────────────────────┐
    144 │ 1. Component calls readFile() or saveFile()                                │
    145 │    (e.g., user clicks a file or presses Ctrl+S)                            │
    146 └────────────────────────────────────────────────────────────────────────────┘
    147    148 ┌────────────────────────────────────────────────────────────────────────────┐
    149 │ 2. EditorStore sends WS packet via readFileWsFn / saveFileWsFn             │
    150 │    → Returns a contractId (UUID)                                           │
    151 └────────────────────────────────────────────────────────────────────────────┘
    152    153 ┌────────────────────────────────────────────────────────────────────────────┐
    154 │ 3. tracker.track(contractId) creates a Promise & stores it in pending Map  │
    155 │    → Returns Promise<T | null> (T=void by default, or T=fileData)          │
    156 └────────────────────────────────────────────────────────────────────────────┘
    157    158 ┌────────────────────────────────────────────────────────────────────────────┐
    159 │ 4. User waits for server response (await track())                          │
    160 └────────────────────────────────────────────────────────────────────────────┘
    161    162 ┌────────────────────────────────────────────────────────────────────────────┐
    163 │ 5. Server responds via WebSocket                                           │
    164 │    → Listener (editorWebsocketEventHandler) receives packet                │
    165 │    → Decodes the contract                                                  │
    166 └────────────────────────────────────────────────────────────────────────────┘
    167    168 ┌────────────────────────────────────────────────────────────────────────────┐
    169 │ 6. applyFileRead / applyFileWritten calls tracker.resolve()                │
    170 │    → Looks up contractId in pending Map                                    │
    171 │    → Clears timeout                                                        │
    172 │    → Calls resolve() callback with data                                    │
    173 └────────────────────────────────────────────────────────────────────────────┘
    174    175 ┌────────────────────────────────────────────────────────────────────────────┐
    176 │ 7. await track() resolves, EditorStore returns typed result                │
    177 │    → { kind: 'success', fileData }                                         │
    178 │    → Or { kind: 'stale' } if epochs enabled and a newer request came in   │
    179 └────────────────────────────────────────────────────────────────────────────┘
    180 ```
    181 
    182 ---
    183 
    184 ### Why We Need It for service-cad/frontend/web
    185 
    186 1. Type-Safe Async/Await Over WebSocket Protocol
    187 
    188 The WebSocket protocol is event-driven (fire-and-forget + callback), but Svelte
    189 components work better with await:
    190 
    191 ```
    192  // Without tracker: messy callback style
    193    sendReadFile(path, (result) => {
    194      if (result.error) { /* handle */ }
    195      if (result.data) { /* handle */ }
    196    });
    197 
    198    // With tracker: clean async/await
    199    const response = await readFile(path);
    200    // Throws on error, returns data on success
    201 ```
    202 
    203 2. Timeout Handling The tracker implements automatic timeouts via setTimeout. If
    204    the server doesn't respond within WEBSOCKET_TIMEOUT_MS, the promise rejects
    205    with a WsTimeoutError.
    206 
    207 3. Epoch Tracking (Last-Write-Wins) With enableEpochs: true: • User rapidly
    208    clicks between files → each click sends a request • Only the latest file's
    209    response matters (the others are "stale") • Stale responses resolve with null
    210    instead of hanging or updating stale UI • The caller checks if (response ===
    211    null) return { kind: 'stale' }
    212 
    213 4. Cleanup & Memory Management • dispose() rejects all pending promises and
    214    clears timeouts • Prevents memory leaks when components unmount • Used in
    215    Svelte onDestroy hooks
    216 
    217 ---
    218 
    219 ### Two Modes of Operation
    220 
    221 Simple Mode (default, enableEpochs: false)
    222 
    223 • Every request gets its own independent promise
    224 
    225 • Used by loadProjectTracker and killSessionTracker
    226 
    227 • Example: loadProjectTracker.track('xyz') → always resolves when server
    228 responds
    229 
    230 Epoch Mode (enableEpochs: true)
    231 
    232 • Each track() increments a monotonic epoch counter
    233 
    234 • When resolve() is called, it checks if the entry's epoch matches current
    235 
    236 • Stale entries (older epoch) resolve with null instead of data
    237 
    238 • Used by readTracker, saveTracker, fileTreeTracker, compileTracker
    239 
    240 ---
    241 
    242 Key Methods
    243 
    244 | Method                       | Description                                             |
    245 | ---------------------------- | ------------------------------------------------------- |
    246 | `track(contractId)`          | Start tracking, returns `Promise<T                      |
    247 | `resolve(contractId, data?)` | Resolve pending request (called by listener)            |
    248 | `reject(contractId, error)`  | Reject on error (always surfaces, never stale-discard)  |
    249 | `invalidate()`               | Bump epoch, mark all pending entries as stale           |
    250 | `dispose()`                  | Reject all pending + clear timeouts (component cleanup) |
    251 | `hasPending(contractId)`     | Check if entry exists (used by listeners)               |
    252 | `pendingCount()`             | Number of unprocessed requests                          |
    253 
    254 ---
    255 
    256 Files Using This Tracker
    257 
    258 • codeEditor/wsHooks/tracker.ts: readTracker, saveTracker (epoch mode)
    259 
    260 • fileTree/wsHooks/tracker.ts: fileTreeTracker (epoch mode)
    261 
    262 • canvas/wsHooks/tracker.ts: compileTracker (default mode)
    263 
    264 • gitEditor/wsHooks/tracker.ts: gitTracker (epoch mode)
    265 
    266 • core/pageState/editorPage/tracker.ts: loadProjectTracker, killSessionTracker
    267 (default mode)
    268 
    269 All use WEBSOCKET_TIMEOUT_MS (configured elsewhere) for timeout duration.
    270 
    271 ```ts
    272 import { WebError } from "@core/utils/errors/webError";
    273 
    274 /**
    275  * Internal bookkeeping for a single in-flight WS request.
    276  *
    277  * `resolve` accepts `unknown` rather than a generic `T` because the pending
    278  * map is type-erased — it stores entries for many different request types
    279  * simultaneously, so there's no single `T` that fits all entries. The
    280  * per-call type safety lives on the public `track<T>()` method instead.
    281  *
    282  * We use `unknown` over `any` deliberately: `unknown` forces a type
    283  * assertion at the one internal call site where we store the resolve
    284  * callback (`resolve as (value: unknown) => void`), making the type
    285  * erasure explicit and auditable. With `any`, TypeScript stops checking
    286  * entirely — if we accidentally passed the wrong value internally, the
    287  * compiler wouldn't catch it. The single assertion is worth the trade-off
    288  * for keeping the rest of the internals type-checked.
    289  */
    290 export type PendingEntry = {
    291   resolve: (value: unknown) => void;
    292   reject: (error: WebError) => void;
    293   timeoutId: ReturnType<typeof setTimeout>;
    294   epoch: number;
    295 };
    296 
    297 /**
    298  * Generic over Epoch — controls the return type of track<T>().
    299  *
    300  * When Epoch is false (default), track<T>() returns Promise<T>.
    301  * When Epoch is true, track<T>() returns Promise<T | null> because
    302  * stale responses resolve with null instead of hanging forever.
    303  *
    304  * TypeScript infers Epoch from the enableEpochs property value:
    305  *   createWsRequestTracker({ timeoutMs: WEBSOCKET_TIMEOUT_MS, enableEpochs: true })
    306  *   // → WsRequestTracker<true>, track returns Promise<T | null>
    307  *
    308  *   createWsRequestTracker({ timeoutMs: WEBSOCKET_TIMEOUT_MS })
    309  *   // → WsRequestTracker<false>, track returns Promise<T>
    310  */
    311 export type WsRequestTrackerOptions<Epoch extends boolean = false> = {
    312   timeoutMs: number;
    313   enableEpochs?: Epoch;
    314 };
    315 
    316 export type WsRequestTracker<Epoch extends boolean = false> = {
    317   /**
    318    * Start tracking a request. Returns a typed Promise that resolves
    319    * when resolve() is called with the matching contract ID.
    320    *
    321    * T defaults to void — callers that expect response data specify it:
    322    *   await tracker.track<string[]>(contractId);  // expects string[]
    323    *   await tracker.track(contractId);             // expects void
    324    *
    325    * With enableEpochs: true, returns Promise<T | null>. null indicates
    326    * a stale response — a newer track() call superseded this one. The
    327    * caller checks for null to detect staleness and return early.
    328    */
    329   track<T = void>(
    330     contractId: string,
    331   ): Promise<Epoch extends true ? T | null : T>;
    332 
    333   /**
    334    * Resolve a pending request with optional data.
    335    *
    336    * Accepts `unknown` because the tracker is type-erased internally —
    337    * it doesn't know which `T` the corresponding track<T>() call used.
    338    * The caller (WS listener) passes whatever the server sent, and the
    339    * awaiting handler receives it typed via the Promise<T> from track().
    340    *
    341    * With enableEpochs: true, stale entries (epoch doesn't match current)
    342    * are settled with null instead of the provided data. This ensures the
    343    * caller's promise always settles — its finally block runs, no leaked
    344    * closures.
    345    */
    346   resolve(contractId: string, data?: unknown): void;
    347 
    348   /** Reject a pending request with an error. Always surfaces (never stale-discarded). */
    349   reject(contractId: string, error: WebError): void;
    350 
    351   /** Bump epoch to stale-discard all in-flight resolves. No-op without enableEpochs. */
    352   invalidate(): void;
    353 
    354   /** Reject all pending and clear timers. Call on component destroy. */
    355   dispose(): void;
    356 
    357   /** Whether a tracked entry exists for this contract ID. */
    358   hasPending(contractId: string): boolean;
    359 
    360   /** Number of currently pending (unresolved) tracked requests. */
    361   pendingCount(): number;
    362 };
    363 
    364 /**
    365  * Creates a tracker that bridges WebSocket request/response pairs into Promises.
    366  *
    367  * The problem: WS senders fire-and-forget a packet, and the response arrives
    368  * later via a separate listener callback. There's no built-in way for the
    369  * caller to await the full round-trip, detect timeouts, or correlate a
    370  * response back to the request that triggered it.
    371  *
    372  * This tracker solves that by letting the caller `track(contractId)` after
    373  * sending a packet. This returns a Promise that resolves when the listener
    374  * calls `resolve(contractId)` with the matching ID, or rejects on timeout.
    375  *
    376  * Two modes of operation:
    377  *
    378  * 1. Simple tracking (enableEpochs=false, the default)
    379  *    Every tracked request gets its own independent promise. resolve() always
    380  *    delivers. This is the mode used by the git editor where each operation
    381  *    (commit, stage, merge) is a distinct action that doesn't supersede others.
    382  *
    383  * 2. Epoch tracking (enableEpochs=true)
    384  *    Each track() call increments a monotonic epoch counter. When resolve()
    385  *    is called, it checks whether the pending entry's epoch matches the
    386  *    current epoch. If stale (a newer request was tracked since), the entry
    387  *    is settled with null — the awaiting promise resolves to null, signaling
    388  *    staleness to the caller. This implements last-write-wins for rapid
    389  *    sequential requests (e.g. clicking between files quickly — only the
    390  *    most recent file read matters).
    391  *
    392  *    Stale timeouts also settle with null (not rejection) — if a stale
    393  *    request's timeout fires, it resolves with null rather than surfacing
    394  *    an error for a request the user already navigated away from.
    395  *
    396  *    reject() always surfaces regardless of epoch — errors should never be
    397  *    silently swallowed.
    398  *
    399  *    invalidate() bumps the epoch without tracking a new request, causing all
    400  *    in-flight responses to be discarded when they arrive. Use this when you
    401  *    want to cancel all pending without triggering error callbacks (e.g.
    402  *    navigating away from the current context).
    403  */
    404 export function createWsRequestTracker<Epoch extends boolean = false>(
    405   options: WsRequestTrackerOptions<Epoch>,
    406 ): WsRequestTracker<Epoch> {
    407   const { timeoutMs, enableEpochs = false } = options;
    408   const pending = new Map<string, PendingEntry>();
    409   let epoch = 0;
    410 
    411   return {
    412     /**
    413      * Start tracking a WS request by its contract ID (the packet UUID).
    414      * Returns a Promise that resolves when the listener calls resolve()
    415      * with the same contract ID, or rejects on timeout.
    416      *
    417      * Call this immediately after sending the packet:
    418      *   const contractId = await sender(...);
    419      *   await tracker.track(contractId);
    420      *
    421      * This creates a `Promise<T>` with a properly typed `resolve: (value: T) => void`
    422      * callback but then immediately erases it to `unknown` for storage. When the
    423      * promise resolves, the caller gets T back. The erasure is a one-line bridge
    424      * between the typed public API and the untyped internal map. This is us making T
    425      * the same type as our storage, not the typescript rule that `unknown` types must
    426      * do a type assertion before use.
    427      */
    428     track<T = void>(
    429       contractId: string,
    430     ): Promise<Epoch extends true ? T | null : T> {
    431       if (enableEpochs) {
    432         epoch++;
    433       }
    434 
    435       // Snapshot the current epoch so we can check staleness on resolve
    436       const requestEpoch = epoch;
    437 
    438       // Cast: TypeScript can't evaluate the conditional return type
    439       // inside a generic factory. The runtime behavior is correct —
    440       // with epochs, stale entries resolve with null; without, they
    441       // resolve with T. The public type signature enforces this.
    442       return new Promise<any>((resolve, reject) => {
    443         const timeoutId = setTimeout(() => {
    444           const entry = pending.get(contractId);
    445           if (!entry) return; // already resolved/rejected
    446 
    447           pending.delete(contractId);
    448 
    449           // Stale epoch check — if a newer request was tracked since this
    450           // one, don't surface a timeout error for an abandoned request.
    451           // Resolve with null so the caller's promise settles cleanly
    452           // (its finally block runs, no leaked closure).
    453           if (enableEpochs && entry.epoch !== epoch) {
    454             entry.resolve(null);
    455             return;
    456           }
    457 
    458           let errorMessage =
    459             `WS request ${contractId} timed out after ${timeoutMs}ms — server did not respond`;
    460           reject(new WebError(errorMessage, "WsTimeoutError"));
    461         }, timeoutMs);
    462 
    463         pending.set(contractId, {
    464           resolve: resolve as (value: unknown) => void,
    465           reject,
    466           timeoutId,
    467           epoch: requestEpoch,
    468         });
    469       });
    470     },
    471 
    472     /**
    473      * Resolve a pending request. Called by the WS listener after applying
    474      * state from the server response.
    475      *
    476      * If epochs are enabled and the entry is stale (a newer request was
    477      * tracked since this one), the entry is settled with null instead of
    478      * the provided data. This ensures the caller's promise always settles
    479      * — its finally block runs and closures are released. The caller
    480      * checks for null to detect staleness.
    481      *
    482      * No-op if contractId is not found (already timed out, already
    483      * resolved, or an unknown/broadcast ID).
    484      */
    485     resolve(contractId: string, data?: unknown): void {
    486       const entry = pending.get(contractId);
    487       if (!entry) return;
    488 
    489       // Stale epoch check — a newer request was tracked since this one.
    490       // Settle with null so the caller's promise resolves (its finally
    491       // block runs, no leaked closure). The caller checks for null to
    492       // detect staleness: if (result === null) return { kind: 'stale' };
    493       if (enableEpochs && entry.epoch !== epoch) {
    494         clearTimeout(entry.timeoutId);
    495         pending.delete(contractId);
    496         entry.resolve(null);
    497         return;
    498       }
    499 
    500       clearTimeout(entry.timeoutId);
    501       pending.delete(contractId);
    502       entry.resolve(data);
    503     },
    504 
    505     /**
    506      * Reject a pending request. Called by the WS listener when applying
    507      * state fails, or internally on timeout.
    508      *
    509      * Unlike resolve, reject always surfaces regardless of epoch staleness.
    510      * Errors should never be silently swallowed — the handler needs to
    511      * know something went wrong so it can surface it to the user.
    512      *
    513      * No-op if contractId is not found.
    514      */
    515     reject(contractId: string, error: WebError): void {
    516       const entry = pending.get(contractId);
    517       if (!entry) return;
    518 
    519       clearTimeout(entry.timeoutId);
    520       pending.delete(contractId);
    521       entry.reject(error);
    522     },
    523 
    524     /**
    525      * Bump the epoch without tracking a new request.
    526      *
    527      * All currently pending entries become stale — their resolve() calls
    528      * will settle with null when the server eventually responds.
    529      * Their timeouts also settle with null (not rejection) since the
    530      * timeout callback checks epoch staleness before rejecting.
    531      *
    532      * Use this when you want to "cancel" all in-flight requests without
    533      * triggering error callbacks. For example, navigating away from a
    534      * context where the responses are no longer relevant.
    535      *
    536      * No-op if epochs are not enabled.
    537      */
    538     invalidate(): void {
    539       if (!enableEpochs) return;
    540       epoch++;
    541     },
    542 
    543     /**
    544      * Reject all pending requests and clear all timeouts.
    545      * Call this from component onDestroy to prevent memory leaks
    546      * and dangling timer callbacks.
    547      */
    548     dispose(): void {
    549       for (const [contractId, entry] of pending) {
    550         clearTimeout(entry.timeoutId);
    551 
    552         // Reject with disposed error
    553         let errMessage =
    554           `WS tracker disposed while request ${contractId} was still pending`;
    555         entry.reject(
    556           new WebError(errMessage, "WsTrackerDisposedError"),
    557         );
    558       }
    559       pending.clear();
    560     },
    561 
    562     /**
    563      * Whether a tracked entry exists for this contract ID.
    564      * Used by listeners to decide whether to apply state:
    565      * - true: this is a response to a tracked request — apply + resolve
    566      * - false: timed out, already resolved, or unknown (future broadcast)
    567      */
    568     hasPending(contractId: string): boolean {
    569       return pending.has(contractId);
    570     },
    571 
    572     /** Number of currently pending (unresolved) tracked requests. */
    573     pendingCount(): number {
    574       return pending.size;
    575     },
    576   };
    577 }
    578 ```