fix: ignore late stream results after cancellation

This commit is contained in:
Jesse_Chen
2026-07-21 13:19:53 +08:00
parent 2eb978064c
commit c12d36e804
2 changed files with 126 additions and 1 deletions
+6 -1
View File
@@ -157,6 +157,7 @@ export function streamTextResponse(
: null;
let pending = "";
let settled = false;
let cancellationStarted = false;
let emitted = false;
let firstOutputSettlementStarted = false;
@@ -180,7 +181,9 @@ export function streamTextResponse(
async pull(controller) {
try {
while (true) {
const { done, value } = await iterator.next();
const next = await iterator.next();
if (settled) return;
const { done, value } = next;
if (done) {
const finalText = visibleTransformer
? visibleTransformer.finish(pending)
@@ -215,6 +218,7 @@ export function streamTextResponse(
}
}
} catch (error) {
if (cancellationStarted) return;
if (!settled) {
settled = true;
await options.onError?.(error, emitted);
@@ -225,6 +229,7 @@ export function streamTextResponse(
async cancel() {
if (settled) return;
settled = true;
cancellationStarted = true;
try {
await iterator.return?.();
} finally {
+120
View File
@@ -131,6 +131,61 @@ test("an asynchronous first-output rejection preserves already committed bytes",
assert.equal(observedEmitted, true);
});
test("a first-output rejection after cancellation does not re-error or settle twice", async (context) => {
const originalControllerError = ReadableStreamDefaultController.prototype.error;
let controllerErrorCalls = 0;
context.mock.method(
ReadableStreamDefaultController.prototype,
"error",
function (this: ReadableStreamDefaultController<unknown>, reason?: unknown) {
controllerErrorCalls += 1;
return originalControllerError.call(this, reason);
},
);
let rejectSettlement = () => {};
const settlement = new Promise<void>((_resolve, reject) => {
rejectSettlement = () => reject(new Error("late_settlement_failure"));
});
let markSettlementStarted = () => {};
const settlementStarted = new Promise<void>((resolve) => {
markSettlementStarted = resolve;
});
async function* reply() {
yield "已经提交给响应的正文。".repeat(120);
yield "不应继续读取";
}
const cancellations: boolean[] = [];
let errorCalls = 0;
let completeCalls = 0;
const response = streamTextResponse(reply(), {
mode: "mastra",
requestId: "00000000-0000-4000-8000-000000000103",
transformText: (text) => text,
onFirstOutput: () => {
markSettlementStarted();
return settlement;
},
onCancel: async (emitted) => { cancellations.push(emitted); },
onError: async () => { errorCalls += 1; },
onComplete: async () => { completeCalls += 1; },
});
const reader = response.body?.getReader();
assert.ok(reader);
const pendingRead = reader.read();
await settlementStarted;
await reader.cancel();
const first = await pendingRead;
rejectSettlement();
await new Promise<void>((resolve) => queueMicrotask(resolve));
await new Promise<void>((resolve) => queueMicrotask(resolve));
assert.equal(first.done, false);
assert.deepEqual(cancellations, [true]);
assert.equal(errorCalls, 0);
assert.equal(completeCalls, 0);
assert.equal(controllerErrorCalls, 0);
});
test("charges a consultation when cancellation happens after partial output", async () => {
// Given
let completed = 0;
@@ -328,6 +383,71 @@ test("cancelling while transformed short output is buffered reports no emitted o
assert.equal(observedEmitted, false);
});
test("a late iterator result after cancellation cannot start output settlement", async () => {
let markNextStarted = () => {};
const nextStarted = new Promise<void>((resolve) => {
markNextStarted = resolve;
});
let resolveNext: (result: IteratorResult<string>) => void = () => {};
const pendingNext = new Promise<IteratorResult<string>>((resolve) => {
resolveNext = resolve;
});
let markReturnStarted = () => {};
const returnStarted = new Promise<void>((resolve) => {
markReturnStarted = resolve;
});
let resolveReturn = () => {};
const pendingReturn = new Promise<IteratorResult<string>>((resolve) => {
resolveReturn = () => resolve({ done: true, value: undefined });
});
const reply: AsyncIterable<string> = {
[Symbol.asyncIterator]() {
return {
next() {
markNextStarted();
return pendingNext;
},
return() {
markReturnStarted();
return pendingReturn;
},
};
},
};
let firstOutputCalls = 0;
let completeCalls = 0;
let errorCalls = 0;
const cancellations: boolean[] = [];
const response = streamTextResponse(reply, {
mode: "mastra",
requestId: "00000000-0000-4000-8000-000000000009",
transformText: (text) => text,
onFirstOutput: async () => { firstOutputCalls += 1; },
onComplete: async () => { completeCalls += 1; },
onError: async () => { errorCalls += 1; },
onCancel: async (emitted) => { cancellations.push(emitted); },
});
const reader = response.body?.getReader();
assert.ok(reader);
const pendingRead = reader.read();
await nextStarted;
const cancellation = reader.cancel();
await returnStarted;
resolveNext({ done: false, value: "取消后才到达的可见正文。".repeat(120) });
await new Promise<void>((resolve) => queueMicrotask(resolve));
resolveReturn();
await cancellation;
const read = await pendingRead;
await new Promise<void>((resolve) => queueMicrotask(resolve));
assert.equal(read.done, true);
assert.deepEqual(cancellations, [false]);
assert.equal(firstOutputCalls, 0);
assert.equal(completeCalls, 0);
assert.equal(errorCalls, 0);
});
test("cancelling a transformed stream after visible output preserves emitted settlement", async () => {
let charged = 0;
let refunded = 0;