From 6f4e09cbac41265b3d05f1168996435efc5d5755 Mon Sep 17 00:00:00 2001 From: pirate Date: Fri, 29 May 2026 22:42:35 +0000 Subject: [PATCH 1/4] feat(core): add browser WASM support via Emscripten Compile the C++ core to wasm32 with Emscripten so the browser runs the same fuzzed engine as every other binding. Isolates POSIX syscalls behind #if defined(__EMSCRIPTEN__) (aligned_alloc on the wasm heap, non-blocking ring), adds tachyon_bus_get_shm_ptr for zero-copy mapping, enforces a hard 2GB capacity limit for the 32-bit heap, and emits a MODULARIZE/EXPORT_ES6 module. Adds the Emscripten toolchain file and CMake presets. --- cmake/CMakePresets.json | 40 +++++++++++++ cmake/modules/TachyonCompileOptions.cmake | 18 +++--- cmake/toolchains/emscripten.cmake | 18 ++++++ core/CMakeLists.txt | 68 +++++++++++++++++------ core/include/tachyon.h | 18 ++++-- core/src/arena.cpp | 22 +++++--- core/src/shm.cpp | 42 +++++++++++++- core/src/tachyon_c.cpp | 14 +++++ core/src/transport_uds.cpp | 28 ++++++++++ 9 files changed, 227 insertions(+), 41 deletions(-) create mode 100644 cmake/toolchains/emscripten.cmake diff --git a/cmake/CMakePresets.json b/cmake/CMakePresets.json index 0d63c66c..e711a501 100644 --- a/cmake/CMakePresets.json +++ b/cmake/CMakePresets.json @@ -60,6 +60,20 @@ "TACHYON_SANITIZER": "none" } }, + { + "name": "emscripten-base", + "hidden": true, + "inherits": "base", + "toolchainFile": "${sourceDir}/cmake/toolchains/emscripten.cmake", + "cacheVariables": { + "TACHYON_ENABLE_TESTS": "OFF", + "TACHYON_ENABLE_BENCH": "OFF", + "TACHYON_ENABLE_TOP": "OFF", + "TACHYON_ENABLE_FUZZING": "OFF", + "TACHYON_ENABLE_SECCOMP": "OFF", + "TACHYON_SANITIZER": "none" + } + }, { "name": "clang-release", "displayName": "Clang - Release", @@ -142,6 +156,22 @@ "TACHYON_ENABLE_BENCH": "OFF" } }, + { + "name": "emscripten-release", + "displayName": "Emscripten - Release", + "inherits": "emscripten-base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release" + } + }, + { + "name": "emscripten-debug", + "displayName": "Emscripten - Debug", + "inherits": "emscripten-base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + } + }, { "name": "asan", "displayName": "ASan + UBSan - Clang", @@ -313,6 +343,16 @@ "configurePreset": "macos-ci", "jobs": 0 }, + { + "name": "emscripten-release", + "configurePreset": "emscripten-release", + "jobs": 0 + }, + { + "name": "emscripten-debug", + "configurePreset": "emscripten-debug", + "jobs": 0 + }, { "name": "asan", "configurePreset": "asan", diff --git a/cmake/modules/TachyonCompileOptions.cmake b/cmake/modules/TachyonCompileOptions.cmake index 80563856..d17d9047 100644 --- a/cmake/modules/TachyonCompileOptions.cmake +++ b/cmake/modules/TachyonCompileOptions.cmake @@ -13,17 +13,19 @@ set(TACHYON_RELEASE_FLAGS -fno-exceptions -fno-rtti ) -if (NOT APPLE) +if (NOT APPLE AND NOT EMSCRIPTEN) list(APPEND TACHYON_RELEASE_FLAGS -fno-plt) endif () -if (NOT TACHYON_PORTABLE_BUILD) - list(APPEND TACHYON_RELEASE_FLAGS "-march=native" "-mtune=native") -else () - if (CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|amd64)$") - list(APPEND TACHYON_FLAGS "-march=x86-64-v3") - elseif (CMAKE_SYSTEM_PROCESSOR STREQUAL "aarch64") - list(APPEND TACHYON_FLAGS "-march=armv8-a") +if (NOT EMSCRIPTEN) + if (NOT TACHYON_PORTABLE_BUILD) + list(APPEND TACHYON_RELEASE_FLAGS "-march=native" "-mtune=native") + else () + if (CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|amd64)$") + list(APPEND TACHYON_FLAGS "-march=x86-64-v3") + elseif (CMAKE_SYSTEM_PROCESSOR STREQUAL "aarch64") + list(APPEND TACHYON_FLAGS "-march=armv8-a") + endif () endif () endif () diff --git a/cmake/toolchains/emscripten.cmake b/cmake/toolchains/emscripten.cmake new file mode 100644 index 00000000..e29dffe5 --- /dev/null +++ b/cmake/toolchains/emscripten.cmake @@ -0,0 +1,18 @@ +if (DEFINED ENV{EMSDK}) + set(EMSDK_ROOT "$ENV{EMSDK}") +else () + set(EMSDK_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../.emsdk") +endif () + +set(EMSCRIPTEN_TOOLCHAIN "${EMSDK_ROOT}/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake") + +if (NOT EXISTS "${EMSCRIPTEN_TOOLCHAIN}") + message(FATAL_ERROR + "[toolchain/emscripten] Toolchain not found at: ${EMSCRIPTEN_TOOLCHAIN}\n" + "Run: bash ci/setup/install_emsdk.sh" + ) +endif () + +include("${EMSCRIPTEN_TOOLCHAIN}") + +message(STATUS "[toolchain/emscripten] Loaded toolchain from: ${EMSCRIPTEN_TOOLCHAIN}") diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 03216e19..38d5c2f2 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -1,4 +1,4 @@ -add_library(tachyon SHARED +set(TACHYON_CORE_SRCS src/arena.cpp src/shm.cpp src/star.cpp @@ -8,10 +8,16 @@ add_library(tachyon SHARED src/transport_uds.cpp ) +if (NOT EMSCRIPTEN) + add_library(tachyon SHARED ${TACHYON_CORE_SRCS}) +else () + add_library(tachyon STATIC ${TACHYON_CORE_SRCS}) +endif () + tachyon_set_compile_options(tachyon) -target_link_options(tachyon PRIVATE - ${TACHYON_LINK_OPTIONS} -) +if (NOT EMSCRIPTEN) + target_link_options(tachyon PRIVATE ${TACHYON_LINK_OPTIONS}) +endif () set_target_properties(tachyon PROPERTIES CXX_VISIBILITY_PRESET hidden @@ -20,10 +26,21 @@ set_target_properties(tachyon PROPERTIES OUTPUT_NAME "tachyon" ) -if (APPLE) - set_target_properties(tachyon PROPERTIES INSTALL_RPATH "@loader_path") -else () - set_target_properties(tachyon PROPERTIES INSTALL_RPATH "$ORIGIN") +if (NOT EMSCRIPTEN) + if (APPLE) + set_target_properties(tachyon PROPERTIES INSTALL_RPATH "@loader_path") + else () + set_target_properties(tachyon PROPERTIES INSTALL_RPATH "$ORIGIN") + endif () + + if (UNIX AND NOT APPLE) + target_link_libraries(tachyon PRIVATE rt) + target_link_options(tachyon PRIVATE + $<$>:-Wl,-z,defs> + "-Wl,-z,now" + "-Wl,-z,relro" + ) + endif () endif () target_include_directories(tachyon @@ -31,13 +48,32 @@ target_include_directories(tachyon PRIVATE src ) -if (UNIX AND NOT APPLE) - target_link_libraries(tachyon PRIVATE rt) - target_link_options(tachyon PRIVATE - $<$>:-Wl,-z,defs> - "-Wl,-z,now" - "-Wl,-z,relro" +set(TACHYON_LIBRARY tachyon PARENT_SCOPE) + +if (EMSCRIPTEN) + file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/wasm_entry.cpp "") + add_executable(tachyon_wasm ${CMAKE_CURRENT_BINARY_DIR}/wasm_entry.cpp) + + target_link_libraries(tachyon_wasm PRIVATE -Wl,--whole-archive tachyon -Wl,--no-whole-archive) + target_link_options(tachyon_wasm PRIVATE + "-sALLOW_MEMORY_GROWTH=1" + "-sMODULARIZE=1" + "-sEXPORT_ES6=1" + "-sEXPORT_NAME=TachyonCore" + "-sENVIRONMENT=web,worker" + "-sSTRICT=1" + "-sNO_EXIT_RUNTIME=1" + "-sWASM_BIGINT=1" + "-sFILESYSTEM=0" + "--no-entry" + "--minify=0" + "--profiling-funcs" + "-sEXPORTED_FUNCTIONS=_malloc,_free" + "-sEXPORTED_RUNTIME_METHODS=ccall,cwrap,getValue,setValue,UTF8ToString,HEAPU8,HEAPU32" ) -endif () -set(TACHYON_LIBRARY tachyon PARENT_SCOPE) + set_target_properties(tachyon_wasm PROPERTIES + OUTPUT_NAME "tachyon" + SUFFIX ".js" + ) +endif () diff --git a/core/include/tachyon.h b/core/include/tachyon.h index 182b7343..c5fceae7 100644 --- a/core/include/tachyon.h +++ b/core/include/tachyon.h @@ -4,6 +4,16 @@ #include #include +#if defined(__EMSCRIPTEN__) +#include + +#define TACHYON_ABI EMSCRIPTEN_KEEPALIVE +#elif defined(_WIN32) || defined(__CYGWIN__) // #if defined(__EMSCRIPTEN__) +#define TACHYON_ABI __declspec(dllexport) +#else // #elif defined(_WIN32) || defined(__CYGWIN__) +#define TACHYON_ABI __attribute__((visibility("default"))) +#endif // #elif defined(_WIN32) || defined(__CYGWIN__) #else + #ifdef __cplusplus #define TACHYON_NOEXCEPT noexcept #define TACHYON_ALIGNAS(n) alignas(n) @@ -13,12 +23,6 @@ extern "C" { #define TACHYON_ALIGNAS(n) _Alignas(n) #endif // #ifdef __cplusplus #else -#if defined(_WIN32) || defined(__CYGWIN__) -#define TACHYON_ABI __declspec(dllexport) -#else // #if defined(_WIN32) || defined(__CYGWIN__) -#define TACHYON_ABI __attribute__((visibility("default"))) -#endif // #if defined(_WIN32) || defined(__CYGWIN__) #else - #define TACHYON_TYPE_ID(route, type) (((uint32_t)(route) << 16) | (uint32_t)(type)) #define TACHYON_ROUTE_ID(type_id) ((uint16_t)((type_id) >> 16)) #define TACHYON_MSG_TYPE(type_id) ((uint16_t)((type_id) & 0xFFFF)) @@ -123,6 +127,8 @@ TACHYON_ABI tachyon_state_t tachyon_get_state(const tachyon_bus_t *bus) TACHYON_ TACHYON_ABI tachyon_error_t tachyon_bus_stats(const tachyon_bus_t *bus, tachyon_bus_stats_t *out_stats) TACHYON_NOEXCEPT; +TACHYON_ABI void *tachyon_bus_get_shm_ptr(const tachyon_bus_t *bus) TACHYON_NOEXCEPT; + TACHYON_ABI tachyon_error_t tachyon_rpc_listen( const char *socket_path, size_t cap_fwd, size_t cap_rev, tachyon_rpc_bus_t **out_rpc ) TACHYON_NOEXCEPT; diff --git a/core/src/arena.cpp b/core/src/arena.cpp index 828bc904..10275057 100644 --- a/core/src/arena.cpp +++ b/core/src/arena.cpp @@ -8,6 +8,10 @@ #include #endif // #if defined(__linux__) +#if !defined(__linux__) && !defined(__APPLE__) && !defined(__EMSCRIPTEN__) +#include +#endif // #if !defined(__linux__) && !defined(__APPLE__) && !defined(__EMSCRIPTEN__) + #include #if defined(__has_feature) @@ -30,10 +34,10 @@ extern "C" void __tsan_release(void *addr); namespace tachyon::core { namespace { - constexpr uint32_t SKIP_MARKER = 0xFFFFFFFF; - constexpr uint32_t WATCHDOG_TIMEOUT_US = 200'000; - constexpr uint32_t HDR_SIZE = TACHYON_MSG_ALIGNMENT; - constexpr uint32_t ALIGN_MASK = TACHYON_MSG_ALIGNMENT - 1U; + constexpr uint32_t SKIP_MARKER = 0xFFFFFFFF; + [[maybe_unused]] constexpr uint32_t WATCHDOG_TIMEOUT_US = 200'000; + constexpr uint32_t HDR_SIZE = TACHYON_MSG_ALIGNMENT; + constexpr uint32_t ALIGN_MASK = TACHYON_MSG_ALIGNMENT - 1U; static_assert( sizeof(MessageHeader) == TACHYON_MSG_ALIGNMENT, @@ -65,7 +69,10 @@ namespace tachyon::core { #endif // #if defined(__APPLE__) inline WaitResult platform_wait(std::atomic *addr) noexcept { -#if defined(__linux__) +#if defined(__EMSCRIPTEN__) + (void)addr; + return WaitResult::Timeout; +#elif defined(__linux__) struct timespec ts = { .tv_sec = static_cast(WATCHDOG_TIMEOUT_US / 1'000'000), .tv_nsec = static_cast((WATCHDOG_TIMEOUT_US % 1'000'000) * 1000) @@ -88,8 +95,6 @@ namespace tachyon::core { TACHYON_TSAN_ACQUIRE(addr); return WaitResult::Woken; #else -#include - std::this_thread::yield(); return WaitResult::Woken; #endif @@ -97,7 +102,8 @@ namespace tachyon::core { inline void platform_wake(std::atomic *addr) noexcept { TACHYON_TSAN_RELEASE(addr); -#if defined(__linux__) +#if defined(__EMSCRIPTEN__) +#elif defined(__linux__) syscall(SYS_futex, addr, FUTEX_WAKE, 1, nullptr, nullptr, 0); #elif defined(__APPLE__) __ulock_wake(UL_COMPARE_AND_WAIT | ULF_WAKE_ALL, addr, 0); diff --git a/core/src/shm.cpp b/core/src/shm.cpp index 53105e0d..2eaa05bc 100644 --- a/core/src/shm.cpp +++ b/core/src/shm.cpp @@ -6,6 +6,10 @@ #include #include +#if defined(__EMSCRIPTEN__) +#include +#endif // #if defined(__EMSCRIPTEN__) + #include #ifndef MFD_ALLOW_SEALING @@ -40,6 +44,19 @@ namespace tachyon::core { std::string path(name); +#if defined(__EMSCRIPTEN__) + if (size > static_cast(INT32_MAX)) [[unlikely]] { + return std::unexpected(ShmError::InvalidSize); + } + + void *ptr = std::aligned_alloc(64, size); + if (!ptr) [[unlikely]] { + return std::unexpected(ShmError::MapFailed); + } + + return SharedMemory(ptr, size, std::move(path), -1, true); +#else // #if defined(__EMSCRIPTEN__) + #if defined(__linux__) const int fd = ::memfd_create(path.c_str(), MFD_ALLOW_SEALING | MFD_CLOEXEC); if (fd == -1) [[unlikely]] @@ -87,14 +104,22 @@ namespace tachyon::core { #if defined(__linux__) ::madvise(ptr, size, MADV_DONTFORK); // CoW safety -#endif // #if defined(__linux__) +#endif // #if defined(__linux__) return SharedMemory(ptr, size, std::move(path), fd, true); +#endif // #if defined(__EMSCRIPTEN__) #else } auto SharedMemory::join(const int fd, const size_t size) -> std::expected { - if (fd == -1 || size == 0) [[unlikely]] +#if defined(__EMSCRIPTEN__) + (void)fd; + (void)size; + return std::unexpected(ShmError::OpenFailed); + +#else + if (fd == -1 || size == 0) [[unlikely]] { return std::unexpected(ShmError::OpenFailed); + } int flags = MAP_SHARED; #if defined(__linux__) @@ -108,19 +133,30 @@ namespace tachyon::core { #if defined(__linux__) ::madvise(ptr, size, MADV_DONTFORK); // CoW safety -#endif // #if defined(__linux__) +#endif // #if defined(__linux__) return SharedMemory(ptr, size, "", fd, false); +#endif } void SharedMemory::release() noexcept { +#if defined(__EMSCRIPTEN__) + if (ptr_ && owner_) [[likely]] { + std::free(ptr_); + ptr_ = nullptr; + } + +#else // #if defined(__EMSCRIPTEN__) if (ptr_ && ptr_ != MAP_FAILED) [[likely]] { ::munmap(ptr_, size_); ptr_ = nullptr; } + if (fd_ != -1) [[likely]] { ::close(fd_); fd_ = -1; } + +#endif // #if defined(__EMSCRIPTEN__) #else } } // namespace tachyon::core diff --git a/core/src/tachyon_c.cpp b/core/src/tachyon_c.cpp index 46536d91..caf9959f 100644 --- a/core/src/tachyon_c.cpp +++ b/core/src/tachyon_c.cpp @@ -34,6 +34,12 @@ tachyon_bus_listen(const char *socket_path, const size_t capacity, tachyon_bus_t if (!socket_path || !out_bus || capacity == 0) return TACHYON_ERR_INVALID_SZ; +#if defined(__EMSCRIPTEN__) + if (capacity > static_cast(INT32_MAX)) [[unlikely]] { + return TACHYON_ERR_INVALID_SZ; + } +#endif // #if defined(__EMSCRIPTEN__) + const size_t required_shm_size = sizeof(MemoryLayout) + capacity; auto shm_res = SharedMemory::create(socket_path, required_shm_size); if (!shm_res.has_value()) @@ -293,4 +299,12 @@ tachyon_error_t tachyon_bus_stats(const tachyon_bus_t *bus, tachyon_bus_stats_t out_stats->state = static_cast(bus->arena.get_state()); return TACHYON_SUCCESS; } + +void *tachyon_bus_get_shm_ptr(const tachyon_bus_t *bus) TACHYON_NOEXCEPT { + if (!bus) [[unlikely]] { + return nullptr; + } + + return bus->shm.get_ptr(); +} } // extern "C" diff --git a/core/src/transport_uds.cpp b/core/src/transport_uds.cpp index b235b166..27fe7f14 100644 --- a/core/src/transport_uds.cpp +++ b/core/src/transport_uds.cpp @@ -1,9 +1,12 @@ #include #include + +#if !defined(__EMSCRIPTEN__) #include #include #include #include +#endif // #if !defined(__EMSCRIPTEN__) #include @@ -11,6 +14,12 @@ namespace tachyon::core { auto uds_export_shm(const std::string_view socket_path, const int shm_fd, const TachyonHandshake &handshake) noexcept -> std::expected { +#if defined(__EMSCRIPTEN__) + (void)socket_path; + (void)shm_fd; + (void)handshake; + return {}; +#else const int sock = ::socket(AF_UNIX, SOCK_STREAM, 0); if (sock < 0) return std::unexpected(TransportError::SocketCreation); @@ -103,9 +112,14 @@ namespace tachyon::core { ::unlink(addr.sun_path); return {}; +#endif } auto uds_import_shm(const std::string_view socket_path) noexcept -> std::expected { +#if defined(__EMSCRIPTEN__) + (void)socket_path; + return std::unexpected(TransportError::SystemError); +#else const int sock = ::socket(AF_UNIX, SOCK_STREAM, 0); if (sock < 0) return std::unexpected(TransportError::SocketCreation); @@ -152,11 +166,19 @@ namespace tachyon::core { } return ImportedShm{received_fd, hs}; +#endif } auto uds_export_shm_rpc( const std::string_view socket_path, const int fd_fwd, const int fd_rev, const TachyonHandshake &handshake ) noexcept -> std::expected { +#if defined(__EMSCRIPTEN__) + (void)socket_path; + (void)fd_fwd; + (void)fd_rev; + (void)handshake; + return {}; +#else const int sock = ::socket(AF_UNIX, SOCK_STREAM, 0); if (sock < 0) return std::unexpected(TransportError::SocketCreation); @@ -248,10 +270,15 @@ namespace tachyon::core { ::unlink(addr.sun_path); return {}; +#endif } auto uds_import_shm_rpc(const std::string_view socket_path) noexcept -> std::expected { +#if defined(__EMSCRIPTEN__) + (void)socket_path; + return std::unexpected(TransportError::SystemError); +#else const int sock = ::socket(AF_UNIX, SOCK_STREAM, 0); if (sock < 0) return std::unexpected(TransportError::SocketCreation); @@ -304,5 +331,6 @@ namespace tachyon::core { } return RpcImportedShm{fds[0], fds[1], hs}; +#endif } } // namespace tachyon::core From f9f94f7ad291ae26badcf46572c39b870608d0dc Mon Sep 17 00:00:00 2001 From: pirate Date: Fri, 29 May 2026 22:42:35 +0000 Subject: [PATCH 2/4] feat(node): add browser WASM transport to @tachyon-ipc/core Add a browser entry to the existing package (resolved via the package `browser` field) that drives the Emscripten C ABI through a single shared tachyon_bus_t, so the browser runs the same fuzzed C++ ring as Node. Shares the API surface in BusBase and fixes the reviewed bugs: recv() returns null on an empty non-blocking poll instead of throwing; guards are generic over the slot type so a browser slot is a Uint8Array and is never cast to Buffer (Node keeps Buffer); a leak-free FinalizationRegistry frees a dropped bus. The Node API is unchanged. Commits the generated Emscripten module and a non-Playwright CDP browser test. The bindings/node -> bindings/js rename is intentionally left to a separate PR. --- bindings/node/.prettierignore | 1 + bindings/node/README.md | 44 ++ bindings/node/eslint.config.mjs | 2 +- bindings/node/package.json | 14 +- bindings/node/scripts/copy-wasm.mjs | 33 + bindings/node/scripts/stage-wasm.mjs | 21 + bindings/node/src/ts/batch.ts | 18 +- bindings/node/src/ts/browser.ts | 303 ++++++++ bindings/node/src/ts/bus.ts | 258 +++---- bindings/node/src/ts/bus_core.ts | 233 +++++++ bindings/node/src/ts/guards.ts | 34 +- bindings/node/src/ts/wasm/tachyon.d.ts | 38 + bindings/node/src/ts/wasm/tachyon.js | 853 +++++++++++++++++++++++ bindings/node/src/ts/wasm/tachyon.wasm | Bin 0 -> 23199 bytes bindings/node/test/browser_wasm.spec.mjs | 408 +++++++++++ bindings/node/tsconfig.json | 1 + 16 files changed, 2080 insertions(+), 181 deletions(-) create mode 100644 bindings/node/.prettierignore create mode 100644 bindings/node/scripts/copy-wasm.mjs create mode 100644 bindings/node/scripts/stage-wasm.mjs create mode 100644 bindings/node/src/ts/browser.ts create mode 100644 bindings/node/src/ts/bus_core.ts create mode 100644 bindings/node/src/ts/wasm/tachyon.d.ts create mode 100644 bindings/node/src/ts/wasm/tachyon.js create mode 100755 bindings/node/src/ts/wasm/tachyon.wasm create mode 100644 bindings/node/test/browser_wasm.spec.mjs diff --git a/bindings/node/.prettierignore b/bindings/node/.prettierignore new file mode 100644 index 00000000..81c70de8 --- /dev/null +++ b/bindings/node/.prettierignore @@ -0,0 +1 @@ +src/ts/wasm/ diff --git a/bindings/node/README.md b/bindings/node/README.md index 0fa552f4..930c2862 100644 --- a/bindings/node/README.md +++ b/bindings/node/README.md @@ -13,6 +13,7 @@ compiled from source at installation time via `cmake-js`. - [Requirements](#requirements) - [Install](#install) - [Quickstart](#quickstart) +- [Browser WASM](#browser-wasm) - [API](#api) - [Zero-copy pattern](#zero-copy-pattern) - [Batch pattern](#batch-pattern) @@ -48,6 +49,9 @@ Clang 17+ must be available on the build machine. The package ships as **ESM** (`"type": "module"`). CommonJS consumers must use dynamic `import()`. +Browser bundlers that honor the package `browser` field resolve `@tachyon-ipc/core` to the WASM browser build. Node.js +continues to resolve the native N-API entrypoint through the existing `main` and `types` fields. + ## Quickstart The consumer must start first, it owns the UNIX socket and the SHM arena. @@ -72,6 +76,46 @@ bus.send(Buffer.from('hello tachyon'), 1); `Bus` implements `Disposable`. The `using` keyword (TypeScript 5.2+, ES2023 Explicit Resource Management) guarantees `close()` is called on scope exit regardless of exceptions. +## Browser WASM + +The browser build is shipped from the same npm package and keeps the same import and constructor shape: + +```typescript +import {Bus} from '@tachyon-ipc/core'; + +using consumer = Bus.listen('/page/demo', 1 << 20); +using producer = Bus.connect('/page/demo'); + +producer.send(new Uint8Array([1, 2, 3, 4]), 7); +const {data, typeId} = consumer.recv(); +``` + +The browser build runs the same C++ core compiled to WebAssembly with Emscripten, so the ring engine is identical to the +native binding — there is no second implementation to keep in sync. + +Browsers do not expose POSIX shared memory or UNIX sockets, so `socketPath` is a page-local endpoint key rather than a +filesystem socket. `listen()` creates the in-page WASM ring and `connect()` attaches to that ring. The message layout +still uses Tachyon's 64-byte header, `type_id`, alignment, and skip-marker rules. Capacities are capped at 2GB because +wasm32 pointers are 32-bit. + +The browser implementation is intentionally direct-doorbell oriented. After JavaScript commits a message, call the WASM +work function immediately instead of scheduling a browser event or spinning in a poll loop. This avoids event-loop +latency and keeps sub-microsecond round trips possible for in-page communication. Because the browser ring is +non-blocking, `recv()` returns `null` when the ring is empty rather than throwing. + +Browser differences: + +- Repeated browser `connect()` calls return aliases to the same page-local ring; they are not independent subscribers, + and multiple consumers compete for the same ordered SPSC stream. +- `recv()` and `acquireRx()` are non-blocking because the main browser thread cannot park like a native futex wait. +- Browser `drainBatch()` preserves order but copies batch entries before returning, so ring slots are released + immediately; use `acquireRx()` for a direct WASM memory view. +- `setNumaNode()` and `setPollingMode()` are no-ops in browsers. +- `Buffer` is not a browser primitive; returned data is a `Uint8Array`. +- Native cross-process IPC still requires Node.js or another native binding. +- The browser build uses wasm32 for now, so WASM pointers, capacities, and slot sizes are `u32`-bounded; it can move to + wasm64/Memory64 later if a single linear-memory arena above 4 GiB becomes necessary. + ## API ### Lifecycle diff --git a/bindings/node/eslint.config.mjs b/bindings/node/eslint.config.mjs index 2831b2df..6ec10aff 100644 --- a/bindings/node/eslint.config.mjs +++ b/bindings/node/eslint.config.mjs @@ -2,7 +2,7 @@ import tseslint from 'typescript-eslint'; export default tseslint.config( { - ignores: ['dist/**', 'build/**', 'node_modules/**', 'test/**'], + ignores: ['dist/**', 'build/**', 'node_modules/**', 'test/**', 'src/ts/wasm/**'], }, ...tseslint.configs.strictTypeChecked, ...tseslint.configs.stylisticTypeChecked, diff --git a/bindings/node/package.json b/bindings/node/package.json index d5a3f6d9..87a1563f 100644 --- a/bindings/node/package.json +++ b/bindings/node/package.json @@ -1,7 +1,7 @@ { "name": "@tachyon-ipc/core", "version": "0.5.1", - "description": "Tachyon IPC bindings for Node.js", + "description": "Tachyon IPC bindings for Node.js and the browser (WASM)", "keywords": [ "ipc", "shm", @@ -17,20 +17,28 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "browser": { + "./dist/index.js": "./dist/browser.js" + }, "engines": { "node": ">=20.0.0" }, "scripts": { "build": "npm run build:native && npm run build:ts", "build:native": "cmake-js compile", - "build:ts": "tsc", + "build:ts": "tsc && node scripts/stage-wasm.mjs", + "build:wasm": "npm run cmake:wasm && npm run copy:wasm", "clean": "rm -rf build dist", "format": "prettier --write src/ts test/", "format:check": "prettier --check src/ts test/", + "cmake:wasm": "cd ../.. && cmake --preset emscripten-release && cmake --build --preset emscripten-release", + "copy:wasm": "node scripts/copy-wasm.mjs", "install": "cmake-js compile", "lint": "eslint src/ts", "prebuild": "cmake-js compile --runtime node --runtime-version $(node -v | cut -c2-)", - "test": "mocha" + "test": "mocha", + "pretest:browser": "npm run build:ts", + "test:browser": "node test/browser_wasm.spec.mjs" }, "dependencies": { "node-addon-api": "^8.7.0" diff --git a/bindings/node/scripts/copy-wasm.mjs b/bindings/node/scripts/copy-wasm.mjs new file mode 100644 index 00000000..f92b9e1e --- /dev/null +++ b/bindings/node/scripts/copy-wasm.mjs @@ -0,0 +1,33 @@ +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const BUILD_DIR = resolve("../../build/emscripten-release/core"); +const TARGET_DIR = resolve("src/ts/wasm"); + +const GENERATED_HEADER = [ + '/* eslint-disable */', + '// Generated by Emscripten. Do not edit by hand.', + '// Regenerate with: npm run build:wasm', + '', +].join('\n'); + +async function main(){ + await mkdir(TARGET_DIR, { recursive: true }); + await copyFile( + resolve(BUILD_DIR, "tachyon.wasm"), + resolve(TARGET_DIR, "tachyon.wasm") + ); + + const jsBody = await readFile(resolve(BUILD_DIR, 'tachyon.js'), 'utf8'); + await writeFile( + resolve(TARGET_DIR, "tachyon.js"), + `${GENERATED_HEADER}\n${jsBody}` + ); + + console.log("WASM artefacts copied"); +} + +main().catch((err) => { + console.error("Failed to process WASM artefacts: ", err); + process.exit(1); +}) \ No newline at end of file diff --git a/bindings/node/scripts/stage-wasm.mjs b/bindings/node/scripts/stage-wasm.mjs new file mode 100644 index 00000000..58713796 --- /dev/null +++ b/bindings/node/scripts/stage-wasm.mjs @@ -0,0 +1,21 @@ +import { copyFile, mkdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +// tsc only emits the TypeScript sources; the committed Emscripten module +// (tachyon.js + tachyon.wasm) is a plain asset, so copy it next to the compiled +// browser entry at dist/wasm/ where `dist/browser.js` imports it from. +const SRC_DIR = resolve('src/ts/wasm'); +const DIST_DIR = resolve('dist/wasm'); + +async function main() { + await mkdir(DIST_DIR, { recursive: true }); + for (const file of ['tachyon.js', 'tachyon.wasm']) { + await copyFile(resolve(SRC_DIR, file), resolve(DIST_DIR, file)); + } + console.log('WASM artefacts staged into dist/wasm'); +} + +main().catch((err) => { + console.error('Failed to stage WASM artefacts: ', err); + process.exit(1); +}); diff --git a/bindings/node/src/ts/batch.ts b/bindings/node/src/ts/batch.ts index d145a638..7b28de42 100644 --- a/bindings/node/src/ts/batch.ts +++ b/bindings/node/src/ts/batch.ts @@ -9,8 +9,8 @@ export interface BatchController { } /** A single message inside an {@link RxBatch}. Valid only until the batch is committed. */ -export interface RxMessage { - readonly data: RxSlot; +export interface RxMessage { + readonly data: RxSlot; readonly typeId: number; readonly size: number; } @@ -22,7 +22,7 @@ export interface RxMessage { * `using` commits automatically. * * All `RxMessage.data` references are invalidated on commit. Any cached reference - * will throw `TypeError` (underlying ArrayBuffers are detached by the C++ side). + * will throw `TypeError` where the platform can detach the underlying ArrayBuffers. * * @example * ```ts @@ -32,13 +32,13 @@ export interface RxMessage { * } * ``` */ -export class RxBatch { +export class RxBatch { #ctrl: BatchController; - #messages: RxMessage[]; + #messages: RxMessage[]; #done = false; /** @internal */ - public constructor(ctrl: BatchController, messages: RxMessage[]) { + public constructor(ctrl: BatchController, messages: RxMessage[]) { this.#ctrl = ctrl; this.#messages = messages; } @@ -55,7 +55,7 @@ export class RxBatch { * @throws {Error} If the batch has already been committed. * @throws {PeerDeadError} If the bus has transitioned to TACHYON_STATE_FATAL_ERROR. */ - public at(i: number): RxMessage { + public at(i: number): RxMessage { this.#assertOpen(); if (this.#ctrl.getState() === 4 /* TACHYON_STATE_FATAL_ERROR */) throw new PeerDeadError(); @@ -74,10 +74,10 @@ export class RxBatch { * * @throws {PeerDeadError} If the bus has transitioned to TACHYON_STATE_FATAL_ERROR. */ - public [Symbol.iterator](): Iterator { + public [Symbol.iterator](): Iterator> { let i = 0; return { - next: (): IteratorResult => { + next: (): IteratorResult> => { if (this.#done || i >= this.#messages.length) { return { value: undefined, done: true }; } diff --git a/bindings/node/src/ts/browser.ts b/bindings/node/src/ts/browser.ts new file mode 100644 index 00000000..a79051ad --- /dev/null +++ b/bindings/node/src/ts/browser.ts @@ -0,0 +1,303 @@ +import type { BusHandle, RawBatchMessage, RawRx } from './bus_core.ts'; +import { BusBase } from './bus_core.ts'; +import { ErrorCode, TachyonError } from './error.ts'; +import type { CwrapFn, TachyonCoreModule } from './wasm/tachyon.js'; +import createTachyonCore from './wasm/tachyon.js'; + +const TACHYON_SUCCESS = 0; + +/** + * wasm32 pointers and `size_t` are 32-bit, so the core rejects any capacity + * larger than `INT32_MAX` (see `tachyon_bus_listen` / `SharedMemory::create`). + * We reject the same boundary here, at the JS API surface, with a clean + * exception so a 2GB+ request never reaches the core as a silent overflow. + */ +const MAX_CAPACITY = 0x7fff_ffff; // 2 GiB - 1 + +/** Maps a `tachyon_error_t` code to the JS error-code surface. */ +function mapError(code: number): ErrorCode { + switch (code) { + case 1: + return ErrorCode.NullPtr; + case 2: + return ErrorCode.Mem; + case 8: + return ErrorCode.InvalidSz; + case 9: + return ErrorCode.Full; + case 10: + return ErrorCode.Empty; + default: + return ErrorCode.System; + } +} + +// The Emscripten module is instantiated once per page. The compiled C++ core is +// the single source of truth; every ring operation goes through the fuzzed C ABI. +const core: TachyonCoreModule = await createTachyonCore(); + +const abi: { + busListen: CwrapFn; + busDestroy: CwrapFn; + getShmPtr: CwrapFn; + acquireTx: CwrapFn; + commitTx: CwrapFn; + rollbackTx: CwrapFn; + acquireRx: CwrapFn; + commitRx: CwrapFn; + flush: CwrapFn; + getState: CwrapFn; + setPollingMode: CwrapFn; +} = { + busListen: core.cwrap('tachyon_bus_listen', 'number', ['string', 'number', 'number']), + busDestroy: core.cwrap('tachyon_bus_destroy', null, ['number']), + getShmPtr: core.cwrap('tachyon_bus_get_shm_ptr', 'number', ['number']), + acquireTx: core.cwrap('tachyon_acquire_tx', 'number', ['number', 'number']), + commitTx: core.cwrap('tachyon_commit_tx', 'number', ['number', 'number', 'number']), + rollbackTx: core.cwrap('tachyon_rollback_tx', 'number', ['number']), + acquireRx: core.cwrap('tachyon_acquire_rx', 'number', ['number', 'number', 'number']), + commitRx: core.cwrap('tachyon_commit_rx', 'number', ['number']), + flush: core.cwrap('tachyon_flush', null, ['number']), + getState: core.cwrap('tachyon_get_state', 'number', ['number']), + setPollingMode: core.cwrap('tachyon_bus_set_polling_mode', null, ['number', 'number']), +}; + +// Scratch heap cells for C out-parameters. acquire_rx writes the type_id and the +// actual size here; listen writes the out bus pointer. Reused across calls. +const scratch = core._malloc(16); +const OUT_TYPE_ID = scratch; // uint32_t +const OUT_SIZE = scratch + 4; // size_t (32-bit on wasm32) +const OUT_BUS = scratch + 8; // tachyon_bus_t* + +/** Maps a heap offset + length onto the live WASM memory as a zero-copy view. */ +function slot(ptr: number, len: number): Uint8Array { + // HEAPU8.buffer is re-read every call because memory growth swaps the buffer. + return new Uint8Array(core.HEAPU8.buffer, ptr, len); +} + +function detachArrayBuffer(buffer: ArrayBuffer): void { + if (buffer.byteLength === 0) return; + structuredClone(buffer, { transfer: [buffer] }); +} + +interface BrowserEndpoint { + /** Raw `tachyon_bus_t*` shared by the listener and every connected peer. */ + busPtr: number; + refs: number; +} + +const endpoints = new Map(); + +class BrowserBusHandle implements BusHandle { + #endpoint: BrowserEndpoint; + #path: string; + #batchBuffers: ArrayBuffer[] = []; + + public constructor(path: string, endpoint: BrowserEndpoint) { + this.#path = path; + this.#endpoint = endpoint; + } + + get #bus(): number { + return this.#endpoint.busPtr; + } + + public close(): void { + this.#endpoint.refs -= 1; + if (this.#endpoint.refs <= 0) { + endpoints.delete(this.#path); + abi.busDestroy(this.#endpoint.busPtr); + this.#endpoint.busPtr = 0; + } + } + + public send(data: Buffer | Uint8Array, typeId = 0): void { + const ptr = abi.acquireTx(this.#bus, data.length); + if (ptr === 0) throw new TachyonError('Bus.send: the ring buffer is full.', ErrorCode.Full); + core.HEAPU8.set(data, ptr); + const rc = abi.commitTx(this.#bus, data.length, typeId); + if (rc !== TACHYON_SUCCESS) throw new TachyonError(`Bus.send: commit failed (error ${rc}).`, mapError(rc)); + abi.flush(this.#bus); + } + + public acquireTx(maxSize: number): Uint8Array { + const ptr = abi.acquireTx(this.#bus, maxSize); + if (ptr === 0) throw new TachyonError('Bus.acquireTx: the ring buffer is full.', ErrorCode.Full); + return slot(ptr, maxSize); + } + + public commitTx(actualSize: number, typeId: number): void { + const rc = abi.commitTx(this.#bus, actualSize, typeId); + if (rc !== TACHYON_SUCCESS) throw new TachyonError(`Bus.commitTx: commit failed (error ${rc}).`, mapError(rc)); + abi.flush(this.#bus); + } + + public commitTxUnflushed(actualSize: number, typeId: number): void { + const rc = abi.commitTx(this.#bus, actualSize, typeId); + if (rc !== TACHYON_SUCCESS) { + throw new TachyonError(`Bus.commitTxUnflushed: commit failed (error ${rc}).`, mapError(rc)); + } + } + + public rollbackTx(): void { + abi.rollbackTx(this.#bus); + } + + public flush(): void { + abi.flush(this.#bus); + } + + public acquireRx(): RawRx | null { + const ptr = abi.acquireRx(this.#bus, OUT_TYPE_ID, OUT_SIZE); + if (ptr === 0) return null; + const typeId = core.getValue(OUT_TYPE_ID, 'i32') >>> 0; + const actualSize = core.getValue(OUT_SIZE, 'i32') >>> 0; + return { data: slot(ptr, actualSize), typeId, actualSize }; + } + + public drainBatch(maxMsgs: number): RawBatchMessage[] { + this.#batchBuffers = []; + const messages: RawBatchMessage[] = []; + for (let i = 0; i < maxMsgs; i += 1) { + const result = this.acquireRx(); + if (result === null) break; + + // Copy out of the ring before committing so the slot can be reused. + const data = new Uint8Array(result.data); + this.#batchBuffers.push(data.buffer); + messages.push({ data, typeId: result.typeId, size: result.actualSize }); + this.commitRx(); + } + return messages; + } + + public commitRx(): void { + abi.commitRx(this.#bus); + } + + public commitBatch(): void { + for (const buffer of this.#batchBuffers) { + detachArrayBuffer(buffer); + } + this.#batchBuffers = []; + } + + public setPollingMode(spinMode: number): void { + // Browser delivery is direct and non-blocking, but the core still tracks + // the pure-spin hint, so forward it to keep parity with the native path. + abi.setPollingMode(this.#bus, spinMode); + } + + public setNumaNode(_nodeId: number): void { + // WASM memory is page-local and cannot be NUMA-bound from browser JS. + } + + public getState(): number { + return abi.getState(this.#bus); + } +} + +/** + * Creates a page-local ring through the C core and returns the raw bus pointer. + * + * @throws {TachyonError} If the core rejects the capacity or allocation fails. + */ +function listenBus(socketPath: string, capacity: number): number { + core.setValue(OUT_BUS, 0, 'i32'); + const rc = abi.busListen(socketPath, capacity, OUT_BUS); + if (rc !== TACHYON_SUCCESS) { + throw new TachyonError(`Bus.listen: the core rejected the request (error ${rc}).`, mapError(rc)); + } + + const busPtr = core.getValue(OUT_BUS, 'i32'); + // tachyon_bus_get_shm_ptr exposes the arena base; a null base means the + // allocation never mapped, so refuse to hand back an unusable bus. + if (busPtr === 0 || abi.getShmPtr(busPtr) === 0) { + throw new TachyonError('Bus.listen: the core returned an unmapped bus.', ErrorCode.Mem); + } + return busPtr; +} + +// GC safety net: if a Bus is dropped without close(), free the underlying core +// bus. The held value is the handle (which never references the Bus), and the +// unregister token is also the handle, so there is no strong cycle back to the +// Bus instance and the registry can never pin it in memory. +const busRegistry = new FinalizationRegistry((handle) => { + handle.close(); +}); + +/** + * Browser implementation of the Tachyon SPSC bus. + * + * Bundlers resolve `@tachyon-ipc/core` to this entry through the package + * `browser` export condition. The constructor shape matches Node: + * `Bus.listen(path, capacity)` creates a page-local ring and + * `Bus.connect(path)` attaches to it. Both share one `tachyon_bus_t`, so the + * single fuzzed C++ ring is the only engine in play. + */ +export class Bus extends BusBase { + readonly #handle: BrowserBusHandle; + + private constructor(path: string, endpoint: BrowserEndpoint) { + const handle = new BrowserBusHandle(path, endpoint); + super(handle, { + defaultSpinThreshold: 0, + retryNullRecv: false, + copyData: (data) => new Uint8Array(data), + }); + this.#handle = handle; + busRegistry.register(this, handle, handle); + } + + public override close(): void { + busRegistry.unregister(this.#handle); + super.close(); + } + + public static listen(socketPath: string, capacity: number): Bus { + if (!Number.isInteger(capacity) || capacity <= 0) { + throw new TachyonError('Bus.listen: capacity must be a positive integer.', ErrorCode.InvalidSz); + } + if (capacity > MAX_CAPACITY) { + throw new TachyonError( + `Bus.listen: capacity ${capacity} exceeds the 2GB limit for wasm32 builds.`, + ErrorCode.InvalidSz, + ); + } + if (endpoints.has(socketPath)) { + throw new Error(`Bus.listen: browser endpoint already exists for ${socketPath}`); + } + + const busPtr = listenBus(socketPath, capacity); + const endpoint: BrowserEndpoint = { busPtr, refs: 1 }; + endpoints.set(socketPath, endpoint); + return new Bus(socketPath, endpoint); + } + + public static connect(socketPath: string): Bus { + const endpoint = endpoints.get(socketPath); + if (endpoint === undefined) { + throw new Error(`Bus.connect: no browser endpoint is listening at ${socketPath}`); + } + + endpoint.refs += 1; + return new Bus(socketPath, endpoint); + } +} + +export { + TachyonError, + AbiMismatchError, + PeerDeadError, + ErrorCode, + isAbiMismatch, + isFull, + isTachyonError, + isPeerDead, +} from './error.ts'; +export type { ErrorCode as ErrorCodeType } from './error.ts'; +export { RxBatch } from './batch.ts'; +export type { RxMessage } from './batch.ts'; +export { TxGuard, RxGuard } from './guards.ts'; +export type { TxSlot, RxSlot } from './guards.ts'; +export { makeTypeId, msgType, routeId } from './type_id.ts'; diff --git a/bindings/node/src/ts/bus.ts b/bindings/node/src/ts/bus.ts index c967899f..0b28d46d 100644 --- a/bindings/node/src/ts/bus.ts +++ b/bindings/node/src/ts/bus.ts @@ -1,11 +1,9 @@ import { createRequire } from 'node:module'; import { isMainThread } from 'node:worker_threads'; -import type { BatchController, RxMessage } from './batch.ts'; -import { RxBatch } from './batch.ts'; -import { AbiMismatchError, ErrorCode, PeerDeadError, isTachyonError } from './error.ts'; -import type { RxController, RxSlot, TxController } from './guards.ts'; -import { RxGuard, TxGuard } from './guards.ts'; +import { AbiMismatchError, ErrorCode, isTachyonError } from './error.ts'; +import type { BusHandle, RawRx } from './bus_core.ts'; +import { BusBase } from './bus_core.ts'; const _require = createRequire(import.meta.url); @@ -85,6 +83,79 @@ function loadNative(): NativeModule { const native = loadNative(); +class NativeBusHandle implements BusHandle { + #handle: NativeBinding; + + public constructor(handle: NativeBinding) { + this.#handle = handle; + } + + public close(): void { + this.#handle.close(); + } + + public send(data: Buffer | Uint8Array, typeId?: number): void { + this.#handle.send(data, typeId); + } + + public acquireTx(maxSize: number): Buffer { + return this.#handle.acquireTx(maxSize); + } + + public commitTx(actualSize: number, typeId: number): void { + this.#handle.commitTx(actualSize, typeId); + } + + public commitTxUnflushed(actualSize: number, typeId: number): void { + this.#handle.commitTxUnflushed(actualSize, typeId); + } + + public rollbackTx(): void { + this.#handle.rollbackTx(); + } + + public flush(): void { + this.#handle.flush(); + } + + public acquireRx(spinThreshold?: number): RawRx | null { + return this.#handle.acquireRxBlocking(spinThreshold); + } + + public drainBatch(maxMsgs: number, spinThreshold?: number): { data: Buffer; typeId: number; size: number }[] { + return this.#handle.drainBatch(maxMsgs, spinThreshold); + } + + public commitRx(): void { + this.#handle.commitRx(); + } + + public commitBatch(): void { + this.#handle.commitBatch(); + } + + public setPollingMode(spinMode: number): void { + this.#handle.setPollingMode(spinMode); + } + + public setNumaNode(nodeId: number): void { + this.#handle.setNumaNode(nodeId); + } + + public getState(): number { + return this.#handle.getState(); + } + + public stats(): BusStats { + return this.#handle.stats(); + } + + /** Exposes the raw native binding for cross-binding wiring (e.g. StarBus). */ + public get native(): NativeBinding { + return this.#handle; + } +} + function warnMainThread(method: string): void { if (isMainThread) { console.warn( @@ -115,12 +186,36 @@ function warnMainThread(method: string): void { * bus.send(Buffer.from('hello'), 1); * ``` */ -export class Bus implements Disposable { - #handle: NativeBinding; - #closed = false; +export class Bus extends BusBase { + #native: NativeBinding; private constructor(handle: NativeBinding) { - this.#handle = handle; + super(new NativeBusHandle(handle), { + defaultSpinThreshold: 10_000, + retryNullRecv: true, + copyData: (data) => Buffer.from(data), + }); + this.#native = handle; + } + + /** Returns a read-only snapshot of the bus state. */ + public stats(): BusStats { + return this.#native.stats(); + } + + /** @internal Raw native binding, consumed by {@link StarBus.create}. */ + public get _handle(): object { + return this.#native; + } + + /** + * Blocks until the next message arrives, then copies and returns it. + * + * The native path retries through EINTR and parks on a futex, so it always + * yields a message; unlike the browser transport it never returns `null`. + */ + public override recv(spinThreshold?: number): { data: Buffer; typeId: number } { + return super.recv(spinThreshold) as { data: Buffer; typeId: number }; } /** @@ -149,149 +244,4 @@ export class Bus implements Disposable { throw err; } } - - /** - * Signals that the consumer will never sleep. The producer skips the seq_cst fence - * and consumer_sleeping check on every flush. Use only on a dedicated SCHED_FIFO thread. - * Call immediately after listen/connect, before the first message. - */ - public setPollingMode(spinMode: 0 | 1): void { - this.#assertOpen(); - this.#handle.setPollingMode(spinMode); - } - - /** - * Binds the SHM pages to a specific NUMA node (MPOL_PREFERRED + MPOL_MF_MOVE). - * No-op on non-Linux platforms. Call immediately after listen/connect. - */ - public setNumaNode(nodeId: number): void { - this.#assertOpen(); - this.#handle.setNumaNode(nodeId); - } - - /** Publishes all pending unflushed TX messages to the consumer. */ - public flush(): void { - this.#assertOpen(); - this.#handle.flush(); - } - - /** Copies `data` into the ring buffer, commits, and flushes. */ - public send(data: Buffer | Uint8Array, typeId = 0): void { - this.#assertOpen(); - this.#handle.send(data, typeId); - } - - /** - * Blocks until a message is available, copies the payload, and returns it. - * Retries transparently on EINTR. - * - * @throws {PeerDeadError} If the bus has transitioned to TACHYON_STATE_FATAL_ERROR. - */ - public recv(spinThreshold = 10_000): { data: Buffer; typeId: number } { - this.#assertOpen(); - for (;;) { - if (this.#handle.getState() === 4) throw new PeerDeadError(); - const result = this.#handle.acquireRxBlocking(spinThreshold); - if (result === null) continue; // EINTR - retry - const copy = Buffer.from(result.data); - this.#handle.commitRx(); - return { data: copy, typeId: result.typeId }; - } - } - - /** - * Acquires an exclusive TX slot of `maxSize` bytes. - * Write into the slot via {@link TxGuard.bytes}, then commit or rollback. - * - * @throws {TachyonError} code `ERR_TACHYON_FULL` if the ring buffer is full. - */ - public acquireTx(maxSize: number): TxGuard { - this.#assertOpen(); - const buf = this.#handle.acquireTx(maxSize); - const ctrl: TxController = { - commitTx: (s, t) => { - this.#handle.commitTx(s, t); - }, - commitTxUnflushed: (s, t) => { - this.#handle.commitTxUnflushed(s, t); - }, - rollbackTx: () => { - this.#handle.rollbackTx(); - }, - }; - return new TxGuard(ctrl, buf); - } - - /** - * Blocks until a message is available and returns a zero-copy read lease. - * Returns `null` on EINTR caller decides whether to retry. - * - * @throws {PeerDeadError} If the bus has transitioned to TACHYON_STATE_FATAL_ERROR. - */ - public acquireRx(spinThreshold = 10_000): RxGuard | null { - this.#assertOpen(); - if (this.#handle.getState() === 4) throw new PeerDeadError(); - const result = this.#handle.acquireRxBlocking(spinThreshold); - if (result === null) return null; - const ctrl: RxController = { - commitRx: () => { - this.#handle.commitRx(); - }, - getState: () => this.#handle.getState(), - }; - return new RxGuard(ctrl, result.data, result.typeId, result.actualSize); - } - - /** - * Blocks until at least one message is available, then drains up to `maxMsgs` - * in a single native call. One native crossing amortizes per-message FFI cost. - * - * @throws {PeerDeadError} If the bus has transitioned to TACHYON_STATE_FATAL_ERROR. - */ - public drainBatch(maxMsgs: number, spinThreshold = 10_000): RxBatch { - this.#assertOpen(); - if (this.#handle.getState() === 4) throw new PeerDeadError(); - const raw = this.#handle.drainBatch(maxMsgs, spinThreshold); - const messages: RxMessage[] = raw.map((m) => ({ - data: m.data as unknown as RxSlot, - typeId: m.typeId, - size: m.size, - })); - const ctrl: BatchController = { - commitBatch: () => { - this.#handle.commitBatch(); - }, - getState: () => this.#handle.getState(), - }; - return new RxBatch(ctrl, messages); - } - - /** - * Returns a read-only snapshot of the bus state. - */ - public stats(): BusStats { - this.#assertOpen(); - return this.#handle.stats(); - } - - /** Closes the bus and unmaps shared memory. Safe to call multiple times. */ - public close(): void { - if (this.#closed) return; - this.#closed = true; - this.#handle.close(); - } - - /** Called automatically by the `using` keyword. */ - public [Symbol.dispose](): void { - this.close(); - } - - /** @internal */ - public get _handle(): object { - return this.#handle; - } - - #assertOpen(): void { - if (this.#closed) throw new Error('Bus: this bus has been closed.'); - } } diff --git a/bindings/node/src/ts/bus_core.ts b/bindings/node/src/ts/bus_core.ts new file mode 100644 index 00000000..6ca518b5 --- /dev/null +++ b/bindings/node/src/ts/bus_core.ts @@ -0,0 +1,233 @@ +import type { BatchController, RxMessage } from './batch.ts'; +import { RxBatch } from './batch.ts'; +import { PeerDeadError } from './error.ts'; +import type { RxController, TxController } from './guards.ts'; +import { RxGuard, TxGuard } from './guards.ts'; + +const TACHYON_STATE_FATAL_ERROR = 4; + +export interface RawRx { + readonly data: T; + readonly typeId: number; + readonly actualSize: number; +} + +export interface RawBatchMessage { + readonly data: T; + readonly typeId: number; + readonly size: number; +} + +export interface BusHandle { + close(): void; + + send(data: Buffer | Uint8Array, typeId?: number): void; + + acquireTx(maxSize: number): T; + + commitTx(actualSize: number, typeId: number): void; + + commitTxUnflushed(actualSize: number, typeId: number): void; + + rollbackTx(): void; + + flush(): void; + + acquireRx(spinThreshold?: number): RawRx | null; + + drainBatch?(maxMsgs: number, spinThreshold?: number): RawBatchMessage[]; + + commitRx(): void; + + commitBatch?(): void; + + setPollingMode(spinMode: number): void; + + setNumaNode(nodeId: number): void; + + getState(): number; +} + +interface BusBaseOptions { + readonly defaultSpinThreshold: number; + readonly retryNullRecv: boolean; + readonly copyData: (data: T) => T; +} + +/** + * Shared JS surface for the native Node addon and the browser WASM transport. + * Platform entrypoints only adapt their handle shape; guard lifecycle, recv + * copying, batching, close semantics, and API compatibility stay in one place. + * + * `T` is the platform byte-buffer type: `Buffer` for native Node, `Uint8Array` + * for browser WASM. It is threaded through the guards so a browser slot is never + * surfaced as a `Buffer`. + */ +export abstract class BusBase implements Disposable { + #handle: BusHandle; + #closed = false; + #options: BusBaseOptions; + + protected constructor(handle: BusHandle, options: BusBaseOptions) { + this.#handle = handle; + this.#options = options; + } + + /** + * Signals that the consumer will never sleep. Native Node can use this to + * skip the seq_cst fence and consumer_sleeping check on flush; browser WASM + * has no futex sleep path, so the browser handle accepts this as a no-op. + */ + public setPollingMode(spinMode: 0 | 1): void { + this.#assertOpen(); + this.#handle.setPollingMode(spinMode); + } + + /** + * Binds native SHM pages to a specific NUMA node where supported. Browser + * WASM memory is page-local and cannot be NUMA-bound, so it is a no-op there. + */ + public setNumaNode(nodeId: number): void { + this.#assertOpen(); + this.#handle.setNumaNode(nodeId); + } + + /** Publishes all pending unflushed TX messages to the consumer. */ + public flush(): void { + this.#assertOpen(); + this.#handle.flush(); + } + + /** Copies `data` into the ring buffer, commits, and flushes. */ + public send(data: Buffer | Uint8Array, typeId = 0): void { + this.#assertOpen(); + this.#handle.send(data, typeId); + } + + /** + * Copies the next payload and returns it with its type discriminator. Native + * Node blocks and retries EINTR through the native handle; browser WASM is + * non-blocking and returns `null` when the ring is empty (an empty ring is a + * normal poll outcome, not an error). + * + * @returns The next message, or `null` when no message is available (browser only). + * @throws {PeerDeadError} If the bus has transitioned to fatal error state. + */ + public recv(spinThreshold = this.#options.defaultSpinThreshold): { data: T; typeId: number } | null { + this.#assertOpen(); + for (;;) { + if (this.#isFatal()) throw new PeerDeadError(); + const result = this.#handle.acquireRx(spinThreshold); + if (result === null) { + if (this.#options.retryNullRecv) continue; + return null; + } + + const copy = this.#options.copyData(result.data); + this.#handle.commitRx(); + return { data: copy, typeId: result.typeId }; + } + } + + /** + * Acquires an exclusive TX slot of `maxSize` bytes. + * Write into the slot via {@link TxGuard.bytes}, then commit or rollback. + */ + public acquireTx(maxSize: number): TxGuard { + this.#assertOpen(); + const buf = this.#handle.acquireTx(maxSize); + const ctrl: TxController = { + commitTx: (s, t) => { + this.#handle.commitTx(s, t); + }, + commitTxUnflushed: (s, t) => { + this.#handle.commitTxUnflushed(s, t); + }, + rollbackTx: () => { + this.#handle.rollbackTx(); + }, + }; + return new TxGuard(ctrl, buf); + } + + /** + * Acquires a zero-copy read lease. Node may block according to + * `spinThreshold`; browser WASM checks once and returns `null` when empty. + * + * @throws {PeerDeadError} If the bus has transitioned to fatal error state. + */ + public acquireRx(spinThreshold = this.#options.defaultSpinThreshold): RxGuard | null { + this.#assertOpen(); + if (this.#isFatal()) throw new PeerDeadError(); + const result = this.#handle.acquireRx(spinThreshold); + if (result === null) return null; + const ctrl: RxController = { + commitRx: () => { + this.#handle.commitRx(); + }, + getState: () => this.#handle.getState(), + }; + return new RxGuard(ctrl, result.data, result.typeId, result.actualSize); + } + + /** + * Drains up to `maxMsgs` messages. Native Node uses one addon call to + * amortize FFI cost; browser WASM falls back to the same guard lifecycle + * with copied batch entries so all slots are released before returning. + */ + public drainBatch(maxMsgs: number, spinThreshold = this.#options.defaultSpinThreshold): RxBatch { + this.#assertOpen(); + if (this.#isFatal()) throw new PeerDeadError(); + + const raw = + this.#handle.drainBatch?.(maxMsgs, spinThreshold) ?? this.#drainBatchByAcquireRx(maxMsgs, spinThreshold); + const messages: RxMessage[] = raw.map((m) => ({ + data: m.data as RxMessage['data'], + typeId: m.typeId, + size: m.size, + })); + const ctrl: BatchController = { + commitBatch: () => { + this.#handle.commitBatch?.(); + }, + getState: () => this.#handle.getState(), + }; + return new RxBatch(ctrl, messages); + } + + /** Closes the bus and releases the underlying platform handle. Safe to call multiple times. */ + public close(): void { + if (this.#closed) return; + this.#closed = true; + this.#handle.close(); + } + + /** Called automatically by the `using` keyword. */ + public [Symbol.dispose](): void { + this.close(); + } + + #drainBatchByAcquireRx(maxMsgs: number, spinThreshold: number): RawBatchMessage[] { + const messages: RawBatchMessage[] = []; + for (let i = 0; i < maxMsgs; i += 1) { + if (this.#isFatal()) throw new PeerDeadError(); + const result = this.#handle.acquireRx(spinThreshold); + if (result === null) break; + messages.push({ + data: this.#options.copyData(result.data), + typeId: result.typeId, + size: result.actualSize, + }); + this.#handle.commitRx(); + } + return messages; + } + + #assertOpen(): void { + if (this.#closed) throw new Error('Bus: this bus has been closed.'); + } + + #isFatal(): boolean { + return this.#handle.getState() === TACHYON_STATE_FATAL_ERROR; + } +} diff --git a/bindings/node/src/ts/guards.ts b/bindings/node/src/ts/guards.ts index 05b77111..93746d8a 100644 --- a/bindings/node/src/ts/guards.ts +++ b/bindings/node/src/ts/guards.ts @@ -1,16 +1,22 @@ import { PeerDeadError } from './error.ts'; -// Branded slot types, nominal subtypes of Buffer. +// Branded slot types, nominal subtypes of the underlying byte buffer. // Brand symbols are not accessible outside this module, so only // TxGuard and RxGuard can produce these types. declare const txSlotBrand: unique symbol; declare const rxSlotBrand: unique symbol; -/** Zero-copy write window into the ring buffer. Valid only until commit or rollback. */ -export type TxSlot = Buffer & { readonly [txSlotBrand]: true }; +/** + * Zero-copy write window into the ring buffer. Valid only until commit or rollback. + * + * The backing type is platform-specific: native Node exposes a `Buffer`, while the + * browser WASM transport exposes a plain `Uint8Array`. The window is never cast across + * those shapes, so browser code can never call a Node-only `Buffer` method on it. + */ +export type TxSlot = S & { readonly [txSlotBrand]: true }; /** Zero-copy read window into the ring buffer. Valid only until commit. */ -export type RxSlot = Buffer & { readonly [rxSlotBrand]: true }; +export type RxSlot = S & { readonly [rxSlotBrand]: true }; /** @internal */ export interface TxController { @@ -42,15 +48,15 @@ export interface RxController { * tx.commit(5, 1); * ``` */ -export class TxGuard { +export class TxGuard { #ctrl: TxController; - #buffer: TxSlot | null; + #buffer: TxSlot | null; #done = false; /** @internal */ - public constructor(ctrl: TxController, buffer: Buffer) { + public constructor(ctrl: TxController, buffer: S) { this.#ctrl = ctrl; - this.#buffer = buffer as TxSlot; + this.#buffer = buffer as TxSlot; } /** @@ -60,7 +66,7 @@ export class TxGuard { * * @throws {Error} If the slot has already been finalized. */ - public bytes(): TxSlot { + public bytes(): TxSlot { if (this.#done || this.#buffer === null) { throw new Error('TxGuard: slot has already been committed or rolled back.'); } @@ -127,9 +133,9 @@ export class TxGuard { * process(rx.data()); * ``` */ -export class RxGuard { +export class RxGuard { #ctrl: RxController; - #buffer: RxSlot | null; + #buffer: RxSlot | null; #done = false; /** Message type discriminator set by the producer. */ @@ -139,9 +145,9 @@ export class RxGuard { public readonly actualSize: number; /** @internal */ - public constructor(ctrl: RxController, buffer: Buffer, typeId: number, actualSize: number) { + public constructor(ctrl: RxController, buffer: S, typeId: number, actualSize: number) { this.#ctrl = ctrl; - this.#buffer = buffer as RxSlot; + this.#buffer = buffer as RxSlot; this.typeId = typeId; this.actualSize = actualSize; } @@ -153,7 +159,7 @@ export class RxGuard { * @throws {Error} If the slot has already been committed. * @throws {PeerDeadError} If the bus has transitioned to TACHYON_STATE_FATAL_ERROR. */ - public data(): RxSlot { + public data(): RxSlot { this.#assertOpen(); if (this.#ctrl.getState() === 4 /* TACHYON_STATE_FATAL_ERROR */) throw new PeerDeadError(); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion diff --git a/bindings/node/src/ts/wasm/tachyon.d.ts b/bindings/node/src/ts/wasm/tachyon.d.ts new file mode 100644 index 00000000..be05f230 --- /dev/null +++ b/bindings/node/src/ts/wasm/tachyon.d.ts @@ -0,0 +1,38 @@ +// Type surface for the Emscripten-generated `tachyon.js` module. +// +// The implementation is produced by Emscripten from the C++ core +// (`npm run build:wasm`). It is MODULARIZE=1 + EXPORT_ES6=1, so the default +// export is an async factory that resolves to the runtime module. Only the +// runtime methods and C ABI accessors the browser bridge needs are declared. + +/** Argument / return marshalling types accepted by Emscripten `cwrap`. */ +export type CwrapType = 'number' | 'string' | 'boolean' | 'array' | null; + +/** A wrapped C function. All Tachyon ABI calls marshal to/from `number` (pointers, sizes, error codes). */ +export type CwrapFn = (...args: Array) => number; + +export interface TachyonCoreModule { + /** Wraps an exported C function for calling from JS. */ + cwrap(name: string, returnType: CwrapType, argTypes: CwrapType[]): CwrapFn; + + /** Reads a value of the given LLVM type (e.g. `'i32'`) from the heap. */ + getValue(ptr: number, type: string): number; + + /** Writes a value of the given LLVM type to the heap. */ + setValue(ptr: number, value: number, type: string): void; + + /** Allocates `size` bytes on the WASM heap and returns the offset. */ + _malloc(size: number): number; + + /** Frees a pointer previously returned by {@link _malloc}. */ + _free(ptr: number): void; + + /** The WASM linear memory as a byte view. Replaced on memory growth. */ + readonly HEAPU8: Uint8Array; + + /** The WASM linear memory as a 32-bit view. Replaced on memory growth. */ + readonly HEAPU32: Uint32Array; +} + +/** Instantiates the Tachyon WASM core. Resolves once the module is ready. */ +export default function TachyonCore(moduleArg?: Record): Promise; diff --git a/bindings/node/src/ts/wasm/tachyon.js b/bindings/node/src/ts/wasm/tachyon.js new file mode 100644 index 00000000..b202de4a --- /dev/null +++ b/bindings/node/src/ts/wasm/tachyon.js @@ -0,0 +1,853 @@ +/* eslint-disable */ +// Generated by Emscripten. Do not edit by hand. +// Regenerate with: npm run build:wasm + +// This code implements the `-sMODULARIZE` settings by taking the generated +// JS program code (INNER_JS_CODE) and wrapping it in a factory function. + +// When targeting node and ES6 we use `await import ..` in the generated code +// so the outer function needs to be marked as async. +async function TachyonCore(moduleArg = {}) { + var moduleRtn; + +// include: shell.js +// include: minimum_runtime_check.js +// end include: minimum_runtime_check.js +// The Module object: Our interface to the outside world. We import +// and export values on it. There are various ways Module can be used: +// 1. Not defined. We create it here +// 2. A function parameter, function(moduleArg) => Promise +// 3. pre-run appended it, var Module = {}; ..generated code.. +// 4. External script tag defines var Module. +// We need to check if Module already exists (e.g. case 3 above). +// Substitution will be replaced with actual code on later stage of the build, +// this way Closure Compiler will not mangle it (e.g. case 4. above). +// Note that if you want to run closure, and also to use Module +// after the generated code, you will need to define var Module = {}; +// before the code. Then that object will be used in the code, and you +// can continue to use Module afterwards as well. +var Module = moduleArg; + +// Determine the runtime environment we are in. You can customize this by +// setting the ENVIRONMENT setting at compile time (see settings.js). +// Attempt to auto-detect the environment +var ENVIRONMENT_IS_WEB = !!globalThis.window; + +var ENVIRONMENT_IS_WORKER = !!globalThis.WorkerGlobalScope; + +// N.b. Electron.js environment is simultaneously a NODE-environment, but +// also a web environment. +var ENVIRONMENT_IS_NODE = globalThis.process?.versions?.node && globalThis.process?.type != "renderer"; + +var _scriptName = import.meta.url; + +// `/` should be present at the end if `scriptDirectory` is not empty +var scriptDirectory = ""; + +function locateFile(path) { + return scriptDirectory + path; +} + +// Hooks that are implemented differently in different runtime environments. +var readAsync, readBinary; + +// Note that this includes Node.js workers when relevant (pthreads is enabled). +// Node.js workers are detected as a combination of ENVIRONMENT_IS_WORKER and +// ENVIRONMENT_IS_NODE. +if (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER) { + try { + scriptDirectory = new URL(".", _scriptName).href; + } catch {} + { + // include: web_or_worker_shell_read.js + if (ENVIRONMENT_IS_WORKER) { + readBinary = url => { + var xhr = new XMLHttpRequest; + xhr.open("GET", url, false); + xhr.responseType = "arraybuffer"; + xhr.send(null); + return new Uint8Array(/** @type{!ArrayBuffer} */ (xhr.response)); + }; + } + readAsync = async url => { + var response = await fetch(url, { + credentials: "same-origin" + }); + if (response.ok) { + return response.arrayBuffer(); + } + throw new Error(response.status + " : " + response.url); + }; + } +} else {} + +var out = console.log.bind(console); + +var err = console.error.bind(console); + +// end include: shell.js +// include: preamble.js +// === Preamble library stuff === +// Documentation for the public APIs defined in this file must be updated in: +// site/source/docs/api_reference/preamble.js.rst +// A prebuilt local version of the documentation is available at: +// site/build/text/docs/api_reference/preamble.js.txt +// You can also build docs locally as HTML or other formats in site/ +// An online HTML version (which may be of a different version of Emscripten) +// is up at http://kripken.github.io/emscripten-site/docs/api_reference/preamble.js.html +var wasmBinary; + +// Wasm globals +//======================================== +// Runtime essentials +//======================================== +// whether we are quitting the application. no code should run after this. +// set in exit() and abort() +var ABORT = false; + +// include: runtime_common.js +// include: runtime_stack_check.js +// end include: runtime_stack_check.js +// include: runtime_exceptions.js +// Base Emscripten EH error class +class EmscriptenEH {} + +class EmscriptenSjLj extends EmscriptenEH {} + +// end include: runtime_exceptions.js +// include: runtime_debug.js +// end include: runtime_debug.js +var readyPromiseResolve, readyPromiseReject; + +// Memory management +var runtimeInitialized = false; + +function updateMemoryViews() { + var b = wasmMemory.buffer; + HEAP8 = new Int8Array(b); + HEAP16 = new Int16Array(b); + Module["HEAPU8"] = HEAPU8 = new Uint8Array(b); + HEAPU16 = new Uint16Array(b); + HEAP32 = new Int32Array(b); + Module["HEAPU32"] = HEAPU32 = new Uint32Array(b); + HEAPF32 = new Float32Array(b); + HEAPF64 = new Float64Array(b); + HEAP64 = new BigInt64Array(b); + HEAPU64 = new BigUint64Array(b); +} + +// include: memoryprofiler.js +// end include: memoryprofiler.js +// end include: runtime_common.js +function preRun() {} + +function initRuntime() { + runtimeInitialized = true; + // No ATINITS hooks + wasmExports["f"](); +} + +function postRun() {} + +/** + * @param {string|number=} what + */ function abort(what) { + what = `Aborted(${what})`; + // TODO(sbc): Should we remove printing and leave it up to whoever + // catches the exception? + err(what); + ABORT = true; + what += ". Build with -sASSERTIONS for more info."; + // Use a wasm runtime error, because a JS error might be seen as a foreign + // exception, which means we'd run destructors on it. We need the error to + // simply make the program stop. + // FIXME This approach does not work in Wasm EH because it currently does not assume + // all RuntimeErrors are from traps; it decides whether a RuntimeError is from + // a trap or not based on a hidden field within the object. So at the moment + // we don't have a way of throwing a wasm trap from JS. TODO Make a JS API that + // allows this in the wasm spec. + // Suppress closure compiler warning here. Closure compiler's builtin extern + // definition for WebAssembly.RuntimeError claims it takes no arguments even + // though it can. + // TODO(https://github.com/google/closure-compiler/pull/3913): Remove if/when upstream closure gets fixed. + /** @suppress {checkTypes} */ var e = new WebAssembly.RuntimeError(what); + readyPromiseReject?.(e); + // Throw the error whether or not MODULARIZE is set because abort is used + // in code paths apart from instantiation where an exception is expected + // to be thrown when abort is called. + throw e; +} + +var wasmBinaryFile; + +function findWasmBinary() { + if (Module["locateFile"]) { + return locateFile("tachyon.wasm"); + } + // Use bundler-friendly `new URL(..., import.meta.url)` pattern; works in browsers too. + return new URL("tachyon.wasm", import.meta.url).href; +} + +function getBinarySync(file) { + if (readBinary) { + return readBinary(file); + } + // Throwing a plain string here, even though it not normally advisable since + // this gets turning into an `abort` in instantiateArrayBuffer. + throw "both async and sync fetching of the wasm failed"; +} + +async function getWasmBinary(binaryFile) { + // If we don't have the binary yet, load it asynchronously using readAsync. + if (!wasmBinary) { + // Fetch the binary using readAsync + try { + var response = await readAsync(binaryFile); + return new Uint8Array(response); + } catch {} + } + // Otherwise, getBinarySync should be able to get it synchronously + return getBinarySync(binaryFile); +} + +async function instantiateArrayBuffer(binaryFile, imports) { + try { + var binary = await getWasmBinary(binaryFile); + var instance = await WebAssembly.instantiate(binary, imports); + return instance; + } catch (reason) { + err(`failed to asynchronously prepare wasm: ${reason}`); + abort(reason); + } +} + +async function instantiateAsync(binary, binaryFile, imports) { + if (!binary) { + try { + var response = fetch(binaryFile, { + credentials: "same-origin" + }); + var instantiationResult = await WebAssembly.instantiateStreaming(response, imports); + return instantiationResult; + } catch (reason) { + // We expect the most common failure cause to be a bad MIME type for the binary, + // in which case falling back to ArrayBuffer instantiation should work. + err(`wasm streaming compile failed: ${reason}`); + err("falling back to ArrayBuffer instantiation"); + } + } + return instantiateArrayBuffer(binaryFile, imports); +} + +function getWasmImports() { + // prepare imports + var imports = { + "a": wasmImports + }; + return imports; +} + +// Create the wasm instance. +// Receives the wasm imports, returns the exports. +async function createWasm() { + // Load the wasm module and create an instance of using native support in the JS engine. + // handle a generated wasm instance, receiving its exports and + // performing other necessary setup + /** @param {WebAssembly.Module=} module*/ function receiveInstance(instance, module) { + wasmExports = instance.exports; + assignWasmExports(wasmExports); + updateMemoryViews(); + return wasmExports; + } + // Prefer streaming instantiation if available. + function receiveInstantiationResult(result) { + // 'result' is a ResultObject object which has both the module and instance. + // receiveInstance() will swap in the exports (to Module.asm) so they can be called + // TODO: Due to Closure regression https://github.com/google/closure-compiler/issues/3193, the above line no longer optimizes out down to the following line. + // When the regression is fixed, can restore the above PTHREADS-enabled path. + return receiveInstance(result["instance"]); + } + var info = getWasmImports(); + wasmBinaryFile ??= findWasmBinary(); + var result = await instantiateAsync(wasmBinary, wasmBinaryFile, info); + var exports = receiveInstantiationResult(result); + return exports; +} + +// end include: preamble.js +// Begin JS library code +class ExitStatus { + name="ExitStatus"; + constructor(status) { + this.message = `Program terminated with exit(${status})`; + this.status = status; + } +} + +/** @type {!Int16Array} */ var HEAP16; + +/** @type {!Int32Array} */ var HEAP32; + +/** not-@type {!BigInt64Array} */ var HEAP64; + +/** @type {!Int8Array} */ var HEAP8; + +/** @type {!Float32Array} */ var HEAPF32; + +/** @type {!Float64Array} */ var HEAPF64; + +/** @type {!Uint16Array} */ var HEAPU16; + +/** @type {!Uint32Array} */ var HEAPU32; + +/** not-@type {!BigUint64Array} */ var HEAPU64; + +/** @type {!Uint8Array} */ var HEAPU8; + +/** + * @param {number} ptr + * @param {string} type + */ function getValue(ptr, type = "i8") { + if (type.endsWith("*")) type = "*"; + switch (type) { + case "i1": + return HEAP8[ptr]; + + case "i8": + return HEAP8[ptr]; + + case "i16": + return HEAP16[((ptr) >> 1)]; + + case "i32": + return HEAP32[((ptr) >> 2)]; + + case "i64": + return HEAP64[((ptr) >> 3)]; + + case "float": + return HEAPF32[((ptr) >> 2)]; + + case "double": + return HEAPF64[((ptr) >> 3)]; + + case "*": + return HEAPU32[((ptr) >> 2)]; + + default: + abort(`invalid type for getValue: ${type}`); + } +} + +/** + * @param {number} ptr + * @param {number} value + * @param {string} type + */ function setValue(ptr, value, type = "i8") { + if (type.endsWith("*")) type = "*"; + switch (type) { + case "i1": + HEAP8[ptr] = value; + break; + + case "i8": + HEAP8[ptr] = value; + break; + + case "i16": + HEAP16[((ptr) >> 1)] = value; + break; + + case "i32": + HEAP32[((ptr) >> 2)] = value; + break; + + case "i64": + HEAP64[((ptr) >> 3)] = BigInt(value); + break; + + case "float": + HEAPF32[((ptr) >> 2)] = value; + break; + + case "double": + HEAPF64[((ptr) >> 3)] = value; + break; + + case "*": + HEAPU32[((ptr) >> 2)] = value; + break; + + default: + abort(`invalid type for setValue: ${type}`); + } +} + +var stackRestore = val => __emscripten_stack_restore(val); + +var stackSave = () => _emscripten_stack_get_current(); + +var __abort_js = () => abort(""); + +var _emscripten_get_now = () => performance.now(); + +var _emscripten_date_now = () => Date.now(); + +var nowIsMonotonic = 1; + +var checkWasiClock = clock_id => clock_id >= 0 && clock_id <= 3; + +var INT53_MAX = 9007199254740992; + +var INT53_MIN = -9007199254740992; + +var bigintToI53Checked = num => (num < INT53_MIN || num > INT53_MAX) ? NaN : Number(num); + +function _clock_time_get(clk_id, ignored_precision, ptime) { + ignored_precision = bigintToI53Checked(ignored_precision); + if (!checkWasiClock(clk_id)) { + return 28; + } + var now; + // all wasi clocks but realtime are monotonic + if (clk_id === 0) { + now = _emscripten_date_now(); + } else if (nowIsMonotonic) { + now = _emscripten_get_now(); + } else { + return 52; + } + // "now" is in ms, and wasi times are in ns. + var nsec = Math.round(now * 1e3 * 1e3); + HEAP64[((ptime) >> 3)] = BigInt(nsec); + return 0; +} + +var getHeapMax = () => // Stay one Wasm page short of 4GB: while e.g. Chrome is able to allocate +// full 4GB Wasm memories, the size will wrap back to 0 bytes in Wasm side +// for any code that deals with heap sizes, which would require special +// casing all heap size related code to treat 0 specially. +2147483648; + +var alignMemory = (size, alignment) => Math.ceil(size / alignment) * alignment; + +var growMemory = size => { + var oldHeapSize = wasmMemory.buffer.byteLength; + var pages = ((size - oldHeapSize + 65535) / 65536) | 0; + try { + // round size grow request up to wasm page size (fixed 64KB per spec) + wasmMemory.grow(pages); + // .grow() takes a delta compared to the previous size + updateMemoryViews(); + return 1; + } catch (e) {} +}; + +var _emscripten_resize_heap = requestedSize => { + var oldSize = HEAPU8.length; + // With CAN_ADDRESS_2GB or MEMORY64, pointers are already unsigned. + requestedSize >>>= 0; + // With multithreaded builds, races can happen (another thread might increase the size + // in between), so return a failure, and let the caller retry. + // Memory resize rules: + // 1. Always increase heap size to at least the requested size, rounded up + // to next page multiple. + // 2a. If MEMORY_GROWTH_LINEAR_STEP == -1, excessively resize the heap + // geometrically: increase the heap size according to + // MEMORY_GROWTH_GEOMETRIC_STEP factor (default +20%), At most + // overreserve by MEMORY_GROWTH_GEOMETRIC_CAP bytes (default 96MB). + // 2b. If MEMORY_GROWTH_LINEAR_STEP != -1, excessively resize the heap + // linearly: increase the heap size by at least + // MEMORY_GROWTH_LINEAR_STEP bytes. + // 3. Max size for the heap is capped at 2048MB-WASM_PAGE_SIZE, or by + // MAXIMUM_MEMORY, or by ASAN limit, depending on which is smallest + // 4. If we were unable to allocate as much memory, it may be due to + // over-eager decision to excessively reserve due to (3) above. + // Hence if an allocation fails, cut down on the amount of excess + // growth, in an attempt to succeed to perform a smaller allocation. + // A limit is set for how much we can grow. We should not exceed that + // (the wasm binary specifies it, so if we tried, we'd fail anyhow). + var maxHeapSize = getHeapMax(); + if (requestedSize > maxHeapSize) { + return false; + } + // Loop through potential heap size increases. If we attempt a too eager + // reservation that fails, cut down on the attempted size and reserve a + // smaller bump instead. (max 3 times, chosen somewhat arbitrarily) + for (var cutDown = 1; cutDown <= 4; cutDown *= 2) { + var overGrownHeapSize = oldSize * (1 + .2 / cutDown); + // ensure geometric growth + // but limit overreserving (default to capping at +96MB overgrowth at most) + overGrownHeapSize = Math.min(overGrownHeapSize, requestedSize + 100663296); + var newSize = Math.min(maxHeapSize, alignMemory(Math.max(requestedSize, overGrownHeapSize), 65536)); + var replacement = growMemory(newSize); + if (replacement) { + return true; + } + } + return false; +}; + +var getCFunc = ident => { + var func = Module["_" + ident]; + // closure exported function + return func; +}; + +var writeArrayToMemory = (array, buffer) => { + HEAP8.set(array, buffer); +}; + +var lengthBytesUTF8 = str => { + var len = 0; + for (var i = 0; i < str.length; ++i) { + // Gotcha: charCodeAt returns a 16-bit word that is a UTF-16 encoded code + // unit, not a Unicode code point of the character! So decode + // UTF16->UTF32->UTF8. + // See http://unicode.org/faq/utf_bom.html#utf16-3 + var c = str.charCodeAt(i); + // possibly a lead surrogate + if (c <= 127) { + len++; + } else if (c <= 2047) { + len += 2; + } else if (c >= 55296 && c <= 57343) { + len += 4; + ++i; + } else { + len += 3; + } + } + return len; +}; + +var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { + // Parameter maxBytesToWrite is not optional. Negative values, 0, null, + // undefined and false each don't write out any bytes. + if (!(maxBytesToWrite > 0)) return 0; + var startIdx = outIdx; + var endIdx = outIdx + maxBytesToWrite - 1; + // -1 for string null terminator. + for (var i = 0; i < str.length; ++i) { + // For UTF8 byte structure, see http://en.wikipedia.org/wiki/UTF-8#Description + // and https://www.ietf.org/rfc/rfc2279.txt + // and https://tools.ietf.org/html/rfc3629 + var u = str.codePointAt(i); + if (u <= 127) { + if (outIdx >= endIdx) break; + heap[outIdx++] = u; + } else if (u <= 2047) { + if (outIdx + 1 >= endIdx) break; + heap[outIdx++] = 192 | (u >> 6); + heap[outIdx++] = 128 | (u & 63); + } else if (u <= 65535) { + if (outIdx + 2 >= endIdx) break; + heap[outIdx++] = 224 | (u >> 12); + heap[outIdx++] = 128 | ((u >> 6) & 63); + heap[outIdx++] = 128 | (u & 63); + } else { + if (outIdx + 3 >= endIdx) break; + heap[outIdx++] = 240 | (u >> 18); + heap[outIdx++] = 128 | ((u >> 12) & 63); + heap[outIdx++] = 128 | ((u >> 6) & 63); + heap[outIdx++] = 128 | (u & 63); + // Gotcha: if codePoint is over 0xFFFF, it is represented as a surrogate pair in UTF-16. + // We need to manually skip over the second code unit for correct iteration. + i++; + } + } + // Null-terminate the pointer to the buffer. + heap[outIdx] = 0; + return outIdx - startIdx; +}; + +var stringToUTF8 = (str, outPtr, maxBytesToWrite) => stringToUTF8Array(str, HEAPU8, outPtr, maxBytesToWrite); + +var stackAlloc = sz => __emscripten_stack_alloc(sz); + +var stringToUTF8OnStack = str => { + var size = lengthBytesUTF8(str) + 1; + var ret = stackAlloc(size); + stringToUTF8(str, ret, size); + return ret; +}; + +var UTF8Decoder = globalThis.TextDecoder && new TextDecoder; + +var findStringEnd = (heapOrArray, idx, maxBytesToRead, ignoreNul) => { + var maxIdx = idx + maxBytesToRead; + if (ignoreNul) return maxIdx; + // TextDecoder needs to know the byte length in advance, it doesn't stop on + // null terminator by itself. + // As a tiny code save trick, compare idx against maxIdx using a negation, + // so that maxBytesToRead=undefined/NaN means Infinity. + while (heapOrArray[idx] && !(idx >= maxIdx)) ++idx; + return idx; +}; + +/** + * Given a pointer 'idx' to a null-terminated UTF8-encoded string in the given + * array that contains uint8 values, returns a copy of that string as a + * Javascript String object. + * heapOrArray is either a regular array, or a JavaScript typed array view. + * @param {number=} idx + * @param {number=} maxBytesToRead + * @param {boolean=} ignoreNul - If true, the function will not stop on a NUL character. + * @return {string} + */ var UTF8ArrayToString = (heapOrArray, idx = 0, maxBytesToRead, ignoreNul) => { + var endPtr = findStringEnd(heapOrArray, idx, maxBytesToRead, ignoreNul); + // When using conditional TextDecoder, skip it for short strings as the overhead of the native call is not worth it. + if (endPtr - idx > 16 && heapOrArray.buffer && UTF8Decoder) { + return UTF8Decoder.decode(heapOrArray.subarray(idx, endPtr)); + } + var str = ""; + while (idx < endPtr) { + // For UTF8 byte structure, see: + // http://en.wikipedia.org/wiki/UTF-8#Description + // https://www.ietf.org/rfc/rfc2279.txt + // https://tools.ietf.org/html/rfc3629 + var u0 = heapOrArray[idx++]; + if (!(u0 & 128)) { + str += String.fromCharCode(u0); + continue; + } + var u1 = heapOrArray[idx++] & 63; + if ((u0 & 224) == 192) { + str += String.fromCharCode(((u0 & 31) << 6) | u1); + continue; + } + var u2 = heapOrArray[idx++] & 63; + if ((u0 & 240) == 224) { + u0 = ((u0 & 15) << 12) | (u1 << 6) | u2; + } else { + u0 = ((u0 & 7) << 18) | (u1 << 12) | (u2 << 6) | (heapOrArray[idx++] & 63); + } + if (u0 < 65536) { + str += String.fromCharCode(u0); + } else { + var ch = u0 - 65536; + str += String.fromCharCode(55296 | (ch >> 10), 56320 | (ch & 1023)); + } + } + return str; +}; + +/** + * Given a pointer 'ptr' to a null-terminated UTF8-encoded string in the + * emscripten HEAP, returns a copy of that string as a Javascript String object. + * + * @param {number} ptr + * @param {number=} maxBytesToRead - An optional length that specifies the + * maximum number of bytes to read. You can omit this parameter to scan the + * string until the first 0 byte. If maxBytesToRead is passed, and the string + * at [ptr, ptr+maxBytesToReadr[ contains a null byte in the middle, then the + * string will cut short at that byte index. + * @param {boolean=} ignoreNul - If true, the function will not stop on a NUL character. + * @return {string} + */ var UTF8ToString = (ptr, maxBytesToRead, ignoreNul) => ptr ? UTF8ArrayToString(HEAPU8, ptr, maxBytesToRead, ignoreNul) : ""; + +/** + * @param {string|null=} returnType + * @param {Array=} argTypes + * @param {Array=} args + * @param {Object=} opts + */ var ccall = (ident, returnType, argTypes, args, opts) => { + // For fast lookup of conversion functions + var toC = { + "string": str => { + var ret = 0; + if (str !== null && str !== undefined && str !== 0) { + // null string + ret = stringToUTF8OnStack(str); + } + return ret; + }, + "array": arr => { + var ret = stackAlloc(arr.length); + writeArrayToMemory(arr, ret); + return ret; + } + }; + function convertReturnValue(ret) { + if (returnType === "string") { + return UTF8ToString(ret); + } + if (returnType === "boolean") return Boolean(ret); + return ret; + } + var func = getCFunc(ident); + var cArgs = []; + var stack = 0; + if (args) { + for (var i = 0; i < args.length; i++) { + var converter = toC[argTypes[i]]; + if (converter) { + if (stack === 0) stack = stackSave(); + cArgs[i] = converter(args[i]); + } else { + cArgs[i] = args[i]; + } + } + } + var ret = func(...cArgs); + function onDone(ret) { + if (stack !== 0) stackRestore(stack); + return convertReturnValue(ret); + } + ret = onDone(ret); + return ret; +}; + +/** + * @param {string=} returnType + * @param {Array=} argTypes + * @param {Object=} opts + */ var cwrap = (ident, returnType, argTypes, opts) => { + // When the function takes numbers and returns a number, we can just return + // the original function + var numericArgs = !argTypes || argTypes.every(type => type === "number" || type === "boolean"); + var numericRet = returnType !== "string"; + if (numericRet && numericArgs && !opts) { + return getCFunc(ident); + } + return (...args) => ccall(ident, returnType, argTypes, args, opts); +}; + +// End JS library code +// include: postlibrary.js +// This file is included after the automatically-generated JS library code +// but before the wasm module is created. +{} + +// Begin runtime exports +Module["ccall"] = ccall; + +Module["cwrap"] = cwrap; + +Module["setValue"] = setValue; + +Module["getValue"] = getValue; + +Module["UTF8ToString"] = UTF8ToString; + +// End runtime exports +// Begin JS library exports +// End JS library exports +// end include: postlibrary.js +// Imports from the Wasm binary. +var _free, _tachyon_bus_destroy, _tachyon_bus_ref, _tachyon_bus_set_numa_node, _tachyon_memory_barrier_acquire, _tachyon_bus_listen, _tachyon_bus_connect, _tachyon_acquire_tx, _tachyon_commit_tx, _tachyon_rollback_tx, _tachyon_acquire_rx, _tachyon_acquire_rx_spin, _tachyon_acquire_rx_blocking, _tachyon_commit_rx, _tachyon_acquire_rx_batch, _tachyon_drain_batch, _tachyon_commit_rx_batch, _tachyon_bus_set_polling_mode, _tachyon_flush, _tachyon_get_state, _tachyon_bus_stats, _tachyon_bus_get_shm_ptr, _tachyon_rpc_listen, _tachyon_rpc_connect, _tachyon_rpc_destroy, _tachyon_rpc_call, _tachyon_rpc_wait, _tachyon_rpc_commit_rx, _tachyon_rpc_serve, _tachyon_rpc_commit_serve, _tachyon_rpc_reply, _tachyon_rpc_acquire_tx, _tachyon_rpc_commit_call, _tachyon_rpc_rollback_call, _tachyon_rpc_acquire_reply_tx, _tachyon_rpc_commit_reply, _tachyon_rpc_rollback_reply, _tachyon_rpc_set_polling_mode, _tachyon_rpc_get_state, _tachyon_star_create, _tachyon_star_destroy, _tachyon_star_poll, _tachyon_star_commit, _tachyon_star_acquire_tx, _tachyon_star_commit_tx, _tachyon_star_rollback_tx, _tachyon_star_flush, _tachyon_star_get_state, _tachyon_star_n_spokes, _malloc, __emscripten_stack_restore, __emscripten_stack_alloc, _emscripten_stack_get_current, memory, __indirect_function_table, wasmMemory; + +function assignWasmExports(wasmExports) { + _free = Module["_free"] = wasmExports["g"]; + _tachyon_bus_destroy = Module["_tachyon_bus_destroy"] = wasmExports["h"]; + _tachyon_bus_ref = Module["_tachyon_bus_ref"] = wasmExports["i"]; + _tachyon_bus_set_numa_node = Module["_tachyon_bus_set_numa_node"] = wasmExports["j"]; + _tachyon_memory_barrier_acquire = Module["_tachyon_memory_barrier_acquire"] = wasmExports["k"]; + _tachyon_bus_listen = Module["_tachyon_bus_listen"] = wasmExports["l"]; + _tachyon_bus_connect = Module["_tachyon_bus_connect"] = wasmExports["m"]; + _tachyon_acquire_tx = Module["_tachyon_acquire_tx"] = wasmExports["n"]; + _tachyon_commit_tx = Module["_tachyon_commit_tx"] = wasmExports["o"]; + _tachyon_rollback_tx = Module["_tachyon_rollback_tx"] = wasmExports["p"]; + _tachyon_acquire_rx = Module["_tachyon_acquire_rx"] = wasmExports["q"]; + _tachyon_acquire_rx_spin = Module["_tachyon_acquire_rx_spin"] = wasmExports["r"]; + _tachyon_acquire_rx_blocking = Module["_tachyon_acquire_rx_blocking"] = wasmExports["s"]; + _tachyon_commit_rx = Module["_tachyon_commit_rx"] = wasmExports["t"]; + _tachyon_acquire_rx_batch = Module["_tachyon_acquire_rx_batch"] = wasmExports["u"]; + _tachyon_drain_batch = Module["_tachyon_drain_batch"] = wasmExports["v"]; + _tachyon_commit_rx_batch = Module["_tachyon_commit_rx_batch"] = wasmExports["w"]; + _tachyon_bus_set_polling_mode = Module["_tachyon_bus_set_polling_mode"] = wasmExports["x"]; + _tachyon_flush = Module["_tachyon_flush"] = wasmExports["y"]; + _tachyon_get_state = Module["_tachyon_get_state"] = wasmExports["z"]; + _tachyon_bus_stats = Module["_tachyon_bus_stats"] = wasmExports["A"]; + _tachyon_bus_get_shm_ptr = Module["_tachyon_bus_get_shm_ptr"] = wasmExports["B"]; + _tachyon_rpc_listen = Module["_tachyon_rpc_listen"] = wasmExports["C"]; + _tachyon_rpc_connect = Module["_tachyon_rpc_connect"] = wasmExports["D"]; + _tachyon_rpc_destroy = Module["_tachyon_rpc_destroy"] = wasmExports["E"]; + _tachyon_rpc_call = Module["_tachyon_rpc_call"] = wasmExports["F"]; + _tachyon_rpc_wait = Module["_tachyon_rpc_wait"] = wasmExports["G"]; + _tachyon_rpc_commit_rx = Module["_tachyon_rpc_commit_rx"] = wasmExports["H"]; + _tachyon_rpc_serve = Module["_tachyon_rpc_serve"] = wasmExports["I"]; + _tachyon_rpc_commit_serve = Module["_tachyon_rpc_commit_serve"] = wasmExports["J"]; + _tachyon_rpc_reply = Module["_tachyon_rpc_reply"] = wasmExports["K"]; + _tachyon_rpc_acquire_tx = Module["_tachyon_rpc_acquire_tx"] = wasmExports["L"]; + _tachyon_rpc_commit_call = Module["_tachyon_rpc_commit_call"] = wasmExports["M"]; + _tachyon_rpc_rollback_call = Module["_tachyon_rpc_rollback_call"] = wasmExports["N"]; + _tachyon_rpc_acquire_reply_tx = Module["_tachyon_rpc_acquire_reply_tx"] = wasmExports["O"]; + _tachyon_rpc_commit_reply = Module["_tachyon_rpc_commit_reply"] = wasmExports["P"]; + _tachyon_rpc_rollback_reply = Module["_tachyon_rpc_rollback_reply"] = wasmExports["Q"]; + _tachyon_rpc_set_polling_mode = Module["_tachyon_rpc_set_polling_mode"] = wasmExports["R"]; + _tachyon_rpc_get_state = Module["_tachyon_rpc_get_state"] = wasmExports["S"]; + _tachyon_star_create = Module["_tachyon_star_create"] = wasmExports["T"]; + _tachyon_star_destroy = Module["_tachyon_star_destroy"] = wasmExports["U"]; + _tachyon_star_poll = Module["_tachyon_star_poll"] = wasmExports["V"]; + _tachyon_star_commit = Module["_tachyon_star_commit"] = wasmExports["W"]; + _tachyon_star_acquire_tx = Module["_tachyon_star_acquire_tx"] = wasmExports["X"]; + _tachyon_star_commit_tx = Module["_tachyon_star_commit_tx"] = wasmExports["Y"]; + _tachyon_star_rollback_tx = Module["_tachyon_star_rollback_tx"] = wasmExports["Z"]; + _tachyon_star_flush = Module["_tachyon_star_flush"] = wasmExports["_"]; + _tachyon_star_get_state = Module["_tachyon_star_get_state"] = wasmExports["$"]; + _tachyon_star_n_spokes = Module["_tachyon_star_n_spokes"] = wasmExports["aa"]; + _malloc = Module["_malloc"] = wasmExports["ba"]; + __emscripten_stack_restore = wasmExports["ca"]; + __emscripten_stack_alloc = wasmExports["da"]; + _emscripten_stack_get_current = wasmExports["ea"]; + memory = wasmMemory = wasmExports["e"]; + __indirect_function_table = wasmExports["__indirect_function_table"]; +} + +var wasmImports = { + /** @export */ c: __abort_js, + /** @export */ d: _clock_time_get, + /** @export */ a: _emscripten_get_now, + /** @export */ b: _emscripten_resize_heap +}; + +// include: postamble.js +// === Auto-generated postamble setup entry stuff === +function run() { + preRun(); + function doRun() { + // run may have just been called through dependencies being fulfilled just in this very frame, + // or while the async setStatus time below was happening + Module["calledRun"] = true; + if (ABORT) return; + initRuntime(); + readyPromiseResolve?.(Module); + postRun(); + } + { + doRun(); + } +} + +var wasmExports; + +// In modularize mode the generated code is within a factory function so we +// can use await here (since it's not top-level-await). +wasmExports = await (createWasm()); + +run(); + +// end include: postamble.js +// include: postamble_modularize.js +// In MODULARIZE mode we wrap the generated code in a factory function +// and return either the Module itself, or a promise of the module. +// We assign to the `moduleRtn` global here and configure closure to see +// this as an extern so it won't get minified. +if (runtimeInitialized) { + moduleRtn = Module; +} else { + // Set up the promise that indicates the Module is initialized + moduleRtn = new Promise((resolve, reject) => { + readyPromiseResolve = resolve; + readyPromiseReject = reject; + }); +} + + + return moduleRtn; +} + +// Export using a UMD style export, or ES6 exports if selected +export default TachyonCore; + diff --git a/bindings/node/src/ts/wasm/tachyon.wasm b/bindings/node/src/ts/wasm/tachyon.wasm new file mode 100755 index 0000000000000000000000000000000000000000..a376a040ca72659d45318c318a5202c89e26aca5 GIT binary patch literal 23199 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 a{My#?#?+zUI`?f{I=i^F=v(*c@&5qaY4x=L literal 0 HcmV?d00001 diff --git a/bindings/node/test/browser_wasm.spec.mjs b/bindings/node/test/browser_wasm.spec.mjs new file mode 100644 index 00000000..107e3782 --- /dev/null +++ b/bindings/node/test/browser_wasm.spec.mjs @@ -0,0 +1,408 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { existsSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, extname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PACKAGE_ROOT = resolve(__dirname, '..'); +const HOME = process.env.HOME ?? ''; + +const TEST_PAGE = ` + + +`; + +function chromiumPath() { + const candidates = [ + process.env.CHROMIUM_BIN, + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + ...playwrightChromiumCandidates(), + ].filter(Boolean); + + for (const candidate of candidates) { + if (candidate !== undefined && existsSync(candidate)) return candidate; + } + throw new Error( + `Chromium not found. Set CHROMIUM_BIN, install /usr/bin/chromium, or install a Chrome/Chromium app.`, + ); +} + +function playwrightChromiumCandidates() { + const roots = [ + HOME === '' ? undefined : join(HOME, 'Library/Caches/ms-playwright'), + HOME === '' ? undefined : join(HOME, '.cache/ms-playwright'), + process.env.PLAYWRIGHT_BROWSERS_PATH, + ].filter(Boolean); + const candidates = []; + + for (const root of roots) { + if (root === undefined || !existsSync(root)) continue; + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('chromium')) continue; + const dir = join(root, entry.name); + candidates.push( + join(dir, 'chrome-linux/chrome'), + join(dir, 'chrome-mac/Chromium.app/Contents/MacOS/Chromium'), + join(dir, 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'), + join(dir, 'chrome-headless-shell-linux64/chrome-headless-shell'), + join(dir, 'chrome-headless-shell-mac-arm64/chrome-headless-shell'), + ); + } + } + + return candidates; +} + +function mimeType(pathname) { + switch (extname(pathname)) { + case '.html': + return 'text/html; charset=utf-8'; + case '.js': + return 'text/javascript; charset=utf-8'; + case '.wasm': + return 'application/wasm'; + default: + return 'application/octet-stream'; + } +} + +async function startServer() { + const { readFile } = await import('node:fs/promises'); + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + if (url.pathname === '/' || url.pathname === '/index.html') { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end(TEST_PAGE); + return; + } + + const filePath = resolve(PACKAGE_ROOT, `.${url.pathname}`); + if (!filePath.startsWith(PACKAGE_ROOT)) { + res.writeHead(403); + res.end('forbidden'); + return; + } + + const body = await readFile(filePath); + res.writeHead(200, { 'content-type': mimeType(filePath) }); + res.end(body); + } catch (error) { + res.writeHead(404); + res.end(String(error)); + } + }); + + await new Promise((resolveListen) => server.listen(0, '127.0.0.1', resolveListen)); + const address = server.address(); + assert.equal(typeof address, 'object'); + return { server, port: address.port }; +} + +async function waitForJson(url, timeoutMs = 30_000) { + const started = Date.now(); + for (;;) { + try { + const res = await fetch(url); + if (res.ok) return await res.json(); + } catch { + // Chromium may still be starting. + } + if (Date.now() - started > timeoutMs) throw new Error(`Timed out waiting for ${url}`); + await new Promise((resolveWait) => setTimeout(resolveWait, 50)); + } +} + +async function openPage(debugPort, url) { + const target = await fetch(`http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(url)}`, { + method: 'PUT', + }); + if (!target.ok) throw new Error(`Failed to open browser page: ${await target.text()}`); + return target.json(); +} + +async function runCdp(webSocketDebuggerUrl) { + const ws = new WebSocket(webSocketDebuggerUrl); + let nextId = 0; + const pending = new Map(); + + ws.addEventListener('message', (event) => { + const msg = JSON.parse(event.data); + if (msg.id === undefined || !pending.has(msg.id)) return; + const { resolve: resolveMessage, reject } = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error !== undefined) reject(new Error(JSON.stringify(msg.error))); + else resolveMessage(msg.result); + }); + + await new Promise((resolveOpen, rejectOpen) => { + ws.addEventListener('open', resolveOpen, { once: true }); + ws.addEventListener('error', rejectOpen, { once: true }); + }); + + const call = (method, params = {}) => { + const id = ++nextId; + ws.send(JSON.stringify({ id, method, params })); + return new Promise((resolveCall, rejectCall) => pending.set(id, { resolve: resolveCall, reject: rejectCall })); + }; + + const evaluate = async (expression, timeout = 15_000) => { + const result = await call('Runtime.evaluate', { + expression, + awaitPromise: true, + returnByValue: true, + timeout, + }); + if (result.exceptionDetails !== undefined) throw new Error(JSON.stringify(result.exceptionDetails)); + return result.result.value; + }; + + // The page opened via /json/new is still navigating when we attach, so the + // initial about:blank execution context is torn down underneath us. Retry + // until the document's real context is live before running the suite. + const evaluateResilient = async (expression, timeout) => { + const deadline = Date.now() + 15_000; + for (;;) { + try { + return await evaluate(expression, timeout); + } catch (error) { + const message = String(error?.message ?? error); + const transient = + message.includes('Execution context was destroyed') || + message.includes('Cannot find context') || + message.includes('uniqueContextId'); + if (!transient || Date.now() > deadline) throw error; + await new Promise((resolveRetry) => setTimeout(resolveRetry, 50)); + } + } + }; + + await call('Runtime.enable'); + await evaluateResilient('document.readyState', 5_000); + await evaluateResilient(`new Promise((resolve, reject) => { + const started = performance.now(); + const tick = () => { + if (window.__tachyonBrowserDone) resolve(true); + else if (performance.now() - started > 10000) reject(new Error("browser wasm tests timed out")); + else setTimeout(tick, 25); + }; + tick(); +})`); + const results = JSON.parse(await evaluateResilient('JSON.stringify(window.__tachyonBrowserResults)')); + ws.close(); + return results; +} + +const { server, port } = await startServer(); +const debugPort = 9333 + Math.floor(Math.random() * 1000); +const userDataDir = await mkdtemp(join(tmpdir(), 'tachyon-browser-wasm-')); +const browser = spawn(chromiumPath(), [ + '--headless=new', + '--disable-gpu', + // CI runners give containers a tiny /dev/shm; without this Chromium crashes + // on startup and never opens the remote-debugging port. + '--disable-dev-shm-usage', + '--no-first-run', + '--no-default-browser-check', + '--no-sandbox', + `--remote-debugging-port=${debugPort}`, + `--user-data-dir=${userDataDir}`, + `http://127.0.0.1:${port}/`, +]); + +let browserStderr = ''; +browser.stderr.on('data', (chunk) => { + browserStderr += chunk; + if (process.env.TACHYON_BROWSER_TEST_DEBUG === '1') process.stderr.write(chunk); +}); +browser.on('error', (err) => { + console.error(`Failed to spawn Chromium: ${err.message}`); +}); + +try { + try { + await waitForJson(`http://127.0.0.1:${debugPort}/json/version`); + } catch (error) { + if (browserStderr.trim() !== '') console.error(`Chromium stderr:\n${browserStderr}`); + throw error; + } + const target = await openPage(debugPort, `http://127.0.0.1:${port}/`); + const results = await runCdp(target.webSocketDebuggerUrl); + const failures = results.filter((result) => !result.ok); + for (const result of results) { + console.log(`${result.ok ? 'ok' : 'not ok'} - ${result.name}`); + } + if (failures.length > 0) { + throw new Error(failures.map((failure) => `${failure.name}: ${failure.message}`).join('\n\n')); + } +} finally { + // Wait for the browser to actually exit before removing its user-data-dir, + // otherwise Chromium is still writing into it and rmdir races with ENOTEMPTY. + const exited = new Promise((resolveExit) => browser.once('exit', resolveExit)); + browser.kill('SIGTERM'); + const killed = await Promise.race([exited, new Promise((r) => setTimeout(() => r('timeout'), 3_000))]); + if (killed === 'timeout') { + browser.kill('SIGKILL'); + await Promise.race([exited, new Promise((r) => setTimeout(r, 2_000))]); + } + server.close(); + // Temp-dir cleanup is best-effort; a lingering Chromium file must not fail the run. + try { + await rm(userDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + } catch { + // ignore: the OS reaps the temp dir eventually + } +} diff --git a/bindings/node/tsconfig.json b/bindings/node/tsconfig.json index 03593981..1ccd5774 100644 --- a/bindings/node/tsconfig.json +++ b/bindings/node/tsconfig.json @@ -17,6 +17,7 @@ "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, + "allowArbitraryExtensions": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, From 3277bf062b5b6f91a4d8fe7ff20ceeb3cc6b2863 Mon Sep 17 00:00:00 2001 From: pirate Date: Fri, 29 May 2026 22:42:36 +0000 Subject: [PATCH 3/4] ci: build the WASM core and run browser tests Add a cpp_wasm job (build the Emscripten core, check artefacts) and a nodejs_browser_wasm job that runs the committed-artifact browser tests against the preinstalled /usr/bin/chromium, plus the setup-emsdk composite action and install_emsdk.sh. --- .github/actions/setup-emsdk/action.yml | 21 +++++++++ .github/workflows/ci.yml | 61 ++++++++++++++++++++++++++ .gitignore | 3 +- ci/setup/install_emsdk.sh | 31 +++++++++++++ 4 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 .github/actions/setup-emsdk/action.yml create mode 100755 ci/setup/install_emsdk.sh diff --git a/.github/actions/setup-emsdk/action.yml b/.github/actions/setup-emsdk/action.yml new file mode 100644 index 00000000..913d4e6a --- /dev/null +++ b/.github/actions/setup-emsdk/action.yml @@ -0,0 +1,21 @@ +name: Setup Emscripten +description: Install Emscripten SDK +inputs: + version: + description: Emscripten Version + required: false + default: 'latest' +runs: + using: composite + steps: + - name: Run setup script + shell: bash + run: bash ci/setup/install_emsdk.sh ${{ inputs.version }} + + - name: Inject Global Environment + shell: bash + run: | + EMSDK_DIR="${GITHUB_WORKSPACE}/.emsdk" + source "${EMSDK_DIR}/emsdk_env.sh" > /dev/null 2>&1 + env | grep '^EMSDK' >> $GITHUB_ENV + echo "$PATH" | tr ':' '\n' | grep "${EMSDK_DIR}" >> $GITHUB_PATH diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92d2775b..f0061ebb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -374,6 +374,67 @@ jobs: working-directory: bindings/node run: npm test + cpp_wasm: + name: C++ (Emscripten, ${{ matrix.build_type }}) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + build_type: [ debug, release ] + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Environment + uses: ./.github/actions/setup-tachyon-env + with: + lang: 'cpp' + + - name: Setup Emscripten + uses: ./.github/actions/setup-emsdk + + - name: Configure + run: cmake --preset emscripten-${{ matrix.build_type }} + + - name: Build + run: cmake --build --preset emscripten-${{ matrix.build_type }} + + - name: Check WASM artefacts + run: | + ls -lh build/emscripten-${{ matrix.build_type }}/core/libtachyon.a + ls -lh build/emscripten-${{ matrix.build_type }}/core/tachyon.js + ls -lh build/emscripten-${{ matrix.build_type }}/core/tachyon.wasm + + nodejs_browser_wasm: + name: Browser WASM tests + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'npm' + cache-dependency-path: bindings/node/package-lock.json + + - name: Install dependencies + working-directory: bindings/node + run: npm install --ignore-scripts + + - name: Check Chromium + run: /usr/bin/chromium --version + + # Do not run build:wasm here. The generated WASM module is committed; CI + # validates the committed artifact (test:browser stages it into dist via + # build:ts) rather than regenerating it and masking a stale-artifact diff. + - name: Browser WASM tests + working-directory: bindings/node + env: + CHROMIUM_BIN: /usr/bin/chromium + run: npm run test:browser + csharp: name: C# Bindings (${{ matrix.runner }}) runs-on: ${{ matrix.runner }} diff --git a/.gitignore b/.gitignore index 6aef3f06..08d08b97 100644 --- a/.gitignore +++ b/.gitignore @@ -161,4 +161,5 @@ Testing/ crash- .private/ oss-fuzz/ - +.emsdk/ +examples/browser_wasm/pkg/ diff --git a/ci/setup/install_emsdk.sh b/ci/setup/install_emsdk.sh new file mode 100755 index 00000000..02b43a75 --- /dev/null +++ b/ci/setup/install_emsdk.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -euo pipefail + +EMSDK_VERSION="${1:-latest}" +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +EMSDK_DIR="${PROJECT_ROOT}/.emsdk" + +echo "[emsdk] Preparing Emscripten SDK (${EMSDK_VERSION}) in ${EMSDK_DIR}..." + +if [[ ! -d "${EMSDK_DIR}" ]]; then + echo "[emsdk] Cloning emsdk repository..." + git clone https://github.com/emscripten-core/emsdk.git "${EMSDK_DIR}" +else + echo "[emsdk] Directory already exists. Pulling latest updates..." + cd "${EMSDK_DIR}" + git pull origin main +fi + +cd "${EMSDK_DIR}" + +echo "[emsdk] Installing version: ${EMSDK_VERSION}..." +./emsdk install "${EMSDK_VERSION}" + +echo "[emsdk] Activating version: ${EMSDK_VERSION}..." +./emsdk activate "${EMSDK_VERSION}" + +echo "" +echo "[emsdk] Emscripten SDK successfully installed." +echo "[emsdk] To activate the toolchain in your current shell, run:" +echo "source ${EMSDK_DIR}/emsdk_env.sh" From f822ea2c17a07e6126e41ff1ce138e0f2c0a1a90 Mon Sep 17 00:00:00 2001 From: pirate Date: Fri, 29 May 2026 22:42:36 +0000 Subject: [PATCH 4/4] example(browser_wasm): C++/WASM echo demo; docs for the browser build Page JavaScript and a C++ echo program (echo.cpp) share one ring through the Emscripten core in a single WASM module built with em++ (build-wasm.mjs); no Rust/cargo/wasm-pack toolchain. Headless Chromium RTT ~98ns p50 / ~171ns p99, in line with the native ~150ns round trip. Documents the browser build. --- README.md | 4 + docs/README.md | 2 + examples/README.md | 2 + examples/browser_wasm/README.md | 40 +++++ examples/browser_wasm/build-wasm.mjs | 60 +++++++ examples/browser_wasm/echo/echo.cpp | 54 +++++++ examples/browser_wasm/index.html | 61 ++++++++ examples/browser_wasm/main.js | 226 +++++++++++++++++++++++++++ examples/browser_wasm/package.json | 13 ++ examples/browser_wasm/style.css | 169 ++++++++++++++++++++ 10 files changed, 631 insertions(+) create mode 100644 examples/browser_wasm/README.md create mode 100644 examples/browser_wasm/build-wasm.mjs create mode 100644 examples/browser_wasm/echo/echo.cpp create mode 100644 examples/browser_wasm/index.html create mode 100644 examples/browser_wasm/main.js create mode 100644 examples/browser_wasm/package.json create mode 100644 examples/browser_wasm/style.css diff --git a/README.md b/README.md index fa7acb74..2e66e4d1 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,10 @@ pip install tachyon-ipc npm install @tachyon-ipc/core ``` +The same npm package also includes a browser WASM build for bundlers. Browser code keeps the same +`import { Bus } from '@tachyon-ipc/core'` shape; bundlers that honor the package `browser` field resolve to the +page-local WASM transport automatically. + **Java (Maven):** ```xml diff --git a/docs/README.md b/docs/README.md index bc1ab196..f14963f1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,2 +1,4 @@ # Documentations +- [Browser WASM example](../examples/browser_wasm/README.md) - in-page JavaScript and C++ WASM communication through + Tachyon rings. diff --git a/examples/README.md b/examples/README.md index 65afe604..7e435647 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,2 +1,4 @@ # Examples +- [browser_wasm](./browser_wasm) - page JavaScript and C++ WASM exchange binary messages through browser-local + Tachyon rings. diff --git a/examples/browser_wasm/README.md b/examples/browser_wasm/README.md new file mode 100644 index 00000000..5d46c507 --- /dev/null +++ b/examples/browser_wasm/README.md @@ -0,0 +1,40 @@ +# Tachyon Browser WASM Example + +This example runs Tachyon inside a single browser page. Page JavaScript writes +binary payloads directly into a ring TX slot in WebAssembly memory, a small +C++ WASM function (`tachyon_browser_echo_once`) polls the inbound ring and +replies on a second ring, and JavaScript reads the reply from WASM memory. + +The demo program lives in `examples/browser_wasm/echo/echo.cpp`. It is compiled +by Emscripten and linked against the fuzzed Tachyon C++ core into a single WASM +module, so the page JavaScript and the C++ program share one WASM memory and one +ring engine. Both sides drive the rings through the exact same sanitized C ABI — +the C++ core is the single source of truth. + +The browser build does not use POSIX shared memory or UNIX sockets. Those APIs +are unavailable in browsers, so the WASM path is a page-local Tachyon ring with +the same 64-byte message header, alignment, `type_id`, and skip-marker rules. + +## Run + +```bash +# from the repo root, make the Emscripten toolchain available: +source .emsdk/emsdk_env.sh # run `bash ci/setup/install_emsdk.sh` first if needed + +cd examples/browser_wasm +npm install +npm run build:wasm # builds the C++ core + echo into pkg/tachyon_example.js +npm run dev +``` + +Open the Vite URL, then use **Send To C++** or **Run Browser RTT Bench**. + +## Native Comparison + +The browser benchmark reports batch-averaged round-trip time because +`performance.now()` is too coarse for individual sub-microsecond samples in many +browsers. Compare the browser mean/p50 against the native Tachyon benchmark +(`cmake --build --preset ` then run the `benchmark/` target, or the +numbers in the root README) for a practical JS/WASM overhead view. Because the +browser path is an in-process ring with no syscalls, its per-RTT cost is in the +same ballpark as the native ~150 ns round trip. diff --git a/examples/browser_wasm/build-wasm.mjs b/examples/browser_wasm/build-wasm.mjs new file mode 100644 index 00000000..7b9dbee0 --- /dev/null +++ b/examples/browser_wasm/build-wasm.mjs @@ -0,0 +1,60 @@ +// Builds the example's single WASM module: the page-specific C++ echo program +// linked against the fuzzed Tachyon core. Both the C ABI (used by the page JS) +// and `tachyon_browser_echo_once` (the demo program) are exported from one +// module, so JavaScript and C++ share one WASM memory and one ring engine. +// +// Requires the Emscripten SDK on PATH (`source .emsdk/emsdk_env.sh`). + +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '../..'); +const CORE_LIB = resolve(REPO_ROOT, 'build/emscripten-release/core/libtachyon.a'); +const OUT_DIR = resolve(HERE, 'pkg'); + +function run(cmd, args, opts = {}) { + const result = spawnSync(cmd, args, { stdio: 'inherit', ...opts }); + if (result.status !== 0) { + console.error(`\n[build-wasm] command failed: ${cmd} ${args.join(' ')}`); + process.exit(result.status ?? 1); + } +} + +// 1. Build the core static library with the Emscripten toolchain (cached by CMake). +if (!existsSync(CORE_LIB)) { + run('cmake', ['--preset', 'emscripten-release'], { cwd: REPO_ROOT }); + run('cmake', ['--build', '--preset', 'emscripten-release'], { cwd: REPO_ROOT }); +} + +// 2. Link the demo program against the whole core archive into one ES module. +mkdirSync(OUT_DIR, { recursive: true }); +run('em++', [ + '-O3', + '-std=c++23', + resolve(HERE, 'echo/echo.cpp'), + '-I', + resolve(REPO_ROOT, 'core/include'), + '-Wl,--whole-archive', + CORE_LIB, + '-Wl,--no-whole-archive', + '-sALLOW_MEMORY_GROWTH=1', + '-sMODULARIZE=1', + '-sEXPORT_ES6=1', + '-sEXPORT_NAME=TachyonExample', + '-sENVIRONMENT=web,worker', + '-sSTRICT=1', + '-sNO_EXIT_RUNTIME=1', + '-sWASM_BIGINT=1', + '-sFILESYSTEM=0', + '--no-entry', + '--minify=0', + '-sEXPORTED_FUNCTIONS=_malloc,_free,_tachyon_browser_echo_once', + '-sEXPORTED_RUNTIME_METHODS=cwrap,getValue,setValue,UTF8ToString,HEAPU8,HEAPU32', + '-o', + resolve(OUT_DIR, 'tachyon_example.js'), +]); + +console.log('[build-wasm] wrote pkg/tachyon_example.js + pkg/tachyon_example.wasm'); diff --git a/examples/browser_wasm/echo/echo.cpp b/examples/browser_wasm/echo/echo.cpp new file mode 100644 index 00000000..68c0a66a --- /dev/null +++ b/examples/browser_wasm/echo/echo.cpp @@ -0,0 +1,54 @@ +// Page-specific demo program for the browser WASM example. +// +// This is the C++ equivalent of the previous Rust echo: it polls an inbound +// ring once, increments a little-endian u32 payload, and republishes it on an +// outbound ring. It deliberately drives the rings through the public Tachyon C +// ABI (`tachyon_acquire_rx`, `tachyon_acquire_tx`, ...) so the demo runs on the +// exact same fuzzed/sanitized core as the JavaScript side. Compiled into the +// example's single WASM module alongside the core via Emscripten. + +#include +#include + +#include +#include + +extern "C" { + +/// Echoes one message between two page-local rings. +/// +/// Reads a 4-byte u32 from `inbound`, increments it, and writes the result to +/// `outbound` with the route bumped by one. Returns 1 when a message was +/// processed, 0 when `inbound` was empty, and -1 on a malformed payload. +EMSCRIPTEN_KEEPALIVE +int tachyon_browser_echo_once(tachyon_bus_t *inbound, tachyon_bus_t *outbound) { + uint32_t type_id = 0; + size_t actual_size = 0; + const void *in_ptr = tachyon_acquire_rx(inbound, &type_id, &actual_size); + if (in_ptr == nullptr) { + return 0; + } + + if (actual_size != sizeof(uint32_t)) { + tachyon_commit_rx(inbound); + return -1; + } + + uint32_t value = 0; + std::memcpy(&value, in_ptr, sizeof(value)); + value += 1; + + void *out_ptr = tachyon_acquire_tx(outbound, sizeof(value)); + if (out_ptr == nullptr) { + tachyon_commit_rx(inbound); + return -1; + } + + std::memcpy(out_ptr, &value, sizeof(value)); + tachyon_commit_tx(outbound, sizeof(value), type_id + (1U << 16)); + tachyon_flush(outbound); + tachyon_commit_rx(inbound); + return 1; +} + +} // extern "C" diff --git a/examples/browser_wasm/index.html b/examples/browser_wasm/index.html new file mode 100644 index 00000000..7f3d5760 --- /dev/null +++ b/examples/browser_wasm/index.html @@ -0,0 +1,61 @@ + + + + + + Tachyon WASM Browser Bus + + + +
+
+
+

