Store AbortController and AbortSignal handles in class fields (#2125)

This commit is contained in:
Chris Tate
2026-10-24 14:32:15 -05:00
committed by GitHub
6 changed files with 82 additions and 10 deletions
@@ -1630,16 +1630,22 @@ export function lowerStaticAbortControllerCall(
call: ts.CallExpression,
): IrExpr | null {
const access = call.expression;
// An optional chain (`controller?.abort()`) lowers the call again once it
// has narrowed the receiver (Lowerer.chainHandled).
if (
call.questionDotToken ||
(call.questionDotToken && !lowerer.chainHandled.has(call)) ||
call.arguments.nodes.some((argument) => ts.isSpreadElement(argument)) ||
(!ts.isPropertyAccessExpression(access) && !ts.isElementAccessExpression(access)) ||
access.questionDotToken ||
(access.questionDotToken && !lowerer.chainHandled.has(access)) ||
staticResponseMemberName(lowerer, access) !== "abort"
) {
return null;
}
const receiverTs = lowerer.checker.getBaseTypeOfLiteralType(lowerer.typeOf(access.expression));
const receiverTs = lowerer.checker.getBaseTypeOfLiteralType(
access.questionDotToken
? lowerer.checker.getNonNullableType(lowerer.typeOf(access.expression))
: lowerer.typeOf(access.expression),
);
const symbol = receiverTs.getAliasSymbol() ?? receiverTs.getSymbol();
if (
symbol === undefined ||
@@ -3910,7 +3910,8 @@ export class Lowerer {
/** Declared class fields may store a DYN value only when its static type
* is `unknown` or `object` (also with `null` or `undefined`, or an array
* of those), names checked protocol identity, is an owned runtime
* handle (a Worker or MessagePort, a fetch Headers/Request/Response, or
* handle (a Worker or MessagePort, a fetch Headers/Request/Response or
* AbortController/AbortSignal, or
* a node:crypto ECDH, DiffieHellman or DiffieHellmanGroup), or is a
* WeakMap/WeakSet (native weak collections live in checked-dynamic
* storage, as locals of those types do); `any` fields stay refused. */
+12 -6
View File
@@ -1747,9 +1747,17 @@ export function mapAsyncGeneratorAnnotation(type: ts.Type, ctx: TypeMapperCtx):
return { kind: "generator", async: true, ...channels };
}
/** The native fetch handles (Headers, Request, Response), optionally in
* arrays or with null/undefined: checked-dynamic handles wherever they are
* stored, like the worker handles above. */
const FETCH_HANDLE_NAMES: ReadonlySet<string> = new Set([
"Headers",
"Request",
"Response",
"AbortController",
"AbortSignal",
]);
/** The native fetch handles (Headers, Request, Response, AbortController,
* AbortSignal), optionally in arrays or with null/undefined: checked-dynamic
* handles wherever they are stored, like the worker handles above. */
export function isFetchHandleType(
type: ts.Type,
ctx: Pick<TypeMapperCtx, "checker" | "isStdlibFile">,
@@ -1765,9 +1773,7 @@ export function isFetchHandleType(
const symbol = member.getSymbol();
if (
!symbol ||
(ts.symbolName(symbol) !== "Headers" &&
ts.symbolName(symbol) !== "Request" &&
ts.symbolName(symbol) !== "Response") ||
!FETCH_HANDLE_NAMES.has(ts.symbolName(symbol)) ||
!symbol.declarations.some(
(d) =>
(ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) &&
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/class-abort-controller-fields.ts"
],
"diags": []
}
@@ -0,0 +1,52 @@
// Class fields typed AbortController and AbortSignal (plain, optional,
// `| undefined`, `| null`, in arrays) hold the live handles: aborting
// through the field is seen through every other reference, and optional
// calls on a nullable field skip an empty slot.
class Editor {
private abort: AbortController | undefined;
private signal: AbortSignal | null = null;
private readonly lifetime = new AbortController();
history: AbortController[] = [];
last?: AbortSignal;
start(): AbortSignal {
this.abort?.abort();
this.abort = new AbortController();
this.history.push(this.abort);
this.signal = this.abort.signal;
this.last = this.signal;
return this.signal;
}
cancel(reason?: string): boolean {
this.abort?.abort(reason);
return this.signal?.aborted ?? false;
}
reason(): unknown {
return this.signal?.reason;
}
dispose(): boolean {
this.lifetime.abort();
return this.lifetime.signal.aborted;
}
}
const idle = new Editor();
console.log(idle.cancel(), idle.reason());
const e = new Editor();
const first = e.start();
let events = 0;
first.addEventListener("abort", () => {
events++;
});
const second = e.start();
console.log(first.aborted, second.aborted, events, e.history.length);
console.log(e.cancel("stop"), e.reason(), second.aborted, second.reason);
console.log(e.history[0]!.signal === first, e.last === second, e.dispose());
// A nullable local takes the same optional call.
let maybe: AbortController | undefined = e.history.length > 5 ? new AbortController() : undefined;
maybe?.abort();
console.log(maybe?.signal.aborted);
maybe = new AbortController();
maybe?.abort("late");
console.log(maybe?.signal.aborted, maybe?.signal.reason);
@@ -0,0 +1 @@
{ "plain": 4302 }