Skip to content

Commit 7abd6b5

Browse files
fix(reconciler): finalize progress and surface cancel failures (#204)
Classify setup and stream interruption as aborts while preserving handoff after normal completion, and propagate standalone Flight reader cancellation failures before complete.
1 parent 6b74fc6 commit 7abd6b5

3 files changed

Lines changed: 111 additions & 12 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@agent-bundle/runtime": patch
3+
---
4+
5+
Finalize progress reporters on setup and stream interruption with an abort
6+
outcome, and surface Flight reader cancellation failures that do not mask an
7+
earlier stream failure.

‎packages/rsc-runtime/src/reconciler.ts‎

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Duration, Effect, Option, Queue, Stream, type Scope } from 'effect';
1+
import { Deferred, Duration, Effect, Exit, Option, Queue, Stream, type Scope } from 'effect';
22
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
33
import { createFromReadableStream } from 'react-server-dom-rspack/client.node';
44

@@ -18,6 +18,7 @@ import { decodeAgentDocument } from './decode-document.js';
1818
import {
1919
abortToInterrupt,
2020
isAbortError,
21+
mapCause,
2122
runPromise,
2223
scopedAbortSignal,
2324
streamToReadableStream,
@@ -353,6 +354,7 @@ const reconcileLoopStream = (
353354
ids: Map<string, string>,
354355
initial: TreeSnapshot,
355356
limits: Partial<AgentRenderLimits> | undefined,
357+
flightDone: Deferred.Deferred<void, Error>,
356358
progressInputs: Queue.Queue<AgentRenderEventInput>,
357359
sequence: AgentRenderEventSequence,
358360
): Stream.Stream<AgentRenderEventInput, Error> =>
@@ -363,7 +365,8 @@ const reconcileLoopStream = (
363365
Error
364366
> => {
365367
if (snapshot.pending.length === 0) {
366-
return Queue.clear(progressInputs).pipe(
368+
return Deferred.await(flightDone).pipe(
369+
Effect.andThen(Queue.clear(progressInputs)),
367370
Effect.flatMap((queued) =>
368371
Effect.try({
369372
catch: (error) => toRuntimeError(error),
@@ -412,14 +415,28 @@ const reconcileLoopStream = (
412415
const gatedFlightStream = (
413416
flight: ReadableStream<Uint8Array>,
414417
demand: FlightDemand,
418+
flightDone: Deferred.Deferred<void, Error>,
415419
): Stream.Stream<Uint8Array, Error> =>
416420
Stream.unwrap(
417421
Effect.gen(function*() {
418422
const reader = yield* Effect.acquireRelease(
419423
Effect.sync(() => flight.getReader()),
420-
// cancel() rejects when the source already errored; a defect inside
421-
// this closing scope must not replace the stream's own failure.
422-
(handle) => Effect.promise(() => handle.cancel().then(() => undefined, () => undefined)),
424+
(handle, exit) => Effect.gen(function*() {
425+
const cancelExit = yield* Effect.exit(Effect.tryPromise({
426+
catch: (error) => toRuntimeError(error),
427+
try: () => handle.cancel(),
428+
}));
429+
if (Exit.isFailure(exit)) {
430+
yield* Deferred.fail(flightDone, mapCause(exit.cause));
431+
return;
432+
}
433+
if (Exit.isFailure(cancelExit)) {
434+
const error = mapCause(cancelExit.cause);
435+
yield* Deferred.fail(flightDone, error);
436+
return yield* Effect.die(error);
437+
}
438+
yield* Deferred.succeed(flightDone, undefined);
439+
}),
423440
);
424441
return Stream.unfold(undefined, () =>
425442
demand.wait.pipe(
@@ -439,6 +456,7 @@ const decodeFlightRoot = (
439456
flight: ReadableStream<Uint8Array>,
440457
demand: FlightDemand,
441458
signal: AbortSignal,
459+
flightDone: Deferred.Deferred<void, Error>,
442460
): Effect.Effect<ReactNode, Error, Scope.Scope> =>
443461
Effect.gen(function*() {
444462
ensureAgentFlightManifest();
@@ -449,7 +467,7 @@ const decodeFlightRoot = (
449467
// (the maxEvents hang). The scoped signal interrupts the source stream
450468
// without touching the locked ReadableStream.
451469
const flightAbort = yield* scopedAbortSignal;
452-
const readable = streamToReadableStream(gatedFlightStream(flight, demand), {
470+
const readable = streamToReadableStream(gatedFlightStream(flight, demand, flightDone), {
453471
signal: flightAbort,
454472
strategy: { highWaterMark: 1 },
455473
});
@@ -521,6 +539,7 @@ export const createAgentRenderEventSession = (
521539
const events = Stream.unwrap(
522540
Effect.gen(function*() {
523541
const progressInputs = yield* Queue.bounded<AgentRenderEventInput>(0);
542+
const flightDone = yield* Deferred.make<void, Error>();
524543
const bindProgress = (): void => {
525544
offerProgress = (input) =>
526545
Queue.offer(progressInputs, input).pipe(
@@ -541,7 +560,7 @@ export const createAgentRenderEventSession = (
541560
try: () => options.flight,
542561
});
543562
if (options.signal.aborted) return yield* Effect.fail(abortError());
544-
const root = yield* decodeFlightRoot(flight, options.demand, options.signal);
563+
const root = yield* decodeFlightRoot(flight, options.demand, options.signal, flightDone);
545564
if (options.signal.aborted) return yield* Effect.fail(abortError());
546565
const prepared = yield* Effect.try({
547566
catch: (error) => toRuntimeError(error),
@@ -566,21 +585,24 @@ export const createAgentRenderEventSession = (
566585
prepared.ids,
567586
prepared.initial,
568587
options.limits,
588+
flightDone,
569589
progressInputs,
570590
sequence,
571591
),
572592
).pipe(
573593
Stream.mapEffect((input) => emitBoundRenderEvent(sequence, input)),
574594
Stream.tap((event) => (event.type === 'shell' ? options.demand.markShell : Effect.void)),
575-
Stream.tapError((error) => Effect.sync(() => {
576-
progressFailure = error;
577-
})),
578595
Stream.takeUntil((event) => event.type === 'complete'),
579-
Stream.ensuring(Effect.suspend(() => finalizeProgress(progressFailure ?? handoffRequired()))),
596+
Stream.onExit((exit) => {
597+
if (Exit.isSuccess(exit)) return finalizeProgress(handoffRequired());
598+
const error = mapCause(exit.cause);
599+
return finalizeProgress(sequence.completed && isAbortError(error) ? handoffRequired() : error);
600+
}),
580601
);
581602
});
582603
return yield* setup.pipe(
583-
Effect.catch((error) => finalizeProgress(error).pipe(Effect.andThen(Effect.fail(error)))),
604+
Effect.onExit((exit) =>
605+
Exit.isFailure(exit) ? finalizeProgress(mapCause(exit.cause)) : Effect.void),
584606
);
585607
}),
586608
);

‎packages/rsc-runtime/tests/dispatcher.test.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,50 @@ describe('AgentRenderDispatcher streaming', () => {
402402
await expect(reader.read()).rejects.toMatchObject({ name: 'AbortError' });
403403
});
404404

405+
it('finalizes progress with an abort when Flight setup is interrupted', async () => {
406+
let progress: AgentProgressReporter | undefined;
407+
const host: AgentFlightExecutionHost = {
408+
execute: (request) => {
409+
progress = request.progress;
410+
return new Promise<ReadableStream<Uint8Array>>(() => undefined);
411+
},
412+
};
413+
const dispatcher = createAgentRenderDispatcher(host);
414+
const controller = new AbortController();
415+
const reader = dispatcher.stream({ invocation, signal: controller.signal }).getReader();
416+
const pending = reader.read();
417+
controller.abort();
418+
419+
await expect(pending).rejects.toMatchObject({ name: 'AbortError' });
420+
if (progress === undefined) throw new Error('expected the dispatcher to install a progress reporter');
421+
await expect(progress.report({ completed: 1, message: 'after-abort' })).rejects.toMatchObject({
422+
name: 'AbortError',
423+
});
424+
});
425+
426+
it('finalizes progress with an abort when the event stream is interrupted', { retry: 2 }, async () => {
427+
let progress: AgentProgressReporter | undefined;
428+
const inner = createWorkerHost('single');
429+
const host: AgentFlightExecutionHost = {
430+
execute: async (request) => {
431+
progress = request.progress;
432+
return inner.execute(request);
433+
},
434+
};
435+
const dispatcher = createAgentRenderDispatcher(host);
436+
const controller = new AbortController();
437+
const reader = dispatcher.stream({ invocation, signal: controller.signal }).getReader();
438+
const shell = await reader.read();
439+
if (shell.value?.type !== 'shell') throw new Error('expected a shell event');
440+
controller.abort();
441+
442+
await expect(reader.read()).rejects.toMatchObject({ name: 'AbortError' });
443+
if (progress === undefined) throw new Error('expected the dispatcher to install a progress reporter');
444+
await expect(progress.report({ completed: 1, message: 'after-abort' })).rejects.toMatchObject({
445+
name: 'AbortError',
446+
});
447+
});
448+
405449
it('rejects a post-completion progress producer with a typed handoff', { retry: 2 }, async () => {
406450
let progress: AgentProgressReporter | undefined;
407451
const inner = createWorkerHost('ready');
@@ -588,4 +632,30 @@ describe('AgentRenderDispatcher streaming', () => {
588632
if (progress === undefined) throw new Error('expected the dispatcher to install a progress reporter');
589633
await expect(progress.report({ completed: 1, message: 'after-fail' })).rejects.toThrow('flight setup failed');
590634
});
635+
636+
it('surfaces a Flight reader cancel rejection without a prior stream failure', { retry: 2 }, async () => {
637+
const inner = createWorkerHost('ready');
638+
let cancelCalls = 0;
639+
const host: AgentFlightExecutionHost = {
640+
execute: async (request) => {
641+
const flight = await inner.execute(request);
642+
const reader = flight.getReader();
643+
return {
644+
getReader: () => ({
645+
cancel: async () => {
646+
cancelCalls += 1;
647+
throw new Error('flight cancel failed');
648+
},
649+
read: () => reader.read(),
650+
}),
651+
} as ReadableStream<Uint8Array>;
652+
},
653+
};
654+
const dispatcher = createAgentRenderDispatcher(host);
655+
656+
await expect(collectEvents(
657+
dispatcher.stream({ invocation, signal: new AbortController().signal }),
658+
)).rejects.toThrow('flight cancel failed');
659+
expect(cancelCalls).toBe(1);
660+
});
591661
});

0 commit comments

Comments
 (0)