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

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

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions