Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion sdk/typescript/src/targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -924,7 +924,10 @@ export async function abortable<T>(
settle(value);
};
void Promise.resolve()
.then(operation)
.then(() => {
throwIfAborted(signal);
return operation();
})
.then(finish(resolvePromise), finish(reject));
});
}
Expand Down
97 changes: 97 additions & 0 deletions sdk/typescript/tests-ts/targets.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { execFileSync, spawnSync } from "node:child_process";
import { getEventListeners } from "node:events";
import { existsSync } from "node:fs";
import {
chmod,
Expand All @@ -24,6 +25,7 @@ import {
type ScanTarget,
} from "../src/index.js";
import {
abortable,
enclosingGitWorktreeRoot,
gitMarkerRoot,
isGitMetadataDirectory,
Expand All @@ -44,6 +46,101 @@ const { temporaryDirectory, cleanup, temporaryDirectories } =

afterEach(cleanup);

describe("abortable", () => {
test.each([
{ label: "error", reason: new Error("Canceled before execution") },
{ label: "object", reason: { message: "Canceled before execution" } },
{ label: "string", reason: "Canceled before execution" },
{ label: "zero", reason: 0 },
{ label: "false", reason: false },
{ label: "null", reason: null },
{ label: "default", reason: undefined },
])(
"does not start canceled work with a $label reason",
async ({ reason }) => {
for (const beforeAdmission of [true, false]) {
const controller = new AbortController();
let calls = 0;
if (beforeAdmission) controller.abort(reason);
const pending = abortable(async () => {
calls++;
return "unused";
}, controller.signal);
if (!beforeAdmission) controller.abort(reason);
if (reason === null)
await expect(pending).rejects.toMatchObject({
name: "AbortError",
message: "The operation was aborted.",
});
else await expect(pending).rejects.toBe(controller.signal.reason);
expect(calls).toBe(0);
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0);
}
},
);

test.each([false, true])(
"preserves operation ordering with a signal: %p",
async (withSignal) => {
const controller = new AbortController();
const order: string[] = [];
const result = { completed: true };
const pending = abortable(
async () => {
order.push("operation");
return result;
},
withSignal ? controller.signal : undefined,
);
order.push("returned");
expect(await pending).toBe(result);
expect(order).toEqual(
withSignal ? ["returned", "operation"] : ["operation", "returned"],
);
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0);
controller.abort(new Error("Canceled after completion"));
expect(await pending).toBe(result);
},
);

test("cancels started work without awaiting its late failure", async () => {
const controller = new AbortController();
const started = Promise.withResolvers<void>();
const operation = Promise.withResolvers<void>();
const pending = abortable(() => {
started.resolve();
return operation.promise;
}, controller.signal);
await started.promise;
const reason = new Error("Canceled while running");
controller.abort(reason);
await expect(pending).rejects.toBe(reason);
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0);
operation.reject(new Error("Late operation failure"));
await expect(pending).rejects.toBe(reason);
});

test.each(["resolve", "reject"])(
"preserves cancellation before a queued %s is observed",
async (settlement) => {
const controller = new AbortController();
const started = Promise.withResolvers<void>();
const operation = Promise.withResolvers<number>();
const pending = abortable(() => {
started.resolve();
return operation.promise;
}, controller.signal);
await started.promise;
if (settlement === "resolve") operation.resolve(7);
else operation.reject(new Error("Queued operation failure"));
const reason = new Error("Canceled before settlement");
controller.abort(reason);
await expect(pending).rejects.toBe(reason);
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0);
},
);
});

async function repository(name = "repo"): Promise<string> {
const root = await temporaryDirectory();
const repo = join(root, name);
Expand Down
Loading