Skip to content

node:timers/promises: implement setInterval as async generator (lazy arm + iterator protocol) - #34619

Open
robobun wants to merge 4 commits into
mainfrom
farm/054edc46/timers-promises-setinterval-async-iterator
Open

node:timers/promises: implement setInterval as async generator (lazy arm + iterator protocol)#34619
robobun wants to merge 4 commits into
mainfrom
farm/054edc46/timers-promises-setinterval-async-iterator

Conversation

@robobun

@robobun robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Problem

The timers/promises setInterval() async iterator was a hand-rolled object that diverged from Node in several ways.

Eager arming pins the event loop. The interval is created inside setInterval() itself, before the iterator is ever consumed, so an iterator that is created but abandoned keeps the process alive forever. Node uses an async generator, whose body runs on the first next():

import { setInterval } from "node:timers/promises";
const it = setInterval(1000);   // created, never iterated
void it;
// node: exits 0 immediately (interval armed lazily on first next())
// bun:  hangs forever (interval armed at setInterval() call time)

Iterator protocol gaps. Separately, the hand-rolled object had a single pending-resolver slot and no completion latch:

const it = tp.setInterval(10, "tick");
const first = it.next();
const second = it.next();   // overwrites the only resolver slot
await second;               // resolves
await first;                // hangs forever
Case Node Bun (before)
Un-iterated iterator process exits event loop pinned
Concurrent next() all settle in order first pending promise never settles
it.return(v) { value: v, done: true } {}
next() after return() { done: true } never settles
next() after return() with buffered ticks { done: true } { done: false, value } from closed iterator
second for await over same iterator completes immediately hangs forever
typeof it.throw "function" "undefined"
next() after abort rejection { done: true } rejects again

Cause

src/js/node/timers.promises.ts built the iterator by hand and called setIntervalGlobal(...) at factory time:

function setInterval(after, value, options) {
  // ...
  interval = setIntervalGlobal(...);   // eager: runs before any next()
  // ...
  return asyncIterator({
    next() { ... callback = resolve; ... },   // single slot
    return() { ...; return Promise.$resolve({}); },   // wrong shape, no done latch
  });
}

Fix

Replace the hand-rolled iterator with a real async function*, matching Node's implementation. The generator body runs lazily on the first next(), so an abandoned iterator never arms an interval. The async generator runtime provides correct next() queueing, return(v) shape, throw(), and done-latching for free, and the try/finally clears the interval on every exit path.

Verification

New tests in test/js/node/timers.promises/timers.promises.test.ts cover the event-loop pin (subprocess exits 0 with an un-iterated iterator), concurrent next(), return(v) shape, post-return next(), buffered ticks after return, second for await, throw(), and abort-then-next(). All fail on current main and pass with this change. test/js/node/test/parallel/test-timers-interval-promisified.js continues to pass.


[review] gate passed · iteration 7 · 2 files touched

fails on main (without fix)
ASAN without fix: 8 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/timers.promises/timers.promises.test.ts
bun test v1.4.0 (bc59b8cfe)

test/js/node/timers.promises/timers.promises.test.ts:
(pass) setTimeout > abort() does not emit global error [213.81ms]
(pass) setTimeout > AbortController can be passed as the `options` argument [32.48ms]
(pass) setTimeout > should reject promise when AbortController is aborted [16.74ms]
(pass) setTimeout > rejects even when another listener stopped propagation [13.58ms]
(pass) setImmediate > abort() does not emit global error [139.96ms]
(pass) setImmediate > rejects even when another listener stopped propagation [19.75ms]
(pass) setInterval > ends the iterator even when another listener stopped propagation [308.85ms]
131 |       stdout: "pipe",
132 |       stderr: "pipe",
133 |     });
134 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
135 |     expect(stderr).toBe("");
136 |     expect(stdout).toBe("created\n");
                         ^
error: expect(received).toBe(expected)

  "crea
... (truncated)

release without fix: 11 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/timers.promises/timers.promises.test.ts:
(pass) setTimeout > abort() does not emit global error [104.24ms]
(pass) setTimeout > AbortController can be passed as the `options` argument [1.40ms]
(pass) setTimeout > should reject promise when AbortController is aborted [0.34ms]
56 |     abortController.signal.addEventListener("abort", e => e.stopImmediatePropagation());
57 | 
58 |     const promise = setTimeout(1, "not-aborted", { signal: abortController.signal });
59 |     abortController.abort();
60 | 
61 |     await expect(promise).rejects.toThrow(expect.objectContaining({ name: "AbortError" }));
                                       ^
error: expect(received).rejects.toThrow(expected)

Expected promise that rejects
Received promise that resolved: Promise { <resolved> }

      at <anonymous> (/workspace/bun/test/js/node/timers.promises/timers.promises.test.ts:61:35)
(fail) setTimeout > rejects even when another listener stopped propagation [1.43ms]
(pass) setImmediate > abort() does not emit global error [101.56ms]
91 |     abortController.signal.addEventListener("abort", e => e.stopImmediatePropagation());
92 | 
93 
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/timers.promises/timers.promises.test.ts
bun test v1.4.0 (bc59b8cfe)

test/js/node/timers.promises/timers.promises.test.ts:
(pass) setTimeout > abort() does not emit global error [144.50ms]
(pass) setTimeout > AbortController can be passed as the `options` argument [10.98ms]
(pass) setTimeout > should reject promise when AbortController is aborted [12.25ms]
(pass) setTimeout > rejects even when another listener stopped propagation [14.29ms]
(pass) setImmediate > abort() does not emit global error [135.57ms]
(pass) setImmediate > rejects even when another listener stopped propagation [15.37ms]
(pass) setInterval > ends the iterator even when another listener stopped propagation [60.00ms]
(pass) setInterval > does not arm the interval until the iterator is first consumed [663.94ms]
(pass) setInterval > settles every concurrent next() call [62.31ms]
(pass) setInterval > return(value) resolves { value, done: true } [25.04ms]
(pass) setInterval > next() after return() resolves { done: true } [25.56ms]
(pass) setInterval > next()
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 986ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen JS modules (bundle-modules)
Preprocess modules (15067ms)
Bundle modules (82ms)
Postprocesss modules (360ms)
Bundle Functions (2227ms)
Generate Code (39ms)

[17.80s] Bundled "src/js" for production
  2569 kb
  193 internal modules
  13 native modules
  90 internal functions across 19 files
[1/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m    Blocking�[0m waiting for file lock on build directory
�[1m�[92m    Blocking�[0m waiting for file lock on build directory
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 2m 31s
[2/5] link bun-profile
[3/5] bun-profile --revision
1.4.0-canary.1+bc59b8cfe
[5/5] strip bun
[build] done
bun test v1.4.0-canary.1 (bc59b8cfe)

test/js/node/timers.promises/timers.promises.test.ts:
(pass) setTimeout > abort() does not emit global error [102.04ms]
(pass) setTimeout > AbortController can be passed as the
... (truncated)
diff hotspot
src/js/node/timers.promises.ts                     | 126 ++++-----------------
 .../node/timers.promises/timers.promises.test.ts   | 107 +++++++++++++++++
 2 files changed, 130 insertions(+), 103 deletions(-)

gate history · 4 passed · 1 rejected · iteration 7

evidence per changed file
file                                                  reads  edits  tests
src/js/node/timers.promises.ts                            2      2      0
test/js/node/timers.promises/timers.promises.test.ts      2      4      0

@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

Status: Ready to merge. The evidence gate passed on bc59b8c ("test fails without the fix and passes with it on ASAN and release builds"). Code review is clean (no open threads).

CI (#81059): 6 build-bun jobs timed out with exit status -1 (darwin-aarch64, linux-x64, linux-aarch64-musl, linux-aarch64-android, freebsd-x64, freebsd-aarch64) before producing any annotations; all downstream test jobs are waiting_failed. This is build-queue congestion, not a code failure; the previous build #80425 had the same pattern and its completed test lanes (darwin, debian aarch64) all passed timers.promises.test.ts.

Local verification: 15/15 pass with the fix, 8 fail with src/js/node/timers.promises.ts reverted to the merge base.

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Timers Promises Interval

Layer / File(s) Summary
Async generator interval contract
src/js/node/timers.promises.ts
Timer globals are captured directly, and setInterval now returns an async generator with updated validation and initial-abort behavior.
Abort and generator cleanup
src/js/node/timers.promises.ts
Abort handling clears the interval, rejects pending waits, throws the abort error, and removes listeners during generator cleanup.
Iterator lifecycle validation
test/js/node/timers.promises/timers.promises.test.ts
Tests cover lazy arming, concurrent next() calls, closure through return() and throw(), repeated iteration, buffered ticks, and abort behavior.

Possibly related PRs

  • oven-sh/bun#32631: Updates mock timers’ timers/promises setInterval behavior with related async-iterator and abort handling.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely summarizes the main change: replacing setInterval with an async generator and lazy iterator behavior.
Description check ✅ Passed The description covers the change and verification details, though it uses Problem/Cause/Fix instead of the template headings.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:26 AM PT - Jul 25th, 2026

@robobun, your commit bc59b8c is building: #81059

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the async-generator rewrite is essentially Node's own setInterval implementation, and the generator runtime handles the queued-next()/return()/throw()/done-latch semantics the hand-rolled iterator got wrong. The two inline nits are test-hygiene only (cleanup ordering, timing margin) and don't affect the implementation.

Extended reasoning...

Overview

Replaces the hand-rolled async-iterator object in src/js/node/timers.promises.ts setInterval() with a real async function*, and adds seven tests in test/js/node/timers.promises/timers.promises.test.ts covering concurrent next(), return(v) shape, post-return next(), buffered-tick discard, second for await, throw(), and abort→next(). Net ~90 lines deleted; the asyncIterator helper and its only callers are removed in the same commit.

Correctness

I traced the generator against each scenario in the PR table plus a few edges the tests don't hit directly:

  • return() while suspended at the internal await (not at yield): the queued return is processed after the next tick resolves the await and control reaches yield, then finally clears the interval — no leak.
  • Abort while suspended at yield (callback undefined): onCancel clears the interval; on resume the while (!signal?.aborted) check falls through to the trailing throw $makeAbortError. Buffered ticks drain first, which matches Node.
  • return() before first next(): generator body never starts, nothing to clean up.
  • Validation errors now throw synchronously inside the generator body → surface as a rejected first next(), which is what test-timers-interval-promisified.js asserts (assert.rejects(() => iterable[Symbol.asyncIterator]().next(), /ERR_INVALID_ARG_TYPE/)).
  • { once: true } on the abort listener is fine — getEventListeners(signal, 'abort').length === 1 in the Node parallel test still holds until finally removes it.

Resolving the pending await with Promise.$reject(...) on abort is the same trick Node uses (resolve-with-rejected-thenable → the await throws), and finally runs on every exit path (return/throw/abort).

Security risks

None. Pure JS iterator-protocol change; no new external input surface. Uses the same primordial patterns (Promise.$reject, $makeAbortError) as the surrounding setTimeout/setImmediate code.

Level of scrutiny

Medium — Node-compat surface, but the change is a simplification that converges on the reference implementation rather than diverging from it. The upstream test/js/node/test/parallel/test-timers-interval-promisified.js exercises validation, pre-aborted signal, mid-iteration abort, ref: false, and listener cleanup, and the PR states it still passes.

Other factors

The two inline findings are both [Nit]-level test hygiene: a missing try/finally around assertions (leaks a harmless 10ms counter-increment interval only on assertion failure) and a 5× timing margin on the buffered-ticks precondition. Neither affects the implementation or introduces flakiness on the happy path, so I don't consider them blocking.

Comment thread test/js/node/timers.promises/timers.promises.test.ts
Comment thread test/js/node/timers.promises/timers.promises.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the two earlier nits (try/finally cleanup on the concurrent-next test, wider buffered-tick margin) are addressed in 3173345.

What was reviewed:

  • New async function* body traced against Node's lib/timers/promises.js — same notYielded/callback loop, same abort-via-resolve(rejectedPromise) shape, finally clears interval + listener on every exit.
  • Checked that validation errors still surface on first next() (not synchronously) as before, matching Node's async-generator semantics.
  • Verified onCancel's Promise.$reject(...) passed to resolve gets a handler attached via the resolution microtask, so no unhandled-rejection regression vs the existing setTimeout/setImmediate paths.
  • test-timers-interval-promisified.js still enabled in test/js/node/test/parallel/; the 7 new cases each map to a row in the divergence table.
Extended reasoning...

Overview

Rewrites setInterval in src/js/node/timers.promises.ts from a hand-rolled { next, return, [Symbol.asyncIterator] } object to a real async function*, and adds 7 protocol-conformance tests in test/js/node/timers.promises/timers.promises.test.ts. Net −100/+30 in the source file; the async-generator runtime now provides next() queueing, return(v) shape, throw(), and done-latching, with a try/finally guaranteeing clearInterval and abort-listener removal on every exit path.

Security risks

None. Pure JS, no untrusted-input parsing beyond the existing validateNumber/validateObject/validateAbortSignal/validateBoolean calls (unchanged). No new allocation driven by external size fields.

Level of scrutiny

Medium — a node: compat behavioural change, but the new body is a near-verbatim port of Node's own lib/timers/promises.js setInterval (same notYielded counter, same callback slot resolved by tick or by PromiseReject(abortError), same while(!signal?.aborted) loop, same post-loop throw AbortError). The interesting edge cases — abort while suspended at yield vs at await, buffered ticks after abort, validation-error timing — all reduce to Node's exact control flow, so "matches the reference implementation" carries most of the correctness argument here.

Other factors

  • The PR author addressed both prior inline nits in 3173345 (concurrent-next test now cleans up in finally; buffered-tick test uses a 1 ms interval + a second awaited next() for a 50× margin).
  • Existing Node parallel test test-timers-interval-promisified.js remains in the enabled set and covers pre-aborted signal, invalid ref/signal/options, abort mid-iteration, and for await break — the surface the old implementation already handled.
  • The dead asyncIterator helper and symbolAsyncIterator capture are deleted in the same PR, per the "delete dead code in the same PR that makes it dead" rule.
  • bound()'s losing 500 ms setTimeout isn't cancelled on race resolution, but it's a bounded ref'd timer that just fires and resolves into the void — not a leak, and each test's total wall time stays well under the ~1 s budget.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — both earlier nits are addressed (try/finally cleanup in the concurrent-next test, and the buffered-ticks test now uses a 1ms interval with an extra awaited next() for a ~50× margin).

What was reviewed:

  • New setInterval body is a near-line-for-line port of Node's lib/timers/promises.js async generator — same notYielded/callback mechanism, same while (!signal?.aborted) loop, same abort-via-resolve(rejectedPromise) and finally cleanup.
  • Checked validation-error semantics: throws inside the generator body still surface as a rejected first next() (async generator doesn't run until first next()), matching both the old behavior and Node — test-timers-interval-promisified.js covers this.
  • Confirmed the removed asyncIterator helper was file-local; no other callers.
Extended reasoning...

Overview

Rewrites node:timers/promises setInterval() from a hand-rolled async-iterator object to a real async function*, fixing seven documented protocol divergences from Node (concurrent next() hangs, wrong return() shape, missing throw(), no done-latch, etc.). The new body is essentially a direct port of Node's implementation. The now-unused file-local asyncIterator helper and symbolAsyncIterator constant are deleted. Seven new tests in timers.promises.test.ts cover each divergence; the existing Node parallel test test-timers-interval-promisified.js continues to exercise validation errors, listener cleanup, ref: false, and abort-at-various-points.

Security risks

None. Pure JS timer/iterator plumbing; no parsing of untrusted input, no auth, no filesystem/network.

Level of scrutiny

Medium. This is user-facing Node-compat surface in a built-in module (src/js/), so tamper-resistance and correctness matter, but the change reduces bespoke logic by delegating iterator-protocol semantics (queued next(), return(v) shape, throw(), done-latch) to the async-generator runtime — strictly less to get wrong. The remaining hand-written logic (tick counter, abort wiring, finally cleanup) matches Node's reference line-for-line, which per REVIEW.md is the spec for ported code.

Other factors

  • I traced the abort paths against Node's source: abort while awaiting → callback(Promise.$reject(...)) rejects the awaited promise → generator throws → finally cleans up → pending next() rejects; abort while suspended at yieldonCancel clears interval, next next() resumes into while (!signal?.aborted) → throws. Both match Node.
  • Validation moved from try/catch-return-rejecting-iterator to plain throws inside the generator body; observably identical (async generator body runs on first next()), and covered by the Node parallel test's assert.rejects(() => iterable[Symbol.asyncIterator]().next(), /ERR_INVALID_ARG_TYPE/) block.
  • Both nits from my earlier review are resolved in 3173345 and visible in the current diff.
  • Evidence block shows 7/7 new tests fail on the pre-fix debug build and pass post-fix on both debug+ASAN and release.

The hand-rolled async iterator armed its underlying interval eagerly at
setInterval() call time, so an iterator that was created but never
iterated kept the event loop alive forever. Node arms the interval
lazily on the first next() call (async generator body semantics).

Replacing the hand-rolled iterator with a real async generator matches
Node's implementation and fixes several other protocol divergences at
the same time: concurrent next() calls now all settle, return(v)
resolves { value: v, done: true }, next() after return() resolves
{ done: true }, throw() is present, and abort followed by next()
resolves { done: true } instead of rejecting again.
@robobun
robobun force-pushed the farm/054edc46/timers-promises-setinterval-async-iterator branch from 2410cee to df61ee1 Compare July 25, 2026 08:47
Comment thread src/js/node/timers.promises.ts Outdated
@robobun robobun changed the title node:timers/promises: fix setInterval() async iterator protocol (concurrent next, return, throw, done) node:timers/promises: implement setInterval as async generator (lazy arm + iterator protocol) Jul 25, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — this is a near line-for-line port of Node's own async function* setInterval, and both earlier test nits (try/finally cleanup, 1ms interval for the buffered-ticks precondition) are addressed.

What was reviewed

  • Diffed the new generator body against nodejs/node lib/timers/promises.js — control flow (lazy arm, notYielded counter, onCancel rejecting the pending await, while/for/throw/finally) matches exactly.
  • Checked the abort-while-suspended-at-yield path: buffered ticks drain then AbortError throws on the next loop check, same as Node.
  • Confirmed test-timers-interval-promisified.js still exercises the validation paths (invalid ref/signal/options reject on first next()).
  • The comment-cop flag on this file is a false positive — the 2-line "only validate non-number inputs" note mirrors the identical pre-existing comment on setTimeout above it.
Extended reasoning...

Overview

Rewrites setInterval in src/js/node/timers.promises.ts from a hand-rolled async-iterator object to a real async function*, and adds 8 tests to test/js/node/timers.promises/timers.promises.test.ts. The generator body is a direct port of Node's reference implementation — I diffed it against nodejs/node/lib/timers/promises.js and the control flow is identical: lazy interval arming inside try, notYielded tick counter, callback slot resolved by either the interval tick or onCancel (which passes a rejected promise so the await throws), while (!signal?.aborted) / drain-for / trailing throw AbortError / finally cleanup. The only deltas are Bun-local spellings (setIntervalGlobal, $makeAbortError, resistStopPropagation, Promise.$reject) and the pre-existing after = 1 default + unconditional validateAbortSignal/validateBoolean calls, which the old implementation already did.

Security risks

None. Pure JS iterator-protocol change in a Node-compat module; no parsing of untrusted data, no native code, no auth/crypto surface.

Level of scrutiny

Moderate — user-facing Node-compat behavior, but the fix is "replace bespoke iterator with the language's built-in async generator that Node itself uses", which structurally eliminates the whole bug class (single-resolver slot, missing throw(), wrong return() shape, no done-latch, eager arming). Per REVIEW.md's ported-code rule, the Node reference is the spec here, and this matches it.

Other factors

  • My two earlier nits are resolved: the concurrent-next() test now wraps assertions in try/finally { await it.return() }, and the buffered-ticks test uses a 1ms interval with two awaited next() calls before the 50ms sleep (50× margin, precondition established).
  • New tests cover the full divergence matrix from the PR description; the lazy-arm test spawns a subprocess with an unref'd 2s sentinel and asserts exitCode === 0 / no STILL_ALIVE output, so a regression would fail rather than hang.
  • bound() uses Promise.race with a 500ms fallback so hung promises fail assertions instead of timing out the file.
  • CI reds (bake/dev timeouts on macOS x64, node-net.test.ts mimalloc threshold) are unrelated — none touch timers/promises and the failure sets differ across builds.
  • The comment-cop bot flagged a "paragraph-long comment" on this file; the only added comment is a 2-line note identical in substance to the one already on setTimeout in the same file — not a workaround justification, just explaining the typeof after !== 'number' guard. Not actionable.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the two test-hygiene nits from my earlier pass are addressed, and the comment-cop note is resolved.

What was reviewed:

  • setInterval generator body against Node's reference shape (lazy arm on first next(), notYielded buffer, abort via resolve(rejectedPromise), finally cleanup on all exits) — matches.
  • Abort while suspended at yield (not at await): buffered ticks drain then while (!signal?.aborted) falls through to the throw — same as Node.
  • Test hermeticity: try/finally around concurrent-next() assertions, subprocess pipes drained concurrently, bound() timers self-clear so no leaks.
Extended reasoning...

Overview

Rewrites setInterval in src/js/node/timers.promises.ts from a hand-rolled async-iterator object to a real async function*, matching Node's implementation. Net -70/+25 lines in src. Adds 8 tests in test/js/node/timers.promises/timers.promises.test.ts covering lazy arming, concurrent next(), return(v) shape, post-return next(), buffered-tick discard, second for await, throw(), and abort-then-next(). Deletes the now-unused asyncIterator helper and symbolAsyncIterator constant.

Security risks

None. Pure JS timer/iterator semantics; no I/O, parsing of untrusted input, auth, or crypto. The { __proto__: null, once: true } listener options and captured setIntervalGlobal follow existing tamper-resistance patterns in the file.

Level of scrutiny

Medium-low. This is a Node-compat correctness fix in a small, self-contained builtin module. The new implementation is a near-verbatim port of Node's lib/timers/promises.js async generator (same notYielded counter, same while (!signal?.aborted) / inner for loop, same callback(PromiseReject(...)) abort path, same finally cleanup). The async-generator runtime provides the iterator-protocol guarantees (queued next(), return(v) shape, done-latching, throw()) that the old hand-rolled object got wrong, so most of the fix is deleting bespoke code in favor of language semantics.

Other factors

  • My two prior nits (leak-on-assertion-failure in the concurrent-next() test; thin timing margin in the buffered-ticks test) were both addressed in 3173345 and are visible in the current diff.
  • The comment-cop flag on the duplicated delay-validation comment was addressed in c80a2a1.
  • test/js/node/test/parallel/test-timers-interval-promisified.js continues to pass per the CI status; remaining CI red is unrelated (bake/dev, node-net mimalloc threshold).
  • I traced the one non-obvious path — abort firing while the generator is suspended at yield rather than at the await — and confirmed it drains buffered ticks then throws AbortError, which is exactly Node's behavior for the same code shape.
  • The subprocess lazy-arm test has a proper hang guard (unref'd 2s watchdog that exits 1 with STILL_ALIVE), so a regression fails cleanly rather than hanging CI.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the async-generator rewrite mirrors Node's lib/timers/promises.js structure and the earlier review nits (try/finally cleanup, tighter buffered-tick margin, comment-cop) are all addressed.

What was reviewed:

  • Generator control flow: abort-while-awaiting (resolve with rejected promise → await throws), abort-while-yielding (falls through to post-loop throw), and finally clearing the interval on every exit path.
  • Validation timing: errors now surface on first next() via generator-body throw — same observable behavior as before and matches Node.
  • New tests: subprocess lazy-arm check, concurrent next(), return/throw/abort protocol, buffered-tick discard; bound() timers are ref'd but bounded at 500ms so no hang risk.
Extended reasoning...

Overview

This PR replaces the hand-rolled async-iterator object in src/js/node/timers.promises.ts's setInterval with a real async function*, and adds eight tests in test/js/node/timers.promises/timers.promises.test.ts covering the iterator-protocol divergences the old implementation had from Node (lazy arming, concurrent next() queueing, return(v) shape, done-latching, throw(), abort-then-next()). The now-unused asyncIterator helper and symbolAsyncIterator constant are deleted.

Security risks

None. This is a pure-JS Node-compat shim over the existing global setInterval; no native code, auth, crypto, or untrusted-input parsing is touched.

Level of scrutiny

Medium — it rewrites a user-facing node:timers/promises API, but the new body is a near-verbatim port of Node's own async-generator implementation, and the async-generator runtime supplies most of the corrected semantics (next() queueing, return shape, done-latch, throw) for free rather than via new hand-written logic. I traced the two abort paths: (a) abort during awaitonCancel resolves the pending promise with a rejected promise, so await rethrows and the finally clears the interval; (b) abort during yieldcallback is undefined, so the generator resumes, the while (!signal?.aborted) guard fails, and the trailing throw $makeAbortError fires. The post-loop signal?.reason is safe because that line is only reachable when signal exists and is aborted (with no signal the loop condition is always true). Validation moving from factory-time try/catch-return-rejecting-iterator to generator-body throws is behavior-equivalent: both reject the first next(), and Node does it the new way.

Other factors

My two prior inline nits (try/finally around the concurrent-next() assertions; widening the buffered-tick interval margin) were applied in 3173345, and the comment-cop duplicated-comment note was addressed in c80a2a1. The vendored Node parallel test test-timers-interval-promisified.js still exercises this path. The bound() helper leaves its 500ms fallback timers un-cancelled, but they're ref'd, bounded, and small in number — not a hang or flake risk. No outstanding reviewer comments remain.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants