From 3cdf2b05be48657edd7df394d36fbf3092a185a4 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Wed, 26 Aug 2026 10:35:17 -0700 Subject: [PATCH 1/9] Pump V8's foreground task queue so async WebAssembly settles AppRuntime_V8 creates a default v8::Platform but nothing ever called v8::platform::PumpMessageLoop. V8 finishes asynchronous WebAssembly compilation on a background thread and then posts a *foreground* task to settle the promise on the isolate thread, so WebAssembly.compile, instantiate and instantiateStreaming never resolved or rejected - any Emscripten module hung forever. Sync `new WebAssembly.Module` was unaffected, which made this look like a hang rather than a failure. AppRuntime_JSI already did the equivalent via TaskRunnerAdapter; the direct-V8 path was simply missing it. DrainMicrotasks now pumps the queue with kDoNotWait, so it never blocks the JavaScript thread. Because that only runs after a dispatched callback, the platform is also wrapped in a DispatchingPlatform whose foreground task runner nudges the app dispatcher, giving a pump when the app is otherwise idle (no render loop, no timers). The wrapper leaves the default platform owning the queue so task ordering, nestability and delays keep V8's own semantics, and the wake is coalesced through an atomic flag so a burst of posted tasks cannot flood the dispatcher. The three new tests time out without this change. --- Core/AppRuntime/Source/AppRuntime_V8.cpp | 216 ++++++++++++++++++++++- Tests/UnitTests/Scripts/tests.ts | 36 ++++ 2 files changed, 247 insertions(+), 5 deletions(-) diff --git a/Core/AppRuntime/Source/AppRuntime_V8.cpp b/Core/AppRuntime/Source/AppRuntime_V8.cpp index 89928dcf..f9547c6b 100644 --- a/Core/AppRuntime/Source/AppRuntime_V8.cpp +++ b/Core/AppRuntime/Source/AppRuntime_V8.cpp @@ -7,12 +7,173 @@ #include #endif +#include +#include +#include +#include +#include #include +#include namespace Babylon { namespace { + // V8 hands work that finishes off-thread - most visibly asynchronous WebAssembly + // compilation - back to the isolate by posting a task to the platform's *foreground* + // task runner. Those tasks only run when someone calls v8::platform::PumpMessageLoop, + // and the host's JavaScript thread sits in a blocking dispatcher wait, so nothing + // would ever pump it: WebAssembly.compile/instantiate simply never settled, and + // anything built on an Emscripten module hung forever. + // + // This platform wraps the default one and leaves it owning the queue (so task + // ordering, nestability and delays keep V8's own semantics). All it adds is a + // wake-up: whenever V8 posts foreground work, the AppRuntime dispatcher is nudged, + // and the pump in AppRuntime::DrainMicrotasks then drains the queue on the + // JavaScript thread. + class DispatchingPlatform final : public v8::Platform + { + public: + explicit DispatchingPlatform(std::unique_ptr inner) + : m_inner{std::move(inner)} + { + } + + v8::Platform& Inner() + { + return *m_inner; + } + + void SetWake(v8::Isolate* isolate, std::function wake) + { + std::scoped_lock lock{m_mutex}; + if (wake) + { + m_wakes[isolate] = std::move(wake); + } + else + { + m_wakes.erase(isolate); + m_taskRunners.erase(isolate); + } + } + + void Wake(v8::Isolate* isolate) + { + std::function wake; + { + std::scoped_lock lock{m_mutex}; + const auto entry = m_wakes.find(isolate); + if (entry == m_wakes.end()) + { + return; + } + wake = entry->second; + } + wake(); + } + + v8::PageAllocator* GetPageAllocator() override { return m_inner->GetPageAllocator(); } + v8::ThreadIsolatedAllocator* GetThreadIsolatedAllocator() override { return m_inner->GetThreadIsolatedAllocator(); } + v8::ZoneBackingAllocator* GetZoneBackingAllocator() override { return m_inner->GetZoneBackingAllocator(); } + void OnCriticalMemoryPressure() override { m_inner->OnCriticalMemoryPressure(); } + int NumberOfWorkerThreads() override { return m_inner->NumberOfWorkerThreads(); } + + std::shared_ptr GetForegroundTaskRunner(v8::Isolate* isolate) override + { + return GetForegroundTaskRunner(isolate, v8::TaskPriority::kUserBlocking); + } + + std::shared_ptr GetForegroundTaskRunner(v8::Isolate* isolate, v8::TaskPriority priority) override; + + void CallOnWorkerThread(std::unique_ptr task) override { m_inner->CallOnWorkerThread(std::move(task)); } + void CallBlockingTaskOnWorkerThread(std::unique_ptr task) override { m_inner->CallBlockingTaskOnWorkerThread(std::move(task)); } + void CallLowPriorityTaskOnWorkerThread(std::unique_ptr task) override { m_inner->CallLowPriorityTaskOnWorkerThread(std::move(task)); } + void CallDelayedOnWorkerThread(std::unique_ptr task, double delayInSeconds) override { m_inner->CallDelayedOnWorkerThread(std::move(task), delayInSeconds); } + bool IdleTasksEnabled(v8::Isolate* isolate) override { return m_inner->IdleTasksEnabled(isolate); } + std::unique_ptr PostJob(v8::TaskPriority priority, std::unique_ptr jobTask) override { return m_inner->PostJob(priority, std::move(jobTask)); } + std::unique_ptr CreateJob(v8::TaskPriority priority, std::unique_ptr jobTask) override { return m_inner->CreateJob(priority, std::move(jobTask)); } + std::unique_ptr CreateBlockingScope(v8::BlockingType blockingType) override { return m_inner->CreateBlockingScope(blockingType); } + double MonotonicallyIncreasingTime() override { return m_inner->MonotonicallyIncreasingTime(); } + int64_t CurrentClockTimeMilliseconds() override { return m_inner->CurrentClockTimeMilliseconds(); } + double CurrentClockTimeMillis() override { return m_inner->CurrentClockTimeMillis(); } + double CurrentClockTimeMillisecondsHighResolution() override { return m_inner->CurrentClockTimeMillisecondsHighResolution(); } + StackTracePrinter GetStackTracePrinter() override { return m_inner->GetStackTracePrinter(); } + v8::TracingController* GetTracingController() override { return m_inner->GetTracingController(); } + void DumpWithoutCrashing() override { m_inner->DumpWithoutCrashing(); } + v8::HighAllocationThroughputObserver* GetHighAllocationThroughputObserver() override { return m_inner->GetHighAllocationThroughputObserver(); } + + private: + std::unique_ptr m_inner; + std::mutex m_mutex; + std::map> m_wakes; + std::map> m_taskRunners; + }; + + class WakingTaskRunner final : public v8::TaskRunner + { + public: + WakingTaskRunner(std::shared_ptr inner, DispatchingPlatform& platform, v8::Isolate* isolate) + : m_inner{std::move(inner)} + , m_platform{platform} + , m_isolate{isolate} + { + } + + void PostTask(std::unique_ptr task) override + { + m_inner->PostTask(std::move(task)); + m_platform.Wake(m_isolate); + } + + void PostNonNestableTask(std::unique_ptr task) override + { + m_inner->PostNonNestableTask(std::move(task)); + m_platform.Wake(m_isolate); + } + + void PostDelayedTask(std::unique_ptr task, double delayInSeconds) override + { + m_inner->PostDelayedTask(std::move(task), delayInSeconds); + m_platform.Wake(m_isolate); + } + + void PostNonNestableDelayedTask(std::unique_ptr task, double delayInSeconds) override + { + m_inner->PostNonNestableDelayedTask(std::move(task), delayInSeconds); + m_platform.Wake(m_isolate); + } + + void PostIdleTask(std::unique_ptr task) override + { + m_inner->PostIdleTask(std::move(task)); + } + + bool IdleTasksEnabled() override { return m_inner->IdleTasksEnabled(); } + bool NonNestableTasksEnabled() const override { return m_inner->NonNestableTasksEnabled(); } + bool NonNestableDelayedTasksEnabled() const override { return m_inner->NonNestableDelayedTasksEnabled(); } + + private: + std::shared_ptr m_inner; + DispatchingPlatform& m_platform; + v8::Isolate* m_isolate; + }; + + std::shared_ptr DispatchingPlatform::GetForegroundTaskRunner(v8::Isolate* isolate, v8::TaskPriority) + { + // The priority-aware overload is not implemented by libplatform's DefaultPlatform in + // this V8 version - the base class default returns nullptr - so always ask the inner + // platform for the plain per-isolate runner. The wrapper is cached because V8 calls + // this often and hands the result around by pointer. + std::scoped_lock lock{m_mutex}; + auto& runner = m_taskRunners[isolate]; + if (!runner) + { + runner = std::make_shared(m_inner->GetForegroundTaskRunner(isolate), *this, isolate); + } + return runner; + } + class Module final { public: @@ -20,7 +181,7 @@ namespace Babylon { v8::V8::InitializeICUDefaultLocation(executablePath); v8::V8::InitializeExternalStartupData(executablePath); - m_platform = v8::platform::NewDefaultPlatform(); + m_platform = std::make_unique(v8::platform::NewDefaultPlatform()); v8::V8::InitializePlatform(m_platform.get()); v8::V8::Initialize(); } @@ -49,13 +210,25 @@ namespace Babylon return *s_module; } - v8::Platform& Platform() + static Module* TryInstance() + { + return s_module.get(); + } + + DispatchingPlatform& Platform() { return *m_platform; } + // v8::platform::PumpMessageLoop downcasts to the libplatform DefaultPlatform, so it + // has to be handed the wrapped platform rather than the wrapper. + v8::Platform& DefaultPlatform() + { + return m_platform->Inner(); + } + private: - std::unique_ptr m_platform; + std::unique_ptr m_platform; static std::unique_ptr s_module; }; @@ -72,6 +245,20 @@ namespace Babylon create_params.array_buffer_allocator = v8::ArrayBuffer::Allocator::NewDefaultAllocator(); v8::Isolate* isolate = v8::Isolate::New(create_params); + // Nudge the dispatcher whenever V8 posts foreground work for this isolate, so the + // pump in DrainMicrotasks gets a chance to run it even when the app is otherwise + // idle (no render loop, no timers). The flag collapses bursts of posts into a single + // pending wake-up; it is cleared once that wake-up has been serviced. + auto wakePending = std::make_shared(false); + Module::Instance().Platform().SetWake(isolate, [this, wakePending]() { + if (wakePending->exchange(true)) + { + return; + } + + Dispatch([wakePending](Napi::Env) { wakePending->store(false); }); + }); + // Use the isolate within a scope. { v8::Isolate::Scope isolate_scope{isolate}; @@ -108,6 +295,8 @@ namespace Babylon } // Destroy the isolate. + Module::Instance().Platform().SetWake(isolate, nullptr); + // todo : GetArrayBufferAllocator not available? // delete isolate->GetArrayBufferAllocator(); isolate->Dispose(); @@ -115,7 +304,24 @@ namespace Babylon void AppRuntime::DrainMicrotasks(Napi::Env) { - // V8 auto-drains microtasks at the end of each script/callback when - // using the default MicrotasksPolicy. No explicit pump needed. + // V8 auto-drains microtasks at the end of each script/callback when using the default + // MicrotasksPolicy, but microtasks are not the whole story: work that V8 hands to the + // v8::Platform completes on a background thread and then posts a *foreground* task to + // settle its result on the isolate thread. Asynchronous WebAssembly compilation is the + // visible case - WebAssembly.compile/instantiate/instantiateStreaming would never + // resolve or reject, hanging any Emscripten module (and therefore anything built on + // one) forever. Nothing else in the host drains that queue, so pump it here, after + // every dispatched callback, which for a rendering app means at least once a frame. + Module* module{Module::TryInstance()}; + v8::Isolate* isolate{v8::Isolate::GetCurrent()}; + if (module == nullptr || isolate == nullptr) + { + return; + } + + // kDoNotWait: never block the JavaScript thread waiting for background work. + while (v8::platform::PumpMessageLoop(&module->DefaultPlatform(), isolate, v8::platform::MessageLoopBehavior::kDoNotWait)) + { + } } } diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index 1dc2aa4c..4255fd5f 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -2148,6 +2148,42 @@ describe("FileReader", function () { }); }); +describe("WebAssembly", function () { + this.timeout(30000); + + // Minimal valid module: the 8-byte header (magic + version) and no sections. + const emptyModule = new Uint8Array([0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00]); + + it("should settle the promise returned by WebAssembly.compile", async function () { + if (typeof WebAssembly === "undefined") { + this.skip(); + } + const module = await WebAssembly.compile(emptyModule); + expect(module).to.be.an.instanceof(WebAssembly.Module); + }); + + it("should settle the promise returned by WebAssembly.instantiate", async function () { + if (typeof WebAssembly === "undefined") { + this.skip(); + } + const result = await WebAssembly.instantiate(emptyModule); + expect(result.instance).to.be.an.instanceof(WebAssembly.Instance); + }); + + it("should reject the promise returned by WebAssembly.compile for invalid bytes", async function () { + if (typeof WebAssembly === "undefined") { + this.skip(); + } + let threw = false; + try { + await WebAssembly.compile(new Uint8Array([0x00, 0x61, 0x73, 0x6D, 0xFF])); + } catch (e) { + threw = true; + } + expect(threw).to.equal(true); + }); +}); + function runTests() { mocha.run((failures: number) => { // Test program will wait for code to be set before exiting From b7fc69ecdd8f966f2e3b979fd6f0be824a1bfbb4 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Wed, 26 Aug 2026 10:53:01 -0700 Subject: [PATCH 2/9] Document why the wake flag is cleared before pumping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09 --- Core/AppRuntime/Source/AppRuntime_V8.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Core/AppRuntime/Source/AppRuntime_V8.cpp b/Core/AppRuntime/Source/AppRuntime_V8.cpp index f9547c6b..bb106c92 100644 --- a/Core/AppRuntime/Source/AppRuntime_V8.cpp +++ b/Core/AppRuntime/Source/AppRuntime_V8.cpp @@ -248,7 +248,14 @@ namespace Babylon // Nudge the dispatcher whenever V8 posts foreground work for this isolate, so the // pump in DrainMicrotasks gets a chance to run it even when the app is otherwise // idle (no render loop, no timers). The flag collapses bursts of posts into a single - // pending wake-up; it is cleared once that wake-up has been serviced. + // pending wake-up. + // + // The flag must be cleared in the dispatched callback, which Dispatch runs *before* + // DrainMicrotasks pumps. Clearing it afterwards instead would lose wake-ups: a task + // posted between the pump draining the queue and the flag being cleared would find + // the flag still set, skip the dispatch, and then sit in the queue with nothing + // scheduled to pump it. Clearing first can only ever cost one redundant no-op + // dispatch, because such a post is already covered by the pump that follows. auto wakePending = std::make_shared(false); Module::Instance().Platform().SetWake(isolate, [this, wakePending]() { if (wakePending->exchange(true)) From 4a5d7226a8c999c8afb9fa83da28fc1a20923454 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Wed, 26 Aug 2026 12:59:51 -0700 Subject: [PATCH 3/9] Fix the Android V8 build and scope the WebAssembly tests to V8 CI caught two problems the local V8 build could not. Android builds against V8 11.0 while desktop uses 11.9, and DispatchingPlatform forwarded the whole v8::Platform interface, including five members that do not exist in 11.0 (ThreadIsolatedAllocator, CreateBlockingScope, CurrentClockTimeMilliseconds, its high-resolution variant, and the TaskPriority overload of GetForegroundTaskRunner). Overriding a method the base class does not declare is a hard error, so those are now version-gated and the two GetForegroundTaskRunner overloads share a helper. All nine of 11.0's pure virtuals are still overridden; the gated-out members fall back to v8::Platform's own defaults, which are benign for each of them. The WebAssembly tests also ran on every engine, but the fix is V8-only, so JSC/Chakra/Hermes/QuickJS hit three 30s timeouts instead of failing fast. Expose the configured engine as a hostEngine global, mirroring hostPlatform, and skip the suite off V8. Chakra: 217 passing, 3 pending, 11.7s (was ~90s of timeouts). V8: 220 passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09 --- Core/AppRuntime/Source/AppRuntime_V8.cpp | 39 +++++++++++++++---- .../Android/app/src/main/cpp/CMakeLists.txt | 1 + Tests/UnitTests/CMakeLists.txt | 1 + Tests/UnitTests/Scripts/tests.ts | 19 ++++----- Tests/UnitTests/Shared/Shared.cpp | 1 + 5 files changed, 45 insertions(+), 16 deletions(-) diff --git a/Core/AppRuntime/Source/AppRuntime_V8.cpp b/Core/AppRuntime/Source/AppRuntime_V8.cpp index bb106c92..cd13332e 100644 --- a/Core/AppRuntime/Source/AppRuntime_V8.cpp +++ b/Core/AppRuntime/Source/AppRuntime_V8.cpp @@ -2,6 +2,16 @@ #include #include +#include + +// Android builds against V8 11.0, desktop against 11.9. A few v8::Platform members below do not +// exist in 11.0, and overriding a method the base class does not declare is a hard error, so they +// are gated. Where a member is gated out, v8::Platform's own default is used instead of forwarding +// to the inner platform; all of them are optional hooks whose defaults are benign (null allocator, +// null blocking scope, clock values derived from CurrentClockTimeMillis). +#define JSRH_V8_AT_LEAST(major, minor) \ + (V8_MAJOR_VERSION > (major) || (V8_MAJOR_VERSION == (major) && V8_MINOR_VERSION >= (minor))) + #ifdef ENABLE_V8_INSPECTOR #include @@ -74,17 +84,24 @@ namespace Babylon } v8::PageAllocator* GetPageAllocator() override { return m_inner->GetPageAllocator(); } +#if JSRH_V8_AT_LEAST(11, 9) v8::ThreadIsolatedAllocator* GetThreadIsolatedAllocator() override { return m_inner->GetThreadIsolatedAllocator(); } +#endif v8::ZoneBackingAllocator* GetZoneBackingAllocator() override { return m_inner->GetZoneBackingAllocator(); } void OnCriticalMemoryPressure() override { m_inner->OnCriticalMemoryPressure(); } int NumberOfWorkerThreads() override { return m_inner->NumberOfWorkerThreads(); } std::shared_ptr GetForegroundTaskRunner(v8::Isolate* isolate) override { - return GetForegroundTaskRunner(isolate, v8::TaskPriority::kUserBlocking); + return WrapForegroundTaskRunner(isolate); } - std::shared_ptr GetForegroundTaskRunner(v8::Isolate* isolate, v8::TaskPriority priority) override; +#if JSRH_V8_AT_LEAST(11, 9) + std::shared_ptr GetForegroundTaskRunner(v8::Isolate* isolate, v8::TaskPriority) override + { + return WrapForegroundTaskRunner(isolate); + } +#endif void CallOnWorkerThread(std::unique_ptr task) override { m_inner->CallOnWorkerThread(std::move(task)); } void CallBlockingTaskOnWorkerThread(std::unique_ptr task) override { m_inner->CallBlockingTaskOnWorkerThread(std::move(task)); } @@ -93,17 +110,25 @@ namespace Babylon bool IdleTasksEnabled(v8::Isolate* isolate) override { return m_inner->IdleTasksEnabled(isolate); } std::unique_ptr PostJob(v8::TaskPriority priority, std::unique_ptr jobTask) override { return m_inner->PostJob(priority, std::move(jobTask)); } std::unique_ptr CreateJob(v8::TaskPriority priority, std::unique_ptr jobTask) override { return m_inner->CreateJob(priority, std::move(jobTask)); } +#if JSRH_V8_AT_LEAST(11, 9) std::unique_ptr CreateBlockingScope(v8::BlockingType blockingType) override { return m_inner->CreateBlockingScope(blockingType); } +#endif double MonotonicallyIncreasingTime() override { return m_inner->MonotonicallyIncreasingTime(); } +#if JSRH_V8_AT_LEAST(11, 9) int64_t CurrentClockTimeMilliseconds() override { return m_inner->CurrentClockTimeMilliseconds(); } +#endif double CurrentClockTimeMillis() override { return m_inner->CurrentClockTimeMillis(); } +#if JSRH_V8_AT_LEAST(11, 9) double CurrentClockTimeMillisecondsHighResolution() override { return m_inner->CurrentClockTimeMillisecondsHighResolution(); } +#endif StackTracePrinter GetStackTracePrinter() override { return m_inner->GetStackTracePrinter(); } v8::TracingController* GetTracingController() override { return m_inner->GetTracingController(); } void DumpWithoutCrashing() override { m_inner->DumpWithoutCrashing(); } v8::HighAllocationThroughputObserver* GetHighAllocationThroughputObserver() override { return m_inner->GetHighAllocationThroughputObserver(); } private: + std::shared_ptr WrapForegroundTaskRunner(v8::Isolate* isolate); + std::unique_ptr m_inner; std::mutex m_mutex; std::map> m_wakes; @@ -159,12 +184,12 @@ namespace Babylon v8::Isolate* m_isolate; }; - std::shared_ptr DispatchingPlatform::GetForegroundTaskRunner(v8::Isolate* isolate, v8::TaskPriority) + std::shared_ptr DispatchingPlatform::WrapForegroundTaskRunner(v8::Isolate* isolate) { - // The priority-aware overload is not implemented by libplatform's DefaultPlatform in - // this V8 version - the base class default returns nullptr - so always ask the inner - // platform for the plain per-isolate runner. The wrapper is cached because V8 calls - // this often and hands the result around by pointer. + // Always ask the inner platform for the plain per-isolate runner: libplatform's + // DefaultPlatform does not implement the priority-aware overload in either V8 version + // we build against. The wrapper is cached because V8 calls this often and hands the + // result around by pointer. std::scoped_lock lock{m_mutex}; auto& runner = m_taskRunners[isolate]; if (!runner) diff --git a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt index 0af5caa8..e25e6df6 100644 --- a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt +++ b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt @@ -22,6 +22,7 @@ add_library(UnitTestsJNI SHARED ${UNIT_TESTS_DIR}/Shared/Shared.cpp) target_compile_definitions(UnitTestsJNI PRIVATE JSRUNTIMEHOST_PLATFORM="${JSRUNTIMEHOST_PLATFORM}") +target_compile_definitions(UnitTestsJNI PRIVATE JSRUNTIMEHOST_ENGINE="${NAPI_JAVASCRIPT_ENGINE}") target_compile_definitions(UnitTestsJNI PRIVATE ARCANA_TEST_HOOKS) target_include_directories(UnitTestsJNI diff --git a/Tests/UnitTests/CMakeLists.txt b/Tests/UnitTests/CMakeLists.txt index 2dbc7619..9111db81 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -45,6 +45,7 @@ endif() add_executable(UnitTests ${SOURCES} ${SCRIPTS} ${TYPE_SCRIPTS} ${ASSETS}) target_compile_definitions(UnitTests PRIVATE JSRUNTIMEHOST_PLATFORM="${JSRUNTIMEHOST_PLATFORM}") +target_compile_definitions(UnitTests PRIVATE JSRUNTIMEHOST_ENGINE="${NAPI_JAVASCRIPT_ENGINE}") # The V8JSI Node-API shim does not implement napi_create_dataview, so the # CreateDataViewRejectsOverflowingRange test is compiled out on that backend. diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index 4255fd5f..562f2bff 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -6,6 +6,7 @@ Mocha.setup('bdd'); Mocha.reporter('spec'); declare const hostPlatform: string; +declare const hostEngine: string; declare const setExitCode: (code: number) => void; @@ -2151,29 +2152,29 @@ describe("FileReader", function () { describe("WebAssembly", function () { this.timeout(30000); + // Only the V8 AppRuntime pumps V8's foreground task queue, which is what lets these promises + // settle. The other engines' runtimes have the same class of gap and hang here instead of + // failing, so scope the suite rather than leave a 30s timeout on every non-V8 leg. + beforeEach(function () { + if (hostEngine !== "V8" || typeof WebAssembly === "undefined") { + this.skip(); + } + }); + // Minimal valid module: the 8-byte header (magic + version) and no sections. const emptyModule = new Uint8Array([0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00]); it("should settle the promise returned by WebAssembly.compile", async function () { - if (typeof WebAssembly === "undefined") { - this.skip(); - } const module = await WebAssembly.compile(emptyModule); expect(module).to.be.an.instanceof(WebAssembly.Module); }); it("should settle the promise returned by WebAssembly.instantiate", async function () { - if (typeof WebAssembly === "undefined") { - this.skip(); - } const result = await WebAssembly.instantiate(emptyModule); expect(result.instance).to.be.an.instanceof(WebAssembly.Instance); }); it("should reject the promise returned by WebAssembly.compile for invalid bytes", async function () { - if (typeof WebAssembly === "undefined") { - this.skip(); - } let threw = false; try { await WebAssembly.compile(new Uint8Array([0x00, 0x61, 0x73, 0x6D, 0xFF])); diff --git a/Tests/UnitTests/Shared/Shared.cpp b/Tests/UnitTests/Shared/Shared.cpp index d1c2aa44..07db1559 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -100,6 +100,7 @@ TEST(JavaScript, All) env.Global().Set("setExitCode", setExitCodeCallback); env.Global().Set("hostPlatform", Napi::Value::From(env, JSRUNTIMEHOST_PLATFORM)); + env.Global().Set("hostEngine", Napi::Value::From(env, JSRUNTIMEHOST_ENGINE)); }); Babylon::ScriptLoader loader{runtime}; From 7bab0cd33cb2b5f0c443ce8c0439e5a731911bee Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Thu, 3 Sep 2026 17:51:48 -0700 Subject: [PATCH 4/9] Dispatch V8 foreground tasks through AppRuntime Replace the wake-and-pump bridge with a foreground task runner that posts v8::Task::Run onto AppRuntime's dispatcher, matching Chromium's V8ForegroundTaskRunner model. Delayed posts reuse a native DeadlineScheduler extracted from TimeoutDispatcher so setTimeout and V8 delayed tasks share one queue. Also name the unit-test compile definition NAPI_JAVASCRIPT_ENGINE to match JSRUNTIMEHOST_PLATFORM. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: da74bc94-a7dc-4817-bd81-59b5c6b123fc --- Core/AppRuntime/Include/Babylon/AppRuntime.h | 4 + Core/AppRuntime/Source/AppRuntime.cpp | 10 +- Core/AppRuntime/Source/AppRuntime_V8.cpp | 237 ++++++++++-------- Core/JsRuntime/CMakeLists.txt | 2 + .../Include/Babylon/DeadlineScheduler.h | 34 +++ Core/JsRuntime/Include/Babylon/JsRuntime.h | 10 +- Core/JsRuntime/Source/DeadlineScheduler.cpp | 205 +++++++++++++++ Core/JsRuntime/Source/JsRuntime.cpp | 35 ++- .../Scheduling/Source/TimeoutDispatcher.cpp | 123 ++------- .../Scheduling/Source/TimeoutDispatcher.h | 19 +- .../Android/app/src/main/cpp/CMakeLists.txt | 2 +- Tests/UnitTests/CMakeLists.txt | 2 +- Tests/UnitTests/Shared/Shared.cpp | 2 +- 13 files changed, 458 insertions(+), 227 deletions(-) create mode 100644 Core/JsRuntime/Include/Babylon/DeadlineScheduler.h create mode 100644 Core/JsRuntime/Source/DeadlineScheduler.cpp diff --git a/Core/AppRuntime/Include/Babylon/AppRuntime.h b/Core/AppRuntime/Include/Babylon/AppRuntime.h index e001fa32..7142cbf9 100644 --- a/Core/AppRuntime/Include/Babylon/AppRuntime.h +++ b/Core/AppRuntime/Include/Babylon/AppRuntime.h @@ -12,6 +12,8 @@ namespace Babylon { + class DeadlineScheduler; + class AppRuntime final { public: @@ -76,6 +78,8 @@ namespace Babylon // queue explicitly (Napi::DrainJobs / JS_ExecutePendingJob). void DrainMicrotasks(Napi::Env env); + DeadlineScheduler& GetDeadlineScheduler(); + Options m_options; class Impl; diff --git a/Core/AppRuntime/Source/AppRuntime.cpp b/Core/AppRuntime/Source/AppRuntime.cpp index 176bc849..5f6d9298 100644 --- a/Core/AppRuntime/Source/AppRuntime.cpp +++ b/Core/AppRuntime/Source/AppRuntime.cpp @@ -1,5 +1,7 @@ #include "AppRuntime.h" +#include + #include #include @@ -35,6 +37,7 @@ namespace Babylon std::optional> m_suspensionLock{}; arcana::cancellation_source m_cancelSource{}; arcana::manual_dispatcher<128> m_dispatcher{}; + std::unique_ptr m_deadlineScheduler{std::make_unique()}; std::thread m_thread; }; @@ -50,7 +53,7 @@ namespace Babylon m_impl->m_thread = std::thread{[this] { RunPlatformTier(); }}; Dispatch([this](Napi::Env env) { - JsRuntime::CreateForJavaScript(env, [this](auto func) { Dispatch(std::move(func)); }); + JsRuntime::CreateForJavaScript(env, [this](auto func) { Dispatch(std::move(func)); }, GetDeadlineScheduler()); }); } @@ -91,6 +94,11 @@ namespace Babylon m_impl->m_dispatcher.clear(); } + DeadlineScheduler& AppRuntime::GetDeadlineScheduler() + { + return *m_impl->m_deadlineScheduler; + } + void AppRuntime::Suspend() { auto suspensionMutex = std::make_shared(); diff --git a/Core/AppRuntime/Source/AppRuntime_V8.cpp b/Core/AppRuntime/Source/AppRuntime_V8.cpp index cd13332e..aad6cbdc 100644 --- a/Core/AppRuntime/Source/AppRuntime_V8.cpp +++ b/Core/AppRuntime/Source/AppRuntime_V8.cpp @@ -1,6 +1,8 @@ #include "AppRuntime.h" #include +#include + #include #include @@ -17,13 +19,15 @@ #include #endif -#include +#include #include #include #include #include #include +#include #include +#include namespace Babylon { @@ -31,16 +35,10 @@ namespace Babylon { // V8 hands work that finishes off-thread - most visibly asynchronous WebAssembly // compilation - back to the isolate by posting a task to the platform's *foreground* - // task runner. Those tasks only run when someone calls v8::platform::PumpMessageLoop, - // and the host's JavaScript thread sits in a blocking dispatcher wait, so nothing - // would ever pump it: WebAssembly.compile/instantiate simply never settled, and - // anything built on an Emscripten module hung forever. - // - // This platform wraps the default one and leaves it owning the queue (so task - // ordering, nestability and delays keep V8's own semantics). All it adds is a - // wake-up: whenever V8 posts foreground work, the AppRuntime dispatcher is nudged, - // and the pump in AppRuntime::DrainMicrotasks then drains the queue on the - // JavaScript thread. + // task runner. Chromium runs those as `v8::Task::Run` on the host task runner. The + // host JavaScript thread is AppRuntime's dispatcher, so this platform's foreground + // runner posts the task itself instead of parking it on libplatform's queue and + // pumping later. Delayed posts reuse the DeadlineScheduler behind setTimeout. class DispatchingPlatform final : public v8::Platform { public: @@ -49,38 +47,52 @@ namespace Babylon { } - v8::Platform& Inner() + struct Host + { + AppRuntime* runtime{}; + DeadlineScheduler* scheduler{}; + }; + + // Isolate::New can ask for a foreground runner before SetHost has the + // isolate pointer. Posts from this thread during construction fall back + // to the thread-local host set around New. + void SetCurrentHost(AppRuntime* runtime, DeadlineScheduler* scheduler) { - return *m_inner; + t_currentRuntime = runtime; + t_currentScheduler = scheduler; } - void SetWake(v8::Isolate* isolate, std::function wake) + void SetHost(v8::Isolate* isolate, AppRuntime* runtime, DeadlineScheduler* scheduler) { - std::scoped_lock lock{m_mutex}; - if (wake) + std::shared_ptr runner; { - m_wakes[isolate] = std::move(wake); - } - else - { - m_wakes.erase(isolate); - m_taskRunners.erase(isolate); + std::scoped_lock lock{m_mutex}; + if (runtime != nullptr && scheduler != nullptr) + { + m_hosts[isolate] = Host{runtime, scheduler}; + } + else + { + m_hosts.erase(isolate); + auto entry = m_taskRunners.find(isolate); + if (entry != m_taskRunners.end()) + { + runner = std::move(entry->second); + m_taskRunners.erase(entry); + } + } } } - void Wake(v8::Isolate* isolate) + Host GetHost(v8::Isolate* isolate) { - std::function wake; + std::scoped_lock lock{m_mutex}; + const auto entry = m_hosts.find(isolate); + if (entry != m_hosts.end()) { - std::scoped_lock lock{m_mutex}; - const auto entry = m_wakes.find(isolate); - if (entry == m_wakes.end()) - { - return; - } - wake = entry->second; + return entry->second; } - wake(); + return {t_currentRuntime, t_currentScheduler}; } v8::PageAllocator* GetPageAllocator() override { return m_inner->GetPageAllocator(); } @@ -107,7 +119,7 @@ namespace Babylon void CallBlockingTaskOnWorkerThread(std::unique_ptr task) override { m_inner->CallBlockingTaskOnWorkerThread(std::move(task)); } void CallLowPriorityTaskOnWorkerThread(std::unique_ptr task) override { m_inner->CallLowPriorityTaskOnWorkerThread(std::move(task)); } void CallDelayedOnWorkerThread(std::unique_ptr task, double delayInSeconds) override { m_inner->CallDelayedOnWorkerThread(std::move(task), delayInSeconds); } - bool IdleTasksEnabled(v8::Isolate* isolate) override { return m_inner->IdleTasksEnabled(isolate); } + bool IdleTasksEnabled(v8::Isolate*) override { return false; } std::unique_ptr PostJob(v8::TaskPriority priority, std::unique_ptr jobTask) override { return m_inner->PostJob(priority, std::move(jobTask)); } std::unique_ptr CreateJob(v8::TaskPriority priority, std::unique_ptr jobTask) override { return m_inner->CreateJob(priority, std::move(jobTask)); } #if JSRH_V8_AT_LEAST(11, 9) @@ -131,70 +143,123 @@ namespace Babylon std::unique_ptr m_inner; std::mutex m_mutex; - std::map> m_wakes; + std::map m_hosts; std::map> m_taskRunners; + + static thread_local AppRuntime* t_currentRuntime; + static thread_local DeadlineScheduler* t_currentScheduler; }; - class WakingTaskRunner final : public v8::TaskRunner + thread_local AppRuntime* DispatchingPlatform::t_currentRuntime{}; + thread_local DeadlineScheduler* DispatchingPlatform::t_currentScheduler{}; + + class DispatchingTaskRunner final : public v8::TaskRunner { public: - WakingTaskRunner(std::shared_ptr inner, DispatchingPlatform& platform, v8::Isolate* isolate) - : m_inner{std::move(inner)} - , m_platform{platform} + DispatchingTaskRunner(DispatchingPlatform& platform, v8::Isolate* isolate) + : m_platform{platform} , m_isolate{isolate} { } + ~DispatchingTaskRunner() override + { + std::scoped_lock lock{m_mutex}; + for (const auto& pending : m_pending) + { + pending.scheduler->Cancel(pending.id); + } + m_pending.clear(); + } + void PostTask(std::unique_ptr task) override { - m_inner->PostTask(std::move(task)); - m_platform.Wake(m_isolate); + PostImmediate(std::move(task)); } void PostNonNestableTask(std::unique_ptr task) override { - m_inner->PostNonNestableTask(std::move(task)); - m_platform.Wake(m_isolate); + PostImmediate(std::move(task)); } void PostDelayedTask(std::unique_ptr task, double delayInSeconds) override { - m_inner->PostDelayedTask(std::move(task), delayInSeconds); - m_platform.Wake(m_isolate); + PostDelayed(std::move(task), delayInSeconds); } void PostNonNestableDelayedTask(std::unique_ptr task, double delayInSeconds) override { - m_inner->PostNonNestableDelayedTask(std::move(task), delayInSeconds); - m_platform.Wake(m_isolate); + PostDelayed(std::move(task), delayInSeconds); } - void PostIdleTask(std::unique_ptr task) override + void PostIdleTask(std::unique_ptr) override { - m_inner->PostIdleTask(std::move(task)); } - bool IdleTasksEnabled() override { return m_inner->IdleTasksEnabled(); } - bool NonNestableTasksEnabled() const override { return m_inner->NonNestableTasksEnabled(); } - bool NonNestableDelayedTasksEnabled() const override { return m_inner->NonNestableDelayedTasksEnabled(); } + bool IdleTasksEnabled() override { return false; } + bool NonNestableTasksEnabled() const override { return true; } + bool NonNestableDelayedTasksEnabled() const override { return true; } private: - std::shared_ptr m_inner; + void PostImmediate(std::unique_ptr task) + { + auto host = m_platform.GetHost(m_isolate); + if (host.runtime == nullptr) + { + return; + } + + auto shared = std::shared_ptr(std::move(task)); + host.runtime->Dispatch([shared, isolate = m_isolate](Napi::Env) { + v8::Isolate::Scope isolate_scope{isolate}; + shared->Run(); + }); + } + + void PostDelayed(std::unique_ptr task, double delayInSeconds) + { + auto host = m_platform.GetHost(m_isolate); + if (host.runtime == nullptr || host.scheduler == nullptr) + { + return; + } + + auto shared = std::shared_ptr(std::move(task)); + auto delay = std::chrono::duration_cast(std::chrono::duration(delayInSeconds)); + if (delay.count() < 0) + { + delay = std::chrono::milliseconds{0}; + } + + std::scoped_lock lock{m_mutex}; + const auto id = host.scheduler->Schedule(delay, [runtime = host.runtime, isolate = m_isolate, shared]() { + runtime->Dispatch([shared, isolate](Napi::Env) { + v8::Isolate::Scope isolate_scope{isolate}; + shared->Run(); + }); + }); + m_pending.push_back(Pending{host.scheduler, id}); + } + + struct Pending + { + DeadlineScheduler* scheduler{}; + DeadlineScheduler::Id id{}; + }; + DispatchingPlatform& m_platform; v8::Isolate* m_isolate; + std::mutex m_mutex; + std::vector m_pending; }; std::shared_ptr DispatchingPlatform::WrapForegroundTaskRunner(v8::Isolate* isolate) { - // Always ask the inner platform for the plain per-isolate runner: libplatform's - // DefaultPlatform does not implement the priority-aware overload in either V8 version - // we build against. The wrapper is cached because V8 calls this often and hands the - // result around by pointer. std::scoped_lock lock{m_mutex}; auto& runner = m_taskRunners[isolate]; if (!runner) { - runner = std::make_shared(m_inner->GetForegroundTaskRunner(isolate), *this, isolate); + runner = std::make_shared(*this, isolate); } return runner; } @@ -235,23 +300,11 @@ namespace Babylon return *s_module; } - static Module* TryInstance() - { - return s_module.get(); - } - DispatchingPlatform& Platform() { return *m_platform; } - // v8::platform::PumpMessageLoop downcasts to the libplatform DefaultPlatform, so it - // has to be handed the wrapped platform rather than the wrapper. - v8::Platform& DefaultPlatform() - { - return m_platform->Inner(); - } - private: std::unique_ptr m_platform; @@ -265,31 +318,13 @@ namespace Babylon { // Create the isolate. Module::Initialize(executablePath); + Module::Instance().Platform().SetCurrentHost(this, &GetDeadlineScheduler()); v8::Isolate::CreateParams create_params; create_params.array_buffer_allocator = v8::ArrayBuffer::Allocator::NewDefaultAllocator(); v8::Isolate* isolate = v8::Isolate::New(create_params); - // Nudge the dispatcher whenever V8 posts foreground work for this isolate, so the - // pump in DrainMicrotasks gets a chance to run it even when the app is otherwise - // idle (no render loop, no timers). The flag collapses bursts of posts into a single - // pending wake-up. - // - // The flag must be cleared in the dispatched callback, which Dispatch runs *before* - // DrainMicrotasks pumps. Clearing it afterwards instead would lose wake-ups: a task - // posted between the pump draining the queue and the flag being cleared would find - // the flag still set, skip the dispatch, and then sit in the queue with nothing - // scheduled to pump it. Clearing first can only ever cost one redundant no-op - // dispatch, because such a post is already covered by the pump that follows. - auto wakePending = std::make_shared(false); - Module::Instance().Platform().SetWake(isolate, [this, wakePending]() { - if (wakePending->exchange(true)) - { - return; - } - - Dispatch([wakePending](Napi::Env) { wakePending->store(false); }); - }); + Module::Instance().Platform().SetHost(isolate, this, &GetDeadlineScheduler()); // Use the isolate within a scope. { @@ -327,7 +362,8 @@ namespace Babylon } // Destroy the isolate. - Module::Instance().Platform().SetWake(isolate, nullptr); + Module::Instance().Platform().SetHost(isolate, nullptr, nullptr); + Module::Instance().Platform().SetCurrentHost(nullptr, nullptr); // todo : GetArrayBufferAllocator not available? // delete isolate->GetArrayBufferAllocator(); @@ -337,23 +373,8 @@ namespace Babylon void AppRuntime::DrainMicrotasks(Napi::Env) { // V8 auto-drains microtasks at the end of each script/callback when using the default - // MicrotasksPolicy, but microtasks are not the whole story: work that V8 hands to the - // v8::Platform completes on a background thread and then posts a *foreground* task to - // settle its result on the isolate thread. Asynchronous WebAssembly compilation is the - // visible case - WebAssembly.compile/instantiate/instantiateStreaming would never - // resolve or reject, hanging any Emscripten module (and therefore anything built on - // one) forever. Nothing else in the host drains that queue, so pump it here, after - // every dispatched callback, which for a rendering app means at least once a frame. - Module* module{Module::TryInstance()}; - v8::Isolate* isolate{v8::Isolate::GetCurrent()}; - if (module == nullptr || isolate == nullptr) - { - return; - } - - // kDoNotWait: never block the JavaScript thread waiting for background work. - while (v8::platform::PumpMessageLoop(&module->DefaultPlatform(), isolate, v8::platform::MessageLoopBehavior::kDoNotWait)) - { - } + // MicrotasksPolicy. Foreground platform tasks (including async WebAssembly settlement) + // are posted through DispatchingTaskRunner onto AppRuntime's dispatcher, so they do + // not need a PumpMessageLoop here. } } diff --git a/Core/JsRuntime/CMakeLists.txt b/Core/JsRuntime/CMakeLists.txt index a5f12428..cf1048e3 100644 --- a/Core/JsRuntime/CMakeLists.txt +++ b/Core/JsRuntime/CMakeLists.txt @@ -1,6 +1,8 @@ set(SOURCES + "Include/Babylon/DeadlineScheduler.h" "Include/Babylon/JsRuntime.h" "Include/Babylon/JsRuntimeScheduler.h" + "Source/DeadlineScheduler.cpp" "Source/JsRuntime.cpp") add_library(JsRuntime ${SOURCES}) diff --git a/Core/JsRuntime/Include/Babylon/DeadlineScheduler.h b/Core/JsRuntime/Include/Babylon/DeadlineScheduler.h new file mode 100644 index 00000000..46c88557 --- /dev/null +++ b/Core/JsRuntime/Include/Babylon/DeadlineScheduler.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include + +namespace Babylon +{ + /// Native deadline queue used by setTimeout/setInterval and by the V8 + /// foreground task runner's delayed posts. Callbacks run on the scheduler + /// thread; callers that need the JavaScript thread Dispatch themselves. + class DeadlineScheduler final + { + public: + using Id = int32_t; + using Callback = std::function; + using TimePoint = std::chrono::time_point; + + DeadlineScheduler(); + ~DeadlineScheduler(); + + DeadlineScheduler(const DeadlineScheduler&) = delete; + DeadlineScheduler& operator=(const DeadlineScheduler&) = delete; + + Id Schedule(TimePoint when, Callback callback); + Id Schedule(std::chrono::milliseconds delay, Callback callback); + void Cancel(Id id); + + private: + class Impl; + std::unique_ptr m_impl; + }; +} diff --git a/Core/JsRuntime/Include/Babylon/JsRuntime.h b/Core/JsRuntime/Include/Babylon/JsRuntime.h index 75a7f4d9..51e6f1b7 100644 --- a/Core/JsRuntime/Include/Babylon/JsRuntime.h +++ b/Core/JsRuntime/Include/Babylon/JsRuntime.h @@ -4,10 +4,13 @@ #include #include +#include #include namespace Babylon { + class DeadlineScheduler; + class JsRuntime { public: @@ -37,17 +40,22 @@ namespace Babylon // later -- an instance of an inheriting type, for example. The dispatch function // must be safely callable as soon as it is passed to the JsRuntime constructor. static JsRuntime& BABYLON_API CreateForJavaScript(Napi::Env, DispatchFunctionT); + static JsRuntime& BABYLON_API CreateForJavaScript(Napi::Env, DispatchFunctionT, DeadlineScheduler&); static JsRuntime& BABYLON_API GetFromJavaScript(Napi::Env); void Dispatch(std::function); + DeadlineScheduler& GetDeadlineScheduler(); + ~JsRuntime(); protected: JsRuntime(const JsRuntime&) = delete; JsRuntime& operator=(const JsRuntime&) = delete; private: - JsRuntime(Napi::Env, DispatchFunctionT); + JsRuntime(Napi::Env, DispatchFunctionT, DeadlineScheduler*); DispatchFunctionT m_dispatchFunction{}; std::mutex m_mutex{}; + std::unique_ptr m_ownedDeadlineScheduler{}; + DeadlineScheduler* m_deadlineScheduler{}; }; } diff --git a/Core/JsRuntime/Source/DeadlineScheduler.cpp b/Core/JsRuntime/Source/DeadlineScheduler.cpp new file mode 100644 index 00000000..f3157391 --- /dev/null +++ b/Core/JsRuntime/Source/DeadlineScheduler.cpp @@ -0,0 +1,205 @@ +#include "DeadlineScheduler.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Babylon +{ + namespace + { + DeadlineScheduler::TimePoint Now() + { + return std::chrono::time_point_cast(std::chrono::steady_clock::now()); + } + } + + class DeadlineScheduler::Impl + { + public: + Impl() + : m_thread{&Impl::ThreadFunction, this} + { + } + + ~Impl() + { + { + std::unique_lock lk{m_mutex}; + m_shutdown = true; + m_idMap.clear(); + m_timeMap.clear(); + } + + m_condVariable.notify_one(); + m_thread.join(); + } + + Id Schedule(TimePoint when, Callback callback) + { + std::unique_lock lk{m_mutex}; + if (m_shutdown) + { + throw std::runtime_error{"DeadlineScheduler: Schedule after shutdown"}; + } + + const auto earliestTime = m_timeMap.empty() ? TimePoint::max() : m_timeMap.begin()->first; + const auto id = NextId(); + auto item = std::make_unique(id, when, std::move(callback)); + Item* const raw = item.get(); + const auto [it, inserted] = m_idMap.try_emplace(id, std::move(item)); + if (!inserted) + { + throw std::logic_error{"DeadlineScheduler: NextId returned a duplicate id"}; + } + m_timeMap.insert({when, raw}); + + if (when <= earliestTime) + { + m_condVariable.notify_one(); + } + + return id; + } + + void Cancel(Id id) + { + std::unique_lock lk{m_mutex}; + const auto it = m_idMap.find(id); + if (it == m_idMap.end()) + { + return; + } + + const auto timeRange = m_timeMap.equal_range(it->second->time); + for (auto itTime = timeRange.first; itTime != timeRange.second; ++itTime) + { + if (itTime->second->id == id) + { + m_timeMap.erase(itTime); + break; + } + } + + m_idMap.erase(it); + } + + private: + struct Item + { + Id id; + TimePoint time; + Callback callback; + + Item(Id id, TimePoint time, Callback callback) + : id{id} + , time{time} + , callback{std::move(callback)} + { + } + }; + + Id NextId() + { + while (true) + { + ++m_lastId; + if (m_lastId <= 0) + { + m_lastId = 1; + } + + if (m_idMap.find(m_lastId) == m_idMap.end()) + { + return m_lastId; + } + } + } + + void ThreadFunction() + { + while (!m_shutdown) + { + std::unique_lock lk{m_mutex}; + while (!m_shutdown && m_timeMap.empty()) + { + m_condVariable.wait(lk); + } + + if (m_shutdown) + { + return; + } + + const auto nextTimePoint = m_timeMap.begin()->first; + if (nextTimePoint > Now()) + { + m_condVariable.wait_until(lk, nextTimePoint); + if (m_shutdown) + { + return; + } + } + + std::vector due; + while (!m_timeMap.empty() && m_timeMap.begin()->first <= Now()) + { + Item* const item = m_timeMap.begin()->second; + due.push_back(std::move(item->callback)); + m_idMap.erase(item->id); + m_timeMap.erase(m_timeMap.begin()); + } + + lk.unlock(); + for (auto& callback : due) + { + if (callback) + { + callback(); + } + } + } + } + + std::mutex m_mutex{}; + std::condition_variable m_condVariable{}; + Id m_lastId{0}; + std::unordered_map> m_idMap; + std::multimap m_timeMap; + std::atomic m_shutdown{false}; + std::thread m_thread; + }; + + DeadlineScheduler::DeadlineScheduler() + : m_impl{std::make_unique()} + { + } + + DeadlineScheduler::~DeadlineScheduler() = default; + + DeadlineScheduler::Id DeadlineScheduler::Schedule(TimePoint when, Callback callback) + { + return m_impl->Schedule(when, std::move(callback)); + } + + DeadlineScheduler::Id DeadlineScheduler::Schedule(std::chrono::milliseconds delay, Callback callback) + { + if (delay.count() < 0) + { + delay = std::chrono::milliseconds{0}; + } + + return Schedule(Now() + delay, std::move(callback)); + } + + void DeadlineScheduler::Cancel(Id id) + { + m_impl->Cancel(id); + } +} diff --git a/Core/JsRuntime/Source/JsRuntime.cpp b/Core/JsRuntime/Source/JsRuntime.cpp index 27db82b0..44a6da7f 100644 --- a/Core/JsRuntime/Source/JsRuntime.cpp +++ b/Core/JsRuntime/Source/JsRuntime.cpp @@ -1,6 +1,9 @@ #include "JsRuntime.h" +#include "Babylon/DeadlineScheduler.h" #include "Babylon/DebugTrace.h" +#include + namespace Babylon { namespace @@ -9,9 +12,19 @@ namespace Babylon static constexpr auto JS_WINDOW_NAME = "window"; } - JsRuntime::JsRuntime(Napi::Env env, DispatchFunctionT dispatchFunction) + JsRuntime::JsRuntime(Napi::Env env, DispatchFunctionT dispatchFunction, DeadlineScheduler* deadlineScheduler) : m_dispatchFunction{std::move(dispatchFunction)} { + if (deadlineScheduler != nullptr) + { + m_deadlineScheduler = deadlineScheduler; + } + else + { + m_ownedDeadlineScheduler = std::make_unique(); + m_deadlineScheduler = m_ownedDeadlineScheduler.get(); + } + auto global = env.Global(); if (global.Get(JS_WINDOW_NAME).IsUndefined()) @@ -28,12 +41,30 @@ namespace Babylon DEBUG_TRACE("JsRuntime created"); } + JsRuntime::~JsRuntime() = default; + JsRuntime& BABYLON_API JsRuntime::CreateForJavaScript(Napi::Env env, DispatchFunctionT dispatchFunction) { - auto* runtime = new JsRuntime(env, std::move(dispatchFunction)); + auto* runtime = new JsRuntime(env, std::move(dispatchFunction), nullptr); return *runtime; } + JsRuntime& BABYLON_API JsRuntime::CreateForJavaScript(Napi::Env env, DispatchFunctionT dispatchFunction, DeadlineScheduler& deadlineScheduler) + { + auto* runtime = new JsRuntime(env, std::move(dispatchFunction), &deadlineScheduler); + return *runtime; + } + + DeadlineScheduler& JsRuntime::GetDeadlineScheduler() + { + if (m_deadlineScheduler == nullptr) + { + throw std::runtime_error{"JsRuntime deadline scheduler is not available"}; + } + + return *m_deadlineScheduler; + } + JsRuntime& BABYLON_API JsRuntime::GetFromJavaScript(Napi::Env env) { return *NativeObject::GetFromJavaScript(env) diff --git a/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp b/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp index 1dea6585..c16b9caf 100644 --- a/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp +++ b/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp @@ -1,15 +1,14 @@ #include "TimeoutDispatcher.h" -#include #include +#include +#include namespace Babylon::Polyfills::Internal { namespace { - using TimePoint = std::chrono::time_point; - - TimePoint Now() + DeadlineScheduler::TimePoint Now() { return std::chrono::time_point_cast(std::chrono::steady_clock::now()); } @@ -18,17 +17,11 @@ namespace Babylon::Polyfills::Internal struct TimeoutDispatcher::Timeout { TimeoutId id; - - // Distinguishes this timeout from a later one that happens to reuse the - // same id, so an in-flight callback can never re-arm its replacement. uint64_t sequence; - - // Make this non-shared when JsRuntime::Dispatch supports it. std::shared_ptr function; - TimePoint time; - std::optional interval; + DeadlineScheduler::Id scheduleId{}; Timeout(TimeoutId id, uint64_t sequence, std::shared_ptr function, TimePoint time, std::optional interval) : id{id} @@ -45,29 +38,21 @@ namespace Babylon::Polyfills::Internal TimeoutDispatcher::TimeoutDispatcher(Babylon::JsRuntime& runtime) : m_runtime{runtime} - , m_thread{std::thread{&TimeoutDispatcher::ThreadFunction, this}} + , m_scheduler{runtime.GetDeadlineScheduler()} { } TimeoutDispatcher::~TimeoutDispatcher() { + std::unique_lock lk{m_mutex}; + for (auto& [id, timeout] : m_idMap) { - std::unique_lock lk{m_mutex}; - m_idMap.clear(); - m_timeMap.clear(); + m_scheduler.Cancel(timeout->scheduleId); } - - m_shutdown = true; - m_condVariable.notify_one(); - m_thread.join(); + m_idMap.clear(); } TimeoutDispatcher::TimeoutId TimeoutDispatcher::Dispatch(std::shared_ptr function, std::chrono::milliseconds delay, bool repeat) - { - return DispatchImpl(function, delay, repeat, 0); - } - - TimeoutDispatcher::TimeoutId TimeoutDispatcher::DispatchImpl(std::shared_ptr function, std::chrono::milliseconds delay, bool repeat, TimeoutId id) { if (delay.count() < 0) { @@ -76,20 +61,20 @@ namespace Babylon::Polyfills::Internal std::unique_lock lk{m_mutex}; - if (id == 0) - { - id = NextTimeoutId(); - } - const auto earliestTime = m_timeMap.empty() ? TimePoint::max() : m_timeMap.cbegin()->second->time; + const auto id = NextTimeoutId(); + const auto sequence = ++m_lastSequence; const auto time = Now() + delay; - const auto result = m_idMap.insert({id, std::make_unique(id, ++m_lastSequence, std::move(function), time, repeat ? std::make_optional(delay) : std::nullopt)}); - m_timeMap.insert({time, result.first->second.get()}); - - if (time <= earliestTime) + auto timeout = std::make_unique(id, sequence, std::move(function), time, repeat ? std::make_optional(delay) : std::nullopt); + const auto [it, inserted] = m_idMap.try_emplace(id, std::move(timeout)); + if (!inserted) { - m_condVariable.notify_one(); + throw std::logic_error{"TimeoutDispatcher: NextTimeoutId returned a duplicate id"}; } + it->second->scheduleId = m_scheduler.Schedule(time, [this, id, sequence]() { + CallFunction(id, sequence); + }); + return id; } @@ -99,19 +84,7 @@ namespace Babylon::Polyfills::Internal const auto itId = m_idMap.find(id); if (itId != m_idMap.end()) { - const auto& timeout = itId->second; - const auto timeRange = m_timeMap.equal_range(timeout->time); - - // Remove any pending entries that have not yet been dispatched. - for (auto itTime = timeRange.first; itTime != timeRange.second; itTime++) - { - if (itTime->second->id == id) - { - m_timeMap.erase(itTime); - break; - } - } - + m_scheduler.Cancel(itId->second->scheduleId); m_idMap.erase(itId); } } @@ -134,48 +107,6 @@ namespace Babylon::Polyfills::Internal } } - void TimeoutDispatcher::ThreadFunction() - { - while (!m_shutdown) - { - std::unique_lock lk{m_mutex}; - TimePoint nextTimePoint{}; - - while (!m_timeMap.empty()) - { - nextTimePoint = m_timeMap.begin()->second->time; - if (nextTimePoint <= Now()) - { - break; - } - - m_condVariable.wait_until(lk, nextTimePoint); - } - - while (!m_timeMap.empty() && m_timeMap.begin()->second->time == nextTimePoint) - { - const auto id = m_timeMap.begin()->second->id; - const auto sequence = m_timeMap.begin()->second->sequence; - m_timeMap.erase(m_timeMap.begin()); - - // Repeating timeouts are deliberately NOT re-armed here. They are - // re-armed on the JS thread once the callback has actually run, so - // that at most one invocation of a given interval is ever queued. - // Re-arming here instead would let this thread -- which never waits - // while a due timeout exists -- spin and enqueue callbacks far - // faster than the JS thread can drain them. The resulting unbounded - // backlog starves every other item on the JS dispatch queue: other - // timers, and native async completions such as shader compilation. - CallFunction(id, sequence); - } - - while (!m_shutdown && m_timeMap.empty()) - { - m_condVariable.wait(lk); - } - } - } - void TimeoutDispatcher::CallFunction(TimeoutId id, uint64_t sequence) { m_runtime.Dispatch([id, sequence, this](Napi::Env) { @@ -260,15 +191,9 @@ namespace Babylon::Polyfills::Internal nextTime = now; } - const auto earliestTime = m_timeMap.empty() ? TimePoint::max() : m_timeMap.cbegin()->second->time; it->second->time = nextTime; - m_timeMap.insert({nextTime, it->second.get()}); - - if (nextTime <= earliestTime) - { - // The timer thread parks while m_timeMap is empty, which is the case - // whenever this timeout was the only one pending. - m_condVariable.notify_one(); - } + it->second->scheduleId = m_scheduler.Schedule(nextTime, [this, id, sequence]() { + CallFunction(id, sequence); + }); } -} +} \ No newline at end of file diff --git a/Polyfills/Scheduling/Source/TimeoutDispatcher.h b/Polyfills/Scheduling/Source/TimeoutDispatcher.h index 0ab4b135..a793f478 100644 --- a/Polyfills/Scheduling/Source/TimeoutDispatcher.h +++ b/Polyfills/Scheduling/Source/TimeoutDispatcher.h @@ -1,15 +1,14 @@ #pragma once +#include #include #include -#include #include -#include -#include -#include #include -#include +#include +#include +#include namespace Babylon::Polyfills::Internal { @@ -26,23 +25,17 @@ namespace Babylon::Polyfills::Internal void Clear(TimeoutId id); private: - using TimePoint = std::chrono::time_point; - - TimeoutId DispatchImpl(std::shared_ptr function, std::chrono::milliseconds delay, bool repeat, TimeoutId id); + using TimePoint = DeadlineScheduler::TimePoint; TimeoutId NextTimeoutId(); - void ThreadFunction(); void CallFunction(TimeoutId id, uint64_t sequence); void Rearm(TimeoutId id, uint64_t sequence, TimePoint scheduledTime, std::chrono::milliseconds interval); Babylon::JsRuntime& m_runtime; + DeadlineScheduler& m_scheduler; std::recursive_mutex m_mutex{}; - std::condition_variable_any m_condVariable{}; TimeoutId m_lastTimeoutId{0}; uint64_t m_lastSequence{0}; std::unordered_map> m_idMap; - std::multimap m_timeMap; - std::atomic m_shutdown{false}; - std::thread m_thread; }; } diff --git a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt index 74a7aff8..84250e46 100644 --- a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt +++ b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt @@ -23,7 +23,7 @@ add_library(UnitTestsJNI SHARED ${UNIT_TESTS_DIR}/Shared/Shared.cpp) target_compile_definitions(UnitTestsJNI PRIVATE JSRUNTIMEHOST_PLATFORM="${JSRUNTIMEHOST_PLATFORM}") -target_compile_definitions(UnitTestsJNI PRIVATE JSRUNTIMEHOST_ENGINE="${NAPI_JAVASCRIPT_ENGINE}") +target_compile_definitions(UnitTestsJNI PRIVATE NAPI_JAVASCRIPT_ENGINE="${NAPI_JAVASCRIPT_ENGINE}") target_compile_definitions(UnitTestsJNI PRIVATE ARCANA_TEST_HOOKS) target_include_directories(UnitTestsJNI diff --git a/Tests/UnitTests/CMakeLists.txt b/Tests/UnitTests/CMakeLists.txt index a9c45935..b4917824 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -46,7 +46,7 @@ endif() add_executable(UnitTests ${SOURCES} ${SCRIPTS} ${TYPE_SCRIPTS} ${ASSETS}) target_compile_definitions(UnitTests PRIVATE JSRUNTIMEHOST_PLATFORM="${JSRUNTIMEHOST_PLATFORM}") -target_compile_definitions(UnitTests PRIVATE JSRUNTIMEHOST_ENGINE="${NAPI_JAVASCRIPT_ENGINE}") +target_compile_definitions(UnitTests PRIVATE NAPI_JAVASCRIPT_ENGINE="${NAPI_JAVASCRIPT_ENGINE}") # The V8JSI Node-API shim does not implement napi_create_dataview, so the # CreateDataViewRejectsOverflowingRange test is compiled out on that backend. diff --git a/Tests/UnitTests/Shared/Shared.cpp b/Tests/UnitTests/Shared/Shared.cpp index 419d2efc..1c7e9ff7 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -100,7 +100,7 @@ TEST(JavaScript, All) env.Global().Set("setExitCode", setExitCodeCallback); env.Global().Set("hostPlatform", Napi::Value::From(env, JSRUNTIMEHOST_PLATFORM)); - env.Global().Set("hostEngine", Napi::Value::From(env, JSRUNTIMEHOST_ENGINE)); + env.Global().Set("hostEngine", Napi::Value::From(env, NAPI_JAVASCRIPT_ENGINE)); }); Babylon::ScriptLoader loader{runtime}; From 7192ac0d95f9fcd83eb8c26e9f42efbbc147546f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87=20=28via=20Copilot=29?= <223556219+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:55:48 -0700 Subject: [PATCH 5/9] Fix V8 delayed task scheduling and separate host lifetime management Round absolute V8 execution times upward to microsecond precision and retire completed delayed task records through shared callback state. Deactivate retained runners before stopping timers and disposing the isolate. Move DelayedTaskScheduler into Foundation, restore JsRuntime's original API, and register AppRuntime's scheduler with the environment for the scheduling polyfill to borrow. Keep an owned fallback for standalone hosts and guard timeout callbacks independently of dispatcher lifetime. Extract the V8 platform adapter and foreground runner, replace implicit host clearing with explicit registration, and add timing, completion, cancellation, ownership, and shutdown regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 54aaa1e1-b4b3-46e7-9de4-5a56add4ac42 --- Core/AppRuntime/CMakeLists.txt | 12 + Core/AppRuntime/Include/Babylon/AppRuntime.h | 7 +- Core/AppRuntime/Source/AppRuntime.cpp | 29 +- Core/AppRuntime/Source/AppRuntime_V8.cpp | 282 +----------------- .../Source/V8ForegroundTaskRunner.cpp | 126 ++++++++ .../Source/V8ForegroundTaskRunner.h | 39 +++ Core/AppRuntime/Source/V8Platform.cpp | 63 ++++ Core/AppRuntime/Source/V8Platform.h | 78 +++++ Core/Foundation/CMakeLists.txt | 2 + .../Include/Babylon/DelayedTaskScheduler.h | 40 +++ .../Source/DelayedTaskScheduler.cpp} | 144 +++++---- Core/JsRuntime/CMakeLists.txt | 4 +- .../Include/Babylon/DeadlineScheduler.h | 34 --- .../DelayedTaskSchedulerRegistration.h | 19 ++ Core/JsRuntime/Include/Babylon/JsRuntime.h | 10 +- .../DelayedTaskSchedulerRegistration.cpp | 31 ++ Core/JsRuntime/Source/JsRuntime.cpp | 35 +-- Polyfills/Scheduling/Source/Scheduling.cpp | 2 +- .../Scheduling/Source/TimeoutDispatcher.cpp | 262 +++++++++------- .../Scheduling/Source/TimeoutDispatcher.h | 20 +- .../Android/app/src/main/cpp/CMakeLists.txt | 6 + Tests/UnitTests/CMakeLists.txt | 5 + .../UnitTests/Shared/DelayedTaskScheduler.cpp | 164 ++++++++++ .../Shared/V8ForegroundTaskRunner.cpp | 201 +++++++++++++ 24 files changed, 1069 insertions(+), 546 deletions(-) create mode 100644 Core/AppRuntime/Source/V8ForegroundTaskRunner.cpp create mode 100644 Core/AppRuntime/Source/V8ForegroundTaskRunner.h create mode 100644 Core/AppRuntime/Source/V8Platform.cpp create mode 100644 Core/AppRuntime/Source/V8Platform.h create mode 100644 Core/Foundation/Include/Babylon/DelayedTaskScheduler.h rename Core/{JsRuntime/Source/DeadlineScheduler.cpp => Foundation/Source/DelayedTaskScheduler.cpp} (54%) delete mode 100644 Core/JsRuntime/Include/Babylon/DeadlineScheduler.h create mode 100644 Core/JsRuntime/Include/Babylon/DelayedTaskSchedulerRegistration.h create mode 100644 Core/JsRuntime/Source/DelayedTaskSchedulerRegistration.cpp create mode 100644 Tests/UnitTests/Shared/DelayedTaskScheduler.cpp create mode 100644 Tests/UnitTests/Shared/V8ForegroundTaskRunner.cpp diff --git a/Core/AppRuntime/CMakeLists.txt b/Core/AppRuntime/CMakeLists.txt index f7bc649d..688c09f3 100644 --- a/Core/AppRuntime/CMakeLists.txt +++ b/Core/AppRuntime/CMakeLists.txt @@ -11,6 +11,14 @@ set(SOURCES "Source/AppRuntime_${NAPI_JAVASCRIPT_ENGINE}.cpp" "Source/AppRuntime_${JSRUNTIMEHOST_PLATFORM}.${IMPL_EXT}") +if(NAPI_JAVASCRIPT_ENGINE STREQUAL "V8") + list(APPEND SOURCES + "Source/V8ForegroundTaskRunner.h" + "Source/V8ForegroundTaskRunner.cpp" + "Source/V8Platform.h" + "Source/V8Platform.cpp") +endif() + add_library(AppRuntime ${SOURCES}) warnings_as_errors(AppRuntime) @@ -58,3 +66,7 @@ endif() set_property(TARGET AppRuntime PROPERTY FOLDER Core) source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) + +add_library(AppRuntimeInternal INTERFACE) +target_include_directories(AppRuntimeInternal INTERFACE "Source") +target_link_libraries(AppRuntimeInternal INTERFACE AppRuntime) diff --git a/Core/AppRuntime/Include/Babylon/AppRuntime.h b/Core/AppRuntime/Include/Babylon/AppRuntime.h index 7142cbf9..e82e2544 100644 --- a/Core/AppRuntime/Include/Babylon/AppRuntime.h +++ b/Core/AppRuntime/Include/Babylon/AppRuntime.h @@ -12,7 +12,7 @@ namespace Babylon { - class DeadlineScheduler; + class DelayedTaskScheduler; class AppRuntime final { @@ -63,7 +63,8 @@ namespace Babylon // that certain program state be allocated and stored only on the stack. void RunPlatformTier(); void RunEnvironmentTier(const char* executablePath = "."); - void Run(Napi::Env); + // Stop engine task routing before joining the scheduler and discarding work. + void Run(Napi::Env, std::function shutdown = {}); // This method is called from Dispatch to allow platform-specific code to add // extra logic around the invocation of a dispatched callback. @@ -78,7 +79,7 @@ namespace Babylon // queue explicitly (Napi::DrainJobs / JS_ExecutePendingJob). void DrainMicrotasks(Napi::Env env); - DeadlineScheduler& GetDeadlineScheduler(); + DelayedTaskScheduler& GetDelayedTaskScheduler(); Options m_options; diff --git a/Core/AppRuntime/Source/AppRuntime.cpp b/Core/AppRuntime/Source/AppRuntime.cpp index 5f6d9298..293186bf 100644 --- a/Core/AppRuntime/Source/AppRuntime.cpp +++ b/Core/AppRuntime/Source/AppRuntime.cpp @@ -1,6 +1,7 @@ #include "AppRuntime.h" -#include +#include +#include #include #include @@ -37,7 +38,8 @@ namespace Babylon std::optional> m_suspensionLock{}; arcana::cancellation_source m_cancelSource{}; arcana::manual_dispatcher<128> m_dispatcher{}; - std::unique_ptr m_deadlineScheduler{std::make_unique()}; + std::unique_ptr m_delayedTaskScheduler{std::make_unique()}; + bool m_delayedTaskSchedulerRegistered{}; std::thread m_thread; }; @@ -53,7 +55,9 @@ namespace Babylon m_impl->m_thread = std::thread{[this] { RunPlatformTier(); }}; Dispatch([this](Napi::Env env) { - JsRuntime::CreateForJavaScript(env, [this](auto func) { Dispatch(std::move(func)); }, GetDeadlineScheduler()); + JsRuntime::CreateForJavaScript(env, [this](auto func) { Dispatch(std::move(func)); }); + DelayedTaskSchedulerRegistration::Register(env, GetDelayedTaskScheduler()); + m_impl->m_delayedTaskSchedulerRegistered = true; }); } @@ -79,7 +83,7 @@ namespace Babylon m_impl->m_thread.join(); } - void AppRuntime::Run(Napi::Env env) + void AppRuntime::Run(Napi::Env env, std::function shutdown) { m_impl->m_env = std::make_optional(env); @@ -90,13 +94,26 @@ namespace Babylon m_impl->m_dispatcher.blocking_tick(m_impl->m_cancelSource); } + Napi::HandleScope scope{env}; + if (shutdown) + { + shutdown(); + } + + if (m_impl->m_delayedTaskSchedulerRegistered) + { + DelayedTaskSchedulerRegistration::Unregister(env); + m_impl->m_delayedTaskSchedulerRegistered = false; + } + GetDelayedTaskScheduler().Shutdown(); + // The dispatcher can be non-empty if something is dispatched after cancellation. m_impl->m_dispatcher.clear(); } - DeadlineScheduler& AppRuntime::GetDeadlineScheduler() + DelayedTaskScheduler& AppRuntime::GetDelayedTaskScheduler() { - return *m_impl->m_deadlineScheduler; + return *m_impl->m_delayedTaskScheduler; } void AppRuntime::Suspend() diff --git a/Core/AppRuntime/Source/AppRuntime_V8.cpp b/Core/AppRuntime/Source/AppRuntime_V8.cpp index aad6cbdc..7886f47b 100644 --- a/Core/AppRuntime/Source/AppRuntime_V8.cpp +++ b/Core/AppRuntime/Source/AppRuntime_V8.cpp @@ -1,269 +1,21 @@ #include "AppRuntime.h" +#include "V8Platform.h" #include -#include - #include -#include - -// Android builds against V8 11.0, desktop against 11.9. A few v8::Platform members below do not -// exist in 11.0, and overriding a method the base class does not declare is a hard error, so they -// are gated. Where a member is gated out, v8::Platform's own default is used instead of forwarding -// to the inner platform; all of them are optional hooks whose defaults are benign (null allocator, -// null blocking scope, clock values derived from CurrentClockTimeMillis). -#define JSRH_V8_AT_LEAST(major, minor) \ - (V8_MAJOR_VERSION > (major) || (V8_MAJOR_VERSION == (major) && V8_MINOR_VERSION >= (minor))) - #ifdef ENABLE_V8_INSPECTOR #include #endif -#include -#include -#include #include -#include #include #include -#include -#include namespace Babylon { namespace { - // V8 hands work that finishes off-thread - most visibly asynchronous WebAssembly - // compilation - back to the isolate by posting a task to the platform's *foreground* - // task runner. Chromium runs those as `v8::Task::Run` on the host task runner. The - // host JavaScript thread is AppRuntime's dispatcher, so this platform's foreground - // runner posts the task itself instead of parking it on libplatform's queue and - // pumping later. Delayed posts reuse the DeadlineScheduler behind setTimeout. - class DispatchingPlatform final : public v8::Platform - { - public: - explicit DispatchingPlatform(std::unique_ptr inner) - : m_inner{std::move(inner)} - { - } - - struct Host - { - AppRuntime* runtime{}; - DeadlineScheduler* scheduler{}; - }; - - // Isolate::New can ask for a foreground runner before SetHost has the - // isolate pointer. Posts from this thread during construction fall back - // to the thread-local host set around New. - void SetCurrentHost(AppRuntime* runtime, DeadlineScheduler* scheduler) - { - t_currentRuntime = runtime; - t_currentScheduler = scheduler; - } - - void SetHost(v8::Isolate* isolate, AppRuntime* runtime, DeadlineScheduler* scheduler) - { - std::shared_ptr runner; - { - std::scoped_lock lock{m_mutex}; - if (runtime != nullptr && scheduler != nullptr) - { - m_hosts[isolate] = Host{runtime, scheduler}; - } - else - { - m_hosts.erase(isolate); - auto entry = m_taskRunners.find(isolate); - if (entry != m_taskRunners.end()) - { - runner = std::move(entry->second); - m_taskRunners.erase(entry); - } - } - } - } - - Host GetHost(v8::Isolate* isolate) - { - std::scoped_lock lock{m_mutex}; - const auto entry = m_hosts.find(isolate); - if (entry != m_hosts.end()) - { - return entry->second; - } - return {t_currentRuntime, t_currentScheduler}; - } - - v8::PageAllocator* GetPageAllocator() override { return m_inner->GetPageAllocator(); } -#if JSRH_V8_AT_LEAST(11, 9) - v8::ThreadIsolatedAllocator* GetThreadIsolatedAllocator() override { return m_inner->GetThreadIsolatedAllocator(); } -#endif - v8::ZoneBackingAllocator* GetZoneBackingAllocator() override { return m_inner->GetZoneBackingAllocator(); } - void OnCriticalMemoryPressure() override { m_inner->OnCriticalMemoryPressure(); } - int NumberOfWorkerThreads() override { return m_inner->NumberOfWorkerThreads(); } - - std::shared_ptr GetForegroundTaskRunner(v8::Isolate* isolate) override - { - return WrapForegroundTaskRunner(isolate); - } - -#if JSRH_V8_AT_LEAST(11, 9) - std::shared_ptr GetForegroundTaskRunner(v8::Isolate* isolate, v8::TaskPriority) override - { - return WrapForegroundTaskRunner(isolate); - } -#endif - - void CallOnWorkerThread(std::unique_ptr task) override { m_inner->CallOnWorkerThread(std::move(task)); } - void CallBlockingTaskOnWorkerThread(std::unique_ptr task) override { m_inner->CallBlockingTaskOnWorkerThread(std::move(task)); } - void CallLowPriorityTaskOnWorkerThread(std::unique_ptr task) override { m_inner->CallLowPriorityTaskOnWorkerThread(std::move(task)); } - void CallDelayedOnWorkerThread(std::unique_ptr task, double delayInSeconds) override { m_inner->CallDelayedOnWorkerThread(std::move(task), delayInSeconds); } - bool IdleTasksEnabled(v8::Isolate*) override { return false; } - std::unique_ptr PostJob(v8::TaskPriority priority, std::unique_ptr jobTask) override { return m_inner->PostJob(priority, std::move(jobTask)); } - std::unique_ptr CreateJob(v8::TaskPriority priority, std::unique_ptr jobTask) override { return m_inner->CreateJob(priority, std::move(jobTask)); } -#if JSRH_V8_AT_LEAST(11, 9) - std::unique_ptr CreateBlockingScope(v8::BlockingType blockingType) override { return m_inner->CreateBlockingScope(blockingType); } -#endif - double MonotonicallyIncreasingTime() override { return m_inner->MonotonicallyIncreasingTime(); } -#if JSRH_V8_AT_LEAST(11, 9) - int64_t CurrentClockTimeMilliseconds() override { return m_inner->CurrentClockTimeMilliseconds(); } -#endif - double CurrentClockTimeMillis() override { return m_inner->CurrentClockTimeMillis(); } -#if JSRH_V8_AT_LEAST(11, 9) - double CurrentClockTimeMillisecondsHighResolution() override { return m_inner->CurrentClockTimeMillisecondsHighResolution(); } -#endif - StackTracePrinter GetStackTracePrinter() override { return m_inner->GetStackTracePrinter(); } - v8::TracingController* GetTracingController() override { return m_inner->GetTracingController(); } - void DumpWithoutCrashing() override { m_inner->DumpWithoutCrashing(); } - v8::HighAllocationThroughputObserver* GetHighAllocationThroughputObserver() override { return m_inner->GetHighAllocationThroughputObserver(); } - - private: - std::shared_ptr WrapForegroundTaskRunner(v8::Isolate* isolate); - - std::unique_ptr m_inner; - std::mutex m_mutex; - std::map m_hosts; - std::map> m_taskRunners; - - static thread_local AppRuntime* t_currentRuntime; - static thread_local DeadlineScheduler* t_currentScheduler; - }; - - thread_local AppRuntime* DispatchingPlatform::t_currentRuntime{}; - thread_local DeadlineScheduler* DispatchingPlatform::t_currentScheduler{}; - - class DispatchingTaskRunner final : public v8::TaskRunner - { - public: - DispatchingTaskRunner(DispatchingPlatform& platform, v8::Isolate* isolate) - : m_platform{platform} - , m_isolate{isolate} - { - } - - ~DispatchingTaskRunner() override - { - std::scoped_lock lock{m_mutex}; - for (const auto& pending : m_pending) - { - pending.scheduler->Cancel(pending.id); - } - m_pending.clear(); - } - - void PostTask(std::unique_ptr task) override - { - PostImmediate(std::move(task)); - } - - void PostNonNestableTask(std::unique_ptr task) override - { - PostImmediate(std::move(task)); - } - - void PostDelayedTask(std::unique_ptr task, double delayInSeconds) override - { - PostDelayed(std::move(task), delayInSeconds); - } - - void PostNonNestableDelayedTask(std::unique_ptr task, double delayInSeconds) override - { - PostDelayed(std::move(task), delayInSeconds); - } - - void PostIdleTask(std::unique_ptr) override - { - } - - bool IdleTasksEnabled() override { return false; } - bool NonNestableTasksEnabled() const override { return true; } - bool NonNestableDelayedTasksEnabled() const override { return true; } - - private: - void PostImmediate(std::unique_ptr task) - { - auto host = m_platform.GetHost(m_isolate); - if (host.runtime == nullptr) - { - return; - } - - auto shared = std::shared_ptr(std::move(task)); - host.runtime->Dispatch([shared, isolate = m_isolate](Napi::Env) { - v8::Isolate::Scope isolate_scope{isolate}; - shared->Run(); - }); - } - - void PostDelayed(std::unique_ptr task, double delayInSeconds) - { - auto host = m_platform.GetHost(m_isolate); - if (host.runtime == nullptr || host.scheduler == nullptr) - { - return; - } - - auto shared = std::shared_ptr(std::move(task)); - auto delay = std::chrono::duration_cast(std::chrono::duration(delayInSeconds)); - if (delay.count() < 0) - { - delay = std::chrono::milliseconds{0}; - } - - std::scoped_lock lock{m_mutex}; - const auto id = host.scheduler->Schedule(delay, [runtime = host.runtime, isolate = m_isolate, shared]() { - runtime->Dispatch([shared, isolate](Napi::Env) { - v8::Isolate::Scope isolate_scope{isolate}; - shared->Run(); - }); - }); - m_pending.push_back(Pending{host.scheduler, id}); - } - - struct Pending - { - DeadlineScheduler* scheduler{}; - DeadlineScheduler::Id id{}; - }; - - DispatchingPlatform& m_platform; - v8::Isolate* m_isolate; - std::mutex m_mutex; - std::vector m_pending; - }; - - std::shared_ptr DispatchingPlatform::WrapForegroundTaskRunner(v8::Isolate* isolate) - { - std::scoped_lock lock{m_mutex}; - auto& runner = m_taskRunners[isolate]; - if (!runner) - { - runner = std::make_shared(*this, isolate); - } - return runner; - } - class Module final { public: @@ -271,7 +23,7 @@ namespace Babylon { v8::V8::InitializeICUDefaultLocation(executablePath); v8::V8::InitializeExternalStartupData(executablePath); - m_platform = std::make_unique(v8::platform::NewDefaultPlatform()); + m_platform = std::make_unique(v8::platform::NewDefaultPlatform()); v8::V8::InitializePlatform(m_platform.get()); v8::V8::Initialize(); } @@ -300,13 +52,13 @@ namespace Babylon return *s_module; } - DispatchingPlatform& Platform() + Internal::V8Platform& Platform() { return *m_platform; } private: - std::unique_ptr m_platform; + std::unique_ptr m_platform; static std::unique_ptr s_module; }; @@ -316,17 +68,16 @@ namespace Babylon void AppRuntime::RunEnvironmentTier(const char* executablePath) { - // Create the isolate. Module::Initialize(executablePath); - Module::Instance().Platform().SetCurrentHost(this, &GetDeadlineScheduler()); + auto& platform = Module::Instance().Platform(); v8::Isolate::CreateParams create_params; create_params.array_buffer_allocator = v8::ArrayBuffer::Allocator::NewDefaultAllocator(); - v8::Isolate* isolate = v8::Isolate::New(create_params); + v8::Isolate* isolate = v8::Isolate::Allocate(); + // Initialization can post foreground work, so register before it starts. + platform.RegisterHost(isolate, *this, GetDelayedTaskScheduler()); + v8::Isolate::Initialize(isolate, create_params); - Module::Instance().Platform().SetHost(isolate, this, &GetDeadlineScheduler()); - - // Use the isolate within a scope. { v8::Isolate::Scope isolate_scope{isolate}; v8::HandleScope isolate_handle_scope{isolate}; @@ -339,7 +90,7 @@ namespace Babylon std::optional agent; if (m_options.EnableDebugger) { - agent.emplace(Module::Instance().Platform(), isolate, context, "JsRuntimeHost"); + agent.emplace(platform, isolate, context, "JsRuntimeHost"); agent->Start(5643, "JsRuntimeHost"); if (m_options.WaitForDebugger) @@ -349,7 +100,9 @@ namespace Babylon } #endif - Run(env); + Run(env, [&platform, isolate] { + platform.UnregisterHost(isolate); + }); #ifdef ENABLE_V8_INSPECTOR if (agent.has_value()) @@ -361,10 +114,6 @@ namespace Babylon Napi::Detach(env); } - // Destroy the isolate. - Module::Instance().Platform().SetHost(isolate, nullptr, nullptr); - Module::Instance().Platform().SetCurrentHost(nullptr, nullptr); - // todo : GetArrayBufferAllocator not available? // delete isolate->GetArrayBufferAllocator(); isolate->Dispose(); @@ -372,9 +121,6 @@ namespace Babylon void AppRuntime::DrainMicrotasks(Napi::Env) { - // V8 auto-drains microtasks at the end of each script/callback when using the default - // MicrotasksPolicy. Foreground platform tasks (including async WebAssembly settlement) - // are posted through DispatchingTaskRunner onto AppRuntime's dispatcher, so they do - // not need a PumpMessageLoop here. + // V8 auto-drains microtasks. Foreground tasks run on AppRuntime's dispatcher. } } diff --git a/Core/AppRuntime/Source/V8ForegroundTaskRunner.cpp b/Core/AppRuntime/Source/V8ForegroundTaskRunner.cpp new file mode 100644 index 00000000..8942b2ae --- /dev/null +++ b/Core/AppRuntime/Source/V8ForegroundTaskRunner.cpp @@ -0,0 +1,126 @@ +#include "V8ForegroundTaskRunner.h" + +#include +#include +#include +#include +#include + +namespace Babylon::Internal +{ + struct V8ForegroundTaskRunner::State + { + struct Pending + { + DelayedTaskScheduler::Id id{}; + bool completed{}; + }; + + State(DispatchFunction dispatch, ScheduleFunction schedule, CancelFunction cancel) + : dispatch{std::move(dispatch)} + , schedule{std::move(schedule)} + , cancel{std::move(cancel)} + { + } + + // Scheduling may complete synchronously; dispatch must only enqueue work. + std::recursive_mutex mutex; + DispatchFunction dispatch; + ScheduleFunction schedule; + CancelFunction cancel; + std::unordered_set> pending; + }; + + V8ForegroundTaskRunner::V8ForegroundTaskRunner(DispatchFunction dispatch, ScheduleFunction schedule, CancelFunction cancel) + : m_state{std::make_shared(std::move(dispatch), std::move(schedule), std::move(cancel))} + { + } + + V8ForegroundTaskRunner::~V8ForegroundTaskRunner() + { + Shutdown(); + } + + void V8ForegroundTaskRunner::Shutdown() + { + std::scoped_lock lock{m_state->mutex}; + m_state->dispatch = {}; + auto pendingTasks = std::move(m_state->pending); + m_state->pending.clear(); + for (const auto& pending : pendingTasks) + { + m_state->cancel(pending->id); + } + } + + void V8ForegroundTaskRunner::PostTask(std::unique_ptr task) + { + std::scoped_lock lock{m_state->mutex}; + if (m_state->dispatch) + { + m_state->dispatch(std::move(task)); + } + } + + void V8ForegroundTaskRunner::PostNonNestableTask(std::unique_ptr task) + { + PostTask(std::move(task)); + } + + DelayedTaskScheduler::TimePoint V8ForegroundTaskRunner::GetScheduledTime(std::chrono::steady_clock::time_point now, double delayInSeconds) + { + return std::chrono::ceil( + now + std::chrono::duration{std::max(0.0, delayInSeconds)}); + } + + void V8ForegroundTaskRunner::PostDelayedTask(std::unique_ptr task, double delayInSeconds) + { + const auto when = GetScheduledTime(std::chrono::steady_clock::now(), delayInSeconds); + const auto state = m_state; + std::scoped_lock lock{state->mutex}; + if (!state->dispatch) + { + return; + } + + const auto pending = std::make_shared(); + pending->id = state->schedule(when, [state, pending, task = std::shared_ptr{std::move(task)}]() { + std::scoped_lock callbackLock{state->mutex}; + pending->completed = true; + state->pending.erase(pending); + if (state->dispatch) + { + state->dispatch(task); + } + }); + // A callback that fired before Schedule returned must not be reinserted. + if (!pending->completed) + { + state->pending.insert(pending); + } + } + + void V8ForegroundTaskRunner::PostNonNestableDelayedTask(std::unique_ptr task, double delayInSeconds) + { + PostDelayedTask(std::move(task), delayInSeconds); + } + + void V8ForegroundTaskRunner::PostIdleTask(std::unique_ptr) + { + } + + bool V8ForegroundTaskRunner::IdleTasksEnabled() + { + return false; + } + + bool V8ForegroundTaskRunner::NonNestableTasksEnabled() const + { + return true; + } + + bool V8ForegroundTaskRunner::NonNestableDelayedTasksEnabled() const + { + return true; + } +} diff --git a/Core/AppRuntime/Source/V8ForegroundTaskRunner.h b/Core/AppRuntime/Source/V8ForegroundTaskRunner.h new file mode 100644 index 00000000..a000c078 --- /dev/null +++ b/Core/AppRuntime/Source/V8ForegroundTaskRunner.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +#include +#include + +namespace Babylon::Internal +{ + class V8ForegroundTaskRunner final : public v8::TaskRunner + { + public: + using DispatchFunction = std::function)>; + using ScheduleFunction = std::function; + using CancelFunction = std::function; + + V8ForegroundTaskRunner(DispatchFunction dispatch, ScheduleFunction schedule, CancelFunction cancel); + ~V8ForegroundTaskRunner() override; + + static DelayedTaskScheduler::TimePoint GetScheduledTime(std::chrono::steady_clock::time_point now, double delayInSeconds); + + // After shutdown, even references retained by V8 discard new posts. + void Shutdown(); + + void PostTask(std::unique_ptr task) override; + void PostNonNestableTask(std::unique_ptr task) override; + void PostDelayedTask(std::unique_ptr task, double delayInSeconds) override; + void PostNonNestableDelayedTask(std::unique_ptr task, double delayInSeconds) override; + void PostIdleTask(std::unique_ptr) override; + bool IdleTasksEnabled() override; + bool NonNestableTasksEnabled() const override; + bool NonNestableDelayedTasksEnabled() const override; + + private: + struct State; + std::shared_ptr m_state; + }; +} diff --git a/Core/AppRuntime/Source/V8Platform.cpp b/Core/AppRuntime/Source/V8Platform.cpp new file mode 100644 index 00000000..cbd813df --- /dev/null +++ b/Core/AppRuntime/Source/V8Platform.cpp @@ -0,0 +1,63 @@ +#include "V8Platform.h" +#include "V8ForegroundTaskRunner.h" + +#include "AppRuntime.h" +#include + +#include + +namespace Babylon::Internal +{ + V8Platform::V8Platform(std::unique_ptr inner) + : m_inner{std::move(inner)} + , m_inactiveRunner{std::make_shared(nullptr, nullptr, nullptr)} + { + } + + void V8Platform::RegisterHost(v8::Isolate* isolate, AppRuntime& runtime, DelayedTaskScheduler& scheduler) + { + auto runner = std::make_shared( + [&runtime, isolate](std::shared_ptr task) { + runtime.Dispatch([task = std::move(task), isolate](Napi::Env) { + v8::Isolate::Scope isolateScope{isolate}; + task->Run(); + }); + }, + [&scheduler](DelayedTaskScheduler::TimePoint when, DelayedTaskScheduler::Callback callback) { + return scheduler.Schedule(when, std::move(callback)); + }, + [&scheduler](DelayedTaskScheduler::Id id) { + scheduler.Cancel(id); + }); + + std::scoped_lock lock{m_mutex}; + if (!m_taskRunners.try_emplace(isolate, std::move(runner)).second) + { + throw std::logic_error{"V8 host already registered"}; + } + } + + void V8Platform::UnregisterHost(v8::Isolate* isolate) + { + std::shared_ptr runner; + { + std::scoped_lock lock{m_mutex}; + const auto entry = m_taskRunners.find(isolate); + if (entry == m_taskRunners.end()) + { + throw std::logic_error{"V8 host is not registered"}; + } + runner = std::move(entry->second); + m_taskRunners.erase(entry); + } + runner->Shutdown(); + } + + std::shared_ptr V8Platform::GetForegroundTaskRunner(v8::Isolate* isolate) + { + std::scoped_lock lock{m_mutex}; + const auto entry = m_taskRunners.find(isolate); + // Disposal can request a runner after unregistration. Never recreate routing. + return entry == m_taskRunners.end() ? m_inactiveRunner : entry->second; + } +} diff --git a/Core/AppRuntime/Source/V8Platform.h b/Core/AppRuntime/Source/V8Platform.h new file mode 100644 index 00000000..3ed04d79 --- /dev/null +++ b/Core/AppRuntime/Source/V8Platform.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +// Android uses V8 11.0; desktop uses 11.9. +#define JSRH_V8_AT_LEAST(major, minor) \ + (V8_MAJOR_VERSION > (major) || (V8_MAJOR_VERSION == (major) && V8_MINOR_VERSION >= (minor))) + +namespace Babylon +{ + class AppRuntime; + class DelayedTaskScheduler; + + namespace Internal + { + class V8ForegroundTaskRunner; + + class V8Platform final : public v8::Platform + { + public: + explicit V8Platform(std::unique_ptr inner); + + void RegisterHost(v8::Isolate* isolate, AppRuntime& runtime, DelayedTaskScheduler& scheduler); + void UnregisterHost(v8::Isolate* isolate); + + v8::PageAllocator* GetPageAllocator() override { return m_inner->GetPageAllocator(); } +#if JSRH_V8_AT_LEAST(11, 9) + v8::ThreadIsolatedAllocator* GetThreadIsolatedAllocator() override { return m_inner->GetThreadIsolatedAllocator(); } +#endif + v8::ZoneBackingAllocator* GetZoneBackingAllocator() override { return m_inner->GetZoneBackingAllocator(); } + void OnCriticalMemoryPressure() override { m_inner->OnCriticalMemoryPressure(); } + int NumberOfWorkerThreads() override { return m_inner->NumberOfWorkerThreads(); } + std::shared_ptr GetForegroundTaskRunner(v8::Isolate* isolate) override; +#if JSRH_V8_AT_LEAST(11, 9) + std::shared_ptr GetForegroundTaskRunner(v8::Isolate* isolate, v8::TaskPriority) override + { + return GetForegroundTaskRunner(isolate); + } +#endif + void CallOnWorkerThread(std::unique_ptr task) override { m_inner->CallOnWorkerThread(std::move(task)); } + void CallBlockingTaskOnWorkerThread(std::unique_ptr task) override { m_inner->CallBlockingTaskOnWorkerThread(std::move(task)); } + void CallLowPriorityTaskOnWorkerThread(std::unique_ptr task) override { m_inner->CallLowPriorityTaskOnWorkerThread(std::move(task)); } + void CallDelayedOnWorkerThread(std::unique_ptr task, double delayInSeconds) override { m_inner->CallDelayedOnWorkerThread(std::move(task), delayInSeconds); } + bool IdleTasksEnabled(v8::Isolate*) override { return false; } + std::unique_ptr PostJob(v8::TaskPriority priority, std::unique_ptr jobTask) override { return m_inner->PostJob(priority, std::move(jobTask)); } + std::unique_ptr CreateJob(v8::TaskPriority priority, std::unique_ptr jobTask) override { return m_inner->CreateJob(priority, std::move(jobTask)); } +#if JSRH_V8_AT_LEAST(11, 9) + std::unique_ptr CreateBlockingScope(v8::BlockingType blockingType) override { return m_inner->CreateBlockingScope(blockingType); } +#endif + double MonotonicallyIncreasingTime() override { return m_inner->MonotonicallyIncreasingTime(); } +#if JSRH_V8_AT_LEAST(11, 9) + int64_t CurrentClockTimeMilliseconds() override { return m_inner->CurrentClockTimeMilliseconds(); } +#endif + double CurrentClockTimeMillis() override { return m_inner->CurrentClockTimeMillis(); } +#if JSRH_V8_AT_LEAST(11, 9) + double CurrentClockTimeMillisecondsHighResolution() override { return m_inner->CurrentClockTimeMillisecondsHighResolution(); } +#endif + StackTracePrinter GetStackTracePrinter() override { return m_inner->GetStackTracePrinter(); } + v8::TracingController* GetTracingController() override { return m_inner->GetTracingController(); } + void DumpWithoutCrashing() override { m_inner->DumpWithoutCrashing(); } + v8::HighAllocationThroughputObserver* GetHighAllocationThroughputObserver() override { return m_inner->GetHighAllocationThroughputObserver(); } + + private: + std::unique_ptr m_inner; + std::mutex m_mutex; + std::map> m_taskRunners; + std::shared_ptr m_inactiveRunner; + }; + } +} + +#undef JSRH_V8_AT_LEAST diff --git a/Core/Foundation/CMakeLists.txt b/Core/Foundation/CMakeLists.txt index 7e4eac35..cbfee69d 100644 --- a/Core/Foundation/CMakeLists.txt +++ b/Core/Foundation/CMakeLists.txt @@ -1,9 +1,11 @@ set(SOURCES "Include/Babylon/Api.h" "Include/Babylon/DebugTrace.h" + "Include/Babylon/DelayedTaskScheduler.h" "Include/Babylon/PerfTrace.h" "Include/Babylon/StandardStreamLogger.h" "Source/DebugTrace.cpp" + "Source/DelayedTaskScheduler.cpp" "Source/PerfTrace.cpp" "Source/StandardStreamLogger.cpp" "Source/StandardStreamLoggerPlatform.h") diff --git a/Core/Foundation/Include/Babylon/DelayedTaskScheduler.h b/Core/Foundation/Include/Babylon/DelayedTaskScheduler.h new file mode 100644 index 00000000..07e6bf30 --- /dev/null +++ b/Core/Foundation/Include/Babylon/DelayedTaskScheduler.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include + +namespace Babylon +{ + /// Native delayed-work queue used by setTimeout/setInterval and by host + /// task runners. Callbacks run on the scheduler thread; callers that need + /// the JavaScript thread must dispatch there themselves. + class DelayedTaskScheduler final + { + public: + using Id = int32_t; + using Callback = std::function; + using TimePoint = std::chrono::time_point; + + DelayedTaskScheduler(); + ~DelayedTaskScheduler(); + + DelayedTaskScheduler(const DelayedTaskScheduler&) = delete; + DelayedTaskScheduler& operator=(const DelayedTaskScheduler&) = delete; + + Id Schedule(TimePoint when, Callback callback); + Id Schedule(std::chrono::milliseconds delay, Callback callback); + + // Does not wait for a callback already extracted by the worker. + void Cancel(Id id); + + // Drops queued work, waits for an extracted callback to finish, and + // rejects subsequent Schedule calls. + void Shutdown(); + + private: + class Impl; + std::unique_ptr m_impl; + }; +} diff --git a/Core/JsRuntime/Source/DeadlineScheduler.cpp b/Core/Foundation/Source/DelayedTaskScheduler.cpp similarity index 54% rename from Core/JsRuntime/Source/DeadlineScheduler.cpp rename to Core/Foundation/Source/DelayedTaskScheduler.cpp index f3157391..a639bc0f 100644 --- a/Core/JsRuntime/Source/DeadlineScheduler.cpp +++ b/Core/Foundation/Source/DelayedTaskScheduler.cpp @@ -1,6 +1,5 @@ -#include "DeadlineScheduler.h" +#include "DelayedTaskScheduler.h" -#include #include #include #include @@ -8,19 +7,18 @@ #include #include #include -#include namespace Babylon { namespace { - DeadlineScheduler::TimePoint Now() + DelayedTaskScheduler::TimePoint Now() { return std::chrono::time_point_cast(std::chrono::steady_clock::now()); } } - class DeadlineScheduler::Impl + class DelayedTaskScheduler::Impl { public: Impl() @@ -30,39 +28,31 @@ namespace Babylon ~Impl() { - { - std::unique_lock lk{m_mutex}; - m_shutdown = true; - m_idMap.clear(); - m_timeMap.clear(); - } - - m_condVariable.notify_one(); - m_thread.join(); + Shutdown(); } Id Schedule(TimePoint when, Callback callback) { - std::unique_lock lk{m_mutex}; + std::unique_lock lock{m_mutex}; if (m_shutdown) { - throw std::runtime_error{"DeadlineScheduler: Schedule after shutdown"}; + throw std::runtime_error{"DelayedTaskScheduler: Schedule after shutdown"}; } const auto earliestTime = m_timeMap.empty() ? TimePoint::max() : m_timeMap.begin()->first; const auto id = NextId(); auto item = std::make_unique(id, when, std::move(callback)); - Item* const raw = item.get(); + Item* const rawItem = item.get(); const auto [it, inserted] = m_idMap.try_emplace(id, std::move(item)); if (!inserted) { - throw std::logic_error{"DeadlineScheduler: NextId returned a duplicate id"}; + throw std::logic_error{"DelayedTaskScheduler: NextId returned a duplicate id"}; } - m_timeMap.insert({when, raw}); + m_timeMap.emplace(when, rawItem); if (when <= earliestTime) { - m_condVariable.notify_one(); + m_conditionVariable.notify_one(); } return id; @@ -70,39 +60,63 @@ namespace Babylon void Cancel(Id id) { - std::unique_lock lk{m_mutex}; - const auto it = m_idMap.find(id); - if (it == m_idMap.end()) + std::unique_lock lock{m_mutex}; + const auto idIt = m_idMap.find(id); + if (idIt == m_idMap.end()) { return; } - const auto timeRange = m_timeMap.equal_range(it->second->time); - for (auto itTime = timeRange.first; itTime != timeRange.second; ++itTime) + const bool wasEarliest = !m_timeMap.empty() && m_timeMap.begin()->second == idIt->second.get(); + const auto timeRange = m_timeMap.equal_range(idIt->second->time); + for (auto timeIt = timeRange.first; timeIt != timeRange.second; ++timeIt) { - if (itTime->second->id == id) + if (timeIt->second == idIt->second.get()) { - m_timeMap.erase(itTime); + m_timeMap.erase(timeIt); break; } } - m_idMap.erase(it); + m_idMap.erase(idIt); + if (wasEarliest) + { + m_conditionVariable.notify_one(); + } + } + + void Shutdown() + { + std::scoped_lock shutdownLock{m_shutdownMutex}; + { + std::unique_lock lock{m_mutex}; + if (m_shutdown) + { + return; + } + + m_shutdown = true; + m_idMap.clear(); + m_timeMap.clear(); + } + + m_conditionVariable.notify_one(); + m_thread.join(); } private: struct Item { - Id id; - TimePoint time; - Callback callback; - Item(Id id, TimePoint time, Callback callback) : id{id} , time{time} , callback{std::move(callback)} { } + + Id id; + TimePoint time; + Callback callback; }; Id NextId() @@ -124,71 +138,64 @@ namespace Babylon void ThreadFunction() { - while (!m_shutdown) + while (true) { - std::unique_lock lk{m_mutex}; - while (!m_shutdown && m_timeMap.empty()) + Callback callback; { - m_condVariable.wait(lk); - } - - if (m_shutdown) - { - return; - } + std::unique_lock lock{m_mutex}; + while (!m_shutdown && m_timeMap.empty()) + { + m_conditionVariable.wait(lock); + } - const auto nextTimePoint = m_timeMap.begin()->first; - if (nextTimePoint > Now()) - { - m_condVariable.wait_until(lk, nextTimePoint); if (m_shutdown) { return; } - } - std::vector due; - while (!m_timeMap.empty() && m_timeMap.begin()->first <= Now()) - { + const auto nextTime = m_timeMap.begin()->first; + if (nextTime > Now()) + { + m_conditionVariable.wait_until(lock, nextTime); + continue; + } + Item* const item = m_timeMap.begin()->second; - due.push_back(std::move(item->callback)); + callback = std::move(item->callback); m_idMap.erase(item->id); m_timeMap.erase(m_timeMap.begin()); } - lk.unlock(); - for (auto& callback : due) + if (callback) { - if (callback) - { - callback(); - } + callback(); } } } - std::mutex m_mutex{}; - std::condition_variable m_condVariable{}; - Id m_lastId{0}; + std::mutex m_mutex; + std::mutex m_shutdownMutex; + std::condition_variable m_conditionVariable; + Id m_lastId{}; std::unordered_map> m_idMap; std::multimap m_timeMap; - std::atomic m_shutdown{false}; + bool m_shutdown{}; std::thread m_thread; }; - DeadlineScheduler::DeadlineScheduler() + DelayedTaskScheduler::DelayedTaskScheduler() : m_impl{std::make_unique()} { } - DeadlineScheduler::~DeadlineScheduler() = default; + DelayedTaskScheduler::~DelayedTaskScheduler() = default; - DeadlineScheduler::Id DeadlineScheduler::Schedule(TimePoint when, Callback callback) + DelayedTaskScheduler::Id DelayedTaskScheduler::Schedule(TimePoint when, Callback callback) { return m_impl->Schedule(when, std::move(callback)); } - DeadlineScheduler::Id DeadlineScheduler::Schedule(std::chrono::milliseconds delay, Callback callback) + DelayedTaskScheduler::Id DelayedTaskScheduler::Schedule(std::chrono::milliseconds delay, Callback callback) { if (delay.count() < 0) { @@ -198,8 +205,13 @@ namespace Babylon return Schedule(Now() + delay, std::move(callback)); } - void DeadlineScheduler::Cancel(Id id) + void DelayedTaskScheduler::Cancel(Id id) { m_impl->Cancel(id); } + + void DelayedTaskScheduler::Shutdown() + { + m_impl->Shutdown(); + } } diff --git a/Core/JsRuntime/CMakeLists.txt b/Core/JsRuntime/CMakeLists.txt index cf1048e3..7a140e6d 100644 --- a/Core/JsRuntime/CMakeLists.txt +++ b/Core/JsRuntime/CMakeLists.txt @@ -1,8 +1,8 @@ set(SOURCES - "Include/Babylon/DeadlineScheduler.h" + "Include/Babylon/DelayedTaskSchedulerRegistration.h" "Include/Babylon/JsRuntime.h" "Include/Babylon/JsRuntimeScheduler.h" - "Source/DeadlineScheduler.cpp" + "Source/DelayedTaskSchedulerRegistration.cpp" "Source/JsRuntime.cpp") add_library(JsRuntime ${SOURCES}) diff --git a/Core/JsRuntime/Include/Babylon/DeadlineScheduler.h b/Core/JsRuntime/Include/Babylon/DeadlineScheduler.h deleted file mode 100644 index 46c88557..00000000 --- a/Core/JsRuntime/Include/Babylon/DeadlineScheduler.h +++ /dev/null @@ -1,34 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -namespace Babylon -{ - /// Native deadline queue used by setTimeout/setInterval and by the V8 - /// foreground task runner's delayed posts. Callbacks run on the scheduler - /// thread; callers that need the JavaScript thread Dispatch themselves. - class DeadlineScheduler final - { - public: - using Id = int32_t; - using Callback = std::function; - using TimePoint = std::chrono::time_point; - - DeadlineScheduler(); - ~DeadlineScheduler(); - - DeadlineScheduler(const DeadlineScheduler&) = delete; - DeadlineScheduler& operator=(const DeadlineScheduler&) = delete; - - Id Schedule(TimePoint when, Callback callback); - Id Schedule(std::chrono::milliseconds delay, Callback callback); - void Cancel(Id id); - - private: - class Impl; - std::unique_ptr m_impl; - }; -} diff --git a/Core/JsRuntime/Include/Babylon/DelayedTaskSchedulerRegistration.h b/Core/JsRuntime/Include/Babylon/DelayedTaskSchedulerRegistration.h new file mode 100644 index 00000000..33e833e0 --- /dev/null +++ b/Core/JsRuntime/Include/Babylon/DelayedTaskSchedulerRegistration.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +namespace Babylon +{ + class DelayedTaskScheduler; + + class DelayedTaskSchedulerRegistration final + { + public: + // Called on the JavaScript thread after JsRuntime initialization. The + // host retains ownership and must outlive all borrowers in the environment. + static void BABYLON_API Register(Napi::Env env, DelayedTaskScheduler& scheduler); + static void BABYLON_API Unregister(Napi::Env env); + static DelayedTaskScheduler* BABYLON_API Get(Napi::Env env); + }; +} diff --git a/Core/JsRuntime/Include/Babylon/JsRuntime.h b/Core/JsRuntime/Include/Babylon/JsRuntime.h index 51e6f1b7..75a7f4d9 100644 --- a/Core/JsRuntime/Include/Babylon/JsRuntime.h +++ b/Core/JsRuntime/Include/Babylon/JsRuntime.h @@ -4,13 +4,10 @@ #include #include -#include #include namespace Babylon { - class DeadlineScheduler; - class JsRuntime { public: @@ -40,22 +37,17 @@ namespace Babylon // later -- an instance of an inheriting type, for example. The dispatch function // must be safely callable as soon as it is passed to the JsRuntime constructor. static JsRuntime& BABYLON_API CreateForJavaScript(Napi::Env, DispatchFunctionT); - static JsRuntime& BABYLON_API CreateForJavaScript(Napi::Env, DispatchFunctionT, DeadlineScheduler&); static JsRuntime& BABYLON_API GetFromJavaScript(Napi::Env); void Dispatch(std::function); - DeadlineScheduler& GetDeadlineScheduler(); - ~JsRuntime(); protected: JsRuntime(const JsRuntime&) = delete; JsRuntime& operator=(const JsRuntime&) = delete; private: - JsRuntime(Napi::Env, DispatchFunctionT, DeadlineScheduler*); + JsRuntime(Napi::Env, DispatchFunctionT); DispatchFunctionT m_dispatchFunction{}; std::mutex m_mutex{}; - std::unique_ptr m_ownedDeadlineScheduler{}; - DeadlineScheduler* m_deadlineScheduler{}; }; } diff --git a/Core/JsRuntime/Source/DelayedTaskSchedulerRegistration.cpp b/Core/JsRuntime/Source/DelayedTaskSchedulerRegistration.cpp new file mode 100644 index 00000000..ab2c0db5 --- /dev/null +++ b/Core/JsRuntime/Source/DelayedTaskSchedulerRegistration.cpp @@ -0,0 +1,31 @@ +#include "DelayedTaskSchedulerRegistration.h" + +#include "JsRuntime.h" + +#include + +namespace Babylon +{ + namespace + { + constexpr auto JS_DELAYED_TASK_SCHEDULER_NAME = "delayedTaskScheduler"; + } + + void BABYLON_API DelayedTaskSchedulerRegistration::Register(Napi::Env env, DelayedTaskScheduler& scheduler) + { + JsRuntime::NativeObject::GetFromJavaScript(env).Set( + JS_DELAYED_TASK_SCHEDULER_NAME, + Napi::External::New(env, &scheduler)); + } + + void BABYLON_API DelayedTaskSchedulerRegistration::Unregister(Napi::Env env) + { + JsRuntime::NativeObject::GetFromJavaScript(env).Set(JS_DELAYED_TASK_SCHEDULER_NAME, env.Undefined()); + } + + DelayedTaskScheduler* BABYLON_API DelayedTaskSchedulerRegistration::Get(Napi::Env env) + { + const auto value = JsRuntime::NativeObject::GetFromJavaScript(env).Get(JS_DELAYED_TASK_SCHEDULER_NAME); + return value.IsUndefined() ? nullptr : value.As>().Data(); + } +} diff --git a/Core/JsRuntime/Source/JsRuntime.cpp b/Core/JsRuntime/Source/JsRuntime.cpp index 44a6da7f..27db82b0 100644 --- a/Core/JsRuntime/Source/JsRuntime.cpp +++ b/Core/JsRuntime/Source/JsRuntime.cpp @@ -1,9 +1,6 @@ #include "JsRuntime.h" -#include "Babylon/DeadlineScheduler.h" #include "Babylon/DebugTrace.h" -#include - namespace Babylon { namespace @@ -12,19 +9,9 @@ namespace Babylon static constexpr auto JS_WINDOW_NAME = "window"; } - JsRuntime::JsRuntime(Napi::Env env, DispatchFunctionT dispatchFunction, DeadlineScheduler* deadlineScheduler) + JsRuntime::JsRuntime(Napi::Env env, DispatchFunctionT dispatchFunction) : m_dispatchFunction{std::move(dispatchFunction)} { - if (deadlineScheduler != nullptr) - { - m_deadlineScheduler = deadlineScheduler; - } - else - { - m_ownedDeadlineScheduler = std::make_unique(); - m_deadlineScheduler = m_ownedDeadlineScheduler.get(); - } - auto global = env.Global(); if (global.Get(JS_WINDOW_NAME).IsUndefined()) @@ -41,30 +28,12 @@ namespace Babylon DEBUG_TRACE("JsRuntime created"); } - JsRuntime::~JsRuntime() = default; - JsRuntime& BABYLON_API JsRuntime::CreateForJavaScript(Napi::Env env, DispatchFunctionT dispatchFunction) { - auto* runtime = new JsRuntime(env, std::move(dispatchFunction), nullptr); + auto* runtime = new JsRuntime(env, std::move(dispatchFunction)); return *runtime; } - JsRuntime& BABYLON_API JsRuntime::CreateForJavaScript(Napi::Env env, DispatchFunctionT dispatchFunction, DeadlineScheduler& deadlineScheduler) - { - auto* runtime = new JsRuntime(env, std::move(dispatchFunction), &deadlineScheduler); - return *runtime; - } - - DeadlineScheduler& JsRuntime::GetDeadlineScheduler() - { - if (m_deadlineScheduler == nullptr) - { - throw std::runtime_error{"JsRuntime deadline scheduler is not available"}; - } - - return *m_deadlineScheduler; - } - JsRuntime& BABYLON_API JsRuntime::GetFromJavaScript(Napi::Env env) { return *NativeObject::GetFromJavaScript(env) diff --git a/Polyfills/Scheduling/Source/Scheduling.cpp b/Polyfills/Scheduling/Source/Scheduling.cpp index 295e30ee..3c7f5141 100644 --- a/Polyfills/Scheduling/Source/Scheduling.cpp +++ b/Polyfills/Scheduling/Source/Scheduling.cpp @@ -35,7 +35,7 @@ namespace Babylon::Polyfills::Scheduling void BABYLON_API Initialize(Napi::Env env) { auto global = env.Global(); - auto timeoutDispatcher = std::make_shared(JsRuntime::GetFromJavaScript(env)); + auto timeoutDispatcher = std::make_shared(env, JsRuntime::GetFromJavaScript(env)); if (global.Get(JS_SET_TIMEOUT_NAME).IsUndefined() && global.Get(JS_CLEAR_TIMEOUT_NAME).IsUndefined()) { diff --git a/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp b/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp index c16b9caf..a0265d26 100644 --- a/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp +++ b/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp @@ -1,14 +1,19 @@ #include "TimeoutDispatcher.h" +#include + +#include #include #include +#include #include +#include namespace Babylon::Polyfills::Internal { namespace { - DeadlineScheduler::TimePoint Now() + DelayedTaskScheduler::TimePoint Now() { return std::chrono::time_point_cast(std::chrono::steady_clock::now()); } @@ -16,14 +21,7 @@ namespace Babylon::Polyfills::Internal struct TimeoutDispatcher::Timeout { - TimeoutId id; - uint64_t sequence; - std::shared_ptr function; - TimePoint time; - std::optional interval; - DeadlineScheduler::Id scheduleId{}; - - Timeout(TimeoutId id, uint64_t sequence, std::shared_ptr function, TimePoint time, std::optional interval) + Timeout(TimeoutId id, uint64_t sequence, std::shared_ptr function, DelayedTaskScheduler::TimePoint time, std::optional interval) : id{id} , sequence{sequence} , function{std::move(function)} @@ -32,107 +30,87 @@ namespace Babylon::Polyfills::Internal { } - Timeout(const Timeout&) = delete; - Timeout(Timeout&&) = delete; + TimeoutId id; + uint64_t sequence; + std::shared_ptr function; + DelayedTaskScheduler::TimePoint time; + std::optional interval; + DelayedTaskScheduler::Id scheduleId{}; }; - TimeoutDispatcher::TimeoutDispatcher(Babylon::JsRuntime& runtime) - : m_runtime{runtime} - , m_scheduler{runtime.GetDeadlineScheduler()} + struct TimeoutDispatcher::State { - } - - TimeoutDispatcher::~TimeoutDispatcher() - { - std::unique_lock lk{m_mutex}; - for (auto& [id, timeout] : m_idMap) + State(Napi::Env env, Babylon::JsRuntime& runtime) + : runtime{&runtime} { - m_scheduler.Cancel(timeout->scheduleId); + scheduler = DelayedTaskSchedulerRegistration::Get(env); + if (scheduler == nullptr) + { + ownedScheduler = std::make_unique(); + scheduler = ownedScheduler.get(); + } } - m_idMap.clear(); - } - TimeoutDispatcher::TimeoutId TimeoutDispatcher::Dispatch(std::shared_ptr function, std::chrono::milliseconds delay, bool repeat) - { - if (delay.count() < 0) + TimeoutId NextTimeoutId() { - delay = std::chrono::milliseconds{0}; - } - - std::unique_lock lk{m_mutex}; + while (true) + { + ++lastTimeoutId; + if (lastTimeoutId <= 0) + { + lastTimeoutId = 1; + } - const auto id = NextTimeoutId(); - const auto sequence = ++m_lastSequence; - const auto time = Now() + delay; - auto timeout = std::make_unique(id, sequence, std::move(function), time, repeat ? std::make_optional(delay) : std::nullopt); - const auto [it, inserted] = m_idMap.try_emplace(id, std::move(timeout)); - if (!inserted) - { - throw std::logic_error{"TimeoutDispatcher: NextTimeoutId returned a duplicate id"}; + if (timeouts.find(lastTimeoutId) == timeouts.end()) + { + return lastTimeoutId; + } + } } - it->second->scheduleId = m_scheduler.Schedule(time, [this, id, sequence]() { - CallFunction(id, sequence); - }); - - return id; - } - - void TimeoutDispatcher::Clear(TimeoutId id) - { - std::unique_lock lk{m_mutex}; - const auto itId = m_idMap.find(id); - if (itId != m_idMap.end()) - { - m_scheduler.Cancel(itId->second->scheduleId); - m_idMap.erase(itId); - } - } + void CallFunction(const std::shared_ptr& self, TimeoutId id, uint64_t sequence); + void Rearm(const std::shared_ptr& self, TimeoutId id, uint64_t sequence, DelayedTaskScheduler::TimePoint scheduledTime, std::chrono::milliseconds interval); + + std::recursive_mutex mutex; + Babylon::JsRuntime* runtime; + std::unique_ptr ownedScheduler; + DelayedTaskScheduler* scheduler; + TimeoutId lastTimeoutId{}; + uint64_t lastSequence{}; + std::unordered_map> timeouts; + bool active{true}; + }; - TimeoutDispatcher::TimeoutId TimeoutDispatcher::NextTimeoutId() + void TimeoutDispatcher::State::CallFunction(const std::shared_ptr& self, TimeoutId id, uint64_t sequence) { - while (true) + std::scoped_lock lock{mutex}; + const auto timeoutIt = timeouts.find(id); + if (!active || timeoutIt == timeouts.end() || timeoutIt->second->sequence != sequence) { - ++m_lastTimeoutId; - - if (m_lastTimeoutId <= 0) - { - m_lastTimeoutId = 1; - } - - if (m_idMap.find(m_lastTimeoutId) == m_idMap.end()) - { - return m_lastTimeoutId; - } + return; } - } - void TimeoutDispatcher::CallFunction(TimeoutId id, uint64_t sequence) - { - m_runtime.Dispatch([id, sequence, this](Napi::Env) { - std::shared_ptr function{}; - std::optional interval{}; - TimePoint scheduledTime{}; + runtime->Dispatch([self, id, sequence](Napi::Env) { + std::shared_ptr function; + std::optional interval; + DelayedTaskScheduler::TimePoint scheduledTime; { - std::unique_lock lk{m_mutex}; - const auto it = m_idMap.find(id); - if (it == m_idMap.end() || it->second->sequence != sequence) + std::scoped_lock callbackLock{self->mutex}; + const auto callbackIt = self->timeouts.find(id); + if (!self->active || callbackIt == self->timeouts.end() || callbackIt->second->sequence != sequence) { - // Cleared before the callback could run, or the id has since - // been reused by an unrelated timeout. return; } - interval = it->second->interval; - scheduledTime = it->second->time; - + interval = callbackIt->second->interval; + scheduledTime = callbackIt->second->time; if (interval.has_value()) { - function = it->second->function; + function = callbackIt->second->function; } else { - const auto timeout = std::move(m_idMap.extract(id).mapped()); + auto timeout = std::move(self->timeouts.extract(id).mapped()); function = std::move(timeout->function); } } @@ -145,13 +123,9 @@ namespace Babylon::Polyfills::Internal } catch (const Napi::Error& error) { - // A throwing tick must not silently stop the interval, which - // is both the pre-existing behavior and what browsers do. - // Re-arm first, then re-raise the error as a pending JS - // exception so JsRuntime::Dispatch still surfaces it. if (interval.has_value()) { - Rearm(id, sequence, scheduledTime, *interval); + self->Rearm(self, id, sequence, scheduledTime, *interval); } error.ThrowAsJavaScriptException(); @@ -161,29 +135,20 @@ namespace Babylon::Polyfills::Internal if (interval.has_value()) { - Rearm(id, sequence, scheduledTime, *interval); + self->Rearm(self, id, sequence, scheduledTime, *interval); } }); } - // Re-arms a repeating timeout. Called on the JS thread once the callback has - // returned, so a repeating timeout can never have more than one invocation - // queued at a time. - void TimeoutDispatcher::Rearm(TimeoutId id, uint64_t sequence, TimePoint scheduledTime, std::chrono::milliseconds interval) + void TimeoutDispatcher::State::Rearm(const std::shared_ptr& self, TimeoutId id, uint64_t sequence, DelayedTaskScheduler::TimePoint scheduledTime, std::chrono::milliseconds interval) { - std::unique_lock lk{m_mutex}; - - const auto it = m_idMap.find(id); - if (it == m_idMap.end() || it->second->sequence != sequence) + std::scoped_lock lock{mutex}; + const auto timeoutIt = timeouts.find(id); + if (!active || timeoutIt == timeouts.end() || timeoutIt->second->sequence != sequence) { - // Cleared from within its own callback, or the id has since been - // reused by an unrelated timeout. return; } - // Anchor the next deadline to the previous scheduled time so that a long - // running callback does not accumulate drift, but never schedule into the - // past. const auto now = Now(); auto nextTime = scheduledTime + interval; if (nextTime < now) @@ -191,9 +156,90 @@ namespace Babylon::Polyfills::Internal nextTime = now; } - it->second->time = nextTime; - it->second->scheduleId = m_scheduler.Schedule(nextTime, [this, id, sequence]() { - CallFunction(id, sequence); + timeoutIt->second->time = nextTime; + timeoutIt->second->scheduleId = scheduler->Schedule(nextTime, [self, id, sequence]() { + self->CallFunction(self, id, sequence); }); } -} \ No newline at end of file + + TimeoutDispatcher::TimeoutDispatcher(Napi::Env env, Babylon::JsRuntime& runtime) + : m_state{std::make_shared(env, runtime)} + { + } + + TimeoutDispatcher::~TimeoutDispatcher() + { + std::vector scheduleIds; + DelayedTaskScheduler* ownedScheduler{}; + { + std::scoped_lock lock{m_state->mutex}; + m_state->active = false; + m_state->runtime = nullptr; + scheduleIds.reserve(m_state->timeouts.size()); + for (const auto& [id, timeout] : m_state->timeouts) + { + scheduleIds.push_back(timeout->scheduleId); + } + m_state->timeouts.clear(); + ownedScheduler = m_state->ownedScheduler.get(); + } + + for (const auto scheduleId : scheduleIds) + { + m_state->scheduler->Cancel(scheduleId); + } + + if (ownedScheduler != nullptr) + { + ownedScheduler->Shutdown(); + } + } + + TimeoutDispatcher::TimeoutId TimeoutDispatcher::Dispatch(std::shared_ptr function, std::chrono::milliseconds delay, bool repeat) + { + if (delay.count() < 0) + { + delay = std::chrono::milliseconds{0}; + } + + std::scoped_lock lock{m_state->mutex}; + const auto id = m_state->NextTimeoutId(); + const auto sequence = ++m_state->lastSequence; + const auto time = Now() + delay; + auto timeout = std::make_unique( + id, + sequence, + std::move(function), + time, + repeat ? std::make_optional(delay) : std::nullopt); + const auto [timeoutIt, inserted] = m_state->timeouts.try_emplace(id, std::move(timeout)); + if (!inserted) + { + throw std::logic_error{"TimeoutDispatcher: NextTimeoutId returned a duplicate id"}; + } + + const auto state = m_state; + timeoutIt->second->scheduleId = m_state->scheduler->Schedule(time, [state, id, sequence]() { + state->CallFunction(state, id, sequence); + }); + return id; + } + + void TimeoutDispatcher::Clear(TimeoutId id) + { + DelayedTaskScheduler::Id scheduleId{}; + { + std::scoped_lock lock{m_state->mutex}; + const auto timeoutIt = m_state->timeouts.find(id); + if (timeoutIt == m_state->timeouts.end()) + { + return; + } + + scheduleId = timeoutIt->second->scheduleId; + m_state->timeouts.erase(timeoutIt); + } + + m_state->scheduler->Cancel(scheduleId); + } +} diff --git a/Polyfills/Scheduling/Source/TimeoutDispatcher.h b/Polyfills/Scheduling/Source/TimeoutDispatcher.h index a793f478..cd39b6f5 100644 --- a/Polyfills/Scheduling/Source/TimeoutDispatcher.h +++ b/Polyfills/Scheduling/Source/TimeoutDispatcher.h @@ -1,14 +1,12 @@ #pragma once -#include +#include #include #include #include #include #include -#include -#include namespace Babylon::Polyfills::Internal { @@ -18,24 +16,14 @@ namespace Babylon::Polyfills::Internal struct Timeout; public: - TimeoutDispatcher(Babylon::JsRuntime& runtime); + TimeoutDispatcher(Napi::Env env, Babylon::JsRuntime& runtime); ~TimeoutDispatcher(); TimeoutId Dispatch(std::shared_ptr function, std::chrono::milliseconds delay, bool repeat = false); void Clear(TimeoutId id); private: - using TimePoint = DeadlineScheduler::TimePoint; - - TimeoutId NextTimeoutId(); - void CallFunction(TimeoutId id, uint64_t sequence); - void Rearm(TimeoutId id, uint64_t sequence, TimePoint scheduledTime, std::chrono::milliseconds interval); - - Babylon::JsRuntime& m_runtime; - DeadlineScheduler& m_scheduler; - std::recursive_mutex m_mutex{}; - TimeoutId m_lastTimeoutId{0}; - uint64_t m_lastSequence{0}; - std::unordered_map> m_idMap; + struct State; + std::shared_ptr m_state; }; } diff --git a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt index 84250e46..c53d6b57 100644 --- a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt +++ b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt @@ -18,10 +18,16 @@ npm(install --silent WORKING_DIRECTORY ${TESTS_DIR}) add_library(UnitTestsJNI SHARED JNI.cpp + ${UNIT_TESTS_DIR}/Shared/DelayedTaskScheduler.cpp ${UNIT_TESTS_DIR}/Shared/StandardStreamLogger.cpp ${UNIT_TESTS_DIR}/Shared/Shared.h ${UNIT_TESTS_DIR}/Shared/Shared.cpp) +if(NAPI_JAVASCRIPT_ENGINE STREQUAL "V8") + target_sources(UnitTestsJNI PRIVATE ${UNIT_TESTS_DIR}/Shared/V8ForegroundTaskRunner.cpp) + target_link_libraries(UnitTestsJNI PRIVATE AppRuntimeInternal) +endif() + target_compile_definitions(UnitTestsJNI PRIVATE JSRUNTIMEHOST_PLATFORM="${JSRUNTIMEHOST_PLATFORM}") target_compile_definitions(UnitTestsJNI PRIVATE NAPI_JAVASCRIPT_ENGINE="${NAPI_JAVASCRIPT_ENGINE}") target_compile_definitions(UnitTestsJNI PRIVATE ARCANA_TEST_HOOKS) diff --git a/Tests/UnitTests/CMakeLists.txt b/Tests/UnitTests/CMakeLists.txt index b4917824..624e9a7e 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -8,6 +8,7 @@ set(TYPE_SCRIPTS file(GLOB ASSETS "${CMAKE_CURRENT_SOURCE_DIR}/Assets/*") set(SOURCES + "Shared/DelayedTaskScheduler.cpp" "Shared/StandardStreamLogger.cpp" "Shared/Shared.cpp" "Shared/Shared.h") @@ -45,6 +46,10 @@ elseif(UNIX AND NOT ANDROID) endif() add_executable(UnitTests ${SOURCES} ${SCRIPTS} ${TYPE_SCRIPTS} ${ASSETS}) +if(NAPI_JAVASCRIPT_ENGINE STREQUAL "V8") + target_sources(UnitTests PRIVATE "Shared/V8ForegroundTaskRunner.cpp") + target_link_libraries(UnitTests PRIVATE AppRuntimeInternal) +endif() target_compile_definitions(UnitTests PRIVATE JSRUNTIMEHOST_PLATFORM="${JSRUNTIMEHOST_PLATFORM}") target_compile_definitions(UnitTests PRIVATE NAPI_JAVASCRIPT_ENGINE="${NAPI_JAVASCRIPT_ENGINE}") diff --git a/Tests/UnitTests/Shared/DelayedTaskScheduler.cpp b/Tests/UnitTests/Shared/DelayedTaskScheduler.cpp new file mode 100644 index 00000000..70f0ed84 --- /dev/null +++ b/Tests/UnitTests/Shared/DelayedTaskScheduler.cpp @@ -0,0 +1,164 @@ +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; + +TEST(DelayedTaskScheduler, RunsAtOrAfterRequestedTime) +{ + Babylon::DelayedTaskScheduler scheduler; + std::promise ranPromise; + const auto requestedTime = std::chrono::time_point_cast( + std::chrono::steady_clock::now() + 10ms); + + scheduler.Schedule(requestedTime, [&ranPromise]() { + ranPromise.set_value(std::chrono::time_point_cast( + std::chrono::steady_clock::now())); + }); + + auto ranFuture = ranPromise.get_future(); + ASSERT_EQ(ranFuture.wait_for(5s), std::future_status::ready); + EXPECT_GE(ranFuture.get(), requestedTime); +} + +TEST(DelayedTaskScheduler, CancelDoesNotWaitForExtractedCallback) +{ + Babylon::DelayedTaskScheduler scheduler; + std::promise enteredPromise; + std::promise releasePromise; + auto release = releasePromise.get_future().share(); + + const auto id = scheduler.Schedule(0ms, [&enteredPromise, release]() { + enteredPromise.set_value(); + release.wait(); + }); + + ASSERT_EQ(enteredPromise.get_future().wait_for(5s), std::future_status::ready); + const auto cancelStarted = std::chrono::steady_clock::now(); + scheduler.Cancel(id); + EXPECT_LT(std::chrono::steady_clock::now() - cancelStarted, 1s); + releasePromise.set_value(); +} + +TEST(DelayedTaskScheduler, CancelRemovesQueuedCallback) +{ + Babylon::DelayedTaskScheduler scheduler; + std::promise calledPromise; + auto calledFuture = calledPromise.get_future(); + + const auto id = scheduler.Schedule(100ms, [&calledPromise]() { + calledPromise.set_value(); + }); + scheduler.Cancel(id); + + EXPECT_EQ(calledFuture.wait_for(200ms), std::future_status::timeout); +} + +TEST(DelayedTaskScheduler, ShutdownWaitsForRunningCallbackAndRejectsNewWork) +{ + Babylon::DelayedTaskScheduler scheduler; + std::promise enteredPromise; + std::promise releasePromise; + auto release = releasePromise.get_future().share(); + + scheduler.Schedule(0ms, [&enteredPromise, release]() { + enteredPromise.set_value(); + release.wait(); + }); + ASSERT_EQ(enteredPromise.get_future().wait_for(5s), std::future_status::ready); + + auto shutdownFuture = std::async(std::launch::async, [&scheduler]() { + scheduler.Shutdown(); + }); + EXPECT_EQ(shutdownFuture.wait_for(20ms), std::future_status::timeout); + releasePromise.set_value(); + EXPECT_EQ(shutdownFuture.wait_for(5s), std::future_status::ready); + EXPECT_THROW(scheduler.Schedule(0ms, [] {}), std::runtime_error); +} + +TEST(DelayedTaskSchedulerRegistration, RegisterAndUnregisterFollowEnvironmentLifetime) +{ + Babylon::AppRuntime runtime; + std::promise lifecyclePromise; + + runtime.Dispatch([&lifecyclePromise](Napi::Env env) { + auto* const scheduler = Babylon::DelayedTaskSchedulerRegistration::Get(env); + if (scheduler == nullptr) + { + lifecyclePromise.set_value(false); + return; + } + + Babylon::DelayedTaskSchedulerRegistration::Unregister(env); + const bool unregistered = Babylon::DelayedTaskSchedulerRegistration::Get(env) == nullptr; + Babylon::DelayedTaskSchedulerRegistration::Register(env, *scheduler); + lifecyclePromise.set_value( + unregistered && + Babylon::DelayedTaskSchedulerRegistration::Get(env) == scheduler); + }); + + auto lifecycleFuture = lifecyclePromise.get_future(); + ASSERT_EQ(lifecycleFuture.wait_for(5s), std::future_status::ready); + EXPECT_TRUE(lifecycleFuture.get()); +} + +TEST(SchedulingLifecycle, UsesOwnedSchedulerWithoutRegistration) +{ + Babylon::AppRuntime runtime; + std::promise timerPromise; + + runtime.Dispatch([&timerPromise](Napi::Env env) { + Babylon::DelayedTaskSchedulerRegistration::Unregister(env); + Babylon::Polyfills::Scheduling::Initialize(env); + env.Global().Set( + "timerComplete", + Napi::Function::New(env, [&timerPromise](const Napi::CallbackInfo&) { + timerPromise.set_value(); + })); + env.Global().Get("setTimeout").As().Call( + env.Global(), + {env.Global().Get("timerComplete"), Napi::Number::New(env, 1)}); + }); + + auto timerFuture = timerPromise.get_future(); + ASSERT_EQ(timerFuture.wait_for(5s), std::future_status::ready); +} + +TEST(SchedulingLifecycle, RuntimeShutdownCancelsBorrowedTimers) +{ + std::atomic_bool called{}; + auto destroyFuture = std::async(std::launch::async, [&called]() { + auto runtime = std::make_unique(); + std::promise initializedPromise; + runtime->Dispatch([&called, &initializedPromise](Napi::Env env) { + Babylon::Polyfills::Scheduling::Initialize(env); + auto callback = Napi::Function::New(env, [&called](const Napi::CallbackInfo&) { + called = true; + }); + env.Global().Get("setTimeout").As().Call( + env.Global(), + {callback, Napi::Number::New(env, 60000)}); + initializedPromise.set_value(); + }); + + if (initializedPromise.get_future().wait_for(5s) != std::future_status::ready) + { + throw std::runtime_error{"Scheduling initialization timed out"}; + } + runtime.reset(); + }); + + ASSERT_EQ(destroyFuture.wait_for(5s), std::future_status::ready); + EXPECT_NO_THROW(destroyFuture.get()); + EXPECT_FALSE(called.load()); +} diff --git a/Tests/UnitTests/Shared/V8ForegroundTaskRunner.cpp b/Tests/UnitTests/Shared/V8ForegroundTaskRunner.cpp new file mode 100644 index 00000000..6575a647 --- /dev/null +++ b/Tests/UnitTests/Shared/V8ForegroundTaskRunner.cpp @@ -0,0 +1,201 @@ +#include "V8ForegroundTaskRunner.h" + +#include + +#include +#include +#include + +namespace +{ + using Runner = Babylon::Internal::V8ForegroundTaskRunner; + using Scheduler = Babylon::DelayedTaskScheduler; + using namespace std::chrono_literals; + + class Task final : public v8::Task + { + public: + void Run() override {} + }; +} + +TEST(V8ForegroundTaskRunner, RoundsAbsoluteTimeUpWithoutTruncatingDelay) +{ + const auto now = std::chrono::steady_clock::time_point{std::chrono::duration_cast(1234567100ns)}; + EXPECT_EQ(Runner::GetScheduledTime(now, 0.0005), Scheduler::TimePoint{1235068us}); + EXPECT_EQ(Runner::GetScheduledTime(now, 0.0000005), Scheduler::TimePoint{1234568us}); + EXPECT_EQ(Runner::GetScheduledTime(now, 0), Scheduler::TimePoint{1234568us}); + + for (const auto delay : {0.0000005, 0.0005, 0.0015}) + { + Scheduler::TimePoint scheduled; + Runner runner{ + [](auto) {}, + [&scheduled](auto when, auto) { + scheduled = when; + return 1; + }, + [](auto) {}}; + const auto before = std::chrono::steady_clock::now(); + runner.PostDelayedTask(std::make_unique(), delay); + const auto after = std::chrono::steady_clock::now(); + EXPECT_GE(scheduled, before + std::chrono::duration{delay}); + EXPECT_LT(scheduled, after + std::chrono::duration{delay} + 1us); + } +} + +TEST(V8ForegroundTaskRunner, NonpositiveDelaysRemainImmediatelyEligible) +{ + for (const auto delay : {0.0, -0.0005, -1.0}) + { + Scheduler::TimePoint scheduled; + Runner runner{ + [](auto) {}, + [&scheduled](auto when, auto) { + scheduled = when; + return 1; + }, + [](auto) {}}; + const auto before = std::chrono::steady_clock::now(); + runner.PostDelayedTask(std::make_unique(), delay); + const auto after = std::chrono::steady_clock::now(); + EXPECT_GE(scheduled, before); + EXPECT_LT(scheduled, after + 1us); + } +} + +TEST(V8ForegroundTaskRunner, FractionalMillisecondTaskDoesNotDispatchEarly) +{ + Scheduler scheduler; + std::promise dispatched; + Runner runner{ + [&dispatched](auto) { dispatched.set_value(std::chrono::steady_clock::now()); }, + [&scheduler](auto when, auto callback) { return scheduler.Schedule(when, std::move(callback)); }, + [&scheduler](auto id) { scheduler.Cancel(id); }}; + + const auto before = std::chrono::steady_clock::now(); + runner.PostNonNestableDelayedTask(std::make_unique(), 0.0005); + auto result = dispatched.get_future(); + ASSERT_EQ(result.wait_for(5s), std::future_status::ready); + EXPECT_GE(result.get(), before + 500us); +} + +TEST(V8ForegroundTaskRunner, CompletionBeforeScheduleReturnsDoesNotRetainId) +{ + size_t dispatched{}; + size_t cancelled{}; + { + Runner runner{ + [&dispatched](auto) { ++dispatched; }, + [](auto, auto callback) { + callback(); + return 1; + }, + [&cancelled](auto) { ++cancelled; }}; + for (size_t index = 0; index < 1000; ++index) + { + runner.PostDelayedTask(std::make_unique(), 0); + } + } + EXPECT_EQ(dispatched, 1000); + EXPECT_EQ(cancelled, 0); +} + +TEST(V8ForegroundTaskRunner, CompletionRemovesOnlyItsOwnPendingRecord) +{ + std::vector callbacks; + std::vector cancelled; + Scheduler::Id nextId{}; + { + Runner runner{ + [](auto) {}, + [&callbacks, &nextId](auto, auto callback) { + callbacks.push_back(std::move(callback)); + return ++nextId; + }, + [&cancelled](auto id) { cancelled.push_back(id); }}; + for (size_t index = 0; index < 1000; ++index) + { + runner.PostDelayedTask(std::make_unique(), 0); + auto callback = std::move(callbacks.back()); + callback(); + } + runner.PostDelayedTask(std::make_unique(), 60); + } + EXPECT_EQ(cancelled, (std::vector{1001})); +} + +TEST(V8ForegroundTaskRunner, ExtractedCallbackDoesNotDependOnRunnerLifetime) +{ + Scheduler::Callback extracted; + size_t dispatched{}; + size_t cancelled{}; + { + Runner runner{ + [&dispatched](auto) { ++dispatched; }, + [&extracted](auto, auto callback) { + extracted = std::move(callback); + return 1; + }, + [&cancelled](auto) { ++cancelled; }}; + runner.PostDelayedTask(std::make_unique(), 0); + } + ASSERT_TRUE(extracted); + extracted(); + EXPECT_EQ(dispatched, 0); + EXPECT_EQ(cancelled, 1); +} + +TEST(V8ForegroundTaskRunner, RetainedRunnerRejectsPostsAfterShutdown) +{ + size_t dispatched{}; + size_t scheduled{}; + Runner runner{ + [&dispatched](auto) { ++dispatched; }, + [&scheduled](auto, auto) { + ++scheduled; + return 1; + }, + [](auto) {}}; + runner.PostTask(std::make_unique()); + runner.PostNonNestableTask(std::make_unique()); + runner.Shutdown(); + runner.Shutdown(); + runner.PostTask(std::make_unique()); + runner.PostNonNestableTask(std::make_unique()); + runner.PostDelayedTask(std::make_unique(), 0); + runner.PostNonNestableDelayedTask(std::make_unique(), 0); + EXPECT_EQ(dispatched, 2); + EXPECT_EQ(scheduled, 0); +} + +TEST(V8ForegroundTaskRunner, ShutdownWaitsForInFlightDispatch) +{ + Scheduler::Callback callback; + std::promise dispatchStarted; + std::promise releaseDispatch; + auto release = releaseDispatch.get_future(); + Runner runner{ + [&dispatchStarted, &release](auto) { + dispatchStarted.set_value(); + release.wait(); + }, + [&callback](auto, auto work) { + callback = std::move(work); + return 1; + }, + [](auto) {}}; + runner.PostDelayedTask(std::make_unique(), 0); + auto worker = std::async(std::launch::async, [&callback]() { callback(); }); + dispatchStarted.get_future().wait(); + std::promise shutdownStarted; + auto shutdown = std::async(std::launch::async, [&runner, &shutdownStarted]() { + shutdownStarted.set_value(); + runner.Shutdown(); + }); + shutdownStarted.get_future().wait(); + EXPECT_EQ(shutdown.wait_for(20ms), std::future_status::timeout); + releaseDispatch.set_value(); + EXPECT_EQ(worker.wait_for(5s), std::future_status::ready); + EXPECT_EQ(shutdown.wait_for(5s), std::future_status::ready); +} From 3014935126855875a96116f330942169c584452f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87=20=28via=20Copilot=29?= <223556219+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:08:30 -0700 Subject: [PATCH 6/9] Use the existing V8 include wrapper in extracted task headers Include napi/env.h before V8 declarations so Android's -Werror build retains the established third-party warning guards and V8 configuration. Reproduced the unused-parameter failure and verified all three V8 sources with Android NDK Clang, without disabling project warnings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 54aaa1e1-b4b3-46e7-9de4-5a56add4ac42 --- Core/AppRuntime/Source/V8ForegroundTaskRunner.h | 2 +- Core/AppRuntime/Source/V8Platform.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Core/AppRuntime/Source/V8ForegroundTaskRunner.h b/Core/AppRuntime/Source/V8ForegroundTaskRunner.h index a000c078..99495894 100644 --- a/Core/AppRuntime/Source/V8ForegroundTaskRunner.h +++ b/Core/AppRuntime/Source/V8ForegroundTaskRunner.h @@ -1,7 +1,7 @@ #pragma once #include -#include +#include #include #include diff --git a/Core/AppRuntime/Source/V8Platform.h b/Core/AppRuntime/Source/V8Platform.h index 3ed04d79..6dc93aa6 100644 --- a/Core/AppRuntime/Source/V8Platform.h +++ b/Core/AppRuntime/Source/V8Platform.h @@ -1,6 +1,6 @@ #pragma once -#include +#include #include #include From 5da35cc3c05c729c7d7079cc8841c41f85552265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87=20=28via=20Copilot=29?= <223556219+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:30:46 -0700 Subject: [PATCH 7/9] Avoid signed overflow when wrapping scheduler and timeout IDs Use a shared constexpr increment helper that checks INT32_MAX before adding and wraps directly to 1. Preserve both generators' live-ID collision checks. Cover initial allocation, INT32_MAX - 1, INT32_MAX, and post-wrap values, including a constant-expression assertion that rejects signed overflow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 54aaa1e1-b4b3-46e7-9de4-5a56add4ac42 --- Core/Foundation/CMakeLists.txt | 1 + .../Include/Babylon/Internal/TimerId.h | 12 ++++++++++++ .../Source/DelayedTaskScheduler.cpp | 7 ++----- .../Scheduling/Source/TimeoutDispatcher.cpp | 7 ++----- .../UnitTests/Shared/DelayedTaskScheduler.cpp | 19 +++++++++++++++++++ 5 files changed, 36 insertions(+), 10 deletions(-) create mode 100644 Core/Foundation/Include/Babylon/Internal/TimerId.h diff --git a/Core/Foundation/CMakeLists.txt b/Core/Foundation/CMakeLists.txt index cbfee69d..febbca55 100644 --- a/Core/Foundation/CMakeLists.txt +++ b/Core/Foundation/CMakeLists.txt @@ -2,6 +2,7 @@ set(SOURCES "Include/Babylon/Api.h" "Include/Babylon/DebugTrace.h" "Include/Babylon/DelayedTaskScheduler.h" + "Include/Babylon/Internal/TimerId.h" "Include/Babylon/PerfTrace.h" "Include/Babylon/StandardStreamLogger.h" "Source/DebugTrace.cpp" diff --git a/Core/Foundation/Include/Babylon/Internal/TimerId.h b/Core/Foundation/Include/Babylon/Internal/TimerId.h new file mode 100644 index 00000000..cea9f185 --- /dev/null +++ b/Core/Foundation/Include/Babylon/Internal/TimerId.h @@ -0,0 +1,12 @@ +#pragma once + +#include +#include + +namespace Babylon::Internal +{ + constexpr int32_t IncrementTimerId(int32_t id) + { + return id == std::numeric_limits::max() ? 1 : id + 1; + } +} diff --git a/Core/Foundation/Source/DelayedTaskScheduler.cpp b/Core/Foundation/Source/DelayedTaskScheduler.cpp index a639bc0f..d06fbc79 100644 --- a/Core/Foundation/Source/DelayedTaskScheduler.cpp +++ b/Core/Foundation/Source/DelayedTaskScheduler.cpp @@ -1,4 +1,5 @@ #include "DelayedTaskScheduler.h" +#include "Internal/TimerId.h" #include #include @@ -123,11 +124,7 @@ namespace Babylon { while (true) { - ++m_lastId; - if (m_lastId <= 0) - { - m_lastId = 1; - } + m_lastId = Internal::IncrementTimerId(m_lastId); if (m_idMap.find(m_lastId) == m_idMap.end()) { diff --git a/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp b/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp index a0265d26..64d7c9d4 100644 --- a/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp +++ b/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp @@ -1,6 +1,7 @@ #include "TimeoutDispatcher.h" #include +#include #include #include @@ -55,11 +56,7 @@ namespace Babylon::Polyfills::Internal { while (true) { - ++lastTimeoutId; - if (lastTimeoutId <= 0) - { - lastTimeoutId = 1; - } + lastTimeoutId = Babylon::Internal::IncrementTimerId(lastTimeoutId); if (timeouts.find(lastTimeoutId) == timeouts.end()) { diff --git a/Tests/UnitTests/Shared/DelayedTaskScheduler.cpp b/Tests/UnitTests/Shared/DelayedTaskScheduler.cpp index 70f0ed84..45d78009 100644 --- a/Tests/UnitTests/Shared/DelayedTaskScheduler.cpp +++ b/Tests/UnitTests/Shared/DelayedTaskScheduler.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include @@ -8,12 +9,30 @@ #include #include #include +#include #include #include #include using namespace std::chrono_literals; +TEST(TimerId, StartsAtOne) +{ + EXPECT_EQ(Babylon::Internal::IncrementTimerId(0), 1); +} + +TEST(TimerId, WrapsBeforeSignedOverflow) +{ + static_assert(Babylon::Internal::IncrementTimerId(std::numeric_limits::max()) == 1); + auto id = std::numeric_limits::max() - 1; + id = Babylon::Internal::IncrementTimerId(id); + EXPECT_EQ(id, std::numeric_limits::max()); + id = Babylon::Internal::IncrementTimerId(id); + EXPECT_EQ(id, 1); + id = Babylon::Internal::IncrementTimerId(id); + EXPECT_EQ(id, 2); +} + TEST(DelayedTaskScheduler, RunsAtOrAfterRequestedTime) { Babylon::DelayedTaskScheduler scheduler; From 864fb3babb1b62c6e6b619036a053d9071ab7154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87=20=28via=20Copilot=29?= <223556219+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:10:25 -0700 Subject: [PATCH 8/9] Align runtime teardown and internal scheduler boundaries Replace the one-off Run shutdown callback with an engine-selected ShutdownEnvironment hook. V8 unregisters foreground routing there; the other engines provide no-op implementations. Keep delayed scheduling and environment association in one internal Foundation abstraction, exposed through FoundationInternal. Use a dedicated Babylon global property instead of depending on JsRuntime's _native object. Inline overflow-safe ID wrapping in each allocator and replace the shared helper tests with boundary coverage through Schedule and Dispatch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 54aaa1e1-b4b3-46e7-9de4-5a56add4ac42 --- Core/AppRuntime/CMakeLists.txt | 3 +- Core/AppRuntime/Include/Babylon/AppRuntime.h | 14 ++-- Core/AppRuntime/Source/AppRuntime.cpp | 18 ++--- Core/AppRuntime/Source/AppRuntime_Chakra.cpp | 4 ++ Core/AppRuntime/Source/AppRuntime_Hermes.cpp | 4 ++ Core/AppRuntime/Source/AppRuntime_JSI.cpp | 4 ++ .../Source/AppRuntime_JavaScriptCore.cpp | 4 ++ Core/AppRuntime/Source/AppRuntime_QuickJS.cpp | 4 ++ Core/AppRuntime/Source/AppRuntime_V8.cpp | 9 ++- .../Source/V8ForegroundTaskRunner.h | 2 +- Core/AppRuntime/Source/V8Platform.h | 2 +- Core/Foundation/CMakeLists.txt | 9 ++- .../Include/Babylon/Internal/TimerId.h | 12 ---- .../Source/DelayedTaskScheduler.cpp | 36 ++++++++-- .../Babylon => Source}/DelayedTaskScheduler.h | 13 +++- Core/JsRuntime/CMakeLists.txt | 2 - .../DelayedTaskSchedulerRegistration.h | 19 ----- .../DelayedTaskSchedulerRegistration.cpp | 31 -------- Polyfills/Scheduling/CMakeLists.txt | 5 ++ .../Scheduling/Source/TimeoutDispatcher.cpp | 20 ++++-- .../Scheduling/Source/TimeoutDispatcher.h | 4 +- .../Android/app/src/main/cpp/CMakeLists.txt | 4 +- Tests/UnitTests/CMakeLists.txt | 5 +- .../UnitTests/Shared/DelayedTaskScheduler.cpp | 70 ++++++++++++------- Tests/UnitTests/Shared/TimeoutDispatcher.cpp | 56 +++++++++++++++ .../Shared/V8ForegroundTaskRunner.cpp | 2 +- 26 files changed, 224 insertions(+), 132 deletions(-) delete mode 100644 Core/Foundation/Include/Babylon/Internal/TimerId.h rename Core/Foundation/{Include/Babylon => Source}/DelayedTaskScheduler.h (73%) delete mode 100644 Core/JsRuntime/Include/Babylon/DelayedTaskSchedulerRegistration.h delete mode 100644 Core/JsRuntime/Source/DelayedTaskSchedulerRegistration.cpp create mode 100644 Tests/UnitTests/Shared/TimeoutDispatcher.cpp diff --git a/Core/AppRuntime/CMakeLists.txt b/Core/AppRuntime/CMakeLists.txt index 688c09f3..9e8d10c9 100644 --- a/Core/AppRuntime/CMakeLists.txt +++ b/Core/AppRuntime/CMakeLists.txt @@ -27,6 +27,7 @@ target_include_directories(AppRuntime INTERFACE "Include") target_link_libraries(AppRuntime + PRIVATE FoundationInternal PRIVATE arcana PUBLIC JsRuntime) @@ -69,4 +70,4 @@ source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) add_library(AppRuntimeInternal INTERFACE) target_include_directories(AppRuntimeInternal INTERFACE "Source") -target_link_libraries(AppRuntimeInternal INTERFACE AppRuntime) +target_link_libraries(AppRuntimeInternal INTERFACE AppRuntime INTERFACE FoundationInternal) diff --git a/Core/AppRuntime/Include/Babylon/AppRuntime.h b/Core/AppRuntime/Include/Babylon/AppRuntime.h index e82e2544..d6d024e5 100644 --- a/Core/AppRuntime/Include/Babylon/AppRuntime.h +++ b/Core/AppRuntime/Include/Babylon/AppRuntime.h @@ -12,7 +12,10 @@ namespace Babylon { - class DelayedTaskScheduler; + namespace Internal + { + class DelayedTaskScheduler; + } class AppRuntime final { @@ -63,8 +66,11 @@ namespace Babylon // that certain program state be allocated and stored only on the stack. void RunPlatformTier(); void RunEnvironmentTier(const char* executablePath = "."); - // Stop engine task routing before joining the scheduler and discarding work. - void Run(Napi::Env, std::function shutdown = {}); + void Run(Napi::Env); + + // Engine-specific hook to stop task routing before joining the scheduler + // and discarding queued work, while the environment is still attached. + void ShutdownEnvironment(Napi::Env env); // This method is called from Dispatch to allow platform-specific code to add // extra logic around the invocation of a dispatched callback. @@ -79,7 +85,7 @@ namespace Babylon // queue explicitly (Napi::DrainJobs / JS_ExecutePendingJob). void DrainMicrotasks(Napi::Env env); - DelayedTaskScheduler& GetDelayedTaskScheduler(); + Internal::DelayedTaskScheduler& GetDelayedTaskScheduler(); Options m_options; diff --git a/Core/AppRuntime/Source/AppRuntime.cpp b/Core/AppRuntime/Source/AppRuntime.cpp index 293186bf..808cc525 100644 --- a/Core/AppRuntime/Source/AppRuntime.cpp +++ b/Core/AppRuntime/Source/AppRuntime.cpp @@ -1,7 +1,6 @@ #include "AppRuntime.h" -#include -#include +#include "DelayedTaskScheduler.h" #include #include @@ -38,7 +37,7 @@ namespace Babylon std::optional> m_suspensionLock{}; arcana::cancellation_source m_cancelSource{}; arcana::manual_dispatcher<128> m_dispatcher{}; - std::unique_ptr m_delayedTaskScheduler{std::make_unique()}; + std::unique_ptr m_delayedTaskScheduler{std::make_unique()}; bool m_delayedTaskSchedulerRegistered{}; std::thread m_thread; }; @@ -56,7 +55,7 @@ namespace Babylon Dispatch([this](Napi::Env env) { JsRuntime::CreateForJavaScript(env, [this](auto func) { Dispatch(std::move(func)); }); - DelayedTaskSchedulerRegistration::Register(env, GetDelayedTaskScheduler()); + GetDelayedTaskScheduler().Register(env); m_impl->m_delayedTaskSchedulerRegistered = true; }); } @@ -83,7 +82,7 @@ namespace Babylon m_impl->m_thread.join(); } - void AppRuntime::Run(Napi::Env env, std::function shutdown) + void AppRuntime::Run(Napi::Env env) { m_impl->m_env = std::make_optional(env); @@ -95,14 +94,11 @@ namespace Babylon } Napi::HandleScope scope{env}; - if (shutdown) - { - shutdown(); - } + ShutdownEnvironment(env); if (m_impl->m_delayedTaskSchedulerRegistered) { - DelayedTaskSchedulerRegistration::Unregister(env); + Internal::DelayedTaskScheduler::Unregister(env); m_impl->m_delayedTaskSchedulerRegistered = false; } GetDelayedTaskScheduler().Shutdown(); @@ -111,7 +107,7 @@ namespace Babylon m_impl->m_dispatcher.clear(); } - DelayedTaskScheduler& AppRuntime::GetDelayedTaskScheduler() + Internal::DelayedTaskScheduler& AppRuntime::GetDelayedTaskScheduler() { return *m_impl->m_delayedTaskScheduler; } diff --git a/Core/AppRuntime/Source/AppRuntime_Chakra.cpp b/Core/AppRuntime/Source/AppRuntime_Chakra.cpp index 315954c2..8fe33493 100644 --- a/Core/AppRuntime/Source/AppRuntime_Chakra.cpp +++ b/Core/AppRuntime/Source/AppRuntime_Chakra.cpp @@ -72,6 +72,10 @@ namespace Babylon Napi::Detach(env); } + void AppRuntime::ShutdownEnvironment(Napi::Env) + { + } + void AppRuntime::DrainMicrotasks(Napi::Env) { // Chakra drains promise continuations through its diff --git a/Core/AppRuntime/Source/AppRuntime_Hermes.cpp b/Core/AppRuntime/Source/AppRuntime_Hermes.cpp index 12debfcb..f9dc3d5a 100644 --- a/Core/AppRuntime/Source/AppRuntime_Hermes.cpp +++ b/Core/AppRuntime/Source/AppRuntime_Hermes.cpp @@ -16,6 +16,10 @@ namespace Babylon Napi::Detach(env); } + void AppRuntime::ShutdownEnvironment(Napi::Env) + { + } + void AppRuntime::DrainMicrotasks(Napi::Env env) { // Hermes does not auto-drain its job queue. Promise continuations, diff --git a/Core/AppRuntime/Source/AppRuntime_JSI.cpp b/Core/AppRuntime/Source/AppRuntime_JSI.cpp index a7c809c7..a81a3900 100644 --- a/Core/AppRuntime/Source/AppRuntime_JSI.cpp +++ b/Core/AppRuntime/Source/AppRuntime_JSI.cpp @@ -46,6 +46,10 @@ namespace Babylon Napi::Detach(env); } + void AppRuntime::ShutdownEnvironment(Napi::Env) + { + } + void AppRuntime::DrainMicrotasks(Napi::Env) { // JSI/V8 backed JSI auto-drains microtasks per scope. diff --git a/Core/AppRuntime/Source/AppRuntime_JavaScriptCore.cpp b/Core/AppRuntime/Source/AppRuntime_JavaScriptCore.cpp index b1334c22..f2483f41 100644 --- a/Core/AppRuntime/Source/AppRuntime_JavaScriptCore.cpp +++ b/Core/AppRuntime/Source/AppRuntime_JavaScriptCore.cpp @@ -24,6 +24,10 @@ namespace Babylon Napi::Detach(env); } + void AppRuntime::ShutdownEnvironment(Napi::Env) + { + } + void AppRuntime::DrainMicrotasks(Napi::Env) { // JavaScriptCore drains microtasks automatically at script boundaries. diff --git a/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp b/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp index a3bf75da..10505fea 100644 --- a/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp +++ b/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp @@ -51,6 +51,10 @@ namespace Babylon JS_FreeRuntime(runtime); } + void AppRuntime::ShutdownEnvironment(Napi::Env) + { + } + void AppRuntime::DrainMicrotasks(Napi::Env env) { // QuickJS does not auto-drain its job queue. Promise continuations, diff --git a/Core/AppRuntime/Source/AppRuntime_V8.cpp b/Core/AppRuntime/Source/AppRuntime_V8.cpp index 7886f47b..83da9550 100644 --- a/Core/AppRuntime/Source/AppRuntime_V8.cpp +++ b/Core/AppRuntime/Source/AppRuntime_V8.cpp @@ -100,9 +100,7 @@ namespace Babylon } #endif - Run(env, [&platform, isolate] { - platform.UnregisterHost(isolate); - }); + Run(env); #ifdef ENABLE_V8_INSPECTOR if (agent.has_value()) @@ -119,6 +117,11 @@ namespace Babylon isolate->Dispose(); } + void AppRuntime::ShutdownEnvironment(Napi::Env) + { + Module::Instance().Platform().UnregisterHost(v8::Isolate::GetCurrent()); + } + void AppRuntime::DrainMicrotasks(Napi::Env) { // V8 auto-drains microtasks. Foreground tasks run on AppRuntime's dispatcher. diff --git a/Core/AppRuntime/Source/V8ForegroundTaskRunner.h b/Core/AppRuntime/Source/V8ForegroundTaskRunner.h index 99495894..61adb192 100644 --- a/Core/AppRuntime/Source/V8ForegroundTaskRunner.h +++ b/Core/AppRuntime/Source/V8ForegroundTaskRunner.h @@ -1,6 +1,6 @@ #pragma once -#include +#include "DelayedTaskScheduler.h" #include #include diff --git a/Core/AppRuntime/Source/V8Platform.h b/Core/AppRuntime/Source/V8Platform.h index 6dc93aa6..de13e0ad 100644 --- a/Core/AppRuntime/Source/V8Platform.h +++ b/Core/AppRuntime/Source/V8Platform.h @@ -15,10 +15,10 @@ namespace Babylon { class AppRuntime; - class DelayedTaskScheduler; namespace Internal { + class DelayedTaskScheduler; class V8ForegroundTaskRunner; class V8Platform final : public v8::Platform diff --git a/Core/Foundation/CMakeLists.txt b/Core/Foundation/CMakeLists.txt index febbca55..04028562 100644 --- a/Core/Foundation/CMakeLists.txt +++ b/Core/Foundation/CMakeLists.txt @@ -1,12 +1,11 @@ set(SOURCES "Include/Babylon/Api.h" "Include/Babylon/DebugTrace.h" - "Include/Babylon/DelayedTaskScheduler.h" - "Include/Babylon/Internal/TimerId.h" "Include/Babylon/PerfTrace.h" "Include/Babylon/StandardStreamLogger.h" "Source/DebugTrace.cpp" "Source/DelayedTaskScheduler.cpp" + "Source/DelayedTaskScheduler.h" "Source/PerfTrace.cpp" "Source/StandardStreamLogger.cpp" "Source/StandardStreamLoggerPlatform.h") @@ -52,4 +51,8 @@ if(ANDROID) endif() set_property(TARGET Foundation PROPERTY FOLDER Core) -source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) \ No newline at end of file +source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) + +add_library(FoundationInternal INTERFACE) +target_include_directories(FoundationInternal INTERFACE "Source") +target_link_libraries(FoundationInternal INTERFACE Foundation) \ No newline at end of file diff --git a/Core/Foundation/Include/Babylon/Internal/TimerId.h b/Core/Foundation/Include/Babylon/Internal/TimerId.h deleted file mode 100644 index cea9f185..00000000 --- a/Core/Foundation/Include/Babylon/Internal/TimerId.h +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once - -#include -#include - -namespace Babylon::Internal -{ - constexpr int32_t IncrementTimerId(int32_t id) - { - return id == std::numeric_limits::max() ? 1 : id + 1; - } -} diff --git a/Core/Foundation/Source/DelayedTaskScheduler.cpp b/Core/Foundation/Source/DelayedTaskScheduler.cpp index d06fbc79..7c008ca8 100644 --- a/Core/Foundation/Source/DelayedTaskScheduler.cpp +++ b/Core/Foundation/Source/DelayedTaskScheduler.cpp @@ -1,7 +1,7 @@ #include "DelayedTaskScheduler.h" -#include "Internal/TimerId.h" #include +#include #include #include #include @@ -9,10 +9,12 @@ #include #include -namespace Babylon +namespace Babylon::Internal { namespace { + constexpr auto JS_DELAYED_TASK_SCHEDULER_NAME = "_BabylonDelayedTaskScheduler"; + DelayedTaskScheduler::TimePoint Now() { return std::chrono::time_point_cast(std::chrono::steady_clock::now()); @@ -22,8 +24,9 @@ namespace Babylon class DelayedTaskScheduler::Impl { public: - Impl() - : m_thread{&Impl::ThreadFunction, this} + explicit Impl(Id lastId) + : m_lastId{lastId} + , m_thread{&Impl::ThreadFunction, this} { } @@ -124,7 +127,7 @@ namespace Babylon { while (true) { - m_lastId = Internal::IncrementTimerId(m_lastId); + m_lastId = m_lastId == std::numeric_limits::max() ? 1 : m_lastId + 1; if (m_idMap.find(m_lastId) == m_idMap.end()) { @@ -181,12 +184,33 @@ namespace Babylon }; DelayedTaskScheduler::DelayedTaskScheduler() - : m_impl{std::make_unique()} + : DelayedTaskScheduler{0} + { + } + + DelayedTaskScheduler::DelayedTaskScheduler(Id lastId) + : m_impl{std::make_unique(lastId)} { } DelayedTaskScheduler::~DelayedTaskScheduler() = default; + void DelayedTaskScheduler::Register(Napi::Env env) + { + env.Global().Set(JS_DELAYED_TASK_SCHEDULER_NAME, Napi::External::New(env, this)); + } + + void DelayedTaskScheduler::Unregister(Napi::Env env) + { + env.Global().Set(JS_DELAYED_TASK_SCHEDULER_NAME, env.Undefined()); + } + + DelayedTaskScheduler* DelayedTaskScheduler::Get(Napi::Env env) + { + const auto value = env.Global().Get(JS_DELAYED_TASK_SCHEDULER_NAME); + return value.IsUndefined() ? nullptr : value.As>().Data(); + } + DelayedTaskScheduler::Id DelayedTaskScheduler::Schedule(TimePoint when, Callback callback) { return m_impl->Schedule(when, std::move(callback)); diff --git a/Core/Foundation/Include/Babylon/DelayedTaskScheduler.h b/Core/Foundation/Source/DelayedTaskScheduler.h similarity index 73% rename from Core/Foundation/Include/Babylon/DelayedTaskScheduler.h rename to Core/Foundation/Source/DelayedTaskScheduler.h index 07e6bf30..17ca1b79 100644 --- a/Core/Foundation/Include/Babylon/DelayedTaskScheduler.h +++ b/Core/Foundation/Source/DelayedTaskScheduler.h @@ -1,11 +1,13 @@ #pragma once +#include + #include #include #include #include -namespace Babylon +namespace Babylon::Internal { /// Native delayed-work queue used by setTimeout/setInterval and by host /// task runners. Callbacks run on the scheduler thread; callers that need @@ -23,6 +25,12 @@ namespace Babylon DelayedTaskScheduler(const DelayedTaskScheduler&) = delete; DelayedTaskScheduler& operator=(const DelayedTaskScheduler&) = delete; + // Environment association is non-owning and accessed on the JavaScript + // thread. The registered scheduler must outlive its borrowers. + void Register(Napi::Env env); + static void Unregister(Napi::Env env); + static DelayedTaskScheduler* Get(Napi::Env env); + Id Schedule(TimePoint when, Callback callback); Id Schedule(std::chrono::milliseconds delay, Callback callback); @@ -34,6 +42,9 @@ namespace Babylon void Shutdown(); private: + friend struct DelayedTaskSchedulerTestAccess; + explicit DelayedTaskScheduler(Id lastId); + class Impl; std::unique_ptr m_impl; }; diff --git a/Core/JsRuntime/CMakeLists.txt b/Core/JsRuntime/CMakeLists.txt index 7a140e6d..a5f12428 100644 --- a/Core/JsRuntime/CMakeLists.txt +++ b/Core/JsRuntime/CMakeLists.txt @@ -1,8 +1,6 @@ set(SOURCES - "Include/Babylon/DelayedTaskSchedulerRegistration.h" "Include/Babylon/JsRuntime.h" "Include/Babylon/JsRuntimeScheduler.h" - "Source/DelayedTaskSchedulerRegistration.cpp" "Source/JsRuntime.cpp") add_library(JsRuntime ${SOURCES}) diff --git a/Core/JsRuntime/Include/Babylon/DelayedTaskSchedulerRegistration.h b/Core/JsRuntime/Include/Babylon/DelayedTaskSchedulerRegistration.h deleted file mode 100644 index 33e833e0..00000000 --- a/Core/JsRuntime/Include/Babylon/DelayedTaskSchedulerRegistration.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include -#include - -namespace Babylon -{ - class DelayedTaskScheduler; - - class DelayedTaskSchedulerRegistration final - { - public: - // Called on the JavaScript thread after JsRuntime initialization. The - // host retains ownership and must outlive all borrowers in the environment. - static void BABYLON_API Register(Napi::Env env, DelayedTaskScheduler& scheduler); - static void BABYLON_API Unregister(Napi::Env env); - static DelayedTaskScheduler* BABYLON_API Get(Napi::Env env); - }; -} diff --git a/Core/JsRuntime/Source/DelayedTaskSchedulerRegistration.cpp b/Core/JsRuntime/Source/DelayedTaskSchedulerRegistration.cpp deleted file mode 100644 index ab2c0db5..00000000 --- a/Core/JsRuntime/Source/DelayedTaskSchedulerRegistration.cpp +++ /dev/null @@ -1,31 +0,0 @@ -#include "DelayedTaskSchedulerRegistration.h" - -#include "JsRuntime.h" - -#include - -namespace Babylon -{ - namespace - { - constexpr auto JS_DELAYED_TASK_SCHEDULER_NAME = "delayedTaskScheduler"; - } - - void BABYLON_API DelayedTaskSchedulerRegistration::Register(Napi::Env env, DelayedTaskScheduler& scheduler) - { - JsRuntime::NativeObject::GetFromJavaScript(env).Set( - JS_DELAYED_TASK_SCHEDULER_NAME, - Napi::External::New(env, &scheduler)); - } - - void BABYLON_API DelayedTaskSchedulerRegistration::Unregister(Napi::Env env) - { - JsRuntime::NativeObject::GetFromJavaScript(env).Set(JS_DELAYED_TASK_SCHEDULER_NAME, env.Undefined()); - } - - DelayedTaskScheduler* BABYLON_API DelayedTaskSchedulerRegistration::Get(Napi::Env env) - { - const auto value = JsRuntime::NativeObject::GetFromJavaScript(env).Get(JS_DELAYED_TASK_SCHEDULER_NAME); - return value.IsUndefined() ? nullptr : value.As>().Data(); - } -} diff --git a/Polyfills/Scheduling/CMakeLists.txt b/Polyfills/Scheduling/CMakeLists.txt index 6b75ffe0..edc872c7 100644 --- a/Polyfills/Scheduling/CMakeLists.txt +++ b/Polyfills/Scheduling/CMakeLists.txt @@ -13,7 +13,12 @@ target_include_directories(Scheduling target_link_libraries(Scheduling PUBLIC napi + PRIVATE FoundationInternal PRIVATE JsRuntime) set_property(TARGET Scheduling PROPERTY FOLDER Polyfills) source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) + +add_library(SchedulingInternal INTERFACE) +target_include_directories(SchedulingInternal INTERFACE "Source") +target_link_libraries(SchedulingInternal INTERFACE Scheduling) diff --git a/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp b/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp index 64d7c9d4..c94abe33 100644 --- a/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp +++ b/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp @@ -1,8 +1,8 @@ #include "TimeoutDispatcher.h" -#include -#include +#include "DelayedTaskScheduler.h" +#include #include #include #include @@ -12,6 +12,8 @@ namespace Babylon::Polyfills::Internal { + using Babylon::Internal::DelayedTaskScheduler; + namespace { DelayedTaskScheduler::TimePoint Now() @@ -41,10 +43,11 @@ namespace Babylon::Polyfills::Internal struct TimeoutDispatcher::State { - State(Napi::Env env, Babylon::JsRuntime& runtime) + State(Napi::Env env, Babylon::JsRuntime& runtime, TimeoutId lastTimeoutId) : runtime{&runtime} + , lastTimeoutId{lastTimeoutId} { - scheduler = DelayedTaskSchedulerRegistration::Get(env); + scheduler = DelayedTaskScheduler::Get(env); if (scheduler == nullptr) { ownedScheduler = std::make_unique(); @@ -56,7 +59,7 @@ namespace Babylon::Polyfills::Internal { while (true) { - lastTimeoutId = Babylon::Internal::IncrementTimerId(lastTimeoutId); + lastTimeoutId = lastTimeoutId == std::numeric_limits::max() ? 1 : lastTimeoutId + 1; if (timeouts.find(lastTimeoutId) == timeouts.end()) { @@ -160,7 +163,12 @@ namespace Babylon::Polyfills::Internal } TimeoutDispatcher::TimeoutDispatcher(Napi::Env env, Babylon::JsRuntime& runtime) - : m_state{std::make_shared(env, runtime)} + : TimeoutDispatcher{env, runtime, 0} + { + } + + TimeoutDispatcher::TimeoutDispatcher(Napi::Env env, Babylon::JsRuntime& runtime, TimeoutId lastTimeoutId) + : m_state{std::make_shared(env, runtime, lastTimeoutId)} { } diff --git a/Polyfills/Scheduling/Source/TimeoutDispatcher.h b/Polyfills/Scheduling/Source/TimeoutDispatcher.h index cd39b6f5..cc25c8f4 100644 --- a/Polyfills/Scheduling/Source/TimeoutDispatcher.h +++ b/Polyfills/Scheduling/Source/TimeoutDispatcher.h @@ -1,6 +1,5 @@ #pragma once -#include #include #include @@ -23,6 +22,9 @@ namespace Babylon::Polyfills::Internal void Clear(TimeoutId id); private: + friend struct TimeoutDispatcherTestAccess; + TimeoutDispatcher(Napi::Env env, Babylon::JsRuntime& runtime, TimeoutId lastTimeoutId); + struct State; std::shared_ptr m_state; }; diff --git a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt index c53d6b57..2e31e0fb 100644 --- a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt +++ b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt @@ -20,6 +20,7 @@ add_library(UnitTestsJNI SHARED JNI.cpp ${UNIT_TESTS_DIR}/Shared/DelayedTaskScheduler.cpp ${UNIT_TESTS_DIR}/Shared/StandardStreamLogger.cpp + ${UNIT_TESTS_DIR}/Shared/TimeoutDispatcher.cpp ${UNIT_TESTS_DIR}/Shared/Shared.h ${UNIT_TESTS_DIR}/Shared/Shared.cpp) @@ -41,7 +42,8 @@ target_link_libraries(UnitTestsJNI PRIVATE AppRuntime PRIVATE AbortController PRIVATE Console - PRIVATE Scheduling + PRIVATE SchedulingInternal + PRIVATE FoundationInternal PRIVATE ScriptLoader PRIVATE URL PRIVATE UrlLib diff --git a/Tests/UnitTests/CMakeLists.txt b/Tests/UnitTests/CMakeLists.txt index 624e9a7e..f3676d7d 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -10,6 +10,7 @@ file(GLOB ASSETS "${CMAKE_CURRENT_SOURCE_DIR}/Assets/*") set(SOURCES "Shared/DelayedTaskScheduler.cpp" "Shared/StandardStreamLogger.cpp" + "Shared/TimeoutDispatcher.cpp" "Shared/Shared.cpp" "Shared/Shared.h") @@ -63,7 +64,7 @@ target_link_libraries(UnitTests PRIVATE AppRuntime PRIVATE Console PRIVATE AbortController - PRIVATE Scheduling + PRIVATE SchedulingInternal PRIVATE ScriptLoader PRIVATE URL PRIVATE UrlLib @@ -71,7 +72,7 @@ target_link_libraries(UnitTests PRIVATE Fetch PRIVATE WebSocket PRIVATE gtest_main - PRIVATE Foundation + PRIVATE FoundationInternal PRIVATE Blob PRIVATE File PRIVATE Performance diff --git a/Tests/UnitTests/Shared/DelayedTaskScheduler.cpp b/Tests/UnitTests/Shared/DelayedTaskScheduler.cpp index 45d78009..81dcde2b 100644 --- a/Tests/UnitTests/Shared/DelayedTaskScheduler.cpp +++ b/Tests/UnitTests/Shared/DelayedTaskScheduler.cpp @@ -1,7 +1,6 @@ +#include "DelayedTaskScheduler.h" + #include -#include -#include -#include #include #include @@ -16,27 +15,40 @@ using namespace std::chrono_literals; -TEST(TimerId, StartsAtOne) +namespace Babylon::Internal +{ + struct DelayedTaskSchedulerTestAccess + { + static DelayedTaskScheduler Create(DelayedTaskScheduler::Id lastId) + { + return DelayedTaskScheduler{lastId}; + } + }; +} + +using Scheduler = Babylon::Internal::DelayedTaskScheduler; + +TEST(DelayedTaskScheduler, IdsStartAtOne) { - EXPECT_EQ(Babylon::Internal::IncrementTimerId(0), 1); + Scheduler scheduler; + EXPECT_EQ(scheduler.Schedule(1h, [] {}), 1); + EXPECT_EQ(scheduler.Schedule(1h, [] {}), 2); } -TEST(TimerId, WrapsBeforeSignedOverflow) +TEST(DelayedTaskScheduler, IdsWrapBeforeSignedOverflow) { - static_assert(Babylon::Internal::IncrementTimerId(std::numeric_limits::max()) == 1); - auto id = std::numeric_limits::max() - 1; - id = Babylon::Internal::IncrementTimerId(id); - EXPECT_EQ(id, std::numeric_limits::max()); - id = Babylon::Internal::IncrementTimerId(id); - EXPECT_EQ(id, 1); - id = Babylon::Internal::IncrementTimerId(id); - EXPECT_EQ(id, 2); + constexpr auto MaxId = std::numeric_limits::max(); + auto scheduler = Babylon::Internal::DelayedTaskSchedulerTestAccess::Create(MaxId - 2); + EXPECT_EQ(scheduler.Schedule(1h, [] {}), MaxId - 1); + EXPECT_EQ(scheduler.Schedule(1h, [] {}), MaxId); + EXPECT_EQ(scheduler.Schedule(1h, [] {}), 1); + EXPECT_EQ(scheduler.Schedule(1h, [] {}), 2); } TEST(DelayedTaskScheduler, RunsAtOrAfterRequestedTime) { - Babylon::DelayedTaskScheduler scheduler; - std::promise ranPromise; + Scheduler scheduler; + std::promise ranPromise; const auto requestedTime = std::chrono::time_point_cast( std::chrono::steady_clock::now() + 10ms); @@ -52,7 +64,7 @@ TEST(DelayedTaskScheduler, RunsAtOrAfterRequestedTime) TEST(DelayedTaskScheduler, CancelDoesNotWaitForExtractedCallback) { - Babylon::DelayedTaskScheduler scheduler; + Scheduler scheduler; std::promise enteredPromise; std::promise releasePromise; auto release = releasePromise.get_future().share(); @@ -71,7 +83,7 @@ TEST(DelayedTaskScheduler, CancelDoesNotWaitForExtractedCallback) TEST(DelayedTaskScheduler, CancelRemovesQueuedCallback) { - Babylon::DelayedTaskScheduler scheduler; + Scheduler scheduler; std::promise calledPromise; auto calledFuture = calledPromise.get_future(); @@ -85,7 +97,7 @@ TEST(DelayedTaskScheduler, CancelRemovesQueuedCallback) TEST(DelayedTaskScheduler, ShutdownWaitsForRunningCallbackAndRejectsNewWork) { - Babylon::DelayedTaskScheduler scheduler; + Scheduler scheduler; std::promise enteredPromise; std::promise releasePromise; auto release = releasePromise.get_future().share(); @@ -105,25 +117,31 @@ TEST(DelayedTaskScheduler, ShutdownWaitsForRunningCallbackAndRejectsNewWork) EXPECT_THROW(scheduler.Schedule(0ms, [] {}), std::runtime_error); } -TEST(DelayedTaskSchedulerRegistration, RegisterAndUnregisterFollowEnvironmentLifetime) +TEST(DelayedTaskScheduler, RegistrationDoesNotDependOnJsRuntimeNativeObject) { Babylon::AppRuntime runtime; std::promise lifecyclePromise; runtime.Dispatch([&lifecyclePromise](Napi::Env env) { - auto* const scheduler = Babylon::DelayedTaskSchedulerRegistration::Get(env); + auto* const scheduler = Scheduler::Get(env); if (scheduler == nullptr) { lifecyclePromise.set_value(false); return; } - Babylon::DelayedTaskSchedulerRegistration::Unregister(env); - const bool unregistered = Babylon::DelayedTaskSchedulerRegistration::Get(env) == nullptr; - Babylon::DelayedTaskSchedulerRegistration::Register(env, *scheduler); + const auto nativeObject = env.Global().Get("_native"); + env.Global().Set("_native", env.Undefined()); + const bool independentOfJsRuntime = Scheduler::Get(env) == scheduler; + Scheduler::Unregister(env); + const bool unregistered = Scheduler::Get(env) == nullptr; + scheduler->Register(env); + const bool registered = Scheduler::Get(env) == scheduler; + env.Global().Set("_native", nativeObject); lifecyclePromise.set_value( + independentOfJsRuntime && unregistered && - Babylon::DelayedTaskSchedulerRegistration::Get(env) == scheduler); + registered); }); auto lifecycleFuture = lifecyclePromise.get_future(); @@ -137,7 +155,7 @@ TEST(SchedulingLifecycle, UsesOwnedSchedulerWithoutRegistration) std::promise timerPromise; runtime.Dispatch([&timerPromise](Napi::Env env) { - Babylon::DelayedTaskSchedulerRegistration::Unregister(env); + Scheduler::Unregister(env); Babylon::Polyfills::Scheduling::Initialize(env); env.Global().Set( "timerComplete", diff --git a/Tests/UnitTests/Shared/TimeoutDispatcher.cpp b/Tests/UnitTests/Shared/TimeoutDispatcher.cpp new file mode 100644 index 00000000..b4ddb014 --- /dev/null +++ b/Tests/UnitTests/Shared/TimeoutDispatcher.cpp @@ -0,0 +1,56 @@ +#include "TimeoutDispatcher.h" + +#include +#include + +#include +#include +#include +#include + +using namespace std::chrono_literals; + +namespace Babylon::Polyfills::Internal +{ + struct TimeoutDispatcherTestAccess + { + static TimeoutDispatcher Create(Napi::Env env, Babylon::JsRuntime& runtime, int32_t lastTimeoutId) + { + return TimeoutDispatcher{env, runtime, lastTimeoutId}; + } + }; +} + +TEST(TimeoutDispatcher, IdsStartAtOne) +{ + Babylon::AppRuntime runtime; + std::promise> idsPromise; + runtime.Dispatch([&idsPromise](Napi::Env env) { + Babylon::Polyfills::Internal::TimeoutDispatcher dispatcher{env, Babylon::JsRuntime::GetFromJavaScript(env)}; + idsPromise.set_value({dispatcher.Dispatch(nullptr, 1h), dispatcher.Dispatch(nullptr, 1h)}); + }); + + auto idsFuture = idsPromise.get_future(); + ASSERT_EQ(idsFuture.wait_for(5s), std::future_status::ready); + EXPECT_EQ(idsFuture.get(), (std::array{1, 2})); +} + +TEST(TimeoutDispatcher, IdsWrapBeforeSignedOverflow) +{ + constexpr auto MaxId = std::numeric_limits::max(); + Babylon::AppRuntime runtime; + std::promise> idsPromise; + runtime.Dispatch([&idsPromise](Napi::Env env) { + auto dispatcher = Babylon::Polyfills::Internal::TimeoutDispatcherTestAccess::Create( + env, Babylon::JsRuntime::GetFromJavaScript(env), MaxId - 2); + idsPromise.set_value({ + dispatcher.Dispatch(nullptr, 1h), + dispatcher.Dispatch(nullptr, 1h), + dispatcher.Dispatch(nullptr, 1h), + dispatcher.Dispatch(nullptr, 1h)}); + }); + + auto idsFuture = idsPromise.get_future(); + ASSERT_EQ(idsFuture.wait_for(5s), std::future_status::ready); + EXPECT_EQ(idsFuture.get(), (std::array{MaxId - 1, MaxId, 1, 2})); +} diff --git a/Tests/UnitTests/Shared/V8ForegroundTaskRunner.cpp b/Tests/UnitTests/Shared/V8ForegroundTaskRunner.cpp index 6575a647..0aba9b1a 100644 --- a/Tests/UnitTests/Shared/V8ForegroundTaskRunner.cpp +++ b/Tests/UnitTests/Shared/V8ForegroundTaskRunner.cpp @@ -9,7 +9,7 @@ namespace { using Runner = Babylon::Internal::V8ForegroundTaskRunner; - using Scheduler = Babylon::DelayedTaskScheduler; + using Scheduler = Babylon::Internal::DelayedTaskScheduler; using namespace std::chrono_literals; class Task final : public v8::Task From 5b356fb29c66652a5a41d1d0b6428efd857bbb4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87=20=28via=20Copilot=29?= <223556219+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:01:25 -0700 Subject: [PATCH 9/9] Clarify non-owning scheduler association API Make SetForJavaScript, ClearFromJavaScript, and GetFromJavaScript static. Update AppRuntime, Scheduling, and the environment-association regression to use the consistent names without changing scheduler ownership. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 54aaa1e1-b4b3-46e7-9de4-5a56add4ac42 --- Core/AppRuntime/Source/AppRuntime.cpp | 4 ++-- .../Source/DelayedTaskScheduler.cpp | 8 ++++---- Core/Foundation/Source/DelayedTaskScheduler.h | 8 ++++---- .../Scheduling/Source/TimeoutDispatcher.cpp | 2 +- .../UnitTests/Shared/DelayedTaskScheduler.cpp | 20 +++++++++---------- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Core/AppRuntime/Source/AppRuntime.cpp b/Core/AppRuntime/Source/AppRuntime.cpp index 808cc525..78ebe2d9 100644 --- a/Core/AppRuntime/Source/AppRuntime.cpp +++ b/Core/AppRuntime/Source/AppRuntime.cpp @@ -55,7 +55,7 @@ namespace Babylon Dispatch([this](Napi::Env env) { JsRuntime::CreateForJavaScript(env, [this](auto func) { Dispatch(std::move(func)); }); - GetDelayedTaskScheduler().Register(env); + Internal::DelayedTaskScheduler::SetForJavaScript(env, GetDelayedTaskScheduler()); m_impl->m_delayedTaskSchedulerRegistered = true; }); } @@ -98,7 +98,7 @@ namespace Babylon if (m_impl->m_delayedTaskSchedulerRegistered) { - Internal::DelayedTaskScheduler::Unregister(env); + Internal::DelayedTaskScheduler::ClearFromJavaScript(env); m_impl->m_delayedTaskSchedulerRegistered = false; } GetDelayedTaskScheduler().Shutdown(); diff --git a/Core/Foundation/Source/DelayedTaskScheduler.cpp b/Core/Foundation/Source/DelayedTaskScheduler.cpp index 7c008ca8..5aae3cde 100644 --- a/Core/Foundation/Source/DelayedTaskScheduler.cpp +++ b/Core/Foundation/Source/DelayedTaskScheduler.cpp @@ -195,17 +195,17 @@ namespace Babylon::Internal DelayedTaskScheduler::~DelayedTaskScheduler() = default; - void DelayedTaskScheduler::Register(Napi::Env env) + void DelayedTaskScheduler::SetForJavaScript(Napi::Env env, DelayedTaskScheduler& scheduler) { - env.Global().Set(JS_DELAYED_TASK_SCHEDULER_NAME, Napi::External::New(env, this)); + env.Global().Set(JS_DELAYED_TASK_SCHEDULER_NAME, Napi::External::New(env, &scheduler)); } - void DelayedTaskScheduler::Unregister(Napi::Env env) + void DelayedTaskScheduler::ClearFromJavaScript(Napi::Env env) { env.Global().Set(JS_DELAYED_TASK_SCHEDULER_NAME, env.Undefined()); } - DelayedTaskScheduler* DelayedTaskScheduler::Get(Napi::Env env) + DelayedTaskScheduler* DelayedTaskScheduler::GetFromJavaScript(Napi::Env env) { const auto value = env.Global().Get(JS_DELAYED_TASK_SCHEDULER_NAME); return value.IsUndefined() ? nullptr : value.As>().Data(); diff --git a/Core/Foundation/Source/DelayedTaskScheduler.h b/Core/Foundation/Source/DelayedTaskScheduler.h index 17ca1b79..1d3ac3cc 100644 --- a/Core/Foundation/Source/DelayedTaskScheduler.h +++ b/Core/Foundation/Source/DelayedTaskScheduler.h @@ -26,10 +26,10 @@ namespace Babylon::Internal DelayedTaskScheduler& operator=(const DelayedTaskScheduler&) = delete; // Environment association is non-owning and accessed on the JavaScript - // thread. The registered scheduler must outlive its borrowers. - void Register(Napi::Env env); - static void Unregister(Napi::Env env); - static DelayedTaskScheduler* Get(Napi::Env env); + // thread. The associated scheduler must outlive its borrowers. + static void SetForJavaScript(Napi::Env env, DelayedTaskScheduler& scheduler); + static void ClearFromJavaScript(Napi::Env env); + static DelayedTaskScheduler* GetFromJavaScript(Napi::Env env); Id Schedule(TimePoint when, Callback callback); Id Schedule(std::chrono::milliseconds delay, Callback callback); diff --git a/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp b/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp index c94abe33..e53b91d4 100644 --- a/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp +++ b/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp @@ -47,7 +47,7 @@ namespace Babylon::Polyfills::Internal : runtime{&runtime} , lastTimeoutId{lastTimeoutId} { - scheduler = DelayedTaskScheduler::Get(env); + scheduler = DelayedTaskScheduler::GetFromJavaScript(env); if (scheduler == nullptr) { ownedScheduler = std::make_unique(); diff --git a/Tests/UnitTests/Shared/DelayedTaskScheduler.cpp b/Tests/UnitTests/Shared/DelayedTaskScheduler.cpp index 81dcde2b..01864ff8 100644 --- a/Tests/UnitTests/Shared/DelayedTaskScheduler.cpp +++ b/Tests/UnitTests/Shared/DelayedTaskScheduler.cpp @@ -117,13 +117,13 @@ TEST(DelayedTaskScheduler, ShutdownWaitsForRunningCallbackAndRejectsNewWork) EXPECT_THROW(scheduler.Schedule(0ms, [] {}), std::runtime_error); } -TEST(DelayedTaskScheduler, RegistrationDoesNotDependOnJsRuntimeNativeObject) +TEST(DelayedTaskScheduler, AssociationDoesNotDependOnJsRuntimeNativeObject) { Babylon::AppRuntime runtime; std::promise lifecyclePromise; runtime.Dispatch([&lifecyclePromise](Napi::Env env) { - auto* const scheduler = Scheduler::Get(env); + auto* const scheduler = Scheduler::GetFromJavaScript(env); if (scheduler == nullptr) { lifecyclePromise.set_value(false); @@ -132,16 +132,16 @@ TEST(DelayedTaskScheduler, RegistrationDoesNotDependOnJsRuntimeNativeObject) const auto nativeObject = env.Global().Get("_native"); env.Global().Set("_native", env.Undefined()); - const bool independentOfJsRuntime = Scheduler::Get(env) == scheduler; - Scheduler::Unregister(env); - const bool unregistered = Scheduler::Get(env) == nullptr; - scheduler->Register(env); - const bool registered = Scheduler::Get(env) == scheduler; + const bool independentOfJsRuntime = Scheduler::GetFromJavaScript(env) == scheduler; + Scheduler::ClearFromJavaScript(env); + const bool cleared = Scheduler::GetFromJavaScript(env) == nullptr; + Scheduler::SetForJavaScript(env, *scheduler); + const bool associated = Scheduler::GetFromJavaScript(env) == scheduler; env.Global().Set("_native", nativeObject); lifecyclePromise.set_value( independentOfJsRuntime && - unregistered && - registered); + cleared && + associated); }); auto lifecycleFuture = lifecyclePromise.get_future(); @@ -155,7 +155,7 @@ TEST(SchedulingLifecycle, UsesOwnedSchedulerWithoutRegistration) std::promise timerPromise; runtime.Dispatch([&timerPromise](Napi::Env env) { - Scheduler::Unregister(env); + Scheduler::ClearFromJavaScript(env); Babylon::Polyfills::Scheduling::Initialize(env); env.Global().Set( "timerComplete",