diff --git a/CHANGELIST.md b/CHANGELIST.md index 9e39683a..1c125a25 100644 --- a/CHANGELIST.md +++ b/CHANGELIST.md @@ -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 `!!`. + - See [Fake Timer Detection](./docs/README.md#fake-timer-detection) for full details and side effects. # v0.6.1 May 31st, 2026 diff --git a/README.md b/README.md index fb6b5507..f90e8642 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/README.md b/docs/README.md index 75317ed2..1a675e8b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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`. | + +**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 diff --git a/lib/src/index.ts b/lib/src/index.ts index 507fee1d..0f5feb7b 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -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, diff --git a/lib/src/promise/itemProcessor.ts b/lib/src/promise/itemProcessor.ts index 9e0eef58..1963d45c 100644 --- a/lib/src/promise/itemProcessor.ts +++ b/lib/src/promise/itemProcessor.ts @@ -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). + * @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; diff --git a/lib/test/src/promise/fakeTimersDetection.test.ts b/lib/test/src/promise/fakeTimersDetection.test.ts new file mode 100644 index 00000000..598b491e --- /dev/null +++ b/lib/test/src/promise/fakeTimersDetection.test.ts @@ -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 { + 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; + + // 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; + + // 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; + + 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; + 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; + + 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"); + }); +});