Repository navigation
bug(sdk): a terminal stream error emitted before .subscribe() is delivered to nobody and never replayed #517
Description
Area: sdk · streaming — bug · found while reconciling the Go SDK (#434) with main
Expected: a terminal stream error reaches the subscriber's error callback, as sdk/reference.md promises without qualification.
Actual: if the error is emitted before .subscribe() runs, it is delivered to nobody and never replayed. The subscriber learns the stream is closed but never learns why.
The core defect
StreamController fans errors out over whoever is subscribed at the instant they fire, and retains nothing (clients/ts/src/stream/controller.ts:57-61):
this._transport.onError = (error) => {
for (const sub of this._subscribers) {
sub.error?.(error);
}
};subscribe() replays status to a newcomer, but there is no equivalent for errors (:128-130):
subscribe(subscriber: StreamSubscriber<T>): () => void {
this._subscribers.add(subscriber);
subscriber.status?.(this._status); // status is replayed; the last error is notErrors are the only thing the controller drops. Pre-subscription events are buffered for the async iterator, and status is replayed on subscribe. The asymmetry is unintentional rather than designed — see below.
Reproduction
Against the built SDK, with a stub transport that emits a terminal error one macrotask after connect() (the real ordering in sse.ts: _emitError(...), then disconnect() → onStatus("closed")):
A subscribe-immediately : GOT the error
B await-then-subscribe : MISSED the error
status visible to caller: closed
Any await between creating the stream and subscribing is enough:
const stream = client.from('clicks').stream();
await somethingElse(); // terminal error lands here
stream.subscribe({ next: ..., error: (e) => { /* never runs */ } });In practice that is subscribing inside a React effect after an await, or after awaiting a backfill query.
Why this is a bug and not "you don't get events from before you subscribed"
sse.ts:188-194 already treats "the error fires before anyone is listening" as a failure mode to engineer around — it yields a microtask specifically to avoid it:
Yield before the first attempt so a failure that is detectable synchronously — an unresolvable
baseURL— still reaches a subscriber.connect()runs inside the StreamController constructor, so anything emitted before this point fires before.stream()has returned and nobody is listening yet.
That fix covers only the synchronous case. The await-gap residual was never addressed. And sdk/reference.md states the contract with no subscription-timing caveat (:26, :48):
streams report
SSE_CONNECT_ERRORto the subscriber'serrorcallback
The
SSE_*codes arrive on the subscriber'serrorcallback rather than in aResult.error
Honest scope
This is narrower than its Go counterpart, and worth stating plainly:
- TypeScript is single-threaded, and the transport deliberately yields, so the documented pattern —
stream(), then a synchronous.subscribe(), thenawait connected()— always receives the error. Every doc example and every test intests/e2e/sdk/streaming.test.tsfollows it. - The late subscriber is not left blind:
status: "closed"is still replayed, andconnected()rejects. What is lost is the reason, which is exactly what a consumer needs to distinguish "bad credentials" from "server restarting".
Reference implementation
The Go SDK had the same defect as a genuine scheduling race — Stream() starts its goroutine immediately, so the window is real rather than await-dependent. Fixed in 29c6b6b on #434: the controller records lastErr, and Subscribe replays it to a late subscriber. Recording and snapshotting the subscriber list share one critical section, so a subscriber racing emitError is served by exactly one path and never sees the error twice. clients/go/stream_test.go pins it — reverting stream.go alone fails the test.
The TS fix is the same shape, minus the locking: keep _lastError, replay it in subscribe() right after the status replay.
Acceptance
- A subscriber that registers after a terminal error has been emitted receives that error
- A subscriber that registers before it still receives it exactly once — no double delivery
- Pinned by a test with an await between
.stream()and.subscribe() - Go and TypeScript agree on the behaviour (AGENTS.md API-tree parity)
Related: #473 (same file, same fan-out loop, but consumer-throw isolation — different trigger and different fix), #434 (the Go fix), #516 (the other Go-fix-implies-TS-bug finding from the same reconciliation).
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsBacklog
Activity