Skip to content

Commit 23730b0

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
fix(core,webapp): treat S2 stream error events as read failures
S2 ends a read that fails mid-stream with an `event: error` followed by a normal end of the response. Trigger.dev's readers ignored that event, so a failed read looked like the stream had finished. Run streams (`runs.subscribeToRun` with streams, `useRealtimeRunWithStreams`) and chat streams stopped early, and server-side record reads returned partial results as if they were complete. ```ts if (chunk.event === "error") { throw new Error(`Stream read failed: ${chunk.data}`); } ``` The stream subscription now throws on the event, so its existing retry path reconnects with `Last-Event-ID` and resumes after the last record it delivered. This covers every client that reads realtime streams, including chat reconnects. If retries run out, the stream errors instead of completing. Server-side record reads now reject on the event and retry transient failures. An input stream or session `.wait()` no longer misses a message sent just before the wait started when the stream read fails briefly. The client protocol docs now list the `error` event, and the reference reader handles it. Mono-RevId: 2bddf6acd0dc4fc56a709eec125251fa97bd064a
1 parent 1fac769 commit 23730b0

9 files changed

Lines changed: 795 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
Realtime streams now reconnect and resume when the stream backend reports a failed read mid-stream, instead of ending early as if the stream had finished. This affects `runs.subscribeToRun` with streams, `useRealtimeRunWithStreams`, and chat streams.

‎apps/webapp/app/routes/api.v1.runs.$runFriendlyId.input-streams.wait.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,16 @@ const { action, loader } = createActionApiRoute(
134134
// Clean up the Redis cache since we completed it ourselves
135135
await deleteInputStreamWaitpoint(run.friendlyId, body.streamId);
136136
}
137-
} catch {
137+
} catch (error) {
138138
// Non-fatal: if the S2 check fails, the waitpoint is still PENDING.
139139
// The next .send() will complete it via the Redis cache path.
140+
logger.warn("input-stream wait race-check failed", {
141+
runId: run.friendlyId,
142+
streamId: body.streamId,
143+
waitpointId: WaitpointId.toFriendlyId(result.waitpoint.id),
144+
error,
145+
environmentId: authentication.environment.id,
146+
});
140147
}
141148
}
142149

‎apps/webapp/app/services/realtime/s2realtimeStreams.server.ts‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,43 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
330330
return names;
331331
}
332332

333+
/**
334+
* Non-blocking read of the records after `afterSeqNum`. Retries transient
335+
* failures (network errors, 5xx, and reads S2 ends with an `error` event)
336+
* because the `.wait()` race check depends on this read to find a record
337+
* sent before the waitpoint registered.
338+
*/
333339
async #readRecordsByName(s2Stream: string, afterSeqNum?: number): Promise<StreamRecord[]> {
340+
const backoffsMs = [100, 300];
341+
const maxAttempts = backoffsMs.length + 1;
342+
343+
for (let attempt = 0; ; attempt++) {
344+
try {
345+
return await this.#readRecordsOnce(s2Stream, afterSeqNum);
346+
} catch (error) {
347+
const status = (error as { status?: number }).status;
348+
const isClientError = status !== undefined && status >= 400 && status < 500;
349+
if (isClientError || attempt + 1 >= maxAttempts) {
350+
this.logger.error("S2 readRecords failed", {
351+
stream: s2Stream,
352+
attempts: attempt + 1,
353+
error,
354+
});
355+
throw error;
356+
}
357+
358+
this.logger.warn("S2 readRecords transient failure, retrying", {
359+
stream: s2Stream,
360+
attempt: attempt + 1,
361+
nextDelayMs: backoffsMs[attempt],
362+
error,
363+
});
364+
await new Promise((resolve) => setTimeout(resolve, backoffsMs[attempt]));
365+
}
366+
}
367+
}
368+
369+
async #readRecordsOnce(s2Stream: string, afterSeqNum?: number): Promise<StreamRecord[]> {
334370
const startSeq = afterSeqNum != null ? afterSeqNum + 1 : 0;
335371

336372
const qs = new URLSearchParams();
@@ -357,7 +393,9 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
357393
return [];
358394
}
359395
await cancelResponseBody(res);
360-
throw new Error(`S2 readRecords failed: ${res.status} ${res.statusText}`);
396+
throw Object.assign(new Error(`S2 readRecords failed: ${res.status} ${res.statusText}`), {
397+
status: res.status,
398+
});
361399
}
362400

363401
// Parse the SSE response body to extract records
@@ -384,6 +422,10 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
384422
}
385423
}
386424

