diff --git a/.github/actions/setup-emsdk/action.yml b/.github/actions/setup-emsdk/action.yml new file mode 100644 index 0000000..913d4e6 --- /dev/null +++ b/.github/actions/setup-emsdk/action.yml @@ -0,0 +1,21 @@ +name: Setup Emscripten +description: Install Emscripten SDK +inputs: + version: + description: Emscripten Version + required: false + default: 'latest' +runs: + using: composite + steps: + - name: Run setup script + shell: bash + run: bash ci/setup/install_emsdk.sh ${{ inputs.version }} + + - name: Inject Global Environment + shell: bash + run: | + EMSDK_DIR="${GITHUB_WORKSPACE}/.emsdk" + source "${EMSDK_DIR}/emsdk_env.sh" > /dev/null 2>&1 + env | grep '^EMSDK' >> $GITHUB_ENV + echo "$PATH" | tr ':' '\n' | grep "${EMSDK_DIR}" >> $GITHUB_PATH diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cffc2da..7f235b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: - name: Install Toolchain (Clang) if: matrix.compiler == 'clang' - run: bash ci/install_llvm.sh 21 + run: bash ci/setup/install_llvm.sh 21 - name: Configure (${{ matrix.compiler }}-${{ matrix.build_type}}) run: | @@ -80,7 +80,7 @@ jobs: sudo apt-get install -y --no-install-recommends gcc-aarch64-linux-gnu libstdc++-14-dev-arm64-cross - name: Install LLVM 21 - run: bash ci/install_llvm.sh 21 + run: bash ci/setup/install_llvm.sh 21 - name: Configure run: cmake --preset clang-aarch64-release -DTACHYON_ENABLE_BENCH=OFF @@ -111,7 +111,7 @@ jobs: uses: ./.github/actions/kitware-apt - name: Install LLVM 21 - run: bash ci/install_llvm.sh 21 + run: bash ci/setup/install_llvm.sh 21 - name: Configure run: cmake --preset asan -DTACHYON_ENABLE_BENCH=OFF @@ -143,10 +143,10 @@ jobs: uses: ./.github/actions/kitware-apt - name: Install LLVM 21 - run: bash ci/install_llvm.sh 21 + run: bash ci/setup/install_llvm.sh 21 - name: Build Instrumented libc++ - run: bash ci/build_msan_libcxx.sh 21 + run: bash ci/setup/build_msan_libcxx.sh 21 - name: Configure run: cmake --preset msan -DTACHYON_ENABLE_BENCH=OFF @@ -177,7 +177,7 @@ jobs: uses: ./.github/actions/kitware-apt - name: Install LLVM 21 - run: bash ci/install_llvm.sh 21 + run: bash ci/setup/install_llvm.sh 21 - name: Configure run: cmake --preset tsan -DTACHYON_ENABLE_BENCH=OFF @@ -212,6 +212,44 @@ jobs: - name: Test run: ctest --test-dir build/macos-${{ matrix.build_type }}/test --output-on-failure --parallel 4 + cpp_wasm: + name: C++ (Emscripten, ${{ matrix.build_type }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + build_type: [ debug, release ] + env: + CMAKE_C_COMPILER_LAUNCHER: ccache + CMAKE_CXX_COMPILER_LAUNCHER: ccache + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup ccache + uses: ./.github/actions/setup-ccache + + - name: Setup Kitware + uses: ./.github/actions/kitware-apt + + - name: Install LLVM 21 + run: bash ci/setup/install_llvm.sh 21 + + - name: Setup Emscripten + uses: ./.github/actions/setup-emsdk + + - name: Configure + run: cmake --preset emscripten-${{ matrix.build_type }} + + - name: Build + run: cmake --build --preset emscripten-${{ matrix.build_type }} + + - name: Check WASM artefacts + run: | + ls -lh build/emscripten-${{ matrix.build_type }}/core/libtachyon.a || exit 1 + ls -lh build/emscripten-${{ matrix.build_type }}/core/tachyon.js || exit 1 + ls -lh build/emscripten-${{ matrix.build_type }}/core/tachyon.wasm || exit 1 + cpp_format_check: name: C++ Format Check runs-on: ubuntu-24.04 @@ -223,7 +261,7 @@ jobs: uses: ./.github/actions/kitware-apt - name: Install LLVM 21 - run: bash ci/install_llvm.sh 21 + run: bash ci/setup/install_llvm.sh 21 - name: Install Clang Format run: | @@ -251,7 +289,7 @@ jobs: uses: ./.github/actions/kitware-apt - name: Install LLVM 21 - run: bash ci/install_llvm.sh 21 + run: bash ci/setup/install_llvm.sh 21 - name: Configure run: cmake --preset tachyon-top @@ -343,6 +381,12 @@ jobs: run: cargo test working-directory: bindings/rust + - name: Check WASM target + run: | + rustup target add wasm32-unknown-unknown + cargo check -p tachyon-ipc --target wasm32-unknown-unknown + working-directory: bindings/rust + rust_macos: name: Rust bindings (${{ matrix.runner }}) runs-on: ${{ matrix.runner }} @@ -382,7 +426,7 @@ jobs: cache-dependency-path: 'bindings/go' - name: Vendor C++ core - run: bash ci/vendor.sh go + run: bash ci/setup/vendor.sh go - name: Go Test env: @@ -419,7 +463,7 @@ jobs: cache-dependency-path: 'bindings/go' - name: Vendor C++ core - run: bash ci/vendor.sh go + run: bash ci/setup/vendor.sh go - name: Go Test env: @@ -445,7 +489,7 @@ jobs: uses: ./.github/actions/kitware-apt - name: Install LLVM 21 - run: bash ci/install_llvm.sh 21 + run: bash ci/setup/install_llvm.sh 21 - name: Setup Java uses: actions/setup-java@v5 @@ -458,7 +502,7 @@ jobs: bindings/java/**/gradle-wrapper.properties - name: Vendor C++ core - run: bash ci/vendor.sh java + run: bash ci/setup/vendor.sh java - name: Build Native library env: @@ -495,7 +539,7 @@ jobs: bindings/java/**/gradle-wrapper.properties - name: Vendor C++ core - run: bash ci/vendor.sh java + run: bash ci/setup/vendor.sh java - name: Build Native library run: | @@ -519,7 +563,7 @@ jobs: uses: ./.github/actions/kitware-apt - name: Install LLVM 21 - run: bash ci/install_llvm.sh 21 + run: bash ci/setup/install_llvm.sh 21 - name: Setup Java uses: actions/setup-java@v5 @@ -532,7 +576,7 @@ jobs: bindings/kotlin/**/gradle-wrapper.properties - name: Vendor C++ core - run: bash ci/vendor.sh java + run: bash ci/setup/vendor.sh java - name: Build Native library env: @@ -569,7 +613,7 @@ jobs: bindings/kotlin/**/gradle-wrapper.properties - name: Vendor C++ core - run: bash ci/vendor.sh java + run: bash ci/setup/vendor.sh java - name: Build Native library run: | @@ -596,45 +640,75 @@ jobs: uses: ./.github/actions/kitware-apt - name: Install LLVM 21 - run: bash ci/install_llvm.sh 21 + run: bash ci/setup/install_llvm.sh 21 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} cache: 'npm' - cache-dependency-path: bindings/node/package-lock.json + cache-dependency-path: bindings/js/package-lock.json - name: Vendor C++ core - run: bash ci/vendor.sh node + run: bash ci/setup/vendor.sh node - name: Install dependencies & build native library - working-directory: bindings/node + working-directory: bindings/js env: CC: clang-21 CXX: clang++-21 run: npm install && npm run build:native - name: Type check - working-directory: bindings/node + working-directory: bindings/js run: npx tsc --noEmit - name: Format check - working-directory: bindings/node + working-directory: bindings/js run: npm run format:check - name: Lint check - working-directory: bindings/node + working-directory: bindings/js run: npm run lint - name: Build TypeScript - working-directory: bindings/node + working-directory: bindings/js run: npm run build:ts - name: Tests - working-directory: bindings/node + working-directory: bindings/js run: npm test + nodejs_browser_wasm: + name: Browser WASM tests + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'npm' + cache-dependency-path: bindings/js/package-lock.json + + - name: Install dependencies + working-directory: bindings/js + run: npm install --ignore-scripts + + - name: Check Chromium + run: /usr/bin/chromium --version + + # - name: Browser WASM tests + # working-directory: bindings/js + # env: + # CHROMIUM_BIN: /usr/bin/chromium + # Do not run build:wasm in this test job. The generated WASM bindings are + # committed, and CI should validate the committed artifacts instead of + # regenerating them and potentially masking a stale generated-code diff. + # run: npm run test:browser + nodejs_macos: name: Node.js bindings ${{ matrix.runner }} (Node ${{ matrix.node-version }}) runs-on: ${{ matrix.runner }} @@ -652,33 +726,33 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: 'npm' - cache-dependency-path: bindings/node/package-lock.json + cache-dependency-path: bindings/js/package-lock.json - name: Vendor C++ core - run: bash ci/vendor.sh node + run: bash ci/setup/vendor.sh node - name: Install dependencies & build native library - working-directory: bindings/node + working-directory: bindings/js run: npm install && npm run build:native - name: Type check - working-directory: bindings/node + working-directory: bindings/js run: npx tsc --noEmit - name: Format check - working-directory: bindings/node + working-directory: bindings/js run: npm run format:check - name: Lint check - working-directory: bindings/node + working-directory: bindings/js run: npm run lint - name: Build TypeScript - working-directory: bindings/node + working-directory: bindings/js run: npm run build:ts - name: Tests - working-directory: bindings/node + working-directory: bindings/js run: npm test csharp_linux: @@ -692,7 +766,7 @@ jobs: uses: ./.github/actions/kitware-apt - name: Install LLVM 21 - run: bash ci/install_llvm.sh 21 + run: bash ci/setup/install_llvm.sh 21 - name: Setup .NET uses: actions/setup-dotnet@v5 @@ -702,7 +776,7 @@ jobs: 10.x - name: Vendor C++ core - run: bash ci/vendor.sh c# + run: bash ci/setup/vendor.sh c# - name: Build native library env: @@ -740,7 +814,7 @@ jobs: 10.x - name: Vendor C++ core - run: bash ci/vendor.sh c# + run: bash ci/setup/vendor.sh c# - name: Build Native library run: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b0bc841..ab61d80 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -48,7 +48,7 @@ jobs: - name: Install LLVM 21 if: matrix.language == 'c-cpp' || matrix.language == 'csharp' || matrix.language == 'java-kotlin' - run: bash ci/install_llvm.sh 21 + run: bash ci/setup/install_llvm.sh 21 - name: Setup Java if: matrix.language == 'java-kotlin' @@ -92,7 +92,7 @@ jobs: CC: clang-21 CXX: clang++-21 run: | - bash ci/vendor.sh c# + bash ci/setup/vendor.sh c# cmake --preset clang-release -DTACHYON_ENABLE_BENCH=OFF cmake --build --preset clang-release --parallel mkdir -p bindings/csharp/src/TachyonIpc/runtimes/linux-x64/native @@ -105,7 +105,7 @@ jobs: CC: clang-21 CXX: clang++-21 run: | - bash ci/vendor.sh java + bash ci/setup/vendor.sh java cmake --preset clang-release -DTACHYON_ENABLE_BENCH=OFF cmake --build --preset clang-release --parallel cmake --build --preset clang-release --target tachyon_java_resources diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 78e382a..d4af83d 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -24,7 +24,7 @@ jobs: uses: ./.github/actions/kitware-apt - name: Install LLVM 21 - run: bash ci/install_llvm.sh 21 + run: bash ci/setup/install_llvm.sh 21 - name: Build fuzz targets run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bdba95..03ab2b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -119,7 +119,7 @@ jobs: version: '16' - name: Vendor C++ core - run: bash ci/vendor.sh rust + run: bash ci/setup/vendor.sh rust - name: Publish tachyon-sys env: @@ -225,10 +225,10 @@ jobs: run: sudo apt-get install -y --no-install-recommends gcc-aarch64-linux-gnu libstdc++-14-dev-arm64-cross - name: Install LLVM 21 - run: bash ci/install_llvm.sh 21 + run: bash ci/setup/install_llvm.sh 21 - name: Vendor C++ core - run: bash ci/vendor.sh java + run: bash ci/setup/vendor.sh java - name: Build Linux FFM (${{ matrix.arch }}) env: @@ -269,7 +269,7 @@ jobs: cache: 'gradle' - name: Vendor C++ core - run: bash ci/vendor.sh java + run: bash ci/setup/vendor.sh java - name: Build Native library run: | @@ -400,7 +400,7 @@ jobs: - name: Install LLVM 21 (Linux) if: matrix.platform == 'linux' - run: bash ci/install_llvm.sh 21 + run: bash ci/setup/install_llvm.sh 21 - name: Setup Node.js uses: actions/setup-node@v6 @@ -408,17 +408,17 @@ jobs: node-version: 20 - name: Vendor C++ core - run: bash ci/vendor.sh node + run: bash ci/setup/vendor.sh node - name: Install dependencies - working-directory: bindings/node + working-directory: bindings/js env: CC: ${{ matrix.platform == 'linux' && 'clang-21' || 'clang' }} CXX: ${{ matrix.platform == 'linux' && 'clang++-21' || 'clang++' }} run: npm install - name: Build Prebuilt - working-directory: bindings/node + working-directory: bindings/js env: CC: ${{ matrix.platform == 'linux' && 'clang-21' || 'clang' }} CXX: ${{ matrix.platform == 'linux' && 'clang++-21' || 'clang++' }} @@ -436,13 +436,13 @@ jobs: - name: Create Tarball Asset run: | mkdir -p dist - tar -czf "dist/tachyon-node-${{ matrix.platform }}-${{ matrix.arch }}.tar.gz" -C bindings/node/prebuilds . + tar -czf "dist/tachyon-node-${{ matrix.platform }}-${{ matrix.arch }}.tar.gz" -C bindings/js/prebuilds . - name: Upload Prebuilt Artifact (NPM publish) uses: actions/upload-artifact@v7 with: name: node-prebuild-${{ matrix.platform }}-${{ matrix.arch }} - path: bindings/node/prebuilds/ + path: bindings/js/prebuilds/ if-no-files-found: 'error' - name: Upload Tarball Asset (GH Release) @@ -471,24 +471,24 @@ jobs: registry-url: 'https://registry.npmjs.org' - name: Vendor C++ core - run: bash ci/vendor.sh node + run: bash ci/setup/vendor.sh node - name: Download all prebuilts uses: actions/download-artifact@v8 with: - path: bindings/node/prebuilds + path: bindings/js/prebuilds pattern: node-prebuild-* merge-multiple: 'true' - name: Build TypeScript - working-directory: bindings/node + working-directory: bindings/js env: CC: 'clang' CXX: 'clang++' run: npm install --ignore-scripts && npm run build:ts - name: Publish to NPM - working-directory: bindings/node + working-directory: bindings/js env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: npm publish --access public @@ -517,10 +517,10 @@ jobs: run: sudo apt-get install -y --no-install-recommends gcc-aarch64-linux-gnu libstdc++-14-dev-arm64-cross - name: Install LLVM 21 - run: bash ci/install_llvm.sh 21 + run: bash ci/setup/install_llvm.sh 21 - name: Vendor C++ core - run: bash ci/vendor.sh c# + run: bash ci/setup/vendor.sh c# - name: Build libtachyon (x86-64) if: matrix.arch == 'amd64' @@ -570,7 +570,7 @@ jobs: uses: actions/checkout@v6 - name: Vendor C++ core - run: bash ci/vendor.sh c# + run: bash ci/setup/vendor.sh c# - name: Build libtachyon.dylib run: | @@ -670,7 +670,7 @@ jobs: uses: ./.github/actions/kitware-apt - name: Install LLVM - run: bash ci/install_llvm.sh 21 + run: bash ci/setup/install_llvm.sh 21 - name: Configure and Build run: | diff --git a/.gitignore b/.gitignore index 6aef3f0..25dbed0 100644 --- a/.gitignore +++ b/.gitignore @@ -150,7 +150,7 @@ bindings/go/tachyon/_core_local/ bindings/java/src/native/_core_local/ bindings/java/build/ .gradle/ -bindings/node/src/native/_core_local/ +bindings/js/src/native/_core_local/ bindings/python/_core_local/ bindings/python/_dlpack_local/ bindings/rust/tachyon-sys/vendor/ @@ -161,4 +161,4 @@ Testing/ crash- .private/ oss-fuzz/ - +.emsdk/ diff --git a/CMakeLists.txt b/CMakeLists.txt index eecbcfa..9667857 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -68,9 +68,12 @@ elseif (NOT TACHYON_PGO_PHASE STREQUAL "") endif () add_subdirectory(core) -add_subdirectory(test) add_subdirectory(bindings) +if (TACHYON_ENABLE_TESTS) + add_subdirectory(test) +endif () + if (TACHYON_ENABLE_BENCH) add_subdirectory(benchmark) endif () diff --git a/README.md b/README.md index fa7acb7..2e66e4d 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,10 @@ pip install tachyon-ipc npm install @tachyon-ipc/core ``` +The same npm package also includes a browser WASM build for bundlers. Browser code keeps the same +`import { Bus } from '@tachyon-ipc/core'` shape; bundlers that honor the package `browser` field resolve to the +page-local WASM transport automatically. + **Java (Maven):** ```xml diff --git a/bindings/CMakeLists.txt b/bindings/CMakeLists.txt index 43d1aaa..6279203 100644 --- a/bindings/CMakeLists.txt +++ b/bindings/CMakeLists.txt @@ -18,4 +18,4 @@ else () message(STATUS "Java Development or JNI not found - skipping Java bindings") endif () -add_subdirectory(node) +add_subdirectory(js) diff --git a/bindings/node/.mocharc.json b/bindings/js/.mocharc.json similarity index 100% rename from bindings/node/.mocharc.json rename to bindings/js/.mocharc.json diff --git a/bindings/node/.npmignore b/bindings/js/.npmignore similarity index 100% rename from bindings/node/.npmignore rename to bindings/js/.npmignore diff --git a/bindings/js/.prettierignore b/bindings/js/.prettierignore new file mode 100644 index 0000000..81c70de --- /dev/null +++ b/bindings/js/.prettierignore @@ -0,0 +1 @@ +src/ts/wasm/ diff --git a/bindings/node/.prettierrc b/bindings/js/.prettierrc similarity index 100% rename from bindings/node/.prettierrc rename to bindings/js/.prettierrc diff --git a/bindings/node/CMakeLists.txt b/bindings/js/CMakeLists.txt similarity index 100% rename from bindings/node/CMakeLists.txt rename to bindings/js/CMakeLists.txt diff --git a/bindings/node/README.md b/bindings/js/README.md similarity index 86% rename from bindings/node/README.md rename to bindings/js/README.md index 0fa552f..efdee76 100644 --- a/bindings/node/README.md +++ b/bindings/js/README.md @@ -13,6 +13,7 @@ compiled from source at installation time via `cmake-js`. - [Requirements](#requirements) - [Install](#install) - [Quickstart](#quickstart) +- [Browser WASM](#browser-wasm) - [API](#api) - [Zero-copy pattern](#zero-copy-pattern) - [Batch pattern](#batch-pattern) @@ -48,6 +49,9 @@ Clang 17+ must be available on the build machine. The package ships as **ESM** (`"type": "module"`). CommonJS consumers must use dynamic `import()`. +Browser bundlers that honor the package `browser` field resolve `@tachyon-ipc/core` to the WASM browser build. Node.js +continues to resolve the native N-API entrypoint through the existing `main` and `types` fields. + ## Quickstart The consumer must start first, it owns the UNIX socket and the SHM arena. @@ -72,6 +76,41 @@ bus.send(Buffer.from('hello tachyon'), 1); `Bus` implements `Disposable`. The `using` keyword (TypeScript 5.2+, ES2023 Explicit Resource Management) guarantees `close()` is called on scope exit regardless of exceptions. +## Browser WASM + +The browser build is shipped from the same npm package and keeps the same import and constructor shape: + +```typescript +import {Bus} from '@tachyon-ipc/core'; + +using consumer = Bus.listen('/page/demo', 1 << 20); +using producer = Bus.connect('/page/demo'); + +producer.send(new Uint8Array([1, 2, 3, 4]), 7); +const {data, typeId} = consumer.recv(); +``` + +Browsers do not expose POSIX shared memory or UNIX sockets, so `socketPath` is a page-local endpoint key rather than a +filesystem socket. `listen()` creates the in-page WASM ring and `connect()` attaches to that ring. The message layout +still uses Tachyon's 64-byte header, `type_id`, alignment, and skip-marker rules. + +The browser implementation is intentionally direct-doorbell oriented. After JavaScript commits a message, call the Rust +WASM work function immediately instead of scheduling a browser event or spinning in a poll loop. This avoids event-loop +latency and keeps sub-microsecond round trips possible for in-page JS/Rust communication. + +Browser differences: + +- Repeated browser `connect()` calls return aliases to the same page-local ring; they are not independent subscribers, + and multiple consumers compete for the same ordered SPSC stream. +- `recv()` and `acquireRx()` are non-blocking because the main browser thread cannot park like a native futex wait. +- Browser `drainBatch()` preserves order but copies batch entries before returning, so ring slots are released + immediately; use `acquireRx()` for a direct WASM memory view. +- `setNumaNode()` and `setPollingMode()` are no-ops in browsers. +- `Buffer` is not a browser primitive; returned data is a `Uint8Array`. +- Native cross-process IPC still requires Node.js or another native binding. +- The browser build uses wasm32 for now, so WASM pointers, capacities, and slot sizes are `u32`-bounded; it can move to + wasm64/Memory64 later if a single linear-memory arena above 4 GiB becomes necessary. + ## API ### Lifecycle diff --git a/bindings/node/eslint.config.mjs b/bindings/js/eslint.config.mjs similarity index 95% rename from bindings/node/eslint.config.mjs rename to bindings/js/eslint.config.mjs index 2831b2d..6ec10af 100644 --- a/bindings/node/eslint.config.mjs +++ b/bindings/js/eslint.config.mjs @@ -2,7 +2,7 @@ import tseslint from 'typescript-eslint'; export default tseslint.config( { - ignores: ['dist/**', 'build/**', 'node_modules/**', 'test/**'], + ignores: ['dist/**', 'build/**', 'node_modules/**', 'test/**', 'src/ts/wasm/**'], }, ...tseslint.configs.strictTypeChecked, ...tseslint.configs.stylisticTypeChecked, diff --git a/bindings/node/package-lock.json b/bindings/js/package-lock.json similarity index 99% rename from bindings/node/package-lock.json rename to bindings/js/package-lock.json index be458d5..f2cb864 100644 --- a/bindings/node/package-lock.json +++ b/bindings/js/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tachyon-ipc/core", - "version": "0.3.5", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tachyon-ipc/core", - "version": "0.3.5", + "version": "0.5.1", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/bindings/node/package.json b/bindings/js/package.json similarity index 80% rename from bindings/node/package.json rename to bindings/js/package.json index d5a3f6d..5289648 100644 --- a/bindings/node/package.json +++ b/bindings/js/package.json @@ -17,6 +17,9 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "browser": { + "./dist/index.js": "./dist/browser.js" + }, "engines": { "node": ">=20.0.0" }, @@ -24,9 +27,12 @@ "build": "npm run build:native && npm run build:ts", "build:native": "cmake-js compile", "build:ts": "tsc", + "build:wasm": "npm run cmake:wasm && npm run copy:wasm", "clean": "rm -rf build dist", "format": "prettier --write src/ts test/", "format:check": "prettier --check src/ts test/", + "cmake:wasm": "cd ../.. && cmake --preset emscripten-release && cmake --build --preset emscripten-release", + "copy:wasm": "node scripts/copy-wasm.mjs", "install": "cmake-js compile", "lint": "eslint src/ts", "prebuild": "cmake-js compile --runtime node --runtime-version $(node -v | cut -c2-)", diff --git a/bindings/js/scripts/copy-wasm.mjs b/bindings/js/scripts/copy-wasm.mjs new file mode 100644 index 0000000..f92b9e1 --- /dev/null +++ b/bindings/js/scripts/copy-wasm.mjs @@ -0,0 +1,33 @@ +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const BUILD_DIR = resolve("../../build/emscripten-release/core"); +const TARGET_DIR = resolve("src/ts/wasm"); + +const GENERATED_HEADER = [ + '/* eslint-disable */', + '// Generated by Emscripten. Do not edit by hand.', + '// Regenerate with: npm run build:wasm', + '', +].join('\n'); + +async function main(){ + await mkdir(TARGET_DIR, { recursive: true }); + await copyFile( + resolve(BUILD_DIR, "tachyon.wasm"), + resolve(TARGET_DIR, "tachyon.wasm") + ); + + const jsBody = await readFile(resolve(BUILD_DIR, 'tachyon.js'), 'utf8'); + await writeFile( + resolve(TARGET_DIR, "tachyon.js"), + `${GENERATED_HEADER}\n${jsBody}` + ); + + console.log("WASM artefacts copied"); +} + +main().catch((err) => { + console.error("Failed to process WASM artefacts: ", err); + process.exit(1); +}) \ No newline at end of file diff --git a/bindings/node/src/native/tachyon_node.cpp b/bindings/js/src/native/tachyon_node.cpp similarity index 100% rename from bindings/node/src/native/tachyon_node.cpp rename to bindings/js/src/native/tachyon_node.cpp diff --git a/bindings/node/src/ts/batch.ts b/bindings/js/src/ts/batch.ts similarity index 97% rename from bindings/node/src/ts/batch.ts rename to bindings/js/src/ts/batch.ts index d145a63..b39cf98 100644 --- a/bindings/node/src/ts/batch.ts +++ b/bindings/js/src/ts/batch.ts @@ -22,7 +22,7 @@ export interface RxMessage { * `using` commits automatically. * * All `RxMessage.data` references are invalidated on commit. Any cached reference - * will throw `TypeError` (underlying ArrayBuffers are detached by the C++ side). + * will throw `TypeError` where the platform can detach the underlying ArrayBuffers. * * @example * ```ts diff --git a/bindings/js/src/ts/browser.ts b/bindings/js/src/ts/browser.ts new file mode 100644 index 0000000..5373aff --- /dev/null +++ b/bindings/js/src/ts/browser.ts @@ -0,0 +1,180 @@ +import type { BusHandle, RawBatchMessage, RawRx } from './bus_core.ts'; +import { BusBase } from './bus_core.ts'; +import initWasm, { WasmBus } from './wasm/tachyon_ipc.ts'; + +interface WasmRuntime { + readonly memory: { + readonly buffer: ArrayBuffer; + }; +} + +const wasm = (await initWasm()) as unknown as WasmRuntime; + +interface BrowserEndpoint { + handle: WasmBus; + refs: number; +} + +const endpoints = new Map(); + +function slot(ptr: number, len: number): Uint8Array { + return new Uint8Array(wasm.memory.buffer, ptr, len); +} + +function detachArrayBuffer(buffer: ArrayBuffer): void { + if (buffer.byteLength === 0) return; + structuredClone(buffer, { transfer: [buffer] }); +} + +class BrowserBusHandle implements BusHandle { + #endpoint: BrowserEndpoint; + #path: string; + #batchBuffers: ArrayBuffer[] = []; + + public constructor(path: string, endpoint: BrowserEndpoint) { + this.#path = path; + this.#endpoint = endpoint; + } + + public close(): void { + this.#endpoint.refs -= 1; + if (this.#endpoint.refs <= 0) { + endpoints.delete(this.#path); + this.#endpoint.handle.free(); + } + } + + public send(data: Buffer | Uint8Array, typeId?: number): void { + this.#endpoint.handle.send(data, typeId ?? 0); + } + + public acquireTx(maxSize: number): Uint8Array { + const ptr = this.#endpoint.handle.acquireTx(maxSize); + return slot(ptr, maxSize); + } + + public commitTx(actualSize: number, typeId: number): void { + this.#endpoint.handle.commitTx(actualSize, typeId); + } + + public commitTxUnflushed(actualSize: number, typeId: number): void { + this.#endpoint.handle.commitTxUnflushed(actualSize, typeId); + } + + public rollbackTx(): void { + this.#endpoint.handle.rollbackTx(); + } + + public flush(): void { + this.#endpoint.handle.flush(); + } + + public acquireRx(): RawRx | null { + if (!this.#endpoint.handle.acquireRx()) return null; + const ptr = this.#endpoint.handle.rxPtr(); + const actualSize = this.#endpoint.handle.rxSize(); + return { + data: slot(ptr, actualSize), + typeId: this.#endpoint.handle.rxTypeId(), + actualSize, + }; + } + + public drainBatch(maxMsgs: number): RawBatchMessage[] { + this.#batchBuffers = []; + const messages: RawBatchMessage[] = []; + for (let i = 0; i < maxMsgs; i += 1) { + const result = this.acquireRx(); + if (result === null) break; + + const data = new Uint8Array(result.data); + this.#batchBuffers.push(data.buffer); + messages.push({ + data, + typeId: result.typeId, + size: result.actualSize, + }); + this.commitRx(); + } + return messages; + } + + public commitRx(): void { + this.#endpoint.handle.commitRx(); + } + + public commitBatch(): void { + for (const buffer of this.#batchBuffers) { + detachArrayBuffer(buffer); + } + this.#batchBuffers = []; + } + + public setPollingMode(_spinMode: number): void { + // Browser delivery is direct and non-blocking; there is no futex polling mode. + } + + public setNumaNode(_nodeId: number): void { + // WASM memory is page-local and cannot be NUMA-bound from browser JS. + } + + public getState(): number { + return this.#endpoint.handle.isFatal() ? 4 : 2; + } +} + +/** + * Browser implementation of the Tachyon SPSC bus. + * + * Bundlers resolve `@tachyon-ipc/core` to this entry through the package + * `browser` export condition. The constructor shape matches Node: + * `Bus.listen(path, capacity)` creates a page-local ring and + * `Bus.connect(path)` attaches to it. + */ +export class Bus extends BusBase { + private constructor(path: string, endpoint: BrowserEndpoint) { + super(new BrowserBusHandle(path, endpoint), { + defaultSpinThreshold: 0, + retryNullRecv: false, + nullRecvMessage: 'Bus.recv: no browser message is available. Use a direct doorbell after send().', + copyData: (data) => new Uint8Array(data), + }); + } + + public static listen(socketPath: string, capacity: number): Bus { + if (endpoints.has(socketPath)) { + throw new Error(`Bus.listen: browser endpoint already exists for ${socketPath}`); + } + + const endpoint = { handle: new WasmBus(capacity), refs: 1 }; + endpoints.set(socketPath, endpoint); + return new Bus(socketPath, endpoint); + } + + public static connect(socketPath: string): Bus { + const endpoint = endpoints.get(socketPath); + if (endpoint === undefined) { + throw new Error(`Bus.connect: no browser endpoint is listening at ${socketPath}`); + } + + endpoint.refs += 1; + return new Bus(socketPath, endpoint); + } +} + +export { + TachyonError, + AbiMismatchError, + PeerDeadError, + ErrorCode, + isAbiMismatch, + isFull, + isTachyonError, + isPeerDead, +} from './error.ts'; +export type { ErrorCode as ErrorCodeType } from './error.ts'; +export { RxBatch } from './batch.ts'; +export type { RxMessage } from './batch.ts'; +export { TxGuard, RxGuard } from './guards.ts'; +export type { TxSlot, RxSlot } from './guards.ts'; +export { makeTypeId, msgType, routeId } from './type_id.ts'; diff --git a/bindings/js/src/ts/bus.ts b/bindings/js/src/ts/bus.ts new file mode 100644 index 0000000..6db5668 --- /dev/null +++ b/bindings/js/src/ts/bus.ts @@ -0,0 +1,199 @@ +import { createRequire } from 'node:module'; +import { isMainThread } from 'node:worker_threads'; + +import { AbiMismatchError, ErrorCode, isTachyonError } from './error.ts'; +import type { BusHandle, RawRx } from './bus_core.ts'; +import { BusBase } from './bus_core.ts'; + +const _require = createRequire(import.meta.url); + +// Raw shape of a native instance returned by TachyonBusNode.listen / .connect. +interface NativeBinding { + close(): void; + + send(data: Buffer | Uint8Array, typeId?: number): void; + + acquireTx(maxSize: number): Buffer; + + commitTx(actualSize: number, typeId: number): void; + + commitTxUnflushed(actualSize: number, typeId: number): void; + + rollbackTx(): void; + + flush(): void; + + acquireRxBlocking(spinThreshold?: number): { data: Buffer; typeId: number; actualSize: number } | null; + + commitRx(): void; + + drainBatch(maxMsgs: number, spinThreshold?: number): { data: Buffer; typeId: number; size: number }[]; + + commitBatch(): void; + + setPollingMode(spinMode: number): void; + + setNumaNode(nodeId: number): void; + + getState(): number; +} + +interface NativeModule { + TachyonBusNode: { + listen(socketPath: string, capacity: number): NativeBinding; + connect(socketPath: string): NativeBinding; + }; +} + +function loadNative(): NativeModule { + const candidates = [ + new URL('../../build/Release/tachyon_node.node', import.meta.url).pathname, + new URL('../../build/Debug/tachyon_node.node', import.meta.url).pathname, + ]; + + for (const p of candidates) { + try { + return _require(p) as NativeModule; + } catch { + // try next candidate + } + } + + throw new Error( + 'tachyon_node.node not found. Run `npm run build:native` first.\n' + `Searched: ${candidates.join(', ')}`, + ); +} + +const native = loadNative(); + +class NativeBusHandle implements BusHandle { + #handle: NativeBinding; + + public constructor(handle: NativeBinding) { + this.#handle = handle; + } + + public close(): void { + this.#handle.close(); + } + + public send(data: Buffer | Uint8Array, typeId?: number): void { + this.#handle.send(data, typeId); + } + + public acquireTx(maxSize: number): Buffer { + return this.#handle.acquireTx(maxSize); + } + + public commitTx(actualSize: number, typeId: number): void { + this.#handle.commitTx(actualSize, typeId); + } + + public commitTxUnflushed(actualSize: number, typeId: number): void { + this.#handle.commitTxUnflushed(actualSize, typeId); + } + + public rollbackTx(): void { + this.#handle.rollbackTx(); + } + + public flush(): void { + this.#handle.flush(); + } + + public acquireRx(spinThreshold?: number): RawRx | null { + return this.#handle.acquireRxBlocking(spinThreshold); + } + + public drainBatch(maxMsgs: number, spinThreshold?: number): { data: Buffer; typeId: number; size: number }[] { + return this.#handle.drainBatch(maxMsgs, spinThreshold); + } + + public commitRx(): void { + this.#handle.commitRx(); + } + + public commitBatch(): void { + this.#handle.commitBatch(); + } + + public setPollingMode(spinMode: number): void { + this.#handle.setPollingMode(spinMode); + } + + public setNumaNode(nodeId: number): void { + this.#handle.setNumaNode(nodeId); + } + + public getState(): number { + return this.#handle.getState(); + } +} + +function warnMainThread(method: string): void { + if (isMainThread) { + console.warn( + `[tachyon] Bus.${method}() called on the main thread. ` + + 'Blocking futex calls will saturate the event loop. ' + + 'Consider moving IPC to a Worker.', + ); + } +} + +/** + * Tachyon SPSC IPC bus. + * + * Start the consumer first, it owns the UNIX socket and the SHM arena. + * All blocking operations (listen, acquireRx, drainBatch) spin then park on a futex; + * call them from a Worker thread to avoid saturating the main event loop. + * + * `using` closes the bus automatically. + * + * @example + * ```ts + * // consumer (start first) + * using bus = Bus.listen('/tmp/demo.sock', 1 << 16); + * const { data, typeId } = bus.recv(); + * + * // producer + * using bus = Bus.connect('/tmp/demo.sock'); + * bus.send(Buffer.from('hello'), 1); + * ``` + */ +export class Bus extends BusBase { + private constructor(handle: NativeBinding) { + super(new NativeBusHandle(handle), { + defaultSpinThreshold: 10_000, + retryNullRecv: true, + nullRecvMessage: 'Bus.recv: interrupted while waiting for a message.', + copyData: (data) => Buffer.from(data), + }); + } + + /** + * Creates the SHM arena and waits for one producer to connect. + * Blocks until the producer calls {@link connect} on the same path. + * + * @param socketPath Socket path + * @param capacity Must be a strictly positive power of two. + */ + public static listen(socketPath: string, capacity: number): Bus { + warnMainThread('listen'); + return new Bus(native.TachyonBusNode.listen(socketPath, capacity)); + } + + /** + * Connects to an existing SHM arena via the UNIX socket at `socketPath`. + * + * @throws {AbiMismatchError} If producer and consumer were compiled with incompatible versions. + */ + public static connect(socketPath: string): Bus { + warnMainThread('connect'); + try { + return new Bus(native.TachyonBusNode.connect(socketPath)); + } catch (err) { + if (isTachyonError(err) && err.code === ErrorCode.AbiMismatch) throw new AbiMismatchError(); + throw err; + } + } +} diff --git a/bindings/js/src/ts/bus_core.ts b/bindings/js/src/ts/bus_core.ts new file mode 100644 index 0000000..d052cc3 --- /dev/null +++ b/bindings/js/src/ts/bus_core.ts @@ -0,0 +1,228 @@ +import type { BatchController, RxMessage } from './batch.ts'; +import { RxBatch } from './batch.ts'; +import { PeerDeadError } from './error.ts'; +import type { RxController, RxSlot, TxController } from './guards.ts'; +import { RxGuard, TxGuard } from './guards.ts'; + +const TACHYON_STATE_FATAL_ERROR = 4; + +export interface RawRx { + readonly data: Buffer | Uint8Array; + readonly typeId: number; + readonly actualSize: number; +} + +export interface RawBatchMessage { + readonly data: Buffer | Uint8Array; + readonly typeId: number; + readonly size: number; +} + +export interface BusHandle { + close(): void; + + send(data: Buffer | Uint8Array, typeId?: number): void; + + acquireTx(maxSize: number): Buffer | Uint8Array; + + commitTx(actualSize: number, typeId: number): void; + + commitTxUnflushed(actualSize: number, typeId: number): void; + + rollbackTx(): void; + + flush(): void; + + acquireRx(spinThreshold?: number): RawRx | null; + + drainBatch?(maxMsgs: number, spinThreshold?: number): RawBatchMessage[]; + + commitRx(): void; + + commitBatch?(): void; + + setPollingMode(spinMode: number): void; + + setNumaNode(nodeId: number): void; + + getState(): number; +} + +interface BusBaseOptions { + readonly defaultSpinThreshold: number; + readonly retryNullRecv: boolean; + readonly nullRecvMessage: string; + readonly copyData: (data: Buffer | Uint8Array) => TRecv; +} + +/** + * Shared JS surface for the native Node addon and the browser WASM transport. + * Platform entrypoints only adapt their handle shape; guard lifecycle, recv + * copying, batching, close semantics, and API compatibility stay in one place. + */ +export abstract class BusBase implements Disposable { + #handle: BusHandle; + #closed = false; + #options: BusBaseOptions; + + protected constructor(handle: BusHandle, options: BusBaseOptions) { + this.#handle = handle; + this.#options = options; + } + + /** + * Signals that the consumer will never sleep. Native Node can use this to + * skip the seq_cst fence and consumer_sleeping check on flush; browser WASM + * has no futex sleep path, so the browser handle accepts this as a no-op. + */ + public setPollingMode(spinMode: 0 | 1): void { + this.#assertOpen(); + this.#handle.setPollingMode(spinMode); + } + + /** + * Binds native SHM pages to a specific NUMA node where supported. Browser + * WASM memory is page-local and cannot be NUMA-bound, so it is a no-op there. + */ + public setNumaNode(nodeId: number): void { + this.#assertOpen(); + this.#handle.setNumaNode(nodeId); + } + + /** Publishes all pending unflushed TX messages to the consumer. */ + public flush(): void { + this.#assertOpen(); + this.#handle.flush(); + } + + /** Copies `data` into the ring buffer, commits, and flushes. */ + public send(data: Buffer | Uint8Array, typeId = 0): void { + this.#assertOpen(); + this.#handle.send(data, typeId); + } + + /** + * Copies the next payload and returns it with its type discriminator. Node + * blocks and retries EINTR through the native handle; browser WASM is + * non-blocking and throws if no message is available. + * + * @throws {PeerDeadError} If the bus has transitioned to fatal error state. + */ + public recv(spinThreshold = this.#options.defaultSpinThreshold): { data: TRecv; typeId: number } { + this.#assertOpen(); + for (;;) { + if (this.#isFatal()) throw new PeerDeadError(); + const result = this.#handle.acquireRx(spinThreshold); + if (result === null) { + if (this.#options.retryNullRecv) continue; + throw new Error(this.#options.nullRecvMessage); + } + + const copy = this.#options.copyData(result.data); + this.#handle.commitRx(); + return { data: copy, typeId: result.typeId }; + } + } + + /** + * Acquires an exclusive TX slot of `maxSize` bytes. + * Write into the slot via {@link TxGuard.bytes}, then commit or rollback. + */ + public acquireTx(maxSize: number): TxGuard { + this.#assertOpen(); + const buf = this.#handle.acquireTx(maxSize); + const ctrl: TxController = { + commitTx: (s, t) => { + this.#handle.commitTx(s, t); + }, + commitTxUnflushed: (s, t) => { + this.#handle.commitTxUnflushed(s, t); + }, + rollbackTx: () => { + this.#handle.rollbackTx(); + }, + }; + return new TxGuard(ctrl, buf as unknown as Buffer); + } + + /** + * Acquires a zero-copy read lease. Node may block according to + * `spinThreshold`; browser WASM checks once and returns `null` when empty. + * + * @throws {PeerDeadError} If the bus has transitioned to fatal error state. + */ + public acquireRx(spinThreshold = this.#options.defaultSpinThreshold): RxGuard | null { + this.#assertOpen(); + if (this.#isFatal()) throw new PeerDeadError(); + const result = this.#handle.acquireRx(spinThreshold); + if (result === null) return null; + const ctrl: RxController = { + commitRx: () => { + this.#handle.commitRx(); + }, + getState: () => this.#handle.getState(), + }; + return new RxGuard(ctrl, result.data as unknown as Buffer, result.typeId, result.actualSize); + } + + /** + * Drains up to `maxMsgs` messages. Native Node uses one addon call to + * amortize FFI cost; browser WASM falls back to the same guard lifecycle + * with copied batch entries so all slots are released before returning. + */ + public drainBatch(maxMsgs: number, spinThreshold = this.#options.defaultSpinThreshold): RxBatch { + this.#assertOpen(); + if (this.#isFatal()) throw new PeerDeadError(); + + const raw = + this.#handle.drainBatch?.(maxMsgs, spinThreshold) ?? this.#drainBatchByAcquireRx(maxMsgs, spinThreshold); + const messages: RxMessage[] = raw.map((m) => ({ + data: m.data as unknown as RxSlot, + typeId: m.typeId, + size: m.size, + })); + const ctrl: BatchController = { + commitBatch: () => { + this.#handle.commitBatch?.(); + }, + getState: () => this.#handle.getState(), + }; + return new RxBatch(ctrl, messages); + } + + /** Closes the bus and releases the underlying platform handle. Safe to call multiple times. */ + public close(): void { + if (this.#closed) return; + this.#closed = true; + this.#handle.close(); + } + + /** Called automatically by the `using` keyword. */ + public [Symbol.dispose](): void { + this.close(); + } + + #drainBatchByAcquireRx(maxMsgs: number, spinThreshold: number): RawBatchMessage[] { + const messages: RawBatchMessage[] = []; + for (let i = 0; i < maxMsgs; i += 1) { + if (this.#isFatal()) throw new PeerDeadError(); + const result = this.#handle.acquireRx(spinThreshold); + if (result === null) break; + messages.push({ + data: this.#options.copyData(result.data), + typeId: result.typeId, + size: result.actualSize, + }); + this.#handle.commitRx(); + } + return messages; + } + + #assertOpen(): void { + if (this.#closed) throw new Error('Bus: this bus has been closed.'); + } + + #isFatal(): boolean { + return this.#handle.getState() === TACHYON_STATE_FATAL_ERROR; + } +} diff --git a/bindings/node/src/ts/error.ts b/bindings/js/src/ts/error.ts similarity index 100% rename from bindings/node/src/ts/error.ts rename to bindings/js/src/ts/error.ts diff --git a/bindings/node/src/ts/guards.ts b/bindings/js/src/ts/guards.ts similarity index 100% rename from bindings/node/src/ts/guards.ts rename to bindings/js/src/ts/guards.ts diff --git a/bindings/node/src/ts/index.ts b/bindings/js/src/ts/index.ts similarity index 100% rename from bindings/node/src/ts/index.ts rename to bindings/js/src/ts/index.ts diff --git a/bindings/node/src/ts/rpc.ts b/bindings/js/src/ts/rpc.ts similarity index 100% rename from bindings/node/src/ts/rpc.ts rename to bindings/js/src/ts/rpc.ts diff --git a/bindings/node/src/ts/type_id.ts b/bindings/js/src/ts/type_id.ts similarity index 100% rename from bindings/node/src/ts/type_id.ts rename to bindings/js/src/ts/type_id.ts diff --git a/bindings/js/src/ts/wasm/tachyon_ipc.d.ts b/bindings/js/src/ts/wasm/tachyon_ipc.d.ts new file mode 100644 index 0000000..c64a801 --- /dev/null +++ b/bindings/js/src/ts/wasm/tachyon_ipc.d.ts @@ -0,0 +1,94 @@ +// Generated by wasm-pack. Do not edit by hand. +// Regenerate with: npm run build:wasm +/* tslint:disable */ +/* eslint-disable */ + +/** + * Browser-local Tachyon SPSC ring backed by WebAssembly linear memory. + * + * This keeps the Tachyon message wire shape (`size`, `type_id`, + * `reserved_size`, 64-byte alignment, and skip marker) while replacing the + * native POSIX shared-memory transport with a WASM memory arena that page + * JavaScript can access through `WebAssembly.Memory`. + */ +export class WasmBus { + free(): void; + [Symbol.dispose](): void; + /** + * Acquire the next RX message, if one is visible. + * + * When this returns `true`, read `rxPtr`, `rxSize`, and `rxTypeId`, then + * call `commitRx` when done. + */ + acquireRx(): boolean; + /** + * Reserve a TX slot and return a pointer to its payload bytes in WASM memory. + * + * JavaScript can create a zero-copy view with: + * `new Uint8Array(wasm.memory.buffer, ptr, maxSize)`. + */ + acquireTx(max_size: number): number; + commitRx(): void; + commitTx(actual_size: number, type_id: number): void; + commitTxUnflushed(actual_size: number, type_id: number): void; + /** + * Publish pending unflushed TX messages. + */ + flush(): void; + isFatal(): boolean; + constructor(capacity: number); + rollbackTx(): void; + rxPtr(): number; + rxSize(): number; + rxTypeId(): number; + /** + * Copy-based send. Prefer `acquireTx` + direct JS view writes for hot paths. + */ + send(data: Uint8Array, type_id: number): void; +} + +export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; + +export interface InitOutput { + readonly memory: WebAssembly.Memory; + readonly __wbg_wasmbus_free: (a: number, b: number) => void; + readonly wasmbus_acquireRx: (a: number) => [number, number, number]; + readonly wasmbus_acquireTx: (a: number, b: number) => [number, number, number]; + readonly wasmbus_commitRx: (a: number) => [number, number]; + readonly wasmbus_commitTx: (a: number, b: number, c: number) => [number, number]; + readonly wasmbus_commitTxUnflushed: (a: number, b: number, c: number) => [number, number]; + readonly wasmbus_flush: (a: number) => void; + readonly wasmbus_isFatal: (a: number) => number; + readonly wasmbus_new: (a: number) => [number, number, number]; + readonly wasmbus_rollbackTx: (a: number) => [number, number]; + readonly wasmbus_rxPtr: (a: number) => number; + readonly wasmbus_rxSize: (a: number) => number; + readonly wasmbus_rxTypeId: (a: number) => number; + readonly wasmbus_send: (a: number, b: number, c: number, d: number) => [number, number]; + readonly __wbindgen_externrefs: WebAssembly.Table; + readonly __externref_table_dealloc: (a: number) => void; + readonly __wbindgen_malloc: (a: number, b: number) => number; + readonly __wbindgen_start: () => void; +} + +export type SyncInitInput = BufferSource | WebAssembly.Module; + +/** + * Instantiates the given `module`, which can either be bytes or + * a precompiled `WebAssembly.Module`. + * + * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. + * + * @returns {InitOutput} + */ +export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; + +/** + * If `module_or_path` is {RequestInfo} or {URL}, makes a request and + * for everything else, calls `WebAssembly.instantiate` directly. + * + * @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated. + * + * @returns {Promise} + */ +export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise; diff --git a/bindings/js/src/ts/wasm/tachyon_ipc.js b/bindings/js/src/ts/wasm/tachyon_ipc.js new file mode 100644 index 0000000..f99a906 --- /dev/null +++ b/bindings/js/src/ts/wasm/tachyon_ipc.js @@ -0,0 +1,309 @@ +/* @ts-self-types="./tachyon_ipc.d.ts" */ +// Generated by wasm-pack. Do not edit by hand. +// Regenerate with: npm run build:wasm + +/** + * Browser-local Tachyon SPSC ring backed by WebAssembly linear memory. + * + * This keeps the Tachyon message wire shape (`size`, `type_id`, + * `reserved_size`, 64-byte alignment, and skip marker) while replacing the + * native POSIX shared-memory transport with a WASM memory arena that page + * JavaScript can access through `WebAssembly.Memory`. + */ +export class WasmBus { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + WasmBusFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_wasmbus_free(ptr, 0); + } + /** + * Acquire the next RX message, if one is visible. + * + * When this returns `true`, read `rxPtr`, `rxSize`, and `rxTypeId`, then + * call `commitRx` when done. + * @returns {boolean} + */ + acquireRx() { + const ret = wasm.wasmbus_acquireRx(this.__wbg_ptr); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return ret[0] !== 0; + } + /** + * Reserve a TX slot and return a pointer to its payload bytes in WASM memory. + * + * JavaScript can create a zero-copy view with: + * `new Uint8Array(wasm.memory.buffer, ptr, maxSize)`. + * @param {number} max_size + * @returns {number} + */ + acquireTx(max_size) { + const ret = wasm.wasmbus_acquireTx(this.__wbg_ptr, max_size); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return ret[0] >>> 0; + } + commitRx() { + const ret = wasm.wasmbus_commitRx(this.__wbg_ptr); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } + } + /** + * @param {number} actual_size + * @param {number} type_id + */ + commitTx(actual_size, type_id) { + const ret = wasm.wasmbus_commitTx(this.__wbg_ptr, actual_size, type_id); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } + } + /** + * @param {number} actual_size + * @param {number} type_id + */ + commitTxUnflushed(actual_size, type_id) { + const ret = wasm.wasmbus_commitTxUnflushed(this.__wbg_ptr, actual_size, type_id); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } + } + /** + * Publish pending unflushed TX messages. + */ + flush() { + wasm.wasmbus_flush(this.__wbg_ptr); + } + /** + * @returns {boolean} + */ + isFatal() { + const ret = wasm.wasmbus_isFatal(this.__wbg_ptr); + return ret !== 0; + } + /** + * @param {number} capacity + */ + constructor(capacity) { + const ret = wasm.wasmbus_new(capacity); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + this.__wbg_ptr = ret[0]; + WasmBusFinalization.register(this, this.__wbg_ptr, this); + return this; + } + rollbackTx() { + const ret = wasm.wasmbus_rollbackTx(this.__wbg_ptr); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } + } + /** + * @returns {number} + */ + rxPtr() { + const ret = wasm.wasmbus_rxPtr(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {number} + */ + rxSize() { + const ret = wasm.wasmbus_rxSize(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {number} + */ + rxTypeId() { + const ret = wasm.wasmbus_rxTypeId(this.__wbg_ptr); + return ret >>> 0; + } + /** + * Copy-based send. Prefer `acquireTx` + direct JS view writes for hot paths. + * @param {Uint8Array} data + * @param {number} type_id + */ + send(data, type_id) { + const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.wasmbus_send(this.__wbg_ptr, ptr0, len0, type_id); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } + } +} +if (Symbol.dispose) WasmBus.prototype[Symbol.dispose] = WasmBus.prototype.free; +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg___wbindgen_throw_9c31b086c2b26051: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbindgen_cast_0000000000000001: function(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return ret; + }, + __wbindgen_init_externref_table: function() { + const table = wasm.__wbindgen_externrefs; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + }, + }; + return { + __proto__: null, + "./tachyon_ipc_bg.js": import0, + }; +} + +const WasmBusFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_wasmbus_free(ptr, 1)); + +function getStringFromWasm0(ptr, len) { + return decodeText(ptr >>> 0, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function passArray8ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 1, 1) >>> 0; + getUint8ArrayMemory0().set(arg, ptr / 1); + WASM_VECTOR_LEN = arg.length; + return ptr; +} + +function takeFromExternrefTable0(idx) { + const value = wasm.__wbindgen_externrefs.get(idx); + wasm.__externref_table_dealloc(idx); + return value; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasmInstance, wasm; +function __wbg_finalize_init(instance, module) { + wasmInstance = instance; + wasm = instance.exports; + wasmModule = module; + cachedUint8ArrayMemory0 = null; + wasm.__wbindgen_start(); + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = module.ok && expectedResponseType(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { throw e; } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case 'basic': case 'cors': case 'default': return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (module_or_path === undefined) { + module_or_path = new URL('tachyon_ipc_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync, __wbg_init as default }; diff --git a/bindings/js/src/ts/wasm/tachyon_ipc_bg.js b/bindings/js/src/ts/wasm/tachyon_ipc_bg.js new file mode 100644 index 0000000..c355616 --- /dev/null +++ b/bindings/js/src/ts/wasm/tachyon_ipc_bg.js @@ -0,0 +1,325 @@ +// Generated by wasm-pack. Do not edit by hand. +// Regenerate with: npm run build:wasm +// Generated by wasm-pack. Do not edit by hand. +// Regenerate with: npm run build:wasm +// Generated by wasm-pack. Do not edit by hand. +// Regenerate with: npm run build:wasm +/** + * Browser-local Tachyon SPSC ring backed by WebAssembly linear memory. + * + * This keeps the Tachyon message wire shape (`size`, `type_id`, + * `reserved_size`, 64-byte alignment, and skip marker) while replacing the + * native POSIX shared-memory transport with a WASM memory arena that page + * JavaScript can access through `WebAssembly.Memory`. + */ +export class WasmBus { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + WasmBusFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_wasmbus_free(ptr, 0); + } + /** + * Acquire the next RX message, if one is visible. + * + * When this returns `true`, read `rxPtr`, `rxSize`, and `rxTypeId`, then + * call `commitRx` when done. + * @returns {boolean} + */ + acquireRx() { + const ret = wasm.wasmbus_acquireRx(this.__wbg_ptr); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return ret[0] !== 0; + } + /** + * Reserve a TX slot and return a pointer to its payload bytes in WASM memory. + * + * JavaScript can create a zero-copy view with: + * `new Uint8Array(wasm.memory.buffer, ptr, maxSize)`. + * @param {number} max_size + * @returns {number} + */ + acquireTx(max_size) { + const ret = wasm.wasmbus_acquireTx(this.__wbg_ptr, max_size); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return ret[0] >>> 0; + } + /** + * @returns {number} + */ + availableBytes() { + const ret = wasm.wasmbus_availableBytes(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {number} + */ + capacity() { + const ret = wasm.wasmbus_capacity(this.__wbg_ptr); + return ret >>> 0; + } + commitRx() { + const ret = wasm.wasmbus_commitRx(this.__wbg_ptr); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } + } + /** + * @param {number} actual_size + * @param {number} type_id + */ + commitTx(actual_size, type_id) { + const ret = wasm.wasmbus_commitTx(this.__wbg_ptr, actual_size, type_id); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } + } + /** + * @param {number} actual_size + * @param {number} type_id + */ + commitTxUnflushed(actual_size, type_id) { + const ret = wasm.wasmbus_commitTxUnflushed(this.__wbg_ptr, actual_size, type_id); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } + } + /** + * @returns {number} + */ + dataPtr() { + const ret = wasm.wasmbus_dataPtr(this.__wbg_ptr); + return ret >>> 0; + } + /** + * Publish pending unflushed TX messages. + */ + flush() { + wasm.wasmbus_flush(this.__wbg_ptr); + } + /** + * @returns {number} + */ + freeBytes() { + const ret = wasm.wasmbus_freeBytes(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {boolean} + */ + isFatal() { + const ret = wasm.wasmbus_isFatal(this.__wbg_ptr); + return ret !== 0; + } + /** + * @param {number} capacity + */ + constructor(capacity) { + const ret = wasm.wasmbus_new(capacity); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + this.__wbg_ptr = ret[0]; + WasmBusFinalization.register(this, this.__wbg_ptr, this); + return this; + } + /** + * @returns {number} + */ + recvU32() { + const ret = wasm.wasmbus_recvU32(this.__wbg_ptr); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return ret[0] >>> 0; + } + rollbackTx() { + const ret = wasm.wasmbus_rollbackTx(this.__wbg_ptr); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } + } + /** + * @returns {number} + */ + rxPtr() { + const ret = wasm.wasmbus_rxPtr(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {number} + */ + rxSize() { + const ret = wasm.wasmbus_rxSize(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {number} + */ + rxTypeId() { + const ret = wasm.wasmbus_rxTypeId(this.__wbg_ptr); + return ret >>> 0; + } + /** + * Copy-based send. Prefer `acquireTx` + direct JS view writes for hot paths. + * @param {Uint8Array} data + * @param {number} type_id + */ + send(data, type_id) { + const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.wasmbus_send(this.__wbg_ptr, ptr0, len0, type_id); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } + } + /** + * @param {number} value + * @param {number} type_id + */ + sendU32(value, type_id) { + const ret = wasm.wasmbus_sendU32(this.__wbg_ptr, value, type_id); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } + } + /** + * @param {number} value + * @param {number} type_id + */ + sendU32Unflushed(value, type_id) { + const ret = wasm.wasmbus_sendU32Unflushed(this.__wbg_ptr, value, type_id); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } + } +} +if (Symbol.dispose) WasmBus.prototype[Symbol.dispose] = WasmBus.prototype.free; + +/** + * @param {number} route + * @param {number} ty + * @returns {number} + */ +export function makeTypeId(route, ty) { + const ret = wasm.makeTypeId(route, ty); + return ret >>> 0; +} + +/** + * @param {number} type_id + * @returns {number} + */ +export function msgType(type_id) { + const ret = wasm.msgType(type_id); + return ret; +} + +/** + * @param {number} type_id + * @returns {number} + */ +export function routeId(type_id) { + const ret = wasm.routeId(type_id); + return ret; +} + +/** + * Tiny Rust-side browser program used by the example page. + * + * It polls `inbound`, increments a little-endian `u32` payload, and publishes + * the result to `outbound`. Non-`u32` payloads are echoed unchanged. + * @param {WasmBus} inbound + * @param {WasmBus} outbound + * @returns {boolean} + */ +export function tachyon_browser_echo_once(inbound, outbound) { + _assertClass(inbound, WasmBus); + _assertClass(outbound, WasmBus); + const ret = wasm.tachyon_browser_echo_once(inbound.__wbg_ptr, outbound.__wbg_ptr); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return ret[0] !== 0; +} +export function __wbg___wbindgen_throw_9c31b086c2b26051(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); +} +export function __wbindgen_cast_0000000000000001(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return ret; +} +export function __wbindgen_init_externref_table() { + const table = wasm.__wbindgen_externrefs; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); +} +const WasmBusFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_wasmbus_free(ptr, 1)); + +function _assertClass(instance, klass) { + if (!(instance instanceof klass)) { + throw new Error(`expected instance of ${klass.name}`); + } +} + +function getStringFromWasm0(ptr, len) { + return decodeText(ptr >>> 0, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function passArray8ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 1, 1) >>> 0; + getUint8ArrayMemory0().set(arg, ptr / 1); + WASM_VECTOR_LEN = arg.length; + return ptr; +} + +function takeFromExternrefTable0(idx) { + const value = wasm.__wbindgen_externrefs.get(idx); + wasm.__externref_table_dealloc(idx); + return value; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +let WASM_VECTOR_LEN = 0; + + +let wasm; +export function __wbg_set_wasm(val) { + wasm = val; +} diff --git a/bindings/js/src/ts/wasm/tachyon_ipc_bg.wasm b/bindings/js/src/ts/wasm/tachyon_ipc_bg.wasm new file mode 100644 index 0000000..cd226f2 Binary files /dev/null and b/bindings/js/src/ts/wasm/tachyon_ipc_bg.wasm differ diff --git a/bindings/js/src/ts/wasm/tachyon_ipc_bg.wasm.d.ts b/bindings/js/src/ts/wasm/tachyon_ipc_bg.wasm.d.ts new file mode 100644 index 0000000..7a31752 --- /dev/null +++ b/bindings/js/src/ts/wasm/tachyon_ipc_bg.wasm.d.ts @@ -0,0 +1,23 @@ +// Generated by wasm-pack. Do not edit by hand. +// Regenerate with: npm run build:wasm +/* tslint:disable */ +/* eslint-disable */ +export const memory: WebAssembly.Memory; +export const __wbg_wasmbus_free: (a: number, b: number) => void; +export const wasmbus_acquireRx: (a: number) => [number, number, number]; +export const wasmbus_acquireTx: (a: number, b: number) => [number, number, number]; +export const wasmbus_commitRx: (a: number) => [number, number]; +export const wasmbus_commitTx: (a: number, b: number, c: number) => [number, number]; +export const wasmbus_commitTxUnflushed: (a: number, b: number, c: number) => [number, number]; +export const wasmbus_flush: (a: number) => void; +export const wasmbus_isFatal: (a: number) => number; +export const wasmbus_new: (a: number) => [number, number, number]; +export const wasmbus_rollbackTx: (a: number) => [number, number]; +export const wasmbus_rxPtr: (a: number) => number; +export const wasmbus_rxSize: (a: number) => number; +export const wasmbus_rxTypeId: (a: number) => number; +export const wasmbus_send: (a: number, b: number, c: number, d: number) => [number, number]; +export const __wbindgen_externrefs: WebAssembly.Table; +export const __externref_table_dealloc: (a: number) => void; +export const __wbindgen_malloc: (a: number, b: number) => number; +export const __wbindgen_start: () => void; diff --git a/bindings/js/test/browser_wasm.spec.mjs b/bindings/js/test/browser_wasm.spec.mjs new file mode 100644 index 0000000..3b71e88 --- /dev/null +++ b/bindings/js/test/browser_wasm.spec.mjs @@ -0,0 +1,365 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { existsSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, extname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PACKAGE_ROOT = resolve(__dirname, '..'); +const HOME = process.env.HOME ?? ''; + +const TEST_PAGE = ` + + +`; + +function chromiumPath() { + const candidates = [ + process.env.CHROMIUM_BIN, + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + ...playwrightChromiumCandidates(), + ].filter(Boolean); + + for (const candidate of candidates) { + if (candidate !== undefined && existsSync(candidate)) return candidate; + } + throw new Error( + `Chromium not found. Set CHROMIUM_BIN, install /usr/bin/chromium, or install a Chrome/Chromium app.`, + ); +} + +function playwrightChromiumCandidates() { + const roots = [ + HOME === '' ? undefined : join(HOME, 'Library/Caches/ms-playwright'), + HOME === '' ? undefined : join(HOME, '.cache/ms-playwright'), + process.env.PLAYWRIGHT_BROWSERS_PATH, + ].filter(Boolean); + const candidates = []; + + for (const root of roots) { + if (root === undefined || !existsSync(root)) continue; + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('chromium')) continue; + const dir = join(root, entry.name); + candidates.push( + join(dir, 'chrome-linux/chrome'), + join(dir, 'chrome-mac/Chromium.app/Contents/MacOS/Chromium'), + join(dir, 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'), + join(dir, 'chrome-headless-shell-linux64/chrome-headless-shell'), + join(dir, 'chrome-headless-shell-mac-arm64/chrome-headless-shell'), + ); + } + } + + return candidates; +} + +function mimeType(pathname) { + switch (extname(pathname)) { + case '.html': + return 'text/html; charset=utf-8'; + case '.js': + return 'text/javascript; charset=utf-8'; + case '.wasm': + return 'application/wasm'; + default: + return 'application/octet-stream'; + } +} + +async function startServer() { + const { readFile } = await import('node:fs/promises'); + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + if (url.pathname === '/' || url.pathname === '/index.html') { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end(TEST_PAGE); + return; + } + + const filePath = resolve(PACKAGE_ROOT, `.${url.pathname}`); + if (!filePath.startsWith(PACKAGE_ROOT)) { + res.writeHead(403); + res.end('forbidden'); + return; + } + + const body = await readFile(filePath); + res.writeHead(200, { 'content-type': mimeType(filePath) }); + res.end(body); + } catch (error) { + res.writeHead(404); + res.end(String(error)); + } + }); + + await new Promise((resolveListen) => server.listen(0, '127.0.0.1', resolveListen)); + const address = server.address(); + assert.equal(typeof address, 'object'); + return { server, port: address.port }; +} + +async function waitForJson(url, timeoutMs = 10_000) { + const started = Date.now(); + for (;;) { + try { + const res = await fetch(url); + if (res.ok) return await res.json(); + } catch { + // Chromium may still be starting. + } + if (Date.now() - started > timeoutMs) throw new Error(`Timed out waiting for ${url}`); + await new Promise((resolveWait) => setTimeout(resolveWait, 50)); + } +} + +async function openPage(debugPort, url) { + const target = await fetch(`http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(url)}`, { + method: 'PUT', + }); + if (!target.ok) throw new Error(`Failed to open browser page: ${await target.text()}`); + return target.json(); +} + +async function runCdp(webSocketDebuggerUrl) { + const ws = new WebSocket(webSocketDebuggerUrl); + let nextId = 0; + const pending = new Map(); + + ws.addEventListener('message', (event) => { + const msg = JSON.parse(event.data); + if (msg.id === undefined || !pending.has(msg.id)) return; + const { resolve: resolveMessage, reject } = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error !== undefined) reject(new Error(JSON.stringify(msg.error))); + else resolveMessage(msg.result); + }); + + await new Promise((resolveOpen, rejectOpen) => { + ws.addEventListener('open', resolveOpen, { once: true }); + ws.addEventListener('error', rejectOpen, { once: true }); + }); + + const call = (method, params = {}) => { + const id = ++nextId; + ws.send(JSON.stringify({ id, method, params })); + return new Promise((resolveCall, rejectCall) => pending.set(id, { resolve: resolveCall, reject: rejectCall })); + }; + + const evaluate = async (expression, timeout = 15_000) => { + const result = await call('Runtime.evaluate', { + expression, + awaitPromise: true, + returnByValue: true, + timeout, + }); + if (result.exceptionDetails !== undefined) throw new Error(JSON.stringify(result.exceptionDetails)); + return result.result.value; + }; + + await call('Runtime.enable'); + await evaluate(`new Promise((resolve, reject) => { + const started = performance.now(); + const tick = () => { + if (window.__tachyonBrowserDone) resolve(true); + else if (performance.now() - started > 10000) reject(new Error("browser wasm tests timed out")); + else setTimeout(tick, 25); + }; + tick(); +})`); + const results = JSON.parse(await evaluate('JSON.stringify(window.__tachyonBrowserResults)')); + ws.close(); + return results; +} + +const { server, port } = await startServer(); +const debugPort = 9333 + Math.floor(Math.random() * 1000); +const userDataDir = await mkdtemp(join(tmpdir(), 'tachyon-browser-wasm-')); +const browser = spawn(chromiumPath(), [ + '--headless=new', + '--disable-gpu', + '--no-first-run', + '--no-default-browser-check', + '--no-sandbox', + `--remote-debugging-port=${debugPort}`, + `--user-data-dir=${userDataDir}`, + `http://127.0.0.1:${port}/`, +]); + +browser.stderr.on('data', (chunk) => { + if (process.env.TACHYON_BROWSER_TEST_DEBUG === '1') process.stderr.write(chunk); +}); + +try { + await waitForJson(`http://127.0.0.1:${debugPort}/json/version`); + const target = await openPage(debugPort, `http://127.0.0.1:${port}/`); + const results = await runCdp(target.webSocketDebuggerUrl); + const failures = results.filter((result) => !result.ok); + for (const result of results) { + console.log(`${result.ok ? 'ok' : 'not ok'} - ${result.name}`); + } + if (failures.length > 0) { + throw new Error(failures.map((failure) => `${failure.name}: ${failure.message}`).join('\n\n')); + } +} finally { + browser.kill('SIGTERM'); + await new Promise((resolveExit) => { + browser.once('exit', resolveExit); + setTimeout(resolveExit, 2_000); + }); + server.close(); + await rm(userDataDir, { recursive: true, force: true }); +} diff --git a/bindings/node/test/bus.spec.ts b/bindings/js/test/bus.spec.ts similarity index 100% rename from bindings/node/test/bus.spec.ts rename to bindings/js/test/bus.spec.ts diff --git a/bindings/node/test/rpc.spec.ts b/bindings/js/test/rpc.spec.ts similarity index 100% rename from bindings/node/test/rpc.spec.ts rename to bindings/js/test/rpc.spec.ts diff --git a/bindings/node/test/rpc_worker.ts b/bindings/js/test/rpc_worker.ts similarity index 100% rename from bindings/node/test/rpc_worker.ts rename to bindings/js/test/rpc_worker.ts diff --git a/bindings/node/test/worker.ts b/bindings/js/test/worker.ts similarity index 100% rename from bindings/node/test/worker.ts rename to bindings/js/test/worker.ts diff --git a/bindings/node/tsconfig.json b/bindings/js/tsconfig.json similarity index 95% rename from bindings/node/tsconfig.json rename to bindings/js/tsconfig.json index 0359398..1ccd577 100644 --- a/bindings/node/tsconfig.json +++ b/bindings/js/tsconfig.json @@ -17,6 +17,7 @@ "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, + "allowArbitraryExtensions": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, diff --git a/bindings/node/src/ts/bus.ts b/bindings/node/src/ts/bus.ts deleted file mode 100644 index d5cb0eb..0000000 --- a/bindings/node/src/ts/bus.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { createRequire } from 'node:module'; -import { isMainThread } from 'node:worker_threads'; - -import type { BatchController, RxMessage } from './batch.ts'; -import { RxBatch } from './batch.ts'; -import { AbiMismatchError, ErrorCode, PeerDeadError, isTachyonError } from './error.ts'; -import type { RxController, RxSlot, TxController } from './guards.ts'; -import { RxGuard, TxGuard } from './guards.ts'; - -const _require = createRequire(import.meta.url); - -// Raw shape of a native instance returned by TachyonBusNode.listen / .connect. -interface NativeBinding { - close(): void; - - send(data: Buffer | Uint8Array, typeId?: number): void; - - acquireTx(maxSize: number): Buffer; - - commitTx(actualSize: number, typeId: number): void; - - commitTxUnflushed(actualSize: number, typeId: number): void; - - rollbackTx(): void; - - flush(): void; - - acquireRxBlocking(spinThreshold?: number): { data: Buffer; typeId: number; actualSize: number } | null; - - commitRx(): void; - - drainBatch(maxMsgs: number, spinThreshold?: number): { data: Buffer; typeId: number; size: number }[]; - - commitBatch(): void; - - setPollingMode(spinMode: number): void; - - setNumaNode(nodeId: number): void; - - getState(): number; -} - -interface NativeModule { - TachyonBusNode: { - listen(socketPath: string, capacity: number): NativeBinding; - connect(socketPath: string): NativeBinding; - }; -} - -function loadNative(): NativeModule { - const candidates = [ - new URL('../../build/Release/tachyon_node.node', import.meta.url).pathname, - new URL('../../build/Debug/tachyon_node.node', import.meta.url).pathname, - ]; - - for (const p of candidates) { - try { - return _require(p) as NativeModule; - } catch { - // try next candidate - } - } - - throw new Error( - 'tachyon_node.node not found. Run `npm run build:native` first.\n' + `Searched: ${candidates.join(', ')}`, - ); -} - -const native = loadNative(); - -function warnMainThread(method: string): void { - if (isMainThread) { - console.warn( - `[tachyon] Bus.${method}() called on the main thread. ` + - 'Blocking futex calls will saturate the event loop. ' + - 'Consider moving IPC to a Worker.', - ); - } -} - -/** - * Tachyon SPSC IPC bus. - * - * Start the consumer first, it owns the UNIX socket and the SHM arena. - * All blocking operations (listen, acquireRx, drainBatch) spin then park on a futex; - * call them from a Worker thread to avoid saturating the main event loop. - * - * `using` closes the bus automatically. - * - * @example - * ```ts - * // consumer (start first) - * using bus = Bus.listen('/tmp/demo.sock', 1 << 16); - * const { data, typeId } = bus.recv(); - * - * // producer - * using bus = Bus.connect('/tmp/demo.sock'); - * bus.send(Buffer.from('hello'), 1); - * ``` - */ -export class Bus implements Disposable { - #handle: NativeBinding; - #closed = false; - - private constructor(handle: NativeBinding) { - this.#handle = handle; - } - - /** - * Creates the SHM arena and waits for one producer to connect. - * Blocks until the producer calls {@link connect} on the same path. - * - * @param socketPath Socket path - * @param capacity Must be a strictly positive power of two. - */ - public static listen(socketPath: string, capacity: number): Bus { - warnMainThread('listen'); - return new Bus(native.TachyonBusNode.listen(socketPath, capacity)); - } - - /** - * Connects to an existing SHM arena via the UNIX socket at `socketPath`. - * - * @throws {AbiMismatchError} If producer and consumer were compiled with incompatible versions. - */ - public static connect(socketPath: string): Bus { - warnMainThread('connect'); - try { - return new Bus(native.TachyonBusNode.connect(socketPath)); - } catch (err) { - if (isTachyonError(err) && err.code === ErrorCode.AbiMismatch) throw new AbiMismatchError(); - throw err; - } - } - - /** - * Signals that the consumer will never sleep. The producer skips the seq_cst fence - * and consumer_sleeping check on every flush. Use only on a dedicated SCHED_FIFO thread. - * Call immediately after listen/connect, before the first message. - */ - public setPollingMode(spinMode: 0 | 1): void { - this.#assertOpen(); - this.#handle.setPollingMode(spinMode); - } - - /** - * Binds the SHM pages to a specific NUMA node (MPOL_PREFERRED + MPOL_MF_MOVE). - * No-op on non-Linux platforms. Call immediately after listen/connect. - */ - public setNumaNode(nodeId: number): void { - this.#assertOpen(); - this.#handle.setNumaNode(nodeId); - } - - /** Publishes all pending unflushed TX messages to the consumer. */ - public flush(): void { - this.#assertOpen(); - this.#handle.flush(); - } - - /** Copies `data` into the ring buffer, commits, and flushes. */ - public send(data: Buffer | Uint8Array, typeId = 0): void { - this.#assertOpen(); - this.#handle.send(data, typeId); - } - - /** - * Blocks until a message is available, copies the payload, and returns it. - * Retries transparently on EINTR. - * - * @throws {PeerDeadError} If the bus has transitioned to TACHYON_STATE_FATAL_ERROR. - */ - public recv(spinThreshold = 10_000): { data: Buffer; typeId: number } { - this.#assertOpen(); - for (;;) { - if (this.#handle.getState() === 4) throw new PeerDeadError(); - const result = this.#handle.acquireRxBlocking(spinThreshold); - if (result === null) continue; // EINTR - retry - const copy = Buffer.from(result.data); - this.#handle.commitRx(); - return { data: copy, typeId: result.typeId }; - } - } - - /** - * Acquires an exclusive TX slot of `maxSize` bytes. - * Write into the slot via {@link TxGuard.bytes}, then commit or rollback. - * - * @throws {TachyonError} code `ERR_TACHYON_FULL` if the ring buffer is full. - */ - public acquireTx(maxSize: number): TxGuard { - this.#assertOpen(); - const buf = this.#handle.acquireTx(maxSize); - const ctrl: TxController = { - commitTx: (s, t) => { - this.#handle.commitTx(s, t); - }, - commitTxUnflushed: (s, t) => { - this.#handle.commitTxUnflushed(s, t); - }, - rollbackTx: () => { - this.#handle.rollbackTx(); - }, - }; - return new TxGuard(ctrl, buf); - } - - /** - * Blocks until a message is available and returns a zero-copy read lease. - * Returns `null` on EINTR caller decides whether to retry. - * - * @throws {PeerDeadError} If the bus has transitioned to TACHYON_STATE_FATAL_ERROR. - */ - public acquireRx(spinThreshold = 10_000): RxGuard | null { - this.#assertOpen(); - if (this.#handle.getState() === 4) throw new PeerDeadError(); - const result = this.#handle.acquireRxBlocking(spinThreshold); - if (result === null) return null; - const ctrl: RxController = { - commitRx: () => { - this.#handle.commitRx(); - }, - getState: () => this.#handle.getState(), - }; - return new RxGuard(ctrl, result.data, result.typeId, result.actualSize); - } - - /** - * Blocks until at least one message is available, then drains up to `maxMsgs` - * in a single native call. One native crossing amortizes per-message FFI cost. - * - * @throws {PeerDeadError} If the bus has transitioned to TACHYON_STATE_FATAL_ERROR. - */ - public drainBatch(maxMsgs: number, spinThreshold = 10_000): RxBatch { - this.#assertOpen(); - if (this.#handle.getState() === 4) throw new PeerDeadError(); - const raw = this.#handle.drainBatch(maxMsgs, spinThreshold); - const messages: RxMessage[] = raw.map((m) => ({ - data: m.data as unknown as RxSlot, - typeId: m.typeId, - size: m.size, - })); - const ctrl: BatchController = { - commitBatch: () => { - this.#handle.commitBatch(); - }, - getState: () => this.#handle.getState(), - }; - return new RxBatch(ctrl, messages); - } - - /** Closes the bus and unmaps shared memory. Safe to call multiple times. */ - public close(): void { - if (this.#closed) return; - this.#closed = true; - this.#handle.close(); - } - - /** Called automatically by the `using` keyword. */ - public [Symbol.dispose](): void { - this.close(); - } - - #assertOpen(): void { - if (this.#closed) throw new Error('Bus: this bus has been closed.'); - } -} diff --git a/bindings/rust/tachyon/Cargo.toml b/bindings/rust/tachyon/Cargo.toml index ae0eb96..1ce5e8e 100644 --- a/bindings/rust/tachyon/Cargo.toml +++ b/bindings/rust/tachyon/Cargo.toml @@ -7,13 +7,19 @@ license = "Apache-2.0" repository = "https://github.com/riyaneel/tachyon" readme = "README.md" -[dependencies] +[lib] +crate-type = ["cdylib", "rlib"] + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] tachyon-sys = { version = "0.5.1", path = "../tachyon-sys" } +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2" + [profile.release] opt-level = 3 codegen-units = 1 lto = "fat" [profile.release.build-override] -opt-level = 3 \ No newline at end of file +opt-level = 3 diff --git a/bindings/rust/tachyon/src/error.rs b/bindings/rust/tachyon/src/error.rs index 80339cf..1a5d140 100644 --- a/bindings/rust/tachyon/src/error.rs +++ b/bindings/rust/tachyon/src/error.rs @@ -1,3 +1,4 @@ +#[cfg(not(target_arch = "wasm32"))] use tachyon_sys::{ tachyon_error_t_TACHYON_ERR_ABI_MISMATCH as ERR_ABI_MISMATCH, tachyon_error_t_TACHYON_ERR_CHMOD as ERR_CHMOD, tachyon_error_t_TACHYON_ERR_EMPTY as ERR_EMPTY, @@ -61,6 +62,7 @@ impl std::fmt::Display for TachyonError { impl std::error::Error for TachyonError {} +#[cfg(not(target_arch = "wasm32"))] pub(crate) fn from_raw(code: u32) -> Result<(), TachyonError> { match code { c if c == SUCCESS => Ok(()), diff --git a/bindings/rust/tachyon/src/lib.rs b/bindings/rust/tachyon/src/lib.rs index e713c6a..dc45fe3 100644 --- a/bindings/rust/tachyon/src/lib.rs +++ b/bindings/rust/tachyon/src/lib.rs @@ -1,12 +1,20 @@ +#[cfg(not(target_arch = "wasm32"))] mod bus; mod error; +#[cfg(not(target_arch = "wasm32"))] mod rpc; mod type_id; +#[cfg(target_arch = "wasm32")] +mod wasm; +#[cfg(not(target_arch = "wasm32"))] pub use bus::{BatchIter, Bus, RxBatchGuard, RxGuard, RxMsgView, TxGuard}; pub use error::TachyonError; +#[cfg(not(target_arch = "wasm32"))] pub use rpc::{RpcBus, RpcRxGuard, RpcTxGuard}; pub use type_id::{make_type_id, msg_type, route_id}; +#[cfg(target_arch = "wasm32")] +pub use wasm::WasmBus; #[cfg(test)] mod tests { diff --git a/bindings/rust/tachyon/src/wasm.rs b/bindings/rust/tachyon/src/wasm.rs new file mode 100644 index 0000000..6989845 --- /dev/null +++ b/bindings/rust/tachyon/src/wasm.rs @@ -0,0 +1,293 @@ +use wasm_bindgen::prelude::*; + +const MSG_ALIGNMENT: usize = 64; +const HEADER_SIZE: usize = MSG_ALIGNMENT; +const ALIGN_MASK: usize = MSG_ALIGNMENT - 1; +const SKIP_MARKER: u32 = 0xFFFF_FFFF; +const BATCH_SIZE: u32 = 32; + +#[inline] +fn align_message_size(payload_size: usize) -> usize { + (HEADER_SIZE + payload_size + ALIGN_MASK) & !ALIGN_MASK +} + +#[inline] +fn js_error(message: &str) -> JsValue { + JsValue::from_str(message) +} + +/// Browser-local Tachyon SPSC ring backed by WebAssembly linear memory. +/// +/// This keeps the Tachyon message wire shape (`size`, `type_id`, +/// `reserved_size`, 64-byte alignment, and skip marker) while replacing the +/// native POSIX shared-memory transport with a WASM memory arena that page +/// JavaScript can access through `WebAssembly.Memory`. +#[wasm_bindgen] +pub struct WasmBus { + arena: Box<[u8]>, + capacity: usize, + mask: usize, + head: usize, + published_head: usize, + tail: usize, + pending_tx: u32, + tx_reserved_size: usize, + pre_acquire_head: usize, + rx_payload_offset: usize, + rx_reserved_size: usize, + rx_actual_size: usize, + rx_type_id: u32, + fatal: bool, +} + +#[wasm_bindgen] +impl WasmBus { + #[wasm_bindgen(constructor)] + pub fn new(capacity: u32) -> Result { + let capacity = capacity as usize; + if capacity < MSG_ALIGNMENT || !capacity.is_power_of_two() { + return Err(js_error( + "WasmBus capacity must be a power of two and at least 64 bytes", + )); + } + + Ok(WasmBus { + arena: vec![0; capacity].into_boxed_slice(), + capacity, + mask: capacity - 1, + head: 0, + published_head: 0, + tail: 0, + pending_tx: 0, + tx_reserved_size: 0, + pre_acquire_head: 0, + rx_payload_offset: 0, + rx_reserved_size: 0, + rx_actual_size: 0, + rx_type_id: 0, + fatal: false, + }) + } + + #[wasm_bindgen(js_name = isFatal)] + pub fn is_fatal(&self) -> bool { + self.fatal + } + + /// Copy-based send. Prefer `acquireTx` + direct JS view writes for hot paths. + pub fn send(&mut self, data: &[u8], type_id: u32) -> Result<(), JsValue> { + let payload_offset = self.acquire_tx_offset(data.len())?; + self.arena[payload_offset..payload_offset + data.len()].copy_from_slice(data); + self.commit_tx(data.len() as u32, type_id) + } + + /// Reserve a TX slot and return a pointer to its payload bytes in WASM memory. + /// + /// JavaScript can create a zero-copy view with: + /// `new Uint8Array(wasm.memory.buffer, ptr, maxSize)`. + #[wasm_bindgen(js_name = acquireTx)] + pub fn acquire_tx(&mut self, max_size: u32) -> Result { + let payload_offset = self.acquire_tx_offset(max_size as usize)?; + Ok((self.arena.as_ptr() as usize + payload_offset) as u32) + } + + #[wasm_bindgen(js_name = commitTx)] + pub fn commit_tx(&mut self, actual_size: u32, type_id: u32) -> Result<(), JsValue> { + self.commit_tx_inner(actual_size as usize, type_id, true) + } + + #[wasm_bindgen(js_name = commitTxUnflushed)] + pub fn commit_tx_unflushed(&mut self, actual_size: u32, type_id: u32) -> Result<(), JsValue> { + self.commit_tx_inner(actual_size as usize, type_id, false) + } + + #[wasm_bindgen(js_name = rollbackTx)] + pub fn rollback_tx(&mut self) -> Result<(), JsValue> { + if self.tx_reserved_size == 0 { + return Err(js_error("no pending TX slot to roll back")); + } + self.head = self.pre_acquire_head; + self.tx_reserved_size = 0; + Ok(()) + } + + /// Publish pending unflushed TX messages. + pub fn flush(&mut self) { + self.published_head = self.head; + self.pending_tx = 0; + } + + /// Acquire the next RX message, if one is visible. + /// + /// When this returns `true`, read `rxPtr`, `rxSize`, and `rxTypeId`, then + /// call `commitRx` when done. + #[wasm_bindgen(js_name = acquireRx)] + pub fn acquire_rx(&mut self) -> Result { + if self.fatal { + return Err(js_error("WasmBus is in fatal state")); + } + + if self.rx_reserved_size != 0 { + return Ok(true); + } + + if self.published_head <= self.tail { + return Ok(false); + } + + let mut physical_idx = self.tail & self.mask; + if self.capacity - physical_idx < 12 { + self.fatal = true; + return Err(js_error("corrupt Tachyon ring: truncated message header")); + } + + let mut size = self.read_u32(physical_idx); + let mut type_id = self.read_u32(physical_idx + 4); + let mut reserved_size = self.read_u32(physical_idx + 8) as usize; + + if size == SKIP_MARKER { + let space_until_end = self.capacity - physical_idx; + self.tail += space_until_end; + physical_idx = 0; + + if self.published_head <= self.tail { + return Ok(false); + } + + size = self.read_u32(0); + type_id = self.read_u32(4); + reserved_size = self.read_u32(8) as usize; + } + + let actual_size = size as usize; + if reserved_size < HEADER_SIZE + || reserved_size > self.capacity + || (reserved_size & ALIGN_MASK) != 0 + || actual_size > reserved_size - HEADER_SIZE + { + self.fatal = true; + return Err(js_error("corrupt Tachyon ring: invalid message metadata")); + } + + self.rx_payload_offset = physical_idx + HEADER_SIZE; + self.rx_reserved_size = reserved_size; + self.rx_actual_size = actual_size; + self.rx_type_id = type_id; + + Ok(true) + } + + #[wasm_bindgen(js_name = rxPtr)] + pub fn rx_ptr(&self) -> u32 { + if self.rx_reserved_size == 0 { + return 0; + } + (self.arena.as_ptr() as usize + self.rx_payload_offset) as u32 + } + + #[wasm_bindgen(js_name = rxSize)] + pub fn rx_size(&self) -> u32 { + self.rx_actual_size as u32 + } + + #[wasm_bindgen(js_name = rxTypeId)] + pub fn rx_type_id(&self) -> u32 { + self.rx_type_id + } + + #[wasm_bindgen(js_name = commitRx)] + pub fn commit_rx(&mut self) -> Result<(), JsValue> { + if self.rx_reserved_size == 0 { + return Err(js_error("no pending RX slot to commit")); + } + + self.tail += self.rx_reserved_size; + self.rx_payload_offset = 0; + self.rx_reserved_size = 0; + self.rx_actual_size = 0; + self.rx_type_id = 0; + + Ok(()) + } +} + +impl WasmBus { + fn acquire_tx_offset(&mut self, max_size: usize) -> Result { + if self.fatal { + return Err(js_error("WasmBus is in fatal state")); + } + if self.tx_reserved_size != 0 { + return Err(js_error("a TX slot is already pending")); + } + + let aligned_size = align_message_size(max_size); + if aligned_size > self.capacity || max_size > (SKIP_MARKER as usize) - HEADER_SIZE { + return Err(js_error("message is larger than the Tachyon ring capacity")); + } + + let mut physical_idx = self.head & self.mask; + let space_until_end = self.capacity - physical_idx; + let need_skip = space_until_end < aligned_size; + let required_space = aligned_size + if need_skip { space_until_end } else { 0 }; + + if self.head - self.tail + required_space > self.capacity { + return Err(js_error("Tachyon ring buffer is full")); + } + + if need_skip { + self.write_u32(physical_idx, SKIP_MARKER); + self.write_u32(physical_idx + 4, 0); + self.write_u32(physical_idx + 8, 0); + self.head += space_until_end; + physical_idx = 0; + } + + self.pre_acquire_head = self.head; + self.tx_reserved_size = aligned_size; + + Ok(physical_idx + HEADER_SIZE) + } + + fn commit_tx_inner( + &mut self, + actual_size: usize, + type_id: u32, + flush: bool, + ) -> Result<(), JsValue> { + if self.tx_reserved_size == 0 { + return Err(js_error("no pending TX slot to commit")); + } + if actual_size > self.tx_reserved_size - HEADER_SIZE { + self.tx_reserved_size = 0; + return Err(js_error("actual TX size exceeds reserved slot size")); + } + + let physical_idx = self.head & self.mask; + self.write_u32(physical_idx, actual_size as u32); + self.write_u32(physical_idx + 4, type_id); + self.write_u32(physical_idx + 8, self.tx_reserved_size as u32); + self.head += self.tx_reserved_size; + self.tx_reserved_size = 0; + self.pending_tx += 1; + + if flush || self.pending_tx >= BATCH_SIZE { + self.flush(); + } + + Ok(()) + } + + fn read_u32(&self, offset: usize) -> u32 { + unsafe { + let ptr = self.arena.as_ptr().add(offset) as *const u32; + u32::from_le(ptr.read_unaligned()) + } + } + + fn write_u32(&mut self, offset: usize, value: u32) { + unsafe { + let ptr = self.arena.as_mut_ptr().add(offset) as *mut u32; + ptr.write_unaligned(value.to_le()); + } + } +} diff --git a/ci/README.md b/ci/README.md index fa283dd..6745f61 100644 --- a/ci/README.md +++ b/ci/README.md @@ -9,7 +9,7 @@ Infrastructure scripts for building, testing, releasing, and measuring Tachyon. | `release/` | Changelog generator | Release workflow on `v*` tags | | `build_msan_libcxx.sh` | Build MSan instrumented libcxx | Development or CI build jobs before compilation | | `install_llvm.sh` | Install llvm toolchain | Development cfg or CI build jobs before compilation | -| `vendor.sh` | Core C++ vendoring per binding | CI build jobs before compilation | +| `setup/` | Toolchains installation and core vendoring | Development or CI build jobs before compilation | ## Quick reference @@ -29,12 +29,15 @@ python3 ci/fuzz/gen_seeds.py # Generate CHANGELOG section for a release tag python3 ci/release/gen_changelog.py v0.2.0 -# Vendor C++ core into a language binding -bash ci/vendor.sh # targets: go, java, rust - # Build MSan instrumented libcxx -bash ci/build_msan_libcxx.sh [version] # default: 21 +bash ci/setup/build_msan_libcxx.sh [version] # default: 21 # Install llvm toolchain -bash ci/install_llvm.sh [version] # default: 21 +bash ci/setup/install_llvm.sh [version] # default: 21 + +# Install Emscripten SDK (WASM toolchain) +bash ci/setup/install_emsdk.sh [version] # default: latest + +# Vendor C++ core into a language binding +bash ci/setup/vendor.sh # targets: go, java, rust ``` diff --git a/ci/release/bump_version.py b/ci/release/bump_version.py index 4baee14..aa6002c 100644 --- a/ci/release/bump_version.py +++ b/ci/release/bump_version.py @@ -70,7 +70,7 @@ def build_entries(old: str, new: str) -> list[Entry]: ), # Node.js Entry( - "bindings/node/package.json", + "bindings/js/package.json", rf'("version"\s*:\s*"){ov}(")', rf"\g<1>{new}\g<2>", ), diff --git a/ci/build_msan_libcxx.sh b/ci/setup/build_msan_libcxx.sh similarity index 96% rename from ci/build_msan_libcxx.sh rename to ci/setup/build_msan_libcxx.sh index f7d3757..061191b 100644 --- a/ci/build_msan_libcxx.sh +++ b/ci/setup/build_msan_libcxx.sh @@ -4,7 +4,7 @@ set -euo pipefail LLVM_VERSION="${1:-21}" -PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" INSTALL_DIR="${PROJECT_ROOT}/.msan_toolchain/llvm-${LLVM_VERSION}" if [[ -d "${INSTALL_DIR}/include/c++/v1" ]]; then diff --git a/ci/setup/install_emsdk.sh b/ci/setup/install_emsdk.sh new file mode 100755 index 0000000..02b43a7 --- /dev/null +++ b/ci/setup/install_emsdk.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -euo pipefail + +EMSDK_VERSION="${1:-latest}" +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +EMSDK_DIR="${PROJECT_ROOT}/.emsdk" + +echo "[emsdk] Preparing Emscripten SDK (${EMSDK_VERSION}) in ${EMSDK_DIR}..." + +if [[ ! -d "${EMSDK_DIR}" ]]; then + echo "[emsdk] Cloning emsdk repository..." + git clone https://github.com/emscripten-core/emsdk.git "${EMSDK_DIR}" +else + echo "[emsdk] Directory already exists. Pulling latest updates..." + cd "${EMSDK_DIR}" + git pull origin main +fi + +cd "${EMSDK_DIR}" + +echo "[emsdk] Installing version: ${EMSDK_VERSION}..." +./emsdk install "${EMSDK_VERSION}" + +echo "[emsdk] Activating version: ${EMSDK_VERSION}..." +./emsdk activate "${EMSDK_VERSION}" + +echo "" +echo "[emsdk] Emscripten SDK successfully installed." +echo "[emsdk] To activate the toolchain in your current shell, run:" +echo "source ${EMSDK_DIR}/emsdk_env.sh" diff --git a/ci/install_llvm.sh b/ci/setup/install_llvm.sh similarity index 100% rename from ci/install_llvm.sh rename to ci/setup/install_llvm.sh diff --git a/ci/vendor.sh b/ci/setup/vendor.sh similarity index 88% rename from ci/vendor.sh rename to ci/setup/vendor.sh index d6edae4..1436a61 100644 --- a/ci/vendor.sh +++ b/ci/setup/vendor.sh @@ -10,7 +10,7 @@ fi TARGET="$1" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" case "${TARGET}" in "c#") @@ -23,7 +23,7 @@ case "${TARGET}" in DEST="${ROOT_DIR}/bindings/java/src/native/_core_local" ;; "node") - DEST="${ROOT_DIR}/bindings/node/src/native/_core_local" + DEST="${ROOT_DIR}/bindings/js/src/native/_core_local" ;; "rust") DEST="${ROOT_DIR}/bindings/rust/tachyon-sys/vendor/core" diff --git a/cmake/CMakePresets.json b/cmake/CMakePresets.json index 02119b9..48da074 100644 --- a/cmake/CMakePresets.json +++ b/cmake/CMakePresets.json @@ -60,6 +60,20 @@ "TACHYON_SANITIZER": "none" } }, + { + "name": "emscripten-base", + "hidden": true, + "inherits": "base", + "toolchainFile": "${sourceDir}/cmake/toolchains/emscripten.cmake", + "cacheVariables": { + "TACHYON_ENABLE_TESTS": "OFF", + "TACHYON_ENABLE_BENCH": "OFF", + "TACHYON_ENABLE_TOP": "OFF", + "TACHYON_ENABLE_FUZZING": "OFF", + "TACHYON_ENABLE_SECCOMP": "OFF", + "TACHYON_SANITIZER": "none" + } + }, { "name": "clang-release", "displayName": "Clang - Release", @@ -121,6 +135,22 @@ "CMAKE_BUILD_TYPE": "Debug" } }, + { + "name": "emscripten-release", + "displayName": "Emscripten - Release", + "inherits": "emscripten-base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release" + } + }, + { + "name": "emscripten-debug", + "displayName": "Emscripten - Debug", + "inherits": "emscripten-base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + } + }, { "name": "asan", "displayName": "ASan + UBSan - Clang", @@ -282,6 +312,16 @@ "configurePreset": "macos-debug", "jobs": 0 }, + { + "name": "emscripten-release", + "configurePreset": "emscripten-release", + "jobs": 0 + }, + { + "name": "emscripten-debug", + "configurePreset": "emscripten-debug", + "jobs": 0 + }, { "name": "asan", "configurePreset": "asan", diff --git a/cmake/deps.cmake b/cmake/deps.cmake index ed23f2d..812d84a 100644 --- a/cmake/deps.cmake +++ b/cmake/deps.cmake @@ -4,29 +4,31 @@ include(FetchContent) set(CMAKE_WARN_DEPRECATED OFF CACHE BOOL "" FORCE) # Google Test -FetchContent_Declare( - googletest - GIT_REPOSITORY https://github.com/google/googletest.git - GIT_TAG v1.17.0 - GIT_SHALLOW TRUE - GIT_PROGRESS FALSE - SYSTEM - OVERRIDE_FIND_PACKAGE -) +if (TACHYON_ENABLE_TESTS) + FetchContent_Declare( + googletest + GIT_REPOSITORY https://github.com/google/googletest.git + GIT_TAG v1.17.0 + GIT_SHALLOW TRUE + GIT_PROGRESS FALSE + SYSTEM + OVERRIDE_FIND_PACKAGE + ) -set(BUILD_MOCK OFF CACHE INTERNAL "") -set(INSTALL_GTEST OFF CACHE INTERNAL "") -set(gtest_force_shared_crt ON CACHE INTERNAL "") -FetchContent_MakeAvailable(googletest) + set(BUILD_MOCK OFF CACHE INTERNAL "") + set(INSTALL_GTEST OFF CACHE INTERNAL "") + set(gtest_force_shared_crt ON CACHE INTERNAL "") + FetchContent_MakeAvailable(googletest) -if (TACHYON_SANITIZER STREQUAL "msan" AND TARGET gtest) - target_compile_options(gtest PRIVATE ${TACHYON_DEBUG_FLAGS}) - target_compile_options(gtest_main PRIVATE ${TACHYON_DEBUG_FLAGS}) -endif () + if (TACHYON_SANITIZER STREQUAL "msan" AND TARGET gtest) + target_compile_options(gtest PRIVATE ${TACHYON_DEBUG_FLAGS}) + target_compile_options(gtest_main PRIVATE ${TACHYON_DEBUG_FLAGS}) + endif () -if (TARGET gtest) - target_compile_options(gtest PRIVATE -w) - target_compile_options(gtest_main PRIVATE -w) + if (TARGET gtest) + target_compile_options(gtest PRIVATE -w) + target_compile_options(gtest_main PRIVATE -w) + endif () endif () # Google Benchmark @@ -80,8 +82,12 @@ if (TACHYON_ENABLE_TOP) FetchContent_MakeAvailable(ftxui) endif () -message(STATUS "[deps] GoogleTest : v1.17.0 (FetchContent)") -message(STATUS "[deps] Benchmark : v1.9.5 (FetchContent)") +if (TACHYON_ENABLE_TESTS) + message(STATUS "[deps] GoogleTest : v1.17.0 (FetchContent)") +endif () +if (TACHYON_ENABLE_BENCH) + message(STATUS "[deps] Benchmark : v1.9.5 (FetchContent)") +endif () message(STATUS "[deps] DLPack : v1.3 (FetchContent)") message(STATUS "[deps] DLPack inc : ${TACHYON_DLPACK_INCLUDE_DIR}") if (TACHYON_ENABLE_TOP) diff --git a/cmake/modules/TachyonCompileOptions.cmake b/cmake/modules/TachyonCompileOptions.cmake index 8056385..d17d904 100644 --- a/cmake/modules/TachyonCompileOptions.cmake +++ b/cmake/modules/TachyonCompileOptions.cmake @@ -13,17 +13,19 @@ set(TACHYON_RELEASE_FLAGS -fno-exceptions -fno-rtti ) -if (NOT APPLE) +if (NOT APPLE AND NOT EMSCRIPTEN) list(APPEND TACHYON_RELEASE_FLAGS -fno-plt) endif () -if (NOT TACHYON_PORTABLE_BUILD) - list(APPEND TACHYON_RELEASE_FLAGS "-march=native" "-mtune=native") -else () - if (CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|amd64)$") - list(APPEND TACHYON_FLAGS "-march=x86-64-v3") - elseif (CMAKE_SYSTEM_PROCESSOR STREQUAL "aarch64") - list(APPEND TACHYON_FLAGS "-march=armv8-a") +if (NOT EMSCRIPTEN) + if (NOT TACHYON_PORTABLE_BUILD) + list(APPEND TACHYON_RELEASE_FLAGS "-march=native" "-mtune=native") + else () + if (CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|amd64)$") + list(APPEND TACHYON_FLAGS "-march=x86-64-v3") + elseif (CMAKE_SYSTEM_PROCESSOR STREQUAL "aarch64") + list(APPEND TACHYON_FLAGS "-march=armv8-a") + endif () endif () endif () diff --git a/cmake/modules/TachyonOptions.cmake b/cmake/modules/TachyonOptions.cmake index 3941ba1..ccd4340 100644 --- a/cmake/modules/TachyonOptions.cmake +++ b/cmake/modules/TachyonOptions.cmake @@ -14,6 +14,7 @@ set_property(CACHE TACHYON_SANITIZER PROPERTY STRINGS none asan_ubsan tsan msan) # Build option(TACHYON_PORTABLE_BUILD "Disable -march=native for redistributable binaries" OFF) +option(TACHYON_ENABLE_TESTS "Enable tests" ON) option(TACHYON_ENABLE_BENCH "Build benchmarks" ON) option(TACHYON_ENABLE_FUZZING "Build libFuzzer harnesses (Clang only)" OFF) option(TACHYON_ENABLE_TOP "Build tachyon-top CLI" OFF) diff --git a/cmake/toolchains/emscripten.cmake b/cmake/toolchains/emscripten.cmake new file mode 100644 index 0000000..e29dffe --- /dev/null +++ b/cmake/toolchains/emscripten.cmake @@ -0,0 +1,18 @@ +if (DEFINED ENV{EMSDK}) + set(EMSDK_ROOT "$ENV{EMSDK}") +else () + set(EMSDK_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../.emsdk") +endif () + +set(EMSCRIPTEN_TOOLCHAIN "${EMSDK_ROOT}/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake") + +if (NOT EXISTS "${EMSCRIPTEN_TOOLCHAIN}") + message(FATAL_ERROR + "[toolchain/emscripten] Toolchain not found at: ${EMSCRIPTEN_TOOLCHAIN}\n" + "Run: bash ci/setup/install_emsdk.sh" + ) +endif () + +include("${EMSCRIPTEN_TOOLCHAIN}") + +message(STATUS "[toolchain/emscripten] Loaded toolchain from: ${EMSCRIPTEN_TOOLCHAIN}") diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 03216e1..8887666 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -1,4 +1,4 @@ -add_library(tachyon SHARED +set(TACHYON_CORE_SRCS src/arena.cpp src/shm.cpp src/star.cpp @@ -8,10 +8,16 @@ add_library(tachyon SHARED src/transport_uds.cpp ) +if (NOT EMSCRIPTEN) + add_library(tachyon SHARED ${TACHYON_CORE_SRCS}) +else () + add_library(tachyon STATIC ${TACHYON_CORE_SRCS}) +endif () + tachyon_set_compile_options(tachyon) -target_link_options(tachyon PRIVATE - ${TACHYON_LINK_OPTIONS} -) +if (NOT EMSCRIPTEN) + target_link_options(tachyon PRIVATE ${TACHYON_LINK_OPTIONS}) +endif () set_target_properties(tachyon PROPERTIES CXX_VISIBILITY_PRESET hidden @@ -20,10 +26,21 @@ set_target_properties(tachyon PROPERTIES OUTPUT_NAME "tachyon" ) -if (APPLE) - set_target_properties(tachyon PROPERTIES INSTALL_RPATH "@loader_path") -else () - set_target_properties(tachyon PROPERTIES INSTALL_RPATH "$ORIGIN") +if (NOT EMSCRIPTEN) + if (APPLE) + set_target_properties(tachyon PROPERTIES INSTALL_RPATH "@loader_path") + else () + set_target_properties(tachyon PROPERTIES INSTALL_RPATH "$ORIGIN") + endif () + + if (UNIX AND NOT APPLE) + target_link_libraries(tachyon PRIVATE rt) + target_link_options(tachyon PRIVATE + $<$>:-Wl,-z,defs> + "-Wl,-z,now" + "-Wl,-z,relro" + ) + endif () endif () target_include_directories(tachyon @@ -31,13 +48,30 @@ target_include_directories(tachyon PRIVATE src ) -if (UNIX AND NOT APPLE) - target_link_libraries(tachyon PRIVATE rt) - target_link_options(tachyon PRIVATE - $<$>:-Wl,-z,defs> - "-Wl,-z,now" - "-Wl,-z,relro" +set(TACHYON_LIBRARY tachyon PARENT_SCOPE) + +if (EMSCRIPTEN) + file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/wasm_entry.cpp "") + add_executable(tachyon_wasm ${CMAKE_CURRENT_BINARY_DIR}/wasm_entry.cpp) + + target_link_libraries(tachyon_wasm PRIVATE -Wl,--whole-archive tachyon -Wl,--no-whole-archive) + target_link_options(tachyon_wasm PRIVATE + "-sALLOW_MEMORY_GROWTH=1" + "-sMODULARIZE=1" + "-sEXPORT_NAME=TachyonCore" + "-sSTRICT=1" + "-sNO_EXIT_RUNTIME=1" + "-sWASM_BIGINT=1" + "-sFILESYSTEM=0" + "--no-entry" + "--minify=0" + "--profiling-funcs" + "-sEXPORTED_FUNCTIONS=_malloc,_free" + "-sEXPORTED_RUNTIME_METHODS=ccall,cwrap,getValue,setValue,UTF8ToString" ) -endif () -set(TACHYON_LIBRARY tachyon PARENT_SCOPE) + set_target_properties(tachyon_wasm PROPERTIES + OUTPUT_NAME "tachyon" + SUFFIX ".js" + ) +endif () diff --git a/core/include/tachyon.h b/core/include/tachyon.h index 709b04a..66dee7c 100644 --- a/core/include/tachyon.h +++ b/core/include/tachyon.h @@ -4,6 +4,16 @@ #include #include +#if defined(__EMSCRIPTEN__) +#include + +#define TACHYON_ABI EMSCRIPTEN_KEEPALIVE +#elif defined(_WIN32) || defined(__CYGWIN__) // #if defined(__EMSCRIPTEN__) +#define TACHYON_ABI __declspec(dllexport) +#else // #elif defined(_WIN32) || defined(__CYGWIN__) +#define TACHYON_ABI __attribute__((visibility("default"))) +#endif // #elif defined(_WIN32) || defined(__CYGWIN__) #else + #ifdef __cplusplus #define TACHYON_NOEXCEPT noexcept #define TACHYON_ALIGNAS(n) alignas(n) @@ -13,12 +23,6 @@ extern "C" { #define TACHYON_ALIGNAS(n) _Alignas(n) #endif // #ifdef __cplusplus #else -#if defined(_WIN32) || defined(__CYGWIN__) -#define TACHYON_ABI __declspec(dllexport) -#else // #if defined(_WIN32) || defined(__CYGWIN__) -#define TACHYON_ABI __attribute__((visibility("default"))) -#endif // #if defined(_WIN32) || defined(__CYGWIN__) #else - #define TACHYON_TYPE_ID(route, type) (((uint32_t)(route) << 16) | (uint32_t)(type)) #define TACHYON_ROUTE_ID(type_id) ((uint16_t)((type_id) >> 16)) #define TACHYON_MSG_TYPE(type_id) ((uint16_t)((type_id) & 0xFFFF)) @@ -113,6 +117,8 @@ TACHYON_ABI void tachyon_flush(tachyon_bus_t *bus) TACHYON_NOEXCEPT; TACHYON_ABI tachyon_state_t tachyon_get_state(const tachyon_bus_t *bus) TACHYON_NOEXCEPT; +TACHYON_ABI void *tachyon_bus_get_shm_ptr(const tachyon_bus_t *bus) TACHYON_NOEXCEPT; + TACHYON_ABI tachyon_error_t tachyon_rpc_listen( const char *socket_path, size_t cap_fwd, size_t cap_rev, tachyon_rpc_bus_t **out_rpc ) TACHYON_NOEXCEPT; diff --git a/core/src/arena.cpp b/core/src/arena.cpp index cd2d491..dcd8b72 100644 --- a/core/src/arena.cpp +++ b/core/src/arena.cpp @@ -8,6 +8,10 @@ #include #endif // #if defined(__linux__) +#if !defined(__linux__) && !defined(__APPLE__) && !defined(__EMSCRIPTEN__) +#include +#endif // #if !defined(__linux__) && !defined(__APPLE__) && !defined(__EMSCRIPTEN__) + #include #if defined(__has_feature) @@ -30,10 +34,10 @@ extern "C" void __tsan_release(void *addr); namespace tachyon::core { namespace { - constexpr uint32_t SKIP_MARKER = 0xFFFFFFFF; - constexpr uint32_t WATCHDOG_TIMEOUT_US = 200'000; - constexpr uint32_t HDR_SIZE = TACHYON_MSG_ALIGNMENT; - constexpr uint32_t ALIGN_MASK = TACHYON_MSG_ALIGNMENT - 1U; + constexpr uint32_t SKIP_MARKER = 0xFFFFFFFF; + [[maybe_unused]] constexpr uint32_t WATCHDOG_TIMEOUT_US = 200'000; + constexpr uint32_t HDR_SIZE = TACHYON_MSG_ALIGNMENT; + constexpr uint32_t ALIGN_MASK = TACHYON_MSG_ALIGNMENT - 1U; static_assert( sizeof(MessageHeader) == TACHYON_MSG_ALIGNMENT, @@ -65,7 +69,10 @@ namespace tachyon::core { #endif // #if defined(__APPLE__) inline WaitResult platform_wait(std::atomic *addr) noexcept { -#if defined(__linux__) +#if defined(__EMSCRIPTEN__) + (void)addr; + return WaitResult::Timeout; +#elif defined(__linux__) struct timespec ts = { .tv_sec = static_cast(WATCHDOG_TIMEOUT_US / 1'000'000), .tv_nsec = static_cast((WATCHDOG_TIMEOUT_US % 1'000'000) * 1000) @@ -88,8 +95,6 @@ namespace tachyon::core { TACHYON_TSAN_ACQUIRE(addr); return WaitResult::Woken; #else -#include - std::this_thread::yield(); return WaitResult::Woken; #endif @@ -97,7 +102,8 @@ namespace tachyon::core { inline void platform_wake(std::atomic *addr) noexcept { TACHYON_TSAN_RELEASE(addr); -#if defined(__linux__) +#if defined(__EMSCRIPTEN__) +#elif defined(__linux__) syscall(SYS_futex, addr, FUTEX_WAKE, 1, nullptr, nullptr, 0); #elif defined(__APPLE__) __ulock_wake(UL_COMPARE_AND_WAIT | ULF_WAKE_ALL, addr, 0); diff --git a/core/src/shm.cpp b/core/src/shm.cpp index 53105e0..2eaa05b 100644 --- a/core/src/shm.cpp +++ b/core/src/shm.cpp @@ -6,6 +6,10 @@ #include #include +#if defined(__EMSCRIPTEN__) +#include +#endif // #if defined(__EMSCRIPTEN__) + #include #ifndef MFD_ALLOW_SEALING @@ -40,6 +44,19 @@ namespace tachyon::core { std::string path(name); +#if defined(__EMSCRIPTEN__) + if (size > static_cast(INT32_MAX)) [[unlikely]] { + return std::unexpected(ShmError::InvalidSize); + } + + void *ptr = std::aligned_alloc(64, size); + if (!ptr) [[unlikely]] { + return std::unexpected(ShmError::MapFailed); + } + + return SharedMemory(ptr, size, std::move(path), -1, true); +#else // #if defined(__EMSCRIPTEN__) + #if defined(__linux__) const int fd = ::memfd_create(path.c_str(), MFD_ALLOW_SEALING | MFD_CLOEXEC); if (fd == -1) [[unlikely]] @@ -87,14 +104,22 @@ namespace tachyon::core { #if defined(__linux__) ::madvise(ptr, size, MADV_DONTFORK); // CoW safety -#endif // #if defined(__linux__) +#endif // #if defined(__linux__) return SharedMemory(ptr, size, std::move(path), fd, true); +#endif // #if defined(__EMSCRIPTEN__) #else } auto SharedMemory::join(const int fd, const size_t size) -> std::expected { - if (fd == -1 || size == 0) [[unlikely]] +#if defined(__EMSCRIPTEN__) + (void)fd; + (void)size; + return std::unexpected(ShmError::OpenFailed); + +#else + if (fd == -1 || size == 0) [[unlikely]] { return std::unexpected(ShmError::OpenFailed); + } int flags = MAP_SHARED; #if defined(__linux__) @@ -108,19 +133,30 @@ namespace tachyon::core { #if defined(__linux__) ::madvise(ptr, size, MADV_DONTFORK); // CoW safety -#endif // #if defined(__linux__) +#endif // #if defined(__linux__) return SharedMemory(ptr, size, "", fd, false); +#endif } void SharedMemory::release() noexcept { +#if defined(__EMSCRIPTEN__) + if (ptr_ && owner_) [[likely]] { + std::free(ptr_); + ptr_ = nullptr; + } + +#else // #if defined(__EMSCRIPTEN__) if (ptr_ && ptr_ != MAP_FAILED) [[likely]] { ::munmap(ptr_, size_); ptr_ = nullptr; } + if (fd_ != -1) [[likely]] { ::close(fd_); fd_ = -1; } + +#endif // #if defined(__EMSCRIPTEN__) #else } } // namespace tachyon::core diff --git a/core/src/tachyon_c.cpp b/core/src/tachyon_c.cpp index 4906d00..4c5b0b5 100644 --- a/core/src/tachyon_c.cpp +++ b/core/src/tachyon_c.cpp @@ -34,6 +34,12 @@ tachyon_bus_listen(const char *socket_path, const size_t capacity, tachyon_bus_t if (!socket_path || !out_bus || capacity == 0) return TACHYON_ERR_INVALID_SZ; +#if defined(__EMSCRIPTEN__) + if (capacity > static_cast(INT32_MAX)) [[unlikely]] { + return TACHYON_ERR_INVALID_SZ; + } +#endif // #if defined(__EMSCRIPTEN__) + const size_t required_shm_size = sizeof(MemoryLayout) + capacity; auto shm_res = SharedMemory::create(socket_path, required_shm_size); if (!shm_res.has_value()) @@ -281,4 +287,12 @@ tachyon_state_t tachyon_get_state(const tachyon_bus_t *bus) TACHYON_NOEXCEPT { return TACHYON_STATE_UNKNOWN; return static_cast(bus->arena.get_state()); } + +void *tachyon_bus_get_shm_ptr(const tachyon_bus_t *bus) TACHYON_NOEXCEPT { + if (!bus) [[unlikely]] { + return nullptr; + } + + return bus->shm.get_ptr(); +} } // extern "C" diff --git a/core/src/transport_uds.cpp b/core/src/transport_uds.cpp index b235b16..27fe7f1 100644 --- a/core/src/transport_uds.cpp +++ b/core/src/transport_uds.cpp @@ -1,9 +1,12 @@ #include #include + +#if !defined(__EMSCRIPTEN__) #include #include #include #include +#endif // #if !defined(__EMSCRIPTEN__) #include @@ -11,6 +14,12 @@ namespace tachyon::core { auto uds_export_shm(const std::string_view socket_path, const int shm_fd, const TachyonHandshake &handshake) noexcept -> std::expected { +#if defined(__EMSCRIPTEN__) + (void)socket_path; + (void)shm_fd; + (void)handshake; + return {}; +#else const int sock = ::socket(AF_UNIX, SOCK_STREAM, 0); if (sock < 0) return std::unexpected(TransportError::SocketCreation); @@ -103,9 +112,14 @@ namespace tachyon::core { ::unlink(addr.sun_path); return {}; +#endif } auto uds_import_shm(const std::string_view socket_path) noexcept -> std::expected { +#if defined(__EMSCRIPTEN__) + (void)socket_path; + return std::unexpected(TransportError::SystemError); +#else const int sock = ::socket(AF_UNIX, SOCK_STREAM, 0); if (sock < 0) return std::unexpected(TransportError::SocketCreation); @@ -152,11 +166,19 @@ namespace tachyon::core { } return ImportedShm{received_fd, hs}; +#endif } auto uds_export_shm_rpc( const std::string_view socket_path, const int fd_fwd, const int fd_rev, const TachyonHandshake &handshake ) noexcept -> std::expected { +#if defined(__EMSCRIPTEN__) + (void)socket_path; + (void)fd_fwd; + (void)fd_rev; + (void)handshake; + return {}; +#else const int sock = ::socket(AF_UNIX, SOCK_STREAM, 0); if (sock < 0) return std::unexpected(TransportError::SocketCreation); @@ -248,10 +270,15 @@ namespace tachyon::core { ::unlink(addr.sun_path); return {}; +#endif } auto uds_import_shm_rpc(const std::string_view socket_path) noexcept -> std::expected { +#if defined(__EMSCRIPTEN__) + (void)socket_path; + return std::unexpected(TransportError::SystemError); +#else const int sock = ::socket(AF_UNIX, SOCK_STREAM, 0); if (sock < 0) return std::unexpected(TransportError::SocketCreation); @@ -304,5 +331,6 @@ namespace tachyon::core { } return RpcImportedShm{fds[0], fds[1], hs}; +#endif } } // namespace tachyon::core diff --git a/docs/README.md b/docs/README.md index bc1ab19..9b05b1c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,2 +1,4 @@ # Documentations +- [Browser WASM example](../examples/browser_wasm/README.md) - in-page JavaScript and Rust WASM communication through + Tachyon rings. diff --git a/examples/README.md b/examples/README.md index 65afe60..0b8e48c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,2 +1,4 @@ # Examples +- [browser_wasm](./browser_wasm) - page JavaScript and Rust WASM exchange binary messages through browser-local + Tachyon rings. diff --git a/examples/browser_wasm/README.md b/examples/browser_wasm/README.md new file mode 100644 index 0000000..f627e7c --- /dev/null +++ b/examples/browser_wasm/README.md @@ -0,0 +1,37 @@ +# Tachyon Browser WASM Example + +This example runs Tachyon inside a single browser page. Page JavaScript writes +binary payloads directly into a `WasmBus` TX slot in WebAssembly memory, a small +Rust WASM function polls the inbound ring and replies on a second ring, and +JavaScript reads the reply from WASM memory. + +The Rust echo function lives in `examples/browser_wasm/rust`; the reusable +browser transport stays in the Tachyon bindings. + +The browser build does not use POSIX shared memory or UNIX sockets. Those APIs +are unavailable in browsers, so the WASM path is a page-local Tachyon ring with +the same 64-byte message header, alignment, `type_id`, and skip-marker rules. + +## Run + +```bash +cd examples/browser_wasm +npm install +npm run build:wasm +npm run dev +``` + +Open the Vite URL, then use **Send To Rust** or **Run Browser RTT Bench**. + +## Native Comparison + +From the same directory: + +```bash +npm run bench:native +``` + +The browser benchmark reports batch-averaged round-trip time because +`performance.now()` is too coarse for individual sub-microsecond samples in many +browsers. Compare the browser mean/p50 against the native Rust `bench_ipc` +output for a practical JS/WASM overhead view. diff --git a/examples/browser_wasm/index.html b/examples/browser_wasm/index.html new file mode 100644 index 0000000..4b46e22 --- /dev/null +++ b/examples/browser_wasm/index.html @@ -0,0 +1,61 @@ + + + + + + Tachyon WASM Browser Bus + + + +
+
+
+

Tachyon WASM Browser Bus

+

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

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

Messages

+

+      
+ +
+

Browser RTT Profile

+ + + + +
Not run yet
+
+
+ + + diff --git a/examples/browser_wasm/main.js b/examples/browser_wasm/main.js new file mode 100644 index 0000000..debff67 --- /dev/null +++ b/examples/browser_wasm/main.js @@ -0,0 +1,205 @@ +import init, { + WasmBus, + tachyon_browser_echo_once, +} from "./pkg/tachyon_browser_wasm_example.js"; + +const CAPACITY = 1 << 20; +const BATCH_SIZE = 4096; + +const els = { + status: document.querySelector("#wasm-status"), + capacity: document.querySelector("#capacity"), + lastReply: document.querySelector("#last-reply"), + value: document.querySelector("#value"), + send: document.querySelector("#send"), + iterations: document.querySelector("#iterations"), + bench: document.querySelector("#bench"), + log: document.querySelector("#log"), + benchTable: document.querySelector("#bench-table"), +}; + +let wasm; +let memoryView; +let jsToRust; +let rustToJs; +let typeCounter; + +function makeTypeId(route, msgType) { + return ((route & 0xffff) << 16) | (msgType & 0xffff); +} + +function routeId(typeId) { + return (typeId >>> 16) & 0xffff; +} + +function msgType(typeId) { + return typeId & 0xffff; +} + +function memory() { + return wasm.memory; +} + +function appendLog(line) { + const time = new Date().toLocaleTimeString(); + els.log.textContent = `[${time}] ${line}\n${els.log.textContent}`; +} + +function writeU32ToBus(bus, value, typeId) { + const ptr = bus.acquireTx(4); + memoryView.setUint32(ptr, value >>> 0, true); + bus.commitTx(4, typeId); +} + +function readU32FromBus(bus) { + if (!bus.acquireRx()) return null; + const ptr = bus.rxPtr(); + const size = bus.rxSize(); + const typeId = bus.rxTypeId(); + const value = size === 4 ? memoryView.getUint32(ptr, true) : null; + bus.commitRx(); + return { value, size, typeId }; +} + +function pingRust(value) { + writeU32ToBus(jsToRust, value, typeCounter); + + if (!tachyon_browser_echo_once(jsToRust, rustToJs)) { + throw new Error("Rust WASM program did not receive the JS message"); + } + + const reply = readU32FromBus(rustToJs); + if (!reply) { + throw new Error("JS did not receive the Rust WASM reply"); + } + + return reply; +} + +function pingRustFast(value) { + let ptr = jsToRust.acquireTx(4); + memoryView.setUint32(ptr, value >>> 0, true); + jsToRust.commitTx(4, typeCounter); + if (!tachyon_browser_echo_once(jsToRust, rustToJs)) { + throw new Error("Rust WASM program did not receive the JS message"); + } + if (!rustToJs.acquireRx()) { + throw new Error("JS did not receive the Rust WASM reply"); + } + ptr = rustToJs.rxPtr(); + const replyValue = memoryView.getUint32(ptr, true); + rustToJs.commitRx(); + return replyValue; +} + +function percentile(sorted, pct) { + const idx = Math.min( + sorted.length - 1, + Math.floor((sorted.length - 1) * pct), + ); + return sorted[idx]; +} + +function formatNs(ns) { + if (ns >= 1000) return `${(ns / 1000).toFixed(2)} us`; + return `${ns.toFixed(1)} ns`; +} + +function setBenchRows(rows) { + els.benchTable.replaceChildren( + ...rows.map(([label, value]) => { + const tr = document.createElement("tr"); + const left = document.createElement("td"); + const right = document.createElement("td"); + left.textContent = label; + right.textContent = value; + tr.append(left, right); + return tr; + }), + ); +} + +async function runBench() { + const iterations = Math.max( + 1000, + Number.parseInt(els.iterations.value, 10) || 1000000, + ); + const warmup = Math.min(10000, Math.floor(iterations / 10)); + + els.bench.disabled = true; + setBenchRows([["Running", `${iterations.toLocaleString()} RTTs`]]); + await new Promise((resolve) => requestAnimationFrame(resolve)); + + for (let i = 0; i < warmup; i += 1) { + pingRust(i); + } + + const samples = []; + let totalStart = performance.now(); + for (let i = 0; i < iterations; i += BATCH_SIZE) { + const batchCount = Math.min(BATCH_SIZE, iterations - i); + const batchStart = performance.now(); + for (let j = 0; j < batchCount; j += 1) { + pingRustFast(i + j); + } + samples.push(((performance.now() - batchStart) * 1_000_000) / batchCount); + } + const totalMs = performance.now() - totalStart; + + samples.sort((a, b) => a - b); + const throughput = iterations / (totalMs / 1000); + setBenchRows([ + ["Payload", "4 bytes u32"], + [ + "Samples", + `${samples.length.toLocaleString()} batch averages x ${BATCH_SIZE}`, + ], + ["Direct doorbell p50", formatNs(percentile(samples, 0.5))], + ["Direct doorbell p90", formatNs(percentile(samples, 0.9))], + ["Direct doorbell p99", formatNs(percentile(samples, 0.99))], + ["Direct doorbell mean", formatNs((totalMs * 1_000_000) / iterations)], + ["Throughput", `${(throughput / 1000).toFixed(1)} K RTT/sec`], + ]); + appendLog( + `browser bench completed: ${(throughput / 1000).toFixed(1)} K RTT/sec`, + ); + els.bench.disabled = false; +} + +async function main() { + wasm = await init(); + typeCounter = makeTypeId(0, 7); + jsToRust = new WasmBus(CAPACITY); + rustToJs = new WasmBus(CAPACITY); + memoryView = new DataView(memory().buffer); + + els.status.textContent = "ready"; + els.capacity.textContent = `${CAPACITY / 1024} KiB x 2`; + els.send.disabled = false; + els.bench.disabled = false; + + els.send.addEventListener("click", () => { + const value = Number.parseInt(els.value.value, 10) >>> 0; + const reply = pingRust(value); + els.lastReply.textContent = `${reply.value}`; + appendLog( + `JS sent ${value}, Rust replied ${reply.value}; route=${routeId(reply.typeId)} type=${msgType( + reply.typeId, + )}`, + ); + }); + + els.bench.addEventListener("click", () => { + runBench().catch((err) => { + appendLog(`bench failed: ${err.message}`); + els.bench.disabled = false; + }); + }); + + appendLog("WASM module initialized"); +} + +main().catch((err) => { + els.status.textContent = "failed"; + appendLog(err.stack || err.message); +}); diff --git a/examples/browser_wasm/package.json b/examples/browser_wasm/package.json new file mode 100644 index 0000000..a51707e --- /dev/null +++ b/examples/browser_wasm/package.json @@ -0,0 +1,15 @@ +{ + "name": "tachyon-browser-wasm-example", + "private": true, + "type": "module", + "scripts": { + "build:wasm": "PATH=$(dirname $(rustup which rustc)):$PATH RUSTC=$(rustup which rustc) wasm-pack build rust --target web --release --out-dir ../pkg", + "dev": "vite --host 127.0.0.1", + "build": "npm run build:wasm && vite build", + "bench:native": "PATH=$(dirname $(rustup which rustc)):$PATH RUSTC=$(rustup which rustc) cargo run --release --manifest-path ../../bindings/rust/tachyon/Cargo.toml --example bench_ipc" + }, + "devDependencies": { + "vite": "latest", + "wasm-pack": "latest" + } +} diff --git a/examples/browser_wasm/rust/Cargo.toml b/examples/browser_wasm/rust/Cargo.toml new file mode 100644 index 0000000..59ceff6 --- /dev/null +++ b/examples/browser_wasm/rust/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "tachyon-browser-wasm-example" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +tachyon-ipc = { path = "../../../bindings/rust/tachyon" } +wasm-bindgen = "0.2" diff --git a/examples/browser_wasm/rust/src/lib.rs b/examples/browser_wasm/rust/src/lib.rs new file mode 100644 index 0000000..ea20052 --- /dev/null +++ b/examples/browser_wasm/rust/src/lib.rs @@ -0,0 +1,39 @@ +use tachyon_ipc::WasmBus; +use wasm_bindgen::prelude::*; + +/// Tiny Rust-side browser program used by this example page. +/// +/// It checks `inbound` once, increments a little-endian `u32` payload, and +/// publishes the result to `outbound`. The reusable transport logic stays in +/// the Tachyon bindings; this file owns only the page-specific demo behavior. +#[wasm_bindgen] +pub fn tachyon_browser_echo_once( + inbound: &mut WasmBus, + outbound: &mut WasmBus, +) -> Result { + if !inbound.acquire_rx()? { + return Ok(false); + } + + let type_id = inbound.rx_type_id(); + let actual_size = inbound.rx_size(); + if actual_size != 4 { + inbound.commit_rx()?; + return Err(JsValue::from_str( + "example echo expects a 4-byte u32 payload", + )); + } + + let inbound_ptr = inbound.rx_ptr() as *const u32; + let outbound_ptr = outbound.acquire_tx(actual_size)? as *mut u32; + + unsafe { + let value = inbound_ptr.read_unaligned().wrapping_add(1); + outbound_ptr.write_unaligned(value); + } + + outbound.commit_tx(actual_size, type_id.wrapping_add(1 << 16))?; + inbound.commit_rx()?; + + Ok(true) +} diff --git a/examples/browser_wasm/style.css b/examples/browser_wasm/style.css new file mode 100644 index 0000000..94ae93c --- /dev/null +++ b/examples/browser_wasm/style.css @@ -0,0 +1,169 @@ +:root { + color-scheme: light; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #f4f6f8; + color: #17202a; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +.shell { + width: min(1120px, calc(100vw - 32px)); + margin: 32px auto; + display: grid; + gap: 16px; +} + +.panel, +.toolbar, +.results { + background: #ffffff; + border: 1px solid #d9e0e7; + border-radius: 8px; + box-shadow: 0 1px 2px rgb(20 30 45 / 8%); +} + +.panel { + display: grid; + grid-template-columns: 1fr auto; + gap: 24px; + padding: 24px; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: 28px; + font-weight: 720; +} + +h2 { + font-size: 16px; + margin-bottom: 12px; +} + +p { + margin-top: 8px; + max-width: 680px; + color: #52606d; +} + +.status-grid { + display: grid; + grid-template-columns: repeat(3, minmax(120px, 1fr)); + gap: 12px; + align-content: center; +} + +.status-grid div { + border-left: 3px solid #2c7a7b; + padding: 4px 12px; +} + +.status-grid span { + display: block; + color: #667788; + font-size: 12px; + text-transform: uppercase; +} + +.status-grid strong { + display: block; + margin-top: 4px; + font-size: 16px; + white-space: nowrap; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: end; + padding: 16px; +} + +label { + display: grid; + gap: 6px; + color: #52606d; + font-size: 13px; +} + +input { + width: 150px; + height: 38px; + border: 1px solid #bfccd8; + border-radius: 6px; + padding: 0 10px; + font: inherit; +} + +button { + height: 38px; + border: 0; + border-radius: 6px; + padding: 0 14px; + background: #1f6f78; + color: #ffffff; + font: inherit; + font-weight: 650; + cursor: pointer; +} + +button:disabled { + background: #9eb0bd; + cursor: wait; +} + +.results { + padding: 16px; +} + +pre { + min-height: 110px; + max-height: 260px; + overflow: auto; + margin: 0; + padding: 12px; + border-radius: 6px; + background: #111827; + color: #d1fae5; + font-size: 13px; + line-height: 1.5; +} + +table { + width: 100%; + border-collapse: collapse; +} + +td { + border-top: 1px solid #e2e8f0; + padding: 9px 0; +} + +td:last-child { + text-align: right; + font-variant-numeric: tabular-nums; +} + +@media (max-width: 760px) { + .panel { + grid-template-columns: 1fr; + } + + .status-grid { + grid-template-columns: 1fr; + } +}