Tachyon WASM Browser Bus

+

C++ WASM and page JavaScript exchanging binary messages through Tachyon rings in one browser page.

+
+
+
+ WASM + loading +
+
+ Capacity + - +
+
+ Last Reply + - +
+
+
+ +
+ + + + +
+ +
+

Messages

+

+      
+ +
+

Browser RTT Profile

+ + + + +
Not run yet
+
+
+ + + diff --git a/examples/browser_wasm/main.js b/examples/browser_wasm/main.js new file mode 100644 index 00000000..ecb619b8 --- /dev/null +++ b/examples/browser_wasm/main.js @@ -0,0 +1,226 @@ +import createTachyonExample from "./pkg/tachyon_example.js"; + +const CAPACITY = 1 << 20; +const BATCH_SIZE = 4096; + +const els = { + status: document.querySelector("#wasm-status"), + capacity: document.querySelector("#capacity"), + lastReply: document.querySelector("#last-reply"), + value: document.querySelector("#value"), + send: document.querySelector("#send"), + iterations: document.querySelector("#iterations"), + bench: document.querySelector("#bench"), + log: document.querySelector("#log"), + benchTable: document.querySelector("#bench-table"), +}; + +let core; +let abi; +let view; +let scratch; +let jsToCpp; +let cppToJs; +let typeCounter; + +function makeTypeId(route, msgType) { + return ((route & 0xffff) << 16) | (msgType & 0xffff); +} + +function routeId(typeId) { + return (typeId >>> 16) & 0xffff; +} + +function msgType(typeId) { + return typeId & 0xffff; +} + +function appendLog(line) { + const time = new Date().toLocaleTimeString(); + els.log.textContent = `[${time}] ${line}\n${els.log.textContent}`; +} + +function listenBus(path) { + core.setValue(scratch, 0, "i32"); + const rc = abi.busListen(path, CAPACITY, scratch); + if (rc !== 0) throw new Error(`tachyon_bus_listen failed for ${path} (error ${rc})`); + const busPtr = core.getValue(scratch, "i32"); + if (busPtr === 0) throw new Error(`tachyon_bus_listen returned null for ${path}`); + return busPtr; +} + +// Two page-local rings: JS produces into jsToCpp, the C++ echo consumes it and +// produces into cppToJs, then JS consumes the reply. +function writeU32ToBus(bus, value, typeId) { + const ptr = abi.acquireTx(bus, 4); + if (ptr === 0) throw new Error("ring full"); + view.setUint32(ptr, value >>> 0, true); + abi.commitTx(bus, 4, typeId); + abi.flush(bus); +} + +function readU32FromBus(bus) { + const ptr = abi.acquireRx(bus, scratch, scratch + 4); + if (ptr === 0) return null; + const typeId = core.getValue(scratch, "i32") >>> 0; + const size = core.getValue(scratch + 4, "i32") >>> 0; + const value = size === 4 ? view.getUint32(ptr, true) : null; + abi.commitRx(bus); + return { value, size, typeId }; +} + +function pingCpp(value) { + writeU32ToBus(jsToCpp, value, typeCounter); + + if (abi.echoOnce(jsToCpp, cppToJs) !== 1) { + throw new Error("C++ WASM program did not receive the JS message"); + } + + const reply = readU32FromBus(cppToJs); + if (!reply) { + throw new Error("JS did not receive the C++ WASM reply"); + } + + return reply; +} + +function pingCppFast(value) { + const txPtr = abi.acquireTx(jsToCpp, 4); + view.setUint32(txPtr, value >>> 0, true); + abi.commitTx(jsToCpp, 4, typeCounter); + abi.flush(jsToCpp); + if (abi.echoOnce(jsToCpp, cppToJs) !== 1) { + throw new Error("C++ WASM program did not receive the JS message"); + } + const rxPtr = abi.acquireRx(cppToJs, scratch, scratch + 4); + if (rxPtr === 0) { + throw new Error("JS did not receive the C++ WASM reply"); + } + const replyValue = view.getUint32(rxPtr, true); + abi.commitRx(cppToJs); + return replyValue; +} + +function percentile(sorted, pct) { + const idx = Math.min( + sorted.length - 1, + Math.floor((sorted.length - 1) * pct), + ); + return sorted[idx]; +} + +function formatNs(ns) { + if (ns >= 1000) return `${(ns / 1000).toFixed(2)} us`; + return `${ns.toFixed(1)} ns`; +} + +function setBenchRows(rows) { + els.benchTable.replaceChildren( + ...rows.map(([label, value]) => { + const tr = document.createElement("tr"); + const left = document.createElement("td"); + const right = document.createElement("td"); + left.textContent = label; + right.textContent = value; + tr.append(left, right); + return tr; + }), + ); +} + +async function runBench() { + const iterations = Math.max( + 1000, + Number.parseInt(els.iterations.value, 10) || 1000000, + ); + const warmup = Math.min(10000, Math.floor(iterations / 10)); + + els.bench.disabled = true; + setBenchRows([["Running", `${iterations.toLocaleString()} RTTs`]]); + await new Promise((resolve) => requestAnimationFrame(resolve)); + + for (let i = 0; i < warmup; i += 1) { + pingCpp(i); + } + + const samples = []; + let totalStart = performance.now(); + for (let i = 0; i < iterations; i += BATCH_SIZE) { + const batchCount = Math.min(BATCH_SIZE, iterations - i); + const batchStart = performance.now(); + for (let j = 0; j < batchCount; j += 1) { + pingCppFast(i + j); + } + samples.push(((performance.now() - batchStart) * 1_000_000) / batchCount); + } + const totalMs = performance.now() - totalStart; + + samples.sort((a, b) => a - b); + const throughput = iterations / (totalMs / 1000); + setBenchRows([ + ["Payload", "4 bytes u32"], + [ + "Samples", + `${samples.length.toLocaleString()} batch averages x ${BATCH_SIZE}`, + ], + ["Direct doorbell p50", formatNs(percentile(samples, 0.5))], + ["Direct doorbell p90", formatNs(percentile(samples, 0.9))], + ["Direct doorbell p99", formatNs(percentile(samples, 0.99))], + ["Direct doorbell mean", formatNs((totalMs * 1_000_000) / iterations)], + ["Throughput", `${(throughput / 1000).toFixed(1)} K RTT/sec`], + ]); + appendLog( + `browser bench completed: ${(throughput / 1000).toFixed(1)} K RTT/sec`, + ); + els.bench.disabled = false; +} + +async function main() { + core = await createTachyonExample(); + abi = { + busListen: core.cwrap("tachyon_bus_listen", "number", ["string", "number", "number"]), + acquireTx: core.cwrap("tachyon_acquire_tx", "number", ["number", "number"]), + commitTx: core.cwrap("tachyon_commit_tx", "number", ["number", "number", "number"]), + flush: core.cwrap("tachyon_flush", null, ["number"]), + acquireRx: core.cwrap("tachyon_acquire_rx", "number", ["number", "number", "number"]), + commitRx: core.cwrap("tachyon_commit_rx", "number", ["number"]), + echoOnce: core.cwrap("tachyon_browser_echo_once", "number", ["number", "number"]), + }; + // 16-byte scratch for C out-parameters (out_bus / out_type_id / out_size). + scratch = core._malloc(16); + + typeCounter = makeTypeId(0, 7); + jsToCpp = listenBus("/example/js-to-cpp"); + cppToJs = listenBus("/example/cpp-to-js"); + view = new DataView(core.HEAPU8.buffer); + + els.status.textContent = "ready"; + els.capacity.textContent = `${CAPACITY / 1024} KiB x 2`; + els.send.disabled = false; + els.bench.disabled = false; + + els.send.addEventListener("click", () => { + const value = Number.parseInt(els.value.value, 10) >>> 0; + const reply = pingCpp(value); + els.lastReply.textContent = `${reply.value}`; + appendLog( + `JS sent ${value}, C++ replied ${reply.value}; route=${routeId(reply.typeId)} type=${msgType( + reply.typeId, + )}`, + ); + }); + + els.bench.addEventListener("click", () => { + runBench().catch((err) => { + appendLog(`bench failed: ${err.message}`); + els.bench.disabled = false; + }); + }); + + appendLog("WASM module initialized"); +} + +main().catch((err) => { + els.status.textContent = "failed"; + appendLog(err.stack || err.message); +}); diff --git a/examples/browser_wasm/package.json b/examples/browser_wasm/package.json new file mode 100644 index 00000000..2fbb82e1 --- /dev/null +++ b/examples/browser_wasm/package.json @@ -0,0 +1,13 @@ +{ + "name": "tachyon-browser-wasm-example", + "private": true, + "type": "module", + "scripts": { + "build:wasm": "node build-wasm.mjs", + "dev": "vite --host 127.0.0.1", + "build": "npm run build:wasm && vite build" + }, + "devDependencies": { + "vite": "latest" + } +} diff --git a/examples/browser_wasm/style.css b/examples/browser_wasm/style.css new file mode 100644 index 00000000..94ae93c8 --- /dev/null +++ b/examples/browser_wasm/style.css @@ -0,0 +1,169 @@ +:root { + color-scheme: light; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #f4f6f8; + color: #17202a; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +.shell { + width: min(1120px, calc(100vw - 32px)); + margin: 32px auto; + display: grid; + gap: 16px; +} + +.panel, +.toolbar, +.results { + background: #ffffff; + border: 1px solid #d9e0e7; + border-radius: 8px; + box-shadow: 0 1px 2px rgb(20 30 45 / 8%); +} + +.panel { + display: grid; + grid-template-columns: 1fr auto; + gap: 24px; + padding: 24px; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: 28px; + font-weight: 720; +} + +h2 { + font-size: 16px; + margin-bottom: 12px; +} + +p { + margin-top: 8px; + max-width: 680px; + color: #52606d; +} + +.status-grid { + display: grid; + grid-template-columns: repeat(3, minmax(120px, 1fr)); + gap: 12px; + align-content: center; +} + +.status-grid div { + border-left: 3px solid #2c7a7b; + padding: 4px 12px; +} + +.status-grid span { + display: block; + color: #667788; + font-size: 12px; + text-transform: uppercase; +} + +.status-grid strong { + display: block; + margin-top: 4px; + font-size: 16px; + white-space: nowrap; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: end; + padding: 16px; +} + +label { + display: grid; + gap: 6px; + color: #52606d; + font-size: 13px; +} + +input { + width: 150px; + height: 38px; + border: 1px solid #bfccd8; + border-radius: 6px; + padding: 0 10px; + font: inherit; +} + +button { + height: 38px; + border: 0; + border-radius: 6px; + padding: 0 14px; + background: #1f6f78; + color: #ffffff; + font: inherit; + font-weight: 650; + cursor: pointer; +} + +button:disabled { + background: #9eb0bd; + cursor: wait; +} + +.results { + padding: 16px; +} + +pre { + min-height: 110px; + max-height: 260px; + overflow: auto; + margin: 0; + padding: 12px; + border-radius: 6px; + background: #111827; + color: #d1fae5; + font-size: 13px; + line-height: 1.5; +} + +table { + width: 100%; + border-collapse: collapse; +} + +td { + border-top: 1px solid #e2e8f0; + padding: 9px 0; +} + +td:last-child { + text-align: right; + font-variant-numeric: tabular-nums; +} + +@media (max-width: 760px) { + .panel { + grid-template-columns: 1fr; + } + + .status-grid { + grid-template-columns: 1fr; + } +}