fix(rectification): keep adopt narration timeout on a clearable ref timer
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user