From b3bc7e2f5cbc47ed8f711b28c1a51c5828965898 Mon Sep 17 00:00:00 2001 From: mpstaton Date: Sat, 2 May 2026 18:13:05 -0500 Subject: [PATCH] fix(streaming): hoist TextDecoder + add 90s idle timeout to surface stalls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two open items from changelog/2026-05-02_01.md: 1. TextDecoder was being instantiated inside the read loop, so the {stream: true} flag (meant to carry partial multi-byte UTF-8 state across chunk boundaries) was discarded every iteration. Multi-byte characters split across a chunk boundary silently became U+FFFD, which then caused the JSON parse on that line to fail — caught and logged silently by the per-line try/catch. Hoisted the decoder outside the loop so the stream-state flag actually does its job. 2. Streaming responses occasionally truncated mid-answer with no console error and no Notice — the loop just stopped writing. Diagnosis (unverified) was that Perplexity's server or Obsidian's Electron fetch was closing the SSE socket without surfacing an error, leaving reader.read() blocked forever. Each read now races against a 90s idle timer; if no chunk arrives in that window, the timer rejects with a clear message ("stream went idle for 90s (likely API stall, rate limit, or socket close)") which propagates to the existing catch and produces a visible Streaming Error notice + editor line instead of silent truncation. 90s is generous enough for sonar-deep-research first-byte (30-60s typical) but short enough that a true stall is caught quickly. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/perplexityService.ts | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/services/perplexityService.ts b/src/services/perplexityService.ts index b7d71ee..934e7a1 100644 --- a/src/services/perplexityService.ts +++ b/src/services/perplexityService.ts @@ -630,19 +630,39 @@ export class PerplexityService { if (!reader) throw new Error('No response body'); console.log(`🔄 Starting streaming response handler [${requestId || 'unknown'}]`); - + + // Hoist decoder out of the loop so the {stream: true} flag carries + // partial multi-byte UTF-8 state across chunk boundaries. + const decoder = new TextDecoder(); let buffer = ''; const currentPos = { ...responseCursor }; let finalResponseData: PerplexityStreamChunk | null = null; + // Race each read against an idle timer — if no chunk arrives within + // STREAM_IDLE_TIMEOUT_MS, throw so the catch surfaces a visible + // "Streaming Error" instead of leaving reader.read() blocked forever + // on a dropped/idle SSE socket. + const STREAM_IDLE_TIMEOUT_MS = 90_000; + const readWithIdleTimeout = (): Promise> => { + let timer: number | undefined; + const timeout = new Promise((_, reject) => { + timer = window.setTimeout(() => { + reject(new Error(`stream went idle for ${STREAM_IDLE_TIMEOUT_MS / 1000}s (likely API stall, rate limit, or socket close)`)); + }, STREAM_IDLE_TIMEOUT_MS); + }); + return Promise.race([reader.read(), timeout]).finally(() => { + if (timer !== undefined) window.clearTimeout(timer); + }); + }; + console.log(`📍 Initial currentPos:`, currentPos); try { while (true) { - const { done, value } = await reader.read(); + const { done, value } = await readWithIdleTimeout(); if (done) break; - const chunk = new TextDecoder().decode(value, { stream: true }); + const chunk = decoder.decode(value, { stream: true }); buffer += chunk; const lines = buffer.split('\n');