From 260de6d10372e5cd0893179c402820e1551eea65 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Sat, 9 May 2026 16:15:17 +0200 Subject: [PATCH 1/4] feat: Make sleep cancellable --- lib/asyncbox.ts | 99 +++++++++++++++++++++++++++++++++++++--- lib/types.ts | 29 ++++++++++++ test/asyncbox-specs.ts | 100 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 5 deletions(-) diff --git a/lib/asyncbox.ts b/lib/asyncbox.ts index 806dfca..17ec35a 100644 --- a/lib/asyncbox.ts +++ b/lib/asyncbox.ts @@ -1,5 +1,12 @@ import {limitFunction} from 'p-limit'; -import type {LongSleepOptions, MapFilterOptions, WaitForConditionOptions} from './types.js'; +import type { + CancellablePromise, + LongSleepOptions, + MapFilterOptions, + SleepArg, + SleepOptions, + WaitForConditionOptions, +} from './types.js'; const LONG_SLEEP_THRESHOLD = 5000; // anything over 5000ms will turn into a spin @@ -11,12 +18,66 @@ export class TimeoutError extends Error { } } +/** Thrown when a promise is cancelled via `cancel`. */ +export class PromiseCancellation extends Error { + constructor(message: string = 'Promise cancelled') { + super(message); + this.name = 'PromiseCancellation'; + } +} + /** - * An async/await version of setTimeout - * @param ms - The number of milliseconds to wait + * An async/await version of `setTimeout`. The returned promise has a `cancel()` method that clears + * the timer and usually rejects with {@link PromiseCancellation} unless you use the object form with + * `cancelError: null` to resolve on cancel. + * + * @param ms - Milliseconds to wait + * @returns A thenable you can `await`; call `.cancel()` to abort early. + */ +export function sleep(ms: number): CancellablePromise; +/** + * Object form: `ms` plus optional `cancelError` (string, `Error`, `null` to resolve on cancel, or + * omitted / empty string for default {@link PromiseCancellation}). + * + * @param options - Duration and optional cancellation behavior + * @returns A thenable you can `await`; call `.cancel()` to abort early. */ -export async function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); +export function sleep(options: SleepOptions): CancellablePromise; +export function sleep(arg: SleepArg): CancellablePromise { + const {ms, cancelError} = parseSleepArg(arg); + let timeoutId: NodeJS.Timeout | undefined; + let resolveFn: ((value: void) => void) | undefined; + let rejectFn: ((error: Error) => void) | undefined; + + const promise = new Promise((resolve, reject) => { + resolveFn = resolve; + rejectFn = reject; + timeoutId = setTimeout(resolve, ms); + }) as CancellablePromise; + + promise.cancel = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + if (cancelError === null) { + resolveFn?.(); + } else { + let err: Error; + if (typeof cancelError === 'string' && cancelError) { + err = new PromiseCancellation(cancelError); + } else if (cancelError instanceof Error) { + err = cancelError; + } else { + err = new PromiseCancellation(); + } + rejectFn?.(err); + } + resolveFn = undefined; + rejectFn = undefined; + }; + + return promise; } /** @@ -270,8 +331,36 @@ export async function waitForCondition( // Re-export types export type { + CancellablePromise, Progress, ProgressCallback, LongSleepOptions, + SleepArg, + SleepOptions, WaitForConditionOptions, } from './types.js'; + +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +function parseSleepArg(arg: SleepArg): {ms: number; cancelError?: string | Error | null} { + if (typeof arg === 'number') { + if (!Number.isFinite(arg)) { + throw new TypeError('sleep: expected a finite number or a plain object with ms'); + } + return {ms: arg}; + } + if (isPlainObject(arg)) { + const ms = arg.ms; + if (typeof ms !== 'number' || !Number.isFinite(ms)) { + throw new TypeError('sleep: options.ms must be a finite number'); + } + return {ms, cancelError: arg.cancelError as string | Error | null | undefined}; + } + throw new TypeError('sleep: expected a finite number or a plain object with ms'); +} diff --git a/lib/types.ts b/lib/types.ts index 40ec4ca..6f0b052 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -36,6 +36,35 @@ export interface LongSleepOptions { */ export type MapFilterOptions = boolean | {concurrency: number}; +/** + * Object form of {@link sleep}'s argument: duration plus optional cancellation rejection override. + */ +export interface SleepOptions { + /** Duration in milliseconds */ + ms: number; + /** + * When {@link sleep}'s `cancel` runs: a non-empty string becomes {@link PromiseCancellation} + * with that message; an `Error` rejects with that same instance; `null` resolves the promise + * instead of rejecting; omitted or other falsy values (except `null`) use the default + * {@link PromiseCancellation}. + */ + cancelError?: string | Error | null; +} + +/** + * Argument to {@link sleep}: either milliseconds or a plain options object (see {@link SleepOptions}). + */ +export type SleepArg = number | SleepOptions; + +/** + * A promise with a {@linkcode cancel} method (e.g. from {@link sleep}). + * + * @typeParam T - Resolved value type; {@link sleep} uses `void`. + */ +export type CancellablePromise = Promise & { + cancel: () => void; +}; + /** * Options for {@link waitForCondition} */ diff --git a/test/asyncbox-specs.ts b/test/asyncbox-specs.ts index c8856de..b9d06c0 100644 --- a/test/asyncbox-specs.ts +++ b/test/asyncbox-specs.ts @@ -10,6 +10,8 @@ import { waitForCondition, withTimeout, TimeoutError, + PromiseCancellation, + type SleepOptions, } from '../lib/asyncbox.js'; use(chaiAsPromised); @@ -20,6 +22,104 @@ describe('sleep', function () { await sleep(20); expect(Date.now() - now).to.be.at.least(19); }); + it('should expose cancel on the promise', function () { + const d = sleep(100); + expect(d.cancel).to.be.a('function'); + }); + it('should reject with PromiseCancellation when cancelled', async function () { + const d = sleep(10_000); + d.cancel(); + await expect(d).to.be.rejectedWith(PromiseCancellation); + }); + it('should reject immediately on cancel without waiting for ms', async function () { + const d = sleep(10_000); + const start = Date.now(); + d.cancel(); + await expect(d).to.be.rejectedWith(PromiseCancellation); + expect(Date.now() - start).to.be.below(100); + }); + it('should use default PromiseCancellation when cancelError is empty string', async function () { + const d = sleep({ms: 10_000, cancelError: ''}); + d.cancel(); + try { + await d; + expect.fail('expected rejection'); + } catch (err: unknown) { + expect(err).to.be.instanceOf(PromiseCancellation); + expect((err as PromiseCancellation).message).to.equal('Promise cancelled'); + } + }); + it('should reject with PromiseCancellation using cancelError string when cancelled', async function () { + const d = sleep({ms: 10_000, cancelError: 'aborted'}); + d.cancel(); + try { + await d; + expect.fail('expected rejection'); + } catch (err: unknown) { + expect(err).to.be.instanceOf(PromiseCancellation); + expect((err as PromiseCancellation).message).to.equal('aborted'); + } + }); + it('should reject with a provided Error instance on cancel', async function () { + class CustomCancel extends Error { + constructor(message?: string) { + super(message ?? 'custom default'); + this.name = 'CustomCancel'; + } + } + const customErr = new CustomCancel('nope'); + const d = sleep({ms: 10_000, cancelError: customErr}); + d.cancel(); + try { + await d; + expect.fail('expected rejection'); + } catch (err: unknown) { + expect(err).to.equal(customErr); + expect(err).to.be.instanceOf(CustomCancel); + expect((err as CustomCancel).message).to.equal('nope'); + } + }); + it('should resolve when cancelled if cancelError is null', async function () { + const d = sleep({ms: 10_000, cancelError: null}); + const start = Date.now(); + d.cancel(); + await d; + expect(Date.now() - start).to.be.below(100); + }); + it('should throw TypeError when ms is not finite', function () { + expect(() => sleep(Number.NaN)).to.throw(TypeError, /finite number or a plain object/); + expect(() => sleep(Number.POSITIVE_INFINITY)).to.throw( + TypeError, + /finite number or a plain object/, + ); + }); + it('should throw TypeError when arg is not a number or plain object', function () { + expect(() => sleep(null as unknown as number)).to.throw( + TypeError, + /finite number or a plain object/, + ); + expect(() => sleep([] as unknown as number)).to.throw( + TypeError, + /finite number or a plain object/, + ); + }); + it('should throw TypeError when plain object has invalid ms', function () { + expect(() => sleep({} as unknown as SleepOptions)).to.throw( + TypeError, + /options\.ms must be a finite number/, + ); + expect(() => sleep({ms: Number.NaN})).to.throw( + TypeError, + /options\.ms must be a finite number/, + ); + }); + it('should accept a null-prototype plain object with ms', async function () { + const o = Object.create(null) as {ms: number}; + o.ms = 20; + const now = Date.now(); + await sleep(o); + expect(Date.now() - now).to.be.at.least(19); + }); }); describe('withTimeout', function () { From fd53c122bc36238433bd756f1b82c2767d534789 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Sat, 9 May 2026 16:20:38 +0200 Subject: [PATCH 2/4] make it strict --- package.json | 2 ++ tsconfig.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index d39aaa3..08b72a0 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,8 @@ "@appium/tsconfig": "^1.0.0", "@semantic-release/changelog": "^6.0.1", "@semantic-release/git": "^10.0.1", + "@types/chai": "^5.2.3", + "@types/chai-as-promised": "^8.0.2", "@types/mocha": "^10.0.10", "@types/node": "^25.0.3", "chai": "^6.2.1", diff --git a/tsconfig.json b/tsconfig.json index 97b6ba6..c01e843 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "@appium/tsconfig/tsconfig.json", "compilerOptions": { - "strict": false, + "strict": true, "outDir": "build", "types": ["node", "mocha"], "checkJs": true From c9e1734dff73501fe83666f452cad490f3a07881 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Sat, 9 May 2026 16:56:23 +0200 Subject: [PATCH 3/4] address comments --- lib/asyncbox.ts | 30 ++++++++++++++---------------- lib/types.ts | 8 ++++---- test/asyncbox-specs.ts | 42 +++++++++++++++++++++++++----------------- 3 files changed, 43 insertions(+), 37 deletions(-) diff --git a/lib/asyncbox.ts b/lib/asyncbox.ts index 17ec35a..b734272 100644 --- a/lib/asyncbox.ts +++ b/lib/asyncbox.ts @@ -19,16 +19,16 @@ export class TimeoutError extends Error { } /** Thrown when a promise is cancelled via `cancel`. */ -export class PromiseCancellation extends Error { +export class PromiseCancellationError extends Error { constructor(message: string = 'Promise cancelled') { super(message); - this.name = 'PromiseCancellation'; + this.name = 'PromiseCancellationError'; } } /** * An async/await version of `setTimeout`. The returned promise has a `cancel()` method that clears - * the timer and usually rejects with {@link PromiseCancellation} unless you use the object form with + * the timer and usually rejects with {@link PromiseCancellationError} unless you use the object form with * `cancelError: null` to resolve on cancel. * * @param ms - Milliseconds to wait @@ -36,8 +36,9 @@ export class PromiseCancellation extends Error { */ export function sleep(ms: number): CancellablePromise; /** - * Object form: `ms` plus optional `cancelError` (string, `Error`, `null` to resolve on cancel, or - * omitted / empty string for default {@link PromiseCancellation}). + * Object form: `ms` plus optional `cancelError` (non-empty string or `Error`, `null` to resolve on + * cancel, or omitted / `undefined` / `''` for default {@link PromiseCancellationError}). Any non-array + * object with a finite `ms` is accepted at runtime (including class instances), matching structural typing. * * @param options - Duration and optional cancellation behavior * @returns A thenable you can `await`; call `.cancel()` to abort early. @@ -65,11 +66,11 @@ export function sleep(arg: SleepArg): CancellablePromise { } else { let err: Error; if (typeof cancelError === 'string' && cancelError) { - err = new PromiseCancellation(cancelError); + err = new PromiseCancellationError(cancelError); } else if (cancelError instanceof Error) { err = cancelError; } else { - err = new PromiseCancellation(); + err = new PromiseCancellationError(); } rejectFn?.(err); } @@ -340,27 +341,24 @@ export type { WaitForConditionOptions, } from './types.js'; -function isPlainObject(value: unknown): value is Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - return false; - } - const proto = Object.getPrototypeOf(value); - return proto === Object.prototype || proto === null; +/** Non-array object values (including class instances); excludes `null` and arrays. */ +function isSleepArgObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); } function parseSleepArg(arg: SleepArg): {ms: number; cancelError?: string | Error | null} { if (typeof arg === 'number') { if (!Number.isFinite(arg)) { - throw new TypeError('sleep: expected a finite number or a plain object with ms'); + throw new TypeError('sleep: expected a finite number or an object with ms'); } return {ms: arg}; } - if (isPlainObject(arg)) { + if (isSleepArgObject(arg)) { const ms = arg.ms; if (typeof ms !== 'number' || !Number.isFinite(ms)) { throw new TypeError('sleep: options.ms must be a finite number'); } return {ms, cancelError: arg.cancelError as string | Error | null | undefined}; } - throw new TypeError('sleep: expected a finite number or a plain object with ms'); + throw new TypeError('sleep: expected a finite number or an object with ms'); } diff --git a/lib/types.ts b/lib/types.ts index 6f0b052..b4c3527 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -38,21 +38,21 @@ export type MapFilterOptions = boolean | {concurrency: number}; /** * Object form of {@link sleep}'s argument: duration plus optional cancellation rejection override. + * Structural typing allows class instances; {@link sleep} accepts any non-array object with a finite `ms`. */ export interface SleepOptions { /** Duration in milliseconds */ ms: number; /** - * When {@link sleep}'s `cancel` runs: a non-empty string becomes {@link PromiseCancellation} + * When {@link sleep}'s `cancel` runs: a non-empty string becomes {@link PromiseCancellationError} * with that message; an `Error` rejects with that same instance; `null` resolves the promise - * instead of rejecting; omitted or other falsy values (except `null`) use the default - * {@link PromiseCancellation}. + * instead of rejecting; omitted, `undefined`, or `''` use the default {@link PromiseCancellationError}. */ cancelError?: string | Error | null; } /** - * Argument to {@link sleep}: either milliseconds or a plain options object (see {@link SleepOptions}). + * Argument to {@link sleep}: either milliseconds or an options object (see {@link SleepOptions}). */ export type SleepArg = number | SleepOptions; diff --git a/test/asyncbox-specs.ts b/test/asyncbox-specs.ts index b9d06c0..89f9223 100644 --- a/test/asyncbox-specs.ts +++ b/test/asyncbox-specs.ts @@ -10,7 +10,7 @@ import { waitForCondition, withTimeout, TimeoutError, - PromiseCancellation, + PromiseCancellationError, type SleepOptions, } from '../lib/asyncbox.js'; @@ -26,38 +26,38 @@ describe('sleep', function () { const d = sleep(100); expect(d.cancel).to.be.a('function'); }); - it('should reject with PromiseCancellation when cancelled', async function () { + it('should reject with PromiseCancellationError when cancelled', async function () { const d = sleep(10_000); d.cancel(); - await expect(d).to.be.rejectedWith(PromiseCancellation); + await expect(d).to.be.rejectedWith(PromiseCancellationError); }); it('should reject immediately on cancel without waiting for ms', async function () { const d = sleep(10_000); const start = Date.now(); d.cancel(); - await expect(d).to.be.rejectedWith(PromiseCancellation); + await expect(d).to.be.rejectedWith(PromiseCancellationError); expect(Date.now() - start).to.be.below(100); }); - it('should use default PromiseCancellation when cancelError is empty string', async function () { + it('should use default PromiseCancellationError when cancelError is empty string', async function () { const d = sleep({ms: 10_000, cancelError: ''}); d.cancel(); try { await d; expect.fail('expected rejection'); } catch (err: unknown) { - expect(err).to.be.instanceOf(PromiseCancellation); - expect((err as PromiseCancellation).message).to.equal('Promise cancelled'); + expect(err).to.be.instanceOf(PromiseCancellationError); + expect((err as PromiseCancellationError).message).to.equal('Promise cancelled'); } }); - it('should reject with PromiseCancellation using cancelError string when cancelled', async function () { + it('should reject with PromiseCancellationError using cancelError string when cancelled', async function () { const d = sleep({ms: 10_000, cancelError: 'aborted'}); d.cancel(); try { await d; expect.fail('expected rejection'); } catch (err: unknown) { - expect(err).to.be.instanceOf(PromiseCancellation); - expect((err as PromiseCancellation).message).to.equal('aborted'); + expect(err).to.be.instanceOf(PromiseCancellationError); + expect((err as PromiseCancellationError).message).to.equal('aborted'); } }); it('should reject with a provided Error instance on cancel', async function () { @@ -87,23 +87,23 @@ describe('sleep', function () { expect(Date.now() - start).to.be.below(100); }); it('should throw TypeError when ms is not finite', function () { - expect(() => sleep(Number.NaN)).to.throw(TypeError, /finite number or a plain object/); + expect(() => sleep(Number.NaN)).to.throw(TypeError, /finite number or an object with ms/); expect(() => sleep(Number.POSITIVE_INFINITY)).to.throw( TypeError, - /finite number or a plain object/, + /finite number or an object with ms/, ); }); - it('should throw TypeError when arg is not a number or plain object', function () { + it('should throw TypeError when arg is not a number or object', function () { expect(() => sleep(null as unknown as number)).to.throw( TypeError, - /finite number or a plain object/, + /finite number or an object with ms/, ); expect(() => sleep([] as unknown as number)).to.throw( TypeError, - /finite number or a plain object/, + /finite number or an object with ms/, ); }); - it('should throw TypeError when plain object has invalid ms', function () { + it('should throw TypeError when object has invalid ms', function () { expect(() => sleep({} as unknown as SleepOptions)).to.throw( TypeError, /options\.ms must be a finite number/, @@ -113,13 +113,21 @@ describe('sleep', function () { /options\.ms must be a finite number/, ); }); - it('should accept a null-prototype plain object with ms', async function () { + it('should accept a null-prototype object with ms', async function () { const o = Object.create(null) as {ms: number}; o.ms = 20; const now = Date.now(); await sleep(o); expect(Date.now() - now).to.be.at.least(19); }); + it('should accept a class instance that satisfies SleepOptions', async function () { + class Opts implements SleepOptions { + ms = 25; + } + const now = Date.now(); + await sleep(new Opts()); + expect(Date.now() - now).to.be.at.least(24); + }); }); describe('withTimeout', function () { From 68a3f57728004926105cc8c5b5a40f33c4af8f46 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Sat, 9 May 2026 22:36:27 +0200 Subject: [PATCH 4/4] address comments --- lib/asyncbox.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/asyncbox.ts b/lib/asyncbox.ts index b734272..18fa183 100644 --- a/lib/asyncbox.ts +++ b/lib/asyncbox.ts @@ -62,7 +62,7 @@ export function sleep(arg: SleepArg): CancellablePromise { timeoutId = undefined; } if (cancelError === null) { - resolveFn?.(); + resolveFn?.(undefined); } else { let err: Error; if (typeof cancelError === 'string' && cancelError) {