diff --git a/Core/AppRuntime/CMakeLists.txt b/Core/AppRuntime/CMakeLists.txt index f7bc649d..9e8d10c9 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) @@ -19,6 +27,7 @@ target_include_directories(AppRuntime INTERFACE "Include") target_link_libraries(AppRuntime + PRIVATE FoundationInternal PRIVATE arcana PUBLIC JsRuntime) @@ -58,3 +67,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 INTERFACE FoundationInternal) diff --git a/Core/AppRuntime/Include/Babylon/AppRuntime.h b/Core/AppRuntime/Include/Babylon/AppRuntime.h index e001fa32..d6d024e5 100644 --- a/Core/AppRuntime/Include/Babylon/AppRuntime.h +++ b/Core/AppRuntime/Include/Babylon/AppRuntime.h @@ -12,6 +12,11 @@ namespace Babylon { + namespace Internal + { + class DelayedTaskScheduler; + } + class AppRuntime final { public: @@ -63,6 +68,10 @@ namespace Babylon void RunEnvironmentTier(const char* executablePath = "."); 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. void Execute(Dispatchable callback); @@ -76,6 +85,8 @@ namespace Babylon // queue explicitly (Napi::DrainJobs / JS_ExecutePendingJob). void DrainMicrotasks(Napi::Env env); + Internal::DelayedTaskScheduler& GetDelayedTaskScheduler(); + Options m_options; class Impl; diff --git a/Core/AppRuntime/Source/AppRuntime.cpp b/Core/AppRuntime/Source/AppRuntime.cpp index 176bc849..78ebe2d9 100644 --- a/Core/AppRuntime/Source/AppRuntime.cpp +++ b/Core/AppRuntime/Source/AppRuntime.cpp @@ -1,5 +1,7 @@ #include "AppRuntime.h" +#include "DelayedTaskScheduler.h" + #include #include @@ -35,6 +37,8 @@ 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()}; + bool m_delayedTaskSchedulerRegistered{}; std::thread m_thread; }; @@ -51,6 +55,8 @@ namespace Babylon Dispatch([this](Napi::Env env) { JsRuntime::CreateForJavaScript(env, [this](auto func) { Dispatch(std::move(func)); }); + Internal::DelayedTaskScheduler::SetForJavaScript(env, GetDelayedTaskScheduler()); + m_impl->m_delayedTaskSchedulerRegistered = true; }); } @@ -87,10 +93,25 @@ namespace Babylon m_impl->m_dispatcher.blocking_tick(m_impl->m_cancelSource); } + Napi::HandleScope scope{env}; + ShutdownEnvironment(env); + + if (m_impl->m_delayedTaskSchedulerRegistered) + { + Internal::DelayedTaskScheduler::ClearFromJavaScript(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(); } + Internal::DelayedTaskScheduler& AppRuntime::GetDelayedTaskScheduler() + { + return *m_impl->m_delayedTaskScheduler; + } + void AppRuntime::Suspend() { auto suspensionMutex = std::make_shared(); 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 89928dcf..83da9550 100644 --- a/Core/AppRuntime/Source/AppRuntime_V8.cpp +++ b/Core/AppRuntime/Source/AppRuntime_V8.cpp @@ -1,4 +1,5 @@ #include "AppRuntime.h" +#include "V8Platform.h" #include #include @@ -7,7 +8,9 @@ #include #endif +#include #include +#include namespace Babylon { @@ -20,7 +23,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 +52,13 @@ namespace Babylon return *s_module; } - v8::Platform& Platform() + Internal::V8Platform& Platform() { return *m_platform; } private: - std::unique_ptr m_platform; + std::unique_ptr m_platform; static std::unique_ptr s_module; }; @@ -65,14 +68,16 @@ namespace Babylon void AppRuntime::RunEnvironmentTier(const char* executablePath) { - // Create the isolate. Module::Initialize(executablePath); + 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); - // Use the isolate within a scope. { v8::Isolate::Scope isolate_scope{isolate}; v8::HandleScope isolate_handle_scope{isolate}; @@ -85,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) @@ -107,15 +112,18 @@ namespace Babylon Napi::Detach(env); } - // Destroy the isolate. // todo : GetArrayBufferAllocator not available? // delete isolate->GetArrayBufferAllocator(); isolate->Dispose(); } + void AppRuntime::ShutdownEnvironment(Napi::Env) + { + Module::Instance().Platform().UnregisterHost(v8::Isolate::GetCurrent()); + } + 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. 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..61adb192 --- /dev/null +++ b/Core/AppRuntime/Source/V8ForegroundTaskRunner.h @@ -0,0 +1,39 @@ +#pragma once + +#include "DelayedTaskScheduler.h" +#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..de13e0ad --- /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; + + namespace Internal + { + class DelayedTaskScheduler; + 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..04028562 100644 --- a/Core/Foundation/CMakeLists.txt +++ b/Core/Foundation/CMakeLists.txt @@ -4,6 +4,8 @@ set(SOURCES "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") @@ -49,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/Source/DelayedTaskScheduler.cpp b/Core/Foundation/Source/DelayedTaskScheduler.cpp new file mode 100644 index 00000000..5aae3cde --- /dev/null +++ b/Core/Foundation/Source/DelayedTaskScheduler.cpp @@ -0,0 +1,238 @@ +#include "DelayedTaskScheduler.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +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()); + } + } + + class DelayedTaskScheduler::Impl + { + public: + explicit Impl(Id lastId) + : m_lastId{lastId} + , m_thread{&Impl::ThreadFunction, this} + { + } + + ~Impl() + { + Shutdown(); + } + + Id Schedule(TimePoint when, Callback callback) + { + std::unique_lock lock{m_mutex}; + if (m_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 rawItem = item.get(); + const auto [it, inserted] = m_idMap.try_emplace(id, std::move(item)); + if (!inserted) + { + throw std::logic_error{"DelayedTaskScheduler: NextId returned a duplicate id"}; + } + + m_timeMap.emplace(when, rawItem); + if (when <= earliestTime) + { + m_conditionVariable.notify_one(); + } + + return id; + } + + void Cancel(Id id) + { + std::unique_lock lock{m_mutex}; + const auto idIt = m_idMap.find(id); + if (idIt == m_idMap.end()) + { + return; + } + + 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 (timeIt->second == idIt->second.get()) + { + m_timeMap.erase(timeIt); + break; + } + } + + 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 + { + Item(Id id, TimePoint time, Callback callback) + : id{id} + , time{time} + , callback{std::move(callback)} + { + } + + Id id; + TimePoint time; + Callback callback; + }; + + Id NextId() + { + while (true) + { + m_lastId = m_lastId == std::numeric_limits::max() ? 1 : m_lastId + 1; + + if (m_idMap.find(m_lastId) == m_idMap.end()) + { + return m_lastId; + } + } + } + + void ThreadFunction() + { + while (true) + { + Callback callback; + { + std::unique_lock lock{m_mutex}; + while (!m_shutdown && m_timeMap.empty()) + { + m_conditionVariable.wait(lock); + } + + if (m_shutdown) + { + return; + } + + const auto nextTime = m_timeMap.begin()->first; + if (nextTime > Now()) + { + m_conditionVariable.wait_until(lock, nextTime); + continue; + } + + Item* const item = m_timeMap.begin()->second; + callback = std::move(item->callback); + m_idMap.erase(item->id); + m_timeMap.erase(m_timeMap.begin()); + } + + if (callback) + { + callback(); + } + } + } + + 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; + bool m_shutdown{}; + std::thread m_thread; + }; + + DelayedTaskScheduler::DelayedTaskScheduler() + : DelayedTaskScheduler{0} + { + } + + DelayedTaskScheduler::DelayedTaskScheduler(Id lastId) + : m_impl{std::make_unique(lastId)} + { + } + + DelayedTaskScheduler::~DelayedTaskScheduler() = default; + + void DelayedTaskScheduler::SetForJavaScript(Napi::Env env, DelayedTaskScheduler& scheduler) + { + env.Global().Set(JS_DELAYED_TASK_SCHEDULER_NAME, Napi::External::New(env, &scheduler)); + } + + void DelayedTaskScheduler::ClearFromJavaScript(Napi::Env env) + { + env.Global().Set(JS_DELAYED_TASK_SCHEDULER_NAME, env.Undefined()); + } + + DelayedTaskScheduler* DelayedTaskScheduler::GetFromJavaScript(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)); + } + + DelayedTaskScheduler::Id DelayedTaskScheduler::Schedule(std::chrono::milliseconds delay, Callback callback) + { + if (delay.count() < 0) + { + delay = std::chrono::milliseconds{0}; + } + + return Schedule(Now() + delay, std::move(callback)); + } + + void DelayedTaskScheduler::Cancel(Id id) + { + m_impl->Cancel(id); + } + + void DelayedTaskScheduler::Shutdown() + { + m_impl->Shutdown(); + } +} diff --git a/Core/Foundation/Source/DelayedTaskScheduler.h b/Core/Foundation/Source/DelayedTaskScheduler.h new file mode 100644 index 00000000..1d3ac3cc --- /dev/null +++ b/Core/Foundation/Source/DelayedTaskScheduler.h @@ -0,0 +1,51 @@ +#pragma once + +#include + +#include +#include +#include +#include + +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 + /// 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; + + // Environment association is non-owning and accessed on the JavaScript + // 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); + + // 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: + friend struct DelayedTaskSchedulerTestAccess; + explicit DelayedTaskScheduler(Id lastId); + + class Impl; + std::unique_ptr m_impl; + }; +} 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/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 1dea6585..e53b91d4 100644 --- a/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp +++ b/Polyfills/Scheduling/Source/TimeoutDispatcher.cpp @@ -1,15 +1,22 @@ #include "TimeoutDispatcher.h" -#include +#include "DelayedTaskScheduler.h" + +#include +#include #include +#include +#include +#include +#include namespace Babylon::Polyfills::Internal { + using Babylon::Internal::DelayedTaskScheduler; + namespace { - using TimePoint = std::chrono::time_point; - - TimePoint Now() + DelayedTaskScheduler::TimePoint Now() { return std::chrono::time_point_cast(std::chrono::steady_clock::now()); } @@ -17,20 +24,7 @@ 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; - - 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)} @@ -39,169 +33,84 @@ 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_thread{std::thread{&TimeoutDispatcher::ThreadFunction, this}} - { - } - - TimeoutDispatcher::~TimeoutDispatcher() - { - { - std::unique_lock lk{m_mutex}; - m_idMap.clear(); - m_timeMap.clear(); - } - - m_shutdown = true; - m_condVariable.notify_one(); - m_thread.join(); - } - - 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) - { - delay = std::chrono::milliseconds{0}; - } - - 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 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) - { - m_condVariable.notify_one(); - } - - return id; - } - - void TimeoutDispatcher::Clear(TimeoutId id) + struct TimeoutDispatcher::State { - std::unique_lock lk{m_mutex}; - const auto itId = m_idMap.find(id); - if (itId != m_idMap.end()) + State(Napi::Env env, Babylon::JsRuntime& runtime, TimeoutId lastTimeoutId) + : runtime{&runtime} + , lastTimeoutId{lastTimeoutId} { - 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++) + scheduler = DelayedTaskScheduler::GetFromJavaScript(env); + if (scheduler == nullptr) { - if (itTime->second->id == id) - { - m_timeMap.erase(itTime); - break; - } + ownedScheduler = std::make_unique(); + scheduler = ownedScheduler.get(); } - - m_idMap.erase(itId); } - } - TimeoutDispatcher::TimeoutId TimeoutDispatcher::NextTimeoutId() - { - while (true) + TimeoutId NextTimeoutId() { - ++m_lastTimeoutId; - - if (m_lastTimeoutId <= 0) + while (true) { - m_lastTimeoutId = 1; - } + lastTimeoutId = lastTimeoutId == std::numeric_limits::max() ? 1 : lastTimeoutId + 1; - if (m_idMap.find(m_lastTimeoutId) == m_idMap.end()) - { - return m_lastTimeoutId; - } - } - } - - 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()) + if (timeouts.find(lastTimeoutId) == timeouts.end()) { - break; + return lastTimeoutId; } - - 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); - } + 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}; + }; - while (!m_shutdown && m_timeMap.empty()) - { - m_condVariable.wait(lk); - } + void TimeoutDispatcher::State::CallFunction(const std::shared_ptr& self, TimeoutId id, uint64_t sequence) + { + std::scoped_lock lock{mutex}; + const auto timeoutIt = timeouts.find(id); + if (!active || timeoutIt == timeouts.end() || timeoutIt->second->sequence != sequence) + { + 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); } } @@ -214,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(); @@ -230,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) @@ -260,15 +156,95 @@ 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()}); + timeoutIt->second->time = nextTime; + timeoutIt->second->scheduleId = scheduler->Schedule(nextTime, [self, id, sequence]() { + self->CallFunction(self, id, sequence); + }); + } + + TimeoutDispatcher::TimeoutDispatcher(Napi::Env env, Babylon::JsRuntime& runtime) + : TimeoutDispatcher{env, runtime, 0} + { + } - if (nextTime <= earliestTime) + TimeoutDispatcher::TimeoutDispatcher(Napi::Env env, Babylon::JsRuntime& runtime, TimeoutId lastTimeoutId) + : m_state{std::make_shared(env, runtime, lastTimeoutId)} + { + } + + TimeoutDispatcher::~TimeoutDispatcher() + { + std::vector scheduleIds; + DelayedTaskScheduler* ownedScheduler{}; { - // 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(); + 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 0ab4b135..cc25c8f4 100644 --- a/Polyfills/Scheduling/Source/TimeoutDispatcher.h +++ b/Polyfills/Scheduling/Source/TimeoutDispatcher.h @@ -3,13 +3,9 @@ #include #include -#include #include -#include -#include -#include #include -#include +#include namespace Babylon::Polyfills::Internal { @@ -19,30 +15,17 @@ 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 = std::chrono::time_point; + friend struct TimeoutDispatcherTestAccess; + TimeoutDispatcher(Napi::Env env, Babylon::JsRuntime& runtime, TimeoutId lastTimeoutId); - TimeoutId DispatchImpl(std::shared_ptr function, std::chrono::milliseconds delay, bool repeat, TimeoutId id); - - 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; - 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; + 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 5f7c83a0..2e31e0fb 100644 --- a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt +++ b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt @@ -18,11 +18,19 @@ 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/TimeoutDispatcher.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) target_include_directories(UnitTestsJNI @@ -34,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 b8446eb2..f3676d7d 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -8,7 +8,9 @@ set(TYPE_SCRIPTS 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") @@ -45,7 +47,12 @@ 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}") # The V8JSI Node-API shim does not implement napi_create_dataview, so the # CreateDataViewRejectsOverflowingRange test is compiled out on that backend. @@ -57,7 +64,7 @@ target_link_libraries(UnitTests PRIVATE AppRuntime PRIVATE Console PRIVATE AbortController - PRIVATE Scheduling + PRIVATE SchedulingInternal PRIVATE ScriptLoader PRIVATE URL PRIVATE UrlLib @@ -65,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/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index bb484ba7..654c60b6 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; @@ -2234,6 +2235,42 @@ 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 () { + 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 () { + 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 () { + 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 diff --git a/Tests/UnitTests/Shared/DelayedTaskScheduler.cpp b/Tests/UnitTests/Shared/DelayedTaskScheduler.cpp new file mode 100644 index 00000000..01864ff8 --- /dev/null +++ b/Tests/UnitTests/Shared/DelayedTaskScheduler.cpp @@ -0,0 +1,201 @@ +#include "DelayedTaskScheduler.h" + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; + +namespace Babylon::Internal +{ + struct DelayedTaskSchedulerTestAccess + { + static DelayedTaskScheduler Create(DelayedTaskScheduler::Id lastId) + { + return DelayedTaskScheduler{lastId}; + } + }; +} + +using Scheduler = Babylon::Internal::DelayedTaskScheduler; + +TEST(DelayedTaskScheduler, IdsStartAtOne) +{ + Scheduler scheduler; + EXPECT_EQ(scheduler.Schedule(1h, [] {}), 1); + EXPECT_EQ(scheduler.Schedule(1h, [] {}), 2); +} + +TEST(DelayedTaskScheduler, IdsWrapBeforeSignedOverflow) +{ + 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) +{ + Scheduler 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) +{ + Scheduler 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) +{ + Scheduler 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) +{ + Scheduler 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(DelayedTaskScheduler, AssociationDoesNotDependOnJsRuntimeNativeObject) +{ + Babylon::AppRuntime runtime; + std::promise lifecyclePromise; + + runtime.Dispatch([&lifecyclePromise](Napi::Env env) { + auto* const scheduler = Scheduler::GetFromJavaScript(env); + if (scheduler == nullptr) + { + lifecyclePromise.set_value(false); + return; + } + + const auto nativeObject = env.Global().Get("_native"); + env.Global().Set("_native", env.Undefined()); + 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 && + cleared && + associated); + }); + + 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) { + Scheduler::ClearFromJavaScript(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/Shared.cpp b/Tests/UnitTests/Shared/Shared.cpp index 0267ce37..1c7e9ff7 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, NAPI_JAVASCRIPT_ENGINE)); }); Babylon::ScriptLoader loader{runtime}; 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 new file mode 100644 index 00000000..0aba9b1a --- /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::Internal::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); +}