425+
if (eventType === "error") {
426+
throw new Error(`S2 readRecords failed: ${data ?? "error event"}`);
427+
}
428+
387429
if (eventType === "batch" && data) {
388430
try {
389431
const parsed = JSON.parse(data) as {
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
2+
import type { AddressInfo } from "node:net";
3+
import { Readable } from "node:stream";
4+
import { pipeline } from "node:stream/promises";
5+
6+
const HOP_BY_HOP = new Set([
7+
"host",
8+
"connection",
9+
"content-length",
10+
"transfer-encoding",
11+
"keep-alive",
12+
"content-encoding",
13+
]);
14+
15+
type Fault = { match: string; afterBatches: number; message: string; remaining: number };
16+
17+
/**
18+
* An HTTP proxy that sits between the webapp and s2-lite and can make one SSE
19+
* read fail the way S2 does when a read session breaks after the response has
20+
* started: forward `afterBatches` real `batch` events, then send
21+
* `event: error` and complete the response normally (no `[DONE]`).
22+
*
23+
* Every other request passes through untouched. Start it before the webapp
24+
* (so its URL can go into `REALTIME_STREAMS_S2_ENDPOINT`) and call
25+
* `setTarget` once s2-lite is up; requests that arrive earlier wait for it.
26+
*/
27+
export class S2FaultProxy {
28+
private server: Server | undefined;
29+
private faults: Fault[] = [];
30+
private resolveTarget!: (endpoint: string) => void;
31+
private readonly target = new Promise<string>((resolve) => (this.resolveTarget = resolve));
32+
33+
/** Stream names whose read had an error injected, in order. */
34+
readonly injected: string[] = [];
35+
36+
/** Starts listening and returns the base URL to use as the webapp's S2 endpoint. */
37+
async start(): Promise<string> {
38+
this.server = createServer((req, res) => {
39+
this.handle(req, res).catch(() => {
40+
if (!res.headersSent) res.writeHead(502);
41+
res.end();
42+
});
43+
});
44+
await new Promise<void>((resolve) => this.server!.listen(0, "127.0.0.1", resolve));
45+
const { port } = this.server.address() as AddressInfo;
46+
return `http://127.0.0.1:${port}/v1`;
47+
}
48+
49+
/** Points the proxy at s2-lite's base URL (without `/v1`). */
50+
setTarget(endpoint: string) {
51+
this.resolveTarget(endpoint.replace(/\/$/, ""));
52+
}
53+
54+
/**
55+
* Fail the next `times` SSE reads (default one) of a stream whose name
56+
* contains `match`, each after `afterBatches` batch events have been
57+
* forwarded.
58+
*/
59+
failNextRead(
60+
match: string,
61+
afterBatches: number,
62+
{ times = 1, message = "Reconnection required" }: { times?: number; message?: string } = {}
63+
) {
64+
this.faults.push({ match, afterBatches, message, remaining: times });
65+
}
66+
67+
async stop() {
68+
if (!this.server) return;
69+
this.server.closeAllConnections();
70+
await new Promise<void>((resolve) => this.server!.close(() => resolve()));
71+
}
72+
73+
private takeFault(req: IncomingMessage): Fault | undefined {
74+
const accept = String(req.headers.accept ?? "");
75+
if (req.method !== "GET" || !accept.includes("text/event-stream")) return undefined;
76+
const path = decodeURIComponent(new URL(req.url ?? "/", "http://proxy").pathname);
77+
if (!path.endsWith("/records")) return undefined;
78+
const index = this.faults.findIndex((fault) => path.includes(fault.match));
79+
if (index === -1) return undefined;
80+
const fault = this.faults[index]!;
81+
fault.remaining--;
82+
if (fault.remaining <= 0) this.faults.splice(index, 1);
83+
this.injected.push(path);
84+
return fault;
85+
}
86+
87+
private async handle(req: IncomingMessage, res: ServerResponse) {
88+
const target = await this.target;
89+
const headers = new Headers();
90+
for (const [name, value] of Object.entries(req.headers)) {
91+
if (value === undefined || HOP_BY_HOP.has(name)) continue;
92+
headers.set(name, Array.isArray(value) ? value.join(", ") : value);
93+
}
94+
95+
const chunks: Buffer[] = [];
96+
for await (const chunk of req) chunks.push(chunk as Buffer);
97+
const body = chunks.length > 0 ? Buffer.concat(chunks) : undefined;
98+
99+
const abort = new AbortController();
100+
res.on("close", () => abort.abort());
101+
102+
const fault = this.takeFault(req);
103+
const upstream = await fetch(`${target}${req.url}`, {
104+
method: req.method,
105+
headers,
106+
body,
107+
signal: abort.signal,
108+
});
109+
110+
const responseHeaders: Record<string, string> = {};
111+
upstream.headers.forEach((value, name) => {
112+
if (!HOP_BY_HOP.has(name)) responseHeaders[name] = value;
113+
});
114+
res.writeHead(upstream.status, responseHeaders);
115+
116+
if (!upstream.body) {
117+
res.end();
118+
return;
119+
}
120+
121+
if (!fault || !upstream.ok) {
122+
await pipeline(Readable.fromWeb(upstream.body as never), res).catch(() => {});
123+
return;
124+
}
125+
126+
await this.forwardUntilFault(upstream.body, res, fault);
127+
abort.abort();
128+
}
129+
130+
private async forwardUntilFault(
131+
body: ReadableStream<Uint8Array>,
132+
res: ServerResponse,
133+
fault: Fault
134+
) {
135+
const errorEvent = `event: error\ndata: ${fault.message}\n\n`;
136+
res.flushHeaders();
137+
if (fault.afterBatches === 0) {
138+
res.end(errorEvent);
139+
await body.cancel().catch(() => {});
140+
return;
141+
}
142+
143+
const reader = body.getReader();
144+
const decoder = new TextDecoder();
145+
let buffer = "";
146+
let batches = 0;
147+
try {
148+
while (true) {
149+
const { done, value } = await reader.read();
150+
if (done) {
151+
res.end(buffer);
152+
return;
153+
}
154+
buffer += decoder.decode(value, { stream: true });
155+
const events = buffer.split(/\r?\n\r?\n/);
156+
buffer = events.pop() ?? "";
157+
for (const event of events) {
158+
res.write(`${event}\n\n`);
159+
if (/^event:\s*batch\s*$/m.test(event)) batches++;
160+
if (batches >= fault.afterBatches) {
161+
res.end(errorEvent);
162+
return;
163+
}
164+
}
165+
}
166+
} finally {
167+
await reader.cancel().catch(() => {});
168+
}
169+
}
170+
}

0 commit comments

Comments
 (0)