Skip to content
Open
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
21 changes: 17 additions & 4 deletions packages/agent-core-v2/src/_base/utils/abort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,28 @@ export function isUserCancellation(value: unknown): value is UserCancellationErr
}

export function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) return Promise.reject(abortReason(signal));
return new Promise<T>((resolve, reject) => {
const onAbort = () => {
reject(abortReason(signal));
};
signal.addEventListener('abort', onAbort, { once: true });
promise.then(resolve, reject).finally(() => {
const cleanup = () => {
signal.removeEventListener('abort', onAbort);
});
};
promise.then(
(value) => {
cleanup();
resolve(value);
},
(error: unknown) => {
cleanup();
reject(error);
},
);
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
});
}

Expand Down
33 changes: 33 additions & 0 deletions packages/agent-core-v2/test/_base/utils/abort.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/**
* Covers abort classification and promise ownership for abort-signal helpers.
*
* Uses real AbortController and Node rejection events. Run with:
* pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run test/_base/utils/abort.test.ts
*/

import { describe, expect, it } from 'vitest';

import {
Expand Down Expand Up @@ -40,6 +47,32 @@ describe('abortable', () => {
await expect(abortable(Promise.resolve('ok'), controller.signal)).rejects.toBe(reason);
});

it('observes the source rejection when the signal is already aborted', async () => {
const controller = new AbortController();
const reason = userCancellationReason();
controller.abort(reason);
const sourceError = new Error('source failed');
let rejectSource: (reason: unknown) => void = () => {};
const source = new Promise<never>((_resolve, reject) => {
rejectSource = reject;
});
let sourceWasUnhandled = false;
const onUnhandledRejection = (_reason: unknown, promise: Promise<unknown>): void => {
if (promise === source) sourceWasUnhandled = true;
};
process.on('unhandledRejection', onUnhandledRejection);

try {
await expect(abortable(source, controller.signal)).rejects.toBe(reason);
rejectSource(sourceError);
await new Promise<void>((resolve) => setImmediate(resolve));
} finally {
process.off('unhandledRejection', onUnhandledRejection);
}

expect(sourceWasUnhandled).toBe(false);
});

it('rejects with the signal reason when aborted while pending', async () => {
const controller = new AbortController();
const reason = userCancellationReason();
Expand Down