diff --git a/Core/AppRuntime/Source/AppRuntime_V8.cpp b/Core/AppRuntime/Source/AppRuntime_V8.cpp index 89928dcf..cd13332e 100644 --- a/Core/AppRuntime/Source/AppRuntime_V8.cpp +++ b/Core/AppRuntime/Source/AppRuntime_V8.cpp @@ -2,17 +2,203 @@ #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 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(); } +#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* 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; + 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::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); + } + return runner; + } + class Module final { public: @@ -20,7 +206,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 +235,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 +270,27 @@ 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. + // + // 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); }); + }); + // Use the isolate within a scope. { v8::Isolate::Scope isolate_scope{isolate}; @@ -108,6 +327,8 @@ namespace Babylon } // Destroy the isolate. + Module::Instance().Platform().SetWake(isolate, nullptr); + // todo : GetArrayBufferAllocator not available? // delete isolate->GetArrayBufferAllocator(); isolate->Dispose(); @@ -115,7 +336,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/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 1dc2aa4c..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; @@ -2148,6 +2149,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/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};