fix(streaming): hoist TextDecoder + add 90s idle timeout to surface stalls

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) <noreply@anthropic.com>
This commit is contained in:
mpstaton 2026-05-02 18:13:05 -05:00
parent ebebfa9f21
commit b3bc7e2f5c

View file

@ -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<ReadableStreamReadResult<Uint8Array>> => {
let timer: number | undefined;
const timeout = new Promise<never>((_, 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');