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