Skip to content

JavaScriptCore execution-time-limit watchdog only polls once per VM entry (WebKit Watchdog bug; worked around by re-arming from the callback) #241

Description

@matthargett

Summary

JavaScriptCore's execution-time-limit watchdog (JSContextGroupSetExecutionTimeLimit, JSContextRefPrivate.h) fires its callback once per VM entry when the callback returns false: Watchdog::shouldTerminate() never restarts the timer in its "callback did nothing" case. Any embedder that polls from that callback — which is how JsRuntimeHost's Worker polyfill lets Worker.terminate() stop a while (true) {} — gets exactly one poll and then a script that can no longer be interrupted.

This is a latent WebKit bug, not something in this repository. JsRuntimeHost works around it by re-setting the limit from inside the callback (the WebKit comment's "case 2"), which restarts the timer through setTimeLimit(). The workaround lives in Core/AppRuntime/Source/AppRuntime_JavaScriptCore.cpp on the Worker branch (ExecutionWatchdog::Rearm, fork commit rebeckerspecialties/JsRuntimeHost@c6f8afd) and will arrive here with the Worker polyfill PR. This issue records the engine behaviour so the re-arm is not "simplified" away later, and so the WebKit report can be tracked.

Where it goes wrong (WebKit Source/JavaScriptCore/runtime/Watchdog.cpp)

//   1. cleared the time limit (i.e. watchdog is disabled),
//   2. set a new time limit via Watchdog::setTimeLimit(), or
//   3. did nothing (i.e. allow another cycle of the current time limit).
...
bool callbackAlreadyStartedTimer = (m_cpuDeadline != noTimeLimit);
if (hasTimeLimit() && !callbackAlreadyStartedTimer)
    startTimer(m_timeLimit);

m_cpuDeadline is only reset to noTimeLimit by stopTimer() (called from exitedVM()). When shouldTerminate() runs after the CPU deadline expired, m_cpuDeadline still holds that expired deadline, so callbackAlreadyStartedTimer is true and case 3 schedules nothing. JSContextRefPrivate.h documents the callback as deciding whether to terminate, not as being responsible for re-arming.

Reproduction (standalone, against the system JavaScriptCore on macOS 27 / Darwin 27.0)

static int calls = 0;
static bool cb(JSContextRef ctx, void* data) { fprintf(stderr, "callback %d\n", ++calls); return calls >= 3; }
...
JSGlobalContextRef ctx = JSGlobalContextCreateInGroup(NULL, NULL);
JSContextGroupSetExecutionTimeLimit(JSContextGetGroup(ctx), 0.05, cb, NULL);
JSEvaluateScript(ctx, JSStringCreateWithUTF8CString("while (true) {}"), NULL, NULL, 1, &exception);
  • Expected: callback 1, callback 2, callback 3, then JSEvaluateScript returns with the termination exception after ~150 ms.
  • Actual: callback 1 only; the script runs forever.
  • With callback = NULL the script is terminated after 50 ms as documented, and re-setting the limit from inside the callback also works — which is the workaround.

Bun's Linux/Android JavaScriptCore archives (#206) carry the same code, so the behaviour is engine-wide, not Apple-specific. (Under ThreadSanitizer on Linux the trap that carries the termination is additionally undeliverable — signal-based VM traps — which is why the fork's TSan job sets JSC_usePollingTraps=1; that is a separate, TSan-only effect.)

Suggested WebKit fix

Clear m_cpuDeadline before invoking the callback so "callback started a timer" is only true when the callback's own setTimeLimit() → startTimer() ran:

    m_cpuDeadline = noTimeLimit;
    bool needsTermination = !m_callback || m_callback(globalObject, m_callbackData1, m_callbackData2);

A bugs.webkit.org report with this content is the next step (draft kept alongside the fork's integration notes); this issue tracks it from the JsRuntimeHost side.

Also worth knowing when touching this path

The termination exception the watchdog raises is a bare string ("JavaScript execution terminated.", VM::ensureTerminationException) since 2021, not an Error object; handing it to napi_create_reference used to trip a RELEASE_ASSERT inside JavaScriptCore on Apple platforms (fixed by #239).

Activity

  1. bghgary commented on Sep 17, 2026

    @bghgary
    Contributor

    [Commented by Copilot on behalf of @bghgary]

    Closing this until the Worker implementation is opened for review in BabylonJS/JsRuntimeHost. Please reopen it when the Worker PR is ready.

  2. matthargett commented on Sep 26, 2026

    @matthargett
    Author

    Reopening as requested: the Worker implementation is open for review as #258.

    The stack, bottom-up (each PR's diff includes the ones below it until they merge); every PR in it is current with main @ b2b51ca and mergeable:

    1. N-API: Build napi as a shared library on Android #183 → Add N-API compliance tests #116 → N-API v7 across runtimes (V8 / JavaScriptCore / Chakra) #189.
    2. Polyfills the worker realm exposes: Add optional WHATWG Streams polyfill #208 Streams, Add CompressionStream and DecompressionStream polyfills #211 Compression, Complete Blob streaming and slicing support #209 Blob streaming, Add browser-compatible Headers and streamed Response bodies #210 Headers/Response, Support data URLs in the fetch polyfill #212 data: fetch.
    3. Runtime fixes the Worker depends on: QuickJS: bound the JS stack to the thread's real stack; Chakra: define globalThis #237, Node-API (JavaScriptCore): only erase the environment registry entry we own #238, JSC Node-API: reference primitives and coerce property receivers (fixes a RELEASE_ASSERT on non-object exceptions) #239, Performance: keep the time origin per environment #240, Blob/URL: identify Blobs by our own constructor; make URL's static methods writable (WebIDL) #251, Keep runtime schedulers safe through teardown #254.
    4. Add a browser-compatible Worker polyfill (dedicated workers, structured clone, transfer lists, focused WPT regressions) #258, the Worker itself. The re-arm workaround described above is ExecutionWatchdog::Rearm in Core/AppRuntime/Source/AppRuntime_JavaScriptCore.cpp there, exercised by the WPT terminate-during-evaluation regression on JavaScriptCore.

    Review/merge order is the same.

  3. matthargett commented on Sep 26, 2026

    @matthargett
    Author

    I can't reopen this from my side (the API refuses the state change for a non-collaborator author, association NONE). @bghgary, would you mind reopening it now that #258 is up? The stack above is the full context.

  4. added this to the Future milestone on Sep 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions