Skip to content

Commit f07d991

Browse files
CedricGuillemetCopilotweb-flowCopilot
authored
Hermes (#176)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <noreply@github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 69b662e commit f07d991

18 files changed

Lines changed: 691 additions & 18 deletions

File tree

.github/workflows/build-android.yml

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,28 @@ env:
1313
jobs:
1414
build:
1515
runs-on: ubuntu-latest
16-
timeout-minutes: 30
16+
timeout-minutes: ${{ inputs.js-engine == 'Hermes' && 60 || 30 }}
1717
steps:
1818
- uses: actions/checkout@v5
1919

20+
# Hermes for Android builds the full LLVH/BCGen/Boost.Context/VM chain
21+
# twice (once as host tools, once for x86_64 NDK) plus an emulator image.
22+
# Default ubuntu-latest runners only ship ~14 GB free on /, which the
23+
# combined output exhausts during `:app:buildCMakeDebug[x86_64]` (gradle
24+
# then aborts with `java.io.IOException: No space left on device`).
25+
# Reclaim space *before* anything is downloaded.
26+
- name: Free up disk space (Hermes only)
27+
if: inputs.js-engine == 'Hermes'
28+
uses: jlumbroso/free-disk-space@main
29+
with:
30+
tool-cache: false
31+
android: false
32+
dotnet: true
33+
haskell: true
34+
large-packages: true
35+
docker-images: true
36+
swap-storage: true
37+
2038
- uses: actions/setup-java@v4
2139
with:
2240
distribution: temurin
@@ -29,6 +47,12 @@ jobs:
2947
working-directory: Tests
3048
run: npm install
3149

50+
- name: Install Ninja (Hermes host-compiler bootstrap)
51+
if: inputs.js-engine == 'Hermes'
52+
run: |
53+
sudo apt-get update
54+
sudo apt-get install -y ninja-build
55+
3256
- name: Enable KVM
3357
run: |
3458
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules

.github/workflows/build-win32.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ on:
1313
jobs:
1414
build:
1515
runs-on: windows-2022
16-
timeout-minutes: 15
16+
# Hermes pulls in a large LLVH/Boost.Context/BCGen chain through
17+
# `hermesvm_a`; on `windows-2022` the full configure+build comfortably
18+
# exceeds the standard 15-minute window. Keep other engines snappy.
19+
timeout-minutes: ${{ inputs.js-engine == 'Hermes' && 60 || 15 }}
1720
steps:
1821
- uses: actions/checkout@v5
1922

.github/workflows/ci.yml

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ on:
77
branches: [main]
88

99
jobs:
10-
# ── Win32 ─────────────────────────────────────────────────────
10+
# Win32
1111
Win32_x86_Chakra:
1212
uses: ./.github/workflows/build-win32.yml
1313
with:
@@ -32,7 +32,13 @@ jobs:
3232
platform: x64
3333
js-engine: V8
3434

35-
# ── UWP ───────────────────────────────────────────────────────
35+
Win32_x64_Hermes:
36+
uses: ./.github/workflows/build-win32.yml
37+
with:
38+
platform: x64
39+
js-engine: Hermes
40+
41+
# UWP
3642
UWP_x64_Chakra:
3743
uses: ./.github/workflows/build-uwp.yml
3844
with:
@@ -57,7 +63,7 @@ jobs:
5763
platform: x64
5864
js-engine: V8
5965

60-
# ── Android ───────────────────────────────────────────────────
66+
# Android
6167
Android_JSC:
6268
uses: ./.github/workflows/build-android.yml
6369
with:
@@ -68,7 +74,12 @@ jobs:
6874
with:
6975
js-engine: V8
7076

71-
# ── macOS ─────────────────────────────────────────────────────
77+
Android_Hermes:
78+
uses: ./.github/workflows/build-android.yml
79+
with:
80+
js-engine: Hermes
81+
82+
# macOS (no Apple + Hermes until the upstream shutdown crash is fixed)
7283
macOS_Xcode164:
7384
uses: ./.github/workflows/build-macos.yml
7485
with:
@@ -124,7 +135,7 @@ jobs:
124135
runs-on: macos-26
125136
simulator: 'iPhone 17'
126137

127-
# ── Linux ─────────────────────────────────────────────────────
138+
# Linux
128139
Ubuntu_gcc:
129140
uses: ./.github/workflows/build-linux.yml
130141

CMakeLists.txt

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ FetchContent_Declare(CMakeExtensions
3232
FetchContent_Declare(googletest
3333
URL "https://github.com/google/googletest/archive/refs/tags/v1.17.0.tar.gz"
3434
EXCLUDE_FROM_ALL)
35+
FetchContent_Declare(hermes
36+
GIT_REPOSITORY https://github.com/facebook/hermes.git
37+
# Pinned to the tip of the `static_h` branch as of 2026-06-03 so CI is
38+
# reproducible. Bump this SHA when you intentionally want to pick up
39+
# upstream Hermes changes — keep it on the static_h branch (Static
40+
# Hermes is the variant our NAPI integration targets).
41+
GIT_TAG 348582831f50954895da8e80cc91112d51036c69
42+
EXCLUDE_FROM_ALL)
3543
FetchContent_Declare(ios-cmake
3644
GIT_REPOSITORY https://github.com/leetal/ios-cmake.git
3745
GIT_TAG 4.4.1
@@ -157,6 +165,148 @@ if(BABYLON_DEBUG_TRACE)
157165
add_definitions(-DBABYLON_DEBUG_TRACE)
158166
endif()
159167

168+
if(NAPI_JAVASCRIPT_ENGINE STREQUAL "Hermes")
169+
# Hermes is currently an experimental engine for JsRuntimeHost and is not
170+
# supported on Apple platforms (iOS or macOS). Bail out early with a
171+
# clear error rather than letting the build fail deep inside Hermes's
172+
# Apple framework / toolchain machinery.
173+
if(APPLE OR IOS)
174+
message(FATAL_ERROR
175+
"NAPI_JAVASCRIPT_ENGINE=Hermes is not supported on Apple platforms "
176+
"(iOS or macOS). Hermes support in JsRuntimeHost is experimental "
177+
"and currently limited to Windows, Android, and Linux.")
178+
endif()
179+
180+
# Configure Hermes static_h options BEFORE making it available so that
181+
# cache variables take effect. We disable the parts of Hermes we don't
182+
# need (the unit-test suite, debugger) to keep build time reasonable and
183+
# to avoid pulling extra targets into our build tree. Notably we leave
184+
# HERMES_ENABLE_NAPI ON (the default) — that's the whole point of
185+
# integrating Hermes here.
186+
#
187+
# HERMES_ENABLE_TOOLS must stay ON: even when HERMES_ENABLE_TEST_SUITE is
188+
# OFF, Hermes unconditionally adds external/node-api-cts and
189+
# external/node-api-tests whenever HERMES_ENABLE_NAPI is on, and those
190+
# subdirectories reference the `hermes` CLI tool target via
191+
# $<TARGET_FILE:hermes>. CMake fails to generate if the target doesn't
192+
# exist, so we let Hermes build its tools. None of them ship in our
193+
# final binaries because they're EXCLUDE_FROM_ALL via FetchContent.
194+
set(HERMES_ENABLE_TOOLS ON CACHE BOOL "" FORCE)
195+
set(HERMES_ENABLE_TEST_SUITE OFF CACHE BOOL "" FORCE)
196+
set(HERMES_ENABLE_DEBUGGER OFF CACHE BOOL "" FORCE)
197+
set(HERMES_BUILD_APPLE_FRAMEWORK OFF CACHE BOOL "" FORCE)
198+
set(HERMES_ENABLE_NAPI ON CACHE BOOL "" FORCE)
199+
if(ANDROID)
200+
# JsRuntimeHost's Android test app embeds Hermes directly, without
201+
# Hermes's React Native Android/fbjni packaging. Unicode-lite avoids
202+
# pulling ICU/fbjni into this standalone N-API build.
203+
set(HERMES_UNICODE_LITE ON CACHE BOOL "" FORCE)
204+
endif()
205+
# Hermes defaults HERMES_SLOW_DEBUG=ON, which compiles in internal
206+
# sanity checks that fire even in optimized (RelWithDebInfo / Release)
207+
# builds — e.g. heap-state assertions in HadesGC and the finalizer
208+
# chain. These can SIGABRT during Runtime teardown on some platforms
209+
# (observed on macOS arm64 RelWithDebInfo: process exited with code
210+
# 134 right after `145 passing`, before any gtest [OK] line, with no
211+
# diagnostic in stderr). Embedders don't need them.
212+
set(HERMES_SLOW_DEBUG OFF CACHE BOOL "" FORCE)
213+
# Hermes vendors Boost.Context (external/boost/boost_1_86_0/libs/context).
214+
# The default fcontext implementation needs the platform-native assembler
215+
# for context switching. On Windows + MSBuild, MASM picks up CXX compile
216+
# flags like /Zc:__cplusplus from the surrounding scope, emits A4018
217+
# warnings for every prefix substring, eventually bails out before writing
218+
# boost_context_obj.dir\Release\make_x86_64_ms_pe_masm.obj, and the link
219+
# step fails with LNK1181. Switch to the Win32 Fiber-based implementation
220+
# (winfib) — no assembler needed, no MASM problem. Hermes only uses the
221+
# fiber API (per a comment in the bundled boost CMakeLists), so winfib is
222+
# functionally equivalent for this embedder.
223+
if(WIN32)
224+
set(BOOST_CONTEXT_IMPLEMENTATION "winfib" CACHE STRING "" FORCE)
225+
endif()
226+
# ------------------------------------------------------------------
227+
# Cross-compilation: bootstrap the Hermes host compilers automatically.
228+
#
229+
# `hermesc`/`shermes` are HOST tools that Hermes runs at build time (to
230+
# emit InternalBytecode and, in -emit-c mode, the native internal unit).
231+
# When cross-compiling (e.g. the Android NDK build) the in-tree Hermes
232+
# tool targets are built for the *target* and can't run on the build host,
233+
# so Hermes must be handed prebuilt HOST compilers via the standard
234+
# IMPORT_HOST_COMPILERS mechanism (it `include()`s a CMake file that
235+
# Hermes `export()`s when those tools are built).
236+
#
237+
# Rather than require a separate, manual "build host compilers" CI step
238+
# plus a -DIMPORT_HOST_COMPILERS=... hand-off, do it here: configure and
239+
# build a native (host-toolchain) copy of hermesc/shermes from THIS source
240+
# tree, then point Hermes at the ImportHostCompilers.cmake it produces.
241+
#
242+
# This runs at configure time (via execute_process) on purpose: Hermes
243+
# consumes IMPORT_HOST_COMPILERS during its own configure, so the exported
244+
# file must already exist before FetchContent_MakeAvailable(hermes) below.
245+
# An explicit -DIMPORT_HOST_COMPILERS=... still wins and skips the
246+
# bootstrap, preserving the manual/prebuilt path.
247+
if(CMAKE_CROSSCOMPILING AND NOT IMPORT_HOST_COMPILERS)
248+
# Shared, source-relative location so the host tools are built once and
249+
# reused across target ABIs (/Build is git-ignored).
250+
set(_jsrh_hermes_host_dir "${CMAKE_CURRENT_SOURCE_DIR}/Build/HermesHostTools")
251+
set(_jsrh_hermes_host_import "${_jsrh_hermes_host_dir}/ImportHostCompilers.cmake")
252+
253+
if(NOT EXISTS "${_jsrh_hermes_host_import}")
254+
message(STATUS "Hermes: bootstrapping host compilers (hermesc/shermes) for cross-compilation")
255+
256+
# Prefer Ninja when available; otherwise fall back to the host's
257+
# default generator (Unix Makefiles).
258+
find_program(_jsrh_host_ninja ninja)
259+
if(_jsrh_host_ninja)
260+
set(_jsrh_host_generator -G Ninja)
261+
else()
262+
set(_jsrh_host_generator "")
263+
endif()
264+
265+
# Configure a native build from the same source tree. No toolchain
266+
# file is passed, so CMake selects the host compiler. Tests are
267+
# off and unicode-lite is on to keep this bootstrap lean.
268+
execute_process(
269+
COMMAND "${CMAKE_COMMAND}"
270+
${_jsrh_host_generator}
271+
-S "${CMAKE_CURRENT_SOURCE_DIR}"
272+
-B "${_jsrh_hermes_host_dir}"
273+
-D CMAKE_BUILD_TYPE=Release
274+
-D NAPI_JAVASCRIPT_ENGINE=Hermes
275+
-D HERMES_UNICODE_LITE=ON
276+
-D JSRUNTIMEHOST_TESTS=OFF
277+
RESULT_VARIABLE _jsrh_host_cfg_result)
278+
if(NOT _jsrh_host_cfg_result EQUAL 0)
279+
message(FATAL_ERROR
280+
"Hermes host-compiler bootstrap: configure failed (${_jsrh_host_cfg_result}).")
281+
endif()
282+
283+
execute_process(
284+
COMMAND "${CMAKE_COMMAND}" --build "${_jsrh_hermes_host_dir}"
285+
--target hermesc shermes --config Release
286+
RESULT_VARIABLE _jsrh_host_build_result)
287+
if(NOT _jsrh_host_build_result EQUAL 0)
288+
message(FATAL_ERROR
289+
"Hermes host-compiler bootstrap: build failed (${_jsrh_host_build_result}).")
290+
endif()
291+
endif()
292+
293+
if(NOT EXISTS "${_jsrh_hermes_host_import}")
294+
message(FATAL_ERROR
295+
"Hermes host-compiler bootstrap did not produce ${_jsrh_hermes_host_import}.")
296+
endif()
297+
298+
set(IMPORT_HOST_COMPILERS "${_jsrh_hermes_host_import}" CACHE FILEPATH
299+
"Imported Hermes host compilers (hermesc/shermes) for cross-compilation" FORCE)
300+
message(STATUS "Hermes: using bootstrapped host compilers at ${IMPORT_HOST_COMPILERS}")
301+
endif()
302+
303+
# Hermes ships its own bundled gtest as `llvh-gtest` (a different
304+
# CMake target name from googletest's `gtest`), so the two coexist
305+
# without clashing. We never link llvh-gtest into our UnitTests
306+
# executable, so there's no duplicate-symbol issue at link time.
307+
FetchContent_MakeAvailable_With_Message(hermes)
308+
endif()
309+
160310
if(NAPI_JAVASCRIPT_ENGINE STREQUAL "V8" AND JSRUNTIMEHOST_CORE_APPRUNTIME_V8_INSPECTOR)
161311
FetchContent_MakeAvailable_With_Message(asio)
162312
add_library(asio INTERFACE)

Core/AppRuntime/CMakeLists.txt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,23 @@ target_link_libraries(AppRuntime
2222
PRIVATE arcana
2323
PUBLIC JsRuntime)
2424

25+
# AppRuntime_macOS.mm / AppRuntime_iOS.mm call NSLog (Foundation) and other
26+
# Apple ObjC++ APIs. Xcode's "Link Frameworks Automatically" setting auto-
27+
# links Foundation/CoreFoundation/UIKit for ObjC translation units, so the
28+
# default `-G Xcode` macOS/iOS builds work without an explicit link. Other
29+
# generators (notably Ninja, which we use for the Hermes macOS CI job to
30+
# work around the Xcode "new build system" rejecting Hermes's multi-target
31+
# generated source) leave those frameworks unresolved. Declaring them
32+
# PUBLIC here propagates them onto the final executable's link line for
33+
# every generator, and also covers other Apple-side static deps in the
34+
# tree (e.g. UrlLib's `UrlRequest_Apple.mm` reference to NSBundle) without
35+
# needing to patch the dep itself.
36+
if(APPLE)
37+
target_link_libraries(AppRuntime
38+
PUBLIC "-framework Foundation"
39+
PUBLIC "-framework CoreFoundation")
40+
endif()
41+
2542
if(NAPI_JAVASCRIPT_ENGINE STREQUAL "V8" AND JSRUNTIMEHOST_CORE_APPRUNTIME_V8_INSPECTOR)
2643
add_subdirectory(V8Inspector)
2744

Core/AppRuntime/Include/Babylon/AppRuntime.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@ namespace Babylon
6767
// extra logic around the invocation of a dispatched callback.
6868
void Execute(Dispatchable<void()> callback);
6969

70+
// Engine-specific hook called from Dispatch immediately after a user
71+
// callback completes. Most engines auto-drain microtasks at scope
72+
// exit, so the implementation is a no-op for Chakra/V8/JSC/JSI.
73+
// Hermes does NOT auto-drain; its implementation calls
74+
// `Napi::DrainJobs(env)` so Promise continuations and queueMicrotask
75+
// callbacks scheduled during the user callback actually run before
76+
// the next top-level dispatch.
77+
void DrainMicrotasks(Napi::Env env);
78+
7079
Options m_options;
7180

7281
class Impl;

Core/AppRuntime/Source/AppRuntime.cpp

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ namespace Babylon
109109
{
110110
m_impl->Append([this, func{std::move(func)}](Napi::Env env) mutable {
111111
Execute([this, env, func{std::move(func)}]() mutable {
112+
// Some engines (notably Hermes) require an open NAPI handle
113+
// scope before any napi_* call that materializes a value.
114+
// The other engines (V8/Chakra/JSC) already provide an outer
115+
// scope at the RunEnvironmentTier level, so this extra
116+
// scope is harmless there but mandatory for Hermes.
117+
Napi::HandleScope scope{env};
118+
112119
try
113120
{
114121
func(env);
@@ -122,6 +129,13 @@ namespace Babylon
122129
assert(false);
123130
std::abort();
124131
}
132+
133+
// Drain engine-level microtasks/jobs queued during the
134+
// callback (Promise continuations, queueMicrotask, etc.) so
135+
// they run before the next top-level Dispatch. No-op for
136+
// engines that drain automatically; Hermes needs an explicit
137+
// pump.
138+
DrainMicrotasks(env);
125139
});
126140
});
127141
}

Core/AppRuntime/Source/AppRuntime_Chakra.cpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,11 @@ namespace Babylon
7171
// Detach must come after JsDisposeRuntime since it triggers finalizers which require env.
7272
Napi::Detach(env);
7373
}
74+
75+
void AppRuntime::DrainMicrotasks(Napi::Env)
76+
{
77+
// Chakra drains promise continuations through its
78+
// JsSetPromiseContinuationCallback hook (see RunEnvironmentTier).
79+
// No explicit pump needed here.
80+
}
7481
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#include "AppRuntime.h"
2+
#include <napi/env.h>
3+
4+
namespace Babylon
5+
{
6+
void AppRuntime::RunEnvironmentTier(const char*)
7+
{
8+
// All Hermes runtime + napi_env setup is encapsulated inside the napi
9+
// library's env_hermes.cc (see Napi::Attach/Detach). Keeping the
10+
// engine-specific machinery there avoids dragging Hermes headers into
11+
// AppRuntime's translation unit.
12+
Napi::Env env = Napi::Attach();
13+
14+
Run(env);
15+
16+
Napi::Detach(env);
17+
}
18+
19+
void AppRuntime::DrainMicrotasks(Napi::Env env)
20+
{
21+
// Hermes does not auto-drain its job queue. Promise continuations,
22+
// queueMicrotask callbacks, and pending NAPI finalizers all run via
23+
// Runtime::drainJobs(). We pump it after each user callback so async
24+
// code (Promises, Mocha's async tests, polyfill schedulers, etc.)
25+
// observes the same "between turns" semantics it gets on V8/Chakra.
26+
Napi::DrainJobs(env);
27+
}
28+
}

Core/AppRuntime/Source/AppRuntime_JSI.cpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,9 @@ namespace Babylon
4545

4646
Napi::Detach(env);
4747
}
48+
49+
void AppRuntime::DrainMicrotasks(Napi::Env)
50+
{
51+
// JSI/V8 backed JSI auto-drains microtasks per scope.
52+
}
4853
}

0 commit comments

Comments
 (0)