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
4 changes: 4 additions & 0 deletions CHANGELIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
60 changes: 30 additions & 30 deletions common/config/rush/npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 12 additions & 1 deletion lib/src/internal/timeout_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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++;
Comment thread
nev21 marked this conversation as resolved.

if (isNumber(timeout)) {
result = timeout;
Expand Down
7 changes: 6 additions & 1 deletion lib/src/promise/asyncPromise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<<T extends readonly unknown[] | []>(input: T, timeout?: number) => IPromise<{ -readonly [P in keyof T]: IPromiseResult<Awaited<T[P]>>; }>>;
let _raceAsyncCreator: ICachedValue<<T extends readonly unknown[] | []>(values: T, timeout?: number) => IPromise<Awaited<T[number]>>>;
Expand All @@ -30,7 +31,11 @@ let _anyAsyncCreator: ICachedValue<<T extends readonly unknown[] | []>(values: T
* @param timeout - Optional timeout to wait before processing the items, defaults to zero.
*/
export function createAsyncPromise<T>(executor: PromiseExecutor<T>, timeout?: number): IPromise<T> {
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);
}

/**
Expand Down
64 changes: 64 additions & 0 deletions lib/test/src/internal/timeout_helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
24 changes: 24 additions & 0 deletions lib/test/src/promise/async.promise.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> = createAsyncPromise<number>((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;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
Loading