fix(rectification): keep adopt narration timeout on a clearable ref timer
Independent Staging Quality Gate / validate (push) Successful in 9m16s
Independent Staging Quality Gate / publish (push) Successful in 1m52s

AbortSignal.timeout() is unref'd, so a hanging generateText drained the test event loop and cancelled the rest of the file. Clear the timer after the model returns.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-04 15:23:13 +08:00
parent 5c9e98790b
commit 45bdb63eca
6 changed files with 226 additions and 14 deletions
@@ -33,24 +33,63 @@ export type AdoptNarrationDelivery = Readonly<{
adopt_narration: AdoptNarrationOutcome;
}>;
type DisposableAbort = Readonly<{
signal: AbortSignal;
dispose: () => void;
}>;
function composedAbortSignal(
signal: AbortSignal | undefined,
timeoutMs: number,
): AbortSignal {
const timeout = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
): DisposableAbort {
const controller = new AbortController();
// Must stay ref'd. The platform timeout signal uses an unref timer, so a hanging
// generateText lets the event loop drain before abort (BUG-523).
const timeoutId = globalThis.setTimeout(() => {
if (!controller.signal.aborted) {
controller.abort(new DOMException("adopt narration timed out", "TimeoutError"));
}
}, timeoutMs);
const onExternalAbort = () => {
if (!controller.signal.aborted) {
controller.abort(signal?.reason ?? new DOMException("aborted", "AbortError"));
}
};
if (signal) {
if (signal.aborted) onExternalAbort();
else signal.addEventListener("abort", onExternalAbort);
}
return {
signal: controller.signal,
dispose: () => {
globalThis.clearTimeout(timeoutId);
signal?.removeEventListener("abort", onExternalAbort);
},
};
}
function whenAborted(signal: AbortSignal): Promise<never> {
return new Promise((_, reject) => {
if (signal.aborted) {
function whenAborted(signal: AbortSignal): {
promise: Promise<never>;
dispose: () => void;
} {
let onAbort: (() => void) | undefined;
const promise = new Promise<never>((_, reject) => {
const fail = () => {
reject(signal.reason ?? new Error("aborted"));
};
if (signal.aborted) {
fail();
return;
}
signal.addEventListener("abort", () => {
reject(signal.reason ?? new Error("aborted"));
}, { once: true });
onAbort = fail;
signal.addEventListener("abort", fail, { once: true });
});
return {
promise,
dispose: () => {
if (onAbort) signal.removeEventListener("abort", onAbort);
},
};
}
export function logAdoptNarrationOutcome(outcome: AdoptNarrationOutcome): void {
@@ -117,14 +156,15 @@ export async function deliverAdoptNarration(input: {
logAdoptNarrationOutcome(delivery.adopt_narration);
return delivery;
}
const signal = composedAbortSignal(
const composed = composedAbortSignal(
input.signal,
input.timeoutMs ?? ADOPT_NARRATION_TIMEOUT_MS,
);
const aborted = whenAborted(composed.signal);
try {
const text = await Promise.race([
generate(input.facts, signal),
whenAborted(signal),
generate(input.facts, composed.signal),
aborted.promise,
]);
const checked = validateAdoptNarration(text, input.facts);
if (!checked.ok) {
@@ -145,6 +185,9 @@ export async function deliverAdoptNarration(input: {
const delivery = { text: input.fallback, adopt_narration: "template:model_error" as const };
logAdoptNarrationOutcome(delivery.adopt_narration);
return delivery;
} finally {
aborted.dispose();
composed.dispose();
}
}