Long deep-research streams no longer get cut off at the ceiling — the timer now resets every time bytes arrive
The per-chunk idle-timeout discipline the legacy modal flow has used for two iterations now governs the directory-template flow too. A healthy slow stream completes naturally; a silently-stalled one fails in seconds instead of minutes.
The structural fix the morning's market-map run earned
Why care?
If you run any directory template on Perplexity — especially the long deep-research templates like market-map-profile — your timeouts now behave the way you'd intuit they should. A stream that is healthily producing bytes runs as long as it needs to run. A stream that goes silent (Perplexity rate-limit, socket close, upstream stall) surfaces the failure inside seconds, not at the end of a 30-minute ceiling. The change is invisible when everything goes well and only noticeable when something would have gone wrong — which is when you most want it noticeable.
Earlier today we shipped a market-map template, watched it produce a beautiful 7,500-word draft on Humanoid Robots, and then watched the last sentence cut off mid-word because the wall-clock timer fired before the stream finished. We patched the immediate pain by raising the ceiling. This entry is the structural fix underneath it.
What's new?
A single concept: the timer that decides "is this stream still alive?" is now armed per chunk, not once per request.
Old behavior. One
setTimeout(controller.abort, timeoutMs)at fetch time. If the stream takes longer thantimeoutMs, even by one byte, abort. If the stream goes silent for 27 of the 30 allotted minutes, wait the full 30 anyway before noticing.New behavior. Each
reader.read()is raced against a fresh timer. As long as bytes keep arriving, the timer is cleared and re-armed. The stream is only killed if it goes quiet foridleMs— by default 270 seconds for deep-research models, 90 seconds for everything else.
Two cft-block keys now govern timeouts (both optional):
provider: perplexity
model: sonar-deep-research
stream-idle-timeout-ms: 270000 # per-chunk idle (default already 270s for deep-research)
request-timeout-ms: 2400000 # absolute wall-clock ceiling (40 min)
system: |
... stream-idle-timeout-ms: is the new primary safety. request-timeout-ms: is the legacy key, now repurposed as an opt-in absolute ceiling — set to 0 to disable and rely on idle-only.
How it works
Two different streaming primitives have lived in this codebase since the directory-template flow forked from the legacy PerplexityModal flow. The legacy modal moved to per-chunk idle-timeout discipline two iterations ago after the same problem hit users there first. The directory-template flow never received the backport — until today.
The pattern, ported from perplexityService.ts:659-668 into streamPerplexityToFile:
const readWithIdleTimeout = (): Promise<ReadableStreamReadResult<Uint8Array>> => {
let timer: number | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = activeWindow.setTimeout(() => {
reject(new Error(`stream went idle for ${idleMs / 1000}s (likely API stall, rate limit, or socket close)`));
}, idleMs);
});
return Promise.race([reader.read(), timeout]).finally(() => {
if (timer !== undefined) activeWindow.clearTimeout(timer);
});
};
// inside the read loop:
({ value, done } = await readWithIdleTimeout()); Promise.race([reader.read(), timeout]) resolves with whichever wins. If a byte arrives, the .finally() clears the timer before it can fire and the loop continues. If the timer fires first, it rejects, the surrounding catch sets truncated = true, the existing cleanup pipeline flushes whatever already arrived, and the run returns with a partial-but-cited draft on disk and a Notice telling the user to re-run.
The AbortController stays — both as the cancel mechanism for the user-initiated Cancel command, and as the abort target for the optional wall-clock ceiling. The two timers are complementary: idle handles "is this stream alive right now?" and ceiling handles "regardless, do not let this run forever."
The two pathologies the old design handled poorly
Shape 1 — slow but healthy stream. Deep-research generations on long templates sustain a slow trickle of tokens for tens of minutes. The old wall-clock cap killed them at the ceiling regardless of whether they were still producing. The idle timer lets them complete as long as bytes keep arriving inside the idle window. (The ceiling is still there if you want a hard cap — it's just not what's making the moment-to-moment safety decision anymore.)
Shape 2 — silently stalled stream. Conversely, a stream may go quiet at minute 3 (Perplexity rate-limit, socket close, upstream stall) and the old wall-clock cap wouldn't notice until minute 30. The user stared at an empty file for 27 unnecessary minutes. The idle timer surfaces the failure within idleMs seconds — fast feedback when something is genuinely wrong.
Both shapes are real. The wall-clock pattern punished healthy-but-slow while tolerating stalled-but-silent. The idle-timeout pattern inverts both — slow-but-healthy completes; stalled-but-silent fails fast.
Migration notes
Nothing breaks for existing templates. The request-timeout-ms: key is still honored where declared; it just means "absolute wall-clock ceiling" now instead of "the only timer at all." Templates that didn't declare it still inherit settings.requestTimeoutMs as their ceiling. market-map-profile.md keeps its 2400000 (40 min) value — under the new semantics it's the ceiling on top of the 270s idle timer, which is exactly what an analyst-grade deep-research budget should look like.
To get truly unbounded healthy streams (idle-only safety, no ceiling), set request-timeout-ms: 0 in the cft block, or set the plugin-level Request timeout (ms) setting to 0. The idle timer will catch silent stalls fast either way.
To override the idle timer itself, declare stream-idle-timeout-ms: <number> in the cft block. The defaults (270s deep, 90s normal) match the legacy modal flow and have been load-bearing there for two iterations, so most templates won't need to touch this.
What's deferred
The open issue listed four follow-ups. This entry closes items 1 and 2 (the port itself, and the dual-key naming decision). Two remain explicitly deferred:
Cross-service audit. The same wall-clock pathology may live in the Gemini service, the LM Studio service, and the Claude streaming flows. The idle-timeout discipline should be the house style across all of them; the audit is its own change.
Settings-pane exposure of idle defaults. Today the 270s/90s values are hardcoded in
streamPerplexityToFile(matchingperplexityService.ts). Exposing them as plugin settings is a follow-up if we ever need to tune them without a code change.
The deep-research detection is by model-name regex (/deep-research/i) — the same shape perplexityService.ts uses. Robust enough for today's Perplexity model lineup; revisit if Perplexity ever ships a long-running model under a different naming convention.
Why this matters for the multi-stage exploration
The Multi-Stage Cooperative Claude + Perplexity with RAG exploration explicitly listed this fix as upstream of the multi-stage spec. Reason: the eventual Claude editorial pass on a 7-8K-word draft is itself a long generation that would hit the same wall-clock cliff. With per-chunk idle-timeout discipline now load-bearing in the directory-template flow, the editorial pass can reuse the same readWithIdleTimeout primitive when it lands. The multi-stage spec is now unblocked on this dimension.
Files touched
src/services/directoryTemplateService.ts—streamPerplexityToFilesignature changed fromtimeoutMs: numbertotimeouts: { idleMs: number; ceilingMs: number }.readWithIdleTimeout()helper added. Callsite inapplyTemplateresolves both keys from cft config with sensible fallbacks (idle defaults from deep-research detection, ceiling defaults fromsettings.requestTimeoutMs, explicit0disables ceiling).docs/directory-templates.md— Per-template timeout override section rewritten as Per-template timeout overrides (plural), now documents both keys, the two pathologies the idle timer fixes, and the migration semantics.src/docs/templates/README.md— cft-key list updated to mention both keys.src/docs/templates/market-map-profile.md— inline comment on therequest-timeout-msdeclaration rewritten to explain it as the absolute ceiling on top of the 270s idle timer.context-v/issues/Wall-Clock-Timeout-Cuts-Off-Long-Deep-Research-Streams.md— referenced as the canonical issue this closes.