mirror of
https://github.com/lossless-group/perplexed-plugin.git
synced 2026-07-22 06:49:50 +00:00
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:
parent
ebebfa9f21
commit
b3bc7e2f5c
1 changed files with 23 additions and 3 deletions
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue