Fix setInterval starving the JS dispatch queue - #220
Open
bkaradzic-microsoft wants to merge 2 commits into
Open
Conversation
A repeating timeout was re-armed on the timer thread immediately, before its
callback had been run on the JS thread:
const auto repeat = m_idMap[id]->interval.has_value();
if (repeat) {
const auto timeout = std::move(m_idMap.extract(id).mapped());
DispatchImpl(std::move(timeout->function), *timeout->interval, true, timeout->id);
}
CallFunction(id);
With a zero delay the re-inserted entry is scheduled at Now(), which is always
already due, so the timer thread's wait_until never blocks. It spins, queueing
callbacks onto the JS dispatch queue far faster than the JS thread can drain
them. The queue is unbounded, so it grows without limit and every other item on
it is starved arbitrarily long -- other timers, and native async completions
posted through JsRuntimeScheduler.
This is easy to hit: setInterval(fn) with no delay argument yields a delay of 0,
and driving a render loop that way is a common pattern. In Babylon Native it
made shader compilation completions never arrive, so scene readiness callbacks
never fired and the app hung with no error.
Re-arm repeating timeouts on the JS thread once the callback has actually
returned, so an interval can have at most one invocation pending at a time, as
in a browser. The next deadline is anchored to the previous scheduled time
rather than the completion time so that a slow callback does not accumulate
drift, and is clamped to not be in the past.
A monotonic sequence number distinguishes a timeout from a later one that reuses
the same id, so an in-flight callback can never re-arm its replacement, and a
clear issued from within a callback is honoured.
This also removes a null dereference: the old code read m_idMap[id]->interval
with operator[], which default-constructs a null unique_ptr when the id is
absent and then dereferences it.
Zero-delay intervals still run at full speed; they simply no longer build a
backlog. In a local benchmark a zero-delay interval reached 38,000 ticks in the
100ms before a competing setTimeout(100) fired, where previously that setTimeout
never fired at all.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes a starvation/backlog issue caused by 0-delay setInterval repeatedly re-arming on the timer thread, which could spin the timer thread and flood the JS dispatch queue, indefinitely delaying unrelated queued work.
Changes:
- Re-arms repeating timeouts on the JS thread only after the callback completes, ensuring at most one pending invocation per interval.
- Adds a monotonic
sequenceto distinguish reused timeout IDs and avoid re-arming the wrong timer. - Adds two unit tests covering the starvation regression and clearing an interval from within its own callback.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| Tests/UnitTests/Scripts/tests.ts | Adds regression and behavioral tests for 0-delay interval starvation and clear-within-callback semantics. |
| Polyfills/Scheduling/Source/TimeoutDispatcher.h | Extends dispatcher state/signature to support per-timeout sequencing. |
| Polyfills/Scheduling/Source/TimeoutDispatcher.cpp | Moves interval re-arm to post-callback execution on the JS thread; introduces sequence handling and re-arm notification. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Re-arming after the callback introduced a behavior change: an exception escaping function->Call unwinds out of the dispatch lambda before the re-arm runs, so an interval stopped permanently the first time a tick threw. Previously the re-arm happened on the timer thread before the call, so a throwing tick was harmless. Browsers report the error and keep the interval running, so restore that. Catch Napi::Error around the call, re-arm, then re-raise it as a pending JS exception so JsRuntime::Dispatch's pending-exception check still surfaces it unchanged. The re-arm logic moves into a TimeoutDispatcher::Rearm helper so both the normal and the throwing path share it. Covered by a new native test, Scheduling.IntervalSurvivesThrowingCallback. It has to be native because the unit test host's UnhandledExceptionHandler fails the entire JavaScript suite, so a throwing timer cannot be exercised from tests.ts. Without the catch the test fails by timing out after the first throw. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
TimeoutDispatcher::ThreadFunctionre-arms a repeating timeout on the timer thread, immediately, before its callback has run on the JS thread:With
delay == 0the re-inserted entry getstime = Now(), which is always already due, so the timer thread'swait_untilnever blocks. The thread spins, enqueueing callbacks onto the JS dispatch queue far faster than the JS thread can drain them. That queue is unbounded, so it grows without limit and every other item on it is starved arbitrarily long — other timers, and native async completions posted throughJsRuntimeScheduler.This is easy to hit by accident.
setInterval(fn)with no delay argument produces a delay of0, and driving a render loop that way is a common pattern.Reproducing with no framework involved at all — a 0-delay
setIntervaland asetTimeout(…, 100)racing each other:setTimeout(100)firedThe callback was never lost, only buried: if the interval is stopped with
clearInterval, the overduesetTimeoutfires ~40ms later — 2.4s late.Downstream impact
In Babylon Native this made
NativeEngine::CreateProgramAsync's shader-compile continuation (dispatched viaJsRuntimeScheduler) never arrive, so effects stayedisReady === falsewith an emptycompilationError, scene readiness callbacks never fired, and the app hung silently with no error. See BabylonJS/BabylonNative#1814.The signature that isolates it: an identical scene reaches ready in ~13 ticks when driven by
engine.runRenderLoop, and never when driven bysetInterval+beginFrame/render/endFrame. The frame driver is the only variable.Fix
Re-arm a repeating timeout on the JS thread, after its callback has actually returned, so an interval can have at most one invocation pending at a time — matching browser behaviour.
Details worth calling out:
scheduledTime + interval), not the completion time, so a slow callback does not make the interval drift. It is clamped to not be in the past. The existing test assertingelapsed >= tickCount * 10for a 10ms interval still passes.sequenceonTimeoutdistinguishes a timeout from a later one that happens to reuse the same id, so an in-flight callback can never re-arm its replacement, and aclearIntervalissued from inside a callback is honoured.m_timeMapis empty, so the timer thread is parked inm_condVariable.wait.Clear()stays correct. While in flight the entry is absent fromm_timeMap, so itsequal_rangefinds nothing and it simply erases fromm_idMap; the re-arm check then skips. Re-arm updatestimeout->timebefore re-inserting, whichClear()matches on.m_idMap[id]->intervalwithoperator[], which default-constructs a nullunique_ptrwhen the id is absent and then dereferences it.NAPI_CPP_EXCEPTIONSis enabled whenever the compiler has exceptions on,function->Callthrows a C++Napi::Error. Re-arming after the callback means that exception would unwind past the re-arm, so the interval is re-armed from acatchand the error is then re-raised viaThrowAsJavaScriptException()— leavingJsRuntime::Dispatch's existing pending-exception handling untouched. Browsers behave the same way, as did this dispatcher before the change.Zero-delay intervals still run at full speed — they just no longer build a backlog. After the fix a 0-delay interval reached 38,357 ticks in the 100ms before the competing
setTimeoutfired.Tests
Two tests added to the
setIntervalsuite inTests/UnitTests/Scripts/tests.ts:should not starve other queued work when the interval has no delay— the regression test.should stop when cleared from within its own callback— covers the new re-arm path.One native test added to
Tests/UnitTests/Shared/Shared.cpp:Scheduling.IntervalSurvivesThrowingCallback— an interval that throws on the first two ticks must still reach the third, and both errors must still reachUnhandledExceptionHandler. This one has to be native, because the unit test host'sUnhandledExceptionHandlersets the exit code to -1 and so a throwing timer intests.tswould fail the entire JavaScript suite.218 JavaScript tests + 7 native tests passing, 0 failing.
Both regression tests were negative-controlled by reverting only the corresponding production change and keeping the test:
should not starve other queued work…setTimeout, so it cannot fire either — a fair illustration of how total the starvation is.Scheduling.IntervalSurvivesThrowingCallbackthe interval stopped after a tick threw.Also validated against Babylon Native (BabylonNative#1805 branch, 301-test Playground validation suite):
setInterval,runRenderLoop} × {parallelShaderCompileon, off} goes from 2 of 4 cells hanging to 4 of 4 ready.Nested BBG), previously an unbounded hang, now passes in 2s.