Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'fix: tokenizer errors, decrypt validation, stream proxy, WS backoff' (#246) from fix/batch5-stream-backoff-crypto-tokenizer into main

scott 299cf3a1 58028cbf

+146 -9
+4 -5
server/index.ts
··· 698 698 699 699 if (upstream.body) { 700 700 const reader = upstream.body.getReader(); 701 - const push = async (): Promise<void> => { 701 + for (;;) { 702 702 const { done, value } = await reader.read(); 703 - if (done) { res.end(); return; } 703 + if (done) break; 704 704 res.write(value); 705 - await push(); 706 - }; 707 - await push(); 705 + } 706 + res.end(); 708 707 } else { 709 708 res.end(); 710 709 }
+3
src/lib/crypto.ts
··· 53 53 * Decrypt data produced by encrypt(). Expects iv || ciphertext. 54 54 */ 55 55 export async function decrypt(data: Uint8Array, key: CryptoKey): Promise<Uint8Array> { 56 + if (data.length <= IV_LENGTH) { 57 + throw new Error(`Ciphertext too short: expected >${IV_LENGTH} bytes, got ${data.length}`); 58 + } 56 59 const iv = data.slice(0, IV_LENGTH); 57 60 const ciphertext = data.slice(IV_LENGTH); 58 61 const plaintext = await crypto.subtle.decrypt(
+7 -2
src/lib/provider.ts
··· 89 89 _lastDebounceTrigger: number; 90 90 _lastEncrypted: ArrayBuffer | Uint8Array | null; 91 91 _saveInProgress: boolean; 92 + _reconnectAttempts: number; 92 93 93 94 /** Resolves after the initial snapshot load completes (success or failure). 94 95 * Await this before writing to the Y.Doc to prevent CRDT conflicts with loaded data. */ ··· 120 121 this._snapshotLoadFailed = false; // Track if server had data we couldn't load 121 122 this._lastDebounceTrigger = 0; // Timestamp of first debounce in current burst 122 123 this._lastEncrypted = null; // Cached encrypted state for synchronous sendBeacon 124 + this._reconnectAttempts = 0; 123 125 this._saveInProgress = false; // Prevents concurrent _saveSnapshot calls 124 126 this.whenReady = new Promise<void>(resolve => { this._resolveReady = resolve; }); 125 127 ··· 177 179 178 180 this.ws.onopen = (): void => { 179 181 this.connected = true; 182 + this._reconnectAttempts = 0; 180 183 this._emit('status', { connected: true }); 181 184 182 185 // Send sync step 1: our state vector so peers can send missing updates ··· 219 222 this._emit('status', { connected: false }); 220 223 clearInterval(this._snapshotTimer!); 221 224 222 - // Reconnect after delay 225 + // Reconnect with exponential backoff (1s, 2s, 4s, 8s... capped at 30s) 223 226 if (!this._destroyed) { 224 - setTimeout(() => this.connect(), 2000 + Math.random() * 2000); 227 + const delay = Math.min(RETRY_BASE_MS * Math.pow(2, this._reconnectAttempts), 30_000); 228 + this._reconnectAttempts++; 229 + setTimeout(() => this.connect(), delay + Math.random() * 1000); 225 230 } 226 231 }; 227 232
+14 -2
src/sheets/formulas.ts
··· 185 185 if (s[i] === ':') { tokens.push({ type: TokenType.COLON }); i++; continue; } 186 186 if (s[i] === '!') { tokens.push({ type: TokenType.BANG }); i++; continue; } 187 187 188 - // Unknown character 189 - i++; 188 + // Error literals: #REF!, #VALUE!, #N/A, #DIV/0!, #ERROR!, #NUM!, #NAME? 189 + if (s[i] === '#') { 190 + let end = i + 1; 191 + while (end < s.length && /[A-Za-z0-9/]/.test(s[end])) end++; 192 + // Consume trailing ! or ? 193 + if (end < s.length && (s[end] === '!' || s[end] === '?')) end++; 194 + const errStr = s.slice(i, end); 195 + tokens.push({ type: TokenType.STRING, value: errStr }); 196 + i = end; 197 + continue; 198 + } 199 + 200 + // Unknown character — abort tokenization 201 + throw new Error(`Unknown character: ${s[i]}`); 190 202 } 191 203 192 204 tokens.push({ type: TokenType.EOF });
+25
tests/crypto.test.ts
··· 181 181 }); 182 182 }); 183 183 184 + describe('decrypt input validation (#389)', () => { 185 + it('rejects empty Uint8Array', async () => { 186 + const key = await generateKey(); 187 + await expect(decrypt(new Uint8Array(0), key)).rejects.toThrow(); 188 + }); 189 + 190 + it('rejects data shorter than IV length (12 bytes)', async () => { 191 + const key = await generateKey(); 192 + await expect(decrypt(new Uint8Array(5), key)).rejects.toThrow(); 193 + }); 194 + 195 + it('rejects data that is exactly IV length (no ciphertext)', async () => { 196 + const key = await generateKey(); 197 + await expect(decrypt(new Uint8Array(12), key)).rejects.toThrow(); 198 + }); 199 + 200 + it('still decrypts valid data correctly', async () => { 201 + const key = await generateKey(); 202 + const plaintext = new Uint8Array([1, 2, 3]); 203 + const encrypted = await encrypt(plaintext, key); 204 + const decrypted = await decrypt(encrypted, key); 205 + expect(Array.from(decrypted)).toEqual([1, 2, 3]); 206 + }); 207 + }); 208 + 184 209 describe('different keys produce different ciphertext', () => { 185 210 it('same plaintext encrypted with different keys yields different results', async () => { 186 211 const key1 = await generateKey();
+30
tests/formulas-edge-cases.test.ts
··· 1386 1386 expect(evalWith('LCM(4, 6)')).toBe(12); 1387 1387 }); 1388 1388 }); 1389 + 1390 + // ============================================================ 1391 + // Tokenizer: unknown characters (#367) 1392 + // ============================================================ 1393 + 1394 + describe('Tokenizer unknown character handling', () => { 1395 + it('returns error for formula with unknown character @', () => { 1396 + const result = evalWith('1+@+2'); 1397 + expect(typeof result).toBe('string'); 1398 + expect(result).toMatch(/^#/); // should be an error string 1399 + }); 1400 + 1401 + it('returns error for formula with backtick', () => { 1402 + const result = evalWith('`hello`'); 1403 + expect(typeof result).toBe('string'); 1404 + expect(result).toMatch(/^#/); 1405 + }); 1406 + 1407 + it('returns error for formula with tilde', () => { 1408 + const result = evalWith('1~2'); 1409 + expect(typeof result).toBe('string'); 1410 + expect(result).toMatch(/^#/); 1411 + }); 1412 + 1413 + it('valid formulas still work normally', () => { 1414 + expect(evalWith('1+2')).toBe(3); 1415 + expect(evalWith('SUM(1,2,3)')).toBe(6); 1416 + expect(evalWith('"hello"&" world"')).toBe('hello world'); 1417 + }); 1418 + });
+63
tests/provider-sync.test.ts
··· 439 439 expect(putCalls).toHaveLength(0); 440 440 }); 441 441 }); 442 + 443 + // =========================================================================== 444 + // WebSocket reconnect exponential backoff (#391) 445 + // =========================================================================== 446 + describe('WebSocket reconnect backoff', () => { 447 + it('uses exponential backoff on repeated disconnects', async () => { 448 + vi.useFakeTimers(); 449 + const { provider, ws } = await createProvider(); 450 + 451 + // Simulate first disconnect 452 + ws._simulateClose(); 453 + 454 + // After first close, a new WS should NOT be created immediately 455 + const instancesAfterClose = MockWebSocket._instances.length; 456 + 457 + // Advance just past the minimum initial delay (should be ~1-2s) 458 + await vi.advanceTimersByTimeAsync(1500); 459 + 460 + // Should have reconnected by now (within first backoff window) 461 + // The key test: second disconnect should use longer delay 462 + const wsAfterFirst = MockWebSocket.latest!; 463 + wsAfterFirst._simulateClose(); 464 + 465 + const instancesAfterSecondClose = MockWebSocket._instances.length; 466 + 467 + // Advance by same amount — should NOT have reconnected yet (backoff doubled) 468 + await vi.advanceTimersByTimeAsync(1500); 469 + const instancesAfterShortWait = MockWebSocket._instances.length; 470 + 471 + // Advance more to allow second backoff to complete 472 + await vi.advanceTimersByTimeAsync(5000); 473 + const instancesAfterLongWait = MockWebSocket._instances.length; 474 + 475 + // We should have more instances after the longer wait 476 + expect(instancesAfterLongWait).toBeGreaterThan(instancesAfterSecondClose); 477 + 478 + vi.useRealTimers(); 479 + provider.destroy(); 480 + }); 481 + 482 + it('resets backoff after successful connection', async () => { 483 + vi.useFakeTimers(); 484 + const { provider, ws } = await createProvider(); 485 + 486 + // Disconnect and reconnect a few times to build up backoff 487 + ws._simulateClose(); 488 + await vi.advanceTimersByTimeAsync(10000); 489 + const ws2 = MockWebSocket.latest!; 490 + 491 + // Simulate successful open — should reset backoff 492 + ws2._simulateOpen(); 493 + ws2._simulateClose(); 494 + 495 + // After reset, should reconnect quickly again (within initial window) 496 + const instancesBefore = MockWebSocket._instances.length; 497 + await vi.advanceTimersByTimeAsync(3000); 498 + const instancesAfter = MockWebSocket._instances.length; 499 + expect(instancesAfter).toBeGreaterThan(instancesBefore); 500 + 501 + vi.useRealTimers(); 502 + provider.destroy(); 503 + }); 504 + });