Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 243 additions & 5 deletions Core/AppRuntime/Source/AppRuntime_V8.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,211 @@
#include <napi/env.h>

#include <libplatform/libplatform.h>
#include <v8-version.h>

// 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 <V8InspectorAgent.h>
#endif

#include <atomic>
#include <functional>
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include <utility>

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<v8::Platform> inner)
: m_inner{std::move(inner)}
{
}

v8::Platform& Inner()
{
return *m_inner;
}

void SetWake(v8::Isolate* isolate, std::function<void()> 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<void()> 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<v8::TaskRunner> GetForegroundTaskRunner(v8::Isolate* isolate) override
{
return WrapForegroundTaskRunner(isolate);
}

#if JSRH_V8_AT_LEAST(11, 9)
std::shared_ptr<v8::TaskRunner> GetForegroundTaskRunner(v8::Isolate* isolate, v8::TaskPriority) override
{
return WrapForegroundTaskRunner(isolate);
}
#endif

void CallOnWorkerThread(std::unique_ptr<v8::Task> task) override { m_inner->CallOnWorkerThread(std::move(task)); }
void CallBlockingTaskOnWorkerThread(std::unique_ptr<v8::Task> task) override { m_inner->CallBlockingTaskOnWorkerThread(std::move(task)); }
void CallLowPriorityTaskOnWorkerThread(std::unique_ptr<v8::Task> task) override { m_inner->CallLowPriorityTaskOnWorkerThread(std::move(task)); }
void CallDelayedOnWorkerThread(std::unique_ptr<v8::Task> 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<v8::JobHandle> PostJob(v8::TaskPriority priority, std::unique_ptr<v8::JobTask> jobTask) override { return m_inner->PostJob(priority, std::move(jobTask)); }
std::unique_ptr<v8::JobHandle> CreateJob(v8::TaskPriority priority, std::unique_ptr<v8::JobTask> jobTask) override { return m_inner->CreateJob(priority, std::move(jobTask)); }
#if JSRH_V8_AT_LEAST(11, 9)
std::unique_ptr<v8::ScopedBlockingCall> 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<v8::TaskRunner> WrapForegroundTaskRunner(v8::Isolate* isolate);

std::unique_ptr<v8::Platform> m_inner;
std::mutex m_mutex;
std::map<v8::Isolate*, std::function<void()>> m_wakes;
std::map<v8::Isolate*, std::shared_ptr<v8::TaskRunner>> m_taskRunners;
};

class WakingTaskRunner final : public v8::TaskRunner
{
public:
WakingTaskRunner(std::shared_ptr<v8::TaskRunner> inner, DispatchingPlatform& platform, v8::Isolate* isolate)
: m_inner{std::move(inner)}
, m_platform{platform}
, m_isolate{isolate}
{
}

void PostTask(std::unique_ptr<v8::Task> task) override
{
m_inner->PostTask(std::move(task));
m_platform.Wake(m_isolate);
}

void PostNonNestableTask(std::unique_ptr<v8::Task> task) override
{
m_inner->PostNonNestableTask(std::move(task));
m_platform.Wake(m_isolate);
}

void PostDelayedTask(std::unique_ptr<v8::Task> task, double delayInSeconds) override
{
m_inner->PostDelayedTask(std::move(task), delayInSeconds);
m_platform.Wake(m_isolate);
}

void PostNonNestableDelayedTask(std::unique_ptr<v8::Task> task, double delayInSeconds) override
{
m_inner->PostNonNestableDelayedTask(std::move(task), delayInSeconds);
m_platform.Wake(m_isolate);
}

void PostIdleTask(std::unique_ptr<v8::IdleTask> 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<v8::TaskRunner> m_inner;
DispatchingPlatform& m_platform;
v8::Isolate* m_isolate;
};

std::shared_ptr<v8::TaskRunner> 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<WakingTaskRunner>(m_inner->GetForegroundTaskRunner(isolate), *this, isolate);
}
return runner;
}

class Module final
{
public:
Module(const char* executablePath)
{
v8::V8::InitializeICUDefaultLocation(executablePath);
v8::V8::InitializeExternalStartupData(executablePath);
m_platform = v8::platform::NewDefaultPlatform();
m_platform = std::make_unique<DispatchingPlatform>(v8::platform::NewDefaultPlatform());
v8::V8::InitializePlatform(m_platform.get());
v8::V8::Initialize();
}
Expand Down Expand Up @@ -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<v8::Platform> m_platform;
std::unique_ptr<DispatchingPlatform> m_platform;

static std::unique_ptr<Module> s_module;
};
Expand All @@ -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<std::atomic_bool>(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};
Expand Down Expand Up @@ -108,14 +327,33 @@ namespace Babylon
}

// Destroy the isolate.
Module::Instance().Platform().SetWake(isolate, nullptr);

// todo : GetArrayBufferAllocator not available?
// delete isolate->GetArrayBufferAllocator();
isolate->Dispose();
}

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))
{
}
}
}
1 change: 1 addition & 0 deletions Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Tests/UnitTests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions Tests/UnitTests/Scripts/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Mocha.setup('bdd');
Mocha.reporter('spec');

declare const hostPlatform: string;
declare const hostEngine: string;
declare const setExitCode: (code: number) => void;


Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Tests/UnitTests/Shared/Shared.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
Loading