I ran into a problem where the SSE stream in StreamableHTTPClientTransport hangs indefinitely when the server starts streaming a response but then stops sending data (half-open TCP connection, server crashes mid-stream, proxy stalls, etc).
What happens
_handleSseStream reads from the SSE stream in a loop:
while (true) {
const { value: event, done } = await reader.read(); // blocks forever if stream stalls
if (done) break;
// process event...
}
If the connection goes quiet (server stalls, proxy drops, network hiccup), reader.read() blocks indefinitely. There's no idle timeout, no chunk timeout, no timer of any kind.
On my setup (OpenCode Desktop via oh-my-openagent, hitting Chinese LLM providers like GLM/Qwen), this happens multiple times per day. The agent GUI shows "thinking" and never recovers — I've seen hangs over 10 minutes before giving up and restarting.
Why the caller's AbortSignal doesn't help
The caller might pass an AbortSignal (e.g. a 120s prompt timeout), but send() uses its own internal _abortController.signal for the fetch call:
async send(message, options) {
const init = {
signal: this._abortController?.signal // internal signal, ignores caller's timeout
};
const response = await fetch(this._url, init);
// ... if response is text/event-stream, hands off to _handleSseStream
}
The caller's signal never reaches _handleSseStream or its reader.
What I'd suggest
Add an optional idle timeout to _handleSseStream. If no SSE chunk arrives within the timeout, cancel the reader and let the reconnection logic handle it. Something like:
const SSE_IDLE_TIMEOUT_MS = options.idleTimeoutMs ?? 60_000;
let idleTimer;
const resetIdleTimer = () => {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
reader.cancel(new Error(`SSE idle timeout after ${SSE_IDLE_TIMEOUT_MS}ms`));
}, SSE_IDLE_TIMEOUT_MS);
};
resetIdleTimer();
while (true) {
const { value: event, done } = await reader.read();
if (done) { clearTimeout(idleTimer); break; }
resetIdleTimer();
// ... process event
}
This would also need cleanup in the catch block.
My workaround
I'm currently patching _handleSseStream in my local oh-my-openagent/dist/index.js with essentially the code above (60s idle timeout). It works — the agent recovers within ~60s instead of hanging forever. But it'd be much better to have this upstream.
Thanks for the great work on this SDK!
I ran into a problem where the SSE stream in
StreamableHTTPClientTransporthangs indefinitely when the server starts streaming a response but then stops sending data (half-open TCP connection, server crashes mid-stream, proxy stalls, etc).What happens
_handleSseStreamreads from the SSE stream in a loop:If the connection goes quiet (server stalls, proxy drops, network hiccup),
reader.read()blocks indefinitely. There's no idle timeout, no chunk timeout, no timer of any kind.On my setup (OpenCode Desktop via oh-my-openagent, hitting Chinese LLM providers like GLM/Qwen), this happens multiple times per day. The agent GUI shows "thinking" and never recovers — I've seen hangs over 10 minutes before giving up and restarting.
Why the caller's AbortSignal doesn't help
The caller might pass an AbortSignal (e.g. a 120s prompt timeout), but
send()uses its own internal_abortController.signalfor the fetch call:The caller's signal never reaches
_handleSseStreamor its reader.What I'd suggest
Add an optional idle timeout to
_handleSseStream. If no SSE chunk arrives within the timeout, cancel the reader and let the reconnection logic handle it. Something like:This would also need cleanup in the catch block.
My workaround
I'm currently patching
_handleSseStreamin my localoh-my-openagent/dist/index.jswith essentially the code above (60s idle timeout). It works — the agent recovers within ~60s instead of hanging forever. But it'd be much better to have this upstream.Thanks for the great work on this SDK!