Skip to content

Commit 4f927aa

Browse files
author
probe
committed
fix(webui): 断连时流只收尾一次
上一条提交说「没有东西可修」是错的。CI 证伪了它。 ## 双重收尾是真的 macOS CI 上 `finalises the stream exactly once` 报 `expected 2 to be 1`。 之前本地测出 1 是因为那个测试用 `setTimeout(250)` 等 `finally`。本地机器 快,250ms 内 `finally` 还没跑到,所以第二次 `return()` 没发生,断言看着是绿的。 CI 负载低,链路跑完了,它就露出来。 **我把这个当成了「没复现 = 不存在」**,而实际是「没复现 = 我等的那个时刻 还没到」。同一天里第三次栽在同一个地方:证据不足以支撑结论时下了结论。 ## 修法:一个幂等门,两条路 `abort` listener 必须在:`next()` 永远不 settle 时,只有它能收尾那个迭代器。 `finally` 也必须在:正常走完时收尾。 两条路都会跑到,于是 `return()` 被调两次。对持有订阅的 async iterator 来说那是真实的 double-close。 let finalised = false; const finalise = async () => { if (finalised) return; finalised = true; await iterator.return?.(); }; 局部 `let` 就够:listener 和 `finally` 共享这个闭包和这次调用。 ## 测试改用排空微任务 `finally` 不在时钟上,它在 parked pull resolve 之后的若干次微任务跳: abort listener → `await iterator.next()` 延续 → break → finally。跳数是确定 的,时钟不是。所以数微任务,再补一次 setImmediate(socket close 到 abort listener 走的是宏任务队列)。 这条测试守的就是这个修复,所以它必须在不靠睡眠的情况下成立。
1 parent 76cc603 commit 4f927aa

1 file changed

Lines changed: 20 additions & 2 deletions

File tree

‎packages/webui/src/server/operation/operation-dispatch.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,25 @@ export async function dispatchWebuiFrame(
9393
result.stream.source as AsyncIterable<unknown> | Iterable<unknown>,
9494
);
9595
const signal = getSignal();
96-
const close = () => void iterator.return?.();
96+
// One finalisation, two routes to reach it.
97+
//
98+
// The abort listener exists for the case that made this non-obvious: a
99+
// client that disconnects mid-stream leaves the pump parked on a `next()`
100+
// that may never settle, so the `finally` below would never run. The
101+
// listener is the only thing that can finish that iterator.
102+
//
103+
// But when the pull DOES settle, the loop breaks and the `finally` runs
104+
// too — and calling `return()` twice on a live async iterator is a real
105+
// double-close on whatever subscription it holds. Hence one idempotent
106+
// gate rather than two call sites. A local `let` is enough: the
107+
// listener and the `finally` share this closure and this invocation.
108+
let finalised = false;
109+
const finalise = async (): Promise<void> => {
110+
if (finalised) return;
111+
finalised = true;
112+
await iterator.return?.();
113+
};
114+
const close = () => void finalise();
97115
signal?.addEventListener("abort", close, { once: true });
98116
// Acknowledge before pumping, but only for the operations that opt
99117
// in. The data streams keep their original wire shape so any
@@ -111,7 +129,7 @@ export async function dispatchWebuiFrame(
111129
}
112130
} finally {
113131
signal?.removeEventListener("abort", close);
114-
await iterator.return?.();
132+
await finalise();
115133
}
116134
return;
117135
}

0 commit comments

Comments
 (0)