From 2933e08aec853e7797cc347b8df5a99932aea8f9 Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:07:21 +0000 Subject: [PATCH 01/26] feat(bb.js): generated bb.js-api package with an in-process wasm transport ipc-runtime gains a generic wasm FFI backend (@aztec-foundation/ipc-runtime/wasm) for any wasi reactor implementing the ipc-codegen FFI contract; ipc-codegen emits the FFI entry itself for --server --ffi (C++ and Rust) and a wasm transport for generated TS packages; bb gets a Warmup command and the generated bb_ffi.cpp replaces the hand-written entry; bb.js consumes the generated @aztec-foundation/bb.js-api package and drops its own wasm plumbing. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- barretenberg/cpp/src/barretenberg/bb/cli.cpp | 1 - .../cpp/src/barretenberg/bbapi/CMakeLists.txt | 12 +- .../cpp/src/barretenberg/bbapi/bb_schema.json | 4 + .../src/barretenberg/bbapi/bbapi_handlers.cpp | 41 ++ .../src/barretenberg/bbapi/bbapi_handlers.hpp | 1 + .../cpp/src/barretenberg/bbapi/c_bind.cpp | 42 +- .../cpp/src/barretenberg/bbapi/c_bind.hpp | 13 - .../bbapi/c_bind_exception.test.cpp | 9 +- barretenberg/ts/.gitignore | 4 +- barretenberg/ts/bb.js/.prettierignore | 4 - barretenberg/ts/bb.js/README.md | 23 +- barretenberg/ts/bb.js/bootstrap.sh | 12 +- barretenberg/ts/bb.js/eslint.config.js | 10 +- barretenberg/ts/bb.js/package.json | 8 +- .../ts/bb.js/scripts/browser_postprocess.sh | 8 - barretenberg/ts/bb.js/scripts/copy_wasm.sh | 24 - barretenberg/ts/bb.js/scripts/generate.sh | 21 - .../ts/bb.js/src/barretenberg/backend.ts | 2 +- .../ts/bb.js/src/barretenberg/index.ts | 32 +- .../src/barretenberg/poseidon.bench.test.ts | 8 - .../barretenberg-threads.wasm.gz | 1 - .../barretenberg_wasm/barretenberg.wasm.gz | 1 - .../barretenberg_wasm_base/index.ts | 118 ----- .../factory/browser/index.ts | 9 - .../factory/browser/main.worker.ts | 7 - .../factory/node/index.ts | 19 - .../factory/node/main.worker.ts | 11 - .../barretenberg_wasm_main/heap_allocator.ts | 72 --- .../barretenberg_wasm_main/index.ts | 264 ----------- .../factory/browser/index.ts | 9 - .../factory/browser/thread.worker.ts | 7 - .../factory/node/index.ts | 19 - .../factory/node/thread.worker.ts | 13 - .../barretenberg_wasm_thread/index.ts | 49 -- .../browser/barretenberg-threads.ts | 3 - .../fetch_code/browser/barretenberg.ts | 3 - .../fetch_code/browser/index.ts | 34 -- .../src/barretenberg_wasm/fetch_code/index.ts | 1 - .../fetch_code/node/index.ts | 33 -- .../fetch_code/wasm-module.d.ts | 4 - .../helpers/browser/index.ts | 55 --- .../src/barretenberg_wasm/helpers/index.ts | 1 - .../barretenberg_wasm/helpers/node/index.ts | 63 --- .../helpers/node/node_endpoint.ts | 28 -- .../bb.js/src/barretenberg_wasm/index.test.ts | 47 -- .../ts/bb.js/src/barretenberg_wasm/index.ts | 21 - .../ts/bb.js/src/bb_backends/browser/index.ts | 15 +- .../ts/bb.js/src/bb_backends/index.ts | 11 +- .../ts/bb.js/src/bb_backends/node/index.ts | 30 +- .../ts/bb.js/src/bb_backends/wasm.test.ts | 59 +++ barretenberg/ts/bb.js/src/bb_backends/wasm.ts | 108 ----- .../src/bbapi/exception_handling.test.ts | 8 +- barretenberg/ts/bb.js/src/index.ts | 6 +- barretenberg/ts/bootstrap.sh | 118 ++++- .../ts/codegen/bb_wasm_host_imports.ts | 19 + barretenberg/ts/package.json | 2 + barretenberg/ts/yarn.lock | 60 ++- ipc-codegen/README.md | 5 +- ipc-codegen/SCHEMA_SPEC.md | 23 + ipc-codegen/bootstrap.sh | 3 + ipc-codegen/echo_example/cpp/CMakeLists.txt | 14 +- ipc-codegen/echo_example/cpp/bootstrap.sh | 3 +- ipc-codegen/echo_example/cpp/src/echo_ffi.cpp | 15 + .../echo_example/cpp/src/echo_handlers.cpp | 52 +++ .../echo_example/cpp/src/echo_handlers.hpp | 32 ++ .../echo_example/cpp/src/echo_server.cpp | 56 +-- ipc-codegen/echo_example/cpp/src/ffi_test.cpp | 117 +++++ ipc-codegen/echo_example/rust/Cargo.toml | 4 +- ipc-codegen/echo_example/rust/bootstrap.sh | 5 +- .../echo_example/rust/src/echo_server.rs | 41 +- ipc-codegen/echo_example/rust/src/handler.rs | 44 ++ ipc-codegen/echo_example/rust/src/lib.rs | 10 + .../echo_example/rust/tests/ffi_roundtrip.rs | 28 ++ ipc-codegen/src/cpp_codegen.ts | 83 ++++ ipc-codegen/src/generate.ts | 108 ++++- ipc-codegen/src/rust_codegen.ts | 104 ++++- ipc-codegen/src/typescript_package_codegen.ts | 326 ++++++++++++- ipc-runtime/README.md | 21 + ipc-runtime/ts/package.json | 5 + ipc-runtime/ts/src/wasm/backend.ts | 431 ++++++++++++++++++ ipc-runtime/ts/src/wasm/host.ts | 209 +++++++++ ipc-runtime/ts/src/wasm/index.browser.ts | 66 +++ ipc-runtime/ts/src/wasm/index.node.ts | 59 +++ .../ts/src/wasm/main.worker.browser.ts | 16 + ipc-runtime/ts/src/wasm/main.worker.node.ts | 10 + ipc-runtime/ts/src/wasm/main_worker.ts | 82 ++++ ipc-runtime/ts/src/wasm/module_source.ts | 125 +++++ ipc-runtime/ts/src/wasm/platform.browser.ts | 49 ++ ipc-runtime/ts/src/wasm/platform.node.ts | 66 +++ ipc-runtime/ts/src/wasm/platform.ts | 28 ++ .../ts/src/wasm/thread.worker.browser.ts | 5 + ipc-runtime/ts/src/wasm/thread.worker.node.ts | 5 + ipc-runtime/ts/src/wasm/thread_worker.ts | 56 +++ ipc-runtime/ts/src/wasm/wasi_shim.ts | 154 +++++++ ipc-runtime/ts/src/wasm_module_source.test.ts | 54 +++ ipc-runtime/ts/tsconfig.json | 2 +- 96 files changed, 2804 insertions(+), 1319 deletions(-) delete mode 100644 barretenberg/cpp/src/barretenberg/bbapi/c_bind.hpp delete mode 100755 barretenberg/ts/bb.js/scripts/copy_wasm.sh delete mode 100755 barretenberg/ts/bb.js/scripts/generate.sh delete mode 120000 barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg-threads.wasm.gz delete mode 120000 barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg.wasm.gz delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_base/index.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/factory/browser/index.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/factory/browser/main.worker.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/factory/node/index.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/factory/node/main.worker.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/heap_allocator.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_main/index.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_thread/factory/browser/index.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_thread/factory/browser/thread.worker.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_thread/factory/node/index.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_thread/factory/node/thread.worker.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/barretenberg_wasm_thread/index.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/browser/barretenberg-threads.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/browser/barretenberg.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/browser/index.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/index.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/node/index.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/fetch_code/wasm-module.d.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/helpers/browser/index.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/helpers/index.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/helpers/node/index.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/helpers/node/node_endpoint.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/index.test.ts delete mode 100644 barretenberg/ts/bb.js/src/barretenberg_wasm/index.ts create mode 100644 barretenberg/ts/bb.js/src/bb_backends/wasm.test.ts delete mode 100644 barretenberg/ts/bb.js/src/bb_backends/wasm.ts create mode 100644 barretenberg/ts/codegen/bb_wasm_host_imports.ts create mode 100644 ipc-codegen/echo_example/cpp/src/echo_ffi.cpp create mode 100644 ipc-codegen/echo_example/cpp/src/echo_handlers.cpp create mode 100644 ipc-codegen/echo_example/cpp/src/echo_handlers.hpp create mode 100644 ipc-codegen/echo_example/cpp/src/ffi_test.cpp create mode 100644 ipc-codegen/echo_example/rust/src/handler.rs create mode 100644 ipc-codegen/echo_example/rust/tests/ffi_roundtrip.rs create mode 100644 ipc-runtime/ts/src/wasm/backend.ts create mode 100644 ipc-runtime/ts/src/wasm/host.ts create mode 100644 ipc-runtime/ts/src/wasm/index.browser.ts create mode 100644 ipc-runtime/ts/src/wasm/index.node.ts create mode 100644 ipc-runtime/ts/src/wasm/main.worker.browser.ts create mode 100644 ipc-runtime/ts/src/wasm/main.worker.node.ts create mode 100644 ipc-runtime/ts/src/wasm/main_worker.ts create mode 100644 ipc-runtime/ts/src/wasm/module_source.ts create mode 100644 ipc-runtime/ts/src/wasm/platform.browser.ts create mode 100644 ipc-runtime/ts/src/wasm/platform.node.ts create mode 100644 ipc-runtime/ts/src/wasm/platform.ts create mode 100644 ipc-runtime/ts/src/wasm/thread.worker.browser.ts create mode 100644 ipc-runtime/ts/src/wasm/thread.worker.node.ts create mode 100644 ipc-runtime/ts/src/wasm/thread_worker.ts create mode 100644 ipc-runtime/ts/src/wasm/wasi_shim.ts create mode 100644 ipc-runtime/ts/src/wasm_module_source.test.ts 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..087fd7017409 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 (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_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..3d737a360762 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 (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..587d9397af66 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" @@ -74,6 +74,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/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..5241995305c8 100644 --- a/barretenberg/ts/bb.js/README.md +++ b/barretenberg/ts/bb.js/README.md @@ -44,6 +44,17 @@ 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) is generated from bb's schema into the +`@aztec-foundation/bb.js-api` package by ipc-codegen; bb.js adds the facades, backend selection and CRS +handling. That package also ships bb's wasm modules (single-thread and threads builds) and runs them +in-process through `@aztec-foundation/ipc-runtime/wasm`: the module in a worker, wasi threads on further +workers, `WebAssembly.compileStreaming` for loading (so browsers that cache compiled code start warm on a +repeat visit). Pass `wasmPath` (or set `BB_WASM_PATH` in node) to run another build of the module, and +`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. + ### 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 +64,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 +98,11 @@ 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. +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..d49729057725 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 { @@ -29,12 +28,13 @@ function build { echo_header "bb.js build" prepare_project yarn formatting + # The wasm modules and bb binary bb.js runs at test time ship in bb.js-api; stage them + # whether or not bb.js's own build is cached. + (cd .. && ./bootstrap.sh build_bb_js_api) 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 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..46ac1f354f6a 100644 --- a/barretenberg/ts/bb.js/package.json +++ b/barretenberg/ts/bb.js/package.json @@ -26,14 +26,12 @@ "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", @@ -70,8 +68,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/copy_wasm.sh b/barretenberg/ts/bb.js/scripts/copy_wasm.sh deleted file mode 100755 index 41099fe7c199..000000000000 --- a/barretenberg/ts/bb.js/scripts/copy_wasm.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/sh -# Builds the wasm and copies it into it's location in dest. -# If you want to build the wasm with debug info for stack traces, use NO_STRIP=1 BUILD_CPP=1. -set -e - -cd $(dirname $0)/.. - -if [ "${BUILD_CPP:-0}" -eq 1 ]; then - parallel --line-buffered --tag '../../cpp/bootstrap.sh {}' ::: build_wasm build_wasm_threads -fi - -# Copy the wasm to its home in the bb.js dest folder. -# We only need the threads wasm, as node always uses threads. -# We need to take two copies for both esm and cjs builds. You can't use symlinks when publishing. -# This probably isn't a big deal however due to compression. -# When building the browser bundle, both wasms are inlined directly. -mkdir -p ./dest/node/barretenberg_wasm -mkdir -p ./dest/node-cjs/barretenberg_wasm -mkdir -p ./dest/browser/barretenberg_wasm - -cp ../../cpp/build-wasm-threads/bin/barretenberg.wasm.gz ./dest/node/barretenberg_wasm/barretenberg-threads.wasm.gz -cp ../../cpp/build-wasm-threads/bin/barretenberg.wasm.gz ./dest/node-cjs/barretenberg_wasm/barretenberg-threads.wasm.gz -cp ../../cpp/build-wasm-threads/bin/barretenberg.wasm.gz ./dest/browser/barretenberg_wasm/barretenberg-threads.wasm.gz -cp ../../cpp/build-wasm/bin/barretenberg.wasm.gz ./dest/browser/barretenberg_wasm/barretenberg.wasm.gz diff --git a/barretenberg/ts/bb.js/scripts/generate.sh b/barretenberg/ts/bb.js/scripts/generate.sh deleted file mode 100755 index 5bb35ba4ca95..000000000000 --- a/barretenberg/ts/bb.js/scripts/generate.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -# Generate bb.js's TypeScript client from the checked-in bb schema, via -# ipc-codegen. Other languages generate their own: see -# barretenberg/rust/bootstrap.sh for the Rust crate. -set -euo pipefail - -cd "$(dirname "$0")/.." -ROOT=$(git rev-parse --show-toplevel) -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 \ - --client \ - --strip-method-prefix \ - --strip-type-prefix \ - --out src/generated \ - --curve-constants "$BBAPI/bb_curve_constants.json" diff --git a/barretenberg/ts/bb.js/src/barretenberg/backend.ts b/barretenberg/ts/bb.js/src/barretenberg/backend.ts index bd622e087e61..d6006952d74d 100644 --- a/barretenberg/ts/bb.js/src/barretenberg/backend.ts +++ b/barretenberg/ts/bb.js/src/barretenberg/backend.ts @@ -1,8 +1,8 @@ +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 type { Barretenberg } from './index.js'; diff --git a/barretenberg/ts/bb.js/src/barretenberg/index.ts b/barretenberg/ts/bb.js/src/barretenberg/index.ts index 44a6209fa0dd..47f90057320e 100644 --- a/barretenberg/ts/bb.js/src/barretenberg/index.ts +++ b/barretenberg/ts/bb.js/src/barretenberg/index.ts @@ -1,10 +1,10 @@ +import { AsyncApi, SyncApi } from '@aztec-foundation/bb.js-api'; + 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 { 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. @@ -54,11 +54,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 +66,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); 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_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 index 730d6594270f..5dca23680ff1 100644 --- a/barretenberg/ts/bb.js/src/bb_backends/browser/index.ts +++ b/barretenberg/ts/bb.js/src/bb_backends/browser/index.ts @@ -1,6 +1,7 @@ +import { createWasmBackend, createWasmBackendSync } from '@aztec-foundation/bb.js-api'; + 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) @@ -13,14 +14,14 @@ export async function createAsyncBackend( switch (type) { case BackendType.Wasm: case BackendType.WasmWorker: { - const useWorker = type === BackendType.WasmWorker; - logger(`Using WASM backend (worker: ${useWorker})`); - return await BarretenbergWasmAsyncBackend.new({ + const worker = type === BackendType.WasmWorker; + logger(`Using WASM backend (worker: ${worker})`); + return await createWasmBackend({ threads: options.threads, - wasmPath: options.wasmPath, + module: options.wasmPath, logger, memory: options.memory, - useWorker, + worker, }); } @@ -40,7 +41,7 @@ export async function createSyncBackend( switch (type) { case BackendType.Wasm: { logger('Using WASM backend'); - return await BarretenbergWasmSyncBackend.new(options.wasmPath, logger); + return await createWasmBackendSync({ module: options.wasmPath, logger, memory: options.memory }); } default: diff --git a/barretenberg/ts/bb.js/src/bb_backends/index.ts b/barretenberg/ts/bb.js/src/bb_backends/index.ts index 760f51fe9da0..5203fe0a716f 100644 --- a/barretenberg/ts/bb.js/src/bb_backends/index.ts +++ b/barretenberg/ts/bb.js/src/bb_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/bb_backends/node/index.ts b/barretenberg/ts/bb.js/src/bb_backends/node/index.ts index b19b1ba43495..daf308a90e17 100644 --- a/barretenberg/ts/bb.js/src/bb_backends/node/index.ts +++ b/barretenberg/ts/bb.js/src/bb_backends/node/index.ts @@ -1,6 +1,7 @@ +import { createWasmBackend, createWasmBackendSync } from '@aztec-foundation/bb.js-api'; + 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'; @@ -46,16 +47,21 @@ export async function createAsyncBackend( case BackendType.Wasm: case BackendType.WasmWorker: { - const useWorker = type === BackendType.WasmWorker; - logger(`Using WASM backend (worker: ${useWorker})`); - return await BarretenbergWasmAsyncBackend.new({ + // 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})`); + const backend = await createWasmBackend({ threads: options.threads, - wasmPath: options.wasmPath, + module: options.wasmPath, logger: options.logger, memory: options.memory, - useWorker, - unref: options.unref, + worker, }); + if (options.unref) { + backend.unref(); + } + return backend; } default: @@ -94,7 +100,15 @@ export async function createSyncBackend( case BackendType.Wasm: { logger('Using WASM backend'); - return await BarretenbergWasmSyncBackend.new(options.wasmPath, logger); + const backend = await createWasmBackendSync({ + module: options.wasmPath, + logger: options.logger, + memory: options.memory, + }); + if (options.unref) { + backend.unref(); + } + return backend; } default: diff --git a/barretenberg/ts/bb.js/src/bb_backends/wasm.test.ts b/barretenberg/ts/bb.js/src/bb_backends/wasm.test.ts new file mode 100644 index 000000000000..f88a3910d35b --- /dev/null +++ b/barretenberg/ts/bb.js/src/bb_backends/wasm.test.ts @@ -0,0 +1,59 @@ +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', 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/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 index 3c90963fd11c..abcfbc06dae3 100644 --- a/barretenberg/ts/bb.js/src/bbapi/exception_handling.test.ts +++ b/barretenberg/ts/bb.js/src/bbapi/exception_handling.test.ts @@ -1,12 +1,12 @@ -import { BarretenbergWasmSyncBackend } from '../bb_backends/wasm.js'; -import { SyncApi } from '../generated/sync.js'; +import { SyncApi, createWasmBackendSync } from '@aztec-foundation/bb.js-api'; +import type { WasmFfiBackendSync } from '@aztec-foundation/ipc-runtime/wasm'; describe('BBApi Exception Handling from bb.js', () => { - let backend: BarretenbergWasmSyncBackend; + let backend: WasmFfiBackendSync; let api: SyncApi; beforeAll(async () => { - backend = await BarretenbergWasmSyncBackend.new(); + backend = await createWasmBackendSync(); api = new SyncApi(backend); }, 60000); diff --git a/barretenberg/ts/bb.js/src/index.ts b/barretenberg/ts/bb.js/src/index.ts index 571793f8c41e..c7e3f96e627a 100644 --- a/barretenberg/ts/bb.js/src/index.ts +++ b/barretenberg/ts/bb.js/src/index.ts @@ -28,9 +28,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). @@ -55,6 +55,6 @@ export { SECP256R1_FR_MODULUS, SECP256R1_FQ_MODULUS, SECP256R1_G1_GENERATOR, -} from './generated/curve_constants.js'; +} from '@aztec-foundation/bb.js-api'; export { findBbBinary, findNapiBinary } from './bb_backends/node/platform.js'; diff --git a/barretenberg/ts/bootstrap.sh b/barretenberg/ts/bootstrap.sh index b4089bf0fbb5..662f34ea8ceb 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) \ @@ -49,12 +50,91 @@ function generate_cdb_package { --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 \ + --client \ + --out "$ROOT/barretenberg/ts/bb.js-api/src/generated" \ + --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 \ + --curve-constants "$bbapi/bb_curve_constants.json" \ + --package-transports uds,shm,wasm \ + --package-ipc-path-args 'msgpack,run,--input,{path}' \ + --package-wasm-module barretenberg.wasm.gz \ + --package-wasm-threads-module barretenberg-threads.wasm.gz \ + --package-wasm-host-imports "$ROOT/barretenberg/ts/codegen/bb_wasm_host_imports.ts" +} + +# 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). +function copy_bb_js_api_wasm { + mkdir -p bb.js-api/wasm + cp "$ROOT/barretenberg/cpp/build-wasm-threads/bin/barretenberg.wasm.gz" bb.js-api/wasm/barretenberg-threads.wasm.gz + cp "$ROOT/barretenberg/cpp/build-wasm/bin/barretenberg.wasm.gz" bb.js-api/wasm/barretenberg.wasm.gz +} + +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 { + (cd bb.js-api && ./scripts/prepare_arch_packages.sh "$@") +} + +# 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 { @@ -173,11 +253,18 @@ function cross_copy_bb_avm_sim { } function cross_copy { + cross_copy_bb_js_api "$@" cross_copy_bb_js "$@" } 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 +296,31 @@ 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). +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 + 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 +328,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/codegen/bb_wasm_host_imports.ts b/barretenberg/ts/codegen/bb_wasm_host_imports.ts new file mode 100644 index 000000000000..1e4483caffe6 --- /dev/null +++ b/barretenberg/ts/codegen/bb_wasm_host_imports.ts @@ -0,0 +1,19 @@ +import type { HostImportsFactory } from "@aztec-foundation/ipc-runtime/wasm"; + +/** + * bb's wasm platform layer imports three functions of its own beyond WASI: a logger, an abort hook + * and the thread count (barretenberg/cpp/src/barretenberg/env, WASM_IMPORT). Copied into the + * generated bb.js-api package as src/wasm_host_imports.ts. + */ +export const hostImports: HostImportsFactory = (ctx) => ({ + env: { + logstr: (ptr: number) => { + const mib = (ctx.memory().buffer.byteLength / (1024 * 1024)).toFixed(2); + ctx.logger(`${ctx.readCString(ptr)} (mem: ${mib}MiB)`); + }, + throw_or_abort_impl: (ptr: number) => { + throw new Error(ctx.readCString(ptr)); + }, + env_hardware_concurrency: () => ctx.threads, + }, +}); 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..15e7644e150c 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" @@ -2694,13 +2745,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..67214cc49613 100644 --- a/ipc-codegen/README.md +++ b/ipc-codegen/README.md @@ -104,7 +104,10 @@ node --experimental-strip-types --experimental-transform-types --no-warnings \ | `--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. | | `--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): adds the `ffi_backend` template, 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`/`ipc_ffi_alloc`/`ipc_ffi_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. 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 diff --git a/ipc-codegen/SCHEMA_SPEC.md b/ipc-codegen/SCHEMA_SPEC.md index d4f1ddabe05e..995c91befa68 100644 --- a/ipc-codegen/SCHEMA_SPEC.md +++ b/ipc-codegen/SCHEMA_SPEC.md @@ -212,6 +212,29 @@ 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); +``` + +`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 Rust/Zig `ffi_backend` +templates 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). + ## 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..64e10e588854 100755 --- a/ipc-codegen/bootstrap.sh +++ b/ipc-codegen/bootstrap.sh @@ -55,6 +55,9 @@ 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" 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..24862bc7c50b --- /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 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..be046c498447 --- /dev/null +++ b/ipc-codegen/echo_example/cpp/src/ffi_test.cpp @@ -0,0 +1,117 @@ +// In-process FFI conformance test (C++): drives the generated 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 +// ipc_ffi_alloc, receiving the response in one it frees with ipc_ffi_free. +std::vector call(const std::vector &request) { + auto *in = static_cast(ipc_ffi_alloc(request.size())); + std::copy(request.begin(), request.end(), in); + uint8_t *out = nullptr; + size_t out_len = 0; + ipc_ffi_entry(in, request.size(), &out, &out_len); + ipc_ffi_free(in); + std::vector response(out, out + out_len); + 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..458375d0b8de 100644 --- a/ipc-codegen/echo_example/rust/Cargo.toml +++ b/ipc-codegen/echo_example/rust/Cargo.toml @@ -14,8 +14,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..2ab81dfd78dc 100755 --- a/ipc-codegen/echo_example/rust/bootstrap.sh +++ b/ipc-codegen/echo_example/rust/bootstrap.sh @@ -15,5 +15,6 @@ $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) 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/src/cpp_codegen.ts b/ipc-codegen/src/cpp_codegen.ts index c1df1e42ca61..ae808036462a 100644 --- a/ipc-codegen/src/cpp_codegen.ts +++ b/ipc-codegen/src/cpp_codegen.ts @@ -779,6 +779,89 @@ 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. + */ + generateFfiHeader(): string { + const { namespace: ns, prefix } = this.opts; + const dispatchHeader = `${toSnakeCase(prefix)}_dispatch.hpp`; + + return `// AUTOGENERATED FILE - DO NOT EDIT +// In-process FFI entry for ${prefix}: the ipc-codegen FFI backend contract. +#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 +// ipc_ffi_alloc(), which the caller releases with ipc_ffi_free(). +IPC_FFI_EXPORT void ipc_ffi_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* ipc_ffi_alloc(size_t size); +IPC_FFI_EXPORT void ipc_ffi_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`; + + 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* ipc_ffi_alloc(size_t size) +{ + // NOLINTNEXTLINE(cppcoreguidelines-no-malloc) + return std::malloc(size == 0 ? 1 : size); +} + +IPC_FFI_EXPORT void ipc_ffi_free(void* ptr) +{ + // NOLINTNEXTLINE(cppcoreguidelines-no-malloc) + std::free(ptr); +} + +IPC_FFI_EXPORT void ipc_ffi_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(ipc_ffi_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..ded2643e6829 100644 --- a/ipc-codegen/src/generate.ts +++ b/ipc-codegen/src/generate.ts @@ -65,6 +65,9 @@ interface Args { binaryEnvVar: string; packageTransports: string; packageIpcPathArgs: string; + packageWasmModule: string; + packageWasmThreadsModule: string; + packageWasmHostImports: string; ipcRuntimeDependency: string; cppNamespace: string; cppWireNamespace: string; @@ -94,9 +97,17 @@ 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 + --package-wasm-threads-module + wasm transport: the threads module, shipped in wasm/ + --package-wasm-host-imports + wasm transport: TS module copied to src/wasm_host_imports.ts + supplying the module's imports beyond WASI (default: none) --ipc-runtime-dependency package.json dependency spec for @aztec-foundation/ipc-runtime --prefix Type prefix (auto-detected when >= 2 commands share one) @@ -106,7 +117,9 @@ 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) @@ -128,6 +141,9 @@ function parseArgs(argv: string[]): Args { binaryEnvVar: "", packageTransports: "uds", packageIpcPathArgs: "--socket,{path}", + packageWasmModule: "", + packageWasmThreadsModule: "", + packageWasmHostImports: "", ipcRuntimeDependency: "@aztec-foundation/ipc-runtime", cppNamespace: "", cppWireNamespace: "wire", @@ -186,6 +202,15 @@ 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 "--package-wasm-host-imports": + args.packageWasmHostImports = takeValue(); + break; case "--ipc-runtime-dependency": args.ipcRuntimeDependency = takeValue(); break; @@ -226,13 +251,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; } @@ -429,20 +467,35 @@ 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, + curveConstants: !!args.curveConstants, }); writePackage("package.json", packageGen.generatePackageJson()); writePackage("tsconfig.json", packageGen.generateTsconfig()); @@ -452,6 +505,28 @@ function generate(args: Args) { if (binaryName) { writePackage("src/bin.ts", packageGen.generateBin()); } + if (wasm) { + writePackage("src/browser.ts", packageGen.generateBrowserIndex()); + writePackage("src/wasm.ts", packageGen.generateWasm()); + writePackage( + "src/wasm/thread.worker.ts", + packageGen.generateThreadWorker(), + ); + writePackage( + "src/wasm/main.worker.ts", + packageGen.generateMainWorker(), + ); + writePackage( + "src/wasm/main.worker.browser.ts", + packageGen.generateBrowserMainWorker(), + ); + writePackage( + "src/wasm_host_imports.ts", + args.packageWasmHostImports + ? readFileSync(resolve(args.packageWasmHostImports), "utf-8") + : packageGen.generateDefaultHostImports(), + ); + } for (const manifest of packageGen.generateArchPackageManifests()) { writePackage(manifest.path, manifest.content); } @@ -479,6 +554,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( @@ -565,6 +643,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( diff --git a/ipc-codegen/src/rust_codegen.ts b/ipc-codegen/src/rust_codegen.ts index 876a533df3c5..cce95a778d44 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,106 @@ pub fn handle_request(handler: &mut dyn Handler, request_bytes: &[u8]) -> Vec *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 ipc_ffi_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 ipc_ffi_alloc(size: usize) -> *mut u8 { + use $ffi as ffi; + ffi::ffi_alloc(size) + } + + #[unsafe(no_mangle)] + pub unsafe extern "C" fn ipc_ffi_free(ptr: *mut u8) { + use $ffi as ffi; + unsafe { ffi::ffi_free(ptr) }; + } + }; +} `; } } diff --git a/ipc-codegen/src/typescript_package_codegen.ts b/ipc-codegen/src/typescript_package_codegen.ts index 7bed8586850e..e7927f256542 100644 --- a/ipc-codegen/src/typescript_package_codegen.ts +++ b/ipc-codegen/src/typescript_package_codegen.ts @@ -8,6 +8,12 @@ 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; + /** Whether generated/curve_constants.ts is emitted alongside (re-exported from the entries). */ + curveConstants?: boolean; } function className(prefix: string): string { @@ -22,6 +28,10 @@ function optionsType(prefix: string): string { return `${prefix}ServiceOptions`; } +function wasmOptionsType(prefix: string): string { + return `${prefix}WasmOptions`; +} + function binaryFinderName(prefix: string): string { return `find${prefix}Binary`; } @@ -177,6 +187,21 @@ runs \`yarn build\`. 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 generatedExports(): string { + return `export * from './generated/api_types.js'; +export { AsyncApi } from './generated/async.js'; +export { SyncApi } from './generated/sync.js'; +${this.opts.curveConstants ? "export * from './generated/curve_constants.js';\n" : ""}`; + } + generatePackageJson(): string { const archPackages = archPackageNames(this.opts.packageName); const scripts: Record = { @@ -196,13 +221,27 @@ 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", exports: { ".": { types: "./dest/index.d.ts", + // Bundlers pick the browser entry, which has no process transport. + ...(this.wasm ? { browser: "./dest/browser.js" } : {}), default: "./dest/index.js", }, + ...(this.wasm + ? { + "./browser": { + types: "./dest/browser.d.ts", + default: "./dest/browser.js", + }, + } + : {}), }, - files: ["dest/", "README.md"], + files: ["dest/", ...(this.wasm ? ["wasm/"] : []), "README.md"], scripts, dependencies: { "@aztec-foundation/ipc-runtime": this.opts.ipcRuntimeDependency, @@ -279,21 +318,20 @@ process.exit(result.status ?? 1); 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 supportsShm = this.processTransports.includes("shm"); + const transports = this.processTransports.map((t) => `'${t}'`).join(" | "); const ipcPathArgs = JSON.stringify(this.opts.ipcPathArgs); - const defaultTransport = this.opts.transports.includes("uds") + const defaultTransport = this.processTransports.includes("uds") ? "uds" - : this.opts.transports[0]!; + : this.processTransports[0]!; + const wasm = this.wasm; + const wasmOptions = wasmOptionsType(prefix); - return `import { IpcSpawnError, SpawnedProcessBackend } from '@aztec-foundation/ipc-runtime'; -import { AsyncApi, type IpcErrorFactory } from './generated/async.js'; + return `import { type IpcClientAsync, IpcSpawnError, SpawnedProcessBackend } from '@aztec-foundation/ipc-runtime'; +${wasm ? "import { type WasmFfiBackend, platform } from '@aztec-foundation/ipc-runtime/wasm';\n" : ""}import { AsyncApi, type IpcErrorFactory } from './generated/async.js'; import { ${findBinary} } from './platform.js'; - -export * from './generated/api_types.js'; -export { AsyncApi } from './generated/async.js'; -export { SyncApi } from './generated/sync.js'; - +${wasm ? `import { type ${wasmOptions}, createWasmBackendWith } from './wasm.js';\n` : ""} +${this.generatedExports()}${wasm ? "export * from './wasm.js';\n" : ""} export type ${serviceTransport} = ${transports}; export interface ${serviceOptions} { @@ -312,7 +350,25 @@ export interface ${serviceOptions} { */ respawn?: boolean; ${supportsShm ? " napiPath?: string;\n clientId?: number;\n" : ""}} - +${ + wasm + ? ` +/** + * Runs the ${this.opts.binaryName} wasm module in-process (node): the main instance in a worker + * thread by default, wasi threads on further workers. + */ +export function createWasmBackend(options: ${wasmOptions} = {}): Promise { + return createWasmBackendWith( + { + createMainWorker: () => platform.createWorker(new URL('./wasm/main.worker.js', import.meta.url)), + createThreadWorker: () => platform.createWorker(new URL('./wasm/thread.worker.js', import.meta.url)), + }, + options, + ); +} +` + : "" +} /** * Spawns and talks to a '${this.opts.binaryName}' server process. Process * lifecycle — connectivity, death detection, optional respawn, teardown — is @@ -321,8 +377,12 @@ ${supportsShm ? " napiPath?: string;\n clientId?: number;\n" : ""}} * the failure was environmental and the operation may be retried. */ export class ${serviceClass} extends AsyncApi { - private constructor(private spawnedBackend: SpawnedProcessBackend, createError?: IpcErrorFactory) { - super(spawnedBackend, createError); + private constructor( + backend: IpcClientAsync, + private readonly spawnedBackend?: SpawnedProcessBackend, + createError?: IpcErrorFactory, + ) { + super(backend, createError); } static async spawn(options: ${serviceOptions} = {}): Promise<${serviceClass}> { @@ -342,20 +402,217 @@ export class ${serviceClass} extends AsyncApi { extraArgs: options.extraArgs, respawn: options.respawn, ${supportsShm ? " clientId: options.clientId,\n napiPath: options.napiPath,\n" : ""} }); - return new ${serviceClass}(backend, options.createError); + return new ${serviceClass}(backend, backend, options.createError); } - +${ + wasm + ? ` + /** The service over the in-process wasm module instead of a spawned process. */ + static async wasm(options: ${wasmOptions} = {}): Promise<${serviceClass}> { + return new ${serviceClass}(await createWasmBackend(options), undefined, options.createError); + } +` + : "" +} getIpcPath(): string { - return this.spawnedBackend.getIpcPath(); + return this.requireSpawned().getIpcPath(); } sendProcessSignal(signal: NodeJS.Signals): void { - this.spawnedBackend.sendProcessSignal(signal); + this.requireSpawned().sendProcessSignal(signal); + } + + private requireSpawned(): SpawnedProcessBackend { + if (!this.spawnedBackend) { + throw new Error('${serviceClass}: not backed by a spawned process'); + } + return this.spawnedBackend; + } +} +`; + } + + /** Browser entry: the service runs in-process as a wasm module; there is no process to spawn. */ + generateBrowserIndex(): string { + const prefix = this.opts.prefix; + const serviceClass = className(prefix); + const wasmOptions = wasmOptionsType(prefix); + + return `import type { IpcClientAsync } from '@aztec-foundation/ipc-runtime'; +import { type WasmFfiBackend, workerHandle } from '@aztec-foundation/ipc-runtime/wasm'; +import { AsyncApi, type IpcErrorFactory } from './generated/async.js'; +import { type ${wasmOptions}, createWasmBackendWith } from './wasm.js'; + +${this.generatedExports()}export * from './wasm.js'; + +/** + * Runs the ${this.opts.binaryName} wasm module in-process: the main instance in a web worker by + * default, wasi threads on further workers when the page is cross-origin isolated (COOP/COEP). + * The worker scripts are spawned with the literal expression bundlers detect, so they ship as + * worker chunks of the consuming application. + */ +export function createWasmBackend(options: ${wasmOptions} = {}): Promise { + return createWasmBackendWith( + { + createMainWorker: () => + workerHandle(new Worker(new URL('./wasm/main.worker.browser.js', import.meta.url), { type: 'module' })), + createThreadWorker: () => + workerHandle(new Worker(new URL('./wasm/thread.worker.js', import.meta.url), { type: 'module' })), + }, + options, + ); +} + +export class ${serviceClass} extends AsyncApi { + private constructor(backend: IpcClientAsync, createError?: IpcErrorFactory) { + super(backend, createError); + } + + /** The service over the in-process wasm module. */ + static async wasm(options: ${wasmOptions} = {}): Promise<${serviceClass}> { + return new ${serviceClass}(await createWasmBackend(options), options.createError); } } `; } + /** Platform-neutral part of the wasm transport: options, module selection, backend construction. */ + generateWasm(): string { + const prefix = this.opts.prefix; + const wasmOptions = wasmOptionsType(prefix); + const moduleUrl = (name: string | undefined) => + name ? `new URL('../wasm/${name}', import.meta.url)` : "undefined"; + + return `import { + type WasmFfiBackend, + type WasmFfiBackendSync, + type WasmModuleSource, + type WorkerHandle, + createWasmFfiBackend, + createWasmFfiBackendSync, + platform, + sharedMemoryAvailable, +} from '@aztec-foundation/ipc-runtime/wasm'; +import type { IpcErrorFactory } from './generated/async.js'; +import { hostImports } from './wasm_host_imports.js'; + +/** Options for running the ${this.opts.binaryName} wasm module in-process. */ +export interface ${wasmOptions} { + /** 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. */ + memory?: { initial?: number; maximum?: number }; + /** + * The module to run instead of the package's own: a URL or path, gzipped or raw bytes, a fetch + * Response, or a compiled WebAssembly.Module. + */ + module?: WasmModuleSource; + /** Run the module in a dedicated worker (default true) so a call never blocks the caller's thread. */ + worker?: boolean; + /** WASI environ for the module. */ + env?: Record; + logger?: (msg: string) => void; + createError?: IpcErrorFactory; +} + +/** Worker factories a platform entry binds (see WasmFfiBinding in ipc-runtime for why factories). */ +export interface WasmWorkers { + createMainWorker: () => WorkerHandle; + createThreadWorker: () => WorkerHandle; +} + +const THREADS_MODULE: URL | undefined = ${moduleUrl(this.opts.wasmThreadsModule)}; +const SINGLE_MODULE: URL | undefined = ${moduleUrl(this.opts.wasmModule)}; + +/** + * The package's own module for a thread count: the threads build when more than one thread is + * wanted and a shared memory is available, otherwise the single-thread build (each falls back to + * the other when the package ships only one). + */ +export function defaultWasmModule(threads: number): URL { + const threaded = threads > 1 && sharedMemoryAvailable(); + const module = threaded ? (THREADS_MODULE ?? SINGLE_MODULE) : (SINGLE_MODULE ?? THREADS_MODULE); + if (!module) { + throw new Error('${this.opts.packageName}: no wasm module ships with this package'); + } + return module; +} + +export function createWasmBackendWith(workers: WasmWorkers, options: ${wasmOptions} = {}): Promise { + const threads = options.threads ?? platform.hardwareConcurrency(); + return createWasmFfiBackend({ + module: options.module ?? defaultWasmModule(threads), + threads, + memory: options.memory, + env: options.env, + logger: options.logger, + worker: options.worker, + hostImports, + createMainWorker: workers.createMainWorker, + createThreadWorker: workers.createThreadWorker, + }); +} + +/** The module on the calling thread with one thread: every call blocks until it returns. */ +export function createWasmBackendSync(options: ${wasmOptions} = {}): Promise { + return createWasmFfiBackendSync({ + module: options.module ?? defaultWasmModule(1), + threads: 1, + memory: options.memory, + env: options.env, + logger: options.logger, + hostImports, + }); +} +`; + } + + /** wasi-threads worker: one module instance per thread. Platform-neutral (it spawns nothing). */ + generateThreadWorker(): string { + return `import { runThreadWorker, workerSide } from '@aztec-foundation/ipc-runtime/wasm'; +import { hostImports } from '../wasm_host_imports.js'; + +runThreadWorker(workerSide(), { hostImports }); +`; + } + + /** Main-instance worker for node. */ + generateMainWorker(): string { + return `import { platform, runMainWorker, workerSide } from '@aztec-foundation/ipc-runtime/wasm'; +import { hostImports } from '../wasm_host_imports.js'; + +runMainWorker(workerSide(), platform, { + hostImports, + createThreadWorker: () => platform.createWorker(new URL('./thread.worker.js', import.meta.url)), +}); +`; + } + + /** Main-instance worker for browsers: spawns thread workers with the expression bundlers detect. */ + generateBrowserMainWorker(): string { + return `import { platform, runMainWorker, workerHandle, workerSide } from '@aztec-foundation/ipc-runtime/wasm'; +import { hostImports } from '../wasm_host_imports.js'; + +runMainWorker(workerSide(), platform, { + hostImports, + createThreadWorker: () => + workerHandle(new Worker(new URL('./thread.worker.js', import.meta.url), { type: 'module' })), +}); +`; + } + + /** Placeholder for a module that needs nothing beyond WASI (--package-wasm-host-imports replaces it). */ + generateDefaultHostImports(): string { + return `import type { HostImportsFactory } from '@aztec-foundation/ipc-runtime/wasm'; + +/** + * Imports the module needs beyond WASI and wasi-threads. The FFI contract needs none; a module + * whose platform layer imports its own hooks ships them via --package-wasm-host-imports. + */ +export const hostImports: HostImportsFactory | undefined = undefined; +`; + } + generatePlatform(): string { const packageName = this.opts.packageName; const findBinary = binaryFinderName(this.opts.prefix); @@ -495,14 +752,39 @@ done } generateReadme(): string { + const serviceClass = className(this.opts.prefix); + const wasmSection = this.wasm + ? ` +## In-process wasm + +The package also ships the service as a wasm module under \`wasm/\` and runs it +in-process through \`@aztec-foundation/ipc-runtime/wasm\`. In browsers (the +\`browser\` export condition) this is the only transport: + +\`\`\`ts +import { ${serviceClass} } from '${this.opts.packageName}'; + +const service = await ${serviceClass}.wasm({ threads: 4 }); +\`\`\` + +The main instance runs in a worker by default; wasi threads run on further +workers when a shared memory is available (node, or a browser page served with +COOP/COEP headers), otherwise the single-thread module is used. The worker +scripts and the module are referenced with \`new URL(..., import.meta.url)\`, so +bundlers emit them as chunks/assets of the application (Vite users: exclude the +package from \`optimizeDeps\`). \`createWasmBackendSync\` gives the synchronous, +single-thread form; the \`module\` option substitutes another build of the +module (a URL, bytes, a fetch Response or a compiled Module). +` + : ""; return `# ${this.opts.packageName} Generated TypeScript IPC package for the ${this.opts.prefix} service. \`\`\`ts -import { ${className(this.opts.prefix)} } from '${this.opts.packageName}'; +import { ${serviceClass} } from '${this.opts.packageName}'; -const service = await ${className(this.opts.prefix)}.spawn({ transport: 'uds' }); +const service = await ${serviceClass}.spawn({ transport: 'uds' }); try { const response = await service.bytes({ data: new Uint8Array([1, 2, 3]) }); } finally { @@ -512,7 +794,7 @@ try { The package resolves \`${this.opts.binaryName}\` from \`${this.opts.binaryEnvVar}\`, an explicit \`binaryPath\`, or an installed/prepared arch package. - +${wasmSection} ## Build The package shell (package.json, tsconfig, src/index.ts, scripts/) is diff --git a/ipc-runtime/README.md b/ipc-runtime/README.md index db519a3a7225..a123e5cf0fb8 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` / `ipc_ffi_alloc` / +`ipc_ffi_free`; 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..66e1a68a1155 100644 --- a/ipc-runtime/ts/package.json +++ b/ipc-runtime/ts/package.json @@ -9,6 +9,11 @@ ".": { "types": "./dest/index.d.ts", "import": "./dest/index.js" + }, + "./wasm": { + "types": "./dest/wasm/index.node.d.ts", + "browser": "./dest/wasm/index.browser.js", + "default": "./dest/wasm/index.node.js" } }, "scripts": { diff --git a/ipc-runtime/ts/src/wasm/backend.ts b/ipc-runtime/ts/src/wasm/backend.ts new file mode 100644 index 000000000000..b5cf3bb931df --- /dev/null +++ b/ipc-runtime/ts/src/wasm/backend.ts @@ -0,0 +1,431 @@ +import type { IpcClientAsync, IpcClientSync } from "../types.js"; +import { type HostImportsFactory, 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; + /** Module-specific imports beyond WASI/wasi-threads (see `HostImportsFactory`). */ + hostImports?: HostImportsFactory; + /** FFI entry export; default `ipc_ffi_entry`. */ + entry?: string; + /** Allocator export pairs to look for, in order of preference. */ + allocatorExports?: Array<[string, 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 (packages whose modules need `hostImports` ship their own). */ + createThreadWorker?: () => WorkerHandle; + createMainWorker?: () => WorkerHandle; +} + +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)}`)), + ); + }); +} + +/** `threads - 1` workers, each holding one instance of the module over the shared memory. */ +class ThreadPool { + private next = 0; + + private constructor(private readonly workers: WorkerHandle[]) {} + + static async create( + createWorker: () => WorkerHandle, + count: number, + init: Record, + logger: (msg: string) => void, + ): Promise { + const workers = Array.from({ length: count }, createWorker); + await Promise.all( + workers.map((w) => { + w.onMessage((msg) => { + if (msg?.type === "log") { + logger(msg.message); + } + }); + const ready = awaitReady(w, "wasm thread worker"); + w.postMessage({ type: "init", ...init }); + return ready; + }), + ); + return new ThreadPool(workers); + } + + /** wasi-threads: run the module's thread entry with `startArg` as thread `tid` on the next worker. */ + start(tid: number, startArg: number): void { + const worker = this.workers[this.next++ % this.workers.length]; + worker.postMessage({ type: "start", tid, startArg }); + } + + unref(): void { + for (const w of this.workers) { + w.unref(); + } + } + + async destroy(): Promise { + await Promise.all(this.workers.map((w) => w.terminate())); + } +} + +/** + * The module running on the current thread: memory, main instance, and the thread pool serving + * its `thread-spawn` requests. `call` is synchronous and blocks until the module returns. + */ +export class WasmFfiEngine { + private constructor( + private readonly host: WasmInstanceHost, + private readonly pool: ThreadPool | 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. + const shared = memory.buffer instanceof SharedArrayBuffer; + const threads = + shared && platform.sharedMemoryAvailable() ? wantThreads : 1; + const env = { + HARDWARE_CONCURRENCY: String(threads), + RAYON_NUM_THREADS: String(threads), + ...(opts.env ?? {}), + }; + logger( + `wasm: ${threads} thread(s), memory ${memory.buffer.byteLength >> 16} pages initial, shared=${shared}`, + ); + + const pool = + threads > 1 + ? await ThreadPool.create( + binding.createThreadWorker, + threads - 1, + { + module, + memory, + env, + entry: opts.entry, + allocatorExports: opts.allocatorExports, + }, + logger, + ) + : undefined; + + let nextTid = FIRST_THREAD_ID; + const host = await WasmInstanceHost.instantiate({ + module, + memory, + env, + logger, + hostImports: opts.hostImports, + entry: opts.entry, + allocatorExports: opts.allocatorExports, + threads, + spawnThread: (startArg) => { + if (!pool) { + return -1; + } + const tid = nextTid++; + pool.start(tid, startArg); + return tid; + }, + }); + return new WasmFfiEngine(host, pool, memory, threads); + } + + call(input: Uint8Array): Uint8Array { + return this.host.call(input); + } + + unref(): void { + this.pool?.unref(); + } + + async destroy(): Promise { + await this.pool?.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 + // shared with the worker; `hostImports` are functions and cannot cross the boundary, so a + // module needing them ships a worker entry that supplies them (see `runMainWorker`). + 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, + allocatorExports: opts.allocatorExports, + }, + }); + 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/host.ts b/ipc-runtime/ts/src/wasm/host.ts new file mode 100644 index 000000000000..c32f44207dc2 --- /dev/null +++ b/ipc-runtime/ts/src/wasm/host.ts @@ -0,0 +1,209 @@ +import { WASI_NAMESPACE, createWasiImports } from "./wasi_shim.js"; + +/** What a module-specific host import gets to work with. */ +export interface HostImportsContext { + memory(): WebAssembly.Memory; + readCString(ptr: number): string; + readBytes(ptr: number, len: number): Uint8Array; + logger(msg: string): void; + /** Threads this instance may use: the engine's count on the main instance, 1 on a thread. */ + threads: number; +} + +/** + * Extra imports a particular module needs beyond WASI and wasi-threads, keyed by import module + * then name (e.g. `{ env: { logstr: ptr => ... } }`). The FFI contract itself needs none; this + * is the escape hatch for modules whose platform layer imports its own logging or abort hooks. + */ +export type HostImportsFactory = ( + ctx: HostImportsContext, +) => Record>; + +export interface InstanceOptions { + module: WebAssembly.Module; + memory: WebAssembly.Memory; + /** WASI environ for the module (e.g. `HARDWARE_CONCURRENCY`). */ + env?: Record; + logger?: (msg: string) => void; + hostImports?: HostImportsFactory; + /** + * 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 `ipc_ffi_entry`. */ + entry?: string; + /** Allocator export pairs to look for, in order of preference. */ + allocatorExports?: Array<[string, string]>; + /** Call the reactor's `_initialize` after instantiation (main instances only). Default true. */ + runInitialize?: boolean; + /** Threads this instance may use (reported to `hostImports`). Default 1. */ + threads?: number; +} + +export const DEFAULT_ENTRY = "ipc_ffi_entry"; + +/** + * The generated `ipc_ffi_alloc`/`ipc_ffi_free` first; wasi-libc's `malloc`/`free` for modules that + * export them; bb's historical `bbmalloc`/`bbfree` last. + */ +export const DEFAULT_ALLOCATOR_EXPORTS: Array<[string, string]> = [ + ["ipc_ffi_alloc", "ipc_ffi_free"], + ["malloc", "free"], + ["bbmalloc", "bbfree"], +]; + +type WasmFn = (...args: number[]) => number; + +/** + * 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. + */ +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, + ) {} + + static async instantiate(opts: InstanceOptions): Promise { + const memory = opts.memory; + const logger = opts.logger ?? (() => {}); + const ctx: HostImportsContext = { + memory: () => memory, + readBytes: (ptr, len) => + new Uint8Array(memory.buffer).slice(ptr >>> 0, (ptr >>> 0) + len), + readCString: (ptr) => { + const m = new Uint8Array(memory.buffer); + let end = ptr >>> 0; + while (m[end] !== 0) { + end++; + } + return new TextDecoder().decode(m.slice(ptr >>> 0, end)); + }, + logger, + threads: opts.threads ?? 1, + }; + + const imports: Record> = {}; + for (const [ns, values] of Object.entries(opts.hostImports?.(ctx) ?? {})) { + imports[ns] = { ...values }; + } + imports.env = { ...(imports.env ?? {}), memory }; + imports[WASI_NAMESPACE] = { + ...createWasiImports(opts.module, () => memory, { + env: opts.env, + onStderr: logger, + onStdout: logger, + }), + ...(imports[WASI_NAMESPACE] ?? {}), + }; + imports.wasi = { + ...(imports.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 not provided by the host (supply them through hostImports): ${missing.join(", ")}`, + ); + } + + const instance = await WebAssembly.instantiate(opts.module, imports); + const exports = instance.exports as Record; + if ( + opts.runInitialize !== false && + typeof exports._initialize === "function" + ) { + (exports._initialize as () => void)(); + } + const entryName = opts.entry ?? DEFAULT_ENTRY; + const entry = exports[entryName]; + if (typeof entry !== "function") { + throw new Error(`wasm module does not export ${entryName}`); + } + const pair = (opts.allocatorExports ?? DEFAULT_ALLOCATOR_EXPORTS).find( + ([a, f]) => + typeof exports[a] === "function" && typeof exports[f] === "function", + ); + if (!pair) { + throw new Error( + `wasm module exports no allocator pair (looked for ${( + opts.allocatorExports ?? DEFAULT_ALLOCATOR_EXPORTS + ) + .map(([a, f]) => `${a}/${f}`) + .join(", ")})`, + ); + } + return new WasmInstanceHost( + instance, + memory, + entry as WasmFn, + exports[pair[0]] as WasmFn, + exports[pair[1]] as WasmFn, + logger, + ); + } + + /** 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); + } + + /** + * One FFI round trip: request bytes in, a copy of the response bytes out. The request is + * placed in module memory with the module's allocator, the response is read from where the + * module left it and freed with the module's free, as the FFI contract requires. + */ + call(input: Uint8Array): Uint8Array { + const inPtr = input.length > 0 ? this.alloc(input.length) >>> 0 : 0; + if (input.length > 0 && inPtr === 0) { + throw new Error("wasm module allocation failed for the request buffer"); + } + const slots = this.alloc(8) >>> 0; + if (slots === 0) { + throw new Error("wasm module allocation failed for the response slots"); + } + let outPtr = 0; + try { + if (input.length > 0) { + new Uint8Array(this.memory.buffer).set(input, inPtr); + } + const before = new DataView(this.memory.buffer); + before.setUint32(slots, 0, true); + before.setUint32(slots + 4, 0, true); + this.entry(inPtr, input.length, slots, slots + 4); + // Re-read through a fresh view: the call may have grown memory and detached the old buffer. + const after = new DataView(this.memory.buffer); + outPtr = after.getUint32(slots, true); + const outLen = after.getUint32(slots + 4, true); + return new Uint8Array(this.memory.buffer).slice(outPtr, outPtr + outLen); + } finally { + if (outPtr !== 0) { + this.free(outPtr); + } + this.free(slots); + if (inPtr !== 0) { + this.free(inPtr); + } + } + } +} diff --git a/ipc-runtime/ts/src/wasm/index.browser.ts b/ipc-runtime/ts/src/wasm/index.browser.ts new file mode 100644 index 000000000000..1db92d9b2ded --- /dev/null +++ b/ipc-runtime/ts/src/wasm/index.browser.ts @@ -0,0 +1,66 @@ +// Browser entry of the wasm FFI backend (`@aztec-foundation/ipc-runtime/wasm`, `browser` condition). +import { + type WasmFfiBackendOptions, + type WasmFfiBinding, + type WasmFfiOptions, + WasmFfiBackend, + WasmFfiBackendSync, + WasmFfiEngine, +} from "./backend.js"; +import { + browserPlatform, + browserWorkerHandle, + browserWorkerSide, +} from "./platform.browser.js"; + +export type { HostImportsContext, HostImportsFactory } from "./host.js"; +export type { WasmModuleSource } from "./module_source.js"; +export type { WasmPlatform, WorkerHandle, WorkerSide } from "./platform.js"; +export type { WasmFfiBackendOptions, WasmFfiBinding, WasmFfiOptions }; +export { WasmFfiBackend, WasmFfiBackendSync, WasmFfiEngine }; +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 { + browserPlatform as platform, + browserWorkerHandle as workerHandle, + browserWorkerSide as workerSide, +}; + +/** + * The runtime's own worker scripts, spawned with the literal expression bundlers detect so they + * are emitted as worker chunks. A package whose module needs `hostImports` points at its own. + */ +export const binding: WasmFfiBinding = { + platform: browserPlatform, + createThreadWorker: () => + browserWorkerHandle( + new Worker(new URL("./thread.worker.browser.js", import.meta.url), { + type: "module", + }), + ), + createMainWorker: () => + browserWorkerHandle( + new Worker(new URL("./main.worker.browser.js", import.meta.url), { + type: "module", + }), + ), +}; + +export function createWasmFfiBackend( + opts: WasmFfiBackendOptions, +): Promise { + return WasmFfiBackend.create(opts, binding); +} + +export function createWasmFfiBackendSync( + opts: WasmFfiOptions, +): Promise { + return WasmFfiBackendSync.create(opts, binding); +} + +/** Threads need `SharedArrayBuffer`, which browsers expose only under COOP/COEP headers. */ +export function sharedMemoryAvailable(): boolean { + return browserPlatform.sharedMemoryAvailable(); +} diff --git a/ipc-runtime/ts/src/wasm/index.node.ts b/ipc-runtime/ts/src/wasm/index.node.ts new file mode 100644 index 000000000000..6ffd2ef40ae2 --- /dev/null +++ b/ipc-runtime/ts/src/wasm/index.node.ts @@ -0,0 +1,59 @@ +// Node entry of the wasm FFI backend (`@aztec-foundation/ipc-runtime/wasm`, `default` condition). +import { + type WasmFfiBackendOptions, + type WasmFfiBinding, + type WasmFfiOptions, + WasmFfiBackend, + WasmFfiBackendSync, + WasmFfiEngine, +} from "./backend.js"; +import { + nodePlatform, + nodeWorkerHandle, + nodeWorkerSide, +} from "./platform.node.js"; + +export type { HostImportsContext, HostImportsFactory } from "./host.js"; +export type { WasmModuleSource } from "./module_source.js"; +export type { WasmPlatform, WorkerHandle, WorkerSide } from "./platform.js"; +export type { WasmFfiBackendOptions, WasmFfiBinding, WasmFfiOptions }; +export { WasmFfiBackend, WasmFfiBackendSync, WasmFfiEngine }; +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 { + nodePlatform as platform, + nodeWorkerHandle as workerHandle, + nodeWorkerSide as workerSide, +}; + +/** The runtime's own worker scripts; a package whose module needs `hostImports` points at its own. */ +export const binding: WasmFfiBinding = { + platform: nodePlatform, + createThreadWorker: () => + nodePlatform.createWorker( + new URL("./thread.worker.node.js", import.meta.url), + ), + createMainWorker: () => + nodePlatform.createWorker( + new URL("./main.worker.node.js", import.meta.url), + ), +}; + +export function createWasmFfiBackend( + opts: WasmFfiBackendOptions, +): Promise { + return WasmFfiBackend.create(opts, binding); +} + +export function createWasmFfiBackendSync( + opts: WasmFfiOptions, +): Promise { + return WasmFfiBackendSync.create(opts, binding); +} + +/** Whether this platform can run the module's threads build (a shared memory is available). */ +export function sharedMemoryAvailable(): boolean { + return nodePlatform.sharedMemoryAvailable(); +} diff --git a/ipc-runtime/ts/src/wasm/main.worker.browser.ts b/ipc-runtime/ts/src/wasm/main.worker.browser.ts new file mode 100644 index 000000000000..d2d9e09b51bb --- /dev/null +++ b/ipc-runtime/ts/src/wasm/main.worker.browser.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.browser.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/main.worker.node.ts b/ipc-runtime/ts/src/wasm/main.worker.node.ts new file mode 100644 index 000000000000..2c1d1348a97c --- /dev/null +++ b/ipc-runtime/ts/src/wasm/main.worker.node.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.node.js"; + +runMainWorker(nodeWorkerSide(), nodePlatform, { + createThreadWorker: () => + nodePlatform.createWorker( + new URL("./thread.worker.node.js", import.meta.url), + ), +}); 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..1777339acde3 --- /dev/null +++ b/ipc-runtime/ts/src/wasm/main_worker.ts @@ -0,0 +1,82 @@ +import { type WasmFfiBinding, WasmFfiEngine } from "./backend.js"; +import type { HostImportsFactory } from "./host.js"; +import type { WasmPlatform, WorkerHandle, WorkerSide } from "./platform.js"; + +export interface MainWorkerOptions { + /** Module-specific imports for the main instance (thread workers get theirs from their own entry). */ + hostImports?: HostImportsFactory; + /** 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, + allocatorExports: o.allocatorExports, + hostImports: opts.hostImports, + 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/platform.browser.ts b/ipc-runtime/ts/src/wasm/platform.browser.ts new file mode 100644 index 000000000000..29a884d11666 --- /dev/null +++ b/ipc-runtime/ts/src/wasm/platform.browser.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/platform.node.ts b/ipc-runtime/ts/src/wasm/platform.node.ts new file mode 100644 index 000000000000..b4a8ffeb0b00 --- /dev/null +++ b/ipc-runtime/ts/src/wasm/platform.node.ts @@ -0,0 +1,66 @@ +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"; +import { browserWorkerHandle } from "./platform.browser.js"; + +/** + * The parent's handle on a `worker_threads` worker. Also accepts a web `Worker`, so code that is + * type-checked against this (node) entry but bundled for browsers type-checks too. + */ +export function nodeWorkerHandle( + worker: Worker | globalThis.Worker, +): WorkerHandle { + if ("addEventListener" in worker) { + return browserWorkerHandle(worker); + } + 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/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/thread.worker.browser.ts b/ipc-runtime/ts/src/wasm/thread.worker.browser.ts new file mode 100644 index 000000000000..b8ba0e16e95d --- /dev/null +++ b/ipc-runtime/ts/src/wasm/thread.worker.browser.ts @@ -0,0 +1,5 @@ +// Default wasi-threads worker for browsers: one module instance per thread, no module-specific imports. +import { browserWorkerSide } from "./platform.browser.js"; +import { runThreadWorker } from "./thread_worker.js"; + +runThreadWorker(browserWorkerSide()); diff --git a/ipc-runtime/ts/src/wasm/thread.worker.node.ts b/ipc-runtime/ts/src/wasm/thread.worker.node.ts new file mode 100644 index 000000000000..c269c9ade573 --- /dev/null +++ b/ipc-runtime/ts/src/wasm/thread.worker.node.ts @@ -0,0 +1,5 @@ +// Default wasi-threads worker for node: one module instance per thread, no module-specific imports. +import { nodeWorkerSide } from "./platform.node.js"; +import { runThreadWorker } from "./thread_worker.js"; + +runThreadWorker(nodeWorkerSide()); 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..a1ffb16fa6af --- /dev/null +++ b/ipc-runtime/ts/src/wasm/thread_worker.ts @@ -0,0 +1,56 @@ +import { type HostImportsFactory, WasmInstanceHost } from "./host.js"; +import type { WorkerSide } from "./platform.js"; + +export interface ThreadWorkerOptions { + /** The same module-specific imports the main instance was given, if any. */ + hostImports?: HostImportsFactory; +} + +/** + * Body of a wasi-threads worker: instantiate the module over the shared memory on `init`, then + * run the module's thread entry for each `start`. A thread runs to completion inside + * `wasi_thread_start`, so one worker serves one wasi thread at a time — the pool is sized to the + * number of threads the module will create. + */ +export function runThreadWorker( + side: WorkerSide, + opts: ThreadWorkerOptions = {}, +): void { + let host: WasmInstanceHost | undefined; + const log = (message: string) => side.postMessage({ type: "log", message }); + side.onMessage(async (msg) => { + try { + switch (msg?.type) { + case "init": + host = await WasmInstanceHost.instantiate({ + module: msg.module, + memory: msg.memory, + env: msg.env, + entry: msg.entry, + allocatorExports: msg.allocatorExports, + hostImports: opts.hostImports, + threads: 1, + runInitialize: false, + // Only the main instance spawns threads; a request from a thread is refused. + spawnThread: () => -1, + logger: log, + }); + side.postMessage({ type: "ready" }); + break; + case "start": + 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}`); + } + } + }); +} 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..d608b422d64a --- /dev/null +++ b/ipc-runtime/ts/src/wasm/wasi_shim.ts @@ -0,0 +1,154 @@ +/** + * 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, + sched_yield: () => ERRNO_SUCCESS, + proc_exit: (code) => { + 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_module_source.test.ts b/ipc-runtime/ts/src/wasm_module_source.test.ts new file mode 100644 index 000000000000..015aa4e6d9ad --- /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/platform.node.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"] From 6cafd816e992ebda8d673858d0c5b7d3a5a09b0c Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:11:46 +0000 Subject: [PATCH 02/26] refactor(ipc-codegen): imply --out from --package The package shell imports its bindings from ./generated, so a --package invocation only ever had one valid --out. Default it to /src/generated, reject a conflicting value, and drop the redundant line from every bootstrap. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- barretenberg/ts/bootstrap.sh | 3 --- ipc-codegen/README.md | 6 +++--- ipc-codegen/echo_example/ts_package/bootstrap.sh | 1 - ipc-codegen/src/generate.ts | 15 ++++++++++++++- wsdb/bootstrap.sh | 1 - 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/barretenberg/ts/bootstrap.sh b/barretenberg/ts/bootstrap.sh index 662f34ea8ceb..2c11172398a7 100755 --- a/barretenberg/ts/bootstrap.sh +++ b/barretenberg/ts/bootstrap.sh @@ -27,7 +27,6 @@ function generate_bb_avm_sim_package { --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" \ @@ -45,7 +44,6 @@ 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" } @@ -62,7 +60,6 @@ function generate_bb_js_api_package { --schema "$bbapi/bb_schema.json" \ --lang ts \ --client \ - --out "$ROOT/barretenberg/ts/bb.js-api/src/generated" \ --package "$ROOT/barretenberg/ts/bb.js-api" \ --package-name "$BB_JS_API_PACKAGE" \ --binary-name bb \ diff --git a/ipc-codegen/README.md b/ipc-codegen/README.md index 67214cc49613..c69495c16fc0 100644 --- a/ipc-codegen/README.md +++ b/ipc-codegen/README.md @@ -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 @@ -167,7 +167,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 \ @@ -175,7 +174,8 @@ 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 diff --git a/ipc-codegen/echo_example/ts_package/bootstrap.sh b/ipc-codegen/echo_example/ts_package/bootstrap.sh index bff76ba45456..df61ac667bdf 100755 --- a/ipc-codegen/echo_example/ts_package/bootstrap.sh +++ b/ipc-codegen/echo_example/ts_package/bootstrap.sh @@ -10,7 +10,6 @@ $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 \ diff --git a/ipc-codegen/src/generate.ts b/ipc-codegen/src/generate.ts index ded2643e6829..02a403760c39 100644 --- a/ipc-codegen/src/generate.ts +++ b/ipc-codegen/src/generate.ts @@ -85,7 +85,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 @@ -244,6 +245,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(); } diff --git a/wsdb/bootstrap.sh b/wsdb/bootstrap.sh index a45dc4a55e3b..fac9e75a45bf 100755 --- a/wsdb/bootstrap.sh +++ b/wsdb/bootstrap.sh @@ -15,7 +15,6 @@ function generate_ts_package { --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" \ From 1434011287fd2432f8e40e45c3c80b072b416d70 Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:25:05 +0000 Subject: [PATCH 03/26] feat(ipc-codegen): prefix FFI symbols with the service name bb_ipc_ffi_entry/_alloc/_free instead of one fixed name, so libraries of several services can be statically linked into one binary. The Rust and Zig client ffi_backend files are generated (link_name) instead of copied templates, the TS package passes the names to the wasm host, and the host discovers the single *_ipc_ffi_entry export when not told. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- barretenberg/cpp/src/CMakeLists.txt | 2 +- .../cpp/src/barretenberg/bbapi/CMakeLists.txt | 2 +- .../cpp/src/barretenberg/bbapi/c_bind.cpp | 2 +- .../bbapi/c_bind_exception.test.cpp | 5 +- ipc-codegen/README.md | 6 +- ipc-codegen/SCHEMA_SPEC.md | 32 ++-- ipc-codegen/echo_example/cpp/src/echo_ffi.cpp | 4 +- ipc-codegen/echo_example/cpp/src/ffi_test.cpp | 18 ++- .../echo_example/zig/src/ffi_check.zig | 12 +- ipc-codegen/src/cpp_codegen.ts | 34 +++-- ipc-codegen/src/generate.ts | 6 +- ipc-codegen/src/rust_codegen.ts | 137 +++++++++++++++++- ipc-codegen/src/typescript_package_codegen.ts | 6 + ipc-codegen/src/zig_codegen.ts | 43 ++++++ ipc-codegen/templates/rust/ffi_backend.rs | 128 ---------------- ipc-codegen/templates/zig/ffi_backend.zig | 34 ----- ipc-runtime/README.md | 4 +- ipc-runtime/ts/src/wasm/backend.ts | 2 +- ipc-runtime/ts/src/wasm/host.ts | 72 ++++++--- 19 files changed, 309 insertions(+), 240 deletions(-) delete mode 100644 ipc-codegen/templates/rust/ffi_backend.rs delete mode 100644 ipc-codegen/templates/zig/ffi_backend.zig 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/bbapi/CMakeLists.txt b/barretenberg/cpp/src/barretenberg/bbapi/CMakeLists.txt index 087fd7017409..423d12aea022 100644 --- a/barretenberg/cpp/src/barretenberg/bbapi/CMakeLists.txt +++ b/barretenberg/cpp/src/barretenberg/bbapi/CMakeLists.txt @@ -22,7 +22,7 @@ if(NOT FUZZING) ${IPC_CODEGEN_DIR}/src/*.ts ${IPC_CODEGEN_DIR}/templates/cpp/*.hpp ) - # --ffi emits the in-process entry (ipc_ffi_entry) bb.js's wasm backend and barretenberg-rs call. + # --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 diff --git a/barretenberg/cpp/src/barretenberg/bbapi/c_bind.cpp b/barretenberg/cpp/src/barretenberg/bbapi/c_bind.cpp index 3d737a360762..3a2b69585ef5 100644 --- a/barretenberg/cpp/src/barretenberg/bbapi/c_bind.cpp +++ b/barretenberg/cpp/src/barretenberg/bbapi/c_bind.cpp @@ -5,7 +5,7 @@ namespace bb::bbapi { /** - * @brief The dispatcher behind the generated in-process FFI entry (ipc_ffi_entry, see bb_ffi.hpp). + * @brief The dispatcher behind the generated in-process FFI entry (bb_ipc_ffi_entry, see bb_ffi.hpp). * * 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. 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 587d9397af66..1b0be6605f05 100644 --- a/barretenberg/cpp/src/barretenberg/bbapi/c_bind_exception.test.cpp +++ b/barretenberg/cpp/src/barretenberg/bbapi/c_bind_exception.test.cpp @@ -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(); diff --git a/ipc-codegen/README.md b/ipc-codegen/README.md index c69495c16fc0..dc2438ba08a3 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 ``` @@ -104,7 +104,7 @@ node --experimental-strip-types --experimental-transform-types --no-warnings \ | `--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. | | `--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` | In-process FFI, both directions of the contract in `SCHEMA_SPEC.md` ("FFI entry"). With `--client` (Rust/Zig): adds the `ffi_backend` template, 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`/`ipc_ffi_alloc`/`ipc_ffi_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. A wasm reactor built from these is what the TS `wasm` transport runs. | +| `--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. | diff --git a/ipc-codegen/SCHEMA_SPEC.md b/ipc-codegen/SCHEMA_SPEC.md index 995c91befa68..269ee61e3803 100644 --- a/ipc-codegen/SCHEMA_SPEC.md +++ b/ipc-codegen/SCHEMA_SPEC.md @@ -218,22 +218,26 @@ A service linked into its caller — a native library, or a wasm reactor — tak 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); +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); ``` -`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 Rust/Zig `ffi_backend` -templates 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). +`` 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 diff --git a/ipc-codegen/echo_example/cpp/src/echo_ffi.cpp b/ipc-codegen/echo_example/cpp/src/echo_ffi.cpp index 24862bc7c50b..bde360edc5cd 100644 --- a/ipc-codegen/echo_example/cpp/src/echo_ffi.cpp +++ b/ipc-codegen/echo_example/cpp/src/echo_ffi.cpp @@ -1,6 +1,6 @@ // The service's half of the generated in-process FFI entry -// (generated/echo_ffi.cpp defines ipc_ffi_entry and calls back here for the -// dispatcher). +// (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" diff --git a/ipc-codegen/echo_example/cpp/src/ffi_test.cpp b/ipc-codegen/echo_example/cpp/src/ffi_test.cpp index be046c498447..522be0daa749 100644 --- a/ipc-codegen/echo_example/cpp/src/ffi_test.cpp +++ b/ipc-codegen/echo_example/cpp/src/ffi_test.cpp @@ -1,6 +1,7 @@ -// In-process FFI conformance test (C++): drives the generated 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. +// 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 @@ -35,16 +36,17 @@ std::vector pack_request(const char *name, const Cmd &cmd) { } // Hand the request over the way a foreign caller does: in a buffer from -// ipc_ffi_alloc, receiving the response in one it frees with ipc_ffi_free. +// 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(ipc_ffi_alloc(request.size())); + 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; - ipc_ffi_entry(in, request.size(), &out, &out_len); - ipc_ffi_free(in); + echo_ipc_ffi_entry(in, request.size(), &out, &out_len); + echo_ipc_ffi_free(in); std::vector response(out, out + out_len); - ipc_ffi_free(out); + echo_ipc_ffi_free(out); return response; } 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 ae808036462a..632735db931d 100644 --- a/ipc-codegen/src/cpp_codegen.ts +++ b/ipc-codegen/src/cpp_codegen.ts @@ -786,12 +786,25 @@ void serve(const std::string& input_path, Ctx& ctx) * 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. +// 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}" @@ -816,12 +829,12 @@ AsyncDispatchHandler& ipc_ffi_dispatcher(); // 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 -// ipc_ffi_alloc(), which the caller releases with ipc_ffi_free(). -IPC_FFI_EXPORT void ipc_ffi_entry(const uint8_t* input, size_t input_len, uint8_t** output, size_t* output_len); +// ${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* ipc_ffi_alloc(size_t size); -IPC_FFI_EXPORT void ipc_ffi_free(void* ptr); +IPC_FFI_EXPORT void* ${alloc}(size_t size); +IPC_FFI_EXPORT void ${free}(void* ptr); `; } @@ -829,6 +842,9 @@ IPC_FFI_EXPORT void ipc_ffi_free(void* ptr); 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. @@ -840,24 +856,24 @@ IPC_FFI_EXPORT void ipc_ffi_free(void* ptr); #include #include -IPC_FFI_EXPORT void* ipc_ffi_alloc(size_t size) +IPC_FFI_EXPORT void* ${alloc}(size_t size) { // NOLINTNEXTLINE(cppcoreguidelines-no-malloc) return std::malloc(size == 0 ? 1 : size); } -IPC_FFI_EXPORT void ipc_ffi_free(void* ptr) +IPC_FFI_EXPORT void ${free}(void* ptr) { // NOLINTNEXTLINE(cppcoreguidelines-no-malloc) std::free(ptr); } -IPC_FFI_EXPORT void ipc_ffi_entry(const uint8_t* input, size_t input_len, uint8_t** output, size_t* output_len) +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(ipc_ffi_alloc(response.size())); + 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 02a403760c39..5cf2609d71d4 100644 --- a/ipc-codegen/src/generate.ts +++ b/ipc-codegen/src/generate.ts @@ -585,7 +585,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; } @@ -621,7 +622,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; } diff --git a/ipc-codegen/src/rust_codegen.ts b/ipc-codegen/src/rust_codegen.ts index cce95a778d44..305546b1bd32 100644 --- a/ipc-codegen/src/rust_codegen.ts +++ b/ipc-codegen/src/rust_codegen.ts @@ -922,16 +922,141 @@ 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. +//! 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 \`ipc_ffi_entry\`, \`ipc_ffi_alloc\` and -//! \`ipc_ffi_free\` over one handler, built with \`Default\` on the first call and kept for the life +//! \`${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: //! @@ -989,7 +1114,7 @@ pub unsafe fn ffi_free(ptr: *mut u8) { macro_rules! ${macroName} { ($ffi:path, $handler:ty) => { #[unsafe(no_mangle)] - pub unsafe extern "C" fn ipc_ffi_entry( + pub unsafe extern "C" fn ${entry}( input: *const u8, input_len: usize, output: *mut *mut u8, @@ -1003,13 +1128,13 @@ macro_rules! ${macroName} { } #[unsafe(no_mangle)] - pub extern "C" fn ipc_ffi_alloc(size: usize) -> *mut u8 { + 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 ipc_ffi_free(ptr: *mut u8) { + 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_package_codegen.ts b/ipc-codegen/src/typescript_package_codegen.ts index e7927f256542..c7de71160550 100644 --- a/ipc-codegen/src/typescript_package_codegen.ts +++ b/ipc-codegen/src/typescript_package_codegen.ts @@ -482,6 +482,10 @@ export class ${serviceClass} extends AsyncApi { const wasmOptions = wasmOptionsType(prefix); 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', + allocatorExports: [['${sym}ipc_ffi_alloc', '${sym}ipc_ffi_free']],`; return `import { type WasmFfiBackend, @@ -548,6 +552,7 @@ export function createWasmBackendWith(workers: WasmWorkers, options: ${wasmOptio logger: options.logger, worker: options.worker, hostImports, +${ffiExports} createMainWorker: workers.createMainWorker, createThreadWorker: workers.createThreadWorker, }); @@ -562,6 +567,7 @@ export function createWasmBackendSync(options: ${wasmOptions} = {}): Promise 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 a123e5cf0fb8..198b8353f69c 100644 --- a/ipc-runtime/README.md +++ b/ipc-runtime/README.md @@ -217,8 +217,8 @@ 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` / `ipc_ffi_alloc` / -`ipc_ffi_free`; see `ipc-codegen/SCHEMA_SPEC.md`): +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 | |-------------------------------|------------------------------------------------------------------------------------------| diff --git a/ipc-runtime/ts/src/wasm/backend.ts b/ipc-runtime/ts/src/wasm/backend.ts index b5cf3bb931df..938820e92ef5 100644 --- a/ipc-runtime/ts/src/wasm/backend.ts +++ b/ipc-runtime/ts/src/wasm/backend.ts @@ -16,7 +16,7 @@ export interface WasmFfiOptions { logger?: (msg: string) => void; /** Module-specific imports beyond WASI/wasi-threads (see `HostImportsFactory`). */ hostImports?: HostImportsFactory; - /** FFI entry export; default `ipc_ffi_entry`. */ + /** FFI entry export; default: the module's one `_ipc_ffi_entry` export. */ entry?: string; /** Allocator export pairs to look for, in order of preference. */ allocatorExports?: Array<[string, string]>; diff --git a/ipc-runtime/ts/src/wasm/host.ts b/ipc-runtime/ts/src/wasm/host.ts index c32f44207dc2..98f7d9ebd8b0 100644 --- a/ipc-runtime/ts/src/wasm/host.ts +++ b/ipc-runtime/ts/src/wasm/host.ts @@ -31,9 +31,15 @@ export interface InstanceOptions { * 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 `ipc_ffi_entry`. */ + /** + * 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; - /** Allocator export pairs to look for, in order of preference. */ + /** + * Allocator export pairs to look for, in order of preference. Default: the entry's sibling + * `_ipc_ffi_alloc`/`_free`, then wasi-libc's `malloc`/`free`, then bb's `bbmalloc`/`bbfree`. + */ allocatorExports?: Array<[string, string]>; /** Call the reactor's `_initialize` after instantiation (main instances only). Default true. */ runInitialize?: boolean; @@ -41,24 +47,50 @@ export interface InstanceOptions { threads?: number; } -export const DEFAULT_ENTRY = "ipc_ffi_entry"; +/** Every FFI entry export ends with this; the generated ones are prefixed by their service. */ +export const ENTRY_SUFFIX = "ipc_ffi_entry"; -/** - * The generated `ipc_ffi_alloc`/`ipc_ffi_free` first; wasi-libc's `malloc`/`free` for modules that - * export them; bb's historical `bbmalloc`/`bbfree` last. - */ -export const DEFAULT_ALLOCATOR_EXPORTS: Array<[string, string]> = [ - ["ipc_ffi_alloc", "ipc_ffi_free"], +/** 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 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. + * specific host imports), `_initialize` run, and the `_ipc_ffi_entry` call protocol + * implemented over the module's own allocator. */ export class WasmInstanceHost { private constructor( @@ -129,20 +161,18 @@ export class WasmInstanceHost { ) { (exports._initialize as () => void)(); } - const entryName = opts.entry ?? DEFAULT_ENTRY; - const entry = exports[entryName]; - if (typeof entry !== "function") { - throw new Error(`wasm module does not export ${entryName}`); - } - const pair = (opts.allocatorExports ?? DEFAULT_ALLOCATOR_EXPORTS).find( + const { entry, prefix } = findEntry(exports, opts.entry); + const allocatorExports = opts.allocatorExports ?? [ + [`${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 ${( - opts.allocatorExports ?? DEFAULT_ALLOCATOR_EXPORTS - ) + `wasm module exports no allocator pair (looked for ${allocatorExports .map(([a, f]) => `${a}/${f}`) .join(", ")})`, ); @@ -150,7 +180,7 @@ export class WasmInstanceHost { return new WasmInstanceHost( instance, memory, - entry as WasmFn, + exports[entry] as WasmFn, exports[pair[0]] as WasmFn, exports[pair[1]] as WasmFn, logger, From 428e22c77c6a3ed41d8c266bc60cd457f93d53fe Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:51:22 +0000 Subject: [PATCH 04/26] feat(ipc-codegen): backend policy in the generated package; bb.js drops its bundled bb The client package now owns how the service is reached. Service.create picks the spawned process when the binary resolves and the wasm module otherwise; `backend:` forces one (no fallback, and more wasm threads than the host can give is an error rather than a silent downgrade) or takes a backend object of the consumer's own; createBackend/createBackendSync expose the same policy to facades. One entry per host (node, browser, react-native), each with its own types, offers only the backends that exist there; the react-native entry has none built in and takes a registered or passed backend. A spawned process receives `threads` as HARDWARE_CONCURRENCY and RAYON_NUM_THREADS. bb.js maps its BackendType options onto that policy, resolves bb through bb.js-api's per-platform packages (the binary's one published home) and keeps bundling only the LMDB NAPI module. --client is dropped from the package bootstraps, which imply it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- barretenberg/ts/bb.js/README.md | 18 +- barretenberg/ts/bb.js/bootstrap.sh | 4 - barretenberg/ts/bb.js/scripts/copy_cross.sh | 7 +- barretenberg/ts/bb.js/scripts/copy_native.sh | 5 +- .../ts/bb.js/src/bb_backends/browser/index.ts | 22 +- .../ts/bb.js/src/bb_backends/node/index.ts | 133 ++-- .../bb.js/src/bb_backends/node/native_shm.ts | 61 -- .../src/bb_backends/node/native_shm_async.ts | 60 -- .../bb_backends/node/native_socket.test.ts | 82 -- .../src/bb_backends/node/native_socket.ts | 50 -- .../ts/bb.js/src/bb_backends/node/platform.ts | 58 +- barretenberg/ts/bootstrap.sh | 15 +- ipc-codegen/README.md | 10 +- .../echo_example/ts_package/bootstrap.sh | 1 - .../ts_package/src/package_test.ts | 20 +- ipc-codegen/src/generate.ts | 7 + ipc-codegen/src/typescript_package_codegen.ts | 710 ++++++++++++++---- wsdb/bootstrap.sh | 1 - 18 files changed, 700 insertions(+), 564 deletions(-) delete mode 100644 barretenberg/ts/bb.js/src/bb_backends/node/native_shm.ts delete mode 100644 barretenberg/ts/bb.js/src/bb_backends/node/native_shm_async.ts delete mode 100644 barretenberg/ts/bb.js/src/bb_backends/node/native_socket.test.ts delete mode 100644 barretenberg/ts/bb.js/src/bb_backends/node/native_socket.ts diff --git a/barretenberg/ts/bb.js/README.md b/barretenberg/ts/bb.js/README.md index 5241995305c8..47e7cc7efcd1 100644 --- a/barretenberg/ts/bb.js/README.md +++ b/barretenberg/ts/bb.js/README.md @@ -46,14 +46,16 @@ See `src/main.ts` for larger example of how to use. ### How bb is reached -The typed API (`Barretenberg` extends it) is generated from bb's schema into the -`@aztec-foundation/bb.js-api` package by ipc-codegen; bb.js adds the facades, backend selection and CRS -handling. That package also ships bb's wasm modules (single-thread and threads builds) and runs them -in-process through `@aztec-foundation/ipc-runtime/wasm`: the module in a worker, wasi threads on further -workers, `WebAssembly.compileStreaming` for loading (so browsers that cache compiled code start warm on a -repeat visit). Pass `wasmPath` (or set `BB_WASM_PATH` in node) to run another build of the module, and -`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. +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, `WebAssembly.compileStreaming` for loading (so browsers that cache compiled code start +warm on a repeat visit). Pass `wasmPath` (or set `BB_WASM_PATH` in node) to run another build of the +module, and `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`). ### Browser Context diff --git a/barretenberg/ts/bb.js/bootstrap.sh b/barretenberg/ts/bb.js/bootstrap.sh index d49729057725..cd1a27e2fc22 100755 --- a/barretenberg/ts/bb.js/bootstrap.sh +++ b/barretenberg/ts/bb.js/bootstrap.sh @@ -80,10 +80,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/scripts/copy_cross.sh b/barretenberg/ts/bb.js/scripts/copy_cross.sh index ffb0d5ac774a..ca5cd158ffc8 100755 --- a/barretenberg/ts/bb.js/scripts/copy_cross.sh +++ b/barretenberg/ts/bb.js/scripts/copy_cross.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Copies cross-compiled bb binary and napi module to dest. +# Copies cross-compiled LMDB NAPI modules to build/. The bb binary itself ships with +# @aztec-foundation/bb.js-api (see barretenberg/ts/bootstrap.sh cross_copy_bb_js_api). set -e NO_CD=1 source $(git rev-parse --show-toplevel)/ci3/source @@ -8,15 +9,13 @@ cd $(dirname $0)/.. if [ -n "${1:-}" ]; then arch="$1" mkdir -p ./build/$arch - cp ../../cpp/build-$arch/bin/bb ./build/$arch cp ../../cpp/build-$arch/lib/nodejs_module.node ./build/$arch elif semver check "${REF_NAME:-}" && [[ "$(arch)" == "amd64" ]]; then # We're building a release. # Copy all cross-compiled architectures for release builds. - # The native amd64-linux binary is already copied by copy_native.sh (bb-ts target). + # The native amd64-linux module is already copied by copy_native.sh (bb-ts target). for arch in arm64-linux amd64-macos arm64-macos; do mkdir -p ./build/$arch - cp ../../cpp/build-$arch/bin/bb ./build/$arch cp ../../cpp/build-$arch/lib/nodejs_module.node ./build/$arch done diff --git a/barretenberg/ts/bb.js/scripts/copy_native.sh b/barretenberg/ts/bb.js/scripts/copy_native.sh index 85487610e83f..e55430c46c43 100755 --- a/barretenberg/ts/bb.js/scripts/copy_native.sh +++ b/barretenberg/ts/bb.js/scripts/copy_native.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Copies native bb binary and napi module to dest. +# Copies bb's LMDB NAPI module to build/. The bb binary itself ships with @aztec-foundation/bb.js-api. set -e NO_CD=1 source $(git rev-parse --show-toplevel)/ci3/source @@ -9,10 +9,9 @@ cd $(dirname $0)/.. target="$(arch)-$(os)" if [ "${BUILD_CPP:-0}" -eq 1 ]; then - ../../cpp/bootstrap.sh build_preset clang20 --target bb --target nodejs_module + ../../cpp/bootstrap.sh build_preset clang20 --target nodejs_module fi mkdir -p ./build/$target -cp ../../cpp/build/bin/bb ./build/$target cp ../../cpp/build/lib/nodejs_module.node ./build/$target diff --git a/barretenberg/ts/bb.js/src/bb_backends/browser/index.ts b/barretenberg/ts/bb.js/src/bb_backends/browser/index.ts index 5dca23680ff1..bd058bd751cd 100644 --- a/barretenberg/ts/bb.js/src/bb_backends/browser/index.ts +++ b/barretenberg/ts/bb.js/src/bb_backends/browser/index.ts @@ -1,4 +1,4 @@ -import { createWasmBackend, createWasmBackendSync } from '@aztec-foundation/bb.js-api'; +import { createBackend, createBackendSync, sharedMemoryAvailable } from '@aztec-foundation/bb.js-api'; import { BackendOptions, BackendType } from '../index.js'; import type { IMsgpackBackendAsync, IMsgpackBackendSync } from '../interface.js'; @@ -16,12 +16,13 @@ export async function createAsyncBackend( case BackendType.WasmWorker: { const worker = type === BackendType.WasmWorker; logger(`Using WASM backend (worker: ${worker})`); - return await createWasmBackend({ - threads: options.threads, - module: options.wasmPath, + 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, - memory: options.memory, - worker, + wasm: { module: options.wasmPath, memory: options.memory, worker }, }); } @@ -39,10 +40,13 @@ export async function createSyncBackend( logger: (msg: string) => void, ): Promise { switch (type) { - case BackendType.Wasm: { + case BackendType.Wasm: logger('Using WASM backend'); - return await createWasmBackendSync({ module: options.wasmPath, logger, memory: options.memory }); - } + 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/node/index.ts b/barretenberg/ts/bb.js/src/bb_backends/node/index.ts index daf308a90e17..a6c71d419bae 100644 --- a/barretenberg/ts/bb.js/src/bb_backends/node/index.ts +++ b/barretenberg/ts/bb.js/src/bb_backends/node/index.ts @@ -1,49 +1,58 @@ -import { createWasmBackend, createWasmBackendSync } from '@aztec-foundation/bb.js-api'; +import { createBackend, createBackendSync } from '@aztec-foundation/bb.js-api'; +import * as os from 'os'; import { BackendOptions, BackendType } from '../index.js'; import type { IMsgpackBackendAsync, IMsgpackBackendSync } from '../interface.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'; + +// 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; /** - * Create backend of specific type (no fallback) + * bb monitors parent death (prctl/kqueue) and exits on its own, so the child must never hold the + * Node event loop open; its log pipes (present with a logger) do, unless the caller asked for unref. + */ +function bbProcessLifetime(options: BackendOptions) { + return { unref: true, unrefStdio: options.unref }; +} + +/** + * 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 { - options = { - ...options, - wasmPath: options.wasmPath ?? process.env.BB_WASM_PATH, - }; + const 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.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', ...bbProcessLifetime(options) }, + }); - 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.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}`], + ...bbProcessLifetime(options), + }, + }); case BackendType.Wasm: case BackendType.WasmWorker: { @@ -51,17 +60,13 @@ export async function createAsyncBackend( // every call blocks until bb returns. const worker = type === BackendType.WasmWorker; logger(`Using WASM backend (worker: ${worker})`); - const backend = await createWasmBackend({ + return await createBackend({ + backend: 'wasm', threads: options.threads, - module: options.wasmPath, logger: options.logger, - memory: options.memory, - worker, + unref: options.unref, + wasm: { module: wasmPath, memory: options.memory, worker }, }); - if (options.unref) { - backend.unref(); - } - return backend; } default: @@ -77,39 +82,33 @@ export async function createSyncBackend( options: BackendOptions, logger: (msg: string) => void, ): Promise { - options = { - ...options, - wasmPath: options.wasmPath ?? process.env.BB_WASM_PATH, - }; + const 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.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}`], + ...bbProcessLifetime(options), + }, + }); - case BackendType.Wasm: { + case BackendType.Wasm: logger('Using WASM backend'); - const backend = await createWasmBackendSync({ - module: options.wasmPath, + return await createBackendSync({ + backend: 'wasm', logger: options.logger, - memory: options.memory, + unref: options.unref, + wasm: { module: wasmPath, memory: options.memory }, }); - if (options.unref) { - backend.unref(); - } - return backend; - } 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/node/platform.ts b/barretenberg/ts/bb.js/src/bb_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/bb_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/bootstrap.sh b/barretenberg/ts/bootstrap.sh index 2c11172398a7..ff34ac1c2614 100755 --- a/barretenberg/ts/bootstrap.sh +++ b/barretenberg/ts/bootstrap.sh @@ -26,7 +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 \ --package "$ROOT/barretenberg/ts/bb-avm-sim" \ --package-name "$BB_AVM_SIM_PACKAGE" \ --binary-name "$BB_AVM_SIM_BINARY" \ @@ -59,7 +58,6 @@ function generate_bb_js_api_package { "$ROOT/ipc-codegen/src/generate.ts" \ --schema "$bbapi/bb_schema.json" \ --lang ts \ - --client \ --package "$ROOT/barretenberg/ts/bb.js-api" \ --package-name "$BB_JS_API_PACKAGE" \ --binary-name bb \ @@ -237,7 +235,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 "$@") } @@ -250,7 +251,6 @@ function cross_copy_bb_avm_sim { } function cross_copy { - cross_copy_bb_js_api "$@" cross_copy_bb_js "$@" } @@ -301,7 +301,8 @@ function cross_copy_bb_js_api { prepare_bb_js_api_arch_packages } -# bb.js depends on bb.js-api, so it is published first (with its 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 @@ -310,6 +311,12 @@ function release_bb_js_api { 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 diff --git a/ipc-codegen/README.md b/ipc-codegen/README.md index dc2438ba08a3..6775f4057c80 100644 --- a/ipc-codegen/README.md +++ b/ipc-codegen/README.md @@ -102,7 +102,7 @@ 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` | 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). | @@ -177,9 +177,11 @@ src/generate.ts \ 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/echo_example/ts_package/bootstrap.sh b/ipc-codegen/echo_example/ts_package/bootstrap.sh index df61ac667bdf..9c22fe734258 100755 --- a/ipc-codegen/echo_example/ts_package/bootstrap.sh +++ b/ipc-codegen/echo_example/ts_package/bootstrap.sh @@ -9,7 +9,6 @@ NODE="node --experimental-strip-types --experimental-transform-types --no-warnin $NODE "$CODEGEN/src/generate.ts" \ --schema "$DIR/../schema/schema.jsonc" \ --lang ts \ - --client \ --package "$DIR" \ --package-name "@aztec/echo-ipc" \ --binary-name echo_server \ 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..1d8c901c2c1b 100644 --- a/ipc-codegen/echo_example/ts_package/src/package_test.ts +++ b/ipc-codegen/echo_example/ts_package/src/package_test.ts @@ -28,6 +28,20 @@ function assertBytes(actual: Uint8Array, expected: Uint8Array, label: string) { ); } +// 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 +64,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/src/generate.ts b/ipc-codegen/src/generate.ts index 5cf2609d71d4..3f18f31266be 100644 --- a/ipc-codegen/src/generate.ts +++ b/ipc-codegen/src/generate.ts @@ -514,7 +514,14 @@ function generate(args: Args) { 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()); } diff --git a/ipc-codegen/src/typescript_package_codegen.ts b/ipc-codegen/src/typescript_package_codegen.ts index c7de71160550..dd5b8468a8f1 100644 --- a/ipc-codegen/src/typescript_package_codegen.ts +++ b/ipc-codegen/src/typescript_package_codegen.ts @@ -20,18 +20,6 @@ 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 wasmOptionsType(prefix: string): string { - return `${prefix}WasmOptions`; -} - function binaryFinderName(prefix: string): string { return `find${prefix}Binary`; } @@ -184,6 +172,12 @@ 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) {} @@ -195,6 +189,14 @@ export class TypeScriptPackageCodegen { 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'; @@ -209,6 +211,10 @@ ${this.opts.curveConstants ? "export * from './generated/curve_constants.js';\n" build: "tsc -p tsconfig.json", prepare_arch_packages: "./scripts/prepare_arch_packages.sh", }; + const entry = (name: string) => ({ + types: `./dest/${name}.d.ts`, + default: `./dest/${name}.js`, + }); const pkg = { name: this.opts.packageName, @@ -225,21 +231,19 @@ ${this.opts.curveConstants ? "export * from './generated/curve_constants.js';\n" // 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", - // Bundlers pick the browser entry, which has no process transport. - ...(this.wasm ? { browser: "./dest/browser.js" } : {}), default: "./dest/index.js", }, - ...(this.wasm - ? { - "./browser": { - types: "./dest/browser.d.ts", - default: "./dest/browser.js", - }, - } - : {}), + ...(this.wasm ? { "./browser": entry("browser") } : {}), + "./react-native": entry("react-native"), }, files: ["dest/", ...(this.wasm ? ["wasm/"] : []), "README.md"], scripts, @@ -312,36 +316,34 @@ 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); + /** The spawned-process backend (node only): options, environment, and the two spawn functions. */ + generateProcess(): string { + const { prefix, binaryName, packageName } = this.opts; const findBinary = binaryFinderName(prefix); - const supportsShm = this.processTransports.includes("shm"); const transports = this.processTransports.map((t) => `'${t}'`).join(" | "); - const ipcPathArgs = JSON.stringify(this.opts.ipcPathArgs); const defaultTransport = this.processTransports.includes("uds") ? "uds" : this.processTransports[0]!; - const wasm = this.wasm; - const wasmOptions = wasmOptionsType(prefix); + const ipcPathArgs = JSON.stringify(this.opts.ipcPathArgs); + const shm = this.shm; - return `import { type IpcClientAsync, IpcSpawnError, SpawnedProcessBackend } from '@aztec-foundation/ipc-runtime'; -${wasm ? "import { type WasmFfiBackend, platform } from '@aztec-foundation/ipc-runtime/wasm';\n" : ""}import { AsyncApi, type IpcErrorFactory } from './generated/async.js'; + return `import { IpcSpawnError, SpawnedProcessBackend${shm ? ", SpawnedProcessBackendSync" : ""} } from '@aztec-foundation/ipc-runtime'; +import type { IpcErrorFactory } from './generated/async.js'; import { ${findBinary} } from './platform.js'; -${wasm ? `import { type ${wasmOptions}, createWasmBackendWith } from './wasm.js';\n` : ""} -${this.generatedExports()}${wasm ? "export * from './wasm.js';\n" : ""} -export type ${serviceTransport} = ${transports}; -export interface ${serviceOptions} { +export type ${prefix}Transport = ${transports}; + +/** Options for running '${binaryName}' as a spawned process (node only). */ +export interface ${prefix}ProcessOptions { + /** Path of the binary. Default: ${this.opts.binaryEnvVar}, then the installed arch package. */ binaryPath?: string; - transport?: ${serviceTransport}; + transport?: ${prefix}Transport; + /** 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[]; - createError?: IpcErrorFactory; /** * Respawn the server on the next call after it dies, instead of failing all * subsequent calls. Only enable for stateless servers: a respawned process @@ -349,15 +351,245 @@ export interface ${serviceOptions} { * silently dangle. */ respawn?: boolean; -${supportsShm ? " napiPath?: string;\n clientId?: number;\n" : ""}} + /** Let node exit while the process is alive (it must exit on its own when its parent does). */ + unref?: boolean; + /** Also unref the child's stdout/stderr pipes (present with \`logger\`); log lines may then go unread at exit. */ + unrefStdio?: boolean; + createError?: IpcErrorFactory; +${shm ? " /** shm: fixed client slot; default: self-allocated (0 for the synchronous backend). */\n clientId?: number;\n /** shm: override the ipc-runtime native addon path. */\n napiPath?: string;\n" : ""}} + +/** The name this package's first version used for the spawn options. */ +export type ${prefix}ServiceOptions = ${prefix}ProcessOptions; + +/** The process's environment: the caller's, plus the thread count under both names services read. */ +export function processEnv(options: { threads?: number; env?: NodeJS.ProcessEnv }): 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 resolveBinary(binaryPath?: string): string { + const resolved = ${findBinary}(binaryPath); + if (!resolved) { + throw new IpcSpawnError('${binaryName} binary not found', /*retry=*/ false); + } + return resolved; +} + +/** + * Spawn '${binaryName}' and connect to it. Process lifecycle — connectivity, death detection, + * optional respawn, teardown — is owned by the backend (see SpawnedProcessBackend in ipc-runtime). + * Failed calls carry a 'retry' property set to true when the failure was environmental. + */ +export async function spawnProcessBackend(options: ${prefix}ProcessOptions = {}): Promise { + return SpawnedProcessBackend.spawn({ + binaryPath: resolveBinary(options.binaryPath), + binaryName: '${binaryName}', + instancePrefix: '${toSnakeCase(prefix)}', + ipcPathArgs: ${ipcPathArgs}, + transport: options.transport ?? '${defaultTransport}', + logger: options.logger, + connectTimeoutMs: options.connectTimeoutMs, + env: processEnv(options), + extraArgs: options.extraArgs, + respawn: options.respawn, + unref: options.unref, + unrefStdio: options.unrefStdio, +${shm ? " clientId: options.clientId,\n napiPath: options.napiPath,\n" : ""} }); +} +${ + shm + ? ` +/** The synchronous process backend: shared memory is the one transport with a synchronous client. */ +export async function spawnProcessBackendSync(options: ${prefix}ProcessOptions = {}): Promise { + if (options.transport !== undefined && options.transport !== 'shm') { + throw new Error('${packageName}: the synchronous backend needs the shm transport'); + } + return SpawnedProcessBackendSync.spawn({ + binaryPath: resolveBinary(options.binaryPath), + binaryName: '${binaryName}', + instancePrefix: '${toSnakeCase(prefix)}-sync', + ipcPathArgs: ${ipcPathArgs}, + transport: 'shm', + logger: options.logger, + connectTimeoutMs: options.connectTimeoutMs, + env: processEnv(options), + extraArgs: options.extraArgs, + unref: options.unref, + unrefStdio: options.unrefStdio, + clientId: options.clientId ?? 0, + napiPath: options.napiPath, + }); +} +` + : "" +}`; + } + + /** 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 { + /** + * 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. + */ + 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; + createError?: IpcErrorFactory; + /** 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' | 'createError'>;\n` : ""}${wasm ? ` wasm?: Omit<${prefix}WasmOptions, 'threads' | 'logger' | 'createError'>;\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; + createError?: IpcErrorFactory; + unref?: boolean; +${process ? ` process?: Omit<${prefix}ProcessOptions, 'threads' | 'logger' | 'createError'>;\n` : ""}${wasm ? ` wasm?: Omit<${prefix}WasmOptions, 'threads' | 'worker' | 'logger' | 'createError'>;\n` : ""}} +`; + } + + /** 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 ${svc} extends AsyncApi { + private constructor(backend: IpcClientAsync, createError?: IpcErrorFactory) { + super(backend, createError); + } + + static async create(options: ${prefix}CreateOptions = {}): Promise<${svc}> { + return new ${svc}(await createBackend(options), options.createError); + } ${ - wasm + 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), options.createError); + } +` + : "" +}${ + 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), options.createError); + } +` + : "" + }${ + 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.requireProcess().getIpcPath(); + } + + sendProcessSignal(signal: NodeJS.Signals): void { + 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, createError?: IpcErrorFactory) { + super(backend, createError); + } + + static async create(options: ${prefix}CreateSyncOptions = {}): Promise<${svc}Sync> { + return new ${svc}Sync(await createBackendSync(options), options.createError); + } +${ + 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), options.createError); + } +` + : "" +}${ + 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), options.createError); + } +` + : "" + }} +`; + } + + /** 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 ? ", SpawnedProcessBackend" : ""} } from '@aztec-foundation/ipc-runtime'; +${wasm ? "import { type WasmFfiBackend, platform } from '@aztec-foundation/ipc-runtime/wasm';\n" : ""}import { AsyncApi, type IpcErrorFactory } 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, createWasmBackendSync, createWasmBackendWith } 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)}${ + wasm + ? ` /** - * Runs the ${this.opts.binaryName} wasm module in-process (node): the main instance in a worker - * thread by default, wasi threads on further workers. + * The ${binaryName} wasm module in-process (node): the main instance in a worker thread by default, + * wasi threads on further workers. */ -export function createWasmBackend(options: ${wasmOptions} = {}): Promise { +export function createWasmBackend(options: ${prefix}WasmOptions = {}): Promise { return createWasmBackendWith( { createMainWorker: () => platform.createWorker(new URL('./wasm/main.worker.js', import.meta.url)), @@ -367,91 +599,115 @@ export function createWasmBackend(options: ${wasmOptions} = {}): Promise { - 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, backend, options.createError); +export async function createBackend(options: ${prefix}CreateOptions = {}): Promise { + if (typeof options.backend === 'object') { + return options.backend; } + const common = { threads: options.threads, logger: options.logger, unref: options.unref }; ${ - wasm - ? ` - /** The service over the in-process wasm module instead of a spawned process. */ - static async wasm(options: ${wasmOptions} = {}): Promise<${serviceClass}> { - return new ${serviceClass}(await createWasmBackend(options), undefined, options.createError); + process + ? ` if (options.backend === 'process' || (options.backend === undefined && (!${wasm} || ${findBinary}(options.process?.binaryPath)))) { + try { + return await spawnProcessBackend({ ...common, unrefStdio: options.unref, ...options.process }); + } catch (err) { + if (options.backend === 'process' || !${wasm}) { + throw err; + } + options.logger?.(\`${binaryName} process unavailable (\${(err as Error).message}); falling back to wasm\`); + } } ` : "" -} - getIpcPath(): string { - return this.requireSpawned().getIpcPath(); +}${ + wasm + ? ` if (options.backend === undefined || options.backend === 'wasm') { + return createWasmBackend({ ...common, ...options.wasm }); } +` + : "" + } throw new Error(\`${packageName}: no such backend here: \${String(options.backend)}\`); +} - sendProcessSignal(signal: NodeJS.Signals): void { - this.requireSpawned().sendProcessSignal(signal); +/** 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 { + if (typeof options.backend === 'object') { + return options.backend; } - - private requireSpawned(): SpawnedProcessBackend { - if (!this.spawnedBackend) { - throw new Error('${serviceClass}: not backed by a spawned process'); + const common = { threads: options.threads, logger: options.logger, unref: options.unref }; +${ + shm + ? ` if (options.backend === 'process' || (options.backend === undefined && (!${wasm} || ${findBinary}(options.process?.binaryPath)))) { + try { + return await spawnProcessBackendSync({ ...common, unrefStdio: options.unref, ...options.process }); + } catch (err) { + if (options.backend === 'process' || !${wasm}) { + throw err; + } + options.logger?.(\`${binaryName} process unavailable (\${(err as Error).message}); falling back to wasm\`); } - return this.spawnedBackend; } +` + : "" +}${ + wasm + ? ` if (options.backend === undefined || options.backend === 'wasm') { + return createWasmBackendSync({ logger: options.logger, unref: options.unref, ...options.wasm }); + } +` + : "" + } throw new Error(\`${packageName}: no such synchronous backend here: \${String(options.backend)}\`); } -`; + +${this.serviceClasses({ process, wasm })}`; } /** Browser entry: the service runs in-process as a wasm module; there is no process to spawn. */ generateBrowserIndex(): string { - const prefix = this.opts.prefix; - const serviceClass = className(prefix); - const wasmOptions = wasmOptionsType(prefix); + const { prefix, packageName } = this.opts; - return `import type { IpcClientAsync } from '@aztec-foundation/ipc-runtime'; + return `import type { IpcClientAsync, IpcClientSync } from '@aztec-foundation/ipc-runtime'; import { type WasmFfiBackend, workerHandle } from '@aztec-foundation/ipc-runtime/wasm'; import { AsyncApi, type IpcErrorFactory } from './generated/async.js'; -import { type ${wasmOptions}, createWasmBackendWith } from './wasm.js'; +import { SyncApi } from './generated/sync.js'; +import { type ${prefix}WasmOptions, createWasmBackendSync, createWasmBackendWith } 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; + createError?: IpcErrorFactory; + unref?: boolean; + wasm?: Omit<${prefix}WasmOptions, 'threads' | 'logger' | 'createError'>; +} + +/** Options for ${className(prefix)}Sync.create / createBackendSync. */ +export interface ${prefix}CreateSyncOptions { + backend?: ${prefix}Backend | IpcClientSync; + logger?: (msg: string) => void; + createError?: IpcErrorFactory; + unref?: boolean; + wasm?: Omit<${prefix}WasmOptions, 'threads' | 'worker' | 'logger' | 'createError'>; +} + /** - * Runs the ${this.opts.binaryName} wasm module in-process: the main instance in a web worker by - * default, wasi threads on further workers when the page is cross-origin isolated (COOP/COEP). - * The worker scripts are spawned with the literal expression bundlers detect, so they ship as - * worker chunks of the consuming application. + * The ${this.opts.binaryName} wasm module in-process: the main instance in a web worker by default, + * wasi threads on further workers when the page is cross-origin isolated (COOP/COEP). The worker + * scripts are spawned with the literal expression bundlers detect, so they ship as worker chunks + * of the consuming application. */ -export function createWasmBackend(options: ${wasmOptions} = {}): Promise { +export function createWasmBackend(options: ${prefix}WasmOptions = {}): Promise { return createWasmBackendWith( { createMainWorker: () => @@ -463,14 +719,116 @@ export function createWasmBackend(options: ${wasmOptions} = {}): 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 }); +} + +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 }); +} + +${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. + */ + generateReactNativeIndex(): string { + const { prefix, packageName, binaryName } = this.opts; + const svc = className(prefix); + + return `import type { IpcClientAsync, IpcClientSync } from '@aztec-foundation/ipc-runtime'; +import { AsyncApi, type IpcErrorFactory } from './generated/async.js'; +import { SyncApi } from './generated/sync.js'; + +${this.generatedExports()} +/** Backend factories a native backend package registers for a service, by service name. */ +export interface RegisteredBackends { + async?: () => Promise | IpcClientAsync; + sync?: () => Promise | IpcClientSync; +} + +// A well-known global rather than an import in either direction, so the native package and this +// one need not depend on each other (a type-only import keeps the runtime free of ipc-runtime). +const REGISTRY_KEY = Symbol.for('@aztec-foundation/ipc-runtime/ffi-backends'); + +function registry(): Map { + const global = globalThis as unknown as Record | undefined>; + return (global[REGISTRY_KEY] ??= new Map()); +} + +/** Make \`factories\` the default backends for ${prefix} in this app; a native backend package calls this when imported. */ +export function registerBackend(factories: RegisteredBackends): void { + registry().set('${prefix}', factories); +} + +export interface ${prefix}CreateOptions { + /** A backend object (anything with call()/destroy()); default: the one a native package registered. */ + backend?: IpcClientAsync; + createError?: IpcErrorFactory; +} + +export interface ${prefix}CreateSyncOptions { + backend?: IpcClientSync; + createError?: IpcErrorFactory; +} + +const NO_BACKEND = + '${packageName}: no backend for React Native. Install a native backend package for ${binaryName} (it registers itself when imported) or pass one in options.backend.'; + +export async function createBackend(options: ${prefix}CreateOptions = {}): Promise { + if (options.backend) { + return options.backend; + } + const registered = registry().get('${prefix}')?.async; + if (!registered) { + throw new Error(NO_BACKEND); + } + return await registered(); +} + +export async function createBackendSync(options: ${prefix}CreateSyncOptions = {}): Promise { + if (options.backend) { + return options.backend; + } + const registered = registry().get('${prefix}')?.sync; + if (!registered) { + throw new Error(NO_BACKEND); + } + return await registered(); +} + +export class ${svc} extends AsyncApi { private constructor(backend: IpcClientAsync, createError?: IpcErrorFactory) { super(backend, createError); } - /** The service over the in-process wasm module. */ - static async wasm(options: ${wasmOptions} = {}): Promise<${serviceClass}> { - return new ${serviceClass}(await createWasmBackend(options), options.createError); + static async create(options: ${prefix}CreateOptions = {}): Promise<${svc}> { + return new ${svc}(await createBackend(options), options.createError); + } +} + +export class ${svc}Sync extends SyncApi { + private constructor(backend: IpcClientSync, createError?: IpcErrorFactory) { + super(backend, createError); + } + + static async create(options: ${prefix}CreateSyncOptions = {}): Promise<${svc}Sync> { + return new ${svc}Sync(await createBackendSync(options), options.createError); } } `; @@ -478,8 +836,7 @@ export class ${serviceClass} extends AsyncApi { /** Platform-neutral part of the wasm transport: options, module selection, backend construction. */ generateWasm(): string { - const prefix = this.opts.prefix; - const wasmOptions = wasmOptionsType(prefix); + 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). @@ -500,9 +857,15 @@ export class ${serviceClass} extends AsyncApi { import type { IpcErrorFactory } from './generated/async.js'; import { hostImports } from './wasm_host_imports.js'; +export { sharedMemoryAvailable } from '@aztec-foundation/ipc-runtime/wasm'; + /** Options for running the ${this.opts.binaryName} wasm module in-process. */ -export interface ${wasmOptions} { - /** Threads to run with (1 = no worker threads). Default: the platform's parallelism, capped at 32. */ +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 }; @@ -516,6 +879,8 @@ export interface ${wasmOptions} { /** WASI environ for the module. */ env?: Record; logger?: (msg: string) => void; + /** Let node exit while the module's workers are alive. */ + unref?: boolean; createError?: IpcErrorFactory; } @@ -529,22 +894,34 @@ const THREADS_MODULE: URL | undefined = ${moduleUrl(this.opts.wasmThreadsModule) const SINGLE_MODULE: URL | undefined = ${moduleUrl(this.opts.wasmModule)}; /** - * The package's own module for a thread count: the threads build when more than one thread is - * wanted and a shared memory is available, otherwise the single-thread build (each falls back to - * the other when the package ships only one). + * The package's own module for a thread count: the threads build for more than one thread, + * otherwise the single-thread build (each falls back to the other when the package ships only one). */ export function defaultWasmModule(threads: number): URL { - const threaded = threads > 1 && sharedMemoryAvailable(); - const module = threaded ? (THREADS_MODULE ?? SINGLE_MODULE) : (SINGLE_MODULE ?? THREADS_MODULE); + const module = threads > 1 ? (THREADS_MODULE ?? SINGLE_MODULE) : (SINGLE_MODULE ?? THREADS_MODULE); if (!module) { - throw new Error('${this.opts.packageName}: no wasm module ships with this package'); + throw new Error('${packageName}: no wasm module ships with this package'); } return module; } -export function createWasmBackendWith(workers: WasmWorkers, options: ${wasmOptions} = {}): Promise { - const threads = options.threads ?? platform.hardwareConcurrency(); - return createWasmFfiBackend({ +/** The thread count to run with: the default where none was asked for, else the request, checked. */ +export function resolveThreads(threads?: number): number { + if (threads === undefined) { + return sharedMemoryAvailable() ? platform.hardwareConcurrency() : 1; + } + if (threads > 1 && !sharedMemoryAvailable()) { + throw new Error( + \`${packageName}: \${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; +} + +export async function createWasmBackendWith(workers: WasmWorkers, options: ${prefix}WasmOptions = {}): Promise { + const threads = resolveThreads(options.threads); + const backend = await createWasmFfiBackend({ module: options.module ?? defaultWasmModule(threads), threads, memory: options.memory, @@ -556,11 +933,15 @@ ${ffiExports} createMainWorker: workers.createMainWorker, createThreadWorker: workers.createThreadWorker, }); + if (options.unref) { + backend.unref(); + } + return backend; } /** The module on the calling thread with one thread: every call blocks until it returns. */ -export function createWasmBackendSync(options: ${wasmOptions} = {}): Promise { - return createWasmFfiBackendSync({ +export async function createWasmBackendSync(options: ${prefix}WasmOptions = {}): Promise { + const backend = await createWasmFfiBackendSync({ module: options.module ?? defaultWasmModule(1), threads: 1, memory: options.memory, @@ -569,6 +950,10 @@ export function createWasmBackendSync(options: ${wasmOptions} = {}): Promise Date: Wed, 9 Sep 2026 16:54:48 +0000 Subject: [PATCH 05/26] fix(ipc-codegen): package README describes only the backends the package has A package without the wasm transport was told it had a browser backend and got a dangling clause in the synchronous-form sentence. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- .../echo_example/ts_package/.gitignore | 2 ++ ipc-codegen/echo_example/ts_package/README.md | 24 ++++++++++++---- ipc-codegen/src/typescript_package_codegen.ts | 28 +++++++++++++------ 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/ipc-codegen/echo_example/ts_package/.gitignore b/ipc-codegen/echo_example/ts_package/.gitignore index 79f99d29e23c..c959ed1378e9 100644 --- a/ipc-codegen/echo_example/ts_package/.gitignore +++ b/ipc-codegen/echo_example/ts_package/.gitignore @@ -7,6 +7,8 @@ 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 diff --git a/ipc-codegen/echo_example/ts_package/README.md b/ipc-codegen/echo_example/ts_package/README.md index 6e04a94a70cd..9801c557c954 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,24 @@ 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. `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). +- 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 +(the process reads it from `HARDWARE_CONCURRENCY`/`RAYON_NUM_THREADS`). `EchoServiceSync.create` is the synchronous form (shared memory for a process). +`createBackend`/`createBackendSync` expose the same policy for code that wraps +the generated API itself. + +## Entries per host + +The package resolves to a different entry per host through export conditions: +node (`default`) has every backend above; 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`. ## 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/typescript_package_codegen.ts b/ipc-codegen/src/typescript_package_codegen.ts index dd5b8468a8f1..59025eea1e12 100644 --- a/ipc-codegen/src/typescript_package_codegen.ts +++ b/ipc-codegen/src/typescript_package_codegen.ts @@ -1165,6 +1165,22 @@ done ] .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} @@ -1187,21 +1203,15 @@ try { ${backends} -\`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). \`${svc}Sync.create\` is the synchronous form -(${this.shm ? "shared memory for a process, " : ""}${wasm ? "the single-threaded wasm module on the calling thread" : ""}). +\`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. ## 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\`. +${hosts}. ## Build From dbe22bcaf2eccd4adaba1717a70b5ddf0a9f912c Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:56:57 +0000 Subject: [PATCH 06/26] fix(barretenberg): order the cross-copy targets that share node_modules cross_copy_bb_js now stages bb.js-api's binaries too, so it installs and builds in barretenberg/ts like bb-avm-sim-cross-copy does; the two had no ordering between them. bb.js's build also ran the API package's generate-and-compile twice. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- Makefile | 10 +++++----- barretenberg/ts/bb.js/bootstrap.sh | 7 +++---- 2 files changed, 8 insertions(+), 9 deletions(-) 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/ts/bb.js/bootstrap.sh b/barretenberg/ts/bb.js/bootstrap.sh index cd1a27e2fc22..0ca192183a6d 100755 --- a/barretenberg/ts/bb.js/bootstrap.sh +++ b/barretenberg/ts/bb.js/bootstrap.sh @@ -26,11 +26,10 @@ function formatting { function build { echo_header "bb.js build" - prepare_project - yarn formatting - # The wasm modules and bb binary bb.js runs at test time ship in bb.js-api; stage them - # whether or not bb.js's own build is cached. + # 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 From 5cd188021f16c4706dcc0e85a5208f1e4e4b080d Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:01:15 +0000 Subject: [PATCH 07/26] refactor(ipc-runtime): create wasm threads on demand A worker runs a thread to completion inside wasi_thread_start, so round-robin over a fixed pool of threads-1 workers only worked because bb spawns exactly that many: any module spawning more would have queued two threads on one worker, and the second would never run. Create a worker when the module asks for a thread and drop it when the thread exits, so the workers follow the module's own pool and a module that never spawns costs nothing. The thread worker now serializes init against start rather than relying on the parent to await readiness first. Adds engine tests over a hand-built module: on-demand creation, the single- threaded refusal, following the module's shared-memory declaration, and narrowing an over-large memory maximum. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- ipc-runtime/ts/src/wasm/backend.ts | 122 +++++++----- ipc-runtime/ts/src/wasm/thread_worker.ts | 26 ++- ipc-runtime/ts/src/wasm_engine.test.ts | 186 ++++++++++++++++++ ipc-runtime/ts/src/wasm_engine_stub.worker.ts | 2 + 4 files changed, 277 insertions(+), 59 deletions(-) create mode 100644 ipc-runtime/ts/src/wasm_engine.test.ts create mode 100644 ipc-runtime/ts/src/wasm_engine_stub.worker.ts diff --git a/ipc-runtime/ts/src/wasm/backend.ts b/ipc-runtime/ts/src/wasm/backend.ts index 938820e92ef5..e6b09d2ef8e3 100644 --- a/ipc-runtime/ts/src/wasm/backend.ts +++ b/ipc-runtime/ts/src/wasm/backend.ts @@ -73,59 +73,86 @@ function awaitReady(worker: WorkerHandle, what: string): Promise { }); } -/** `threads - 1` workers, each holding one instance of the module over the shared memory. */ -class ThreadPool { - private next = 0; - - private constructor(private readonly workers: WorkerHandle[]) {} - - static async create( - createWorker: () => WorkerHandle, - count: number, - init: Record, - logger: (msg: string) => void, - ): Promise { - const workers = Array.from({ length: count }, createWorker); - await Promise.all( - workers.map((w) => { - w.onMessage((msg) => { - if (msg?.type === "log") { - logger(msg.message); - } - }); - const ready = awaitReady(w, "wasm thread worker"); - w.postMessage({ type: "init", ...init }); - return ready; - }), - ); - return new ThreadPool(workers); - } +/** + * 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: run the module's thread entry with `startArg` as thread `tid` on the next worker. */ - start(tid: number, startArg: number): void { - const worker = this.workers[this.next++ % this.workers.length]; + /** + * 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 { - for (const w of this.workers) { + this.unrefed = true; + for (const w of this.workers.values()) { w.unref(); } } async destroy(): Promise { - await Promise.all(this.workers.map((w) => w.terminate())); + 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 thread pool serving - * its `thread-spawn` requests. `call` is synchronous and blocks until the module returns. + * 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( - private readonly host: WasmInstanceHost, - private readonly pool: ThreadPool | undefined, + /** 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, ) {} @@ -166,11 +193,12 @@ export class WasmFfiEngine { `wasm: ${threads} thread(s), memory ${memory.buffer.byteLength >> 16} pages initial, shared=${shared}`, ); - const pool = + // 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 - ? await ThreadPool.create( + ? new Threads( binding.createThreadWorker, - threads - 1, { module, memory, @@ -182,7 +210,6 @@ export class WasmFfiEngine { ) : undefined; - let nextTid = FIRST_THREAD_ID; const host = await WasmInstanceHost.instantiate({ module, memory, @@ -192,16 +219,9 @@ export class WasmFfiEngine { entry: opts.entry, allocatorExports: opts.allocatorExports, threads, - spawnThread: (startArg) => { - if (!pool) { - return -1; - } - const tid = nextTid++; - pool.start(tid, startArg); - return tid; - }, + spawnThread: (startArg) => threadWorkers?.spawn(startArg) ?? -1, }); - return new WasmFfiEngine(host, pool, memory, threads); + return new WasmFfiEngine(host, threadWorkers, memory, threads); } call(input: Uint8Array): Uint8Array { @@ -209,11 +229,11 @@ export class WasmFfiEngine { } unref(): void { - this.pool?.unref(); + this.threadWorkers?.unref(); } async destroy(): Promise { - await this.pool?.destroy(); + await this.threadWorkers?.destroy(); } } diff --git a/ipc-runtime/ts/src/wasm/thread_worker.ts b/ipc-runtime/ts/src/wasm/thread_worker.ts index a1ffb16fa6af..0509275d424a 100644 --- a/ipc-runtime/ts/src/wasm/thread_worker.ts +++ b/ipc-runtime/ts/src/wasm/thread_worker.ts @@ -7,22 +7,25 @@ export interface ThreadWorkerOptions { } /** - * Body of a wasi-threads worker: instantiate the module over the shared memory on `init`, then - * run the module's thread entry for each `start`. A thread runs to completion inside - * `wasi_thread_start`, so one worker serves one wasi thread at a time — the pool is sized to the - * number of threads the module will create. + * 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, opts: ThreadWorkerOptions = {}, ): void { - let host: WasmInstanceHost | undefined; + // `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": - host = await WasmInstanceHost.instantiate({ + instantiated = WasmInstanceHost.instantiate({ module: msg.module, memory: msg.memory, env: msg.env, @@ -35,12 +38,18 @@ export function runThreadWorker( spawnThread: () => -1, logger: log, }); + await instantiated; side.postMessage({ type: "ready" }); break; - case "start": - host!.callExport("wasi_thread_start", msg.tid, msg.startArg); + 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; } @@ -50,6 +59,7 @@ export function runThreadWorker( 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_engine.test.ts b/ipc-runtime/ts/src/wasm_engine.test.ts new file mode 100644 index 000000000000..c2cf25b09e13 --- /dev/null +++ b/ipc-runtime/ts/src/wasm_engine.test.ts @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { WasmFfiEngine } from "./wasm/backend.js"; +import type { WasmFfiBinding, WorkerHandle } from "./wasm/index.node.js"; +import { nodePlatform } from "./wasm/platform.node.js"; + +// A module small enough to write out by hand, so the engine's behaviour can be pinned without a +// real service: it imports a memory and wasi-threads, and exports the FFI contract's three symbols +// plus a `spawn_thread` that forwards to the import. +function buildModule(opts: { + shared: boolean; + min: number; + max: number; +}): WebAssembly.Module { + const str = (s: string) => [s.length, ...[...s].map((c) => c.charCodeAt(0))]; + const section = (id: number, body: number[]) => [id, body.length, ...body]; + const I32 = 0x7f; + + const types = section(1, [ + 3, + 0x60, + 1, + I32, + 1, + I32, // (i32) -> i32 + 0x60, + 4, + I32, + I32, + I32, + I32, + 0, // (i32,i32,i32,i32) -> () + 0x60, + 1, + I32, + 0, // (i32) -> () + ]); + const imports = section(2, [ + 2, + ...str("env"), + ...str("memory"), + 0x02, + opts.shared ? 0x03 : 0x01, + opts.min, + opts.max, + ...str("wasi"), + ...str("thread-spawn"), + 0x00, + 0, + ]); + // Local functions: 1 spawn_thread, 2 ipc_ffi_entry, 3 ipc_ffi_alloc, 4 ipc_ffi_free + // (index 0 is the imported thread-spawn). + const functions = section(3, [4, 0, 1, 0, 2]); + const exports = section(7, [ + 4, + ...str("spawn_thread"), + 0x00, + 1, + ...str("ipc_ffi_entry"), + 0x00, + 2, + ...str("ipc_ffi_alloc"), + 0x00, + 3, + ...str("ipc_ffi_free"), + 0x00, + 4, + ]); + // body size, no locals, the code, and the `end` every function body carries. + const body = (code: number[]) => [code.length + 2, 0, ...code, 0x0b]; + const code = section(10, [ + 4, + ...body([0x20, 0, 0x10, 0]), // spawn_thread(arg) = thread-spawn(arg) + ...body([]), // ipc_ffi_entry: does nothing + ...body([0x41, 0x10]), // ipc_ffi_alloc: always the same pointer + ...body([]), // ipc_ffi_free: does nothing + ]); + + return new WebAssembly.Module( + new Uint8Array([ + 0x00, + 0x61, + 0x73, + 0x6d, + 0x01, + 0x00, + 0x00, + 0x00, + ...types, + ...imports, + ...functions, + ...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's `wasi_thread_start` does not exist, 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"); + }, + }; +} + +test("creates no thread workers until the module spawns one, then one per thread", async () => { + const binding = countingBinding(); + const engine = await WasmFfiEngine.create( + { + module: buildModule({ shared: true, min: 1, max: 4 }), + threads: 4, + memory: { initial: 1, maximum: 4 }, + }, + 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: buildModule({ shared: true, min: 1, max: 4 }), + threads: 1, + memory: { initial: 1, maximum: 4 }, + }, + binding, + ); + const spawn = (arg: number) => engine.host.callExport("spawn_thread", arg); + assert.equal(spawn(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: 1, max: 4 }), + threads: 4, + memory: { initial: 1, maximum: 4 }, + }, + 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; 4 pages here against the engine's default ceiling. + const engine = await WasmFfiEngine.create( + { + module: buildModule({ shared: true, min: 1, max: 4 }), + threads: 2, + memory: { initial: 1 }, + }, + countingBinding(), + ); + assert.equal(engine.memory.buffer.byteLength, 64 * 1024); + 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 {}; From f1d496b5f8d80bef1961b6453de9a04dd8149ce9 Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:20:34 +0000 Subject: [PATCH 08/26] perf(ipc-runtime): reuse one request buffer and the memory views per call Each call allocated a request buffer and a slot pair in the module, freed both, and built three JS views over module memory. Hold one buffer that grows to fit and cache the views, rebuilding them only when a call grows memory. The cheapest call bb offers (blake2s of one byte) goes 1.455 -> 1.254 us; a call doing real work is unchanged, since the saving is fixed per call. The response still comes back in a buffer the module allocated, as the FFI contract says: measured, that malloc is a small part of the fixed cost, and taking it out would change the contract for every language. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- ipc-runtime/ts/src/wasm/host.ts | 87 +++++++++----- ipc-runtime/ts/src/wasm_engine.test.ts | 150 +++++++++++++++++++------ 2 files changed, 176 insertions(+), 61 deletions(-) diff --git a/ipc-runtime/ts/src/wasm/host.ts b/ipc-runtime/ts/src/wasm/host.ts index 98f7d9ebd8b0..469ab3063bb1 100644 --- a/ipc-runtime/ts/src/wasm/host.ts +++ b/ipc-runtime/ts/src/wasm/host.ts @@ -58,6 +58,11 @@ export const FALLBACK_ALLOCATOR_EXPORTS: Array<[string, string]> = [ 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, @@ -198,42 +203,74 @@ export class WasmInstanceHost { 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); + } + } + /** - * One FFI round trip: request bytes in, a copy of the response bytes out. The request is - * placed in module memory with the module's allocator, the response is read from where the - * module left it and freed with the module's free, as the FFI contract requires. + * 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. */ - call(input: Uint8Array): Uint8Array { - const inPtr = input.length > 0 ? this.alloc(input.length) >>> 0 : 0; - if (input.length > 0 && inPtr === 0) { - throw new Error("wasm module allocation failed for the request buffer"); + 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`, + ); } - const slots = this.alloc(8) >>> 0; - if (slots === 0) { - throw new Error("wasm module allocation failed for the response slots"); + 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; try { - if (input.length > 0) { - new Uint8Array(this.memory.buffer).set(input, inPtr); - } - const before = new DataView(this.memory.buffer); - before.setUint32(slots, 0, true); - before.setUint32(slots + 4, 0, true); + this.refreshViews(); + this.bytes.set(input, inPtr); + this.words.setUint32(slots, 0, true); + this.words.setUint32(slots + 4, 0, true); this.entry(inPtr, input.length, slots, slots + 4); - // Re-read through a fresh view: the call may have grown memory and detached the old buffer. - const after = new DataView(this.memory.buffer); - outPtr = after.getUint32(slots, true); - const outLen = after.getUint32(slots + 4, true); - return new Uint8Array(this.memory.buffer).slice(outPtr, outPtr + outLen); + // 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); } - this.free(slots); - if (inPtr !== 0) { - this.free(inPtr); - } } } } diff --git a/ipc-runtime/ts/src/wasm_engine.test.ts b/ipc-runtime/ts/src/wasm_engine.test.ts index c2cf25b09e13..3c07809cdcf2 100644 --- a/ipc-runtime/ts/src/wasm_engine.test.ts +++ b/ipc-runtime/ts/src/wasm_engine.test.ts @@ -4,17 +4,34 @@ import { WasmFfiEngine } from "./wasm/backend.js"; import type { WasmFfiBinding, WorkerHandle } from "./wasm/index.node.js"; import { nodePlatform } from "./wasm/platform.node.js"; -// A module small enough to write out by hand, so the engine's behaviour can be pinned without a -// real service: it imports a memory and wasi-threads, and exports the FFI contract's three symbols -// plus a `spawn_thread` that forwards to the import. +/** + * 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; }): 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, body.length, ...body]; + 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, @@ -22,18 +39,18 @@ function buildModule(opts: { 1, I32, 1, - I32, // (i32) -> i32 + I32, // (i32) -> i32 alloc, spawn_thread 0x60, 4, I32, I32, I32, I32, - 0, // (i32,i32,i32,i32) -> () + 0, // (i32 x4) -> () entry 0x60, 1, I32, - 0, // (i32) -> () + 0, // (i32) -> () free ]); const imports = section(2, [ 2, @@ -41,39 +58,77 @@ function buildModule(opts: { ...str("memory"), 0x02, opts.shared ? 0x03 : 0x01, - opts.min, - opts.max, + ...leb(opts.min), + ...leb(opts.max), ...str("wasi"), ...str("thread-spawn"), 0x00, 0, ]); - // Local functions: 1 spawn_thread, 2 ipc_ffi_entry, 3 ipc_ffi_alloc, 4 ipc_ffi_free - // (index 0 is the imported thread-spawn). 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, [ 4, ...str("spawn_thread"), 0x00, - 1, + SPAWN, ...str("ipc_ffi_entry"), 0x00, - 2, + ENTRY, ...str("ipc_ffi_alloc"), 0x00, - 3, + ALLOC, ...str("ipc_ffi_free"), 0x00, - 4, + FREE, ]); - // body size, no locals, the code, and the `end` every function body carries. - const body = (code: number[]) => [code.length + 2, 0, ...code, 0x0b]; + 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, - ...body([0x20, 0, 0x10, 0]), // spawn_thread(arg) = thread-spawn(arg) - ...body([]), // ipc_ffi_entry: does nothing - ...body([0x41, 0x10]), // ipc_ffi_alloc: always the same pointer - ...body([]), // ipc_ffi_free: does nothing + // 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( @@ -89,6 +144,7 @@ function buildModule(opts: { ...types, ...imports, ...functions, + ...globals, ...exports, ...code, ]), @@ -103,8 +159,8 @@ function countingBinding(): WasmFfiBinding & { created: () => number } { created: () => created, createThreadWorker: () => { created++; - // The thread never starts: this module's `wasi_thread_start` does not exist, and the test is - // about when a worker is asked for, not what runs on it. + // 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), ); @@ -115,13 +171,15 @@ function countingBinding(): WasmFfiBinding & { created: () => number } { }; } +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: buildModule({ shared: true, min: 1, max: 4 }), + module: threadedModule(), threads: 4, - memory: { initial: 1, maximum: 4 }, + memory: { initial: 4, maximum: 64 }, }, binding, ); @@ -142,14 +200,17 @@ test("refuses to spawn when the engine runs single-threaded", async () => { const binding = countingBinding(); const engine = await WasmFfiEngine.create( { - module: buildModule({ shared: true, min: 1, max: 4 }), + module: threadedModule(), threads: 1, - memory: { initial: 1, maximum: 4 }, + memory: { initial: 4, maximum: 64 }, }, binding, ); - const spawn = (arg: number) => engine.host.callExport("spawn_thread", arg); - assert.equal(spawn(0), -1, "wasi-threads errno, not a tid"); + assert.equal( + engine.host.callExport("spawn_thread", 0), + -1, + "wasi-threads errno, not a tid", + ); assert.equal(binding.created(), 0); await engine.destroy(); }); @@ -159,9 +220,9 @@ test("follows the module's memory declaration rather than the request", async () // change that, so the engine drops to one thread rather than failing to link. const engine = await WasmFfiEngine.create( { - module: buildModule({ shared: false, min: 1, max: 4 }), + module: buildModule({ shared: false, min: 4, max: 64 }), threads: 4, - memory: { initial: 1, maximum: 4 }, + memory: { initial: 4, maximum: 64 }, }, countingBinding(), ); @@ -172,15 +233,32 @@ test("follows the module's memory declaration rather than the request", async () 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; 4 pages here against the engine's default ceiling. + // 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: buildModule({ shared: true, min: 1, max: 4 }), - threads: 2, - memory: { initial: 1 }, + module: threadedModule(), + threads: 1, + memory: { initial: 64, maximum: 64 }, }, countingBinding(), ); - assert.equal(engine.memory.buffer.byteLength, 64 * 1024); + // 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(); }); From d4256b9a7c25d259e0e41921606008aaf554d135 Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:18:14 +0000 Subject: [PATCH 09/26] refactor(ipc-runtime): share the platform entries, drop the allocator override The node and browser entries were near-identical: fourteen re-exports and three factory functions each, differing only in the platform they bind and how they spawn a worker. They now share entry.ts and are twenty lines apiece. The allocator pair was both discovered from the entry's own name and passable as an option, threaded through five files to reach the instance. Only the discovery is kept; `entry` stays, since a binary linking two services is the reason its name is service-prefixed at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- ipc-codegen/src/typescript_package_codegen.ts | 3 +- ipc-runtime/ts/src/wasm/backend.ts | 5 --- ipc-runtime/ts/src/wasm/entry.ts | 40 +++++++++++++++++++ ipc-runtime/ts/src/wasm/host.ts | 7 +--- ipc-runtime/ts/src/wasm/index.browser.ts | 40 ++++--------------- ipc-runtime/ts/src/wasm/index.node.ts | 40 ++++--------------- ipc-runtime/ts/src/wasm/main_worker.ts | 1 - ipc-runtime/ts/src/wasm/thread_worker.ts | 1 - 8 files changed, 56 insertions(+), 81 deletions(-) create mode 100644 ipc-runtime/ts/src/wasm/entry.ts diff --git a/ipc-codegen/src/typescript_package_codegen.ts b/ipc-codegen/src/typescript_package_codegen.ts index 59025eea1e12..0a4539b2a81b 100644 --- a/ipc-codegen/src/typescript_package_codegen.ts +++ b/ipc-codegen/src/typescript_package_codegen.ts @@ -841,8 +841,7 @@ export class ${svc}Sync extends SyncApi { 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', - allocatorExports: [['${sym}ipc_ffi_alloc', '${sym}ipc_ffi_free']],`; + const ffiExports = ` entry: '${sym}ipc_ffi_entry',`; return `import { type WasmFfiBackend, diff --git a/ipc-runtime/ts/src/wasm/backend.ts b/ipc-runtime/ts/src/wasm/backend.ts index e6b09d2ef8e3..02744f6ad876 100644 --- a/ipc-runtime/ts/src/wasm/backend.ts +++ b/ipc-runtime/ts/src/wasm/backend.ts @@ -18,8 +18,6 @@ export interface WasmFfiOptions { hostImports?: HostImportsFactory; /** FFI entry export; default: the module's one `_ipc_ffi_entry` export. */ entry?: string; - /** Allocator export pairs to look for, in order of preference. */ - allocatorExports?: Array<[string, string]>; } /** @@ -204,7 +202,6 @@ export class WasmFfiEngine { memory, env, entry: opts.entry, - allocatorExports: opts.allocatorExports, }, logger, ) @@ -217,7 +214,6 @@ export class WasmFfiEngine { logger, hostImports: opts.hostImports, entry: opts.entry, - allocatorExports: opts.allocatorExports, threads, spawnThread: (startArg) => threadWorkers?.spawn(startArg) ?? -1, }); @@ -344,7 +340,6 @@ export class WasmFfiBackend implements IpcClientAsync { memory: opts.memory, env: opts.env, entry: opts.entry, - allocatorExports: opts.allocatorExports, }, }); await ready; diff --git a/ipc-runtime/ts/src/wasm/entry.ts b/ipc-runtime/ts/src/wasm/entry.ts new file mode 100644 index 000000000000..ea02efeb214b --- /dev/null +++ b/ipc-runtime/ts/src/wasm/entry.ts @@ -0,0 +1,40 @@ +// 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 { HostImportsContext, HostImportsFactory } from "./host.js"; +export type { WasmModuleSource } from "./module_source.js"; +export type { WasmPlatform, WorkerHandle, WorkerSide } from "./platform.js"; +export type { WasmFfiBackendOptions, WasmFfiBinding, WasmFfiOptions }; +export { + 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 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 index 469ab3063bb1..09ff699435f1 100644 --- a/ipc-runtime/ts/src/wasm/host.ts +++ b/ipc-runtime/ts/src/wasm/host.ts @@ -36,11 +36,6 @@ export interface InstanceOptions { * export named `_ipc_ffi_entry` (or bare `ipc_ffi_entry`). */ entry?: string; - /** - * Allocator export pairs to look for, in order of preference. Default: the entry's sibling - * `_ipc_ffi_alloc`/`_free`, then wasi-libc's `malloc`/`free`, then bb's `bbmalloc`/`bbfree`. - */ - allocatorExports?: Array<[string, string]>; /** Call the reactor's `_initialize` after instantiation (main instances only). Default true. */ runInitialize?: boolean; /** Threads this instance may use (reported to `hostImports`). Default 1. */ @@ -167,7 +162,7 @@ export class WasmInstanceHost { (exports._initialize as () => void)(); } const { entry, prefix } = findEntry(exports, opts.entry); - const allocatorExports = opts.allocatorExports ?? [ + const allocatorExports: Array<[string, string]> = [ [`${prefix}ipc_ffi_alloc`, `${prefix}ipc_ffi_free`], ...FALLBACK_ALLOCATOR_EXPORTS, ]; diff --git a/ipc-runtime/ts/src/wasm/index.browser.ts b/ipc-runtime/ts/src/wasm/index.browser.ts index 1db92d9b2ded..371bcee7bb3b 100644 --- a/ipc-runtime/ts/src/wasm/index.browser.ts +++ b/ipc-runtime/ts/src/wasm/index.browser.ts @@ -1,27 +1,12 @@ // Browser entry of the wasm FFI backend (`@aztec-foundation/ipc-runtime/wasm`, `browser` condition). -import { - type WasmFfiBackendOptions, - type WasmFfiBinding, - type WasmFfiOptions, - WasmFfiBackend, - WasmFfiBackendSync, - WasmFfiEngine, -} from "./backend.js"; +import { type WasmFfiBinding, bindEntry } from "./entry.js"; import { browserPlatform, browserWorkerHandle, browserWorkerSide, } from "./platform.browser.js"; -export type { HostImportsContext, HostImportsFactory } from "./host.js"; -export type { WasmModuleSource } from "./module_source.js"; -export type { WasmPlatform, WorkerHandle, WorkerSide } from "./platform.js"; -export type { WasmFfiBackendOptions, WasmFfiBinding, WasmFfiOptions }; -export { WasmFfiBackend, WasmFfiBackendSync, WasmFfiEngine }; -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 * from "./entry.js"; export { browserPlatform as platform, browserWorkerHandle as workerHandle, @@ -48,19 +33,8 @@ export const binding: WasmFfiBinding = { ), }; -export function createWasmFfiBackend( - opts: WasmFfiBackendOptions, -): Promise { - return WasmFfiBackend.create(opts, binding); -} - -export function createWasmFfiBackendSync( - opts: WasmFfiOptions, -): Promise { - return WasmFfiBackendSync.create(opts, binding); -} - -/** Threads need `SharedArrayBuffer`, which browsers expose only under COOP/COEP headers. */ -export function sharedMemoryAvailable(): boolean { - return browserPlatform.sharedMemoryAvailable(); -} +export const { + createWasmFfiBackend, + createWasmFfiBackendSync, + sharedMemoryAvailable, +} = bindEntry(binding); diff --git a/ipc-runtime/ts/src/wasm/index.node.ts b/ipc-runtime/ts/src/wasm/index.node.ts index 6ffd2ef40ae2..8bc1cac2ec0c 100644 --- a/ipc-runtime/ts/src/wasm/index.node.ts +++ b/ipc-runtime/ts/src/wasm/index.node.ts @@ -1,27 +1,12 @@ // Node entry of the wasm FFI backend (`@aztec-foundation/ipc-runtime/wasm`, `default` condition). -import { - type WasmFfiBackendOptions, - type WasmFfiBinding, - type WasmFfiOptions, - WasmFfiBackend, - WasmFfiBackendSync, - WasmFfiEngine, -} from "./backend.js"; +import { type WasmFfiBinding, bindEntry } from "./entry.js"; import { nodePlatform, nodeWorkerHandle, nodeWorkerSide, } from "./platform.node.js"; -export type { HostImportsContext, HostImportsFactory } from "./host.js"; -export type { WasmModuleSource } from "./module_source.js"; -export type { WasmPlatform, WorkerHandle, WorkerSide } from "./platform.js"; -export type { WasmFfiBackendOptions, WasmFfiBinding, WasmFfiOptions }; -export { WasmFfiBackend, WasmFfiBackendSync, WasmFfiEngine }; -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 * from "./entry.js"; export { nodePlatform as platform, nodeWorkerHandle as workerHandle, @@ -41,19 +26,8 @@ export const binding: WasmFfiBinding = { ), }; -export function createWasmFfiBackend( - opts: WasmFfiBackendOptions, -): Promise { - return WasmFfiBackend.create(opts, binding); -} - -export function createWasmFfiBackendSync( - opts: WasmFfiOptions, -): Promise { - return WasmFfiBackendSync.create(opts, binding); -} - -/** Whether this platform can run the module's threads build (a shared memory is available). */ -export function sharedMemoryAvailable(): boolean { - return nodePlatform.sharedMemoryAvailable(); -} +export const { + createWasmFfiBackend, + createWasmFfiBackendSync, + sharedMemoryAvailable, +} = bindEntry(binding); diff --git a/ipc-runtime/ts/src/wasm/main_worker.ts b/ipc-runtime/ts/src/wasm/main_worker.ts index 1777339acde3..c9277e107407 100644 --- a/ipc-runtime/ts/src/wasm/main_worker.ts +++ b/ipc-runtime/ts/src/wasm/main_worker.ts @@ -39,7 +39,6 @@ export function runMainWorker( memory: o.memory, env: o.env, entry: o.entry, - allocatorExports: o.allocatorExports, hostImports: opts.hostImports, logger: log, }, diff --git a/ipc-runtime/ts/src/wasm/thread_worker.ts b/ipc-runtime/ts/src/wasm/thread_worker.ts index 0509275d424a..75009172f8ad 100644 --- a/ipc-runtime/ts/src/wasm/thread_worker.ts +++ b/ipc-runtime/ts/src/wasm/thread_worker.ts @@ -30,7 +30,6 @@ export function runThreadWorker( memory: msg.memory, env: msg.env, entry: msg.entry, - allocatorExports: msg.allocatorExports, hostImports: opts.hostImports, threads: 1, runInitialize: false, From 2811f174a61856a359651ae5253eee5c33de5740 Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:23:48 +0000 Subject: [PATCH 10/26] refactor(ipc-runtime): split the wasm host code into node/ and browser/ The platform-specific files were distinguished by a suffix and interleaved with the shared ones. Each host now has a directory holding the same four file names, so the split is visible in the tree rather than read off filenames. Each host also gets its own export subpath with its own types, so code already committed to one host imports it by name. That removes the shim by which the node worker handle also accepted a web Worker, which existed only because the `types` condition resolved to node for everyone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- ipc-codegen/src/generate.ts | 4 ++-- ipc-codegen/src/typescript_package_codegen.ts | 20 +++++++++---------- ipc-runtime/ts/package.json | 19 +++++++++++++++--- .../{index.browser.ts => browser/index.ts} | 6 +++--- .../main.worker.ts} | 4 ++-- .../platform.ts} | 2 +- .../thread.worker.ts} | 4 ++-- .../src/wasm/{index.node.ts => node/index.ts} | 6 +++--- .../main.worker.ts} | 4 ++-- .../{platform.node.ts => node/platform.ts} | 15 +++----------- .../thread.worker.ts} | 4 ++-- ipc-runtime/ts/src/wasm_engine.test.ts | 4 ++-- ipc-runtime/ts/src/wasm_module_source.test.ts | 2 +- 13 files changed, 49 insertions(+), 45 deletions(-) rename ipc-runtime/ts/src/wasm/{index.browser.ts => browser/index.ts} (89%) rename ipc-runtime/ts/src/wasm/{main.worker.browser.ts => browser/main.worker.ts} (83%) rename ipc-runtime/ts/src/wasm/{platform.browser.ts => browser/platform.ts} (96%) rename ipc-runtime/ts/src/wasm/{thread.worker.browser.ts => browser/thread.worker.ts} (56%) rename ipc-runtime/ts/src/wasm/{index.node.ts => node/index.ts} (87%) rename ipc-runtime/ts/src/wasm/{main.worker.node.ts => node/main.worker.ts} (70%) rename ipc-runtime/ts/src/wasm/{platform.node.ts => node/platform.ts} (76%) rename ipc-runtime/ts/src/wasm/{thread.worker.node.ts => node/thread.worker.ts} (56%) diff --git a/ipc-codegen/src/generate.ts b/ipc-codegen/src/generate.ts index 3f18f31266be..937b7768a0a3 100644 --- a/ipc-codegen/src/generate.ts +++ b/ipc-codegen/src/generate.ts @@ -533,11 +533,11 @@ function generate(args: Args) { packageGen.generateThreadWorker(), ); writePackage( - "src/wasm/main.worker.ts", + "src/wasm/node/main.worker.ts", packageGen.generateMainWorker(), ); writePackage( - "src/wasm/main.worker.browser.ts", + "src/wasm/browser/main.worker.ts", packageGen.generateBrowserMainWorker(), ); writePackage( diff --git a/ipc-codegen/src/typescript_package_codegen.ts b/ipc-codegen/src/typescript_package_codegen.ts index 0a4539b2a81b..86002cf7bf91 100644 --- a/ipc-codegen/src/typescript_package_codegen.ts +++ b/ipc-codegen/src/typescript_package_codegen.ts @@ -576,7 +576,7 @@ ${ ) as string[]; return `import { type IpcClientAsync, type IpcClientSync${process ? ", SpawnedProcessBackend" : ""} } from '@aztec-foundation/ipc-runtime'; -${wasm ? "import { type WasmFfiBackend, platform } from '@aztec-foundation/ipc-runtime/wasm';\n" : ""}import { AsyncApi, type IpcErrorFactory } from './generated/async.js'; +${wasm ? "import { type WasmFfiBackend, platform } from '@aztec-foundation/ipc-runtime/wasm/node';\n" : ""}import { AsyncApi, type IpcErrorFactory } 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, createWasmBackendSync, createWasmBackendWith } from './wasm.js';\n` : ""} @@ -592,7 +592,7 @@ ${this.createOptionTypes(backends)}${ export function createWasmBackend(options: ${prefix}WasmOptions = {}): Promise { return createWasmBackendWith( { - createMainWorker: () => platform.createWorker(new URL('./wasm/main.worker.js', import.meta.url)), + createMainWorker: () => platform.createWorker(new URL('./wasm/node/main.worker.js', import.meta.url)), createThreadWorker: () => platform.createWorker(new URL('./wasm/thread.worker.js', import.meta.url)), }, options, @@ -672,7 +672,7 @@ ${this.serviceClasses({ process, wasm })}`; const { prefix, packageName } = this.opts; return `import type { IpcClientAsync, IpcClientSync } from '@aztec-foundation/ipc-runtime'; -import { type WasmFfiBackend, workerHandle } from '@aztec-foundation/ipc-runtime/wasm'; +import { type WasmFfiBackend, workerHandle } from '@aztec-foundation/ipc-runtime/wasm/browser'; import { AsyncApi, type IpcErrorFactory } from './generated/async.js'; import { SyncApi } from './generated/sync.js'; import { type ${prefix}WasmOptions, createWasmBackendSync, createWasmBackendWith } from './wasm.js'; @@ -711,7 +711,7 @@ export function createWasmBackend(options: ${prefix}WasmOptions = {}): Promise - workerHandle(new Worker(new URL('./wasm/main.worker.browser.js', import.meta.url), { type: 'module' })), + workerHandle(new Worker(new URL('./wasm/browser/main.worker.js', import.meta.url), { type: 'module' })), createThreadWorker: () => workerHandle(new Worker(new URL('./wasm/thread.worker.js', import.meta.url), { type: 'module' })), }, @@ -968,25 +968,25 @@ runThreadWorker(workerSide(), { hostImports }); /** Main-instance worker for node. */ generateMainWorker(): string { - return `import { platform, runMainWorker, workerSide } from '@aztec-foundation/ipc-runtime/wasm'; -import { hostImports } from '../wasm_host_imports.js'; + return `import { platform, runMainWorker, workerSide } from '@aztec-foundation/ipc-runtime/wasm/node'; +import { hostImports } from '../../wasm_host_imports.js'; runMainWorker(workerSide(), platform, { hostImports, - createThreadWorker: () => platform.createWorker(new URL('./thread.worker.js', import.meta.url)), + createThreadWorker: () => platform.createWorker(new URL('../thread.worker.js', import.meta.url)), }); `; } /** Main-instance worker for browsers: spawns thread workers with the expression bundlers detect. */ generateBrowserMainWorker(): string { - return `import { platform, runMainWorker, workerHandle, workerSide } from '@aztec-foundation/ipc-runtime/wasm'; -import { hostImports } from '../wasm_host_imports.js'; + return `import { platform, runMainWorker, workerHandle, workerSide } from '@aztec-foundation/ipc-runtime/wasm/browser'; +import { hostImports } from '../../wasm_host_imports.js'; runMainWorker(workerSide(), platform, { hostImports, createThreadWorker: () => - workerHandle(new Worker(new URL('./thread.worker.js', import.meta.url), { type: 'module' })), + workerHandle(new Worker(new URL('../thread.worker.js', import.meta.url), { type: 'module' })), }); `; } diff --git a/ipc-runtime/ts/package.json b/ipc-runtime/ts/package.json index 66e1a68a1155..141670c1ed79 100644 --- a/ipc-runtime/ts/package.json +++ b/ipc-runtime/ts/package.json @@ -11,9 +11,22 @@ "import": "./dest/index.js" }, "./wasm": { - "types": "./dest/wasm/index.node.d.ts", - "browser": "./dest/wasm/index.browser.js", - "default": "./dest/wasm/index.node.js" + "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" } }, "scripts": { diff --git a/ipc-runtime/ts/src/wasm/index.browser.ts b/ipc-runtime/ts/src/wasm/browser/index.ts similarity index 89% rename from ipc-runtime/ts/src/wasm/index.browser.ts rename to ipc-runtime/ts/src/wasm/browser/index.ts index 371bcee7bb3b..959a68e186c8 100644 --- a/ipc-runtime/ts/src/wasm/index.browser.ts +++ b/ipc-runtime/ts/src/wasm/browser/index.ts @@ -1,12 +1,12 @@ // Browser entry of the wasm FFI backend (`@aztec-foundation/ipc-runtime/wasm`, `browser` condition). -import { type WasmFfiBinding, bindEntry } from "./entry.js"; +import { type WasmFfiBinding, bindEntry } from "../entry.js"; import { browserPlatform, browserWorkerHandle, browserWorkerSide, -} from "./platform.browser.js"; +} from "./platform.js"; -export * from "./entry.js"; +export * from "../entry.js"; export { browserPlatform as platform, browserWorkerHandle as workerHandle, diff --git a/ipc-runtime/ts/src/wasm/main.worker.browser.ts b/ipc-runtime/ts/src/wasm/browser/main.worker.ts similarity index 83% rename from ipc-runtime/ts/src/wasm/main.worker.browser.ts rename to ipc-runtime/ts/src/wasm/browser/main.worker.ts index d2d9e09b51bb..f49d77c8310a 100644 --- a/ipc-runtime/ts/src/wasm/main.worker.browser.ts +++ b/ipc-runtime/ts/src/wasm/browser/main.worker.ts @@ -1,10 +1,10 @@ // 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 { runMainWorker } from "../main_worker.js"; import { browserPlatform, browserWorkerHandle, browserWorkerSide, -} from "./platform.browser.js"; +} from "./platform.js"; runMainWorker(browserWorkerSide(), browserPlatform, { createThreadWorker: () => diff --git a/ipc-runtime/ts/src/wasm/platform.browser.ts b/ipc-runtime/ts/src/wasm/browser/platform.ts similarity index 96% rename from ipc-runtime/ts/src/wasm/platform.browser.ts rename to ipc-runtime/ts/src/wasm/browser/platform.ts index 29a884d11666..c79f04eb0340 100644 --- a/ipc-runtime/ts/src/wasm/platform.browser.ts +++ b/ipc-runtime/ts/src/wasm/browser/platform.ts @@ -1,4 +1,4 @@ -import type { WasmPlatform, WorkerHandle, WorkerSide } from "./platform.js"; +import type { WasmPlatform, WorkerHandle, WorkerSide } from "../platform.js"; /** The parent's handle on a browser `Worker`. */ export function browserWorkerHandle(worker: Worker): WorkerHandle { diff --git a/ipc-runtime/ts/src/wasm/thread.worker.browser.ts b/ipc-runtime/ts/src/wasm/browser/thread.worker.ts similarity index 56% rename from ipc-runtime/ts/src/wasm/thread.worker.browser.ts rename to ipc-runtime/ts/src/wasm/browser/thread.worker.ts index b8ba0e16e95d..ebd4133bf927 100644 --- a/ipc-runtime/ts/src/wasm/thread.worker.browser.ts +++ b/ipc-runtime/ts/src/wasm/browser/thread.worker.ts @@ -1,5 +1,5 @@ // Default wasi-threads worker for browsers: one module instance per thread, no module-specific imports. -import { browserWorkerSide } from "./platform.browser.js"; -import { runThreadWorker } from "./thread_worker.js"; +import { browserWorkerSide } from "./platform.js"; +import { runThreadWorker } from "../thread_worker.js"; runThreadWorker(browserWorkerSide()); diff --git a/ipc-runtime/ts/src/wasm/index.node.ts b/ipc-runtime/ts/src/wasm/node/index.ts similarity index 87% rename from ipc-runtime/ts/src/wasm/index.node.ts rename to ipc-runtime/ts/src/wasm/node/index.ts index 8bc1cac2ec0c..9438f747ae93 100644 --- a/ipc-runtime/ts/src/wasm/index.node.ts +++ b/ipc-runtime/ts/src/wasm/node/index.ts @@ -1,12 +1,12 @@ // Node entry of the wasm FFI backend (`@aztec-foundation/ipc-runtime/wasm`, `default` condition). -import { type WasmFfiBinding, bindEntry } from "./entry.js"; +import { type WasmFfiBinding, bindEntry } from "../entry.js"; import { nodePlatform, nodeWorkerHandle, nodeWorkerSide, -} from "./platform.node.js"; +} from "./platform.js"; -export * from "./entry.js"; +export * from "../entry.js"; export { nodePlatform as platform, nodeWorkerHandle as workerHandle, diff --git a/ipc-runtime/ts/src/wasm/main.worker.node.ts b/ipc-runtime/ts/src/wasm/node/main.worker.ts similarity index 70% rename from ipc-runtime/ts/src/wasm/main.worker.node.ts rename to ipc-runtime/ts/src/wasm/node/main.worker.ts index 2c1d1348a97c..e83f1814b1d7 100644 --- a/ipc-runtime/ts/src/wasm/main.worker.node.ts +++ b/ipc-runtime/ts/src/wasm/node/main.worker.ts @@ -1,6 +1,6 @@ // 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.node.js"; +import { runMainWorker } from "../main_worker.js"; +import { nodePlatform, nodeWorkerSide } from "./platform.js"; runMainWorker(nodeWorkerSide(), nodePlatform, { createThreadWorker: () => diff --git a/ipc-runtime/ts/src/wasm/platform.node.ts b/ipc-runtime/ts/src/wasm/node/platform.ts similarity index 76% rename from ipc-runtime/ts/src/wasm/platform.node.ts rename to ipc-runtime/ts/src/wasm/node/platform.ts index b4a8ffeb0b00..8e2c7556c496 100644 --- a/ipc-runtime/ts/src/wasm/platform.node.ts +++ b/ipc-runtime/ts/src/wasm/node/platform.ts @@ -2,19 +2,10 @@ 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"; -import { browserWorkerHandle } from "./platform.browser.js"; +import type { WasmPlatform, WorkerHandle, WorkerSide } from "../platform.js"; -/** - * The parent's handle on a `worker_threads` worker. Also accepts a web `Worker`, so code that is - * type-checked against this (node) entry but bundled for browsers type-checks too. - */ -export function nodeWorkerHandle( - worker: Worker | globalThis.Worker, -): WorkerHandle { - if ("addEventListener" in worker) { - return browserWorkerHandle(worker); - } +/** The parent's handle on a `worker_threads` worker. */ +export function nodeWorkerHandle(worker: Worker): WorkerHandle { return { postMessage: (message, transfer) => worker.postMessage( diff --git a/ipc-runtime/ts/src/wasm/thread.worker.node.ts b/ipc-runtime/ts/src/wasm/node/thread.worker.ts similarity index 56% rename from ipc-runtime/ts/src/wasm/thread.worker.node.ts rename to ipc-runtime/ts/src/wasm/node/thread.worker.ts index c269c9ade573..d1fa09a16912 100644 --- a/ipc-runtime/ts/src/wasm/thread.worker.node.ts +++ b/ipc-runtime/ts/src/wasm/node/thread.worker.ts @@ -1,5 +1,5 @@ // Default wasi-threads worker for node: one module instance per thread, no module-specific imports. -import { nodeWorkerSide } from "./platform.node.js"; -import { runThreadWorker } from "./thread_worker.js"; +import { nodeWorkerSide } from "./platform.js"; +import { runThreadWorker } from "../thread_worker.js"; runThreadWorker(nodeWorkerSide()); diff --git a/ipc-runtime/ts/src/wasm_engine.test.ts b/ipc-runtime/ts/src/wasm_engine.test.ts index 3c07809cdcf2..85b79593fcf1 100644 --- a/ipc-runtime/ts/src/wasm_engine.test.ts +++ b/ipc-runtime/ts/src/wasm_engine.test.ts @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { WasmFfiEngine } from "./wasm/backend.js"; -import type { WasmFfiBinding, WorkerHandle } from "./wasm/index.node.js"; -import { nodePlatform } from "./wasm/platform.node.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. diff --git a/ipc-runtime/ts/src/wasm_module_source.test.ts b/ipc-runtime/ts/src/wasm_module_source.test.ts index 015aa4e6d9ad..3d242461c1df 100644 --- a/ipc-runtime/ts/src/wasm_module_source.test.ts +++ b/ipc-runtime/ts/src/wasm_module_source.test.ts @@ -2,7 +2,7 @@ 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/platform.node.js"; +import { nodePlatform } from "./wasm/node/platform.js"; // The smallest valid module: magic + version, no sections. const EMPTY_MODULE = new Uint8Array([ From 8055180c163c56ab83421c2ca5f965efb315502d Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:26:02 +0000 Subject: [PATCH 11/26] docs(ipc-codegen): state which thread a wasm call runs on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The choice was already the caller's — a worker by default, the calling thread with `worker: false`, and always the calling thread for the synchronous backend — but nothing said so. Name it on the option and give the generated README a section, for packages that have a wasm backend. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- ipc-codegen/src/typescript_package_codegen.ts | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/ipc-codegen/src/typescript_package_codegen.ts b/ipc-codegen/src/typescript_package_codegen.ts index 86002cf7bf91..77bc4429be9e 100644 --- a/ipc-codegen/src/typescript_package_codegen.ts +++ b/ipc-codegen/src/typescript_package_codegen.ts @@ -873,7 +873,13 @@ export interface ${prefix}WasmOptions { * Response, or a compiled WebAssembly.Module. */ module?: WasmModuleSource; - /** Run the module in a dedicated worker (default true) so a call never blocks the caller's thread. */ + /** + * 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; @@ -938,7 +944,11 @@ ${ffiExports} return backend; } -/** The module on the calling thread with one thread: every call blocks until it returns. */ +/** + * 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), @@ -1206,7 +1216,20 @@ ${backends} ${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: From 2c3b7e912b40bd6af3d42a35f4fe916ccdf63a1a Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:39:02 +0000 Subject: [PATCH 12/26] fix(ipc-runtime): use the module's own memory when it exports one A module either imports its memory or defines and exports one. bb imports, so the host always used the memory it had made itself; a Rust cdylib exports its own, and the host would have read and written a memory the module never touches. Adopt the exported one after instantiation, and refuse to thread such a module, since every instance would have a memory to itself. Found by giving the echo example a wasm reactor: the Rust crate already emits the FFI entry, so a cdylib on wasm32-wasip1 is the whole build. The TS package now offers uds, shm and wasm from that one crate, and its test drives the reactor worker-hosted, on the calling thread, and synchronously. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- ipc-codegen/bootstrap.sh | 1 + ipc-codegen/echo_example/rust/Cargo.toml | 3 ++ ipc-codegen/echo_example/rust/bootstrap.sh | 5 ++ .../echo_example/ts_package/.gitignore | 5 ++ ipc-codegen/echo_example/ts_package/README.md | 18 +++++-- .../echo_example/ts_package/bootstrap.sh | 12 +++-- .../ts_package/src/package_test.ts | 47 +++++++++++++++++-- ipc-runtime/ts/src/wasm/backend.ts | 21 ++++++--- ipc-runtime/ts/src/wasm/host.ts | 9 +++- ipc-runtime/ts/src/wasm_engine.test.ts | 33 +++++++++---- 10 files changed, 129 insertions(+), 25 deletions(-) diff --git a/ipc-codegen/bootstrap.sh b/ipc-codegen/bootstrap.sh index 64e10e588854..ed9603852f41 100755 --- a/ipc-codegen/bootstrap.sh +++ b/ipc-codegen/bootstrap.sh @@ -60,6 +60,7 @@ function test_cmds { 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/rust/Cargo.toml b/ipc-codegen/echo_example/rust/Cargo.toml index 458375d0b8de..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" diff --git a/ipc-codegen/echo_example/rust/bootstrap.sh b/ipc-codegen/echo_example/rust/bootstrap.sh index 2ab81dfd78dc..e979e28e4b39 100755 --- a/ipc-codegen/echo_example/rust/bootstrap.sh +++ b/ipc-codegen/echo_example/rust/bootstrap.sh @@ -18,3 +18,8 @@ $NODE "$CODEGEN/src/generate.ts" \ # 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/ts_package/.gitignore b/ipc-codegen/echo_example/ts_package/.gitignore index c959ed1378e9..2080cb48950a 100644 --- a/ipc-codegen/echo_example/ts_package/.gitignore +++ b/ipc-codegen/echo_example/ts_package/.gitignore @@ -11,4 +11,9 @@ src/react-native.ts src/platform.ts src/process.ts src/bin.ts +src/browser.ts +src/wasm.ts +src/wasm/ +src/wasm_host_imports.ts +wasm/ scripts/prepare_arch_packages.sh diff --git a/ipc-codegen/echo_example/ts_package/README.md b/ipc-codegen/echo_example/ts_package/README.md index 9801c557c954..17893bc4d898 100644 --- a/ipc-codegen/echo_example/ts_package/README.md +++ b/ipc-codegen/echo_example/ts_package/README.md @@ -15,20 +15,32 @@ try { } ``` -`create` picks the process when the binary resolves. `options.backend` forces one, with no fallback: +`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 (Vite users: exclude the package from `optimizeDeps`). - 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 -(the process reads it from `HARDWARE_CONCURRENCY`/`RAYON_NUM_THREADS`). `EchoServiceSync.create` is the synchronous form (shared memory for a process). +(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 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`. +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 diff --git a/ipc-codegen/echo_example/ts_package/bootstrap.sh b/ipc-codegen/echo_example/ts_package/bootstrap.sh index 9c22fe734258..f2bea760ed79 100755 --- a/ipc-codegen/echo_example/ts_package/bootstrap.sh +++ b/ipc-codegen/echo_example/ts_package/bootstrap.sh @@ -12,10 +12,13 @@ $NODE "$CODEGEN/src/generate.ts" \ --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. @@ -23,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 1d8c901c2c1b..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,48 @@ 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(); diff --git a/ipc-runtime/ts/src/wasm/backend.ts b/ipc-runtime/ts/src/wasm/backend.ts index 02744f6ad876..826fa2372f61 100644 --- a/ipc-runtime/ts/src/wasm/backend.ts +++ b/ipc-runtime/ts/src/wasm/backend.ts @@ -179,18 +179,21 @@ export class WasmFfiEngine { ); // 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 = - shared && platform.sharedMemoryAvailable() ? wantThreads : 1; + importsMemory && shared && platform.sharedMemoryAvailable() + ? wantThreads + : 1; const env = { HARDWARE_CONCURRENCY: String(threads), RAYON_NUM_THREADS: String(threads), ...(opts.env ?? {}), }; - logger( - `wasm: ${threads} thread(s), memory ${memory.buffer.byteLength >> 16} pages initial, shared=${shared}`, - ); - // 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 = @@ -217,7 +220,13 @@ export class WasmFfiEngine { threads, spawnThread: (startArg) => threadWorkers?.spawn(startArg) ?? -1, }); - return new WasmFfiEngine(host, threadWorkers, memory, threads); + // 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 { diff --git a/ipc-runtime/ts/src/wasm/host.ts b/ipc-runtime/ts/src/wasm/host.ts index 09ff699435f1..b3125500cb0b 100644 --- a/ipc-runtime/ts/src/wasm/host.ts +++ b/ipc-runtime/ts/src/wasm/host.ts @@ -103,7 +103,10 @@ export class WasmInstanceHost { ) {} static async instantiate(opts: InstanceOptions): Promise { - const memory = opts.memory; + // 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 ctx: HostImportsContext = { memory: () => memory, @@ -155,6 +158,10 @@ export class WasmInstanceHost { 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" diff --git a/ipc-runtime/ts/src/wasm_engine.test.ts b/ipc-runtime/ts/src/wasm_engine.test.ts index 85b79593fcf1..b84304af98ca 100644 --- a/ipc-runtime/ts/src/wasm_engine.test.ts +++ b/ipc-runtime/ts/src/wasm_engine.test.ts @@ -13,6 +13,8 @@ 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 = []; @@ -52,24 +54,23 @@ function buildModule(opts: { I32, 0, // (i32) -> () free ]); + const limits = [opts.shared ? 0x03 : 0x01, ...leb(opts.min), ...leb(opts.max)]; const imports = section(2, [ - 2, - ...str("env"), - ...str("memory"), - 0x02, - opts.shared ? 0x03 : 0x01, - ...leb(opts.min), - ...leb(opts.max), + ...(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, [ - 4, + opts.ownMemory ? 5 : 4, + ...(opts.ownMemory ? [...str("memory"), 0x02, 0] : []), ...str("spawn_thread"), 0x00, SPAWN, @@ -144,6 +145,7 @@ function buildModule(opts: { ...types, ...imports, ...functions, + ...memories, ...globals, ...exports, ...code, @@ -262,3 +264,18 @@ test("round-trips requests, growing the reused request buffer to fit", async () } 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(); +}); From 6ff9b7a2cee17250fe1ad656f2ffecd00c54b194 Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:37:25 +0000 Subject: [PATCH 13/26] fix(ipc-codegen): apply the engine's thread cap when resolving a default The package resolved a default thread count from the host's parallelism without the cap the engine then applies, so on a large machine it reported 160 threads for a run that used 32. Export the cap from the runtime and use it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- ipc-codegen/src/typescript_package_codegen.ts | 3 ++- ipc-runtime/ts/src/wasm/backend.ts | 3 ++- ipc-runtime/ts/src/wasm/entry.ts | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/ipc-codegen/src/typescript_package_codegen.ts b/ipc-codegen/src/typescript_package_codegen.ts index 77bc4429be9e..710649bbaa34 100644 --- a/ipc-codegen/src/typescript_package_codegen.ts +++ b/ipc-codegen/src/typescript_package_codegen.ts @@ -848,6 +848,7 @@ export class ${svc}Sync extends SyncApi { type WasmFfiBackendSync, type WasmModuleSource, type WorkerHandle, + MAX_THREADS, createWasmFfiBackend, createWasmFfiBackendSync, platform, @@ -913,7 +914,7 @@ export function defaultWasmModule(threads: number): URL { /** The thread count to run with: the default where none was asked for, else the request, checked. */ export function resolveThreads(threads?: number): number { if (threads === undefined) { - return sharedMemoryAvailable() ? platform.hardwareConcurrency() : 1; + return sharedMemoryAvailable() ? Math.min(platform.hardwareConcurrency(), MAX_THREADS) : 1; } if (threads > 1 && !sharedMemoryAvailable()) { throw new Error( diff --git a/ipc-runtime/ts/src/wasm/backend.ts b/ipc-runtime/ts/src/wasm/backend.ts index 826fa2372f61..f6f5b0a4eb08 100644 --- a/ipc-runtime/ts/src/wasm/backend.ts +++ b/ipc-runtime/ts/src/wasm/backend.ts @@ -46,7 +46,8 @@ export interface WasmFfiBackendOptions extends WasmFfiOptions { createMainWorker?: () => WorkerHandle; } -const MAX_THREADS = 32; +/** 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; diff --git a/ipc-runtime/ts/src/wasm/entry.ts b/ipc-runtime/ts/src/wasm/entry.ts index ea02efeb214b..fcae60129b9d 100644 --- a/ipc-runtime/ts/src/wasm/entry.ts +++ b/ipc-runtime/ts/src/wasm/entry.ts @@ -13,6 +13,7 @@ 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 d6eb4fc1a7389387210cb2779254059c87bdc905 Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:49:45 +0000 Subject: [PATCH 14/26] feat(bb.js): ship the wasm modules uncompressed Only a real application/wasm response reaches WebAssembly.compileStreaming as a network response, which is the precondition for a browser caching the compiled code between visits; a precompressed module has to be inflated into a synthetic response first, and forfeits that. npm tarballs are gzipped either way, so this costs nothing on install, and on a host that compresses the wire size is the same or better. It does assume the host compresses: most CDNs compress application/wasm, nginx does not unless it is added to gzip_types. wasmPath (or BB_WASM_PATH) takes a gzipped copy for hosts that cannot, so the choice stays the consumer's. With base64 embedding already gone, nothing now inlines a module into a bundle: both are assets, and only the one chosen is fetched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- barretenberg/ts/bb.js/README.md | 18 +++++++++++++----- barretenberg/ts/bootstrap.sh | 17 +++++++++++++---- ipc-codegen/src/generate.ts | 4 +++- ipc-codegen/src/typescript_package_codegen.ts | 8 +++++--- 4 files changed, 34 insertions(+), 13 deletions(-) diff --git a/barretenberg/ts/bb.js/README.md b/barretenberg/ts/bb.js/README.md index 47e7cc7efcd1..22ac40ed0963 100644 --- a/barretenberg/ts/bb.js/README.md +++ b/barretenberg/ts/bb.js/README.md @@ -51,11 +51,17 @@ The typed API (`Barretenberg` extends it) and every way of reaching bb come from 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, `WebAssembly.compileStreaming` for loading (so browsers that cache compiled code start -warm on a repeat visit). Pass `wasmPath` (or set `BB_WASM_PATH` in node) to run another build of the -module, and `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`). +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 @@ -107,4 +113,6 @@ stripped one: BB_WASM_PATH=$(git rev-parse --show-toplevel)/barretenberg/cpp/build-wasm-threads/bin/barretenberg-debug.wasm ``` +(the loader takes a `.wasm` or a `.wasm.gz` either way) + Run your test again to get a trace. diff --git a/barretenberg/ts/bootstrap.sh b/barretenberg/ts/bootstrap.sh index ff34ac1c2614..f5998841eb7d 100755 --- a/barretenberg/ts/bootstrap.sh +++ b/barretenberg/ts/bootstrap.sh @@ -67,8 +67,8 @@ function generate_bb_js_api_package { --curve-constants "$bbapi/bb_curve_constants.json" \ --package-transports uds,shm,wasm \ --package-ipc-path-args 'msgpack,run,--input,{path}' \ - --package-wasm-module barretenberg.wasm.gz \ - --package-wasm-threads-module barretenberg-threads.wasm.gz \ + --package-wasm-module barretenberg.wasm \ + --package-wasm-threads-module barretenberg-threads.wasm \ --package-wasm-host-imports "$ROOT/barretenberg/ts/codegen/bb_wasm_host_imports.ts" } @@ -83,10 +83,19 @@ function generate_packages { # 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.gz" bb.js-api/wasm/barretenberg-threads.wasm.gz - cp "$ROOT/barretenberg/cpp/build-wasm/bin/barretenberg.wasm.gz" bb.js-api/wasm/barretenberg.wasm.gz + 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 { diff --git a/ipc-codegen/src/generate.ts b/ipc-codegen/src/generate.ts index 937b7768a0a3..6279d3a63caf 100644 --- a/ipc-codegen/src/generate.ts +++ b/ipc-codegen/src/generate.ts @@ -103,7 +103,9 @@ Optional: 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 + 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/ --package-wasm-host-imports diff --git a/ipc-codegen/src/typescript_package_codegen.ts b/ipc-codegen/src/typescript_package_codegen.ts index 710649bbaa34..a51a06f559ba 100644 --- a/ipc-codegen/src/typescript_package_codegen.ts +++ b/ipc-codegen/src/typescript_package_codegen.ts @@ -870,8 +870,10 @@ export interface ${prefix}WasmOptions { /** 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, gzipped or raw bytes, a fetch - * Response, or a compiled WebAssembly.Module. + * 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; /** @@ -1164,7 +1166,7 @@ done 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 (Vite users: exclude the package from \`optimizeDeps\`).`, + `- \`'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) From 5b98d91b791cb2bd2b607710dcd2e2fca944837e Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:11:46 +0000 Subject: [PATCH 15/26] refactor(ipc-codegen): move the generated package's logic into ipc-runtime Two generated packages differed by one or two lines per file across six files: binary resolution, process spawning, thread resolution, module selection and the backend-selection policy were the same code with different literals, regenerated per package. A fix to any of them would have had to reach every package by regeneration; today's two bugs were both in the runtime, where one fix covers everyone. ipc-runtime gains findServiceBinary, spawnServiceBackend(Sync), runServiceBinary, serviceProcessEnv and pickServiceBackend over a ServiceBinary description, and resolveWasmThreads/chooseWasmModule in the wasm entry. The package now supplies what only it can: that description, the module and worker URLs, which are relative to its own files and must be literals a bundler can see, and the typed service classes. platform.ts 68 -> 29, process.ts 100 -> 37, bin.ts 19 -> 5, and the selection policy loses the `!true` an interpolated flag had left in it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- ipc-codegen/echo_example/ts_package/README.md | 2 +- ipc-codegen/src/typescript_package_codegen.ts | 332 ++++++------------ ipc-runtime/ts/src/index.ts | 10 + ipc-runtime/ts/src/service.ts | 239 +++++++++++++ ipc-runtime/ts/src/wasm/entry.ts | 1 + ipc-runtime/ts/src/wasm/service.ts | 46 +++ 6 files changed, 398 insertions(+), 232 deletions(-) create mode 100644 ipc-runtime/ts/src/service.ts create mode 100644 ipc-runtime/ts/src/wasm/service.ts diff --git a/ipc-codegen/echo_example/ts_package/README.md b/ipc-codegen/echo_example/ts_package/README.md index 17893bc4d898..9c2aa9b1fdcf 100644 --- a/ipc-codegen/echo_example/ts_package/README.md +++ b/ipc-codegen/echo_example/ts_package/README.md @@ -18,7 +18,7 @@ try { `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 (Vite users: exclude the package from `optimizeDeps`). +- `'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 diff --git a/ipc-codegen/src/typescript_package_codegen.ts b/ipc-codegen/src/typescript_package_codegen.ts index a51a06f559ba..db4b0b42b8bb 100644 --- a/ipc-codegen/src/typescript_package_codegen.ts +++ b/ipc-codegen/src/typescript_package_codegen.ts @@ -267,26 +267,11 @@ ${this.opts.curveConstants ? "export * from './generated/curve_constants.js';\n" } 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)); `; } @@ -317,115 +302,56 @@ process.exit(result.status ?? 1); } /** 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, packageName } = this.opts; - const findBinary = binaryFinderName(prefix); + const { prefix, binaryName } = this.opts; const transports = this.processTransports.map((t) => `'${t}'`).join(" | "); const defaultTransport = this.processTransports.includes("uds") ? "uds" : this.processTransports[0]!; - const ipcPathArgs = JSON.stringify(this.opts.ipcPathArgs); - const shm = this.shm; + 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${shm ? ", SpawnedProcessBackendSync" : ""} } from '@aztec-foundation/ipc-runtime'; + return `import { + type ServiceProcessOptions, + type SpawnedProcessBackend,${syncImports} + spawnServiceBackend, +} from '@aztec-foundation/ipc-runtime'; import type { IpcErrorFactory } from './generated/async.js'; -import { ${findBinary} } from './platform.js'; +import { BINARY } from './platform.js'; export type ${prefix}Transport = ${transports}; -/** Options for running '${binaryName}' as a spawned process (node only). */ -export interface ${prefix}ProcessOptions { - /** Path of the binary. Default: ${this.opts.binaryEnvVar}, then the installed arch package. */ - binaryPath?: string; +/** + * 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; - /** 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 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. - */ - respawn?: boolean; - /** Let node exit while the process is alive (it must exit on its own when its parent does). */ - unref?: boolean; - /** Also unref the child's stdout/stderr pipes (present with \`logger\`); log lines may then go unread at exit. */ - unrefStdio?: boolean; createError?: IpcErrorFactory; -${shm ? " /** shm: fixed client slot; default: self-allocated (0 for the synchronous backend). */\n clientId?: number;\n /** shm: override the ipc-runtime native addon path. */\n napiPath?: string;\n" : ""}} +} /** The name this package's first version used for the spawn options. */ export type ${prefix}ServiceOptions = ${prefix}ProcessOptions; -/** The process's environment: the caller's, plus the thread count under both names services read. */ -export function processEnv(options: { threads?: number; env?: NodeJS.ProcessEnv }): 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 resolveBinary(binaryPath?: string): string { - const resolved = ${findBinary}(binaryPath); - if (!resolved) { - throw new IpcSpawnError('${binaryName} binary not found', /*retry=*/ false); - } - return resolved; -} - /** - * Spawn '${binaryName}' and connect to it. Process lifecycle — connectivity, death detection, - * optional respawn, teardown — is owned by the backend (see SpawnedProcessBackend in ipc-runtime). - * Failed calls carry a 'retry' property set to true when the failure was environmental. + * 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 async function spawnProcessBackend(options: ${prefix}ProcessOptions = {}): Promise { - return SpawnedProcessBackend.spawn({ - binaryPath: resolveBinary(options.binaryPath), - binaryName: '${binaryName}', - instancePrefix: '${toSnakeCase(prefix)}', - ipcPathArgs: ${ipcPathArgs}, - transport: options.transport ?? '${defaultTransport}', - logger: options.logger, - connectTimeoutMs: options.connectTimeoutMs, - env: processEnv(options), - extraArgs: options.extraArgs, - respawn: options.respawn, - unref: options.unref, - unrefStdio: options.unrefStdio, -${shm ? " clientId: options.clientId,\n napiPath: options.napiPath,\n" : ""} }); -} -${ - shm - ? ` -/** The synchronous process backend: shared memory is the one transport with a synchronous client. */ -export async function spawnProcessBackendSync(options: ${prefix}ProcessOptions = {}): Promise { - if (options.transport !== undefined && options.transport !== 'shm') { - throw new Error('${packageName}: the synchronous backend needs the shm transport'); - } - return SpawnedProcessBackendSync.spawn({ - binaryPath: resolveBinary(options.binaryPath), - binaryName: '${binaryName}', - instancePrefix: '${toSnakeCase(prefix)}-sync', - ipcPathArgs: ${ipcPathArgs}, - transport: 'shm', - logger: options.logger, - connectTimeoutMs: options.connectTimeoutMs, - env: processEnv(options), - extraArgs: options.extraArgs, - unref: options.unref, - unrefStdio: options.unrefStdio, - clientId: options.clientId ?? 0, - napiPath: options.napiPath, - }); +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). */ @@ -575,7 +501,11 @@ ${ Boolean, ) as string[]; - return `import { type IpcClientAsync, type IpcClientSync${process ? ", SpawnedProcessBackend" : ""} } from '@aztec-foundation/ipc-runtime'; + return `import { + type IpcClientAsync, + type IpcClientSync,${process ? "\n SpawnedProcessBackend," : ""} + pickServiceBackend, +} from '@aztec-foundation/ipc-runtime'; ${wasm ? "import { type WasmFfiBackend, platform } from '@aztec-foundation/ipc-runtime/wasm/node';\n" : ""}import { AsyncApi, type IpcErrorFactory } 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'; @@ -606,62 +536,46 @@ export function createWasmBackend(options: ${prefix}WasmOptions = {}): Promise { - if (typeof options.backend === 'object') { - return options.backend; - } const common = { threads: options.threads, logger: options.logger, unref: options.unref }; + return pickServiceBackend(options.backend, { + label: '${packageName}', + logger: options.logger, ${ process - ? ` if (options.backend === 'process' || (options.backend === undefined && (!${wasm} || ${findBinary}(options.process?.binaryPath)))) { - try { - return await spawnProcessBackend({ ...common, unrefStdio: options.unref, ...options.process }); - } catch (err) { - if (options.backend === 'process' || !${wasm}) { - throw err; - } - options.logger?.(\`${binaryName} process unavailable (\${(err as Error).message}); falling back to wasm\`); - } - } + ? ` process: { + available: () => ${findBinary}(options.process?.binaryPath) !== null, + create: () => spawnProcessBackend({ ...common, unrefStdio: options.unref, ...options.process }), + }, ` : "" }${ wasm - ? ` if (options.backend === undefined || options.backend === 'wasm') { - return createWasmBackend({ ...common, ...options.wasm }); - } + ? ` wasm: { create: () => createWasmBackend({ ...common, ...options.wasm }) }, ` : "" - } throw new Error(\`${packageName}: no such backend here: \${String(options.backend)}\`); + } }); } /** 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 { - if (typeof options.backend === 'object') { - return options.backend; - } const common = { threads: options.threads, logger: options.logger, unref: options.unref }; + return pickServiceBackend(options.backend, { + label: '${packageName}', + logger: options.logger, ${ shm - ? ` if (options.backend === 'process' || (options.backend === undefined && (!${wasm} || ${findBinary}(options.process?.binaryPath)))) { - try { - return await spawnProcessBackendSync({ ...common, unrefStdio: options.unref, ...options.process }); - } catch (err) { - if (options.backend === 'process' || !${wasm}) { - throw err; - } - options.logger?.(\`${binaryName} process unavailable (\${(err as Error).message}); falling back to wasm\`); - } - } + ? ` process: { + available: () => ${findBinary}(options.process?.binaryPath) !== null, + create: () => spawnProcessBackendSync({ ...common, unrefStdio: options.unref, ...options.process }), + }, ` : "" }${ wasm - ? ` if (options.backend === undefined || options.backend === 'wasm') { - return createWasmBackendSync({ logger: options.logger, unref: options.unref, ...options.wasm }); - } + ? ` wasm: { create: () => createWasmBackendSync({ logger: options.logger, unref: options.unref, ...options.wasm }) }, ` : "" - } throw new Error(\`${packageName}: no such synchronous backend here: \${String(options.backend)}\`); + } }); } ${this.serviceClasses({ process, wasm })}`; @@ -848,11 +762,11 @@ export class ${svc}Sync extends SyncApi { type WasmFfiBackendSync, type WasmModuleSource, type WorkerHandle, - MAX_THREADS, + chooseWasmModule, createWasmFfiBackend, createWasmFfiBackendSync, platform, - sharedMemoryAvailable, + resolveWasmThreads, } from '@aztec-foundation/ipc-runtime/wasm'; import type { IpcErrorFactory } from './generated/async.js'; import { hostImports } from './wasm_host_imports.js'; @@ -898,33 +812,21 @@ export interface WasmWorkers { createThreadWorker: () => WorkerHandle; } -const THREADS_MODULE: URL | undefined = ${moduleUrl(this.opts.wasmThreadsModule)}; -const SINGLE_MODULE: URL | undefined = ${moduleUrl(this.opts.wasmModule)}; +// 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 package's own module for a thread count: the threads build for more than one thread, - * otherwise the single-thread build (each falls back to the other when the package ships only one). - */ +/** The module to run for a thread count: the threads build above one thread, else single. */ export function defaultWasmModule(threads: number): URL { - const module = threads > 1 ? (THREADS_MODULE ?? SINGLE_MODULE) : (SINGLE_MODULE ?? THREADS_MODULE); - if (!module) { - throw new Error('${packageName}: no wasm module ships with this package'); - } - return module; + return chooseWasmModule(MODULES, '${packageName}', threads); } -/** The thread count to run with: the default where none was asked for, else the request, checked. */ +/** The thread count to run with: the host's parallelism by default, a request checked against it. */ export function resolveThreads(threads?: number): number { - if (threads === undefined) { - return sharedMemoryAvailable() ? Math.min(platform.hardwareConcurrency(), MAX_THREADS) : 1; - } - if (threads > 1 && !sharedMemoryAvailable()) { - throw new Error( - \`${packageName}: \${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; + return resolveWasmThreads(platform, '${packageName}', threads); } export async function createWasmBackendWith(workers: WasmWorkers, options: ${prefix}WasmOptions = {}): Promise { @@ -1016,81 +918,49 @@ export const hostImports: HostImportsFactory | undefined = undefined; `; } + /** + * Where this package's binary lives. Resolving it and spawning it are ipc-runtime's job; this + * is only the description, which is all that differs from one generated package to the next. + */ 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); + const archPackages = archPackageNames(this.opts.packageName); + // Keyed as `process.arch`-`process.platform` reads at runtime. + const byPlatform = [ + ["x64-linux", archPackages["linux-x64"]], + ["x64-darwin", archPackages["darwin-x64"]], + ["arm64-linux", archPackages["linux-arm64"]], + ["arm64-darwin", archPackages["darwin-arm64"]], + ] + .map(([key, name]) => ` '${key}': '${name}',`) + .join("\n"); - return `import { createRequire } from 'node:module'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; + return `import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; - -export type Platform = 'x86_64-linux' | 'x86_64-darwin' | 'aarch64-linux' | 'aarch64-darwin'; - -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"]}', +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)), '..'), }; -function currentDir(): string { - return path.dirname(fileURLToPath(import.meta.url)); -} - -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; -} - -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; - } -} - /** - * The '${this.opts.binaryName}' binary to run: \`customPath\` if given, else \`${envVar}\`, else the - * installed arch package for this platform. Null when none of those yields an existing file. + * 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 { - if (customPath) { - return fs.existsSync(customPath) ? path.resolve(customPath) : null; - } - - const envPath = process.env.${envVar}; - if (envPath) { - return fs.existsSync(envPath) ? path.resolve(envPath) : null; - } - - const platform = detectPlatform(); - if (!platform) { - return null; - } - - const archDir = findArchPackageDir(platform); - if (archDir) { - const candidate = path.join(archDir, '${this.opts.binaryName}'); - if (fs.existsSync(candidate)) { - return candidate; - } - } - - return null; + return findServiceBinary(BINARY, customPath); } -export const ARCH_PACKAGE_STEM = '${stem}'; +export const ARCH_PACKAGE_STEM = '${packageStem(this.opts.packageName)}'; `; } diff --git a/ipc-runtime/ts/src/index.ts b/ipc-runtime/ts/src/index.ts index 3a095cfd47fc..aedcab977fa8 100644 --- a/ipc-runtime/ts/src/index.ts +++ b/ipc-runtime/ts/src/index.ts @@ -33,3 +33,13 @@ export { loadIpcRuntimeNapi, type Platform, } from "./native_loader.js"; +export { + type ServiceBinary, + type ServiceProcessOptions, + findServiceBinary, + pickServiceBackend, + runServiceBinary, + serviceProcessEnv, + spawnServiceBackend, + spawnServiceBackendSync, +} from "./service.js"; diff --git a/ipc-runtime/ts/src/service.ts b/ipc-runtime/ts/src/service.ts new file mode 100644 index 000000000000..2572452d672e --- /dev/null +++ b/ipc-runtime/ts/src/service.ts @@ -0,0 +1,239 @@ +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; + unref?: boolean; + unrefStdio?: 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, + unrefStdio: options.unrefStdio, + 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, + unrefStdio: options.unrefStdio, + 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/wasm/entry.ts b/ipc-runtime/ts/src/wasm/entry.ts index fcae60129b9d..61908ccabddf 100644 --- a/ipc-runtime/ts/src/wasm/entry.ts +++ b/ipc-runtime/ts/src/wasm/entry.ts @@ -22,6 +22,7 @@ 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 interface WasmEntry { createWasmFfiBackend(opts: WasmFfiBackendOptions): Promise; 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; +} From 7a7dd0cb29c9193530f1e0596ae2bd444a414cfe Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:21:46 +0000 Subject: [PATCH 16/26] refactor(ipc-codegen): move the backend registry and arch-package staging out Two more things were generated that had no reason to be. The React Native backend registry is a global-symbol map keyed by service name, identical in every package: it is now ipc-runtime's, reachable through a host-neutral ./registry subpath so a React Native entry does not pull in the node transports or the wasm engine to find it. And prepare_arch_packages.sh was 63 lines of shell per package that differed only in two names, both of which it can read from the package's own package.json; it is now an ipc-runtime bin. react-native.ts 84 -> 54, and scripts/prepare_arch_packages.sh is gone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- barretenberg/ts/yarn.lock | 2 + ipc-codegen/src/generate.ts | 5 - ipc-codegen/src/typescript_package_codegen.ts | 120 +++--------------- ipc-runtime/ts/package.json | 12 +- .../ts/scripts/prepare_arch_packages.sh | 76 +++++++++++ ipc-runtime/ts/src/backend_registry.ts | 54 ++++++++ ipc-runtime/ts/src/index.ts | 5 + ipc-runtime/ts/src/wasm/entry.ts | 5 + 8 files changed, 169 insertions(+), 110 deletions(-) create mode 100755 ipc-runtime/ts/scripts/prepare_arch_packages.sh create mode 100644 ipc-runtime/ts/src/backend_registry.ts diff --git a/barretenberg/ts/yarn.lock b/barretenberg/ts/yarn.lock index 15e7644e150c..c0f5b05b79e1 100644 --- a/barretenberg/ts/yarn.lock +++ b/barretenberg/ts/yarn.lock @@ -162,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.sh languageName: node linkType: soft diff --git a/ipc-codegen/src/generate.ts b/ipc-codegen/src/generate.ts index 6279d3a63caf..0922bacb7773 100644 --- a/ipc-codegen/src/generate.ts +++ b/ipc-codegen/src/generate.ts @@ -552,11 +552,6 @@ function generate(args: Args) { for (const manifest of packageGen.generateArchPackageManifests()) { writePackage(manifest.path, manifest.content); } - writePackage( - "scripts/prepare_arch_packages.sh", - packageGen.generatePrepareArchPackagesScript(), - { executable: true }, - ); } } break; diff --git a/ipc-codegen/src/typescript_package_codegen.ts b/ipc-codegen/src/typescript_package_codegen.ts index db4b0b42b8bb..00372ce634eb 100644 --- a/ipc-codegen/src/typescript_package_codegen.ts +++ b/ipc-codegen/src/typescript_package_codegen.ts @@ -209,7 +209,7 @@ ${this.opts.curveConstants ? "export * from './generated/curve_constants.js';\n" const scripts: Record = { clean: "rm -rf dest .tsbuildinfo", build: "tsc -p tsconfig.json", - prepare_arch_packages: "./scripts/prepare_arch_packages.sh", + prepare_arch_packages: "ipc-runtime-prepare-arch-packages", }; const entry = (name: string) => ({ types: `./dest/${name}.d.ts`, @@ -661,33 +661,26 @@ ${this.serviceClasses({ process: false, wasm: true })}`; * 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, binaryName } = this.opts; + const { prefix, packageName } = this.opts; const svc = className(prefix); - return `import type { IpcClientAsync, IpcClientSync } from '@aztec-foundation/ipc-runtime'; + return `import type { IpcClientAsync, IpcClientSync } from '@aztec-foundation/ipc-runtime/registry'; +import { registerBackend as register, registeredBackend } from '@aztec-foundation/ipc-runtime/registry'; import { AsyncApi, type IpcErrorFactory } from './generated/async.js'; import { SyncApi } from './generated/sync.js'; -${this.generatedExports()} -/** Backend factories a native backend package registers for a service, by service name. */ -export interface RegisteredBackends { - async?: () => Promise | IpcClientAsync; - sync?: () => Promise | IpcClientSync; -} - -// A well-known global rather than an import in either direction, so the native package and this -// one need not depend on each other (a type-only import keeps the runtime free of ipc-runtime). -const REGISTRY_KEY = Symbol.for('@aztec-foundation/ipc-runtime/ffi-backends'); - -function registry(): Map { - const global = globalThis as unknown as Record | undefined>; - return (global[REGISTRY_KEY] ??= new Map()); -} +${this.generatedExports()}export type { RegisteredBackends } from '@aztec-foundation/ipc-runtime/registry'; -/** Make \`factories\` the default backends for ${prefix} in this app; a native backend package calls this when imported. */ -export function registerBackend(factories: RegisteredBackends): void { - registry().set('${prefix}', factories); +/** 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 { @@ -701,29 +694,12 @@ export interface ${prefix}CreateSyncOptions { createError?: IpcErrorFactory; } -const NO_BACKEND = - '${packageName}: no backend for React Native. Install a native backend package for ${binaryName} (it registers itself when imported) or pass one in options.backend.'; - export async function createBackend(options: ${prefix}CreateOptions = {}): Promise { - if (options.backend) { - return options.backend; - } - const registered = registry().get('${prefix}')?.async; - if (!registered) { - throw new Error(NO_BACKEND); - } - return await registered(); + return options.backend ?? (await registeredBackend('${prefix}', 'async', '${packageName}')()); } export async function createBackendSync(options: ${prefix}CreateSyncOptions = {}): Promise { - if (options.backend) { - return options.backend; - } - const registered = registry().get('${prefix}')?.sync; - if (!registered) { - throw new Error(NO_BACKEND); - } - return await registered(); + return options.backend ?? (await registeredBackend('${prefix}', 'sync', '${packageName}')()); } export class ${svc} extends AsyncApi { @@ -964,69 +940,7 @@ export const ARCH_PACKAGE_STEM = '${packageStem(this.opts.packageName)}'; `; } - 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" <= ...] +# platform: linux-x64 | linux-arm64 | darwin-x64 | darwin-arm64, or a build dir name +# (amd64-linux, arm64-linux, amd64-macos, arm64-macos) +# With no argument for a platform, build// is used when it exists. +set -euo pipefail + +declare -A PLATFORMS=( + ["amd64-linux"]="linux-x64 linux x64" + ["arm64-linux"]="linux-arm64 linux arm64" + ["amd64-macos"]="darwin-x64 darwin x64" + ["arm64-macos"]="darwin-arm64 darwin arm64" +) + +read -r package_name binary_name version < <(node -p " + const p = require('./package.json'); + [p.name, Object.keys(p.bin ?? {})[0] ?? '', p.version].join(' '); +") + +if [ -z "$binary_name" ]; then + echo "prepare_arch_packages: $package_name declares no bin, so it ships no binary" >&2 + exit 1 +fi + +# The scoped name's last segment, which is what the per-platform directories are named after. +stem="${package_name##*/}" + +declare -A BINARIES=() +for arg in "$@"; do + case "$arg" in + *=*) BINARIES["${arg%%=*}"]="${arg#*=}" ;; + *) + echo "Usage: 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="${package_name}-${suffix}" + out_dir="packages/${stem}-${suffix}" + binary_path="${BINARIES[$suffix]:-${BINARIES[$build_dir]:-}}" + + if [ -z "$binary_path" ]; then + binary_path="build/${build_dir}/${binary_name}" + 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}/${binary_name}" + chmod +x "${out_dir}/${binary_name}" 2>/dev/null || true + + cat > "${out_dir}/package.json" < 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 aedcab977fa8..85712c580a95 100644 --- a/ipc-runtime/ts/src/index.ts +++ b/ipc-runtime/ts/src/index.ts @@ -43,3 +43,8 @@ export { spawnServiceBackend, spawnServiceBackendSync, } from "./service.js"; +export { + type RegisteredBackends, + registerBackend, + registeredBackend, +} from "./backend_registry.js"; diff --git a/ipc-runtime/ts/src/wasm/entry.ts b/ipc-runtime/ts/src/wasm/entry.ts index 61908ccabddf..b18b21464e8a 100644 --- a/ipc-runtime/ts/src/wasm/entry.ts +++ b/ipc-runtime/ts/src/wasm/entry.ts @@ -23,6 +23,11 @@ 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; From 7930421f0066c447451814233d615ecfb7821bf3 Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:45:35 +0000 Subject: [PATCH 17/26] refactor: move the curve constants out of ipc-codegen into bb.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A schema-driven IPC generator had a --curve-constants flag that read a JSON of BN254, Grumpkin and secp moduli and generators and emitted them as TypeScript. That is one service's domain knowledge sitting in a tool that otherwise knows nothing about any service. The flag, its JSON and the generated module are gone. bb.js now carries them as source, written as hex so they read as the constants they are, with a test asserting they match `bb msgpack curve_constants` — which computes the same values from the curve definitions bb is compiled against. The old JSON was hand-maintained with nothing checking it; it was correct, but by luck rather than by construction. Also makes the arch-package staging script Node rather than shell: yarn shims a bin through node, so a .sh bin failed with ERR_UNKNOWN_FILE_EXTENSION and the staging step silently did nothing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- .../bbapi/bb_curve_constants.json | 36 --------- .../ts/bb.js/src/curve_constants.test.ts | 55 ++++++++++++++ barretenberg/ts/bb.js/src/curve_constants.ts | 71 +++++++++++++++++ barretenberg/ts/bb.js/src/index.ts | 18 +---- barretenberg/ts/bootstrap.sh | 5 +- barretenberg/ts/yarn.lock | 2 +- ipc-codegen/README.md | 8 +- ipc-codegen/src/generate.ts | 57 +------------- ipc-codegen/src/typescript_package_codegen.ts | 4 +- ipc-runtime/ts/package.json | 2 +- .../ts/scripts/prepare_arch_packages.mjs | 73 ++++++++++++++++++ .../ts/scripts/prepare_arch_packages.sh | 76 ------------------- wsdb/bootstrap.sh | 4 +- 13 files changed, 212 insertions(+), 199 deletions(-) delete mode 100644 barretenberg/cpp/src/barretenberg/bbapi/bb_curve_constants.json create mode 100644 barretenberg/ts/bb.js/src/curve_constants.test.ts create mode 100644 barretenberg/ts/bb.js/src/curve_constants.ts create mode 100755 ipc-runtime/ts/scripts/prepare_arch_packages.mjs delete mode 100755 ipc-runtime/ts/scripts/prepare_arch_packages.sh 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/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.ts b/barretenberg/ts/bb.js/src/index.ts index c7e3f96e627a..422db121c5ef 100644 --- a/barretenberg/ts/bb.js/src/index.ts +++ b/barretenberg/ts/bb.js/src/index.ts @@ -40,21 +40,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 '@aztec-foundation/bb.js-api'; +// Curve constants, for callers doing their own field arithmetic. +export * from './curve_constants.js'; export { findBbBinary, findNapiBinary } from './bb_backends/node/platform.js'; diff --git a/barretenberg/ts/bootstrap.sh b/barretenberg/ts/bootstrap.sh index f5998841eb7d..4a8eae533e35 100755 --- a/barretenberg/ts/bootstrap.sh +++ b/barretenberg/ts/bootstrap.sh @@ -64,7 +64,6 @@ function generate_bb_js_api_package { --binary-env-var BB_BINARY_PATH \ --strip-method-prefix \ --strip-type-prefix \ - --curve-constants "$bbapi/bb_curve_constants.json" \ --package-transports uds,shm,wasm \ --package-ipc-path-args 'msgpack,run,--input,{path}' \ --package-wasm-module barretenberg.wasm \ @@ -120,7 +119,7 @@ function copy_bb_js_api_cross { } function prepare_bb_js_api_arch_packages { - (cd bb.js-api && ./scripts/prepare_arch_packages.sh "$@") + yarn workspace "$BB_JS_API_PACKAGE" run prepare_arch_packages "$@" } # Generate + compile the package bb.js compiles against, without the wasm/binary artifacts @@ -165,7 +164,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 { diff --git a/barretenberg/ts/yarn.lock b/barretenberg/ts/yarn.lock index c0f5b05b79e1..7a76ab18c604 100644 --- a/barretenberg/ts/yarn.lock +++ b/barretenberg/ts/yarn.lock @@ -163,7 +163,7 @@ __metadata: 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.sh + ipc-runtime-prepare-arch-packages: ./scripts/prepare_arch_packages.mjs languageName: node linkType: soft diff --git a/ipc-codegen/README.md b/ipc-codegen/README.md index 6775f4057c80..c74f9009c0f2 100644 --- a/ipc-codegen/README.md +++ b/ipc-codegen/README.md @@ -132,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. | @@ -146,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. diff --git a/ipc-codegen/src/generate.ts b/ipc-codegen/src/generate.ts index 0922bacb7773..ae04f246a262 100644 --- a/ipc-codegen/src/generate.ts +++ b/ipc-codegen/src/generate.ts @@ -74,7 +74,6 @@ interface Args { cppIncludeDir: string; uds: boolean; ffi: boolean; - curveConstants: string; stripMethodPrefix: boolean; stripTypePrefix: boolean; } @@ -126,7 +125,7 @@ Optional: --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); } @@ -153,7 +152,6 @@ function parseArgs(argv: string[]): Args { cppIncludeDir: "", uds: false, ffi: false, - curveConstants: "", stripMethodPrefix: false, stripTypePrefix: false, }; @@ -232,9 +230,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; @@ -441,9 +436,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 = @@ -510,7 +502,6 @@ function generate(args: Args) { .filter(Boolean), wasmModule: args.packageWasmModule || undefined, wasmThreadsModule: args.packageWasmThreadsModule || undefined, - curveConstants: !!args.curveConstants, }); writePackage("package.json", packageGen.generatePackageJson()); writePackage("tsconfig.json", packageGen.generateTsconfig()); @@ -705,52 +696,6 @@ function generate(args: Args) { 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/typescript_package_codegen.ts b/ipc-codegen/src/typescript_package_codegen.ts index 00372ce634eb..456fb95058ed 100644 --- a/ipc-codegen/src/typescript_package_codegen.ts +++ b/ipc-codegen/src/typescript_package_codegen.ts @@ -12,8 +12,6 @@ export interface TypeScriptPackageOptions { wasmModule?: string; /** wasm transport: basename of the threads module shipped in the package's wasm/ directory. */ wasmThreadsModule?: string; - /** Whether generated/curve_constants.ts is emitted alongside (re-exported from the entries). */ - curveConstants?: boolean; } function className(prefix: string): string { @@ -201,7 +199,7 @@ export class TypeScriptPackageCodegen { return `export * from './generated/api_types.js'; export { AsyncApi } from './generated/async.js'; export { SyncApi } from './generated/sync.js'; -${this.opts.curveConstants ? "export * from './generated/curve_constants.js';\n" : ""}`; +`; } generatePackageJson(): string { diff --git a/ipc-runtime/ts/package.json b/ipc-runtime/ts/package.json index 1f2d055104c3..ada5c1c31b4c 100644 --- a/ipc-runtime/ts/package.json +++ b/ipc-runtime/ts/package.json @@ -49,6 +49,6 @@ "typescript": "^5.6.3" }, "bin": { - "ipc-runtime-prepare-arch-packages": "./scripts/prepare_arch_packages.sh" + "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/scripts/prepare_arch_packages.sh b/ipc-runtime/ts/scripts/prepare_arch_packages.sh deleted file mode 100755 index 2343f0c124b3..000000000000 --- a/ipc-runtime/ts/scripts/prepare_arch_packages.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env bash -# 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 and binary name 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 a build dir name -# (amd64-linux, arm64-linux, amd64-macos, arm64-macos) -# With no argument for a platform, build// is used when it exists. -set -euo pipefail - -declare -A PLATFORMS=( - ["amd64-linux"]="linux-x64 linux x64" - ["arm64-linux"]="linux-arm64 linux arm64" - ["amd64-macos"]="darwin-x64 darwin x64" - ["arm64-macos"]="darwin-arm64 darwin arm64" -) - -read -r package_name binary_name version < <(node -p " - const p = require('./package.json'); - [p.name, Object.keys(p.bin ?? {})[0] ?? '', p.version].join(' '); -") - -if [ -z "$binary_name" ]; then - echo "prepare_arch_packages: $package_name declares no bin, so it ships no binary" >&2 - exit 1 -fi - -# The scoped name's last segment, which is what the per-platform directories are named after. -stem="${package_name##*/}" - -declare -A BINARIES=() -for arg in "$@"; do - case "$arg" in - *=*) BINARIES["${arg%%=*}"]="${arg#*=}" ;; - *) - echo "Usage: 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="${package_name}-${suffix}" - out_dir="packages/${stem}-${suffix}" - binary_path="${BINARIES[$suffix]:-${BINARIES[$build_dir]:-}}" - - if [ -z "$binary_path" ]; then - binary_path="build/${build_dir}/${binary_name}" - 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}/${binary_name}" - chmod +x "${out_dir}/${binary_name}" 2>/dev/null || true - - cat > "${out_dir}/package.json" < Date: Thu, 10 Sep 2026 14:51:07 +0000 Subject: [PATCH 18/26] fix(ipc-codegen): remove generated files the generator no longer emits Generation only ever wrote files, so a change to what it emits left the old output behind: dropping --curve-constants left curve_constants.ts in every checkout's src/generated, still compiled and still exported. CI runners reuse working directories, so this is not only a local annoyance. Each run now records what it produced and deletes anything the previous run produced that this one did not. Generated packages also compile from a clean dest, since tsc would otherwise keep the output of a source that is gone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- .../echo_example/ts_package/.gitignore | 2 +- ipc-codegen/src/generate.ts | 41 ++++++++++++++++++- ipc-codegen/src/typescript_package_codegen.ts | 6 ++- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/ipc-codegen/echo_example/ts_package/.gitignore b/ipc-codegen/echo_example/ts_package/.gitignore index 2080cb48950a..fb38dae611c7 100644 --- a/ipc-codegen/echo_example/ts_package/.gitignore +++ b/ipc-codegen/echo_example/ts_package/.gitignore @@ -16,4 +16,4 @@ src/wasm.ts src/wasm/ src/wasm_host_imports.ts wasm/ -scripts/prepare_arch_packages.sh +.ipc-codegen-manifest diff --git a/ipc-codegen/src/generate.ts b/ipc-codegen/src/generate.ts index ae04f246a262..42ed87378032 100644 --- a/ipc-codegen/src/generate.ts +++ b/ipc-codegen/src/generate.ts @@ -25,7 +25,7 @@ import { rmSync, } 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 +48,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 // --------------------------------------------------------------------------- @@ -348,6 +351,35 @@ 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. + } + 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)`); + } + 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); @@ -355,6 +387,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)`); } @@ -363,6 +396,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)`); } @@ -409,6 +443,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; } @@ -450,6 +485,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}`); @@ -693,6 +729,9 @@ 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."); } diff --git a/ipc-codegen/src/typescript_package_codegen.ts b/ipc-codegen/src/typescript_package_codegen.ts index 456fb95058ed..6bab96e348d7 100644 --- a/ipc-codegen/src/typescript_package_codegen.ts +++ b/ipc-codegen/src/typescript_package_codegen.ts @@ -115,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", @@ -206,7 +206,9 @@ export { SyncApi } from './generated/sync.js'; const archPackages = archPackageNames(this.opts.packageName); const scripts: Record = { clean: "rm -rf dest .tsbuildinfo", - build: "tsc -p tsconfig.json", + // 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) => ({ From c3b0491f113280ce7ea23813b9c0ff939431f43a Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:46:40 +0000 Subject: [PATCH 19/26] refactor(bb.js): drop code the move to bb.js-api left behind IMsgpackBackend, IMsgpackBackendSync and IMsgpackBackendAsync were a second set of names for ipc-runtime's IpcClientAsync and IpcClientSync, which is what the backends have been for a while; they were never exported, so the three internal users now name the runtime types directly. asyncMap and writeBenchmark have no callers anywhere and are not part of the package's exports, so nothing could reach them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- barretenberg/ts/bb.js/src/async_map/index.ts | 15 -------- .../ts/bb.js/src/barretenberg/index.ts | 6 ++-- .../ts/bb.js/src/bb_backends/browser/index.ts | 6 ++-- .../ts/bb.js/src/bb_backends/interface.ts | 35 ------------------- .../ts/bb.js/src/bb_backends/node/index.ts | 6 ++-- barretenberg/ts/bb.js/src/benchmark/index.ts | 27 -------------- 6 files changed, 9 insertions(+), 86 deletions(-) delete mode 100644 barretenberg/ts/bb.js/src/async_map/index.ts delete mode 100644 barretenberg/ts/bb.js/src/bb_backends/interface.ts delete mode 100644 barretenberg/ts/bb.js/src/benchmark/index.ts diff --git a/barretenberg/ts/bb.js/src/async_map/index.ts b/barretenberg/ts/bb.js/src/async_map/index.ts deleted file mode 100644 index 3ff1d0fea2f6..000000000000 --- a/barretenberg/ts/bb.js/src/async_map/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Much the same as Array.map, only it takes an async fn as an element handler, and ensures that each element handler - * is executed sequentially. - * The pattern of `await Promise.all(arr.map(async e => { ... }))` 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/barretenberg/index.ts b/barretenberg/ts/bb.js/src/barretenberg/index.ts index 47f90057320e..409f852db3cf 100644 --- a/barretenberg/ts/bb.js/src/barretenberg/index.ts +++ b/barretenberg/ts/bb.js/src/barretenberg/index.ts @@ -1,7 +1,7 @@ import { AsyncApi, SyncApi } from '@aztec-foundation/bb.js-api'; +import type { IpcClientAsync, IpcClientSync } from '@aztec-foundation/ipc-runtime'; 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 { Crs, GrumpkinCrs } from '../crs/index.js'; @@ -35,7 +35,7 @@ export type CircuitOptions = { export class Barretenberg extends AsyncApi { private options: BackendOptions; - constructor(backend: IMsgpackBackendAsync, options: BackendOptions) { + constructor(backend: IpcClientAsync, options: BackendOptions) { super(backend, message => new BBApiException(message)); this.options = options; } @@ -191,7 +191,7 @@ let barretenbergSyncSingletonPromise: Promise | undefined; let barretenbergSyncSingleton: BarretenbergSync | undefined; export class BarretenbergSync extends SyncApi { - constructor(backend: IMsgpackBackendSync) { + constructor(backend: IpcClientSync) { super(backend, message => new BBApiException(message)); } diff --git a/barretenberg/ts/bb.js/src/bb_backends/browser/index.ts b/barretenberg/ts/bb.js/src/bb_backends/browser/index.ts index bd058bd751cd..9568785de165 100644 --- a/barretenberg/ts/bb.js/src/bb_backends/browser/index.ts +++ b/barretenberg/ts/bb.js/src/bb_backends/browser/index.ts @@ -1,7 +1,7 @@ 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'; -import type { IMsgpackBackendAsync, IMsgpackBackendSync } from '../interface.js'; /** * Create backend of specific type (no fallback) @@ -10,7 +10,7 @@ export async function createAsyncBackend( type: BackendType, options: BackendOptions, logger: (msg: string) => void, -): Promise { +): Promise { switch (type) { case BackendType.Wasm: case BackendType.WasmWorker: { @@ -38,7 +38,7 @@ export async function createSyncBackend( type: BackendType, options: BackendOptions, logger: (msg: string) => void, -): Promise { +): Promise { switch (type) { case BackendType.Wasm: logger('Using WASM backend'); 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 index a6c71d419bae..5dd1184c5550 100644 --- a/barretenberg/ts/bb.js/src/bb_backends/node/index.ts +++ b/barretenberg/ts/bb.js/src/bb_backends/node/index.ts @@ -1,8 +1,8 @@ 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'; -import type { IMsgpackBackendAsync, IMsgpackBackendSync } from '../interface.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. @@ -24,7 +24,7 @@ export async function createAsyncBackend( type: BackendType, options: BackendOptions, logger: (msg: string) => void, -): Promise { +): Promise { const wasmPath = options.wasmPath ?? process.env.BB_WASM_PATH; switch (type) { @@ -81,7 +81,7 @@ export async function createSyncBackend( type: BackendType, options: BackendOptions, logger: (msg: string) => void, -): Promise { +): Promise { const wasmPath = options.wasmPath ?? process.env.BB_WASM_PATH; switch (type) { 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); -} From 4ea4e89169a0119a1f30c1a92e53d0ac0b47093f Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:52:48 +0000 Subject: [PATCH 20/26] docs(bb.js): credit the parent-death watch to ipc-runtime, not bb The comment said bb monitors parent death and so must be unref'd. bb does not: ipc-runtime's C++ installs the watch (prctl on Linux, a kqueue NOTE_EXIT watcher on macOS) and the generated serve() calls it, so this is true of every service spawned this way, not of bb. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- barretenberg/ts/bb.js/src/bb_backends/node/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/barretenberg/ts/bb.js/src/bb_backends/node/index.ts b/barretenberg/ts/bb.js/src/bb_backends/node/index.ts index 5dd1184c5550..816364cad87b 100644 --- a/barretenberg/ts/bb.js/src/bb_backends/node/index.ts +++ b/barretenberg/ts/bb.js/src/bb_backends/node/index.ts @@ -9,8 +9,10 @@ import { BackendOptions, BackendType } from '../index.js'; const SHM_RING_SIZE = 1024 * 1024 * 4; /** - * bb monitors parent death (prctl/kqueue) and exits on its own, so the child must never hold the - * Node event loop open; its log pipes (present with a logger) do, unless the caller asked for unref. + * A spawned server dies with its parent — ipc-runtime's C++ installs that watch, and the + * generated serve() calls it — so it must never hold the Node event loop open. The runtime does + * not assume it, so ask. Its log pipes are separate: they exist only when a logger is attached, + * and unref'ing them lets the process exit with lines still unread, so they follow the caller. */ function bbProcessLifetime(options: BackendOptions) { return { unref: true, unrefStdio: options.unref }; From cbe3580616e136e36403e4680af12c8d3972de78 Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:01:19 +0000 Subject: [PATCH 21/26] fix(ipc-runtime): make unref mean what it says, and be one option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unref was meant to say "you never have to destroy this for node to exit". Both transports already implemented that honestly — the uds client refs the socket while a call is outstanding and unrefs when the last one lands, and the shm client acquires a thread-safe-function reference for the same span. The child process handle did not: it was unref'd at spawn, before anything was connected, so during a slow start nothing held the loop and node could exit mid-startup, silently. The child is now unref'd once the backend is connected, after which the transports' own refcounting covers every call. That also collapses unrefStdio into unref. It existed because unref'ing the child leaves its stdout/stderr pipes holding the loop, so the guarantee did not hold whenever a logger was attached. The pipes are now unref'd at the same moment, and the one cost — a trailing log line lost if the process exits mid-write — is stated on the remaining option. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- .../ts/bb.js/src/bb_backends/node/index.ts | 18 ++++---- ipc-codegen/src/typescript_package_codegen.ts | 4 +- ipc-runtime/ts/src/service.ts | 4 +- ipc-runtime/ts/src/spawned_backend.ts | 46 +++++++++++-------- 4 files changed, 38 insertions(+), 34 deletions(-) diff --git a/barretenberg/ts/bb.js/src/bb_backends/node/index.ts b/barretenberg/ts/bb.js/src/bb_backends/node/index.ts index 816364cad87b..c2a45d6d54f6 100644 --- a/barretenberg/ts/bb.js/src/bb_backends/node/index.ts +++ b/barretenberg/ts/bb.js/src/bb_backends/node/index.ts @@ -9,14 +9,12 @@ import { BackendOptions, BackendType } from '../index.js'; const SHM_RING_SIZE = 1024 * 1024 * 4; /** - * A spawned server dies with its parent — ipc-runtime's C++ installs that watch, and the - * generated serve() calls it — so it must never hold the Node event loop open. The runtime does - * not assume it, so ask. Its log pipes are separate: they exist only when a logger is attached, - * and unref'ing them lets the process exit with lines still unread, so they follow the caller. + * 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. */ -function bbProcessLifetime(options: BackendOptions) { - return { unref: true, unrefStdio: options.unref }; -} +const BB_PROCESS_LIFETIME = { unref: true }; /** * Create backend of specific type (no fallback). Everything here is bb's choice of options over @@ -37,7 +35,7 @@ export async function createAsyncBackend( // 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', ...bbProcessLifetime(options) }, + process: { binaryPath: options.bbPath, transport: 'uds', ...BB_PROCESS_LIFETIME }, }); case BackendType.NativeSharedMemory: @@ -52,7 +50,7 @@ export async function createAsyncBackend( clientId: 0, napiPath: options.napiPath, extraArgs: ['--request-ring-size', `${SHM_RING_SIZE}`, '--response-ring-size', `${SHM_RING_SIZE}`], - ...bbProcessLifetime(options), + ...BB_PROCESS_LIFETIME, }, }); @@ -99,7 +97,7 @@ export async function createSyncBackend( transport: 'shm', napiPath: options.napiPath, extraArgs: ['--request-ring-size', `${SHM_RING_SIZE}`], - ...bbProcessLifetime(options), + ...BB_PROCESS_LIFETIME, }, }); diff --git a/ipc-codegen/src/typescript_package_codegen.ts b/ipc-codegen/src/typescript_package_codegen.ts index 6bab96e348d7..fffeb23d973d 100644 --- a/ipc-codegen/src/typescript_package_codegen.ts +++ b/ipc-codegen/src/typescript_package_codegen.ts @@ -544,7 +544,7 @@ ${ process ? ` process: { available: () => ${findBinary}(options.process?.binaryPath) !== null, - create: () => spawnProcessBackend({ ...common, unrefStdio: options.unref, ...options.process }), + create: () => spawnProcessBackend({ ...common, ...options.process }), }, ` : "" @@ -566,7 +566,7 @@ ${ shm ? ` process: { available: () => ${findBinary}(options.process?.binaryPath) !== null, - create: () => spawnProcessBackendSync({ ...common, unrefStdio: options.unref, ...options.process }), + create: () => spawnProcessBackendSync({ ...common, ...options.process }), }, ` : "" diff --git a/ipc-runtime/ts/src/service.ts b/ipc-runtime/ts/src/service.ts index 2572452d672e..071d2447b7cf 100644 --- a/ipc-runtime/ts/src/service.ts +++ b/ipc-runtime/ts/src/service.ts @@ -40,8 +40,8 @@ export interface ServiceProcessOptions { env?: NodeJS.ProcessEnv; extraArgs?: string[]; respawn?: boolean; + /** When true, an idle backend does not keep the process alive; see SpawnedProcessBackendOptions. */ unref?: boolean; - unrefStdio?: boolean; clientId?: number; napiPath?: string; } @@ -142,7 +142,6 @@ export function spawnServiceBackend( extraArgs: options.extraArgs, respawn: options.respawn, unref: options.unref, - unrefStdio: options.unrefStdio, clientId: options.clientId, napiPath: options.napiPath, }); @@ -169,7 +168,6 @@ export function spawnServiceBackendSync( env: serviceProcessEnv(options), extraArgs: options.extraArgs, unref: options.unref, - unrefStdio: options.unrefStdio, clientId: options.clientId ?? 0, napiPath: options.napiPath, }); 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, From ef3a240a6270960070569d8091374b338be9ebfa Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:34:27 +0000 Subject: [PATCH 22/26] refactor(bb.js): lay the package out so it reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four folders held one file each, and the one named after the API held only tests. Now: retry moves next to its single caller in crs, proof and the test Timer become plain modules, the Chonk test joins the rest of the barretenberg tests, and bb_backends loses the stutter to become backends. The platform-split directories stay directories — the browser build finds them by rewriting "/node/" in import paths. Two dead things go with it: index.html loaded a simple_test.js that has never existed in this tree, alongside the simple_test script pointing at a missing src/examples, and bbapi/exception_handling.test.ts asserted the same srsInitSrs failure the wasm backend test already covers, so its synchronous half moves there and the file goes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- barretenberg/ts/bb.js/bootstrap.sh | 11 ++-- barretenberg/ts/bb.js/package.json | 3 +- .../browser/index.ts | 0 .../browser/platform.ts | 0 .../src/{bb_backends => backends}/index.ts | 0 .../{bb_backends => backends}/node/index.ts | 0 .../node/platform.ts | 0 .../{bb_backends => backends}/wasm.test.ts | 15 +++++- .../ts/bb.js/src/barretenberg/backend.ts | 2 +- .../chonk_pinned_inputs.test.ts | 0 .../ts/bb.js/src/barretenberg/index.ts | 6 +-- .../bb.js/src/barretenberg/pedersen.test.ts | 2 +- .../bb.js/src/barretenberg/poseidon.test.ts | 2 +- .../testing}/timer.ts | 0 .../src/bbapi/exception_handling.test.ts | 52 ------------------- barretenberg/ts/bb.js/src/bin/index.ts | 2 +- barretenberg/ts/bb.js/src/crs/net_crs.ts | 2 +- .../src/{retry/index.ts => crs/retry.ts} | 0 barretenberg/ts/bb.js/src/index.html | 9 ---- barretenberg/ts/bb.js/src/index.ts | 4 +- .../ts/bb.js/src/{proof/index.ts => proof.ts} | 0 21 files changed, 28 insertions(+), 82 deletions(-) rename barretenberg/ts/bb.js/src/{bb_backends => backends}/browser/index.ts (100%) rename barretenberg/ts/bb.js/src/{bb_backends => backends}/browser/platform.ts (100%) rename barretenberg/ts/bb.js/src/{bb_backends => backends}/index.ts (100%) rename barretenberg/ts/bb.js/src/{bb_backends => backends}/node/index.ts (100%) rename barretenberg/ts/bb.js/src/{bb_backends => backends}/node/platform.ts (100%) rename barretenberg/ts/bb.js/src/{bb_backends => backends}/wasm.test.ts (73%) rename barretenberg/ts/bb.js/src/{bbapi => barretenberg}/chonk_pinned_inputs.test.ts (100%) rename barretenberg/ts/bb.js/src/{benchmark => barretenberg/testing}/timer.ts (100%) delete mode 100644 barretenberg/ts/bb.js/src/bbapi/exception_handling.test.ts rename barretenberg/ts/bb.js/src/{retry/index.ts => crs/retry.ts} (100%) delete mode 100644 barretenberg/ts/bb.js/src/index.html rename barretenberg/ts/bb.js/src/{proof/index.ts => proof.ts} (100%) diff --git a/barretenberg/ts/bb.js/bootstrap.sh b/barretenberg/ts/bb.js/bootstrap.sh index 0ca192183a6d..cb1fde4154d4 100755 --- a/barretenberg/ts/bb.js/bootstrap.sh +++ b/barretenberg/ts/bb.js/bootstrap.sh @@ -56,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 { diff --git a/barretenberg/ts/bb.js/package.json b/barretenberg/ts/bb.js/package.json index 46ac1f354f6a..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": { @@ -37,7 +37,6 @@ "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": { diff --git a/barretenberg/ts/bb.js/src/bb_backends/browser/index.ts b/barretenberg/ts/bb.js/src/backends/browser/index.ts similarity index 100% rename from barretenberg/ts/bb.js/src/bb_backends/browser/index.ts rename to barretenberg/ts/bb.js/src/backends/browser/index.ts 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 100% rename from barretenberg/ts/bb.js/src/bb_backends/index.ts rename to barretenberg/ts/bb.js/src/backends/index.ts diff --git a/barretenberg/ts/bb.js/src/bb_backends/node/index.ts b/barretenberg/ts/bb.js/src/backends/node/index.ts similarity index 100% rename from barretenberg/ts/bb.js/src/bb_backends/node/index.ts rename to barretenberg/ts/bb.js/src/backends/node/index.ts 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 100% rename from barretenberg/ts/bb.js/src/bb_backends/node/platform.ts rename to barretenberg/ts/bb.js/src/backends/node/platform.ts diff --git a/barretenberg/ts/bb.js/src/bb_backends/wasm.test.ts b/barretenberg/ts/bb.js/src/backends/wasm.test.ts similarity index 73% rename from barretenberg/ts/bb.js/src/bb_backends/wasm.test.ts rename to barretenberg/ts/bb.js/src/backends/wasm.test.ts index f88a3910d35b..821eaf106e7b 100644 --- a/barretenberg/ts/bb.js/src/bb_backends/wasm.test.ts +++ b/barretenberg/ts/bb.js/src/backends/wasm.test.ts @@ -46,7 +46,20 @@ describe('wasm backends', () => { } }, 60000); - it('surfaces bb errors as exceptions', async () => { + 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( diff --git a/barretenberg/ts/bb.js/src/barretenberg/backend.ts b/barretenberg/ts/bb.js/src/barretenberg/backend.ts index d6006952d74d..1fb00b445b2b 100644 --- a/barretenberg/ts/bb.js/src/barretenberg/backend.ts +++ b/barretenberg/ts/bb.js/src/barretenberg/backend.ts @@ -3,7 +3,7 @@ import { Decoder, Encoder } from 'msgpackr'; import { ungzip } from 'pako'; import { CircuitKind } from '../circuit_kind.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/index.ts b/barretenberg/ts/bb.js/src/barretenberg/index.ts index 409f852db3cf..feba46bf00a8 100644 --- a/barretenberg/ts/bb.js/src/barretenberg/index.ts +++ b/barretenberg/ts/bb.js/src/barretenberg/index.ts @@ -1,8 +1,8 @@ import { AsyncApi, SyncApi } from '@aztec-foundation/bb.js-api'; import type { IpcClientAsync, IpcClientSync } from '@aztec-foundation/ipc-runtime'; -import { BackendOptions, BackendType } from '../bb_backends/index.js'; -import { createAsyncBackend, createSyncBackend } from '../bb_backends/node/index.js'; +import { BackendOptions, BackendType } from '../backends/index.js'; +import { createAsyncBackend, createSyncBackend } from '../backends/node/index.js'; import { BBApiException } from '../bbapi_exception.js'; import { Crs, GrumpkinCrs } from '../crs/index.js'; @@ -21,7 +21,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 */ 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.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/bbapi/exception_handling.test.ts b/barretenberg/ts/bb.js/src/bbapi/exception_handling.test.ts deleted file mode 100644 index abcfbc06dae3..000000000000 --- a/barretenberg/ts/bb.js/src/bbapi/exception_handling.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { SyncApi, createWasmBackendSync } from '@aztec-foundation/bb.js-api'; -import type { WasmFfiBackendSync } from '@aztec-foundation/ipc-runtime/wasm'; - -describe('BBApi Exception Handling from bb.js', () => { - let backend: WasmFfiBackendSync; - let api: SyncApi; - - beforeAll(async () => { - backend = await createWasmBackendSync(); - 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/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/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 422db121c5ef..a7ed9aa75332 100644 --- a/barretenberg/ts/bb.js/src/index.ts +++ b/barretenberg/ts/bb.js/src/index.ts @@ -16,7 +16,7 @@ export { } from './barretenberg/index.js'; export { randomBytes } from './random/index.js'; -export { splitHonkProof, reconstructHonkProof, deflattenFields, type ProofData } from './proof/index.js'; +export { splitHonkProof, reconstructHonkProof, deflattenFields, type ProofData } from './proof.js'; export { BBApiException } from './bbapi_exception.js'; // Export Point types for use in foundation and other packages @@ -43,4 +43,4 @@ export { CircuitKind } from './circuit_kind.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 From 880a9412ec542daac207912054d9fb999ba4d924 Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:41:26 +0000 Subject: [PATCH 23/26] chore(bb.js): drop the dead half of the CJS post-process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing has ever been tagged // POSTPROCESS ESM ONLY, so the pass that stripped those lines only ever read files. What remains is the one real problem — a single import.meta.url, which is a syntax error in CommonJS — and a note saying what may not be written in code this build compiles, since blanking it makes the expression evaluate rather than fail. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- barretenberg/ts/bb.js/scripts/cjs_postprocess.sh | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) 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 < Date: Thu, 10 Sep 2026 16:50:41 +0000 Subject: [PATCH 24/26] refactor(bb.js): trim BBApiException to what it does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The captureStackTrace call removed exactly one frame — its own constructor — and the comment claimed it maintained a stack trace it was not responsible for; super() captures that either way. Nothing has ever caught the type in this repository, so what the class earns is its name: a log line that says the failure came from bb rather than from bb.js. Also records an asymmetry a caller would otherwise meet by surprise. Only the native backends raise it. bb's wasm build compiles with BB_NO_EXCEPTIONS, so a failing command aborts into the host's throw hook and arrives as a plain Error with the same message. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- barretenberg/ts/bb.js/src/bbapi_exception.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/barretenberg/ts/bb.js/src/bbapi_exception.ts b/barretenberg/ts/bb.js/src/bbapi_exception.ts index 47f3fac43aa8..615dcc522c15 100644 --- a/barretenberg/ts/bb.js/src/bbapi_exception.ts +++ b/barretenberg/ts/bb.js/src/bbapi_exception.ts @@ -1,13 +1,15 @@ /** - * Exception thrown when barretenberg API operations fail + * Raised when bb answers a command with an error rather than a result. The message is bb's own, + * so the type is what distinguishes "bb said no" from a fault in bb.js itself — in a log, and for + * a caller that wants to catch one and not the other. + * + * Only the native backends raise it. bb's wasm build compiles with BB_NO_EXCEPTIONS, so a failing + * command aborts into the host's throw hook instead of coming back as an error frame, and the + * caller sees a plain Error carrying the same message. Catch Error if you need both. */ 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); - } } } From c395e73b8bda9e72916cc426ddbca89a4efcf524 Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:14:44 +0000 Subject: [PATCH 25/26] refactor(ipc-codegen): generate the error class, drop the error factory Every generated TS API took an optional `createError` so a consumer could name the error a failed command raises. Only bb.js ever passed one, to get BBApiException, and the option was threaded through six option interfaces and four constructors in every generated package, with 'createError' listed in each Omit<> alongside it. Generate the class instead: api_types.ts declares one named for the service's error response (BbErrorResponse -> BbError), and the async and sync APIs throw it. Every package now has a typed error with no configuration, and the factory is gone. bb.js re-exports it, aliased to BBApiException so existing callers keep compiling. The new test pins the contract, which is per-failure-site rather than per-backend: a handler that reports with BBAPI_ERROR returns an error frame and raises BbError on every backend, while one that reports with throw_or_abort raises BbError on native but a plain Error on wasm, where BB_NO_EXCEPTIONS compiles the dispatcher's catch away and the throw reaches the host hook instead. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- .../ts/bb.js/src/barretenberg/errors.test.ts | 62 +++++++++++++++++++ .../ts/bb.js/src/barretenberg/index.ts | 9 +-- barretenberg/ts/bb.js/src/bbapi_exception.ts | 15 ----- barretenberg/ts/bb.js/src/index.ts | 13 +++- ipc-codegen/src/generate.ts | 6 ++ ipc-codegen/src/typescript_codegen.ts | 41 +++++++----- ipc-codegen/src/typescript_package_codegen.ts | 56 +++++++---------- 7 files changed, 131 insertions(+), 71 deletions(-) create mode 100644 barretenberg/ts/bb.js/src/barretenberg/errors.test.ts delete mode 100644 barretenberg/ts/bb.js/src/bbapi_exception.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..db7e5a52f97b --- /dev/null +++ b/barretenberg/ts/bb.js/src/barretenberg/errors.test.ts @@ -0,0 +1,62 @@ +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 + // throw_or_abort reaches the host's throw hook instead. 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 feba46bf00a8..8b73f96114dc 100644 --- a/barretenberg/ts/bb.js/src/barretenberg/index.ts +++ b/barretenberg/ts/bb.js/src/barretenberg/index.ts @@ -1,9 +1,8 @@ import { AsyncApi, SyncApi } from '@aztec-foundation/bb.js-api'; -import type { IpcClientAsync, IpcClientSync } from '@aztec-foundation/ipc-runtime'; +import type { IpcClientAsync } from '@aztec-foundation/ipc-runtime'; import { BackendOptions, BackendType } from '../backends/index.js'; import { createAsyncBackend, createSyncBackend } from '../backends/node/index.js'; -import { BBApiException } from '../bbapi_exception.js'; import { Crs, GrumpkinCrs } from '../crs/index.js'; const DEFAULT_BB_CRS_SIZE = 2 ** 19; @@ -36,7 +35,7 @@ export class Barretenberg extends AsyncApi { private options: BackendOptions; constructor(backend: IpcClientAsync, options: BackendOptions) { - super(backend, message => new BBApiException(message)); + super(backend); this.options = options; } @@ -191,10 +190,6 @@ let barretenbergSyncSingletonPromise: Promise | undefined; let barretenbergSyncSingleton: BarretenbergSync | undefined; export class BarretenbergSync extends SyncApi { - constructor(backend: IpcClientSync) { - super(backend, message => new BBApiException(message)); - } - /** * Create a new BarretenbergSync instance. * 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 615dcc522c15..000000000000 --- a/barretenberg/ts/bb.js/src/bbapi_exception.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Raised when bb answers a command with an error rather than a result. The message is bb's own, - * so the type is what distinguishes "bb said no" from a fault in bb.js itself — in a log, and for - * a caller that wants to catch one and not the other. - * - * Only the native backends raise it. bb's wasm build compiles with BB_NO_EXCEPTIONS, so a failing - * command aborts into the host's throw hook instead of coming back as an error frame, and the - * caller sees a plain Error carrying the same message. Catch Error if you need both. - */ -export class BBApiException extends Error { - constructor(message: string) { - super(message); - this.name = 'BBApiException'; - } -} diff --git a/barretenberg/ts/bb.js/src/index.ts b/barretenberg/ts/bb.js/src/index.ts index a7ed9aa75332..fe0ecc071443 100644 --- a/barretenberg/ts/bb.js/src/index.ts +++ b/barretenberg/ts/bb.js/src/index.ts @@ -17,7 +17,18 @@ export { export { randomBytes } from './random/index.js'; export { splitHonkProof, reconstructHonkProof, deflattenFields, type ProofData } from './proof.js'; -export { BBApiException } from './bbapi_exception.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 { diff --git a/ipc-codegen/src/generate.ts b/ipc-codegen/src/generate.ts index 42ed87378032..b78f131dc06b 100644 --- a/ipc-codegen/src/generate.ts +++ b/ipc-codegen/src/generate.ts @@ -569,6 +569,12 @@ function generate(args: Args) { "src/wasm/browser/main.worker.ts", packageGen.generateBrowserMainWorker(), ); + // The host imports have to be a module inside the package rather than something the + // caller passes to a constructor: they are closures, and the same closures are needed + // in every realm that instantiates the module — the calling thread, the main worker + // and each wasi thread worker. Functions cannot be cloned across a worker boundary, so + // a worker can only get them by importing them, from a path a bundler can resolve at + // build time. writePackage( "src/wasm_host_imports.ts", args.packageWasmHostImports 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 fffeb23d973d..a6655c9574fc 100644 --- a/ipc-codegen/src/typescript_package_codegen.ts +++ b/ipc-codegen/src/typescript_package_codegen.ts @@ -326,7 +326,6 @@ export function spawnProcessBackendSync(options: ${prefix}ProcessOptions = {}): type SpawnedProcessBackend,${syncImports} spawnServiceBackend, } from '@aztec-foundation/ipc-runtime'; -import type { IpcErrorFactory } from './generated/async.js'; import { BINARY } from './platform.js'; export type ${prefix}Transport = ${transports}; @@ -337,7 +336,6 @@ export type ${prefix}Transport = ${transports}; */ export interface ${prefix}ProcessOptions extends ServiceProcessOptions { transport?: ${prefix}Transport; - createError?: IpcErrorFactory; } /** The name this package's first version used for the spawn options. */ @@ -379,10 +377,9 @@ export interface ${prefix}CreateOptions { /** 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; - createError?: IpcErrorFactory; /** 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' | 'createError'>;\n` : ""}${wasm ? ` wasm?: Omit<${prefix}WasmOptions, 'threads' | 'logger' | 'createError'>;\n` : ""}} +${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 { @@ -391,9 +388,8 @@ export interface ${prefix}CreateSyncOptions { /** Threads a spawned process may use (the synchronous wasm module always has one). */ threads?: number; logger?: (msg: string) => void; - createError?: IpcErrorFactory; unref?: boolean; -${process ? ` process?: Omit<${prefix}ProcessOptions, 'threads' | 'logger' | 'createError'>;\n` : ""}${wasm ? ` wasm?: Omit<${prefix}WasmOptions, 'threads' | 'worker' | 'logger' | 'createError'>;\n` : ""}} +${process ? ` process?: Omit<${prefix}ProcessOptions, 'threads' | 'logger'>;\n` : ""}${wasm ? ` wasm?: Omit<${prefix}WasmOptions, 'threads' | 'worker' | 'logger'>;\n` : ""}} `; } @@ -407,19 +403,19 @@ ${process ? ` process?: Omit<${prefix}ProcessOptions, 'threads' | 'logger' | 'c * given.${process ? " Process lifecycle stays inside the backend and never leaks onto this API." : ""} */ export class ${svc} extends AsyncApi { - private constructor(backend: IpcClientAsync, createError?: IpcErrorFactory) { - super(backend, createError); + private constructor(backend: IpcClientAsync) { + super(backend); } static async create(options: ${prefix}CreateOptions = {}): Promise<${svc}> { - return new ${svc}(await createBackend(options), options.createError); + 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), options.createError); + return new ${svc}(await spawnProcessBackend(options)); } ` : "" @@ -428,7 +424,7 @@ ${ ? ` /** The service over the in-process wasm module (no fallback). */ static async wasm(options: ${prefix}WasmOptions = {}): Promise<${svc}> { - return new ${svc}(await createWasmBackend(options), options.createError); + return new ${svc}(await createWasmBackend(options)); } ` : "" @@ -461,19 +457,19 @@ ${ /** The synchronous ${prefix} service: every call blocks the calling thread until the service answers. */ export class ${svc}Sync extends SyncApi { - private constructor(backend: IpcClientSync, createError?: IpcErrorFactory) { - super(backend, createError); + private constructor(backend: IpcClientSync) { + super(backend); } static async create(options: ${prefix}CreateSyncOptions = {}): Promise<${svc}Sync> { - return new ${svc}Sync(await createBackendSync(options), options.createError); + 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), options.createError); + return new ${svc}Sync(await spawnProcessBackendSync(options)); } ` : "" @@ -482,7 +478,7 @@ ${ ? ` /** 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), options.createError); + return new ${svc}Sync(await createWasmBackendSync(options)); } ` : "" @@ -506,7 +502,7 @@ ${ type IpcClientSync,${process ? "\n SpawnedProcessBackend," : ""} pickServiceBackend, } from '@aztec-foundation/ipc-runtime'; -${wasm ? "import { type WasmFfiBackend, platform } from '@aztec-foundation/ipc-runtime/wasm/node';\n" : ""}import { AsyncApi, type IpcErrorFactory } from './generated/async.js'; +${wasm ? "import { type WasmFfiBackend, platform } from '@aztec-foundation/ipc-runtime/wasm/node';\n" : ""}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, createWasmBackendSync, createWasmBackendWith } from './wasm.js';\n` : ""} @@ -587,7 +583,7 @@ ${this.serviceClasses({ process, wasm })}`; return `import type { IpcClientAsync, IpcClientSync } from '@aztec-foundation/ipc-runtime'; import { type WasmFfiBackend, workerHandle } from '@aztec-foundation/ipc-runtime/wasm/browser'; -import { AsyncApi, type IpcErrorFactory } from './generated/async.js'; +import { AsyncApi } from './generated/async.js'; import { SyncApi } from './generated/sync.js'; import { type ${prefix}WasmOptions, createWasmBackendSync, createWasmBackendWith } from './wasm.js'; @@ -601,18 +597,16 @@ export interface ${prefix}CreateOptions { /** Worker threads to run with; more than one needs a cross-origin isolated page (COOP/COEP). */ threads?: number; logger?: (msg: string) => void; - createError?: IpcErrorFactory; unref?: boolean; - wasm?: Omit<${prefix}WasmOptions, 'threads' | 'logger' | 'createError'>; + wasm?: Omit<${prefix}WasmOptions, 'threads' | 'logger'>; } /** Options for ${className(prefix)}Sync.create / createBackendSync. */ export interface ${prefix}CreateSyncOptions { backend?: ${prefix}Backend | IpcClientSync; logger?: (msg: string) => void; - createError?: IpcErrorFactory; unref?: boolean; - wasm?: Omit<${prefix}WasmOptions, 'threads' | 'worker' | 'logger' | 'createError'>; + wasm?: Omit<${prefix}WasmOptions, 'threads' | 'worker' | 'logger'>; } /** @@ -673,7 +667,7 @@ ${this.serviceClasses({ process: false, wasm: true })}`; return `import type { IpcClientAsync, IpcClientSync } from '@aztec-foundation/ipc-runtime/registry'; import { registerBackend as register, registeredBackend } from '@aztec-foundation/ipc-runtime/registry'; -import { AsyncApi, type IpcErrorFactory } from './generated/async.js'; +import { AsyncApi } from './generated/async.js'; import { SyncApi } from './generated/sync.js'; ${this.generatedExports()}export type { RegisteredBackends } from '@aztec-foundation/ipc-runtime/registry'; @@ -686,12 +680,10 @@ export function registerBackend(factories: Parameters[1]): void export interface ${prefix}CreateOptions { /** A backend object (anything with call()/destroy()); default: the one a native package registered. */ backend?: IpcClientAsync; - createError?: IpcErrorFactory; } export interface ${prefix}CreateSyncOptions { backend?: IpcClientSync; - createError?: IpcErrorFactory; } export async function createBackend(options: ${prefix}CreateOptions = {}): Promise { @@ -703,22 +695,22 @@ export async function createBackendSync(options: ${prefix}CreateSyncOptions = {} } export class ${svc} extends AsyncApi { - private constructor(backend: IpcClientAsync, createError?: IpcErrorFactory) { - super(backend, createError); + private constructor(backend: IpcClientAsync) { + super(backend); } static async create(options: ${prefix}CreateOptions = {}): Promise<${svc}> { - return new ${svc}(await createBackend(options), options.createError); + return new ${svc}(await createBackend(options)); } } export class ${svc}Sync extends SyncApi { - private constructor(backend: IpcClientSync, createError?: IpcErrorFactory) { - super(backend, createError); + private constructor(backend: IpcClientSync) { + super(backend); } static async create(options: ${prefix}CreateSyncOptions = {}): Promise<${svc}Sync> { - return new ${svc}Sync(await createBackendSync(options), options.createError); + return new ${svc}Sync(await createBackendSync(options)); } } `; @@ -744,7 +736,6 @@ export class ${svc}Sync extends SyncApi { platform, resolveWasmThreads, } from '@aztec-foundation/ipc-runtime/wasm'; -import type { IpcErrorFactory } from './generated/async.js'; import { hostImports } from './wasm_host_imports.js'; export { sharedMemoryAvailable } from '@aztec-foundation/ipc-runtime/wasm'; @@ -779,7 +770,6 @@ export interface ${prefix}WasmOptions { logger?: (msg: string) => void; /** Let node exit while the module's workers are alive. */ unref?: boolean; - createError?: IpcErrorFactory; } /** Worker factories a platform entry binds (see WasmFfiBinding in ipc-runtime for why factories). */ From b8a1e02b2fcf519e2260593c5a5ce1db4ce77f23 Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:12:34 +0000 Subject: [PATCH 26/26] refactor(bb): make the wasm build a plain WASI reactor bb's wasm imported three functions of its own beyond WASI: a logger, an abort hook and the thread count. That forced every consumer to supply them, which is why the generated package carried a wasm_host_imports module and three worker entries of its own: the closures are needed in the calling thread, the main worker and every wasi thread, and functions cannot cross a worker boundary, so each realm had to import them from a file the package owned. None of it was necessary. The reason bb had those imports is barretenberg/wasi, which stubbed out the WASI calls the old JavaScript host did not implement: fd_write was answered by routing it back into logstr, and environ_get by reporting an empty environment. ipc-runtime implements both properly, so the stubs are what stood between bb and its own host. Delete them, and define the three functions in barretenberg/wasi instead: - logstr writes to stderr, with linear memory size where native reports peak RSS - env_hardware_concurrency returns 1; thread.cpp already prefers HARDWARE_CONCURRENCY from the environment, which the runtime already puts there, so wasm now picks its thread count exactly the way a spawned process does - throw_or_abort_impl writes the reason and exits, since a build without exceptions cannot unwind The module now imports nothing but WASI, its memory, and wasi-threads' thread-spawn. Size is unchanged. The generated package ships no host imports and no worker entries, and ipc-runtime's own workers, which nothing had used before, are what run. Runtime changes this needs: - fd_prestat_get and fd_prestat_dir_name must answer EBADF, not ENOSYS: wasi-libc walks descriptors looking for preopens at startup and aborts on any other error - proc_exit flushes a line still waiting for its terminator, so a module that gives up part way through a message does not lose it - a throw out of the entry is re-raised carrying what the module wrote to stderr, which is what turns the bare exit into an error a caller can read - hostImports is gone from ipc-runtime as well, having no users left Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0136VgtizjT6krfyRB6TeqKG --- .../src/barretenberg/common/wasm_export.hpp | 7 - .../barretenberg/env/hardware_concurrency.hpp | 2 +- .../cpp/src/barretenberg/env/logstr.hpp | 6 +- .../barretenberg/env/throw_or_abort_impl.hpp | 6 +- .../cpp/src/barretenberg/wasi/wasi_stubs.cpp | 260 ------------------ .../cpp/src/barretenberg/wasi/wasm_env.cpp | 45 +++ .../ts/bb.js/src/barretenberg/errors.test.ts | 5 +- barretenberg/ts/bootstrap.sh | 3 +- .../ts/codegen/bb_wasm_host_imports.ts | 19 -- ipc-codegen/src/generate.ts | 47 +--- ipc-codegen/src/typescript_package_codegen.ts | 110 +------- ipc-runtime/ts/src/wasm/backend.ts | 10 +- ipc-runtime/ts/src/wasm/browser/index.ts | 8 +- ipc-runtime/ts/src/wasm/entry.ts | 1 - ipc-runtime/ts/src/wasm/host.ts | 105 ++++--- ipc-runtime/ts/src/wasm/main_worker.ts | 4 - ipc-runtime/ts/src/wasm/node/index.ts | 6 +- ipc-runtime/ts/src/wasm/thread_worker.ts | 13 +- ipc-runtime/ts/src/wasm/wasi_shim.ts | 13 + ipc-runtime/ts/src/wasm_engine.test.ts | 96 +++++++ 20 files changed, 252 insertions(+), 514 deletions(-) delete mode 100644 barretenberg/cpp/src/barretenberg/wasi/wasi_stubs.cpp create mode 100644 barretenberg/cpp/src/barretenberg/wasi/wasm_env.cpp delete mode 100644 barretenberg/ts/codegen/bb_wasm_host_imports.ts 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/bb.js/src/barretenberg/errors.test.ts b/barretenberg/ts/bb.js/src/barretenberg/errors.test.ts index db7e5a52f97b..5ce81b2c8245 100644 --- a/barretenberg/ts/bb.js/src/barretenberg/errors.test.ts +++ b/barretenberg/ts/bb.js/src/barretenberg/errors.test.ts @@ -51,8 +51,9 @@ describe('command errors', () => { }); // bb's wasm build compiles with BB_NO_EXCEPTIONS, so the dispatcher's catch is compiled away and - // throw_or_abort reaches the host's throw hook instead. The message survives; the type does not. - // Pinned so the divergence cannot change silently — catch Error to handle both. + // 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); diff --git a/barretenberg/ts/bootstrap.sh b/barretenberg/ts/bootstrap.sh index 4a8eae533e35..d83849ffaef8 100755 --- a/barretenberg/ts/bootstrap.sh +++ b/barretenberg/ts/bootstrap.sh @@ -67,8 +67,7 @@ function generate_bb_js_api_package { --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 \ - --package-wasm-host-imports "$ROOT/barretenberg/ts/codegen/bb_wasm_host_imports.ts" + --package-wasm-threads-module barretenberg-threads.wasm } # bb-avm-sim, cdb and bb.js-api are gitignored workspaces declared in package.json, so diff --git a/barretenberg/ts/codegen/bb_wasm_host_imports.ts b/barretenberg/ts/codegen/bb_wasm_host_imports.ts deleted file mode 100644 index 1e4483caffe6..000000000000 --- a/barretenberg/ts/codegen/bb_wasm_host_imports.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { HostImportsFactory } from "@aztec-foundation/ipc-runtime/wasm"; - -/** - * bb's wasm platform layer imports three functions of its own beyond WASI: a logger, an abort hook - * and the thread count (barretenberg/cpp/src/barretenberg/env, WASM_IMPORT). Copied into the - * generated bb.js-api package as src/wasm_host_imports.ts. - */ -export const hostImports: HostImportsFactory = (ctx) => ({ - env: { - logstr: (ptr: number) => { - const mib = (ctx.memory().buffer.byteLength / (1024 * 1024)).toFixed(2); - ctx.logger(`${ctx.readCString(ptr)} (mem: ${mib}MiB)`); - }, - throw_or_abort_impl: (ptr: number) => { - throw new Error(ctx.readCString(ptr)); - }, - env_hardware_concurrency: () => ctx.threads, - }, -}); diff --git a/ipc-codegen/src/generate.ts b/ipc-codegen/src/generate.ts index b78f131dc06b..805540934966 100644 --- a/ipc-codegen/src/generate.ts +++ b/ipc-codegen/src/generate.ts @@ -23,6 +23,7 @@ import { mkdirSync, cpSync, rmSync, + rmdirSync, } from "fs"; import { execSync } from "child_process"; import { basename, dirname, join, relative, resolve } from "path"; @@ -70,7 +71,6 @@ interface Args { packageIpcPathArgs: string; packageWasmModule: string; packageWasmThreadsModule: string; - packageWasmHostImports: string; ipcRuntimeDependency: string; cppNamespace: string; cppWireNamespace: string; @@ -110,9 +110,6 @@ Optional: WebAssembly.compileStreaming and cached by the browser --package-wasm-threads-module wasm transport: the threads module, shipped in wasm/ - --package-wasm-host-imports - wasm transport: TS module copied to src/wasm_host_imports.ts - supplying the module's imports beyond WASI (default: none) --ipc-runtime-dependency package.json dependency spec for @aztec-foundation/ipc-runtime --prefix Type prefix (auto-detected when >= 2 commands share one) @@ -148,7 +145,6 @@ function parseArgs(argv: string[]): Args { packageIpcPathArgs: "--socket,{path}", packageWasmModule: "", packageWasmThreadsModule: "", - packageWasmHostImports: "", ipcRuntimeDependency: "@aztec-foundation/ipc-runtime", cppNamespace: "", cppWireNamespace: "wire", @@ -212,9 +208,6 @@ function parseArgs(argv: string[]): Args { case "--package-wasm-threads-module": args.packageWasmThreadsModule = takeValue(); break; - case "--package-wasm-host-imports": - args.packageWasmHostImports = takeValue(); - break; case "--ipc-runtime-dependency": args.ipcRuntimeDependency = takeValue(); break; @@ -372,10 +365,24 @@ function pruneStale(root: string) { // 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"); } @@ -557,30 +564,6 @@ function generate(args: Args) { if (wasm) { writePackage("src/browser.ts", packageGen.generateBrowserIndex()); writePackage("src/wasm.ts", packageGen.generateWasm()); - writePackage( - "src/wasm/thread.worker.ts", - packageGen.generateThreadWorker(), - ); - writePackage( - "src/wasm/node/main.worker.ts", - packageGen.generateMainWorker(), - ); - writePackage( - "src/wasm/browser/main.worker.ts", - packageGen.generateBrowserMainWorker(), - ); - // The host imports have to be a module inside the package rather than something the - // caller passes to a constructor: they are closures, and the same closures are needed - // in every realm that instantiates the module — the calling thread, the main worker - // and each wasi thread worker. Functions cannot be cloned across a worker boundary, so - // a worker can only get them by importing them, from a path a bundler can resolve at - // build time. - writePackage( - "src/wasm_host_imports.ts", - args.packageWasmHostImports - ? readFileSync(resolve(args.packageWasmHostImports), "utf-8") - : packageGen.generateDefaultHostImports(), - ); } for (const manifest of packageGen.generateArchPackageManifests()) { writePackage(manifest.path, manifest.content); diff --git a/ipc-codegen/src/typescript_package_codegen.ts b/ipc-codegen/src/typescript_package_codegen.ts index a6655c9574fc..128a0cd93c83 100644 --- a/ipc-codegen/src/typescript_package_codegen.ts +++ b/ipc-codegen/src/typescript_package_codegen.ts @@ -502,31 +502,13 @@ ${ type IpcClientSync,${process ? "\n SpawnedProcessBackend," : ""} pickServiceBackend, } from '@aztec-foundation/ipc-runtime'; -${wasm ? "import { type WasmFfiBackend, platform } from '@aztec-foundation/ipc-runtime/wasm/node';\n" : ""}import { AsyncApi } from './generated/async.js'; +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, createWasmBackendSync, createWasmBackendWith } from './wasm.js';\n` : ""} +${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)}${ - wasm - ? ` -/** - * The ${binaryName} wasm module in-process (node): the main instance in a worker thread by default, - * wasi threads on further workers. - */ -export function createWasmBackend(options: ${prefix}WasmOptions = {}): Promise { - return createWasmBackendWith( - { - createMainWorker: () => platform.createWorker(new URL('./wasm/node/main.worker.js', import.meta.url)), - createThreadWorker: () => platform.createWorker(new URL('./wasm/thread.worker.js', import.meta.url)), - }, - options, - ); -} -` - : "" - } +${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"}. @@ -582,10 +564,9 @@ ${this.serviceClasses({ process, wasm })}`; const { prefix, packageName } = this.opts; return `import type { IpcClientAsync, IpcClientSync } from '@aztec-foundation/ipc-runtime'; -import { type WasmFfiBackend, workerHandle } from '@aztec-foundation/ipc-runtime/wasm/browser'; import { AsyncApi } from './generated/async.js'; import { SyncApi } from './generated/sync.js'; -import { type ${prefix}WasmOptions, createWasmBackendSync, createWasmBackendWith } from './wasm.js'; +import { type ${prefix}WasmOptions, createWasmBackend, createWasmBackendSync } from './wasm.js'; ${this.generatedExports()}export * from './wasm.js'; @@ -609,24 +590,6 @@ export interface ${prefix}CreateSyncOptions { wasm?: Omit<${prefix}WasmOptions, 'threads' | 'worker' | 'logger'>; } -/** - * The ${this.opts.binaryName} wasm module in-process: the main instance in a web worker by default, - * wasi threads on further workers when the page is cross-origin isolated (COOP/COEP). The worker - * scripts are spawned with the literal expression bundlers detect, so they ship as worker chunks - * of the consuming application. - */ -export function createWasmBackend(options: ${prefix}WasmOptions = {}): Promise { - return createWasmBackendWith( - { - createMainWorker: () => - workerHandle(new Worker(new URL('./wasm/browser/main.worker.js', import.meta.url), { type: 'module' })), - createThreadWorker: () => - workerHandle(new Worker(new URL('./wasm/thread.worker.js', import.meta.url), { type: 'module' })), - }, - options, - ); -} - export async function createBackend(options: ${prefix}CreateOptions = {}): Promise { if (typeof options.backend === 'object') { return options.backend; @@ -729,14 +692,12 @@ export class ${svc}Sync extends SyncApi { type WasmFfiBackend, type WasmFfiBackendSync, type WasmModuleSource, - type WorkerHandle, chooseWasmModule, createWasmFfiBackend, createWasmFfiBackendSync, platform, resolveWasmThreads, } from '@aztec-foundation/ipc-runtime/wasm'; -import { hostImports } from './wasm_host_imports.js'; export { sharedMemoryAvailable } from '@aztec-foundation/ipc-runtime/wasm'; @@ -772,12 +733,6 @@ export interface ${prefix}WasmOptions { unref?: boolean; } -/** Worker factories a platform entry binds (see WasmFfiBinding in ipc-runtime for why factories). */ -export interface WasmWorkers { - createMainWorker: () => WorkerHandle; - createThreadWorker: () => WorkerHandle; -} - // 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 = { @@ -795,7 +750,12 @@ export function resolveThreads(threads?: number): number { return resolveWasmThreads(platform, '${packageName}', threads); } -export async function createWasmBackendWith(workers: WasmWorkers, options: ${prefix}WasmOptions = {}): Promise { +/** + * 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), @@ -804,10 +764,7 @@ export async function createWasmBackendWith(workers: WasmWorkers, options: ${pre env: options.env, logger: options.logger, worker: options.worker, - hostImports, ${ffiExports} - createMainWorker: workers.createMainWorker, - createThreadWorker: workers.createThreadWorker, }); if (options.unref) { backend.unref(); @@ -827,7 +784,6 @@ export async function createWasmBackendSync(options: ${prefix}WasmOptions = {}): memory: options.memory, env: options.env, logger: options.logger, - hostImports, ${ffiExports} }); if (options.unref) { @@ -838,52 +794,6 @@ ${ffiExports} `; } - /** wasi-threads worker: one module instance per thread. Platform-neutral (it spawns nothing). */ - generateThreadWorker(): string { - return `import { runThreadWorker, workerSide } from '@aztec-foundation/ipc-runtime/wasm'; -import { hostImports } from '../wasm_host_imports.js'; - -runThreadWorker(workerSide(), { hostImports }); -`; - } - - /** Main-instance worker for node. */ - generateMainWorker(): string { - return `import { platform, runMainWorker, workerSide } from '@aztec-foundation/ipc-runtime/wasm/node'; -import { hostImports } from '../../wasm_host_imports.js'; - -runMainWorker(workerSide(), platform, { - hostImports, - createThreadWorker: () => platform.createWorker(new URL('../thread.worker.js', import.meta.url)), -}); -`; - } - - /** Main-instance worker for browsers: spawns thread workers with the expression bundlers detect. */ - generateBrowserMainWorker(): string { - return `import { platform, runMainWorker, workerHandle, workerSide } from '@aztec-foundation/ipc-runtime/wasm/browser'; -import { hostImports } from '../../wasm_host_imports.js'; - -runMainWorker(workerSide(), platform, { - hostImports, - createThreadWorker: () => - workerHandle(new Worker(new URL('../thread.worker.js', import.meta.url), { type: 'module' })), -}); -`; - } - - /** Placeholder for a module that needs nothing beyond WASI (--package-wasm-host-imports replaces it). */ - generateDefaultHostImports(): string { - return `import type { HostImportsFactory } from '@aztec-foundation/ipc-runtime/wasm'; - -/** - * Imports the module needs beyond WASI and wasi-threads. The FFI contract needs none; a module - * whose platform layer imports its own hooks ships them via --package-wasm-host-imports. - */ -export const hostImports: HostImportsFactory | undefined = undefined; -`; - } - /** * Where this package's binary lives. Resolving it and spawning it are ipc-runtime's job; this * is only the description, which is all that differs from one generated package to the next. diff --git a/ipc-runtime/ts/src/wasm/backend.ts b/ipc-runtime/ts/src/wasm/backend.ts index f6f5b0a4eb08..9c7031cf6c66 100644 --- a/ipc-runtime/ts/src/wasm/backend.ts +++ b/ipc-runtime/ts/src/wasm/backend.ts @@ -1,5 +1,5 @@ import type { IpcClientAsync, IpcClientSync } from "../types.js"; -import { type HostImportsFactory, WasmInstanceHost } from "./host.js"; +import { WasmInstanceHost } from "./host.js"; import { type WasmModuleSource, compileWasmModule } from "./module_source.js"; import type { WasmPlatform, WorkerHandle } from "./platform.js"; @@ -14,8 +14,6 @@ export interface WasmFfiOptions { /** WASI environ for the module. `HARDWARE_CONCURRENCY` and `RAYON_NUM_THREADS` default to `threads`. */ env?: Record; logger?: (msg: string) => void; - /** Module-specific imports beyond WASI/wasi-threads (see `HostImportsFactory`). */ - hostImports?: HostImportsFactory; /** FFI entry export; default: the module's one `_ipc_ffi_entry` export. */ entry?: string; } @@ -41,7 +39,7 @@ export interface WasmFfiBackendOptions extends WasmFfiOptions { * the main instance runs on the calling thread and every call blocks it until it returns. */ worker?: boolean; - /** Override the worker factories (packages whose modules need `hostImports` ship their own). */ + /** Override the worker factories the platform entry binds. */ createThreadWorker?: () => WorkerHandle; createMainWorker?: () => WorkerHandle; } @@ -216,7 +214,6 @@ export class WasmFfiEngine { memory, env, logger, - hostImports: opts.hostImports, entry: opts.entry, threads, spawnThread: (startArg) => threadWorkers?.spawn(startArg) ?? -1, @@ -335,8 +332,7 @@ export class WasmFfiBackend implements IpcClientAsync { ); } // Compile here so the (cached, streaming) compilation happens once and the compiled Module is - // shared with the worker; `hostImports` are functions and cannot cross the boundary, so a - // module needing them ships a worker entry that supplies them (see `runMainWorker`). + // 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); diff --git a/ipc-runtime/ts/src/wasm/browser/index.ts b/ipc-runtime/ts/src/wasm/browser/index.ts index 959a68e186c8..d05518d22e1f 100644 --- a/ipc-runtime/ts/src/wasm/browser/index.ts +++ b/ipc-runtime/ts/src/wasm/browser/index.ts @@ -14,20 +14,20 @@ export { }; /** - * The runtime's own worker scripts, spawned with the literal expression bundlers detect so they - * are emitted as worker chunks. A package whose module needs `hostImports` points at its own. + * 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.browser.js", import.meta.url), { + new Worker(new URL("./thread.worker.js", import.meta.url), { type: "module", }), ), createMainWorker: () => browserWorkerHandle( - new Worker(new URL("./main.worker.browser.js", import.meta.url), { + new Worker(new URL("./main.worker.js", import.meta.url), { type: "module", }), ), diff --git a/ipc-runtime/ts/src/wasm/entry.ts b/ipc-runtime/ts/src/wasm/entry.ts index b18b21464e8a..53758ab4e72b 100644 --- a/ipc-runtime/ts/src/wasm/entry.ts +++ b/ipc-runtime/ts/src/wasm/entry.ts @@ -8,7 +8,6 @@ import { WasmFfiBackendSync, } from "./backend.js"; -export type { HostImportsContext, HostImportsFactory } from "./host.js"; export type { WasmModuleSource } from "./module_source.js"; export type { WasmPlatform, WorkerHandle, WorkerSide } from "./platform.js"; export type { WasmFfiBackendOptions, WasmFfiBinding, WasmFfiOptions }; diff --git a/ipc-runtime/ts/src/wasm/host.ts b/ipc-runtime/ts/src/wasm/host.ts index b3125500cb0b..fe0b4a5e79e2 100644 --- a/ipc-runtime/ts/src/wasm/host.ts +++ b/ipc-runtime/ts/src/wasm/host.ts @@ -1,31 +1,11 @@ import { WASI_NAMESPACE, createWasiImports } from "./wasi_shim.js"; -/** What a module-specific host import gets to work with. */ -export interface HostImportsContext { - memory(): WebAssembly.Memory; - readCString(ptr: number): string; - readBytes(ptr: number, len: number): Uint8Array; - logger(msg: string): void; - /** Threads this instance may use: the engine's count on the main instance, 1 on a thread. */ - threads: number; -} - -/** - * Extra imports a particular module needs beyond WASI and wasi-threads, keyed by import module - * then name (e.g. `{ env: { logstr: ptr => ... } }`). The FFI contract itself needs none; this - * is the escape hatch for modules whose platform layer imports its own logging or abort hooks. - */ -export type HostImportsFactory = ( - ctx: HostImportsContext, -) => Record>; - export interface InstanceOptions { module: WebAssembly.Module; memory: WebAssembly.Memory; /** WASI environ for the module (e.g. `HARDWARE_CONCURRENCY`). */ env?: Record; logger?: (msg: string) => void; - hostImports?: HostImportsFactory; /** * 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. @@ -38,7 +18,7 @@ export interface InstanceOptions { entry?: string; /** Call the reactor's `_initialize` after instantiation (main instances only). Default true. */ runInitialize?: boolean; - /** Threads this instance may use (reported to `hostImports`). Default 1. */ + /** Threads this instance may use, for the WASI environ the module reads. Default 1. */ threads?: number; } @@ -92,6 +72,32 @@ function findEntry( * 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, @@ -100,6 +106,7 @@ export class WasmInstanceHost { private readonly alloc: WasmFn, private readonly free: WasmFn, readonly logger: (msg: string) => void, + private readonly stderr: StderrTail, ) {} static async instantiate(opts: InstanceOptions): Promise { @@ -108,39 +115,21 @@ export class WasmInstanceHost { // instantiation, so everything reading memory goes through this. let memory = opts.memory; const logger = opts.logger ?? (() => {}); - const ctx: HostImportsContext = { - memory: () => memory, - readBytes: (ptr, len) => - new Uint8Array(memory.buffer).slice(ptr >>> 0, (ptr >>> 0) + len), - readCString: (ptr) => { - const m = new Uint8Array(memory.buffer); - let end = ptr >>> 0; - while (m[end] !== 0) { - end++; - } - return new TextDecoder().decode(m.slice(ptr >>> 0, end)); - }, - logger, - threads: opts.threads ?? 1, - }; - - const imports: Record> = {}; - for (const [ns, values] of Object.entries(opts.hostImports?.(ctx) ?? {})) { - imports[ns] = { ...values }; - } - imports.env = { ...(imports.env ?? {}), memory }; - imports[WASI_NAMESPACE] = { - ...createWasiImports(opts.module, () => memory, { + const stderr = new StderrTail(); + const imports: Record> = { + env: { memory }, + [WASI_NAMESPACE]: createWasiImports(opts.module, () => memory, { env: opts.env, - onStderr: logger, + onStderr: (line) => { + stderr.record(line); + logger(line); + }, onStdout: logger, }), - ...(imports[WASI_NAMESPACE] ?? {}), - }; - imports.wasi = { - ...(imports.wasi ?? {}), - "thread-spawn": (startArg: number) => - opts.spawnThread ? opts.spawnThread(startArg >>> 0) : -1, + wasi: { + "thread-spawn": (startArg: number) => + opts.spawnThread ? opts.spawnThread(startArg >>> 0) : -1, + }, }; const missing: string[] = []; @@ -152,7 +141,7 @@ export class WasmInstanceHost { } if (missing.length > 0) { throw new Error( - `wasm module imports not provided by the host (supply them through hostImports): ${missing.join(", ")}`, + `wasm module imports what this host does not provide; it must be a WASI reactor: ${missing.join(", ")}`, ); } @@ -191,6 +180,7 @@ export class WasmInstanceHost { exports[pair[0]] as WasmFn, exports[pair[1]] as WasmFn, logger, + stderr, ); } @@ -258,12 +248,21 @@ export class WasmInstanceHost { 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); - this.entry(inPtr, input.length, slots, slots + 4); + 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); diff --git a/ipc-runtime/ts/src/wasm/main_worker.ts b/ipc-runtime/ts/src/wasm/main_worker.ts index c9277e107407..470a4dcefa01 100644 --- a/ipc-runtime/ts/src/wasm/main_worker.ts +++ b/ipc-runtime/ts/src/wasm/main_worker.ts @@ -1,10 +1,7 @@ import { type WasmFfiBinding, WasmFfiEngine } from "./backend.js"; -import type { HostImportsFactory } from "./host.js"; import type { WasmPlatform, WorkerHandle, WorkerSide } from "./platform.js"; export interface MainWorkerOptions { - /** Module-specific imports for the main instance (thread workers get theirs from their own entry). */ - hostImports?: HostImportsFactory; /** Spawns the thread pool's workers (see `WasmFfiBinding` for why this is a factory). */ createThreadWorker: () => WorkerHandle; } @@ -39,7 +36,6 @@ export function runMainWorker( memory: o.memory, env: o.env, entry: o.entry, - hostImports: opts.hostImports, logger: log, }, binding, diff --git a/ipc-runtime/ts/src/wasm/node/index.ts b/ipc-runtime/ts/src/wasm/node/index.ts index 9438f747ae93..04d1a09273e4 100644 --- a/ipc-runtime/ts/src/wasm/node/index.ts +++ b/ipc-runtime/ts/src/wasm/node/index.ts @@ -13,16 +13,16 @@ export { nodeWorkerSide as workerSide, }; -/** The runtime's own worker scripts; a package whose module needs `hostImports` points at its own. */ +/** 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.node.js", import.meta.url), + new URL("./thread.worker.js", import.meta.url), ), createMainWorker: () => nodePlatform.createWorker( - new URL("./main.worker.node.js", import.meta.url), + new URL("./main.worker.js", import.meta.url), ), }; diff --git a/ipc-runtime/ts/src/wasm/thread_worker.ts b/ipc-runtime/ts/src/wasm/thread_worker.ts index 75009172f8ad..2a5e6de75957 100644 --- a/ipc-runtime/ts/src/wasm/thread_worker.ts +++ b/ipc-runtime/ts/src/wasm/thread_worker.ts @@ -1,21 +1,13 @@ -import { type HostImportsFactory, WasmInstanceHost } from "./host.js"; +import { WasmInstanceHost } from "./host.js"; import type { WorkerSide } from "./platform.js"; -export interface ThreadWorkerOptions { - /** The same module-specific imports the main instance was given, if any. */ - hostImports?: HostImportsFactory; -} - /** * 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, - opts: ThreadWorkerOptions = {}, -): void { +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; @@ -30,7 +22,6 @@ export function runThreadWorker( memory: msg.memory, env: msg.env, entry: msg.entry, - hostImports: opts.hostImports, threads: 1, runInitialize: false, // Only the main instance spawns threads; a request from a thread is refused. diff --git a/ipc-runtime/ts/src/wasm/wasi_shim.ts b/ipc-runtime/ts/src/wasm/wasi_shim.ts index d608b422d64a..fe666b38d2c3 100644 --- a/ipc-runtime/ts/src/wasm/wasi_shim.ts +++ b/ipc-runtime/ts/src/wasm/wasi_shim.ts @@ -137,8 +137,21 @@ export function createWasiImports( 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); }, }; diff --git a/ipc-runtime/ts/src/wasm_engine.test.ts b/ipc-runtime/ts/src/wasm_engine.test.ts index b84304af98ca..fc9c39c9a043 100644 --- a/ipc-runtime/ts/src/wasm_engine.test.ts +++ b/ipc-runtime/ts/src/wasm_engine.test.ts @@ -279,3 +279,99 @@ test("uses the module's own memory when it exports one instead of importing", as 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(); +});