Skip to content

Commit 485ab83

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
fix(core): end runs.subscribeToRun after the run completes
`runs.subscribeToRun` now ends after the run completes. The `for await` loop exits right after the update with the final status and the subscription stops its connection, so a script that subscribes to a run exits on its own. Pass `stopOnCompletion: false` to keep the subscription open. `withStreams()` subscriptions keep their current behaviour. Mono-RevId: 04b515ab1c1e80b3cb328b5f80075d9086760c54
1 parent febbfef commit 485ab83

4 files changed

Lines changed: 78 additions & 4 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+
`runs.subscribeToRun` now ends after the run completes. The `for await` loop exits right after the update with the final status, and the subscription stops its connection, so a script that subscribes to a run exits on its own. Pass `stopOnCompletion: false` to keep the subscription open after completion.

‎packages/core/src/v3/apiClient/runStream-cancel.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,64 @@ describe("RunSubscription cancellation", () => {
139139
expect(pendingTimers()).toBeLessThanOrEqual(baseline);
140140
});
141141

142+
it("ends iteration and stops polling once the run completes", async () => {
143+
runFields = {
144+
status: "COMPLETED_SUCCESSFULLY",
145+
completedAt: new Date().toISOString().slice(0, -1),
146+
};
147+
const client = new ApiClient(baseUrl, "tr_test");
148+
const shared = new AbortController();
149+
const baseline = pendingTimers();
150+
const statuses: string[] = [];
151+
152+
const iterate = (async () => {
153+
for await (const run of client.subscribeToRun("run_1", { signal: shared.signal })) {
154+
statuses.push(run.status);
155+
}
156+
return "ended" as const;
157+
})();
158+
159+
const outcome = await Promise.race([iterate, sleep(2_000).then(() => "timeout" as const)]);
160+
teardown.abort();
161+
if (outcome === "timeout") {
162+
shared.abort();
163+
}
164+
165+
expect(outcome).toBe("ended");
166+
expect(statuses).toEqual(["COMPLETED"]);
167+
const { settled, later } = await requestsAfterSettling();
168+
expect(later).toBe(settled);
169+
expect(pendingTimers()).toBeLessThanOrEqual(baseline);
170+
expect(getEventListeners(shared.signal, "abort")).toHaveLength(0);
171+
});
172+
173+
it("keeps iterating after the run completes when closeOnComplete is false", async () => {
174+
runFields = {
175+
status: "COMPLETED_SUCCESSFULLY",
176+
completedAt: new Date().toISOString().slice(0, -1),
177+
};
178+
const client = new ApiClient(baseUrl, "tr_test");
179+
const subscription = client.subscribeToRun("run_1", {
180+
signal: teardown.signal,
181+
closeOnComplete: false,
182+
});
183+
184+
const iterate = (async () => {
185+
for await (const _ of subscription) {
186+
}
187+
return "ended" as const;
188+
})();
189+
190+
const outcome = await Promise.race([iterate, sleep(500).then(() => "open" as const)]);
191+
const before = requests;
192+
await sleep(200);
193+
194+
expect(outcome).toBe("open");
195+
expect(requests).toBeGreaterThan(before);
196+
subscription.unsubscribe();
197+
await iterate;
198+
});
199+
142200
it("removes its listener from a shared signal when the consumer breaks", async () => {
143201
const client = new ApiClient(baseUrl, "tr_test");
144202
const shared = new AbortController();

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ export function runShapeStream<TRunTypes extends AnyRunTypes>(
153153

154154
const $options: RunSubscriptionOptions = {
155155
runShapeStream: runStreamInstance.stream,
156-
stopRunShapeStream: () => runStreamInstance.stop(30 * 1000),
156+
stopRunShapeStream: (delay) => runStreamInstance.stop(delay),
157157
streamFactory: streamFactory,
158158
abortController,
159159
...options,
@@ -760,7 +760,7 @@ export class SSEStreamSubscriptionFactory implements StreamSubscriptionFactory {
760760

761761
export type RunSubscriptionOptions = RunShapeStreamOptions & {
762762
runShapeStream: ReadableStream<SubscribeRunRawShape>;
763-
stopRunShapeStream: () => void;
763+
stopRunShapeStream: (delay?: number) => void;
764764
streamFactory: StreamSubscriptionFactory;
765765
abortController: AbortController;
766766
};
@@ -773,6 +773,7 @@ export class RunSubscription<TRunTypes extends AnyRunTypes> {
773773
>();
774774
private _closeOnComplete: boolean;
775775
private _isRunComplete = false;
776+
private _withStreams = false;
776777

777778
constructor(private options: RunSubscriptionOptions) {
778779
this._closeOnComplete =
@@ -794,12 +795,18 @@ export class RunSubscription<TRunTypes extends AnyRunTypes> {
794795
this._isRunComplete &&
795796
!this.options.abortController.signal.aborted
796797
) {
797-
this.options.stopRunShapeStream();
798+
if (this._withStreams) {
799+
this.options.stopRunShapeStream(30 * 1000);
800+
} else {
801+
this.options.stopRunShapeStream();
802+
controller.terminate();
803+
}
798804
}
799805
},
800806
},
801807
this.options.abortController.signal,
802808
// A consumer `break` cancels the stream; unsubscribe so the shape stream stops polling
809+
() => this.unsubscribe(),
803810
() => this.unsubscribe()
804811
);
805812
}
@@ -822,6 +829,8 @@ export class RunSubscription<TRunTypes extends AnyRunTypes> {
822829
withStreams<TStreams extends Record<string, any>>(): AsyncIterableStream<
823830
RunWithStreamsResult<RunShape<TRunTypes>, TStreams>
824831
> {
832+
this._withStreams = true;
833+
825834
// Keep track of which streams we've already subscribed to
826835
const activeStreams = new Set<string>();
827836

‎packages/core/src/v3/streams/asyncIterableStream.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ export function createAsyncIterableReadable<S, T>(
2323
source: ReadableStream<S>,
2424
transformer: Transformer<S, T>,
2525
signal: AbortSignal,
26-
onCancel?: () => void
26+
onCancel?: () => void,
27+
onClose?: () => void
2728
): AsyncIterableStream<T> {
2829
// Set once the stream is closed, errored or cancelled; the controller must not be touched after
2930
let closed = false;
@@ -70,6 +71,7 @@ export function createAsyncIterableReadable<S, T>(
7071
}
7172
} finally {
7273
signal.removeEventListener("abort", onAbort);
74+
onClose?.();
7375
}
7476
},
7577
cancel(reason) {

0 commit comments

Comments
 (0)