diff --git a/CHANGELIST.md b/CHANGELIST.md index 66c58eb..9e39683 100644 --- a/CHANGELIST.md +++ b/CHANGELIST.md @@ -6,6 +6,10 @@ - New exported function `setMaxSyncPromiseChainDepth(maxDepth?: number)` allows configuring the maximum number of synchronous `.then()` continuations that may execute in a single turn before the chain is deferred via a microtask hop. Defaults to 200. - **Behavior change**: deep synchronous promise chains (longer than the configured max depth) will yield asynchronously once the limit is exceeded, meaning a `createSyncPromise`/`createSyncResolvedPromise` chain may remain pending temporarily for very deep/recursive chains. - Pass no argument (or `undefined`) to reset back to the default depth. +- [#528](https://github.com/nevware21/ts-async/pull/528) [BUG] Fix unbounded loop in timeout normalization and long-chain timeout propagation + - `_normalizeTimeoutValue` unwrapped nested array timeout values with no iteration cap; a self-referential array (e.g. `const a: any[] = []; a[0] = a;`) passed as a timeout would loop forever. The unwrap loop is now capped at 5 iterations, falling back to the default timeout if no numeric value is found within that bound. + - `createAsyncPromise` was re-wrapping the raw (potentially array) timeout as the chained promise's additional args on every `then()`/`catch()`/`finally()` link, growing an extra array-nesting level per link. Combined with the new depth cap this silently dropped an explicit timeout after ~5-6 chained calls. Fixed by normalizing the timeout once before it is stored, matching the existing `createIdlePromise` pattern, so nesting never exceeds one level regardless of chain length. + # v0.6.1 May 31st, 2026 diff --git a/common/config/rush/npm-shrinkwrap.json b/common/config/rush/npm-shrinkwrap.json index 7c80090..b62e74d 100644 --- a/common/config/rush/npm-shrinkwrap.json +++ b/common/config/rush/npm-shrinkwrap.json @@ -1484,9 +1484,9 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==" }, "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", "dependencies": { "@types/unist": "*" } @@ -1761,9 +1761,9 @@ } }, "node_modules/asn1.js/node_modules/bn.js": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", - "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==" + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==" }, "node_modules/assert": { "version": "2.1.0", @@ -1865,14 +1865,14 @@ } }, "node_modules/bn.js": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.4.tgz", - "integrity": "sha512-QL7sb18rJ1PbdsKsqPA0guxL563vIMwRHgzNrW/uzQuRGN1Cjqd/wonUBAVqHox9KwzHA6vCbM0lXx3k4iQMow==" + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.5.tgz", + "integrity": "sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==" }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", @@ -2600,9 +2600,9 @@ } }, "node_modules/create-ecdh/node_modules/bn.js": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", - "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==" + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==" }, "node_modules/create-hash": { "version": "1.2.0", @@ -2903,9 +2903,9 @@ } }, "node_modules/diffie-hellman/node_modules/bn.js": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", - "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==" + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==" }, "node_modules/dom-serialize": { "version": "2.2.1", @@ -2967,9 +2967,9 @@ } }, "node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", - "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==" + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==" }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -5372,9 +5372,9 @@ } }, "node_modules/miller-rabin/node_modules/bn.js": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", - "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==" + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==" }, "node_modules/mime": { "version": "2.6.0", @@ -5642,9 +5642,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "engines": { "node": ">=18" } @@ -6438,9 +6438,9 @@ } }, "node_modules/public-encrypt/node_modules/bn.js": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", - "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==" + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==" }, "node_modules/pump": { "version": "3.0.4", diff --git a/lib/src/internal/timeout_helpers.ts b/lib/src/internal/timeout_helpers.ts index 20a991d..b9fa354 100644 --- a/lib/src/internal/timeout_helpers.ts +++ b/lib/src/internal/timeout_helpers.ts @@ -8,6 +8,15 @@ import { isArray, isNumber, isUndefined } from "@nevware21/ts-utils"; +/** + * @internal + * @ignore + * The maximum number of nested array levels that {@link _normalizeTimeoutValue} will unwrap while + * looking for a numeric timeout value. This bounds the work performed against a self-referential + * array (e.g. `const a: any[] = []; a[0] = a;`), which would otherwise loop forever. + */ +const _MAX_TIMEOUT_UNWRAP_DEPTH = 5; + /** * @internal * @ignore @@ -26,8 +35,10 @@ export function _normalizeTimeoutValue(timeout?: number | any, defaultTimeout?: // Promise creation can re-wrap additional args for chained promises (for example [[10]]). // Unwrap nested array values so explicit timeouts keep flowing through then/catch/finally chains. - while(!isUndefined(timeout) && isArray(timeout) && timeout.length > 0) { + let depth = 0; + while(!isUndefined(timeout) && isArray(timeout) && timeout.length > 0 && depth < _MAX_TIMEOUT_UNWRAP_DEPTH) { timeout = timeout[0]; + depth++; if (isNumber(timeout)) { result = timeout; diff --git a/lib/src/promise/asyncPromise.ts b/lib/src/promise/asyncPromise.ts index 2d7e14a..7269f31 100644 --- a/lib/src/promise/asyncPromise.ts +++ b/lib/src/promise/asyncPromise.ts @@ -15,6 +15,7 @@ import { timeoutItemProcessor } from "./itemProcessor"; import { PromiseExecutor } from "../interfaces/types"; import { IPromiseResult } from "../interfaces/IPromiseResult"; import { ICachedValue } from "@nevware21/ts-utils"; +import { _normalizeTimeoutValue } from "../internal/timeout_helpers"; let _allAsyncSettledCreator: ICachedValue<(input: T, timeout?: number) => IPromise<{ -readonly [P in keyof T]: IPromiseResult>; }>>; let _raceAsyncCreator: ICachedValue<(values: T, timeout?: number) => IPromise>>; @@ -30,7 +31,11 @@ let _anyAsyncCreator: ICachedValue<(values: T * @param timeout - Optional timeout to wait before processing the items, defaults to zero. */ export function createAsyncPromise(executor: PromiseExecutor, timeout?: number): IPromise { - return _createPromise(createAsyncPromise, timeoutItemProcessor(timeout), executor, timeout); + // Normalize before storing as the chained promise's additional args (rather than passing the raw + // value through), so a timeout propagated across a long then/catch/finally chain is re-wrapped as + // a single-element array at each link instead of growing an extra level of array nesting per link. + let theTimeout = _normalizeTimeoutValue(timeout); + return _createPromise(createAsyncPromise, timeoutItemProcessor(theTimeout), executor, theTimeout); } /** diff --git a/lib/test/src/internal/timeout_helpers.test.ts b/lib/test/src/internal/timeout_helpers.test.ts new file mode 100644 index 0000000..73f661c --- /dev/null +++ b/lib/test/src/internal/timeout_helpers.test.ts @@ -0,0 +1,64 @@ +/* + * @nevware21/ts-async + * https://github.com/nevware21/ts-async + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { assert } from "@nevware21/tripwire"; +import { _normalizeTimeoutValue } from "../../../src/internal/timeout_helpers"; + +describe("_normalizeTimeoutValue", () => { + it("should return the default when timeout is undefined", () => { + assert.strictEqual(_normalizeTimeoutValue(undefined, 42), 42); + assert.strictEqual(_normalizeTimeoutValue(undefined, undefined), undefined); + }); + + it("should return the numeric timeout directly", () => { + assert.strictEqual(_normalizeTimeoutValue(100, 42), 100); + assert.strictEqual(_normalizeTimeoutValue(0, 42), 0); + }); + + it("should unwrap a single-nested array to find the numeric timeout", () => { + assert.strictEqual(_normalizeTimeoutValue([10], 42), 10); + }); + + it("should unwrap several levels of nested arrays", () => { + assert.strictEqual(_normalizeTimeoutValue([[[10]]], 42), 10); + }); + + it("should fall back to the default when the array holds no numeric value", () => { + assert.strictEqual(_normalizeTimeoutValue([], 42), 42); + assert.strictEqual(_normalizeTimeoutValue([undefined], 42), 42); + assert.strictEqual(_normalizeTimeoutValue(["not-a-number"], 42), 42); + }); + + it("should fall back to the default once the unwrap depth cap is exceeded", () => { + // Nested 6 levels deep - one more than the 5 iteration cap - so no numeric value is ever found. + let deeplyNested: any = 10; + for (let i = 0; i < 6; i++) { + deeplyNested = [deeplyNested]; + } + + assert.strictEqual(_normalizeTimeoutValue(deeplyNested, 42), 42); + }); + + it("should still find the numeric value when nested exactly at the depth cap", () => { + let nested: any = 10; + for (let i = 0; i < 5; i++) { + nested = [nested]; + } + + assert.strictEqual(_normalizeTimeoutValue(nested, 42), 10); + }); + + it("should not hang or overflow the stack for a self-referential array", () => { + let selfRef: any[] = []; + selfRef[0] = selfRef; + + let start = Date.now(); + assert.strictEqual(_normalizeTimeoutValue(selfRef, 42), 42); + assert.isTrue((Date.now() - start) < 1200, "Should return promptly rather than looping forever"); + }); +}); diff --git a/lib/test/src/promise/async.promise.test.ts b/lib/test/src/promise/async.promise.test.ts index 8fe2c04..6ce907a 100644 --- a/lib/test/src/promise/async.promise.test.ts +++ b/lib/test/src/promise/async.promise.test.ts @@ -195,6 +195,30 @@ describe("Validate createAsyncPromise() timeout usages", () => { assert.equal(_unhandledEvents.length, 0, "No unhandled rejections"); }); + it("Test that an explicit timeout keeps propagating through a long then() chain", () => { + // Historically, chaining could cause the timeout extra-args to become increasingly nested arrays + // (see _normalizeTimeoutValue). This long chain guards against regressions where the explicit + // timeout would be dropped once the unwrap-depth cap is exceeded. + const linkCount = 20; + let times: number[] = []; + let p: IPromise = createAsyncPromise((resolve) => resolve(1), 10); + for (let i = 0; i < linkCount; i++) { + p = p.then((v: number) => { + times.push(clock.now); + return v + 1; + }); + } + + for (let i = 0; i <= linkCount; i++) { + clock.tick(10); + } + + assert.equal(times.length, linkCount, "Expected every link in the chain to have resolved"); + for (let i = 0; i < times.length; i++) { + assert.equal(times[i], (i + 1) * 10, `Expected link ${i} to resolve after its 10ms timeout was honored`); + } + }); + it("Test resolving promise using then/catch synchronously and state", () => { let resolvedValue: number | null = null; let rejectedValue = null; diff --git a/package.json b/package.json index e94416f..5c1b8f8 100644 --- a/package.json +++ b/package.json @@ -180,7 +180,7 @@ { "name": "es5-any", "path": "lib/dist/es5/mod/ts-async.js", - "limit": "12.75 kb", + "limit": "13 kb", "import": "{ createAnyPromise }", "brotli": false, "ignore": [