Skip to content

Commit f26770b

Browse files
committed
feat(sdk,core): close resumed chat streams promptly when caught up
Resuming a chat session stream (page reload or reconnect) held the SSE connection open for the whole long-poll window even after every buffered output record had already arrived. The client now detects when it has caught up to the latest output and closes the resumed stream right away, so reconnecting to an idle chat settles immediately. Detection reuses the stream's tail-carrying heartbeat: batch and ping frames now carry the tail, and CaughtUpTracker from @s2-dev/streamstore 0.25.0 turns "last delivered seq + 1 === tail" into a caught-up signal. When the tail is absent (older self-hosted stream backends) the client keeps its previous behavior, so nothing regresses. Also moves to the current S2 hosts that 0.25.0 defaults to.
1 parent e9ac98b commit f26770b

12 files changed

Lines changed: 233 additions & 17 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"@trigger.dev/sdk": patch
4+
---
5+
6+
Chat sessions now close a resumed stream as soon as it has caught up to the latest output, instead of holding the connection open for the full long-poll window. Reloading or reconnecting to an idle chat settles faster.
7+
</content>

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,8 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
107107

108108
constructor(opts: S2RealtimeStreamsOptions) {
109109
this.basin = opts.basin;
110-
this.baseUrl = opts.endpoint ?? `https://${this.basin}.b.aws.s2.dev/v1`;
111-
this.accountUrl = opts.endpoint ?? `https://aws.s2.dev/v1`;
110+
this.baseUrl = opts.endpoint ?? `https://${this.basin}.b.s2.dev/v1`;
111+
this.accountUrl = opts.endpoint ?? `https://a.s2.dev/v1`;
112112
this.endpoint = opts.endpoint;
113113
this.token = opts.accessToken;
114114
this.streamPrefix = opts.streamPrefix ?? "";

apps/webapp/app/services/realtime/streamBasinProvisioner.server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ type CreateBasinOptions = {
185185
};
186186

187187
async function s2CreateBasin(name: string, opts: CreateBasinOptions): Promise<void> {
188-
const url = `https://aws.s2.dev/v1/basins`;
188+
const url = `https://a.s2.dev/v1/basins`;
189189
const body = {
190190
basin: name,
191191
config: {
@@ -222,7 +222,7 @@ type ReconfigureBasinOptions = {
222222
};
223223

224224
async function s2ReconfigureBasin(name: string, opts: ReconfigureBasinOptions): Promise<void> {
225-
const url = `https://aws.s2.dev/v1/basins/${encodeURIComponent(name)}`;
225+
const url = `https://a.s2.dev/v1/basins/${encodeURIComponent(name)}`;
226226
const body = {
227227
default_stream_config: {
228228
retention_policy: { age: parseDuration(opts.retentionPolicy) },

apps/webapp/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@
103103
"@remix-run/react": "2.17.5",
104104
"@remix-run/router": "^1.23.3",
105105
"@remix-run/server-runtime": "2.17.5",
106-
"@s2-dev/streamstore": "^0.22.10",
106+
"@s2-dev/streamstore": "^0.25.0",
107107
"@sentry/remix": "9.46.0",
108108
"@slack/web-api": "7.16.0",
109109
"@socket.io/redis-adapter": "^8.3.0",

packages/cli-v3/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@
9595
"@opentelemetry/resources": "2.7.1",
9696
"@opentelemetry/sdk-trace-node": "2.7.1",
9797
"@opentelemetry/semantic-conventions": "1.41.1",
98-
"@s2-dev/streamstore": "^0.22.10",
98+
"@s2-dev/streamstore": "^0.25.0",
9999
"@trigger.dev/build": "workspace:4.5.7",
100100
"@trigger.dev/core": "workspace:4.5.7",
101101
"@trigger.dev/schema-to-json": "workspace:4.5.7",

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@
207207
"@opentelemetry/sdk-trace-base": "2.7.1",
208208
"@opentelemetry/sdk-trace-node": "2.7.1",
209209
"@opentelemetry/semantic-conventions": "1.41.1",
210-
"@s2-dev/streamstore": "0.22.10",
210+
"@s2-dev/streamstore": "0.25.0",
211211
"dequal": "^2.0.3",
212212
"eventsource": "^3.0.5",
213213
"eventsource-parser": "^3.0.0",

packages/core/src/v3/apiClient/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1410,6 +1410,13 @@ export class ApiClient {
14101410
* enqueued into the consumer stream — handle the event here.
14111411
*/
14121412
onControl?: (event: ControlEvent) => void;
1413+
/**
1414+
* Fires once when the session reaches the live tail (backlog drained),
1415+
* with the observed tail position. No-op when the backend omits the
1416+
* tail-carrying heartbeat. Mirrors the browser transport's caught-up
1417+
* signal on the worker / apiClient read path.
1418+
*/
1419+
onCaughtUp?: (tail: { seqNum: number; timestamp: Date }) => void;
14131420
}
14141421
): Promise<AsyncIterableStream<T>> {
14151422
const url = `${options?.baseUrl ?? this.baseUrl}/realtime/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}/${io}`;
@@ -1424,6 +1431,9 @@ export class ApiClient {
14241431
});
14251432

14261433
const stream = await subscription.subscribe();
1434+
if (options?.onCaughtUp) {
1435+
subscription.caughtUp().then(options.onCaughtUp).catch(() => {});
1436+
}
14271437
const onPart = options?.onPart;
14281438
const onControl = options?.onControl;
14291439

packages/core/src/v3/apiClient/runStream.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,3 +600,133 @@ describe("SSEStreamSubscription v2 batch parsing — record kinds", () => {
600600
expect((parts[1]!.chunk as any).delta).toBe("x");
601601
});
602602
});
603+
604+
describe("SSEStreamSubscription caught-up tracking", () => {
605+
const originalFetch = globalThis.fetch;
606+
607+
afterEach(() => {
608+
globalThis.fetch = originalFetch;
609+
vi.restoreAllMocks();
610+
});
611+
612+
type Rec = { body: string; seq_num: number; timestamp: number; headers?: Array<[string, string]> };
613+
type Tail = { seq_num: number; timestamp: number };
614+
615+
function dataRec(seq: number): Rec {
616+
return {
617+
body: JSON.stringify({ data: { type: "text-delta", delta: "x" }, id: `p${seq}` }),
618+
seq_num: seq,
619+
timestamp: 1,
620+
headers: [],
621+
};
622+
}
623+
624+
function batchEvent(records: Rec[], tail?: Tail): string {
625+
const data = tail ? { records, tail } : { records };
626+
return `event: batch\ndata: ${JSON.stringify(data)}\n\n`;
627+
}
628+
629+
function pingEvent(tail?: Tail): string {
630+
const data = tail ? { timestamp: 1, tail } : { timestamp: 1 };
631+
return `event: ping\ndata: ${JSON.stringify(data)}\n\n`;
632+
}
633+
634+
function makeEventsResponse(events: string[]) {
635+
const body = new ReadableStream<Uint8Array>({
636+
start(controller) {
637+
for (const e of events) controller.enqueue(new TextEncoder().encode(e));
638+
controller.close();
639+
},
640+
});
641+
return new Response(body, {
642+
status: 200,
643+
headers: { "Content-Type": "text/event-stream", "X-Stream-Version": "v2" },
644+
});
645+
}
646+
647+
async function drain(stream: ReadableStream<{ id: string; chunk: unknown }>) {
648+
const reader = stream.getReader();
649+
const parts: Array<{ id: string; chunk: unknown }> = [];
650+
while (true) {
651+
const { done, value } = await reader.read();
652+
if (done) {
653+
reader.releaseLock();
654+
return parts;
655+
}
656+
parts.push(value);
657+
}
658+
}
659+
660+
it("resolves caughtUp() when a batch reaches the reported tail", async () => {
661+
globalThis.fetch = vi
662+
.fn()
663+
.mockResolvedValue(
664+
makeEventsResponse([
665+
batchEvent([dataRec(0), dataRec(1), dataRec(2)], { seq_num: 3, timestamp: 1 }),
666+
])
667+
);
668+
const sub = new SSEStreamSubscription("http://x", { maxRetries: 0 });
669+
const stream = await sub.subscribe();
670+
const cu = sub.caughtUp();
671+
await drain(stream);
672+
const tail = await cu;
673+
expect(tail.seqNum).toBe(3);
674+
expect(sub.isCaughtUp()).toBe(true);
675+
});
676+
677+
it("stays behind when the batch does not reach the tail", async () => {
678+
globalThis.fetch = vi
679+
.fn()
680+
.mockResolvedValue(makeEventsResponse([batchEvent([dataRec(0)], { seq_num: 3, timestamp: 1 })]));
681+
const sub = new SSEStreamSubscription("http://x", { maxRetries: 0 });
682+
const stream = await sub.subscribe();
683+
await drain(stream);
684+
expect(sub.isCaughtUp()).toBe(false);
685+
});
686+
687+
it("resolves caughtUp() from a ping tail with an empty backlog (open-at-tail)", async () => {
688+
globalThis.fetch = vi
689+
.fn()
690+
.mockResolvedValue(makeEventsResponse([pingEvent({ seq_num: 3, timestamp: 1 })]));
691+
const sub = new SSEStreamSubscription("http://x", { maxRetries: 0 });
692+
const stream = await sub.subscribe();
693+
const cu = sub.caughtUp();
694+
await drain(stream);
695+
const tail = await cu;
696+
expect(tail.seqNum).toBe(3);
697+
expect(sub.isCaughtUp()).toBe(true);
698+
});
699+
700+
it("reaches caught-up when the tail is a trim command record (raw counts include it)", async () => {
701+
globalThis.fetch = vi.fn().mockResolvedValue(
702+
makeEventsResponse([
703+
batchEvent(
704+
[
705+
dataRec(0),
706+
{ body: "", seq_num: 1, timestamp: 1, headers: [["trigger-control", "turn-complete"]] },
707+
{ body: "AAAAAAAAAAQ=", seq_num: 2, timestamp: 1, headers: [["", "trim"]] },
708+
],
709+
{ seq_num: 3, timestamp: 1 }
710+
),
711+
])
712+
);
713+
const sub = new SSEStreamSubscription("http://x", { maxRetries: 0 });
714+
const stream = await sub.subscribe();
715+
const cu = sub.caughtUp();
716+
const parts = await drain(stream);
717+
const tail = await cu;
718+
expect(tail.seqNum).toBe(3);
719+
expect(sub.isCaughtUp()).toBe(true);
720+
expect(parts).toHaveLength(2);
721+
});
722+
723+
it("never reaches caught-up when the wire carries no tail (feature-detect fallback)", async () => {
724+
globalThis.fetch = vi
725+
.fn()
726+
.mockResolvedValue(makeEventsResponse([batchEvent([dataRec(0), dataRec(1), dataRec(2)]), pingEvent()]));
727+
const sub = new SSEStreamSubscription("http://x", { maxRetries: 0 });
728+
const stream = await sub.subscribe();
729+
await drain(stream);
730+
expect(sub.isCaughtUp()).toBe(false);
731+
});
732+
});

packages/core/src/v3/apiClient/runStream.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { conditionallyImportAndParsePacket, parsePacket } from "../utils/ioSeria
1414
import { ApiError, isTriggerRealtimeAuthError } from "./errors.js";
1515
import type { ApiClient } from "./index.js";
1616
import { zodShapeStream } from "./stream.js";
17+
import { CaughtUpTracker } from "@s2-dev/streamstore";
1718

1819
export type RunShape<TRunTypes extends AnyRunTypes> = TRunTypes extends AnyRunTypes
1920
? {
@@ -197,6 +198,7 @@ export class SSEStreamSubscription implements StreamSubscription {
197198
private nonRetryableStatuses: ReadonlySet<number>;
198199
private retryNowController: AbortController | null = null;
199200
private internalAbort: AbortController | null = null;
201+
private caughtUpTracker = new CaughtUpTracker();
200202

201203
constructor(
202204
private url: string,
@@ -278,6 +280,26 @@ export class SSEStreamSubscription implements StreamSubscription {
278280
this.retryNowController?.abort();
279281
}
280282

283+
/**
284+
* True once this session has consumed everything up to the tail the server
285+
* last reported (via a batch `tail` or a heartbeat `ping`). Resets to false
286+
* on reconnect and while records remain before the reported tail. Backed by
287+
* S2's `CaughtUpTracker`, fed from the v2 batch/ping wire signals.
288+
*/
289+
isCaughtUp(): boolean {
290+
return this.caughtUpTracker.isCaughtUp();
291+
}
292+
293+
/**
294+
* Resolves when this session reaches the live tail (backlog drained),
295+
* carrying the last observed tail position. Resolves immediately if already
296+
* caught up; call again after falling behind. Rejects if the stream ends
297+
* before catching up. Stays pending across internal reconnects.
298+
*/
299+
caughtUp(): ReturnType<CaughtUpTracker["caughtUp"]> {
300+
return this.caughtUpTracker.caughtUp();
301+
}
302+
281303
async subscribe(): Promise<ReadableStream<SSEStreamPart>> {
282304
// eslint-disable-next-line no-this-alias
283305
const self = this;
@@ -407,9 +429,18 @@ export class SSEStreamSubscription implements StreamSubscription {
407429
timestamp: number;
408430
headers?: Array<[string, string]>;
409431
}>;
432+
tail?: { seq_num: number; timestamp: number };
410433
};
411434
if (!data || !Array.isArray(data.records)) return;
412435

436+
const boundary = this.caughtUpTracker.observeBatch({
437+
recordCount: data.records.length,
438+
lastSeqNum: data.records.at(-1)?.seq_num,
439+
tail: data.tail
440+
? { seqNum: data.tail.seq_num, timestamp: new Date(data.tail.timestamp) }
441+
: undefined,
442+
});
443+
413444
for (const record of data.records) {
414445
// Always advance the resume cursor — even for records we
415446
// skip — so a future Last-Event-ID reconnect lands past
@@ -444,6 +475,22 @@ export class SSEStreamSubscription implements StreamSubscription {
444475
headers: record.headers ?? [],
445476
});
446477
}
478+
479+
boundary?.markDelivered();
480+
} else if (chunk.event === "ping") {
481+
const ping = safeParseJSON(chunk.data) as
482+
| { tail?: { seq_num: number; timestamp: number } }
483+
| undefined;
484+
if (ping?.tail) {
485+
const pingBoundary = this.caughtUpTracker.observeBatch({
486+
recordCount: 0,
487+
tail: {
488+
seqNum: ping.tail.seq_num,
489+
timestamp: new Date(ping.tail.timestamp),
490+
},
491+
});
492+
pingBoundary?.markDelivered();
493+
}
447494
}
448495
}
449496
},
@@ -458,6 +505,7 @@ export class SSEStreamSubscription implements StreamSubscription {
458505

459506
if (done) {
460507
reader.releaseLock();
508+
this.caughtUpTracker.end();
461509
controller.close();
462510
this.options.onComplete?.();
463511
return;
@@ -490,6 +538,7 @@ export class SSEStreamSubscription implements StreamSubscription {
490538
// `onError` was already invoked in the `!response.ok` branch above
491539
// (where the auth ApiError was originally constructed and thrown).
492540
// Auth errors are non-retryable: terminate the stream cleanly.
541+
this.caughtUpTracker.end();
493542
controller.error(error as Error);
494543
return;
495544
}
@@ -513,6 +562,7 @@ export class SSEStreamSubscription implements StreamSubscription {
513562

514563
if (this.retryCount >= this.maxRetries) {
515564
const finalError = error || new Error("Max retries reached");
565+
this.caughtUpTracker.end();
516566
controller.error(finalError);
517567
this.options.onError?.(finalError);
518568
return;
@@ -554,6 +604,7 @@ export class SSEStreamSubscription implements StreamSubscription {
554604
}
555605

556606
// Reconnect
607+
this.caughtUpTracker.reconnect();
557608
await this.connectStream(controller);
558609
}
559610
}

packages/core/src/v3/sessionStreams/manager.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,11 @@ export class StandardSessionStreamManager implements SessionStreamManager {
466466
console.error(`[SessionStreamManager] Tail error for "${key}":`, error);
467467
}
468468
},
469+
onCaughtUp: (tail) => {
470+
if (this.debug) {
471+
console.log(`[SessionStreamManager] Caught up on "${key}" at seq ${tail.seqNum}`);
472+
}
473+
},
469474
});
470475

471476
// Drain to keep the pipeThrough flowing. Records were already

0 commit comments

Comments
 (0)