diff --git a/Makefile b/Makefile index 2d8e9d15af10..75122426efab 100644 --- a/Makefile +++ b/Makefile @@ -259,7 +259,7 @@ bb-cpp-full: bb-cpp bb-cpp-gcc bb-cpp-fuzzing bb-cpp-windows bb-cpp-asan bb-cpp- bb-ts: bb-cpp-wasm bb-cpp-wasm-threads bb-cpp-native ipc-runtime $(call build,$@,barretenberg/ts,build_bb_js) -# Copies the cross-compiles into bb.js. +# Copies the cross-compiles into bb.js (its NAPI module) and bb.js-api (the bb binaries). bb-ts-cross-copy: bb-ts bb-cpp-cross $(call build,$@,barretenberg/ts,cross_copy_bb_js) @@ -270,10 +270,10 @@ bb-ts-cross-copy: bb-ts bb-cpp-cross bb-avm-sim: ipc-codegen ipc-runtime bb-cpp-native bb-ts $(call build,$@,barretenberg/ts,build_bb_avm_sim) -# Ordered after bb-cdb for the same reason bb-cdb is ordered after bb-avm-sim: -# all three regenerate the same barretenberg/ts workspaces and install into the -# same node_modules. -bb-avm-sim-cross-copy: bb-avm-sim bb-cdb bb-cpp-cross +# Ordered after bb-cdb (and after bb-ts-cross-copy, which builds bb.js-api's cross copies) +# for the same reason bb-cdb is ordered after bb-avm-sim: they all regenerate the same +# barretenberg/ts workspaces and install into the same node_modules. +bb-avm-sim-cross-copy: bb-avm-sim bb-cdb bb-ts-cross-copy bb-cpp-cross $(call build,$@,barretenberg/ts,cross_copy_bb_avm_sim) # Generated @aztec-foundation/cdb server bindings. Ordered after bb-avm-sim rather than run diff --git a/barretenberg/cpp/src/CMakeLists.txt b/barretenberg/cpp/src/CMakeLists.txt index 62aeb3ea129e..b9e153c53f41 100644 --- a/barretenberg/cpp/src/CMakeLists.txt +++ b/barretenberg/cpp/src/CMakeLists.txt @@ -233,7 +233,7 @@ add_library( ) # bb-external: static library for external consumers (e.g. barretenberg-rs). -# Uses the core object list without lmdb/world_state — FFI consumers only need ipc_ffi_entry(). +# Uses the core object list without lmdb/world_state — FFI consumers only need bb_ipc_ffi_entry(). # Built with -fvisibility=hidden; only WASM_EXPORT symbols remain visible. if(NOT WASM) add_library( diff --git a/barretenberg/cpp/src/barretenberg/bb/cli.cpp b/barretenberg/cpp/src/barretenberg/bb/cli.cpp index eb7fb3e16999..562871eb91c4 100644 --- a/barretenberg/cpp/src/barretenberg/bb/cli.cpp +++ b/barretenberg/cpp/src/barretenberg/bb/cli.cpp @@ -26,7 +26,6 @@ #include "barretenberg/bbapi/bbapi.hpp" #include "barretenberg/bbapi/bbapi_schema.hpp" #include "barretenberg/bbapi/bbapi_ultra_honk.hpp" -#include "barretenberg/bbapi/c_bind.hpp" #include "barretenberg/common/bb_bench.hpp" #include "barretenberg/common/get_bytecode.hpp" #include "barretenberg/common/memory_profile.hpp" diff --git a/barretenberg/cpp/src/barretenberg/bbapi/CMakeLists.txt b/barretenberg/cpp/src/barretenberg/bbapi/CMakeLists.txt index e7a90defb53a..423d12aea022 100644 --- a/barretenberg/cpp/src/barretenberg/bbapi/CMakeLists.txt +++ b/barretenberg/cpp/src/barretenberg/bbapi/CMakeLists.txt @@ -9,6 +9,8 @@ set(BB_GEN_DIR ${CMAKE_CURRENT_SOURCE_DIR}/generated) if(NOT FUZZING) set(BB_GEN_OUTPUTS ${BB_GEN_DIR}/bb_dispatch.hpp + ${BB_GEN_DIR}/bb_ffi.cpp + ${BB_GEN_DIR}/bb_ffi.hpp ${BB_GEN_DIR}/bb_ipc_server.hpp ${BB_GEN_DIR}/bb_types.hpp ${BB_GEN_DIR}/ipc_codegen/msgpack_adaptor.hpp @@ -20,6 +22,7 @@ if(NOT FUZZING) ${IPC_CODEGEN_DIR}/src/*.ts ${IPC_CODEGEN_DIR}/templates/cpp/*.hpp ) + # --ffi emits the in-process entry (bb_ipc_ffi_entry) bb.js's wasm backend and barretenberg-rs call. add_custom_command( OUTPUT ${BB_GEN_OUTPUTS} COMMAND node --experimental-strip-types --experimental-transform-types --no-warnings @@ -28,11 +31,12 @@ if(NOT FUZZING) --lang cpp --out ${BB_GEN_DIR} --server + --ffi --cpp-namespace bb::bbapi --cpp-include-dir barretenberg/bbapi/generated --strip-method-prefix DEPENDS ${BB_SCHEMA} ${IPC_CODEGEN_SRC} - COMMENT "Generating BB IPC wire types + server dispatch from bb_schema.json" + COMMENT "Generating BB IPC wire types + server dispatch + FFI entry from bb_schema.json" VERBATIM ) add_custom_target(bb_ipc_generated DEPENDS ${BB_GEN_OUTPUTS}) @@ -48,6 +52,12 @@ barretenberg_module(bbapi common chonk dsl crypto_poseidon2 crypto_pedersen_comm if(NOT FUZZING) add_dependencies(bbapi_objects bb_ipc_generated) + # The FFI entry is generated at build time, so the module's configure-time source glob misses it + # on a clean tree; list it explicitly (skipping the duplicate once the glob does see it). + get_target_property(BBAPI_SOURCES bbapi_objects SOURCES) + if(NOT "${BB_GEN_DIR}/bb_ffi.cpp" IN_LIST BBAPI_SOURCES) + target_sources(bbapi_objects PRIVATE ${BB_GEN_DIR}/bb_ffi.cpp) + endif() endif() # bbapi_tests needs vm2_stub to resolve dsl's AVM recursion constraint references diff --git a/barretenberg/cpp/src/barretenberg/bbapi/bb_curve_constants.json b/barretenberg/cpp/src/barretenberg/bbapi/bb_curve_constants.json deleted file mode 100644 index 20dab049c505..000000000000 --- a/barretenberg/cpp/src/barretenberg/bbapi/bb_curve_constants.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "bn254_fr_modulus": "30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001", - "bn254_fq_modulus": "30644e72e131a029b85045b68181585d97816a916871ca8d3c208c16d87cfd47", - "bn254_g1_generator": { - "x": "0000000000000000000000000000000000000000000000000000000000000001", - "y": "0000000000000000000000000000000000000000000000000000000000000002" - }, - "bn254_g2_generator": { - "x": [ - "1800deef121f1e76426a00665e5c4479674322d4f75edadd46debd5cd992f6ed", - "198e9393920d483a7260bfb731fb5d25f1aa493335a9e71297e485b7aef312c2" - ], - "y": [ - "12c85ea5db8c6deb4aab71808dcb408fe3d1e7690c43d37b4ce6cc0166fa7daa", - "090689d0585ff075ec9e99ad690c3395bc4b313370b38ef355acdadcd122975b" - ] - }, - "grumpkin_fr_modulus": "30644e72e131a029b85045b68181585d97816a916871ca8d3c208c16d87cfd47", - "grumpkin_fq_modulus": "30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001", - "grumpkin_g1_generator": { - "x": "0000000000000000000000000000000000000000000000000000000000000001", - "y": "0000000000000002cf135e7506a45d632d270d45f1181294833fc48d823f272c" - }, - "secp256k1_fr_modulus": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", - "secp256k1_fq_modulus": "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f", - "secp256k1_g1_generator": { - "x": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - "y": "483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8" - }, - "secp256r1_fr_modulus": "ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551", - "secp256r1_fq_modulus": "ffffffff00000001000000000000000000000000ffffffffffffffffffffffff", - "secp256r1_g1_generator": { - "x": "6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296", - "y": "4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5" - } -} \ No newline at end of file diff --git a/barretenberg/cpp/src/barretenberg/bbapi/bb_schema.json b/barretenberg/cpp/src/barretenberg/bbapi/bb_schema.json index 64f1c2c36cbe..d7007332f53b 100644 --- a/barretenberg/cpp/src/barretenberg/bbapi/bb_schema.json +++ b/barretenberg/cpp/src/barretenberg/bbapi/bb_schema.json @@ -641,6 +641,10 @@ "response": { "dummy": "u8" } + }, + "Warmup": { + "request": {}, + "response": {} } } } diff --git a/barretenberg/cpp/src/barretenberg/bbapi/bbapi_handlers.cpp b/barretenberg/cpp/src/barretenberg/bbapi/bbapi_handlers.cpp index 19b847adefbd..ef8f4935c710 100644 --- a/barretenberg/cpp/src/barretenberg/bbapi/bbapi_handlers.cpp +++ b/barretenberg/cpp/src/barretenberg/bbapi/bbapi_handlers.cpp @@ -30,6 +30,8 @@ #include "barretenberg/crypto/poseidon2/poseidon2_permutation.hpp" #include "barretenberg/crypto/schnorr/schnorr.hpp" #include "barretenberg/crypto/sha256/sha256.hpp" +#include "barretenberg/ecc/scalar_multiplication/scalar_multiplication.hpp" +#include "barretenberg/polynomials/polynomial.hpp" #include "barretenberg/srs/factories/bn254_crs_data.hpp" #include "barretenberg/srs/factories/bn254_g1_chunk_hashes.hpp" #include "barretenberg/srs/global_crs.hpp" @@ -567,4 +569,43 @@ void handle_srs_init_grumpkin_srs(BBApiRequest& /*ctx*/, respond.ok({}); } +void handle_warmup(BBApiRequest& /*ctx*/, wire::BbWarmup&& /*cmd*/, Responder respond) +{ + // Run the prover's hot loops once — field arithmetic, batch inversion, Poseidon2 and a Pippenger + // MSM — so a tiering JIT (wasm) has optimized them before real work runs through them. + constexpr size_t NUM_POINTS = 1 << 14; + constexpr size_t NUM_HASHES = 1 << 10; + + // Successive doublings of the generator: sums of distinct subsets of {2^i G} are distinct, so the + // MSM's addition tree never meets two equal points, which the unsafe (SRS-shaped) path cannot + // handle. Small multiples of G would (G + 4G = 2G + 3G). + std::vector points(NUM_POINTS); + points[0] = g1::one; + for (size_t i = 1; i < NUM_POINTS; ++i) { + points[i] = points[i - 1].dbl(); + } + g1::element::batch_normalize(points.data(), NUM_POINTS); + std::vector affine_points(NUM_POINTS); + for (size_t i = 0; i < NUM_POINTS; ++i) { + affine_points[i] = g1::affine_element(points[i]); + } + Polynomial scalars = Polynomial::random(NUM_POINTS); + g1::element commitment = scalar_multiplication::pippenger_unsafe(scalars, affine_points); + + std::vector elements(NUM_POINTS); + for (size_t i = 0; i < NUM_POINTS; ++i) { + elements[i] = scalars[i].sqr() + fr(static_cast(i + 1)); + } + fr::batch_invert(elements.data(), NUM_POINTS); + + fr acc = elements[0]; + for (size_t i = 0; i < NUM_HASHES; ++i) { + acc = crypto::Poseidon2::hash({ acc, elements[i] }); + } + if (commitment.is_point_at_infinity() || acc.is_zero()) { + throw_or_abort("Warmup: degenerate result"); + } + respond.ok({}); +} + } // namespace bb::bbapi diff --git a/barretenberg/cpp/src/barretenberg/bbapi/bbapi_handlers.hpp b/barretenberg/cpp/src/barretenberg/bbapi/bbapi_handlers.hpp index 1afd873b9cea..4616d5878968 100644 --- a/barretenberg/cpp/src/barretenberg/bbapi/bbapi_handlers.hpp +++ b/barretenberg/cpp/src/barretenberg/bbapi/bbapi_handlers.hpp @@ -170,4 +170,5 @@ void handle_chonk_batch_verifier_stop(BBApiRequest& ctx, void handle_srs_init_grumpkin_srs(BBApiRequest& ctx, wire::BbSrsInitGrumpkinSrs&& cmd, Responder respond); +void handle_warmup(BBApiRequest& ctx, wire::BbWarmup&& cmd, Responder respond); } // namespace bb::bbapi diff --git a/barretenberg/cpp/src/barretenberg/bbapi/c_bind.cpp b/barretenberg/cpp/src/barretenberg/bbapi/c_bind.cpp index a977a4f308dd..3a2b69585ef5 100644 --- a/barretenberg/cpp/src/barretenberg/bbapi/c_bind.cpp +++ b/barretenberg/cpp/src/barretenberg/bbapi/c_bind.cpp @@ -1,39 +1,21 @@ -#include "c_bind.hpp" #include "barretenberg/bbapi/bbapi_handlers.hpp" #include "barretenberg/bbapi/bbapi_shared.hpp" -#include "barretenberg/bbapi/generated/bb_dispatch.hpp" -#include -#include -#include -#include -#include +#include "barretenberg/bbapi/generated/bb_ffi.hpp" -namespace { -// One request context for the process so stateful command sequences -// (ChonkStart/Load/Accumulate/Prove) share IVC state, mirroring a serve loop's -// single connection context. -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -bb::bbapi::BBApiRequest global_request; -} // namespace +namespace bb::bbapi { /** - * @brief In-process FFI/wasm entrypoint: the ipc-codegen FFI backend contract. + * @brief The dispatcher behind the generated in-process FFI entry (bb_ipc_ffi_entry, see bb_ffi.hpp). * - * Takes exactly the msgpack command payload a transport client would put inside - * a frame (no length/id envelope — framing is transport-level and an in-process - * call has none) and answers through the same generated dispatch the pipe / - * socket / shared-memory servers use. The output buffer is aligned_alloc'd and - * owned by the caller (free()-compatible), matching the cbind buffer contract. + * One request context for the process so stateful command sequences (ChonkStart/Load/Accumulate/ + * Prove) share IVC state, mirroring a serve loop's single connection context. */ -WASM_EXPORT void ipc_ffi_entry(const uint8_t* input, size_t input_len, uint8_t** output, size_t* output_len) +AsyncDispatchHandler& ipc_ffi_dispatcher() { - static auto handler = bb::bbapi::make_bb_handler(global_request); - std::vector response; - handler(std::span(input, input_len), - [&response](std::vector r) { response = std::move(r); }); - // NOLINTNEXTLINE(cppcoreguidelines-no-malloc) - auto* out = static_cast(aligned_alloc(64, response.size())); - std::memcpy(out, response.data(), response.size()); - *output = out; - *output_len = response.size(); + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static BBApiRequest request; + static AsyncDispatchHandler handler = make_bb_handler(request); + return handler; } + +} // namespace bb::bbapi diff --git a/barretenberg/cpp/src/barretenberg/bbapi/c_bind.hpp b/barretenberg/cpp/src/barretenberg/bbapi/c_bind.hpp deleted file mode 100644 index 1c9794db74a2..000000000000 --- a/barretenberg/cpp/src/barretenberg/bbapi/c_bind.hpp +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once -#include "barretenberg/common/wasm_export.hpp" -#include -#include - -/** - * @brief In-process FFI/wasm entrypoint (the ipc-codegen FFI backend symbol). - * - * Same msgpack command/response payload as every transport, without the - * transport-level length/id envelope. Output is aligned_alloc'd; the caller - * frees it with free(). - */ -WASM_EXPORT void ipc_ffi_entry(const uint8_t* input, size_t input_len, uint8_t** output, size_t* output_len); diff --git a/barretenberg/cpp/src/barretenberg/bbapi/c_bind_exception.test.cpp b/barretenberg/cpp/src/barretenberg/bbapi/c_bind_exception.test.cpp index 4fc6a49c5979..1b0be6605f05 100644 --- a/barretenberg/cpp/src/barretenberg/bbapi/c_bind_exception.test.cpp +++ b/barretenberg/cpp/src/barretenberg/bbapi/c_bind_exception.test.cpp @@ -1,4 +1,4 @@ -#include "barretenberg/bbapi/c_bind.hpp" +#include "barretenberg/bbapi/generated/bb_ffi.hpp" #include "barretenberg/bbapi/generated/bb_types.hpp" #include "barretenberg/bbapi/generated/ipc_codegen/msgpack_adaptor.hpp" #include "barretenberg/bbapi/generated/ipc_codegen/msgpack_include.hpp" @@ -26,11 +26,10 @@ template std::string ffi_response_type(const char* name, const Cm uint8_t* out = nullptr; size_t out_len = 0; - ipc_ffi_entry(reinterpret_cast(buf.data()), buf.size(), &out, &out_len); + bb_ipc_ffi_entry(reinterpret_cast(buf.data()), buf.size(), &out, &out_len); auto oh = msgpack::unpack(reinterpret_cast(out), out_len); - // NOLINTNEXTLINE(cppcoreguidelines-no-malloc) - free(out); + bb_ipc_ffi_free(out); auto arr = oh.get().via.array; EXPECT_EQ(arr.size, 2U); auto type = arr.ptr[0].as(); @@ -74,6 +73,13 @@ TEST(CBind, UnknownCommandReturnsErrorResponse) EXPECT_EQ(ffi_response_type("NoSuchCommand", cmd), "BbErrorResponse"); } +// Warmup runs the prover's hot loops on self-made inputs; they must be shaped so that +// the unsafe MSM path never meets two equal points. +TEST(CBind, WarmupSucceeds) +{ + EXPECT_EQ(ffi_response_type("BbWarmup", wire::BbWarmup{}), "BbWarmupResponse"); +} + #else TEST(CBind, ExceptionsDisabled) { diff --git a/barretenberg/cpp/src/barretenberg/common/wasm_export.hpp b/barretenberg/cpp/src/barretenberg/common/wasm_export.hpp index cab57a90e1f8..2020b5ee86d7 100644 --- a/barretenberg/cpp/src/barretenberg/common/wasm_export.hpp +++ b/barretenberg/cpp/src/barretenberg/common/wasm_export.hpp @@ -9,11 +9,4 @@ #define ASYNC_WASM_EXPORT extern "C" __attribute__((visibility("default"))) #endif -#ifdef __wasm__ -// Allow linker to not link this -#define WASM_IMPORT(name) extern "C" __attribute__((import_module("env"), import_name(name))) -#else -#define WASM_IMPORT(name) extern "C" -#endif - using uint8_vec_vec_in_buf = uint8_t const*; diff --git a/barretenberg/cpp/src/barretenberg/env/hardware_concurrency.hpp b/barretenberg/cpp/src/barretenberg/env/hardware_concurrency.hpp index 44124b452a18..afa50cd1a6ee 100644 --- a/barretenberg/cpp/src/barretenberg/env/hardware_concurrency.hpp +++ b/barretenberg/cpp/src/barretenberg/env/hardware_concurrency.hpp @@ -2,4 +2,4 @@ #include "barretenberg/common/wasm_export.hpp" #include -WASM_IMPORT("env_hardware_concurrency") uint32_t env_hardware_concurrency(); \ No newline at end of file +extern "C" uint32_t env_hardware_concurrency(); \ No newline at end of file diff --git a/barretenberg/cpp/src/barretenberg/env/logstr.hpp b/barretenberg/cpp/src/barretenberg/env/logstr.hpp index 6503b88dcdd6..8f6c930a8cba 100644 --- a/barretenberg/cpp/src/barretenberg/env/logstr.hpp +++ b/barretenberg/cpp/src/barretenberg/env/logstr.hpp @@ -1,10 +1,8 @@ -// To be provided by the environment. -// For a WASM build, this is provided by the JavaScript environment. -// For a native build, this is provided in this module. +// Defined by this module for native builds, and by barretenberg/wasi for the wasm reactor. #include "barretenberg/common/wasm_export.hpp" #include -WASM_IMPORT("logstr") void logstr(char const*); +extern "C" void logstr(char const*); // Returns the peak RSS in bytes for the current process, or 0 on failure / unsupported platform. std::size_t peak_rss_bytes(); diff --git a/barretenberg/cpp/src/barretenberg/env/throw_or_abort_impl.hpp b/barretenberg/cpp/src/barretenberg/env/throw_or_abort_impl.hpp index 000c950a0fd7..f5587b3537ba 100644 --- a/barretenberg/cpp/src/barretenberg/env/throw_or_abort_impl.hpp +++ b/barretenberg/cpp/src/barretenberg/env/throw_or_abort_impl.hpp @@ -1,6 +1,4 @@ -// To be provided by the environment. -// For a WASM build, this is provided by the JavaScript environment. -// For a native build, this is provided in this module. +// Defined by this module for native builds, and by barretenberg/wasi for the wasm reactor. #include "barretenberg/common/wasm_export.hpp" -WASM_IMPORT("throw_or_abort_impl") void throw_or_abort_impl [[noreturn]] (char const*); +extern "C" void throw_or_abort_impl [[noreturn]] (char const*); diff --git a/barretenberg/cpp/src/barretenberg/wasi/wasi_stubs.cpp b/barretenberg/cpp/src/barretenberg/wasi/wasi_stubs.cpp deleted file mode 100644 index 31258e97195e..000000000000 --- a/barretenberg/cpp/src/barretenberg/wasi/wasi_stubs.cpp +++ /dev/null @@ -1,260 +0,0 @@ -// If building WASM, we can stub out functions we know we don't need, to save the host -// environment from having to stub them itself. -#include -#include -#include -#include - -extern "C" { - -int32_t __imported_wasi_snapshot_preview1_sched_yield() -{ - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_poll_oneoff(int32_t, int32_t, int32_t, int32_t) -{ - info("poll_oneoff not implemented."); - abort(); -} - -// void __imported_wasi_snapshot_preview1_proc_exit(int32_t) -// { -// info("proc_exit not implemented."); -// abort(); -// } - -struct iovs_struct { - char* data; - size_t len; -}; - -int32_t __imported_wasi_snapshot_preview1_fd_write(int32_t fd, iovs_struct* iovs_ptr, size_t iovs_len, size_t* ret_ptr) -{ - if (fd != 1 && fd != 2) { - info("fd_write to unsupported file descriptor: ", fd); - abort(); - } - std::string str; - for (size_t i = 0; i < iovs_len; ++i) { - auto iovs = iovs_ptr[i]; - str += std::string(iovs.data, iovs.len); - } - logstr(str.c_str()); - *ret_ptr = str.length(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_fd_seek(int32_t, int64_t, int32_t, int32_t) -{ - info("fd_seek not implemented."); - abort(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_fd_close(int32_t) -{ - info("fd_close not implemented."); - abort(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_environ_get(int32_t environ_ptr, int32_t environ_buf_ptr) -{ - // No environment variables, so nothing to write. The pointers point to - // arrays that would hold the environ entries and the concatenated - // key=value strings respectively, but with count == 0 they are empty. - (void)environ_ptr; - (void)environ_buf_ptr; - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_environ_sizes_get(int32_t count_ptr, int32_t buf_size_ptr) -{ - // WASI requires writing the number of environment variables and the total - // buffer size needed to hold them. We have none of either. - *(int32_t*)(uintptr_t)count_ptr = 0; - *(int32_t*)(uintptr_t)buf_size_ptr = 0; - return 0; -} - -// int32_t __imported_wasi_snapshot_preview1_clock_time_get(int32_t, int64_t, int32_t) -// { -// info("clock_time_get not implemented."); -// abort(); -// return 0; -// } - -int32_t __imported_wasi_snapshot_preview1_fd_fdstat_get(int32_t fd, void* buf) -{ - // info("fd_fdstat_get not implemented."); - // abort(); - if (fd != 1 && fd != 2) { - info("fd_fdstat_get with unsupported file descriptor: ", fd); - abort(); - } - memset(buf, 0, 20); - *(uint8_t*)buf = (uint8_t)fd; - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_fd_fdstat_set_flags(int32_t, int32_t) -{ - info("fd_fdstat_set_flags not implemented."); - abort(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_fd_filestat_get(int32_t, int32_t) -{ - info("fd_filestat_get not implemented."); - abort(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_fd_filestat_set_size(int32_t, int64_t) -{ - info("fd_filestat_set_size not implemented."); - abort(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_path_create_directory(int32_t, int32_t, int32_t) -{ - info("path_create_directory not implemented."); - abort(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_fd_readdir(int32_t, int32_t, int32_t, int64_t, int32_t) -{ - info("fd_readdir not implemented."); - abort(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_fd_advise(int32_t, int64_t, int64_t, int32_t) -{ - info("fd_advise not implemented."); - abort(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_fd_allocate(int32_t, int64_t, int64_t) -{ - info("fd_allocate not implemented."); - abort(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_fd_datasync(int32_t) -{ - info("fd_datasync not implemented."); - abort(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_fd_sync(int32_t) -{ - info("fd_sync not implemented."); - abort(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_fd_renumber(int32_t, int32_t) -{ - info("fd_renumber not implemented."); - abort(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_fd_tell(int32_t, uint64_t*) -{ - info("fd_tell stubbed."); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_fd_read(int32_t, int32_t, int32_t, int32_t) -{ - info("fd_read not implemented."); - abort(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_path_open( - int32_t, int32_t, int32_t, int32_t, int32_t, int64_t, int64_t, int32_t, int32_t) -{ - info("path_open not implemented."); - abort(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_fd_prestat_get(int32_t, int32_t) -{ - // info("fd_prestat_get not implemented."); - // abort(); - return 8; -} - -int32_t __imported_wasi_snapshot_preview1_fd_prestat_dir_name(int32_t, int32_t, int32_t) -{ - info("fd_prestat_dir_name not implemented."); - abort(); - return 28; -} - -int32_t __imported_wasi_snapshot_preview1_path_filestat_get(int32_t, int32_t, int32_t, int32_t, int32_t) -{ - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_path_filestat_set_times( - int32_t, int32_t, int32_t, int32_t, int64_t, int64_t, int32_t) -{ - info("path_filestat_set_times not implemented."); - abort(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_path_link(int32_t, int32_t, int32_t, int32_t, int32_t, int32_t, int32_t) -{ - info("path_link not implemented."); - abort(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_path_readlink(int32_t, int32_t, int32_t, int32_t, int32_t, int32_t) -{ - info("path_readlink not implemented."); - abort(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_path_remove_directory(int32_t, int32_t, int32_t) -{ - info("path_remove_directory not implemented."); - abort(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_path_rename(int32_t, int32_t, int32_t, int32_t, int32_t, int32_t) -{ - info("path_rename not implemented."); - abort(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_path_symlink(int32_t, int32_t, int32_t, int32_t, int32_t) -{ - info("path_symlink not implemented."); - abort(); - return 0; -} - -int32_t __imported_wasi_snapshot_preview1_path_unlink_file(int32_t, int32_t, int32_t) -{ - info("path_unlink_file not implemented."); - abort(); - return 0; -} -} diff --git a/barretenberg/cpp/src/barretenberg/wasi/wasm_env.cpp b/barretenberg/cpp/src/barretenberg/wasi/wasm_env.cpp new file mode 100644 index 000000000000..8f2e074fe37f --- /dev/null +++ b/barretenberg/cpp/src/barretenberg/wasi/wasm_env.cpp @@ -0,0 +1,45 @@ +/** + * The three environment functions barretenberg declares in barretenberg/env, implemented for the + * wasm reactor. The native build gets them from the env module; this one gets them from here, so + * the module asks its host for nothing but WASI. + * + * Everything here reaches the host through wasi-libc, which is why barretenberg/wasi carries no + * WASI stubs of its own: a module that answers its own fd_write cannot have its output routed by + * whoever embeds it. + */ +#include "barretenberg/common/wasm_export.hpp" +#include +#include +#include + +extern "C" { + +// WASM_EXPORT ensures these symbols stay visible when compiling with -fvisibility=hidden. + +/** Logs to stderr with the module's linear memory size, the wasm counterpart of native peak RSS. */ +WASM_EXPORT void logstr(char const* msg) +{ + constexpr size_t PAGES_PER_MIB = 16; // 64 KiB pages + const size_t mib = __builtin_wasm_memory_size(0) / PAGES_PER_MIB; + std::cerr << msg << " (mem: " << mib << " MiB)\n"; +} + +/** + * Only reached when HARDWARE_CONCURRENCY is unset, which an embedder is expected to put in the + * module's WASI environment. Wasm has no way to ask how many cores the machine has. + */ +WASM_EXPORT uint32_t env_hardware_concurrency() +{ + return 1; +} + +/** + * The wasm build compiles without exceptions, so a throw cannot unwind: report and exit. The + * embedder sees proc_exit, which its WASI implementation turns back into an exception. + */ +WASM_EXPORT void throw_or_abort_impl [[noreturn]] (char const* err) +{ + std::cerr << "abort: " << err << "\n" << std::flush; + _Exit(1); +} +} diff --git a/barretenberg/ts/.gitignore b/barretenberg/ts/.gitignore index ba0a722c837f..ecde99a30730 100644 --- a/barretenberg/ts/.gitignore +++ b/barretenberg/ts/.gitignore @@ -8,6 +8,7 @@ bb.js/build bb.js/.tsbuildinfo* bb.js/*.tsbuildinfo bb-avm-sim/ +bb.js-api/ cdb/ .tsbuildinfo* *.tsbuildinfo @@ -16,6 +17,3 @@ cdb/ package.tgz package packages/ - -# Generated files -bb.js/src/generated/ diff --git a/barretenberg/ts/bb.js/.prettierignore b/barretenberg/ts/bb.js/.prettierignore index 0d4529771b2f..e69de29bb2d1 100644 --- a/barretenberg/ts/bb.js/.prettierignore +++ b/barretenberg/ts/bb.js/.prettierignore @@ -1,4 +0,0 @@ -# Codegen output: regenerated by `yarn generate`, never hand-edited, and not -# emitted in prettier's style. It is gitignored, but CI runners reuse working -# directories, so it can be present when `yarn formatting` runs. -src/generated/ diff --git a/barretenberg/ts/bb.js/README.md b/barretenberg/ts/bb.js/README.md index 28cbb4d8cd86..22ac40ed0963 100644 --- a/barretenberg/ts/bb.js/README.md +++ b/barretenberg/ts/bb.js/README.md @@ -44,6 +44,25 @@ If `1` is specified, fallback to non multi-threaded wasm that doesn't need share See `src/main.ts` for larger example of how to use. +### How bb is reached + +The typed API (`Barretenberg` extends it) and every way of reaching bb come from the +`@aztec-foundation/bb.js-api` package, generated from bb's schema by ipc-codegen; bb.js adds the facades, +its `BackendType` options and CRS handling. That package ships the bb binary as per-platform optional +dependencies (override with `bbPath` or `BB_BINARY_PATH`) and bb's wasm modules (single-thread and threads +builds), run in-process through `@aztec-foundation/ipc-runtime/wasm`: the module in a worker, wasi threads +on further workers. Pass `warmup: true` to run bb's `Warmup` command after initialization, which takes the +prover's hot loops through the engine's optimizing tier before the first real request. bb.js itself only +bundles bb's LMDB NAPI module (`findNapiBinary`). + +The wasm modules ship uncompressed and are fetched as ordinary assets, so the browser streams them +straight into `WebAssembly.compileStreaming` and can cache the compiled code between visits. That means +serving them with your host's own compression: most CDNs compress `application/wasm` by default, but nginx +does not unless `application/wasm` is added to `gzip_types`. Where you cannot compress on the wire, pass +`wasmPath` (or set `BB_WASM_PATH` in node) pointing at a gzipped copy — the loader recognises gzip, at the +cost of the compiled-code cache. `wasmPath` also takes any other build of the module, and is used verbatim: +unlike earlier versions, bb.js no longer rewrites the filename to pick a `-threads` variant. + ### Browser Context It's recommended to use a dynamic import. This allows the developer to pick the time at which the package (several MB @@ -53,6 +72,11 @@ in size) is loaded and keeps page load times responsive. const { Barretenberg, RawBuffer, Crs } = await import('@aztec-foundation/bb.js'); ``` +The worker scripts and the wasm modules are referenced with `new URL('...', import.meta.url)` (the workers as +`new Worker(new URL(...), { type: 'module' })`), which webpack 5, Vite and similar bundlers turn into chunks and +assets of your application. Vite users should exclude `@aztec-foundation/bb.js` and `@aztec-foundation/bb.js-api` +from `optimizeDeps`, so those references are resolved from the packages rather than from a pre-bundled copy. + ### Multithreading in browser Multithreading in bb.js requires [`SharedArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer) to be enabled. It is only enabled in browsers if COOP and COEP headers are set by the server. Read more [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements). @@ -82,10 +106,13 @@ You can enable these headers for specific pages that perform proof generation, b ## Debugging -Got an unhelpful stack trace in wasm? Run: +Got an unhelpful stack trace in wasm? Point bb.js at the unstripped module the wasm build leaves next to the +stripped one: ``` -BUILD_CPP=1 NO_STRIP=1 ./script/copy_wasm.sh +BB_WASM_PATH=$(git rev-parse --show-toplevel)/barretenberg/cpp/build-wasm-threads/bin/barretenberg-debug.wasm ``` -This will drop unstripped wasms into the dest folder. Run your test again to get a trace. +(the loader takes a `.wasm` or a `.wasm.gz` either way) + +Run your test again to get a trace. diff --git a/barretenberg/ts/bb.js/bootstrap.sh b/barretenberg/ts/bb.js/bootstrap.sh index bd905c6953a0..cb1fde4154d4 100755 --- a/barretenberg/ts/bb.js/bootstrap.sh +++ b/barretenberg/ts/bb.js/bootstrap.sh @@ -13,10 +13,9 @@ hash=$(hash_str \ ${AVM_TRANSPILER:-1}) function prepare_project { - (cd .. && ./bootstrap.sh generate_packages) - # Same cache-key inputs as barretenberg/ts/bootstrap.sh: the workspaces - # portal into ipc-runtime/ts, so its manifest belongs in the key. - (cd .. && npm_install_deps "^ipc-runtime/ts/package\.json$") + # Generates the workspace packages (including @aztec-foundation/bb.js-api, which bb.js + # compiles against) and installs; same cache-key inputs as barretenberg/ts/bootstrap.sh. + (cd .. && ./bootstrap.sh build_bb_js_api_ts) } function formatting { @@ -27,14 +26,14 @@ function formatting { function build { echo_header "bb.js build" - prepare_project + # bb.js compiles against @aztec-foundation/bb.js-api and runs the wasm modules and bb binary + # that package stages, so stage them whether or not bb.js's own build is cached. + (cd .. && ./bootstrap.sh build_bb_js_api) yarn formatting if ! cache_download bb.js-$hash.tar.gz; then find . -exec touch -d "@0" {} + 2>/dev/null || true yarn clean - yarn generate - yarn build:wasm yarn build:native parallel -v --line-buffered --tag 'denoise "yarn {}"' ::: build:esm build:cjs build:browser cache_upload bb.js-$hash.tar.gz dest build @@ -57,16 +56,11 @@ function test_cmds { for test in **/*.test.js; do # Skip benchmarks here. [[ "$test" =~ \.bench\.test\.js$ ]] && continue - [[ "$test" == "bbapi/chonk_pinned_inputs.test.js" ]] && continue + [[ "$test" == "barretenberg/chonk_pinned_inputs.test.js" ]] && continue - local prefix=$hash - # Extra resource. - if [[ "$test" =~ ^examples/ ]]; then - prefix="$prefix:CPUS=16" - fi - echo "$prefix barretenberg/ts/bb.js/scripts/run_test.sh $test" + echo "$hash barretenberg/ts/bb.js/scripts/run_test.sh $test" done - echo "$hash:CPUS=8:MEM=32g:TIMEOUT=20m barretenberg/cpp/scripts/chonk_inputs.sh download && barretenberg/ts/bb.js/scripts/run_test.sh bbapi/chonk_pinned_inputs.test.js" + echo "$hash:CPUS=8:MEM=32g:TIMEOUT=20m barretenberg/cpp/scripts/chonk_inputs.sh download && barretenberg/ts/bb.js/scripts/run_test.sh barretenberg/chonk_pinned_inputs.test.js" } function bench_cmds { @@ -80,10 +74,6 @@ function test { function release { cross_copy - # The bundled binaries are restored from the build cache, which is keyed on source, not on - # the release: finalize them here so they carry this release's version like every other copy. - local f - for f in ./build/*/bb ./build/*/bb.exe; do [ -f "$f" ] && ../../cpp/bootstrap.sh finalize_bb_binary "$(realpath "$f")"; done retry "deploy_npm ${REF_NAME#v}" } diff --git a/barretenberg/ts/bb.js/eslint.config.js b/barretenberg/ts/bb.js/eslint.config.js index 2f7fa274213c..ec78cb464243 100644 --- a/barretenberg/ts/bb.js/eslint.config.js +++ b/barretenberg/ts/bb.js/eslint.config.js @@ -22,8 +22,6 @@ export default [ 'eslint.config.js', 'eslint.config.*.js', 'src/jest/*.mjs', - // Codegen output; see .prettierignore. - 'src/generated/**', ]), ...tseslint.config({ extends: [ @@ -92,13 +90,7 @@ export default [ curly: ['error', 'all'], camelcase: 'error', 'import-x/no-relative-packages': 'error', - 'import-x/no-unresolved': [ - 'error', - { - // Generated later in bootstrap; the tracked wasm symlinks are broken in a clean checkout until the C++ build runs. - ignore: ['generated', '\\.wasm\\.gz$'], - }, - ], + 'import-x/no-unresolved': 'error', 'import-x/no-extraneous-dependencies': 'error', // this unfortunately doesn't block `fit` and `fdescribe` 'no-only-tests/no-only-tests': ['error'], diff --git a/barretenberg/ts/bb.js/package.json b/barretenberg/ts/bb.js/package.json index 2e50525bd178..50ce62a27b78 100644 --- a/barretenberg/ts/bb.js/package.json +++ b/barretenberg/ts/bb.js/package.json @@ -13,7 +13,7 @@ "default": "./dest/node/index.js" }, "./platform": { - "default": "./dest/node/bb_backends/node/platform.js" + "default": "./dest/node/backends/node/platform.js" } }, "bin": { @@ -26,20 +26,17 @@ "README.md" ], "scripts": { - "clean": "rm -rf ./dest .tsbuildinfo .tsbuildinfo.cjs ./src/generated", - "build": "yarn clean && yarn generate && yarn build:wasm && yarn build:native && yarn build:esm && yarn build:cjs && yarn build:browser", - "build:wasm": "./scripts/copy_wasm.sh", + "clean": "rm -rf ./dest .tsbuildinfo .tsbuildinfo.cjs", + "build": "yarn clean && yarn build:native && yarn build:esm && yarn build:cjs && yarn build:browser", "build:native": "./scripts/copy_native.sh", "build:esm": "tsgo -b tsconfig.esm.json && chmod +x ./dest/node/bin/index.js", "build:cjs": "tsgo -b tsconfig.cjs.json && ./scripts/cjs_postprocess.sh", "build:browser": "tsgo -b tsconfig.browser.json && ./scripts/browser_postprocess.sh", - "generate": "./scripts/generate.sh", "formatting": "prettier --check ./src && eslint --max-warnings 0 ./src", "formatting:fix": "eslint --fix ./src && prettier -w ./src", "test": "NODE_OPTIONS='--loader ts-node/esm' NODE_NO_WARNINGS=1 node --experimental-vm-modules $(yarn bin jest) --no-cache --passWithNoTests", "test:chonk-inputs": "yarn test chonk_pinned_inputs", "test:debug": "NODE_NO_WARNINGS=1 node --inspect-brk=0.0.0.0 --experimental-vm-modules ./node_modules/.bin/jest --no-cache --passWithNoTests --runInBand", - "simple_test": "NODE_NO_WARNINGS=1 node ./src/examples/simple.rawtest.ts", "deploy": "npm publish --access public" }, "jest": { @@ -70,8 +67,8 @@ "rootDir": "./src" }, "dependencies": { + "@aztec-foundation/bb.js-api": "workspace:^", "@aztec-foundation/ipc-runtime": "@aztec-foundation/ipc-runtime", - "comlink": "^4.4.1", "commander": "^12.1.0", "idb-keyval": "^6.2.1", "msgpackr": "^1.11.2", diff --git a/barretenberg/ts/bb.js/scripts/browser_postprocess.sh b/barretenberg/ts/bb.js/scripts/browser_postprocess.sh index ad3e1a091c18..82442186050c 100755 --- a/barretenberg/ts/bb.js/scripts/browser_postprocess.sh +++ b/barretenberg/ts/bb.js/scripts/browser_postprocess.sh @@ -9,11 +9,3 @@ done # Replace all **/node/** imports and exports with **/browser/** find "$DIR" -type f -name "*.js" -exec sed -i 's/\(import\|export\)\(.*\)from\(.*\)\/node\//\1\2from\3\/browser\//g' {} + - -# Provide default wasm files as gziped base64 strings -for file in barretenberg barretenberg-threads; do - GZIP_FILE=${DIR}/barretenberg_wasm/$file.wasm.gz - BB_BASE64=$(cat ${GZIP_FILE} | base64 -w0) - printf "const barretenberg = \"data:application/gzip;base64,$BB_BASE64\"; \\nexport default barretenberg;" > $DIR/barretenberg_wasm/fetch_code/browser/$file.js - rm $GZIP_FILE -done diff --git a/barretenberg/ts/bb.js/scripts/cjs_postprocess.sh b/barretenberg/ts/bb.js/scripts/cjs_postprocess.sh index b167435ef72c..def40b0aeef6 100755 --- a/barretenberg/ts/bb.js/scripts/cjs_postprocess.sh +++ b/barretenberg/ts/bb.js/scripts/cjs_postprocess.sh @@ -5,12 +5,9 @@ cat >dest/node-cjs/package.json < { ... }))` only works if one's happy with each element handler - * being run concurrently. - * If one required sequential execution of async fn's, the only alternative was regular loops with mutable state vars. - * The equivalent with asyncMap: `await asyncMap(arr, async e => { ... })`. - */ -export async function asyncMap(arr: T[], fn: (e: T, i: number) => Promise): Promise { - const results: U[] = []; - for (let i = 0; i < arr.length; ++i) { - results.push(await fn(arr[i], i)); - } - return results; -} diff --git a/barretenberg/ts/bb.js/src/backends/browser/index.ts b/barretenberg/ts/bb.js/src/backends/browser/index.ts new file mode 100644 index 000000000000..9568785de165 --- /dev/null +++ b/barretenberg/ts/bb.js/src/backends/browser/index.ts @@ -0,0 +1,54 @@ +import { createBackend, createBackendSync, sharedMemoryAvailable } from '@aztec-foundation/bb.js-api'; +import type { IpcClientAsync, IpcClientSync } from '@aztec-foundation/ipc-runtime'; + +import { BackendOptions, BackendType } from '../index.js'; + +/** + * Create backend of specific type (no fallback) + */ +export async function createAsyncBackend( + type: BackendType, + options: BackendOptions, + logger: (msg: string) => void, +): Promise { + switch (type) { + case BackendType.Wasm: + case BackendType.WasmWorker: { + const worker = type === BackendType.WasmWorker; + logger(`Using WASM backend (worker: ${worker})`); + return await createBackend({ + backend: 'wasm', + // bb.js's documented behaviour: a page served without COOP/COEP gets the single-threaded + // module, whatever thread count was asked for. + threads: sharedMemoryAvailable() ? options.threads : 1, + logger, + wasm: { module: options.wasmPath, memory: options.memory, worker }, + }); + } + + default: + throw new Error(`Unknown backend type: ${type}`); + } +} + +/** + * Create backend of specific type (no fallback) + */ +export async function createSyncBackend( + type: BackendType, + options: BackendOptions, + logger: (msg: string) => void, +): Promise { + switch (type) { + case BackendType.Wasm: + logger('Using WASM backend'); + return await createBackendSync({ + backend: 'wasm', + logger, + wasm: { module: options.wasmPath, memory: options.memory }, + }); + + default: + throw new Error(`Backend ${type} not supported for BarretenbergSync`); + } +} diff --git a/barretenberg/ts/bb.js/src/bb_backends/browser/platform.ts b/barretenberg/ts/bb.js/src/backends/browser/platform.ts similarity index 100% rename from barretenberg/ts/bb.js/src/bb_backends/browser/platform.ts rename to barretenberg/ts/bb.js/src/backends/browser/platform.ts diff --git a/barretenberg/ts/bb.js/src/bb_backends/index.ts b/barretenberg/ts/bb.js/src/backends/index.ts similarity index 84% rename from barretenberg/ts/bb.js/src/bb_backends/index.ts rename to barretenberg/ts/bb.js/src/backends/index.ts index 760f51fe9da0..5203fe0a716f 100644 --- a/barretenberg/ts/bb.js/src/bb_backends/index.ts +++ b/barretenberg/ts/bb.js/src/backends/index.ts @@ -25,9 +25,18 @@ export type BackendOptions = { /** @description Number of G1 points to download when initializing the CRS/SRS for WASM backends */ srsSize?: number; - /** @description Path to download WASM files */ + /** + * @description A bb wasm module to run instead of the one bundled with @aztec-foundation/bb.js-api: + * a file path (node) or URL of the (optionally gzipped) module. Defaults to BB_WASM_PATH in node. + */ wasmPath?: string; + /** + * @description After initializing a WASM backend, run bb's Warmup command: a pass over the + * prover's hot loops so the engine has optimized them before the first real request. + */ + warmup?: boolean; + /** @description Custom path to bb binary for native backend (overrides automatic detection) */ bbPath?: string; diff --git a/barretenberg/ts/bb.js/src/backends/node/index.ts b/barretenberg/ts/bb.js/src/backends/node/index.ts new file mode 100644 index 000000000000..c2a45d6d54f6 --- /dev/null +++ b/barretenberg/ts/bb.js/src/backends/node/index.ts @@ -0,0 +1,116 @@ +import { createBackend, createBackendSync } from '@aztec-foundation/bb.js-api'; +import type { IpcClientAsync, IpcClientSync } from '@aztec-foundation/ipc-runtime'; +import * as os from 'os'; + +import { BackendOptions, BackendType } from '../index.js'; + +// Shared-memory rings sized for bb's payloads (witnesses, proofs); the async backend pipelines, so +// it gets a response ring of the same size. +const SHM_RING_SIZE = 1024 * 1024 * 4; + +/** + * An idle bb never keeps the caller's process alive: it dies with its parent anyway, since + * ipc-runtime's C++ installs that watch and the generated serve() calls it. Starting up and + * calls in flight still hold the loop, so this costs nothing but the child's trailing log lines + * if the process exits mid-line. + */ +const BB_PROCESS_LIFETIME = { unref: true }; + +/** + * Create backend of specific type (no fallback). Everything here is bb's choice of options over + * @aztec-foundation/bb.js-api's backends: thread defaults, ring sizes, artifact overrides. + */ +export async function createAsyncBackend( + type: BackendType, + options: BackendOptions, + logger: (msg: string) => void, +): Promise { + const wasmPath = options.wasmPath ?? process.env.BB_WASM_PATH; + + switch (type) { + case BackendType.NativeUnixSocket: + logger('Using native Unix socket backend'); + return await createBackend({ + backend: 'process', + // If threads not set use num cpu cores, max 16. + threads: options.threads ?? Math.min(16, os.cpus().length), + logger: options.logger, + process: { binaryPath: options.bbPath, transport: 'uds', ...BB_PROCESS_LIFETIME }, + }); + + case BackendType.NativeSharedMemory: + logger('Using native shared memory async backend'); + return await createBackend({ + backend: 'process', + threads: options.threads ?? 16, + logger: options.logger, + process: { + binaryPath: options.bbPath, + transport: 'shm', + clientId: 0, + napiPath: options.napiPath, + extraArgs: ['--request-ring-size', `${SHM_RING_SIZE}`, '--response-ring-size', `${SHM_RING_SIZE}`], + ...BB_PROCESS_LIFETIME, + }, + }); + + case BackendType.Wasm: + case BackendType.WasmWorker: { + // WasmWorker hosts the module in a worker thread; Wasm runs it on the calling thread, where + // every call blocks until bb returns. + const worker = type === BackendType.WasmWorker; + logger(`Using WASM backend (worker: ${worker})`); + return await createBackend({ + backend: 'wasm', + threads: options.threads, + logger: options.logger, + unref: options.unref, + wasm: { module: wasmPath, memory: options.memory, worker }, + }); + } + + default: + throw new Error(`Unknown backend type: ${type}`); + } +} + +/** + * Create backend of specific type (no fallback) + */ +export async function createSyncBackend( + type: BackendType, + options: BackendOptions, + logger: (msg: string) => void, +): Promise { + const wasmPath = options.wasmPath ?? process.env.BB_WASM_PATH; + + switch (type) { + case BackendType.NativeSharedMemory: + logger('Using native shared memory backend'); + return await createBackendSync({ + backend: 'process', + // Sync callers do short, one-at-a-time requests: one thread, one request ring. + threads: options.threads ?? 1, + logger: options.logger, + process: { + binaryPath: options.bbPath, + transport: 'shm', + napiPath: options.napiPath, + extraArgs: ['--request-ring-size', `${SHM_RING_SIZE}`], + ...BB_PROCESS_LIFETIME, + }, + }); + + case BackendType.Wasm: + logger('Using WASM backend'); + return await createBackendSync({ + backend: 'wasm', + logger: options.logger, + unref: options.unref, + wasm: { module: wasmPath, memory: options.memory }, + }); + + default: + throw new Error(`Backend ${type} not supported for BarretenbergSync`); + } +} diff --git a/barretenberg/ts/bb.js/src/bb_backends/node/platform.ts b/barretenberg/ts/bb.js/src/backends/node/platform.ts similarity index 69% rename from barretenberg/ts/bb.js/src/bb_backends/node/platform.ts rename to barretenberg/ts/bb.js/src/backends/node/platform.ts index 3decbd611f5a..a44f29a809e8 100644 --- a/barretenberg/ts/bb.js/src/bb_backends/node/platform.ts +++ b/barretenberg/ts/bb.js/src/backends/node/platform.ts @@ -2,6 +2,10 @@ import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; +// The bb binary ships with @aztec-foundation/bb.js-api (per-platform optional dependencies, or +// BB_BINARY_PATH); bb.js itself only carries the LMDB NAPI module below. +export { findBbBinary } from '@aztec-foundation/bb.js-api'; + function getCurrentDir() { if (typeof __dirname !== 'undefined') { return __dirname; @@ -77,58 +81,10 @@ export function detectPlatform(): Platform | null { } /** - * Find the bb binary for the native backend. - * @param customPath Optional custom path to bb binary (overrides automatic detection) - * @returns Absolute path to bb binary, or null if not found - * - * Search order: - * 1. If customPath is provided and exists, return it - * 2. If BB_BINARY_PATH is set and exists, return it - * 3. Otherwise search in /build//bb + * Find bb's LMDB NAPI module (nodejs_module.node) bundled with this package. + * @param customPath Optional custom path (overrides automatic detection) + * @returns Absolute path to the module, or null if not found */ -export function findBbBinary(customPath?: string): string | null { - // Check custom path first if provided - if (customPath) { - if (fs.existsSync(customPath)) { - return path.resolve(customPath); - } - // Custom path provided but doesn't exist - return null - return null; - } - - const envPath = process.env.BB_BINARY_PATH; - if (envPath) { - if (fs.existsSync(envPath)) { - return path.resolve(envPath); - } - return null; - } - - // Automatic detection - const platform = detectPlatform(); - if (!platform) { - return null; - } - - const buildDir = PLATFORM_TO_BUILD_DIR[platform]; - - // Get package root by climbing directory tree to find package.json - const packageRoot = findPackageRoot(); - - if (!packageRoot) { - return null; - } - - // Check in build//bb - const bbPath = path.join(packageRoot, 'build', buildDir, 'bb'); - - if (fs.existsSync(bbPath)) { - return bbPath; - } - - return null; -} - export function findNapiBinary(customPath?: string): string | null { // Check custom path first if provided if (customPath) { diff --git a/barretenberg/ts/bb.js/src/backends/wasm.test.ts b/barretenberg/ts/bb.js/src/backends/wasm.test.ts new file mode 100644 index 000000000000..821eaf106e7b --- /dev/null +++ b/barretenberg/ts/bb.js/src/backends/wasm.test.ts @@ -0,0 +1,72 @@ +import { createHash } from 'crypto'; + +import { BackendType, Barretenberg, BarretenbergSync } from '../index.js'; + +// The WASM backends come from @aztec-foundation/bb.js-api (the generic wasm FFI backend of +// ipc-runtime over bb's module); bb.js only selects and configures them. +describe('wasm backends', () => { + const input = Buffer.from('hello bb.js-api'); + const expected = createHash('blake2s256').update(input).digest(); + + it.each([BackendType.Wasm, BackendType.WasmWorker])( + '%s hashes with 4 threads', + async backend => { + const api = await Barretenberg.new({ backend, threads: 4, skipSrsInit: true }); + try { + const { hash } = await api.blake2s({ data: input }); + expect(Buffer.from(hash)).toEqual(expected); + } finally { + await api.destroy(); + } + }, + 60000, + ); + + it('runs bb Warmup on request and keeps answering', async () => { + const api = await Barretenberg.new({ + backend: BackendType.WasmWorker, + threads: 4, + skipSrsInit: true, + warmup: true, + }); + try { + const { hash } = await api.blake2s({ data: input }); + expect(Buffer.from(hash)).toEqual(expected); + } finally { + await api.destroy(); + } + }, 120000); + + it('sync backend runs single-threaded on the calling thread', async () => { + const api = await BarretenbergSync.new({ backend: BackendType.Wasm }); + try { + expect(Buffer.from(api.blake2s({ data: input }).hash)).toEqual(expected); + } finally { + api.destroy(); + } + }, 60000); + + it('surfaces bb errors as exceptions, with the message bb gave', () => { + // Buffers too small for the point count they claim: bb throws inside from_buffer, and under + // BB_NO_EXCEPTIONS (which wasm builds with) that becomes an abort the host turns into a throw. + const tooSmall = { numPoints: 100, pointsBuf: new Uint8Array(10), g2Point: new Uint8Array(10) }; + return BarretenbergSync.new({ backend: BackendType.Wasm }).then(api => { + try { + expect(() => api.srsInitSrs(tooSmall)).toThrow(/invalid points_buf size/); + } finally { + api.destroy(); + } + }); + }, 60000); + + it('surfaces bb errors as exceptions asynchronously', async () => { + const api = await Barretenberg.new({ backend: BackendType.WasmWorker, threads: 1, skipSrsInit: true }); + try { + await expect( + api.srsInitSrs({ numPoints: 100, pointsBuf: new Uint8Array(10), g2Point: new Uint8Array(10) }), + ).rejects.toThrow(/invalid points_buf size/); + } finally { + await api.destroy(); + } + }, 60000); +}); diff --git a/barretenberg/ts/bb.js/src/barretenberg/backend.ts b/barretenberg/ts/bb.js/src/barretenberg/backend.ts index bd622e087e61..1fb00b445b2b 100644 --- a/barretenberg/ts/bb.js/src/barretenberg/backend.ts +++ b/barretenberg/ts/bb.js/src/barretenberg/backend.ts @@ -1,9 +1,9 @@ +import { ChonkProof, fromChonkProof, toChonkProof } from '@aztec-foundation/bb.js-api'; import { Decoder, Encoder } from 'msgpackr'; import { ungzip } from 'pako'; import { CircuitKind } from '../circuit_kind.js'; -import { ChonkProof, fromChonkProof, toChonkProof } from '../generated/api_types.js'; -import { ProofData, hexToUint8Array, uint8ArrayToHex } from '../proof/index.js'; +import { ProofData, hexToUint8Array, uint8ArrayToHex } from '../proof.js'; import type { Barretenberg } from './index.js'; export class AztecClientBackendError extends Error { diff --git a/barretenberg/ts/bb.js/src/bbapi/chonk_pinned_inputs.test.ts b/barretenberg/ts/bb.js/src/barretenberg/chonk_pinned_inputs.test.ts similarity index 100% rename from barretenberg/ts/bb.js/src/bbapi/chonk_pinned_inputs.test.ts rename to barretenberg/ts/bb.js/src/barretenberg/chonk_pinned_inputs.test.ts diff --git a/barretenberg/ts/bb.js/src/barretenberg/errors.test.ts b/barretenberg/ts/bb.js/src/barretenberg/errors.test.ts new file mode 100644 index 000000000000..5ce81b2c8245 --- /dev/null +++ b/barretenberg/ts/bb.js/src/barretenberg/errors.test.ts @@ -0,0 +1,63 @@ +import { BbError } from '@aztec-foundation/bb.js-api'; + +import { BackendType, Barretenberg } from './index.js'; + +/** + * A failure bb reports with BBAPI_ERROR: an off-curve point, rejected before any work is done. + * The handler returns an error response, so the reply is an error frame on every backend. + */ +function reportedFailure(api: Barretenberg) { + const one = new Uint8Array(32); + one[31] = 1; + const five = new Uint8Array(32); + five[31] = 5; + return api.bn254G1Mul({ point: { x: one, y: one }, scalar: five }); +} + +/** + * A failure bb raises with throw_or_abort: a compressed SRS buffer that is not chunk-aligned. + * The handler throws rather than returning, which is the case the two backends diverge on. + */ +function thrownFailure(api: Barretenberg) { + return api.srsInitSrs({ pointsBuf: new Uint8Array(32), numPoints: 1, g2Point: new Uint8Array(128) }); +} + +describe('command errors', () => { + let native: Barretenberg; + let wasm: Barretenberg; + + beforeAll(async () => { + native = await Barretenberg.new({ backend: BackendType.NativeUnixSocket, threads: 1, skipSrsInit: true }); + wasm = await Barretenberg.new({ backend: BackendType.Wasm, threads: 1, skipSrsInit: true }); + }); + + afterAll(async () => { + await native?.destroy(); + await wasm?.destroy(); + }); + + it('reports a returned error as BbError on both backends', async () => { + for (const api of [native, wasm]) { + const err = await reportedFailure(api).catch(e => e); + expect(err).toBeInstanceOf(BbError); + expect(err.message).toMatch(/must be on the curve/); + } + }); + + it('reports a thrown error as BbError on native', async () => { + const err = await thrownFailure(native).catch(e => e); + expect(err).toBeInstanceOf(BbError); + expect(err.message).toMatch(/must be a positive multiple of/); + }); + + // bb's wasm build compiles with BB_NO_EXCEPTIONS, so the dispatcher's catch is compiled away and + // a throw cannot unwind: bb writes the reason to stderr and exits, and ipc-runtime turns that + // into an error carrying the text. The message survives; the type does not. Pinned so the + // divergence cannot change silently — catch Error to handle both. + it('reports a thrown error as a plain Error on wasm', async () => { + const err = await thrownFailure(wasm).catch(e => e); + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(BbError); + expect(err.message).toMatch(/must be a positive multiple of/); + }); +}); diff --git a/barretenberg/ts/bb.js/src/barretenberg/index.ts b/barretenberg/ts/bb.js/src/barretenberg/index.ts index 44a6209fa0dd..8b73f96114dc 100644 --- a/barretenberg/ts/bb.js/src/barretenberg/index.ts +++ b/barretenberg/ts/bb.js/src/barretenberg/index.ts @@ -1,10 +1,9 @@ -import { BackendOptions, BackendType } from '../bb_backends/index.js'; -import { IMsgpackBackendAsync, IMsgpackBackendSync } from '../bb_backends/interface.js'; -import { createAsyncBackend, createSyncBackend } from '../bb_backends/node/index.js'; -import { BBApiException } from '../bbapi_exception.js'; +import { AsyncApi, SyncApi } from '@aztec-foundation/bb.js-api'; +import type { IpcClientAsync } from '@aztec-foundation/ipc-runtime'; + +import { BackendOptions, BackendType } from '../backends/index.js'; +import { createAsyncBackend, createSyncBackend } from '../backends/node/index.js'; import { Crs, GrumpkinCrs } from '../crs/index.js'; -import { AsyncApi } from '../generated/async.js'; -import { SyncApi } from '../generated/sync.js'; const DEFAULT_BB_CRS_SIZE = 2 ** 19; // Keep the iOS default separate so it can diverge when mobile memory limits require it. @@ -21,7 +20,7 @@ export { type UltraHonkBackendOptions, type VerifierTarget, } from './backend.js'; -export * from '../bb_backends/index.js'; +export * from '../backends/index.js'; export type CircuitOptions = { /** @description Whether to produce SNARK friendly proofs */ @@ -35,8 +34,8 @@ export type CircuitOptions = { export class Barretenberg extends AsyncApi { private options: BackendOptions; - constructor(backend: IMsgpackBackendAsync, options: BackendOptions) { - super(backend, message => new BBApiException(message)); + constructor(backend: IpcClientAsync, options: BackendOptions) { + super(backend); this.options = options; } @@ -54,11 +53,8 @@ export class Barretenberg extends AsyncApi { if (options.backend) { // Explicit backend required - no fallback const backend = new Barretenberg(await createAsyncBackend(options.backend, options, logger), options); - if ( - !options.skipSrsInit && - (options.backend === BackendType.Wasm || options.backend === BackendType.WasmWorker) - ) { - await backend.initSRSChonk(options.srsSize); + if (options.backend === BackendType.Wasm || options.backend === BackendType.WasmWorker) { + await backend.initWasm(); } return backend; } @@ -69,21 +65,30 @@ export class Barretenberg extends AsyncApi { } catch (err: any) { logger(`Unix socket unavailable (${err.message}), falling back to WASM`); const backend = new Barretenberg(await createAsyncBackend(BackendType.Wasm, options, logger), options); - if (!options.skipSrsInit) { - await backend.initSRSChonk(options.srsSize); - } + await backend.initWasm(); return backend; } } else { logger(`In browser, using WASM over worker backend.`); const backend = new Barretenberg(await createAsyncBackend(BackendType.WasmWorker, options, logger), options); - if (!options.skipSrsInit) { - await backend.initSRSChonk(options.srsSize); - } + await backend.initWasm(); return backend; } } + /** + * A WASM backend starts with no SRS loaded (a native bb reads its own), and with its code + * unoptimized by the engine's baseline tier until it has run. + */ + private async initWasm(): Promise { + if (!this.options.skipSrsInit) { + await this.initSRSChonk(this.options.srsSize); + } + if (this.options.warmup) { + await this.warmup({}); + } + } + async initSRSChonk(srsSize = this.getDefaultSrsSize()): Promise { // crsPath can be undefined const crs = await Crs.new(srsSize, this.options.crsPath, this.options.logger); @@ -185,10 +190,6 @@ let barretenbergSyncSingletonPromise: Promise | undefined; let barretenbergSyncSingleton: BarretenbergSync | undefined; export class BarretenbergSync extends SyncApi { - constructor(backend: IMsgpackBackendSync) { - super(backend, message => new BBApiException(message)); - } - /** * Create a new BarretenbergSync instance. * diff --git a/barretenberg/ts/bb.js/src/barretenberg/pedersen.test.ts b/barretenberg/ts/bb.js/src/barretenberg/pedersen.test.ts index c8699362cf6f..9bdc1213ee58 100644 --- a/barretenberg/ts/bb.js/src/barretenberg/pedersen.test.ts +++ b/barretenberg/ts/bb.js/src/barretenberg/pedersen.test.ts @@ -1,6 +1,6 @@ -import { Timer } from '../benchmark/timer.js'; import { BarretenbergSync } from './index.js'; import { Fr } from './testing/fields.js'; +import { Timer } from './testing/timer.js'; describe('pedersen sync', () => { let api: BarretenbergSync; diff --git a/barretenberg/ts/bb.js/src/barretenberg/poseidon.bench.test.ts b/barretenberg/ts/bb.js/src/barretenberg/poseidon.bench.test.ts index de1901950d35..006c1f664b9c 100644 --- a/barretenberg/ts/bb.js/src/barretenberg/poseidon.bench.test.ts +++ b/barretenberg/ts/bb.js/src/barretenberg/poseidon.bench.test.ts @@ -1,5 +1,3 @@ -import { BarretenbergWasmMain } from '../barretenberg_wasm/barretenberg_wasm_main/index.js'; -import { fetchModuleAndThreads } from '../barretenberg_wasm/index.js'; import { Barretenberg, BarretenbergSync } from '../index.js'; import { BackendType } from './index.js'; import { Fr } from './testing/fields.js'; @@ -25,14 +23,8 @@ describe('poseidon2Hash benchmark (Async API): WASM vs Native', () => { let nativeSocketApi: Barretenberg | null = null; let nativeShmApi: Barretenberg | null = null; let nativeShmSyncApi: BarretenbergSync | null = null; - let wasm: BarretenbergWasmMain; beforeAll(async () => { - // Setup direct WASM access for baseline benchmark (always required) - wasm = new BarretenbergWasmMain(); - const { module } = await fetchModuleAndThreads(1); - await wasm.init(module, 1); - // Setup WASM API try { wasmApi = await Barretenberg.new({ backend: BackendType.Wasm, threads: 1, skipSrsInit: true }); diff --git a/barretenberg/ts/bb.js/src/barretenberg/poseidon.test.ts b/barretenberg/ts/bb.js/src/barretenberg/poseidon.test.ts index e34fe2f4a788..de060ee9b9e9 100644 --- a/barretenberg/ts/bb.js/src/barretenberg/poseidon.test.ts +++ b/barretenberg/ts/bb.js/src/barretenberg/poseidon.test.ts @@ -1,6 +1,6 @@ -import { Timer } from '../benchmark/timer.js'; import { BarretenbergSync } from './index.js'; import { Fr } from './testing/fields.js'; +import { Timer } from './testing/timer.js'; describe('poseidon sync', () => { let api: BarretenbergSync; diff --git a/barretenberg/ts/bb.js/src/benchmark/timer.ts b/barretenberg/ts/bb.js/src/barretenberg/testing/timer.ts similarity index 100% rename from barretenberg/ts/bb.js/src/benchmark/timer.ts rename to barretenberg/ts/bb.js/src/barretenberg/testing/timer.ts diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg-threads.wasm.gz b/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg-threads.wasm.gz deleted file mode 120000 index e9fd64c59ea4..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg-threads.wasm.gz +++ /dev/null @@ -1 +0,0 @@ -../../../../cpp/build-wasm-threads/bin/barretenberg.wasm.gz \ No newline at end of file diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg.wasm.gz b/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg.wasm.gz deleted file mode 120000 index e7a5a91393a9..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg.wasm.gz +++ /dev/null @@ -1 +0,0 @@ -../../../../cpp/build-wasm/bin/barretenberg.wasm.gz \ No newline at end of file diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_base/index.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_base/index.ts deleted file mode 100644 index 9b810ebba70e..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_base/index.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { randomBytes } from '../../random/index.js'; - -/** - * Base implementation of BarretenbergWasm. - * Contains code that is common to the "main thread" implementation and the "child thread" implementation. - */ -export class BarretenbergWasmBase { - protected memory!: WebAssembly.Memory; - protected instance!: WebAssembly.Instance; - protected logger: (msg: string) => void = () => {}; - - protected getImportObj(memory: WebAssembly.Memory) { - /* eslint-disable camelcase */ - const importObj = { - // We need to implement a part of the wasi api: - // https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md - // We literally only need to support random_get, everything else is noop implementated in barretenberg.wasm. - wasi_snapshot_preview1: { - random_get: (out: any, length: number) => { - out = out >>> 0; - const randomData = randomBytes(length); - const mem = this.getMemory(); - mem.set(randomData, out); - }, - clock_time_get: (a1: number, a2: number, out: number) => { - out = out >>> 0; - const ts = BigInt(new Date().getTime()) * 1000000n; - const view = new DataView(this.getMemory().buffer); - view.setBigUint64(out, ts, true); - }, - proc_exit: () => { - this.logger('PANIC: proc_exit was called.'); - throw new Error(); - }, - }, - - // These are functions implementations for imports we've defined are needed. - // The native C++ build defines these in a module called "env". We must implement TypeScript versions here. - env: { - /** - * The 'info' call we use for logging in C++, calls this under the hood. - * The native code will just print to std:err (to avoid std::cout which is used for IPC). - * Here we just emit the log line for the client to decide what to do with. - */ - logstr: (addr: number) => { - const str = this.stringFromAddress(addr); - const m = this.getMemory(); - const str2 = `${str} (mem: ${(m.length / (1024 * 1024)).toFixed(2)}MiB)`; - this.logger(str2); - }, - - throw_or_abort_impl: (addr: number) => { - const str = this.stringFromAddress(addr); - throw new Error(str); - }, - - memory, - }, - }; - /* eslint-enable camelcase */ - - return importObj; - } - - public exports(): any { - return this.instance.exports; - } - - /** - * When returning values from the WASM, use >>> operator to convert signed representation to unsigned representation. - */ - public call(name: string, ...args: any) { - if (!this.exports()[name]) { - throw new Error(`WASM function ${name} not found.`); - } - try { - return this.exports()[name](...args) >>> 0; - } catch (err: any) { - const message = `WASM function ${name} aborted, error: ${err}`; - this.logger(message); - this.logger(err.stack); - throw err; - } - } - - public memSize() { - return this.getMemory().length; - } - - /** - * Returns a copy of the data, not a view. - */ - public getMemorySlice(start: number, end: number) { - return this.getMemory().subarray(start, end).slice(); - } - - public writeMemory(offset: number, arr: Uint8Array) { - const mem = this.getMemory(); - mem.set(arr, offset); - } - - public getMemory() { - return new Uint8Array(this.memory.buffer); - } - - // PRIVATE METHODS - - private stringFromAddress(addr: number) { - addr = addr >>> 0; - const m = this.getMemory(); - let i = addr; - while (m[i] !== 0) { - ++i; - } - const textDecoder = new TextDecoder('ascii'); - return textDecoder.decode(m.slice(addr, i)); - } -} diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/factory/browser/index.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/factory/browser/index.ts deleted file mode 100644 index 730d44031e4c..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/factory/browser/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { readinessListener } from '../../../helpers/browser/index.js'; - -export async function createMainWorker() { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const worker = new Worker(new URL('./main.worker.js', import.meta.url), { type: 'module' }); - await new Promise(resolve => readinessListener(worker, resolve)); - return worker; -} diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/factory/browser/main.worker.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/factory/browser/main.worker.ts deleted file mode 100644 index efa1613e99d9..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/factory/browser/main.worker.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { expose } from 'comlink'; - -import { Ready } from '../../../helpers/browser/index.js'; -import { BarretenbergWasmMain } from '../../index.js'; - -expose(new BarretenbergWasmMain()); -postMessage(Ready); diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/factory/node/index.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/factory/node/index.ts deleted file mode 100644 index a9b21d903a22..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/factory/node/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { Worker } from 'worker_threads'; - -function getCurrentDir() { - if (typeof __dirname !== 'undefined') { - return __dirname; - } else { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - return dirname(fileURLToPath(import.meta.url)); - } -} - -export function createMainWorker() { - const __dirname = getCurrentDir(); - const worker = new Worker(__dirname + `/main.worker.js`); - return Promise.resolve(worker); -} diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/factory/node/main.worker.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/factory/node/main.worker.ts deleted file mode 100644 index 4b3d1e934cb3..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/factory/node/main.worker.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { expose } from 'comlink'; -import { parentPort } from 'worker_threads'; - -import { nodeEndpoint } from '../../../helpers/node/node_endpoint.js'; -import { BarretenbergWasmMain } from '../../index.js'; - -if (!parentPort) { - throw new Error('No parentPort'); -} - -expose(new BarretenbergWasmMain(), nodeEndpoint(parentPort)); diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/heap_allocator.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/heap_allocator.ts deleted file mode 100644 index fec7282d9a14..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/heap_allocator.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { BarretenbergWasmMain } from './index.js'; - -/** - * Keeps track of heap allocations so they can be easily freed. - * The WASM memory layout has 1024 bytes of unused "scratch" space at the start (addresses 0-1023). - * We can leverage this for IO rather than making expensive bb_malloc bb_free calls. - * Heap allocations will be created for input/output args that don't fit into the scratch space. - * Input scratch grows UP from 0, output scratch grows DOWN from 1024, meeting in the middle. - * This maximizes space utilization while preventing overlap. - */ -export class HeapAllocator { - private allocs: number[] = []; - private inScratchPtr = 0; // Next input starts here, grows UP - private outScratchPtr = 1024; // Next output ends here, grows DOWN - - constructor(private wasm: BarretenbergWasmMain) {} - - getInputs(buffers: (Uint8Array | number)[]) { - return buffers.map(bufOrNum => { - if (typeof bufOrNum === 'object') { - const size = bufOrNum.length; - // Check if there's room in scratch space (inputs grow up, outputs grow down) - if (this.inScratchPtr + size <= this.outScratchPtr) { - const ptr = this.inScratchPtr; - this.inScratchPtr += size; // Grow UP - this.wasm.writeMemory(ptr, bufOrNum); - return ptr; - } else { - // Fall back to heap allocation - const ptr = this.wasm.call('bbmalloc', size); - this.wasm.writeMemory(ptr, bufOrNum); - this.allocs.push(ptr); - return ptr; - } - } else { - return bufOrNum; - } - }); - } - - getOutputPtrs(outLens: (number | undefined)[]) { - return outLens.map(len => { - // If the obj is variable length, we need a 4 byte ptr to write the serialized data address to. - // WARNING: 4 only works with WASM as it has 32 bit memory. - const size = len || 4; - - // Check if there's room in scratch space (inputs grow up, outputs grow down) - if (this.inScratchPtr + size <= this.outScratchPtr) { - this.outScratchPtr -= size; // Grow DOWN - return this.outScratchPtr; - } else { - // Fall back to heap allocation - const ptr = this.wasm.call('bbmalloc', size); - this.allocs.push(ptr); - return ptr; - } - }); - } - - addOutputPtr(ptr: number) { - // Only add to dealloc list if it's a heap allocation (not in scratch space 0-1023) - if (ptr >= 1024) { - this.allocs.push(ptr); - } - } - - freeAll() { - for (const ptr of this.allocs) { - this.wasm.call('bbfree', ptr); - } - } -} diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/index.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/index.ts deleted file mode 100644 index f14bc9092d58..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/index.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { Remote } from 'comlink'; -import type { Worker } from 'worker_threads'; - -import { BarretenbergWasmBase } from '../barretenberg_wasm_base/index.js'; -import { createThreadWorker } from '../barretenberg_wasm_thread/factory/node/index.js'; -import type { BarretenbergWasmThreadWorker } from '../barretenberg_wasm_thread/index.js'; -import { getNumCpu, getRemoteBarretenbergWasm, getSharedMemoryAvailable } from '../helpers/index.js'; -import { HeapAllocator } from './heap_allocator.js'; - -/** - * This is the "main thread" implementation of BarretenbergWasm. - * It spawns a bunch of "child thread" implementations. - * In a browser context, this still runs on a worker, as it will block waiting on child threads. - */ -export class BarretenbergWasmMain extends BarretenbergWasmBase { - static MAX_THREADS = 32; - private workers: Worker[] = []; - private remoteWasms: BarretenbergWasmThreadWorker[] = []; - private nextWorker = 0; - private nextThreadId = 1; - private useCustomLogger = false; - - // Pre-allocated scratch buffers for msgpack I/O to avoid malloc/free overhead - private msgpackInputScratch: number = 0; // 8MB input buffer - private msgpackOutputScratch: number = 0; // 8MB output buffer - private readonly MSGPACK_SCRATCH_SIZE = 1024 * 1024 * 8; // 8MB - - public getNumThreads() { - return this.workers.length + 1; - } - - /** - * Init as main thread. Spawn child threads. - */ - public async init( - module: WebAssembly.Module, - threads = Math.min(getNumCpu(), BarretenbergWasmMain.MAX_THREADS), - logger?: (msg: string) => void, - initial = 37, - maximum = this.getDefaultMaximumMemoryPages(), - unref = false, - ) { - // Track whether a custom logger was provided so workers know whether to postMessage logs - this.useCustomLogger = logger !== undefined; - this.logger = logger ?? (() => {}); - - const initialMb = (initial * 2 ** 16) / (1024 * 1024); - const maxMb = (maximum * 2 ** 16) / (1024 * 1024); - const shared = getSharedMemoryAvailable(); - - this.logger( - `Initializing bb wasm: initial memory ${initial} pages ${initialMb}MiB; ` + - `max memory: ${maximum} pages, ${maxMb}MiB; ` + - `threads: ${threads}; shared memory: ${shared}`, - ); - - this.memory = new WebAssembly.Memory({ initial, maximum, shared }); - - const instance = await WebAssembly.instantiate(module, this.getImportObj(this.memory)); - - this.instance = instance; - - // Init all global/static data. - this.call('_initialize'); - - // Allocate dedicated msgpack scratch buffers (never freed, reused for all msgpack calls) - this.msgpackInputScratch = this.call('bbmalloc', this.MSGPACK_SCRATCH_SIZE); - this.msgpackOutputScratch = this.call('bbmalloc', this.MSGPACK_SCRATCH_SIZE); - this.logger( - `Allocated msgpack scratch buffers: ` + - `input @ ${this.msgpackInputScratch}, output @ ${this.msgpackOutputScratch} (${this.MSGPACK_SCRATCH_SIZE} bytes each)`, - ); - - // Create worker threads. Create 1 less than requested, as main thread counts as a thread. - if (threads > 1) { - this.logger(`Creating ${threads} worker threads`); - this.workers = await Promise.all(Array.from({ length: threads - 1 }).map(createThreadWorker)); - - // Set up log message forwarding from workers to our logger (only if custom logger provided) - if (this.useCustomLogger) { - this.workers.forEach(worker => this.setupWorkerLogForwarding(worker)); - } - - this.remoteWasms = this.workers.map(getRemoteBarretenbergWasm); - await Promise.all(this.remoteWasms.map(w => w.initThread(module, this.memory, this.useCustomLogger))); - - if (unref) { - for (const worker of this.workers) { - worker.unref(); - } - } - } - } - - private getDefaultMaximumMemoryPages(): number { - // iOS browser is very aggressive with memory. Check if running in browser and on iOS. - // We at any rate expect the mobile iOS browser to kill us >=1GB, so we don't set a maximum higher than that. - // Use `self` instead of `window` so this check also works inside Web Workers. - if ( - typeof self !== 'undefined' && - typeof self.navigator !== 'undefined' && - /iPad|iPhone/.test(self.navigator.userAgent) - ) { - return 2 ** 14; - } - return 2 ** 16; - } - - /** - * Set up forwarding of log messages from worker threads to our logger. - * Workers post messages with { type: 'log', msg: string } which we intercept here. - */ - private setupWorkerLogForwarding(worker: Worker) { - const handler = (data: unknown) => { - if (data && typeof data === 'object' && 'type' in data && data.type === 'log' && 'msg' in data) { - this.logger(data.msg as string); - } - }; - - // Node Workers use 'on' method, browser Workers use 'addEventListener' - // The 'worker' variable is typed as Node's Worker, but at runtime in browser - // it will be a browser Worker (due to browser_postprocess.sh import rewriting) - if ('on' in worker && typeof worker.on === 'function') { - // Node.js worker_threads Worker - worker.on('message', handler); - } else if ('addEventListener' in worker) { - // Browser Web Worker - (worker as unknown as globalThis.Worker).addEventListener('message', (event: MessageEvent) => { - handler(event.data); - }); - } - } - - /** - * Called on main thread. Signals child threads to gracefully exit. - */ - public async destroy() { - await Promise.all(this.workers.map(w => w.terminate())); - } - - protected getImportObj(memory: WebAssembly.Memory) { - const baseImports = super.getImportObj(memory); - - /* eslint-disable camelcase */ - return { - ...baseImports, - wasi: { - 'thread-spawn': (arg: number) => { - arg = arg >>> 0; - const id = this.nextThreadId++; - const worker = this.nextWorker++ % this.remoteWasms.length; - // this.logger(`spawning thread ${id} on worker ${worker} with arg ${arg >>> 0}`); - this.remoteWasms[worker].call('wasi_thread_start', id, arg).catch(this.logger); - // this.remoteWasms[worker].postMessage({ msg: 'thread', data: { id, arg } }); - return id; - }, - }, - env: { - ...baseImports.env, - env_hardware_concurrency: () => { - // If there are no workers (we're already running as a worker, or the main thread requested no workers) - // then we return 1, which should cause any algos using threading to just not create a thread. - return this.remoteWasms.length + 1; - }, - }, - }; - /* eslint-enable camelcase */ - } - - callWasmExport(funcName: string, inArgs: (Uint8Array | number)[], outLens: (number | undefined)[]) { - const alloc = new HeapAllocator(this); - const inPtrs = alloc.getInputs(inArgs); - const outPtrs = alloc.getOutputPtrs(outLens); - this.call(funcName, ...inPtrs, ...outPtrs); - const outArgs = this.getOutputArgs(outLens, outPtrs, alloc); - alloc.freeAll(); - return outArgs; - } - - private getOutputArgs(outLens: (number | undefined)[], outPtrs: number[], alloc: HeapAllocator) { - return outLens.map((len, i) => { - if (len) { - return this.getMemorySlice(outPtrs[i], outPtrs[i] + len); - } - const slice = this.getMemorySlice(outPtrs[i], outPtrs[i] + 4); - const ptr = new DataView(slice.buffer, slice.byteOffset, slice.byteLength).getUint32(0, true); - - // Add our heap buffer to the dealloc list. - alloc.addOutputPtr(ptr); - - // The length will be found in the first 4 bytes of the buffer, big endian. See to_heap_buffer. - const lslice = this.getMemorySlice(ptr, ptr + 4); - const length = new DataView(lslice.buffer, lslice.byteOffset, lslice.byteLength).getUint32(0, false); - - return this.getMemorySlice(ptr + 4, ptr + 4 + length); - }); - } - - cbindCall(cbind: string, inputBuffer: Uint8Array): any { - const needsCustomInputBuffer = inputBuffer.length > this.MSGPACK_SCRATCH_SIZE; - let inputPtr: number; - - if (needsCustomInputBuffer) { - // Allocate temporary buffer for oversized input - inputPtr = this.call('bbmalloc', inputBuffer.length); - } else { - // Use pre-allocated scratch buffer - inputPtr = this.msgpackInputScratch; - } - - // Write input to buffer - this.writeMemory(inputPtr, inputBuffer); - - // Setup output scratch buffer with IN-OUT parameter pattern: - // Reserve 8 bytes for metadata (pointer + size), rest is scratch data space - const METADATA_SIZE = 8; - const outputPtrLocation = this.msgpackOutputScratch; - const outputSizeLocation = this.msgpackOutputScratch + 4; - const scratchDataPtr = this.msgpackOutputScratch + METADATA_SIZE; - const scratchDataSize = this.MSGPACK_SCRATCH_SIZE - METADATA_SIZE; - - // Get memory and create DataView for writing IN values - let mem = this.getMemory(); - let view = new DataView(mem.buffer); - - // Write IN values: provide scratch buffer pointer and size to C++ - view.setUint32(outputPtrLocation, scratchDataPtr, true); - view.setUint32(outputSizeLocation, scratchDataSize, true); - - // Call WASM - this.call(cbind, inputPtr, inputBuffer.length, outputPtrLocation, outputSizeLocation); - - // Free custom input buffer if allocated - if (needsCustomInputBuffer) { - this.call('bbfree', inputPtr); - } - - // Re-fetch memory after WASM call, as the buffer may have been detached if memory grew - mem = this.getMemory(); - view = new DataView(mem.buffer); - - // Read OUT values: C++ returns actual buffer pointer and size - const outputDataPtr = view.getUint32(outputPtrLocation, true); - const outputSize = view.getUint32(outputSizeLocation, true); - - // Check if C++ used scratch (pointer unchanged) or allocated (pointer changed) - const usedScratch = outputDataPtr === scratchDataPtr; - - // Copy output data from WASM memory - const encodedResult = this.getMemorySlice(outputDataPtr, outputDataPtr + outputSize); - - // Only free if C++ allocated beyond scratch - if (!usedScratch) { - this.call('bbfree', outputDataPtr); - } - - return encodedResult; - } -} - -/** - * The comlink type that asyncifies the BarretenbergWasmMain api. - */ -export type BarretenbergWasmMainWorker = Remote; diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_thread/factory/browser/index.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_thread/factory/browser/index.ts deleted file mode 100644 index 4b65988cf278..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_thread/factory/browser/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { readinessListener } from '../../../helpers/browser/index.js'; - -export async function createThreadWorker() { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const worker = new Worker(new URL('./thread.worker.js', import.meta.url), { type: 'module' }); - await new Promise(resolve => readinessListener(worker, resolve)); - return worker; -} diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_thread/factory/browser/thread.worker.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_thread/factory/browser/thread.worker.ts deleted file mode 100644 index e914600ec6a3..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_thread/factory/browser/thread.worker.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { expose } from 'comlink'; - -import { Ready } from '../../../helpers/browser/index.js'; -import { BarretenbergWasmThread } from '../../index.js'; - -expose(new BarretenbergWasmThread()); -postMessage(Ready); diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_thread/factory/node/index.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_thread/factory/node/index.ts deleted file mode 100644 index 03c7988c10dd..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_thread/factory/node/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { Worker } from 'worker_threads'; - -function getCurrentDir() { - if (typeof __dirname !== 'undefined') { - return __dirname; - } else { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - return dirname(fileURLToPath(import.meta.url)); - } -} - -export function createThreadWorker() { - const __dirname = getCurrentDir(); - const worker = new Worker(__dirname + `/thread.worker.js`); - return Promise.resolve(worker); -} diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_thread/factory/node/thread.worker.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_thread/factory/node/thread.worker.ts deleted file mode 100644 index 21cad4523fea..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_thread/factory/node/thread.worker.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { expose } from 'comlink'; -import { parentPort } from 'worker_threads'; - -import { nodeEndpoint } from '../../../helpers/node/node_endpoint.js'; -import { BarretenbergWasmThread } from '../../index.js'; - -if (!parentPort) { - throw new Error('No parentPort'); -} - -const endpoint = nodeEndpoint(parentPort); - -expose(new BarretenbergWasmThread(), endpoint); diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_thread/index.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_thread/index.ts deleted file mode 100644 index ff134479ffe9..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_thread/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Remote } from 'comlink'; - -import { BarretenbergWasmBase } from '../barretenberg_wasm_base/index.js'; -import { killSelf, threadLogger } from '../helpers/index.js'; - -export class BarretenbergWasmThread extends BarretenbergWasmBase { - /** - * Init as worker thread. - * @param useCustomLogger - If true, logs will be posted back to main thread for custom logger routing - */ - public async initThread(module: WebAssembly.Module, memory: WebAssembly.Memory, useCustomLogger = false) { - this.logger = threadLogger(useCustomLogger) || this.logger; - this.memory = memory; - this.instance = await WebAssembly.instantiate(module, this.getImportObj(this.memory)); - } - - public destroy() { - killSelf(); - } - - protected getImportObj(memory: WebAssembly.Memory) { - const baseImports = super.getImportObj(memory); - - /* eslint-disable camelcase */ - return { - ...baseImports, - wasi: { - 'thread-spawn': () => { - this.logger('PANIC: threads cannot spawn threads!'); - this.logger(new Error().stack!); - killSelf(); - }, - }, - - // These are functions implementations for imports we've defined are needed. - // The native C++ build defines these in a module called "env". We must implement TypeScript versions here. - env: { - ...baseImports.env, - env_hardware_concurrency: () => { - // We return 1, which should cause any algos using threading to just not create a thread. - return 1; - }, - }, - }; - /* eslint-enable camelcase */ - } -} - -export type BarretenbergWasmThreadWorker = Remote; diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/browser/barretenberg-threads.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/browser/barretenberg-threads.ts deleted file mode 100644 index 23aa9ed841f7..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/browser/barretenberg-threads.ts +++ /dev/null @@ -1,3 +0,0 @@ -import barretenbergThreadsModule from '../../barretenberg-threads.wasm.gz'; - -export default barretenbergThreadsModule; diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/browser/barretenberg.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/browser/barretenberg.ts deleted file mode 100644 index c75591e5c6e1..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/browser/barretenberg.ts +++ /dev/null @@ -1,3 +0,0 @@ -import barretenbergModule from '../../barretenberg.wasm.gz'; - -export default barretenbergModule; diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/browser/index.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/browser/index.ts deleted file mode 100644 index 4b227fcf15e7..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/browser/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ungzip } from 'pako'; - -// Annoyingly the wasm declares if it's memory is shared or not. So now we need two wasms if we want to be -// able to fallback on "non shared memory" situations. -export async function fetchCode(multithreaded: boolean, wasmPath?: string) { - let url: string; - if (wasmPath) { - const suffix = multithreaded ? '-threads' : ''; - const filePath = wasmPath.split('/').slice(0, -1).join('/'); - const fileNameWithExtensions = wasmPath.split('/').pop(); - const [fileName, ...extensions] = fileNameWithExtensions!.split('.'); - url = `${filePath}/${fileName}${suffix}.${extensions.join('.')}`; - } else { - url = multithreaded - ? (await import('./barretenberg-threads.js')).default - : (await import('./barretenberg.js')).default; - } - const res = await fetch(url); - // Default bb wasm is compressed, but user could point it to a non-compressed version - const maybeCompressedData = await res.arrayBuffer(); - const buffer = new Uint8Array(maybeCompressedData); - const isGzip = - // Check magic number - buffer[0] === 0x1f && - buffer[1] === 0x8b && - // Check compression method: - buffer[2] === 0x08; - if (isGzip) { - const decompressedData = ungzip(buffer); - return decompressedData.buffer as unknown as Uint8Array; - } else { - return buffer; - } -} diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/index.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/index.ts deleted file mode 100644 index 950c3e006707..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './node/index.js'; diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/node/index.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/node/index.ts deleted file mode 100644 index 375fc8c49881..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/node/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { readFile } from 'fs/promises'; -import { ungzip } from 'pako'; -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; - -function getCurrentDir() { - if (typeof __dirname !== 'undefined') { - return __dirname; - } else { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - return dirname(fileURLToPath(import.meta.url)); - } -} - -export async function fetchCode(multithreaded: boolean, wasmPath?: string) { - const path = wasmPath ?? getCurrentDir() + '/../../barretenberg-threads.wasm.gz'; - // Default bb wasm is compressed, but user could point it to a non-compressed version - const maybeCompressedData = await readFile(path); - const buffer = new Uint8Array(maybeCompressedData); - const isGzip = - // Check magic number - buffer[0] === 0x1f && - buffer[1] === 0x8b && - // Check compression method: - buffer[2] === 0x08; - if (isGzip) { - const decompressedData = ungzip(buffer); - return decompressedData.buffer as unknown as Uint8Array; - } else { - return buffer; - } -} diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/wasm-module.d.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/wasm-module.d.ts deleted file mode 100644 index b5f0a8c6ba00..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/wasm-module.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module '*.wasm.gz' { - const content: string; - export default content; -} diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/helpers/browser/index.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/helpers/browser/index.ts deleted file mode 100644 index 6040e2dd147a..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/helpers/browser/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { wrap } from 'comlink'; - -export function getSharedMemoryAvailable() { - const globalScope = typeof window !== 'undefined' ? window : globalThis; - return typeof SharedArrayBuffer !== 'undefined' && globalScope.crossOriginIsolated; -} - -export function getRemoteBarretenbergWasm(worker: Worker) { - return wrap(worker); -} - -export function getNumCpu() { - return navigator.hardwareConcurrency; -} - -export function threadLogger(useCustomLogger: boolean): ((msg: string) => void) | undefined { - if (useCustomLogger) { - // Post log messages back to main thread for routing through user-provided logger - return (msg: string) => { - postMessage({ type: 'log', msg }); - }; - } - // Use console.log directly when no custom logger is provided - // eslint-disable-next-line no-console -- Console logging is the worker fallback when no logger is provided. - return console.log; -} - -export function killSelf() { - self.close(); -} - -export function getAvailableThreads(logger: (msg: string) => void): number { - if (typeof navigator !== 'undefined' && navigator.hardwareConcurrency) { - return navigator.hardwareConcurrency; - } else { - logger(`Could not detect environment to query number of threads. Falling back to one thread.`); - return 1; - } -} - -// Solution to async initialization of workers, taken from -// https://github.com/GoogleChromeLabs/comlink/issues/635#issuecomment-1598913044 - -/** The message expected by the `readinessListener`. */ -export const Ready = { ready: true }; - -/** Listen for the readiness message from the Worker and call the `callback` once. */ -export function readinessListener(worker: Worker, callback: () => void) { - worker.addEventListener('message', function ready(event: MessageEvent) { - if (!!event.data && event.data.ready === true) { - worker.removeEventListener('message', ready); - callback(); - } - }); -} diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/helpers/index.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/helpers/index.ts deleted file mode 100644 index 950c3e006707..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/helpers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './node/index.js'; diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/helpers/node/index.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/helpers/node/index.ts deleted file mode 100644 index 2e8ed4648659..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/helpers/node/index.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { wrap } from 'comlink'; -import { writeSync } from 'fs'; -import os from 'os'; -import { Worker, parentPort } from 'worker_threads'; - -import { nodeEndpoint } from './node_endpoint.js'; - -export function getSharedMemoryAvailable() { - return true; -} - -/** - * Comlink allows you to produce a Proxy to the worker, enabling you to call methods as if it were a normal class. - * Note we give it the type information it needs so the returned Proxy object looks like that type. - * Node has a different implementation, needing this nodeEndpoint wrapper, hence this function exists here. - */ -export function getRemoteBarretenbergWasm(worker: Worker) { - return wrap(nodeEndpoint(worker)); -} - -/** - * Returns number of cpus as reported by the system, unless overriden by HARDWARE_CONCURRENCY env var. - */ -export function getNumCpu() { - return +process.env.HARDWARE_CONCURRENCY! || os.cpus().length; -} - -/** - * Returns a logger function for worker threads. - * When a custom logger is provided, posts messages back to the main thread. - * Otherwise, writes directly to stdout. - */ -export function threadLogger(useCustomLogger: boolean): ((msg: string) => void) | undefined { - if (useCustomLogger) { - return (msg: string) => { - if (parentPort) { - parentPort.postMessage({ type: 'log', msg }); - } - }; - } - // Write directly to stdout when no custom logger is provided - return (msg: string) => { - writeSync(1, msg + '\n'); - }; -} - -export function killSelf(): never { - // Extordinarily hard process termination. Due to how parent threads block on child threads etc, even process.exit - // doesn't seem to be able to abort the process. The following does. - process.kill(process.pid); - throw new Error(); -} - -export function getAvailableThreads(logger: (msg: string) => void): number { - try { - return os.cpus().length; - } catch (e: any) { - logger( - `Could not detect environment to query number of threads. Falling back to one thread. Error: ${e.message ?? e}`, - ); - return 1; - } -} diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/helpers/node/node_endpoint.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/helpers/node/node_endpoint.ts deleted file mode 100644 index 63d5301b2ea2..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/helpers/node/node_endpoint.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NodeEndpoint } from 'comlink/dist/esm/node-adapter.js'; - -export function nodeEndpoint(nep: NodeEndpoint) { - const listeners = new WeakMap(); - return { - postMessage: nep.postMessage.bind(nep), - addEventListener: (_: any, eh: any) => { - const l = (data: any) => { - if ('handleEvent' in eh) { - eh.handleEvent({ data }); - } else { - eh({ data }); - } - }; - nep.on('message', l); - listeners.set(eh, l); - }, - removeEventListener: (_: any, eh: any) => { - const l = listeners.get(eh); - if (!l) { - return; - } - nep.off('message', l); - listeners.delete(eh); - }, - start: nep.start && nep.start.bind(nep), - }; -} diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/index.test.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/index.test.ts deleted file mode 100644 index 6b26b90d819e..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/index.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Worker } from 'worker_threads'; - -import { createMainWorker } from '../barretenberg_wasm/barretenberg_wasm_main/factory/node/index.js'; -import { BarretenbergWasmMainWorker } from '../barretenberg_wasm/barretenberg_wasm_main/index.js'; -import { getRemoteBarretenbergWasm } from '../barretenberg_wasm/helpers/index.js'; -import { fetchModuleAndThreads } from '../barretenberg_wasm/index.js'; - -describe('barretenberg wasm', () => { - let wasm: BarretenbergWasmMainWorker; - let worker: Worker; - - beforeAll(async () => { - worker = await createMainWorker(); - wasm = getRemoteBarretenbergWasm(worker); - const { module, threads } = await fetchModuleAndThreads(2); - await wasm.init(module, threads); - }, 20000); - - afterAll(async () => { - await wasm.destroy(); - await worker.terminate(); - }); - - it('should new malloc, transfer and slice mem', async () => { - const length = 1024; - const ptr = await wasm.call('bbmalloc', length); - const buf = Buffer.alloc(length, 128); - await wasm.writeMemory(ptr, Uint8Array.from(buf)); - const result = Buffer.from(await wasm.getMemorySlice(ptr, ptr + length)); - await wasm.call('bbfree', ptr); - expect(result).toStrictEqual(buf); - }); - - it('test abort', async () => { - await expect(() => wasm.call('test_abort')).rejects.toThrow(); - }); - - it('should new malloc, transfer and slice mem', async () => { - const length = 1024; - const ptr = await wasm.call('bbmalloc', length); - const buf = Buffer.alloc(length, 128); - await wasm.writeMemory(ptr, Uint8Array.from(buf)); - const result = Buffer.from(await wasm.getMemorySlice(ptr, ptr + length)); - await wasm.call('bbfree', ptr); - expect(result).toStrictEqual(buf); - }); -}); diff --git a/barretenberg/ts/bb.js/src/barretenberg_wasm/index.ts b/barretenberg/ts/bb.js/src/barretenberg_wasm/index.ts deleted file mode 100644 index 070cbe9d1cda..000000000000 --- a/barretenberg/ts/bb.js/src/barretenberg_wasm/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { fetchCode } from './fetch_code/index.js'; -import { getAvailableThreads, getSharedMemoryAvailable } from './helpers/node/index.js'; - -export async function fetchModuleAndThreads( - desiredThreads = 32, - wasmPath?: string, - logger: (msg: string) => void = () => {}, -) { - const shared = getSharedMemoryAvailable(); - - const availableThreads = shared ? getAvailableThreads(logger) : 1; - // We limit the number of threads to 32 as we do not benefit from greater numbers. - const limitedThreads = Math.min(desiredThreads, availableThreads, 32); - - logger(`Fetching bb wasm from ${wasmPath ?? 'default location'}`); - const code = await fetchCode(shared, wasmPath); - logger(`Compiling bb wasm of ${code.byteLength} bytes`); - const module = await WebAssembly.compile(code); - logger('Compilation of bb wasm complete'); - return { module, threads: limitedThreads }; -} diff --git a/barretenberg/ts/bb.js/src/bb_backends/browser/index.ts b/barretenberg/ts/bb.js/src/bb_backends/browser/index.ts deleted file mode 100644 index 730d6594270f..000000000000 --- a/barretenberg/ts/bb.js/src/bb_backends/browser/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { BackendOptions, BackendType } from '../index.js'; -import type { IMsgpackBackendAsync, IMsgpackBackendSync } from '../interface.js'; -import { BarretenbergWasmAsyncBackend, BarretenbergWasmSyncBackend } from '../wasm.js'; - -/** - * Create backend of specific type (no fallback) - */ -export async function createAsyncBackend( - type: BackendType, - options: BackendOptions, - logger: (msg: string) => void, -): Promise { - switch (type) { - case BackendType.Wasm: - case BackendType.WasmWorker: { - const useWorker = type === BackendType.WasmWorker; - logger(`Using WASM backend (worker: ${useWorker})`); - return await BarretenbergWasmAsyncBackend.new({ - threads: options.threads, - wasmPath: options.wasmPath, - logger, - memory: options.memory, - useWorker, - }); - } - - default: - throw new Error(`Unknown backend type: ${type}`); - } -} - -/** - * Create backend of specific type (no fallback) - */ -export async function createSyncBackend( - type: BackendType, - options: BackendOptions, - logger: (msg: string) => void, -): Promise { - switch (type) { - case BackendType.Wasm: { - logger('Using WASM backend'); - return await BarretenbergWasmSyncBackend.new(options.wasmPath, logger); - } - - default: - throw new Error(`Backend ${type} not supported for BarretenbergSync`); - } -} diff --git a/barretenberg/ts/bb.js/src/bb_backends/interface.ts b/barretenberg/ts/bb.js/src/bb_backends/interface.ts deleted file mode 100644 index fe2e54a84f95..000000000000 --- a/barretenberg/ts/bb.js/src/bb_backends/interface.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Generic interface for msgpack backend implementations. - * Both WASM and native binary backends implement this interface. - */ -export interface IMsgpackBackend { - /** - * Execute a msgpack command and return the msgpack response. - * @param inputBuffer The msgpack-encoded input buffer - * @returns The msgpack-encoded response buffer (sync or async) - */ - call(inputBuffer: Uint8Array): Uint8Array | Promise; - - /** - * Clean up resources. - */ - destroy(): void | Promise; -} - -/** - * Synchronous variant of IMsgpackBackend. - * Used by BarretenbergSync and SyncApi. - */ -export interface IMsgpackBackendSync extends IMsgpackBackend { - call(inputBuffer: Uint8Array): Uint8Array; - destroy(): void; -} - -/** - * Asynchronous variant of IMsgpackBackend. - * Used by Barretenberg and AsyncApi. - */ -export interface IMsgpackBackendAsync extends IMsgpackBackend { - call(inputBuffer: Uint8Array): Promise; - destroy(): Promise; -} diff --git a/barretenberg/ts/bb.js/src/bb_backends/node/index.ts b/barretenberg/ts/bb.js/src/bb_backends/node/index.ts deleted file mode 100644 index b19b1ba43495..000000000000 --- a/barretenberg/ts/bb.js/src/bb_backends/node/index.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { BackendOptions, BackendType } from '../index.js'; -import type { IMsgpackBackendAsync, IMsgpackBackendSync } from '../interface.js'; -import { BarretenbergWasmAsyncBackend, BarretenbergWasmSyncBackend } from '../wasm.js'; -import { BarretenbergNativeShmSyncBackend } from './native_shm.js'; -import { BarretenbergNativeShmAsyncBackend } from './native_shm_async.js'; -import { BarretenbergNativeSocketAsyncBackend } from './native_socket.js'; -import { findBbBinary } from './platform.js'; - -/** - * Create backend of specific type (no fallback) - */ -export async function createAsyncBackend( - type: BackendType, - options: BackendOptions, - logger: (msg: string) => void, -): Promise { - options = { - ...options, - wasmPath: options.wasmPath ?? process.env.BB_WASM_PATH, - }; - - switch (type) { - case BackendType.NativeUnixSocket: { - const bbPath = findBbBinary(options.bbPath); - if (!bbPath) { - throw new Error('Native backend requires bb binary.'); - } - logger(`Using native Unix socket backend: ${bbPath}`); - return await BarretenbergNativeSocketAsyncBackend.new(bbPath, options.threads, options.logger, options.unref); - } - - case BackendType.NativeSharedMemory: { - const bbPath = findBbBinary(options.bbPath); - if (!bbPath) { - throw new Error('Native backend requires bb binary.'); - } - logger(`Using native shared memory async backend: ${bbPath}`); - return await BarretenbergNativeShmAsyncBackend.new( - bbPath, - options.napiPath, - options.threads, - options.logger, - options.unref, - ); - } - - case BackendType.Wasm: - case BackendType.WasmWorker: { - const useWorker = type === BackendType.WasmWorker; - logger(`Using WASM backend (worker: ${useWorker})`); - return await BarretenbergWasmAsyncBackend.new({ - threads: options.threads, - wasmPath: options.wasmPath, - logger: options.logger, - memory: options.memory, - useWorker, - unref: options.unref, - }); - } - - default: - throw new Error(`Unknown backend type: ${type}`); - } -} - -/** - * Create backend of specific type (no fallback) - */ -export async function createSyncBackend( - type: BackendType, - options: BackendOptions, - logger: (msg: string) => void, -): Promise { - options = { - ...options, - wasmPath: options.wasmPath ?? process.env.BB_WASM_PATH, - }; - - switch (type) { - case BackendType.NativeSharedMemory: { - const bbPath = findBbBinary(options.bbPath); - if (!bbPath) { - throw new Error('Native backend requires bb binary.'); - } - logger(`Using native shared memory backend: ${bbPath}`); - return await BarretenbergNativeShmSyncBackend.new( - bbPath, - options.napiPath, - options.threads, - options.logger, - options.unref, - ); - } - - case BackendType.Wasm: { - logger('Using WASM backend'); - return await BarretenbergWasmSyncBackend.new(options.wasmPath, logger); - } - - default: - throw new Error(`Backend ${type} not supported for BarretenbergSync`); - } -} diff --git a/barretenberg/ts/bb.js/src/bb_backends/node/native_shm.ts b/barretenberg/ts/bb.js/src/bb_backends/node/native_shm.ts deleted file mode 100644 index c0ce6472a386..000000000000 --- a/barretenberg/ts/bb.js/src/bb_backends/node/native_shm.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { SpawnedProcessBackendSync } from '@aztec-foundation/ipc-runtime'; - -import { IMsgpackBackendSync } from '../interface.js'; - -// Sync callers do short, one-at-a-time requests, so a single 4MB request ring -// is ample; the response ring keeps the runtime default. -const REQUEST_RING_SIZE = 1024 * 1024 * 4; - -/** - * Synchronous native backend: bb serves over shared memory (`bb msgpack run - * --input .shm`) and @aztec-foundation/ipc-runtime's SpawnedProcessBackendSync owns - * the process lifecycle — stale-segment removal, spawn, retrying connect, - * death attribution and teardown. - */ -export class BarretenbergNativeShmSyncBackend implements IMsgpackBackendSync { - private constructor(private backend: SpawnedProcessBackendSync) {} - - /** - * Create and initialize a shared memory backend. - * @param bbBinaryPath Path to bb binary - * @param napiPath Optional override for the ipc-runtime NAPI addon - * @param threads Optional number of threads - * @param logger Optional receiver for bb's output - */ - static async new( - bbBinaryPath: string, - napiPath?: string, - threads?: number, - logger?: (msg: string) => void, - unref?: boolean, - ): Promise { - // Sync backends aren't expected to do long-lived work, so default to one thread. - const backend = await SpawnedProcessBackendSync.spawn({ - binaryPath: bbBinaryPath, - binaryName: 'bb', - instancePrefix: 'bb-sync', - ipcPathArgs: ['msgpack', 'run', '--input', '{path}'], - extraArgs: ['--request-ring-size', `${REQUEST_RING_SIZE}`], - transport: 'shm', - clientId: 0, - napiPath, - logger, - // bb monitors parent death (prctl/kqueue) and exits on its own, so it - // must not hold the Node event loop open; calls in flight still keep it - // alive. Without this a caller that never destroy()s the backend hangs - // at exit. - unref: true, - unrefStdio: unref, - env: { HARDWARE_CONCURRENCY: threads ? threads.toString() : '1' }, - }); - return new BarretenbergNativeShmSyncBackend(backend); - } - - call(inputBuffer: Uint8Array): Uint8Array { - return this.backend.call(inputBuffer); - } - - destroy(): void { - this.backend.destroy(); - } -} diff --git a/barretenberg/ts/bb.js/src/bb_backends/node/native_shm_async.ts b/barretenberg/ts/bb.js/src/bb_backends/node/native_shm_async.ts deleted file mode 100644 index 68608fba1ac9..000000000000 --- a/barretenberg/ts/bb.js/src/bb_backends/node/native_shm_async.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { SpawnedProcessBackend } from '@aztec-foundation/ipc-runtime'; - -import { IMsgpackBackendAsync } from '../interface.js'; - -// Larger rings than the sync backend: this one pipelines, so several requests -// and responses can be in the rings at once. -const RING_SIZE = 1024 * 1024 * 4; - -/** - * Asynchronous native backend: bb serves over shared memory and - * @aztec-foundation/ipc-runtime's SpawnedProcessBackend owns the process lifecycle. - * Supports pipelining — responses are paired to callers by request id, so bb - * may complete them in any order. - */ -export class BarretenbergNativeShmAsyncBackend implements IMsgpackBackendAsync { - private constructor(private backend: SpawnedProcessBackend) {} - - /** - * Create and initialize an async shared memory backend. - * @param bbBinaryPath Path to bb binary - * @param napiPath Optional override for the ipc-runtime NAPI addon - * @param threads Optional number of threads (defaults to 16) - * @param logger Optional receiver for bb's output - */ - static async new( - bbBinaryPath: string, - napiPath?: string, - threads?: number, - logger?: (msg: string) => void, - unref?: boolean, - ): Promise { - const backend = await SpawnedProcessBackend.spawn({ - binaryPath: bbBinaryPath, - binaryName: 'bb', - instancePrefix: 'bb-async', - ipcPathArgs: ['msgpack', 'run', '--input', '{path}'], - extraArgs: ['--request-ring-size', `${RING_SIZE}`, '--response-ring-size', `${RING_SIZE}`], - transport: 'shm', - clientId: 0, - napiPath, - logger, - // bb monitors parent death (prctl/kqueue) and exits on its own, so it - // must not hold the Node event loop open; calls in flight still keep it - // alive. Without this a caller that never destroy()s the backend hangs - // at exit. - unref: true, - unrefStdio: unref, - env: { HARDWARE_CONCURRENCY: threads ? threads.toString() : '16' }, - }); - return new BarretenbergNativeShmAsyncBackend(backend); - } - - call(inputBuffer: Uint8Array): Promise { - return this.backend.call(inputBuffer); - } - - destroy(): Promise { - return this.backend.destroy(); - } -} diff --git a/barretenberg/ts/bb.js/src/bb_backends/node/native_socket.test.ts b/barretenberg/ts/bb.js/src/bb_backends/node/native_socket.test.ts deleted file mode 100644 index 9dbfc3ba3f4b..000000000000 --- a/barretenberg/ts/bb.js/src/bb_backends/node/native_socket.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { jest } from '@jest/globals'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - -import { BarretenbergNativeSocketAsyncBackend } from './native_socket.js'; - -jest.setTimeout(30_000); - -// Echo server speaking the bb msgpack socket protocol (4-byte LE length prefix), started after -// an optional delay to simulate bb's startup time on a loaded machine. -const ECHO_SERVER_JS = ` -const net = require('net'); -const socketPath = process.argv[2]; -const server = net.createServer(sock => { - let buf = Buffer.alloc(0); - sock.on('data', d => { - buf = Buffer.concat([buf, d]); - while (buf.length >= 4) { - const len = buf.readUInt32LE(0); - if (buf.length < 4 + len) break; - const payload = buf.subarray(4, 4 + len); - const out = Buffer.alloc(4); - out.writeUInt32LE(payload.length, 0); - sock.write(out); - sock.write(payload); - buf = buf.subarray(4 + len); - } - }); -}); -server.listen(socketPath); -`; - -// A fake bb binary: a bash script that optionally sleeps, then runs the echo server on the -// socket path bb receives via `msgpack run --input ` ($4). -function writeFakeBb(startupDelaySecs: number): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-bb-')); - const serverJs = path.join(dir, 'echo_server.cjs'); - fs.writeFileSync(serverJs, ECHO_SERVER_JS); - const file = path.join(dir, 'bb'); - const sleep = startupDelaySecs > 0 ? `sleep ${startupDelaySecs}\n` : ''; - fs.writeFileSync(file, `#!/bin/bash\n${sleep}exec node ${serverJs} "$4"\n`, { mode: 0o755 }); - return file; -} - -function writeFakeBbScript(script: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-bb-')); - const file = path.join(dir, 'bb'); - fs.writeFileSync(file, script, { mode: 0o755 }); - return file; -} - -describe('BarretenbergNativeSocketAsyncBackend', () => { - it('connects and echoes when bb starts promptly', async () => { - const fakeBb = writeFakeBb(0); - const backend = await BarretenbergNativeSocketAsyncBackend.new(fakeBb); - const response = await backend.call(new Uint8Array([1, 2, 3, 4])); - expect(response).toEqual(new Uint8Array([1, 2, 3, 4])); - await backend.destroy(); - }); - - it('connects even when bb takes longer than 5s to create its socket', async () => { - const fakeBb = writeFakeBb(7); - const backend = await BarretenbergNativeSocketAsyncBackend.new(fakeBb); - const response = await backend.call(new Uint8Array([42])); - expect(response).toEqual(new Uint8Array([42])); - await backend.destroy(); - }); - - it('fails with the exit cause when bb dies before creating its socket', async () => { - const fakeBb = writeFakeBbScript(`#!/bin/bash\nexit 17\n`); - await expect(BarretenbergNativeSocketAsyncBackend.new(fakeBb)).rejects.toThrow( - /exited before IPC connection was ready \(code=17/, - ); - }); - - it('fails with the spawn error when the bb binary does not exist', async () => { - await expect(BarretenbergNativeSocketAsyncBackend.new('/nonexistent/bb-binary')).rejects.toThrow( - /Failed to spawn bb/, - ); - }); -}); diff --git a/barretenberg/ts/bb.js/src/bb_backends/node/native_socket.ts b/barretenberg/ts/bb.js/src/bb_backends/node/native_socket.ts deleted file mode 100644 index bc8e2f506475..000000000000 --- a/barretenberg/ts/bb.js/src/bb_backends/node/native_socket.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { SpawnedProcessBackend } from '@aztec-foundation/ipc-runtime'; -import * as os from 'os'; - -import { IMsgpackBackendAsync } from '../interface.js'; - -/** - * Asynchronous native backend that communicates with the bb binary over a Unix - * Domain Socket, via @aztec-foundation/ipc-runtime's SpawnedProcessBackend: bb is spawned - * as the server (`bb msgpack run --input .sock`) and the runtime owns - * spawn, connect (raced against child death), envelope framing / request-id - * correlation, and teardown. - * - * The child and the idle socket are always unref'd: bb monitors parent death - * (prctl/kqueue) and exits on its own, so it must not hold the Node event loop - * open. `unref` additionally unrefs the log pipes, which would otherwise keep - * the loop alive while a logger is attached. - */ -export class BarretenbergNativeSocketAsyncBackend implements IMsgpackBackendAsync { - private constructor(private backend: SpawnedProcessBackend) {} - - static async new( - bbBinaryPath: string, - threads?: number, - logger?: (msg: string) => void, - unref?: boolean, - ): Promise { - // If threads not set use num cpu cores, max 16. - const hwc = threads ? threads.toString() : Math.min(16, os.cpus().length).toString(); - const backend = await SpawnedProcessBackend.spawn({ - binaryPath: bbBinaryPath, - binaryName: 'bb', - instancePrefix: 'bb', - ipcPathArgs: ['msgpack', 'run', '--input', '{path}'], - transport: 'uds', - logger, - env: { HARDWARE_CONCURRENCY: hwc }, - unref: true, - unrefStdio: unref, - }); - return new BarretenbergNativeSocketAsyncBackend(backend); - } - - call(inputBuffer: Uint8Array): Promise { - return this.backend.call(inputBuffer); - } - - destroy(): Promise { - return this.backend.destroy(); - } -} diff --git a/barretenberg/ts/bb.js/src/bb_backends/wasm.ts b/barretenberg/ts/bb.js/src/bb_backends/wasm.ts deleted file mode 100644 index 5d08a5456686..000000000000 --- a/barretenberg/ts/bb.js/src/bb_backends/wasm.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { proxy } from 'comlink'; - -import { createMainWorker } from '../barretenberg_wasm/barretenberg_wasm_main/factory/node/index.js'; -import { BarretenbergWasmMain, BarretenbergWasmMainWorker } from '../barretenberg_wasm/barretenberg_wasm_main/index.js'; -import { getRemoteBarretenbergWasm } from '../barretenberg_wasm/helpers/index.js'; -import { fetchModuleAndThreads } from '../barretenberg_wasm/index.js'; -import { IMsgpackBackendAsync, IMsgpackBackendSync } from './interface.js'; - -/** - * Synchronous WASM backend that wraps BarretenbergWasmMain. - * Encapsulates all WASM initialization and memory management. - */ -export class BarretenbergWasmSyncBackend implements IMsgpackBackendSync { - private constructor(private wasm: BarretenbergWasmMain) {} - - /** - * Create and initialize a synchronous WASM backend. - * @param wasmPath Optional path to WASM files - * @param logger Optional logging function - */ - static async new(wasmPath?: string, logger?: (msg: string) => void): Promise { - const wasm = new BarretenbergWasmMain(); - const { module, threads } = await fetchModuleAndThreads(1, wasmPath, logger); - await wasm.init(module, threads, logger); - return new BarretenbergWasmSyncBackend(wasm); - } - - call(inputBuffer: Uint8Array): Uint8Array { - return this.wasm.cbindCall('ipc_ffi_entry', inputBuffer); - } - - destroy(): void { - // BarretenbergWasmMain has async destroy, but for sync API we call it without awaiting - // This is consistent with the synchronous semantics expected by the caller - void this.wasm.destroy(); - } -} - -/** - * Asynchronous WASM backend that supports both direct WASM and worker-based modes. - * - * Worker mode (default): Runs WASM on a worker thread to avoid blocking the main thread. Used in browsers. - * Direct mode: Runs WASM directly on the calling thread. Used by node.js for better performance. - */ -export class BarretenbergWasmAsyncBackend implements IMsgpackBackendAsync { - private constructor( - private wasm: BarretenbergWasmMain | BarretenbergWasmMainWorker, - private worker?: any, - ) {} - - /** - * Create and initialize an asynchronous WASM backend. - * @param options.threads Number of threads (defaults to hardware max, up to 32 for parallel proving) - * @param options.wasmPath Optional path to WASM files - * @param options.logger Optional logging function - * @param options.memory Optional initial and maximum memory configuration - * @param options.useWorker Run on worker thread (default: true for browser safety) - * @param options.unref Unref worker handles so they don't prevent process exit - */ - static async new( - options: { - threads?: number; - wasmPath?: string; - logger?: (msg: string) => void; - memory?: { initial?: number; maximum?: number }; - useWorker?: boolean; - unref?: boolean; - } = {}, - ): Promise { - // Default to worker mode for browser safety - const useWorker = options.useWorker ?? true; - - if (useWorker) { - // Worker-based mode: runs on worker thread (browser-safe) - const worker = await createMainWorker(); - const wasm = getRemoteBarretenbergWasm(worker); - const { module, threads } = await fetchModuleAndThreads(options.threads, options.wasmPath, options.logger); - await wasm.init( - module, - threads, - proxy(options.logger ?? (() => {})), - options.memory?.initial, - options.memory?.maximum, - ); - if (options.unref) { - worker.unref(); - } - return new BarretenbergWasmAsyncBackend(wasm, worker); - } else { - // Direct mode: runs on calling thread (faster but blocks thread) - const wasm = new BarretenbergWasmMain(); - const { module, threads } = await fetchModuleAndThreads(options.threads, options.wasmPath, options.logger); - await wasm.init(module, threads, options.logger, options.memory?.initial, options.memory?.maximum, options.unref); - return new BarretenbergWasmAsyncBackend(wasm); - } - } - - call(inputBuffer: Uint8Array): Promise { - return Promise.resolve(this.wasm.cbindCall('ipc_ffi_entry', inputBuffer)); - } - - async destroy(): Promise { - await this.wasm.destroy(); - if (this.worker) { - await this.worker.terminate(); - } - } -} diff --git a/barretenberg/ts/bb.js/src/bbapi/exception_handling.test.ts b/barretenberg/ts/bb.js/src/bbapi/exception_handling.test.ts deleted file mode 100644 index 3c90963fd11c..000000000000 --- a/barretenberg/ts/bb.js/src/bbapi/exception_handling.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { BarretenbergWasmSyncBackend } from '../bb_backends/wasm.js'; -import { SyncApi } from '../generated/sync.js'; - -describe('BBApi Exception Handling from bb.js', () => { - let backend: BarretenbergWasmSyncBackend; - let api: SyncApi; - - beforeAll(async () => { - backend = await BarretenbergWasmSyncBackend.new(); - api = new SyncApi(backend); - }, 60000); - - afterAll(() => { - backend.destroy(); - }); - - it('should catch CRS initialization exceptions from WASM', () => { - // Create an SrsInitSrs command with invalid data that will cause an exception in C++ - // We pass buffers that are too small, which will cause validation to fail - const invalidCommand = { - numPoints: 100, // Request 100 points (requires 6400 bytes) - pointsBuf: new Uint8Array(10), // Only 10 bytes - will cause exception - g2Point: new Uint8Array(10), // Only 10 bytes (needs 128) - will cause exception - }; - - // In WASM builds, throw_or_abort calls abort directly which throws a generic Error - // In native builds with exceptions, our try-catch in bbapi converts it to ErrorResponse - // This test verifies that errors are catchable from bb.js (even if as generic Error in WASM) - expect(() => { - api.srsInitSrs(invalidCommand); - }).toThrow(); - }); - - it('should return error message from caught exception', () => { - const invalidCommand = { - numPoints: 100, - pointsBuf: new Uint8Array(10), - g2Point: new Uint8Array(10), - }; - - try { - api.srsInitSrs(invalidCommand); - fail('Expected exception to be thrown'); - } catch (error) { - // Error is catchable and contains a useful message - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toBeTruthy(); - expect((error as Error).message.length).toBeGreaterThan(0); - expect((error as Error).message).toContain('invalid points_buf size'); - } - }); -}); diff --git a/barretenberg/ts/bb.js/src/bbapi_exception.ts b/barretenberg/ts/bb.js/src/bbapi_exception.ts deleted file mode 100644 index 47f3fac43aa8..000000000000 --- a/barretenberg/ts/bb.js/src/bbapi_exception.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Exception thrown when barretenberg API operations fail - */ -export class BBApiException extends Error { - constructor(message: string) { - super(message); - this.name = 'BBApiException'; - // Maintains proper stack trace for where our error was thrown (only available on V8) - if (Error.captureStackTrace) { - Error.captureStackTrace(this, BBApiException); - } - } -} diff --git a/barretenberg/ts/bb.js/src/benchmark/index.ts b/barretenberg/ts/bb.js/src/benchmark/index.ts deleted file mode 100644 index 607fd705b5b3..000000000000 --- a/barretenberg/ts/bb.js/src/benchmark/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import * as fs from 'fs'; - -export * from './timer.js'; - -const bfd = (() => { - const bfdStr = process.env.BENCHMARK_FD; - const bfd = bfdStr ? parseInt(bfdStr) : -1; - if (bfd >= 0 && !fs.fstatSync(bfd)) { - throw new Error('fd is not open. Did you redirect in your shell?'); - } - return bfd; -})(); - -export function writeBenchmark(name: string, value: T, labels: Record = {}) { - if (bfd === -1) { - return; - } - const data = { - timestamp: new Date().toISOString(), - name, - type: typeof value, - value, - ...labels, - }; - const jsonl = JSON.stringify(data) + '\n'; - fs.writeSync(bfd, jsonl); -} diff --git a/barretenberg/ts/bb.js/src/bin/index.ts b/barretenberg/ts/bb.js/src/bin/index.ts index f4064ef5b534..aa9f59beb98a 100644 --- a/barretenberg/ts/bb.js/src/bin/index.ts +++ b/barretenberg/ts/bb.js/src/bin/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { spawnSync } from 'node:child_process'; -import { findBbBinary } from '../bb_backends/node/platform.js'; +import { findBbBinary } from '../backends/node/platform.js'; const bin = findBbBinary(); diff --git a/barretenberg/ts/bb.js/src/crs/net_crs.ts b/barretenberg/ts/bb.js/src/crs/net_crs.ts index 00717a4557ce..9cd373d8d99e 100644 --- a/barretenberg/ts/bb.js/src/crs/net_crs.ts +++ b/barretenberg/ts/bb.js/src/crs/net_crs.ts @@ -1,4 +1,4 @@ -import { makeBackoff, retry } from '../retry/index.js'; +import { makeBackoff, retry } from './retry.js'; // Primary CRS host (Cloudflare R2) const CRS_PRIMARY_HOST = 'https://crs.aztec-cdn.foundation'; diff --git a/barretenberg/ts/bb.js/src/retry/index.ts b/barretenberg/ts/bb.js/src/crs/retry.ts similarity index 100% rename from barretenberg/ts/bb.js/src/retry/index.ts rename to barretenberg/ts/bb.js/src/crs/retry.ts diff --git a/barretenberg/ts/bb.js/src/curve_constants.test.ts b/barretenberg/ts/bb.js/src/curve_constants.test.ts new file mode 100644 index 000000000000..ab42f0e8248a --- /dev/null +++ b/barretenberg/ts/bb.js/src/curve_constants.test.ts @@ -0,0 +1,55 @@ +import { findBbBinary } from '@aztec-foundation/bb.js-api'; +import { execFileSync } from 'child_process'; +import { Decoder } from 'msgpackr'; + +import { + BN254_FQ_MODULUS, + BN254_FR_MODULUS, + BN254_G1_GENERATOR, + BN254_G2_GENERATOR, + GRUMPKIN_FQ_MODULUS, + GRUMPKIN_FR_MODULUS, + GRUMPKIN_G1_GENERATOR, + SECP256K1_FQ_MODULUS, + SECP256K1_FR_MODULUS, + SECP256K1_G1_GENERATOR, + SECP256R1_FQ_MODULUS, + SECP256R1_FR_MODULUS, + SECP256R1_G1_GENERATOR, +} from './curve_constants.js'; + +// The constants are written out in curve_constants.ts; bb computes the same values from the curve +// definitions it is compiled against. If the two ever disagree, one of them is wrong. +describe('curve constants', () => { + const bb = findBbBinary(); + const itWithBb = bb ? it : it.skip; + + itWithBb('match the curves bb is built against', () => { + const emitted = execFileSync(bb!, ['msgpack', 'curve_constants'], { + maxBuffer: 1024 * 1024, + }); + const actual = new Decoder({ useRecords: false }).decode(emitted) as Record; + + const hex = (b: Uint8Array) => Buffer.from(b).toString('hex'); + const point = (p: { x: Uint8Array; y: Uint8Array }) => [hex(p.x), hex(p.y)]; + const pointFq2 = (p: { x: readonly Uint8Array[]; y: readonly Uint8Array[] }) => [p.x.map(hex), p.y.map(hex)]; + const asModulus = (b: Uint8Array) => BigInt(`0x${hex(b)}`); + + expect(asModulus(actual.bn254_fr_modulus)).toEqual(BN254_FR_MODULUS); + expect(asModulus(actual.bn254_fq_modulus)).toEqual(BN254_FQ_MODULUS); + expect(point(actual.bn254_g1_generator)).toEqual(point(BN254_G1_GENERATOR)); + expect(pointFq2(actual.bn254_g2_generator)).toEqual(pointFq2(BN254_G2_GENERATOR)); + + expect(asModulus(actual.grumpkin_fr_modulus)).toEqual(GRUMPKIN_FR_MODULUS); + expect(asModulus(actual.grumpkin_fq_modulus)).toEqual(GRUMPKIN_FQ_MODULUS); + expect(point(actual.grumpkin_g1_generator)).toEqual(point(GRUMPKIN_G1_GENERATOR)); + + expect(asModulus(actual.secp256k1_fr_modulus)).toEqual(SECP256K1_FR_MODULUS); + expect(asModulus(actual.secp256k1_fq_modulus)).toEqual(SECP256K1_FQ_MODULUS); + expect(point(actual.secp256k1_g1_generator)).toEqual(point(SECP256K1_G1_GENERATOR)); + + expect(asModulus(actual.secp256r1_fr_modulus)).toEqual(SECP256R1_FR_MODULUS); + expect(asModulus(actual.secp256r1_fq_modulus)).toEqual(SECP256R1_FQ_MODULUS); + expect(point(actual.secp256r1_g1_generator)).toEqual(point(SECP256R1_G1_GENERATOR)); + }); +}); diff --git a/barretenberg/ts/bb.js/src/curve_constants.ts b/barretenberg/ts/bb.js/src/curve_constants.ts new file mode 100644 index 000000000000..1ded9f279756 --- /dev/null +++ b/barretenberg/ts/bb.js/src/curve_constants.ts @@ -0,0 +1,71 @@ +/** + * Field moduli and generator points for the curves bb works over, for callers doing their own + * field arithmetic rather than asking bb. + * + * These are properties of the curves, not of any bb build, so they are written out here rather + * than fetched or generated. `curve_constants.test.ts` checks them against `bb msgpack + * curve_constants`, which computes the same values from the curve definitions bb is compiled + * against, so a divergence is caught rather than assumed away. + * + * Coordinates are 32-byte big-endian, matching how bb serialises a field element. BN254's G2 is + * over Fq2, so each of its coordinates is a pair. + */ + +function modulus(hex: string): bigint { + return BigInt(`0x${hex}`); +} + +function coord(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +/** A point with 32-byte big-endian coordinates. */ +export type CurvePoint = { readonly x: Uint8Array; readonly y: Uint8Array }; +/** A point over a quadratic extension field, each coordinate a pair. */ +export type CurvePointFq2 = { + readonly x: readonly [Uint8Array, Uint8Array]; + readonly y: readonly [Uint8Array, Uint8Array]; +}; + +export const BN254_FR_MODULUS = modulus('30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001'); +export const BN254_FQ_MODULUS = modulus('30644e72e131a029b85045b68181585d97816a916871ca8d3c208c16d87cfd47'); +export const BN254_G1_GENERATOR: CurvePoint = { + x: coord('0000000000000000000000000000000000000000000000000000000000000001'), + y: coord('0000000000000000000000000000000000000000000000000000000000000002'), +}; +export const BN254_G2_GENERATOR: CurvePointFq2 = { + x: [ + coord('1800deef121f1e76426a00665e5c4479674322d4f75edadd46debd5cd992f6ed'), + coord('198e9393920d483a7260bfb731fb5d25f1aa493335a9e71297e485b7aef312c2'), + ], + y: [ + coord('12c85ea5db8c6deb4aab71808dcb408fe3d1e7690c43d37b4ce6cc0166fa7daa'), + coord('090689d0585ff075ec9e99ad690c3395bc4b313370b38ef355acdadcd122975b'), + ], +}; + +// Grumpkin is BN254 with the two fields swapped: its scalar field is BN254's base field. +export const GRUMPKIN_FR_MODULUS = modulus('30644e72e131a029b85045b68181585d97816a916871ca8d3c208c16d87cfd47'); +export const GRUMPKIN_FQ_MODULUS = modulus('30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001'); +export const GRUMPKIN_G1_GENERATOR: CurvePoint = { + x: coord('0000000000000000000000000000000000000000000000000000000000000001'), + y: coord('0000000000000002cf135e7506a45d632d270d45f1181294833fc48d823f272c'), +}; + +export const SECP256K1_FR_MODULUS = modulus('fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141'); +export const SECP256K1_FQ_MODULUS = modulus('fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f'); +export const SECP256K1_G1_GENERATOR: CurvePoint = { + x: coord('79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'), + y: coord('483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8'), +}; + +export const SECP256R1_FR_MODULUS = modulus('ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551'); +export const SECP256R1_FQ_MODULUS = modulus('ffffffff00000001000000000000000000000000ffffffffffffffffffffffff'); +export const SECP256R1_G1_GENERATOR: CurvePoint = { + x: coord('6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296'), + y: coord('4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5'), +}; diff --git a/barretenberg/ts/bb.js/src/index.html b/barretenberg/ts/bb.js/src/index.html deleted file mode 100644 index 82293342f4ef..000000000000 --- a/barretenberg/ts/bb.js/src/index.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - My App - - - - - diff --git a/barretenberg/ts/bb.js/src/index.ts b/barretenberg/ts/bb.js/src/index.ts index 571793f8c41e..fe0ecc071443 100644 --- a/barretenberg/ts/bb.js/src/index.ts +++ b/barretenberg/ts/bb.js/src/index.ts @@ -16,8 +16,19 @@ export { } from './barretenberg/index.js'; export { randomBytes } from './random/index.js'; -export { splitHonkProof, reconstructHonkProof, deflattenFields, type ProofData } from './proof/index.js'; -export { BBApiException } from './bbapi_exception.js'; +export { splitHonkProof, reconstructHonkProof, deflattenFields, type ProofData } from './proof.js'; +/** + * `BbError` is thrown when bb answers a command with an error rather than a result. It comes from + * the generated API, so the async and sync classes throw the same one. + * + * One failure does not raise it: a handler that reports by throwing (throw_or_abort) rather than by + * returning an error response gives a plain Error on the wasm backends, because bb's wasm build + * compiles with BB_NO_EXCEPTIONS and the throw reaches the host's hook instead of the dispatcher's + * catch. The message is the same either way, so catch Error to handle both. + * + * BBApiException is the name the previous API used, kept so existing callers keep compiling. + */ +export { BbError, BbError as BBApiException } from '@aztec-foundation/bb.js-api'; // Export Point types for use in foundation and other packages export type { @@ -28,9 +39,9 @@ export type { GrumpkinPoint, Secp256k1Point, Secp256r1Point, -} from './generated/api_types.js'; +} from '@aztec-foundation/bb.js-api'; -export { toChonkProof } from './generated/api_types.js'; +export { toChonkProof } from '@aztec-foundation/bb.js-api'; /** * @deprecated Fq2 coordinates are typed per curve now (see Bn254G2Point). @@ -40,21 +51,7 @@ export type Field2 = [Uint8Array, Uint8Array]; export { CircuitKind } from './circuit_kind.js'; -// Export curve constants for use in foundation -export { - BN254_FQ_MODULUS, - BN254_FR_MODULUS, - BN254_G1_GENERATOR, - BN254_G2_GENERATOR, - GRUMPKIN_FR_MODULUS, - GRUMPKIN_FQ_MODULUS, - GRUMPKIN_G1_GENERATOR, - SECP256K1_FR_MODULUS, - SECP256K1_FQ_MODULUS, - SECP256K1_G1_GENERATOR, - SECP256R1_FR_MODULUS, - SECP256R1_FQ_MODULUS, - SECP256R1_G1_GENERATOR, -} from './generated/curve_constants.js'; +// Curve constants, for callers doing their own field arithmetic. +export * from './curve_constants.js'; -export { findBbBinary, findNapiBinary } from './bb_backends/node/platform.js'; +export { findBbBinary, findNapiBinary } from './backends/node/platform.js'; diff --git a/barretenberg/ts/bb.js/src/proof/index.ts b/barretenberg/ts/bb.js/src/proof.ts similarity index 100% rename from barretenberg/ts/bb.js/src/proof/index.ts rename to barretenberg/ts/bb.js/src/proof.ts diff --git a/barretenberg/ts/bootstrap.sh b/barretenberg/ts/bootstrap.sh index b4089bf0fbb5..d83849ffaef8 100755 --- a/barretenberg/ts/bootstrap.sh +++ b/barretenberg/ts/bootstrap.sh @@ -5,6 +5,7 @@ ROOT=$(git rev-parse --show-toplevel) BB_AVM_SIM_BINARY=bb-avm-sim BB_AVM_SIM_PACKAGE=@aztec-foundation/bb-avm-sim CDB_PACKAGE=@aztec-foundation/cdb +BB_JS_API_PACKAGE=@aztec-foundation/bb.js-api hash=$(hash_str \ $(bb.js/bootstrap.sh hash) \ @@ -25,8 +26,6 @@ function generate_bb_avm_sim_package { "$ROOT/ipc-codegen/src/generate.ts" \ --schema "$ROOT/barretenberg/cpp/src/barretenberg/avm/avm_schema.json" \ --lang ts \ - --client \ - --out "$ROOT/barretenberg/ts/bb-avm-sim/src/generated" \ --package "$ROOT/barretenberg/ts/bb-avm-sim" \ --package-name "$BB_AVM_SIM_PACKAGE" \ --binary-name "$BB_AVM_SIM_BINARY" \ @@ -44,17 +43,100 @@ function generate_cdb_package { --schema "$ROOT/barretenberg/cpp/src/barretenberg/cdb/cdb_schema.json" \ --lang ts \ --server \ - --out "$ROOT/barretenberg/ts/cdb/src/generated" \ --package "$ROOT/barretenberg/ts/cdb" \ --package-name "$CDB_PACKAGE" } -# Both bb-avm-sim and cdb are gitignored workspaces declared in package.json, so -# `yarn install --immutable` fails against the committed lockfile unless both exist. +# The bb API as a generated client package: typed AsyncApi/SyncApi over bb spawned as a +# process (uds, shm) or run in-process as the wasm module (node and browsers). bb.js is +# a consumer of this package, keeping only its facades and CRS handling. +function generate_bb_js_api_package { + local bbapi="$ROOT/barretenberg/cpp/src/barretenberg/bbapi" + # bb.js keeps its historical API surface (poseidon2Hash, Poseidon2Hash), so the Bb + # service prefix is stripped from identifiers; wire tags keep it. + node --experimental-strip-types --experimental-transform-types --no-warnings \ + "$ROOT/ipc-codegen/src/generate.ts" \ + --schema "$bbapi/bb_schema.json" \ + --lang ts \ + --package "$ROOT/barretenberg/ts/bb.js-api" \ + --package-name "$BB_JS_API_PACKAGE" \ + --binary-name bb \ + --binary-env-var BB_BINARY_PATH \ + --strip-method-prefix \ + --strip-type-prefix \ + --package-transports uds,shm,wasm \ + --package-ipc-path-args 'msgpack,run,--input,{path}' \ + --package-wasm-module barretenberg.wasm \ + --package-wasm-threads-module barretenberg-threads.wasm +} + +# bb-avm-sim, cdb and bb.js-api are gitignored workspaces declared in package.json, so +# `yarn install --immutable` fails against the committed lockfile unless all exist. # Generate them together before installing, whichever one we're about to build. function generate_packages { generate_bb_avm_sim_package generate_cdb_package + generate_bb_js_api_package +} + +# The wasm builds the bb.js-api package ships: threads (node, cross-origin isolated +# browsers) and single-thread (browsers without SharedArrayBuffer). +# +# Shipped uncompressed. npm tarballs are gzipped either way, so this costs nothing on install, +# and it is the form a host's own compression and the browser's compiled-code cache both want: +# only a real application/wasm response can be streamed straight into WebAssembly.compileStreaming +# and cached. A consumer serving from a host that does not compress can point bb.js's wasmPath (or +# BB_WASM_PATH) at a compressed copy instead; the loader recognises gzip. +function copy_bb_js_api_wasm { + # Replace rather than add to: the package publishes everything under wasm/, so a module left + # from an earlier build would ship alongside the current one. + rm -rf bb.js-api/wasm + mkdir -p bb.js-api/wasm + cp "$ROOT/barretenberg/cpp/build-wasm-threads/bin/barretenberg.wasm" bb.js-api/wasm/barretenberg-threads.wasm + cp "$ROOT/barretenberg/cpp/build-wasm/bin/barretenberg.wasm" bb.js-api/wasm/barretenberg.wasm +} + +function copy_bb_js_api_native { + local target_dir="bb.js-api/build/$(arch)-$(os)" + mkdir -p "$target_dir" + cp "$ROOT/barretenberg/cpp/build/bin/bb" "$target_dir/bb" +} + +function copy_bb_js_api_cross { + if [ -n "${1:-}" ]; then + local cross_arch="$1" + mkdir -p "bb.js-api/build/$cross_arch" + cp "$ROOT/barretenberg/cpp/build-$cross_arch/bin/bb" "bb.js-api/build/$cross_arch/bb" + elif semver check "${REF_NAME:-}" && [ "$(arch)" == "amd64" ]; then + for cross_arch in arm64-linux amd64-macos arm64-macos; do + mkdir -p "bb.js-api/build/$cross_arch" + cp "$ROOT/barretenberg/cpp/build-$cross_arch/bin/bb" "bb.js-api/build/$cross_arch/bb" + done + else + echo "This task is expected to be run with an explicit arch or in an x86 release context." + fi +} + +function prepare_bb_js_api_arch_packages { + yarn workspace "$BB_JS_API_PACKAGE" run prepare_arch_packages "$@" +} + +# Generate + compile the package bb.js compiles against, without the wasm/binary artifacts +# (enough for type-checking, formatting and lint). Not cached: it is a few seconds of tsc. +function build_bb_js_api_ts { + generate_packages + npm_install_deps "$IPC_RUNTIME_PKG" + yarn workspace "$BB_JS_API_PACKAGE" build +} + +# The full package: compiled TS plus the wasm modules and this machine's bb binary. bb.js +# runs these at test time, so it stages them even when its own build is cached. +function build_bb_js_api { + echo_header "bb.js-api package build" + build_bb_js_api_ts + copy_bb_js_api_wasm + copy_bb_js_api_native + prepare_bb_js_api_arch_packages "$(arch)-$(os)=build/$(arch)-$(os)/bb" } function copy_bb_avm_sim_native { @@ -81,7 +163,7 @@ function copy_bb_avm_sim_cross { } function prepare_bb_avm_sim_arch_packages { - (cd bb-avm-sim && ./scripts/prepare_arch_packages.sh "$@") + yarn workspace "$BB_AVM_SIM_PACKAGE" run prepare_arch_packages "$@" } function build_bb_js { @@ -160,7 +242,10 @@ function test { (cd bb.js && ./bootstrap.sh test) } +# bb.js's own cross copies (the LMDB NAPI module) and bb.js-api's (the bb binary), which bb.js +# runs through. function cross_copy_bb_js { + cross_copy_bb_js_api "$@" (cd bb.js && ./bootstrap.sh cross_copy "$@") } @@ -178,6 +263,12 @@ function cross_copy { function get_projects { echo "$PWD/bb.js" + if [ -d bb.js-api ]; then + for package_dir in bb.js-api/packages/*; do + [ -d "$package_dir" ] && echo "$PWD/$package_dir" + done + echo "$PWD/bb.js-api" + fi if [ -d bb-avm-sim ]; then for package_dir in bb-avm-sim/packages/*; do [ -d "$package_dir" ] && echo "$PWD/$package_dir" @@ -209,7 +300,38 @@ function release_cdb { (cd cdb && retry "deploy_npm ${REF_NAME#v}") } +function cross_copy_bb_js_api { + generate_packages + copy_bb_js_api_cross "$@" + npm_install_deps "$IPC_RUNTIME_PKG" + yarn workspace "$BB_JS_API_PACKAGE" build + prepare_bb_js_api_arch_packages +} + +# bb.js depends on bb.js-api, so it is published first (with its arch packages, the one published +# home of the bb binary). +function release_bb_js_api { + generate_packages + copy_bb_js_api_wasm + copy_bb_js_api_native + copy_bb_js_api_cross + npm_install_deps "$IPC_RUNTIME_PKG" + yarn workspace "$BB_JS_API_PACKAGE" build + prepare_bb_js_api_arch_packages + # The binaries come from builds keyed on source, not on the release: finalize them so they + # carry this release's version like every other copy. + local f + for f in bb.js-api/packages/*/bb; do + [ -f "$f" ] && ../cpp/bootstrap.sh finalize_bb_binary "$(realpath "$f")" + done + for package_dir in bb.js-api/packages/*; do + (cd "$package_dir" && retry "deploy_npm ${REF_NAME#v}") + done + (cd bb.js-api && retry "deploy_npm ${REF_NAME#v}") +} + function release { + release_bb_js_api (cd bb.js && ./bootstrap.sh release) release_bb_avm_sim release_cdb @@ -217,7 +339,8 @@ function release { } export -f generate_bb_avm_sim_package copy_bb_avm_sim_native copy_bb_avm_sim_cross generate_cdb_package generate_packages -export -f build_bb_js build_bb_avm_sim build_cdb build cross_copy_bb_js cross_copy_bb_avm_sim release release_cdb +export -f generate_bb_js_api_package copy_bb_js_api_wasm copy_bb_js_api_native copy_bb_js_api_cross prepare_bb_js_api_arch_packages build_bb_js_api_ts build_bb_js_api +export -f build_bb_js build_bb_avm_sim build_cdb build cross_copy_bb_js cross_copy_bb_avm_sim cross_copy_bb_js_api release release_cdb release_bb_js_api case "$cmd" in "") diff --git a/barretenberg/ts/package.json b/barretenberg/ts/package.json index 552ffc5c233a..ab6588c9187a 100644 --- a/barretenberg/ts/package.json +++ b/barretenberg/ts/package.json @@ -8,6 +8,8 @@ }, "workspaces": [ "bb.js", + "bb.js-api", + "bb.js-api/packages/*", "bb-avm-sim", "bb-avm-sim/packages/*", "cdb" diff --git a/barretenberg/ts/yarn.lock b/barretenberg/ts/yarn.lock index ccee440319c3..7a76ab18c604 100644 --- a/barretenberg/ts/yarn.lock +++ b/barretenberg/ts/yarn.lock @@ -56,10 +56,62 @@ __metadata: languageName: unknown linkType: soft +"@aztec-foundation/bb.js-api-darwin-arm64@npm:0.1.0, @aztec-foundation/bb.js-api-darwin-arm64@workspace:bb.js-api/packages/bb.js-api-darwin-arm64": + version: 0.0.0-use.local + resolution: "@aztec-foundation/bb.js-api-darwin-arm64@workspace:bb.js-api/packages/bb.js-api-darwin-arm64" + languageName: unknown + linkType: soft + +"@aztec-foundation/bb.js-api-darwin-x64@npm:0.1.0, @aztec-foundation/bb.js-api-darwin-x64@workspace:bb.js-api/packages/bb.js-api-darwin-x64": + version: 0.0.0-use.local + resolution: "@aztec-foundation/bb.js-api-darwin-x64@workspace:bb.js-api/packages/bb.js-api-darwin-x64" + languageName: unknown + linkType: soft + +"@aztec-foundation/bb.js-api-linux-arm64@npm:0.1.0, @aztec-foundation/bb.js-api-linux-arm64@workspace:bb.js-api/packages/bb.js-api-linux-arm64": + version: 0.0.0-use.local + resolution: "@aztec-foundation/bb.js-api-linux-arm64@workspace:bb.js-api/packages/bb.js-api-linux-arm64" + languageName: unknown + linkType: soft + +"@aztec-foundation/bb.js-api-linux-x64@npm:0.1.0, @aztec-foundation/bb.js-api-linux-x64@workspace:bb.js-api/packages/bb.js-api-linux-x64": + version: 0.0.0-use.local + resolution: "@aztec-foundation/bb.js-api-linux-x64@workspace:bb.js-api/packages/bb.js-api-linux-x64" + languageName: unknown + linkType: soft + +"@aztec-foundation/bb.js-api@workspace:^, @aztec-foundation/bb.js-api@workspace:bb.js-api": + version: 0.0.0-use.local + resolution: "@aztec-foundation/bb.js-api@workspace:bb.js-api" + dependencies: + "@aztec-foundation/bb.js-api-darwin-arm64": "npm:0.1.0" + "@aztec-foundation/bb.js-api-darwin-x64": "npm:0.1.0" + "@aztec-foundation/bb.js-api-linux-arm64": "npm:0.1.0" + "@aztec-foundation/bb.js-api-linux-x64": "npm:0.1.0" + "@aztec-foundation/ipc-runtime": "@aztec-foundation/ipc-runtime" + "@types/node": "npm:^22.15.17" + msgpackr: "npm:^1.11.2" + tslib: "npm:^2.4.0" + typescript: "npm:^5.3.3" + dependenciesMeta: + "@aztec-foundation/bb.js-api-darwin-arm64": + optional: true + "@aztec-foundation/bb.js-api-darwin-x64": + optional: true + "@aztec-foundation/bb.js-api-linux-arm64": + optional: true + "@aztec-foundation/bb.js-api-linux-x64": + optional: true + bin: + bb: ./dest/bin.js + languageName: unknown + linkType: soft + "@aztec-foundation/bb.js@workspace:bb.js": version: 0.0.0-use.local resolution: "@aztec-foundation/bb.js@workspace:bb.js" dependencies: + "@aztec-foundation/bb.js-api": "workspace:^" "@aztec-foundation/ipc-runtime": "@aztec-foundation/ipc-runtime" "@jest/globals": "npm:^30.0.0" "@swc/core": "npm:^1.10.1" @@ -71,7 +123,6 @@ __metadata: "@types/pako": "npm:^2.0.3" "@types/source-map-support": "npm:^0.5.6" "@typescript/native-preview": "npm:7.0.0-dev.20251126.1" - comlink: "npm:^4.4.1" commander: "npm:^12.1.0" eslint: "npm:^9.26.0" eslint-config-prettier: "npm:^10.1.5" @@ -111,6 +162,8 @@ __metadata: "@aztec-foundation/ipc-runtime@portal:../../ipc-runtime/ts::locator=%40aztec%2Fbarretenberg-ts-packages%40workspace%3A.": version: 0.0.0-use.local resolution: "@aztec-foundation/ipc-runtime@portal:../../ipc-runtime/ts::locator=%40aztec%2Fbarretenberg-ts-packages%40workspace%3A." + bin: + ipc-runtime-prepare-arch-packages: ./scripts/prepare_arch_packages.mjs languageName: node linkType: soft @@ -2694,13 +2747,6 @@ __metadata: languageName: node linkType: hard -"comlink@npm:^4.4.1": - version: 4.4.2 - resolution: "comlink@npm:4.4.2" - checksum: 10/ecee53b5b4536b3aa3f7636c383f831e68fbc013def77665cc7fad873d72cfa23b994e1ec4b49e83e4e909c1089a03acae03a523e33a5e5ed938cdb613456434 - languageName: node - linkType: hard - "commander@npm:^12.1.0": version: 12.1.0 resolution: "commander@npm:12.1.0" diff --git a/ipc-codegen/README.md b/ipc-codegen/README.md index ddde1aaf1e44..c74f9009c0f2 100644 --- a/ipc-codegen/README.md +++ b/ipc-codegen/README.md @@ -68,8 +68,8 @@ ipc-codegen/ naming.ts # snake_case / PascalCase helpers templates/ # static templates copied alongside generated code cpp/ipc_codegen/*.hpp # C++ support headers copied into generated output - rust/{backend,error,ffi_backend}.rs - zig/{backend,ffi_backend}.zig + rust/{backend,error}.rs + zig/backend.zig echo_example/ # 4-language echo service (cross-lang test harness) SCHEMA_SPEC.md # wire protocol and schema-format reference ``` @@ -94,7 +94,7 @@ node --experimental-strip-types --experimental-transform-types --no-warnings \ |---|---| | `--schema ` | Path to the schema (JSON or JSONC; friendly or legacy positional form — see `SCHEMA_SPEC.md`). | | `--lang ` | Target language. | -| `--out ` | Output directory. Generated files are (re)written every run; static templates are copied alongside and re-copied only if missing (so handwritten edits to templated scaffolding are preserved). | +| `--out ` | Output directory. Generated files are (re)written every run; static templates are copied alongside and re-copied only if missing (so handwritten edits to templated scaffolding are preserved). Implied by `--package` (`/src/generated`, where the package shell imports the bindings from). | ### Role flags @@ -102,9 +102,12 @@ node --experimental-strip-types --experimental-transform-types --no-warnings \ |---|---| | `--server` | Emit server dispatch (matches request name to handler, deserialises, calls handler, serialises response). Pair it with an `ipc::IpcServer` from ipc-runtime. | | `--client` | Emit a typed client class/struct with one method per command. Pair it with an `ipc::IpcClient` (C++) or the equivalent Rust/Zig/TS binding. | -| `--package ` | TS only. Emit a complete package shell. Which of the two shells you get depends on the role flags. With `--client` (or with neither role flag): a wrapper around the generated async client that launches a native service binary, connects over UDS or SHM, and resolves the binary from an override path, environment variable, installed arch package, or local `build//` directory. With `--server` and no `--client`: a pure-TS server binding package instead, holding wire types, the `Handler` interface and `handleRequest`/`dispatch`, plus the schema file at the package root, with no binary launcher and no arch packages. The byte transport is then supplied by the consumer, e.g. `UdsIpcServer` from ipc-runtime. Passing `--server --client` selects the client shell. | +| `--package ` | TS only. Emit a complete client package: the generated API over every backend the package has (`--package-transports`), with a `Service.create(options)` that picks one by default (the spawned process when the binary resolves, else the wasm module), can be forced to one, or takes a backend object of the consumer's own; one entry per host (node, `browser`, `react-native`) so each host sees only the backends that exist there; the binary resolved from an override path, an environment variable, or the installed per-platform arch package (this package's optional dependencies). With `--server` and no `--client`: a pure-TS server binding package instead, holding wire types, the `Handler` interface and `handleRequest`/`dispatch`, plus the schema file at the package root, with no binary launcher and no arch packages. The byte transport is then supplied by the consumer, e.g. `UdsIpcServer` from ipc-runtime. | | `--uds` | Rust/Zig only. Copies the `Backend` trait template (and `error.rs` for Rust) into `` so consumers can plug ipc-runtime — or any custom transport — behind the generated client. The flag name is historical: the trait is transport-agnostic. | -| `--ffi` | Rust/Zig only. Adds the `ffi_backend` template (a thin wrapper exposing the generated client over a C ABI for embedding in other languages). | +| `--ffi` | In-process FFI, both directions of the contract in `SCHEMA_SPEC.md` ("FFI entry"). With `--client` (Rust/Zig): generates `ffi_backend`, a client backend that calls a linked library's `_ipc_ffi_entry`. With `--server` (C++/Rust): emits the entry itself — `_ffi.{hpp,cpp}` defining `_ipc_ffi_entry`/`_alloc`/`_free` over the generated dispatch (the service defines `ipc_ffi_dispatcher()`), or `_ffi.rs` with an `export__ffi!` macro doing the same over a `Handler + Default` type. The service prefix on the symbols lets several services be linked into one binary. A wasm reactor built from these is what the TS `wasm` transport runs. | +| `--package-transports ` | TS `--package` only. Comma-separated transports the package offers: `uds`, `shm` (spawned process) and `wasm` (the service's wasm module, run in-process through `@aztec-foundation/ipc-runtime/wasm`; adds a browser entry). | +| `--package-wasm-module `, `--package-wasm-threads-module ` | TS `wasm` transport. Basenames of the single-thread and threads builds the owning project copies into the package's `wasm/` directory; at least one is required. | +| `--package-wasm-host-imports ` | TS `wasm` transport. A TS module copied to `src/wasm_host_imports.ts` exporting `hostImports`, for a module whose platform layer imports functions beyond WASI (bb imports a logger, an abort hook and its thread count). Default: none. | ### Naming flags @@ -129,7 +132,6 @@ flags below are only for legacy positional schemas, which have no `service`. | Flag | Purpose | |---|---| -| `--curve-constants` | TS only. Also emit `curve_constants.ts` with bn254/grumpkin/secp moduli & generators for schemas that need curve constants. | | `--skeleton ` | One-shot scaffolding: writes a `_handlers.{ts,rs,zig,cpp}` stub, `main`, and a build file into `` if they don't already exist. Skipped on subsequent runs. | | `--package-name ` | TS package mode only. Package name to write into the generated `package.json`. | | `--binary-name ` | Client package shell only; ignored by the server shell. Native service binary name to launch. | @@ -143,18 +145,17 @@ Paths below are illustrative — consumers commit their own schema next to the C++ server that owns the wire format and supply absolute or relative paths on the command line. -### TypeScript client, with curve constants +### TypeScript client ```sh src/generate.ts \ --schema /path/to/myservice_schema.jsonc \ --lang ts \ --out /path/to/output/generated \ - --client \ - --curve-constants + --client ``` -Produces `api_types.ts`, `async.ts`, `sync.ts`, `curve_constants.ts`. The TS +Produces `api_types.ts`, `async.ts`, `sync.ts`. The TS client uses `@aztec-foundation/ipc-runtime`'s `UdsIpcClient` or `NapiShmSyncClient` for transport — no template copy. @@ -164,7 +165,6 @@ transport — no template copy. src/generate.ts \ --schema /path/to/myservice_schema.jsonc \ --lang ts \ - --out /path/to/myservice/src/generated \ --client \ --package /path/to/myservice \ --package-name @aztec/myservice \ @@ -172,11 +172,14 @@ src/generate.ts \ --package-transports uds,shm ``` -Produces the generated TS client under `src/generated/` plus a package shell +Produces the generated TS client under `src/generated/` (the package implies +`--out`) plus a package shell (`package.json`, `tsconfig.json`, `src/index.ts`, `src/platform.ts`, and -`scripts/prepare_arch_packages.sh`). The package exports a -`MyServiceService.spawn(...)` helper that launches the native binary and wraps -the generated async client. `scripts/prepare_arch_packages.sh` turns +`scripts/prepare_arch_packages.sh`). The package exports +`MyServiceService.create(...)`, which wraps the generated async client around +the backend chosen for the host (a spawned process by default here; `spawn` +and `wasm` force one), plus `createBackend` for facades that wrap the API +themselves. `scripts/prepare_arch_packages.sh` turns `build//` directories into per-architecture npm packages matching the binary resolution path. diff --git a/ipc-codegen/SCHEMA_SPEC.md b/ipc-codegen/SCHEMA_SPEC.md index d4f1ddabe05e..269ee61e3803 100644 --- a/ipc-codegen/SCHEMA_SPEC.md +++ b/ipc-codegen/SCHEMA_SPEC.md @@ -212,6 +212,33 @@ msgpack uses the smallest encoding that fits the value, not the declared type: a `u64` of `5` encodes as a single positive-fixint byte. Decoders MUST accept any integer encoding width for any integer field. +### FFI entry + +A service linked into its caller — a native library, or a wasm reactor — takes +requests through one exported C symbol instead of a transport: + +```c +void _ipc_ffi_entry(const uint8_t* input, size_t input_len, + uint8_t** output, size_t* output_len); +void* _ipc_ffi_alloc(size_t size); +void _ipc_ffi_free(void* ptr); +``` + +`` is the schema's `service` in snake_case (`Bb` → `bb_ipc_ffi_entry`; +a positional schema uses the generator's type prefix, or the bare names when +there is none). The prefix is what lets several services be linked into one +binary. `input` is exactly the request payload above — no framing envelope, +since an in-process call has no transport — and `*output` receives the response +payload in a buffer from `_ipc_ffi_alloc()` that the caller releases with +`_ipc_ffi_free()` (both malloc/free-compatible). Input buffers the caller +hands over are allocated the same way when the caller lives outside the +module's memory (wasm). The entry is not thread-safe; callers serialize their +calls. `--server --ffi` generates it; the client side is the generated Rust/Zig +`ffi_backend` natively and `@aztec-foundation/ipc-runtime/wasm` for a wasm +module, which also expects a wasi reactor (`_initialize`, WASI imports, +wasi-threads `thread-spawn` for a threads build) and finds the entry by its +`_ipc_ffi_entry` suffix when not told its name. + ## Schema versioning A SHA-256 hash of the schema can be computed and embedded in generated code for diff --git a/ipc-codegen/bootstrap.sh b/ipc-codegen/bootstrap.sh index fa01a18fa960..ed9603852f41 100755 --- a/ipc-codegen/bootstrap.sh +++ b/ipc-codegen/bootstrap.sh @@ -55,8 +55,12 @@ function test_cmds { echo "$prefix $script golden ts" echo "$prefix $script golden cpp" echo "$prefix $script golden zig" + # The generated C++ FFI entry, driven in-process (the Rust equivalent runs as + # a cargo test during build()). + echo "$prefix ipc-codegen/echo_example/cpp/build/bin/ffi_test" echo "$prefix ipc-codegen/echo_example/ts_package/test.sh uds" echo "$prefix ipc-codegen/echo_example/ts_package/test.sh shm" + echo "$prefix ipc-codegen/echo_example/ts_package/test.sh wasm" # Matrix: one command per (server, client) pair over UDS. for server in "${matrix_langs[@]}"; do diff --git a/ipc-codegen/echo_example/cpp/CMakeLists.txt b/ipc-codegen/echo_example/cpp/CMakeLists.txt index ab156f9b048a..fabe0bd0cde0 100644 --- a/ipc-codegen/echo_example/cpp/CMakeLists.txt +++ b/ipc-codegen/echo_example/cpp/CMakeLists.txt @@ -39,7 +39,7 @@ target_compile_definitions(echo_common INTERFACE MSGPACK_USE_STD_VARIANT_ADAPTOR ) -add_executable(echo_server src/echo_server.cpp) +add_executable(echo_server src/echo_server.cpp src/echo_handlers.cpp) target_link_libraries(echo_server PRIVATE echo_common ipc_runtime) add_executable(echo_client @@ -50,3 +50,15 @@ target_link_libraries(echo_client PRIVATE echo_common ipc_runtime) add_executable(golden_test src/golden_test.cpp) target_link_libraries(golden_test PRIVATE echo_common) + +# The service as an in-process library: the generated FFI entry over the same +# handlers the socket server uses. +add_library(echo_ffi STATIC + src/echo_ffi.cpp + src/echo_handlers.cpp + src/generated/echo_ffi.cpp +) +target_link_libraries(echo_ffi PUBLIC echo_common) + +add_executable(ffi_test src/ffi_test.cpp) +target_link_libraries(ffi_test PRIVATE echo_ffi) diff --git a/ipc-codegen/echo_example/cpp/bootstrap.sh b/ipc-codegen/echo_example/cpp/bootstrap.sh index 9f536ff54fe4..36a89694075e 100755 --- a/ipc-codegen/echo_example/cpp/bootstrap.sh +++ b/ipc-codegen/echo_example/cpp/bootstrap.sh @@ -10,8 +10,9 @@ $NODE "$CODEGEN/src/generate.ts" \ --lang cpp \ --server \ --client \ + --ffi \ --out "$DIR/src/generated" \ --cpp-namespace echo cmake -S "$DIR" -B "$DIR/build" -cmake --build "$DIR/build" --target echo_server echo_client golden_test +cmake --build "$DIR/build" --target echo_server echo_client golden_test ffi_test diff --git a/ipc-codegen/echo_example/cpp/src/echo_ffi.cpp b/ipc-codegen/echo_example/cpp/src/echo_ffi.cpp new file mode 100644 index 000000000000..bde360edc5cd --- /dev/null +++ b/ipc-codegen/echo_example/cpp/src/echo_ffi.cpp @@ -0,0 +1,15 @@ +// The service's half of the generated in-process FFI entry +// (generated/echo_ffi.cpp defines echo_ipc_ffi_entry and calls back here for +// the dispatcher). +#include "generated/echo_ffi.hpp" +#include "echo_handlers.hpp" + +namespace echo { + +AsyncDispatchHandler &ipc_ffi_dispatcher() { + static EchoCtx ctx; + static AsyncDispatchHandler handler = make_echo_handler(ctx); + return handler; +} + +} // namespace echo diff --git a/ipc-codegen/echo_example/cpp/src/echo_handlers.cpp b/ipc-codegen/echo_example/cpp/src/echo_handlers.cpp new file mode 100644 index 000000000000..32ba0562a1dd --- /dev/null +++ b/ipc-codegen/echo_example/cpp/src/echo_handlers.cpp @@ -0,0 +1,52 @@ +// Echo service handlers — echo input fields back in the response. Handlers are +// asynchronous: they produce their result via respond.ok(...) (synchronously +// here; a real service could defer to a thread pool and respond later). +#include "echo_handlers.hpp" + +#include +#include + +namespace echo { + +template <> +void handle_bytes(EchoCtx & /*ctx*/, wire::EchoBytes &&cmd, + Responder respond) { + respond.ok({.data = std::move(cmd.data)}); +} + +template <> +void handle_fields(EchoCtx & /*ctx*/, wire::EchoFields &&cmd, + Responder respond) { + respond.ok({.a = cmd.a, .b = cmd.b, .name = std::move(cmd.name)}); +} + +template <> +void handle_nested(EchoCtx & /*ctx*/, wire::EchoNested &&cmd, + Responder respond) { + respond.ok({.inner = std::move(cmd.inner)}); +} + +template <> +void handle_aliases(EchoCtx & /*ctx*/, wire::EchoAliases &&cmd, + Responder respond) { + respond.ok({.treeId = cmd.treeId, + .hash = cmd.hash, + .maybeHash = cmd.maybeHash, + .hashes = std::move(cmd.hashes)}); +} + +template <> +void handle_blobs(EchoCtx & /*ctx*/, wire::EchoBlobs &&cmd, + Responder respond) { + respond.ok( + {.maybeData = std::move(cmd.maybeData), .parts = std::move(cmd.parts)}); +} + +template <> +void handle_fail(EchoCtx & /*ctx*/, wire::EchoFail &&cmd, + Responder /*respond*/) { + // Throwing is turned into an error frame by the generated dispatch. + throw std::runtime_error(cmd.message); +} + +} // namespace echo diff --git a/ipc-codegen/echo_example/cpp/src/echo_handlers.hpp b/ipc-codegen/echo_example/cpp/src/echo_handlers.hpp new file mode 100644 index 000000000000..21016159c7c3 --- /dev/null +++ b/ipc-codegen/echo_example/cpp/src/echo_handlers.hpp @@ -0,0 +1,32 @@ +// Echo service handlers: the template specializations the header-only generated +// dispatch calls, shared by the socket server (echo_server.cpp) and the +// in-process FFI entry (echo_ffi.cpp). Declared here so every translation unit +// that instantiates make_echo_handler() sees them first. +#pragma once + +#include "generated/echo_dispatch.hpp" + +namespace echo { + +struct EchoCtx {}; // empty context for the echo service + +template <> +void handle_bytes(EchoCtx &ctx, wire::EchoBytes &&cmd, + Responder respond); +template <> +void handle_fields(EchoCtx &ctx, wire::EchoFields &&cmd, + Responder respond); +template <> +void handle_nested(EchoCtx &ctx, wire::EchoNested &&cmd, + Responder respond); +template <> +void handle_aliases(EchoCtx &ctx, wire::EchoAliases &&cmd, + Responder respond); +template <> +void handle_blobs(EchoCtx &ctx, wire::EchoBlobs &&cmd, + Responder respond); +template <> +void handle_fail(EchoCtx &ctx, wire::EchoFail &&cmd, + Responder respond); + +} // namespace echo diff --git a/ipc-codegen/echo_example/cpp/src/echo_server.cpp b/ipc-codegen/echo_example/cpp/src/echo_server.cpp index f272d6e01fd8..12814cdb7bc1 100644 --- a/ipc-codegen/echo_example/cpp/src/echo_server.cpp +++ b/ipc-codegen/echo_example/cpp/src/echo_server.cpp @@ -1,63 +1,13 @@ -// Echo IPC server (C++) — provides handler specializations for the -// header-only generated dispatch. +// Echo IPC server (C++) over the header-only generated dispatch and +// ipc-runtime's socket/shared-memory server. // Usage: echo_server --socket /tmp/echo.sock +#include "echo_handlers.hpp" #include "generated/echo_ipc_server.hpp" #include -#include #include -namespace echo { - -struct EchoCtx {}; // empty context for the echo service - -// Template specializations — echo input fields back in response. Handlers are -// asynchronous: they produce their result via respond.ok(...) (synchronously -// here; a real service could defer to a thread pool and respond later). -template <> -void handle_bytes(EchoCtx & /*ctx*/, wire::EchoBytes &&cmd, - Responder respond) { - respond.ok({.data = std::move(cmd.data)}); -} - -template <> -void handle_fields(EchoCtx & /*ctx*/, wire::EchoFields &&cmd, - Responder respond) { - respond.ok({.a = cmd.a, .b = cmd.b, .name = std::move(cmd.name)}); -} - -template <> -void handle_nested(EchoCtx & /*ctx*/, wire::EchoNested &&cmd, - Responder respond) { - respond.ok({.inner = std::move(cmd.inner)}); -} - -template <> -void handle_aliases(EchoCtx & /*ctx*/, wire::EchoAliases &&cmd, - Responder respond) { - respond.ok({.treeId = cmd.treeId, - .hash = cmd.hash, - .maybeHash = cmd.maybeHash, - .hashes = std::move(cmd.hashes)}); -} - -template <> -void handle_blobs(EchoCtx & /*ctx*/, wire::EchoBlobs &&cmd, - Responder respond) { - respond.ok( - {.maybeData = std::move(cmd.maybeData), .parts = std::move(cmd.parts)}); -} - -template <> -void handle_fail(EchoCtx & /*ctx*/, wire::EchoFail &&cmd, - Responder /*respond*/) { - // Throwing is turned into an error frame by the generated dispatch. - throw std::runtime_error(cmd.message); -} - -} // namespace echo - int main(int argc, char **argv) { const char *socket_path = nullptr; for (int i = 1; i < argc - 1; i++) { diff --git a/ipc-codegen/echo_example/cpp/src/ffi_test.cpp b/ipc-codegen/echo_example/cpp/src/ffi_test.cpp new file mode 100644 index 000000000000..522be0daa749 --- /dev/null +++ b/ipc-codegen/echo_example/cpp/src/ffi_test.cpp @@ -0,0 +1,119 @@ +// In-process FFI conformance test (C++): drives the generated +// echo_ipc_ffi_entry with wire requests and checks the responses — a round +// trip, the error frame for a failing command, and the error frame for +// malformed input. +// +// Usage: ffi_test + +#include "generated/echo_ffi.hpp" + +#include +#include +#include +#include +#include + +namespace { + +int g_fail = 0; + +void check(bool ok, const std::string &label) { + std::cerr << (ok ? " PASS: " : " FAIL: ") << label << "\n"; + if (!ok) + g_fail++; +} + +// [[name, payload]] — a named-union pair inside the one-element argument array. +template +std::vector pack_request(const char *name, const Cmd &cmd) { + msgpack::sbuffer buf; + msgpack::packer pk(buf); + pk.pack_array(1); + pk.pack_array(2); + pk.pack(std::string(name)); + pk.pack(cmd); + return std::vector(buf.data(), buf.data() + buf.size()); +} + +// Hand the request over the way a foreign caller does: in a buffer from +// echo_ipc_ffi_alloc, receiving the response in one it frees with +// echo_ipc_ffi_free. +std::vector call(const std::vector &request) { + auto *in = static_cast(echo_ipc_ffi_alloc(request.size())); + std::copy(request.begin(), request.end(), in); + uint8_t *out = nullptr; + size_t out_len = 0; + echo_ipc_ffi_entry(in, request.size(), &out, &out_len); + echo_ipc_ffi_free(in); + std::vector response(out, out + out_len); + echo_ipc_ffi_free(out); + return response; +} + +struct Response { + std::string type; + msgpack::object_handle handle; + msgpack::object payload; +}; + +Response decode(const std::vector &bytes) { + Response r; + r.handle = msgpack::unpack(reinterpret_cast(bytes.data()), + bytes.size()); + auto obj = r.handle.get(); + if (obj.type != msgpack::type::ARRAY || obj.via.array.size != 2) { + throw std::runtime_error("response is not a [name, payload] pair"); + } + r.type = obj.via.array.ptr[0].as(); + r.payload = obj.via.array.ptr[1]; + return r; +} + +std::string error_message(const msgpack::object &payload) { + std::map fields; + payload.convert(fields); + return fields["message"]; +} + +} // namespace + +int main() { + { + echo::wire::EchoBytes cmd{.data = {0xde, 0xad, 0xbe, 0xef, 0x42}}; + auto response = decode(call(pack_request("EchoBytes", cmd))); + echo::wire::EchoBytesResponse decoded; + response.payload.convert(decoded); + check(response.type == "EchoBytesResponse" && decoded.data == cmd.data, + "EchoBytes round trip"); + } + { + echo::wire::EchoFields cmd{.a = 42, .b = 999999, .name = "hello ffi"}; + auto response = decode(call(pack_request("EchoFields", cmd))); + echo::wire::EchoFieldsResponse decoded; + response.payload.convert(decoded); + check(response.type == "EchoFieldsResponse" && decoded.a == 42 && + decoded.b == 999999 && decoded.name == "hello ffi", + "EchoFields round trip"); + } + { + echo::wire::EchoFail cmd{.message = "boom"}; + auto response = decode(call(pack_request("EchoFail", cmd))); + check(response.type == "EchoErrorResponse" && + error_message(response.payload) == "boom", + "EchoFail becomes an error frame carrying the message"); + } + { + auto response = decode(call(pack_request("NoSuchCommand", 0))); + check(response.type == "EchoErrorResponse" && + error_message(response.payload).find("unknown command") != + std::string::npos, + "unknown command becomes an error frame"); + } + + if (g_fail > 0) { + std::cerr << "ffi_test: " << g_fail << " failure(s)\n"; + return 1; + } + std::cerr << "ffi_test: all passed\n"; + return 0; +} diff --git a/ipc-codegen/echo_example/rust/Cargo.toml b/ipc-codegen/echo_example/rust/Cargo.toml index b951bba882df..f1c8c0af18d3 100644 --- a/ipc-codegen/echo_example/rust/Cargo.toml +++ b/ipc-codegen/echo_example/rust/Cargo.toml @@ -3,6 +3,9 @@ name = "echo-wire-compat" version = "0.1.0" edition = "2021" +[lib] +crate-type = ["rlib", "cdylib"] + [[bin]] name = "echo_server" path = "src/echo_server.rs" @@ -14,8 +17,8 @@ path = "src/echo_client.rs" [features] default = ["ipc-runtime"] ipc-runtime = ["dep:ipc-runtime"] -# Compile-checks the generated FFI backend (no real FFI library is linked; -# the extern symbol is only required at link time of a consumer binary). +# The generated FFI entry (export_echo_ffi!) and FFI client backend; the two +# meet in-process in tests/ffi_roundtrip.rs. ffi = ["dep:libc"] [dependencies] diff --git a/ipc-codegen/echo_example/rust/bootstrap.sh b/ipc-codegen/echo_example/rust/bootstrap.sh index 570221c29f41..e979e28e4b39 100755 --- a/ipc-codegen/echo_example/rust/bootstrap.sh +++ b/ipc-codegen/echo_example/rust/bootstrap.sh @@ -15,5 +15,11 @@ $NODE "$CODEGEN/src/generate.ts" \ --out "$DIR/src/generated" (cd "$DIR" && cargo build --locked --quiet) -# Compile-check the generated FFI backend (not linked into the binaries). -(cd "$DIR" && cargo check --locked --quiet --features ffi) +# The generated FFI entry and the FFI client backend, linked into one crate: the +# client calls the service in-process (tests/ffi_roundtrip.rs). +(cd "$DIR" && cargo test --locked --quiet --features ffi) +# The same FFI entry as a wasi reactor, which is what the TS package's wasm +# transport runs. A cdylib on wasm32-wasip1 needs no linker flags of its own: +# the exported echo_ipc_ffi_* symbols are the whole contract. +(cd "$DIR" && cargo build --locked --quiet --lib --release \ + --target wasm32-wasip1 --no-default-features --features ffi) diff --git a/ipc-codegen/echo_example/rust/src/echo_server.rs b/ipc-codegen/echo_example/rust/src/echo_server.rs index ebf4e68fcbb0..6a136c3fd2d9 100644 --- a/ipc-codegen/echo_example/rust/src/echo_server.rs +++ b/ipc-codegen/echo_example/rust/src/echo_server.rs @@ -1,49 +1,10 @@ //! Echo IPC server — uses GENERATED dispatch + types + ipc-runtime transport. //! Usage: echo_server --socket /tmp/echo.sock -use echo_wire_compat::generated::echo_server::{Handler, Responder}; -use echo_wire_compat::generated::echo_types::*; +use echo_wire_compat::handler::EchoHandler; use ipc_runtime::IpcServer; use std::cell::RefCell; -struct EchoHandler; - -// Handlers are asynchronous: they produce their result via respond.ok(...) / -// respond.error(...) (synchronously here; an async transport could defer and -// respond later from another thread). -impl Handler for EchoHandler { - fn bytes(&mut self, cmd: EchoBytes, respond: Responder) { - respond.ok(EchoBytesResponse { data: cmd.data }); - } - fn fields(&mut self, cmd: EchoFields, respond: Responder) { - respond.ok(EchoFieldsResponse { - a: cmd.a, - b: cmd.b, - name: cmd.name, - }); - } - fn nested(&mut self, cmd: EchoNested, respond: Responder) { - respond.ok(EchoNestedResponse { inner: cmd.inner }); - } - fn aliases(&mut self, cmd: EchoAliases, respond: Responder) { - respond.ok(EchoAliasesResponse { - tree_id: cmd.tree_id, - hash: cmd.hash, - maybe_hash: cmd.maybe_hash, - hashes: cmd.hashes, - }); - } - fn blobs(&mut self, cmd: EchoBlobs, respond: Responder) { - respond.ok(EchoBlobsResponse { - maybe_data: cmd.maybe_data, - parts: cmd.parts, - }); - } - fn fail(&mut self, cmd: EchoFail, respond: Responder) { - respond.error(cmd.message); - } -} - fn main() { let args: Vec = std::env::args().collect(); let socket_path = args diff --git a/ipc-codegen/echo_example/rust/src/handler.rs b/ipc-codegen/echo_example/rust/src/handler.rs new file mode 100644 index 000000000000..83c1738e17ce --- /dev/null +++ b/ipc-codegen/echo_example/rust/src/handler.rs @@ -0,0 +1,44 @@ +//! The echo service's handler, shared by the socket server binary and the +//! in-process FFI entry (`export_echo_ffi!` in lib.rs). + +use crate::generated::echo_server::{Handler, Responder}; +use crate::generated::echo_types::*; + +#[derive(Default)] +pub struct EchoHandler; + +// Handlers are asynchronous: they produce their result via respond.ok(...) / +// respond.error(...) (synchronously here; an async transport could defer and +// respond later from another thread). +impl Handler for EchoHandler { + fn bytes(&mut self, cmd: EchoBytes, respond: Responder) { + respond.ok(EchoBytesResponse { data: cmd.data }); + } + fn fields(&mut self, cmd: EchoFields, respond: Responder) { + respond.ok(EchoFieldsResponse { + a: cmd.a, + b: cmd.b, + name: cmd.name, + }); + } + fn nested(&mut self, cmd: EchoNested, respond: Responder) { + respond.ok(EchoNestedResponse { inner: cmd.inner }); + } + fn aliases(&mut self, cmd: EchoAliases, respond: Responder) { + respond.ok(EchoAliasesResponse { + tree_id: cmd.tree_id, + hash: cmd.hash, + maybe_hash: cmd.maybe_hash, + hashes: cmd.hashes, + }); + } + fn blobs(&mut self, cmd: EchoBlobs, respond: Responder) { + respond.ok(EchoBlobsResponse { + maybe_data: cmd.maybe_data, + parts: cmd.parts, + }); + } + fn fail(&mut self, cmd: EchoFail, respond: Responder) { + respond.error(cmd.message); + } +} diff --git a/ipc-codegen/echo_example/rust/src/lib.rs b/ipc-codegen/echo_example/rust/src/lib.rs index 8f7c1acd17e8..d5b167479b7b 100644 --- a/ipc-codegen/echo_example/rust/src/lib.rs +++ b/ipc-codegen/echo_example/rust/src/lib.rs @@ -4,6 +4,8 @@ pub mod generated { pub mod backend; pub mod echo_client; + #[cfg(feature = "ffi")] + pub mod echo_ffi; pub mod echo_server; pub mod echo_types; pub mod error; @@ -11,8 +13,16 @@ pub mod generated { pub mod ffi_backend; } +pub mod handler; + // Re-export under the names that generated server/client code expects // (they use `crate::types_gen`, `crate::error`, `crate::backend`) pub use generated::backend; pub use generated::echo_types as types_gen; pub use generated::error; + +// The service as an in-process library: the generated FFI entry over the same +// handler the socket server uses. With `ffi_backend` in the same crate, the +// generated client can call it without any process (see tests/ffi_roundtrip.rs). +#[cfg(feature = "ffi")] +crate::export_echo_ffi!(crate::generated::echo_ffi, crate::handler::EchoHandler); diff --git a/ipc-codegen/echo_example/rust/tests/ffi_roundtrip.rs b/ipc-codegen/echo_example/rust/tests/ffi_roundtrip.rs new file mode 100644 index 000000000000..4464ed73c396 --- /dev/null +++ b/ipc-codegen/echo_example/rust/tests/ffi_roundtrip.rs @@ -0,0 +1,28 @@ +//! In-process round trip: the generated FFI client backend calling this crate's +//! own generated FFI entry (`export_echo_ffi!` in lib.rs), no process involved. +#![cfg(feature = "ffi")] + +use echo_wire_compat::generated::echo_client::EchoApi; +use echo_wire_compat::generated::ffi_backend::FfiBackend; + +#[test] +fn ffi_roundtrip() { + let mut api = EchoApi::new(FfiBackend::new().expect("FfiBackend")); + + let data = vec![0xde, 0xad, 0xbe, 0xef, 0x42]; + let resp = api.bytes(&data).expect("EchoBytes"); + assert_eq!(resp.data, data); + + let resp = api + .fields(42, 999_999, "hello ffi".to_string()) + .expect("EchoFields"); + assert_eq!( + (resp.a, resp.b, resp.name.as_str()), + (42, 999_999, "hello ffi") + ); + + assert!( + api.fail("boom".to_string()).is_err(), + "EchoFail must surface as an error" + ); +} diff --git a/ipc-codegen/echo_example/ts_package/.gitignore b/ipc-codegen/echo_example/ts_package/.gitignore index 79f99d29e23c..fb38dae611c7 100644 --- a/ipc-codegen/echo_example/ts_package/.gitignore +++ b/ipc-codegen/echo_example/ts_package/.gitignore @@ -7,6 +7,13 @@ package.json tsconfig.json src/generated/ src/index.ts +src/react-native.ts src/platform.ts +src/process.ts src/bin.ts -scripts/prepare_arch_packages.sh +src/browser.ts +src/wasm.ts +src/wasm/ +src/wasm_host_imports.ts +wasm/ +.ipc-codegen-manifest diff --git a/ipc-codegen/echo_example/ts_package/README.md b/ipc-codegen/echo_example/ts_package/README.md index 6e04a94a70cd..9c2aa9b1fdcf 100644 --- a/ipc-codegen/echo_example/ts_package/README.md +++ b/ipc-codegen/echo_example/ts_package/README.md @@ -1,11 +1,13 @@ # @aztec/echo-ipc -Generated TypeScript IPC package for the Echo service. +Generated TypeScript package for the Echo service: the typed API +(`AsyncApi`/`SyncApi`, one method per command) over whichever backend reaches +the service on the current host. ```ts import { EchoService } from '@aztec/echo-ipc'; -const service = await EchoService.spawn({ transport: 'uds' }); +const service = await EchoService.create(); try { const response = await service.bytes({ data: new Uint8Array([1, 2, 3]) }); } finally { @@ -13,12 +15,36 @@ try { } ``` -The package resolves `echo_server` from `ECHO_SERVER_PATH`, -an explicit `binaryPath`, or an installed/prepared arch package. +`create` picks the process when the binary resolves, otherwise the wasm module. `options.backend` forces one, with no fallback: + +- `'process'`: spawns the `echo_server` binary (node) and talks to it over uds or shm. The binary is resolved from `ECHO_SERVER_PATH`, an explicit `process.binaryPath`, or the installed arch package (one of this package's optional dependencies). +- `'wasm'`: runs the service's wasm module in-process (node and browsers) through `@aztec-foundation/ipc-runtime/wasm`: the main instance in a worker, wasi threads on further workers where a shared memory is available (node, or a browser page served with COOP/COEP headers), otherwise the single-thread module. The worker scripts and the module are referenced with `new URL(..., import.meta.url)`, so bundlers emit them as chunks and assets of the application, and only the module actually chosen is ever fetched (Vite users: exclude the package from `optimizeDeps`). The module ships uncompressed, which is what lets the browser stream it into `WebAssembly.compileStreaming` and cache the compiled code between visits; serve it with your host's own compression. Where that is not possible, `wasm.module` takes a compressed copy — or bytes, a `Response`, or an already compiled `Module`. +- an object: anything with `call(bytes)`/`destroy()`, for a transport of your own (a bridge to a natively linked library, for instance). + +`threads` sets the service's parallelism for any backend +(a process reads it from `HARDWARE_CONCURRENCY`/`RAYON_NUM_THREADS`; wasm runs that many +worker threads, and asking for more than one where no shared memory exists is an error rather than +a silent downgrade). `EchoServiceSync.create` is the synchronous form (shared memory for a process, else the single-threaded wasm module on the calling thread). +`createBackend`/`createBackendSync` expose the same policy for code that wraps +the generated API itself. + +## Which thread the work runs on + +The caller chooses. An asynchronous wasm backend runs the module in a worker by +default, so a long call never blocks the caller; `{ wasm: { worker: false } }` +runs it on the calling thread instead, blocking until it returns. The +synchronous backend always runs on the calling thread, which is what short work +such as hashing wants, including on a browser's main thread. A spawned process +is off the caller's thread either way. + +## Entries per host + +The package resolves to a different entry per host through export conditions: +node (`default`) has every backend above; browsers (`browser`) have the wasm module only; React Native (`react-native`) has no built-in backend, because Hermes has no WebAssembly or workers — a native backend package registers one with `registerBackend`, or the app passes `options.backend`. ## Build -The package shell (package.json, tsconfig, src/index.ts, scripts/) is +The package shell (package.json, tsconfig, `src/*.ts`, scripts/) is generated; build through the owning project's `./bootstrap.sh`, which regenerates and then runs `npm install --omit=optional && npm run build`. diff --git a/ipc-codegen/echo_example/ts_package/bootstrap.sh b/ipc-codegen/echo_example/ts_package/bootstrap.sh index bff76ba45456..f2bea760ed79 100755 --- a/ipc-codegen/echo_example/ts_package/bootstrap.sh +++ b/ipc-codegen/echo_example/ts_package/bootstrap.sh @@ -9,15 +9,16 @@ NODE="node --experimental-strip-types --experimental-transform-types --no-warnin $NODE "$CODEGEN/src/generate.ts" \ --schema "$DIR/../schema/schema.jsonc" \ --lang ts \ - --client \ - --out "$DIR/src/generated" \ --package "$DIR" \ --package-name "@aztec/echo-ipc" \ --binary-name echo_server \ - --package-transports uds,shm \ + --package-transports uds,shm,wasm \ + --package-wasm-module echo.wasm \ --ipc-runtime-dependency "file:../../../ipc-runtime/ts" -(cd "$DIR/../cpp" && ./bootstrap.sh) +# Both the spawned binary and the wasm reactor come from the Rust crate, so the package +# offers one implementation over three transports. +(cd "$DIR/../rust" && ./bootstrap.sh) # ipc-runtime is built by the Makefile (ipc-codegen depends on it) so its ts/dest # and NAPI addon are ready for the file: link below; don't reinstall the shared # ipc-runtime/ts here — concurrent build units doing so corrupt its node_modules. @@ -25,8 +26,9 @@ $NODE "$CODEGEN/src/generate.ts" \ platform_dir="$( node -e "const arch = { x64: 'amd64', arm64: 'arm64' }[process.arch] ?? process.arch; const os = { linux: 'linux', darwin: 'macos' }[process.platform] ?? process.platform; console.log(arch + '-' + os);" )" -mkdir -p "$DIR/build/$platform_dir" -cp "$DIR/../cpp/build/bin/echo_server" "$DIR/build/$platform_dir/echo_server" +mkdir -p "$DIR/build/$platform_dir" "$DIR/wasm" +cp "$DIR/../rust/target/debug/echo_server" "$DIR/build/$platform_dir/echo_server" +cp "$DIR/../rust/target/wasm32-wasip1/release/echo_wire_compat.wasm" "$DIR/wasm/echo.wasm" rm -rf "$DIR/node_modules" (cd "$DIR" && npm install --omit=optional --no-package-lock --quiet) diff --git a/ipc-codegen/echo_example/ts_package/src/package_test.ts b/ipc-codegen/echo_example/ts_package/src/package_test.ts index 9365da43104d..ab69aa78fe62 100644 --- a/ipc-codegen/echo_example/ts_package/src/package_test.ts +++ b/ipc-codegen/echo_example/ts_package/src/package_test.ts @@ -1,12 +1,11 @@ -import { EchoService, SyncApi, type EchoTransport } from "./index.js"; +import { EchoService, EchoServiceSync, SyncApi } from "./index.js"; import { createNapiShmSyncClient } from "@aztec-foundation/ipc-runtime"; const args = process.argv.slice(2); const transportArg = args[args.indexOf("--transport") + 1] ?? "uds"; -if (transportArg !== "uds" && transportArg !== "shm") { +if (transportArg !== "uds" && transportArg !== "shm" && transportArg !== "wasm") { throw new Error(`Unknown --transport '${transportArg}'`); } -const transport = transportArg as EchoTransport; function testHash(base: number): Uint8Array { return Uint8Array.from({ length: 32 }, (_v, i) => base + i); @@ -28,6 +27,62 @@ function assertBytes(actual: Uint8Array, expected: Uint8Array, label: string) { ); } +// The wasm transport runs the service's own wasi reactor in-process, with no binary at all. +// Both the worker-hosted and calling-thread forms, and the synchronous one. +if (transportArg === "wasm") { + for (const worker of [true, false]) { + const wasmService = await EchoService.create({ + backend: "wasm", + wasm: { worker }, + }); + try { + const data = Uint8Array.from([0xde, 0xad, 0xbe, 0xef]); + assertBytes((await wasmService.bytes({ data })).data, data, `wasm(worker=${worker}).bytes`); + const fields = await wasmService.fields({ a: 1, b: 2, name: "wasm" }); + assertEqual(fields.name, "wasm", `wasm(worker=${worker}).fields.name`); + await wasmService + .fail({ message: "boom" }) + .then(() => { + throw new Error(`wasm(worker=${worker}): fail should reject`); + }) + .catch((e: Error) => { + if (!e.message.includes("boom")) throw e; + }); + } finally { + await wasmService.destroy(); + } + } + + const sync = await EchoServiceSync.create({ backend: "wasm" }); + try { + assertBytes( + sync.bytes({ data: Uint8Array.from([1, 2, 3]) }).data, + Uint8Array.from([1, 2, 3]), + "wasm sync bytes.data", + ); + } finally { + sync.destroy(); + } + console.error("echo ts package: wasm OK"); + process.exit(0); +} + +const transport = transportArg; + +// The default policy: the echo binary resolves, so create() spawns it. +{ + const created = await EchoService.create(); + try { + if (!created.process) { + throw new Error("create(): expected a spawned process backend"); + } + const data = Uint8Array.from([7, 8, 9]); + assertBytes((await created.bytes({ data })).data, data, "create().bytes"); + } finally { + await created.destroy(); + } +} + const service = await EchoService.spawn({ transport }); try { const data = Uint8Array.from([0xde, 0xad, 0xbe, 0xef, 0x42]); @@ -50,7 +105,11 @@ try { const nested = await service.nested({ inner }); assertEqual(nested.inner.flag, true, "nested.inner.flag"); assertEqual(nested.inner.values.length, 2, "nested.inner.values.length"); - assertBytes(nested.inner.values[0]!, inner.values[0]!, "nested.inner.values[0]"); + assertBytes( + nested.inner.values[0]!, + inner.values[0]!, + "nested.inner.values[0]", + ); const hash = testHash(0x10); const second = testHash(0x40); diff --git a/ipc-codegen/echo_example/zig/src/ffi_check.zig b/ipc-codegen/echo_example/zig/src/ffi_check.zig index 9d89b86e07c1..5fccd90fe760 100644 --- a/ipc-codegen/echo_example/zig/src/ffi_check.zig +++ b/ipc-codegen/echo_example/zig/src/ffi_check.zig @@ -1,16 +1,20 @@ -//! Compile coverage for the generated FFI backend. The real FFI symbol is -//! provided by whatever native library a consumer links; a stub satisfies -//! the linker here so the backend's code is fully analyzed and built. +//! Compile coverage for the generated FFI backend. The real FFI symbols are +//! provided by whatever native library a consumer links; stubs satisfy the +//! linker here so the backend's code is fully analyzed and built. const std = @import("std"); const ffi = @import("generated/ffi_backend.zig"); -export fn ipc_ffi_entry(input: [*]const u8, input_len: usize, output: *[*]u8, output_len: *usize) void { +export fn echo_ipc_ffi_entry(input: [*]const u8, input_len: usize, output: *[*]u8, output_len: *usize) void { _ = input; _ = input_len; output.* = undefined; output_len.* = 0; } +export fn echo_ipc_ffi_free(ptr: [*]u8) void { + _ = ptr; +} + pub fn main() void { comptime { std.testing.refAllDeclsRecursive(ffi); diff --git a/ipc-codegen/src/cpp_codegen.ts b/ipc-codegen/src/cpp_codegen.ts index c1df1e42ca61..632735db931d 100644 --- a/ipc-codegen/src/cpp_codegen.ts +++ b/ipc-codegen/src/cpp_codegen.ts @@ -779,6 +779,105 @@ void serve(const std::string& input_path, Ctx& ctx) } } // namespace ${ns} +`; + } + + /** + * Header of the in-process FFI entry (the ipc-codegen FFI backend contract): the exported C + * symbols, and the one hook the service defines to hand over its dispatcher. + */ + /** + * Exported symbol names carry the service (`bb_ipc_ffi_entry`), so libraries of several services + * can be linked into one binary. A schema with no service/prefix keeps the bare names. + */ + private ffiSymbol(name: string): string { + const prefix = this.opts.prefix ? `${toSnakeCase(this.opts.prefix)}_` : ""; + return `${prefix}${name}`; + } + + generateFfiHeader(): string { + const { namespace: ns, prefix } = this.opts; + const dispatchHeader = `${toSnakeCase(prefix)}_dispatch.hpp`; + const entry = this.ffiSymbol("ipc_ffi_entry"); + const alloc = this.ffiSymbol("ipc_ffi_alloc"); + const free = this.ffiSymbol("ipc_ffi_free"); + + return `// AUTOGENERATED FILE - DO NOT EDIT +// In-process FFI entry for ${prefix}: the ipc-codegen FFI backend contract, with the symbols +// prefixed by the service so several services can be linked into one binary. +#pragma once + +#include "${dispatchHeader}" + +#include +#include + +// Default visibility so the symbols survive -fvisibility=hidden in a static library and are +// exported from a wasm reactor linked with --export-dynamic. +#ifndef IPC_FFI_EXPORT +#define IPC_FFI_EXPORT extern "C" __attribute__((visibility("default"))) +#endif + +namespace ${ns} { + +// Defined by the service: the dispatcher the FFI entry answers through. Build it once with +// make_${toSnakeCase(prefix)}_handler() over a context that lives as long as the process, so +// stateful command sequences share state the way they would on one transport connection. +AsyncDispatchHandler& ipc_ffi_dispatcher(); + +} // namespace ${ns} + +// Execute one msgpack command payload — the same bytes a transport client puts inside a frame, +// without the length/id envelope — and leave the response payload in a buffer from +// ${alloc}(), which the caller releases with ${free}(). +IPC_FFI_EXPORT void ${entry}(const uint8_t* input, size_t input_len, uint8_t** output, size_t* output_len); + +// malloc/free-compatible allocation for buffers crossing the FFI boundary in either direction. +IPC_FFI_EXPORT void* ${alloc}(size_t size); +IPC_FFI_EXPORT void ${free}(void* ptr); +`; + } + + /** Definitions of the FFI entry symbols declared by generateFfiHeader(). */ + generateFfiSource(): string { + const { namespace: ns, prefix } = this.opts; + const ffiHeader = `${toSnakeCase(prefix)}_ffi.hpp`; + const entry = this.ffiSymbol("ipc_ffi_entry"); + const alloc = this.ffiSymbol("ipc_ffi_alloc"); + const free = this.ffiSymbol("ipc_ffi_free"); + + return `// AUTOGENERATED FILE - DO NOT EDIT +// In-process FFI entry for ${prefix}: the ipc-codegen FFI backend contract. +#include "${ffiHeader}" + +#include +#include +#include +#include +#include + +IPC_FFI_EXPORT void* ${alloc}(size_t size) +{ + // NOLINTNEXTLINE(cppcoreguidelines-no-malloc) + return std::malloc(size == 0 ? 1 : size); +} + +IPC_FFI_EXPORT void ${free}(void* ptr) +{ + // NOLINTNEXTLINE(cppcoreguidelines-no-malloc) + std::free(ptr); +} + +IPC_FFI_EXPORT void ${entry}(const uint8_t* input, size_t input_len, uint8_t** output, size_t* output_len) +{ + std::vector response; + ${ns}::ipc_ffi_dispatcher()(std::span(input, input_len), + [&response](std::vector frame) { response = std::move(frame); }); + auto* out = static_cast(${alloc}(response.size())); + std::memcpy(out, response.data(), response.size()); + *output = out; + *output_len = response.size(); +} `; } } diff --git a/ipc-codegen/src/generate.ts b/ipc-codegen/src/generate.ts index 3ff3949d12c6..805540934966 100644 --- a/ipc-codegen/src/generate.ts +++ b/ipc-codegen/src/generate.ts @@ -23,9 +23,10 @@ import { mkdirSync, cpSync, rmSync, + rmdirSync, } from "fs"; import { execSync } from "child_process"; -import { basename, dirname, join, resolve } from "path"; +import { basename, dirname, join, relative, resolve } from "path"; import { fileURLToPath } from "url"; import { SchemaVisitor, @@ -48,6 +49,9 @@ import { toSnakeCase } from "./naming.ts"; // @ts-ignore const __dirname = dirname(fileURLToPath(import.meta.url)); +/** Records what the last run produced, so this one can remove what it no longer emits. */ +const MANIFEST = ".ipc-codegen-manifest"; + // --------------------------------------------------------------------------- // Argument parsing // --------------------------------------------------------------------------- @@ -65,13 +69,14 @@ interface Args { binaryEnvVar: string; packageTransports: string; packageIpcPathArgs: string; + packageWasmModule: string; + packageWasmThreadsModule: string; ipcRuntimeDependency: string; cppNamespace: string; cppWireNamespace: string; cppIncludeDir: string; uds: boolean; ffi: boolean; - curveConstants: string; stripMethodPrefix: boolean; stripTypePrefix: boolean; } @@ -82,7 +87,8 @@ function usage(): never { Required: --schema JSON schema file --lang Target language (ts, rust, zig, cpp) - --out Output directory + --out Output directory for the generated bindings (implied by + --package: /src/generated) Optional: --server Generate server dispatch @@ -94,9 +100,16 @@ Optional: --package-name TS package name for --package --binary-name Native service binary name for --package --binary-env-var Env var overriding the binary path for --package - --package-transports Comma-separated transports for --package (uds,shm) + --package-transports Comma-separated transports for --package (uds,shm,wasm) --package-ipc-path-args Comma-separated binary args for IPC path; use {path} + --package-wasm-module + wasm transport: the single-thread module, shipped in the + package's wasm/ directory. Ship it uncompressed: only a + real application/wasm response can be streamed into + WebAssembly.compileStreaming and cached by the browser + --package-wasm-threads-module + wasm transport: the threads module, shipped in wasm/ --ipc-runtime-dependency package.json dependency spec for @aztec-foundation/ipc-runtime --prefix Type prefix (auto-detected when >= 2 commands share one) @@ -106,11 +119,13 @@ Optional: names too (e.g. BbCircuitProve -> CircuitProve). Wire tags always keep the full schema name. --uds Copy UDS backend templates (rust, zig only) - --ffi Copy in-process FFI backend templates (rust, zig only) + --ffi In-process FFI. With --client (rust, zig): copy the FFI + client backend template. With --server (rust, cpp): emit + the exported FFI entry (ipc_ffi_entry) over the dispatch --cpp-namespace C++ namespace (e.g. my::ns) --cpp-wire-namespace Wire types sub-namespace (default: wire) --cpp-include-dir Include path for generated dir (e.g. myservice/generated) - --curve-constants Generate TS curve constants from JSON at `); +`); process.exit(1); } @@ -128,13 +143,14 @@ function parseArgs(argv: string[]): Args { binaryEnvVar: "", packageTransports: "uds", packageIpcPathArgs: "--socket,{path}", + packageWasmModule: "", + packageWasmThreadsModule: "", ipcRuntimeDependency: "@aztec-foundation/ipc-runtime", cppNamespace: "", cppWireNamespace: "wire", cppIncludeDir: "", uds: false, ffi: false, - curveConstants: "", stripMethodPrefix: false, stripTypePrefix: false, }; @@ -186,6 +202,12 @@ function parseArgs(argv: string[]): Args { case "--package-ipc-path-args": args.packageIpcPathArgs = takeValue(); break; + case "--package-wasm-module": + args.packageWasmModule = takeValue(); + break; + case "--package-wasm-threads-module": + args.packageWasmThreadsModule = takeValue(); + break; case "--ipc-runtime-dependency": args.ipcRuntimeDependency = takeValue(); break; @@ -204,9 +226,6 @@ function parseArgs(argv: string[]): Args { case "--ffi": args.ffi = true; break; - case "--curve-constants": - args.curveConstants = takeValue(); - break; case "--strip-method-prefix": args.stripMethodPrefix = true; break; @@ -219,6 +238,18 @@ function parseArgs(argv: string[]): Args { } } + if (args.packageDir) { + // The package shell imports the bindings from ./generated, so that is where they go. + const packageOut = join(resolve(args.packageDir), "src", "generated"); + if (!args.out) { + args.out = packageOut; + } else if (resolve(args.out) !== packageOut) { + console.error( + `--out must be /src/generated (${packageOut}) when --package is given; omit it`, + ); + process.exit(1); + } + } if (!args.schema || !args.lang || !args.out) { usage(); } @@ -226,13 +257,26 @@ function parseArgs(argv: string[]): Args { console.error(`--package is only supported for --lang ts`); process.exit(1); } - if ((args.uds || args.ffi) && args.lang !== "rust" && args.lang !== "zig") { + if (args.uds && args.lang !== "rust" && args.lang !== "zig") { console.error( - `--uds/--ffi copy backend templates and only apply to rust and zig; ` + + `--uds copies backend templates and only applies to rust and zig; ` + `ts and cpp consume transports from ipc-runtime directly`, ); process.exit(1); } + if (args.ffi && !["rust", "zig", "cpp"].includes(args.lang)) { + console.error( + `--ffi applies to rust, zig and cpp; a ts package reaches an FFI module ` + + `through the wasm transport (--package-transports wasm)`, + ); + process.exit(1); + } + if (args.ffi && args.lang === "cpp" && !args.server) { + console.error( + `--ffi for cpp emits the server-side FFI entry; pass --server`, + ); + process.exit(1); + } return args; } @@ -300,6 +344,49 @@ function detectPrefix(compiled: CompiledSchema): string { // Template copying // --------------------------------------------------------------------------- +/** + * Every path this run produced. Generated output is disposable and the set of files changes as + * the generator does, so the next run uses this to delete what it no longer emits — otherwise a + * checkout keeps compiling a file that is no longer generated from anything. + */ +const written: string[] = []; + +/** + * Remove anything an earlier run produced under `root` that this one did not, then record what + * this one did. Paths are stored relative to `root` so the tree can move. + */ +function pruneStale(root: string) { + const manifestPath = join(root, MANIFEST); + const current = written.map((p) => relative(root, p)).sort(); + let previous: string[] = []; + try { + previous = readFileSync(manifestPath, "utf-8").split("\n").filter(Boolean); + } catch { + // No manifest: either the first run here, or output from before manifests existed. Either + // way there is nothing we can safely claim to own, so only record. + } + const emptied = new Set(); + for (const stale of previous.filter((p) => !current.includes(p))) { + const path = join(root, stale); + rmSync(path, { recursive: true, force: true }); + console.log(` ${path} (removed, no longer generated)`); + for (let dir = dirname(path); dir !== root; dir = dirname(dir)) { + emptied.add(dir); + } + } + // Deepest first, so a directory holding only now-empty directories goes too. rmdir on a + // directory that still holds something fails, which is exactly the test we want. + for (const dir of [...emptied].sort((a, b) => b.length - a.length)) { + try { + rmdirSync(dir); + console.log(` ${dir} (removed, now empty)`); + } catch { + // Still holds something generated, or something we did not write. Leave it. + } + } + writeFileSync(manifestPath, current.join("\n") + "\n"); +} + function copyTemplate(lang: string, filename: string, outDir: string) { const templatePath = join(__dirname, "..", "templates", lang, filename); const destPath = join(outDir, filename); @@ -307,6 +394,7 @@ function copyTemplate(lang: string, filename: string, outDir: string) { const tmpPath = `${destPath}.${process.pid}.tmp`; writeFileSync(tmpPath, readFileSync(templatePath, "utf-8")); renameSync(tmpPath, destPath); + written.push(destPath); console.log(` ${destPath} (template)`); } @@ -315,6 +403,7 @@ function copyTemplateDir(lang: string, dirname: string, outDir: string) { const destPath = join(outDir, dirname); rmSync(destPath, { recursive: true, force: true }); cpSync(templatePath, destPath, { recursive: true }); + written.push(destPath); console.log(` ${destPath} (template)`); } @@ -361,6 +450,7 @@ function generate(args: Args) { const tmpPath = `${path}.${process.pid}.tmp`; writeFileSync(tmpPath, content); renameSync(tmpPath, path); + written.push(path); console.log(` ${path}`); return path; } @@ -388,9 +478,6 @@ function generate(args: Args) { // No transport template copy — consumers import IpcClient from // '@aztec-foundation/ipc-runtime' (or hand in a compatible byte backend). } - if (args.curveConstants) { - generateCurveConstants(absOut, resolve(args.curveConstants)); - } if (args.packageDir) { const packageDir = resolve(args.packageDir); const packageName = @@ -405,6 +492,7 @@ function generate(args: Args) { const tmpPath = `${path}.${process.pid}.tmp`; writeFileSync(tmpPath, content); renameSync(tmpPath, path); + written.push(path); if (opts?.executable) { try { execSync(`chmod +x ${path}`); @@ -429,37 +517,57 @@ function generate(args: Args) { } else { const binaryName = args.binaryName || toSnakeCase(prefix).replace(/_/g, "-"); + const transports = args.packageTransports + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + const wasm = transports.includes("wasm"); + if ( + wasm && + !args.packageWasmModule && + !args.packageWasmThreadsModule + ) { + console.error( + `--package-transports wasm needs --package-wasm-module and/or --package-wasm-threads-module`, + ); + process.exit(1); + } const packageGen = new TypeScriptPackageCodegen({ prefix, packageName, binaryName, binaryEnvVar: args.binaryEnvVar || defaultBinaryEnvVar(binaryName), ipcRuntimeDependency: args.ipcRuntimeDependency, - transports: args.packageTransports - .split(",") - .map((t) => t.trim()) - .filter(Boolean), + transports, ipcPathArgs: args.packageIpcPathArgs .split(",") .map((arg) => arg.trim()) .filter(Boolean), + wasmModule: args.packageWasmModule || undefined, + wasmThreadsModule: args.packageWasmThreadsModule || undefined, }); writePackage("package.json", packageGen.generatePackageJson()); writePackage("tsconfig.json", packageGen.generateTsconfig()); writePackage("README.md", packageGen.generateReadme()); writePackage("src/index.ts", packageGen.generateIndex()); + writePackage( + "src/react-native.ts", + packageGen.generateReactNativeIndex(), + ); writePackage("src/platform.ts", packageGen.generatePlatform()); + if (transports.some((t) => t !== "wasm")) { + writePackage("src/process.ts", packageGen.generateProcess()); + } if (binaryName) { writePackage("src/bin.ts", packageGen.generateBin()); } + if (wasm) { + writePackage("src/browser.ts", packageGen.generateBrowserIndex()); + writePackage("src/wasm.ts", packageGen.generateWasm()); + } for (const manifest of packageGen.generateArchPackageManifests()) { writePackage(manifest.path, manifest.content); } - writePackage( - "scripts/prepare_arch_packages.sh", - packageGen.generatePrepareArchPackagesScript(), - { executable: true }, - ); } } break; @@ -479,6 +587,9 @@ function generate(args: Args) { `${toSnakeCase(prefix)}_server.rs`, gen.generateServer(compiled), ); + if (args.ffi) { + writeFile(`${toSnakeCase(prefix)}_ffi.rs`, gen.generateFfi()); + } } if (args.client) { writeFile( @@ -494,7 +605,8 @@ function generate(args: Args) { copyTemplate("rust", "error.rs", absOut); } if (args.ffi) { - copyTemplate("rust", "ffi_backend.rs", absOut); + // Generated rather than a template: it links the service-prefixed symbols. + writeFile("ffi_backend.rs", gen.generateFfiBackend()); } break; } @@ -530,7 +642,8 @@ function generate(args: Args) { copyTemplate("zig", "backend.zig", absOut); } if (args.ffi) { - copyTemplate("zig", "ffi_backend.zig", absOut); + // Generated rather than a template: it links the service-prefixed symbols. + writeFile("ffi_backend.zig", gen.generateFfiBackend()); } break; } @@ -565,6 +678,20 @@ function generate(args: Args) { gen.generateServerHeader(), ), ); + if (args.ffi) { + cppFiles.push( + writeFile( + `${toSnakeCase(prefix)}_ffi.hpp`, + gen.generateFfiHeader(), + ), + ); + cppFiles.push( + writeFile( + `${toSnakeCase(prefix)}_ffi.cpp`, + gen.generateFfiSource(), + ), + ); + } } if (args.client) { cppFiles.push( @@ -591,55 +718,12 @@ function generate(args: Args) { process.exit(1); } + // Generated output for one service lives under the package when there is one, and under --out + // otherwise; either way that directory is the generator's to keep tidy. + pruneStale(args.packageDir ? resolve(args.packageDir) : absOut); console.log("Done."); } -// --------------------------------------------------------------------------- -// Curve constants -// --------------------------------------------------------------------------- - -function hexToBigInt(hex: string): bigint { - return BigInt("0x" + hex); -} - -function hexToByteList(hex: string): string { - const bytes: number[] = []; - for (let i = 0; i < hex.length; i += 2) - bytes.push(parseInt(hex.substring(i, i + 2), 16)); - return `new Uint8Array([${bytes.join(", ")}])`; -} - -function serializeCoordinate(coord: string | string[]): string { - return Array.isArray(coord) - ? `[${coord.map((c) => hexToByteList(c)).join(", ")}]` - : hexToByteList(coord); -} - -function generateCurveConstants(outputDir: string, constantsPath: string) { - const constants = JSON.parse(readFileSync(constantsPath, "utf-8")); - const content = `// AUTOGENERATED FILE - DO NOT EDIT -export const BN254_FR_MODULUS = ${hexToBigInt(constants.bn254_fr_modulus)}n; -export const BN254_FQ_MODULUS = ${hexToBigInt(constants.bn254_fq_modulus)}n; -export const BN254_G1_GENERATOR = { x: ${serializeCoordinate(constants.bn254_g1_generator.x)}, y: ${serializeCoordinate(constants.bn254_g1_generator.y)} } as const; -export const BN254_G2_GENERATOR = { x: ${serializeCoordinate(constants.bn254_g2_generator.x)}, y: ${serializeCoordinate(constants.bn254_g2_generator.y)} } as const; -export const GRUMPKIN_FR_MODULUS = ${hexToBigInt(constants.grumpkin_fr_modulus)}n; -export const GRUMPKIN_FQ_MODULUS = ${hexToBigInt(constants.grumpkin_fq_modulus)}n; -export const GRUMPKIN_G1_GENERATOR = { x: ${serializeCoordinate(constants.grumpkin_g1_generator.x)}, y: ${serializeCoordinate(constants.grumpkin_g1_generator.y)} } as const; -export const SECP256K1_FR_MODULUS = ${hexToBigInt(constants.secp256k1_fr_modulus)}n; -export const SECP256K1_FQ_MODULUS = ${hexToBigInt(constants.secp256k1_fq_modulus)}n; -export const SECP256K1_G1_GENERATOR = { x: ${serializeCoordinate(constants.secp256k1_g1_generator.x)}, y: ${serializeCoordinate(constants.secp256k1_g1_generator.y)} } as const; -export const SECP256R1_FR_MODULUS = ${hexToBigInt(constants.secp256r1_fr_modulus)}n; -export const SECP256R1_FQ_MODULUS = ${hexToBigInt(constants.secp256r1_fq_modulus)}n; -export const SECP256R1_G1_GENERATOR = { x: ${serializeCoordinate(constants.secp256r1_g1_generator.x)}, y: ${serializeCoordinate(constants.secp256r1_g1_generator.y)} } as const; -`; - mkdirSync(outputDir, { recursive: true }); - const path = join(outputDir, "curve_constants.ts"); - const tmpPath = `${path}.${process.pid}.tmp`; - writeFileSync(tmpPath, content); - renameSync(tmpPath, path); - console.log(` ${path}`); -} - // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- diff --git a/ipc-codegen/src/rust_codegen.ts b/ipc-codegen/src/rust_codegen.ts index 876a533df3c5..305546b1bd32 100644 --- a/ipc-codegen/src/rust_codegen.ts +++ b/ipc-codegen/src/rust_codegen.ts @@ -162,9 +162,7 @@ export class RustCodegen { // which the C++ side rejects. private needsSerdeFixedBytes(type: Type): boolean { return ( - type.kind === "array" && - type.size! <= 32 && - this.isU8(type.element!) + type.kind === "array" && type.size! <= 32 && this.isU8(type.element!) ); } @@ -917,6 +915,231 @@ pub fn handle_request(handler: &mut dyn Handler, request_bytes: &[u8]) -> Vec Result { + Ok(Self { _initialized: true }) + } +} + +impl Backend for FfiBackend { + fn call(&mut self, input: &[u8]) -> Result> { + let mut output_ptr: *mut u8 = ptr::null_mut(); + let mut output_len: usize = 0; + + // SAFETY: input is valid for input.len() bytes; output_ptr/output_len are valid stack slots. + unsafe { + ipc_ffi_entry(input.as_ptr(), input.len(), &mut output_ptr, &mut output_len); + } + + if output_ptr.is_null() { + return Err(IpcError::Backend("FFI entry returned null pointer".to_string())); + } + + if output_len == 0 { + // SAFETY: the entry allocated output_ptr; it is freed exactly once. + unsafe { ipc_ffi_free(output_ptr) }; + return Err(IpcError::Backend("FFI entry returned empty response".to_string())); + } + + // SAFETY: output_ptr is valid for output_len bytes until freed below. + let output = unsafe { std::slice::from_raw_parts(output_ptr, output_len).to_vec() }; + + // SAFETY: the entry allocated output_ptr; it is freed exactly once. + unsafe { ipc_ffi_free(output_ptr) }; + + Ok(output) + } + + fn destroy(&mut self) -> Result<()> { + self._initialized = false; + Ok(()) + } +} + +impl Drop for FfiBackend { + fn drop(&mut self) { + let _ = self.destroy(); + } +} + +impl Default for FfiBackend { + fn default() -> Self { + Self::new().expect("Failed to initialize FfiBackend") + } +} +`; + } + + generateFfi(): string { + const { prefix } = this.opts; + const snake = toSnakeCase(prefix || "ipc"); + const macroName = `export_${snake}_ffi`; + const entry = this.ffiSymbol("ipc_ffi_entry"); + const alloc = this.ffiSymbol("ipc_ffi_alloc"); + const free = this.ffiSymbol("ipc_ffi_free"); + + return `//! AUTOGENERATED - DO NOT EDIT +//! In-process FFI entry for ${prefix || "service"}: the ipc-codegen FFI backend contract, with the +//! symbols prefixed by the service so several services can be linked into one binary. +//! +//! \`${macroName}!\` defines the exported C-ABI symbols \`${entry}\`, \`${alloc}\` and +//! \`${free}\` over one handler, built with \`Default\` on the first call and kept for the life +//! of the process, so stateful command sequences share state the way they would on one transport +//! connection. Invoke it once at the crate root of a \`cdylib\`/\`staticlib\`, or of a wasm reactor: +//! +//! \`\`\`ignore +//! ${macroName}!(crate::generated::${snake}_ffi, MyHandler); +//! \`\`\` +//! +//! Needs the \`libc\` crate for the malloc-compatible buffers. + +use super::${snake}_server::{handle_request, Handler}; + +/// One FFI round trip: decode and dispatch \`input\`, leaving the response payload in a buffer +/// from [\`ffi_alloc\`] that the caller releases with [\`ffi_free\`]. +/// +/// # Safety +/// \`input\` must be valid for \`input_len\` bytes; \`output\` and \`output_len\` must be valid for writes. +pub unsafe fn ffi_call( + handler: &mut dyn Handler, + input: *const u8, + input_len: usize, + output: *mut *mut u8, + output_len: *mut usize, +) { + let request: &[u8] = if input_len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(input, input_len) } + }; + let response = handle_request(handler, request); + let buf = ffi_alloc(response.len()); + unsafe { + std::ptr::copy_nonoverlapping(response.as_ptr(), buf, response.len()); + *output = buf; + *output_len = response.len(); + } +} + +/// malloc-compatible allocation for buffers crossing the FFI boundary in either direction. +pub fn ffi_alloc(size: usize) -> *mut u8 { + // A zero-size request still yields a distinct, freeable pointer. + unsafe { libc::malloc(size.max(1)) as *mut u8 } +} + +/// Release a buffer from [\`ffi_alloc\`]. +/// +/// # Safety +/// \`ptr\` must have come from [\`ffi_alloc\`] and not have been freed already. +pub unsafe fn ffi_free(ptr: *mut u8) { + unsafe { libc::free(ptr as *mut libc::c_void) }; +} + +/// Define the exported C-ABI FFI symbols over \`$handler\` (a \`Handler + Default + Send\` type). +/// \`$ffi\` is the path of this module, which the expansion calls back into. +#[macro_export] +macro_rules! ${macroName} { + ($ffi:path, $handler:ty) => { + #[unsafe(no_mangle)] + pub unsafe extern "C" fn ${entry}( + input: *const u8, + input_len: usize, + output: *mut *mut u8, + output_len: *mut usize, + ) { + use $ffi as ffi; + static HANDLER: ::std::sync::Mutex> = ::std::sync::Mutex::new(None); + let mut guard = HANDLER.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let handler = guard.get_or_insert_with(<$handler as ::std::default::Default>::default); + unsafe { ffi::ffi_call(handler, input, input_len, output, output_len) }; + } + + #[unsafe(no_mangle)] + pub extern "C" fn ${alloc}(size: usize) -> *mut u8 { + use $ffi as ffi; + ffi::ffi_alloc(size) + } + + #[unsafe(no_mangle)] + pub unsafe extern "C" fn ${free}(ptr: *mut u8) { + use $ffi as ffi; + unsafe { ffi::ffi_free(ptr) }; + } + }; +} `; } } diff --git a/ipc-codegen/src/typescript_codegen.ts b/ipc-codegen/src/typescript_codegen.ts index 5315a65e9ac2..386331f178fc 100644 --- a/ipc-codegen/src/typescript_codegen.ts +++ b/ipc-codegen/src/typescript_codegen.ts @@ -41,7 +41,10 @@ export class TypeScriptCodegen { /** Prefix to strip from generated type and converter names (e.g. "Bb" -> BbCircuitProve becomes CircuitProve) */ private typePrefix: string = ""; - constructor(options?: { stripMethodPrefix?: string; stripTypePrefix?: string }) { + constructor(options?: { + stripMethodPrefix?: string; + stripTypePrefix?: string; + }) { if (options?.stripMethodPrefix) { this.methodPrefix = options.stripMethodPrefix; } @@ -376,6 +379,11 @@ ${conversions} return this.generateConverter("from", type, value); } + /** Error class name for a service's error response, e.g. `BbErrorResponse` -> `BbError`. */ + private errorClassName(errorTypeName: string): string { + return errorTypeName.replace(/Response$/, ""); + } + // Generate types file (api_types.ts) generateTypes(schema: CompiledSchema, schemaHash?: string): string { const allStructs = dedupeStructsByName([ @@ -490,6 +498,18 @@ ${toFunctions} ${fromFunctions} +/** + * Thrown when the service answers a command with ${schema.errorTypeName} rather than a result. + * The message is the service's own, so the type is what separates "the service said no" from a + * fault in the client. Declared here so the async and sync APIs throw the same class. + */ +export class ${this.errorClassName(schema.errorTypeName)} extends Error { + constructor(message: string) { + super(message); + this.name = '${this.errorClassName(schema.errorTypeName)}'; + } +} + // Base API interfaces export interface AsyncApiBase { ${asyncApiMethods} @@ -513,7 +533,7 @@ ${syncApiMethods} const msgpackCommand = from${cmdType}(command); return msgpackCall(this.backend, [["${command.name}", msgpackCommand]]).then(([variantName, result]: [string, any]) => { if (variantName === '${this.errorTypeName}') { - throw this.createError(result.message || 'Unknown error from server'); + throw new ${this.errorClassName(this.errorTypeName)}(result.message || 'Unknown error from server'); } if (variantName !== '${command.responseType}') { throw new Error(\`Expected variant name '${command.responseType}' but got '\${variantName}'\`); @@ -532,7 +552,7 @@ ${syncApiMethods} const msgpackCommand = from${cmdType}(command); const [variantName, result] = msgpackCall(this.backend, [["${command.name}", msgpackCommand]]); if (variantName === '${this.errorTypeName}') { - throw this.createError(result.message || 'Unknown error from server'); + throw new ${this.errorClassName(this.errorTypeName)}(result.message || 'Unknown error from server'); } if (variantName !== '${command.responseType}') { throw new Error(\`Expected variant name '${command.responseType}' but got '\${variantName}'\`); @@ -559,8 +579,6 @@ export interface IpcClientAsync { destroy(): Promise; } -export type IpcErrorFactory = (message: string) => Error; - async function msgpackCall(backend: IpcClientAsync, input: any[]) { const inputBuffer = new Encoder({ useRecords: false, variableMapSize: true }).pack(input); const encodedResult = await backend.call(inputBuffer); @@ -568,10 +586,7 @@ async function msgpackCall(backend: IpcClientAsync, input: any[]) { } export class AsyncApi implements AsyncApiBase { - constructor( - protected backend: IpcClientAsync, - protected createError: IpcErrorFactory = message => new Error(message), - ) {} + constructor(protected backend: IpcClientAsync) {} ${methods} @@ -600,8 +615,6 @@ export interface IpcClientSync { destroy(): void; } -export type IpcErrorFactory = (message: string) => Error; - function msgpackCall(backend: IpcClientSync, input: any[]) { const inputBuffer = new Encoder({ useRecords: false, variableMapSize: true }).pack(input); const encodedResult = backend.call(inputBuffer); @@ -609,10 +622,7 @@ function msgpackCall(backend: IpcClientSync, input: any[]) { } export class SyncApi implements SyncApiBase { - constructor( - protected backend: IpcClientSync, - protected createError: IpcErrorFactory = message => new Error(message), - ) {} + constructor(protected backend: IpcClientSync) {} ${methods} @@ -641,6 +651,7 @@ ${methods} } types.add(baseInterface); + types.add(this.errorClassName(schema.errorTypeName)); const sortedTypes = Array.from(types).sort(); return `import { ${sortedTypes.join(", ")} } from './api_types.js';`; diff --git a/ipc-codegen/src/typescript_package_codegen.ts b/ipc-codegen/src/typescript_package_codegen.ts index 7bed8586850e..128a0cd93c83 100644 --- a/ipc-codegen/src/typescript_package_codegen.ts +++ b/ipc-codegen/src/typescript_package_codegen.ts @@ -8,20 +8,16 @@ export interface TypeScriptPackageOptions { ipcRuntimeDependency: string; ipcPathArgs: string[]; transports: string[]; + /** wasm transport: basename of the single-thread module shipped in the package's wasm/ directory. */ + wasmModule?: string; + /** wasm transport: basename of the threads module shipped in the package's wasm/ directory. */ + wasmThreadsModule?: string; } function className(prefix: string): string { return `${prefix}Service`; } -function transportType(prefix: string): string { - return `${prefix}Transport`; -} - -function optionsType(prefix: string): string { - return `${prefix}ServiceOptions`; -} - function binaryFinderName(prefix: string): string { return `find${prefix}Binary`; } @@ -119,7 +115,7 @@ export class TypeScriptServerPackageCodegen { files: ["dest/", this.opts.schemaFileName, "README.md"], scripts: { clean: "rm -rf dest .tsbuildinfo", - build: "tsc -p tsconfig.json", + build: "rm -rf dest .tsbuildinfo && tsc -p tsconfig.json", }, dependencies: { msgpackr: "^1.11.2", @@ -174,16 +170,51 @@ runs \`yarn build\`. } } +/** + * Client package shell: one TS package that owns how the service is reached. Its entries + * (node, browser, react-native — selected by export condition) offer the backends that exist on + * that host and a `create` that picks one by default, can be forced to one, or takes a backend + * object of the consumer's own. + */ export class TypeScriptPackageCodegen { constructor(private opts: TypeScriptPackageOptions) {} + private get wasm(): boolean { + return this.opts.transports.includes("wasm"); + } + + private get processTransports(): string[] { + return this.opts.transports.filter((t) => t !== "wasm"); + } + + private get process(): boolean { + return this.processTransports.length > 0; + } + + private get shm(): boolean { + return this.processTransports.includes("shm"); + } + + private generatedExports(): string { + return `export * from './generated/api_types.js'; +export { AsyncApi } from './generated/async.js'; +export { SyncApi } from './generated/sync.js'; +`; + } + generatePackageJson(): string { const archPackages = archPackageNames(this.opts.packageName); const scripts: Record = { clean: "rm -rf dest .tsbuildinfo", - build: "tsc -p tsconfig.json", - prepare_arch_packages: "./scripts/prepare_arch_packages.sh", + // Clean first: tsc would leave behind the output of a source file the generator no + // longer emits, and these packages are a couple of seconds to compile. + build: "rm -rf dest .tsbuildinfo && tsc -p tsconfig.json", + prepare_arch_packages: "ipc-runtime-prepare-arch-packages", }; + const entry = (name: string) => ({ + types: `./dest/${name}.d.ts`, + default: `./dest/${name}.js`, + }); const pkg = { name: this.opts.packageName, @@ -196,13 +227,25 @@ export class TypeScriptPackageCodegen { ...(this.opts.binaryName ? { bin: { [this.opts.binaryName]: "./dest/bin.js" } } : {}), + // main/types for consumers resolving without export conditions (a CommonJS build's + // TypeScript resolution); `exports` is authoritative everywhere else. + main: "./dest/index.js", + types: "./dest/index.d.ts", + // Metro reads this field; bundlers with export conditions use the entry below. + "react-native": "./dest/react-native.js", exports: { + // One entry per host, each with its own types: a browser consumer never sees the + // process backend, a React Native one sees neither process nor wasm. ".": { + ...(this.wasm ? { browser: entry("browser") } : {}), + "react-native": entry("react-native"), types: "./dest/index.d.ts", default: "./dest/index.js", }, + ...(this.wasm ? { "./browser": entry("browser") } : {}), + "./react-native": entry("react-native"), }, - files: ["dest/", "README.md"], + files: ["dest/", ...(this.wasm ? ["wasm/"] : []), "README.md"], scripts, dependencies: { "@aztec-foundation/ipc-runtime": this.opts.ipcRuntimeDependency, @@ -224,26 +267,11 @@ export class TypeScriptPackageCodegen { } generateBin(): string { - const findBinary = binaryFinderName(this.opts.prefix); return `#!/usr/bin/env node -import { spawnSync } from 'node:child_process'; -import { ${findBinary} } from './platform.js'; - -const binaryPath = ${findBinary}(); -if (!binaryPath) { - console.error( - "${this.opts.binaryName}: native binary not found. Install the matching " + - "'${this.opts.packageName}-' package, set ${this.opts.binaryEnvVar}, or pass its path.", - ); - process.exit(1); -} +import { runServiceBinary } from '@aztec-foundation/ipc-runtime'; +import { BINARY } from './platform.js'; -const result = spawnSync(binaryPath, process.argv.slice(2), { stdio: 'inherit' }); -if (result.error) { - console.error(result.error.message); - process.exit(1); -} -process.exit(result.status ?? 1); +runServiceBinary(BINARY, '${this.opts.packageName}', process.argv.slice(2)); `; } @@ -273,236 +301,593 @@ process.exit(result.status ?? 1); return generatePackageTsconfig(); } - generateIndex(): string { - const prefix = this.opts.prefix; - const serviceClass = className(prefix); - const serviceOptions = optionsType(prefix); - const serviceTransport = transportType(prefix); - const findBinary = binaryFinderName(prefix); - const supportsShm = this.opts.transports.includes("shm"); - const transports = this.opts.transports.map((t) => `'${t}'`).join(" | "); - const ipcPathArgs = JSON.stringify(this.opts.ipcPathArgs); - const defaultTransport = this.opts.transports.includes("uds") + /** The spawned-process backend (node only): options, environment, and the two spawn functions. */ + /** The spawned-process backend (node only): this package's options over ipc-runtime's spawner. */ + generateProcess(): string { + const { prefix, binaryName } = this.opts; + const transports = this.processTransports.map((t) => `'${t}'`).join(" | "); + const defaultTransport = this.processTransports.includes("uds") ? "uds" - : this.opts.transports[0]!; + : this.processTransports[0]!; + const syncImports = this.shm + ? "\n type SpawnedProcessBackendSync,\n spawnServiceBackendSync," + : ""; + const syncSpawn = this.shm + ? ` +/** The synchronous form: shared memory is the one transport with a synchronous client. */ +export function spawnProcessBackendSync(options: ${prefix}ProcessOptions = {}): Promise { + return spawnServiceBackendSync(BINARY, options); +} +` + : ""; - return `import { IpcSpawnError, SpawnedProcessBackend } from '@aztec-foundation/ipc-runtime'; -import { AsyncApi, type IpcErrorFactory } from './generated/async.js'; -import { ${findBinary} } from './platform.js'; + return `import { + type ServiceProcessOptions, + type SpawnedProcessBackend,${syncImports} + spawnServiceBackend, +} from '@aztec-foundation/ipc-runtime'; +import { BINARY } from './platform.js'; -export * from './generated/api_types.js'; -export { AsyncApi } from './generated/async.js'; -export { SyncApi } from './generated/sync.js'; +export type ${prefix}Transport = ${transports}; -export type ${serviceTransport} = ${transports}; +/** + * Options for running '${binaryName}' as a spawned process (node only). See ServiceProcessOptions + * in ipc-runtime for the rest: binaryPath, threads, logger, env, extraArgs, respawn and unref. + */ +export interface ${prefix}ProcessOptions extends ServiceProcessOptions { + transport?: ${prefix}Transport; +} -export interface ${serviceOptions} { - binaryPath?: string; - transport?: ${serviceTransport}; - logger?: (msg: string) => void; - connectTimeoutMs?: number; - env?: NodeJS.ProcessEnv; - extraArgs?: string[]; - createError?: IpcErrorFactory; +/** The name this package's first version used for the spawn options. */ +export type ${prefix}ServiceOptions = ${prefix}ProcessOptions; + +/** + * Spawn '${binaryName}' and connect to it over ${this.processTransports.join(" or ")}. Process + * lifecycle — connectivity, death detection, optional respawn, teardown — is owned by the backend + * and never leaks onto the caller's API. + */ +export function spawnProcessBackend(options: ${prefix}ProcessOptions = {}): Promise { + return spawnServiceBackend(BINARY, '${defaultTransport}', options); +} +${syncSpawn}`; + } + + /** Shared shape of the create options: a backend name, a backend object, or nothing (defaults). */ + private createOptionTypes(backends: string[]): string { + const { prefix } = this.opts; + const process = this.process; + const wasm = this.wasm; + const backendType = backends.join(" | ") || "never"; + const defaults = [ + process && + `the spawned process when the '${this.opts.binaryName}' binary resolves`, + wasm && "the wasm module", + ] + .filter(Boolean) + .join(", otherwise "); + return `export type ${prefix}Backend = ${backendType}; + +/** Options for ${className(prefix)}.create / createBackend. */ +export interface ${prefix}CreateOptions { /** - * Respawn the server on the next call after it dies, instead of failing all - * subsequent calls. Only enable for stateless servers: a respawned process - * remembers nothing, so any server-side session state held by callers would - * silently dangle. + * Unset: ${defaults}. A name forces that backend, with no fallback. An object (anything with + * call()/destroy(), e.g. a bridge to a natively linked library) is used as is. */ - respawn?: boolean; -${supportsShm ? " napiPath?: string;\n clientId?: number;\n" : ""}} + backend?: ${prefix}Backend | IpcClientAsync; + /** Threads the service may use: a process reads them from HARDWARE_CONCURRENCY/RAYON_NUM_THREADS; wasm runs that many. */ + threads?: number; + logger?: (msg: string) => void; + /** Let node exit while the backend is alive (a process that watches its parent, or the wasm workers). */ + unref?: boolean; +${process ? ` process?: Omit<${prefix}ProcessOptions, 'threads' | 'logger'>;\n` : ""}${wasm ? ` wasm?: Omit<${prefix}WasmOptions, 'threads' | 'logger'>;\n` : ""}} + +/** Options for ${className(prefix)}Sync.create / createBackendSync. */ +export interface ${prefix}CreateSyncOptions { + /** As for the asynchronous form; the synchronous wasm module runs on the calling thread, single-threaded. */ + backend?: ${prefix}Backend | IpcClientSync; + /** Threads a spawned process may use (the synchronous wasm module always has one). */ + threads?: number; + logger?: (msg: string) => void; + unref?: boolean; +${process ? ` process?: Omit<${prefix}ProcessOptions, 'threads' | 'logger'>;\n` : ""}${wasm ? ` wasm?: Omit<${prefix}WasmOptions, 'threads' | 'worker' | 'logger'>;\n` : ""}} +`; + } -/** - * Spawns and talks to a '${this.opts.binaryName}' server process. Process - * lifecycle — connectivity, death detection, optional respawn, teardown — is - * owned by the backend (see SpawnedProcessBackend in ipc-runtime); it never - * leaks onto this API. Failed calls carry a 'retry' property set to true when - * the failure was environmental and the operation may be retried. + /** The service classes over createBackend/createBackendSync, with the direct constructors this entry offers. */ + private serviceClasses(opts: { process: boolean; wasm: boolean }): string { + const { prefix } = this.opts; + const svc = className(prefix); + const { process, wasm } = opts; + return `/** + * The ${prefix} service: the generated API over whichever backend \`create\` chose, forced, or was + * given.${process ? " Process lifecycle stays inside the backend and never leaks onto this API." : ""} */ -export class ${serviceClass} extends AsyncApi { - private constructor(private spawnedBackend: SpawnedProcessBackend, createError?: IpcErrorFactory) { - super(spawnedBackend, createError); +export class ${svc} extends AsyncApi { + private constructor(backend: IpcClientAsync) { + super(backend); } - static async spawn(options: ${serviceOptions} = {}): Promise<${serviceClass}> { - const binaryPath = ${findBinary}(options.binaryPath); - if (!binaryPath) { - throw new IpcSpawnError('${this.opts.binaryName} binary not found', /*retry=*/ false); - } - const backend = await SpawnedProcessBackend.spawn({ - binaryPath, - binaryName: '${this.opts.binaryName}', - instancePrefix: '${toSnakeCase(prefix)}', - ipcPathArgs: ${ipcPathArgs}, - transport: options.transport ?? '${defaultTransport}', - logger: options.logger, - connectTimeoutMs: options.connectTimeoutMs, - env: options.env, - extraArgs: options.extraArgs, - respawn: options.respawn, -${supportsShm ? " clientId: options.clientId,\n napiPath: options.napiPath,\n" : ""} }); - return new ${serviceClass}(backend, options.createError); + static async create(options: ${prefix}CreateOptions = {}): Promise<${svc}> { + return new ${svc}(await createBackend(options)); + } +${ + process + ? ` + /** The service as a spawned '${this.opts.binaryName}' process (no fallback). */ + static async spawn(options: ${prefix}ProcessOptions = {}): Promise<${svc}> { + return new ${svc}(await spawnProcessBackend(options)); + } +` + : "" +}${ + wasm + ? ` + /** The service over the in-process wasm module (no fallback). */ + static async wasm(options: ${prefix}WasmOptions = {}): Promise<${svc}> { + return new ${svc}(await createWasmBackend(options)); + } +` + : "" + }${ + process + ? ` + /** The spawned process behind this service, when that is what backs it. */ + get process(): SpawnedProcessBackend | undefined { + return this.backend instanceof SpawnedProcessBackend ? this.backend : undefined; } getIpcPath(): string { - return this.spawnedBackend.getIpcPath(); + return this.requireProcess().getIpcPath(); } sendProcessSignal(signal: NodeJS.Signals): void { - this.spawnedBackend.sendProcessSignal(signal); + this.requireProcess().sendProcessSignal(signal); } -} + + private requireProcess(): SpawnedProcessBackend { + const process = this.process; + if (!process) { + throw new Error('${svc}: not backed by a spawned process'); + } + return process; + } +` + : "" + }} + +/** The synchronous ${prefix} service: every call blocks the calling thread until the service answers. */ +export class ${svc}Sync extends SyncApi { + private constructor(backend: IpcClientSync) { + super(backend); + } + + static async create(options: ${prefix}CreateSyncOptions = {}): Promise<${svc}Sync> { + return new ${svc}Sync(await createBackendSync(options)); + } +${ + process && this.shm + ? ` + /** The service as a spawned '${this.opts.binaryName}' process over shared memory (no fallback). */ + static async spawn(options: ${prefix}ProcessOptions = {}): Promise<${svc}Sync> { + return new ${svc}Sync(await spawnProcessBackendSync(options)); + } +` + : "" +}${ + wasm + ? ` + /** The service over the single-threaded in-process wasm module (no fallback). */ + static async wasm(options: ${prefix}WasmOptions = {}): Promise<${svc}Sync> { + return new ${svc}Sync(await createWasmBackendSync(options)); + } +` + : "" + }} `; } - generatePlatform(): string { - const packageName = this.opts.packageName; - const findBinary = binaryFinderName(this.opts.prefix); - const envVar = this.opts.binaryEnvVar; - const stem = packageStem(packageName); - const archPackages = archPackageNames(packageName); + /** Node entry: every backend the package has, and the default policy over them. */ + generateIndex(): string { + const { prefix, binaryName, packageName } = this.opts; + const findBinary = binaryFinderName(prefix); + const process = this.process; + const wasm = this.wasm; + const shm = this.shm; + const backends = [process && "'process'", wasm && "'wasm'"].filter( + Boolean, + ) as string[]; + + return `import { + type IpcClientAsync, + type IpcClientSync,${process ? "\n SpawnedProcessBackend," : ""} + pickServiceBackend, +} from '@aztec-foundation/ipc-runtime'; +import { AsyncApi } from './generated/async.js'; +import { SyncApi } from './generated/sync.js'; +${process ? `import { type ${prefix}ProcessOptions, spawnProcessBackend${shm ? ", spawnProcessBackendSync" : ""} } from './process.js';\n` : ""}import { ${findBinary} } from './platform.js'; +${wasm ? `import { type ${prefix}WasmOptions, createWasmBackend, createWasmBackendSync } from './wasm.js';\n` : ""} +${this.generatedExports()}${process ? "export * from './process.js';\n" : ""}${wasm ? "export * from './wasm.js';\n" : ""}export { ${findBinary} } from './platform.js'; + +${this.createOptionTypes(backends)} +/** + * The backend \`${className(prefix)}.create\` would use for \`options\`, for facades that wrap the + * generated API themselves. Unset backend: ${process ? `the process when the binary resolves${wasm ? ", falling back to wasm if it cannot be spawned" : ""}` : "the wasm module"}. + */ +export async function createBackend(options: ${prefix}CreateOptions = {}): Promise { + const common = { threads: options.threads, logger: options.logger, unref: options.unref }; + return pickServiceBackend(options.backend, { + label: '${packageName}', + logger: options.logger, +${ + process + ? ` process: { + available: () => ${findBinary}(options.process?.binaryPath) !== null, + create: () => spawnProcessBackend({ ...common, ...options.process }), + }, +` + : "" +}${ + wasm + ? ` wasm: { create: () => createWasmBackend({ ...common, ...options.wasm }) }, +` + : "" + } }); +} - return `import { createRequire } from 'node:module'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; +/** The synchronous counterpart of createBackend${shm ? ": the process over shared memory" : ""}${shm && wasm ? ", else " : ""}${wasm ? "the single-threaded wasm module" : ""}. */ +export async function createBackendSync(options: ${prefix}CreateSyncOptions = {}): Promise { + const common = { threads: options.threads, logger: options.logger, unref: options.unref }; + return pickServiceBackend(options.backend, { + label: '${packageName}', + logger: options.logger, +${ + shm + ? ` process: { + available: () => ${findBinary}(options.process?.binaryPath) !== null, + create: () => spawnProcessBackendSync({ ...common, ...options.process }), + }, +` + : "" +}${ + wasm + ? ` wasm: { create: () => createWasmBackendSync({ logger: options.logger, unref: options.unref, ...options.wasm }) }, +` + : "" + } }); +} -export type Platform = 'x86_64-linux' | 'x86_64-darwin' | 'aarch64-linux' | 'aarch64-darwin'; +${this.serviceClasses({ process, wasm })}`; + } -const PLATFORM_TO_PACKAGE: Record = { - 'x86_64-linux': '${archPackages["linux-x64"]}', - 'x86_64-darwin': '${archPackages["darwin-x64"]}', - 'aarch64-linux': '${archPackages["linux-arm64"]}', - 'aarch64-darwin': '${archPackages["darwin-arm64"]}', -}; + /** Browser entry: the service runs in-process as a wasm module; there is no process to spawn. */ + generateBrowserIndex(): string { + const { prefix, packageName } = this.opts; + + return `import type { IpcClientAsync, IpcClientSync } from '@aztec-foundation/ipc-runtime'; +import { AsyncApi } from './generated/async.js'; +import { SyncApi } from './generated/sync.js'; +import { type ${prefix}WasmOptions, createWasmBackend, createWasmBackendSync } from './wasm.js'; + +${this.generatedExports()}export * from './wasm.js'; + +export type ${prefix}Backend = 'wasm'; + +/** Options for ${className(prefix)}.create / createBackend (browser: the wasm module, or a backend object). */ +export interface ${prefix}CreateOptions { + backend?: ${prefix}Backend | IpcClientAsync; + /** Worker threads to run with; more than one needs a cross-origin isolated page (COOP/COEP). */ + threads?: number; + logger?: (msg: string) => void; + unref?: boolean; + wasm?: Omit<${prefix}WasmOptions, 'threads' | 'logger'>; +} -function currentDir(): string { - return path.dirname(fileURLToPath(import.meta.url)); +/** Options for ${className(prefix)}Sync.create / createBackendSync. */ +export interface ${prefix}CreateSyncOptions { + backend?: ${prefix}Backend | IpcClientSync; + logger?: (msg: string) => void; + unref?: boolean; + wasm?: Omit<${prefix}WasmOptions, 'threads' | 'worker' | 'logger'>; } -function detectPlatform(): Platform | null { - if (process.arch === 'x64' && process.platform === 'linux') return 'x86_64-linux'; - if (process.arch === 'x64' && process.platform === 'darwin') return 'x86_64-darwin'; - if (process.arch === 'arm64' && process.platform === 'linux') return 'aarch64-linux'; - if (process.arch === 'arm64' && process.platform === 'darwin') return 'aarch64-darwin'; - return null; +export async function createBackend(options: ${prefix}CreateOptions = {}): Promise { + if (typeof options.backend === 'object') { + return options.backend; + } + if (options.backend !== undefined && options.backend !== 'wasm') { + throw new Error(\`${packageName}: no such backend in a browser: \${String(options.backend)}\`); + } + return createWasmBackend({ threads: options.threads, logger: options.logger, unref: options.unref, ...options.wasm }); } -function findArchPackageDir(platform: Platform): string | null { - const packageName = PLATFORM_TO_PACKAGE[platform]; - try { - const require = createRequire(import.meta.url); - return path.dirname(require.resolve(packageName + '/package.json')); - } catch { - const siblingPackageDir = path.join(currentDir(), '..', 'packages', packageName.split('/').pop()!); - return fs.existsSync(path.join(siblingPackageDir, 'package.json')) ? siblingPackageDir : null; +export async function createBackendSync(options: ${prefix}CreateSyncOptions = {}): Promise { + if (typeof options.backend === 'object') { + return options.backend; } + if (options.backend !== undefined && options.backend !== 'wasm') { + throw new Error(\`${packageName}: no such synchronous backend in a browser: \${String(options.backend)}\`); + } + return createWasmBackendSync({ logger: options.logger, unref: options.unref, ...options.wasm }); } -export function ${findBinary}(customPath?: string): string | null { - if (customPath) { - return fs.existsSync(customPath) ? path.resolve(customPath) : null; +${this.serviceClasses({ process: false, wasm: true })}`; + } + + /** + * React Native entry: Hermes has no WebAssembly or workers and Metro cannot bundle worker URLs, so + * this entry ships no backend of its own. A native backend package (a JSI/TurboModule bridge to + * the service's linked library) registers itself; otherwise the consumer passes a backend object. + */ + /** + * React Native entry: Hermes has no WebAssembly or workers and Metro cannot bundle worker URLs, + * so this entry ships no backend of its own. A native backend package (a JSI/TurboModule bridge + * to the service's linked library) registers one with ipc-runtime; otherwise the consumer passes + * a backend object. + */ + generateReactNativeIndex(): string { + const { prefix, packageName } = this.opts; + const svc = className(prefix); + + return `import type { IpcClientAsync, IpcClientSync } from '@aztec-foundation/ipc-runtime/registry'; +import { registerBackend as register, registeredBackend } from '@aztec-foundation/ipc-runtime/registry'; +import { AsyncApi } from './generated/async.js'; +import { SyncApi } from './generated/sync.js'; + +${this.generatedExports()}export type { RegisteredBackends } from '@aztec-foundation/ipc-runtime/registry'; + +/** Make these the backends for ${prefix} in this app; a native backend package calls it on import. */ +export function registerBackend(factories: Parameters[1]): void { + register('${prefix}', factories); +} + +export interface ${prefix}CreateOptions { + /** A backend object (anything with call()/destroy()); default: the one a native package registered. */ + backend?: IpcClientAsync; +} + +export interface ${prefix}CreateSyncOptions { + backend?: IpcClientSync; +} + +export async function createBackend(options: ${prefix}CreateOptions = {}): Promise { + return options.backend ?? (await registeredBackend('${prefix}', 'async', '${packageName}')()); +} + +export async function createBackendSync(options: ${prefix}CreateSyncOptions = {}): Promise { + return options.backend ?? (await registeredBackend('${prefix}', 'sync', '${packageName}')()); +} + +export class ${svc} extends AsyncApi { + private constructor(backend: IpcClientAsync) { + super(backend); } - const envPath = process.env.${envVar}; - if (envPath) { - return fs.existsSync(envPath) ? path.resolve(envPath) : null; + static async create(options: ${prefix}CreateOptions = {}): Promise<${svc}> { + return new ${svc}(await createBackend(options)); } +} - const platform = detectPlatform(); - if (!platform) { - return null; +export class ${svc}Sync extends SyncApi { + private constructor(backend: IpcClientSync) { + super(backend); } - const archDir = findArchPackageDir(platform); - if (archDir) { - const candidate = path.join(archDir, '${this.opts.binaryName}'); - if (fs.existsSync(candidate)) { - return candidate; - } + static async create(options: ${prefix}CreateSyncOptions = {}): Promise<${svc}Sync> { + return new ${svc}Sync(await createBackendSync(options)); } +} +`; + } + + /** Platform-neutral part of the wasm transport: options, module selection, backend construction. */ + generateWasm(): string { + const { prefix, packageName } = this.opts; + const moduleUrl = (name: string | undefined) => + name ? `new URL('../wasm/${name}', import.meta.url)` : "undefined"; + // The module's FFI symbols carry the service name (see the FFI entry in SCHEMA_SPEC.md). + const sym = prefix ? `${toSnakeCase(prefix)}_` : ""; + const ffiExports = ` entry: '${sym}ipc_ffi_entry',`; + + return `import { + type WasmFfiBackend, + type WasmFfiBackendSync, + type WasmModuleSource, + chooseWasmModule, + createWasmFfiBackend, + createWasmFfiBackendSync, + platform, + resolveWasmThreads, +} from '@aztec-foundation/ipc-runtime/wasm'; + +export { sharedMemoryAvailable } from '@aztec-foundation/ipc-runtime/wasm'; + +/** Options for running the ${this.opts.binaryName} wasm module in-process. */ +export interface ${prefix}WasmOptions { + /** + * Threads to run with (1 = no worker threads; selects the single-thread module). Default: the + * platform's parallelism where a shared memory is available, else 1. More than one thread where + * no shared memory is available is an error, not a silent downgrade. + */ + threads?: number; + /** Linear memory bounds in 64 KiB pages. */ + memory?: { initial?: number; maximum?: number }; + /** + * The module to run instead of the package's own: a URL or path, raw or gzipped bytes, a fetch + * Response, or an already compiled WebAssembly.Module. The package's own module is uncompressed, + * so that a browser can stream it into compileStreaming and cache the compiled code; point this + * at a gzipped copy when serving from a host that applies no compression of its own. + */ + module?: WasmModuleSource; + /** + * Where a call runs. Default (true) is a dedicated worker, so a long call never blocks the + * caller — the right choice for proving, and the only safe one on a browser's main thread. + * False runs the module on the calling thread and every call blocks it until the module + * returns, which is cheaper per call and fine for short work off the main thread. For short + * work *on* the main thread, prefer the synchronous backend, which always runs there. + */ + worker?: boolean; + /** WASI environ for the module. */ + env?: Record; + logger?: (msg: string) => void; + /** Let node exit while the module's workers are alive. */ + unref?: boolean; +} + +// This package's own modules, as URLs relative to its own files: only the package can express +// these, which is why the choosing lives here and the deciding does not. +const MODULES = { + threads: ${moduleUrl(this.opts.wasmThreadsModule)}, + single: ${moduleUrl(this.opts.wasmModule)}, +}; + +/** The module to run for a thread count: the threads build above one thread, else single. */ +export function defaultWasmModule(threads: number): URL { + return chooseWasmModule(MODULES, '${packageName}', threads); +} + +/** The thread count to run with: the host's parallelism by default, a request checked against it. */ +export function resolveThreads(threads?: number): number { + return resolveWasmThreads(platform, '${packageName}', threads); +} - return null; +/** + * The ${this.opts.binaryName} wasm module in-process: the main instance in a worker by default, wasi + * threads on further workers. The workers are ipc-runtime's own, spawned with the literal + * expression bundlers detect, so they ship as worker chunks of the consuming application. + */ +export async function createWasmBackend(options: ${prefix}WasmOptions = {}): Promise { + const threads = resolveThreads(options.threads); + const backend = await createWasmFfiBackend({ + module: options.module ?? defaultWasmModule(threads), + threads, + memory: options.memory, + env: options.env, + logger: options.logger, + worker: options.worker, +${ffiExports} + }); + if (options.unref) { + backend.unref(); + } + return backend; } -export const ARCH_PACKAGE_STEM = '${stem}'; +/** + * The module on the calling thread, single-threaded: every call blocks until it returns, with no + * worker and no message passing. This is the form for short synchronous work such as hashing, + * including on a browser's main thread. + */ +export async function createWasmBackendSync(options: ${prefix}WasmOptions = {}): Promise { + const backend = await createWasmFfiBackendSync({ + module: options.module ?? defaultWasmModule(1), + threads: 1, + memory: options.memory, + env: options.env, + logger: options.logger, +${ffiExports} + }); + if (options.unref) { + backend.unref(); + } + return backend; +} `; } - generatePrepareArchPackagesScript(): string { - return `#!/usr/bin/env bash -set -euo pipefail - -cd "$(dirname "$0")/.." - -declare -A PLATFORMS=( -${ARCH_PACKAGES.map(({ buildDir, suffix, os, cpu }) => ` ["${buildDir}"]="${suffix} ${os} ${cpu}"`).join("\n")} -) - -version=$(node -p "require('./package.json').version") - -declare -A BINARIES=() -for arg in "$@"; do - case "$arg" in - *=*) - key="\${arg%%=*}" - value="\${arg#*=}" - BINARIES["$key"]="$value" - ;; - *) - echo "Usage: npm run prepare_arch_packages -- [= ...]" >&2 - echo "Platforms: linux-x64, linux-arm64, darwin-x64, darwin-arm64" >&2 - exit 1 - ;; - esac -done - -for build_dir in "\${!PLATFORMS[@]}"; do - read -r suffix os cpu <<< "\${PLATFORMS[$build_dir]}" - pkg_name="${this.opts.packageName}-\${suffix}" - out_dir="packages/${packageStem(this.opts.packageName)}-\${suffix}" - binary_path="\${BINARIES[$suffix]:-\${BINARIES[$build_dir]:-}}" - - if [ -z "$binary_path" ]; then - binary_path="build/\${build_dir}/${this.opts.binaryName}" - fi - - if [ ! -f "$binary_path" ]; then - echo "Skipping \${pkg_name}: no binary at \${binary_path}" - continue - fi - - rm -rf "\${out_dir}" - mkdir -p "\${out_dir}" - cp "$binary_path" "\${out_dir}/${this.opts.binaryName}" - chmod +x "\${out_dir}/${this.opts.binaryName}" 2>/dev/null || true - - cat > "\${out_dir}/package.json" < ` '${key}': '${name}',`) + .join("\n"); + + return `import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { type ServiceBinary, findServiceBinary } from '@aztec-foundation/ipc-runtime'; + +/** This package's native binary, for ipc-runtime's resolver and process backends. */ +export const BINARY: ServiceBinary = { + name: '${this.opts.binaryName}', + envVar: '${this.opts.binaryEnvVar}', + archPackages: { +${byPlatform} + }, + ipcPathArgs: ${JSON.stringify(this.opts.ipcPathArgs)}, + instancePrefix: '${toSnakeCase(this.opts.prefix)}', + packageDir: path.join(path.dirname(fileURLToPath(import.meta.url)), '..'), +}; + +/** + * The '${this.opts.binaryName}' binary to run: an explicit path if given, else + * \`${this.opts.binaryEnvVar}\`, else the installed arch package for this platform. Null when none + * of those yields an existing file. + */ +export function ${findBinary}(customPath?: string): string | null { + return findServiceBinary(BINARY, customPath); +} + +export const ARCH_PACKAGE_STEM = '${packageStem(this.opts.packageName)}'; `; } + + generateReadme(): string { + const svc = className(this.opts.prefix); + const process = this.process; + const wasm = this.wasm; + const backends = [ + process && + `- \`'process'\`: spawns the \`${this.opts.binaryName}\` binary (node) and talks to it over ${this.processTransports.join(" or ")}. The binary is resolved from \`${this.opts.binaryEnvVar}\`, an explicit \`process.binaryPath\`, or the installed arch package (one of this package's optional dependencies).`, + wasm && + `- \`'wasm'\`: runs the service's wasm module in-process (node and browsers) through \`@aztec-foundation/ipc-runtime/wasm\`: the main instance in a worker, wasi threads on further workers where a shared memory is available (node, or a browser page served with COOP/COEP headers), otherwise the single-thread module. The worker scripts and the module are referenced with \`new URL(..., import.meta.url)\`, so bundlers emit them as chunks and assets of the application, and only the module actually chosen is ever fetched (Vite users: exclude the package from \`optimizeDeps\`). The module ships uncompressed, which is what lets the browser stream it into \`WebAssembly.compileStreaming\` and cache the compiled code between visits; serve it with your host's own compression. Where that is not possible, \`wasm.module\` takes a compressed copy — or bytes, a \`Response\`, or an already compiled \`Module\`.`, + `- an object: anything with \`call(bytes)\`/\`destroy()\`, for a transport of your own (a bridge to a natively linked library, for instance).`, + ] + .filter(Boolean) + .join("\n"); + const defaultPolicy = [ + process && `the process when the binary resolves`, + wasm && `the wasm module`, + ] + .filter(Boolean) + .join(", otherwise "); + const threadsNote = wasm + ? "(a process reads it from `HARDWARE_CONCURRENCY`/`RAYON_NUM_THREADS`; wasm runs that many\nworker threads, and asking for more than one where no shared memory exists is an error rather than\na silent downgrade)" + : "(the process reads it from `HARDWARE_CONCURRENCY`/`RAYON_NUM_THREADS`)"; + const syncForm = [ + this.shm && "shared memory for a process", + wasm && "the single-threaded wasm module on the calling thread", + ] + .filter(Boolean) + .join(", else "); + const hosts = [ + "node (`default`) has every backend above", + wasm + ? "browsers (`browser`) have the wasm module only" + : "browsers have no backend of their own (this service has no wasm module)", + "React Native (`react-native`) has no built-in backend, because Hermes has no WebAssembly or workers — a native backend package registers one with `registerBackend`, or the app passes `options.backend`", + ].join("; "); + return `# ${this.opts.packageName} -Generated TypeScript IPC package for the ${this.opts.prefix} service. +Generated TypeScript package for the ${this.opts.prefix} service: the typed API +(\`AsyncApi\`/\`SyncApi\`, one method per command) over whichever backend reaches +the service on the current host. \`\`\`ts -import { ${className(this.opts.prefix)} } from '${this.opts.packageName}'; +import { ${svc} } from '${this.opts.packageName}'; -const service = await ${className(this.opts.prefix)}.spawn({ transport: 'uds' }); +const service = await ${svc}.create(); try { const response = await service.bytes({ data: new Uint8Array([1, 2, 3]) }); } finally { @@ -510,12 +895,36 @@ try { } \`\`\` -The package resolves \`${this.opts.binaryName}\` from \`${this.opts.binaryEnvVar}\`, -an explicit \`binaryPath\`, or an installed/prepared arch package. +\`create\` picks ${defaultPolicy}. \`options.backend\` forces one, with no fallback: + +${backends} + +\`threads\` sets the service's parallelism for any backend +${threadsNote}.${syncForm ? ` \`${svc}Sync.create\` is the synchronous form (${syncForm}).` : ""} +\`createBackend\`/\`createBackendSync\` expose the same policy for code that wraps +the generated API itself. +${ + wasm + ? ` +## Which thread the work runs on + +The caller chooses. An asynchronous wasm backend runs the module in a worker by +default, so a long call never blocks the caller; \`{ wasm: { worker: false } }\` +runs it on the calling thread instead, blocking until it returns. The +synchronous backend always runs on the calling thread, which is what short work +such as hashing wants, including on a browser's main thread. A spawned process +is off the caller's thread either way. +` + : "" +} +## Entries per host + +The package resolves to a different entry per host through export conditions: +${hosts}. ## Build -The package shell (package.json, tsconfig, src/index.ts, scripts/) is +The package shell (package.json, tsconfig, \`src/*.ts\`, scripts/) is generated; build through the owning project's \`./bootstrap.sh\`, which regenerates and then runs \`npm install --omit=optional && npm run build\`. diff --git a/ipc-codegen/src/zig_codegen.ts b/ipc-codegen/src/zig_codegen.ts index 52f833083569..720bd5ca691f 100644 --- a/ipc-codegen/src/zig_codegen.ts +++ b/ipc-codegen/src/zig_codegen.ts @@ -43,6 +43,49 @@ export class ZigCodegen { }; } + /** + * Client-side FFI backend over a linked library exporting this service's FFI entry. The symbols + * carry the service (`bb_ipc_ffi_entry`) so several services can be linked into one binary. + */ + generateFfiBackend(): string { + const sym = this.opts.prefix ? `${toSnakeCase(this.opts.prefix)}_` : ""; + return `//! AUTOGENERATED - DO NOT EDIT +//! FFI client backend for ${this.opts.prefix || "service"}: calls the service's in-process FFI +//! entry directly (\`${sym}ipc_ffi_entry\`), with no process and no IPC. Link the library +//! exporting it in your build.zig. +//! +//! Satisfies the backend interface: call(request) -> response, destroy(). +const std = @import("std"); + +extern fn ${sym}ipc_ffi_entry(input: [*]const u8, input_len: usize, output: *[*]u8, output_len: *usize) void; +extern fn ${sym}ipc_ffi_free(ptr: [*]u8) void; + +/// Allocator contract: callers free returned slices with this allocator +/// (the generated client uses std.heap.page_allocator), so the FFI buffer is +/// copied into it and released with the service's own free — freeing it with +/// a Zig allocator is undefined behaviour. +const alloc = std.heap.page_allocator; + +pub const FfiBackend = struct { + /// Send a msgpack command and receive the response via FFI. + pub fn call(self: *FfiBackend, request: []const u8) ![]u8 { + _ = self; + var out_ptr: [*]u8 = undefined; + var out_len: usize = 0; + ${sym}ipc_ffi_entry(request.ptr, request.len, &out_ptr, &out_len); + defer ${sym}ipc_ffi_free(out_ptr); + const response = try alloc.alloc(u8, out_len); + @memcpy(response, out_ptr[0..out_len]); + return response; + } + + pub fn destroy(self: *FfiBackend) void { + _ = self; + } +}; +`; + } + private primitiveType(type: Type): string { switch (type.primitive) { case "bool": diff --git a/ipc-codegen/templates/rust/ffi_backend.rs b/ipc-codegen/templates/rust/ffi_backend.rs deleted file mode 100644 index 5137ea99c6fa..000000000000 --- a/ipc-codegen/templates/rust/ffi_backend.rs +++ /dev/null @@ -1,128 +0,0 @@ -//! FFI backend scaffold for direct library linking. -//! -//! Calls a C symbol with msgpack bytes — no IPC overhead. Link against a -//! native library that exports `ipc_ffi_entry`, and add the appropriate -//! `-L` / `-l` directives to your `build.rs`. -//! -//! # Requirements -//! -//! 1. A native library exporting an extern-C function with this signature: -//! ```text -//! void ipc_ffi_entry( -//! const uint8_t* input, size_t input_len, -//! uint8_t** output_out, size_t* output_len_out); -//! ``` -//! `*output_out` must be a `malloc`'d buffer the caller is responsible for freeing. -//! 2. Library search path configured (via `.cargo/config.toml`, `RUSTFLAGS`, or -//! `cargo:rustc-link-search` in `build.rs`). -//! -//! # Example -//! -//! ```ignore -//! use my_service_client::{ServiceApi, FfiBackend}; -//! -//! let backend = FfiBackend::new()?; -//! let mut api = ServiceApi::new(backend); -//! let response = api.some_command(args)?; -//! ``` - -use super::backend::Backend; -use super::error::{IpcError, Result}; -use std::ptr; - -extern "C" { - /// Execute a msgpack-encoded command and return msgpack-encoded response. - /// - /// # Safety - /// - `input_in` must point to valid memory of `input_len_in` bytes - /// - `output_out` and `output_len_out` must be valid pointers - /// - Caller must free `*output_out` using `libc::free` - fn ipc_ffi_entry( - input_in: *const u8, - input_len_in: usize, - output_out: *mut *mut u8, - output_len_out: *mut usize, - ); -} - -/// FFI backend that calls a native library directly via its C ABI. -/// -/// Most performant backend (no process spawn, no IPC overhead) but requires -/// linking against the native library at build time. -/// -/// # Thread Safety -/// -/// This backend is **not** thread-safe by default. Each thread should have -/// its own `FfiBackend` instance, or access should be synchronized externally. -pub struct FfiBackend { - _initialized: bool, -} - -impl FfiBackend { - /// Create a new FFI backend. - pub fn new() -> Result { - Ok(Self { _initialized: true }) - } -} - -impl Backend for FfiBackend { - fn call(&mut self, input: &[u8]) -> Result> { - let mut output_ptr: *mut u8 = ptr::null_mut(); - let mut output_len: usize = 0; - - // SAFETY: - // - input.as_ptr() is valid for input.len() bytes - // - output_ptr and output_len are valid stack pointers - // - the FFI entrypoint allocates output using malloc, which we free below - unsafe { - ipc_ffi_entry( - input.as_ptr(), - input.len(), - &mut output_ptr, - &mut output_len, - ); - } - - if output_ptr.is_null() { - return Err(IpcError::Backend( - "FFI entry returned null pointer".to_string(), - )); - } - - if output_len == 0 { - unsafe { - libc::free(output_ptr as *mut libc::c_void); - } - return Err(IpcError::Backend( - "FFI entry returned empty response".to_string(), - )); - } - - // SAFETY: output_ptr is valid for output_len bytes, allocated by malloc - let output = unsafe { std::slice::from_raw_parts(output_ptr, output_len).to_vec() }; - - // SAFETY: output_ptr was allocated by the FFI entrypoint using malloc - unsafe { - libc::free(output_ptr as *mut libc::c_void); - } - - Ok(output) - } - - fn destroy(&mut self) -> Result<()> { - self._initialized = false; - Ok(()) - } -} - -impl Drop for FfiBackend { - fn drop(&mut self) { - let _ = self.destroy(); - } -} - -impl Default for FfiBackend { - fn default() -> Self { - Self::new().expect("Failed to initialize FfiBackend") - } -} diff --git a/ipc-codegen/templates/zig/ffi_backend.zig b/ipc-codegen/templates/zig/ffi_backend.zig deleted file mode 100644 index 93e16eb915eb..000000000000 --- a/ipc-codegen/templates/zig/ffi_backend.zig +++ /dev/null @@ -1,34 +0,0 @@ -/// FFI backend scaffold for direct library linking. -/// -/// Calls a C symbol with msgpack bytes — no IPC overhead. Link against a -/// native library that exports `ipc_ffi_entry`, and adjust the link -/// configuration in your build.zig to pull that library in. -/// -/// Satisfies the backend interface: call(request) -> response, destroy(). -const std = @import("std"); - -extern fn ipc_ffi_entry(input: [*]const u8, input_len: usize, output: *[*]u8, output_len: *usize) void; - -/// Allocator contract: callers free returned slices with this allocator -/// (the generated client uses std.heap.page_allocator), so the malloc'd FFI -/// buffer is copied into it and freed with the C allocator here — freeing a -/// malloc'd pointer with a Zig allocator is undefined behaviour. -const alloc = std.heap.page_allocator; - -pub const FfiBackend = struct { - /// Send a msgpack command and receive the response via FFI. - pub fn call(self: *FfiBackend, request: []const u8) ![]u8 { - _ = self; - var out_ptr: [*]u8 = undefined; - var out_len: usize = 0; - ipc_ffi_entry(request.ptr, request.len, &out_ptr, &out_len); - defer std.c.free(out_ptr); - const response = try alloc.alloc(u8, out_len); - @memcpy(response, out_ptr[0..out_len]); - return response; - } - - pub fn destroy(self: *FfiBackend) void { - _ = self; - } -}; diff --git a/ipc-runtime/README.md b/ipc-runtime/README.md index db519a3a7225..198b8353f69c 100644 --- a/ipc-runtime/README.md +++ b/ipc-runtime/README.md @@ -215,6 +215,27 @@ Two transport-specific clients: `UdsIpcServer` is provided for in-process tests; production servers are in C++. +The `@aztec-foundation/ipc-runtime/wasm` entry (node and `browser` export +conditions) runs a service compiled to a wasi reactor in-process, through the +FFI entry of the ipc-codegen contract (`_ipc_ffi_entry` / `_alloc` / +`_free`, found by suffix when not named; see `ipc-codegen/SCHEMA_SPEC.md`): + +| Export | Role | +|-------------------------------|------------------------------------------------------------------------------------------| +| `WasmFfiBackend` | async `IpcClientAsync`; main instance in a worker by default, wasi threads on further workers | +| `WasmFfiBackendSync` | sync `IpcClientSync`; one thread on the calling thread | +| `createWasmFfiBackend(Sync)` | the above bound to this platform's worker scripts | +| `runMainWorker`, `runThreadWorker` | bodies for a package's own worker scripts (needed when the module has `hostImports`) | +| `compileWasmModule` | `Module` from a URL, `Response`, raw or gzipped bytes; streaming compilation for URLs | + +The module is loaded through `WebAssembly.compileStreaming` where possible, so +browsers that cache compiled wasm start from optimized code on a repeat visit. +Memory is created to the module's declared shape (a threads build gets a +shared memory even for one thread), WASI is served by a small shim (clock, +random, stdout/stderr to the logger, environ), and modules whose platform +layer imports functions of its own (bb imports a logger, an abort hook and its +thread count) supply them through `hostImports`. + ### Zig (`zig/`) `Server.fromPath(path)` / `Client.fromPath(path)` over the same C ABI; the diff --git a/ipc-runtime/ts/package.json b/ipc-runtime/ts/package.json index d23a1accc8df..ada5c1c31b4c 100644 --- a/ipc-runtime/ts/package.json +++ b/ipc-runtime/ts/package.json @@ -9,6 +9,28 @@ ".": { "types": "./dest/index.d.ts", "import": "./dest/index.js" + }, + "./wasm": { + "browser": { + "types": "./dest/wasm/browser/index.d.ts", + "default": "./dest/wasm/browser/index.js" + }, + "default": { + "types": "./dest/wasm/node/index.d.ts", + "default": "./dest/wasm/node/index.js" + } + }, + "./wasm/node": { + "types": "./dest/wasm/node/index.d.ts", + "default": "./dest/wasm/node/index.js" + }, + "./wasm/browser": { + "types": "./dest/wasm/browser/index.d.ts", + "default": "./dest/wasm/browser/index.js" + }, + "./registry": { + "types": "./dest/backend_registry.d.ts", + "default": "./dest/backend_registry.js" } }, "scripts": { @@ -17,12 +39,16 @@ "test": "tsc -p tsconfig.json && node --test dest/*.test.js" }, "files": [ + "build", "dest", - "src", - "build" + "scripts", + "src" ], "devDependencies": { "@types/node": "^22", "typescript": "^5.6.3" + }, + "bin": { + "ipc-runtime-prepare-arch-packages": "./scripts/prepare_arch_packages.mjs" } } diff --git a/ipc-runtime/ts/scripts/prepare_arch_packages.mjs b/ipc-runtime/ts/scripts/prepare_arch_packages.mjs new file mode 100755 index 000000000000..2cf6e38695cf --- /dev/null +++ b/ipc-runtime/ts/scripts/prepare_arch_packages.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +// Stage a generated client package's per-platform binary packages: the optional dependencies its +// binary resolver looks for. Run from the package root; the package name, binary name and version +// come from its own package.json, so this is the same script for every service. +// +// Usage: prepare_arch_packages [= ...] +// platform: linux-x64 | linux-arm64 | darwin-x64 | darwin-arm64, or the build directory name +// (amd64-linux, arm64-linux, amd64-macos, arm64-macos) +// Without an argument for a platform, build// is used when it exists. +import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const PLATFORMS = [ + { buildDir: 'amd64-linux', suffix: 'linux-x64', os: 'linux', cpu: 'x64' }, + { buildDir: 'arm64-linux', suffix: 'linux-arm64', os: 'linux', cpu: 'arm64' }, + { buildDir: 'amd64-macos', suffix: 'darwin-x64', os: 'darwin', cpu: 'x64' }, + { buildDir: 'arm64-macos', suffix: 'darwin-arm64', os: 'darwin', cpu: 'arm64' }, +]; + +const pkg = JSON.parse(readFileSync('package.json', 'utf8')); +const binaryName = Object.keys(pkg.bin ?? {})[0]; +if (!binaryName) { + console.error(`prepare_arch_packages: ${pkg.name} declares no bin, so it ships no binary`); + process.exit(1); +} +// The scoped name's last segment, which is what the per-platform directories are named after. +const stem = pkg.name.split('/').pop(); + +const overrides = new Map(); +for (const arg of process.argv.slice(2)) { + const eq = arg.indexOf('='); + if (eq < 0) { + console.error('Usage: prepare_arch_packages [= ...]'); + console.error('Platforms: linux-x64, linux-arm64, darwin-x64, darwin-arm64'); + process.exit(1); + } + overrides.set(arg.slice(0, eq), arg.slice(eq + 1)); +} + +for (const { buildDir, suffix, os, cpu } of PLATFORMS) { + const name = `${pkg.name}-${suffix}`; + const outDir = join('packages', `${stem}-${suffix}`); + const binaryPath = + overrides.get(suffix) ?? overrides.get(buildDir) ?? join('build', buildDir, binaryName); + + if (!existsSync(binaryPath)) { + console.log(`Skipping ${name}: no binary at ${binaryPath}`); + continue; + } + + rmSync(outDir, { recursive: true, force: true }); + mkdirSync(outDir, { recursive: true }); + copyFileSync(binaryPath, join(outDir, binaryName)); + chmodSync(join(outDir, binaryName), 0o755); + writeFileSync( + join(outDir, 'package.json'), + JSON.stringify( + { + name, + version: pkg.version, + description: `Native binary for ${pkg.name} (${suffix})`, + license: 'MIT', + os: [os], + cpu: [cpu], + files: [binaryName], + preferUnplugged: true, + }, + null, + 2, + ) + '\n', + ); + console.log(`Staged ${name} from ${binaryPath}`); +} diff --git a/ipc-runtime/ts/src/backend_registry.ts b/ipc-runtime/ts/src/backend_registry.ts new file mode 100644 index 000000000000..38243aa77e7d --- /dev/null +++ b/ipc-runtime/ts/src/backend_registry.ts @@ -0,0 +1,54 @@ +import type { IpcClientAsync, IpcClientSync } from "./types.js"; + +export type { IpcClientAsync, IpcClientSync }; + +/** + * Backends a native package registers for a service, so an app that installs it needs no wiring. + * This is how a host with no backend of its own is served — React Native, where Hermes has no + * WebAssembly and no workers, and the service is reached through a linked library instead. + */ +export interface RegisteredBackends { + async?: () => Promise | IpcClientAsync; + sync?: () => Promise | IpcClientSync; +} + +// A well-known global rather than an import in either direction, so a native backend package and +// the generated client package need not depend on each other, and two copies of this module in +// one app still agree. +const REGISTRY_KEY = Symbol.for("@aztec-foundation/ipc-runtime/backends"); + +function registry(): Map { + const global = globalThis as unknown as Record< + symbol, + Map | undefined + >; + return (global[REGISTRY_KEY] ??= new Map()); +} + +/** Make `factories` the backends for `service`; a native backend package calls this when imported. */ +export function registerBackend( + service: string, + factories: RegisteredBackends, +): void { + registry().set(service, factories); +} + +/** + * The registered backend for a service, or a message naming what to install. `hint` is the + * package a caller should be told about. + */ +export function registeredBackend( + service: string, + kind: K, + hint: string, +): NonNullable { + const factory = registry().get(service)?.[kind]; + if (!factory) { + throw new Error( + `${hint}: no ${kind === "sync" ? "synchronous " : ""}backend registered for ${service}. ` + + "Install a native backend package for it (registering itself when imported), or pass one " + + "as options.backend.", + ); + } + return factory as NonNullable; +} diff --git a/ipc-runtime/ts/src/index.ts b/ipc-runtime/ts/src/index.ts index 3a095cfd47fc..85712c580a95 100644 --- a/ipc-runtime/ts/src/index.ts +++ b/ipc-runtime/ts/src/index.ts @@ -33,3 +33,18 @@ export { loadIpcRuntimeNapi, type Platform, } from "./native_loader.js"; +export { + type ServiceBinary, + type ServiceProcessOptions, + findServiceBinary, + pickServiceBackend, + runServiceBinary, + serviceProcessEnv, + spawnServiceBackend, + spawnServiceBackendSync, +} from "./service.js"; +export { + type RegisteredBackends, + registerBackend, + registeredBackend, +} from "./backend_registry.js"; diff --git a/ipc-runtime/ts/src/service.ts b/ipc-runtime/ts/src/service.ts new file mode 100644 index 000000000000..071d2447b7cf --- /dev/null +++ b/ipc-runtime/ts/src/service.ts @@ -0,0 +1,237 @@ +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + SpawnedProcessBackend, + SpawnedProcessBackendSync, +} from "./spawned_backend.js"; +import { IpcSpawnError } from "./errors.js"; +import type { IpcClientAsync, IpcClientSync } from "./types.js"; + +/** + * How a generated client package reaches its service as a spawned process. Everything here is + * fixed at generation time from the schema and the flags: the package supplies it, this module + * does the work, so the same logic is not regenerated into every package. + */ +export interface ServiceBinary { + /** Binary name, used for the arch-package lookup, log labels and errors. */ + name: string; + /** Environment variable that overrides the binary's path. */ + envVar: string; + /** npm package holding the binary, per platform (`process.arch`-`process.platform`). */ + archPackages: Record; + /** Argv template; each '{path}' is replaced with the backend's ipc path. */ + ipcPathArgs: string[]; + /** Prefix for the per-instance ipc path. */ + instancePrefix: string; + /** Directory of the package that owns the arch packages, for the sibling fallback. */ + packageDir: string; +} + +/** Options a caller may give when the service runs as a spawned process. */ +export interface ServiceProcessOptions { + binaryPath?: string; + transport?: "uds" | "shm"; + /** Threads the service may use, exported to it as HARDWARE_CONCURRENCY and RAYON_NUM_THREADS. */ + threads?: number; + logger?: (msg: string) => void; + connectTimeoutMs?: number; + env?: NodeJS.ProcessEnv; + extraArgs?: string[]; + respawn?: boolean; + /** When true, an idle backend does not keep the process alive; see SpawnedProcessBackendOptions. */ + unref?: boolean; + clientId?: number; + napiPath?: string; +} + +function platformKey(): string { + return `${process.arch}-${process.platform}`; +} + +function archPackageDir(binary: ServiceBinary): string | null { + const packageName = binary.archPackages[platformKey()]; + if (!packageName) { + return null; + } + try { + const require = createRequire(import.meta.url); + return path.dirname(require.resolve(`${packageName}/package.json`)); + } catch { + // Not installed as a dependency: fall back to a copy prepared inside the owning package, + // which is how a repository checkout runs before anything is published. + const sibling = path.join( + binary.packageDir, + "packages", + packageName.split("/").pop()!, + ); + return fs.existsSync(path.join(sibling, "package.json")) ? sibling : null; + } +} + +/** + * The binary to run: `customPath` if given, else the package's environment variable, else the + * installed arch package for this platform. Null when none of those yields an existing file. + */ +export function findServiceBinary( + binary: ServiceBinary, + customPath?: string, +): string | null { + const explicit = customPath ?? process.env[binary.envVar]; + if (explicit) { + return fs.existsSync(explicit) ? path.resolve(explicit) : null; + } + const dir = archPackageDir(binary); + if (dir) { + const candidate = path.join(dir, binary.name); + if (fs.existsSync(candidate)) { + return candidate; + } + } + return null; +} + +/** The child's environment: the caller's, plus the thread count under both names services read. */ +export function serviceProcessEnv( + options: Pick, +): NodeJS.ProcessEnv | undefined { + if (options.threads === undefined) { + return options.env; + } + const threads = String(options.threads); + return { + HARDWARE_CONCURRENCY: threads, + RAYON_NUM_THREADS: threads, + ...options.env, + }; +} + +function resolveOrThrow( + binary: ServiceBinary, + binaryPath: string | undefined, +): string { + const resolved = findServiceBinary(binary, binaryPath); + if (!resolved) { + throw new IpcSpawnError( + `${binary.name} binary not found`, + /*retry=*/ false, + ); + } + return resolved; +} + +/** + * Spawn the service and connect to it. Process lifecycle — connectivity, death detection, + * optional respawn, teardown — is owned by the backend and never leaks onto the caller's API. + */ +export function spawnServiceBackend( + binary: ServiceBinary, + defaultTransport: "uds" | "shm", + options: ServiceProcessOptions = {}, +): Promise { + return SpawnedProcessBackend.spawn({ + binaryPath: resolveOrThrow(binary, options.binaryPath), + binaryName: binary.name, + instancePrefix: binary.instancePrefix, + ipcPathArgs: binary.ipcPathArgs, + transport: options.transport ?? defaultTransport, + logger: options.logger, + connectTimeoutMs: options.connectTimeoutMs, + env: serviceProcessEnv(options), + extraArgs: options.extraArgs, + respawn: options.respawn, + unref: options.unref, + clientId: options.clientId, + napiPath: options.napiPath, + }); +} + +/** The synchronous form: shared memory is the one transport with a synchronous client. */ +export function spawnServiceBackendSync( + binary: ServiceBinary, + options: ServiceProcessOptions = {}, +): Promise { + if (options.transport !== undefined && options.transport !== "shm") { + throw new Error( + `${binary.name}: the synchronous backend needs the shm transport`, + ); + } + return SpawnedProcessBackendSync.spawn({ + binaryPath: resolveOrThrow(binary, options.binaryPath), + binaryName: binary.name, + instancePrefix: `${binary.instancePrefix}-sync`, + ipcPathArgs: binary.ipcPathArgs, + transport: "shm", + logger: options.logger, + connectTimeoutMs: options.connectTimeoutMs, + env: serviceProcessEnv(options), + extraArgs: options.extraArgs, + unref: options.unref, + clientId: options.clientId ?? 0, + napiPath: options.napiPath, + }); +} + +/** Run the service binary as a CLI, forwarding argv and exit status. Backs a package's `bin`. */ +export function runServiceBinary( + binary: ServiceBinary, + packageName: string, + argv: string[], +): never { + const binaryPath = findServiceBinary(binary); + if (!binaryPath) { + console.error( + `${binary.name}: native binary not found. Install the matching ` + + `'${packageName}-' package, set ${binary.envVar}, or pass its path.`, + ); + process.exit(1); + } + const result = spawnSync(binaryPath, argv, { stdio: "inherit" }); + if (result.error) { + console.error(result.error.message); + process.exit(1); + } + process.exit(result.status ?? 1); +} + +/** + * Which backend to use, given what this host can offer. Unset: the process when its binary + * resolves, else wasm. A name forces one, with no fallback to the other. + */ +export async function pickServiceBackend< + T extends IpcClientAsync | IpcClientSync, +>( + wanted: "process" | "wasm" | T | undefined, + choices: { + label: string; + process?: { available: () => boolean; create: () => Promise }; + wasm?: { create: () => Promise }; + logger?: (msg: string) => void; + }, +): Promise { + if (typeof wanted === "object") { + return wanted; + } + const { process: proc, wasm } = choices; + if ( + proc && + (wanted === "process" || + (wanted === undefined && (!wasm || proc.available()))) + ) { + try { + return await proc.create(); + } catch (err) { + if (wanted === "process" || !wasm) { + throw err; + } + choices.logger?.( + `${choices.label} process unavailable (${(err as Error).message}); falling back to wasm`, + ); + } + } + if (wasm && (wanted === undefined || wanted === "wasm")) { + return wasm.create(); + } + throw new Error(`${choices.label}: no such backend here: ${String(wanted)}`); +} diff --git a/ipc-runtime/ts/src/spawned_backend.ts b/ipc-runtime/ts/src/spawned_backend.ts index f11d378a22dc..d1cdf69a52ca 100644 --- a/ipc-runtime/ts/src/spawned_backend.ts +++ b/ipc-runtime/ts/src/spawned_backend.ts @@ -37,19 +37,15 @@ export interface SpawnedProcessBackendOptions { */ respawn?: boolean; /** - * Unref the child process (and, over UDS, the idle socket) so a backend - * that is never destroy()ed cannot hold the Node event loop open. Calls in - * flight still keep the loop alive until their response arrives. + * When true, an idle backend does not keep the caller's process alive: you never have to + * destroy() it for node to exit. Work in progress still holds the loop — the connect while + * the server starts, and each call until its response arrives — so nothing exits early. + * + * The one thing given up is trailing output: the child's stdout/stderr pipes, which exist + * only when `logger` is set, stop holding the loop too, so a process that exits while the + * child is mid-line loses it. */ unref?: boolean; - /** - * Also unref the child's stdout/stderr pipes, which exist only when - * `logger` is set. Separate from `unref` because those pipes are how the - * caller sees the child's output: unref'ing them lets the process exit with - * log lines still unread, so it is opt-in even when the child itself is - * unref'd. - */ - unrefStdio?: boolean; /** SHM only: fixed client slot id. When unset the client self-allocates a free slot. */ clientId?: number; /** SHM only: override the native addon path. */ @@ -106,6 +102,20 @@ async function removeStaleIpcPath( * live logger when there is one and to `logFd` otherwise. Shared by the async * and sync backends so process setup has exactly one implementation. */ +/** + * Stop the child and its output pipes holding the caller's event loop. Called once the backend is + * connected, never before: until then the child is the only thing keeping the process alive while + * the server starts, and unref'ing it lets node exit mid-startup. After it, each transport refs + * itself for the duration of a call (see UdsIpcClient's idle unref and the shm client's TSFN + * acquire/release), so only a genuinely idle backend is invisible to the loop. + */ +function unrefWhenIdle(child: ChildProcess): void { + child.unref(); + // The stdio pipes are net.Sockets at runtime but typed as Readable. + (child.stdout as unknown as { unref?: () => void } | null)?.unref?.(); + (child.stderr as unknown as { unref?: () => void } | null)?.unref?.(); +} + function spawnServerProcess( options: SpawnedProcessBackendOptions, ipcPath: string, @@ -138,14 +148,6 @@ function spawnServerProcess( ), ); } - if (options.unref) { - child.unref(); - } - if (options.unrefStdio) { - // The stdio pipes are net.Sockets at runtime but typed as Readable. - (child.stdout as unknown as { unref?: () => void } | null)?.unref?.(); - (child.stderr as unknown as { unref?: () => void } | null)?.unref?.(); - } return child; } @@ -433,6 +435,9 @@ export class SpawnedProcessBackend implements IpcClientAsync { await this.cleanupIpcPath(); throw this.asSpawnError(err); } + if (options.unref) { + unrefWhenIdle(child); + } return incarnation as Incarnation; } @@ -581,6 +586,9 @@ export class SpawnedProcessBackendSync implements IpcClientSync { connectShmSyncClient(options, ipcPath), childReadyFailure, ]); + if (options.unref) { + unrefWhenIdle(child); + } return new SpawnedProcessBackendSync( options, child, diff --git a/ipc-runtime/ts/src/wasm/backend.ts b/ipc-runtime/ts/src/wasm/backend.ts new file mode 100644 index 000000000000..9c7031cf6c66 --- /dev/null +++ b/ipc-runtime/ts/src/wasm/backend.ts @@ -0,0 +1,452 @@ +import type { IpcClientAsync, IpcClientSync } from "../types.js"; +import { WasmInstanceHost } from "./host.js"; +import { type WasmModuleSource, compileWasmModule } from "./module_source.js"; +import type { WasmPlatform, WorkerHandle } from "./platform.js"; + +/** Options shared by every way of running an FFI-contract module. */ +export interface WasmFfiOptions { + /** The module: a compiled `Module`, bytes (gzipped or not), a `Response`, or a URL/string. */ + module: WasmModuleSource; + /** Threads to run with (1 = no worker threads). Default: the platform's parallelism, capped at 32. */ + threads?: number; + /** Linear memory bounds in 64 KiB pages; `shared` defaults to `threads > 1`. */ + memory?: { initial?: number; maximum?: number; shared?: boolean }; + /** WASI environ for the module. `HARDWARE_CONCURRENCY` and `RAYON_NUM_THREADS` default to `threads`. */ + env?: Record; + logger?: (msg: string) => void; + /** FFI entry export; default: the module's one `_ipc_ffi_entry` export. */ + entry?: string; +} + +/** + * What a platform entry (`index.node.ts` / `index.browser.ts`) binds for the backend. Workers are + * created through factories rather than URLs: bundlers only bundle a worker's dependency graph when + * they see the literal `new Worker(new URL('./x.js', import.meta.url), { type: 'module' })`, so + * that expression has to live in the code that owns the worker script. + */ +export interface WasmFfiBinding { + platform: WasmPlatform; + /** Spawn a worker hosting one module instance per wasi thread. */ + createThreadWorker: () => WorkerHandle; + /** Spawn the worker hosting the main instance when `worker: true`. */ + createMainWorker: () => WorkerHandle; +} + +export interface WasmFfiBackendOptions extends WasmFfiOptions { + /** + * Run the main instance in a dedicated worker (default true), so a long call never blocks the + * caller's event loop and, in browsers, the thread pool is spawned from a worker. With `false` + * the main instance runs on the calling thread and every call blocks it until it returns. + */ + worker?: boolean; + /** Override the worker factories the platform entry binds. */ + createThreadWorker?: () => WorkerHandle; + createMainWorker?: () => WorkerHandle; +} + +/** Above this, more wasi threads stop paying for themselves; the engine clamps to it. */ +export const MAX_THREADS = 32; +const DEFAULT_INITIAL_PAGES = 64; +const DEFAULT_MAXIMUM_PAGES = 65536; +const FIRST_THREAD_ID = 2; + +type Pending = { + resolve: (out: Uint8Array) => void; + reject: (err: Error) => void; +}; + +function awaitReady(worker: WorkerHandle, what: string): Promise { + return new Promise((resolve, reject) => { + worker.onMessage((msg) => { + if (msg?.type === "ready") { + resolve(); + } else if (msg?.type === "init-error") { + reject(new Error(`${what}: ${msg.message}`)); + } + }); + worker.onError((err) => + reject(err instanceof Error ? err : new Error(`${what}: ${String(err)}`)), + ); + }); +} + +/** + * One worker per wasi thread, created when the module spawns it and dropped when it exits. + * + * A worker runs a thread to completion inside `wasi_thread_start`, so it can serve exactly one + * thread at a time; giving each its own worker is both the simplest arrangement and the only + * correct one. The module decides how many threads it wants — typically a pool of its own, sized + * from the thread count it was told — and this follows. + */ +class Threads { + private readonly workers = new Map(); + private nextTid = FIRST_THREAD_ID; + private unrefed = false; + + constructor( + private readonly createWorker: () => WorkerHandle, + private readonly init: Record, + private readonly logger: (msg: string) => void, + ) {} + + /** + * wasi-threads `thread-spawn`: start the module's thread entry on a new worker and return its + * tid. The import is synchronous, so the worker boots while the caller carries on; its `init` + * and `start` messages are queued in order and the worker serializes them. + */ + spawn(startArg: number): number { + const tid = this.nextTid++; + let worker: WorkerHandle; + try { + worker = this.createWorker(); + } catch (e) { + this.logger(`wasm thread ${tid} could not be created: ${String(e)}`); + return -1; + } + this.workers.set(tid, worker); + worker.onMessage((msg) => { + if (msg?.type === "log") { + this.logger(msg.message); + } else if (msg?.type === "thread-exit" || msg?.type === "init-error") { + if (msg.type === "init-error") { + this.logger(`wasm thread ${tid} failed to start: ${msg.message}`); + } + this.workers.delete(tid); + void worker.terminate(); + } + }); + worker.onError((err) => { + this.logger(`wasm thread ${tid}: ${String(err)}`); + this.workers.delete(tid); + }); + worker.postMessage({ type: "init", ...this.init }); + worker.postMessage({ type: "start", tid, startArg }); + if (this.unrefed) { + worker.unref(); + } + return tid; + } + + unref(): void { + this.unrefed = true; + for (const w of this.workers.values()) { + w.unref(); + } + } + + async destroy(): Promise { + const workers = [...this.workers.values()]; + this.workers.clear(); + await Promise.all(workers.map((w) => w.terminate())); + } +} + +/** + * The module running on the current thread: memory, main instance, and the workers serving its + * `thread-spawn` requests. `call` is synchronous and blocks until the module returns. + */ +export class WasmFfiEngine { + private constructor( + /** The module instance, for reaching exports of its own beyond the FFI entry. */ + readonly host: WasmInstanceHost, + private readonly threadWorkers: Threads | undefined, + readonly memory: WebAssembly.Memory, + readonly threads: number, + ) {} + + static async create( + opts: WasmFfiOptions, + binding: WasmFfiBinding, + ): Promise { + const { platform } = binding; + const logger = opts.logger ?? (() => {}); + const module = await compileWasmModule(opts.module, platform); + const wantThreads = Math.max( + 1, + Math.min(opts.threads ?? platform.hardwareConcurrency(), MAX_THREADS), + ); + const wantShared = + opts.memory?.shared ?? + (wantThreads > 1 && platform.sharedMemoryAvailable()); + const memory = createMemory( + module, + opts.memory?.initial ?? DEFAULT_INITIAL_PAGES, + opts.memory?.maximum ?? + platform.maximumMemoryPages?.() ?? + DEFAULT_MAXIMUM_PAGES, + wantShared, + ); + // The module's declaration wins over the request: a threads build gets a shared memory even + // for one thread, and workers need a shared memory, so the thread count follows the memory. + // A module that defines its own memory instead of importing one cannot be threaded at all — + // every instance would get a memory of its own, with nothing shared between them. + const importsMemory = WebAssembly.Module.imports(module).some( + (i) => i.kind === "memory", + ); + const shared = memory.buffer instanceof SharedArrayBuffer; + const threads = + importsMemory && shared && platform.sharedMemoryAvailable() + ? wantThreads + : 1; + const env = { + HARDWARE_CONCURRENCY: String(threads), + RAYON_NUM_THREADS: String(threads), + ...(opts.env ?? {}), + }; + // Threads are created when the module asks for them, so nothing is spawned here — and + // nothing at all if the module never spawns a thread. + const threadWorkers = + threads > 1 + ? new Threads( + binding.createThreadWorker, + { + module, + memory, + env, + entry: opts.entry, + }, + logger, + ) + : undefined; + + const host = await WasmInstanceHost.instantiate({ + module, + memory, + env, + logger, + entry: opts.entry, + threads, + spawnThread: (startArg) => threadWorkers?.spawn(startArg) ?? -1, + }); + // host.memory is the one the module actually uses, which is its own when it exports one. + logger( + `wasm: ${threads} thread(s), memory ${host.memory.buffer.byteLength >> 16} pages initial, ` + + `shared=${host.memory.buffer instanceof SharedArrayBuffer}, ` + + `${importsMemory ? "imported" : "owned by the module"}`, + ); + return new WasmFfiEngine(host, threadWorkers, host.memory, threads); + } + + call(input: Uint8Array): Uint8Array { + return this.host.call(input); + } + + unref(): void { + this.threadWorkers?.unref(); + } + + async destroy(): Promise { + await this.threadWorkers?.destroy(); + } +} + +/** + * The module's declared maximum is not visible from JS: instantiating with a larger `maximum` + * fails with a LinkError naming it, so probe downward until the import links. + */ +function createMemory( + module: WebAssembly.Module, + initial: number, + maximum: number, + shared: boolean, +): WebAssembly.Memory { + const imported = WebAssembly.Module.imports(module).find( + (i) => i.kind === "memory", + ); + let max = Math.max(initial, maximum); + let flippedShared = false; + for (;;) { + let memory: WebAssembly.Memory; + try { + memory = new WebAssembly.Memory({ initial, maximum: max, shared }); + } catch (e) { + // The engine could not reserve that much address space (e.g. mobile browsers). + if (max > initial) { + max = Math.max(initial, Math.floor(max / 2)); + continue; + } + throw e; + } + if (!imported) { + return memory; + } + try { + // A dry instantiation only to validate the memory import. V8 checks that every import + // module exists before it validates any single import, so stub every function import; + // the only errors left are then about the memory itself. + const stubs: Record> = {}; + for (const imp of WebAssembly.Module.imports(module)) { + stubs[imp.module] ??= {}; + if (imp.kind === "memory") { + stubs[imp.module][imp.name] = memory; + } else if (imp.kind === "function") { + stubs[imp.module][imp.name] = () => 0; + } + } + new WebAssembly.Instance(module, stubs); + return memory; + } catch (e) { + const message = e instanceof WebAssembly.LinkError ? String(e) : ""; + if (message.includes("maximum") && max > initial) { + max = Math.max(initial, Math.floor(max / 2)); + continue; + } + // A threads build declares its memory shared even when it will run with one thread (and a + // single-thread build declares it unshared): follow the module's declaration. + if (message.includes("shared") && !flippedShared) { + flippedShared = true; + shared = !shared; + continue; + } + return memory; + } + } +} + +/** + * Asynchronous FFI backend over a wasm module. Implements the runtime's `IpcClientAsync`, so the + * generated `AsyncApi` sits on it exactly as it sits on a spawned process. + */ +export class WasmFfiBackend implements IpcClientAsync { + private seq = 0; + private readonly pending = new Map(); + + private constructor( + private readonly engine: WasmFfiEngine | undefined, + private readonly worker: WorkerHandle | undefined, + ) {} + + static async create( + opts: WasmFfiBackendOptions, + binding: WasmFfiBinding, + ): Promise { + const bound: WasmFfiBinding = { + platform: binding.platform, + createThreadWorker: opts.createThreadWorker ?? binding.createThreadWorker, + createMainWorker: opts.createMainWorker ?? binding.createMainWorker, + }; + if (opts.worker === false) { + return new WasmFfiBackend( + await WasmFfiEngine.create(opts, bound), + undefined, + ); + } + // Compile here so the (cached, streaming) compilation happens once and the compiled Module is + // what crosses to the worker, rather than each side compiling the same bytes. + const module = await compileWasmModule(opts.module, bound.platform); + const worker = bound.createMainWorker(); + const backend = new WasmFfiBackend(undefined, worker); + const ready = awaitReady(worker, "wasm main worker"); + worker.onMessage((msg) => backend.onWorkerMessage(msg, opts.logger)); + worker.postMessage({ + type: "init", + module, + options: { + threads: opts.threads, + memory: opts.memory, + env: opts.env, + entry: opts.entry, + }, + }); + await ready; + return backend; + } + + private onWorkerMessage(msg: any, logger?: (msg: string) => void): void { + if (msg?.type === "log") { + logger?.(msg.message); + return; + } + if (msg?.type !== "result" && msg?.type !== "error") { + return; + } + const pending = this.pending.get(msg.id); + if (!pending) { + return; + } + this.pending.delete(msg.id); + if (msg.type === "result") { + pending.resolve(new Uint8Array(msg.output)); + } else { + pending.reject(new Error(msg.message)); + } + } + + call(input: Uint8Array): Promise { + if (this.engine) { + try { + return Promise.resolve(this.engine.call(input)); + } catch (e) { + return Promise.reject(e); + } + } + const id = ++this.seq; + // A private copy so the request can be transferred rather than cloned. Not `slice()`: on a + // node Buffer that aliases the caller's (pooled) memory, which the transfer would detach. + const copy = new Uint8Array(input.byteLength); + copy.set(input); + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.worker!.postMessage({ type: "call", id, input: copy }, [ + copy.buffer, + ]); + }); + } + + /** Let the host process exit even while the module's workers are alive (node). */ + unref(): void { + this.engine?.unref(); + this.worker?.unref(); + } + + async destroy(): Promise { + if (this.engine) { + await this.engine.destroy(); + return; + } + const worker = this.worker!; + const done = new Promise((resolve) => { + worker.onMessage((msg) => { + if (msg?.type === "destroyed") { + resolve(); + } + }); + }); + worker.postMessage({ type: "destroy" }); + await Promise.race([done, new Promise((r) => setTimeout(r, 2000))]); + await worker.terminate(); + for (const p of this.pending.values()) { + p.reject(new Error("wasm backend destroyed")); + } + this.pending.clear(); + } +} + +/** + * Synchronous FFI backend: the module runs on the calling thread and every call blocks until it + * returns. Defaults to one thread, since a blocked caller cannot service anything else. + */ +export class WasmFfiBackendSync implements IpcClientSync { + private constructor(private readonly engine: WasmFfiEngine) {} + + static async create( + opts: WasmFfiOptions, + binding: WasmFfiBinding, + ): Promise { + return new WasmFfiBackendSync( + await WasmFfiEngine.create({ threads: 1, ...opts }, binding), + ); + } + + call(input: Uint8Array): Uint8Array { + return this.engine.call(input); + } + + unref(): void { + this.engine.unref(); + } + + destroy(): void { + void this.engine.destroy(); + } +} diff --git a/ipc-runtime/ts/src/wasm/browser/index.ts b/ipc-runtime/ts/src/wasm/browser/index.ts new file mode 100644 index 000000000000..d05518d22e1f --- /dev/null +++ b/ipc-runtime/ts/src/wasm/browser/index.ts @@ -0,0 +1,40 @@ +// Browser entry of the wasm FFI backend (`@aztec-foundation/ipc-runtime/wasm`, `browser` condition). +import { type WasmFfiBinding, bindEntry } from "../entry.js"; +import { + browserPlatform, + browserWorkerHandle, + browserWorkerSide, +} from "./platform.js"; + +export * from "../entry.js"; +export { + browserPlatform as platform, + browserWorkerHandle as workerHandle, + browserWorkerSide as workerSide, +}; + +/** + * The worker scripts spawned for the main instance and each wasi thread, with the literal + * expression bundlers detect so they are emitted as worker chunks of the consuming application. + */ +export const binding: WasmFfiBinding = { + platform: browserPlatform, + createThreadWorker: () => + browserWorkerHandle( + new Worker(new URL("./thread.worker.js", import.meta.url), { + type: "module", + }), + ), + createMainWorker: () => + browserWorkerHandle( + new Worker(new URL("./main.worker.js", import.meta.url), { + type: "module", + }), + ), +}; + +export const { + createWasmFfiBackend, + createWasmFfiBackendSync, + sharedMemoryAvailable, +} = bindEntry(binding); diff --git a/ipc-runtime/ts/src/wasm/browser/main.worker.ts b/ipc-runtime/ts/src/wasm/browser/main.worker.ts new file mode 100644 index 000000000000..f49d77c8310a --- /dev/null +++ b/ipc-runtime/ts/src/wasm/browser/main.worker.ts @@ -0,0 +1,16 @@ +// Default main-instance worker for browsers: hosts the module and its thread pool off the page's thread. +import { runMainWorker } from "../main_worker.js"; +import { + browserPlatform, + browserWorkerHandle, + browserWorkerSide, +} from "./platform.js"; + +runMainWorker(browserWorkerSide(), browserPlatform, { + createThreadWorker: () => + browserWorkerHandle( + new Worker(new URL("./thread.worker.browser.js", import.meta.url), { + type: "module", + }), + ), +}); diff --git a/ipc-runtime/ts/src/wasm/browser/platform.ts b/ipc-runtime/ts/src/wasm/browser/platform.ts new file mode 100644 index 000000000000..c79f04eb0340 --- /dev/null +++ b/ipc-runtime/ts/src/wasm/browser/platform.ts @@ -0,0 +1,49 @@ +import type { WasmPlatform, WorkerHandle, WorkerSide } from "../platform.js"; + +/** The parent's handle on a browser `Worker`. */ +export function browserWorkerHandle(worker: Worker): WorkerHandle { + return { + postMessage: (message, transfer) => + worker.postMessage(message, transfer ?? []), + onMessage: (handler) => + worker.addEventListener("message", (e) => handler(e.data)), + onError: (handler) => worker.addEventListener("error", (e) => handler(e)), + terminate: async () => worker.terminate(), + unref: () => {}, + }; +} + +export const browserPlatform: WasmPlatform = { + // For scripts served as-is. Bundled code must spawn its workers with the literal + // `new Worker(new URL('./x.js', import.meta.url), { type: 'module' })` and wrap them with + // `browserWorkerHandle` (see `WasmFfiBinding`). + createWorker: (url: URL) => + browserWorkerHandle(new Worker(url, { type: "module" })), + hardwareConcurrency: () => navigator.hardwareConcurrency || 1, + sharedMemoryAvailable: () => + typeof SharedArrayBuffer !== "undefined" && + (globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated === + true, + // iOS browsers kill a page well before it reaches the 4 GiB a wasm memory may declare; asking + // for a 1 GiB maximum keeps the reservation within what they grant. + maximumMemoryPages: () => + typeof navigator !== "undefined" && /iPad|iPhone/.test(navigator.userAgent) + ? 2 ** 14 + : 2 ** 16, +}; + +/** The worker's side of a browser `Worker` channel. */ +export function browserWorkerSide(): WorkerSide { + const scope = globalThis as unknown as { + addEventListener(type: "message", handler: (e: MessageEvent) => void): void; + postMessage(message: unknown, transfer?: Transferable[]): void; + close(): void; + }; + return { + onMessage: (handler) => + scope.addEventListener("message", (e) => handler(e.data)), + postMessage: (message, transfer) => + scope.postMessage(message, transfer ?? []), + close: () => scope.close(), + }; +} diff --git a/ipc-runtime/ts/src/wasm/browser/thread.worker.ts b/ipc-runtime/ts/src/wasm/browser/thread.worker.ts new file mode 100644 index 000000000000..ebd4133bf927 --- /dev/null +++ b/ipc-runtime/ts/src/wasm/browser/thread.worker.ts @@ -0,0 +1,5 @@ +// Default wasi-threads worker for browsers: one module instance per thread, no module-specific imports. +import { browserWorkerSide } from "./platform.js"; +import { runThreadWorker } from "../thread_worker.js"; + +runThreadWorker(browserWorkerSide()); diff --git a/ipc-runtime/ts/src/wasm/entry.ts b/ipc-runtime/ts/src/wasm/entry.ts new file mode 100644 index 000000000000..53758ab4e72b --- /dev/null +++ b/ipc-runtime/ts/src/wasm/entry.ts @@ -0,0 +1,46 @@ +// What both platform entries (`index.node.ts`, `index.browser.ts`) share. They differ only in the +// platform they bind and in how they spawn a worker, so everything else lives here. +import { + type WasmFfiBackendOptions, + type WasmFfiBinding, + type WasmFfiOptions, + WasmFfiBackend, + WasmFfiBackendSync, +} from "./backend.js"; + +export type { WasmModuleSource } from "./module_source.js"; +export type { WasmPlatform, WorkerHandle, WorkerSide } from "./platform.js"; +export type { WasmFfiBackendOptions, WasmFfiBinding, WasmFfiOptions }; +export { + MAX_THREADS, + WasmFfiBackend, + WasmFfiBackendSync, + WasmFfiEngine, +} from "./backend.js"; +export { compileWasmModule } from "./module_source.js"; +export { WasmExitError } from "./wasi_shim.js"; +export { runMainWorker } from "./main_worker.js"; +export { runThreadWorker } from "./thread_worker.js"; +export { chooseWasmModule, resolveWasmThreads } from "./service.js"; +export { + type RegisteredBackends, + registerBackend, + registeredBackend, +} from "../backend_registry.js"; + +export interface WasmEntry { + createWasmFfiBackend(opts: WasmFfiBackendOptions): Promise; + createWasmFfiBackendSync(opts: WasmFfiOptions): Promise; + /** Whether this platform can run a module's threads build (a shared memory is available). */ + sharedMemoryAvailable(): boolean; +} + +/** The entry points a platform offers, over the workers and platform it binds. */ +export function bindEntry(binding: WasmFfiBinding): WasmEntry { + return { + createWasmFfiBackend: (opts) => WasmFfiBackend.create(opts, binding), + createWasmFfiBackendSync: (opts) => + WasmFfiBackendSync.create(opts, binding), + sharedMemoryAvailable: () => binding.platform.sharedMemoryAvailable(), + }; +} diff --git a/ipc-runtime/ts/src/wasm/host.ts b/ipc-runtime/ts/src/wasm/host.ts new file mode 100644 index 000000000000..fe0b4a5e79e2 --- /dev/null +++ b/ipc-runtime/ts/src/wasm/host.ts @@ -0,0 +1,277 @@ +import { WASI_NAMESPACE, createWasiImports } from "./wasi_shim.js"; + +export interface InstanceOptions { + module: WebAssembly.Module; + memory: WebAssembly.Memory; + /** WASI environ for the module (e.g. `HARDWARE_CONCURRENCY`). */ + env?: Record; + logger?: (msg: string) => void; + /** + * wasi-threads `thread-spawn`: start the module's thread entry on another instance and return + * its tid, or a negative errno when no thread can be started. + */ + spawnThread?: (startArg: number) => number; + /** + * Export taking `(input, input_len, output_out, output_len_out)`. Default: the module's one + * export named `_ipc_ffi_entry` (or bare `ipc_ffi_entry`). + */ + entry?: string; + /** Call the reactor's `_initialize` after instantiation (main instances only). Default true. */ + runInitialize?: boolean; + /** Threads this instance may use, for the WASI environ the module reads. Default 1. */ + threads?: number; +} + +/** Every FFI entry export ends with this; the generated ones are prefixed by their service. */ +export const ENTRY_SUFFIX = "ipc_ffi_entry"; + +/** Allocator pairs tried after the entry's own `_ipc_ffi_alloc`/`_free`. */ +export const FALLBACK_ALLOCATOR_EXPORTS: Array<[string, string]> = [ + ["malloc", "free"], + ["bbmalloc", "bbfree"], +]; + +type WasmFn = (...args: number[]) => number; + +/** The response pointer and length the entry writes back, ahead of the request in the scratch. */ +const SLOTS_BYTES = 8; +/** Starting size of the request scratch; it grows to fit and never shrinks. */ +const MIN_SCRATCH_BYTES = 64 * 1024; + +/** The entry export to use and the service prefix it carries (`bb_` for `bb_ipc_ffi_entry`). */ +function findEntry( + exports: Record, + wanted?: string, +): { entry: string; prefix: string } { + const prefixOf = (name: string) => + name.endsWith(ENTRY_SUFFIX) ? name.slice(0, -ENTRY_SUFFIX.length) : ""; + if (wanted) { + if (typeof exports[wanted] !== "function") { + throw new Error(`wasm module does not export ${wanted}`); + } + return { entry: wanted, prefix: prefixOf(wanted) }; + } + const candidates = Object.keys(exports).filter( + (name) => + typeof exports[name] === "function" && + (name === ENTRY_SUFFIX || name.endsWith(`_${ENTRY_SUFFIX}`)), + ); + if (candidates.length === 0) { + throw new Error(`wasm module exports no FFI entry (*_${ENTRY_SUFFIX})`); + } + if (candidates.length > 1) { + throw new Error( + `wasm module exports several FFI entries (${candidates.join(", ")}); pass \`entry\``, + ); + } + return { entry: candidates[0], prefix: prefixOf(candidates[0]) }; +} + +/** + * One instance of an FFI-contract module: imports resolved (WASI shim, wasi-threads, module + * specific host imports), `_initialize` run, and the `_ipc_ffi_entry` call protocol + * implemented over the module's own allocator. + */ +/** + * The tail of what the module wrote to stderr during the current call. A module compiled without + * exceptions cannot return a message when it gives up: it writes one out and exits. Keeping the + * text is what turns the bare `proc_exit` or trap the caller would otherwise see into an error + * that says what went wrong. + */ +class StderrTail { + private static readonly MAX_LINES = 8; + private lines: string[] = []; + + record(line: string): void { + this.lines.push(line); + if (this.lines.length > StderrTail.MAX_LINES) { + this.lines.shift(); + } + } + + reset(): void { + this.lines.length = 0; + } + + text(): string { + return this.lines.join("\n").trim(); + } +} + +export class WasmInstanceHost { + private constructor( + readonly instance: WebAssembly.Instance, + readonly memory: WebAssembly.Memory, + private readonly entry: WasmFn, + private readonly alloc: WasmFn, + private readonly free: WasmFn, + readonly logger: (msg: string) => void, + private readonly stderr: StderrTail, + ) {} + + static async instantiate(opts: InstanceOptions): Promise { + // A module either imports its memory, and is handed the one the caller made, or defines and + // exports its own — the default for a Rust cdylib. Which it is is only settled below, after + // instantiation, so everything reading memory goes through this. + let memory = opts.memory; + const logger = opts.logger ?? (() => {}); + const stderr = new StderrTail(); + const imports: Record> = { + env: { memory }, + [WASI_NAMESPACE]: createWasiImports(opts.module, () => memory, { + env: opts.env, + onStderr: (line) => { + stderr.record(line); + logger(line); + }, + onStdout: logger, + }), + wasi: { + "thread-spawn": (startArg: number) => + opts.spawnThread ? opts.spawnThread(startArg >>> 0) : -1, + }, + }; + + const missing: string[] = []; + for (const imp of WebAssembly.Module.imports(opts.module)) { + if (imports[imp.module]?.[imp.name] !== undefined) { + continue; + } + missing.push(`${imp.module}.${imp.name} (${imp.kind})`); + } + if (missing.length > 0) { + throw new Error( + `wasm module imports what this host does not provide; it must be a WASI reactor: ${missing.join(", ")}`, + ); + } + + const instance = await WebAssembly.instantiate(opts.module, imports); + const exports = instance.exports as Record; + if (exports.memory instanceof WebAssembly.Memory) { + // The module defined its own; the one passed in (if any) is not what it reads and writes. + memory = exports.memory; + } + if ( + opts.runInitialize !== false && + typeof exports._initialize === "function" + ) { + (exports._initialize as () => void)(); + } + const { entry, prefix } = findEntry(exports, opts.entry); + const allocatorExports: Array<[string, string]> = [ + [`${prefix}ipc_ffi_alloc`, `${prefix}ipc_ffi_free`], + ...FALLBACK_ALLOCATOR_EXPORTS, + ]; + const pair = allocatorExports.find( + ([a, f]) => + typeof exports[a] === "function" && typeof exports[f] === "function", + ); + if (!pair) { + throw new Error( + `wasm module exports no allocator pair (looked for ${allocatorExports + .map(([a, f]) => `${a}/${f}`) + .join(", ")})`, + ); + } + return new WasmInstanceHost( + instance, + memory, + exports[entry] as WasmFn, + exports[pair[0]] as WasmFn, + exports[pair[1]] as WasmFn, + logger, + stderr, + ); + } + + /** Call an arbitrary numeric export (used by thread workers for `wasi_thread_start`). */ + callExport(name: string, ...args: number[]): number { + const fn = ( + this.instance.exports as Record + )[name]; + if (typeof fn !== "function") { + throw new Error(`wasm module does not export ${name}`); + } + return (fn as WasmFn)(...args); + } + + /** Views over module memory, rebuilt only when a call grows it and detaches the old buffer. */ + private viewed?: ArrayBufferLike; + private bytes!: Uint8Array; + private words!: DataView; + + private refreshViews(): void { + if (this.viewed !== this.memory.buffer) { + this.viewed = this.memory.buffer; + this.bytes = new Uint8Array(this.memory.buffer); + this.words = new DataView(this.memory.buffer); + } + } + + /** + * A buffer held across calls for the request and the two response slots, so a round trip costs + * no allocator traffic of its own. It only ever grows, and calls are serialized (the entry is + * synchronous and single-threaded), so one buffer is enough. + */ + private scratch = 0; + private scratchCapacity = 0; + + private reserveScratch(size: number): number { + if (size <= this.scratchCapacity) { + return this.scratch; + } + const capacity = Math.max( + size, + this.scratchCapacity * 2, + MIN_SCRATCH_BYTES, + ); + const scratch = this.alloc(capacity) >>> 0; + if (scratch === 0) { + throw new Error( + `wasm module could not allocate a ${capacity} byte request buffer`, + ); + } + if (this.scratch !== 0) { + this.free(this.scratch); + } + this.scratch = scratch; + this.scratchCapacity = capacity; + return scratch; + } + + /** + * One FFI round trip: request bytes in, a copy of the response bytes out. The request is placed + * in module memory, and the response is read from where the module left it and released with the + * module's own free, as the FFI contract requires. + */ + call(input: Uint8Array): Uint8Array { + const slots = this.reserveScratch(SLOTS_BYTES + input.length); + const inPtr = slots + SLOTS_BYTES; + let outPtr = 0; + this.stderr.reset(); + try { + this.refreshViews(); + this.bytes.set(input, inPtr); + this.words.setUint32(slots, 0, true); + this.words.setUint32(slots + 4, 0, true); + try { + this.entry(inPtr, input.length, slots, slots + 4); + } catch (cause) { + const reported = this.stderr.text(); + if (!reported) { + throw cause; + } + throw new Error(reported, { cause }); + } + // The call may have grown memory, detaching the buffer the views were built over. + this.refreshViews(); + outPtr = this.words.getUint32(slots, true); + const outLen = this.words.getUint32(slots + 4, true); + return this.bytes.slice(outPtr, outPtr + outLen); + } finally { + if (outPtr !== 0) { + this.free(outPtr); + } + } + } +} diff --git a/ipc-runtime/ts/src/wasm/main_worker.ts b/ipc-runtime/ts/src/wasm/main_worker.ts new file mode 100644 index 000000000000..470a4dcefa01 --- /dev/null +++ b/ipc-runtime/ts/src/wasm/main_worker.ts @@ -0,0 +1,77 @@ +import { type WasmFfiBinding, WasmFfiEngine } from "./backend.js"; +import type { WasmPlatform, WorkerHandle, WorkerSide } from "./platform.js"; + +export interface MainWorkerOptions { + /** Spawns the thread pool's workers (see `WasmFfiBinding` for why this is a factory). */ + createThreadWorker: () => WorkerHandle; +} + +/** + * Body of the worker hosting a module's main instance: create the engine on `init`, serve `call` + * requests, tear down on `destroy`. Calls run to completion on this worker's thread; the parent's + * event loop stays free. + */ +export function runMainWorker( + side: WorkerSide, + platform: WasmPlatform, + opts: MainWorkerOptions, +): void { + let engine: WasmFfiEngine | undefined; + const log = (message: string) => side.postMessage({ type: "log", message }); + side.onMessage(async (msg) => { + switch (msg?.type) { + case "init": { + try { + const o = msg.options ?? {}; + const binding: WasmFfiBinding = { + platform, + createThreadWorker: opts.createThreadWorker, + // Unused here: this worker is the main worker. + createMainWorker: opts.createThreadWorker, + }; + engine = await WasmFfiEngine.create( + { + module: msg.module, + threads: o.threads, + memory: o.memory, + env: o.env, + entry: o.entry, + logger: log, + }, + binding, + ); + side.postMessage({ type: "ready" }); + } catch (e) { + side.postMessage({ + type: "init-error", + message: e instanceof Error ? (e.stack ?? e.message) : String(e), + }); + } + break; + } + case "call": { + try { + const output = engine!.call(new Uint8Array(msg.input)); + side.postMessage({ type: "result", id: msg.id, output }, [ + output.buffer as ArrayBuffer, + ]); + } catch (e) { + side.postMessage({ + type: "error", + id: msg.id, + message: e instanceof Error ? e.message : String(e), + }); + } + break; + } + case "destroy": { + await engine?.destroy(); + side.postMessage({ type: "destroyed" }); + side.close(); + break; + } + default: + break; + } + }); +} diff --git a/ipc-runtime/ts/src/wasm/module_source.ts b/ipc-runtime/ts/src/wasm/module_source.ts new file mode 100644 index 000000000000..447e65136def --- /dev/null +++ b/ipc-runtime/ts/src/wasm/module_source.ts @@ -0,0 +1,125 @@ +/** + * Compile a wasm module from wherever a consumer keeps it: an already compiled `Module`, raw or + * gzipped bytes, a fetch `Response`, or a URL (`http(s):`, `data:`, `blob:`, `file:`). + * + * URLs and responses go through `WebAssembly.compileStreaming`, so compilation overlaps the + * download and, in browsers that cache compiled wasm (Chrome, Firefox), a repeat visit starts + * from the optimized code of the previous one. Gzip is recognised from the response headers, the + * URL, or the magic bytes and inflated with the platform's `DecompressionStream`, so a `.wasm.gz` + * asset or a `data:application/gzip;base64,…` URL needs no JavaScript inflater. + */ +export type WasmModuleSource = + | WebAssembly.Module + | Uint8Array + | ArrayBuffer + | Response + | URL + | string; + +export interface ModuleLoadPlatform { + /** Read a `file:` URL (node). Browsers fetch everything. */ + readFile?(url: URL): Promise; + /** Turn a plain filesystem path into a URL (node). */ + resolvePath?(path: string): URL; +} + +const GZIP_MAGIC = [0x1f, 0x8b, 0x08]; +const WASM_MAGIC = [0x00, 0x61, 0x73, 0x6d]; + +function startsWith(bytes: Uint8Array, magic: number[]): boolean { + return magic.every((b, i) => bytes[i] === b); +} + +async function gunzip(bytes: Uint8Array): Promise { + const stream = new Blob([bytes as BlobPart]) + .stream() + .pipeThrough(new DecompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +async function compileBytes(bytes: Uint8Array): Promise { + if (startsWith(bytes, GZIP_MAGIC)) { + bytes = await gunzip(bytes); + } + if (!startsWith(bytes, WASM_MAGIC)) { + throw new Error("not a wasm module: bad magic bytes"); + } + // Our bytes never live on a SharedArrayBuffer; the cast only satisfies BufferSource's typing. + return WebAssembly.compile(bytes as unknown as BufferSource); +} + +async function compileStreamingOrBuffer( + response: Response, +): Promise { + if (typeof WebAssembly.compileStreaming === "function") { + try { + return await WebAssembly.compileStreaming(response.clone()); + } catch { + // Some hosts reject streaming compilation of synthetic responses; buffer instead. + } + } + return compileBytes(new Uint8Array(await response.arrayBuffer())); +} + +async function compileResponse( + response: Response, + label: string, +): Promise { + if (!response.ok) { + throw new Error(`fetching wasm module ${label}: HTTP ${response.status}`); + } + const type = (response.headers.get("content-type") ?? "").toLowerCase(); + const gzipped = type.includes("gzip") || /\.gz(\?|#|$)/.test(label); + if (gzipped && response.body) { + const inflated = new Response( + response.body.pipeThrough(new DecompressionStream("gzip")), + { headers: { "content-type": "application/wasm" } }, + ); + return compileStreamingOrBuffer(inflated); + } + if (type.includes("application/wasm")) { + return compileStreamingOrBuffer(response); + } + // Unknown or missing type (octet-stream, data: URLs): sniff the bytes. + return compileBytes(new Uint8Array(await response.arrayBuffer())); +} + +export async function compileWasmModule( + source: WasmModuleSource, + platform: ModuleLoadPlatform = {}, +): Promise { + if (source instanceof WebAssembly.Module) { + return source; + } + if (source instanceof Uint8Array) { + return compileBytes(source); + } + if (source instanceof ArrayBuffer) { + return compileBytes(new Uint8Array(source)); + } + if (source instanceof Response) { + return compileResponse(source, source.url); + } + let url: URL; + if (source instanceof URL) { + url = source; + } else { + try { + url = new URL(source); + } catch { + if (!platform.resolvePath) { + throw new Error(`wasm module source is not a URL: ${source}`); + } + url = platform.resolvePath(source); + } + } + if (url.protocol === "file:") { + if (!platform.readFile) { + throw new Error( + `file: wasm module sources need a platform with readFile: ${url.href}`, + ); + } + return compileBytes(await platform.readFile(url)); + } + return compileResponse(await fetch(url), url.href); +} diff --git a/ipc-runtime/ts/src/wasm/node/index.ts b/ipc-runtime/ts/src/wasm/node/index.ts new file mode 100644 index 000000000000..04d1a09273e4 --- /dev/null +++ b/ipc-runtime/ts/src/wasm/node/index.ts @@ -0,0 +1,33 @@ +// Node entry of the wasm FFI backend (`@aztec-foundation/ipc-runtime/wasm`, `default` condition). +import { type WasmFfiBinding, bindEntry } from "../entry.js"; +import { + nodePlatform, + nodeWorkerHandle, + nodeWorkerSide, +} from "./platform.js"; + +export * from "../entry.js"; +export { + nodePlatform as platform, + nodeWorkerHandle as workerHandle, + nodeWorkerSide as workerSide, +}; + +/** The worker scripts the node entry spawns for the main instance and each wasi thread. */ +export const binding: WasmFfiBinding = { + platform: nodePlatform, + createThreadWorker: () => + nodePlatform.createWorker( + new URL("./thread.worker.js", import.meta.url), + ), + createMainWorker: () => + nodePlatform.createWorker( + new URL("./main.worker.js", import.meta.url), + ), +}; + +export const { + createWasmFfiBackend, + createWasmFfiBackendSync, + sharedMemoryAvailable, +} = bindEntry(binding); diff --git a/ipc-runtime/ts/src/wasm/node/main.worker.ts b/ipc-runtime/ts/src/wasm/node/main.worker.ts new file mode 100644 index 000000000000..e83f1814b1d7 --- /dev/null +++ b/ipc-runtime/ts/src/wasm/node/main.worker.ts @@ -0,0 +1,10 @@ +// Default main-instance worker for node: hosts the module and its thread pool off the caller's thread. +import { runMainWorker } from "../main_worker.js"; +import { nodePlatform, nodeWorkerSide } from "./platform.js"; + +runMainWorker(nodeWorkerSide(), nodePlatform, { + createThreadWorker: () => + nodePlatform.createWorker( + new URL("./thread.worker.node.js", import.meta.url), + ), +}); diff --git a/ipc-runtime/ts/src/wasm/node/platform.ts b/ipc-runtime/ts/src/wasm/node/platform.ts new file mode 100644 index 000000000000..8e2c7556c496 --- /dev/null +++ b/ipc-runtime/ts/src/wasm/node/platform.ts @@ -0,0 +1,57 @@ +import { readFile } from "node:fs/promises"; +import { availableParallelism } from "node:os"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { Worker, type MessagePort, parentPort } from "node:worker_threads"; +import type { WasmPlatform, WorkerHandle, WorkerSide } from "../platform.js"; + +/** The parent's handle on a `worker_threads` worker. */ +export function nodeWorkerHandle(worker: Worker): WorkerHandle { + return { + postMessage: (message, transfer) => + worker.postMessage( + message, + transfer as unknown as readonly import("node:worker_threads").TransferListItem[], + ), + onMessage: (handler) => { + worker.on("message", handler); + }, + onError: (handler) => { + worker.on("error", handler); + }, + terminate: async () => { + await worker.terminate(); + }, + unref: () => worker.unref(), + }; +} + +export const nodePlatform: WasmPlatform = { + readFile: async (url) => new Uint8Array(await readFile(fileURLToPath(url))), + resolvePath: (path) => pathToFileURL(path), + createWorker: (url: URL) => nodeWorkerHandle(new Worker(url)), + hardwareConcurrency: () => + Number(process.env.HARDWARE_CONCURRENCY) || availableParallelism(), + sharedMemoryAvailable: () => true, +}; + +/** The worker's side of a node `worker_threads` channel. */ +export function nodeWorkerSide( + port: MessagePort | null = parentPort, +): WorkerSide { + if (!port) { + throw new Error( + "nodeWorkerSide: not running inside a worker (no parentPort)", + ); + } + return { + onMessage: (handler) => { + port.on("message", handler); + }, + postMessage: (message, transfer) => + port.postMessage( + message, + transfer as unknown as readonly import("node:worker_threads").TransferListItem[], + ), + close: () => port.close(), + }; +} diff --git a/ipc-runtime/ts/src/wasm/node/thread.worker.ts b/ipc-runtime/ts/src/wasm/node/thread.worker.ts new file mode 100644 index 000000000000..d1fa09a16912 --- /dev/null +++ b/ipc-runtime/ts/src/wasm/node/thread.worker.ts @@ -0,0 +1,5 @@ +// Default wasi-threads worker for node: one module instance per thread, no module-specific imports. +import { nodeWorkerSide } from "./platform.js"; +import { runThreadWorker } from "../thread_worker.js"; + +runThreadWorker(nodeWorkerSide()); diff --git a/ipc-runtime/ts/src/wasm/platform.ts b/ipc-runtime/ts/src/wasm/platform.ts new file mode 100644 index 000000000000..590e3aa2f2d9 --- /dev/null +++ b/ipc-runtime/ts/src/wasm/platform.ts @@ -0,0 +1,28 @@ +import type { ModuleLoadPlatform } from "./module_source.js"; + +/** The parent's handle on a spawned worker (node `worker_threads` or a browser `Worker`). */ +export interface WorkerHandle { + postMessage(message: unknown, transfer?: ArrayBuffer[]): void; + onMessage(handler: (message: any) => void): void; + onError(handler: (error: unknown) => void): void; + terminate(): Promise; + /** Let the host process exit even while this worker is alive (node only; no-op elsewhere). */ + unref(): void; +} + +/** What the wasm backend needs from its environment; one implementation per platform. */ +export interface WasmPlatform extends ModuleLoadPlatform { + createWorker(url: URL): WorkerHandle; + hardwareConcurrency(): number; + /** Threads need a shared memory; browsers only allow one under COOP/COEP. */ + sharedMemoryAvailable(): boolean; + /** Upper bound on linear memory (64 KiB pages) to ask for when the caller sets none. */ + maximumMemoryPages?(): number; +} + +/** The worker's view of its parent (node `parentPort` or a browser worker global). */ +export interface WorkerSide { + onMessage(handler: (message: any) => void): void; + postMessage(message: unknown, transfer?: ArrayBuffer[]): void; + close(): void; +} diff --git a/ipc-runtime/ts/src/wasm/service.ts b/ipc-runtime/ts/src/wasm/service.ts new file mode 100644 index 000000000000..fe96c81e8c8f --- /dev/null +++ b/ipc-runtime/ts/src/wasm/service.ts @@ -0,0 +1,46 @@ +import { MAX_THREADS } from "./backend.js"; +import type { WasmPlatform } from "./platform.js"; + +/** + * How many threads to run a module with: the platform's parallelism when the caller named none, + * else the number asked for, checked against what this host can actually give. More than one + * thread needs a shared memory, which browsers only allow on a cross-origin isolated page, and + * asking for it where it cannot be had is an error rather than a silent downgrade. + */ +export function resolveWasmThreads( + platform: WasmPlatform, + label: string, + threads?: number, +): number { + if (threads === undefined) { + return platform.sharedMemoryAvailable() + ? Math.min(platform.hardwareConcurrency(), MAX_THREADS) + : 1; + } + if (threads > 1 && !platform.sharedMemoryAvailable()) { + throw new Error( + `${label}: ${threads} threads requested but no shared memory is available here ` + + "(browsers need a cross-origin isolated page: COOP/COEP headers); pass threads: 1", + ); + } + return threads; +} + +/** + * Which of a package's own modules to run: the threads build for more than one thread, otherwise + * the single-thread build. Each stands in for the other when a package ships only one. + */ +export function chooseWasmModule( + modules: { single?: URL; threads?: URL }, + label: string, + threads: number, +): URL { + const module = + threads > 1 + ? (modules.threads ?? modules.single) + : (modules.single ?? modules.threads); + if (!module) { + throw new Error(`${label}: no wasm module ships with this package`); + } + return module; +} diff --git a/ipc-runtime/ts/src/wasm/thread_worker.ts b/ipc-runtime/ts/src/wasm/thread_worker.ts new file mode 100644 index 000000000000..2a5e6de75957 --- /dev/null +++ b/ipc-runtime/ts/src/wasm/thread_worker.ts @@ -0,0 +1,56 @@ +import { WasmInstanceHost } from "./host.js"; +import type { WorkerSide } from "./platform.js"; + +/** + * Body of a wasi-threads worker: instantiate the module over the shared memory on `init`, then run + * the module's thread entry on `start`. A thread runs to completion inside `wasi_thread_start`, so + * this worker serves that one thread and is done; the parent creates one worker per thread the + * module spawns. + */ +export function runThreadWorker(side: WorkerSide): void { + // `init` and `start` arrive back to back, and instantiation is asynchronous, so `start` waits on + // this rather than on the message order. + let instantiated: Promise | undefined; + const log = (message: string) => side.postMessage({ type: "log", message }); + + side.onMessage(async (msg) => { + try { + switch (msg?.type) { + case "init": + instantiated = WasmInstanceHost.instantiate({ + module: msg.module, + memory: msg.memory, + env: msg.env, + entry: msg.entry, + threads: 1, + runInitialize: false, + // Only the main instance spawns threads; a request from a thread is refused. + spawnThread: () => -1, + logger: log, + }); + await instantiated; + side.postMessage({ type: "ready" }); + break; + case "start": { + if (!instantiated) { + throw new Error("start before init"); + } + const host = await instantiated; + host.callExport("wasi_thread_start", msg.tid, msg.startArg); + side.postMessage({ type: "thread-exit", tid: msg.tid }); + break; + } + default: + break; + } + } catch (e) { + const message = e instanceof Error ? (e.stack ?? e.message) : String(e); + if (msg?.type === "init") { + side.postMessage({ type: "init-error", message }); + } else { + log(`wasm thread worker: ${message}`); + side.postMessage({ type: "thread-exit", tid: msg?.tid }); + } + } + }); +} diff --git a/ipc-runtime/ts/src/wasm/wasi_shim.ts b/ipc-runtime/ts/src/wasm/wasi_shim.ts new file mode 100644 index 000000000000..fe666b38d2c3 --- /dev/null +++ b/ipc-runtime/ts/src/wasm/wasi_shim.ts @@ -0,0 +1,167 @@ +/** + * The slice of WASI preview1 an in-process service needs: clocks, randomness, environ/args, + * stdout/stderr, exit. Every other WASI function the module imports is answered with `ENOSYS`, + * so linking never fails on a syscall the module carries but never calls (file I/O in a module + * that gets its inputs over the FFI entry, for instance). + */ +export const WASI_NAMESPACE = "wasi_snapshot_preview1"; + +const ERRNO_SUCCESS = 0; +const ERRNO_BADF = 8; +const ERRNO_NOSYS = 52; + +/** Raised when the module calls `proc_exit`; the process must not actually exit. */ +export class WasmExitError extends Error { + constructor(public readonly code: number) { + super(`wasm module called proc_exit(${code})`); + this.name = "WasmExitError"; + } +} + +export interface WasiShimOptions { + /** Environment visible to the module through `environ_get` (e.g. `HARDWARE_CONCURRENCY`). */ + env?: Record; + /** Program arguments visible through `args_get`. */ + args?: string[]; + /** Receives complete lines written to fd 1 / fd 2. */ + onStdout?: (line: string) => void; + onStderr?: (line: string) => void; +} + +function encodeNulTerminated(values: string[]): Uint8Array[] { + const encoder = new TextEncoder(); + return values.map((v) => encoder.encode(`${v}\0`)); +} + +/** + * Build the WASI import object for `module`. `memory` is read lazily on every call because the + * module's memory may grow (and its `buffer` be detached) between calls. + */ +export function createWasiImports( + module: WebAssembly.Module, + memory: () => WebAssembly.Memory, + opts: WasiShimOptions = {}, +): Record number> { + const view = () => new DataView(memory().buffer); + const bytes = () => new Uint8Array(memory().buffer); + const decoder = new TextDecoder(); + const envEntries = encodeNulTerminated( + Object.entries(opts.env ?? {}).map(([k, v]) => `${k}=${v}`), + ); + const argEntries = encodeNulTerminated(opts.args ?? []); + const pending: Record = {}; + + const sizesGet = ( + entries: Uint8Array[], + countOut: number, + sizeOut: number, + ) => { + view().setUint32(countOut, entries.length, true); + view().setUint32( + sizeOut, + entries.reduce((n, e) => n + e.length, 0), + true, + ); + return ERRNO_SUCCESS; + }; + const stringsGet = ( + entries: Uint8Array[], + ptrsOut: number, + bufOut: number, + ) => { + let p = bufOut; + entries.forEach((e, i) => { + view().setUint32(ptrsOut + 4 * i, p, true); + bytes().set(e, p); + p += e.length; + }); + return ERRNO_SUCCESS; + }; + + const implemented: Record number> = { + args_sizes_get: (countOut, sizeOut) => + sizesGet(argEntries, countOut, sizeOut), + args_get: (ptrsOut, bufOut) => stringsGet(argEntries, ptrsOut, bufOut), + environ_sizes_get: (countOut, sizeOut) => + sizesGet(envEntries, countOut, sizeOut), + environ_get: (ptrsOut, bufOut) => stringsGet(envEntries, ptrsOut, bufOut), + clock_res_get: (_id, out) => { + view().setBigUint64(out, 1000n, true); + return ERRNO_SUCCESS; + }, + clock_time_get: (_id, _precision, out) => { + const ns = BigInt( + Math.round((performance.timeOrigin + performance.now()) * 1e6), + ); + view().setBigUint64(out, ns, true); + return ERRNO_SUCCESS; + }, + random_get: (ptr, len) => { + // getRandomValues refuses views over shared memory and caps a call at 64 KiB: fill a + // private buffer in chunks and copy it in. + const dst = bytes(); + for (let off = 0; off < len; off += 65536) { + const chunk = new Uint8Array(Math.min(65536, len - off)); + crypto.getRandomValues(chunk); + dst.set(chunk, ptr + off); + } + return ERRNO_SUCCESS; + }, + fd_write: (fd, iovs, iovsLen, nwrittenOut) => { + let total = 0; + let text = ""; + for (let i = 0; i < iovsLen; i++) { + const ptr = view().getUint32(iovs + i * 8, true); + const len = view().getUint32(iovs + i * 8 + 4, true); + text += decoder.decode(bytes().subarray(ptr, ptr + len)); + total += len; + } + const sink = + fd === 1 ? opts.onStdout : fd === 2 ? opts.onStderr : undefined; + if (sink) { + const lines = ((pending[fd] ?? "") + text).split("\n"); + pending[fd] = lines.pop() ?? ""; + for (const line of lines) { + sink(line); + } + } + view().setUint32(nwrittenOut, total, true); + return ERRNO_SUCCESS; + }, + fd_fdstat_get: (fd, out) => { + if (fd > 2) { + return ERRNO_BADF; + } + bytes().fill(0, out, out + 24); + view().setUint8(out, 2); // filetype: character device + return ERRNO_SUCCESS; + }, + fd_close: () => ERRNO_BADF, + // wasi-libc walks descriptors from 3 looking for preopened directories and aborts on any + // error but this one, so a module with no preopens must be told BADF, not NOSYS. + fd_prestat_get: () => ERRNO_BADF, + fd_prestat_dir_name: () => ERRNO_BADF, + sched_yield: () => ERRNO_SUCCESS, + proc_exit: (code) => { + // Nothing more will be written, so a line still waiting for its terminator is final output. + // A module that gives up part way through a message would otherwise lose it entirely. + for (const [fd, text] of Object.entries(pending)) { + const sink = Number(fd) === 1 ? opts.onStdout : opts.onStderr; + if (text && sink) { + sink(text); + } + delete pending[Number(fd)]; + } + throw new WasmExitError(code); + }, + }; + + const imports: Record number> = {}; + for (const imp of WebAssembly.Module.imports(module)) { + if (imp.module !== WASI_NAMESPACE || imp.kind !== "function") { + continue; + } + imports[imp.name] = implemented[imp.name] ?? (() => ERRNO_NOSYS); + } + return imports; +} diff --git a/ipc-runtime/ts/src/wasm_engine.test.ts b/ipc-runtime/ts/src/wasm_engine.test.ts new file mode 100644 index 000000000000..fc9c39c9a043 --- /dev/null +++ b/ipc-runtime/ts/src/wasm_engine.test.ts @@ -0,0 +1,377 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { WasmFfiEngine } from "./wasm/backend.js"; +import type { WasmFfiBinding, WorkerHandle } from "./wasm/node/index.js"; +import { nodePlatform } from "./wasm/node/platform.js"; + +/** + * A module small enough to write out by hand, so the engine can be pinned without a real service. + * It imports a memory and wasi-threads, and implements the FFI contract with a bump allocator and + * an entry that echoes the request back as the response. + */ +function buildModule(opts: { + shared: boolean; + min: number; + max: number; + /** Define and export a memory instead of importing one, the way a Rust cdylib does. */ + ownMemory?: boolean; +}): WebAssembly.Module { + const leb = (n: number) => { + const out = []; + do { + const byte = n & 0x7f; + n >>>= 7; + out.push(n === 0 ? byte : byte | 0x80); + } while (n !== 0); + return out; + }; + const str = (s: string) => [s.length, ...[...s].map((c) => c.charCodeAt(0))]; + const section = (id: number, body: number[]) => [ + id, + ...leb(body.length), + ...body, + ]; + const I32 = 0x7f; + // Function indices: 0 is the imported thread-spawn, then the four defined below. + const [SPAWN, ENTRY, ALLOC, FREE] = [1, 2, 3, 4]; + + const types = section(1, [ + 3, + 0x60, + 1, + I32, + 1, + I32, // (i32) -> i32 alloc, spawn_thread + 0x60, + 4, + I32, + I32, + I32, + I32, + 0, // (i32 x4) -> () entry + 0x60, + 1, + I32, + 0, // (i32) -> () free + ]); + const limits = [opts.shared ? 0x03 : 0x01, ...leb(opts.min), ...leb(opts.max)]; + const imports = section(2, [ + ...(opts.ownMemory + ? [1] + : [2, ...str("env"), ...str("memory"), 0x02, ...limits]), + ...str("wasi"), + ...str("thread-spawn"), + 0x00, + 0, + ]); + const memories = opts.ownMemory ? section(5, [1, ...limits]) : []; + const functions = section(3, [4, 0, 1, 0, 2]); + // The bump allocator's next free offset, past the page the entry never touches. + const globals = section(6, [1, I32, 0x01, 0x41, ...leb(4096), 0x0b]); + const exports = section(7, [ + opts.ownMemory ? 5 : 4, + ...(opts.ownMemory ? [...str("memory"), 0x02, 0] : []), + ...str("spawn_thread"), + 0x00, + SPAWN, + ...str("ipc_ffi_entry"), + 0x00, + ENTRY, + ...str("ipc_ffi_alloc"), + 0x00, + ALLOC, + ...str("ipc_ffi_free"), + 0x00, + FREE, + ]); + const body = (locals: number[], code: number[]) => { + const bytes = [...locals, ...code, 0x0b]; + return [...leb(bytes.length), ...bytes]; + }; + const STORE = [0x36, 0x02, 0x00]; // i32.store, natural alignment, no offset + const code = section(10, [ + 4, + // spawn_thread(arg) = thread-spawn(arg) + ...body([0], [0x20, 0, 0x10, 0]), + // entry(in, len, outPtr, outLenPtr): out = alloc(len); *outPtr = out; *outLenPtr = len; + // copy the request into it, so a caller sees its own bytes come back. + ...body( + [1, 1, I32], + [ + 0x20, + 1, + 0x10, + ALLOC, + 0x21, + 4, // local 4 = alloc(len) + 0x20, + 2, + 0x20, + 4, + ...STORE, + 0x20, + 3, + 0x20, + 1, + ...STORE, + 0x20, + 4, + 0x20, + 0, + 0x20, + 1, + 0xfc, + 0x0a, + 0x00, + 0x00, // memory.copy(out, in, len) + ], + ), + // alloc(n): return the bump pointer, then advance it. + ...body([0], [0x23, 0, 0x23, 0, 0x20, 0, 0x6a, 0x24, 0]), + // free(p): nothing to do. + ...body([0], []), + ]); + + return new WebAssembly.Module( + new Uint8Array([ + 0x00, + 0x61, + 0x73, + 0x6d, + 0x01, + 0x00, + 0x00, + 0x00, + ...types, + ...imports, + ...functions, + ...memories, + ...globals, + ...exports, + ...code, + ]), + ); +} + +/** A binding that counts the workers it is asked for, so on-demand creation is observable. */ +function countingBinding(): WasmFfiBinding & { created: () => number } { + let created = 0; + return { + platform: nodePlatform, + created: () => created, + createThreadWorker: () => { + created++; + // The thread never starts: this module has no `wasi_thread_start`, and the test is about + // when a worker is asked for, not what runs on it. + return nodePlatform.createWorker( + new URL("./wasm_engine_stub.worker.js", import.meta.url), + ); + }, + createMainWorker: (): WorkerHandle => { + throw new Error("the engine under test runs in-process"); + }, + }; +} + +const threadedModule = () => buildModule({ shared: true, min: 4, max: 64 }); + +test("creates no thread workers until the module spawns one, then one per thread", async () => { + const binding = countingBinding(); + const engine = await WasmFfiEngine.create( + { + module: threadedModule(), + threads: 4, + memory: { initial: 4, maximum: 64 }, + }, + binding, + ); + assert.equal(engine.threads, 4); + assert.equal(binding.created(), 0, "nothing spawned before the module asks"); + + const spawn = (arg: number) => engine.host.callExport("spawn_thread", arg); + assert.equal(spawn(0), 2, "first tid"); + assert.equal(binding.created(), 1); + assert.equal(spawn(0), 3, "tids keep counting up"); + assert.equal(spawn(0), 4); + assert.equal(binding.created(), 3, "one worker per spawned thread, no pool"); + + await engine.destroy(); +}); + +test("refuses to spawn when the engine runs single-threaded", async () => { + const binding = countingBinding(); + const engine = await WasmFfiEngine.create( + { + module: threadedModule(), + threads: 1, + memory: { initial: 4, maximum: 64 }, + }, + binding, + ); + assert.equal( + engine.host.callExport("spawn_thread", 0), + -1, + "wasi-threads errno, not a tid", + ); + assert.equal(binding.created(), 0); + await engine.destroy(); +}); + +test("follows the module's memory declaration rather than the request", async () => { + // A module built without threads declares an unshared memory; asking for several threads cannot + // change that, so the engine drops to one thread rather than failing to link. + const engine = await WasmFfiEngine.create( + { + module: buildModule({ shared: false, min: 4, max: 64 }), + threads: 4, + memory: { initial: 4, maximum: 64 }, + }, + countingBinding(), + ); + assert.equal(engine.memory.buffer instanceof SharedArrayBuffer, false); + assert.equal(engine.threads, 1); + await engine.destroy(); +}); + +test("finds a maximum the module accepts when the caller asks for more", async () => { + // The module's declared maximum is not visible from JS, so an over-large request has to be + // narrowed by probing; 64 pages here against the engine's default ceiling. + const engine = await WasmFfiEngine.create( + { module: threadedModule(), threads: 2, memory: { initial: 4 } }, + countingBinding(), + ); + assert.equal(engine.memory.buffer.byteLength, 4 * 64 * 1024); + await engine.destroy(); +}); + +test("round-trips requests, growing the reused request buffer to fit", async () => { + const engine = await WasmFfiEngine.create( + { + module: threadedModule(), + threads: 1, + memory: { initial: 64, maximum: 64 }, + }, + countingBinding(), + ); + // Sizes that cross the initial scratch and shrink back again: a stale pointer or a buffer that + // failed to grow would echo the wrong bytes. + for (const size of [1, 64, 100_000, 8, 250_000, 32]) { + const request = Uint8Array.from( + { length: size }, + (_v, i) => (i * 31 + size) & 0xff, + ); + assert.deepEqual(engine.call(request), request, `echo of ${size} bytes`); + } + await engine.destroy(); +}); + +test("uses the module's own memory when it exports one instead of importing", async () => { + // A Rust cdylib defines its own memory, so the one the engine would have made is not the one the + // module reads and writes: a request echoed through the wrong memory comes back as zeroes. Such + // a module also cannot be threaded, since every instance would have a memory to itself. + const engine = await WasmFfiEngine.create( + { module: buildModule({ shared: false, min: 4, max: 64, ownMemory: true }), threads: 4 }, + countingBinding(), + ); + assert.equal(engine.threads, 1, "a module owning its memory cannot share it with workers"); + assert.equal(engine.memory, engine.host.instance.exports.memory); + const request = Uint8Array.from({ length: 5000 }, (_v, i) => (i * 7) & 0xff); + assert.deepEqual(engine.call(request), request); + await engine.destroy(); +}); + +/** + * A module whose entry reports a failure the only way one compiled without exceptions can: it + * writes the reason to stderr and exits. Everything before the entry is the smallest wrapping the + * engine will accept, so the test is about what the caller sees, not about the module. + */ +function buildAbortingModule(message: string): WebAssembly.Module { + const leb = (n: number) => { + const out = []; + do { + const byte = n & 0x7f; + n >>>= 7; + out.push(n === 0 ? byte : byte | 0x80); + } while (n !== 0); + return out; + }; + const str = (s: string) => [s.length, ...[...s].map((c) => c.charCodeAt(0))]; + const section = (id: number, body: number[]) => [id, ...leb(body.length), ...body]; + const body = (locals: number[], code: number[]) => { + const bytes = [...locals, ...code, 0x0b]; + return [...leb(bytes.length), ...bytes]; + }; + const I32 = 0x7f; + const WASI = "wasi_snapshot_preview1"; + // Imported functions are numbered first, so the two WASI calls take 0 and 1. + const [FD_WRITE, PROC_EXIT] = [0, 1]; + // Scratch below the message: the iovec at 0, fd_write's byte count at 8. + const [IOV, NWRITTEN, MSG] = [0, 8, 16]; + const text = [...new TextEncoder().encode(message)]; + if (MSG + text.length >= 64 || text.length >= 64) { + throw new Error("the hand-rolled encoder only emits single-byte i32.const operands"); + } + + const types = section(1, [ + 4, + 0x60, 4, I32, I32, I32, I32, 1, I32, // fd_write + 0x60, 1, I32, 0, // proc_exit, free + 0x60, 4, I32, I32, I32, I32, 0, // entry + 0x60, 1, I32, 1, I32, // alloc + ]); + const imports = section(2, [ + 3, + ...str("env"), ...str("memory"), 0x02, 0x01, ...leb(1), ...leb(4), + ...str(WASI), ...str("fd_write"), 0x00, 0, + ...str(WASI), ...str("proc_exit"), 0x00, 1, + ]); + const functions = section(3, [3, 2, 3, 1]); + const globals = section(6, [1, I32, 0x01, 0x41, ...leb(4096), 0x0b]); + const exports = section(7, [ + 3, + ...str("ipc_ffi_entry"), 0x00, 2, + ...str("ipc_ffi_alloc"), 0x00, 3, + ...str("ipc_ffi_free"), 0x00, 4, + ]); + const STORE = [0x36, 0x02, 0x00]; + const code = section(10, [ + 3, + // entry: write the message to stderr, then exit non-zero without touching the out slots. + ...body( + [0], + [ + 0x41, IOV, 0x41, MSG, ...STORE, + 0x41, IOV + 4, 0x41, text.length, ...STORE, + 0x41, 2, 0x41, IOV, 0x41, 1, 0x41, NWRITTEN, 0x10, FD_WRITE, 0x1a, + 0x41, 1, 0x10, PROC_EXIT, + ], + ), + ...body([0], [0x23, 0, 0x23, 0, 0x20, 0, 0x6a, 0x24, 0]), + ...body([0], []), + ]); + const data = section(11, [1, 0x00, 0x41, MSG, 0x0b, ...leb(text.length), ...text]); + + return new WebAssembly.Module( + new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + ...types, ...imports, ...functions, ...globals, ...exports, ...code, ...data, + ]), + ); +} + +test("a module that reports a failure and exits raises it as an error", async () => { + const engine = await WasmFfiEngine.create( + { + module: buildAbortingModule("abort: not on the curve"), + threads: 1, + memory: { initial: 1, maximum: 4 }, + }, + countingBinding(), + ); + assert.throws( + () => engine.call(Uint8Array.of(1, 2, 3)), + (err: Error) => err.message.includes("not on the curve"), + "the text the module wrote is what the caller sees, not a bare proc_exit", + ); + await engine.destroy(); +}); diff --git a/ipc-runtime/ts/src/wasm_engine_stub.worker.ts b/ipc-runtime/ts/src/wasm_engine_stub.worker.ts new file mode 100644 index 000000000000..ca3c2451128a --- /dev/null +++ b/ipc-runtime/ts/src/wasm_engine_stub.worker.ts @@ -0,0 +1,2 @@ +// A worker that does nothing, for tests that observe when the engine asks for one. +export {}; diff --git a/ipc-runtime/ts/src/wasm_module_source.test.ts b/ipc-runtime/ts/src/wasm_module_source.test.ts new file mode 100644 index 000000000000..3d242461c1df --- /dev/null +++ b/ipc-runtime/ts/src/wasm_module_source.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { gzipSync } from "node:zlib"; +import { compileWasmModule } from "./wasm/module_source.js"; +import { nodePlatform } from "./wasm/node/platform.js"; + +// The smallest valid module: magic + version, no sections. +const EMPTY_MODULE = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, +]); + +test("compiles raw bytes", async () => { + const module = await compileWasmModule(EMPTY_MODULE); + assert.ok(module instanceof WebAssembly.Module); + assert.deepEqual(WebAssembly.Module.exports(module), []); +}); + +test("recognises and inflates gzipped bytes", async () => { + const module = await compileWasmModule( + new Uint8Array(gzipSync(EMPTY_MODULE)), + ); + assert.ok(module instanceof WebAssembly.Module); +}); + +test("accepts a gzip data: URL", async () => { + const b64 = Buffer.from(gzipSync(EMPTY_MODULE)).toString("base64"); + const module = await compileWasmModule(`data:application/gzip;base64,${b64}`); + assert.ok(module instanceof WebAssembly.Module); +}); + +test("reads file: URLs and plain paths through the platform", async () => { + const { writeFileSync, mkdtempSync } = await import("node:fs"); + const { join } = await import("node:path"); + const { tmpdir } = await import("node:os"); + const dir = mkdtempSync(join(tmpdir(), "ipc-runtime-wasm-")); + const path = join(dir, "empty.wasm.gz"); + writeFileSync(path, gzipSync(EMPTY_MODULE)); + assert.ok( + (await compileWasmModule(path, nodePlatform)) instanceof WebAssembly.Module, + ); + assert.ok( + (await compileWasmModule( + nodePlatform.resolvePath!(path), + nodePlatform, + )) instanceof WebAssembly.Module, + ); +}); + +test("rejects bytes that are neither wasm nor gzip", async () => { + await assert.rejects( + compileWasmModule(new Uint8Array([1, 2, 3, 4])), + /bad magic/, + ); +}); diff --git a/ipc-runtime/ts/tsconfig.json b/ipc-runtime/ts/tsconfig.json index 9ab2e0c930d3..4df9ed2e851a 100644 --- a/ipc-runtime/ts/tsconfig.json +++ b/ipc-runtime/ts/tsconfig.json @@ -9,7 +9,7 @@ "declaration": true, "outDir": "dest", "rootDir": "src", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["node"] }, "include": ["src"] diff --git a/wsdb/bootstrap.sh b/wsdb/bootstrap.sh index a45dc4a55e3b..820a8c95ef1c 100755 --- a/wsdb/bootstrap.sh +++ b/wsdb/bootstrap.sh @@ -14,8 +14,6 @@ function generate_ts_package { "$ROOT/ipc-codegen/src/generate.ts" \ --schema "$ROOT/barretenberg/cpp/src/barretenberg/wsdb/wsdb_schema.jsonc" \ --lang ts \ - --client \ - --out "$ROOT/wsdb/ts/src/generated" \ --package "$ROOT/wsdb/ts" \ --package-name @aztec-foundation/wsdb \ --binary-name "$WSDB_BINARY" \ @@ -50,7 +48,7 @@ function build { copy_native npm_install_deps yarn build - (cd ts && ./scripts/prepare_arch_packages.sh "$(arch)-$(os)=build/$(arch)-$(os)/$WSDB_BINARY") + (cd ts && npm run prepare_arch_packages -- "$(arch)-$(os)=build/$(arch)-$(os)/$WSDB_BINARY") } function clean { @@ -63,7 +61,7 @@ function release { copy_cross npm_install_deps yarn build - (cd ts && ./scripts/prepare_arch_packages.sh) + (cd ts && npm run prepare_arch_packages) for package_dir in ts/packages/*; do (cd "$package_dir" && retry "deploy_npm ${REF_NAME#v}") done