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 @@ -9,6 +9,10 @@
- [#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.
- [Feature] Add `setDisableFakeTimersDetection` to allow opting out of Sinon fake-timer fingerprinting
- New exported function `setDisableFakeTimersDetection(disable?: boolean)` allows a consumer to force the library's internal Sinon-fake-timer detection (a patched `setTimeout.clock` fingerprint used to decide whether deferred continuations use a `0ms` timeout or a microtask) to always report "not present", so deferred work always uses a real microtask.
- Default behavior (detection enabled) is unchanged when never called, or called with `undefined`. Any other value is coerced via `!!`.
Comment thread
nev21 marked this conversation as resolved.
- See [Fake Timer Detection](./docs/README.md#fake-timer-detection) for full details and side effects.


# v0.6.1 May 31st, 2026
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ async function myApi()
}
```

### Fake Timer Detection

Internally this library detects Sinon-style fake timers (a patched global `setTimeout` exposing a `.clock`
property) so that deferred/queued continuations remain testable with fake clocks. This is on by default;
call [`setDisableFakeTimersDetection`](./docs/README.md#fake-timer-detection) to opt out if your own code
patches `setTimeout` in a way that could otherwise be mistaken for fake timers. See
[Fake Timer Detection](./docs/README.md#fake-timer-detection) for full details and side effects.

### Unhandled Promise Rejection Event

All implementations will "emit/dispatch" the unhandled promise rejections event (`unhandledRejection` (node) or `unhandledrejection`) (if supported by the runtime) using the standard runtime mechanisms. So any existing handlers for native (`new Promise`) unhandled rejections will also receive them from the `idle`, `sync` and `async` implementations. The only exception to this is when the runtime (like IE) doesn't support this event in those cases "if" an `onunhandledrejection` function is registered it will be called and if that also doesn't exist it will logged to the console (if possible).
Expand Down
34 changes: 34 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,40 @@ Also of note is that all implementations will "emit/dispatch" the unhandled prom

The provided polyfill wrapper is build around the `asynchronous` promise implementation which is tested and validated against the standard native (`Promise()`) implementations for node, browser and web-worker to ensure compatibility.

## Fake Timer Detection

**Where:** [`setDisableFakeTimersDetection`](https://nevware21.github.io/ts-async/typedoc/functions/setDisableFakeTimersDetection.html) (`lib/src/promise/itemProcessor.ts`), exported from the package root since `0.7.0`.

Internally, whenever this library needs to defer work to the next tick (eg. the [`setMaxSyncPromiseChainDepth`](https://nevware21.github.io/ts-async/typedoc/functions/setMaxSyncPromiseChainDepth.html) synchronous-chain guard forcing a "hop", or the async/timeout item processors scheduling their queued continuations) it normally uses a real microtask (`scheduleMicrotask`). As an affordance for unit tests that install Sinon-style fake timers, it detects a patched global `setTimeout` that exposes a `.clock` property and, when found, uses a `0ms` timer (`scheduleTimeout(fn, 0)`) instead of a microtask -- because queued microtasks are not advanced by fake-clock `tick()` calls, only real timers are.

This detection is a simple fingerprint (`(setTimeout as any).clock`) and is always active by default: **any** code that happens to patch the global `setTimeout` and also add a `.clock` property to it -- not necessarily Sinon -- will be treated the same way and will change this library's internal scheduling to use fake-timer-compatible `0ms` timeouts instead of microtasks.

`setDisableFakeTimersDetection(disable?: boolean): void` lets a consumer opt out of this fingerprinting:

| Call | Effect |
|------|--------|
| `setDisableFakeTimersDetection(true)` | Disables the check -- the library always behaves as though fake timers are **not** present and always uses real microtask scheduling to defer work, even if a patched `setTimeout.clock` is present. |
| `setDisableFakeTimersDetection(false)` | Explicitly (re-)enables the check (same as the default). |
| `setDisableFakeTimersDetection()` / `setDisableFakeTimersDetection(undefined)` | Restores the default (detection enabled). |
| Any other value | Coerced to a boolean via `!!value`, so e.g. `setDisableFakeTimersDetection(1)` behaves the same as passing `true`. |
Comment thread
nev21 marked this conversation as resolved.

**Side effects:**
- This is global, process/runtime-wide mutable state (a module-level flag), not scoped to a single Promise, chain, or scheduler -- calling it affects **all** subsequent deferred scheduling across the entire library until it is called again.
- It takes effect immediately for any future deferral decision; it does not retroactively change already-scheduled callbacks.
- If your own test suite legitimately relies on this library's fake-timer-aware scheduling (eg. advancing a Sinon clock to drive queued continuations), disabling detection will cause those queued continuations to instead wait on a real microtask, which fake clock `tick()`/`next()` calls will **not** flush -- only draining the real microtask queue (eg. `await`ing a native `Promise`) will. Conversely, if you never install fake timers, calling this has no observable effect.
- It is safe to call from application startup code (outside of tests) if you want to unconditionally opt out of the fingerprinting check described above; there is no cleanup required beyond calling it again (or with `undefined`) to change the behavior.

```ts
import { setDisableFakeTimersDetection } from "@nevware21/ts-async";

// Opt out of the setTimeout.clock fingerprint -- deferred continuations always use a
// real microtask, regardless of any patched setTimeout global.
setDisableFakeTimersDetection(true);

// Restore the default (fingerprinting / fake timer detection enabled)
setDisableFakeTimersDetection();
```

## Documentation

Documentation [generated from source code](https://nevware21.github.io/ts-async/typedoc/index.html) via typedoc
2 changes: 1 addition & 1 deletion lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export {
} from "./interfaces/types"
export { doAwaitResponse, doAwait, doFinally } from "./promise/await";
export { setPromiseDebugState } from "./promise/debug";
export { setMaxSyncPromiseChainDepth } from "./promise/itemProcessor";
export { setMaxSyncPromiseChainDepth, setDisableFakeTimersDetection } from "./promise/itemProcessor";
export {
createNativePromise, createNativeAllPromise, createNativeResolvedPromise, createNativeRejectedPromise,
createNativeAnyPromise, createNativeRacePromise,
Expand Down
44 changes: 44 additions & 0 deletions lib/src/promise/itemProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,51 @@ export function setMaxSyncPromiseChainDepth(maxDepth?: number): void {
_maxSyncChainDepth = isNumber(maxDepth) ? maxDepth : DEFAULT_MAX_SYNC_CHAIN_DEPTH;
}

/**
* @internal
* @ignore
* When `true`, {@link _isFakeTimersEnabled} is forced to always report `false` regardless of whether a
* patched `setTimeout.clock` is actually present, see {@link setDisableFakeTimersDetection}.
*/
let _disableFakeTimersDetection = false;

/**
* Enables or disables this library's automatic detection of Sinon-style fake timers (a patched
* `setTimeout` exposing a `.clock` property). By default (and when this function has never been called,
* or has been called with `undefined`) detection remains active -- this is the existing / historical
* behavior, so any global that patches `setTimeout` and happens to also expose a `.clock` property (not
* necessarily Sinon) will still be treated as fake timers and change this library's internal scheduling
* (eg. using a `0ms` timeout "hop" instead of a microtask when deferring a queued continuation).
* Passing `true` disables the detection so it always behaves as though fake timers are **not** present
* (real microtask / timer scheduling is always used), which avoids that false-positive fingerprinting
* risk for consumers who don't rely on it. Passing `false` re-enables detection.
* @since 0.7.0
* @group Promise
* @param disable - When `true` (or any other truthy value), disables fake timer detection so
* {@link _isFakeTimersEnabled} always returns `false`. When `false` (or any other falsy value),
* (re-)enables detection. When `undefined`, restores the default (detection enabled).
Comment thread
nev21 marked this conversation as resolved.
* @example
* ```ts
* // Disable fake timer detection -- this library will never treat a patched
* // setTimeout.clock as an indication that fake timers are active
* setDisableFakeTimersDetection(true);
*
* // Re-enable detection
* setDisableFakeTimersDetection(false);
*
* // Restore the default (detection enabled)
* setDisableFakeTimersDetection();
* ```
*/
export function setDisableFakeTimersDetection(disable?: boolean): void {
_disableFakeTimersDetection = disable === undefined ? false : !!disable;
}

function _isFakeTimersEnabled(): boolean {
if (_disableFakeTimersDetection) {
return false;
}

// Sinon fake timers patch setTimeout and expose the active clock instance as `setTimeout.clock`.
// This check intentionally targets that behavior so async promise callbacks remain testable with fake clocks.
let setTimeoutFn = setTimeout as any;
Expand Down
118 changes: 118 additions & 0 deletions lib/test/src/promise/fakeTimersDetection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* @nevware21/ts-async
* https://github.com/nevware21/ts-async
*
* Copyright (c) 2026 NevWare21 Solutions LLC
* Licensed under the MIT license.
*/

import * as sinon from "sinon";
import { assert } from "@nevware21/tripwire";
import { IPromise } from "../../../src/interfaces/IPromise";
import { createSyncResolvedPromise } from "../../../src/promise/syncPromise";
import { setDisableFakeTimersDetection, setMaxSyncPromiseChainDepth } from "../../../src/promise/itemProcessor";

describe("Validate setDisableFakeTimersDetection()", () => {
let clock: sinon.SinonFakeTimers | null;

beforeEach(() => {
// Force the synchronous chain guard to trip quickly so a deferred "hop" is always needed
setMaxSyncPromiseChainDepth(3);
clock = null;
});

afterEach(() => {
setMaxSyncPromiseChainDepth();
setDisableFakeTimersDetection();

if (clock) {
clock.restore();
clock = null;
}
});

// Same "recursive thenable" pattern used by syncChainDepth.test.ts -- each link resolves via
// a new chained (already resolved) sync promise, which will trip the guard once deep enough.
function _recursiveChain(value: number, remaining: number, log: number[]): IPromise<number> {
return createSyncResolvedPromise(value).then((v) => {
log.push(v);
if (remaining <= 0) {
return v;
}

return _recursiveChain(v + 1, remaining - 1, log);
});
}

it("defers via a fake 0ms timeout (requiring an explicit clock tick) when fake timers are detected (default behavior)", async () => {
clock = sinon.useFakeTimers();

let log: number[] = [];
let promise = _recursiveChain(0, 20, log) as IPromise<number>;

// Draining real microtasks should NOT progress the chain -- the deferred continuation
// was scheduled via the (fake) setTimeout, which only fires once the clock is ticked.
await Promise.resolve();
await Promise.resolve();
assert.equal(promise.state, "pending", "Expecting the chain to still be waiting on the fake timer");

// Drain every (possibly repeatedly re-scheduled) fake 0ms timeout hop
await clock.runAllAsync();

assert.equal(promise.state, "resolved", "Expecting the chain to resolve once the fake timer has ticked");
assert.equal(log.length, 21, "Expecting every link in the chain to have run");
});

it("always defers via a real microtask when fake timer detection is disabled", async () => {
clock = sinon.useFakeTimers();
setDisableFakeTimersDetection(true);

let log: number[] = [];
let promise = _recursiveChain(0, 20, log) as IPromise<number>;

// No clock.tick() -- this should only be able to complete if the deferred
// continuation is using a real microtask rather than the fake timer.
let result = await promise;

assert.equal(result, 20, "Expecting the final value to be the last resolved value");
assert.equal(log.length, 21, "Expecting every link in the chain to have run");
});

it("restores the default (detection enabled) behavior when called with undefined", async () => {
clock = sinon.useFakeTimers();
setDisableFakeTimersDetection(true);
setDisableFakeTimersDetection();

let log: number[] = [];
let promise = _recursiveChain(0, 20, log) as IPromise<number>;

await Promise.resolve();
await Promise.resolve();
assert.equal(promise.state, "pending", "Expecting detection to be re-enabled, so the chain is stuck on the fake timer");

await clock.runAllAsync();

assert.equal(promise.state, "resolved", "Expecting the chain to resolve once the fake timer has ticked");
});

it("treats any truthy/falsy value the same as !!/! would (not just literal true/false)", async () => {
clock = sinon.useFakeTimers();

setDisableFakeTimersDetection(1 as any);
let log1: number[] = [];
let promise1 = _recursiveChain(0, 20, log1) as IPromise<number>;
let result1 = await promise1;
assert.equal(result1, 20, "Expecting a truthy value to disable detection, same as passing true");

setDisableFakeTimersDetection(0 as any);
let log2: number[] = [];
let promise2 = _recursiveChain(0, 20, log2) as IPromise<number>;

await Promise.resolve();
await Promise.resolve();
assert.equal(promise2.state, "pending", "Expecting a falsy value to re-enable detection, same as passing false");

await clock.runAllAsync();
assert.equal(promise2.state, "resolved");
});
});
Loading