diff --git a/.github/dependabot.yml b/.github/dependabot.yml index da94dbc..93918ea 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -37,3 +37,12 @@ updates: update-types: - minor - patch + # Major bumps of these need dedicated migrations (newer Java baseline / breaking + # APIs), so don't open PRs for them until tackled deliberately. + ignore: + - dependency-name: "org.springframework.boot*" + update-types: ["version-update:semver-major"] + - dependency-name: "org.junit*" + update-types: ["version-update:semver-major"] + - dependency-name: "org.graalvm*" + update-types: ["version-update:semver-major"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5562e27..a31ee88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,15 +44,15 @@ jobs: java: '21' runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v5 with: java-version: ${{ matrix.java }} distribution: 'temurin' - name: Cache Maven - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.m2/repository key: m2-core-${{ matrix.os }}-${{ matrix.java }}-${{ hashFiles('**/pom.xml') }} @@ -65,7 +65,7 @@ jobs: shell: bash run: | ./mvnw test -B -q \ - -pl webassembly4j-api,webassembly4j-spi,chicory4j-provider,webassembly4j-runtime,webassembly4j-testing,webassembly4j-pool,webassembly4j-bindgen \ + -pl webassembly4j-api,webassembly4j-spi,endive4j-provider,webassembly4j-runtime,webassembly4j-testing,webassembly4j-pool,webassembly4j-bindgen \ -am - name: Build and test all modules (Java 17+) @@ -73,12 +73,12 @@ jobs: shell: bash run: | ./mvnw test -B -q \ - -pl webassembly4j-api,webassembly4j-spi,chicory4j-provider,graalwasm4j-provider,webassembly4j-runtime,webassembly4j-testing,webassembly4j-pool,webassembly4j-spring,webassembly4j-bindgen \ + -pl webassembly4j-api,webassembly4j-spi,endive4j-provider,graalwasm4j-provider,webassembly4j-runtime,webassembly4j-testing,webassembly4j-pool,webassembly4j-spring,webassembly4j-bindgen \ -am - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: test-results-${{ matrix.os }}-java${{ matrix.java }} path: '**/target/surefire-reports/' @@ -89,15 +89,15 @@ jobs: name: Code Quality runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v5 with: java-version: '23' distribution: 'temurin' - name: Cache Maven - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.m2/repository key: m2-quality-${{ hashFiles('**/pom.xml') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab2d6b6..5d5bc08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,15 +31,15 @@ jobs: java: '21' runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v5 with: java-version: ${{ matrix.java }} distribution: 'temurin' - name: Cache Maven - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.m2/repository key: m2-release-${{ matrix.os }}-${{ matrix.java }}-${{ hashFiles('**/pom.xml') }} @@ -50,7 +50,7 @@ jobs: shell: bash run: | ./mvnw test -B -q \ - -pl webassembly4j-api,webassembly4j-spi,chicory4j-provider,webassembly4j-runtime,webassembly4j-testing,webassembly4j-pool,webassembly4j-bindgen \ + -pl webassembly4j-api,webassembly4j-spi,endive4j-provider,webassembly4j-runtime,webassembly4j-testing,webassembly4j-pool,webassembly4j-bindgen \ -am - name: Run tests (Java 17+ — all modules) @@ -58,7 +58,7 @@ jobs: shell: bash run: | ./mvnw test -B -q \ - -pl webassembly4j-api,webassembly4j-spi,chicory4j-provider,graalwasm4j-provider,webassembly4j-runtime,webassembly4j-testing,webassembly4j-pool,webassembly4j-spring,webassembly4j-bindgen \ + -pl webassembly4j-api,webassembly4j-spi,endive4j-provider,graalwasm4j-provider,webassembly4j-runtime,webassembly4j-testing,webassembly4j-pool,webassembly4j-spring,webassembly4j-bindgen \ -am # Static analysis quality gate @@ -66,15 +66,15 @@ jobs: name: Code Quality runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v5 with: java-version: '23' distribution: 'temurin' - name: Cache Maven - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.m2/repository key: m2-quality-${{ hashFiles('**/pom.xml') }} @@ -99,9 +99,9 @@ jobs: contents: write packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v5 with: java-version: '21' distribution: 'temurin' @@ -112,7 +112,7 @@ jobs: gpg-passphrase: GPG_PASSPHRASE - name: Cache Maven - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.m2/repository key: m2-publish-${{ hashFiles('**/pom.xml') }} @@ -128,9 +128,9 @@ jobs: - name: Install modules locally run: | - DEPLOY_MODULES="-pl webassembly4j-api,webassembly4j-spi,wasmtime4j-provider,wamr4j-provider,wasm3-provider,chicory4j-provider,graalwasm4j-provider,webassembly4j-runtime,webassembly4j-testing,webassembly4j-pool,webassembly4j-spring,webassembly4j-bindgen" + DEPLOY_MODULES="-pl webassembly4j-api,webassembly4j-spi,wasmtime4j-provider,wamr4j-provider,wasm3-provider,endive4j-provider,graalwasm4j-provider,webassembly4j-runtime,webassembly4j-testing,webassembly4j-pool,webassembly4j-spring,webassembly4j-bindgen" ./mvnw install -B -q \ - -DskipTests -Dmaven.test.skip=true -Dmaven.javadoc.skip=true -Dgpg.skip=true -DskipNativeTests \ + -DskipTests -Dmaven.test.skip=true -Dmaven.javadoc.skip=true -Dgpg.skip=true -DskipNativeTests -Dskip.rust.build=true \ $DEPLOY_MODULES -am - name: Deploy to Maven Central @@ -139,16 +139,16 @@ jobs: MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} run: | - DEPLOY_MODULES="-pl webassembly4j-api,webassembly4j-spi,wasmtime4j-provider,wamr4j-provider,wasm3-provider,chicory4j-provider,graalwasm4j-provider,webassembly4j-runtime,webassembly4j-testing,webassembly4j-pool,webassembly4j-spring,webassembly4j-bindgen" + DEPLOY_MODULES="-pl webassembly4j-api,webassembly4j-spi,wasmtime4j-provider,wamr4j-provider,wasm3-provider,endive4j-provider,graalwasm4j-provider,webassembly4j-runtime,webassembly4j-testing,webassembly4j-pool,webassembly4j-spring,webassembly4j-bindgen" ./mvnw deploy -B -P release \ - -DskipTests -Dmaven.test.skip=true -DskipNativeTests \ + -DskipTests -Dmaven.test.skip=true -DskipNativeTests -Dskip.rust.build=true \ $DEPLOY_MODULES - name: Deploy to GitHub Packages env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - DEPLOY_MODULES="-pl webassembly4j-api,webassembly4j-spi,wasmtime4j-provider,wamr4j-provider,wasm3-provider,chicory4j-provider,graalwasm4j-provider,webassembly4j-runtime,webassembly4j-testing,webassembly4j-pool,webassembly4j-spring,webassembly4j-bindgen" + DEPLOY_MODULES="-pl webassembly4j-api,webassembly4j-spi,wasmtime4j-provider,wamr4j-provider,wasm3-provider,endive4j-provider,graalwasm4j-provider,webassembly4j-runtime,webassembly4j-testing,webassembly4j-pool,webassembly4j-spring,webassembly4j-bindgen" mkdir -p ~/.m2 cat > ~/.m2/settings-github.xml <<'SETTINGS' @@ -162,7 +162,7 @@ jobs: SETTINGS ./mvnw deploy -B \ - -DskipTests -Dmaven.test.skip=true -DskipNativeTests \ + -DskipTests -Dmaven.test.skip=true -DskipNativeTests -Dskip.rust.build=true \ -DaltDeploymentRepository=github::https://maven.pkg.github.com/${{ github.repository }} \ -Dgpg.skip=true \ -s ~/.m2/settings-github.xml \ @@ -170,14 +170,14 @@ jobs: - name: Package artifacts run: | - DEPLOY_MODULES="-pl webassembly4j-api,webassembly4j-spi,wasmtime4j-provider,wamr4j-provider,wasm3-provider,chicory4j-provider,graalwasm4j-provider,webassembly4j-runtime,webassembly4j-testing,webassembly4j-pool,webassembly4j-spring,webassembly4j-bindgen" + DEPLOY_MODULES="-pl webassembly4j-api,webassembly4j-spi,wasmtime4j-provider,wamr4j-provider,wasm3-provider,endive4j-provider,graalwasm4j-provider,webassembly4j-runtime,webassembly4j-testing,webassembly4j-pool,webassembly4j-spring,webassembly4j-bindgen" ./mvnw package -B -P release \ - -DskipTests -Dmaven.test.skip=true -DskipNativeTests \ + -DskipTests -Dmaven.test.skip=true -DskipNativeTests -Dskip.rust.build=true \ $DEPLOY_MODULES - name: Create checksums run: | - for dir in webassembly4j-api/target webassembly4j-spi/target webassembly4j-runtime/target webassembly4j-testing/target webassembly4j-pool/target webassembly4j-spring/target webassembly4j-bindgen/target wasmtime4j-provider/target wamr4j-provider/target wasm3-provider/target chicory4j-provider/target graalwasm4j-provider/target; do + for dir in webassembly4j-api/target webassembly4j-spi/target webassembly4j-runtime/target webassembly4j-testing/target webassembly4j-pool/target webassembly4j-spring/target webassembly4j-bindgen/target wasmtime4j-provider/target wamr4j-provider/target wasm3-provider/target endive4j-provider/target graalwasm4j-provider/target; do if [ -d "$dir" ]; then for jar in "$dir"/*.jar; do [ -f "$jar" ] && sha256sum "$jar" > "$jar.sha256" @@ -186,7 +186,7 @@ jobs: done - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: generate_release_notes: true files: | @@ -210,7 +210,7 @@ jobs: wamr4j-provider/target/*.jar.sha256 wasm3-provider/target/*.jar wasm3-provider/target/*.jar.sha256 - chicory4j-provider/target/*.jar - chicory4j-provider/target/*.jar.sha256 + endive4j-provider/target/*.jar + endive4j-provider/target/*.jar.sha256 graalwasm4j-provider/target/*.jar graalwasm4j-provider/target/*.jar.sha256 diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar index cb28b0e..eebc3c1 100644 Binary files a/.mvn/wrapper/maven-wrapper.jar and b/.mvn/wrapper/maven-wrapper.jar differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index 1a60da7..970a3d0 100644 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -1,18 +1,4 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip -wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar +wrapperVersion=3.3.4 +distributionType=bin +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.4/maven-wrapper-3.3.4.jar diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c40f3c..d887f18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,76 @@ # Changelog +## 2.4.0 + +### New Features + +- **Provider-neutral `wasi:nn` opt-in**: new `WasiNnConfig` marker in `webassembly4j-api` and `LinkingContext.Builder.enableWasiNn(WasiNnConfig)` let a caller enable wasi:nn on a component without touching a native `ComponentLinker`. The Wasmtime provider translates it to `ai.tegmentum.wasmtime4j.wasi.nn.WasiNnConfig` and calls the native `ComponentLinker#enableWasiNn`. Providers without wasi:nn surface a `WasmException` from their native linker which `InstantiationException` wraps at the API boundary. +- **`supportsWasiNn()` capability probe** on `LinkingContext` + `Component` (default `false`), so callers can gate `enableWasiNn(...)` on the actual provider capability rather than on their own configuration intent. The Wasmtime adapter reports the compile-time `wasi-nn` feature bit of the loaded `libwasmtime4j` native. Closes a regression where plugins calling `enableWasiNn(...)` unconditionally against a wasmtime4j native built without the `wasi-nn` cargo feature would throw at instantiation time even for guests importing zero `wasi:nn` interfaces. +- **`WitCallableResource` — component-model resource-method invocation**: new provider-neutral surface that wraps a component-model resource handle with `invokeMethod` / `invokeMethodWit` / `close`, so JVM plugins driving components via SPARQL SERVICE can address aggregate-state, iterator, and handle-then-method WIT patterns without threading the receiver + instance pair by hand. The Wasmtime provider implements this by binding a wasmtime4j `WitResource` to its originating `ComponentInstance` and delegating through the new `invokeResourceMethodWit` + `dropResource` entry points. Endive / GraalWasm / WAMR providers inherit the default `UnsupportedFeatureException` — extending them is a follow-up when their underlying runtimes grow equivalent hooks. +- **`ComponentInstance.consumeFuel(long)` + `fuelConsumed()`**: default methods on the API `ComponentInstance` surface providers can implement to expose fuel accounting. `consumeFuel` defaults to `UnsupportedFeatureException`, `fuelConsumed` defaults to `-1` (unsupported sentinel). The Wasmtime provider overrides both to delegate to the underlying wasmtime4j `ComponentInstance`. Non-fuel-metered providers (Endive, GraalWasm, WAMR, wasm3) inherit the defaults unchanged. Closes the upstream gap that forced a JVM-side counter workaround for fuel metering. +- **`WasiContext.wasiHttpEnabled` + `DefaultWasiContext.Builder.wasiHttp(...)`**: opt-in `wasi:http` hint on the provider-neutral `WasiContext` so policies can request that the `wasi:http/outgoing-handler` + `types` import surface be published on a component's linker without a bespoke host call. Default `false` — no behavioural change for existing callers. The interface method has a default so third-party `WasiContext` implementations pick it up without a source-incompatible signature change. The Wasmtime provider forwards the flag to `WasiPreview2Config.Builder.wasiHttp(true)`; the wasmtime `ComponentLinker` then invokes `enableWasiHttp()` after preview 2 is enabled. **Publishing the imports does not itself grant network egress** — the existing `allowNetwork` + `egressRules` gates still apply to any HTTP connection the guest attempts. Non-Wasmtime providers may no-op, log, or throw `UnsupportedFeatureException` on the flag; the policy is provider-local. +- **`BridgingSparqlExtensionDispatch` (Wasmtime provider)**: transitional helper that lets JVM plugins dispatch against SPARQL extension guests speaking either the new `sparql-extension` ABI (`tegmentum:webfunction@0.1.0`) or the old flat `evaluate` / `aggregate-step` / `aggregate-finish` ABI. Detects the guest's shape once at construction and caches the result. Intended to be deleted once every guest exports the new `sparql-extension` world. + +### Bug Fixes + +- **WasmGC on JDK 22+**: restore an explicit `isMutable()` accessor on the Java 22 MRJAR variant of `GcStructType.Field` (which was converted to a `record`, whose component accessor is `mutable()` with no `is-` prefix). Downstream consumers compiled against the base variant call `isMutable()` and previously blew up with `NoSuchMethodError` when the java22 class was preferred at runtime. +- **Bridging shape detection**: `BridgingSparqlExtensionDispatch#aggregateIsNewShape` now falls back to `ComponentInstance.exportsInterface(...)` when `hasFunction("...#new-aggregate")` returns `false`. On wasmtime4j runtimes older than `46.0.1-1.4.6` the qualified-export probe returned `false` for interface-qualified export names, so new-shape guests mis-detected as old-shape and dispatch trapped against absent flat aggregate-step exports. Also unwraps the `new-aggregate` ctor's `WitResult` ok-arm before `asCallableResource` and wraps the returned resource so callers can dispatch step/finish via the neutral method vocabulary the shim aggregate resource accepts. +- **Wasmtime resource adapter**: `WasmtimeComponentInstanceAdapter#asCallableResource` now accepts `WitOwn` / `WitBorrow` (discriminators 22/23) in addition to `WitResource`, translating via `WitResource.fromHandle`. The wasmtime WIT deserializer surfaces `own` / `borrow` as `WitOwn` / `WitBorrow`, not `WitResource` — the prior check rejected the shape actually returned by `invokeWit` at run time. + +### Dependency Upgrades + +- wasmtime4j: 46.0.1-1.3.1 → 47.0.2-1.5.0 (major bump — wasmtime 47 with GC + exception-handling enabled by default upstream, wasi-common removed; also carries the underlying `wasi:nn` config, resource-method dispatch, interface-qualified `hasFunction`, and fuel accessors added across the 1.4.x line). + +## 2.3.0 + +### New Features + +- **Engine-neutral filesystem access observer**: new `ai.tegmentum.webassembly4j.api.FsAccessObserver`, an observe-only callback fired when a component's path-based filesystem open is denied on the WASI preopen path. Surfaces the raw guest path and a classified reason (`java.lang` types only, no runtime dependency). Wired as an optional `WasiContext.fsAccessObserver()`. The Wasmtime provider bridges a present observer through to wasmtime4j's `WasiPreview2Config.fsAccessObserver` so denied `open-at`/`stat-at` calls report the raw guest path + reason to the host. Observe-only: the observer cannot change the enforcement outcome. + +### Dependency Upgrades + +- wasmtime4j: 46.0.1-1.3.0 → 46.0.1-1.3.1 + +## 2.2.0 + +### New Features + +- **Network egress allow-list (Wasmtime)**: new `NetworkEgressRule` (literal IP or CIDR + port + tcp/udp) with total, fail-closed matching including CIDR containment. `WasiContext` gains `allowNetwork()` and `egressRules()`; `DefaultWasiContext.Builder` gains `allowNetwork()` and `allowEgress()`. The Wasmtime component adapter maps a granted context to `allowNetwork(true)` + `allowTcp`/`allowUdp` and installs a `SocketAddrCheck` that permits only `connect`/`outgoing-datagram` to allow-listed endpoints while denying every `bind`/`listen` use (egress only, no ingress). + +### Dependency Upgrades + +- wasmtime4j: 46.0.1-1.2.0 → 46.0.1-1.3.0 +- endive: 1.0.0 → 1.0.1 +- GraalVM Polyglot: 24.2.1 → 24.2.2 +- JUnit Jupiter: 5.10.4 → 5.14.4 +- Jackson: 2.22.0 → 2.22.1 +- Spring Boot: 3.2.5 → 3.5.16 + +## 2.1.0 + +### New Features + +- **Component Model invocation, end-to-end (Wasmtime)**: `ComponentInstance.invoke()` now marshals natural Java argument types across the full composite type surface (record / list / option / variant / result / tuple / enum / flags), and `ComponentInstance.invokeWit` provides a symmetric typed round-trip that returns the raw `WitValue` tree instead of a lossy `toJava()` conversion, preserving `u64` width, enum discriminants, and option inner types. +- **WIT-typed host imports via `LinkingContext`**: register host functions against WIT interface types so component imports resolve to strongly-typed Java lambdas. +- **WASI preopens with per-directory read-only + host→guest path remapping**: preopens can now be marked read-only individually, and the guest-visible path can differ from the host path. +- **Per-instance component resource caps**: memory, epoch, and fuel caps are forwarded through to component instantiation on the Wasmtime provider (metered stores get a real fuel budget instead of running unbounded). + +### Bug Fixes + +- **Wasmtime provider**: component instantiation is now routed through the WASI-config path so `WasiContext` + `ComponentConfig` are honored end-to-end. + +### Dependency Upgrades + +- wasmtime4j: 46.0.1-1.1.7 → 46.0.1-1.2.0 (Component Model end-to-end, per-instance fuel/memory/epoch caps, WIT marshalling fixes, panama artifact fix) + +## 2.0.0 + +### Breaking Changes + +- **Chicory provider renamed to Endive**: Chicory has been donated to the Bytecode Alliance and renamed [Endive](https://endive.run/). The provider module `chicory4j-provider` is now `endive4j-provider`, the provider/engine ID `"chicory"` is now `"endive"`, and the config facade classes (`ChicoryConfig`, `ChicoryConfigs`) are now `EndiveConfig`/`EndiveConfigs`. The underlying dependency moved from `com.dylibso.chicory` to `run.endive` (1.0.0). Update any `.provider("chicory")`/`.engine("chicory")` calls to `"endive"` and any `ChicoryConfig` references to `EndiveConfig`. + + The major version bump reflects this breaking change to the public provider ID and configuration classes; all other modules are source- and behavior-compatible with 1.5.0. + ## 1.4.0 ### New Features diff --git a/NOTES-CALLER-AWARE-2026-07-26.md b/NOTES-CALLER-AWARE-2026-07-26.md new file mode 100644 index 0000000..173084f --- /dev/null +++ b/NOTES-CALLER-AWARE-2026-07-26.md @@ -0,0 +1,59 @@ +# Caller-Aware-Host-Function — working notes (r.1 in progress) + +**Charter**: `f-webassembly4j-caller-aware-host-function-charter-2026-07-26` +**Branch**: `f-webassembly4j-caller-aware` (branched from `main` at `a553e37`) +**Type**: feature — engine-agnostic exposure of the wasmtime4j r.2 / r.2.b `Caller` pattern +**Version**: 2.5.2-SNAPSHOT (unchanged; accumulates before release cut) + +## Charter scope + +Propagate the `Caller` scoped-mutation pattern UP from wasmtime4j to the +webassembly4j api layer so any provider can offer caller-aware host functions +without forcing consumers to reach into wasmtime4j directly. + +Downstream unblock: F-JIT-Loader-Java-Reference r.5.c (Fiji jit-loader +switches from `HostFunction` to `CallerAwareHostFunction` for +compile+instantiate+growTable inside the callback). + +## Wasmtime4j surface reference (r.2 + r.2.b at wasmtime4j 47.0.2-1.5.1) + +- `ai.tegmentum.wasmtime4j.func.Caller` — 10 scoped methods + (data, getMemory, getTable, getFunction, getGlobal, compileModule, + growTable, setTableElement, growMemory, instantiate). +- `ai.tegmentum.wasmtime4j.func.HostFunction.multiValueWithCaller(impl)` / + `singleValueWithCaller(impl)` / `voidFunctionWithCaller(impl)` — factories + that wrap a caller-aware Java lambda into a `CallerAwareHostFunction` + which the JNI backend recognises and injects the live caller into. +- Delivery route: JNI dispatch pushes a `JniCaller` onto + `JniHostFunction.CALLER_CONTEXT` ThreadLocal, and + `CallerAwareHostFunction.getCurrentCaller` retrieves it via ServiceLoader + (`CallerContextProvider`). Consumer of the wrapped `HostFunction` never + needs to hand-plumb the caller. + +## Plan (single slice) + +### r.1-1 — webassembly4j-api additions +- New: `Caller.java` (10 methods matching wasmtime4j subset) +- New: `CallerAwareHostFunction.java` (@FunctionalInterface, `Object[] execute(Caller, Object...)`) +- New: `CallerAwareHostFunctionDefinition.java` (parallel to `HostFunctionDefinition`) +- Edit: `LinkingContext.java` — add `callerAwareHostFunctions()` default +- Edit: `DefaultLinkingContext.java` — field, accessor, builder overloads + +### r.1-2 — wasmtime4j-provider bridge +- New: `WasmtimeCallerAdapter.java` — wraps native `Caller` as api `Caller` +- Delegates each method to underlying `WasmMemory/Table/Global/Function` adapters + +### r.1-3 — WasmtimeModuleAdapter.instantiate extension +- Iterate `linkingContext.callerAwareHostFunctions()` +- For each, register via `linker.defineHostFunction(module, name, funcType, + HostFunction.multiValueWithCaller((nativeCaller, wasmArgs) -> { wrap caller, + convert args, invoke, convert results }))` + +### r.1-4 — test +- WAT: exports memory + table + `run()` that calls `env.trigger_grow(1)` +- `CallerAwareHostFunction` grows the caller-visible table via `caller.growTable` +- Assert grow observable + use-after-return throws `IllegalStateException` + +## Status + +- 2026-07-26 r.1-0 STARTED: branch created, notes stub committed. diff --git a/NOTES-CALLER-AWARE-r1-close-2026-07-26.md b/NOTES-CALLER-AWARE-r1-close-2026-07-26.md new file mode 100644 index 0000000..e869de1 --- /dev/null +++ b/NOTES-CALLER-AWARE-r1-close-2026-07-26.md @@ -0,0 +1,204 @@ +# NOTES — Webassembly4j Caller-Aware Host Function — r.1 CLOSE (2026-07-26) + +**Branch**: `f-webassembly4j-caller-aware` (off `main @ a553e37`) + +**Charter**: `fijivm/doctrine/specs/f-webassembly4j-caller-aware-host-function-charter-2026-07-26.md` + +**Prior partial**: `fijivm/doctrine/specs/f-webassembly4j-caller-aware-host-function-r1-partial-2026-07-26.md` +(r.1-1 landed at `a2fa3d9` before session limit; resume executed r.1-2..r.1-θ) + +## Executive summary + +Webassembly4j api layer now exposes the caller-aware host-function +pattern behind a `Caller` interface + `CallerAwareHostFunction` ++ `LinkingContext.callerAwareHostFunctions()` accumulator. Wasmtime4j +provider bridges the api into the wasmtime4j r.2 caller-scoped +implementation (JNI-only in wasmtime4j 47.0.2-1.5.1); non-wasmtime +providers inherit the empty-list default and are unaffected. + +Integration test (`WasmtimeCallerAwareHostFunctionTest`) grows the +caller's table by 1 from inside a caller-aware callback and confirms +the wasmtime4j r.2 generation counter propagates `IllegalStateException` +through the api-layer Caller adapter on use-after-return — the SIGSEGV +class first witnessed at F-JIT-Loader-Java-Reference r.5.b is now +closed at the webassembly4j api layer. + +## Commits landed on `f-webassembly4j-caller-aware` + +- `1b8b907` docs(caller): begin webassembly4j caller-aware charter r.1 [pre-partial] +- `a2fa3d9` feat(api): add Caller + CallerAwareHostFunction interfaces + LinkingContext plumbing [pre-partial, r.1-1] +- `cb81e9b` feat(wasmtime-provider): WasmtimeCallerAdapter [r.1-2] +- `2aa300d` feat(wasmtime-provider): WasmtimeModuleAdapter consumes callerAwareHostFunctions from LinkingContext [r.1-3] +- `bec6292` test(wasmtime-provider): caller-aware host function + scoped growTable from callback [r.1-4 initial] +- `e84654c` test(wasmtime-provider): restrict caller-aware test callback to scoped ops only [r.1-4 fix — see "Test discovery" below] + +## Files touched + +- **New — api (r.1-1, pre-partial)**: + - `webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/Caller.java` + - `webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/CallerAwareHostFunction.java` + - `webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/CallerAwareHostFunctionDefinition.java` +- **Modified — api (r.1-1, pre-partial)**: + - `webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/LinkingContext.java` (+ default `callerAwareHostFunctions()`) + - `webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/DefaultLinkingContext.java` (+ Builder.addCallerAwareHostFunction) +- **New — wasmtime provider (r.1-2)**: + - `wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeCallerAdapter.java` +- **Modified — wasmtime provider (r.1-3)**: + - `wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeModuleAdapter.java` + - `callerScoped(...)` factory for caller-produced Modules + - `defineHostFunctions` extracted to package-private static helper for reuse + - `defineCallerAwareHostFunctions` wires each definition through + wasmtime4j's `HostFunction.CallerAwareHostFunction` + - `nativeModule()` package-private accessor for the caller-scoped bridge + - `close()` skips `store.close()` for caller-scoped Modules (their + store is the caller's live store) +- **New — wasmtime provider test (r.1-4)**: + - `wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeCallerAwareHostFunctionTest.java` + +## API adjustments from charter spec + +The charter spec proposed `Caller.instantiate(Module, LinkingContext)`. +Wasmtime4j's `Caller.instantiate(InstancePre)` takes an +`InstancePre`, not a Module + import bundle — the r.2.b close notes +document this explicitly (pre-instantiation happens outside the +callback frame so the reentrant step is minimal). + +The api-layer signature is preserved as spec'd (no api change). +`WasmtimeCallerAdapter.instantiate(Module, LinkingContext)` +reconciles the mismatch internally: + +1. Extracts the native wasmtime4j Module from the api Module +2. Builds a transient `Linker` from `caller.engine()` +3. Wires `hostFunctions()` + `callerAwareHostFunctions()` from the + passed LinkingContext (extern imports are rejected — they require + a live store binding that wasmtime4j's Caller does not expose) +4. Calls `linker.instantiatePre(nativeModule)` to build the InstancePre +5. Delegates to `nativeCaller.instantiate(pre)` — the borrow-safe + scoped instantiate landed at wasmtime4j r.2.b +6. Wraps the returned Instance in `WasmtimeInstanceAdapter` + +`WasmtimeModuleAdapter` gained a caller-scoped construction path +(package-private `callerScoped(...)` factory) so +`caller.compileModule(bytes)` can return a Module handle that borrows +the caller's implicit store. Its own `instantiate()` / `instantiate(ctx)` +methods throw `IllegalStateException("… no owning store …")` — the +caller-produced Module must be instantiated via +`Caller.instantiate(Module, LinkingContext)`, not directly. + +## Test outcome + +- `mvn -B -pl webassembly4j-api,wasmtime4j-provider -am test` + (`-Dtest='!JavaToWasmRoundTripTest,!WasmtimeCallableResourceTest'`, + `-Dskip.rust.build=true`) — **269 tests PASS**, 0 failures, 0 errors, + 0 skipped: + - api: 162 (unchanged) + - spi: 18 (unchanged) + - wasmtime4j-provider: 89 (88 pre-existing unchanged + 1 new + `WasmtimeCallerAwareHostFunctionTest`) + +## Test discovery — regular WasmTable accessors SIGSEGV from callback + +Initial test called `t.size()` from inside the caller callback via +the ordinary `WasmtimeTableAdapter.size()` delegate; that path routes +to `JniTable.getSize()` which the native side implements via +`store.try_lock_store()`. Inside a live caller-borrow the native +crashes rather than throws — witnessed via hs_err at +`libwasmtime4j.dylib Table::size + JniTable_nativeGetSize`. + +This is consistent with the wasmtime4j r.2 doctrine: **mutation from +inside a callback MUST route through caller-scoped entrypoints** +(`caller.growTable`, `caller.setTableElement`, `caller.growMemory`, +`caller.instantiate`), which use `caller.as_context_mut()` instead of +the reentrant store lock. Non-mutation reads on the ordinary handles +(e.g. `WasmMemory.readBytes`, `WasmTable.get(index)`) may or may not +be safe depending on the native impl's lock discipline. The test now +uses only caller-scoped ops in the callback and reads +`instance.table("table").size()` on the outside (post-callback) where +the store lock is available. + +Also encountered a pre-existing bug in `WasmtimeFunctionAdapter`: +the fast-path classifier maps `(i32) -> ()` to `FastPath.I_V` and +calls `callVoid()`, dropping the argument. This trips "Parameter +validation failed for function 'run'" for a `run(param i32)` export. +The test module was updated to a no-param `run()` with an inline +`i32.const 1` so the fast-path used is `V_V`. Out of scope for this +charter. + +## mvn install refreshed SNAPSHOT + +- `mvn -B install -DskipTests -Dspotbugs.skip=true -Dcheckstyle.skip=true -Dpmd.skip=true -Dskip.rust.build=true` — **BUILD SUCCESS** +- All 15 modules installed (Parent + api + spi + 5 providers + runtime + testing + pool + spring + benchmarks + bindgen + component-builder + wasmos-embedder) +- Version stays `2.5.2-SNAPSHOT` (no re-bump per charter) + +## r.5.c unblock status + +**F-JIT-Loader-Java-Reference r.5.c** is now UNBLOCKED. + +The engine-agnostic path Fiji jit-loader was waiting for is live: +- `CallerAwareHostFunction` (or any T) is registered via + `DefaultLinkingContext.Builder.addCallerAwareHostFunction` +- The callback receives an api `Caller` handle with + `compileModule(byte[])`, `instantiate(Module, LinkingContext)`, + `growTable(Table, int, Object)`, `setTableElement`, `growMemory` — + all borrow-safe via the wasmtime4j r.2 + r.2.b caller pipeline +- Non-wasmtime providers (WAMR/GraalWasm/Wasm3/Endive) inherit the + empty-list default from `LinkingContext.callerAwareHostFunctions` + and never construct a Caller — non-breaking for them; if a + non-wasmtime LinkingContext ends up carrying caller-aware + definitions in a future consumer, the provider naturally throws at + instantiate time (per charter's discipline verification) + +The known-good caller-scoped ops for r.5.c per empirical evidence +here + wasmtime4j r.2.b test suite: +- `caller.compileModule(bytes)` — routes through caller's engine +- `caller.instantiate(module, imports)` — routes through borrow-safe + scoped instantiate +- `caller.growTable(table, delta, null)` — verified in this charter's + test +- `caller.setTableElement(table, index, value)` — API present; + wasmtime4j r.2c banked follow-up for funcref-encoding parity when + non-null funcref values are needed inside the callback (workaround: + install funcref via outer-instance `Table.set` after callback + returns, per wasmtime4j r.2.b `testCallerScopedJitInstallLoop`) +- `caller.growMemory(memory, deltaPages)` — verified in wasmtime4j + r.2 test suite + +## Discipline notes preserved + +- No breaking changes to existing webassembly4j api signatures. +- Non-wasmtime providers inherit UOE-free empty-list default; no + provider outside wasmtime4j-provider was touched. +- Webassembly4j pom.xml versions untouched (still `2.5.2-SNAPSHOT`). +- CHANGELOG.md untouched. +- Wasmtime4j NOT modified (already installed as 47.0.2-1.5.1 with + r.2 + r.2.b caller additions). +- Fiji: NOT touched. +- OJ9 substrate `1e7c5205e5`: NOT touched. +- HotSpot: NOT touched. FROZEN WITs preserved. Matrix v7 unchanged. +- No push. Branch `f-webassembly4j-caller-aware` remains local. + +## Stopping-condition classification + +**CLOSED_SUCCEEDED** + +- All 5 sub-slices (r.1-1 pre-partial + r.1-2..r.1-5) land as real + functionality. +- Integration test passes on first run after the SIGSEGV-in-test + discovery and fix; no build regressions; all 88 pre-existing + wasmtime4j-provider tests still pass. +- The r.5.b SIGSEGV pattern is provably closed at the webassembly4j + api layer — use-after-return is caught by IllegalStateException, + in-callback grow succeeds via the borrow-safe path. +- r.5.c (Fiji retry) is fully unblocked — consumer-side work only + from here. + +## Related + +- Charter: `fijivm/doctrine/specs/f-webassembly4j-caller-aware-host-function-charter-2026-07-26.md` +- Prior partial (r.1-1 pre-close): `fijivm/doctrine/specs/f-webassembly4j-caller-aware-host-function-r1-partial-2026-07-26.md` +- Wasmtime4j-side companion: r.2 + r.2.b close notes at + `~/git/wasmtime4j/NOTES-CALLER-AWARE-HOST-FUNCTION-r2-implement-2026-07-26.md` + + `~/git/wasmtime4j/NOTES-CALLER-AWARE-HOST-FUNCTION-r2b-instantiate-2026-07-26.md` +- Prior sibling closures (all merged to main pre-branch): + Table, LinkingContext, Function.unwrap, Cross-Module-Store-Sharing +- Downstream: F-JIT-Loader-Java-Reference r.5.c (now unblocked) diff --git a/NOTES-LINKINGCONTEXT-IMPORTS-2026-07-26.md b/NOTES-LINKINGCONTEXT-IMPORTS-2026-07-26.md new file mode 100644 index 0000000..8805ba8 --- /dev/null +++ b/NOTES-LINKINGCONTEXT-IMPORTS-2026-07-26.md @@ -0,0 +1,49 @@ +# LinkingContext-Imports-Wiring-Fix — working notes (r.1 in progress) + +**Charter**: `f-webassembly4j-linkingcontext-imports-wiring-fix-charter-2026-07-26` +**Branch**: `f-linkingcontext-imports-wiring-fix` (branched from `main` at `fd07215`) +**Type**: bug fix — closes semantic hole in existing public API + +## Problem + +- `DefaultLinkingContext.Builder.addImport(String, Object)` is a public method +- `DefaultLinkingContext.imports()` returns the resulting map +- `LinkingContext` interface never exposes it +- Providers cannot reach the map → the call has no effect +- Also: single-string key can't represent two-part `(moduleName, name)` Wasm import keys + +## Approach — Option B.2 (typed variants) + +1. Add sealed `ExternImportDefinition` in api layer with variants + `MemoryImport / TableImport / GlobalImport / FunctionImport` +2. Add `LinkingContext.externImports()` default method +3. Add typed builder methods on `DefaultLinkingContext.Builder` +4. Deprecate untyped `addImport(String, Object)` (kept for backwards compat) +5. Wire `WasmtimeModuleAdapter.instantiate(LinkingContext)` to consume them + +## Empirical unwrap availability audit + +- `Memory.unwrap(Class)` — YES +- `Table.unwrap(Class)` — YES +- `Global.unwrap(Class)` — YES +- `Function.unwrap(Class)` — NO (interface lacks it) + +`WasmtimeMemoryAdapter`, `WasmtimeTableAdapter`, `WasmtimeGlobalAdapter` all +implement `unwrap` and hand back the native `WasmMemory` / `WasmTable` / +`WasmGlobal` handles that `Linker.defineMemory/defineTable/defineGlobal` +require. + +## Scope decision (r.1) + +- MemoryImport / TableImport / GlobalImport — fully wired +- FunctionImport — sealed-type variant lands; wasmtime provider throws + `UnsupportedOperationException` at instantiate. Consuming requires adding + `Function.unwrap(Class)` on the api interface + adapter impl — deferred + to a follow-up charter. + +## Charter A coordination + +Charter A (table interface completion) is on branch `f-table-interface-completion` +and touches `Table.java` + `WasmtimeTableAdapter.java`. Charter B (this) is +branched from `main` (identical HEAD to Charter A start) and touches disjoint +files. No file overlap. diff --git a/NOTES-TABLE-COMPLETION-2026-07-26.md b/NOTES-TABLE-COMPLETION-2026-07-26.md new file mode 100644 index 0000000..c0de64a --- /dev/null +++ b/NOTES-TABLE-COMPLETION-2026-07-26.md @@ -0,0 +1,112 @@ +# F-Webassembly4j-Table-Interface-Completion — Working Notes + +Charter: `~/git/fijivm/doctrine/specs/f-webassembly4j-table-interface-completion-charter-2026-07-26.md` + +Date opened: 2026-07-26 +Branch: `f-table-interface-completion` + +## Scope (r.1) + +Add 4 spec-standard operations to `ai.tegmentum.webassembly4j.api.Table` and +implement them in `wasmtime4j-provider`. Other provider adapters are left +unchanged — the new interface methods carry `throw UnsupportedOperationException` +defaults so downstream `wamr4j-provider`, `graalwasm4j-provider`, `wasm3-provider`, +and `endive4j-provider` continue to compile. + +| Method | Spec op | Native site (`ai.tegmentum.wasmtime4j.WasmTable`) | +|---|---|---| +| `OptionalInt maxSize()` | `table.type().max` | `int getMaxSize()` — returns `-1` when unlimited | +| `Object get(int)` | `table.get` | `Object get(int)` | +| `void set(int, Object)` | `table.set` | `void set(int, Object)` | +| `int grow(int, Object)` | `table.grow` | `int grow(int, Object)` | + +## Native convention discoveries + +`ai.tegmentum.wasmtime4j.WasmTable.getMaxSize()` Javadoc (verified in +`~/git/wasmtime4j/wasmtime4j/src/main/java/ai/tegmentum/wasmtime4j/WasmTable.java:79`): + +> "Gets the maximum size of the table. @return the maximum number of +> elements, or -1 if unlimited" + +The adapter therefore treats `max < 0` as unbounded. + +## Explicitly out of scope for r.1 + +`fill`, `copy`, `init`, `dropElementSegment`, `growAsync`, `elementType()`, +`getSize64/get64/set64/…` — deferred per charter. + +## Slice plan + +- r.1-1: extend `Table` interface (defaults throw UOE) +- r.1-2: implement in `WasmtimeTableAdapter` +- r.1-3: add `WasmtimeTableAdapterTest` +- r.1-4: `mvn -B -pl webassembly4j-api,wasmtime4j-provider -am test` +- r.1-θ: close commit + +## Guardrails followed + +- No pom.xml version bump +- No CHANGELOG.md edit (deferred to release cut) +- No touch to other provider modules, spi, runtime, testing, pool, spring, + benchmarks, bindgen, component-builder, embedder-generated +- No touch to `LinkingContext.java`, `DefaultLinkingContext.java`, + `WasmtimeModuleAdapter.java` (Charter B territory) +- No push + +## Verification (r.1-4) + +Command (JavaToWasmRoundTripTest + WasmtimeCallableResourceTest excluded — +the former recompiles a 7.8 MB WasmGC calculator per test and stalls in +JniEngine.nativeCompileModule for 8+ min per iteration; the latter needs +a Rust-toolchain-built counter_component.wasm which is skipped when +`-Dskip.rust.build=true`; neither test touches Table code paths): + +``` +mvn -B -pl webassembly4j-api,wasmtime4j-provider -am \ + -Dskip.rust.build=true \ + -Dtest='!JavaToWasmRoundTripTest,!WasmtimeCallableResourceTest' \ + -DfailIfNoTests=false test +``` + +Result: BUILD SUCCESS. + +- webassembly4j-api: 162 tests, 0 failures +- webassembly4j-spi: 18 tests, 0 failures (transitive dep) +- wasmtime4j-provider: 78 tests, 0 failures + - WasmtimeTableAdapterTest: 9 tests, 0 failures (all new) + +Log: `/tmp/charter-a-mvn-test.log`. + +## Native-convention discoveries (fed back to charter) + +- `ai.tegmentum.wasmtime4j.WasmTable#getMaxSize()` returns `-1` when the + table is unbounded (Javadoc-documented at + `~/git/wasmtime4j/wasmtime4j/src/main/java/ai/tegmentum/wasmtime4j/WasmTable.java:79` + and mirrored in the JNI implementation + `~/git/wasmtime4j/wasmtime4j-jni/src/main/java/ai/tegmentum/wasmtime4j/jni/JniTable.java:118`). + Adapter treats any negative return as unbounded. +- `JniTable#get`/`JniTable#set` do NOT throw `IndexOutOfBoundsException` + for out-of-bounds indices; they wrap the underlying + `WasmRuntimeException` in a bare `RuntimeException` (see + `JniTable.java:241-246,266-277`). The adapter therefore bounds-checks + the index against `getSize()` before delegation and raises + `IndexOutOfBoundsException` itself to honour the api-layer contract. +- `JniTable#get`/`JniTable#set` convert a negative index into + `IllegalArgumentException` via `Validation.requireNonNegative`. Same + precheck normalizes this to `IndexOutOfBoundsException`. + +## Follow-ups (candidate Charter A r.2) + +- Release cut. Charter suggests bumping webassembly4j to 2.5.2 (patch) + since this is additive-with-defaults; or 2.6.0 if bundled with + Charter B. Version bump + CHANGELOG update explicitly deferred per + operator invariants. +- `wamr4j-provider` / `graalwasm4j-provider` / `wasm3-provider` / + `endive4j-provider` remain compile-clean via the UOE defaults but + do not yet implement the new operations. If a downstream consumer + needs Table dynamism on those providers, opening focused adapter + arcs is straightforward — the wasmtime adapter is 30 lines. +- The api-layer `Function` interface does not expose `unwrap`. The + new test compensates by reaching the native `WasmFunction` through + the native `Instance` (also unwrap). If future work adds + `Function.unwrap`, `WasmtimeFunctionAdapter` should also implement it. diff --git a/README.md b/README.md index f7c37ff..ed010ec 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A unified Java API for executing WebAssembly across multiple runtimes. -Write your code once against a stable API and swap runtimes without changing application code. WebAssembly4J supports [Wasmtime](https://wasmtime.dev/), [WAMR](https://bytecodealliance.github.io/wamr.dev/), [GraalWasm](https://www.graalvm.org/latest/reference-manual/wasm/), and [Chicory](https://github.com/nicholasgasior/chicory) out of the box. +Write your code once against a stable API and swap runtimes without changing application code. WebAssembly4J supports [Wasmtime](https://wasmtime.dev/), [WAMR](https://bytecodealliance.github.io/wamr.dev/), [GraalWasm](https://www.graalvm.org/latest/reference-manual/wasm/), and [Endive](https://endive.run/) out of the box. ## Quick Start @@ -116,13 +116,13 @@ try (WasmInstance inst = compiled.instantiate()) { | `wasmtime4j-provider` | Wasmtime (via wasmtime4j) | 11+ | 200 | | `graalwasm4j-provider` | GraalWasm (Polyglot API) | 17+ | 150 | | `wamr4j-provider` | WAMR (via wamr4j) | 17+ | 100 | -| `chicory4j-provider` | Chicory (pure Java) | 11+ | 50 | +| `endive4j-provider` | Endive (pure Java) | 11+ | 50 | Providers are discovered automatically via `ServiceLoader`. When multiple providers are on the classpath, the one with the highest priority is selected. To select a specific provider, use the builder: ```java Engine engine = WebAssembly.builder() - .provider("chicory") // "wasmtime", "wamr", "graalwasm", or "chicory" + .provider("endive") // "wasmtime", "wamr", "graalwasm", or "endive" .build(); ``` diff --git a/chicory4j-provider/pom.xml b/chicory4j-provider/pom.xml deleted file mode 100644 index f410892..0000000 --- a/chicory4j-provider/pom.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - 4.0.0 - - - ai.tegmentum.webassembly4j - webassembly4j-parent - 1.5.0 - - - chicory4j-provider - jar - - WebAssembly4J Chicory Provider - Chicory pure-Java engine provider for WebAssembly4J - - - - ai.tegmentum.webassembly4j - webassembly4j-spi - - - com.dylibso.chicory - runtime - - - com.dylibso.chicory - wasm - - - com.dylibso.chicory - wasi - - - com.dylibso.chicory - compiler - true - - - org.junit.jupiter - junit-jupiter - test - - - diff --git a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryConfigs.java b/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryConfigs.java deleted file mode 100644 index 1cb7100..0000000 --- a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryConfigs.java +++ /dev/null @@ -1,42 +0,0 @@ -package ai.tegmentum.webassembly4j.provider.chicory; - -import ai.tegmentum.webassembly4j.api.config.WebAssemblyConfig; -import ai.tegmentum.webassembly4j.api.config.WebAssemblyConfigBuilder; -import ai.tegmentum.webassembly4j.provider.chicory.config.ChicoryConfig; - -import java.util.function.Consumer; - -/** - * Convenience entry point for building a {@link WebAssemblyConfig} pre-wired for the - * Chicory pure-Java engine. - * - *

Examples

- *
{@code
- * // Just Chicory tunings:
- * WebAssemblyConfig cfg = ChicoryConfigs.of(b -> b
- *         .executionMode(ChicoryConfig.ExecutionMode.COMPILE));
- *
- * // Default interpreter + cross-engine settings:
- * WebAssemblyConfig cfg = ChicoryConfigs.builder(b -> {})
- *         .debug(true)
- *         .build();
- * }
- */ -public final class ChicoryConfigs { - - public static final String ENGINE_ID = "chicory"; - - private ChicoryConfigs() {} - - public static WebAssemblyConfig of(Consumer configurator) { - return builder(configurator).build(); - } - - public static WebAssemblyConfigBuilder builder(Consumer configurator) { - ChicoryConfig.Builder cb = ChicoryConfig.builder(); - configurator.accept(cb); - return WebAssemblyConfig.builder() - .engine(ENGINE_ID) - .engineConfig(cb.build()); - } -} diff --git a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryEngineAdapter.java b/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryEngineAdapter.java deleted file mode 100644 index ef2a320..0000000 --- a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryEngineAdapter.java +++ /dev/null @@ -1,90 +0,0 @@ -package ai.tegmentum.webassembly4j.provider.chicory; - -import ai.tegmentum.webassembly4j.api.Component; -import ai.tegmentum.webassembly4j.api.Engine; -import ai.tegmentum.webassembly4j.api.EngineCapabilities; -import ai.tegmentum.webassembly4j.api.EngineInfo; -import ai.tegmentum.webassembly4j.api.Module; -import ai.tegmentum.webassembly4j.api.config.WebAssemblyConfig; -import ai.tegmentum.webassembly4j.api.exception.UnsupportedFeatureException; -import ai.tegmentum.webassembly4j.api.exception.ValidationException; -import ai.tegmentum.webassembly4j.provider.chicory.config.ChicoryConfig; -import com.dylibso.chicory.wasm.Parser; -import com.dylibso.chicory.wasm.WasmModule; - -import java.util.Optional; -import java.util.function.Function; - -final class ChicoryEngineAdapter implements Engine { - - private final WebAssemblyConfig config; - private final ChicoryConfig.ExecutionMode executionMode; - - private ChicoryEngineAdapter(WebAssemblyConfig config, ChicoryConfig.ExecutionMode executionMode) { - this.config = config; - this.executionMode = executionMode; - } - - static ChicoryEngineAdapter create(WebAssemblyConfig config) { - ChicoryConfig.ExecutionMode mode = ChicoryConfig.ExecutionMode.INTERPRET; - if (config != null) { - Optional ec = config.engineConfig(); - if (ec.isPresent() && ec.get() instanceof ChicoryConfig) { - mode = ((ChicoryConfig) ec.get()).executionMode(); - } - } - return new ChicoryEngineAdapter(config, mode); - } - - @Override - public EngineInfo info() { - return new ChicoryEngineInfo(); - } - - @Override - public EngineCapabilities capabilities() { - return new ChicoryEngineCapabilities(); - } - - @Override - public Module loadModule(byte[] bytes) { - try { - WasmModule wasmModule = Parser.parse(bytes); - Function builderFactory = - createBuilderFactory(wasmModule); - return new ChicoryModuleAdapter(wasmModule, builderFactory); - } catch (Exception e) { - throw new ValidationException("Failed to load WebAssembly module", e); - } - } - - private Function createBuilderFactory( - WasmModule wasmModule) { - if (executionMode == ChicoryConfig.ExecutionMode.COMPILE) { - return CompilerSupport.createCompilingBuilderFactory(); - } - return m -> com.dylibso.chicory.runtime.Instance.builder(m); - } - - @Override - public Component loadComponent(byte[] bytes) { - throw new UnsupportedFeatureException( - "Component model is not supported by Chicory"); - } - - @Override - public Optional extension(Class extensionType) { - return Optional.empty(); - } - - @Override - @SuppressWarnings("unchecked") - public Optional unwrap(Class nativeType) { - return Optional.empty(); - } - - @Override - public void close() { - // No native resources to release - } -} diff --git a/chicory4j-provider/src/main/resources/META-INF/services/ai.tegmentum.webassembly4j.spi.EngineProvider b/chicory4j-provider/src/main/resources/META-INF/services/ai.tegmentum.webassembly4j.spi.EngineProvider deleted file mode 100644 index 080a37a..0000000 --- a/chicory4j-provider/src/main/resources/META-INF/services/ai.tegmentum.webassembly4j.spi.EngineProvider +++ /dev/null @@ -1 +0,0 @@ -ai.tegmentum.webassembly4j.provider.chicory.ChicoryProvider diff --git a/chicory4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryConfigsTest.java b/chicory4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryConfigsTest.java deleted file mode 100644 index f6e7b77..0000000 --- a/chicory4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryConfigsTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package ai.tegmentum.webassembly4j.provider.chicory; - -import ai.tegmentum.webassembly4j.api.config.WebAssemblyConfig; -import ai.tegmentum.webassembly4j.provider.chicory.config.ChicoryConfig; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -class ChicoryConfigsTest { - - @Test - void ofWiresEngineAndEngineConfig() { - WebAssemblyConfig cfg = ChicoryConfigs.of(b -> b - .executionMode(ChicoryConfig.ExecutionMode.COMPILE)); - - assertEquals("chicory", cfg.engineId().orElse(null)); - assertTrue(cfg.engineConfig().isPresent()); - ChicoryConfig cc = (ChicoryConfig) cfg.engineConfig().get(); - assertEquals(ChicoryConfig.ExecutionMode.COMPILE, cc.executionMode()); - } - - @Test - void builderHandsOffToCommonSettings() { - WebAssemblyConfig cfg = ChicoryConfigs.builder(b -> {}) - .debug(true) - .build(); - - assertEquals("chicory", cfg.engineId().orElse(null)); - assertTrue(cfg.commonConfig().debug().orElse(false)); - ChicoryConfig cc = (ChicoryConfig) cfg.engineConfig().get(); - assertEquals(ChicoryConfig.ExecutionMode.INTERPRET, cc.executionMode()); - } - - @Test - void engineIdConstantMatchesProviderDescriptor() { - assertEquals(new ChicoryProvider().descriptor().engineId(), ChicoryConfigs.ENGINE_ID); - } -} diff --git a/endive4j-provider/pom.xml b/endive4j-provider/pom.xml new file mode 100644 index 0000000..6c0fdb6 --- /dev/null +++ b/endive4j-provider/pom.xml @@ -0,0 +1,92 @@ + + + 4.0.0 + + + ai.tegmentum.webassembly4j + webassembly4j-parent + 2.5.2-SNAPSHOT + + + endive4j-provider + jar + + WebAssembly4J Endive Provider + Endive pure-Java engine provider for WebAssembly4J + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + ${wasmcm.runtime.guest.wasm.path} + + + + + + + + + ${user.home}/git/wasm-cm/target/wasm32-unknown-unknown/release/wasmcm_runtime_guest.wasm + 0.1.0-SNAPSHOT + + + + + ai.tegmentum.webassembly4j + webassembly4j-spi + + + run.endive + runtime + + + run.endive + wasm + + + run.endive + wasi + + + run.endive + compiler + true + + + + run.tegmentum.wasmcm + wasmcm-endive-adapter + ${wasmcm-endive-adapter.version} + + + org.junit.jupiter + junit-jupiter + test + + + diff --git a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/CompilerSupport.java b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/CompilerSupport.java similarity index 51% rename from chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/CompilerSupport.java rename to endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/CompilerSupport.java index 7c38dc1..9d2fe64 100644 --- a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/CompilerSupport.java +++ b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/CompilerSupport.java @@ -1,29 +1,29 @@ -package ai.tegmentum.webassembly4j.provider.chicory; +package ai.tegmentum.webassembly4j.provider.endive; import ai.tegmentum.webassembly4j.api.exception.UnsupportedFeatureException; -import com.dylibso.chicory.wasm.WasmModule; +import run.endive.wasm.WasmModule; import java.util.function.Function; /** - * Provides runtime compilation support via the optional {@code chicory-compiler} dependency. + * Provides runtime compilation support via the optional {@code endive-compiler} dependency. *

- * Uses reflection to avoid a hard dependency on {@code com.dylibso.chicory:compiler}, - * which is only needed when {@link ai.tegmentum.webassembly4j.provider.chicory.config.ChicoryConfig.ExecutionMode#COMPILE} + * Uses reflection to avoid a hard dependency on {@code run.endive:compiler}, + * which is only needed when {@link ai.tegmentum.webassembly4j.provider.endive.config.EndiveConfig.ExecutionMode#COMPILE} * is selected. */ final class CompilerSupport { - private static final String COMPILER_CLASS = "com.dylibso.chicory.compiler.MachineFactoryCompiler"; + private static final String COMPILER_CLASS = "run.endive.compiler.MachineFactoryCompiler"; private CompilerSupport() {} - static Function createCompilingBuilderFactory() { + static Function createCompilingBuilderFactory() { try { Class.forName(COMPILER_CLASS); } catch (ClassNotFoundException e) { throw new UnsupportedFeatureException( - "Runtime compilation requires com.dylibso.chicory:compiler on the classpath. " + + "Runtime compilation requires run.endive:compiler on the classpath. " + "Add the dependency or use ExecutionMode.INTERPRET."); } return CompilerBridge::createBuilder; @@ -34,10 +34,10 @@ static Function create * when actually needed (after the class presence check). */ private static final class CompilerBridge { - static com.dylibso.chicory.runtime.Instance.Builder createBuilder(WasmModule module) { - return com.dylibso.chicory.runtime.Instance.builder(module) + static run.endive.runtime.Instance.Builder createBuilder(WasmModule module) { + return run.endive.runtime.Instance.builder(module) .withMachineFactory( - com.dylibso.chicory.compiler.MachineFactoryCompiler::compile); + run.endive.compiler.MachineFactoryCompiler::compile); } } } diff --git a/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveComponentAdapter.java b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveComponentAdapter.java new file mode 100644 index 0000000..80c490e --- /dev/null +++ b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveComponentAdapter.java @@ -0,0 +1,203 @@ +package ai.tegmentum.webassembly4j.provider.endive; + +import ai.tegmentum.webassembly4j.api.Component; +import ai.tegmentum.webassembly4j.api.ComponentInstance; +import ai.tegmentum.webassembly4j.api.LinkingContext; +import ai.tegmentum.webassembly4j.api.WitHostFunctionDefinition; +import ai.tegmentum.webassembly4j.api.exception.InstantiationException; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import run.tegmentum.wasmcm.endive.HostCallbackDispatcher; +import run.tegmentum.wasmcm.endive.HostCallbackException; +import run.tegmentum.wasmcm.endive.WasmcmRuntimeGuestLoader; + +/** + * SPI-facing {@link Component} that layers the Component Model on top of the + * unmodified Endive core-Wasm engine via the {@code wasmcm_runtime_guest.wasm} + * portable Rust runtime. + * + *

The heavy lifting happens in {@link WasmcmRuntimeGuestLoader}: the CM + * component bytes have already been parsed through the guest by the time this + * adapter is constructed (see {@link EndiveEngineAdapter#loadComponent(byte[])}) + * — this class just adapts the {@link Component#instantiate(LinkingContext)} + * surface onto {@code wasmcm_instantiate_v2} / + * {@code wasmcm_instantiate_with_imports_v2} and hands back an + * {@link EndiveComponentInstanceAdapter}. + * + *

Linking context translation

+ * + *

{@link LinkingContext#witHostFunctions()} entries are grouped by their + * WIT-path interface prefix (everything before the {@code '#'}), each group + * registered as a host provider via + * {@link WasmcmRuntimeGuestLoader#registerHostProvider(String, int)}. A single + * {@link HostCallbackDispatcher} per provider routes the guest's + * {@code wasmcm_host_call} extern into the caller's Java implementations by + * function index. + * + *

Function-index order matches the order in which the caller added the + * {@link WitHostFunctionDefinition}s to the linking context — the WIT interface + * is not read back from the component here, so the caller controls the mapping. + * This is stable and deterministic; callers building against a specific WIT + * world should add their host functions in the WIT interface's declared order. + * + *

Note on runtime-guest gaps at this milestone

+ * + *

Registering a host provider only records intent inside the runtime guest; + * the Wall 2 chain that routes a {@code canon lower} against a component-level + * import through {@code wasmcm_host_call} is a work in progress on the wasm-cm + * Rust side (see {@code HostCallbackDispatcher} javadoc for the specific gap). + * When a component that imports interfaces the runtime cannot yet route is + * instantiated, {@code wasmcm_instantiate_with_imports_v2} surfaces + * {@code HOST_ERR_UNLINKABLE} — this adapter re-throws as + * {@link InstantiationException} with the guest's diagnostic message, matching + * the "no fake success" contract. + */ +final class EndiveComponentAdapter implements Component { + + private final WasmcmRuntimeGuestLoader loader; + private final long componentHandle; + private final boolean ownsLoader; + private volatile boolean closed; + + EndiveComponentAdapter( + WasmcmRuntimeGuestLoader loader, long componentHandle, boolean ownsLoader) { + this.loader = loader; + this.componentHandle = componentHandle; + this.ownsLoader = ownsLoader; + } + + @Override + public ComponentInstance instantiate() { + return instantiate((LinkingContext) null); + } + + @Override + public ComponentInstance instantiate(LinkingContext linkingContext) { + checkOpen(); + try { + long instanceHandle; + if (linkingContext == null) { + instanceHandle = loader.instantiate(componentHandle); + } else { + Map imports = registerImports(linkingContext); + if (imports.isEmpty()) { + instanceHandle = loader.instantiate(componentHandle); + } else { + instanceHandle = loader.instantiateWithImports(componentHandle, imports); + } + } + return new EndiveComponentInstanceAdapter(loader, instanceHandle); + } catch (IllegalStateException e) { + throw new InstantiationException( + "Failed to instantiate Component Model component via wasmcm runtime guest: " + + e.getMessage(), + e); + } + } + + /** + * Bind each unique WIT-path interface prefix declared by the linking + * context's {@link LinkingContext#witHostFunctions()} to a provider handle + * and route the guest's {@code wasmcm_host_call} extern to the caller's Java + * implementations. Function index within a provider is the caller's + * insertion order. + */ + private Map registerImports(LinkingContext linkingContext) { + List defs = linkingContext.witHostFunctions(); + // Group by interface prefix (the segment before the final '#'). + Map> byInterface = new LinkedHashMap<>(); + for (WitHostFunctionDefinition def : defs) { + String iface = interfacePrefix(def.witPath()); + byInterface.computeIfAbsent(iface, k -> new java.util.ArrayList<>()).add(def); + } + Map providers = new LinkedHashMap<>(); + for (Map.Entry> e : byInterface.entrySet()) { + String iface = e.getKey(); + java.util.List funcs = e.getValue(); + long providerHandle = loader.registerHostProvider(iface, funcs.size()); + loader.registerHostCallback(providerHandle, new WitDispatcher(funcs)); + providers.put(iface, providerHandle); + } + return providers; + } + + private static String interfacePrefix(String witPath) { + int hash = witPath.indexOf('#'); + return hash < 0 ? "" : witPath.substring(0, hash); + } + + /** + * Routes a {@code wasmcm_host_call} for a given interface into the WIT host + * function at the guest-supplied {@code funcIdx}. Argument / result codec + * matches {@code wasmcm_runtime_guest::codec}. + */ + private static final class WitDispatcher implements HostCallbackDispatcher { + + private final List funcs; + + WitDispatcher(List funcs) { + this.funcs = Collections.unmodifiableList(new java.util.ArrayList<>(funcs)); + } + + @Override + public byte[] dispatch(int funcIdx, byte[] argFrame) throws HostCallbackException { + if (funcIdx < 0 || funcIdx >= funcs.size()) { + throw new HostCallbackException( + "no WIT host function registered at index " + funcIdx + + " (interface has " + funcs.size() + " slots)"); + } + WitHostFunctionDefinition def = funcs.get(funcIdx); + List decoded = WasmcmValueCodec.decodeFrame(argFrame); + Object[] argsArr = decoded.toArray(new Object[0]); + Object[] results; + try { + results = def.function().execute(argsArr); + } catch (RuntimeException e) { + throw new HostCallbackException( + "WIT host function '" + def.witPath() + "' threw: " + e.getMessage()); + } + if (results == null) { + results = new Object[0]; + } + return WasmcmValueCodec.encodeFrame(results); + } + } + + @Override + public List exportedInterfaces() { + // The runtime guest surfaces exports only after instantiation (they are + // properties of the instance, not the parsed component handle at this + // milestone); return empty at the pre-instantiation Component level so + // callers know to instantiate first. + return Collections.emptyList(); + } + + @Override + public List importedInterfaces() { + return Collections.emptyList(); + } + + private void checkOpen() { + if (closed) { + throw new IllegalStateException("EndiveComponentAdapter already closed"); + } + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + if (ownsLoader) { + // The loader owns an Endive Instance running the runtime guest; + // Endive Instances currently do not expose a close() (they release + // when GC'd), so the ownership boundary is intentionally soft here. + // Left as a no-op — the JVM will collect the companion instance + // with the loader. + } + } +} diff --git a/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveComponentInstanceAdapter.java b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveComponentInstanceAdapter.java new file mode 100644 index 0000000..71d66e2 --- /dev/null +++ b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveComponentInstanceAdapter.java @@ -0,0 +1,177 @@ +package ai.tegmentum.webassembly4j.provider.endive; + +import ai.tegmentum.webassembly4j.api.ComponentInstance; +import ai.tegmentum.webassembly4j.api.Function; +import ai.tegmentum.webassembly4j.api.Global; +import ai.tegmentum.webassembly4j.api.Memory; +import ai.tegmentum.webassembly4j.api.Table; +import ai.tegmentum.webassembly4j.api.exception.ExecutionException; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import run.endive.runtime.ExportFunction; +import run.tegmentum.wasmcm.endive.WasmcmRuntimeGuestLoader; + +/** + * SPI-facing {@link ComponentInstance} whose entire concrete implementation is + * layered on top of {@code wasmcm_runtime_guest.wasm} running inside an Endive + * companion instance. All Component Model semantics — export dispatch, canon + * lift/lower, resource tables — live in the guest blob; the adapter only + * marshals values across the wire and dispatches guest exports. + * + *

Instances are minted by {@link EndiveComponentAdapter#instantiate()} + * / {@link EndiveComponentAdapter#instantiate(ai.tegmentum.webassembly4j.api.LinkingContext)} + * and hold the u32 instance handle the guest returned. Multiple instances of + * the same {@code Component} share the same {@link WasmcmRuntimeGuestLoader}. + */ +final class EndiveComponentInstanceAdapter implements ComponentInstance { + + private final WasmcmRuntimeGuestLoader loader; + private final long instanceHandle; + + EndiveComponentInstanceAdapter(WasmcmRuntimeGuestLoader loader, long instanceHandle) { + this.loader = loader; + this.instanceHandle = instanceHandle; + } + + @Override + public Object invoke(String functionName, Object... args) { + Object[] safeArgs = args == null ? new Object[0] : args; + byte[] argFrame; + try { + argFrame = safeArgs.length == 0 + ? new byte[0] + : WasmcmValueCodec.encodeFrame(safeArgs); + } catch (RuntimeException e) { + throw new ExecutionException( + "Failed to marshal arguments for component export '" + functionName + "'", e); + } + byte[] resultFrame; + try { + resultFrame = loader.callExport(instanceHandle, functionName, argFrame); + } catch (IllegalStateException e) { + throw new ExecutionException( + "Component export '" + functionName + "' failed: " + e.getMessage(), e); + } + List results; + try { + results = WasmcmValueCodec.decodeFrame(resultFrame); + } catch (RuntimeException e) { + throw new ExecutionException( + "Failed to decode results for component export '" + functionName + "'", e); + } + if (results.isEmpty()) { + return null; + } + if (results.size() == 1) { + return results.get(0); + } + return results; + } + + @Override + public boolean hasFunction(String name) { + for (String exp : exportedFunctions()) { + if (exp.equals(name)) { + return true; + } + } + return false; + } + + @Override + public List exportedFunctions() { + // The runtime guest exposes the flattened export list of the instantiated + // component through wasmcm_instance_export_count / wasmcm_instance_export_name. + // These are string names as the component itself surfaces them (function names + // for root-level exports; interface-qualified names for interface exports). + ExportFunction countFn = loader.guest().exports().function("wasmcm_instance_export_count"); + ExportFunction nameFn = loader.guest().exports().function("wasmcm_instance_export_name"); + if (countFn == null || nameFn == null) { + return Collections.emptyList(); + } + run.endive.runtime.Memory mem = loader.guest().exports().memory("memory"); + ExportFunction alloc = loader.guest().exports().function("wasmcm_alloc"); + ExportFunction free = loader.guest().exports().function("wasmcm_free"); + long[] countResult = countFn.apply(instanceHandle); + int count = (int) (countResult[0] & 0xFFFFFFFFL); + if (count == 0) { + return Collections.emptyList(); + } + int bufCap = 512; + long[] allocResult = alloc.apply((long) bufCap, 1L); + int bufPtr = (int) (allocResult[0] & 0xFFFFFFFFL); + if (bufPtr == 0) { + return Collections.emptyList(); + } + try { + java.util.List out = new java.util.ArrayList<>(count); + for (int i = 0; i < count; i++) { + long[] r = nameFn.apply(instanceHandle, (long) i, (long) bufPtr, (long) bufCap); + int n = (int) (r[0] & 0xFFFFFFFFL); + if (n == 0) { + continue; + } + byte[] bytes = mem.readBytes(bufPtr, n); + out.add(new String(bytes, StandardCharsets.UTF_8)); + } + return Collections.unmodifiableList(out); + } finally { + free.apply((long) bufPtr, (long) bufCap); + } + } + + @Override + public List exportedInterfaces() { + // The runtime guest's flattened export list mingles interface-qualified + // function exports (e.g. "wasi:clocks/monotonic-clock@0.2.3#now") with + // root-level ones. Extract the interface prefixes to give callers a + // provider-agnostic view of "which interfaces does this instance surface". + java.util.LinkedHashSet ifaces = new java.util.LinkedHashSet<>(); + for (String name : exportedFunctions()) { + int hash = name.indexOf('#'); + if (hash > 0) { + ifaces.add(name.substring(0, hash)); + } + } + return java.util.Collections.unmodifiableList(new java.util.ArrayList<>(ifaces)); + } + + @Override + public boolean exportsInterface(String name) { + return exportedInterfaces().contains(name); + } + + @Override + public Optional function(String name) { + // Component instances do not surface core-module exports; the SPI's + // ComponentInstance javadoc documents this contract explicitly. + return Optional.empty(); + } + + @Override + public Optional memory(String name) { + return Optional.empty(); + } + + @Override + public Optional table(String name) { + return Optional.empty(); + } + + @Override + public Optional global(String name) { + return Optional.empty(); + } + + @Override + @SuppressWarnings("unchecked") + public Optional unwrap(Class nativeType) { + if (nativeType.isInstance(loader)) { + return Optional.of((T) loader); + } + return Optional.empty(); + } +} diff --git a/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveConfigs.java b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveConfigs.java new file mode 100644 index 0000000..ac684a2 --- /dev/null +++ b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveConfigs.java @@ -0,0 +1,42 @@ +package ai.tegmentum.webassembly4j.provider.endive; + +import ai.tegmentum.webassembly4j.api.config.WebAssemblyConfig; +import ai.tegmentum.webassembly4j.api.config.WebAssemblyConfigBuilder; +import ai.tegmentum.webassembly4j.provider.endive.config.EndiveConfig; + +import java.util.function.Consumer; + +/** + * Convenience entry point for building a {@link WebAssemblyConfig} pre-wired for the + * Endive pure-Java engine. + * + *

Examples

+ *
{@code
+ * // Just Endive tunings:
+ * WebAssemblyConfig cfg = EndiveConfigs.of(b -> b
+ *         .executionMode(EndiveConfig.ExecutionMode.COMPILE));
+ *
+ * // Default interpreter + cross-engine settings:
+ * WebAssemblyConfig cfg = EndiveConfigs.builder(b -> {})
+ *         .debug(true)
+ *         .build();
+ * }
+ */ +public final class EndiveConfigs { + + public static final String ENGINE_ID = "endive"; + + private EndiveConfigs() {} + + public static WebAssemblyConfig of(Consumer configurator) { + return builder(configurator).build(); + } + + public static WebAssemblyConfigBuilder builder(Consumer configurator) { + EndiveConfig.Builder cb = EndiveConfig.builder(); + configurator.accept(cb); + return WebAssemblyConfig.builder() + .engine(ENGINE_ID) + .engineConfig(cb.build()); + } +} diff --git a/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveEngineAdapter.java b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveEngineAdapter.java new file mode 100644 index 0000000..827ed96 --- /dev/null +++ b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveEngineAdapter.java @@ -0,0 +1,107 @@ +package ai.tegmentum.webassembly4j.provider.endive; + +import ai.tegmentum.webassembly4j.api.Component; +import ai.tegmentum.webassembly4j.api.Engine; +import ai.tegmentum.webassembly4j.api.EngineCapabilities; +import ai.tegmentum.webassembly4j.api.EngineInfo; +import ai.tegmentum.webassembly4j.api.Module; +import ai.tegmentum.webassembly4j.api.config.WebAssemblyConfig; +import ai.tegmentum.webassembly4j.api.exception.UnsupportedFeatureException; +import ai.tegmentum.webassembly4j.api.exception.ValidationException; +import ai.tegmentum.webassembly4j.provider.endive.config.EndiveConfig; +import run.endive.wasm.Parser; +import run.endive.wasm.WasmModule; +import run.tegmentum.wasmcm.endive.WasmcmRuntimeGuestLoader; + +import java.util.Optional; +import java.util.function.Function; + +final class EndiveEngineAdapter implements Engine { + + private final WebAssemblyConfig config; + private final EndiveConfig.ExecutionMode executionMode; + + private EndiveEngineAdapter(WebAssemblyConfig config, EndiveConfig.ExecutionMode executionMode) { + this.config = config; + this.executionMode = executionMode; + } + + static EndiveEngineAdapter create(WebAssemblyConfig config) { + EndiveConfig.ExecutionMode mode = EndiveConfig.ExecutionMode.INTERPRET; + if (config != null) { + Optional ec = config.engineConfig(); + if (ec.isPresent() && ec.get() instanceof EndiveConfig) { + mode = ((EndiveConfig) ec.get()).executionMode(); + } + } + return new EndiveEngineAdapter(config, mode); + } + + @Override + public EngineInfo info() { + return new EndiveEngineInfo(); + } + + @Override + public EngineCapabilities capabilities() { + return new EndiveEngineCapabilities(); + } + + @Override + public Module loadModule(byte[] bytes) { + try { + WasmModule wasmModule = Parser.parse(bytes); + Function builderFactory = + createBuilderFactory(wasmModule); + return new EndiveModuleAdapter(wasmModule, builderFactory); + } catch (Exception e) { + throw new ValidationException("Failed to load WebAssembly module", e); + } + } + + private Function createBuilderFactory( + WasmModule wasmModule) { + if (executionMode == EndiveConfig.ExecutionMode.COMPILE) { + return CompilerSupport.createCompilingBuilderFactory(); + } + return m -> run.endive.runtime.Instance.builder(m); + } + + @Override + public Component loadComponent(byte[] bytes) { + byte[] guestWasm = WasmcmGuestBlobLocator.locateOrNull(); + if (guestWasm == null) { + throw new UnsupportedFeatureException( + "Component Model support requires wasmcm_runtime_guest.wasm; provide it via " + + WasmcmGuestBlobLocator.describeLookupOrder()); + } + try { + WasmcmRuntimeGuestLoader loader = new WasmcmRuntimeGuestLoader(guestWasm); + long componentHandle = loader.parseComponent(bytes); + return new EndiveComponentAdapter(loader, componentHandle, true); + } catch (IllegalStateException e) { + throw new ValidationException( + "Failed to parse Component Model binary through wasmcm runtime guest: " + + e.getMessage(), + e); + } catch (RuntimeException e) { + throw new ValidationException("Failed to load Component Model binary", e); + } + } + + @Override + public Optional extension(Class extensionType) { + return Optional.empty(); + } + + @Override + @SuppressWarnings("unchecked") + public Optional unwrap(Class nativeType) { + return Optional.empty(); + } + + @Override + public void close() { + // No native resources to release + } +} diff --git a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryEngineCapabilities.java b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveEngineCapabilities.java similarity index 71% rename from chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryEngineCapabilities.java rename to endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveEngineCapabilities.java index 97976f0..7242b13 100644 --- a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryEngineCapabilities.java +++ b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveEngineCapabilities.java @@ -1,8 +1,8 @@ -package ai.tegmentum.webassembly4j.provider.chicory; +package ai.tegmentum.webassembly4j.provider.endive; import ai.tegmentum.webassembly4j.api.EngineCapabilities; -final class ChicoryEngineCapabilities implements EngineCapabilities { +final class EndiveEngineCapabilities implements EngineCapabilities { @Override public boolean supportsCoreModules() { @@ -11,7 +11,9 @@ public boolean supportsCoreModules() { @Override public boolean supportsComponents() { - return false; + // Component Model is layered in via wasmcm_runtime_guest.wasm; concretely + // available whenever the guest blob is resolvable at loadComponent time. + return WasmcmGuestBlobLocator.locateOrNull() != null; } @Override diff --git a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryEngineInfo.java b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveEngineInfo.java similarity index 72% rename from chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryEngineInfo.java rename to endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveEngineInfo.java index cb2194b..0e519fa 100644 --- a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryEngineInfo.java +++ b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveEngineInfo.java @@ -1,17 +1,17 @@ -package ai.tegmentum.webassembly4j.provider.chicory; +package ai.tegmentum.webassembly4j.provider.endive; import ai.tegmentum.webassembly4j.api.EngineInfo; -final class ChicoryEngineInfo implements EngineInfo { +final class EndiveEngineInfo implements EngineInfo { @Override public String engineId() { - return "chicory"; + return "endive"; } @Override public String providerId() { - return "chicory"; + return "endive"; } @Override diff --git a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryFunctionAdapter.java b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveFunctionAdapter.java similarity index 79% rename from chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryFunctionAdapter.java rename to endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveFunctionAdapter.java index 9f79d42..69ea7d5 100644 --- a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryFunctionAdapter.java +++ b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveFunctionAdapter.java @@ -1,16 +1,16 @@ -package ai.tegmentum.webassembly4j.provider.chicory; +package ai.tegmentum.webassembly4j.provider.endive; import ai.tegmentum.webassembly4j.api.Function; import ai.tegmentum.webassembly4j.api.ValueType; import ai.tegmentum.webassembly4j.api.exception.ExecutionException; import ai.tegmentum.webassembly4j.api.exception.TrapException; -import com.dylibso.chicory.runtime.ExportFunction; -import com.dylibso.chicory.wasm.types.FunctionType; -import com.dylibso.chicory.wasm.types.ValType; +import run.endive.runtime.ExportFunction; +import run.endive.wasm.types.FunctionType; +import run.endive.wasm.types.ValType; import java.util.List; -final class ChicoryFunctionAdapter implements Function { +final class EndiveFunctionAdapter implements Function { private static final long[] EMPTY_LONGS = new long[0]; @@ -24,7 +24,7 @@ private enum FastPath { V_V, V_I, I_I, II_I, I_V, J_J, II_V, I_J, D_D, V_J, GENE private final long[] argScratch; private final FastPath fastPath; - ChicoryFunctionAdapter(ExportFunction nativeFunction, FunctionType functionType) { + EndiveFunctionAdapter(ExportFunction nativeFunction, FunctionType functionType) { this.nativeFunction = nativeFunction; this.functionType = functionType; this.cachedParamTypes = functionType.params(); @@ -116,14 +116,14 @@ public Object invoke(Object... args) { argScratch[0] = ((Number) args[0]).longValue(); return nativeFunction.apply(argScratch)[0]; case D_D: - argScratch[0] = com.dylibso.chicory.wasm.types.Value.doubleToLong( + argScratch[0] = run.endive.wasm.types.Value.doubleToLong( ((Number) args[0]).doubleValue()); - return com.dylibso.chicory.wasm.types.Value.longToDouble( + return run.endive.wasm.types.Value.longToDouble( nativeFunction.apply(argScratch)[0]); default: return invokeGeneric(args); } - } catch (com.dylibso.chicory.runtime.TrapException e) { + } catch (run.endive.runtime.TrapException e) { throw new TrapException(e.getMessage(), e); } catch (Exception e) { if (e instanceof ExecutionException) throw (ExecutionException) e; @@ -164,9 +164,9 @@ private long convertToLong(Object value, ValType type) { } else if (ValType.I64.equals(type)) { return num.longValue(); } else if (ValType.F32.equals(type)) { - return com.dylibso.chicory.wasm.types.Value.floatToLong(num.floatValue()); + return run.endive.wasm.types.Value.floatToLong(num.floatValue()); } else if (ValType.F64.equals(type)) { - return com.dylibso.chicory.wasm.types.Value.doubleToLong(num.doubleValue()); + return run.endive.wasm.types.Value.doubleToLong(num.doubleValue()); } else { throw new ExecutionException("Unsupported parameter type: " + type); } @@ -178,30 +178,30 @@ private Object extractValue(long raw, ValType type) { } else if (ValType.I64.equals(type)) { return raw; } else if (ValType.F32.equals(type)) { - return com.dylibso.chicory.wasm.types.Value.longToFloat(raw); + return run.endive.wasm.types.Value.longToFloat(raw); } else if (ValType.F64.equals(type)) { - return com.dylibso.chicory.wasm.types.Value.longToDouble(raw); + return run.endive.wasm.types.Value.longToDouble(raw); } else { return raw; } } - private static ValueType[] convertTypes(List chicoryTypes) { - ValueType[] types = new ValueType[chicoryTypes.size()]; - for (int i = 0; i < chicoryTypes.size(); i++) { - types[i] = convertType(chicoryTypes.get(i)); + private static ValueType[] convertTypes(List endiveTypes) { + ValueType[] types = new ValueType[endiveTypes.size()]; + for (int i = 0; i < endiveTypes.size(); i++) { + types[i] = convertType(endiveTypes.get(i)); } return types; } - private static ValueType convertType(ValType chicoryType) { - if (chicoryType .equals(ValType.I32)) return ValueType.I32; - if (chicoryType .equals(ValType.I64)) return ValueType.I64; - if (chicoryType .equals(ValType.F32)) return ValueType.F32; - if (chicoryType .equals(ValType.F64)) return ValueType.F64; - if (chicoryType .equals(ValType.V128)) return ValueType.V128; - if (chicoryType .equals(ValType.FuncRef)) return ValueType.FUNCREF; - if (chicoryType .equals(ValType.ExternRef)) return ValueType.EXTERNREF; - throw new IllegalArgumentException("Unknown type: " + chicoryType); + private static ValueType convertType(ValType endiveType) { + if (endiveType .equals(ValType.I32)) return ValueType.I32; + if (endiveType .equals(ValType.I64)) return ValueType.I64; + if (endiveType .equals(ValType.F32)) return ValueType.F32; + if (endiveType .equals(ValType.F64)) return ValueType.F64; + if (endiveType .equals(ValType.V128)) return ValueType.V128; + if (endiveType .equals(ValType.FuncRef)) return ValueType.FUNCREF; + if (endiveType .equals(ValType.ExternRef)) return ValueType.EXTERNREF; + throw new IllegalArgumentException("Unknown type: " + endiveType); } } diff --git a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryGcExtension.java b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveGcExtension.java similarity index 84% rename from chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryGcExtension.java rename to endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveGcExtension.java index 980ae01..27cdce4 100644 --- a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryGcExtension.java +++ b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveGcExtension.java @@ -1,4 +1,4 @@ -package ai.tegmentum.webassembly4j.provider.chicory; +package ai.tegmentum.webassembly4j.provider.endive; import ai.tegmentum.webassembly4j.api.gc.GcArrayInstance; import ai.tegmentum.webassembly4j.api.gc.GcArrayType; @@ -12,23 +12,23 @@ import ai.tegmentum.webassembly4j.api.gc.GcStructInstance; import ai.tegmentum.webassembly4j.api.gc.GcStructType; import ai.tegmentum.webassembly4j.api.gc.GcValue; -import com.dylibso.chicory.runtime.WasmArray; -import com.dylibso.chicory.runtime.WasmI31Ref; -import com.dylibso.chicory.runtime.WasmStruct; -import com.dylibso.chicory.wasm.types.Value; +import run.endive.runtime.WasmArray; +import run.endive.runtime.WasmI31Ref; +import run.endive.runtime.WasmStruct; +import run.endive.wasm.types.Value; import java.util.List; import java.util.concurrent.atomic.AtomicLong; /** - * Chicory implementation of the GC extension. + * Endive implementation of the GC extension. * - *

Chicory GC objects are plain Java objects allocated on the JVM heap. - * Values are encoded as {@code long} using Chicory's {@link Value} helpers. + *

Endive GC objects are plain Java objects allocated on the JVM heap. + * Values are encoded as {@code long} using Endive's {@link Value} helpers. * A synthetic type index starting from 0x10000 is used for programmatically * created types to avoid collision with module type sections. */ -final class ChicoryGcExtension implements GcExtension { +final class EndiveGcExtension implements GcExtension { private static final int SYNTHETIC_TYPE_BASE = 0x10000; @@ -48,7 +48,7 @@ public GcStructInstance createStruct(GcStructType type, GcValue... values) { int typeIdx = (int) nextTypeIndex.getAndIncrement(); WasmStruct nativeStruct = new WasmStruct(typeIdx, fields); allocationCount.incrementAndGet(); - return new ChicoryGcStructInstance(nativeStruct, type); + return new EndiveGcStructInstance(nativeStruct, type); } @Override @@ -66,7 +66,7 @@ public GcArrayInstance createArray(GcArrayType type, GcValue... elements) { int typeIdx = (int) nextTypeIndex.getAndIncrement(); WasmArray nativeArray = new WasmArray(typeIdx, encoded); allocationCount.incrementAndGet(); - return new ChicoryGcArrayInstance(nativeArray, type); + return new EndiveGcArrayInstance(nativeArray, type); } @Override @@ -83,14 +83,14 @@ public GcArrayInstance createArray(GcArrayType type, int length) { int typeIdx = (int) nextTypeIndex.getAndIncrement(); WasmArray nativeArray = new WasmArray(typeIdx, encoded); allocationCount.incrementAndGet(); - return new ChicoryGcArrayInstance(nativeArray, type); + return new EndiveGcArrayInstance(nativeArray, type); } @Override public GcI31Instance createI31(int value) { WasmI31Ref nativeRef = new WasmI31Ref(value); allocationCount.incrementAndGet(); - return new ChicoryGcI31Instance(nativeRef); + return new EndiveGcI31Instance(nativeRef); } @Override @@ -162,12 +162,12 @@ private static GcValue decodeValue(long raw, GcFieldType fieldType) { // --- Inner adapter classes --- - private static final class ChicoryGcStructInstance implements GcStructInstance { + private static final class EndiveGcStructInstance implements GcStructInstance { private final WasmStruct nativeStruct; private final GcStructType structType; - ChicoryGcStructInstance(WasmStruct nativeStruct, GcStructType structType) { + EndiveGcStructInstance(WasmStruct nativeStruct, GcStructType structType) { this.nativeStruct = nativeStruct; this.structType = structType; } @@ -214,19 +214,19 @@ public boolean isNull() { @Override public boolean refEquals(GcObject other) { - if (!(other instanceof ChicoryGcStructInstance)) { + if (!(other instanceof EndiveGcStructInstance)) { return false; } - return this.nativeStruct == ((ChicoryGcStructInstance) other).nativeStruct; + return this.nativeStruct == ((EndiveGcStructInstance) other).nativeStruct; } } - private static final class ChicoryGcArrayInstance implements GcArrayInstance { + private static final class EndiveGcArrayInstance implements GcArrayInstance { private final WasmArray nativeArray; private final GcArrayType arrayType; - ChicoryGcArrayInstance(WasmArray nativeArray, GcArrayType arrayType) { + EndiveGcArrayInstance(WasmArray nativeArray, GcArrayType arrayType) { this.nativeArray = nativeArray; this.arrayType = arrayType; } @@ -271,18 +271,18 @@ public boolean isNull() { @Override public boolean refEquals(GcObject other) { - if (!(other instanceof ChicoryGcArrayInstance)) { + if (!(other instanceof EndiveGcArrayInstance)) { return false; } - return this.nativeArray == ((ChicoryGcArrayInstance) other).nativeArray; + return this.nativeArray == ((EndiveGcArrayInstance) other).nativeArray; } } - private static final class ChicoryGcI31Instance implements GcI31Instance { + private static final class EndiveGcI31Instance implements GcI31Instance { private final WasmI31Ref nativeRef; - ChicoryGcI31Instance(WasmI31Ref nativeRef) { + EndiveGcI31Instance(WasmI31Ref nativeRef) { this.nativeRef = nativeRef; } @@ -313,10 +313,10 @@ public boolean isNull() { @Override public boolean refEquals(GcObject other) { - if (!(other instanceof ChicoryGcI31Instance)) { + if (!(other instanceof EndiveGcI31Instance)) { return false; } - return this.nativeRef.value() == ((ChicoryGcI31Instance) other).nativeRef.value(); + return this.nativeRef.value() == ((EndiveGcI31Instance) other).nativeRef.value(); } } } diff --git a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryGlobalAdapter.java b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveGlobalAdapter.java similarity index 70% rename from chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryGlobalAdapter.java rename to endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveGlobalAdapter.java index 18992b5..c26f92b 100644 --- a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryGlobalAdapter.java +++ b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveGlobalAdapter.java @@ -1,19 +1,19 @@ -package ai.tegmentum.webassembly4j.provider.chicory; +package ai.tegmentum.webassembly4j.provider.endive; import ai.tegmentum.webassembly4j.api.Global; import ai.tegmentum.webassembly4j.api.ValueType; -import com.dylibso.chicory.runtime.GlobalInstance; -import com.dylibso.chicory.wasm.types.MutabilityType; -import com.dylibso.chicory.wasm.types.ValType; -import com.dylibso.chicory.wasm.types.Value; +import run.endive.runtime.GlobalInstance; +import run.endive.wasm.types.MutabilityType; +import run.endive.wasm.types.ValType; +import run.endive.wasm.types.Value; import java.util.Optional; -final class ChicoryGlobalAdapter implements Global { +final class EndiveGlobalAdapter implements Global { private final GlobalInstance nativeGlobal; - ChicoryGlobalAdapter(GlobalInstance nativeGlobal) { + EndiveGlobalAdapter(GlobalInstance nativeGlobal) { this.nativeGlobal = nativeGlobal; } @@ -70,11 +70,11 @@ public Optional unwrap(Class nativeType) { return Optional.empty(); } - private static ValueType convertType(ValType chicoryType) { - if (chicoryType .equals(ValType.I32)) return ValueType.I32; - if (chicoryType .equals(ValType.I64)) return ValueType.I64; - if (chicoryType .equals(ValType.F32)) return ValueType.F32; - if (chicoryType .equals(ValType.F64)) return ValueType.F64; - throw new IllegalArgumentException("Unknown type: " + chicoryType); + private static ValueType convertType(ValType endiveType) { + if (endiveType .equals(ValType.I32)) return ValueType.I32; + if (endiveType .equals(ValType.I64)) return ValueType.I64; + if (endiveType .equals(ValType.F32)) return ValueType.F32; + if (endiveType .equals(ValType.F64)) return ValueType.F64; + throw new IllegalArgumentException("Unknown type: " + endiveType); } } diff --git a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryInstanceAdapter.java b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveInstanceAdapter.java similarity index 71% rename from chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryInstanceAdapter.java rename to endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveInstanceAdapter.java index c5819ce..1f38a23 100644 --- a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryInstanceAdapter.java +++ b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveInstanceAdapter.java @@ -1,4 +1,4 @@ -package ai.tegmentum.webassembly4j.provider.chicory; +package ai.tegmentum.webassembly4j.provider.endive; import ai.tegmentum.webassembly4j.api.Function; import ai.tegmentum.webassembly4j.api.Global; @@ -11,13 +11,13 @@ import java.util.Map; import java.util.Optional; -final class ChicoryInstanceAdapter implements Instance { +final class EndiveInstanceAdapter implements Instance { - private final com.dylibso.chicory.runtime.Instance nativeInstance; + private final run.endive.runtime.Instance nativeInstance; private final Map> functionCache = new HashMap<>(); private final Map> memoryCache = new HashMap<>(); - ChicoryInstanceAdapter(com.dylibso.chicory.runtime.Instance nativeInstance) { + EndiveInstanceAdapter(run.endive.runtime.Instance nativeInstance) { this.nativeInstance = nativeInstance; } @@ -29,11 +29,11 @@ public Optional function(String name) { } Optional result; try { - com.dylibso.chicory.runtime.ExportFunction exportFunc = + run.endive.runtime.ExportFunction exportFunc = nativeInstance.exports().function(name); - com.dylibso.chicory.wasm.types.FunctionType funcType = + run.endive.wasm.types.FunctionType funcType = nativeInstance.exportType(name); - result = Optional.of(new ChicoryFunctionAdapter(exportFunc, funcType)); + result = Optional.of(new EndiveFunctionAdapter(exportFunc, funcType)); } catch (Exception e) { result = Optional.empty(); } @@ -49,9 +49,9 @@ public Optional memory(String name) { } Optional result; try { - com.dylibso.chicory.runtime.Memory nativeMemory = + run.endive.runtime.Memory nativeMemory = nativeInstance.exports().memory(name); - result = Optional.of(new ChicoryMemoryAdapter(nativeMemory)); + result = Optional.of(new EndiveMemoryAdapter(nativeMemory)); } catch (Exception e) { result = Optional.empty(); } @@ -62,9 +62,9 @@ public Optional memory(String name) { @Override public Optional

table(String name) { try { - com.dylibso.chicory.runtime.TableInstance tableInstance = + run.endive.runtime.TableInstance tableInstance = nativeInstance.exports().table(name); - return Optional.of(new ChicoryTableAdapter(tableInstance)); + return Optional.of(new EndiveTableAdapter(tableInstance)); } catch (Exception e) { return Optional.empty(); } @@ -73,9 +73,9 @@ public Optional
table(String name) { @Override public Optional global(String name) { try { - com.dylibso.chicory.runtime.GlobalInstance globalInstance = + run.endive.runtime.GlobalInstance globalInstance = nativeInstance.exports().global(name); - return Optional.of(new ChicoryGlobalAdapter(globalInstance)); + return Optional.of(new EndiveGlobalAdapter(globalInstance)); } catch (Exception e) { return Optional.empty(); } @@ -85,7 +85,7 @@ public Optional global(String name) { @SuppressWarnings("unchecked") public Optional extension(Class extensionType) { if (extensionType == GcExtension.class) { - return Optional.of((T) new ChicoryGcExtension()); + return Optional.of((T) new EndiveGcExtension()); } return Optional.empty(); } diff --git a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryMemoryAdapter.java b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveMemoryAdapter.java similarity index 72% rename from chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryMemoryAdapter.java rename to endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveMemoryAdapter.java index 7dd075e..477030e 100644 --- a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryMemoryAdapter.java +++ b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveMemoryAdapter.java @@ -1,21 +1,21 @@ -package ai.tegmentum.webassembly4j.provider.chicory; +package ai.tegmentum.webassembly4j.provider.endive; import ai.tegmentum.webassembly4j.api.Memory; import java.nio.ByteBuffer; import java.util.Optional; -final class ChicoryMemoryAdapter implements Memory { +final class EndiveMemoryAdapter implements Memory { - private final com.dylibso.chicory.runtime.Memory nativeMemory; + private final run.endive.runtime.Memory nativeMemory; - ChicoryMemoryAdapter(com.dylibso.chicory.runtime.Memory nativeMemory) { + EndiveMemoryAdapter(run.endive.runtime.Memory nativeMemory) { this.nativeMemory = nativeMemory; } @Override public long byteSize() { - return (long) nativeMemory.pages() * com.dylibso.chicory.runtime.Memory.PAGE_SIZE; + return (long) nativeMemory.pages() * run.endive.runtime.Memory.PAGE_SIZE; } @Override @@ -36,7 +36,7 @@ public long grow(long pages) { @Override public ByteBuffer asByteBuffer() { throw new UnsupportedOperationException( - "Chicory does not expose memory as a direct ByteBuffer"); + "Endive does not expose memory as a direct ByteBuffer"); } @Override diff --git a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryModuleAdapter.java b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveModuleAdapter.java similarity index 83% rename from chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryModuleAdapter.java rename to endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveModuleAdapter.java index 2053352..d001f20 100644 --- a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryModuleAdapter.java +++ b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveModuleAdapter.java @@ -1,4 +1,4 @@ -package ai.tegmentum.webassembly4j.provider.chicory; +package ai.tegmentum.webassembly4j.provider.endive; import ai.tegmentum.webassembly4j.api.ExportDescriptor; import ai.tegmentum.webassembly4j.api.HostFunctionDefinition; @@ -10,19 +10,19 @@ import ai.tegmentum.webassembly4j.api.WasiContext; import ai.tegmentum.webassembly4j.api.exception.InstantiationException; import ai.tegmentum.webassembly4j.api.exception.LinkingException; -import com.dylibso.chicory.runtime.HostFunction; -import com.dylibso.chicory.runtime.ImportFunction; -import com.dylibso.chicory.runtime.ImportValues; -import com.dylibso.chicory.wasi.WasiOptions; -import com.dylibso.chicory.wasi.WasiPreview1; -import com.dylibso.chicory.wasm.WasmModule; -import com.dylibso.chicory.wasm.types.Export; -import com.dylibso.chicory.wasm.types.ExternalType; -import com.dylibso.chicory.wasm.types.FunctionImport; -import com.dylibso.chicory.wasm.types.FunctionType; -import com.dylibso.chicory.wasm.types.Import; -import com.dylibso.chicory.wasm.types.ValType; -import com.dylibso.chicory.wasm.types.Value; +import run.endive.runtime.HostFunction; +import run.endive.runtime.ImportFunction; +import run.endive.runtime.ImportValues; +import run.endive.wasi.WasiOptions; +import run.endive.wasi.WasiPreview1; +import run.endive.wasm.WasmModule; +import run.endive.wasm.types.Export; +import run.endive.wasm.types.ExternalType; +import run.endive.wasm.types.FunctionImport; +import run.endive.wasm.types.FunctionType; +import run.endive.wasm.types.Import; +import run.endive.wasm.types.ValType; +import run.endive.wasm.types.Value; import java.nio.file.Paths; import java.util.ArrayList; @@ -31,13 +31,13 @@ import java.util.List; import java.util.Map; -final class ChicoryModuleAdapter implements Module { +final class EndiveModuleAdapter implements Module { private final WasmModule wasmModule; - private final java.util.function.Function builderFactory; + private final java.util.function.Function builderFactory; - ChicoryModuleAdapter(WasmModule wasmModule, - java.util.function.Function builderFactory) { + EndiveModuleAdapter(WasmModule wasmModule, + java.util.function.Function builderFactory) { this.wasmModule = wasmModule; this.builderFactory = builderFactory; } @@ -45,9 +45,9 @@ final class ChicoryModuleAdapter implements Module { @Override public Instance instantiate() { try { - com.dylibso.chicory.runtime.Instance nativeInstance = + run.endive.runtime.Instance nativeInstance = builderFactory.apply(wasmModule).build(); - return new ChicoryInstanceAdapter(nativeInstance); + return new EndiveInstanceAdapter(nativeInstance); } catch (Exception e) { throw new InstantiationException( "Failed to instantiate WebAssembly module", e); @@ -90,8 +90,8 @@ public Instance instantiate(LinkingContext linkingContext) { List hostFunctions = linkingContext.hostFunctions(); for (HostFunctionDefinition def : hostFunctions) { - List paramTypes = convertToChicoryTypes(def.parameterTypes()); - List returnTypes = convertToChicoryTypes(def.resultTypes()); + List paramTypes = convertToEndiveTypes(def.parameterTypes()); + List returnTypes = convertToEndiveTypes(def.resultTypes()); HostFunction hostFunc = new HostFunction( def.moduleName(), def.functionName(), @@ -115,25 +115,25 @@ public Instance instantiate(LinkingContext linkingContext) { importBuilder.withFunctions(functions); ImportValues imports = importBuilder.build(); - com.dylibso.chicory.runtime.Instance nativeInstance = + run.endive.runtime.Instance nativeInstance = builderFactory.apply(wasmModule) .withImportValues(imports) .build(); - return new ChicoryInstanceAdapter(nativeInstance); + return new EndiveInstanceAdapter(nativeInstance); } catch (Exception e) { throw new LinkingException("Failed to instantiate with linking context", e); } } - private static List convertToChicoryTypes(ValueType[] types) { + private static List convertToEndiveTypes(ValueType[] types) { List result = new ArrayList<>(types.length); for (ValueType type : types) { - result.add(convertToChicoryType(type)); + result.add(convertToEndiveType(type)); } return result; } - private static ValType convertToChicoryType(ValueType type) { + private static ValType convertToEndiveType(ValueType type) { switch (type) { case I32: return ValType.I32; case I64: return ValType.I64; @@ -232,8 +232,8 @@ private ExportDescriptor convertFunctionExport(Export export) { FunctionType funcType = wasmModule.functionSection() .getFunctionType(localIndex, wasmModule.typeSection()); return ExportDescriptor.function(export.name(), - convertChicoryTypes(funcType.params()), - convertChicoryTypes(funcType.returns())); + convertEndiveTypes(funcType.params()), + convertEndiveTypes(funcType.returns())); } return ExportDescriptor.function(export.name(), new ValueType[0], new ValueType[0]); @@ -248,8 +248,8 @@ private ImportDescriptor convertImport(Import imp) { if (typeIdx < wasmModule.typeSection().typeCount()) { FunctionType funcType = wasmModule.typeSection().getType(typeIdx); return ImportDescriptor.function(imp.module(), imp.name(), - convertChicoryTypes(funcType.params()), - convertChicoryTypes(funcType.returns())); + convertEndiveTypes(funcType.params()), + convertEndiveTypes(funcType.returns())); } return ImportDescriptor.function(imp.module(), imp.name(), new ValueType[0], new ValueType[0]); @@ -264,15 +264,15 @@ private ImportDescriptor convertImport(Import imp) { } } - private static ValueType[] convertChicoryTypes(List types) { + private static ValueType[] convertEndiveTypes(List types) { ValueType[] result = new ValueType[types.size()]; for (int i = 0; i < types.size(); i++) { - result[i] = convertChicoryType(types.get(i)); + result[i] = convertEndiveType(types.get(i)); } return result; } - private static ValueType convertChicoryType(ValType type) { + private static ValueType convertEndiveType(ValType type) { if (type .equals(ValType.I32)) return ValueType.I32; if (type .equals(ValType.I64)) return ValueType.I64; if (type .equals(ValType.F32)) return ValueType.F32; diff --git a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryProvider.java b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveProvider.java similarity index 76% rename from chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryProvider.java rename to endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveProvider.java index 8178220..eb9939b 100644 --- a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryProvider.java +++ b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveProvider.java @@ -1,9 +1,9 @@ -package ai.tegmentum.webassembly4j.provider.chicory; +package ai.tegmentum.webassembly4j.provider.endive; import ai.tegmentum.webassembly4j.api.Engine; import ai.tegmentum.webassembly4j.api.config.EngineConfig; import ai.tegmentum.webassembly4j.api.config.WebAssemblyConfig; -import ai.tegmentum.webassembly4j.provider.chicory.config.ChicoryConfig; +import ai.tegmentum.webassembly4j.provider.endive.config.EndiveConfig; import ai.tegmentum.webassembly4j.spi.EngineProvider; import ai.tegmentum.webassembly4j.spi.ProviderAvailability; import ai.tegmentum.webassembly4j.spi.ProviderDescriptor; @@ -13,10 +13,10 @@ import java.util.Optional; import java.util.Set; -public final class ChicoryProvider implements EngineProvider { +public final class EndiveProvider implements EngineProvider { - private static final String ENGINE_ID = "chicory"; - private static final String PROVIDER_ID = "chicory"; + private static final String ENGINE_ID = "endive"; + private static final String PROVIDER_ID = "endive"; private static final String VERSION = "1.0.0-SNAPSHOT"; private static final int MIN_JAVA = 11; private static final int PRIORITY = 50; @@ -42,17 +42,17 @@ public ProviderDescriptor descriptor() { public ProviderAvailability availability() { return new ProviderAvailability() { @Override public boolean available() { return true; } - @Override public String message() { return "Chicory pure-Java runtime always available"; } + @Override public String message() { return "Endive pure-Java runtime always available"; } }; } @Override public Optional> configType() { - return Optional.of(ChicoryConfig.class); + return Optional.of(EndiveConfig.class); } @Override public Engine create(WebAssemblyConfig config) { - return ChicoryEngineAdapter.create(config); + return EndiveEngineAdapter.create(config); } } diff --git a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryTableAdapter.java b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveTableAdapter.java similarity index 70% rename from chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryTableAdapter.java rename to endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveTableAdapter.java index cd0542f..5fbf670 100644 --- a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryTableAdapter.java +++ b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/EndiveTableAdapter.java @@ -1,15 +1,15 @@ -package ai.tegmentum.webassembly4j.provider.chicory; +package ai.tegmentum.webassembly4j.provider.endive; import ai.tegmentum.webassembly4j.api.Table; -import com.dylibso.chicory.runtime.TableInstance; +import run.endive.runtime.TableInstance; import java.util.Optional; -final class ChicoryTableAdapter implements Table { +final class EndiveTableAdapter implements Table { private final TableInstance nativeTable; - ChicoryTableAdapter(TableInstance nativeTable) { + EndiveTableAdapter(TableInstance nativeTable) { this.nativeTable = nativeTable; } diff --git a/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/WasmcmGuestBlobLocator.java b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/WasmcmGuestBlobLocator.java new file mode 100644 index 0000000..7b8fb9b --- /dev/null +++ b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/WasmcmGuestBlobLocator.java @@ -0,0 +1,122 @@ +package ai.tegmentum.webassembly4j.provider.endive; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Locates the {@code wasmcm_runtime_guest.wasm} blob — the portable Rust Component + * Model runtime cross-compiled to {@code wasm32-unknown-unknown} — that the Endive + * provider layers on top of Endive to obtain a real Component Model surface. + * + *

Lookup order: + *

    + *
  1. System property {@code wasmcm.runtime.guest.wasm} — absolute path to the {@code .wasm}.
  2. + *
  3. Environment variable {@code WASMCM_RUNTIME_GUEST_WASM} — absolute path to the {@code .wasm}.
  4. + *
  5. Environment variable {@code WASMCM_HOME} — the guest is resolved to + * {@code $WASMCM_HOME/target/wasm32-unknown-unknown/release/wasmcm_runtime_guest.wasm}.
  6. + *
  7. Classpath resource {@code wasmcm_runtime_guest.wasm} on the thread-context class loader + * (with a fallback to this class's loader).
  8. + *
  9. Sibling checkout heuristic {@code ~/git/wasm-cm/target/wasm32-unknown-unknown/release/wasmcm_runtime_guest.wasm} + * — convenient for local development when a companion {@code wasm-cm} tree is present.
  10. + *
+ * + *

Returns {@code null} when nothing matches; callers surface a clear error to the user + * rather than pretending Component Model works with no guest present. + */ +final class WasmcmGuestBlobLocator { + + static final String SYSTEM_PROPERTY = "wasmcm.runtime.guest.wasm"; + static final String ENV_GUEST_WASM = "WASMCM_RUNTIME_GUEST_WASM"; + static final String ENV_HOME = "WASMCM_HOME"; + static final String CLASSPATH_RESOURCE = "wasmcm_runtime_guest.wasm"; + static final String HOME_RELATIVE_GUEST = + "target/wasm32-unknown-unknown/release/wasmcm_runtime_guest.wasm"; + + private WasmcmGuestBlobLocator() {} + + static byte[] locateOrNull() { + String prop = System.getProperty(SYSTEM_PROPERTY); + byte[] bytes = readIfRegular(prop); + if (bytes != null) { + return bytes; + } + + String envGuest = System.getenv(ENV_GUEST_WASM); + bytes = readIfRegular(envGuest); + if (bytes != null) { + return bytes; + } + + String home = System.getenv(ENV_HOME); + if (home != null && !home.isEmpty()) { + bytes = readIfRegular(Paths.get(home, HOME_RELATIVE_GUEST).toString()); + if (bytes != null) { + return bytes; + } + } + + bytes = readClasspath(Thread.currentThread().getContextClassLoader()); + if (bytes != null) { + return bytes; + } + bytes = readClasspath(WasmcmGuestBlobLocator.class.getClassLoader()); + if (bytes != null) { + return bytes; + } + + String userHome = System.getProperty("user.home"); + if (userHome != null && !userHome.isEmpty()) { + Path sibling = Paths.get(userHome, "git", "wasm-cm", HOME_RELATIVE_GUEST); + bytes = readIfRegular(sibling.toString()); + if (bytes != null) { + return bytes; + } + } + + return null; + } + + /** + * Human-readable summary of the lookup paths, embedded in error messages when + * no blob can be found so callers know how to point the provider at one. + */ + static String describeLookupOrder() { + return "-D" + SYSTEM_PROPERTY + "=" + + " | $" + ENV_GUEST_WASM + + " | $" + ENV_HOME + "/" + HOME_RELATIVE_GUEST + + " | classpath:" + CLASSPATH_RESOURCE + + " | ~/git/wasm-cm/" + HOME_RELATIVE_GUEST; + } + + private static byte[] readIfRegular(String path) { + if (path == null || path.isEmpty()) { + return null; + } + Path p = Paths.get(path); + if (!Files.isRegularFile(p)) { + return null; + } + try { + return Files.readAllBytes(p); + } catch (IOException e) { + return null; + } + } + + private static byte[] readClasspath(ClassLoader loader) { + if (loader == null) { + return null; + } + try (InputStream is = loader.getResourceAsStream(CLASSPATH_RESOURCE)) { + if (is == null) { + return null; + } + return is.readAllBytes(); + } catch (IOException e) { + return null; + } + } +} diff --git a/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/WasmcmValueCodec.java b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/WasmcmValueCodec.java new file mode 100644 index 0000000..8aa507c --- /dev/null +++ b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/WasmcmValueCodec.java @@ -0,0 +1,585 @@ +package ai.tegmentum.webassembly4j.provider.endive; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Wire codec for the {@code wasmcm_runtime_guest} argument / result frames. + * + *

Mirrors the tags and layout documented on {@code wasmcm_runtime_guest::codec} + * (see {@code crates/wasmcm-runtime-guest/src/codec.rs} in the wasm-cm tree): + * + *

+ * frame  := [u32 total_bytes_including_header][u32 count][value * count]
+ * value  := [u32 tag][payload...]
+ * 
+ * + *

All integers are little-endian and unaligned. + * + *

The Java surface exchanges natural Java types with callers of + * {@link EndiveComponentInstanceAdapter#invoke}: {@code Boolean, Byte, Short, + * Integer, Long, Float, Double, Character, String, byte[], List, Map} — mapped + * onto the tag arms below at encode time. Decoding produces the mirror shape. + * + *

Types the Java-side natural mapping cannot express unambiguously (unsigned + * widths, variants, options, results, enums, flags, resources) accept a + * pre-boxed {@link Boxed} sentinel that carries the wire tag alongside the + * payload. + */ +final class WasmcmValueCodec { + + static final int TAG_BOOL = 0; + static final int TAG_S8 = 1; + static final int TAG_U8 = 2; + static final int TAG_S16 = 3; + static final int TAG_U16 = 4; + static final int TAG_S32 = 5; + static final int TAG_U32 = 6; + static final int TAG_S64 = 7; + static final int TAG_U64 = 8; + static final int TAG_F32 = 9; + static final int TAG_F64 = 10; + static final int TAG_CHAR = 11; + static final int TAG_STRING = 12; + static final int TAG_LIST = 13; + static final int TAG_RECORD = 14; + static final int TAG_TUPLE = 15; + static final int TAG_VARIANT = 16; + static final int TAG_OPTION = 17; + static final int TAG_RESULT = 18; + static final int TAG_FLAGS = 19; + static final int TAG_ENUM = 20; + static final int TAG_OWN = 21; + static final int TAG_BORROW = 22; + + private WasmcmValueCodec() {} + + /** + * Envelope for a payload plus its explicit wire tag — the escape hatch for + * WIT types the Java-native shape can't disambiguate on its own (unsigned + * widths, variants, options, results, enums, flags, own/borrow handles). + * + *

The concrete payload shape per tag: + *

    + *
  • {@link #TAG_U8}/{@link #TAG_U16}/{@link #TAG_U32}: {@link Integer} + * carrying the unsigned bit pattern.
  • + *
  • {@link #TAG_U64}: {@link Long} carrying the unsigned bit pattern.
  • + *
  • {@link #TAG_S8}: {@link Byte}. {@link #TAG_S16}: {@link Short}. + * {@link #TAG_S32}: {@link Integer}. {@link #TAG_S64}: {@link Long}.
  • + *
  • {@link #TAG_OPTION}: {@link Boxed} for {@code some(v)} carrying the + * inner value, or {@code null} payload for {@code none}.
  • + *
  • {@link #TAG_RESULT}: {@code Object[]{isOkBoolean, payloadOrNull}}.
  • + *
  • {@link #TAG_ENUM}: {@code Object[]{caseIndexInt, caseNameString}}.
  • + *
  • {@link #TAG_VARIANT}: {@code Object[]{caseIndexInt, caseNameString, payloadOrNull}}.
  • + *
  • {@link #TAG_FLAGS}: {@link List} of {@link String}.
  • + *
  • {@link #TAG_OWN}/{@link #TAG_BORROW}: {@link Integer} handle.
  • + *
+ */ + static final class Boxed { + final int tag; + final Object payload; + + Boxed(int tag, Object payload) { + this.tag = tag; + this.payload = payload; + } + + int tag() { + return tag; + } + + Object payload() { + return payload; + } + } + + // ----------------------------------------------------------------- + // Encode + // ----------------------------------------------------------------- + + static byte[] encodeFrame(Object[] values) { + ByteArrayOutputStream inner = new ByteArrayOutputStream(); + writeU32(inner, values.length); + for (Object v : values) { + encodeValue(inner, v); + } + byte[] body = inner.toByteArray(); + int total = 4 + body.length; + ByteArrayOutputStream out = new ByteArrayOutputStream(total); + writeU32(out, total); + try { + out.write(body); + } catch (java.io.IOException e) { + throw new IllegalStateException("BAOS never throws", e); + } + return out.toByteArray(); + } + + static void encodeValue(ByteArrayOutputStream buf, Object v) { + if (v instanceof Boxed) { + Boxed b = (Boxed) v; + encodeBoxed(buf, b); + return; + } + if (v instanceof Boolean) { + writeU32(buf, TAG_BOOL); + buf.write(((Boolean) v) ? 1 : 0); + return; + } + if (v instanceof Byte) { + writeU32(buf, TAG_S8); + buf.write(((Byte) v) & 0xFF); + return; + } + if (v instanceof Short) { + writeU32(buf, TAG_S16); + writeI16(buf, (Short) v); + return; + } + if (v instanceof Integer) { + writeU32(buf, TAG_S32); + writeI32(buf, (Integer) v); + return; + } + if (v instanceof Long) { + writeU32(buf, TAG_S64); + writeI64(buf, (Long) v); + return; + } + if (v instanceof Float) { + writeU32(buf, TAG_F32); + writeI32(buf, Float.floatToRawIntBits((Float) v)); + return; + } + if (v instanceof Double) { + writeU32(buf, TAG_F64); + writeI64(buf, Double.doubleToRawLongBits((Double) v)); + return; + } + if (v instanceof Character) { + writeU32(buf, TAG_CHAR); + writeU32(buf, (Character) v); + return; + } + if (v instanceof String) { + writeU32(buf, TAG_STRING); + writeBytes(buf, ((String) v).getBytes(StandardCharsets.UTF_8)); + return; + } + if (v instanceof byte[]) { + byte[] bytes = (byte[]) v; + writeU32(buf, TAG_LIST); + writeU32(buf, bytes.length); + for (byte b : bytes) { + writeU32(buf, TAG_U8); + buf.write(b & 0xFF); + } + return; + } + if (v instanceof List) { + List list = (List) v; + writeU32(buf, TAG_LIST); + writeU32(buf, list.size()); + for (Object item : list) { + encodeValue(buf, item); + } + return; + } + if (v instanceof Map) { + Map map = (Map) v; + writeU32(buf, TAG_RECORD); + writeU32(buf, map.size()); + for (Map.Entry e : map.entrySet()) { + writeBytes(buf, e.getKey().toString().getBytes(StandardCharsets.UTF_8)); + encodeValue(buf, e.getValue()); + } + return; + } + throw new IllegalArgumentException( + "cannot encode Java type " + + (v == null ? "null" : v.getClass().getName()) + + " as a wasmcm ComponentValue; wrap in " + + Boxed.class.getName() + + " with an explicit wire tag"); + } + + private static void encodeBoxed(ByteArrayOutputStream buf, Boxed b) { + switch (b.tag) { + case TAG_BOOL: + writeU32(buf, TAG_BOOL); + buf.write(((Boolean) b.payload) ? 1 : 0); + return; + case TAG_S8: + writeU32(buf, TAG_S8); + buf.write(((Number) b.payload).byteValue() & 0xFF); + return; + case TAG_U8: + writeU32(buf, TAG_U8); + buf.write(((Number) b.payload).intValue() & 0xFF); + return; + case TAG_S16: + writeU32(buf, TAG_S16); + writeI16(buf, ((Number) b.payload).shortValue()); + return; + case TAG_U16: + writeU32(buf, TAG_U16); + writeI16(buf, (short) (((Number) b.payload).intValue() & 0xFFFF)); + return; + case TAG_S32: + writeU32(buf, TAG_S32); + writeI32(buf, ((Number) b.payload).intValue()); + return; + case TAG_U32: + writeU32(buf, TAG_U32); + writeI32(buf, ((Number) b.payload).intValue()); + return; + case TAG_S64: + writeU32(buf, TAG_S64); + writeI64(buf, ((Number) b.payload).longValue()); + return; + case TAG_U64: + writeU32(buf, TAG_U64); + writeI64(buf, ((Number) b.payload).longValue()); + return; + case TAG_F32: + writeU32(buf, TAG_F32); + writeI32(buf, Float.floatToRawIntBits(((Number) b.payload).floatValue())); + return; + case TAG_F64: + writeU32(buf, TAG_F64); + writeI64(buf, Double.doubleToRawLongBits(((Number) b.payload).doubleValue())); + return; + case TAG_CHAR: + writeU32(buf, TAG_CHAR); + writeU32(buf, (int) ((Character) b.payload).charValue()); + return; + case TAG_STRING: + writeU32(buf, TAG_STRING); + writeBytes(buf, ((String) b.payload).getBytes(StandardCharsets.UTF_8)); + return; + case TAG_LIST: + writeU32(buf, TAG_LIST); + { + List list = (List) b.payload; + writeU32(buf, list.size()); + for (Object item : list) { + encodeValue(buf, item); + } + } + return; + case TAG_RECORD: + writeU32(buf, TAG_RECORD); + { + @SuppressWarnings("unchecked") + Map map = (Map) b.payload; + writeU32(buf, map.size()); + for (Map.Entry e : map.entrySet()) { + writeBytes(buf, e.getKey().getBytes(StandardCharsets.UTF_8)); + encodeValue(buf, e.getValue()); + } + } + return; + case TAG_TUPLE: + writeU32(buf, TAG_TUPLE); + { + List list = (List) b.payload; + writeU32(buf, list.size()); + for (Object item : list) { + encodeValue(buf, item); + } + } + return; + case TAG_VARIANT: + writeU32(buf, TAG_VARIANT); + { + Object[] parts = (Object[]) b.payload; + int caseIdx = ((Number) parts[0]).intValue(); + String caseName = (String) parts[1]; + Object innerPayload = parts.length > 2 ? parts[2] : null; + writeU32(buf, caseIdx); + writeBytes(buf, caseName.getBytes(StandardCharsets.UTF_8)); + if (innerPayload == null) { + buf.write(0); + } else { + buf.write(1); + encodeValue(buf, innerPayload); + } + } + return; + case TAG_OPTION: + writeU32(buf, TAG_OPTION); + if (b.payload == null) { + buf.write(0); + } else { + buf.write(1); + encodeValue(buf, b.payload); + } + return; + case TAG_RESULT: + writeU32(buf, TAG_RESULT); + { + Object[] parts = (Object[]) b.payload; + boolean isOk = ((Boolean) parts[0]); + Object inner = parts.length > 1 ? parts[1] : null; + buf.write(isOk ? 1 : 0); + if (inner == null) { + buf.write(0); + } else { + buf.write(1); + encodeValue(buf, inner); + } + } + return; + case TAG_FLAGS: + writeU32(buf, TAG_FLAGS); + { + List labels = (List) b.payload; + writeU32(buf, labels.size()); + for (Object label : labels) { + writeBytes(buf, ((String) label).getBytes(StandardCharsets.UTF_8)); + } + } + return; + case TAG_ENUM: + writeU32(buf, TAG_ENUM); + { + Object[] parts = (Object[]) b.payload; + int caseIdx = ((Number) parts[0]).intValue(); + String caseName = (String) parts[1]; + writeU32(buf, caseIdx); + writeBytes(buf, caseName.getBytes(StandardCharsets.UTF_8)); + } + return; + case TAG_OWN: + writeU32(buf, TAG_OWN); + writeU32(buf, ((Number) b.payload).intValue()); + return; + case TAG_BORROW: + writeU32(buf, TAG_BORROW); + writeU32(buf, ((Number) b.payload).intValue()); + return; + default: + throw new IllegalArgumentException("unknown wire tag " + b.tag); + } + } + + // ----------------------------------------------------------------- + // Decode + // ----------------------------------------------------------------- + + static List decodeFrame(byte[] bytes) { + if (bytes == null || bytes.length == 0) { + return java.util.Collections.emptyList(); + } + Cursor c = new Cursor(bytes, 0); + readU32(c); // total_bytes — discard, callers already sized the array + int count = readU32(c); + List out = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + out.add(decodeValue(c)); + } + return out; + } + + static Object decodeValue(Cursor c) { + int tag = readU32(c); + switch (tag) { + case TAG_BOOL: + return c.readU8() != 0; + case TAG_S8: + return (byte) c.readU8(); + case TAG_U8: + return c.readU8() & 0xFF; + case TAG_S16: + return (short) readI16(c); + case TAG_U16: + return readI16(c) & 0xFFFF; + case TAG_S32: + return readU32(c); + case TAG_U32: + // Represent unsigned u32 as a Long to keep the natural mapping non-lossy. + return ((long) readU32(c)) & 0xFFFFFFFFL; + case TAG_S64: + return readI64(c); + case TAG_U64: + // No unsigned long in Java; caller can reinterpret bits. + return readI64(c); + case TAG_F32: + return Float.intBitsToFloat(readU32(c)); + case TAG_F64: + return Double.longBitsToDouble(readI64(c)); + case TAG_CHAR: + return (char) readU32(c); + case TAG_STRING: + return new String(readBytes(c), StandardCharsets.UTF_8); + case TAG_LIST: { + int n = readU32(c); + List list = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + list.add(decodeValue(c)); + } + return list; + } + case TAG_RECORD: { + int n = readU32(c); + Map record = new LinkedHashMap<>(); + for (int i = 0; i < n; i++) { + String name = new String(readBytes(c), StandardCharsets.UTF_8); + Object value = decodeValue(c); + record.put(name, value); + } + return record; + } + case TAG_TUPLE: { + int n = readU32(c); + List list = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + list.add(decodeValue(c)); + } + return new Boxed(TAG_TUPLE, list); + } + case TAG_VARIANT: { + int caseIdx = readU32(c); + String caseName = new String(readBytes(c), StandardCharsets.UTF_8); + int hasPayload = c.readU8(); + Object payload = hasPayload == 0 ? null : decodeValue(c); + return new Boxed(TAG_VARIANT, new Object[] {caseIdx, caseName, payload}); + } + case TAG_OPTION: { + int has = c.readU8(); + return has == 0 ? new Boxed(TAG_OPTION, null) : new Boxed(TAG_OPTION, decodeValue(c)); + } + case TAG_RESULT: { + boolean isOk = c.readU8() != 0; + int has = c.readU8(); + Object payload = has == 0 ? null : decodeValue(c); + return new Boxed(TAG_RESULT, new Object[] {isOk, payload}); + } + case TAG_FLAGS: { + int n = readU32(c); + List labels = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + labels.add(new String(readBytes(c), StandardCharsets.UTF_8)); + } + return new Boxed(TAG_FLAGS, labels); + } + case TAG_ENUM: { + int caseIdx = readU32(c); + String caseName = new String(readBytes(c), StandardCharsets.UTF_8); + return new Boxed(TAG_ENUM, new Object[] {caseIdx, caseName}); + } + case TAG_OWN: + return new Boxed(TAG_OWN, readU32(c)); + case TAG_BORROW: + return new Boxed(TAG_BORROW, readU32(c)); + default: + throw new IllegalStateException("unknown wire tag " + tag); + } + } + + // ----------------------------------------------------------------- + // Low-level LE readers / writers + // ----------------------------------------------------------------- + + static final class Cursor { + final byte[] bytes; + int off; + + Cursor(byte[] bytes, int off) { + this.bytes = bytes; + this.off = off; + } + + int readU8() { + require(1); + return bytes[off++] & 0xFF; + } + + void require(int n) { + if (off + n > bytes.length) { + throw new IllegalStateException( + "wire frame truncated: need " + n + " at " + off + " of " + bytes.length); + } + } + } + + private static void writeU32(ByteArrayOutputStream buf, int v) { + buf.write(v & 0xFF); + buf.write((v >>> 8) & 0xFF); + buf.write((v >>> 16) & 0xFF); + buf.write((v >>> 24) & 0xFF); + } + + private static void writeI16(ByteArrayOutputStream buf, int v) { + buf.write(v & 0xFF); + buf.write((v >>> 8) & 0xFF); + } + + private static void writeI32(ByteArrayOutputStream buf, int v) { + writeU32(buf, v); + } + + private static void writeI64(ByteArrayOutputStream buf, long v) { + buf.write((int) (v & 0xFF)); + buf.write((int) ((v >>> 8) & 0xFF)); + buf.write((int) ((v >>> 16) & 0xFF)); + buf.write((int) ((v >>> 24) & 0xFF)); + buf.write((int) ((v >>> 32) & 0xFF)); + buf.write((int) ((v >>> 40) & 0xFF)); + buf.write((int) ((v >>> 48) & 0xFF)); + buf.write((int) ((v >>> 56) & 0xFF)); + } + + private static void writeBytes(ByteArrayOutputStream buf, byte[] bytes) { + writeU32(buf, bytes.length); + try { + buf.write(bytes); + } catch (java.io.IOException e) { + throw new IllegalStateException("BAOS never throws", e); + } + } + + private static int readU32(Cursor c) { + c.require(4); + int v = (c.bytes[c.off] & 0xFF) + | ((c.bytes[c.off + 1] & 0xFF) << 8) + | ((c.bytes[c.off + 2] & 0xFF) << 16) + | ((c.bytes[c.off + 3] & 0xFF) << 24); + c.off += 4; + return v; + } + + private static int readI16(Cursor c) { + c.require(2); + int v = (c.bytes[c.off] & 0xFF) | ((c.bytes[c.off + 1] & 0xFF) << 8); + c.off += 2; + return v; + } + + private static long readI64(Cursor c) { + c.require(8); + long v = (long) (c.bytes[c.off] & 0xFF) + | ((long) (c.bytes[c.off + 1] & 0xFF) << 8) + | ((long) (c.bytes[c.off + 2] & 0xFF) << 16) + | ((long) (c.bytes[c.off + 3] & 0xFF) << 24) + | ((long) (c.bytes[c.off + 4] & 0xFF) << 32) + | ((long) (c.bytes[c.off + 5] & 0xFF) << 40) + | ((long) (c.bytes[c.off + 6] & 0xFF) << 48) + | ((long) (c.bytes[c.off + 7] & 0xFF) << 56); + c.off += 8; + return v; + } + + private static byte[] readBytes(Cursor c) { + int n = readU32(c); + c.require(n); + byte[] out = new byte[n]; + System.arraycopy(c.bytes, c.off, out, 0, n); + c.off += n; + return out; + } +} diff --git a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/config/ChicoryConfig.java b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/config/EndiveConfig.java similarity index 71% rename from chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/config/ChicoryConfig.java rename to endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/config/EndiveConfig.java index a834ffc..ae257e3 100644 --- a/chicory4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/chicory/config/ChicoryConfig.java +++ b/endive4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/endive/config/EndiveConfig.java @@ -1,20 +1,20 @@ -package ai.tegmentum.webassembly4j.provider.chicory.config; +package ai.tegmentum.webassembly4j.provider.endive.config; import ai.tegmentum.webassembly4j.api.config.EngineConfig; import java.util.Optional; /** - * Configuration for the Chicory WebAssembly engine. + * Configuration for the Endive WebAssembly engine. * *

Controls the execution mode used when instantiating modules: *

    *
  • {@link ExecutionMode#INTERPRET} (default) — pure interpretation, no extra dependencies
  • *
  • {@link ExecutionMode#COMPILE} — runtime AOT compilation to JVM bytecode via ASM, - * requires {@code com.dylibso.chicory:compiler} on the classpath
  • + * requires {@code run.endive:compiler} on the classpath *
*/ -public final class ChicoryConfig implements EngineConfig { +public final class EndiveConfig implements EngineConfig { /** * Execution mode for WebAssembly modules. @@ -22,13 +22,13 @@ public final class ChicoryConfig implements EngineConfig { public enum ExecutionMode { /** Pure interpreter — no extra dependencies required. */ INTERPRET, - /** Runtime compilation to JVM bytecode — requires chicory-compiler dependency. */ + /** Runtime compilation to JVM bytecode — requires endive-compiler dependency. */ COMPILE } private final ExecutionMode executionMode; - private ChicoryConfig(Builder builder) { + private EndiveConfig(Builder builder) { this.executionMode = builder.executionMode; } @@ -50,8 +50,8 @@ public Builder executionMode(ExecutionMode executionMode) { return this; } - public ChicoryConfig build() { - return new ChicoryConfig(this); + public EndiveConfig build() { + return new EndiveConfig(this); } } } diff --git a/endive4j-provider/src/main/resources/META-INF/services/ai.tegmentum.webassembly4j.spi.EngineProvider b/endive4j-provider/src/main/resources/META-INF/services/ai.tegmentum.webassembly4j.spi.EngineProvider new file mode 100644 index 0000000..5918e2e --- /dev/null +++ b/endive4j-provider/src/main/resources/META-INF/services/ai.tegmentum.webassembly4j.spi.EngineProvider @@ -0,0 +1 @@ +ai.tegmentum.webassembly4j.provider.endive.EndiveProvider diff --git a/endive4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/endive/EndiveComponentIntegrationTest.java b/endive4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/endive/EndiveComponentIntegrationTest.java new file mode 100644 index 0000000..11e1e02 --- /dev/null +++ b/endive4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/endive/EndiveComponentIntegrationTest.java @@ -0,0 +1,184 @@ +package ai.tegmentum.webassembly4j.provider.endive; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import ai.tegmentum.webassembly4j.api.Component; +import ai.tegmentum.webassembly4j.api.ComponentInstance; +import ai.tegmentum.webassembly4j.api.DefaultLinkingContext; +import ai.tegmentum.webassembly4j.api.Engine; +import ai.tegmentum.webassembly4j.api.WitHostFunctionDefinition; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.junit.jupiter.api.Test; + +/** + * End-to-end verification of Endive's Component Model surface: parse a real CM + * binary, instantiate it under the layered runtime guest, invoke an exported + * function and inspect exported names / interfaces. + * + *

The tests are skipped when the {@code wasmcm_runtime_guest.wasm} blob is + * not resolvable via {@link WasmcmGuestBlobLocator} — a fresh checkout that has + * not built the sibling {@code wasm-cm} tree gets a green {@code mvn test} + * rather than a red failure. + */ +final class EndiveComponentIntegrationTest { + + @Test + void lookupOrderStringNamesBlobExplicitly() { + // The UnsupportedFeatureException raised when the guest is missing embeds + // this string; verify it stays actionable ("names the blob and every knob"). + String s = WasmcmGuestBlobLocator.describeLookupOrder(); + assertTrue(s.contains("wasmcm_runtime_guest.wasm"), + "lookup order should name the blob: " + s); + assertTrue(s.contains(WasmcmGuestBlobLocator.SYSTEM_PROPERTY), + "lookup order should name the system property: " + s); + assertTrue(s.contains(WasmcmGuestBlobLocator.ENV_HOME), + "lookup order should name WASMCM_HOME: " + s); + assertTrue(s.contains("classpath"), + "lookup order should mention classpath: " + s); + } + + @Test + void scalarComponentRoundTripsAddThroughGuest() throws Exception { + assumeGuestPresent(); + byte[] bytes = loadClasspath("scalar-component.wasm"); + assumeTrue(bytes != null, "scalar-component.wasm missing from test resources"); + + try (Engine engine = EndiveEngineAdapter.create(null); + Component component = engine.loadComponent(bytes)) { + assertNotNull(component, "loadComponent must not return null"); + ComponentInstance instance = component.instantiate(); + assertNotNull(instance, "instantiate must not return null"); + + // The scalar-component fixture exposes exactly `add: func(u32, u32) -> u32` + // at the component root. hasFunction / exportedFunctions rely on the guest's + // wasmcm_instance_export_count / wasmcm_instance_export_name. + assertTrue(instance.hasFunction("add"), + "scalar-component should export 'add', got " + instance.exportedFunctions()); + + // U32 args round-trip through Boxed(TAG_U32, int); the fixture returns U32. + Object result = instance.invoke( + "add", + new WasmcmValueCodec.Boxed(WasmcmValueCodec.TAG_U32, 2), + new WasmcmValueCodec.Boxed(WasmcmValueCodec.TAG_U32, 3)); + assertEquals(5L, ((Number) result).longValue(), + "add(2, 3) should be 5; got " + result); + } + } + + /** + * Instantiate a real WASI Preview 2 component that imports + * {@code wasip2:host/primitives@0.1.0} and {@code wasi:io/poll@0.2.3}. + * + *

Walls 2, 3, 8 and 9 have landed: the register/dispatch + * surface, the runtime-side {@code ImportedInstance::HostCallback} + * plumbing, the guest-side {@code HostEngineWasm::create_host_func} + * + {@code instantiate}-with-CoreFunc-imports pair, and — most + * recently — the variable-length import descriptor codec that + * turns {@code HostEngineWasm::module_imports} into a real + * enumeration are all wired end-to-end. The runtime's + * {@code instantiate_with_ref_imports} builds a full + * {@code ext_imports} list from the compiled module's declared + * imports and Endive's linker successfully binds every row.

+ * + *

Exit criterion: {@code component.instantiate(ctx)} returns a + * non-null instance and the guest surfaces at least one export. + * If a Wall 10 emerges (e.g. an ImportedInstance::Component gap or + * a Resource wiring issue), this test will fail with a fresh + * diagnostic and the assertions get flipped again per the + * milestone discipline.

+ */ + @Test + void wasiClocksComponentInstantiatesCleanly() throws Exception { + assumeGuestPresent(); + byte[] bytes = loadClasspath("wasi_p2_clocks.component.wasm"); + assumeTrue(bytes != null, "wasi_p2_clocks.component.wasm missing from test resources"); + + try (Engine engine = EndiveEngineAdapter.create(null); + Component component = engine.loadComponent(bytes)) { + assertNotNull(component, "loadComponent should succeed on a WASI P2 component binary"); + + DefaultLinkingContext ctx = DefaultLinkingContext.builder() + .addWitHostFunction( + new WitHostFunctionDefinition( + "wasip2:host/primitives@0.1.0#clock-monotonic-now-ns", + args -> new Object[] { + new WasmcmValueCodec.Boxed( + WasmcmValueCodec.TAG_U64, 42L) + })) + .addWitHostFunction( + new WitHostFunctionDefinition( + "wasip2:host/primitives@0.1.0#clock-wall-now-ns", + args -> new Object[] { + new WasmcmValueCodec.Boxed( + WasmcmValueCodec.TAG_U64, 43L) + })) + .addWitHostFunction( + new WitHostFunctionDefinition( + "wasip2:host/primitives@0.1.0#clock-monotonic-resolution-ns", + args -> new Object[] { + new WasmcmValueCodec.Boxed( + WasmcmValueCodec.TAG_U64, 1L) + })) + // wasi:io/poll@0.2.3 is a resource-type-only import + // (num_funcs=0 in the sibling Rust probe); a + // placeholder entry keeps the guest's provider + // cross-check happy. + .addWitHostFunction( + new WitHostFunctionDefinition( + "wasi:io/poll@0.2.3#__placeholder", + args -> new Object[0])) + .build(); + + ComponentInstance instance = component.instantiate(ctx); + assertNotNull(instance, "instantiate should return a live instance after Wall 9"); + assertTrue( + !instance.exportedFunctions().isEmpty(), + "wasi_p2_clocks should surface at least one export; got " + + instance.exportedFunctions()); + } + } + + private static void assumeGuestPresent() { + byte[] guest = WasmcmGuestBlobLocator.locateOrNull(); + assumeTrue( + guest != null, + () -> + "wasmcm_runtime_guest.wasm not found; searched: " + + WasmcmGuestBlobLocator.describeLookupOrder()); + } + + private static byte[] loadClasspath(String resource) throws IOException { + try (InputStream is = EndiveComponentIntegrationTest.class + .getClassLoader() + .getResourceAsStream(resource)) { + if (is == null) { + // Fallback: check the module test resources on disk. Convenience path so + // running from an IDE without a fresh test-compile still works. + Path fallback = Paths.get( + System.getProperty("user.dir"), + "src", "test", "resources", resource); + if (Files.isRegularFile(fallback)) { + return Files.readAllBytes(fallback); + } + return null; + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = is.read(buf)) > 0) { + out.write(buf, 0, n); + } + return out.toByteArray(); + } + } +} diff --git a/endive4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/endive/EndiveConfigsTest.java b/endive4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/endive/EndiveConfigsTest.java new file mode 100644 index 0000000..fa8377f --- /dev/null +++ b/endive4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/endive/EndiveConfigsTest.java @@ -0,0 +1,38 @@ +package ai.tegmentum.webassembly4j.provider.endive; + +import ai.tegmentum.webassembly4j.api.config.WebAssemblyConfig; +import ai.tegmentum.webassembly4j.provider.endive.config.EndiveConfig; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class EndiveConfigsTest { + + @Test + void ofWiresEngineAndEngineConfig() { + WebAssemblyConfig cfg = EndiveConfigs.of(b -> b + .executionMode(EndiveConfig.ExecutionMode.COMPILE)); + + assertEquals("endive", cfg.engineId().orElse(null)); + assertTrue(cfg.engineConfig().isPresent()); + EndiveConfig cc = (EndiveConfig) cfg.engineConfig().get(); + assertEquals(EndiveConfig.ExecutionMode.COMPILE, cc.executionMode()); + } + + @Test + void builderHandsOffToCommonSettings() { + WebAssemblyConfig cfg = EndiveConfigs.builder(b -> {}) + .debug(true) + .build(); + + assertEquals("endive", cfg.engineId().orElse(null)); + assertTrue(cfg.commonConfig().debug().orElse(false)); + EndiveConfig cc = (EndiveConfig) cfg.engineConfig().get(); + assertEquals(EndiveConfig.ExecutionMode.INTERPRET, cc.executionMode()); + } + + @Test + void engineIdConstantMatchesProviderDescriptor() { + assertEquals(new EndiveProvider().descriptor().engineId(), EndiveConfigs.ENGINE_ID); + } +} diff --git a/chicory4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryGcExtensionTest.java b/endive4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/endive/EndiveGcExtensionTest.java similarity index 96% rename from chicory4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryGcExtensionTest.java rename to endive4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/endive/EndiveGcExtensionTest.java index c904892..ecd37eb 100644 --- a/chicory4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryGcExtensionTest.java +++ b/endive4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/endive/EndiveGcExtensionTest.java @@ -1,4 +1,4 @@ -package ai.tegmentum.webassembly4j.provider.chicory; +package ai.tegmentum.webassembly4j.provider.endive; import ai.tegmentum.webassembly4j.api.gc.GcArrayInstance; import ai.tegmentum.webassembly4j.api.gc.GcArrayType; @@ -17,13 +17,13 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -class ChicoryGcExtensionTest { +class EndiveGcExtensionTest { - private ChicoryGcExtension gc; + private EndiveGcExtension gc; @BeforeEach void setUp() { - gc = new ChicoryGcExtension(); + gc = new EndiveGcExtension(); } @Test diff --git a/chicory4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryIntegrationTest.java b/endive4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/endive/EndiveIntegrationTest.java similarity index 86% rename from chicory4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryIntegrationTest.java rename to endive4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/endive/EndiveIntegrationTest.java index a0d130a..522066d 100644 --- a/chicory4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryIntegrationTest.java +++ b/endive4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/endive/EndiveIntegrationTest.java @@ -1,4 +1,4 @@ -package ai.tegmentum.webassembly4j.provider.chicory; +package ai.tegmentum.webassembly4j.provider.endive; import ai.tegmentum.webassembly4j.api.DefaultLinkingContext; import ai.tegmentum.webassembly4j.api.Engine; @@ -9,12 +9,12 @@ import ai.tegmentum.webassembly4j.api.ValueType; import ai.tegmentum.webassembly4j.api.config.WebAssemblyConfig; import ai.tegmentum.webassembly4j.api.exception.WebAssemblyException; -import ai.tegmentum.webassembly4j.provider.chicory.config.ChicoryConfig; +import ai.tegmentum.webassembly4j.provider.endive.config.EndiveConfig; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; -class ChicoryIntegrationTest { +class EndiveIntegrationTest { // (func (export "add") (param i32 i32) (result i32) local.get 0 local.get 1 i32.add) private static final byte[] ADD_MODULE = new byte[] { @@ -59,7 +59,7 @@ class ChicoryIntegrationTest { @Test void addFunction() { - try (Engine engine = ChicoryEngineAdapter.create(null); + try (Engine engine = EndiveEngineAdapter.create(null); Module module = engine.loadModule(ADD_MODULE)) { Instance instance = module.instantiate(); Function add = instance.function("add").orElseThrow(); @@ -70,7 +70,7 @@ void addFunction() { @Test void voidFunction() { - try (Engine engine = ChicoryEngineAdapter.create(null); + try (Engine engine = EndiveEngineAdapter.create(null); Module module = engine.loadModule(VOID_MODULE)) { Instance instance = module.instantiate(); Function noop = instance.function("noop").orElseThrow(); @@ -81,14 +81,14 @@ void voidFunction() { @Test void invalidModuleThrows() { - try (Engine engine = ChicoryEngineAdapter.create(null)) { + try (Engine engine = EndiveEngineAdapter.create(null)) { assertThrows(WebAssemblyException.class, () -> engine.loadModule(INVALID_MODULE)); } } @Test void nonexistentFunctionReturnsEmpty() { - try (Engine engine = ChicoryEngineAdapter.create(null); + try (Engine engine = EndiveEngineAdapter.create(null); Module module = engine.loadModule(ADD_MODULE)) { Instance instance = module.instantiate(); assertFalse(instance.function("nonexistent").isPresent()); @@ -97,7 +97,7 @@ void nonexistentFunctionReturnsEmpty() { @Test void memoryReadWrite() { - try (Engine engine = ChicoryEngineAdapter.create(null); + try (Engine engine = EndiveEngineAdapter.create(null); Module module = engine.loadModule(MEMORY_MODULE)) { Instance instance = module.instantiate(); Function store = instance.function("store").orElseThrow(); @@ -111,7 +111,7 @@ void memoryReadWrite() { @Test void memoryDirectAccess() { - try (Engine engine = ChicoryEngineAdapter.create(null); + try (Engine engine = EndiveEngineAdapter.create(null); Module module = engine.loadModule(MEMORY_MODULE)) { Instance instance = module.instantiate(); Memory memory = instance.memory("memory").orElseThrow(); @@ -125,7 +125,7 @@ void memoryDirectAccess() { @Test void memoryGrow() { - try (Engine engine = ChicoryEngineAdapter.create(null); + try (Engine engine = EndiveEngineAdapter.create(null); Module module = engine.loadModule(MEMORY_MODULE)) { Instance instance = module.instantiate(); Memory memory = instance.memory("memory").orElseThrow(); @@ -140,7 +140,7 @@ void memoryGrow() { @Test void hostFunctionLinking() { - try (Engine engine = ChicoryEngineAdapter.create(null); + try (Engine engine = EndiveEngineAdapter.create(null); Module module = engine.loadModule(IMPORT_MODULE)) { DefaultLinkingContext ctx = DefaultLinkingContext.builder() .addHostFunction("env", "add_offset", @@ -158,12 +158,12 @@ void hostFunctionLinking() { @Test void runtimeCompilationMode() { WebAssemblyConfig config = WebAssemblyConfig.builder() - .engineConfig(ChicoryConfig.builder() - .executionMode(ChicoryConfig.ExecutionMode.COMPILE) + .engineConfig(EndiveConfig.builder() + .executionMode(EndiveConfig.ExecutionMode.COMPILE) .build()) .build(); - try (Engine engine = ChicoryEngineAdapter.create(config); + try (Engine engine = EndiveEngineAdapter.create(config); Module module = engine.loadModule(ADD_MODULE)) { Instance instance = module.instantiate(); Function add = instance.function("add").orElseThrow(); @@ -174,12 +174,12 @@ void runtimeCompilationMode() { @Test void compilationModeWithHostFunctions() { WebAssemblyConfig config = WebAssemblyConfig.builder() - .engineConfig(ChicoryConfig.builder() - .executionMode(ChicoryConfig.ExecutionMode.COMPILE) + .engineConfig(EndiveConfig.builder() + .executionMode(EndiveConfig.ExecutionMode.COMPILE) .build()) .build(); - try (Engine engine = ChicoryEngineAdapter.create(config); + try (Engine engine = EndiveEngineAdapter.create(config); Module module = engine.loadModule(IMPORT_MODULE)) { DefaultLinkingContext ctx = DefaultLinkingContext.builder() .addHostFunction("env", "add_offset", diff --git a/chicory4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryProviderTest.java b/endive4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/endive/EndiveProviderTest.java similarity index 51% rename from chicory4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryProviderTest.java rename to endive4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/endive/EndiveProviderTest.java index 1ec0ea9..401ea84 100644 --- a/chicory4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/chicory/ChicoryProviderTest.java +++ b/endive4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/endive/EndiveProviderTest.java @@ -1,26 +1,28 @@ -package ai.tegmentum.webassembly4j.provider.chicory; +package ai.tegmentum.webassembly4j.provider.endive; import ai.tegmentum.webassembly4j.api.Engine; import ai.tegmentum.webassembly4j.api.EngineCapabilities; import ai.tegmentum.webassembly4j.api.EngineInfo; import ai.tegmentum.webassembly4j.api.config.WebAssemblyConfig; import ai.tegmentum.webassembly4j.api.exception.UnsupportedFeatureException; -import ai.tegmentum.webassembly4j.provider.chicory.config.ChicoryConfig; +import ai.tegmentum.webassembly4j.api.exception.ValidationException; +import ai.tegmentum.webassembly4j.api.exception.WebAssemblyException; +import ai.tegmentum.webassembly4j.provider.endive.config.EndiveConfig; import ai.tegmentum.webassembly4j.spi.ProviderAvailability; import ai.tegmentum.webassembly4j.spi.ProviderDescriptor; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; -class ChicoryProviderTest { +class EndiveProviderTest { - private final ChicoryProvider provider = new ChicoryProvider(); + private final EndiveProvider provider = new EndiveProvider(); @Test void descriptorIsCorrect() { ProviderDescriptor desc = provider.descriptor(); - assertEquals("chicory", desc.engineId()); - assertEquals("chicory", desc.providerId()); + assertEquals("endive", desc.engineId()); + assertEquals("endive", desc.providerId()); assertEquals(11, desc.minimumJavaVersion()); assertEquals(50, desc.priority()); assertTrue(desc.tags().contains("pure-java")); @@ -44,8 +46,8 @@ void createEngine() { try (Engine engine = provider.create(null)) { assertNotNull(engine); EngineInfo info = engine.info(); - assertEquals("chicory", info.engineId()); - assertEquals("chicory", info.providerId()); + assertEquals("endive", info.engineId()); + assertEquals("endive", info.providerId()); assertEquals(11, info.minimumJavaVersion()); } } @@ -55,7 +57,9 @@ void capabilities() { try (Engine engine = provider.create(null)) { EngineCapabilities caps = engine.capabilities(); assertTrue(caps.supportsCoreModules()); - assertFalse(caps.supportsComponents()); + // supportsComponents() reflects runtime-guest presence; asserting a + // hard value would make the test order-dependent on the env + // (see WasmcmGuestBlobLocator). Locking in the rest of the surface. assertTrue(caps.supportsWasi()); assertFalse(caps.supportsFuel()); assertFalse(caps.supportsNativeInterop()); @@ -66,15 +70,28 @@ void capabilities() { } @Test - void loadComponentThrowsUnsupported() { + void loadComponentSurfacesFailureCleanly() { try (Engine engine = provider.create(null)) { - assertThrows(UnsupportedFeatureException.class, + // Two honest outcomes depending on whether the wasmcm_runtime_guest.wasm + // blob is resolvable at test time: + // - guest present -> the guest rejects the empty payload with a + // malformed/invalid diagnostic; the provider + // wraps that as ValidationException. + // - guest missing -> the provider surfaces UnsupportedFeatureException + // naming the blob and how to point at it. + // Both are WebAssemblyException subtypes; the test locks in the union. + WebAssemblyException ex = assertThrows( + WebAssemblyException.class, () -> engine.loadComponent(new byte[0])); + assertTrue( + ex instanceof UnsupportedFeatureException || ex instanceof ValidationException, + "expected UnsupportedFeatureException or ValidationException, got " + + ex.getClass().getName()); } } @Test - void configTypeAdvertisesChicoryConfig() { - assertEquals(ChicoryConfig.class, provider.configType().orElse(null)); + void configTypeAdvertisesEndiveConfig() { + assertEquals(EndiveConfig.class, provider.configType().orElse(null)); } } diff --git a/endive4j-provider/src/test/resources/scalar-component.wasm b/endive4j-provider/src/test/resources/scalar-component.wasm new file mode 100644 index 0000000..a67fa30 Binary files /dev/null and b/endive4j-provider/src/test/resources/scalar-component.wasm differ diff --git a/endive4j-provider/src/test/resources/wasi_p2_clocks.component.wasm b/endive4j-provider/src/test/resources/wasi_p2_clocks.component.wasm new file mode 100644 index 0000000..04caa87 Binary files /dev/null and b/endive4j-provider/src/test/resources/wasi_p2_clocks.component.wasm differ diff --git a/graalwasm4j-provider/pom.xml b/graalwasm4j-provider/pom.xml index ae756ac..46b3dbf 100644 --- a/graalwasm4j-provider/pom.xml +++ b/graalwasm4j-provider/pom.xml @@ -8,7 +8,7 @@ ai.tegmentum.webassembly4j webassembly4j-parent - 1.5.0 + 2.5.2-SNAPSHOT graalwasm4j-provider diff --git a/mvnw b/mvnw index 8d937f4..1ddd97b 100755 --- a/mvnw +++ b/mvnw @@ -19,7 +19,7 @@ # ---------------------------------------------------------------------------- # ---------------------------------------------------------------------------- -# Apache Maven Wrapper startup batch script, version 3.2.0 +# Apache Maven Wrapper startup batch script, version 3.3.4 # # Required ENV vars: # ------------------ @@ -33,75 +33,84 @@ # MAVEN_SKIP_RC - flag to disable loading of mavenrc files # ---------------------------------------------------------------------------- -if [ -z "$MAVEN_SKIP_RC" ] ; then +if [ -z "$MAVEN_SKIP_RC" ]; then - if [ -f /usr/local/etc/mavenrc ] ; then + if [ -f /usr/local/etc/mavenrc ]; then . /usr/local/etc/mavenrc fi - if [ -f /etc/mavenrc ] ; then + if [ -f /etc/mavenrc ]; then . /etc/mavenrc fi - if [ -f "$HOME/.mavenrc" ] ; then + if [ -f "$HOME/.mavenrc" ]; then . "$HOME/.mavenrc" fi fi # OS specific support. $var _must_ be set to either true or false. -cygwin=false; -darwin=false; +cygwin=false +darwin=false mingw=false case "$(uname)" in - CYGWIN*) cygwin=true ;; - MINGW*) mingw=true;; - Darwin*) darwin=true - # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home - # See https://developer.apple.com/library/mac/qa/qa1170/_index.html - if [ -z "$JAVA_HOME" ]; then - if [ -x "/usr/libexec/java_home" ]; then - JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME - else - JAVA_HOME="/Library/Java/Home"; export JAVA_HOME - fi +CYGWIN*) cygwin=true ;; +MINGW*) mingw=true ;; +Darwin*) + darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + JAVA_HOME="$(/usr/libexec/java_home)" + export JAVA_HOME + else + JAVA_HOME="/Library/Java/Home" + export JAVA_HOME fi - ;; + fi + ;; esac -if [ -z "$JAVA_HOME" ] ; then - if [ -r /etc/gentoo-release ] ; then +if [ -z "$JAVA_HOME" ]; then + if [ -r /etc/gentoo-release ]; then JAVA_HOME=$(java-config --jre-home) fi fi # For Cygwin, ensure paths are in UNIX format before anything is touched -if $cygwin ; then - [ -n "$JAVA_HOME" ] && - JAVA_HOME=$(cygpath --unix "$JAVA_HOME") - [ -n "$CLASSPATH" ] && - CLASSPATH=$(cygpath --path --unix "$CLASSPATH") +if $cygwin; then + [ -n "$JAVA_HOME" ] \ + && JAVA_HOME=$(cygpath --unix "$JAVA_HOME") + [ -n "$CLASSPATH" ] \ + && CLASSPATH=$(cygpath --path --unix "$CLASSPATH") fi # For Mingw, ensure paths are in UNIX format before anything is touched -if $mingw ; then - [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] && - JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)" +if $mingw; then + [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] \ + && JAVA_HOME="$( + cd "$JAVA_HOME" || ( + echo "cannot cd into $JAVA_HOME." >&2 + exit 1 + ) + pwd + )" fi if [ -z "$JAVA_HOME" ]; then javaExecutable="$(which javac)" - if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then + if [ -n "$javaExecutable" ] && ! [ "$(expr "$javaExecutable" : '\([^ ]*\)')" = "no" ]; then # readlink(1) is not available as standard on Solaris 10. readLink=$(which readlink) if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then - if $darwin ; then - javaHome="$(dirname "\"$javaExecutable\"")" - javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac" + if $darwin; then + javaHome="$(dirname "$javaExecutable")" + javaExecutable="$(cd "$javaHome" && pwd -P)/javac" else - javaExecutable="$(readlink -f "\"$javaExecutable\"")" + javaExecutable="$(readlink -f "$javaExecutable")" fi - javaHome="$(dirname "\"$javaExecutable\"")" + javaHome="$(dirname "$javaExecutable")" javaHome=$(expr "$javaHome" : '\(.*\)/bin') JAVA_HOME="$javaHome" export JAVA_HOME @@ -109,52 +118,60 @@ if [ -z "$JAVA_HOME" ]; then fi fi -if [ -z "$JAVACMD" ] ; then - if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then +if [ -z "$JAVACMD" ]; then + if [ -n "$JAVA_HOME" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD="$JAVA_HOME/jre/sh/java" else JAVACMD="$JAVA_HOME/bin/java" fi else - JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)" + JAVACMD="$( + \unset -f command 2>/dev/null + \command -v java + )" fi fi -if [ ! -x "$JAVACMD" ] ; then +if [ ! -x "$JAVACMD" ]; then echo "Error: JAVA_HOME is not defined correctly." >&2 echo " We cannot execute $JAVACMD" >&2 exit 1 fi -if [ -z "$JAVA_HOME" ] ; then - echo "Warning: JAVA_HOME environment variable is not set." +if [ -z "$JAVA_HOME" ]; then + echo "Warning: JAVA_HOME environment variable is not set." >&2 fi # traverses directory structure from process work directory to filesystem root # first directory with .mvn subdirectory is considered project base directory find_maven_basedir() { - if [ -z "$1" ] - then - echo "Path not specified to find_maven_basedir" + if [ -z "$1" ]; then + echo "Path not specified to find_maven_basedir" >&2 return 1 fi basedir="$1" wdir="$1" - while [ "$wdir" != '/' ] ; do - if [ -d "$wdir"/.mvn ] ; then + while [ "$wdir" != '/' ]; do + if [ -d "$wdir"/.mvn ]; then basedir=$wdir break fi # workaround for JBEAP-8937 (on Solaris 10/Sparc) if [ -d "${wdir}" ]; then - wdir=$(cd "$wdir/.." || exit 1; pwd) + wdir=$( + cd "$wdir/.." || exit 1 + pwd + ) fi # end of workaround done - printf '%s' "$(cd "$basedir" || exit 1; pwd)" + printf '%s' "$( + cd "$basedir" || exit 1 + pwd + )" } # concatenates all lines of a file @@ -165,7 +182,7 @@ concat_lines() { # enabled. Otherwise, we may read lines that are delimited with # \r\n and produce $'-Xarg\r' rather than -Xarg due to word # splitting rules. - tr -s '\r\n' ' ' < "$1" + tr -s '\r\n' ' ' <"$1" fi } @@ -177,75 +194,85 @@ log() { BASE_DIR=$(find_maven_basedir "$(dirname "$0")") if [ -z "$BASE_DIR" ]; then - exit 1; + exit 1 fi -MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR +MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +export MAVEN_PROJECTBASEDIR log "$MAVEN_PROJECTBASEDIR" +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + ########################################################################################## # Extension to allow automatically downloading the maven-wrapper.jar from Maven-central # This allows using the maven wrapper in projects that prohibit checking in binary data. ########################################################################################## wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" if [ -r "$wrapperJarPath" ]; then - log "Found $wrapperJarPath" + log "Found $wrapperJarPath" else - log "Couldn't find $wrapperJarPath, downloading it ..." + log "Couldn't find $wrapperJarPath, downloading it ..." - if [ -n "$MVNW_REPOURL" ]; then - wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + if [ -n "$MVNW_REPOURL" ]; then + wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.3.4/maven-wrapper-3.3.4.jar" + else + wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.4/maven-wrapper-3.3.4.jar" + fi + while IFS="=" read -r key value; do + case "$key" in wrapperUrl) + wrapperUrl=$(trim "${value-}") + break + ;; + esac + done <"$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" + log "Downloading from: $wrapperUrl" + + if $cygwin; then + wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") + fi + + if command -v wget >/dev/null; then + log "Found wget ... using wget" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget ${QUIET:+"$QUIET"} "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" else - wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + wget ${QUIET:+"$QUIET"} --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" fi - while IFS="=" read -r key value; do - # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) - safeValue=$(echo "$value" | tr -d '\r') - case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;; - esac - done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" - log "Downloading from: $wrapperUrl" - + elif command -v curl >/dev/null; then + log "Found curl ... using curl" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl ${QUIET:+"$QUIET"} -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" + else + curl ${QUIET:+"$QUIET"} --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" + fi + else + log "Falling back to using Java to download" + javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" + javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" + # For Cygwin, switch paths to Windows format before running javac if $cygwin; then - wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") + javaSource=$(cygpath --path --windows "$javaSource") + javaClass=$(cygpath --path --windows "$javaClass") fi - - if command -v wget > /dev/null; then - log "Found wget ... using wget" - [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" - if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then - wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" - else - wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" - fi - elif command -v curl > /dev/null; then - log "Found curl ... using curl" - [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" - if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then - curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" - else - curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" - fi - else - log "Falling back to using Java to download" - javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" - javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" - # For Cygwin, switch paths to Windows format before running javac - if $cygwin; then - javaSource=$(cygpath --path --windows "$javaSource") - javaClass=$(cygpath --path --windows "$javaClass") - fi - if [ -e "$javaSource" ]; then - if [ ! -e "$javaClass" ]; then - log " - Compiling MavenWrapperDownloader.java ..." - ("$JAVA_HOME/bin/javac" "$javaSource") - fi - if [ -e "$javaClass" ]; then - log " - Running MavenWrapperDownloader.java ..." - ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" - fi - fi + if [ -e "$javaSource" ]; then + if [ ! -e "$javaClass" ]; then + log " - Compiling MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/javac" "$javaSource") + fi + if [ -e "$javaClass" ]; then + log " - Running MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" + fi fi + fi fi ########################################################################################## # End of extension @@ -254,22 +281,25 @@ fi # If specified, validate the SHA-256 sum of the Maven wrapper jar file wrapperSha256Sum="" while IFS="=" read -r key value; do - case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;; + case "$key" in wrapperSha256Sum) + wrapperSha256Sum=$(trim "${value-}") + break + ;; esac -done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" +done <"$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" if [ -n "$wrapperSha256Sum" ]; then wrapperSha256Result=false - if command -v sha256sum > /dev/null; then - if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then + if command -v sha256sum >/dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c - >/dev/null 2>&1; then wrapperSha256Result=true fi - elif command -v shasum > /dev/null; then - if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then + elif command -v shasum >/dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c >/dev/null 2>&1; then wrapperSha256Result=true fi else - echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." - echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." >&2 exit 1 fi if [ $wrapperSha256Result = false ]; then @@ -284,12 +314,12 @@ MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" # For Cygwin, switch paths to Windows format before running java if $cygwin; then - [ -n "$JAVA_HOME" ] && - JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") - [ -n "$CLASSPATH" ] && - CLASSPATH=$(cygpath --path --windows "$CLASSPATH") - [ -n "$MAVEN_PROJECTBASEDIR" ] && - MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") + [ -n "$JAVA_HOME" ] \ + && JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") + [ -n "$CLASSPATH" ] \ + && CLASSPATH=$(cygpath --path --windows "$CLASSPATH") + [ -n "$MAVEN_PROJECTBASEDIR" ] \ + && MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") fi # Provide a "standardized" way to retrieve the CLI args that will diff --git a/mvnw.cmd b/mvnw.cmd index f80fbad..c453424 100644 --- a/mvnw.cmd +++ b/mvnw.cmd @@ -18,7 +18,7 @@ @REM ---------------------------------------------------------------------------- @REM ---------------------------------------------------------------------------- -@REM Apache Maven Wrapper startup batch script, version 3.2.0 +@REM Apache Maven Wrapper startup batch script, version 3.3.4 @REM @REM Required ENV vars: @REM JAVA_HOME - location of a JDK home dir @@ -59,22 +59,22 @@ set ERROR_CODE=0 @REM ==== START VALIDATION ==== if not "%JAVA_HOME%" == "" goto OkJHome -echo. +echo. >&2 echo Error: JAVA_HOME not found in your environment. >&2 echo Please set the JAVA_HOME variable in your environment to match the >&2 echo location of your Java installation. >&2 -echo. +echo. >&2 goto error :OkJHome if exist "%JAVA_HOME%\bin\java.exe" goto init -echo. +echo. >&2 echo Error: JAVA_HOME is set to an invalid directory. >&2 echo JAVA_HOME = "%JAVA_HOME%" >&2 echo Please set the JAVA_HOME variable in your environment to match the >&2 echo location of your Java installation. >&2 -echo. +echo. >&2 goto error @REM ==== END VALIDATION ==== @@ -119,7 +119,7 @@ SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain -set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" +set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.4/maven-wrapper-3.3.4.jar" FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B @@ -133,7 +133,7 @@ if exist %WRAPPER_JAR% ( ) ) else ( if not "%MVNW_REPOURL%" == "" ( - SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.3.4/maven-wrapper-3.3.4.jar" ) if "%MVNW_VERBOSE%" == "true" ( echo Couldn't find %WRAPPER_JAR%, downloading it ... @@ -160,11 +160,12 @@ FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapp ) IF NOT %WRAPPER_SHA_256_SUM%=="" ( powershell -Command "&{"^ + "Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash;"^ "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ - " Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ - " Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ - " Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ + " Write-Error 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ + " Write-Error 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ + " Write-Error 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ " exit 1;"^ "}"^ "}" diff --git a/pom.xml b/pom.xml index 7008c50..2d92038 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ ai.tegmentum.webassembly4j webassembly4j-parent - 1.5.0 + 2.5.2-SNAPSHOT pom WebAssembly4J Parent @@ -43,7 +43,7 @@ wasmtime4j-provider wamr4j-provider wasm3-provider - chicory4j-provider + endive4j-provider graalwasm4j-provider webassembly4j-runtime webassembly4j-testing @@ -52,6 +52,9 @@ webassembly4j-benchmarks webassembly4j-bindgen webassembly4j-component-builder + wasmos-embedder-generated + wasmos-runtime-generated + wasmos-provider @@ -62,22 +65,22 @@ UTF-8 - 5.10.4 - 46.0.1-1.1.7 + 5.14.4 + 47.0.2-1.5.1 2.4.4-1.0.2 - 1.7.3 - 24.2.1 + 1.1.0 + 24.2.2 1.37 - 2.15.2 - 3.2.5 + 2.22.2 + 3.5.16 - 3.12.1 - 3.2.5 - 3.3.0 - 3.3.0 - 3.6.3 - 1.6.0 + 3.16.0 + 3.6.0 + 3.5.1 + 3.4.0 + 3.12.0 + 1.8.0 @@ -92,6 +95,16 @@ webassembly4j-spi ${project.version} + + ai.tegmentum.webassembly4j + wasmos-embedder-generated + ${project.version} + + + ai.tegmentum.webassembly4j + wasmos-runtime-generated + ${project.version} + ai.tegmentum wasmtime4j @@ -123,24 +136,24 @@ ${wamr4j.version} - com.dylibso.chicory + run.endive runtime - ${chicory.version} + ${endive.version} - com.dylibso.chicory + run.endive wasm - ${chicory.version} + ${endive.version} - com.dylibso.chicory + run.endive wasi - ${chicory.version} + ${endive.version} - com.dylibso.chicory + run.endive compiler - ${chicory.version} + ${endive.version} org.graalvm.polyglot @@ -304,7 +317,7 @@ org.apache.maven.plugins maven-gpg-plugin - 3.1.0 + 3.2.8 sign-artifacts diff --git a/wamr4j-provider/pom.xml b/wamr4j-provider/pom.xml index 50c46c5..0d6f270 100644 --- a/wamr4j-provider/pom.xml +++ b/wamr4j-provider/pom.xml @@ -8,7 +8,7 @@ ai.tegmentum.webassembly4j webassembly4j-parent - 1.5.0 + 2.5.2-SNAPSHOT wamr4j-provider diff --git a/wasm3-provider/pom.xml b/wasm3-provider/pom.xml index 8522d4f..3566a5d 100644 --- a/wasm3-provider/pom.xml +++ b/wasm3-provider/pom.xml @@ -8,7 +8,7 @@ ai.tegmentum.webassembly4j webassembly4j-parent - 1.5.0 + 2.5.2-SNAPSHOT wasm3-provider diff --git a/wasmos-embedder-generated/pom.xml b/wasmos-embedder-generated/pom.xml new file mode 100644 index 0000000..480ac8a --- /dev/null +++ b/wasmos-embedder-generated/pom.xml @@ -0,0 +1,74 @@ + + + 4.0.0 + + + ai.tegmentum.webassembly4j + webassembly4j-parent + 2.5.2-SNAPSHOT + + + wasmos-embedder-generated + jar + + WebAssembly4J :: wasmos-embedder generated bindings + + Java bindings generated by webassembly4j-bindgen from + wasmos:host/embedder@0.1.0 (per ADR-006). The sole content of this + module is: + + - The 8 generated source files under + ai.tegmentum.wasmos.embedder.generated: + * RuntimeInstance, HostProvider — resources whose bodies + dispatch through the bindgen-2.0 runtime-provider SPI + (see EmbedderRuntime below). + * EmbedderCallbacks, ImportSatisfaction, Error, ErrorCode. + * EmbedderRuntime — bindgen-2.0 runtime-provider SPI. The + embedder implements this once (e.g. + wasi-p2-rs's WasmcmEmbedderRuntime) and installs it via + EmbedderRuntimeRegistry.install(...). + * EmbedderRuntimeRegistry — single-slot registry the + generated resource bodies consult. + - A local copy of the WitResult carrier at + ai.tegmentum.webassembly4j.bindgen.wit.WitResult so the module + has no compile-time or runtime dependency on + webassembly4j-bindgen (which is a maven-plugin-packaged + build-time-only artifact). + + Downstream embedders (wasi-p2-rs, wasm-cm) depend on this jar for + the ADR-006 embedder surface and provide a concrete EmbedderRuntime + implementation that maps the SPI onto their runtime driver + (WasmcmRuntimeGuestLoader for the JVM lane). + + Regenerate the wasmos.embedder.generated files with: + + java -jar webassembly4j-bindgen/target/webassembly4j-bindgen-2.5.1-cli.jar \ + --wit ~/git/wasm-cm/wit/wasmos-host/embedder.wit \ + --package ai.tegmentum.wasmos.embedder.generated \ + --output wasmos-embedder-generated/src/main/java \ + --no-impl \ + --runtime-provider EmbedderRuntime + + + + + ai.tegmentum.webassembly4j + webassembly4j-api + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 11 + + + + + diff --git a/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/EmbedderApi.java b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/EmbedderApi.java new file mode 100644 index 0000000..d13fb50 --- /dev/null +++ b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/EmbedderApi.java @@ -0,0 +1,8 @@ +package ai.tegmentum.wasmos.embedder.generated; + +import ai.tegmentum.webassembly4j.bindgen.wit.WitResult; +import java.util.List; + +public interface EmbedderApi { + WitResult, Error> dispatchHostCallback(long callbackId, List argsFrame); +} diff --git a/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/EmbedderCallbacks.java b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/EmbedderCallbacks.java new file mode 100644 index 0000000..ff532f4 --- /dev/null +++ b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/EmbedderCallbacks.java @@ -0,0 +1,12 @@ +package ai.tegmentum.wasmos.embedder.generated; + +import ai.tegmentum.webassembly4j.bindgen.wit.WitResult; +import java.util.List; + +public interface EmbedderCallbacks { + WitResult, String> hostCall(int providerHandle, int funcIdx, List args); + + long nowMonotonicNs(); + + List readRandom(int len); +} diff --git a/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/EmbedderRuntime.java b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/EmbedderRuntime.java new file mode 100644 index 0000000..0b421ba --- /dev/null +++ b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/EmbedderRuntime.java @@ -0,0 +1,32 @@ +package ai.tegmentum.wasmos.embedder.generated; + +import ai.tegmentum.webassembly4j.bindgen.wit.WitResult; +import java.util.List; + +/** + * Runtime-provider SPI for the generated bindings. + * + *

The embedder implements this interface once and installs the implementation + * via {@link EmbedderRuntimeRegistry#install(EmbedderRuntime)}. Generated resource method bodies dispatch + * through {@link EmbedderRuntimeRegistry#runtime()} to reach the installed provider. + * + *

Emitted by webassembly4j-bindgen 2.0 (runtime-provider dispatch mode). + */ +public interface EmbedderRuntime { + HostProvider hostProviderCreate(String interfaceName, int numFuncs); + + void hostProviderAttachMemory(long handle, HostProviderMemory mem); + + void hostProviderClose(long handle); + + WitResult runtimeInstanceInstantiate(List componentBytes, + List imports); + + WitResult, Error> runtimeInstanceCallExport(long handle, String name, List args); + + String runtimeInstanceIntrospect(long handle); + + WitResult runtimeInstanceVerifyWorld(long handle, String expectedWit); + + void runtimeInstanceClose(long handle); +} diff --git a/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/EmbedderRuntimeRegistry.java b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/EmbedderRuntimeRegistry.java new file mode 100644 index 0000000..a998298 --- /dev/null +++ b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/EmbedderRuntimeRegistry.java @@ -0,0 +1,36 @@ +package ai.tegmentum.wasmos.embedder.generated; + +import java.util.Objects; + +/** + * Single-slot registry for the {@link EmbedderRuntime} runtime-provider SPI. + * + *

Call {@link #install(EmbedderRuntime)} once from the embedder's startup path, before + * invoking any generated resource method. Generated bodies reach the installed + * provider via {@link #runtime()}, which throws {@link IllegalStateException} if + * nothing was installed. + * + *

Emitted by webassembly4j-bindgen 2.0. + */ +public final class EmbedderRuntimeRegistry { + private static volatile EmbedderRuntime provider; + + private EmbedderRuntimeRegistry() { + } + + public static void install(EmbedderRuntime provider) { + EmbedderRuntimeRegistry.provider = Objects.requireNonNull(provider, "provider"); + } + + public static void uninstall() { + EmbedderRuntimeRegistry.provider = null; + } + + public static EmbedderRuntime runtime() { + EmbedderRuntime r = provider; + if (r == null) { + throw new IllegalStateException("EmbedderRuntime not installed; call EmbedderRuntimeRegistry.install(...) before invoking generated bindings"); + } + return r; + } +} diff --git a/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/Error.java b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/Error.java new file mode 100644 index 0000000..09cbbd1 --- /dev/null +++ b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/Error.java @@ -0,0 +1,38 @@ +package ai.tegmentum.wasmos.embedder.generated; + +public final class Error { + private final ErrorCode code; + + private final String message; + + public Error(ErrorCode code, String message) { + this.code = code; + this.message = message; + } + + public ErrorCode code() { + return this.code; + } + + public String message() { + return this.message; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + Error that = (Error) obj; + return java.util.Objects.equals(this.code, that.code) && java.util.Objects.equals(this.message, that.message); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(code, message); + } + + @Override + public String toString() { + return "Error[code=" + code + ", message=" + message + "]"; + } +} diff --git a/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/ErrorCode.java b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/ErrorCode.java new file mode 100644 index 0000000..13f028a --- /dev/null +++ b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/ErrorCode.java @@ -0,0 +1,17 @@ +package ai.tegmentum.wasmos.embedder.generated; + +public enum ErrorCode { + INVALID_COMPONENT, + + UNSATISFIED_IMPORT, + + MISSING_EXPORT, + + HOST_CALLBACK_FAILED, + + TRAP, + + WIT_MISMATCH, + + OTHER +} diff --git a/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/HostProvider.java b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/HostProvider.java new file mode 100644 index 0000000..789ea52 --- /dev/null +++ b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/HostProvider.java @@ -0,0 +1,31 @@ +package ai.tegmentum.wasmos.embedder.generated; + +/** + * Resource type: host-provider + * + *

This resource should be closed after use. + */ +public class HostProvider implements AutoCloseable { + private final long handle; + + public HostProvider(long handle) { + this.handle = handle; + } + + public long handle() { + return this.handle; + } + + @Override + public void close() { + EmbedderRuntimeRegistry.runtime().hostProviderClose(this.handle); + } + + public static HostProvider create(String interfaceName, int numFuncs) { + return EmbedderRuntimeRegistry.runtime().hostProviderCreate(interfaceName, numFuncs); + } + + public void attachMemory(HostProviderMemory mem) { + EmbedderRuntimeRegistry.runtime().hostProviderAttachMemory(this.handle, mem); + } +} diff --git a/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/HostProviderMemory.java b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/HostProviderMemory.java new file mode 100644 index 0000000..4496d12 --- /dev/null +++ b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/HostProviderMemory.java @@ -0,0 +1,45 @@ +package ai.tegmentum.wasmos.embedder.generated; + +public final class HostProviderMemory { + private final long memory; + + private final long realloc; + + private final StringEncoding stringEncoding; + + public HostProviderMemory(long memory, long realloc, StringEncoding stringEncoding) { + this.memory = memory; + this.realloc = realloc; + this.stringEncoding = stringEncoding; + } + + public long memory() { + return this.memory; + } + + public long realloc() { + return this.realloc; + } + + public StringEncoding stringEncoding() { + return this.stringEncoding; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + HostProviderMemory that = (HostProviderMemory) obj; + return this.memory == that.memory && this.realloc == that.realloc && java.util.Objects.equals(this.stringEncoding, that.stringEncoding); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(memory, realloc, stringEncoding); + } + + @Override + public String toString() { + return "HostProviderMemory[memory=" + memory + ", realloc=" + realloc + ", stringEncoding=" + stringEncoding + "]"; + } +} diff --git a/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/ImportSatisfaction.java b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/ImportSatisfaction.java new file mode 100644 index 0000000..e8cb76e --- /dev/null +++ b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/ImportSatisfaction.java @@ -0,0 +1,38 @@ +package ai.tegmentum.wasmos.embedder.generated; + +public final class ImportSatisfaction { + private final String interfaceName; + + private final HostProvider provider; + + public ImportSatisfaction(String interfaceName, HostProvider provider) { + this.interfaceName = interfaceName; + this.provider = provider; + } + + public String interfaceName() { + return this.interfaceName; + } + + public HostProvider provider() { + return this.provider; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + ImportSatisfaction that = (ImportSatisfaction) obj; + return java.util.Objects.equals(this.interfaceName, that.interfaceName) && java.util.Objects.equals(this.provider, that.provider); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(interfaceName, provider); + } + + @Override + public String toString() { + return "ImportSatisfaction[interfaceName=" + interfaceName + ", provider=" + provider + "]"; + } +} diff --git a/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/RuntimeInstance.java b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/RuntimeInstance.java new file mode 100644 index 0000000..b65247a --- /dev/null +++ b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/RuntimeInstance.java @@ -0,0 +1,43 @@ +package ai.tegmentum.wasmos.embedder.generated; + +import ai.tegmentum.webassembly4j.bindgen.wit.WitResult; +import java.util.List; + +/** + * Resource type: runtime-instance + * + *

This resource should be closed after use. + */ +public class RuntimeInstance implements AutoCloseable { + private final long handle; + + public RuntimeInstance(long handle) { + this.handle = handle; + } + + public long handle() { + return this.handle; + } + + @Override + public void close() { + EmbedderRuntimeRegistry.runtime().runtimeInstanceClose(this.handle); + } + + public static WitResult instantiate(List componentBytes, + List imports) { + return EmbedderRuntimeRegistry.runtime().runtimeInstanceInstantiate(componentBytes, imports); + } + + public WitResult, Error> callExport(String name, List args) { + return EmbedderRuntimeRegistry.runtime().runtimeInstanceCallExport(this.handle, name, args); + } + + public String introspect() { + return EmbedderRuntimeRegistry.runtime().runtimeInstanceIntrospect(this.handle); + } + + public WitResult verifyWorld(String expectedWit) { + return EmbedderRuntimeRegistry.runtime().runtimeInstanceVerifyWorld(this.handle, expectedWit); + } +} diff --git a/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/StringEncoding.java b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/StringEncoding.java new file mode 100644 index 0000000..3109daa --- /dev/null +++ b/wasmos-embedder-generated/src/main/java/ai/tegmentum/wasmos/embedder/generated/StringEncoding.java @@ -0,0 +1,18 @@ +package ai.tegmentum.wasmos.embedder.generated; + +public interface StringEncoding { + final class Utf8 implements StringEncoding { + public Utf8() { + } + } + + final class Utf16 implements StringEncoding { + public Utf16() { + } + } + + final class Latin1OrUtf16 implements StringEncoding { + public Latin1OrUtf16() { + } + } +} diff --git a/wasmos-embedder-generated/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitResult.java b/wasmos-embedder-generated/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitResult.java new file mode 100644 index 0000000..ba37ff4 --- /dev/null +++ b/wasmos-embedder-generated/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitResult.java @@ -0,0 +1,127 @@ +package ai.tegmentum.webassembly4j.bindgen.wit; + +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +/** + * Runtime representation of the WIT {@code result} type. + * + *

The bindgen code generator references this class by its fully-qualified + * name (see {@link + * ai.tegmentum.webassembly4j.bindgen.generator.TypeMappingRegistry}); it must + * therefore exist as a real class on the consumer's classpath. The two + * factory methods {@link #ok(Object)} and {@link #err(Object)} construct + * either side; the resulting instance is immutable. + * + *

Generated code typically follows this pattern: + * + *

{@code
+ * WitResult result = parser.parse(language, source);
+ * if (result.isOk()) {
+ *     ParseResult tree = result.unwrap();
+ * } else {
+ *     String msg = result.unwrapErr();
+ * }
+ * }
+ * + * @param the success-payload type ({@link Void} when the WIT signature is + * {@code result<_, err>}) + * @param the error-payload type ({@link Void} when the WIT signature is + * {@code result} or {@code result}) + */ +public final class WitResult { + + private final T ok; + private final E err; + private final boolean isOk; + + private WitResult(final T ok, final E err, final boolean isOk) { + this.ok = ok; + this.err = err; + this.isOk = isOk; + } + + /** Construct a successful result. */ + public static WitResult ok(final T value) { + return new WitResult<>(value, null, true); + } + + /** Construct an error result. */ + public static WitResult err(final E value) { + return new WitResult<>(null, value, false); + } + + /** True if this is a success result. */ + public boolean isOk() { + return isOk; + } + + /** True if this is an error result. */ + public boolean isErr() { + return !isOk; + } + + /** Returns the success payload or throws when {@link #isErr()}. */ + public T unwrap() { + if (!isOk) { + throw new NoSuchElementException("WitResult is err: " + err); + } + return ok; + } + + /** Returns the error payload or throws when {@link #isOk()}. */ + public E unwrapErr() { + if (isOk) { + throw new NoSuchElementException("WitResult is ok: " + ok); + } + return err; + } + + /** Optional view of the success side. */ + public Optional ok() { + return isOk ? Optional.ofNullable(ok) : Optional.empty(); + } + + /** Optional view of the error side. */ + public Optional err() { + return isOk ? Optional.empty() : Optional.ofNullable(err); + } + + /** Map the success payload through {@code f}, leaving an error unchanged. */ + public WitResult map(final Function f) { + Objects.requireNonNull(f, "f"); + return isOk ? WitResult.ok(f.apply(ok)) : WitResult.err(err); + } + + /** Map the error payload through {@code f}, leaving a success unchanged. */ + public WitResult mapErr(final Function f) { + Objects.requireNonNull(f, "f"); + return isOk ? WitResult.ok(ok) : WitResult.err(f.apply(err)); + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof WitResult)) { + return false; + } + final WitResult other = (WitResult) o; + return isOk == other.isOk + && Objects.equals(ok, other.ok) + && Objects.equals(err, other.err); + } + + @Override + public int hashCode() { + return Objects.hash(ok, err, isOk); + } + + @Override + public String toString() { + return isOk ? "Ok(" + ok + ")" : "Err(" + err + ")"; + } +} diff --git a/wasmos-provider/.gitignore b/wasmos-provider/.gitignore new file mode 100644 index 0000000..70ebdef --- /dev/null +++ b/wasmos-provider/.gitignore @@ -0,0 +1,15 @@ +# Cargo build tree — lives outside Maven's target/ (see pom.xml exec-maven-plugin +# CARGO_TARGET_DIR env var) so `mvn clean` doesn't blow away the Rust cache. +# Everything under here is regenerated by cargo build; never committed. +.cargo-target/ + +# Standard cargo target dir — used if cargo is invoked directly without the +# CARGO_TARGET_DIR override. +native/target/ + +# Native library outputs staged by the maven-antrun step into +# src/main/resources/natives/{platform}/ — these are regenerated by every +# build from the cargo output, so tracking them creates the stale-dylib +# landmine we hit in wasmtime4j (see local-dev profile discussion). The +# maven build's unpack-dependencies overlay is authoritative. +src/main/resources/natives/ diff --git a/wasmos-provider/README.md b/wasmos-provider/README.md new file mode 100644 index 0000000..ebbfeb8 --- /dev/null +++ b/wasmos-provider/README.md @@ -0,0 +1,119 @@ +# wasmos-provider + +`webassembly4j` provider backed by the Rust +[`wasmos-runtime`](https://github.com/tegmentum/wasmos) library. Wraps +wasmos-runtime's `add_all_to_linker` + `caps::wasi_p2` surface via JNI +so Java consumers can host wasmos-shaped WASM components (typically +control-plane guests composed with an adapter) through +webassembly4j's normal `Engine` / `Component` / `ComponentInstance` API. + +Selected via `EngineSelection.byId("wasmos")`; provider priority 90. + +## What works + +- Load a wasmos-composed component from bytes +- Instantiate with default caps or a `LinkingContext.wasiContext()` + ("wasi_p2" cap granted through wasmos-runtime) +- Honor `ComponentConfig` fields: `maxMemoryBytes`, `epochDeadline`, + `fuelLimit`, `maxTableElements`, `maxInstances`, `maxTables`, + `maxMemories` +- Invoke exported functions — sync path (`ComponentInstance.invoke`) + and async path (`WasmosAsyncExtension.invokeAsync` returning + `CompletableFuture`) with best-effort cancellation +- Two-way marshalling for primitives, `String`, `List`, `Map`, + `Set` (flags), `Optional`, tuples, `byte[]` (list fast path), + and the typed carriers `WitResource`, `WitFuture`, `WitStream`, + `WitErrorContext` +- Component serialize / deserialize for hot-reload +- Nested-interface function lookup (functions inside exported + interfaces resolve via `#` syntax) + +## What is intentionally not supported + +- Java-provided core-wasm host functions (`LinkingContext.hostFunctions`) + — wasmos is WIT-first; use `wasmtime4j-provider` if you need to inject + Java host functions into a core-wasm module +- `Engine.loadModule` (core-wasm loading) — wasmos-provider is + component-only +- Host-side reading of `Val::Future` / `Val::Stream` — wasmtime 47's + typed reader API doesn't support dynamic-payload polling. Parking, + closing, and passing these values back into guests works; awaiting or + iterating them host-side throws a documented "wasmtime API gap". + When wasmtime upstream ships the dynamic reader, the stub JNI + entries (`futureAwait`, `streamRead`) can be filled in without a + Java surface change. +- `wasi:nn`, `wasi:http`, per-endpoint network egress — needs + wasmtime-wasi upstream API surface + +## Build setup — REQUIRES sibling repository checkouts + +`wasmos-runtime` (this module's Rust dep) is declared via a RELATIVE +path (`../../../wasmos` from `wasmos-provider/native/Cargo.toml`), +which itself declares path deps on other tegmentum repositories that +must be co-checked-out at fixed relative paths. **Building this crate +from a fresh clone of `webassembly4j` alone WILL fail.** You need +four sibling checkouts: + +``` +~/git/webassembly4j (this repo) +~/git/wasmos (wasmos-provider/native/Cargo.toml points here via ../../../wasmos) +~/git/wasm-cm (wasmos's sibling path dep, ../wasm-cm/...) +~/git/wasm-continuity (wasmos's sibling path dep, ../wasm-continuity/...) +``` + +Once all four are checked out at the same parent (any parent — the +paths are all relative), `cd wasmos-provider && mvn install` from the +webassembly4j root works. + +### Why not git-dep? + +`wasmos-runtime = { git = "..." }` fails at build time because cargo +fetches wasmos from GitHub but then cannot resolve wasmos's own +`../wasm-cm/...` and `../wasm-continuity/...` path deps in the fetched +tree. + +### Why not crates.io? + +Neither `wasmos-runtime`, `wasm-cm`, nor `wasm-continuity` publishes +versioned crates yet. Publishing wasmos-provider to Maven Central is +blocked on this. + +### Paths to unblock external / CI builds + +1. wasmos + wasm-cm + wasm-continuity publish versioned crates. +2. wasmos restructures to git-dep (or workspace-internal) its + siblings — no more `path = "..//..."`. +3. Fork wasmos into a git-dep-clean tree. + +The `wasmos-provider/native/Cargo.toml` comment tracks the same +information for cargo-only readers. + +## Layout + +``` +wasmos-provider/ +├── pom.xml # exec-maven-plugin runs cargo +├── native/ +│ ├── Cargo.toml # path dep to ~/git/wasmos +│ └── src/lib.rs # JNI extern "C" shims +└── src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/ + ├── WasmosProvider.java # EngineProvider SPI impl + ├── WasmosEngineAdapter.java + ├── WasmosComponentAdapter.java + ├── WasmosComponentInstanceAdapter.java + ├── WasmosMarshalling.java # WIT-shape carriers + JSON codec + ├── ext/ + │ ├── WasmosAsyncExtension.java # invokeAsync + futures/streams + │ └── WitErrorContextException.java + └── jni/ + ├── WasmosNative.java # native method decls + └── WasmosNativeLoader.java # extracts dylib from jar +``` + +## Testing + +`mvn test -pl wasmos-provider` runs the Java suite (62 tests) plus +the Rust unit tests (19). Includes an end-to-end test that loads +`~/git/wasmos/tests/e2e-fixtures/composed_wasmtime.wasm` — assumeTrue- +skipped if the fixture path doesn't exist. The wasmos-composed component +returns 42 from its `run` export. diff --git a/wasmos-provider/native/Cargo.lock b/wasmos-provider/native/Cargo.lock new file mode 100644 index 0000000..947d0ad --- /dev/null +++ b/wasmos-provider/native/Cargo.lock @@ -0,0 +1,3932 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +dependencies = [ + "gimli", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cap-fs-ext" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78e5a3368ae89b7cb68186411452b4b9fac8b41be9c19bf3f47c2d2c8e36e6b" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes 3.0.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "cap-primitives" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdadbd7c002d3a484b35243669abdae85a0ebaded5a61117169dc3400f9a7ff0" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cap-std" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7281235d6e96d3544ca18bba9049be92f4190f8d923e3caef1b5f66cfa752608" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes 3.0.1", + "rustix", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.19", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpp_demangle" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "cranelift-assembler-x64" +version = "0.134.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0435c6939d6ef218bf0a3063094f2cc5c46f7a44a88f81cf932da8d3922ff8" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.134.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aca52511cc21a2c9c927aef493d11c8087bdc5483249a3d93aee41e81b8bd4d1" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.134.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6867e99f068454b0c81c50b807af88aa80aaf383105e2be26675d81fc4278466" +dependencies = [ + "cranelift-entity", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-bitset" +version = "0.134.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cf66017aed971344230199ad35ca01c955cf824a0e65fed5240452d2a302445" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen" +version = "0.134.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "933b2e5237665bf9367a34ad2100eed70c94b2796586cb09d9ba11693e061667" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.17.1", + "libm", + "log", + "postcard", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "serde_derive", + "sha2", + "smallvec", + "target-lexicon", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.134.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322e3ca0603683cc17f0c808559c0fb85ad52b0f20ace0478bdd4ba2fa1e9678" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.134.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287ab88211d049178dc8dda243e2db176919d8b29db605c0efc7c6d76ebea0f7" + +[[package]] +name = "cranelift-control" +version = "0.134.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c11438f9becf9a1e1e061bf76a33241b941a07ed01a489e9dad5fa79dc3624e9" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.134.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "584ecabe1462593a9cba9efbf9022e0987421ddb548989b455ab8bdd444b743c" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-frontend" +version = "0.134.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34249873145213a12106d5fd8a98a9dc9e90e16525e2337b2907874d1e7e9ee7" +dependencies = [ + "cranelift-codegen", + "hashbrown 0.17.1", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.134.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f1c1dc4d6b4bc4f0be12b08cee0e59f7610287827aa62590f8e9ec6f45dff4" + +[[package]] +name = "cranelift-native" +version = "0.134.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31d9e4f6d9a3777b1ffa818000b4a6d7afd1b8787af71a9c58d94baef9076ced" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.134.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4155f8a1afdef96a0272c67804926eee4ef35f9708ab22ad6878d4c9543df596" + +[[package]] +name = "crc-fast" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +dependencies = [ + "digest 0.10.7", + "spin", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxprof-processed-profile" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" +dependencies = [ + "bitflags", + "debugid", + "rustc-hash", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.60.2", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "ittapi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" +dependencies = [ + "anyhow", + "ittapi-sys", + "log", +] + +[[package]] +name = "ittapi-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" +dependencies = [ + "cc", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.19", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "leb128" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "crc32fast", + "hashbrown 0.17.1", + "indexmap", + "memchr", +] + +[[package]] +name = "object_store" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354792e39fa5f0009e47623cf8b15b099bf9a652fa55c6f817fe28ac84fea50" +dependencies = [ + "async-trait", + "aws-lc-rs", + "base64", + "bytes", + "chrono", + "crc-fast", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body-util", + "humantime", + "hyper", + "itertools 0.15.0", + "md-5", + "nix", + "parking_lot", + "percent-encoding", + "quick-xml", + "rand 0.10.2", + "reqwest 0.13.4", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.19", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulley-interpreter" +version = "47.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6064e52588328c1275d690baad99f8d4b3fdd870736966f1a63cfcbb20560b6" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "47.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e832980317bfc7f8c8538ffc9c69b82912f3e33ec70f7c855d521654c70fa62" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.19", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.19", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regalloc2" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.17.1", + "log", + "rustc-hash", + "serde", + "smallvec", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.19", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-compose" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59b710751a35d54732a63851cdfacbfb2266b7160ce174faf269d3f4e84e31b" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "log", + "petgraph", + "smallvec", + "wasm-encoder 0.252.0", + "wasmparser 0.252.0", + "wat", +] + +[[package]] +name = "wasm-continuity" +version = "0.1.0" +dependencies = [ + "blake3", + "serde", + "serde_json", + "thiserror 2.0.19", + "uuid", +] + +[[package]] +name = "wasm-continuity-driver" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.19", + "wasm-continuity", + "wasm-continuity-fencing", +] + +[[package]] +name = "wasm-continuity-fencing" +version = "0.1.0" +dependencies = [ + "thiserror 2.0.19", + "wasm-continuity", +] + +[[package]] +name = "wasm-continuity-kv" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.19", + "wasm-continuity", + "wasm-continuity-kv-backend", +] + +[[package]] +name = "wasm-continuity-kv-backend" +version = "0.1.0" +dependencies = [ + "serde", + "thiserror 2.0.19", +] + +[[package]] +name = "wasm-encoder" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f" +dependencies = [ + "leb128fmt", + "wasmparser 0.252.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09480d646178e5fdd12bb06e812d0af9a3a191dbc9cd697fdc86687beade7393" +dependencies = [ + "leb128fmt", + "wasmparser 0.254.0", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmcm-cm-host-rust" +version = "0.0.1" +dependencies = [ + "anyhow", + "wasmcm-host-rust", + "wasmtime", +] + +[[package]] +name = "wasmcm-host-rust" +version = "0.0.1" +dependencies = [ + "wasmtime", +] + +[[package]] +name = "wasmos-bootstrap-log" +version = "0.1.0" + +[[package]] +name = "wasmos-capability" +version = "0.1.0" + +[[package]] +name = "wasmos-graph" +version = "0.1.0" +dependencies = [ + "wasmos-capability", + "wasmos-machine", +] + +[[package]] +name = "wasmos-machine" +version = "0.1.0" +dependencies = [ + "wasmos-bootstrap-log", +] + +[[package]] +name = "wasmos-provider-native" +version = "0.1.0" +dependencies = [ + "anyhow", + "jni 0.21.1", + "serde", + "serde_json", + "tokio", + "wasmos-runtime", + "wasmtime", + "wasmtime-wasi", +] + +[[package]] +name = "wasmos-resolver" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "wasmos-capability", + "wasmos-graph", + "wasmos-machine", +] + +[[package]] +name = "wasmos-runtime" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "futures", + "futures-core", + "futures-util", + "log", + "object_store", + "reqwest 0.12.28", + "serde_json", + "tokio", + "tungstenite", + "uuid", + "wasm-continuity", + "wasm-continuity-driver", + "wasm-continuity-fencing", + "wasm-continuity-kv", + "wasm-continuity-kv-backend", + "wasmcm-cm-host-rust", + "wasmcm-host-rust", + "wasmos-resolver", + "wasmparser 0.253.0", + "wasmtime", + "wasmtime-wasi", + "wat", +] + +[[package]] +name = "wasmparser" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.253.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19db11f87d2486580e1e8b6f494c54df7e0566b87d0b599db843c24019667339" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5769a29f799fbab136aaf65b4fe5384cd7d93fe6fc9ba0dcb6c8382a1f16e27" +dependencies = [ + "bitflags", + "indexmap", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7142797de29b35ab8dbf15c00f55fda75d409da4c423a8ab8bd6b667a785824b" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.252.0", +] + +[[package]] +name = "wasmtime" +version = "47.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d9f263ccb212017dae1d109b9997f218a7675db1e9a7bedf07ae1643322bf3" +dependencies = [ + "addr2line", + "async-trait", + "bitflags", + "bumpalo", + "bytes", + "cc", + "cfg-if", + "encoding_rs", + "futures", + "fxprof-processed-profile", + "gimli", + "ittapi", + "libc", + "log", + "mach2", + "memfd", + "object", + "once_cell", + "postcard", + "pulley-interpreter", + "rayon", + "rustix", + "semver", + "serde", + "serde_derive", + "serde_json", + "smallvec", + "target-lexicon", + "tempfile", + "wasm-compose", + "wasm-encoder 0.252.0", + "wasmparser 0.252.0", + "wasmtime-environ", + "wasmtime-internal-cache", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wat", + "windows-sys 0.61.2", + "wit-parser", +] + +[[package]] +name = "wasmtime-environ" +version = "47.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9915e3d90c36a16e0fec8be746ee87e21cf0d4a017ecf09c49b7d1cc6f31b2b6" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "hashbrown 0.17.1", + "indexmap", + "log", + "object", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "sha2", + "smallvec", + "target-lexicon", + "wasm-encoder 0.252.0", + "wasmparser 0.252.0", + "wasmprinter", + "wasmtime-internal-component-util", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-cache" +version = "47.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7c25436e1602aad2de80bfb3f586dcdfb6fe426d5b17cba37fabf9a1714d32" +dependencies = [ + "base64", + "directories-next", + "log", + "postcard", + "rustix", + "serde", + "serde_derive", + "sha2", + "toml", + "wasmtime-environ", + "windows-sys 0.61.2", + "zstd", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "47.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b627ea698db36fb9110aa77868467f1d23791a76a88a433cb6a43e1242073b77" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "47.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f45bf943cff1d2f1976bac6a241ca4ecaf29a6efd5f1ad89e7722439fa5c392f" + +[[package]] +name = "wasmtime-internal-core" +version = "47.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea33e1f9685be82f90223bebcf2ffefb6b596ea3d82a3c42384527b9c6f5b447" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "47.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1ce104719932a48dbdfec0cc292c96494b0bfe764946e45fbfd3340e3a68f9" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools 0.14.0", + "log", + "object", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.19", + "wasmparser 0.252.0", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "47.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976e4b71b9c0da8a9a7bb8d5fa0abedc4da879b397111fd91ece73be524fe621" +dependencies = [ + "cc", + "cfg-if", + "libc", + "rustix", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "47.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f60ecba3497cfc26284903c2e79023fd5ea991b95b0e8dafe994fd9fca4c4e" +dependencies = [ + "cc", + "object", + "rustix", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "47.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fae6decb025ae6d12f3391b36deb62a6838d45b18864f796d84bced722ceac0d" +dependencies = [ + "cfg-if", + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-unwinder" +version = "47.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0982122d9470d3d72ed7aa9ed91e69538ae44c1494cd22d78fec7846b248a940" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "log", + "object", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "47.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "363cf88a80e6ed13311d0d34d9bcd02a2e07d98113b994ff41ed386a206f498e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "47.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ff41bb1656cbeb7befc42b8a7078b30a5f8393a0c8bca36a6cec94360c1a16" +dependencies = [ + "anyhow", + "bitflags", + "heck", + "indexmap", + "wit-parser", +] + +[[package]] +name = "wasmtime-wasi" +version = "47.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed1750b8e5e5d5b1ee3c0eb602d2c49d6a37a36e9996eaf0708b48ac92b62b06" +dependencies = [ + "async-trait", + "bitflags", + "bytes", + "cap-fs-ext", + "cap-std", + "cfg-if", + "futures", + "io-lifetimes 3.0.1", + "rand 0.10.2", + "rustix", + "thiserror 2.0.19", + "tokio", + "tracing", + "url", + "wasmtime", + "wasmtime-wasi-io", + "wiggle", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-wasi-io" +version = "47.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54777d3afeaf3f32cc444df661d9cb4fd57067971e183c2b1f5251f33b939e68" +dependencies = [ + "async-trait", + "bytes", + "futures", + "tracing", + "wasmtime", +] + +[[package]] +name = "wast" +version = "35.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" +dependencies = [ + "leb128", +] + +[[package]] +name = "wast" +version = "254.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ed4dfc8f6b9fc38b231065e2cdfbf7359af5ab945990abf09658dcc63c3e32" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder 0.254.0", +] + +[[package]] +name = "wat" +version = "1.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7127f7f9b8f127c879991cecd35f494e4628bae1b0874c681414d8d8831e952c" +dependencies = [ + "wast 254.0.0", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "wiggle" +version = "47.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a23c2dc694dfafb35beb542e172d7c256488912cd84902828f8661723ad0eb7" +dependencies = [ + "bitflags", + "thiserror 2.0.19", + "tracing", + "wasmtime", + "wasmtime-environ", + "wiggle-macro", +] + +[[package]] +name = "wiggle-generate" +version = "47.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fe48e5e455e827339d8e65de602cc7839d241bd7063be1fc235e35581788ad" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasmtime-environ", + "witx", +] + +[[package]] +name = "wiggle-macro" +version = "47.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb520c20bed78e5a311d4c9ad883ac3b6d070617d07a19fbe5dc0b408c501b4b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "wiggle-generate", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-parser" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4266bea110371c620ccf3201c5023676046bc4556e5c7cfb5d500bda5ebc162d" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser 0.252.0", +] + +[[package]] +name = "witx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" +dependencies = [ + "anyhow", + "log", + "thiserror 1.0.69", + "wast 35.0.2", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/wasmos-provider/native/Cargo.toml b/wasmos-provider/native/Cargo.toml new file mode 100644 index 0000000..2b8890d --- /dev/null +++ b/wasmos-provider/native/Cargo.toml @@ -0,0 +1,71 @@ +[package] +name = "wasmos-provider-native" +version = "0.1.0" +edition = "2021" +description = "JNI wrapper crate for wasmos-runtime, backing the webassembly4j wasmos-provider" +license = "Apache-2.0" + +[lib] +# webassembly4j's Java layer loads the resulting dylib via System.load() at +# ServiceLoader-discovery time; a cdylib gives us the plain shared object with +# the right #[no_mangle] extern "C" symbols on macOS / Linux / Windows. +name = "wasmos_provider" +crate-type = ["cdylib"] + +[dependencies] +# wasmos-runtime dep — RELATIVE path (../../../wasmos) resolves against +# a co-checked-out sibling tegmentum working tree. Same tree also needs +# ~/git/wasm-cm and ~/git/wasm-continuity because wasmos-runtime's own +# Cargo.toml declares path deps to those siblings. +# +# Standard developer layout expected: +# +# ~/git/webassembly4j (this repo) +# ~/git/wasmos (../../../wasmos from wasmos-provider/native/) +# ~/git/wasm-cm (wasmos's sibling path dep) +# ~/git/wasm-continuity (wasmos's sibling path dep) +# +# A git-dep pinned to tegmentum/wasmos main fails to resolve at build +# time — cargo fetches wasmos but the sibling `../wasm-cm/...` and +# `../wasm-continuity/...` paths don't exist in the fetched tree. Same +# problem would hit crates.io publication. +# +# Unblocking external / CI builds needs one of: +# 1. wasmos + wasm-cm + wasm-continuity publish versioned crates. +# 2. wasmos restructures to git-dep (or workspace-internal) its +# siblings — no more `path = "..//..."`. +# 3. Fork wasmos into a git-dep-clean tree we control. +# +# See wasmos-provider/README.md for the full build-setup story. +wasmos-runtime = { path = "../../../wasmos" } + +# Must match wasmos-runtime's wasmtime pin (47.0.2). Using anything else +# would compile-fail against wasmos-runtime's public wasmtime types +# (Engine / Component / Linker / Store). +wasmtime = { version = "47.0.2", features = ["component-model-bytes"] } +wasmtime-wasi = "47.0.2" + +# instantiate_async / call_async require a Tokio runtime; we drive one per +# call inside the JNI shim rather than expecting the JVM caller to. +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } + +# JNI bindings. Version tracks wasmtime4j-native's pin (0.21 is Java 8+ +# compatible and matches wasmtime4j-native/Cargo.toml at the time of this +# scaffold). +jni = "0.21" + +# Better panic diagnostics when the wrapper crashes on a JVM caller. +anyhow = "1.0" + +# JSON marshalling for two-way Val <-> Java-object conversion across the +# JNI boundary. Modelled on wasmtime4j-native's concurrent_call_json module — +# the "trade compile speed for invoke correctness now, swap in binary later" +# strategy. serde + serde_json are already deep transitive deps via wasmtime +# so the compile-cost delta is zero. +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[profile.release] +opt-level = 3 +lto = false +codegen-units = 16 diff --git a/wasmos-provider/native/src/lib.rs b/wasmos-provider/native/src/lib.rs new file mode 100644 index 0000000..2aad418 --- /dev/null +++ b/wasmos-provider/native/src/lib.rs @@ -0,0 +1,2552 @@ +//! JNI wrapper around `wasmos-runtime` — the Rust crate at +//! `~/git/wasmos` that layers wasmos-shaped WIT-typed host bindings +//! (host:wasmtime, host:bootstrap, host:caps, host:grants, +//! wasmos:host/*) on top of an internal wasmtime linkage. +//! +//! # Surface +//! +//! Engine / component / instance lifecycle mirrors the wasmtime4j +//! bindings 1:1 as opaque `long` handles. The invoke path has two +//! flavours: +//! +//! * `instanceInvokeReturningI32` — nullary fast path for the demo +//! (`guest-demo-portable`'s `run() -> i32`). Skips JSON round-trip. +//! * `instanceInvokeJson` — general path: Java hands over a JSON blob +//! of typed `JsonVal` argument values, Rust converts to +//! `wasmtime::component::Val`, calls the function, converts results +//! back into JSON. The JSON strategy is the one wasmtime4j-native +//! uses for its concurrent-call path — cheap-and-cheerful, easy to +//! evolve. A binary marshaller can replace it later without touching +//! the Java-side API. +//! * `instanceInvokeJsonReturningBytes` — same as `instanceInvokeJson` +//! but returns the first `list` result as a raw jbyteArray, +//! dodging the base64 detour on the JSON path. +//! +//! Instantiation has two flavours: +//! +//! * `componentInstantiate` — default WASI ctx, no limits, no epoch. +//! Kept for the MVP demo path. +//! * `componentInstantiateWithConfig` — accepts optional WASI settings +//! (args / env / preopens / stdio inheritance / http toggle) plus +//! optional `maxMemoryBytes` and `epochDeadline`. Handles the +//! `LinkingContext.wasiContext()` + `ComponentConfig` plumbing. +//! +//! # Concurrency +//! +//! wasmos-runtime's `add_all_to_linker` uses async wasmtime imports +//! (see the "Async linker" doc comment on `caps::wasi_p2` in wasmos +//! ~/git/wasmos/src/lib.rs), so instantiation and invocation must +//! run inside a Tokio runtime. A multi-thread runtime is created +//! once per engine and reused for every `block_on` on that engine's +//! components / instances. +//! +//! # Error handling +//! +//! Every entry point catches `anyhow::Error`s and throws a Java +//! `WebAssemblyException`. Rust panics in this crate would be a bug +//! (we take input from Java-side validated shims), not a normal +//! path — we do NOT `catch_unwind` on the JNI boundary because +//! `&mut JNIEnv` isn't `UnwindSafe`. A panic would abort the JVM; +//! any code path that could plausibly panic should return a Result +//! instead. + +#![allow(non_snake_case)] +#![allow(clippy::missing_safety_doc)] + +use std::collections::HashMap; +use std::sync::Mutex; + +use jni::objects::{JByteArray, JClass, JObjectArray, JString}; +use jni::sys::{jboolean, jbyteArray, jint, jlong, jstring}; +use jni::JNIEnv; + +use wasmtime::component::{Component, FutureAny, Linker, ResourceAny, StreamAny, Val}; +use wasmtime::{AsContextMut, Config, Engine, Store}; +use wasmos_runtime::{caps, HostState}; +use wasmtime_wasi::{DirPerms, FilePerms, WasiCtxBuilder}; + +use serde::{Deserialize, Serialize}; + +// ----------------------------------------------------------------------------- +// Handle wrappers +// ----------------------------------------------------------------------------- +// Each Java `long` handle is a `Box::into_raw(Box::new(_))` pointer. We hand +// the JVM the pointer as an opaque `jlong`; ownership returns to Rust via the +// matching `*Close` entry point which reconstructs the Box and drops it. + +/// Owned engine + persistent Tokio runtime. The runtime is created eagerly at +/// engine creation time so subsequent instantiate / invoke calls can `block_on` +/// with no per-call setup cost — and, more importantly, so async wasi:p2 +/// timers/clocks see a live runtime for their whole lifetime. +/// +/// The engine has `epoch_interruption(true)` set so +/// `componentInstantiateWithConfig` can wire an epoch deadline without needing +/// a second engine — the flag is a no-op for callers that don't set a deadline. +struct EngineHandle { + engine: Engine, + runtime: tokio::runtime::Runtime, +} + +/// Owned component + a back-pointer to the engine (needed at instantiate +/// time to build a fresh Linker + Store). +struct ComponentHandle { + engine_ptr: *mut EngineHandle, + component: Component, +} + +/// Owned component instance + its Store. The store lives here so +/// subsequent invoke calls have the store to thread through wasmtime's +/// call_async. Also carries the engine back-pointer so invoke can +/// grab the same Tokio runtime that instantiated it. +/// +/// `resources` is a per-instance registry that parks `ResourceAny` values +/// returned from guest calls. Java receives an opaque `WitResource{ tableId, +/// typeName, owned }` — the wasm ResourceAny stays in Rust; only its slot id +/// crosses the JNI boundary. On the way back in, we look up the parked value +/// and hand it back to wasmtime. `ResourceAny` is `Copy`, so lookups are +/// cheap; no reference-counting needed. +struct InstanceHandle { + engine_ptr: *mut EngineHandle, + store: Mutex>, + instance: wasmtime::component::Instance, + resources: Mutex, + /// Per-instance registry for `FutureAny` values returned by guest calls. + /// Slot ids are handed to Java as `long`s (see `JsonVal::Future.table_id`); + /// monotonic, never reused (same policy as `ResourceRegistry`). See + /// `FutureRegistry` docs for lifecycle details, and the module-level + /// "wasmtime Val::Future API gap" note for what awaiting looks like + /// today (spoiler: type-erased polling isn't in the public API). + futures: Mutex, + /// Per-instance registry for `StreamAny` values returned by guest calls. + /// Mirrors `FutureRegistry` exactly — `StreamAny`'s public API is + /// structurally identical to `FutureAny`'s in wasmtime 47 (Clone, Debug, + /// `try_into_stream_reader::` requires compile-time `T`, and `close(store)` + /// for disposal). Park + close + pass-in work; host-side dynamic reading + /// is the same API gap as futures. See `StreamRegistry` for docs. + streams: Mutex, + /// Per-instance registry for `ErrorContextAny` values returned by guest + /// calls or received on `result<_, error-context>` err branches. Uses the + /// same parking-lot shape as the other registries so pass-back into a + /// guest is possible even though wasmtime 47 exposes no `close`/`drop` on + /// `ErrorContextAny` (see the FIXME on + /// `wasmtime::component::ErrorContextAny`). The parked value is only ever + /// released when the instance handle is dropped, or when Java explicitly + /// evicts it via `errorContextClose`. + error_contexts: Mutex, +} + +/// Per-instance parking lot for `ResourceAny` values. Slot ids are handed to +/// Java as `long`s (see `JsonVal::Resource.table_id`). Ids are monotonically +/// increasing; we never reuse a freed slot so the "stale handle after +/// take-owned" case surfaces as a clean "unknown resource id" error rather +/// than an aliased hit on a newly parked resource. +/// +/// Ownership semantics — driven by the `owned` bit on the way IN from Java: +/// * `owned=true` → the Java caller is transferring the resource to the +/// guest (matches WIT `own`). We remove the entry; subsequent uses of +/// that WitResource fail. +/// * `owned=false` → the Java caller is lending the resource for the length +/// of this call (matches WIT `borrow`). The entry stays. +/// +/// On the way OUT from a guest return, we park with the ResourceAny's own +/// `owned()` flag reported back to Java verbatim. +#[derive(Default)] +struct ResourceRegistry { + next_id: u64, + entries: HashMap, +} + +impl ResourceRegistry { + /// Park a `ResourceAny` and hand out a fresh slot id. Ids monotonically + /// increase; freed slots are never reused (see struct docs). + fn park(&mut self, any: ResourceAny) -> u64 { + let id = self.next_id; + self.next_id = self + .next_id + .checked_add(1) + .expect("wasmos-provider: resource id counter overflow"); + self.entries.insert(id, any); + id + } + + /// Fetch (borrow-shaped) — returns a copy but keeps the entry so the same + /// WitResource can be lent multiple times. + fn peek(&self, id: u64) -> Option { + self.entries.get(&id).copied() + } + + /// Fetch and remove (own-shaped) — the entry is consumed; subsequent + /// lookups against this id will miss. + fn take(&mut self, id: u64) -> Option { + self.entries.remove(&id) + } +} + +/// Per-instance parking lot for `FutureAny` values. Mirrors `ResourceRegistry` +/// exactly — monotonic u64 ids, no reuse, take-vs-peek semantics driven by +/// the Java caller's intent. See the module-level docs for how this interacts +/// with the wasmtime 47 `FutureAny` public API (short version: return-direction +/// parking works; pass-in direction works; dynamic *awaiting* a `FutureAny` +/// with a runtime-known payload type is a wasmtime public-API gap — the +/// `FutureAny::try_into_future_reader::` path requires `T` at compile time +/// and `FutureConsumer::Item` is an associated type, so there's no +/// type-erased await surface today). +#[derive(Default)] +struct FutureRegistry { + next_id: u64, + entries: HashMap, +} + +impl FutureRegistry { + fn park(&mut self, any: FutureAny) -> u64 { + let id = self.next_id; + self.next_id = self + .next_id + .checked_add(1) + .expect("wasmos-provider: future id counter overflow"); + self.entries.insert(id, any); + id + } + + /// Borrow-shaped fetch. `FutureAny: Clone` (wasmtime derives it) so we + /// can hand a copy to a guest call while keeping the entry parked, which + /// matches the "read end" semantics — the same `FutureAny` may in + /// principle be lowered into more than one call before being closed. + /// + /// Currently unused by production code (there's no borrow-shaped Future + /// arm in `to_val` — the JsonVal::Future decode always takes; see the + /// enum docs for why); kept for parity with `ResourceRegistry::peek` and + /// exercised by the test suite. Suppress dead-code warning accordingly. + #[allow(dead_code)] + fn peek(&self, id: u64) -> Option { + self.entries.get(&id).cloned() + } + + /// Own-shaped fetch. Removes the entry; subsequent lookups miss. + fn take(&mut self, id: u64) -> Option { + self.entries.remove(&id) + } +} + +/// Per-instance parking lot for `StreamAny` values. Mirrors `FutureRegistry` +/// exactly — monotonic u64 ids, no reuse, take-vs-peek semantics driven by +/// the Java caller's intent. See the module-level docs for how this interacts +/// with the wasmtime 47 `StreamAny` public API (short version: same story as +/// `FutureAny` — park and close and pass-in are wired; dynamic host-side +/// *reading* of a `StreamAny` with a runtime-known payload type is a wasmtime +/// public-API gap because `StreamAny::try_into_stream_reader::` requires +/// `T` at compile time). +#[derive(Default)] +struct StreamRegistry { + next_id: u64, + entries: HashMap, +} + +impl StreamRegistry { + fn park(&mut self, any: StreamAny) -> u64 { + let id = self.next_id; + self.next_id = self + .next_id + .checked_add(1) + .expect("wasmos-provider: stream id counter overflow"); + self.entries.insert(id, any); + id + } + + /// Borrow-shaped fetch. `StreamAny: Clone`; see `FutureRegistry::peek` + /// docs for symmetry rationale. Currently unused by production code + /// (there's no borrow-shaped Stream arm in `to_val`), kept for parity + /// with `FutureRegistry` and exercised by tests. + #[allow(dead_code)] + fn peek(&self, id: u64) -> Option { + self.entries.get(&id).cloned() + } + + /// Own-shaped fetch. Removes the entry; subsequent lookups miss. + fn take(&mut self, id: u64) -> Option { + self.entries.remove(&id) + } +} + +/// Per-instance parking lot for `error-context` handles. Mirrors the shape +/// of the other registries but differs on lifecycle: wasmtime 47's +/// `ErrorContextAny` is a placeholder (see `FIXME(#11161)` on the wasmtime +/// side) with `pub(crate) u32` internals and no publicly-defined `close` or +/// `drop`. It is also not publicly re-exported at the `wasmtime::component` +/// module boundary — its `pub` visibility only reaches as far as being usable +/// through the `Val::ErrorContext(...)` enum variant. To sidestep the naming +/// problem we park the whole `Val::ErrorContext(any)` value; `Val: Clone` +/// preserves the parking-lot copy semantics without needing to name the +/// inner type. +/// +/// Eviction is Java-driven only (`errorContextClose`) or automatic on +/// instance handle drop. On pass-in, the parked `Val` is cloned back +/// verbatim, so wasmtime sees the original ErrorContextAny handle. +#[derive(Default)] +struct ErrorContextRegistry { + next_id: u64, + entries: HashMap, +} + +impl ErrorContextRegistry { + /// Park a `Val::ErrorContext(any)` value. The debug caller-passed value + /// MUST be an `ErrorContext`-shaped `Val`; other variants would still + /// park but round-trip incoherently on pass-in — the type is asserted + /// upstream in `from_val`. + fn park(&mut self, ec: Val) -> u64 { + debug_assert!( + matches!(&ec, Val::ErrorContext(_)), + "ErrorContextRegistry::park requires a Val::ErrorContext, got {ec:?}" + ); + let id = self.next_id; + self.next_id = self + .next_id + .checked_add(1) + .expect("wasmos-provider: error-context id counter overflow"); + self.entries.insert(id, ec); + id + } + + #[allow(dead_code)] + fn peek(&self, id: u64) -> Option { + self.entries.get(&id).cloned() + } + + fn take(&mut self, id: u64) -> Option { + self.entries.remove(&id) + } +} + +// ----------------------------------------------------------------------------- +// Error helpers +// ----------------------------------------------------------------------------- + +const EXCEPTION_CLASS: &str = "ai/tegmentum/webassembly4j/api/exception/WebAssemblyException"; + +/// Throw a `WebAssemblyException` with the given message. Ignores JNI errors +/// (the JVM will already be in an exception-pending state on any failure here). +fn throw(env: &mut JNIEnv, msg: &str) { + let _ = env.throw_new(EXCEPTION_CLASS, msg); +} + +/// Ergonomic helper — translate an `anyhow::Result` into a JNI return +/// value: on `Ok`, return the value; on `Err`, throw +/// `WebAssemblyException` and return the caller-supplied sentinel. +fn or_throw(env: &mut JNIEnv, result: anyhow::Result, sentinel: T) -> T { + match result { + Ok(v) => v, + Err(e) => { + throw(env, &format!("wasmos-provider: {:#}", e)); + sentinel + } + } +} + +/// Read a jstring into a `String`, translating any JNI error into `anyhow`. +fn jstring_to_string(env: &mut JNIEnv, s: &JString) -> anyhow::Result { + env.get_string(s) + .map(|jni_str| jni_str.into()) + .map_err(|e| anyhow::anyhow!("get_string: {e}")) +} + +/// Read a JObjectArray of jstrings into `Vec`. Skips null entries +/// (returns them as empty strings — callers pass tightly-packed arrays). +fn jstringarray_to_vec(env: &mut JNIEnv, arr: &JObjectArray) -> anyhow::Result> { + let len = env + .get_array_length(arr) + .map_err(|e| anyhow::anyhow!("get_array_length: {e}"))?; + let mut out = Vec::with_capacity(len as usize); + for i in 0..len { + let elem = env + .get_object_array_element(arr, i) + .map_err(|e| anyhow::anyhow!("get_object_array_element[{i}]: {e}"))?; + if elem.is_null() { + out.push(String::new()); + continue; + } + let js: JString = elem.into(); + out.push(jstring_to_string(env, &js)?); + } + Ok(out) +} + +// ----------------------------------------------------------------------------- +// Exported-function lookup helpers +// ----------------------------------------------------------------------------- + +/// Resolve a callable `Func` from an `Instance` by name, supporting both +/// top-level exports and nested-in-interface exports. +/// +/// * If `name` contains a `#`, split on the FIRST `#` — the left half is +/// the exported interface name (e.g. `"tegmentum:counter/counter-api@0.1.0"`) +/// and the right half is the item name within that interface (e.g. +/// `"[constructor]counter"` or `"[method]counter.get"`). +/// Wasmtime's `Instance::get_func` doesn't descend into exported instances +/// on its own; we chain two `get_export_index` calls to reach the func. +/// * Otherwise, look up `name` directly at the top level — the existing +/// plain-function path. +/// +/// Returns `None` if `name` resolves to something that isn't a lifted +/// component function (e.g. a nested instance, a type export, or unknown). +fn resolve_func( + instance: &wasmtime::component::Instance, + store: &mut Store, + name: &str, +) -> Option { + if let Some(hash_at) = name.find('#') { + let iface_name = &name[..hash_at]; + let item_name = &name[hash_at + 1..]; + let iface_idx = instance.get_export_index(&mut *store, None, iface_name)?; + let func_idx = instance.get_export_index(&mut *store, Some(&iface_idx), item_name)?; + instance.get_func(&mut *store, &func_idx) + } else { + instance.get_func(&mut *store, name) + } +} + +// ----------------------------------------------------------------------------- +// JSON Val marshalling +// ----------------------------------------------------------------------------- +// Design mirrors wasmtime4j-native's `concurrent_call_json::JsonVal`. Keeps +// tag names short (`t` / `v`) to trim payload size — the Java side has a +// matching Jackson-free hand-rolled parser/writer so JVM adds no runtime dep. +// +// `Bytes` is a fast-path variant that base64-encodes a `list` payload — +// avoids the O(n) JsonVal-per-byte round-trip for large binary blobs. On the +// return path we detect `Val::List` whose elements are all `Val::U8` and +// emit the `Bytes` variant automatically. + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "t", content = "v")] +enum JsonVal { + Bool(bool), + S8(i8), + S16(i16), + S32(i32), + S64(i64), + U8(u8), + U16(u16), + U32(u32), + U64(u64), + F32(f32), + F64(f64), + Char(char), + String(String), + /// `list` fast path — base64-encoded bytes. Reduces both wire size + /// and per-element boxing on both sides. + Bytes(String), + List(Vec), + Option(Option>), + /// Result-shaped value. `is_ok` picks the branch; the corresponding side + /// carries the payload (or `None` for `result<_,_>` with a unit branch). + Result { + is_ok: bool, + #[serde(skip_serializing_if = "Option::is_none", default)] + ok: Option>, + #[serde(skip_serializing_if = "Option::is_none", default)] + err: Option>, + }, + Record(Vec<(String, JsonVal)>), + Tuple(Vec), + Variant { + discriminant: String, + #[serde(skip_serializing_if = "Option::is_none", default)] + value: Option>, + }, + Enum(String), + Flags(Vec), + /// Distinct from `Record`: keys can be arbitrary `Val`s, not just + /// field-name strings. Wire form is a flat array of `[key_json, + /// value_json]` two-tuples — identical layout to what wasmtime's + /// `Val::Map(Vec<(Val, Val)>)` implies. The Java side uses a + /// `WitMap` explicit wrapper so callers opt in intentionally rather + /// than have every `Map` guessed one way or another. + Map(Vec<(JsonVal, JsonVal)>), + /// Component-model `own` / `borrow` handle. The `ResourceAny` lives + /// Rust-side in `InstanceHandle::resources`; Java only ever sees + /// `table_id` (opaque slot number), `type_name` (opaque + /// `ResourceType`-derived identity, useful only for equality), and + /// `owned` (drives take-vs-peek on the way back in — see + /// `ResourceRegistry` docs). This is the wasmos-provider's `Val::Resource` + /// story; no other JsonVal shape crosses a "handle" over the wire. + Resource { + table_id: u64, + type_name: String, + owned: bool, + }, + /// Component-model `future` handle. Same parking-lot pattern as + /// `Resource`: the `FutureAny` lives Rust-side in + /// `InstanceHandle::futures`; Java only ever sees `table_id` (opaque + /// slot number) and `type_name` (opaque Debug rendering of the + /// `FutureAny`, useful only for equality-adjacent introspection). + /// + /// There is no `owned` bit here — wasmtime 47's `FutureAny: Clone` and + /// the read/write ends are already separated at the type level (this is + /// the read end); "borrow" as a distinct concept doesn't apply. The + /// Java carrier consumes the slot on pass-in by default (parity with + /// how a future handle typically flows exactly once through a guest + /// import); explicit disposal is via `futureClose`. + /// + /// See the `FutureRegistry` docs for the wasmtime API gap that + /// currently blocks a dynamic host-side *await* on this handle. + Future { + table_id: u64, + type_name: String, + }, + /// Component-model `stream` handle. Same parking-lot pattern as + /// `Future`: the `StreamAny` lives Rust-side in + /// `InstanceHandle::streams`; Java only ever sees `table_id` (opaque + /// slot number) and `type_name` (opaque Debug rendering of the + /// `StreamAny`, useful only for equality-adjacent introspection). + /// + /// Same lifecycle contract as `Future`: the decode arm always takes + /// (a stream handle's read end flows through a guest import exactly + /// once). Explicit disposal is via `streamClose`. Host-side dynamic + /// reading of the stream's items has the identical wasmtime API gap + /// as `Future` — `StreamAny::try_into_stream_reader::` is + /// compile-time typed. + Stream { + table_id: u64, + type_name: String, + }, + /// Component-model `error-context` handle. Parked in + /// `InstanceHandle::error_contexts` and surfaced to Java as an opaque + /// slot id plus the wasmtime-derived numeric `rep` (parsed out of + /// `ErrorContextAny`'s Debug rendering — the underlying `u32` is + /// `pub(crate)` so Debug is the only public window). The `rep` value + /// is useful for equality-adjacent identification; it has no other + /// user-facing meaning today because wasmtime 47's `ErrorContextAny` + /// is a placeholder (see wasmtime's `FIXME(#11161)`). + /// + /// There is no wasmtime-defined dispose on `ErrorContextAny`, so + /// `errorContextClose` on the Java side is a pure Rust-side registry + /// eviction — it drops the parked handle so the slot id becomes + /// unknown for later pass-in attempts. + ErrorContext { + table_id: u64, + rep: u32, + }, +} + +/// Base64 alphabet — hand-rolled to avoid pulling in the `base64` crate for a +/// couple of `encode` / `decode` calls. Standard alphabet with `=` padding. +const B64_ALPHABET: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +fn b64_encode(bytes: &[u8]) -> String { + let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4); + let mut i = 0; + while i + 3 <= bytes.len() { + let b0 = bytes[i]; + let b1 = bytes[i + 1]; + let b2 = bytes[i + 2]; + out.push(B64_ALPHABET[(b0 >> 2) as usize] as char); + out.push(B64_ALPHABET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); + out.push(B64_ALPHABET[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char); + out.push(B64_ALPHABET[(b2 & 0x3f) as usize] as char); + i += 3; + } + let rem = bytes.len() - i; + if rem == 1 { + let b0 = bytes[i]; + out.push(B64_ALPHABET[(b0 >> 2) as usize] as char); + out.push(B64_ALPHABET[((b0 & 0x03) << 4) as usize] as char); + out.push('='); + out.push('='); + } else if rem == 2 { + let b0 = bytes[i]; + let b1 = bytes[i + 1]; + out.push(B64_ALPHABET[(b0 >> 2) as usize] as char); + out.push(B64_ALPHABET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); + out.push(B64_ALPHABET[((b1 & 0x0f) << 2) as usize] as char); + out.push('='); + } + out +} + +fn b64_decode(s: &str) -> anyhow::Result> { + // Build a reverse-lookup table lazily. Cheap; done once per decode. + let mut rev = [255u8; 256]; + for (i, &c) in B64_ALPHABET.iter().enumerate() { + rev[c as usize] = i as u8; + } + let bytes = s.as_bytes(); + if bytes.len() % 4 != 0 { + anyhow::bail!("base64: length not a multiple of 4"); + } + let mut out = Vec::with_capacity(bytes.len() / 4 * 3); + let mut i = 0; + while i < bytes.len() { + let c0 = bytes[i]; + let c1 = bytes[i + 1]; + let c2 = bytes[i + 2]; + let c3 = bytes[i + 3]; + let v0 = rev[c0 as usize]; + let v1 = rev[c1 as usize]; + if v0 == 255 || v1 == 255 { + anyhow::bail!("base64: invalid char at offset {i}"); + } + out.push((v0 << 2) | (v1 >> 4)); + if c2 != b'=' { + let v2 = rev[c2 as usize]; + if v2 == 255 { + anyhow::bail!("base64: invalid char at offset {}", i + 2); + } + out.push(((v1 & 0x0f) << 4) | (v2 >> 2)); + if c3 != b'=' { + let v3 = rev[c3 as usize]; + if v3 == 255 { + anyhow::bail!("base64: invalid char at offset {}", i + 3); + } + out.push(((v2 & 0x03) << 6) | v3); + } + } + i += 4; + } + Ok(out) +} + +impl JsonVal { + /// Decode a JsonVal into a wasmtime `Val`, using `resources`, + /// `futures`, `streams`, and `error_contexts` to resolve any parked + /// slot ids back into `ResourceAny` / `FutureAny` / `StreamAny` / + /// `ErrorContextAny` handles. All registries are passed as `&mut` + /// because the pass-in arms consume (take) entries — Resource by way + /// of the caller-supplied `owned` bit, Future/Stream unconditionally + /// (a future/stream read end typically flows through a guest import + /// exactly once), ErrorContext unconditionally (Clone would let us + /// peek but no known WIT shape wants to hold the same handle twice). + fn to_val( + &self, + resources: &mut ResourceRegistry, + futures: &mut FutureRegistry, + streams: &mut StreamRegistry, + error_contexts: &mut ErrorContextRegistry, + ) -> anyhow::Result { + Ok(match self { + JsonVal::Bool(v) => Val::Bool(*v), + JsonVal::S8(v) => Val::S8(*v), + JsonVal::S16(v) => Val::S16(*v), + JsonVal::S32(v) => Val::S32(*v), + JsonVal::S64(v) => Val::S64(*v), + JsonVal::U8(v) => Val::U8(*v), + JsonVal::U16(v) => Val::U16(*v), + JsonVal::U32(v) => Val::U32(*v), + JsonVal::U64(v) => Val::U64(*v), + JsonVal::F32(v) => Val::Float32(*v), + JsonVal::F64(v) => Val::Float64(*v), + JsonVal::Char(v) => Val::Char(*v), + JsonVal::String(v) => Val::String(v.clone()), + JsonVal::Bytes(b64) => { + let bytes = b64_decode(b64)?; + Val::List(bytes.into_iter().map(Val::U8).collect()) + } + JsonVal::List(items) => Val::List( + items + .iter() + .map(|v| v.to_val(resources, futures, streams, error_contexts)) + .collect::>>()?, + ), + JsonVal::Option(opt) => Val::Option( + opt.as_ref() + .map(|v| v.to_val(resources, futures, streams, error_contexts).map(Box::new)) + .transpose()?, + ), + JsonVal::Result { is_ok, ok, err } => { + if *is_ok { + Val::Result(Ok(ok + .as_ref() + .map(|v| v.to_val(resources, futures, streams, error_contexts).map(Box::new)) + .transpose()?)) + } else { + Val::Result(Err(err + .as_ref() + .map(|v| v.to_val(resources, futures, streams, error_contexts).map(Box::new)) + .transpose()?)) + } + } + JsonVal::Record(fields) => Val::Record( + fields + .iter() + .map(|(name, jv)| jv.to_val(resources, futures, streams, error_contexts).map(|v| (name.clone(), v))) + .collect::>>()?, + ), + JsonVal::Tuple(items) => Val::Tuple( + items + .iter() + .map(|v| v.to_val(resources, futures, streams, error_contexts)) + .collect::>>()?, + ), + JsonVal::Variant { discriminant, value } => Val::Variant( + discriminant.clone(), + value + .as_ref() + .map(|v| v.to_val(resources, futures, streams, error_contexts).map(Box::new)) + .transpose()?, + ), + JsonVal::Enum(v) => Val::Enum(v.clone()), + JsonVal::Flags(v) => Val::Flags(v.clone()), + JsonVal::Map(pairs) => Val::Map( + pairs + .iter() + .map(|(k, v)| Ok((k.to_val(resources, futures, streams, error_contexts)?, + v.to_val(resources, futures, streams, error_contexts)?))) + .collect::>>()?, + ), + JsonVal::Resource { table_id, owned, .. } => { + // Java-side `owned` bit drives the take-vs-peek choice — the + // WIT signature the guest expects is what the caller is + // targeting, and it dictates whether we transfer ownership + // (own) or lend a copy (borrow). `type_name` is not + // consulted here; it's Java-side introspection only. + let any = if *owned { + resources.take(*table_id).ok_or_else(|| { + anyhow::anyhow!( + "wasmos-provider: unknown resource id {} (already consumed \ + as own or never parked)", + table_id + ) + })? + } else { + resources.peek(*table_id).ok_or_else(|| { + anyhow::anyhow!( + "wasmos-provider: unknown resource id {} (never parked or \ + already consumed by prior own transfer)", + table_id + ) + })? + }; + Val::Resource(any) + } + JsonVal::Future { table_id, .. } => { + // Futures unconditionally take: a `future` handle + // typically flows through a guest import exactly once, and + // the read end (which is what we're holding) is single-shot + // by nature. Peek-style repeated pass-in isn't a shape the + // component model surfaces, so we don't try to model it. + // Callers that need to keep the handle after passing it in + // shouldn't; there's no "borrow>" in the wire + // model. + let any = futures.take(*table_id).ok_or_else(|| { + anyhow::anyhow!( + "wasmos-provider: unknown future id {} (already consumed or \ + never parked)", + table_id + ) + })?; + Val::Future(any) + } + JsonVal::Stream { table_id, .. } => { + // Streams unconditionally take, same rationale as Future: + // the read end of a `stream` is single-shot at the + // wire-model level. + let any = streams.take(*table_id).ok_or_else(|| { + anyhow::anyhow!( + "wasmos-provider: unknown stream id {} (already consumed or \ + never parked)", + table_id + ) + })?; + Val::Stream(any) + } + JsonVal::ErrorContext { table_id, .. } => { + // ErrorContext unconditionally takes on pass-in. There is no + // borrow shape for `error-context` in the WIT wire model — + // an error-context handle is terminal by convention. If a + // caller needs to re-park after peeking they can round-trip + // via `from_val` on the returned Val, but pass-in through + // JSON always consumes to keep parity with Future/Stream. + // + // The registry stores the whole `Val::ErrorContext(any)` + // (see ErrorContextRegistry docs for why); pass-in hands + // that Val straight back to wasmtime. + error_contexts.take(*table_id).ok_or_else(|| { + anyhow::anyhow!( + "wasmos-provider: unknown error-context id {} (already consumed \ + or never parked)", + table_id + ) + })? + } + }) + } + + /// Encode a wasmtime `Val` into a JsonVal, parking any `Resource(any)` / + /// `Future(any)` / `Stream(any)` / `ErrorContext(any)` values in their + /// respective registries and emitting a slot id in the JSON. + fn from_val( + val: &Val, + resources: &mut ResourceRegistry, + futures: &mut FutureRegistry, + streams: &mut StreamRegistry, + error_contexts: &mut ErrorContextRegistry, + ) -> anyhow::Result { + Ok(match val { + Val::Bool(v) => JsonVal::Bool(*v), + Val::S8(v) => JsonVal::S8(*v), + Val::S16(v) => JsonVal::S16(*v), + Val::S32(v) => JsonVal::S32(*v), + Val::S64(v) => JsonVal::S64(*v), + Val::U8(v) => JsonVal::U8(*v), + Val::U16(v) => JsonVal::U16(*v), + Val::U32(v) => JsonVal::U32(*v), + Val::U64(v) => JsonVal::U64(*v), + Val::Float32(v) => JsonVal::F32(*v), + Val::Float64(v) => JsonVal::F64(*v), + Val::Char(v) => JsonVal::Char(*v), + Val::String(v) => JsonVal::String(v.clone()), + Val::List(items) => { + // Fast-path: if all items are U8, encode as `Bytes(base64)`. + if !items.is_empty() && items.iter().all(|v| matches!(v, Val::U8(_))) { + let raw: Vec = items + .iter() + .map(|v| match v { + Val::U8(b) => *b, + _ => unreachable!("guarded by matches! above"), + }) + .collect(); + JsonVal::Bytes(b64_encode(&raw)) + } else { + JsonVal::List( + items + .iter() + .map(|v| JsonVal::from_val(v, resources, futures, streams, error_contexts)) + .collect::>>()?, + ) + } + } + Val::Option(opt) => JsonVal::Option( + opt.as_ref() + .map(|v| JsonVal::from_val(v, resources, futures, streams, error_contexts).map(Box::new)) + .transpose()?, + ), + Val::Result(res) => match res { + Ok(ok_val) => JsonVal::Result { + is_ok: true, + ok: ok_val + .as_ref() + .map(|v| JsonVal::from_val(v, resources, futures, streams, error_contexts).map(Box::new)) + .transpose()?, + err: None, + }, + Err(err_val) => JsonVal::Result { + is_ok: false, + ok: None, + err: err_val + .as_ref() + .map(|v| JsonVal::from_val(v, resources, futures, streams, error_contexts).map(Box::new)) + .transpose()?, + }, + }, + Val::Record(fields) => JsonVal::Record( + fields + .iter() + .map(|(name, v)| { + JsonVal::from_val(v, resources, futures, streams, error_contexts).map(|jv| (name.clone(), jv)) + }) + .collect::>>()?, + ), + Val::Tuple(items) => JsonVal::Tuple( + items + .iter() + .map(|v| JsonVal::from_val(v, resources, futures, streams, error_contexts)) + .collect::>>()?, + ), + Val::Variant(discriminant, payload) => JsonVal::Variant { + discriminant: discriminant.clone(), + value: payload + .as_ref() + .map(|v| JsonVal::from_val(v, resources, futures, streams, error_contexts).map(Box::new)) + .transpose()?, + }, + Val::Enum(discriminant) => JsonVal::Enum(discriminant.clone()), + Val::Flags(v) => JsonVal::Flags(v.clone()), + Val::Map(pairs) => JsonVal::Map( + pairs + .iter() + .map(|(k, v)| { + Ok(( + JsonVal::from_val(k, resources, futures, streams, error_contexts)?, + JsonVal::from_val(v, resources, futures, streams, error_contexts)?, + )) + }) + .collect::>>()?, + ), + Val::Resource(any) => { + // Park the ResourceAny; hand Java a stable u64 slot id and + // the ResourceType's Debug rendering as opaque type identity. + // The Debug format for a guest ResourceType includes the + // component's internal resource index; that's stable within + // an instance's lifetime, which is exactly the window a + // WitResource is valid for. + let owned = any.owned(); + let type_name = format!("{:?}", any.ty()); + let table_id = resources.park(*any); + JsonVal::Resource { + table_id, + type_name, + owned, + } + } + Val::Future(any) => { + // Park the FutureAny; hand Java an opaque u64 slot id + the + // FutureAny's Debug rendering as a type-identity hint (the + // payload type is buried in a private `PayloadType` + // field, so Debug is the widest window wasmtime 47's public + // API gives us). See the module-level "wasmtime API gap" + // note on why Java cannot yet host-side *await* this handle. + let type_name = format!("{:?}", any); + // `FutureAny: Clone` — safe to clone into the registry + // rather than consuming the borrow. Java has to explicitly + // dispose via `futureClose`; the registry keeps the parked + // copy alive until then (or until the instance handle + // itself is dropped, which drops the whole registry). + let table_id = futures.park(any.clone()); + JsonVal::Future { + table_id, + type_name, + } + } + Val::Stream(any) => { + // Same pattern as Val::Future. `StreamAny: Clone` in + // wasmtime 47, so we clone into the registry and keep the + // borrow intact. Debug format is the only public window on + // the payload type — same "opaque hint" role as Future. + let type_name = format!("{:?}", any); + let table_id = streams.park(any.clone()); + JsonVal::Stream { + table_id, + type_name, + } + } + Val::ErrorContext(any) => { + // Park the ErrorContext-shaped Val. Its Debug form is + // `ErrorContextAny(N)` where N is the pub(crate) u32 rep; + // parse the number out so Java gets a plain numeric hint + // instead of the debug string wrapper. If the format ever + // changes (unlikely — it's a plain tuple struct) fall back + // to 0. The rep is only meant for equality-adjacent + // introspection; wasmtime's `FIXME(#11161)` documents that + // the type has no other operations at 47.0.2. + // + // We store the whole `Val` in the registry (not the + // `ErrorContextAny`) because `ErrorContextAny` isn't + // re-exported at the `wasmtime::component` module boundary + // and can't be named as a HashMap value type from outside + // the wasmtime crate. Cloning the enclosing Val preserves + // the underlying handle. + let dbg = format!("{:?}", any); + let rep = dbg + .strip_prefix("ErrorContextAny(") + .and_then(|s| s.strip_suffix(')')) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let table_id = error_contexts.park(val.clone()); + JsonVal::ErrorContext { table_id, rep } + } + }) + } +} + +// ----------------------------------------------------------------------------- +// Engine lifecycle +// ----------------------------------------------------------------------------- + +/// Create a wasmos-friendly wasmtime engine. `wasm_component_model = true` is +/// mandatory to load composed components; `epoch_interruption = true` is +/// mandatory so per-instance epoch deadlines are cheap to opt into via +/// `componentInstantiateWithConfig` (the flag is a no-op for callers who +/// don't set a deadline). +#[no_mangle] +pub extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_engineCreate( + mut env: JNIEnv, + _class: JClass, +) -> jlong { + let result: anyhow::Result = (|| { + let mut config = Config::new(); + // NB: wasmtime 47 removed `Config::async_support` — async is now + // available on any engine that turned on `component-model` (which + // it does by default). Explicit call intentionally omitted; leaving + // this comment as a breadcrumb for any future wasmtime downgrade. + config.wasm_component_model(true); + config.epoch_interruption(true); + // Enable fuel bookkeeping globally so per-instance fuel limits are + // cheap to opt into via `componentInstantiateWithConfig`. No-op + // performance hit for callers that never set fuel — wasmtime only + // decrements on backedges when the store has a nonzero fuel budget. + config.consume_fuel(true); + let engine = Engine::new(&config)?; + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + let handle = Box::new(EngineHandle { engine, runtime }); + Ok(Box::into_raw(handle) as jlong) + })(); + or_throw(&mut env, result, 0) +} + +/// Drop the engine and its Tokio runtime. Safe to call once with a +/// nonzero handle produced by `engineCreate`; no-op on zero. +/// +/// # Safety +/// The handle MUST originate from `engineCreate`. Passing anything else is UB. +#[no_mangle] +pub unsafe extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_engineClose( + _env: JNIEnv, + _class: JClass, + handle: jlong, +) { + if handle == 0 { + return; + } + let _ = Box::from_raw(handle as *mut EngineHandle); +} + +/// Increment the engine's epoch counter — call from a Java timer thread when +/// an instance was instantiated with `epochDeadline`. Callers that never set +/// a deadline can ignore this. +#[no_mangle] +pub extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_engineIncrementEpoch( + mut env: JNIEnv, + _class: JClass, + handle: jlong, +) { + if handle == 0 { + throw(&mut env, "wasmos-provider: engine handle is null"); + return; + } + let engine = unsafe { &(*(handle as *mut EngineHandle)).engine }; + engine.increment_epoch(); +} + +// ----------------------------------------------------------------------------- +// Component lifecycle +// ----------------------------------------------------------------------------- + +/// Compile a Component from raw bytes. Returns a handle usable by +/// `componentInstantiate`. +#[no_mangle] +pub extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_componentLoad( + mut env: JNIEnv, + _class: JClass, + engine_handle: jlong, + bytes: JByteArray, +) -> jlong { + // Convert the byte array up-front so the fallible-body closure below + // doesn't need to hold `&mut env`. + let raw_result: anyhow::Result> = env + .convert_byte_array(&bytes) + .map_err(|e| anyhow::anyhow!("convert_byte_array: {e}")); + let raw = match raw_result { + Ok(v) => v, + Err(e) => { + throw(&mut env, &format!("wasmos-provider: {:#}", e)); + return 0; + } + }; + + let result: anyhow::Result = (|| { + if engine_handle == 0 { + anyhow::bail!("engine handle is null"); + } + let engine_ptr = engine_handle as *mut EngineHandle; + // SAFETY: We never move the Box; we only borrow through the raw pointer. + // Java is responsible for keeping the engine alive as long as any component + // handle derived from it is live (documented on WasmosEngineAdapter). + let engine = unsafe { &(*engine_ptr).engine }; + let component = Component::from_binary(engine, &raw)?; + let handle = Box::new(ComponentHandle { + engine_ptr, + component, + }); + Ok(Box::into_raw(handle) as jlong) + })(); + or_throw(&mut env, result, 0) +} + +/// Drop a component handle. See `engineClose` safety note. +#[no_mangle] +pub unsafe extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_componentClose( + _env: JNIEnv, + _class: JClass, + handle: jlong, +) { + if handle == 0 { + return; + } + let _ = Box::from_raw(handle as *mut ComponentHandle); +} + +/// Serialize a compiled Component into its cached AOT byte representation. +/// The returned blob can later be handed to `componentDeserialize` on an +/// engine with a compatible wasmtime config to skip the compile step — +/// the intended use case is warm restarts / hot-reload where the same +/// component gets loaded repeatedly across process launches. +/// +/// Byte compatibility is tied to the wasmtime version, engine config, and +/// target ISA; a deserialize on a different setup will fail cleanly rather +/// than silently produce wrong code. +#[no_mangle] +pub extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_componentSerialize( + mut env: JNIEnv, + _class: JClass, + component_handle: jlong, +) -> jbyteArray { + let result: anyhow::Result> = (|| { + if component_handle == 0 { + anyhow::bail!("component handle is null"); + } + let ch = component_handle as *mut ComponentHandle; + let component = unsafe { &(*ch).component }; + component + .serialize() + .map_err(|e| anyhow::anyhow!("Component::serialize: {e}")) + })(); + match result { + Ok(bytes) => match env.byte_array_from_slice(&bytes) { + Ok(a) => a.into_raw(), + Err(e) => { + throw(&mut env, &format!("wasmos-provider: byte_array_from_slice: {e}")); + std::ptr::null_mut() + } + }, + Err(e) => { + throw(&mut env, &format!("wasmos-provider: {:#}", e)); + std::ptr::null_mut() + } + } +} + +/// Deserialize a component previously produced by `componentSerialize`. +/// Returns a fresh ComponentHandle bound to the caller's engine. +/// +/// # Safety +/// +/// `Component::deserialize` is `unsafe` in wasmtime because it trusts the +/// input bytes to be a well-formed AOT image that the current engine +/// produced (a hostile / corrupted image can cause UB when its +/// pre-computed function pointers are called). The trust boundary belongs +/// to the JVM caller: they're responsible for only feeding this bytes +/// they got from a prior `componentSerialize` call on this or a +/// compatible engine. We don't try to validate here — the wasmtime API +/// puts the safety contract in the caller's hands and we relay it. +#[no_mangle] +pub extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_componentDeserialize( + mut env: JNIEnv, + _class: JClass, + engine_handle: jlong, + bytes: JByteArray, +) -> jlong { + let raw_result: anyhow::Result> = env + .convert_byte_array(&bytes) + .map_err(|e| anyhow::anyhow!("convert_byte_array: {e}")); + let raw = match raw_result { + Ok(v) => v, + Err(e) => { + throw(&mut env, &format!("wasmos-provider: {:#}", e)); + return 0; + } + }; + + let result: anyhow::Result = (|| { + if engine_handle == 0 { + anyhow::bail!("engine handle is null"); + } + let engine_ptr = engine_handle as *mut EngineHandle; + let engine = unsafe { &(*engine_ptr).engine }; + // SAFETY: The JVM caller warranted the input bytes came from a + // prior componentSerialize call against a compatible engine. Any + // failure will surface as an anyhow error caught below. + let component = unsafe { + Component::deserialize(engine, &raw) + .map_err(|e| anyhow::anyhow!("Component::deserialize: {e}"))? + }; + let handle = Box::new(ComponentHandle { + engine_ptr, + component, + }); + Ok(Box::into_raw(handle) as jlong) + })(); + or_throw(&mut env, result, 0) +} + +// ----------------------------------------------------------------------------- +// Instance lifecycle + invoke +// ----------------------------------------------------------------------------- + +/// Instantiate a component with the full wasmos-runtime host surface +/// (`add_all_to_linker`) plus `caps::wasi_p2` for the composed +/// adapter-wasmtime's wasi imports. +/// +/// A fresh `Store` is created per instantiation with a default +/// `HostState::new()` — empty preview-2 WasiCtx, no memory limits, no epoch +/// deadline. For a configured WASI ctx / memory-limits / epoch-deadline path, +/// use `componentInstantiateWithConfig`. +#[no_mangle] +pub extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_componentInstantiate( + mut env: JNIEnv, + _class: JClass, + component_handle: jlong, +) -> jlong { + let result: anyhow::Result = (|| { + instantiate_with(component_handle, HostState::new(), LimitsBag::none()) + })(); + or_throw(&mut env, result, 0) +} + +/// Same as `componentInstantiate` but with an optional configured WASI ctx +/// (built from the argsArr / envKeysArr / envValsArr / preopen arrays) plus +/// a `LimitsBag` struct of outer-store limits. Any `-1` numeric arg means +/// "unset". +/// +/// Bit-flags in `flags`: 0x01=inheritStdin, 0x02=inheritStdout, +/// 0x04=inheritStderr, 0x08=hasWasiContext (if 0, all wasi args are ignored +/// and default HostState::new() is used). +/// +/// Limit args (all `-1` = unset): +/// * `max_memory_bytes` — per-linear-memory upper bound in bytes +/// * `epoch_deadline` — trap when epoch counter passes this value +/// * `fuel_limit` — set the store's initial fuel budget (requires +/// `Config::consume_fuel(true)`, which the engine enables globally) +/// * `max_table_elements` — per-table element cap +/// * `max_instances` — max component-model instances per store +/// * `max_tables` — max tables per store +/// * `max_memories` — max linear memories per store +#[no_mangle] +pub extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_componentInstantiateWithConfig( + mut env: JNIEnv, + _class: JClass, + component_handle: jlong, + flags: jint, + args_arr: JObjectArray, + env_keys_arr: JObjectArray, + env_vals_arr: JObjectArray, + preopen_hosts_arr: JObjectArray, + preopen_guests_arr: JObjectArray, + preopen_writable_arr: JByteArray, + max_memory_bytes: jlong, + epoch_deadline: jlong, + fuel_limit: jlong, + max_table_elements: jlong, + max_instances: jlong, + max_tables: jlong, + max_memories: jlong, +) -> jlong { + let has_wasi = (flags & 0x08) != 0; + + // Pull all jni-scoped inputs up front so the fallible body doesn't hold + // multiple simultaneous borrows of &mut env. + let host_state_result: anyhow::Result = (|| { + if !has_wasi { + return Ok(HostState::new()); + } + let inherit_stdin = (flags & 0x01) != 0; + let inherit_stdout = (flags & 0x02) != 0; + let inherit_stderr = (flags & 0x04) != 0; + let args = if args_arr.is_null() { + Vec::new() + } else { + jstringarray_to_vec(&mut env, &args_arr)? + }; + let env_keys = if env_keys_arr.is_null() { + Vec::new() + } else { + jstringarray_to_vec(&mut env, &env_keys_arr)? + }; + let env_vals = if env_vals_arr.is_null() { + Vec::new() + } else { + jstringarray_to_vec(&mut env, &env_vals_arr)? + }; + if env_keys.len() != env_vals.len() { + anyhow::bail!( + "envKeys / envVals length mismatch: {} vs {}", + env_keys.len(), + env_vals.len() + ); + } + let preopen_hosts = if preopen_hosts_arr.is_null() { + Vec::new() + } else { + jstringarray_to_vec(&mut env, &preopen_hosts_arr)? + }; + let preopen_guests = if preopen_guests_arr.is_null() { + Vec::new() + } else { + jstringarray_to_vec(&mut env, &preopen_guests_arr)? + }; + let preopen_writable: Vec = if preopen_writable_arr.is_null() { + Vec::new() + } else { + env.convert_byte_array(&preopen_writable_arr) + .map_err(|e| anyhow::anyhow!("convert_byte_array(preopen_writable): {e}"))? + }; + if preopen_hosts.len() != preopen_guests.len() + || preopen_hosts.len() != preopen_writable.len() + { + anyhow::bail!( + "preopen array length mismatch: hosts={} guests={} writable={}", + preopen_hosts.len(), + preopen_guests.len(), + preopen_writable.len() + ); + } + + let mut b = WasiCtxBuilder::new(); + if inherit_stdin { + b.inherit_stdin(); + } + if inherit_stdout { + b.inherit_stdout(); + } + if inherit_stderr { + b.inherit_stderr(); + } + for arg in &args { + b.arg(arg); + } + for (k, v) in env_keys.iter().zip(env_vals.iter()) { + b.env(k, v); + } + for i in 0..preopen_hosts.len() { + let host_path = &preopen_hosts[i]; + let guest_path = &preopen_guests[i]; + let writable = preopen_writable[i] != 0; + let (dp, fp) = if writable { + (DirPerms::all(), FilePerms::all()) + } else { + (DirPerms::READ, FilePerms::READ) + }; + b.preopened_dir(host_path, guest_path, dp, fp) + .map_err(|e| anyhow::anyhow!("preopened_dir({host_path} -> {guest_path}): {e}"))?; + } + Ok(HostState::with_wasi(b.build())) + })(); + let host_state = match host_state_result { + Ok(v) => v, + Err(e) => { + throw(&mut env, &format!("wasmos-provider: {:#}", e)); + return 0; + } + }; + + let limits = LimitsBag { + max_memory_bytes: neg_to_none(max_memory_bytes).map(|v| v as usize), + epoch_deadline: neg_to_none(epoch_deadline).map(|v| v as u64), + fuel_limit: neg_to_none(fuel_limit).map(|v| v as u64), + max_table_elements: neg_to_none(max_table_elements).map(|v| v as usize), + max_instances: neg_to_none(max_instances).map(|v| v as usize), + max_tables: neg_to_none(max_tables).map(|v| v as usize), + max_memories: neg_to_none(max_memories).map(|v| v as usize), + }; + + let result: anyhow::Result = (|| { + instantiate_with(component_handle, host_state, limits) + })(); + or_throw(&mut env, result, 0) +} + +/// `-1` (or any negative) → None; otherwise Some(v). Centralised so the +/// "-1 means unset" convention is threaded through one place, not every +/// call site. +fn neg_to_none(v: jlong) -> Option { + if v < 0 { + None + } else { + Some(v) + } +} + +/// Outer-store limits bundle. Every field is optional — unset means "use +/// wasmtime's default" for that dimension. Fields map 1:1 onto +/// `StoreLimitsBuilder` setters (except `fuel_limit`, which goes through +/// `Store::set_fuel`, and `epoch_deadline`, which goes through +/// `Store::set_epoch_deadline` + `epoch_deadline_trap`). +struct LimitsBag { + max_memory_bytes: Option, + epoch_deadline: Option, + fuel_limit: Option, + max_table_elements: Option, + max_instances: Option, + max_tables: Option, + max_memories: Option, +} + +impl LimitsBag { + fn none() -> Self { + Self { + max_memory_bytes: None, + epoch_deadline: None, + fuel_limit: None, + max_table_elements: None, + max_instances: None, + max_tables: None, + max_memories: None, + } + } + + /// True if any of the StoreLimits fields (memory / tables / memories / + /// instances / table-elements) is set — used to decide whether to + /// install `Store::limiter`. `fuel_limit` and `epoch_deadline` go + /// through separate Store APIs so they don't count. + fn wants_limiter(&self) -> bool { + self.max_memory_bytes.is_some() + || self.max_table_elements.is_some() + || self.max_instances.is_some() + || self.max_tables.is_some() + || self.max_memories.is_some() + } +} + +/// Shared instantiate path. `host_state` supplies any WASI context; +/// `limits` bundles the optional outer-store limits (memory / fuel / +/// epoch / instances / tables / memories / table-elements). +fn instantiate_with( + component_handle: jlong, + mut host_state: HostState, + limits: LimitsBag, +) -> anyhow::Result { + if component_handle == 0 { + anyhow::bail!("component handle is null"); + } + let ch = component_handle as *mut ComponentHandle; + let engine_ptr = unsafe { (*ch).engine_ptr }; + let engine = unsafe { &(*engine_ptr).engine }; + let runtime = unsafe { &(*engine_ptr).runtime }; + let component_ref = unsafe { &(*ch).component }; + + // Configure outer_limits BEFORE building the store — the limiter closure + // borrows the field mutably. Only build a fresh StoreLimits when at + // least one dimension was requested; otherwise leave HostState's + // default (wide-open, matches pre-limits behaviour). + if limits.wants_limiter() { + let mut b = wasmtime::StoreLimitsBuilder::new(); + if let Some(v) = limits.max_memory_bytes { + b = b.memory_size(v); + } + if let Some(v) = limits.max_table_elements { + b = b.table_elements(v); + } + if let Some(v) = limits.max_instances { + b = b.instances(v); + } + if let Some(v) = limits.max_tables { + b = b.tables(v); + } + if let Some(v) = limits.max_memories { + b = b.memories(v); + } + host_state.outer_limits = b.build(); + } + + let mut linker: Linker = Linker::new(engine); + wasmos_runtime::add_all_to_linker(&mut linker, |s| s) + .map_err(|e| anyhow::anyhow!("add_all_to_linker: {e}"))?; + caps::wasi_p2(&mut linker).map_err(|e| anyhow::anyhow!("caps::wasi_p2: {e}"))?; + + let mut store = Store::new(engine, host_state); + if limits.wants_limiter() { + store.limiter(|state| &mut state.outer_limits); + } + // The engine has epoch_interruption(true) globally so per-instance + // deadlines are cheap to opt into. Without this, the store's default + // deadline of 0 traps immediately on the first backedge check — even + // for callers that never asked for epoch interruption. Setting to + // u64::MAX effectively disables the trap for unopted-in callers. + if let Some(deadline) = limits.epoch_deadline { + // Trap when the deadline is exceeded — the simplest sane default; + // yield-based deadlines can be added later behind a separate flag. + store.epoch_deadline_trap(); + store.set_epoch_deadline(deadline); + } else { + store.set_epoch_deadline(u64::MAX); + } + // Fuel: engine has `consume_fuel(true)` globally so `set_fuel` is a + // cheap opt-in per store. Wasmtime starts fuel-enabled stores at 0 + // fuel — which would trap the very first backedge for callers who + // never asked for a fuel budget. Setting to u64::MAX by default + // makes the "no fuel limit" case work identically to a fuel-disabled + // engine from the caller's perspective; the small per-backedge + // instrumentation cost is the price for lazy opt-in. + let fuel = limits.fuel_limit.unwrap_or(u64::MAX); + store + .set_fuel(fuel) + .map_err(|e| anyhow::anyhow!("set_fuel({fuel}): {e}"))?; + + let instance = + runtime.block_on(async { linker.instantiate_async(&mut store, component_ref).await })?; + + let handle = Box::new(InstanceHandle { + engine_ptr, + store: Mutex::new(store), + instance, + resources: Mutex::new(ResourceRegistry::default()), + futures: Mutex::new(FutureRegistry::default()), + streams: Mutex::new(StreamRegistry::default()), + error_contexts: Mutex::new(ErrorContextRegistry::default()), + }); + Ok(Box::into_raw(handle) as jlong) +} + +/// Drop an instance handle (which drops its Store). +#[no_mangle] +pub unsafe extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_instanceClose( + _env: JNIEnv, + _class: JClass, + handle: jlong, +) { + if handle == 0 { + return; + } + let _ = Box::from_raw(handle as *mut InstanceHandle); +} + +// ----------------------------------------------------------------------------- +// Future handle lifecycle +// ----------------------------------------------------------------------------- +// See the `FutureRegistry` docs for the parking-lot design; these two entry +// points are the Java-visible surface for disposing of a parked `FutureAny` +// and (attempting to) await its value. Await is a wasmtime-47 public-API gap +// (see below); close is fully functional. + +/// Evict a parked `FutureAny` from the instance's future registry and call +/// `FutureAny::close(store)` on it so the write end sees the read end as +/// dropped. Idempotent-adjacent: passing an unknown id is an error (matches +/// the Resource contract), passing a null instance handle throws. +/// +/// Safe to call on a WitFuture that was already consumed by a guest pass-in +/// only in the sense that the entry will have been removed — the error path +/// will surface as "unknown future id". Java-side callers should keep a +/// close call in a `finally` block when they own a WitFuture but never pass +/// it back to a guest. +#[no_mangle] +pub extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_futureClose( + mut env: JNIEnv, + _class: JClass, + instance_handle: jlong, + future_id: jlong, +) { + let result: anyhow::Result<()> = (|| { + if instance_handle == 0 { + anyhow::bail!("instance handle is null"); + } + let ih = instance_handle as *mut InstanceHandle; + let store_mutex = unsafe { &(*ih).store }; + let futures_mutex = unsafe { &(*ih).futures }; + let mut store = store_mutex.lock().unwrap(); + let mut futures = futures_mutex.lock().unwrap(); + let mut any = futures.take(future_id as u64).ok_or_else(|| { + anyhow::anyhow!( + "wasmos-provider: unknown future id {} (already closed, transferred \ + to guest, or never parked)", + future_id + ) + })?; + // FutureAny::close signals the writer that the read end is gone. + // Swallow a "future already closed" error only implicitly (via the + // take path above); a live registry hit that fails to close is a + // real error worth surfacing. + any.close(store.as_context_mut()) + .map_err(|e| anyhow::anyhow!("FutureAny::close: {e}"))?; + Ok(()) + })(); + or_throw(&mut env, result, ()); +} + +/// Attempt to await a parked `FutureAny` and hand back the resolved value +/// as a JSON blob (same schema as `instanceInvokeJson` returns). +/// +/// # wasmtime 47 API gap +/// +/// This method currently ALWAYS throws `WebAssemblyException` with a clear +/// "wasmtime API gap" message. The `FutureAny` public API in wasmtime 47 +/// only exposes: +/// +/// * `try_into_future_reader::()` — requires `T: ComponentType` at +/// COMPILE time. Not usable for our runtime-typed marshalling layer. +/// * `try_from_future_reader::(store, reader)` — same static-T constraint, +/// only useful on the way into a guest. +/// * `close(&mut self, store)` — plain disposal, no value extraction. +/// +/// The Rust-side lift path (`FutureReader::pipe`) also needs +/// `T: Lift + 'static` compile-time. There is no `FutureAny::poll_dynamic` / +/// `FutureAny::await_val` shape in the public surface today. When one +/// lands upstream (see wasmtime issues around dynamically-typed +/// component-model concurrency), this method's body swaps in the real +/// implementation without touching the JNI signature. +/// +/// The Java caller receives a `CompletableFuture` that completes +/// exceptionally — the async layer handles that shape naturally. +#[no_mangle] +pub extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_futureAwait( + mut env: JNIEnv, + _class: JClass, + instance_handle: jlong, + future_id: jlong, +) -> jstring { + let result: anyhow::Result = (|| { + if instance_handle == 0 { + anyhow::bail!("instance handle is null"); + } + let ih = instance_handle as *mut InstanceHandle; + let futures_mutex = unsafe { &(*ih).futures }; + let futures = futures_mutex.lock().unwrap(); + if !futures.entries.contains_key(&(future_id as u64)) { + anyhow::bail!( + "wasmos-provider: unknown future id {} (already awaited/closed, \ + transferred to guest, or never parked)", + future_id + ); + } + // Registry hit — the handle is real. But we can't resolve it: the + // wasmtime 47 public API has no dynamic-payload-type await surface. + // See this function's doc comment for the API gap details. + anyhow::bail!( + "wasmos-provider: FutureAny host-side await is not supported by wasmtime 47's \ + public API (FutureAny::try_into_future_reader requires a compile-time T, \ + and FutureConsumer::Item is an associated type — there is no type-erased \ + await path). The WitFuture can still be closed via WasmosAsyncExtension.closeFuture \ + or passed back into a guest import that consumes it. This is tracked as slice-1's \ + known limitation; a real await lands when wasmtime exposes a dynamic API." + ) + })(); + match result { + Ok(s) => match env.new_string(s) { + Ok(js) => js.into_raw(), + Err(e) => { + throw(&mut env, &format!("wasmos-provider: new_string: {e}")); + std::ptr::null_mut() + } + }, + Err(e) => { + throw(&mut env, &format!("wasmos-provider: {:#}", e)); + std::ptr::null_mut() + } + } +} + +// ----------------------------------------------------------------------------- +// Stream handle lifecycle +// ----------------------------------------------------------------------------- +// Structural mirror of the Future entries above. Same wasmtime 47 API gap — +// there is no dynamic-payload-type read surface on `StreamAny` (only the +// compile-time-typed `try_into_stream_reader::`), so `streamRead` is +// intentionally a stub-that-throws matching `futureAwait`. Close and pass-in +// are fully wired. + +/// Evict a parked `StreamAny` from the instance's stream registry and call +/// `StreamAny::close(store)` on it so the write end sees the read end as +/// dropped. Symmetric with `futureClose`. +#[no_mangle] +pub extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_streamClose( + mut env: JNIEnv, + _class: JClass, + instance_handle: jlong, + stream_id: jlong, +) { + let result: anyhow::Result<()> = (|| { + if instance_handle == 0 { + anyhow::bail!("instance handle is null"); + } + let ih = instance_handle as *mut InstanceHandle; + let store_mutex = unsafe { &(*ih).store }; + let streams_mutex = unsafe { &(*ih).streams }; + let mut store = store_mutex.lock().unwrap(); + let mut streams = streams_mutex.lock().unwrap(); + let mut any = streams.take(stream_id as u64).ok_or_else(|| { + anyhow::anyhow!( + "wasmos-provider: unknown stream id {} (already closed, transferred \ + to guest, or never parked)", + stream_id + ) + })?; + any.close(store.as_context_mut()) + .map_err(|e| anyhow::anyhow!("StreamAny::close: {e}"))?; + Ok(()) + })(); + or_throw(&mut env, result, ()); +} + +/// Attempt to read from a parked `StreamAny`. Currently ALWAYS throws +/// with a clear "wasmtime API gap" message — the same reason `futureAwait` +/// does. Kept as a stable JNI entry so the Java surface is future-proof: +/// when wasmtime exposes a dynamic-payload-type read surface for +/// `StreamAny`, only the Rust body swaps in. +/// +/// # wasmtime 47 API gap +/// +/// `StreamAny`'s public API in wasmtime 47 only exposes +/// `try_into_stream_reader::()` (requires a compile-time `T`) and +/// `close(store)`. There is no `try_read_dynamic` / `poll_next_dynamic` +/// surface, so runtime-typed marshalling cannot lift stream items to +/// `Val`s here. +#[no_mangle] +pub extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_streamRead( + mut env: JNIEnv, + _class: JClass, + instance_handle: jlong, + stream_id: jlong, +) -> jstring { + let result: anyhow::Result = (|| { + if instance_handle == 0 { + anyhow::bail!("instance handle is null"); + } + let ih = instance_handle as *mut InstanceHandle; + let streams_mutex = unsafe { &(*ih).streams }; + let streams = streams_mutex.lock().unwrap(); + if !streams.entries.contains_key(&(stream_id as u64)) { + anyhow::bail!( + "wasmos-provider: unknown stream id {} (already read/closed, \ + transferred to guest, or never parked)", + stream_id + ); + } + // Registry hit — same API-gap story as `futureAwait`. + anyhow::bail!( + "wasmos-provider: StreamAny host-side read is not supported by wasmtime 47's \ + public API (StreamAny::try_into_stream_reader requires a compile-time T, \ + and there is no type-erased next/poll surface). The WitStream can still be \ + closed via WasmosAsyncExtension.closeStream or passed back into a guest \ + import that consumes it. This is tracked as slice-2's known limitation; a \ + real read lands when wasmtime exposes a dynamic API." + ) + })(); + match result { + Ok(s) => match env.new_string(s) { + Ok(js) => js.into_raw(), + Err(e) => { + throw(&mut env, &format!("wasmos-provider: new_string: {e}")); + std::ptr::null_mut() + } + }, + Err(e) => { + throw(&mut env, &format!("wasmos-provider: {:#}", e)); + std::ptr::null_mut() + } + } +} + +// ----------------------------------------------------------------------------- +// ErrorContext handle lifecycle +// ----------------------------------------------------------------------------- +// wasmtime 47's `ErrorContextAny` is a placeholder (see `FIXME(#11161)` on the +// wasmtime side) — it has no publicly-defined `close`, `drop`, or any accessor +// beyond `Debug`. All we can do is evict from our registry. + +/// Evict a parked `ErrorContextAny` from the instance's registry. Pure +/// Rust-side eviction — wasmtime has no dispose surface on +/// `ErrorContextAny`, so a Java caller who is done with a WitErrorContext +/// simply drops the slot to keep the registry from growing. +#[no_mangle] +pub extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_errorContextClose( + mut env: JNIEnv, + _class: JClass, + instance_handle: jlong, + error_context_id: jlong, +) { + let result: anyhow::Result<()> = (|| { + if instance_handle == 0 { + anyhow::bail!("instance handle is null"); + } + let ih = instance_handle as *mut InstanceHandle; + let ec_mutex = unsafe { &(*ih).error_contexts }; + let mut ec = ec_mutex.lock().unwrap(); + ec.take(error_context_id as u64).ok_or_else(|| { + anyhow::anyhow!( + "wasmos-provider: unknown error-context id {} (already closed or \ + never parked)", + error_context_id + ) + })?; + Ok(()) + })(); + or_throw(&mut env, result, ()); +} + +/// Invoke an exported function with signature `func() -> i32`. Kept as a +/// nullary fast path for the demo (`guest-demo-portable`'s `run` export) — +/// skips the JSON round-trip that `instanceInvokeJson` incurs. +/// +/// Returns the i32 result; on any error, throws `WebAssemblyException` and +/// returns 0. +#[no_mangle] +pub extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_instanceInvokeReturningI32( + mut env: JNIEnv, + _class: JClass, + instance_handle: jlong, + name: JString, +) -> jint { + let fn_name = match jstring_to_string(&mut env, &name) { + Ok(v) => v, + Err(e) => { + throw(&mut env, &format!("wasmos-provider: {:#}", e)); + return 0; + } + }; + + let result: anyhow::Result = (|| { + if instance_handle == 0 { + anyhow::bail!("instance handle is null"); + } + let ih = instance_handle as *mut InstanceHandle; + let engine_ptr = unsafe { (*ih).engine_ptr }; + let runtime = unsafe { &(*engine_ptr).runtime }; + + let store_mutex = unsafe { &(*ih).store }; + let instance = unsafe { &(*ih).instance }; + let mut store = store_mutex.lock().unwrap(); + + let typed = instance + .get_typed_func::<(), (i32,)>(&mut *store, &fn_name) + .map_err(|e| anyhow::anyhow!("get_typed_func('{}'): {}", fn_name, e))?; + + let (out,) = runtime.block_on(async { typed.call_async(&mut *store, ()).await })?; + + Ok(out) + })(); + or_throw(&mut env, result, 0) +} + +/// General invoke path — hand over a JSON array of typed argument `JsonVal`s, +/// receive a JSON array of typed result `JsonVal`s. See the `JsonVal` +/// definition above for the schema; the Java-side `WasmosMarshalling` +/// mirrors it. +/// +/// For a `func() -> ()` return, the Rust side returns `"[]"`. On error, +/// throws `WebAssemblyException` and returns null. +#[no_mangle] +pub extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_instanceInvokeJson( + mut env: JNIEnv, + _class: JClass, + instance_handle: jlong, + name: JString, + args_json: JString, +) -> jstring { + let fn_name = match jstring_to_string(&mut env, &name) { + Ok(v) => v, + Err(e) => { + throw(&mut env, &format!("wasmos-provider: {:#}", e)); + return std::ptr::null_mut(); + } + }; + let args_raw = match jstring_to_string(&mut env, &args_json) { + Ok(v) => v, + Err(e) => { + throw(&mut env, &format!("wasmos-provider: {:#}", e)); + return std::ptr::null_mut(); + } + }; + + let result: anyhow::Result = (|| { + if instance_handle == 0 { + anyhow::bail!("instance handle is null"); + } + let json_args: Vec = serde_json::from_str(&args_raw) + .map_err(|e| anyhow::anyhow!("deserialize args JSON: {e}"))?; + + let ih = instance_handle as *mut InstanceHandle; + let engine_ptr = unsafe { (*ih).engine_ptr }; + let runtime = unsafe { &(*engine_ptr).runtime }; + let store_mutex = unsafe { &(*ih).store }; + let instance = unsafe { &(*ih).instance }; + let resources_mutex = unsafe { &(*ih).resources }; + let futures_mutex = unsafe { &(*ih).futures }; + let streams_mutex = unsafe { &(*ih).streams }; + let error_contexts_mutex = unsafe { &(*ih).error_contexts }; + let mut store = store_mutex.lock().unwrap(); + let mut resources = resources_mutex.lock().unwrap(); + let mut futures = futures_mutex.lock().unwrap(); + let mut streams = streams_mutex.lock().unwrap(); + let mut error_contexts = error_contexts_mutex.lock().unwrap(); + + // Decode args AFTER acquiring the registry locks — the Resource / + // Future / Stream / ErrorContext arms call take/peek against those + // registries. + let mut params: Vec = Vec::with_capacity(json_args.len()); + for jv in &json_args { + params.push(jv.to_val( + &mut resources, + &mut futures, + &mut streams, + &mut error_contexts, + )?); + } + + let func = resolve_func(instance, &mut store, &fn_name) + .ok_or_else(|| anyhow::anyhow!("no exported function '{}'", fn_name))?; + + // Preallocate results — wasmtime `Func::call_async` needs a slice + // sized to the return arity. Grab that from the func's declared type. + let ty = func.ty(&*store); + let result_arity = ty.results().len(); + let mut results: Vec = vec![Val::Bool(false); result_arity]; + + runtime.block_on(async { + func.call_async(&mut *store, ¶ms, &mut results).await + })?; + // NB: post_return_async is a no-op in wasmtime 47 (per the crate's + // #[deprecated] annotation) — omitted intentionally. + + // Encoding the return path parks any Resource(any) / Future(any) / + // Stream(any) / ErrorContext(any) values in their registries — Java + // sees the fresh slot ids in the JSON blob. + let json_results: Vec = results + .iter() + .map(|v| JsonVal::from_val( + v, + &mut resources, + &mut futures, + &mut streams, + &mut error_contexts, + )) + .collect::>>()?; + Ok(serde_json::to_string(&json_results) + .map_err(|e| anyhow::anyhow!("serialize results JSON: {e}"))?) + })(); + + match result { + Ok(s) => match env.new_string(s) { + Ok(js) => js.into_raw(), + Err(e) => { + throw(&mut env, &format!("wasmos-provider: new_string: {e}")); + std::ptr::null_mut() + } + }, + Err(e) => { + throw(&mut env, &format!("wasmos-provider: {:#}", e)); + std::ptr::null_mut() + } + } +} + +/// Byte-array-returning variant of `instanceInvokeJson`. Callers who know +/// the exported function returns exactly one `list` can skip the JSON +/// detour on the return path — we hand back a raw jbyteArray of the +/// element bytes. Args still go through JSON (rare enough that we don't +/// bother with a fast-path array-in surface). +/// +/// If the return arity isn't 1, or the sole result isn't a `list`, +/// throws `WebAssemblyException`. Returns null on error. +#[no_mangle] +pub extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_instanceInvokeJsonReturningBytes( + mut env: JNIEnv, + _class: JClass, + instance_handle: jlong, + name: JString, + args_json: JString, +) -> jbyteArray { + let fn_name = match jstring_to_string(&mut env, &name) { + Ok(v) => v, + Err(e) => { + throw(&mut env, &format!("wasmos-provider: {:#}", e)); + return std::ptr::null_mut(); + } + }; + let args_raw = match jstring_to_string(&mut env, &args_json) { + Ok(v) => v, + Err(e) => { + throw(&mut env, &format!("wasmos-provider: {:#}", e)); + return std::ptr::null_mut(); + } + }; + + let result: anyhow::Result> = (|| { + if instance_handle == 0 { + anyhow::bail!("instance handle is null"); + } + let json_args: Vec = serde_json::from_str(&args_raw) + .map_err(|e| anyhow::anyhow!("deserialize args JSON: {e}"))?; + + let ih = instance_handle as *mut InstanceHandle; + let engine_ptr = unsafe { (*ih).engine_ptr }; + let runtime = unsafe { &(*engine_ptr).runtime }; + let store_mutex = unsafe { &(*ih).store }; + let instance = unsafe { &(*ih).instance }; + let resources_mutex = unsafe { &(*ih).resources }; + let futures_mutex = unsafe { &(*ih).futures }; + let streams_mutex = unsafe { &(*ih).streams }; + let error_contexts_mutex = unsafe { &(*ih).error_contexts }; + let mut store = store_mutex.lock().unwrap(); + let mut resources = resources_mutex.lock().unwrap(); + let mut futures = futures_mutex.lock().unwrap(); + let mut streams = streams_mutex.lock().unwrap(); + let mut error_contexts = error_contexts_mutex.lock().unwrap(); + + // Resolve any Resource / Future / Stream / ErrorContext args after + // grabbing the registry locks. + let mut params: Vec = Vec::with_capacity(json_args.len()); + for jv in &json_args { + params.push(jv.to_val( + &mut resources, + &mut futures, + &mut streams, + &mut error_contexts, + )?); + } + // Result path here is byte-array-only — no need to hold the registry + // borrows after params decode, but keeping them live is cheap and + // future-proofs any list-adjacent variants that might carry + // resource / future / stream / error-context handles alongside the + // bytes. + let _ = &mut *resources; + let _ = &mut *futures; + let _ = &mut *streams; + let _ = &mut *error_contexts; + + let func = resolve_func(instance, &mut store, &fn_name) + .ok_or_else(|| anyhow::anyhow!("no exported function '{}'", fn_name))?; + let ty = func.ty(&*store); + let result_arity = ty.results().len(); + if result_arity != 1 { + anyhow::bail!( + "invokeBytes expects exactly one return value, got {}", + result_arity + ); + } + let mut results: Vec = vec![Val::Bool(false); 1]; + runtime.block_on(async { + func.call_async(&mut *store, ¶ms, &mut results).await + })?; + // post_return_async is a no-op in wasmtime 47 (deprecated). + + match &results[0] { + Val::List(items) => { + let mut out = Vec::with_capacity(items.len()); + for v in items { + match v { + Val::U8(b) => out.push(*b), + other => anyhow::bail!( + "invokeBytes expects list, got list element {:?}", + other + ), + } + } + Ok(out) + } + Val::String(s) => Ok(s.as_bytes().to_vec()), + other => anyhow::bail!( + "invokeBytes expects list or string result, got {:?}", + other + ), + } + })(); + + match result { + Ok(bytes) => match env.byte_array_from_slice(&bytes) { + Ok(a) => a.into_raw(), + Err(e) => { + throw(&mut env, &format!("wasmos-provider: byte_array_from_slice: {e}")); + std::ptr::null_mut() + } + }, + Err(e) => { + throw(&mut env, &format!("wasmos-provider: {:#}", e)); + std::ptr::null_mut() + } + } +} + +/// Return list of exported function names, as a null-separated UTF-8 blob in a +/// jbyteArray. Java splits on 0x00. Keeping it a byte blob avoids per-call +/// jstring allocation loops for what's an occasional metadata read. +#[no_mangle] +pub extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_componentExportedFunctionNames( + mut env: JNIEnv, + _class: JClass, + component_handle: jlong, +) -> jbyteArray { + let names_result: anyhow::Result> = (|| { + if component_handle == 0 { + anyhow::bail!("component handle is null"); + } + let ch = component_handle as *mut ComponentHandle; + let engine_ptr = unsafe { (*ch).engine_ptr }; + let engine = unsafe { &(*engine_ptr).engine }; + let component = unsafe { &(*ch).component }; + let component_type = component.component_type(); + let mut names: Vec = Vec::new(); + for (name, item) in component_type.exports(engine) { + if let wasmtime::component::types::ComponentItem::ComponentFunc(_) = item.ty { + names.push(name.to_string()); + } + } + Ok(names) + })(); + let names = match names_result { + Ok(v) => v, + Err(e) => { + throw(&mut env, &format!("wasmos-provider: {:#}", e)); + return std::ptr::null_mut(); + } + }; + let joined = names.join("\0"); + match env.byte_array_from_slice(joined.as_bytes()) { + Ok(a) => a.into_raw(), + Err(e) => { + throw(&mut env, &format!("wasmos-provider: byte_array_from_slice: {e}")); + std::ptr::null_mut() + } + } +} + +/// Introspect an instance's function signature — returns "argCount:resultCount" +/// as an ASCII byte blob. Used by the Java-side marshalling helper when it +/// needs to size a param list without a prior WIT descriptor. Both counts are +/// jint-sized; negative values would indicate an error but we always throw +/// instead. Returns null on error; jboolean sentinel isn't in scope. +#[no_mangle] +pub extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_instanceFunctionArity( + mut env: JNIEnv, + _class: JClass, + instance_handle: jlong, + name: JString, +) -> jbyteArray { + let fn_name = match jstring_to_string(&mut env, &name) { + Ok(v) => v, + Err(e) => { + throw(&mut env, &format!("wasmos-provider: {:#}", e)); + return std::ptr::null_mut(); + } + }; + let result: anyhow::Result<(usize, usize)> = (|| { + if instance_handle == 0 { + anyhow::bail!("instance handle is null"); + } + let ih = instance_handle as *mut InstanceHandle; + let store_mutex = unsafe { &(*ih).store }; + let instance = unsafe { &(*ih).instance }; + let mut store = store_mutex.lock().unwrap(); + let func = resolve_func(instance, &mut store, &fn_name) + .ok_or_else(|| anyhow::anyhow!("no exported function '{}'", fn_name))?; + let ty = func.ty(&*store); + let arity = (ty.params().len(), ty.results().len()); + Ok(arity) + })(); + let (args, rets) = match result { + Ok(v) => v, + Err(e) => { + throw(&mut env, &format!("wasmos-provider: {:#}", e)); + return std::ptr::null_mut(); + } + }; + let s = format!("{args}:{rets}"); + match env.byte_array_from_slice(s.as_bytes()) { + Ok(a) => a.into_raw(), + Err(e) => { + throw(&mut env, &format!("wasmos-provider: byte_array_from_slice: {e}")); + std::ptr::null_mut() + } + } +} + +/// Report whether an instance exports a given function name. Cheaper than +/// pulling the whole export list when a Java caller only wants a hasFunction +/// probe. Returns JNI_TRUE / JNI_FALSE. Throws on invalid handle. +#[no_mangle] +pub extern "system" fn Java_ai_tegmentum_webassembly4j_provider_wasmos_jni_WasmosNative_instanceHasFunction( + mut env: JNIEnv, + _class: JClass, + instance_handle: jlong, + name: JString, +) -> jboolean { + let fn_name = match jstring_to_string(&mut env, &name) { + Ok(v) => v, + Err(e) => { + throw(&mut env, &format!("wasmos-provider: {:#}", e)); + return 0; + } + }; + if instance_handle == 0 { + throw(&mut env, "wasmos-provider: instance handle is null"); + return 0; + } + let ih = instance_handle as *mut InstanceHandle; + let store_mutex = unsafe { &(*ih).store }; + let instance = unsafe { &(*ih).instance }; + let mut store = store_mutex.lock().unwrap(); + if resolve_func(instance, &mut store, &fn_name).is_some() { + 1 + } else { + 0 + } +} + +// ----------------------------------------------------------------------------- +// Tests — Rust-side smoke tests for the JsonVal marshalling helpers. Confirm +// the on-the-wire schema is stable and round-trips cleanly for every variant +// the Java side is expected to send. +// ----------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn roundtrip(v: Val) { + let mut reg = ResourceRegistry::default(); + let mut futs = FutureRegistry::default(); + let mut strs = StreamRegistry::default(); + let mut ecs = ErrorContextRegistry::default(); + let jv = JsonVal::from_val(&v, &mut reg, &mut futs, &mut strs, &mut ecs) + .expect("from_val"); + let json = serde_json::to_string(&jv).expect("serialize"); + let back: JsonVal = serde_json::from_str(&json).expect("deserialize"); + let v2 = back + .to_val(&mut reg, &mut futs, &mut strs, &mut ecs) + .expect("to_val"); + // Val doesn't implement PartialEq; compare Debug reprs. + assert_eq!(format!("{:?}", v), format!("{:?}", v2)); + } + + #[test] + fn primitives_roundtrip() { + roundtrip(Val::Bool(true)); + roundtrip(Val::S8(-1)); + roundtrip(Val::U8(200)); + roundtrip(Val::S16(-1234)); + roundtrip(Val::U16(60000)); + roundtrip(Val::S32(-42)); + roundtrip(Val::U32(4_000_000_000)); + roundtrip(Val::S64(-1_000_000_000_000)); + roundtrip(Val::U64(u64::MAX)); + roundtrip(Val::Float32(1.5)); + roundtrip(Val::Float64(-2.25)); + roundtrip(Val::Char('Z')); + roundtrip(Val::String("hello".to_string())); + } + + #[test] + fn list_and_bytes_roundtrip() { + // Empty list stays empty list (not Bytes fast path). + roundtrip(Val::List(vec![])); + // All-U8 list picks up the base64 fast path — check the wire form is + // Bytes and it decodes back to Val::List(U8). + let bytes = Val::List(vec![Val::U8(0), Val::U8(1), Val::U8(2), Val::U8(255)]); + let mut reg = ResourceRegistry::default(); + let mut futs = FutureRegistry::default(); + let mut strs = StreamRegistry::default(); + let mut ecs = ErrorContextRegistry::default(); + let jv = JsonVal::from_val(&bytes, &mut reg, &mut futs, &mut strs, &mut ecs).unwrap(); + assert!(matches!(jv, JsonVal::Bytes(_))); + roundtrip(bytes); + // Non-U8 list stays a List. + roundtrip(Val::List(vec![Val::S32(1), Val::S32(2), Val::S32(3)])); + } + + #[test] + fn option_and_result_roundtrip() { + roundtrip(Val::Option(None)); + roundtrip(Val::Option(Some(Box::new(Val::S32(7))))); + roundtrip(Val::Result(Ok(Some(Box::new(Val::String("ok".into())))))); + roundtrip(Val::Result(Err(Some(Box::new(Val::S32(-1)))))); + roundtrip(Val::Result(Ok(None))); + roundtrip(Val::Result(Err(None))); + } + + #[test] + fn record_and_tuple_roundtrip() { + roundtrip(Val::Tuple(vec![Val::S32(1), Val::String("x".into())])); + roundtrip(Val::Record(vec![ + ("name".into(), Val::String("alice".into())), + ("age".into(), Val::U32(30)), + ])); + } + + #[test] + fn variant_and_enum_and_flags_roundtrip() { + roundtrip(Val::Variant("some".into(), Some(Box::new(Val::S32(1))))); + roundtrip(Val::Variant("none".into(), None)); + roundtrip(Val::Enum("red".into())); + roundtrip(Val::Flags(vec!["read".into(), "write".into()])); + } + + #[test] + fn map_roundtrip() { + // Map — keys are non-string so this MUST land as + // Val::Map, not Val::Record. + roundtrip(Val::Map(vec![ + (Val::S32(1), Val::String("one".into())), + (Val::S32(2), Val::String("two".into())), + ])); + // Empty map. + roundtrip(Val::Map(vec![])); + // Map> — checks that inner Bytes fast path + // survives when nested inside a Map value slot. + roundtrip(Val::Map(vec![ + ( + Val::String("a".into()), + Val::List(vec![Val::U8(1), Val::U8(2), Val::U8(3)]), + ), + ( + Val::String("b".into()), + Val::List(vec![Val::U8(255), Val::U8(0)]), + ), + ])); + } + + #[test] + fn resource_json_wire_format_is_stable() { + // We can't easily fabricate a ResourceAny here (its constructor is + // pub(crate)), but the JsonVal::Resource wire schema is what the + // Java side depends on. Round-trip via serde to lock in field + // names — a rename would silently break Java parsing otherwise. + let jv = JsonVal::Resource { + table_id: 42, + type_name: "TestType".into(), + owned: true, + }; + let json = serde_json::to_string(&jv).unwrap(); + assert!( + json.contains("\"Resource\"") && json.contains("\"table_id\":42") + && json.contains("\"type_name\":\"TestType\"") + && json.contains("\"owned\":true"), + "wire format regression: {json}" + ); + let back: JsonVal = serde_json::from_str(&json).unwrap(); + match back { + JsonVal::Resource { + table_id, + type_name, + owned, + } => { + assert_eq!(table_id, 42); + assert_eq!(type_name, "TestType"); + assert!(owned); + } + other => panic!("expected Resource, got {other:?}"), + } + } + + #[test] + fn resource_registry_starts_empty() { + // Baseline invariants for the ResourceRegistry — full park/take + // exercise happens through the JNI path once a live ResourceAny is + // available (needs a real component-model resource). We can't + // fabricate a ResourceAny here because its constructor is + // pub(crate); a zeroed ResourceAny would be UB (invalid + // HostResourceIndex). The full lifecycle is covered end-to-end via + // Java-side round-trip tests plus the E2E-with-component path. + let reg = ResourceRegistry::default(); + assert_eq!(reg.next_id, 0); + assert!(reg.entries.is_empty()); + assert!(reg.peek(0).is_none()); + } + + #[test] + fn resource_to_val_errors_on_unknown_id() { + // The Resource decode arm must fail cleanly when Java sends a + // slot id that isn't parked. Two failure modes: owned=true takes + // (miss on second use); owned=false peeks (miss when never parked). + let mut reg = ResourceRegistry::default(); + let mut futs = FutureRegistry::default(); + let mut strs = StreamRegistry::default(); + let mut ecs = ErrorContextRegistry::default(); + let jv_take = JsonVal::Resource { + table_id: 12345, + type_name: "T".into(), + owned: true, + }; + let err = jv_take + .to_val(&mut reg, &mut futs, &mut strs, &mut ecs) + .unwrap_err() + .to_string(); + assert!( + err.contains("12345") && err.contains("unknown resource id"), + "unexpected error: {err}" + ); + let jv_peek = JsonVal::Resource { + table_id: 999, + type_name: "T".into(), + owned: false, + }; + let err = jv_peek + .to_val(&mut reg, &mut futs, &mut strs, &mut ecs) + .unwrap_err() + .to_string(); + assert!( + err.contains("999") && err.contains("unknown resource id"), + "unexpected error: {err}" + ); + } + + #[test] + fn future_json_wire_format_is_stable() { + // JsonVal::Future wire schema — the Java side keys on the exact + // field names, so a rename here would silently break parsing. + let jv = JsonVal::Future { + table_id: 17, + type_name: "FutureAny { .. }".into(), + }; + let json = serde_json::to_string(&jv).unwrap(); + assert!( + json.contains("\"Future\"") + && json.contains("\"table_id\":17") + && json.contains("\"type_name\":\"FutureAny { .. }\""), + "wire format regression: {json}" + ); + let back: JsonVal = serde_json::from_str(&json).unwrap(); + match back { + JsonVal::Future { table_id, type_name } => { + assert_eq!(table_id, 17); + assert_eq!(type_name, "FutureAny { .. }"); + } + other => panic!("expected Future, got {other:?}"), + } + } + + #[test] + fn future_registry_starts_empty() { + // Same coverage shape as `resource_registry_starts_empty`. + // Fabricating a live FutureAny requires a real component-model + // future value on a live store, so the full park/take exercise + // lives on the Java-side integration path (once a fixture with a + // `future` export exists). + let reg = FutureRegistry::default(); + assert_eq!(reg.next_id, 0); + assert!(reg.entries.is_empty()); + assert!(reg.peek(0).is_none()); + } + + #[test] + fn future_to_val_errors_on_unknown_id() { + // The Future decode arm always takes (there's no borrow shape for + // futures — see the JsonVal::Future doc comment) so a single failure + // mode is enough here. + let mut reg = ResourceRegistry::default(); + let mut futs = FutureRegistry::default(); + let mut strs = StreamRegistry::default(); + let mut ecs = ErrorContextRegistry::default(); + let jv = JsonVal::Future { + table_id: 54321, + type_name: "T".into(), + }; + let err = jv + .to_val(&mut reg, &mut futs, &mut strs, &mut ecs) + .unwrap_err() + .to_string(); + assert!( + err.contains("54321") && err.contains("unknown future id"), + "unexpected error: {err}" + ); + } + + #[test] + fn stream_json_wire_format_is_stable() { + // Mirrors `future_json_wire_format_is_stable` — the Java side keys on + // the exact field names, so a rename here would silently break parsing. + let jv = JsonVal::Stream { + table_id: 21, + type_name: "StreamAny { .. }".into(), + }; + let json = serde_json::to_string(&jv).unwrap(); + assert!( + json.contains("\"Stream\"") + && json.contains("\"table_id\":21") + && json.contains("\"type_name\":\"StreamAny { .. }\""), + "wire format regression: {json}" + ); + let back: JsonVal = serde_json::from_str(&json).unwrap(); + match back { + JsonVal::Stream { table_id, type_name } => { + assert_eq!(table_id, 21); + assert_eq!(type_name, "StreamAny { .. }"); + } + other => panic!("expected Stream, got {other:?}"), + } + } + + #[test] + fn stream_registry_starts_empty() { + // Same coverage shape as `future_registry_starts_empty`; a full + // park/take exercise needs a live StreamAny value, which requires a + // real component-model stream on a live store. + let reg = StreamRegistry::default(); + assert_eq!(reg.next_id, 0); + assert!(reg.entries.is_empty()); + assert!(reg.peek(0).is_none()); + } + + #[test] + fn stream_to_val_errors_on_unknown_id() { + // Stream decode always takes — mirrors the Future contract. + let mut reg = ResourceRegistry::default(); + let mut futs = FutureRegistry::default(); + let mut strs = StreamRegistry::default(); + let mut ecs = ErrorContextRegistry::default(); + let jv = JsonVal::Stream { + table_id: 7777, + type_name: "T".into(), + }; + let err = jv + .to_val(&mut reg, &mut futs, &mut strs, &mut ecs) + .unwrap_err() + .to_string(); + assert!( + err.contains("7777") && err.contains("unknown stream id"), + "unexpected error: {err}" + ); + } + + #[test] + fn error_context_json_wire_format_is_stable() { + // Wire schema lock — the Java side keys on the exact field names + // ("table_id", "rep"). A rename would silently break parsing. + let jv = JsonVal::ErrorContext { + table_id: 3, + rep: 42, + }; + let json = serde_json::to_string(&jv).unwrap(); + assert!( + json.contains("\"ErrorContext\"") + && json.contains("\"table_id\":3") + && json.contains("\"rep\":42"), + "wire format regression: {json}" + ); + let back: JsonVal = serde_json::from_str(&json).unwrap(); + match back { + JsonVal::ErrorContext { table_id, rep } => { + assert_eq!(table_id, 3); + assert_eq!(rep, 42); + } + other => panic!("expected ErrorContext, got {other:?}"), + } + } + + #[test] + fn error_context_registry_starts_empty() { + let reg = ErrorContextRegistry::default(); + assert_eq!(reg.next_id, 0); + assert!(reg.entries.is_empty()); + assert!(reg.peek(0).is_none()); + } + + #[test] + fn error_context_to_val_errors_on_unknown_id() { + let mut reg = ResourceRegistry::default(); + let mut futs = FutureRegistry::default(); + let mut strs = StreamRegistry::default(); + let mut ecs = ErrorContextRegistry::default(); + let jv = JsonVal::ErrorContext { + table_id: 88888, + rep: 0, + }; + let err = jv + .to_val(&mut reg, &mut futs, &mut strs, &mut ecs) + .unwrap_err() + .to_string(); + assert!( + err.contains("88888") && err.contains("unknown error-context id"), + "unexpected error: {err}" + ); + } + + #[test] + fn base64_edge_cases() { + assert_eq!(b64_encode(&[]), ""); + assert_eq!(b64_encode(&[0]), "AA=="); + assert_eq!(b64_encode(&[0, 1]), "AAE="); + assert_eq!(b64_encode(&[0, 1, 2]), "AAEC"); + assert_eq!(b64_decode("AAECAw==").unwrap(), vec![0, 1, 2, 3]); + // Reject non-multiple-of-4 lengths. + assert!(b64_decode("AAA").is_err()); + } +} diff --git a/wasmos-provider/pom.xml b/wasmos-provider/pom.xml new file mode 100644 index 0000000..90d689a --- /dev/null +++ b/wasmos-provider/pom.xml @@ -0,0 +1,194 @@ + + + 4.0.0 + + + ai.tegmentum.webassembly4j + webassembly4j-parent + 2.5.2-SNAPSHOT + + + wasmos-provider + jar + + WebAssembly4J wasmos Provider + + wasmos engine provider — a wasmtime-backed component runtime that + pre-links the wasmos-runtime host surface (host:wasmtime, + host:bootstrap, host:caps, host:grants, wasmos:host/*) plus + wasi:p2 onto every component instance. Backed by a JNI wrapper + cdylib that links wasmos-runtime (Rust crate at ~/git/wasmos). + + + + + ${project.basedir}/.cargo-target + ${project.basedir}/native + release + + + false + + + + + ai.tegmentum.webassembly4j + webassembly4j-spi + + + org.junit.jupiter + junit-jupiter + test + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.6.4 + + + + cargo-build-wasmos-provider-native + generate-resources + + exec + + + cargo + ${native.crate.dir} + + build + --${cargo.build.mode} + + + ${native.cargo.target.dir} + 1 + + ${skip.native.build} + + + + + + + org.apache.maven.plugins + maven-antrun-plugin + 3.2.0 + + + + stage-native-library + process-resources + + run + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Unknown OS/arch combination for wasmos-provider native + staging. Set -Dskip.native.build=true to skip, or add a + matching <condition> in wasmos-provider/pom.xml. + + + + + + + + + + + + + + + + + + + + diff --git a/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosAsyncExecutors.java b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosAsyncExecutors.java new file mode 100644 index 0000000..15acedf --- /dev/null +++ b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosAsyncExecutors.java @@ -0,0 +1,75 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package ai.tegmentum.webassembly4j.provider.wasmos; + +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Factory for the {@code wasmos-provider-async} worker pool used by + * {@link ai.tegmentum.webassembly4j.provider.wasmos.ext.WasmosAsyncExtension} + * to schedule blocking JNI invokes off the caller's thread. + * + *

Rationale for a dedicated pool over {@link java.util.concurrent.ForkJoinPool#commonPool}: + * the commonPool is shared with parallel streams and any other JVM async + * code; blocking JNI calls on it starve unrelated work and can deadlock + * fork/join-based algorithms. A named pool also makes stacks / thread + * dumps immediately readable. + * + *

Sizing: 0 core threads (so an idle engine doesn't hold worker threads + * indefinitely), max {@value #MAX_WORKERS} workers, 60s keep-alive. Chosen + * to match the "cached thread pool with a bounded max" recommendation in + * the slice-1 brief. Callers submitting more than {@value #MAX_WORKERS} + * concurrent invocations will queue — safer than unbounded expansion which + * would fight wasmtime's own worker threads for OS-level scheduling + * resources. + * + *

Threads are daemons so a stray reference to an unclosed engine doesn't + * block JVM shutdown. + */ +final class WasmosAsyncExecutors { + + static final int MAX_WORKERS = 32; + private static final long KEEP_ALIVE_SECONDS = 60L; + + private WasmosAsyncExecutors() {} + + /** + * Create a fresh bounded cached pool. One per {@link WasmosEngineAdapter}; + * shut down when the engine closes. + */ + static ThreadPoolExecutor newEnginePool() { + return new ThreadPoolExecutor( + 0, + MAX_WORKERS, + KEEP_ALIVE_SECONDS, + TimeUnit.SECONDS, + new LinkedBlockingQueue<>(), + new AsyncThreadFactory()); + } + + /** Named daemon threads for cleaner stack dumps. */ + private static final class AsyncThreadFactory implements ThreadFactory { + private static final AtomicInteger POOL_ID = new AtomicInteger(); + private final AtomicInteger threadId = new AtomicInteger(); + private final int poolIndex = POOL_ID.incrementAndGet(); + + @Override + public Thread newThread(Runnable r) { + final Thread t = new Thread(r, + "wasmos-provider-async-" + poolIndex + "-" + threadId.incrementAndGet()); + t.setDaemon(true); + return t; + } + } +} diff --git a/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosComponentAdapter.java b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosComponentAdapter.java new file mode 100644 index 0000000..705ee92 --- /dev/null +++ b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosComponentAdapter.java @@ -0,0 +1,380 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package ai.tegmentum.webassembly4j.provider.wasmos; + +import ai.tegmentum.webassembly4j.api.Component; +import ai.tegmentum.webassembly4j.api.ComponentInstance; +import ai.tegmentum.webassembly4j.api.LinkingContext; +import ai.tegmentum.webassembly4j.api.WasiContext; +import ai.tegmentum.webassembly4j.api.config.ComponentConfig; +import ai.tegmentum.webassembly4j.api.exception.InstantiationException; +import ai.tegmentum.webassembly4j.api.exception.UnsupportedFeatureException; +import ai.tegmentum.webassembly4j.api.exception.WebAssemblyException; +import ai.tegmentum.webassembly4j.provider.wasmos.jni.WasmosNative; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Adapter over a native wasmos component handle. Instantiation defers to + * the JNI shim, which wires {@code wasmos_runtime::add_all_to_linker} + + * {@code caps::wasi_p2} on a fresh Linker<HostState> per instance — + * matching the reference Rust flow in {@code wasmos/tests/e2e_smoke.rs}. + * + *

Supported linking-context features: + *

    + *
  • {@link LinkingContext#wasiContext()} — args, env, preopens + * (read-only + read-write), stdio inheritance are all plumbed + * through to {@code wasmtime_wasi::WasiCtxBuilder} on the Rust + * side.
  • + *
+ * + *

Rejected linking-context features: + *

    + *
  • {@link LinkingContext#hostFunctions()} + {@link + * LinkingContext#witHostFunctions()} — wasmos's philosophy is + * WIT-first; guest imports are satisfied through wasmos's own host + * surfaces (host:wasmtime, host:bootstrap, host:caps, host:grants, + * wasmos:host/*), not by Java-provided closures. Callers who want + * Java-hosted imports should use the {@code "wasmtime"} provider + * instead.
  • + *
  • {@link LinkingContext#wasiNnConfig()} — wasi:nn isn't wired into + * wasmos-runtime yet.
  • + *
+ * + *

Supported {@link ComponentConfig} features (all optional, all + * routed through wasmtime's outer-store surface): + *

    + *
  • {@link ComponentConfig#maxMemoryBytes()} — surfaced as + * {@code wasmtime::StoreLimitsBuilder::memory_size} on the outer + * store.
  • + *
  • {@link ComponentConfig#epochDeadline()} — surfaced as a + * {@code Store::set_epoch_deadline} + {@code epoch_deadline_trap} + * on the fresh store. Callers must drive the epoch counter via + * {@link WasmosNative#engineIncrementEpoch(long)} from an + * out-of-band scheduler; the provider doesn't spawn a background + * ticker.
  • + *
  • {@link ComponentConfig#fuelLimit()} — surfaced as + * {@code Store::set_fuel} on the fresh store. The engine has + * {@code Config::consume_fuel(true)} enabled globally so this is + * a cheap per-store opt-in.
  • + *
  • {@link ComponentConfig#maxTableElements()} — surfaced as + * {@code StoreLimitsBuilder::table_elements}.
  • + *
  • {@link ComponentConfig#maxInstances()} — surfaced as + * {@code StoreLimitsBuilder::instances}.
  • + *
  • {@link ComponentConfig#maxTables()} — surfaced as + * {@code StoreLimitsBuilder::tables}.
  • + *
  • {@link ComponentConfig#maxMemories()} — surfaced as + * {@code StoreLimitsBuilder::memories}.
  • + *
+ * + *

{@link ComponentConfig#trapOnGrowFailure()} is silently accepted — + * wasmtime's {@code StoreLimits} always traps on limit exceed; there's + * no separate switch to gate. + */ +final class WasmosComponentAdapter implements Component { + + private final WasmosEngineAdapter engine; + private final long handle; + private volatile boolean closed = false; + + WasmosComponentAdapter(final WasmosEngineAdapter engine, final long handle) { + this.engine = engine; + this.handle = handle; + } + + private long nativeHandle() { + if (closed) { + throw new IllegalStateException("wasmos component has been closed"); + } + return handle; + } + + @Override + public ComponentInstance instantiate() { + return instantiateInternal(null, null); + } + + @Override + public ComponentInstance instantiate(LinkingContext linkingContext) { + rejectUnsupportedLinkingContext(linkingContext); + return instantiateInternal(linkingContext, null); + } + + @Override + public ComponentInstance instantiate(ComponentConfig config) { + rejectUnsupportedComponentConfig(config); + return instantiateInternal(null, config); + } + + @Override + public ComponentInstance instantiate(LinkingContext linkingContext, ComponentConfig config) { + rejectUnsupportedLinkingContext(linkingContext); + rejectUnsupportedComponentConfig(config); + return instantiateInternal(linkingContext, config); + } + + private void rejectUnsupportedLinkingContext(LinkingContext ctx) { + if (ctx == null) return; + if (!ctx.hostFunctions().isEmpty() || !ctx.witHostFunctions().isEmpty()) { + throw new UnsupportedFeatureException( + "wasmos-provider does not accept Java-hosted host functions — " + + "wasmos's design is WIT-first, guest imports must be satisfied via " + + "wasmos's own host surfaces (host:wasmtime, wasmos:host/*). " + + "For Java-hosted imports, use the \"wasmtime\" provider instead."); + } + if (ctx.wasiNnConfig() != null) { + throw new UnsupportedFeatureException( + "wasmos-provider does not surface wasi:nn (WASI Neural Networks)"); + } + if (!ctx.externImports().isEmpty()) { + throw new UnsupportedFeatureException( + "wasmos-provider does not accept externImports — the wasmos host " + + "linker resolves imports through its own registered surfaces"); + } + } + + private void rejectUnsupportedComponentConfig(ComponentConfig config) { + if (config == null) return; + // As of the deferred-items pass every ComponentConfig field is + // plumbed through to the outer store — nothing to reject here + // beyond a sanity check that the caller didn't ask for a negative + // number (which would collide with the `-1 = unset` sentinel on + // the JNI boundary). + rejectNegative("fuelLimit", config.fuelLimit()); + rejectNegative("maxMemoryBytes", config.maxMemoryBytes()); + rejectNegative("epochDeadline", config.epochDeadline()); + rejectNegative("maxTableElements", config.maxTableElements()); + rejectNegative("maxInstances", config.maxInstances()); + rejectNegative("maxTables", config.maxTables()); + rejectNegative("maxMemories", config.maxMemories()); + // trapOnGrowFailure is implied by any positive limit — wasmtime's + // StoreLimits always traps on limit exceed; no separate switch. So we + // silently accept it. + } + + private static void rejectNegative(String name, java.util.OptionalLong opt) { + if (opt.isPresent() && opt.getAsLong() < 0) { + throw new IllegalArgumentException( + "ComponentConfig." + name + " must be >= 0; got " + opt.getAsLong() + + " (the wasmos-provider JNI uses -1 as the 'unset' sentinel)"); + } + } + + private ComponentInstance instantiateInternal(LinkingContext ctx, ComponentConfig config) { + final long inst; + try { + if (ctx == null && config == null) { + inst = WasmosNative.componentInstantiate(nativeHandle()); + } else { + inst = instantiateWithConfig(ctx, config); + } + if (inst == 0) { + throw new InstantiationException( + "wasmos componentInstantiate returned 0 handle without throwing"); + } + return new WasmosComponentInstanceAdapter(this, inst); + } catch (WebAssemblyException e) { + // Preserve upstream messages (which include the wasmos-runtime + // error) but classify as an instantiation-time failure so + // downstream error handling can distinguish it from invoke-time + // failures. + throw new InstantiationException(e.getMessage(), e); + } + } + + /** + * Marshal a WasiContext + ComponentConfig into the flat argument set the + * native {@code componentInstantiateWithConfig} shim expects, then call + * it. See {@link WasmosNative#componentInstantiateWithConfig} for the + * flag-bit and array-shape contract. + */ + private long instantiateWithConfig(LinkingContext ctx, ComponentConfig config) { + final WasiContext wasi = ctx == null ? null : ctx.wasiContext(); + int flags = 0; + String[] args = new String[0]; + String[] envKeys = new String[0]; + String[] envVals = new String[0]; + String[] preopenHosts = new String[0]; + String[] preopenGuests = new String[0]; + byte[] preopenWritable = new byte[0]; + + if (wasi != null) { + flags |= 0x08; + if (wasi.inheritStdin()) flags |= 0x01; + if (wasi.inheritStdout()) flags |= 0x02; + if (wasi.inheritStderr()) flags |= 0x04; + + final List argsList = wasi.args(); + args = argsList == null ? new String[0] : argsList.toArray(new String[0]); + + final Map envMap = wasi.env(); + if (envMap != null && !envMap.isEmpty()) { + envKeys = new String[envMap.size()]; + envVals = new String[envMap.size()]; + int i = 0; + for (Map.Entry e : envMap.entrySet()) { + envKeys[i] = e.getKey(); + envVals[i] = e.getValue(); + i++; + } + } + + final List preopens = wasi.preopenDirs(); + if (preopens != null && !preopens.isEmpty()) { + final Map guestMap = wasi.preopenGuestPaths(); + final List readOnly = wasi.readOnlyPreopenDirs(); + final int n = preopens.size(); + preopenHosts = new String[n]; + preopenGuests = new String[n]; + preopenWritable = new byte[n]; + for (int i = 0; i < n; i++) { + final String host = preopens.get(i); + preopenHosts[i] = host; + final String guest = guestMap == null ? null : guestMap.get(host); + // Convention when no remap is supplied: guest sees the + // host path unchanged. + preopenGuests[i] = guest != null ? guest : host; + preopenWritable[i] = (readOnly != null && readOnly.contains(host)) + ? (byte) 0 + : (byte) 1; + } + } + + if (wasi.wasiHttpEnabled()) { + throw new UnsupportedFeatureException( + "wasmos-provider does not surface wasi:http yet"); + } + if (wasi.allowNetwork() && !wasi.egressRules().isEmpty()) { + // wasmtime-wasi 47 sockets are all-or-nothing at the ctx + // level; we don't yet plumb per-endpoint allow-lists. + throw new UnsupportedFeatureException( + "wasmos-provider does not enforce network egress allow-lists yet"); + } + } + + return WasmosNative.componentInstantiateWithConfig( + nativeHandle(), + flags, + args, + envKeys, + envVals, + preopenHosts, + preopenGuests, + preopenWritable, + optLong(config == null ? null : config.maxMemoryBytes()), + optLong(config == null ? null : config.epochDeadline()), + optLong(config == null ? null : config.fuelLimit()), + optLong(config == null ? null : config.maxTableElements()), + optLong(config == null ? null : config.maxInstances()), + optLong(config == null ? null : config.maxTables()), + optLong(config == null ? null : config.maxMemories())); + } + + /** {@code -1} sentinel for absent OptionalLongs — matches the JNI + * convention documented on + * {@link WasmosNative#componentInstantiateWithConfig}. */ + private static long optLong(java.util.OptionalLong opt) { + return (opt != null && opt.isPresent()) ? opt.getAsLong() : -1L; + } + + @Override + public List exportedInterfaces() { + // The MVP JNI surface only lists exported functions, not the + // component's exported interfaces (which requires walking the + // component-type's nested Instance items). Return empty rather + // than pretending; callers doing introspection should probe + // exports directly against the instance. + return Collections.emptyList(); + } + + @Override + public List importedInterfaces() { + return Collections.emptyList(); + } + + @Override + public boolean exportsInterface(String name) { + return false; + } + + @Override + public boolean importsInterface(String name) { + return false; + } + + /** + * Serialize the compiled component into wasmtime's cached AOT byte + * representation. Bytes can be fed to + * {@link WasmosEngineAdapter#deserializeComponent(byte[])} on a + * compatible engine to skip re-compiling — the primary use case is + * warm-restart / hot-reload where the same component gets loaded + * repeatedly across process launches. + * + *

Byte-compat scope: tied to wasmtime version, engine + * {@code Config}, and target ISA. A mismatched engine surfaces a + * clean deserialize error rather than silently misbehaving. + */ + @Override + public byte[] serialize() { + final byte[] bytes = WasmosNative.componentSerialize(nativeHandle()); + if (bytes == null) { + // The JNI shim throws WebAssemblyException on failure; null + // here would be a contract violation on the Rust side — + // convert to something diagnosable rather than a nondescript + // NPE downstream. + throw new WebAssemblyException( + "wasmos componentSerialize returned null without throwing"); + } + return bytes; + } + + /** + * Returns the component's exported function names. Not part of the + * {@link Component} API but useful for tests / debugging — callers can + * grab it via {@code component.exportedFunctionNames()} from a cast + * reference. + */ + List exportedFunctionNames() { + final byte[] blob = WasmosNative.componentExportedFunctionNames(nativeHandle()); + if (blob == null || blob.length == 0) { + return Collections.emptyList(); + } + final String joined = new String(blob, StandardCharsets.UTF_8); + // Rust side joins on NUL (0x00); splitting on it recovers the names. + // Previous code split on space, which silently coalesced multi-name + // exports into one — invisible only when a component happens to + // export exactly one function. + final String[] parts = joined.split("\0"); + final List out = new ArrayList<>(parts.length); + for (String p : parts) { + if (!p.isEmpty()) { + out.add(p); + } + } + return out; + } + + WasmosEngineAdapter engine() { + return engine; + } + + @Override + public synchronized void close() { + if (closed) { + return; + } + closed = true; + WasmosNative.componentClose(handle); + } +} diff --git a/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosComponentInstanceAdapter.java b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosComponentInstanceAdapter.java new file mode 100644 index 0000000..8cb6094 --- /dev/null +++ b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosComponentInstanceAdapter.java @@ -0,0 +1,473 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package ai.tegmentum.webassembly4j.provider.wasmos; + +import ai.tegmentum.webassembly4j.api.ComponentInstance; +import ai.tegmentum.webassembly4j.api.Function; +import ai.tegmentum.webassembly4j.api.Global; +import ai.tegmentum.webassembly4j.api.Memory; +import ai.tegmentum.webassembly4j.api.Table; +import ai.tegmentum.webassembly4j.api.exception.ExecutionException; +import ai.tegmentum.webassembly4j.api.exception.WebAssemblyException; +import ai.tegmentum.webassembly4j.provider.wasmos.ext.WasmosAsyncExtension; +import ai.tegmentum.webassembly4j.provider.wasmos.ext.WitErrorContextException; +import ai.tegmentum.webassembly4j.provider.wasmos.jni.WasmosNative; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; + +/** + * Adapter over a native wasmos component instance handle. + * + *

Invoke path: + *

    + *
  • {@link #invoke(String, Object...)} — general path via JSON + * marshalling ({@link WasmosMarshalling}). Handles primitives (bool, + * s8/s16/s32/s64, u8/u16/u32/u64 via wrappers, f32/f64, char), + * string, list, record, tuple, option, result, variant, enum, flags, + * plus a byte[] fast path for {@code list}. Wasmtime type-checks + * against the exported function's WIT signature on the Rust side.
  • + *
  • {@link #invokeBytes(String, Object...)} — byte[] fast path for a + * single {@code list} (or {@code string}) return. Skips the + * JSON detour on the return path.
  • + *
  • {@link #invokeWit(String, Object...)} — currently identical to + * {@link #invoke(String, Object...)}. A future WIT-value overload + * would branch here.
  • + *
+ * + *

The old nullary-i32 fast path is preserved internally: any + * {@code invoke(name)} whose exported function has arity {@code 0:1} and + * whose result parses cleanly as an {@code S32} takes it, saving the JSON + * round-trip on the demo path. + */ +final class WasmosComponentInstanceAdapter implements ComponentInstance { + + private final WasmosComponentAdapter component; + private final long handle; + private volatile boolean closed = false; + + WasmosComponentInstanceAdapter(final WasmosComponentAdapter component, final long handle) { + this.component = component; + this.handle = handle; + } + + private long nativeHandle() { + if (closed) { + throw new IllegalStateException("wasmos component instance has been closed"); + } + return handle; + } + + @Override + public Object invoke(String functionName, Object... args) { + if (functionName == null || functionName.isEmpty()) { + throw new IllegalArgumentException("functionName must be non-empty"); + } + try { + // Fast path: preserve the pre-marshalling `run() -> i32` shape for + // the demo test. Only applies when caller passes no args AND the + // exported function's arity is exactly (0 args, 1 result). + if (args == null || args.length == 0) { + final ArityInfo arity = readArity(functionName); + if (arity != null && arity.args == 0 && arity.results == 1) { + try { + return WasmosNative.instanceInvokeReturningI32(nativeHandle(), functionName); + } catch (WebAssemblyException wex) { + // Signature mismatch (not i32-returning) — fall back + // to the general JSON path. Don't leak the fast-path + // failure to the caller: it's an implementation detail. + } + } + } + final String argsJson = WasmosMarshalling.marshalArgs(args); + final String resultJson = WasmosNative.instanceInvokeJson( + nativeHandle(), functionName, argsJson); + final List results = WasmosMarshalling.unmarshalResults(resultJson); + return unwrapResults(results); + } catch (WebAssemblyException e) { + throw new ExecutionException( + "wasmos invoke failed for '" + functionName + "': " + e.getMessage(), e); + } + } + + @Override + public byte[] invokeBytes(String functionName, Object... args) { + if (functionName == null || functionName.isEmpty()) { + throw new IllegalArgumentException("functionName must be non-empty"); + } + try { + final String argsJson = WasmosMarshalling.marshalArgs(args); + return WasmosNative.instanceInvokeJsonReturningBytes( + nativeHandle(), functionName, argsJson); + } catch (WebAssemblyException e) { + throw new ExecutionException( + "wasmos invokeBytes failed for '" + functionName + "': " + e.getMessage(), e); + } + } + + // invokeWit currently mirrors invoke. Kept as an override rather than + // inheriting the default so future WIT-shape returns (records / variants + // with typed carriers) can diverge here without touching invoke's + // simpler natural-shape contract. + @Override + public Object invokeWit(String functionName, Object... args) { + return invoke(functionName, args); + } + + /** + * Result flattener — the shape callers expect from {@link ComponentInstance#invoke} + * for the return arity varies: + *
    + *
  • arity 0 → null
  • + *
  • arity 1 → the sole value directly
  • + *
  • arity 2+ → a {@link List} of values
  • + *
+ */ + private static Object unwrapResults(List results) { + if (results.isEmpty()) return null; + if (results.size() == 1) return results.get(0); + return results; + } + + /** Package-private for tests + `invoke` fast-path arity probe. */ + ArityInfo readArity(String name) { + try { + final byte[] blob = WasmosNative.instanceFunctionArity(nativeHandle(), name); + if (blob == null) return null; + final String s = new String(blob, StandardCharsets.UTF_8); + final int idx = s.indexOf(':'); + if (idx < 0) return null; + return new ArityInfo( + Integer.parseInt(s.substring(0, idx)), + Integer.parseInt(s.substring(idx + 1))); + } catch (Throwable t) { + return null; + } + } + + static final class ArityInfo { + final int args; + final int results; + + ArityInfo(int args, int results) { + this.args = args; + this.results = results; + } + } + + @Override + public boolean hasFunction(String name) { + return WasmosNative.instanceHasFunction(nativeHandle(), name); + } + + @Override + public List exportedFunctions() { + return component.exportedFunctionNames(); + } + + @Override + public List exportedInterfaces() { + return component.exportedInterfaces(); + } + + @Override + public boolean exportsInterface(String name) { + return component.exportsInterface(name); + } + + // Component instances don't expose core module exports; empty is the + // documented convention (see webassembly4j api ComponentInstance javadoc). + + @Override + public Optional function(String name) { return Optional.empty(); } + + @Override + public Optional memory(String name) { return Optional.empty(); } + + @Override + public Optional

table(String name) { return Optional.empty(); } + + @Override + public Optional global(String name) { return Optional.empty(); } + + @Override + public Optional unwrap(Class nativeType) { + // Bare-long handle only; no Java-object native representation to expose. + return Optional.empty(); + } + + /** + * Provider-specific extension surface. Currently exposes + * {@link WasmosAsyncExtension} for the slice-1 async invoke path; + * later slices will add stream / error-context extensions here. + */ + @Override + @SuppressWarnings("unchecked") + public Optional extension(Class extensionType) { + if (extensionType == WasmosAsyncExtension.class) { + return Optional.of((T) new AsyncExtensionImpl()); + } + return Optional.empty(); + } + + // ---- Async invoke ------------------------------------------------------ + + /** + * Package-private async invoke entry — schedules the blocking JNI + * {@link #invoke} on the engine's shared {@code wasmos-provider-async} + * pool and returns a cancellation-aware {@link CompletableFuture}. + * Exposed publicly through {@link WasmosAsyncExtension}. + */ + CompletableFuture invokeAsync(String functionName, Object... args) { + if (functionName == null || functionName.isEmpty()) { + throw new IllegalArgumentException("functionName must be non-empty"); + } + final ExecutorService pool = component.engine().asyncExecutor(); + final CancelAwareFuture cf = new CancelAwareFuture<>(component.engine()); + pool.execute(() -> { + if (cf.isCancelled()) return; // dropped before the worker picked it up + try { + cf.complete(invoke(functionName, args)); + } catch (Throwable t) { + cf.completeExceptionally(t); + } + }); + return cf; + } + + /** Byte-array-returning counterpart, mirroring {@link #invokeBytes}. */ + CompletableFuture invokeBytesAsync(String functionName, Object... args) { + if (functionName == null || functionName.isEmpty()) { + throw new IllegalArgumentException("functionName must be non-empty"); + } + final ExecutorService pool = component.engine().asyncExecutor(); + final CancelAwareFuture cf = new CancelAwareFuture<>(component.engine()); + pool.execute(() -> { + if (cf.isCancelled()) return; + try { + cf.complete(invokeBytes(functionName, args)); + } catch (Throwable t) { + cf.completeExceptionally(t); + } + }); + return cf; + } + + /** + * CompletableFuture subclass that fires a best-effort + * {@code engineIncrementEpoch} nudge on cancellation. See the + * {@link WasmosAsyncExtension} class-level javadoc for the exact + * semantics — the nudge only reliably traps guests instantiated with + * an epoch deadline; guests without a deadline finish their WASM call + * regardless. The {@link CompletableFuture} promise is always marked + * cancelled so downstream chains stop propagating. + */ + private static final class CancelAwareFuture extends CompletableFuture { + private final WasmosEngineAdapter engine; + + CancelAwareFuture(WasmosEngineAdapter engine) { + this.engine = engine; + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + final boolean wasCancelled = super.cancel(mayInterruptIfRunning); + if (wasCancelled && mayInterruptIfRunning) { + // Best-effort nudge — safe on a shared engine (only bumps a + // counter, doesn't touch store state). Wrapped in try/catch + // so a failure here (e.g. engine closed racing with cancel) + // never leaks past the Future contract. + try { + WasmosNative.engineIncrementEpoch(engine.nativeHandle()); + } catch (Throwable ignored) { + // Swallow — cancellation is best-effort by contract. + } + } + return wasCancelled; + } + } + + /** + * Trivial delegate to the enclosing adapter — separate class so the + * public {@link WasmosAsyncExtension} interface doesn't require the + * adapter itself to be public. + */ + private final class AsyncExtensionImpl implements WasmosAsyncExtension { + @Override + public CompletableFuture invokeAsync(String functionName, Object... args) { + return WasmosComponentInstanceAdapter.this.invokeAsync(functionName, args); + } + + @Override + public CompletableFuture invokeBytesAsync(String functionName, Object... args) { + return WasmosComponentInstanceAdapter.this.invokeBytesAsync(functionName, args); + } + + @Override + public CompletableFuture awaitFuture(Object witFuture) { + // Route through the async pool for shape parity with the other + // await-flavoured entry points — callers get a CompletableFuture + // that they can chain regardless of underlying blocking semantics. + // The JNI call is expected to throw (wasmtime API gap); we + // surface that as an exceptional completion rather than a + // synchronous throw so `thenApply` / `handle` chains behave + // uniformly and the failure isn't observed off the calling + // thread's stack. + final CompletableFuture cf = new CompletableFuture<>(); + if (!(witFuture instanceof WasmosMarshalling.WitFuture)) { + cf.completeExceptionally(new IllegalArgumentException( + "awaitFuture expects a WitFuture; got " + + (witFuture == null ? "null" : witFuture.getClass().getName()))); + return cf; + } + final WasmosMarshalling.WitFuture wf = (WasmosMarshalling.WitFuture) witFuture; + component.engine().asyncExecutor().execute(() -> { + try { + final String json = WasmosNative.futureAwait(nativeHandle(), wf.tableId()); + // If wasmtime ever grows a dynamic await, the JNI method + // returns the JSON blob for the resolved value; wrap it + // through the same unmarshaller as invoke() so callers + // get natural Java types. + final List results = WasmosMarshalling.unmarshalResults(json); + cf.complete(unwrapResults(results)); + } catch (Throwable t) { + cf.completeExceptionally(new ExecutionException( + "wasmos awaitFuture failed for tableId=" + wf.tableId() + + ": " + t.getMessage(), t)); + } + }); + return cf; + } + + @Override + public void closeFuture(Object witFuture) { + if (!(witFuture instanceof WasmosMarshalling.WitFuture)) { + throw new IllegalArgumentException( + "closeFuture expects a WitFuture; got " + + (witFuture == null ? "null" : witFuture.getClass().getName())); + } + final WasmosMarshalling.WitFuture wf = (WasmosMarshalling.WitFuture) witFuture; + try { + WasmosNative.futureClose(nativeHandle(), wf.tableId()); + } catch (WebAssemblyException e) { + throw new ExecutionException( + "wasmos closeFuture failed for tableId=" + wf.tableId() + + ": " + e.getMessage(), e); + } + } + + @Override + public CompletableFuture readStream(Object witStream) { + // Same shape as awaitFuture — route through the async pool so + // callers see uniform CompletableFuture semantics. The JNI is + // expected to throw (wasmtime API gap) and we surface that as + // an exceptional completion. + final CompletableFuture cf = new CompletableFuture<>(); + if (!(witStream instanceof WasmosMarshalling.WitStream)) { + cf.completeExceptionally(new IllegalArgumentException( + "readStream expects a WitStream; got " + + (witStream == null ? "null" : witStream.getClass().getName()))); + return cf; + } + final WasmosMarshalling.WitStream ws = (WasmosMarshalling.WitStream) witStream; + component.engine().asyncExecutor().execute(() -> { + try { + final String json = WasmosNative.streamRead(nativeHandle(), ws.tableId()); + // Future-proofing: when wasmtime exposes a dynamic read + // surface, the JNI is expected to hand back the + // resolved value(s) as a JSON blob using the same + // marshalling schema as invokeAsync's results. Callers + // get the natural Java-typed value(s). + final List results = WasmosMarshalling.unmarshalResults(json); + cf.complete(unwrapResults(results)); + } catch (Throwable t) { + cf.completeExceptionally(new ExecutionException( + "wasmos readStream failed for tableId=" + ws.tableId() + + ": " + t.getMessage(), t)); + } + }); + return cf; + } + + @Override + public void closeStream(Object witStream) { + if (!(witStream instanceof WasmosMarshalling.WitStream)) { + throw new IllegalArgumentException( + "closeStream expects a WitStream; got " + + (witStream == null ? "null" : witStream.getClass().getName())); + } + final WasmosMarshalling.WitStream ws = (WasmosMarshalling.WitStream) witStream; + try { + WasmosNative.streamClose(nativeHandle(), ws.tableId()); + } catch (WebAssemblyException e) { + throw new ExecutionException( + "wasmos closeStream failed for tableId=" + ws.tableId() + + ": " + e.getMessage(), e); + } + } + + @Override + public void closeErrorContext(Object witErrorContext) { + if (!(witErrorContext instanceof WasmosMarshalling.WitErrorContext)) { + throw new IllegalArgumentException( + "closeErrorContext expects a WitErrorContext; got " + + (witErrorContext == null ? "null" + : witErrorContext.getClass().getName())); + } + final WasmosMarshalling.WitErrorContext we = + (WasmosMarshalling.WitErrorContext) witErrorContext; + try { + WasmosNative.errorContextClose(nativeHandle(), we.tableId()); + } catch (WebAssemblyException e) { + throw new ExecutionException( + "wasmos closeErrorContext failed for tableId=" + we.tableId() + + ": " + e.getMessage(), e); + } + } + + @Override + public WitErrorContextException wrapErrorContext(Object witErrorContext) { + if (!(witErrorContext instanceof WasmosMarshalling.WitErrorContext)) { + throw new IllegalArgumentException( + "wrapErrorContext expects a WitErrorContext; got " + + (witErrorContext == null ? "null" + : witErrorContext.getClass().getName())); + } + final WasmosMarshalling.WitErrorContext we = + (WasmosMarshalling.WitErrorContext) witErrorContext; + // Message includes tableId + rep so the exception's message alone + // is diagnosable without pulling the carrier out via + // errorContext() — matches the "cheap first look" ergonomics of + // ExecutionException elsewhere in the provider. + return new WitErrorContextException( + "wasmos guest returned error-context (tableId=" + we.tableId() + + ", rep=" + we.rep() + ')', + we); + } + } + + /** + * Instances are dropped implicitly with the component (the wasmos + * runtime keeps them together). The API doesn't declare + * {@code close()} here, so we free the native handle via a + * package-private cleanup hook driven from + * {@link WasmosComponentAdapter#close()}. + */ + synchronized void closeInternal() { + if (closed) { + return; + } + closed = true; + WasmosNative.instanceClose(handle); + } +} diff --git a/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosEngineAdapter.java b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosEngineAdapter.java new file mode 100644 index 0000000..25a7ab3 --- /dev/null +++ b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosEngineAdapter.java @@ -0,0 +1,201 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package ai.tegmentum.webassembly4j.provider.wasmos; + +import ai.tegmentum.webassembly4j.api.Component; +import ai.tegmentum.webassembly4j.api.Engine; +import ai.tegmentum.webassembly4j.api.EngineCapabilities; +import ai.tegmentum.webassembly4j.api.EngineInfo; +import ai.tegmentum.webassembly4j.api.Module; +import ai.tegmentum.webassembly4j.api.exception.UnsupportedFeatureException; +import ai.tegmentum.webassembly4j.api.exception.WebAssemblyException; +import ai.tegmentum.webassembly4j.provider.wasmos.jni.WasmosNative; + +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * {@link Engine} adapter — thin Java-side owner of a native wasmos engine + * handle. Lifecycle model: + * + *
    + *
  • {@link #create()} allocates the handle (which itself allocates a + * wasmtime {@code Engine} + a persistent Tokio runtime).
  • + *
  • {@link #loadComponent(byte[])} produces a + * {@link WasmosComponentAdapter} sharing this engine.
  • + *
  • {@link #close()} releases the native handle. Any components / + * instances still alive at that point produce undefined behaviour on + * subsequent use — mirroring wasmtime4j-provider's contract.
  • + *
+ * + *

Core-module loading is intentionally unsupported: wasmos-runtime is a + * component-model host. Callers that just want core wasm should pick the + * {@code "wasmtime"} provider instead. + */ +final class WasmosEngineAdapter implements Engine { + + private final long handle; + private volatile boolean closed = false; + /** + * Lazily-created worker pool for + * {@link ai.tegmentum.webassembly4j.provider.wasmos.ext.WasmosAsyncExtension#invokeAsync}. + * Null until first use; shut down on {@link #close()}. See + * {@link WasmosAsyncExecutors} for sizing rationale. + */ + private volatile ThreadPoolExecutor asyncPool; + + private WasmosEngineAdapter(final long handle) { + this.handle = handle; + } + + static WasmosEngineAdapter create() { + final long h = WasmosNative.engineCreate(); + if (h == 0) { + throw new WebAssemblyException( + "wasmos engineCreate returned 0 handle without throwing"); + } + return new WasmosEngineAdapter(h); + } + + long nativeHandle() { + if (closed) { + throw new IllegalStateException("wasmos engine has been closed"); + } + return handle; + } + + @Override + public EngineInfo info() { + return new WasmosEngineInfo(); + } + + @Override + public EngineCapabilities capabilities() { + return new WasmosEngineCapabilities(); + } + + /** + * Core modules are intentionally unsupported — wasmos-runtime exposes + * only the component-model surface. Callers that need core-wasm + * hosting should select the {@code "wasmtime"} provider instead. + */ + @Override + public Module loadModule(byte[] bytes) { + throw new UnsupportedFeatureException( + "wasmos-provider does not host core wasm modules; " + + "use the \"wasmtime\" provider for core-module hosting"); + } + + @Override + public Component loadComponent(byte[] bytes) { + if (bytes == null || bytes.length == 0) { + throw new IllegalArgumentException("component bytes must be non-empty"); + } + final long compHandle = WasmosNative.componentLoad(nativeHandle(), bytes); + if (compHandle == 0) { + throw new WebAssemblyException( + "wasmos componentLoad returned 0 handle without throwing"); + } + return new WasmosComponentAdapter(this, compHandle); + } + + /** + * Deserialize a component from bytes previously produced by + * {@link WasmosComponentAdapter#serialize()} on a compatible engine. + * Skips the compile step — useful for warm-restart / hot-reload + * scenarios. + * + *

Trust boundary: the underlying wasmtime + * {@code Component::deserialize} is {@code unsafe} — it assumes + * the bytes are a well-formed AOT image from a compatible engine. + * Passing arbitrary or tampered bytes is undefined behaviour on + * the Rust side. Callers must trust the byte provenance. + */ + Component deserializeComponent(byte[] bytes) { + if (bytes == null || bytes.length == 0) { + throw new IllegalArgumentException("serialized bytes must be non-empty"); + } + final long compHandle = WasmosNative.componentDeserialize(nativeHandle(), bytes); + if (compHandle == 0) { + throw new WebAssemblyException( + "wasmos componentDeserialize returned 0 handle without throwing"); + } + return new WasmosComponentAdapter(this, compHandle); + } + + @Override + public Optional extension(Class extensionType) { + return Optional.empty(); + } + + @Override + public Optional unwrap(Class nativeType) { + // The native handle is a plain long; there's no Java-side object + // to hand out. A future refactor could expose HostState / caps + // configuration through this hook. + return Optional.empty(); + } + + /** + * Package-private accessor for the shared async pool. Instantiated on + * first call (double-checked locking) so engines that never dispatch + * async invokes pay no thread-pool overhead. Returns the underlying + * {@link ExecutorService} — callers should NOT shut it down; the engine + * owns its lifecycle and stops it in {@link #close()}. + */ + ExecutorService asyncExecutor() { + ThreadPoolExecutor local = asyncPool; + if (local == null) { + synchronized (this) { + local = asyncPool; + if (local == null) { + if (closed) { + throw new IllegalStateException( + "wasmos engine has been closed; cannot start async executor"); + } + local = WasmosAsyncExecutors.newEnginePool(); + asyncPool = local; + } + } + } + return local; + } + + @Override + public synchronized void close() { + if (closed) { + return; + } + closed = true; + // Stop accepting new async work first. Existing tasks finish on + // their worker threads — we don't interrupt because a blocking JNI + // invoke ignores Thread.interrupt() and interrupting would just + // leave stale interrupt state on the worker. + final ThreadPoolExecutor local = asyncPool; + if (local != null) { + asyncPool = null; + local.shutdown(); + try { + // Bounded wait so close() doesn't hang on a runaway invoke; + // 2s is enough for well-behaved workloads to drain and short + // enough that a hung guest doesn't block engine teardown. + if (!local.awaitTermination(2, TimeUnit.SECONDS)) { + local.shutdownNow(); + } + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + local.shutdownNow(); + } + } + WasmosNative.engineClose(handle); + } +} diff --git a/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosEngineCapabilities.java b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosEngineCapabilities.java new file mode 100644 index 0000000..938e815 --- /dev/null +++ b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosEngineCapabilities.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package ai.tegmentum.webassembly4j.provider.wasmos; + +import ai.tegmentum.webassembly4j.api.EngineCapabilities; + +/** + * Capability matrix for wasmos. Reflects the MVP surface — component-model + * hosting + WASI preview 2 are on; core-module hosting, GC, fuel, epoch + * interruption, threads are off (either because wasmos-runtime doesn't + * expose them yet or because the wasmos-provider MVP hasn't wired them + * through). A future pass can flip these once we plumb configuration + * through the JNI shim. + */ +final class WasmosEngineCapabilities implements EngineCapabilities { + + @Override public boolean supportsCoreModules() { return false; } + @Override public boolean supportsComponents() { return true; } + @Override public boolean supportsWasi() { return true; } + @Override public boolean supportsFuel() { return false; } + @Override public boolean supportsEpochInterruption(){ return false; } + @Override public boolean supportsThreads() { return false; } + @Override public boolean supportsGc() { return false; } + @Override public boolean supportsReferenceTypes() { return false; } + @Override public boolean supportsMultiMemory() { return false; } + @Override public boolean supportsNativeInterop() { return true; } + @Override public boolean supportsWasiHttp() { return false; } + + /** wasmos-runtime uses async wasmtime under the hood — every instance + * goes through {@code instantiate_async} + {@code call_async} on the + * provider's persistent Tokio runtime. */ + @Override public boolean supportsAsyncComponents() { return true; } +} diff --git a/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosEngineInfo.java b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosEngineInfo.java new file mode 100644 index 0000000..d2e2dc4 --- /dev/null +++ b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosEngineInfo.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package ai.tegmentum.webassembly4j.provider.wasmos; + +import ai.tegmentum.webassembly4j.api.EngineInfo; + +/** + * Static {@link EngineInfo} for the wasmos provider. The engine version + * tracks wasmos-runtime, not wasmtime — a wasmos-runtime version bump + * reshuffles what's on the linker, whereas the underlying wasmtime bump + * is a transitive detail. + */ +final class WasmosEngineInfo implements EngineInfo { + + @Override + public String engineId() { + return "wasmos"; + } + + @Override + public String providerId() { + return "wasmos"; + } + + @Override + public String providerVersion() { + return "1.0.0-SNAPSHOT"; + } + + @Override + public String engineVersion() { + // wasmos-runtime = 0.1.0 as of the initial provider scaffold; when we + // move to a versioned crate release we should read this from a + // manifest header instead of hardcoding. + return "0.1.0"; + } + + @Override + public int minimumJavaVersion() { + return 11; + } +} diff --git a/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosMarshalling.java b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosMarshalling.java new file mode 100644 index 0000000..229ca3e --- /dev/null +++ b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosMarshalling.java @@ -0,0 +1,1408 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package ai.tegmentum.webassembly4j.provider.wasmos; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Cheap-and-cheerful JSON marshalling between Java-natural objects and the + * on-the-wire {@code JsonVal} schema that + * {@code wasmos-provider/native/src/lib.rs} defines. + * + *

Design principles: + *

    + *
  • No third-party JSON dep — vanilla JVM, hand-rolled writer/reader. + * Kept minimal because the schema is closed (a fixed set of + * {@code JsonVal} variants).
  • + *
  • Symmetric with the Rust {@code JsonVal} enum. Tag key is {@code "t"}, + * payload key is {@code "v"} — matches the {@code #[serde(tag = "t", + * content = "v")]} annotation.
  • + *
  • Fast path for {@code byte[]}: encoded as {@code {"t":"Bytes", + * "v":"...base64..."}}; the Rust side decodes to {@code Val::List} + * and detects the same pattern on the return path to hand it back as + * {@code Bytes} instead of an O(n) list of tagged u8s.
  • + *
+ * + *

Java type mapping — mirrors the natural-shape convention documented on + * {@code ComponentInstance.invoke} for symmetry with other providers: + *

+ * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
BooleanBool
ByteS8
ShortS16
IntegerS32
LongS64
FloatF32
DoubleF64
CharacterChar
StringString
byte[]Bytes(base64)
List<?>List
Optional<?>/nullOption
Map<String,?>Record (field iteration order matters)
{@link WitMap}Map (arbitrary key types)
Set<String>Flags
{@link WitResult}Result
{@link WitVariant}Variant
{@link WitEnum}Enum
{@link WitResource}Resource (opaque host-parked handle)
{@link WitFuture}Future (opaque host-parked handle; awaiting is a wasmtime API gap)
{@link WitStream}Stream (opaque host-parked handle; reading is a wasmtime API gap)
{@link WitErrorContext}ErrorContext (opaque host-parked handle; rep is a wasmtime numeric hint)
+ * + *

Unsigned integers: the WIT spec has u8/u16/u32/u64 but Java doesn't; + * callers who need those specific WIT widths must use {@link WitU8} / {@link + * WitU16} / {@link WitU32} / {@link WitU64} wrappers (rare enough that we + * don't try to guess). Plain Byte/Short/Integer/Long default to signed + * widths — that's what the widest range of Java code produces naturally. + */ +final class WasmosMarshalling { + + private WasmosMarshalling() {} + + // Tag names — must match the Rust JsonVal enum exactly (serde tag = "t"). + private static final String TAG = "t"; + private static final String VAL = "v"; + + // ---- Explicit-typed wrappers for unsigned / typed-result shapes ------- + + /** Wrap a Java int as a WIT u8. Overflow at u8 boundary throws. */ + static final class WitU8 { + final short value; + + WitU8(int v) { + if (v < 0 || v > 0xFF) { + throw new IllegalArgumentException("u8 out of range: " + v); + } + this.value = (short) v; + } + } + + static final class WitU16 { + final int value; + + WitU16(int v) { + if (v < 0 || v > 0xFFFF) { + throw new IllegalArgumentException("u16 out of range: " + v); + } + this.value = v; + } + } + + static final class WitU32 { + final long value; + + WitU32(long v) { + if (v < 0 || v > 0xFFFFFFFFL) { + throw new IllegalArgumentException("u32 out of range: " + v); + } + this.value = v; + } + } + + /** u64 via {@link java.math.BigInteger}-free Long — bit-pattern is + * reinterpreted on the wire. */ + static final class WitU64 { + final long bits; + + WitU64(long bits) { this.bits = bits; } + } + + /** WIT result carrier. Exactly one of {@link #ok} / {@link #err} is + * meaningful, selected by {@link #isOk}. */ + static final class WitResult { + final boolean isOk; + final Object ok; + final Object err; + + private WitResult(boolean isOk, Object ok, Object err) { + this.isOk = isOk; + this.ok = ok; + this.err = err; + } + + static WitResult ok(Object v) { return new WitResult(true, v, null); } + + static WitResult err(Object v) { return new WitResult(false, null, v); } + } + + /** WIT variant carrier — a named discriminant plus an optional payload. */ + static final class WitVariant { + final String discriminant; + final Object value; + + WitVariant(String discriminant, Object value) { + this.discriminant = discriminant; + this.value = value; + } + } + + /** WIT enum carrier — a named discriminant, no payload. Distinguishable + * from a plain String on the marshalling path. */ + static final class WitEnum { + final String discriminant; + + WitEnum(String discriminant) { this.discriminant = discriminant; } + } + + /** + * WIT component-model resource handle. The actual guest resource lives + * Rust-side in the wasmos-provider native crate's per-instance + * {@code ResourceRegistry}; a {@code WitResource} is just a lightweight + * carrier for the slot id + opaque type identity + ownership flag that + * lets Java-side callers pass the handle back into another invoke. + * + *

Fields: + *

    + *
  • {@code tableId} — the Rust-side slot number. Monotonically + * increasing per instance; never reused, so a stale WitResource + * (e.g. after an own-transfer) surfaces as a clean error on + * reuse rather than an aliased hit.
  • + *
  • {@code typeIdentifier} — the wasmtime {@code ResourceType}'s + * {@code Debug} rendering. Opaque to Java; useful only for + * equality checks between two WitResources.
  • + *
  • {@code owned} — on values RECEIVED from a guest return, this + * reflects the ResourceAny's own ownership status. On values + * PASSED IN to a call, this is the caller's intent: {@code true} + * transfers ownership (matches WIT {@code own}) and consumes + * the WitResource; {@code false} lends a borrow view (matches + * WIT {@code borrow}) and leaves the WitResource reusable. + * The caller is responsible for setting this to match the + * function's WIT signature.
  • + *
+ * + *

Immutable — the fields are final and there are no setters. Same + * {@code WitResource} can be threaded through multiple borrow-shaped + * calls before a final own-shaped call consumes it. + */ + static final class WitResource { + private final long tableId; + private final String typeIdentifier; + private final boolean owned; + + WitResource(long tableId, String typeIdentifier, boolean owned) { + this.tableId = tableId; + this.typeIdentifier = typeIdentifier == null ? "" : typeIdentifier; + this.owned = owned; + } + + public long tableId() { return tableId; } + public String typeIdentifier() { return typeIdentifier; } + public boolean owned() { return owned; } + + @Override + public boolean equals(Object o) { + if (!(o instanceof WitResource)) return false; + final WitResource other = (WitResource) o; + return tableId == other.tableId + && owned == other.owned + && typeIdentifier.equals(other.typeIdentifier); + } + + @Override + public int hashCode() { + int h = Long.hashCode(tableId); + h = 31 * h + typeIdentifier.hashCode(); + h = 31 * h + Boolean.hashCode(owned); + return h; + } + + @Override + public String toString() { + return "WitResource{tableId=" + tableId + + ", typeIdentifier='" + typeIdentifier + + "', owned=" + owned + '}'; + } + } + + /** + * WIT component-model {@code future} handle. Same lightweight-carrier + * shape as {@link WitResource}: the {@code FutureAny} lives Rust-side in + * the wasmos-provider native crate's per-instance {@code FutureRegistry}, + * and Java only ever sees a stable slot id + an opaque Debug rendering + * of the {@code FutureAny} for equality-adjacent introspection. + * + *

Unlike {@link WitResource}, {@code WitFuture} has no {@code owned} + * bit. Wasmtime 47's {@code FutureAny} is already the "read end" of a + * split future, and there is no borrow-shaped pass-in variant in the + * component model — a future handle flows through a guest import + * exactly once. The Rust-side decode arm always consumes (takes) the + * parked entry. + * + *

Awaiting is currently unsupported. Wasmtime 47's public + * {@code FutureAny} API only exposes + * {@code try_into_future_reader::()} (compile-time typed) and + * {@code close(store)}. There is no dynamic-payload-type await surface, + * which means our JSON-based marshalling can't lift the resolved value + * to a {@link Object}. See + * {@link ai.tegmentum.webassembly4j.provider.wasmos.ext.WasmosAsyncExtension#awaitFuture} + * for how the extension exposes this gap (returned + * {@link java.util.concurrent.CompletableFuture} completes exceptionally + * with a clear message). A parked {@code WitFuture} can still be: + *

    + *
  • passed back into a guest import that consumes it (the pass-in + * path IS wired end-to-end and simply hands the parked + * {@code FutureAny} back to wasmtime);
  • + *
  • explicitly disposed of via + * {@link ai.tegmentum.webassembly4j.provider.wasmos.ext.WasmosAsyncExtension#closeFuture}.
  • + *
+ * + *

Immutable — fields are final and there are no setters. + */ + static final class WitFuture { + private final long tableId; + private final String typeIdentifier; + + WitFuture(long tableId, String typeIdentifier) { + this.tableId = tableId; + this.typeIdentifier = typeIdentifier == null ? "" : typeIdentifier; + } + + public long tableId() { return tableId; } + public String typeIdentifier() { return typeIdentifier; } + + @Override + public boolean equals(Object o) { + if (!(o instanceof WitFuture)) return false; + final WitFuture other = (WitFuture) o; + return tableId == other.tableId + && typeIdentifier.equals(other.typeIdentifier); + } + + @Override + public int hashCode() { + int h = Long.hashCode(tableId); + h = 31 * h + typeIdentifier.hashCode(); + return h; + } + + @Override + public String toString() { + return "WitFuture{tableId=" + tableId + + ", typeIdentifier='" + typeIdentifier + "'}"; + } + } + + /** + * WIT component-model {@code stream} handle. Structural mirror of + * {@link WitFuture} — the {@code StreamAny} lives Rust-side in the + * wasmos-provider native crate's per-instance {@code StreamRegistry}, + * and Java only ever sees a stable slot id + an opaque Debug rendering + * of the {@code StreamAny} for equality-adjacent introspection. + * + *

Same lifecycle as {@link WitFuture}: no {@code owned} bit + * (streams have a read-vs-write split at the type level in wasmtime), + * pass-in consumes on the Rust decode path, and explicit disposal is + * via + * {@link ai.tegmentum.webassembly4j.provider.wasmos.ext.WasmosAsyncExtension#closeStream}. + * + *

Reading is currently unsupported. Wasmtime 47's public + * {@code StreamAny} API only exposes + * {@code try_into_stream_reader::()} (compile-time typed) and + * {@code close(store)}. There is no dynamic-payload-type read/poll + * surface, which means our JSON-based marshalling can't lift stream + * items to Java objects. See + * {@link ai.tegmentum.webassembly4j.provider.wasmos.ext.WasmosAsyncExtension#readStream} + * for how the extension exposes this gap (returned + * {@link java.util.concurrent.CompletableFuture} completes exceptionally + * with a clear message). + * + *

Immutable — fields are final and there are no setters. + */ + static final class WitStream { + private final long tableId; + private final String typeIdentifier; + + WitStream(long tableId, String typeIdentifier) { + this.tableId = tableId; + this.typeIdentifier = typeIdentifier == null ? "" : typeIdentifier; + } + + public long tableId() { return tableId; } + public String typeIdentifier() { return typeIdentifier; } + + @Override + public boolean equals(Object o) { + if (!(o instanceof WitStream)) return false; + final WitStream other = (WitStream) o; + return tableId == other.tableId + && typeIdentifier.equals(other.typeIdentifier); + } + + @Override + public int hashCode() { + int h = Long.hashCode(tableId); + h = 31 * h + typeIdentifier.hashCode(); + return h; + } + + @Override + public String toString() { + return "WitStream{tableId=" + tableId + + ", typeIdentifier='" + typeIdentifier + "'}"; + } + } + + /** + * WIT component-model {@code error-context} handle. The underlying + * wasmtime 47 {@code ErrorContextAny} is a placeholder type (see + * wasmtime's {@code FIXME(#11161)}) that carries only an opaque + * {@code pub(crate) u32} rep — no debug message, no backtrace, no + * dispose surface. The Rust side still parks it in a per-instance + * registry so a Java caller can pass it back into a guest import + * that accepts an {@code error-context} argument; Java-visible + * disposal is a pure Rust-side registry eviction via + * {@link ai.tegmentum.webassembly4j.provider.wasmos.ext.WasmosAsyncExtension#closeErrorContext}. + * + *

Fields: + *

    + *
  • {@code tableId} — Rust-side slot number, monotonically + * increasing, never reused. Used to look up the parked handle + * on pass-in and to target close.
  • + *
  • {@code rep} — parsed out of {@code ErrorContextAny}'s Debug + * rendering. Numeric identity hint only; not meaningful beyond + * equality-adjacent introspection.
  • + *
+ * + *

Immutable. Structural equality; parking multiple WitErrorContexts + * in a Set is safe. + */ + static final class WitErrorContext { + private final long tableId; + private final long rep; + + WitErrorContext(long tableId, long rep) { + this.tableId = tableId; + this.rep = rep; + } + + public long tableId() { return tableId; } + public long rep() { return rep; } + + @Override + public boolean equals(Object o) { + if (!(o instanceof WitErrorContext)) return false; + final WitErrorContext other = (WitErrorContext) o; + return tableId == other.tableId && rep == other.rep; + } + + @Override + public int hashCode() { + return 31 * Long.hashCode(tableId) + Long.hashCode(rep); + } + + @Override + public String toString() { + return "WitErrorContext{tableId=" + tableId + ", rep=" + rep + '}'; + } + } + + /** + * WIT map carrier. Distinguishes {@code map} from + * {@code record { ... }} at the marshalling boundary: a plain + * {@code Map} routes to WIT record (field-name lookup); + * a {@code WitMap} routes to WIT map (arbitrary key type). + * + *

Keys and values are marshalled by the same rules as any other + * WitVal — nesting other typed carriers is supported. Iteration + * order is preserved on the wire; the underlying map is treated as + * a {@link LinkedHashMap} even if the caller hands in another + * implementation. + */ + static final class WitMap { + final Map pairs; + + WitMap(Map pairs) { + // Copy into a fresh LinkedHashMap so downstream mutations to + // the caller's map don't leak into the on-wire form. + this.pairs = new LinkedHashMap<>(); + for (Map.Entry e : pairs.entrySet()) { + this.pairs.put((Object) e.getKey(), (Object) e.getValue()); + } + } + } + + // ---- Public API -------------------------------------------------------- + + /** + * Marshal a Java args array to the JSON-array wire form. The empty-args + * case ({@code args == null || args.length == 0}) returns the literal + * {@code "[]"} without any allocation. + */ + static String marshalArgs(Object[] args) { + if (args == null || args.length == 0) { + return "[]"; + } + final StringBuilder sb = new StringBuilder(64); + sb.append('['); + for (int i = 0; i < args.length; i++) { + if (i > 0) sb.append(','); + writeJsonVal(sb, args[i]); + } + sb.append(']'); + return sb.toString(); + } + + /** + * Unmarshal a JSON-array of {@code JsonVal}s into a list of Java-natural + * objects. Empty results ({@code "[]"}) returns an empty list. + */ + static List unmarshalResults(String json) { + if (json == null) { + return new ArrayList<>(); + } + final Parser p = new Parser(json); + p.skipWs(); + p.expect('['); + p.skipWs(); + final List out = new ArrayList<>(); + if (p.peek() == ']') { + p.consume(); + return out; + } + while (true) { + p.skipWs(); + out.add(readJsonVal(p)); + p.skipWs(); + final char c = p.peek(); + if (c == ',') { + p.consume(); + continue; + } + if (c == ']') { + p.consume(); + return out; + } + throw new IllegalArgumentException( + "expected ',' or ']' at pos " + p.pos + " in: " + json); + } + } + + // ---- Writer ------------------------------------------------------------ + + private static void writeJsonVal(StringBuilder sb, Object v) { + if (v == null) { + sb.append("{\"").append(TAG).append("\":\"Option\",\"").append(VAL).append("\":null}"); + return; + } + if (v instanceof Boolean) { + sb.append("{\"").append(TAG).append("\":\"Bool\",\"").append(VAL).append("\":"); + sb.append(((Boolean) v) ? "true" : "false"); + sb.append('}'); + return; + } + if (v instanceof Byte) { + writeTagged(sb, "S8", Byte.toString((Byte) v)); + return; + } + if (v instanceof Short) { + writeTagged(sb, "S16", Short.toString((Short) v)); + return; + } + if (v instanceof Integer) { + writeTagged(sb, "S32", Integer.toString((Integer) v)); + return; + } + if (v instanceof Long) { + writeTagged(sb, "S64", Long.toString((Long) v)); + return; + } + if (v instanceof Float) { + writeTagged(sb, "F32", Float.toString((Float) v)); + return; + } + if (v instanceof Double) { + writeTagged(sb, "F64", Double.toString((Double) v)); + return; + } + if (v instanceof Character) { + // Char is encoded as an int (u32 codepoint) — matches serde's + // representation of Rust's `char` (single Unicode scalar value). + writeTagged(sb, "Char", Integer.toString(((Character) v).charValue())); + return; + } + if (v instanceof String) { + sb.append("{\"").append(TAG).append("\":\"String\",\"").append(VAL).append("\":"); + writeJsonString(sb, (String) v); + sb.append('}'); + return; + } + if (v instanceof byte[]) { + final String b64 = Base64.getEncoder().encodeToString((byte[]) v); + sb.append("{\"").append(TAG).append("\":\"Bytes\",\"").append(VAL).append("\":"); + writeJsonString(sb, b64); + sb.append('}'); + return; + } + if (v instanceof WitU8) { + writeTagged(sb, "U8", Short.toString(((WitU8) v).value)); + return; + } + if (v instanceof WitU16) { + writeTagged(sb, "U16", Integer.toString(((WitU16) v).value)); + return; + } + if (v instanceof WitU32) { + writeTagged(sb, "U32", Long.toString(((WitU32) v).value)); + return; + } + if (v instanceof WitU64) { + // u64 is encoded as an unsigned integer literal. Java Long can't + // represent the full u64 range with `Long.toString`; use the + // unsigned formatter. + writeTagged(sb, "U64", Long.toUnsignedString(((WitU64) v).bits)); + return; + } + if (v instanceof Optional) { + final Optional opt = (Optional) v; + sb.append("{\"").append(TAG).append("\":\"Option\",\"").append(VAL).append("\":"); + if (opt.isPresent()) { + writeJsonVal(sb, opt.get()); + } else { + sb.append("null"); + } + sb.append('}'); + return; + } + if (v instanceof WitResult) { + final WitResult r = (WitResult) v; + sb.append("{\"").append(TAG).append("\":\"Result\",\"").append(VAL).append("\":{"); + sb.append("\"is_ok\":").append(r.isOk); + if (r.isOk && r.ok != null) { + sb.append(",\"ok\":"); + writeJsonVal(sb, r.ok); + } else if (!r.isOk && r.err != null) { + sb.append(",\"err\":"); + writeJsonVal(sb, r.err); + } + sb.append("}}"); + return; + } + if (v instanceof WitVariant) { + final WitVariant wv = (WitVariant) v; + sb.append("{\"").append(TAG).append("\":\"Variant\",\"").append(VAL).append("\":{"); + sb.append("\"discriminant\":"); + writeJsonString(sb, wv.discriminant); + if (wv.value != null) { + sb.append(",\"value\":"); + writeJsonVal(sb, wv.value); + } + sb.append("}}"); + return; + } + if (v instanceof WitEnum) { + sb.append("{\"").append(TAG).append("\":\"Enum\",\"").append(VAL).append("\":"); + writeJsonString(sb, ((WitEnum) v).discriminant); + sb.append('}'); + return; + } + if (v instanceof Set) { + final Set set = (Set) v; + sb.append("{\"").append(TAG).append("\":\"Flags\",\"").append(VAL).append("\":["); + boolean first = true; + for (Object flag : set) { + if (!first) sb.append(','); + first = false; + writeJsonString(sb, String.valueOf(flag)); + } + sb.append("]}"); + return; + } + if (v instanceof WitResource) { + // Wire form: { "t":"Resource", "v": { "table_id": , + // "type_name": "", "owned": } }. Field names + // MUST match the JsonVal::Resource variant in Rust + // (wasmos-provider/native/src/lib.rs) exactly — serde + // deserialization is field-name-driven. + final WitResource r = (WitResource) v; + sb.append("{\"").append(TAG).append("\":\"Resource\",\"").append(VAL).append("\":{"); + sb.append("\"table_id\":").append(r.tableId); + sb.append(",\"type_name\":"); + writeJsonString(sb, r.typeIdentifier); + sb.append(",\"owned\":").append(r.owned); + sb.append("}}"); + return; + } + if (v instanceof WitFuture) { + // Wire form: { "t":"Future", "v": { "table_id": , + // "type_name": "" } }. Field names MUST match the + // JsonVal::Future variant in Rust (wasmos-provider/native/src/lib.rs). + // No `owned` bit here — see WitFuture javadoc; futures always + // take on the Rust decode path. + final WitFuture wf = (WitFuture) v; + sb.append("{\"").append(TAG).append("\":\"Future\",\"").append(VAL).append("\":{"); + sb.append("\"table_id\":").append(wf.tableId); + sb.append(",\"type_name\":"); + writeJsonString(sb, wf.typeIdentifier); + sb.append("}}"); + return; + } + if (v instanceof WitStream) { + // Wire form: { "t":"Stream", "v": { "table_id": , + // "type_name": "" } }. Structural mirror of WitFuture. + final WitStream ws = (WitStream) v; + sb.append("{\"").append(TAG).append("\":\"Stream\",\"").append(VAL).append("\":{"); + sb.append("\"table_id\":").append(ws.tableId); + sb.append(",\"type_name\":"); + writeJsonString(sb, ws.typeIdentifier); + sb.append("}}"); + return; + } + if (v instanceof WitErrorContext) { + // Wire form: { "t":"ErrorContext", "v": { "table_id": , + // "rep": } }. Field names MUST match the + // JsonVal::ErrorContext variant in Rust — the wasmtime rep is + // a u32, we widen to Java long so it survives the wire form. + final WitErrorContext we = (WitErrorContext) v; + sb.append("{\"").append(TAG).append("\":\"ErrorContext\",\"").append(VAL).append("\":{"); + sb.append("\"table_id\":").append(we.tableId); + sb.append(",\"rep\":").append(we.rep); + sb.append("}}"); + return; + } + if (v instanceof WitMap) { + // WIT map: keys are typed JsonVals, so `[key_json, + // value_json]` pairs — same layout as Val::Map(Vec<(Val, Val)>) + // on the Rust side. Distinct from Record because Record's + // wire layout uses raw JSON strings for the key half. + final WitMap wm = (WitMap) v; + sb.append("{\"").append(TAG).append("\":\"Map\",\"").append(VAL).append("\":["); + boolean first = true; + for (Map.Entry e : wm.pairs.entrySet()) { + if (!first) sb.append(','); + first = false; + sb.append('['); + writeJsonVal(sb, e.getKey()); + sb.append(','); + writeJsonVal(sb, e.getValue()); + sb.append(']'); + } + sb.append("]}"); + return; + } + if (v instanceof Map) { + final Map m = (Map) v; + sb.append("{\"").append(TAG).append("\":\"Record\",\"").append(VAL).append("\":["); + boolean first = true; + for (Map.Entry e : m.entrySet()) { + if (!first) sb.append(','); + first = false; + sb.append('['); + writeJsonString(sb, String.valueOf(e.getKey())); + sb.append(','); + writeJsonVal(sb, e.getValue()); + sb.append(']'); + } + sb.append("]}"); + return; + } + if (v instanceof Collection) { + final Collection c = (Collection) v; + sb.append("{\"").append(TAG).append("\":\"List\",\"").append(VAL).append("\":["); + boolean first = true; + for (Object it : c) { + if (!first) sb.append(','); + first = false; + writeJsonVal(sb, it); + } + sb.append("]}"); + return; + } + throw new IllegalArgumentException( + "wasmos-provider marshalling: unsupported Java type " + + v.getClass().getName()); + } + + private static void writeTagged(StringBuilder sb, String tag, String val) { + sb.append("{\"").append(TAG).append("\":\"").append(tag).append("\",\"") + .append(VAL).append("\":").append(val).append('}'); + } + + private static void writeJsonString(StringBuilder sb, String s) { + sb.append('"'); + final int n = s.length(); + for (int i = 0; i < n; i++) { + final char c = s.charAt(i); + switch (c) { + case '"': sb.append("\\\""); break; + case '\\': sb.append("\\\\"); break; + case '\b': sb.append("\\b"); break; + case '\f': sb.append("\\f"); break; + case '\n': sb.append("\\n"); break; + case '\r': sb.append("\\r"); break; + case '\t': sb.append("\\t"); break; + default: + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + sb.append('"'); + } + + // ---- Reader ------------------------------------------------------------ + + /** + * Read a single tagged JsonVal object from the parser at its current + * position (after any leading whitespace). Returns a Java-natural object. + */ + private static Object readJsonVal(Parser p) { + p.expect('{'); + p.skipWs(); + String tag = null; + String rawValue = null; + int rawValueStart = -1; + int rawValueEnd = -1; + // Two-field object: {"t":"...", "v":...}. Values can be null. + boolean sawT = false; + boolean sawV = false; + while (true) { + p.skipWs(); + final String key = p.readString(); + p.skipWs(); + p.expect(':'); + p.skipWs(); + if (TAG.equals(key)) { + tag = p.readString(); + sawT = true; + } else if (VAL.equals(key)) { + rawValueStart = p.pos; + p.skipJsonValue(); + rawValueEnd = p.pos; + rawValue = p.source.substring(rawValueStart, rawValueEnd); + sawV = true; + } else { + // Unknown field — skip its value to stay forward-compatible. + p.skipJsonValue(); + } + p.skipWs(); + if (p.peek() == ',') { + p.consume(); + continue; + } + p.expect('}'); + break; + } + if (!sawT) { + throw new IllegalArgumentException("missing '" + TAG + "' at pos " + p.pos); + } + if (!sawV) { + // No `v` field. Only meaningful for unit-payload variants — treat + // as null. + rawValue = "null"; + } + return decodeTag(tag, rawValue); + } + + private static Object decodeTag(String tag, String rawValue) { + switch (tag) { + case "Bool": + return Boolean.valueOf("true".equalsIgnoreCase(rawValue.trim())); + case "S8": + return Byte.parseByte(rawValue.trim()); + case "S16": + return Short.parseShort(rawValue.trim()); + case "S32": + return Integer.parseInt(rawValue.trim()); + case "S64": + return Long.parseLong(rawValue.trim()); + case "U8": + return (short) Integer.parseInt(rawValue.trim()); + case "U16": + return Integer.parseInt(rawValue.trim()); + case "U32": + return Long.parseLong(rawValue.trim()); + case "U64": + return Long.parseUnsignedLong(rawValue.trim()); + case "F32": + return Float.parseFloat(rawValue.trim()); + case "F64": + return Double.parseDouble(rawValue.trim()); + case "Char": { + // Encoded as an integer codepoint. + final int cp = Integer.parseInt(rawValue.trim()); + return (char) cp; + } + case "String": { + final Parser p = new Parser(rawValue); + p.skipWs(); + return p.readString(); + } + case "Bytes": { + final Parser p = new Parser(rawValue); + p.skipWs(); + final String b64 = p.readString(); + return Base64.getDecoder().decode(b64); + } + case "List": { + final Parser p = new Parser(rawValue); + p.skipWs(); + return readJsonArray(p); + } + case "Option": { + final String trimmed = rawValue.trim(); + if ("null".equals(trimmed)) { + return Optional.empty(); + } + final Parser p = new Parser(rawValue); + p.skipWs(); + return Optional.of(readJsonVal(p)); + } + case "Result": { + final Parser p = new Parser(rawValue); + p.skipWs(); + p.expect('{'); + boolean isOk = false; + Object ok = null; + Object err = null; + while (true) { + p.skipWs(); + final String key = p.readString(); + p.skipWs(); + p.expect(':'); + p.skipWs(); + if ("is_ok".equals(key)) { + final String bool = p.readBareLiteral(); + isOk = "true".equalsIgnoreCase(bool); + } else if ("ok".equals(key)) { + if (p.peek() == 'n') { + p.readBareLiteral(); // "null" + } else { + ok = readJsonVal(p); + } + } else if ("err".equals(key)) { + if (p.peek() == 'n') { + p.readBareLiteral(); + } else { + err = readJsonVal(p); + } + } else { + p.skipJsonValue(); + } + p.skipWs(); + if (p.peek() == ',') { + p.consume(); + continue; + } + p.expect('}'); + break; + } + return isOk ? WitResult.ok(ok) : WitResult.err(err); + } + case "Record": { + final Parser p = new Parser(rawValue); + p.skipWs(); + p.expect('['); + final Map out = new LinkedHashMap<>(); + p.skipWs(); + if (p.peek() == ']') { + p.consume(); + return out; + } + while (true) { + p.skipWs(); + p.expect('['); + p.skipWs(); + final String key = p.readString(); + p.skipWs(); + p.expect(','); + p.skipWs(); + final Object value = readJsonVal(p); + p.skipWs(); + p.expect(']'); + out.put(key, value); + p.skipWs(); + if (p.peek() == ',') { + p.consume(); + continue; + } + p.expect(']'); + return out; + } + } + case "Tuple": { + final Parser p = new Parser(rawValue); + p.skipWs(); + return readJsonArray(p); + } + case "Variant": { + final Parser p = new Parser(rawValue); + p.skipWs(); + p.expect('{'); + String disc = null; + Object value = null; + while (true) { + p.skipWs(); + final String key = p.readString(); + p.skipWs(); + p.expect(':'); + p.skipWs(); + if ("discriminant".equals(key)) { + disc = p.readString(); + } else if ("value".equals(key)) { + if (p.peek() == 'n') { + p.readBareLiteral(); + } else { + value = readJsonVal(p); + } + } else { + p.skipJsonValue(); + } + p.skipWs(); + if (p.peek() == ',') { + p.consume(); + continue; + } + p.expect('}'); + break; + } + return new WitVariant(disc, value); + } + case "Enum": { + final Parser p = new Parser(rawValue); + p.skipWs(); + return new WitEnum(p.readString()); + } + case "Map": { + // Wire form: [[key_json, value_json], ...]. Keys are decoded + // as full JsonVals rather than raw strings; we surface the + // result as a plain LinkedHashMap so callers get natural + // Java iteration semantics (the WitMap wrapper is only + // needed on the outgoing side to disambiguate from Record). + final Parser p = new Parser(rawValue); + p.skipWs(); + p.expect('['); + final Map out = new LinkedHashMap<>(); + p.skipWs(); + if (p.peek() == ']') { + p.consume(); + return out; + } + while (true) { + p.skipWs(); + p.expect('['); + p.skipWs(); + final Object key = readJsonVal(p); + p.skipWs(); + p.expect(','); + p.skipWs(); + final Object value = readJsonVal(p); + p.skipWs(); + p.expect(']'); + out.put(key, value); + p.skipWs(); + if (p.peek() == ',') { + p.consume(); + continue; + } + p.expect(']'); + return out; + } + } + case "Future": { + // Object payload: { "table_id": , "type_name": }. + // Fields may arrive in any order; unknown fields are skipped + // for forward-compat. See the JsonVal::Future doc comment + // for why there's no `owned` bit here. + final Parser p = new Parser(rawValue); + p.skipWs(); + p.expect('{'); + long tid = 0L; + String typeName = ""; + boolean sawTableId = false; + while (true) { + p.skipWs(); + final String key = p.readString(); + p.skipWs(); + p.expect(':'); + p.skipWs(); + if ("table_id".equals(key)) { + tid = Long.parseLong(p.readBareLiteral().trim()); + sawTableId = true; + } else if ("type_name".equals(key)) { + typeName = p.readString(); + } else { + p.skipJsonValue(); + } + p.skipWs(); + if (p.peek() == ',') { + p.consume(); + continue; + } + p.expect('}'); + break; + } + if (!sawTableId) { + throw new IllegalArgumentException( + "Future JsonVal missing required 'table_id' field"); + } + return new WitFuture(tid, typeName); + } + case "Stream": { + // Structural mirror of the Future arm — same field names, + // same forward-compat unknown-key skip, same required-field + // set. Kept as a separate case (rather than sharing a helper) + // so failure messages name the WIT shape the caller was + // handed. + final Parser p = new Parser(rawValue); + p.skipWs(); + p.expect('{'); + long tid = 0L; + String typeName = ""; + boolean sawTableId = false; + while (true) { + p.skipWs(); + final String key = p.readString(); + p.skipWs(); + p.expect(':'); + p.skipWs(); + if ("table_id".equals(key)) { + tid = Long.parseLong(p.readBareLiteral().trim()); + sawTableId = true; + } else if ("type_name".equals(key)) { + typeName = p.readString(); + } else { + p.skipJsonValue(); + } + p.skipWs(); + if (p.peek() == ',') { + p.consume(); + continue; + } + p.expect('}'); + break; + } + if (!sawTableId) { + throw new IllegalArgumentException( + "Stream JsonVal missing required 'table_id' field"); + } + return new WitStream(tid, typeName); + } + case "ErrorContext": { + // Object payload: { "table_id": , "rep": }. + // The `rep` field is a u32 on the Rust side; Java carries it + // as long so the whole u32 range survives without silent + // truncation. + final Parser p = new Parser(rawValue); + p.skipWs(); + p.expect('{'); + long tid = 0L; + long rep = 0L; + boolean sawTableId = false; + while (true) { + p.skipWs(); + final String key = p.readString(); + p.skipWs(); + p.expect(':'); + p.skipWs(); + if ("table_id".equals(key)) { + tid = Long.parseLong(p.readBareLiteral().trim()); + sawTableId = true; + } else if ("rep".equals(key)) { + rep = Long.parseLong(p.readBareLiteral().trim()); + } else { + p.skipJsonValue(); + } + p.skipWs(); + if (p.peek() == ',') { + p.consume(); + continue; + } + p.expect('}'); + break; + } + if (!sawTableId) { + throw new IllegalArgumentException( + "ErrorContext JsonVal missing required 'table_id' field"); + } + return new WitErrorContext(tid, rep); + } + case "Resource": { + // Object payload: { "table_id": , "type_name": , + // "owned": }. Fields may arrive in any order (serde + // doesn't guarantee ordering across versions). Unknown fields + // are skipped for forward-compat with future wire additions. + final Parser p = new Parser(rawValue); + p.skipWs(); + p.expect('{'); + long tid = 0L; + String typeName = ""; + boolean owned = false; + boolean sawTableId = false; + while (true) { + p.skipWs(); + final String key = p.readString(); + p.skipWs(); + p.expect(':'); + p.skipWs(); + if ("table_id".equals(key)) { + tid = Long.parseLong(p.readBareLiteral().trim()); + sawTableId = true; + } else if ("type_name".equals(key)) { + typeName = p.readString(); + } else if ("owned".equals(key)) { + owned = "true".equalsIgnoreCase(p.readBareLiteral().trim()); + } else { + p.skipJsonValue(); + } + p.skipWs(); + if (p.peek() == ',') { + p.consume(); + continue; + } + p.expect('}'); + break; + } + if (!sawTableId) { + throw new IllegalArgumentException( + "Resource JsonVal missing required 'table_id' field"); + } + return new WitResource(tid, typeName, owned); + } + case "Flags": { + final Parser p = new Parser(rawValue); + p.skipWs(); + p.expect('['); + final List out = new ArrayList<>(); + p.skipWs(); + if (p.peek() == ']') { + p.consume(); + return new java.util.LinkedHashSet<>(out); + } + while (true) { + p.skipWs(); + out.add(p.readString()); + p.skipWs(); + if (p.peek() == ',') { + p.consume(); + continue; + } + p.expect(']'); + return new java.util.LinkedHashSet<>(out); + } + } + default: + throw new IllegalArgumentException( + "wasmos-provider marshalling: unknown JsonVal tag '" + tag + "'"); + } + } + + private static List readJsonArray(Parser p) { + p.expect('['); + p.skipWs(); + final List out = new ArrayList<>(); + if (p.peek() == ']') { + p.consume(); + return out; + } + while (true) { + p.skipWs(); + out.add(readJsonVal(p)); + p.skipWs(); + if (p.peek() == ',') { + p.consume(); + continue; + } + p.expect(']'); + return out; + } + } + + // ---- Minimal JSON parser ---------------------------------------------- + + /** + * Hand-rolled JSON parser tuned for the tightly-scoped JsonVal schema. + * Not a general-purpose JSON reader; skips validation that would be + * dead code on Rust-produced input (we trust the sibling serde emitter). + */ + private static final class Parser { + + final String source; + int pos; + + Parser(String source) { + this.source = source; + this.pos = 0; + } + + char peek() { + if (pos >= source.length()) { + throw new IllegalArgumentException("unexpected end of JSON at pos " + pos); + } + return source.charAt(pos); + } + + void consume() { pos++; } + + void expect(char c) { + if (pos >= source.length()) { + throw new IllegalArgumentException( + "expected '" + c + "' at pos " + pos + " but hit end of input"); + } + if (source.charAt(pos) != c) { + throw new IllegalArgumentException( + "expected '" + c + "' at pos " + pos + " but got '" + + source.charAt(pos) + "'"); + } + pos++; + } + + void skipWs() { + while (pos < source.length()) { + final char c = source.charAt(pos); + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + pos++; + } else { + return; + } + } + } + + String readString() { + if (source.charAt(pos) != '"') { + throw new IllegalArgumentException( + "expected '\"' at pos " + pos + " but got '" + source.charAt(pos) + "'"); + } + pos++; + final StringBuilder sb = new StringBuilder(); + while (pos < source.length()) { + final char c = source.charAt(pos); + if (c == '"') { + pos++; + return sb.toString(); + } + if (c == '\\') { + pos++; + final char esc = source.charAt(pos); + pos++; + switch (esc) { + case '"': sb.append('"'); break; + case '\\': sb.append('\\'); break; + case '/': sb.append('/'); break; + case 'b': sb.append('\b'); break; + case 'f': sb.append('\f'); break; + case 'n': sb.append('\n'); break; + case 'r': sb.append('\r'); break; + case 't': sb.append('\t'); break; + case 'u': { + final String hex = source.substring(pos, pos + 4); + pos += 4; + sb.append((char) Integer.parseInt(hex, 16)); + break; + } + default: + throw new IllegalArgumentException( + "invalid escape \\" + esc + " at pos " + pos); + } + continue; + } + sb.append(c); + pos++; + } + throw new IllegalArgumentException("unterminated string at pos " + pos); + } + + /** Read a bareword literal (true/false/null/number) up to a + * delimiter (comma / brace / bracket / whitespace / EOF). */ + String readBareLiteral() { + final int start = pos; + while (pos < source.length()) { + final char c = source.charAt(pos); + if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' + || c == '\n' || c == '\r') { + break; + } + pos++; + } + return source.substring(start, pos); + } + + /** + * Advance past a JSON value (string / number / object / array / + * true / false / null) without decoding it. Used to slice out the + * raw substring of a `"v"` field. + */ + void skipJsonValue() { + skipWs(); + if (pos >= source.length()) return; + final char c = source.charAt(pos); + if (c == '"') { + readString(); + return; + } + if (c == '{') { + pos++; + int depth = 1; + boolean inString = false; + boolean esc = false; + while (pos < source.length() && depth > 0) { + final char ch = source.charAt(pos); + if (inString) { + if (esc) { + esc = false; + } else if (ch == '\\') { + esc = true; + } else if (ch == '"') { + inString = false; + } + } else { + if (ch == '"') inString = true; + else if (ch == '{') depth++; + else if (ch == '}') depth--; + } + pos++; + } + return; + } + if (c == '[') { + pos++; + int depth = 1; + boolean inString = false; + boolean esc = false; + while (pos < source.length() && depth > 0) { + final char ch = source.charAt(pos); + if (inString) { + if (esc) { + esc = false; + } else if (ch == '\\') { + esc = true; + } else if (ch == '"') { + inString = false; + } + } else { + if (ch == '"') inString = true; + else if (ch == '[') depth++; + else if (ch == ']') depth--; + } + pos++; + } + return; + } + readBareLiteral(); + } + } + + // Kept for symmetry with the Rust side's exposure of raw bytes — unused + // by the marshalling helpers themselves but handy for tests that want to + // reconstruct the wire form byte-for-byte. + static byte[] asUtf8(String s) { + return s == null ? new byte[0] : s.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosProvider.java b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosProvider.java new file mode 100644 index 0000000..ca2ebe7 --- /dev/null +++ b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosProvider.java @@ -0,0 +1,112 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package ai.tegmentum.webassembly4j.provider.wasmos; + +import ai.tegmentum.webassembly4j.api.Engine; +import ai.tegmentum.webassembly4j.api.config.WebAssemblyConfig; +import ai.tegmentum.webassembly4j.provider.wasmos.jni.WasmosNative; +import ai.tegmentum.webassembly4j.spi.EngineProvider; +import ai.tegmentum.webassembly4j.spi.ProviderAvailability; +import ai.tegmentum.webassembly4j.spi.ProviderDescriptor; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * webassembly4j {@link EngineProvider} for wasmos — a wasmtime-backed + * component runtime that pre-links the wasmos-runtime host surface + * (host:wasmtime, host:bootstrap, host:caps, host:grants, wasmos:host/*) + * and wasi:p2 onto every component instance. + * + *

Discovered via {@link java.util.ServiceLoader}. Selection: pass + * provider id {@code "wasmos"} to + * {@link ai.tegmentum.webassembly4j.api.EngineSelection}, or let the + * default priority ordering pick it up (priority 90 — deliberately + * below the plain-wasmtime provider so a caller that just wants + * component-model support doesn't accidentally instantiate a + * wasmos-shaped host on a component that doesn't need it). + * + *

The underlying runtime is wasmos-runtime (Rust crate at + * {@code ~/git/wasmos}) accessed through a JNI wrapper cdylib bundled + * in this jar's {@code /natives/} directory. + */ +public final class WasmosProvider implements EngineProvider { + + private static final String ENGINE_ID = "wasmos"; + private static final String PROVIDER_ID = "wasmos"; + private static final String VERSION = "1.0.0-SNAPSHOT"; + private static final int MIN_JAVA = 11; + /** Priority sits between wasmtime (200) and graalwasm (150): a caller who + * wants any-provider-will-do lands on wasmtime; a caller who wants + * wasmos-shaped hosting asks for "wasmos" by id explicitly. */ + private static final int PRIORITY = 90; + + @Override + public ProviderDescriptor descriptor() { + return new ProviderDescriptor() { + @Override public String engineId() { return ENGINE_ID; } + @Override public String providerId() { return PROVIDER_ID; } + @Override public String version() { return VERSION; } + @Override public int minimumJavaVersion() { return MIN_JAVA; } + @Override public Set tags() { + final Set tags = new HashSet<>(); + tags.add("native"); + tags.add("component-model"); + tags.add("wasmos"); + return Collections.unmodifiableSet(tags); + } + @Override public int priority() { return PRIORITY; } + }; + } + + /** + * Probes availability by trying to create + immediately close a + * wasmos engine handle. This exercises the whole native surface + * (dylib extraction, JNI symbol resolution, wasmtime + Tokio + * initialization) so a false "available" answer is very unlikely. + */ + @Override + public ProviderAvailability availability() { + try { + final long h = WasmosNative.engineCreate(); + if (h == 0) { + return unavailable("engineCreate returned 0 handle without throwing"); + } + WasmosNative.engineClose(h); + return available(); + } catch (Throwable t) { + return unavailable(t.getClass().getName() + ": " + t.getMessage()); + } + } + + @Override + public Engine create(WebAssemblyConfig config) { + // WebAssemblyConfig is accepted for SPI symmetry but the MVP + // provider has no engine-specific knobs; the wasmos-runtime side + // is opinionated (async on, component-model on, wasi_p2 on) and + // isn't tunable through the current webassembly4j API. + return WasmosEngineAdapter.create(); + } + + private static ProviderAvailability available() { + return new ProviderAvailability() { + @Override public boolean available() { return true; } + @Override public String message() { return "wasmos runtime available"; } + }; + } + + private static ProviderAvailability unavailable(final String reason) { + return new ProviderAvailability() { + @Override public boolean available() { return false; } + @Override public String message() { return "wasmos not available: " + reason; } + }; + } +} diff --git a/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/ext/WasmosAsyncExtension.java b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/ext/WasmosAsyncExtension.java new file mode 100644 index 0000000..8236593 --- /dev/null +++ b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/ext/WasmosAsyncExtension.java @@ -0,0 +1,255 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package ai.tegmentum.webassembly4j.provider.wasmos.ext; + +import java.util.concurrent.CompletableFuture; + +/** + * Provider-specific async extension exposed by the {@code wasmos} engine + * provider. Obtain from a wasmos-backed {@link ai.tegmentum.webassembly4j.api.ComponentInstance} + * via + *

+ *     instance.extension(WasmosAsyncExtension.class)
+ *             .ifPresent(async -> async.invokeAsync("run").thenAccept(System.out::println));
+ * 
+ * + *

This is the slice-1 (Option C hybrid) async surface: {@code Val::Future}, + * {@code Val::Stream}, and {@code Val::ErrorContext} marshalling live in + * later slices. What ships here is a Java-side {@link CompletableFuture} + * wrapper around the blocking JNI invoke path — Java consumers preferring + * async ergonomics can chain component invocations non-blockingly while the + * work runs on a dedicated worker pool. + * + *

Threading: Each invocation is scheduled onto the engine's + * shared {@code wasmos-provider-async} pool (bounded cached executor, daemon + * threads). The store is Rust-side {@code Mutex}-protected, so concurrent + * invocations against the same {@link ai.tegmentum.webassembly4j.api.ComponentInstance} + * still serialize; only work targeting different instances runs in + * parallel. Callers doing sustained parallel workloads should instantiate + * one component per parallel stream of calls. + * + *

Cancellation: {@code future.cancel(true)} on the returned + * future is best-effort. Semantics: + *

    + *
  • If the future has not yet started running on the worker pool + * (still queued), it is dropped and the WASM invoke never runs.
  • + *
  • If the future has started, the Java-side promise is marked + * cancelled and downstream {@code thenApply} / {@code thenCompose} + * chains stop propagating. The provider additionally invokes + * {@link ai.tegmentum.webassembly4j.provider.wasmos.jni.WasmosNative#engineIncrementEpoch} + * on the underlying engine as a nudge — this reliably traps the + * running invoke only when the instance was instantiated with an + * epoch deadline (via {@link ai.tegmentum.webassembly4j.api.config.ComponentConfig#epochDeadline}). + * Instances without an epoch deadline will finish their current + * WASM call to completion; the {@link CompletableFuture} contract + * still returns {@code true} from {@code cancel} to signal that the + * downstream chain will not observe the result.
  • + *
+ * + *

Callers that need authoritative cancellation should always instantiate + * with a non-negative {@code epochDeadline}. The nudge above is safe on a + * shared engine — {@code engineIncrementEpoch} only bumps the counter, it + * doesn't touch any store state. + * + * @since 2.5.2 + */ +public interface WasmosAsyncExtension { + + /** + * Async counterpart of {@link ai.tegmentum.webassembly4j.api.ComponentInstance#invoke}. + * Returns a {@link CompletableFuture} that completes with the invoke + * result on the {@code wasmos-provider-async} worker pool, or + * completes exceptionally with the wrapping + * {@link ai.tegmentum.webassembly4j.api.exception.ExecutionException} + * that the sync path would have thrown. + * + *

Argument / return marshalling is identical to the sync path — see + * {@link ai.tegmentum.webassembly4j.api.ComponentInstance#invoke} for + * the type-mapping table. + * + *

See the class-level javadoc for cancellation semantics; the + * returned future's {@code cancel(true)} is best-effort and its + * authoritative-ness depends on whether the instance was instantiated + * with an epoch deadline. + */ + CompletableFuture invokeAsync(String functionName, Object... args); + + /** + * Async counterpart of + * {@link ai.tegmentum.webassembly4j.api.ComponentInstance#invokeBytes}. + * Skips the JSON detour on the return path for functions returning + * {@code list} or {@code string}. + * + *

Cancellation semantics mirror {@link #invokeAsync}. + */ + CompletableFuture invokeBytesAsync(String functionName, Object... args); + + /** + * Await a parked {@code WitFuture} (a {@code future} handle returned + * by an earlier guest call) and complete with its resolved value. + * + *

Currently unsupported due to a wasmtime 47 public-API gap. + * The returned {@link CompletableFuture} always completes exceptionally + * with an + * {@link ai.tegmentum.webassembly4j.api.exception.WebAssemblyException} + * explaining the gap. + * + *

The wasmtime 47 {@code FutureAny} API only exposes: + *

    + *
  • {@code try_into_future_reader::()} — requires a compile-time + * {@code T}. Incompatible with our runtime-typed JSON marshalling.
  • + *
  • {@code close(store)} — plain disposal, no value extraction.
  • + *
+ * {@code FutureConsumer::Item} is likewise a compile-time associated + * type, so there's no type-erased await surface upstream today. + * + *

The wrapping method is stable in shape: when wasmtime exposes a + * dynamic await API upstream, only the JNI body and this method's + * implementation change; callers using + * {@code future.awaitFuture(handle).thenApply(...)} chains keep working + * without recompilation. + * + *

In the meantime, callers can still round-trip a {@code WitFuture} + * through a guest import that consumes it (that path IS wired + * end-to-end via {@link #invokeAsync}), or dispose of it via + * {@link #closeFuture(Object)}. + * + * @param witFuture a {@code WitFuture} produced by the marshalling + * layer as a return value of an earlier invoke call; passed as + * {@link Object} to avoid leaking the package-private carrier type + * into the public API. + */ + CompletableFuture awaitFuture(Object witFuture); + + /** + * Close a parked {@code WitFuture} — evicts it from the Rust-side + * future registry and calls wasmtime's {@code FutureAny::close(store)}. + * Should always be called in a {@code finally} block when a caller + * receives a {@code WitFuture} but doesn't pass it back to a guest + * import (which would consume the parked entry naturally). + * + *

Throws + * {@link ai.tegmentum.webassembly4j.api.exception.WebAssemblyException} + * if the slot id is unknown (already closed, or transferred to a guest). + * + * @param witFuture a {@code WitFuture} produced by the marshalling + * layer, passed as {@link Object} to avoid leaking the carrier type. + */ + void closeFuture(Object witFuture); + + /** + * Read a parked {@code WitStream} and complete with a materialised + * value. + * + *

Currently unsupported due to a wasmtime 47 public-API gap. + * Structurally identical to {@link #awaitFuture} — the returned + * {@link CompletableFuture} always completes exceptionally with a + * {@link ai.tegmentum.webassembly4j.api.exception.WebAssemblyException} + * explaining the gap. + * + *

Wasmtime 47's {@code StreamAny} API only exposes + * {@code try_into_stream_reader::()} (compile-time typed) and + * {@code close(store)}. There is no dynamic-payload-type read/poll + * surface, so runtime-typed marshalling can't lift stream items to + * Java. We deliberately expose this as a single-value + * {@code CompletableFuture} (rather than a {@link java.util.concurrent.Flow.Publisher}) + * because the underlying gap means only the "hit the wall" signal is + * actionable today — a multi-value publisher over a + * gap-that-throws-immediately would be over-engineering. When + * wasmtime lands a dynamic read surface upstream, this shape can be + * revisited (either evolved to return a publisher, or paired with a + * new streaming-shaped method) without breaking the current no-op + * contract. + * + *

In the meantime, callers can still round-trip a {@code WitStream} + * through a guest import that consumes it (that path IS wired + * end-to-end via {@link #invokeAsync}), or dispose of it via + * {@link #closeStream(Object)}. + * + * @param witStream a {@code WitStream} produced by the marshalling + * layer as a return value of an earlier invoke call; passed as + * {@link Object} to avoid leaking the package-private carrier type. + */ + CompletableFuture readStream(Object witStream); + + /** + * Close a parked {@code WitStream} — evicts it from the Rust-side + * stream registry and calls wasmtime's {@code StreamAny::close(store)}. + * Mirrors {@link #closeFuture}. + * + *

Throws + * {@link ai.tegmentum.webassembly4j.api.exception.WebAssemblyException} + * if the slot id is unknown (already closed or transferred to a guest). + * + * @param witStream a {@code WitStream} produced by the marshalling + * layer, passed as {@link Object} to avoid leaking the carrier type. + */ + void closeStream(Object witStream); + + /** + * Close a parked {@code WitErrorContext} — evicts it from the + * Rust-side error-context registry. Wasmtime 47's + * {@code ErrorContextAny} has no publicly-defined dispose surface + * (see wasmtime's {@code FIXME(#11161)}), so this is a pure + * eviction — no wasmtime-side call is made. Callers who received a + * {@code WitErrorContext} but don't pass it back to a guest should + * call this in a {@code finally} block to keep the registry from + * growing unbounded. + * + *

Throws + * {@link ai.tegmentum.webassembly4j.api.exception.ExecutionException} + * if the slot id is unknown (already closed or transferred to a guest). + * + * @param witErrorContext a {@code WitErrorContext} produced by the + * marshalling layer, passed as {@link Object} to avoid leaking + * the carrier type. + */ + void closeErrorContext(Object witErrorContext); + + /** + * Convenience wrapper that transforms a Java-visible + * {@code WitErrorContext} into a + * {@link WitErrorContextException} so callers can complete a + * downstream {@link CompletableFuture} exceptionally with an + * inspectable exception type. + * + *

Intended usage — inside a {@code handle(...)} or {@code compose(...)} + * chain, when the caller has parsed a {@code result} + * return value and wants to lift its {@code err} branch into a + * standard exception path: + * + *

+     * invokeAsync("get-thing")
+     *     .thenApply(v -> {
+     *         if (v instanceof WasmosMarshalling.WitResult) {
+     *             final var r = (WasmosMarshalling.WitResult) v;
+     *             if (!r.isOk && r.err instanceof WasmosMarshalling.WitErrorContext) {
+     *                 throw async.wrapErrorContext((WasmosMarshalling.WitErrorContext) r.err);
+     *             }
+     *             return r.ok;
+     *         }
+     *         return v;
+     *     });
+     * 
+ * + *

The parameter is typed as {@link Object} because the concrete + * {@code WasmosMarshalling.WitErrorContext} type is package-private + * on purpose. + * + * @param witErrorContext an opaque {@code WitErrorContext} produced + * by the marshalling layer + * @return a {@link WitErrorContextException} carrying the handle; + * ready to be thrown from a {@code CompletableFuture} + * transformer to trigger exceptional completion + * @throws IllegalArgumentException if {@code witErrorContext} isn't a + * recognised WitErrorContext carrier + */ + WitErrorContextException wrapErrorContext(Object witErrorContext); +} diff --git a/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/ext/WitErrorContextException.java b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/ext/WitErrorContextException.java new file mode 100644 index 0000000..47454e4 --- /dev/null +++ b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/ext/WitErrorContextException.java @@ -0,0 +1,77 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package ai.tegmentum.webassembly4j.provider.wasmos.ext; + +import ai.tegmentum.webassembly4j.api.exception.ExecutionException; + +/** + * Java-side exception wrapper for a wasmos-provider WIT + * {@code error-context} handle. Carries the opaque handle (as an + * {@link Object} — the underlying carrier type is package-private in + * {@code wasmos-provider}) so consumers can inspect it via the + * exceptional {@link java.util.concurrent.CompletableFuture} chain. + * + *

Wasmtime 47's {@code ErrorContextAny} is a placeholder type (see + * wasmtime's {@code FIXME(#11161)}) — the only publicly-accessible + * information is a numeric rep. That's carried on the wrapped + * {@code WitErrorContext}. Downstream code that wants to introspect can + * do so via the pass-in path (route the handle back into a guest import + * that accepts an error-context argument) or via + * {@link WasmosAsyncExtension#closeErrorContext} for disposal. + * + *

Extends {@link ExecutionException} so + * {@code CompletableFuture.get()} rethrows it wrapped in + * {@link java.util.concurrent.ExecutionException} with a consistent + * exception-hierarchy story shared with other invoke-time failures. + * Callers doing {@code .handle((v, t) -> …)} can pattern-match on + * {@code WitErrorContextException} to detect the specific case and + * pull the underlying handle out via {@link #errorContext()}. + * + * @since 2.5.2 + */ +public final class WitErrorContextException extends ExecutionException { + + private static final long serialVersionUID = 1L; + + /** + * The opaque {@code WitErrorContext} carrier. Passed through as + * {@link Object} because the concrete + * {@code WasmosMarshalling.WitErrorContext} type is package-private + * on purpose (the wasmos-provider marshalling layer keeps its + * carrier types out of the public API surface). Callers who + * absolutely need the numeric rep can call {@code toString()} — the + * carrier's toString includes {@code rep=} — or route the + * handle back into a guest via + * {@link WasmosAsyncExtension#invokeAsync}. + */ + private final Object errorContext; + + public WitErrorContextException(String message, Object errorContext) { + super(message); + this.errorContext = errorContext; + } + + public WitErrorContextException(String message, Object errorContext, Throwable cause) { + super(message, cause); + this.errorContext = errorContext; + } + + /** + * The wrapped {@code WitErrorContext} — an opaque + * {@code WasmosMarshalling.WitErrorContext} carrier. Passed as + * {@link Object} to avoid leaking the package-private carrier type; + * downstream code that wants to introspect can call {@code toString()} + * on it (which includes the numeric rep) or pass it back into a guest + * import via {@link WasmosAsyncExtension#invokeAsync}. + */ + public Object errorContext() { + return errorContext; + } +} diff --git a/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/jni/WasmosNative.java b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/jni/WasmosNative.java new file mode 100644 index 0000000..e976800 --- /dev/null +++ b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/jni/WasmosNative.java @@ -0,0 +1,293 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package ai.tegmentum.webassembly4j.provider.wasmos.jni; + +/** + * Raw JNI surface into the wasmos-provider Rust cdylib (see + * {@code wasmos-provider/native/src/lib.rs}). Every method here maps 1:1 to + * an {@code extern "C"} fn on the Rust side; the Rust side throws + * {@link ai.tegmentum.webassembly4j.api.exception.WebAssemblyException} on + * failure and returns a sentinel value (0 for {@code long}, {@code null} for + * arrays / strings). + * + *

All handles are opaque {@code long} pointers to Rust-heap-owned + * structs. Every {@code *Create} / {@code *Load} / {@code *Instantiate} + * call must be paired with the corresponding {@code *Close} call — + * failure to do so leaks a wasmtime Engine or Store, and (in the case of + * the engine handle) a Tokio runtime with a worker-thread pool. + * + *

Package-private on purpose: this is not part of the provider's public + * API. Callers should use the framework-neutral webassembly4j + * {@link ai.tegmentum.webassembly4j.api.Engine} / + * {@link ai.tegmentum.webassembly4j.api.Component} / + * {@link ai.tegmentum.webassembly4j.api.ComponentInstance} surface, which the + * wasmos-provider adapters implement on top of this class. + */ +public final class WasmosNative { + + static { + WasmosNativeLoader.load(); + } + + private WasmosNative() {} + + // ---- Engine ------------------------------------------------------------ + + /** + * Create a wasmtime Engine wired for the wasmos-runtime host surface, + * plus a persistent Tokio runtime for driving async instantiation / + * invocation. Returns an opaque handle. + */ + public static native long engineCreate(); + + /** Drop the engine + its Tokio runtime. */ + public static native void engineClose(long engineHandle); + + /** + * Increment the engine's epoch counter. Only meaningful for instances + * instantiated with a non-negative {@code epochDeadline} via + * {@link #componentInstantiateWithConfig}. Callers running without a + * deadline can ignore this method. + */ + public static native void engineIncrementEpoch(long engineHandle); + + // ---- Component --------------------------------------------------------- + + /** + * Compile a Component from raw bytes. The engine handle must outlive + * the returned component handle. + */ + public static native long componentLoad(long engineHandle, byte[] bytes); + + /** Drop a component. */ + public static native void componentClose(long componentHandle); + + /** + * Serialize the component into wasmtime's cached AOT byte + * representation. Bytes can be fed back to + * {@link #componentDeserialize(long, byte[])} on a compatible engine + * to skip re-compiling. Intended for warm-restart / hot-reload + * scenarios; compatibility is tied to wasmtime version + Config + + * target ISA (a mismatched engine will fail to deserialize rather + * than silently misbehave). + */ + public static native byte[] componentSerialize(long componentHandle); + + /** + * Deserialize bytes produced by {@link #componentSerialize(long)} + * against a compatible engine. Returns a fresh component handle. + * + *

Note: wasmtime's underlying {@code Component::deserialize} is + * {@code unsafe} — it trusts the input to be a well-formed AOT + * image from a compatible engine. Passing arbitrary or tampered + * bytes is undefined behaviour. Callers are responsible for the + * provenance of the bytes. + */ + public static native long componentDeserialize(long engineHandle, byte[] bytes); + + /** + * List the component's exported function names as a null-separated + * UTF-8 blob. Cheap-and-cheerful serialization to avoid a jstring + * allocation loop for what's a metadata query. + */ + public static native byte[] componentExportedFunctionNames(long componentHandle); + + // ---- Instance lifecycle ------------------------------------------------ + + /** + * Instantiate a component with the full wasmos-runtime host surface + * (bootstrap / caps / grants / host:wasmtime) plus wasi:p2. The engine + * associated with the component must still be alive. Uses a default + * {@code HostState::new()} — empty WASI ctx, no limits, no epoch. + */ + public static native long componentInstantiate(long componentHandle); + + /** + * Instantiate with optional WASI ctx + optional resource / epoch limits. + * + *

Flag bits: + *

    + *
  • {@code 0x01} — inheritStdin
  • + *
  • {@code 0x02} — inheritStdout
  • + *
  • {@code 0x04} — inheritStderr
  • + *
  • {@code 0x08} — hasWasiContext (when unset, all WASI args are + * ignored and a default empty WasiCtx is used — equivalent to + * calling {@link #componentInstantiate})
  • + *
+ * + *

String arrays are index-aligned: + * {@code envKeys[i]} pairs with {@code envVals[i]}; + * {@code preopenHosts[i]} maps to {@code preopenGuests[i]} with + * writability decided by the i-th byte of {@code preopenWritable} + * ({@code 0}=read-only, non-zero=read-write). + * + *

Numeric args use {@code -1} to mean "unset": + * {@code maxMemoryBytes<0} skips the memory limiter, + * {@code epochDeadline<0} skips epoch wiring, and so on. Each + * outer-store limit is independent — a caller can set only fuel or + * only tables without dragging the others in. + * + * @param componentHandle from {@link #componentLoad} + * @param flags see the bit-flag doc above + * @param args wasi arguments (empty array if not needed) + * @param envKeys wasi env keys + * @param envVals wasi env vals (same length as envKeys) + * @param preopenHosts host paths to preopen + * @param preopenGuests guest paths for each preopen (same length) + * @param preopenWritable per-preopen writability flag (same length) + * @param maxMemoryBytes memory-size limit in bytes, or {@code -1} for none + * @param epochDeadline epoch deadline (ticks beyond current), or {@code -1} + * @param fuelLimit initial store fuel budget (engine has + * {@code Config::consume_fuel(true)} on globally so this is a cheap + * opt-in), or {@code -1} for unlimited + * @param maxTableElements per-table element cap, or {@code -1} + * @param maxInstances max component-model instances per store, or {@code -1} + * @param maxTables max tables per store, or {@code -1} + * @param maxMemories max linear memories per store, or {@code -1} + */ + public static native long componentInstantiateWithConfig( + long componentHandle, + int flags, + String[] args, + String[] envKeys, + String[] envVals, + String[] preopenHosts, + String[] preopenGuests, + byte[] preopenWritable, + long maxMemoryBytes, + long epochDeadline, + long fuelLimit, + long maxTableElements, + long maxInstances, + long maxTables, + long maxMemories); + + /** Drop an instance + its Store. */ + public static native void instanceClose(long instanceHandle); + + // ---- Instance invoke --------------------------------------------------- + + /** + * Invoke {@code name: func() -> i32} on the instance. Fast path — no + * marshalling, no JSON. Throws + * {@link ai.tegmentum.webassembly4j.api.exception.WebAssemblyException} on + * any wasmtime error, including a signature mismatch (Rust side calls + * {@code get_typed_func::<(), (i32,)>}). + */ + public static native int instanceInvokeReturningI32(long instanceHandle, String name); + + /** + * General invoke path. {@code argsJson} is a JSON array of typed + * {@code JsonVal} objects (see the schema on the Rust side or the + * {@code WasmosMarshalling} Java helper). Returns a JSON array of typed + * {@code JsonVal} result objects. + */ + public static native String instanceInvokeJson( + long instanceHandle, String name, String argsJson); + + /** + * Byte-array-returning variant. Callers who know the exported function + * returns exactly one {@code list} (or {@code string}) can skip the + * JSON detour on the return path — the Rust side hands back a raw + * {@code byte[]}. Args still go through JSON. + */ + public static native byte[] instanceInvokeJsonReturningBytes( + long instanceHandle, String name, String argsJson); + + /** + * Introspect an exported function's arity as an ASCII byte blob of + * the form {@code "argCount:resultCount"} (e.g. {@code "2:1"} for a + * function taking two args and returning one result). Used by the + * Java marshalling helper when it needs to size a param list without + * a WIT descriptor in hand. + */ + public static native byte[] instanceFunctionArity(long instanceHandle, String name); + + /** + * Cheap {@code exports.contains(name)} probe against a live instance. + * Avoids the whole export list when a caller only needs a boolean. + */ + public static native boolean instanceHasFunction(long instanceHandle, String name); + + // ---- Future handle lifecycle ------------------------------------------ + // + // Val::Future marshalling (slice-1 async, r.2): FutureAny values returned + // from a guest call are parked in a per-instance FutureRegistry on the + // Rust side; Java receives a {@code WitFuture} carrying just the u64 slot + // id + a Debug-formatted type-name hint. These two natives are the + // Java-visible lifecycle surface for those parked handles. + + /** + * Close a parked {@code FutureAny} — evicts it from the instance's + * future registry and calls wasmtime's {@code FutureAny::close(store)} + * so the writer end sees the read end as dropped. Throws + * {@link ai.tegmentum.webassembly4j.api.exception.WebAssemblyException} + * if the slot id is unknown (already closed, transferred to a guest via + * pass-in, or never parked) or the instance handle is null. + */ + public static native void futureClose(long instanceHandle, long futureId); + + /** + * Attempt to await a parked {@code FutureAny}. Currently ALWAYS throws + * {@link ai.tegmentum.webassembly4j.api.exception.WebAssemblyException} + * with a clear "wasmtime 47 API gap" message. See the Rust-side + * {@code futureAwait} doc for the underlying reason — the public + * {@code FutureAny} surface has no dynamic-payload-type await/poll + * method, so runtime-typed marshalling can't lift the resolved value. + * + *

Kept as a JNI entry point so the Java surface is future-proof: + * when wasmtime exposes a dynamic await API, only the Rust body needs + * to swap in — the JNI signature and the Java-side wrapper stay put. + */ + public static native String futureAwait(long instanceHandle, long futureId); + + // ---- Stream handle lifecycle ------------------------------------------ + // + // Val::Stream marshalling (slice-2 async): structurally identical to + // futures — StreamAny values returned from a guest call are parked in a + // per-instance StreamRegistry on the Rust side; Java receives a {@code + // WitStream} carrying just the u64 slot id + a Debug-formatted type-name + // hint. Same wasmtime 47 "typed-reader only" API gap applies to reading. + + /** + * Close a parked {@code StreamAny} — evicts it from the instance's + * stream registry and calls wasmtime's {@code StreamAny::close(store)} + * so the writer end sees the read end as dropped. Symmetric with + * {@link #futureClose}. + */ + public static native void streamClose(long instanceHandle, long streamId); + + /** + * Attempt to read from a parked {@code StreamAny}. Currently ALWAYS + * throws {@link ai.tegmentum.webassembly4j.api.exception.WebAssemblyException} + * with a "wasmtime 47 API gap" message — same story as + * {@link #futureAwait}, since {@code StreamAny::try_into_stream_reader} + * requires a compile-time {@code T}. Kept as a stable JNI entry so the + * Java surface is future-proof; only the Rust body swaps in when + * wasmtime exposes a dynamic API. + */ + public static native String streamRead(long instanceHandle, long streamId); + + // ---- ErrorContext handle lifecycle ------------------------------------ + // + // Val::ErrorContext marshalling (slice-3): wasmtime 47's ErrorContextAny + // is a placeholder (see FIXME(#11161) upstream) with `pub(crate) u32` + // internals and no publicly-defined dispose. We park the Val on the Rust + // side purely to allow round-trip pass-back; Java sees a slot id + the + // parsed numeric rep as an equality-adjacent hint. + + /** + * Evict a parked error-context handle from the instance's registry. + * Pure Rust-side eviction — wasmtime 47 has no dispose surface on + * error-context, so this simply drops the parked handle so subsequent + * pass-in attempts against this id fail cleanly. + */ + public static native void errorContextClose(long instanceHandle, long errorContextId); +} diff --git a/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/jni/WasmosNativeLoader.java b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/jni/WasmosNativeLoader.java new file mode 100644 index 0000000..1122eae --- /dev/null +++ b/wasmos-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmos/jni/WasmosNativeLoader.java @@ -0,0 +1,156 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package ai.tegmentum.webassembly4j.provider.wasmos.jni; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Loads the {@code libwasmos_provider.{so,dylib,dll}} native library out of + * the packaged jar's {@code /natives/{os}-{arch}/} directory. + * + *

Deliberately self-contained (no dependency on wasmtime4j's + * {@code wasmtime4j-native-loader}) so wasmos-provider stands alone with + * no wasmtime4j coupling — per the F-Webassembly4j-Wasmos-Provider r.1 + * architectural constraint. + * + *

The layout matches wasmtime4j-native's convention: + *

+ *   /natives/darwin-aarch64/libwasmos_provider.dylib
+ *   /natives/darwin-x86_64/libwasmos_provider.dylib
+ *   /natives/linux-x86_64/libwasmos_provider.so
+ *   /natives/linux-aarch64/libwasmos_provider.so
+ *   /natives/windows-x86_64/wasmos_provider.dll
+ * 
+ * + *

System properties for troubleshooting: + *

    + *
  • {@code webassembly4j.wasmos.native.path} — absolute path to a prebuilt + * dylib. Bypasses jar extraction. Useful during development when + * cargo builds outside the Maven cycle. + *
  • {@code webassembly4j.wasmos.native.debug} — set to {@code true} to + * log resolution attempts to {@code System.err}. + *
+ */ +final class WasmosNativeLoader { + + private static final String PROP_OVERRIDE = "webassembly4j.wasmos.native.path"; + private static final String PROP_DEBUG = "webassembly4j.wasmos.native.debug"; + private static final String LIB_BASENAME = "wasmos_provider"; + + // Loaded exactly once per classloader; a second call is a no-op (System.load + // would throw UnsatisfiedLinkError on double-load of the same absolute path, + // and the singleton fingerprint gives idempotency in the face of static-init + // races across parallel threads). + private static volatile boolean loaded = false; + + private WasmosNativeLoader() {} + + static synchronized void load() { + if (loaded) { + return; + } + final String override = System.getProperty(PROP_OVERRIDE); + if (override != null && !override.isEmpty()) { + debug("using override path: " + override); + System.load(override); + loaded = true; + return; + } + + final String platform = detectPlatform(); + final String fileName = libraryFileName(platform); + final String resourcePath = "/natives/" + platform + "/" + fileName; + debug("looking up " + resourcePath); + + try (InputStream in = WasmosNativeLoader.class.getResourceAsStream(resourcePath)) { + if (in == null) { + throw new UnsatisfiedLinkError( + "wasmos-provider native library not found on classpath at " + resourcePath + + " — either the wasmos-provider jar was built without the platform's" + + " native library, or the current platform (" + platform + ") is not" + + " supported. Override with -D" + PROP_OVERRIDE + "=/absolute/path."); + } + final Path tmp = Files.createTempFile("wasmos-provider-", fileName); + tmp.toFile().deleteOnExit(); + // TRUNCATE_EXISTING: createTempFile made an empty file we're + // about to fill; the option keeps us defensive if the file + // ever gets prefixed by unrelated content between calls. + try (OutputStream out = Files.newOutputStream( + tmp, java.nio.file.StandardOpenOption.TRUNCATE_EXISTING)) { + copy(in, out); + } + debug("extracted to " + tmp); + System.load(tmp.toAbsolutePath().toString()); + loaded = true; + } catch (IOException e) { + throw new UnsatisfiedLinkError( + "failed to extract wasmos-provider native library: " + e.getMessage()); + } + } + + private static String detectPlatform() { + final String os = System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT); + final String archRaw = System.getProperty("os.arch", "").toLowerCase(java.util.Locale.ROOT); + final String arch; + switch (archRaw) { + case "aarch64": + case "arm64": + arch = "aarch64"; + break; + case "x86_64": + case "amd64": + arch = "x86_64"; + break; + default: + arch = archRaw; + } + final String osFamily; + if (os.contains("mac") || os.contains("darwin")) { + osFamily = "darwin"; + } else if (os.contains("linux")) { + osFamily = "linux"; + } else if (os.contains("win")) { + osFamily = "windows"; + } else { + osFamily = os; + } + return osFamily + "-" + arch; + } + + private static String libraryFileName(String platform) { + if (platform.startsWith("windows-")) { + return LIB_BASENAME + ".dll"; + } + if (platform.startsWith("darwin-")) { + return "lib" + LIB_BASENAME + ".dylib"; + } + // linux + everything else — .so + return "lib" + LIB_BASENAME + ".so"; + } + + private static void copy(InputStream in, OutputStream out) throws IOException { + final byte[] buf = new byte[64 * 1024]; + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + } + + private static void debug(String msg) { + if (Boolean.getBoolean(PROP_DEBUG)) { + System.err.println("[wasmos-provider-loader] " + msg); + } + } + +} diff --git a/wasmos-provider/src/main/resources/META-INF/services/ai.tegmentum.webassembly4j.spi.EngineProvider b/wasmos-provider/src/main/resources/META-INF/services/ai.tegmentum.webassembly4j.spi.EngineProvider new file mode 100644 index 0000000..b805d91 --- /dev/null +++ b/wasmos-provider/src/main/resources/META-INF/services/ai.tegmentum.webassembly4j.spi.EngineProvider @@ -0,0 +1 @@ +ai.tegmentum.webassembly4j.provider.wasmos.WasmosProvider diff --git a/wasmos-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosAsyncInvokeTest.java b/wasmos-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosAsyncInvokeTest.java new file mode 100644 index 0000000..b39dc30 --- /dev/null +++ b/wasmos-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosAsyncInvokeTest.java @@ -0,0 +1,616 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package ai.tegmentum.webassembly4j.provider.wasmos; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import ai.tegmentum.webassembly4j.api.Component; +import ai.tegmentum.webassembly4j.api.ComponentInstance; +import ai.tegmentum.webassembly4j.api.Engine; +import ai.tegmentum.webassembly4j.provider.wasmos.ext.WasmosAsyncExtension; +import ai.tegmentum.webassembly4j.provider.wasmos.ext.WitErrorContextException; +import ai.tegmentum.webassembly4j.spi.EngineProvider; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ServiceLoader; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Slice-1 async coverage for wasmos-provider. The MVP promise is: + * {@code instance.extension(WasmosAsyncExtension.class).invokeAsync(...)} + * returns a {@link CompletableFuture} that behaves like a well-formed + * async wrapper around the sync invoke path — the chain composes, errors + * propagate, and cancel() at least severs the downstream promise. + * + *

These tests reuse the {@code composed_wasmtime.wasm} demo fixture as + * the async surface's north-star acceptance case (mirroring the sync-side + * {@link WasmosProviderDemoTest#composedRunReturns42}). If the fixture + * isn't present the async tests skip via {@link org.junit.jupiter.api.Assumptions} + * — the local wasmos checkout is a soft dependency, not a hard one. + */ +class WasmosAsyncInvokeTest { + + private static final Path COMPOSED_FIXTURE = Path.of( + System.getProperty("user.home"), + "git", "wasmos", "tests", "e2e-fixtures", "composed_wasmtime.wasm"); + + private static EngineProvider wasmosProvider() { + return ServiceLoader.load(EngineProvider.class) + .stream() + .map(ServiceLoader.Provider::get) + .filter(p -> "wasmos".equals(p.descriptor().providerId())) + .findFirst() + .orElseThrow(() -> new AssertionError( + "wasmos provider not on classpath — see WasmosProviderDemoTest")); + } + + @Test + @DisplayName("extension(WasmosAsyncExtension.class) is present on wasmos component instance") + void extensionIsPresent() { + assumeTrue(Files.exists(COMPOSED_FIXTURE), + "composed_wasmtime.wasm fixture not present at " + COMPOSED_FIXTURE); + try (Engine engine = wasmosProvider().create(null); + Component component = engine.loadComponent(Files.readAllBytes(COMPOSED_FIXTURE))) { + final ComponentInstance instance = component.instantiate(); + try { + assertTrue(instance.extension(WasmosAsyncExtension.class).isPresent(), + "wasmos component instance must surface WasmosAsyncExtension"); + } finally { + if (instance instanceof WasmosComponentInstanceAdapter) { + ((WasmosComponentInstanceAdapter) instance).closeInternal(); + } + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @DisplayName("invokeAsync(\"run\").get() returns 42 through the demo fixture") + void invokeAsyncRunReturns42() throws Exception { + assumeTrue(Files.exists(COMPOSED_FIXTURE), + "composed_wasmtime.wasm fixture not present at " + COMPOSED_FIXTURE); + final byte[] bytes = Files.readAllBytes(COMPOSED_FIXTURE); + + try (Engine engine = wasmosProvider().create(null); + Component component = engine.loadComponent(bytes)) { + final ComponentInstance instance = component.instantiate(); + try { + final WasmosAsyncExtension async = instance + .extension(WasmosAsyncExtension.class) + .orElseThrow(() -> new AssertionError("async extension missing")); + + final CompletableFuture future = async.invokeAsync("run"); + assertNotNull(future, "invokeAsync must return a non-null future"); + + // 30s cap is generous — the sync path returns in ms; timing + // out here would indicate the worker pool never dispatched. + final Object result = future.get(30, TimeUnit.SECONDS); + assertEquals(42, ((Number) result).intValue(), + "invokeAsync('run') must return 42, matching sync semantics"); + } finally { + if (instance instanceof WasmosComponentInstanceAdapter) { + ((WasmosComponentInstanceAdapter) instance).closeInternal(); + } + } + } + } + + @Test + @DisplayName("thenApply chain composes: invokeAsync('run').thenApply(+1).get() returns 43") + void thenApplyChainComposes() throws Exception { + assumeTrue(Files.exists(COMPOSED_FIXTURE), + "composed_wasmtime.wasm fixture not present at " + COMPOSED_FIXTURE); + final byte[] bytes = Files.readAllBytes(COMPOSED_FIXTURE); + + try (Engine engine = wasmosProvider().create(null); + Component component = engine.loadComponent(bytes)) { + final ComponentInstance instance = component.instantiate(); + try { + final WasmosAsyncExtension async = instance + .extension(WasmosAsyncExtension.class) + .orElseThrow(() -> new AssertionError("async extension missing")); + + final Integer result = async.invokeAsync("run") + .thenApply(x -> ((Number) x).intValue() + 1) + .get(30, TimeUnit.SECONDS); + assertEquals(43, result.intValue(), + "thenApply on invokeAsync must propagate the (+1) transform"); + } finally { + if (instance instanceof WasmosComponentInstanceAdapter) { + ((WasmosComponentInstanceAdapter) instance).closeInternal(); + } + } + } + } + + @Test + @DisplayName("invokeAsync on a missing export completes exceptionally with ExecutionException") + void invokeAsyncMissingExportCompletesExceptionally() throws Exception { + assumeTrue(Files.exists(COMPOSED_FIXTURE), + "composed_wasmtime.wasm fixture not present at " + COMPOSED_FIXTURE); + final byte[] bytes = Files.readAllBytes(COMPOSED_FIXTURE); + + try (Engine engine = wasmosProvider().create(null); + Component component = engine.loadComponent(bytes)) { + final ComponentInstance instance = component.instantiate(); + try { + final WasmosAsyncExtension async = instance + .extension(WasmosAsyncExtension.class) + .orElseThrow(() -> new AssertionError("async extension missing")); + + final CompletableFuture future = + async.invokeAsync("this-export-does-not-exist"); + + final ExecutionException ex = assertThrows(ExecutionException.class, + () -> future.get(30, TimeUnit.SECONDS)); + assertNotNull(ex.getCause(), + "ExecutionException must wrap the underlying invoke failure"); + } finally { + if (instance instanceof WasmosComponentInstanceAdapter) { + ((WasmosComponentInstanceAdapter) instance).closeInternal(); + } + } + } + } + + @Test + @DisplayName("cancel(true) marks the Java future cancelled — WASM interrupt is best-effort") + void cancelIsBestEffort() throws Exception { + assumeTrue(Files.exists(COMPOSED_FIXTURE), + "composed_wasmtime.wasm fixture not present at " + COMPOSED_FIXTURE); + final byte[] bytes = Files.readAllBytes(COMPOSED_FIXTURE); + + try (Engine engine = wasmosProvider().create(null); + Component component = engine.loadComponent(bytes)) { + final ComponentInstance instance = component.instantiate(); + try { + final WasmosAsyncExtension async = instance + .extension(WasmosAsyncExtension.class) + .orElseThrow(() -> new AssertionError("async extension missing")); + + // Kick off a call and immediately cancel it. On this fast + // demo the worker may have already finished — either way, + // the semantics we're pinning down are: + // * cancel(true) never throws + // * post-cancel, the future either reports cancelled OR + // already-completed (well-defined race; we accept both) + // * the promise chain sees CancellationException on + // downstream get() if cancel won the race + final CompletableFuture future = async.invokeAsync("run"); + final boolean cancelResult = future.cancel(true); + + if (cancelResult) { + assertTrue(future.isCancelled(), + "cancel(true) that returned true must leave isCancelled() true"); + assertThrows(CancellationException.class, + () -> future.get(5, TimeUnit.SECONDS), + "cancelled future must throw CancellationException on get()"); + } else { + // Lost the race — the future had already completed + // normally before cancel could take effect. Confirm the + // result is still the expected 42 so we know we're not + // silently masking a broken invoke path. + assertTrue(future.isDone(), + "cancel(true)==false must mean the future had already completed"); + assertFalse(future.isCancelled(), + "cancel(true)==false must leave isCancelled() false"); + assertEquals(42, ((Number) future.get()).intValue(), + "if cancel lost the race, the result must still be the sync 42"); + } + } finally { + if (instance instanceof WasmosComponentInstanceAdapter) { + ((WasmosComponentInstanceAdapter) instance).closeInternal(); + } + } + } + } + + @Test + @DisplayName("awaitFuture on unknown WitFuture id completes exceptionally with API-gap message") + void awaitFutureUnknownIdSurfacesGap() throws Exception { + // Slice-1 r.2 boundary test — the parked-slot lookup on the Rust + // side happens BEFORE the "wasmtime API gap" bail-out, so hitting + // an unknown id gets the standard "unknown future id" error path, + // NOT the API-gap message. The API-gap message only surfaces once + // the slot lookup succeeds. Both paths must complete the Java + // future exceptionally (never throw synchronously from awaitFuture) + // and both must wrap the JNI failure so downstream error handling + // has a stable exception shape. + assumeTrue(Files.exists(COMPOSED_FIXTURE), + "composed_wasmtime.wasm fixture not present at " + COMPOSED_FIXTURE); + final byte[] bytes = Files.readAllBytes(COMPOSED_FIXTURE); + + try (Engine engine = wasmosProvider().create(null); + Component component = engine.loadComponent(bytes)) { + final ComponentInstance instance = component.instantiate(); + try { + final WasmosAsyncExtension async = instance + .extension(WasmosAsyncExtension.class) + .orElseThrow(() -> new AssertionError("async extension missing")); + + // No component in-tree returns a `future`, so we can't + // exercise a real parked handle end-to-end here — but the + // "unknown id" path is what the API-gap swap-in will + // preserve, so it's the right shape to lock down. + final WasmosMarshalling.WitFuture bogus = + new WasmosMarshalling.WitFuture(999_999L, "no-such-future"); + + final CompletableFuture future = async.awaitFuture(bogus); + assertNotNull(future, "awaitFuture must never return null"); + final ExecutionException ex = assertThrows(ExecutionException.class, + () -> future.get(10, TimeUnit.SECONDS)); + assertNotNull(ex.getCause(), + "awaitFuture failure must wrap the JNI exception in ExecutionException"); + final String msg = ex.getCause().getMessage(); + assertTrue(msg.contains("999999") || msg.contains("unknown future id"), + "expected 'unknown future id' or the numeric id in the error; got: " + msg); + } finally { + if (instance instanceof WasmosComponentInstanceAdapter) { + ((WasmosComponentInstanceAdapter) instance).closeInternal(); + } + } + } + } + + @Test + @DisplayName("awaitFuture(null) completes exceptionally with IllegalArgumentException") + void awaitFutureRejectsNullArg() throws Exception { + // Type-safety guard on the Object-typed carrier: passing null (or a + // non-WitFuture) must fail cleanly rather than NPE'ing inside the + // JNI shim. Completes-exceptionally rather than sync-throws so + // callers using thenApply/handle chains see a uniform failure shape. + assumeTrue(Files.exists(COMPOSED_FIXTURE), + "composed_wasmtime.wasm fixture not present at " + COMPOSED_FIXTURE); + final byte[] bytes = Files.readAllBytes(COMPOSED_FIXTURE); + + try (Engine engine = wasmosProvider().create(null); + Component component = engine.loadComponent(bytes)) { + final ComponentInstance instance = component.instantiate(); + try { + final WasmosAsyncExtension async = instance + .extension(WasmosAsyncExtension.class) + .orElseThrow(() -> new AssertionError("async extension missing")); + + final CompletableFuture future = async.awaitFuture(null); + assertNotNull(future); + final ExecutionException ex = assertThrows(ExecutionException.class, + () -> future.get(5, TimeUnit.SECONDS)); + assertTrue(ex.getCause() instanceof IllegalArgumentException, + "awaitFuture(null) should complete exceptionally with IAE; got " + + ex.getCause()); + + // Same shape for a wrongly-typed argument. + final CompletableFuture f2 = async.awaitFuture("not-a-witfuture"); + final ExecutionException ex2 = assertThrows(ExecutionException.class, + () -> f2.get(5, TimeUnit.SECONDS)); + assertTrue(ex2.getCause() instanceof IllegalArgumentException); + } finally { + if (instance instanceof WasmosComponentInstanceAdapter) { + ((WasmosComponentInstanceAdapter) instance).closeInternal(); + } + } + } + } + + @Test + @DisplayName("closeFuture on unknown WitFuture id throws ExecutionException wrapping the JNI failure") + void closeFutureUnknownIdThrows() throws Exception { + // Synchronous by design (no async pool detour) — closeFuture is a + // registry eviction + wasmtime FutureAny::close(store), fast enough + // that offloading to a worker adds latency without value. + assumeTrue(Files.exists(COMPOSED_FIXTURE), + "composed_wasmtime.wasm fixture not present at " + COMPOSED_FIXTURE); + final byte[] bytes = Files.readAllBytes(COMPOSED_FIXTURE); + + try (Engine engine = wasmosProvider().create(null); + Component component = engine.loadComponent(bytes)) { + final ComponentInstance instance = component.instantiate(); + try { + final WasmosAsyncExtension async = instance + .extension(WasmosAsyncExtension.class) + .orElseThrow(() -> new AssertionError("async extension missing")); + + // Null / wrong type — IllegalArgumentException, synchronous. + assertThrows(IllegalArgumentException.class, + () -> async.closeFuture(null)); + assertThrows(IllegalArgumentException.class, + () -> async.closeFuture(new Object())); + + // Unknown id — the JNI throws WebAssemblyException; the + // extension wraps it in ExecutionException to keep the + // caller-visible exception type consistent with invoke + // failures. + final WasmosMarshalling.WitFuture bogus = + new WasmosMarshalling.WitFuture(424242L, "gone"); + final ai.tegmentum.webassembly4j.api.exception.ExecutionException ex = + assertThrows( + ai.tegmentum.webassembly4j.api.exception.ExecutionException.class, + () -> async.closeFuture(bogus)); + assertTrue(ex.getMessage().contains("424242") + || ex.getMessage().contains("unknown future id"), + "expected 'unknown future id' or the numeric id in the error; got: " + + ex.getMessage()); + } finally { + if (instance instanceof WasmosComponentInstanceAdapter) { + ((WasmosComponentInstanceAdapter) instance).closeInternal(); + } + } + } + } + + @Test + @DisplayName("readStream on unknown WitStream id completes exceptionally with wrapping ExecutionException") + void readStreamUnknownIdSurfacesGap() throws Exception { + // Structural mirror of awaitFutureUnknownIdSurfacesGap. The parked + // slot lookup happens BEFORE the wasmtime API-gap bail-out, so an + // unknown id gets the "unknown stream id" error path. Both paths + // must complete the Java future exceptionally. + assumeTrue(Files.exists(COMPOSED_FIXTURE), + "composed_wasmtime.wasm fixture not present at " + COMPOSED_FIXTURE); + final byte[] bytes = Files.readAllBytes(COMPOSED_FIXTURE); + + try (Engine engine = wasmosProvider().create(null); + Component component = engine.loadComponent(bytes)) { + final ComponentInstance instance = component.instantiate(); + try { + final WasmosAsyncExtension async = instance + .extension(WasmosAsyncExtension.class) + .orElseThrow(() -> new AssertionError("async extension missing")); + + final WasmosMarshalling.WitStream bogus = + new WasmosMarshalling.WitStream(777_777L, "no-such-stream"); + + final CompletableFuture future = async.readStream(bogus); + assertNotNull(future, "readStream must never return null"); + final ExecutionException ex = assertThrows(ExecutionException.class, + () -> future.get(10, TimeUnit.SECONDS)); + assertNotNull(ex.getCause(), + "readStream failure must wrap the JNI exception in ExecutionException"); + final String msg = ex.getCause().getMessage(); + assertTrue(msg.contains("777777") || msg.contains("unknown stream id"), + "expected 'unknown stream id' or the numeric id in the error; got: " + msg); + } finally { + if (instance instanceof WasmosComponentInstanceAdapter) { + ((WasmosComponentInstanceAdapter) instance).closeInternal(); + } + } + } + } + + @Test + @DisplayName("readStream(null) completes exceptionally with IllegalArgumentException") + void readStreamRejectsNullArg() throws Exception { + // Type-safety guard on the Object-typed carrier — passing null or + // a non-WitStream must fail cleanly rather than NPE'ing inside the + // JNI shim. + assumeTrue(Files.exists(COMPOSED_FIXTURE), + "composed_wasmtime.wasm fixture not present at " + COMPOSED_FIXTURE); + final byte[] bytes = Files.readAllBytes(COMPOSED_FIXTURE); + + try (Engine engine = wasmosProvider().create(null); + Component component = engine.loadComponent(bytes)) { + final ComponentInstance instance = component.instantiate(); + try { + final WasmosAsyncExtension async = instance + .extension(WasmosAsyncExtension.class) + .orElseThrow(() -> new AssertionError("async extension missing")); + + final CompletableFuture future = async.readStream(null); + assertNotNull(future); + final ExecutionException ex = assertThrows(ExecutionException.class, + () -> future.get(5, TimeUnit.SECONDS)); + assertTrue(ex.getCause() instanceof IllegalArgumentException); + + final CompletableFuture f2 = async.readStream("not-a-witstream"); + final ExecutionException ex2 = assertThrows(ExecutionException.class, + () -> f2.get(5, TimeUnit.SECONDS)); + assertTrue(ex2.getCause() instanceof IllegalArgumentException); + } finally { + if (instance instanceof WasmosComponentInstanceAdapter) { + ((WasmosComponentInstanceAdapter) instance).closeInternal(); + } + } + } + } + + @Test + @DisplayName("closeStream on unknown WitStream id throws ExecutionException wrapping the JNI failure") + void closeStreamUnknownIdThrows() throws Exception { + // Synchronous — mirrors closeFutureUnknownIdThrows. + assumeTrue(Files.exists(COMPOSED_FIXTURE), + "composed_wasmtime.wasm fixture not present at " + COMPOSED_FIXTURE); + final byte[] bytes = Files.readAllBytes(COMPOSED_FIXTURE); + + try (Engine engine = wasmosProvider().create(null); + Component component = engine.loadComponent(bytes)) { + final ComponentInstance instance = component.instantiate(); + try { + final WasmosAsyncExtension async = instance + .extension(WasmosAsyncExtension.class) + .orElseThrow(() -> new AssertionError("async extension missing")); + + assertThrows(IllegalArgumentException.class, + () -> async.closeStream(null)); + assertThrows(IllegalArgumentException.class, + () -> async.closeStream(new Object())); + + final WasmosMarshalling.WitStream bogus = + new WasmosMarshalling.WitStream(313131L, "gone"); + final ai.tegmentum.webassembly4j.api.exception.ExecutionException ex = + assertThrows( + ai.tegmentum.webassembly4j.api.exception.ExecutionException.class, + () -> async.closeStream(bogus)); + assertTrue(ex.getMessage().contains("313131") + || ex.getMessage().contains("unknown stream id"), + "expected 'unknown stream id' or the numeric id in the error; got: " + + ex.getMessage()); + } finally { + if (instance instanceof WasmosComponentInstanceAdapter) { + ((WasmosComponentInstanceAdapter) instance).closeInternal(); + } + } + } + } + + @Test + @DisplayName("closeErrorContext on unknown WitErrorContext id throws ExecutionException wrapping the JNI failure") + void closeErrorContextUnknownIdThrows() throws Exception { + // Synchronous like the other close* methods. The Rust side has no + // wasmtime dispose to call — it's pure registry eviction — but the + // "unknown id" error surface is the same shape. + assumeTrue(Files.exists(COMPOSED_FIXTURE), + "composed_wasmtime.wasm fixture not present at " + COMPOSED_FIXTURE); + final byte[] bytes = Files.readAllBytes(COMPOSED_FIXTURE); + + try (Engine engine = wasmosProvider().create(null); + Component component = engine.loadComponent(bytes)) { + final ComponentInstance instance = component.instantiate(); + try { + final WasmosAsyncExtension async = instance + .extension(WasmosAsyncExtension.class) + .orElseThrow(() -> new AssertionError("async extension missing")); + + assertThrows(IllegalArgumentException.class, + () -> async.closeErrorContext(null)); + assertThrows(IllegalArgumentException.class, + () -> async.closeErrorContext(new Object())); + + final WasmosMarshalling.WitErrorContext bogus = + new WasmosMarshalling.WitErrorContext(212121L, 5L); + final ai.tegmentum.webassembly4j.api.exception.ExecutionException ex = + assertThrows( + ai.tegmentum.webassembly4j.api.exception.ExecutionException.class, + () -> async.closeErrorContext(bogus)); + assertTrue(ex.getMessage().contains("212121") + || ex.getMessage().contains("unknown error-context id"), + "expected 'unknown error-context id' or the numeric id in the error; got: " + + ex.getMessage()); + } finally { + if (instance instanceof WasmosComponentInstanceAdapter) { + ((WasmosComponentInstanceAdapter) instance).closeInternal(); + } + } + } + } + + @Test + @DisplayName("wrapErrorContext produces a WitErrorContextException that completes a CF exceptionally") + void wrapErrorContextCompletesFutureExceptionally() throws Exception { + // The integration story: a caller receives a WitErrorContext (via an + // invoke result), transforms it into a WitErrorContextException in a + // thenApply / handle chain, and downstream get() surfaces the + // exception with the carrier still inspectable via .errorContext(). + // Uses a synthetic WitErrorContext so no fixture-returning-error-context + // is required (none exists in-tree). + assumeTrue(Files.exists(COMPOSED_FIXTURE), + "composed_wasmtime.wasm fixture not present at " + COMPOSED_FIXTURE); + final byte[] bytes = Files.readAllBytes(COMPOSED_FIXTURE); + + try (Engine engine = wasmosProvider().create(null); + Component component = engine.loadComponent(bytes)) { + final ComponentInstance instance = component.instantiate(); + try { + final WasmosAsyncExtension async = instance + .extension(WasmosAsyncExtension.class) + .orElseThrow(() -> new AssertionError("async extension missing")); + + // Type-safety guards first — null / wrong type. + assertThrows(IllegalArgumentException.class, + () -> async.wrapErrorContext(null)); + assertThrows(IllegalArgumentException.class, + () -> async.wrapErrorContext(new Object())); + + final WasmosMarshalling.WitErrorContext we = + new WasmosMarshalling.WitErrorContext(101L, 202L); + final WitErrorContextException wrapped = async.wrapErrorContext(we); + assertNotNull(wrapped, "wrapErrorContext must never return null"); + assertNotNull(wrapped.errorContext(), + "wrapped exception must carry the underlying handle"); + assertTrue(wrapped.getMessage().contains("101") + && wrapped.getMessage().contains("202"), + "wrapped exception message should include tableId + rep; got: " + + wrapped.getMessage()); + + // The lift-through-CompletableFuture story. + final CompletableFuture src = CompletableFuture.completedFuture(we); + final CompletableFuture composed = src.thenApply(v -> { + throw async.wrapErrorContext(v); + }); + final ExecutionException ex = assertThrows(ExecutionException.class, + () -> composed.get(5, TimeUnit.SECONDS)); + assertTrue(ex.getCause() instanceof WitErrorContextException, + "cause should be WitErrorContextException; got " + + (ex.getCause() == null ? "null" : ex.getCause().getClass())); + final WitErrorContextException cause = (WitErrorContextException) ex.getCause(); + assertEquals(we, cause.errorContext(), + "wrapped handle should equal the source WitErrorContext"); + } finally { + if (instance instanceof WasmosComponentInstanceAdapter) { + ((WasmosComponentInstanceAdapter) instance).closeInternal(); + } + } + } + } + + @Test + @DisplayName("engine close() shuts down the async pool cleanly") + void engineCloseShutsDownAsyncPool() throws Exception { + assumeTrue(Files.exists(COMPOSED_FIXTURE), + "composed_wasmtime.wasm fixture not present at " + COMPOSED_FIXTURE); + final byte[] bytes = Files.readAllBytes(COMPOSED_FIXTURE); + + final Engine engine = wasmosProvider().create(null); + try { + final Component component = engine.loadComponent(bytes); + final ComponentInstance instance = component.instantiate(); + try { + final WasmosAsyncExtension async = instance + .extension(WasmosAsyncExtension.class) + .orElseThrow(() -> new AssertionError("async extension missing")); + + // Prime the pool by dispatching at least one invoke — this + // forces asyncExecutor() to create the ThreadPoolExecutor + // whose shutdown we want to exercise. + async.invokeAsync("run").get(30, TimeUnit.SECONDS); + } finally { + if (instance instanceof WasmosComponentInstanceAdapter) { + ((WasmosComponentInstanceAdapter) instance).closeInternal(); + } + component.close(); + } + } finally { + // The main assertion: close() returns in bounded time with the + // pool torn down. If pool shutdown hangs, the JVM-level test + // timeout will fail this. Bounded via an explicit future in + // case someone regresses the internal awaitTermination. + final CompletableFuture closed = CompletableFuture.runAsync(engine::close); + try { + closed.get(10, TimeUnit.SECONDS); + } catch (TimeoutException te) { + throw new AssertionError("engine.close() did not return within 10s — " + + "async pool shutdown likely hanging"); + } + } + } +} diff --git a/wasmos-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosMarshallingTest.java b/wasmos-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosMarshallingTest.java new file mode 100644 index 0000000..90024e9 --- /dev/null +++ b/wasmos-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosMarshallingTest.java @@ -0,0 +1,575 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package ai.tegmentum.webassembly4j.provider.wasmos; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Round-trip tests for {@link WasmosMarshalling} — Java object -> JSON -> Java + * object. + * + *

The full end-to-end path is Java -> JSON -> Rust {@code JsonVal} -> {@code Val} + * -> wasmtime -> {@code Val} -> {@code JsonVal} -> JSON -> Java. This test + * covers the Java-side halves; the Rust-side round-trip is validated by the + * {@code #[cfg(test)]} module in {@code wasmos-provider/native/src/lib.rs}. + */ +final class WasmosMarshallingTest { + + /** + * Round-trip a single Java value through marshal(args) + unmarshal(results), + * treating results as if it were the args JSON blob (they share the exact + * same JsonVal-array schema). Returns the round-tripped Java object. + */ + private static Object roundTrip(Object v) { + final String json = WasmosMarshalling.marshalArgs(new Object[] { v }); + final List back = WasmosMarshalling.unmarshalResults(json); + assertEquals(1, back.size(), () -> "expected exactly one result for " + json); + return back.get(0); + } + + @Test + @DisplayName("primitive round-trips preserve type + value") + void primitives() { + assertEquals(Boolean.TRUE, roundTrip(Boolean.TRUE)); + assertEquals(Boolean.FALSE, roundTrip(Boolean.FALSE)); + assertEquals((byte) -7, roundTrip((byte) -7)); + assertEquals((short) 1234, roundTrip((short) 1234)); + assertEquals(-42, roundTrip(-42)); + assertEquals(1_000_000_000_000L, roundTrip(1_000_000_000_000L)); + assertEquals(1.5f, roundTrip(1.5f)); + assertEquals(-2.25, roundTrip(-2.25)); + assertEquals('Z', roundTrip('Z')); + assertEquals("hello", roundTrip("hello")); + } + + @Test + @DisplayName("string round-trip handles quotes, backslashes, newlines, unicode BMP") + void stringEscapes() { + assertEquals("he said \"hi\"", roundTrip("he said \"hi\"")); + assertEquals("a\\b", roundTrip("a\\b")); + assertEquals("line1\nline2", roundTrip("line1\nline2")); + assertEquals("tab\there", roundTrip("tab\there")); + assertEquals("Ω hello", roundTrip("Ω hello")); + } + + @Test + @DisplayName("byte[] round-trip preserves bytes (base64 wire form)") + void bytesFastPath() { + final byte[] input = { 0, 1, 2, 3, 127, -1, -128 }; + final Object round = roundTrip(input); + assertInstanceOf(byte[].class, round); + assertArrayEquals(input, (byte[]) round); + } + + @Test + @DisplayName("empty byte[] round-trips as empty byte[]") + void bytesEmpty() { + final Object round = roundTrip(new byte[0]); + assertInstanceOf(byte[].class, round); + assertEquals(0, ((byte[]) round).length); + } + + @Test + @DisplayName("unsigned wrapper types round-trip through their tagged forms") + void unsignedWrappers() { + assertEquals((short) 200, roundTrip(new WasmosMarshalling.WitU8(200))); + assertEquals(60_000, roundTrip(new WasmosMarshalling.WitU16(60_000))); + assertEquals(4_000_000_000L, roundTrip(new WasmosMarshalling.WitU32(4_000_000_000L))); + // u64 uses unsigned string encoding — round-trip preserves the exact bit pattern. + assertEquals(-1L, roundTrip(new WasmosMarshalling.WitU64(-1L))); // 0xFFFF...FFFF + } + + @Test + @DisplayName("Optional.of + Optional.empty round-trip") + void optionRoundtrip() { + final Object present = roundTrip(Optional.of(42)); + assertInstanceOf(Optional.class, present); + assertEquals(Optional.of(42), present); + + final Object empty = roundTrip(Optional.empty()); + assertInstanceOf(Optional.class, empty); + assertFalse(((Optional) empty).isPresent()); + } + + @Test + @DisplayName("null argument round-trips as Optional.empty") + void nullBecomesEmptyOption() { + final Object round = roundTrip(null); + assertInstanceOf(Optional.class, round); + assertFalse(((Optional) round).isPresent()); + } + + @Test + @DisplayName("List round-trip preserves element order + types") + void listRoundtrip() { + final List input = Arrays.asList("alpha", "beta", "gamma"); + final Object round = roundTrip(input); + assertInstanceOf(List.class, round); + assertEquals(input, round); + } + + @Test + @DisplayName("Record (Map) round-trip preserves insertion order") + void recordRoundtrip() { + final Map input = new LinkedHashMap<>(); + input.put("name", "alice"); + input.put("age", 30); + input.put("greeting", "hi"); + final Object round = roundTrip(input); + assertInstanceOf(Map.class, round); + assertEquals(new ArrayList<>(input.keySet()), + new ArrayList<>(((Map) round).keySet()), + "record field iteration order should be preserved for wasmtime lookup"); + assertEquals(input, round); + } + + @Test + @DisplayName("Set round-trip as WIT flags") + void flagsRoundtrip() { + final Set input = new LinkedHashSet<>(); + input.add("read"); + input.add("write"); + final Object round = roundTrip(input); + assertInstanceOf(Set.class, round); + assertEquals(input, round); + } + + @Test + @DisplayName("Result carriers round-trip both branches") + void resultRoundtrip() { + final Object ok = roundTrip(WasmosMarshalling.WitResult.ok(7)); + assertInstanceOf(WasmosMarshalling.WitResult.class, ok); + assertTrue(((WasmosMarshalling.WitResult) ok).isOk); + assertEquals(7, ((WasmosMarshalling.WitResult) ok).ok); + + final Object err = roundTrip(WasmosMarshalling.WitResult.err("bad")); + assertInstanceOf(WasmosMarshalling.WitResult.class, err); + assertFalse(((WasmosMarshalling.WitResult) err).isOk); + assertEquals("bad", ((WasmosMarshalling.WitResult) err).err); + + // Unit-payload branches + final Object okUnit = roundTrip(WasmosMarshalling.WitResult.ok(null)); + assertNull(((WasmosMarshalling.WitResult) okUnit).ok); + assertTrue(((WasmosMarshalling.WitResult) okUnit).isOk); + } + + @Test + @DisplayName("Variant + enum carriers survive round-trip") + void variantEnumRoundtrip() { + final Object variant = roundTrip(new WasmosMarshalling.WitVariant("some", 42)); + assertInstanceOf(WasmosMarshalling.WitVariant.class, variant); + assertEquals("some", ((WasmosMarshalling.WitVariant) variant).discriminant); + assertEquals(42, ((WasmosMarshalling.WitVariant) variant).value); + + final Object bareVariant = roundTrip(new WasmosMarshalling.WitVariant("none", null)); + assertNull(((WasmosMarshalling.WitVariant) bareVariant).value); + + final Object enumVal = roundTrip(new WasmosMarshalling.WitEnum("red")); + assertInstanceOf(WasmosMarshalling.WitEnum.class, enumVal); + assertEquals("red", ((WasmosMarshalling.WitEnum) enumVal).discriminant); + } + + @Test + @DisplayName("empty args JSON is the literal '[]'") + void emptyArgs() { + assertEquals("[]", WasmosMarshalling.marshalArgs(null)); + assertEquals("[]", WasmosMarshalling.marshalArgs(new Object[0])); + } + + @Test + @DisplayName("empty results JSON parses to empty list") + void emptyResults() { + assertEquals(0, WasmosMarshalling.unmarshalResults("[]").size()); + } + + @Test + @DisplayName("nested list-of-lists round-trip") + void nestedList() { + final List nested = Arrays.asList( + Arrays.asList(1, 2, 3), + Arrays.asList("a", "b")); + final Object round = roundTrip(nested); + assertInstanceOf(List.class, round); + assertEquals(nested, round); + } + + @Test + @DisplayName("WitMap round-trip preserves typed keys + order") + void witMapWithIntegerKeys() { + // Distinct from Record: keys are Integer (not String) so plain + // Map can't be used. WitMap forces the Val::Map branch. + final Map input = new LinkedHashMap<>(); + input.put(1, "one"); + input.put(2, "two"); + input.put(3, "three"); + final Object round = roundTrip(new WasmosMarshalling.WitMap(input)); + assertInstanceOf(Map.class, round); + final Map back = (Map) round; + assertEquals(input.size(), back.size()); + assertEquals("one", back.get(1)); + assertEquals("two", back.get(2)); + assertEquals("three", back.get(3)); + // Order preservation matters for anyone using WitMap as a + // deterministic ordered container. + assertEquals(new java.util.ArrayList<>(input.keySet()), + new java.util.ArrayList<>(back.keySet())); + } + + @Test + @DisplayName("WitMap round-trips byte[] values through Bytes fast path") + void witMapWithByteArrayValues() { + final Map input = new LinkedHashMap<>(); + input.put("first", new byte[] { 1, 2, 3 }); + input.put("second", new byte[] { -1, 0, 127 }); + final Object round = roundTrip(new WasmosMarshalling.WitMap(input)); + assertInstanceOf(Map.class, round); + final Map back = (Map) round; + assertArrayEquals(new byte[] { 1, 2, 3 }, (byte[]) back.get("first")); + assertArrayEquals(new byte[] { -1, 0, 127 }, (byte[]) back.get("second")); + } + + @Test + @DisplayName("WitMap with empty pairs round-trips as empty Map") + void witMapEmpty() { + final Object round = roundTrip(new WasmosMarshalling.WitMap(new LinkedHashMap<>())); + assertInstanceOf(Map.class, round); + assertTrue(((Map) round).isEmpty()); + } + + @Test + @DisplayName("WitResource round-trips tableId + typeIdentifier + owned") + void witResourceRoundtrip() { + // Owned handle — matches what a guest return of `own` produces + // on the Rust side. Round-trip must preserve every field byte-for-byte + // because Java uses tableId to re-find the parked ResourceAny. + final WasmosMarshalling.WitResource owned = new WasmosMarshalling.WitResource( + 42L, "ResourceType { kind: Guest { component: 0, resource: 3 } }", true); + final Object roundOwned = roundTrip(owned); + assertInstanceOf(WasmosMarshalling.WitResource.class, roundOwned); + final WasmosMarshalling.WitResource ownedBack = + (WasmosMarshalling.WitResource) roundOwned; + assertEquals(42L, ownedBack.tableId()); + assertEquals("ResourceType { kind: Guest { component: 0, resource: 3 } }", + ownedBack.typeIdentifier()); + assertTrue(ownedBack.owned()); + // Equals / hashCode consistency — WitResource is used as a value + // handle so structural equality matters for anyone parking it in a + // Set / Map. + assertEquals(owned, ownedBack); + assertEquals(owned.hashCode(), ownedBack.hashCode()); + } + + @Test + @DisplayName("WitResource borrow-flavored round-trip preserves owned=false") + void witResourceBorrowRoundtrip() { + // Same shape as the owned case but with owned=false — matches a + // WIT `borrow` on the way OUT. Round-trip must not silently + // upgrade to owned; the Rust side keys on this to decide + // take-vs-peek on the way back IN. + final WasmosMarshalling.WitResource borrow = new WasmosMarshalling.WitResource( + 7L, "my-type", false); + final Object round = roundTrip(borrow); + assertInstanceOf(WasmosMarshalling.WitResource.class, round); + final WasmosMarshalling.WitResource back = + (WasmosMarshalling.WitResource) round; + assertEquals(7L, back.tableId()); + assertEquals("my-type", back.typeIdentifier()); + assertFalse(back.owned()); + } + + @Test + @DisplayName("WitResource with large tableId (u64-adjacent) round-trips") + void witResourceLargeTableId() { + // Rust-side ids are u64; Java carries them as long. The wire form + // must survive a u64-ish value without overflow — pick something + // > Integer.MAX_VALUE. + final long largeId = 9_999_999_999L; + final WasmosMarshalling.WitResource wr = new WasmosMarshalling.WitResource( + largeId, "T", true); + final Object round = roundTrip(wr); + assertEquals(largeId, + ((WasmosMarshalling.WitResource) round).tableId()); + } + + @Test + @DisplayName("WitResource in a nested List round-trips through container marshalling") + void witResourceNestedInList() { + // Resources can appear as elements of collections just like any + // other WitVal — verify the Resource writer + reader arms compose + // with List. This is the shape a `list>` return would + // produce. + final List items = Arrays.asList( + new WasmosMarshalling.WitResource(1L, "T", true), + new WasmosMarshalling.WitResource(2L, "T", false), + new WasmosMarshalling.WitResource(3L, "T", true)); + final Object round = roundTrip(items); + assertInstanceOf(List.class, round); + final List back = (List) round; + assertEquals(3, back.size()); + assertEquals(new WasmosMarshalling.WitResource(1L, "T", true), back.get(0)); + assertEquals(new WasmosMarshalling.WitResource(2L, "T", false), back.get(1)); + assertEquals(new WasmosMarshalling.WitResource(3L, "T", true), back.get(2)); + } + + @Test + @DisplayName("WitResource typeIdentifier with special JSON chars round-trips") + void witResourceEscapedTypeIdentifier() { + // Debug output for ResourceType contains braces and colons — those + // don't need escaping in JSON strings, but quotes and backslashes + // do. The Debug format is unlikely to contain quotes, but be + // defensive so a future wasmtime change doesn't silently corrupt + // the type identifier field on the wire. + final WasmosMarshalling.WitResource wr = new WasmosMarshalling.WitResource( + 1L, "quoted \"value\" with \\ backslash", false); + final Object round = roundTrip(wr); + assertEquals("quoted \"value\" with \\ backslash", + ((WasmosMarshalling.WitResource) round).typeIdentifier()); + } + + @Test + @DisplayName("WitFuture round-trips tableId + typeIdentifier") + void witFutureRoundtrip() { + // Return-direction shape: a WitFuture parked by the Rust decoder, + // handed to Java, then round-tripped through the marshalling layer. + // Every field must survive byte-for-byte — tableId is what Java uses + // to re-find the parked FutureAny (or call futureClose against it). + final WasmosMarshalling.WitFuture wf = new WasmosMarshalling.WitFuture( + 7L, "FutureAny { id: TableId(3), ty: Guest(Future(u32)) }"); + final Object round = roundTrip(wf); + assertInstanceOf(WasmosMarshalling.WitFuture.class, round); + final WasmosMarshalling.WitFuture back = (WasmosMarshalling.WitFuture) round; + assertEquals(7L, back.tableId()); + assertEquals("FutureAny { id: TableId(3), ty: Guest(Future(u32)) }", + back.typeIdentifier()); + // Value-shape equality — a WitFuture might be parked in a Set / Map + // by callers doing bookkeeping across multiple futures, so equals / + // hashCode consistency matters. + assertEquals(wf, back); + assertEquals(wf.hashCode(), back.hashCode()); + } + + @Test + @DisplayName("WitFuture with large tableId (u64-adjacent) round-trips") + void witFutureLargeTableId() { + // Rust-side ids are u64 monotonic; Java carries them as long. The + // wire form must survive the >Integer.MAX_VALUE range without any + // silent truncation. + final long largeId = 12_345_678_901L; + final WasmosMarshalling.WitFuture wf = new WasmosMarshalling.WitFuture( + largeId, "F"); + final Object round = roundTrip(wf); + assertEquals(largeId, + ((WasmosMarshalling.WitFuture) round).tableId()); + } + + @Test + @DisplayName("WitFuture in a nested List round-trips through container marshalling") + void witFutureNestedInList() { + // A guest returning `list>` produces exactly this shape; + // verify the Future writer + reader arms compose with List. Same + // regression guarantee as the WitResource nested-list test — proves + // the marshalling arm plays with the container arms cleanly. + final List items = Arrays.asList( + new WasmosMarshalling.WitFuture(1L, "F"), + new WasmosMarshalling.WitFuture(2L, "F"), + new WasmosMarshalling.WitFuture(3L, "F")); + final Object round = roundTrip(items); + assertInstanceOf(List.class, round); + final List back = (List) round; + assertEquals(3, back.size()); + assertEquals(new WasmosMarshalling.WitFuture(1L, "F"), back.get(0)); + assertEquals(new WasmosMarshalling.WitFuture(2L, "F"), back.get(1)); + assertEquals(new WasmosMarshalling.WitFuture(3L, "F"), back.get(2)); + } + + @Test + @DisplayName("WitFuture wire form uses \"Future\" tag (matches Rust JsonVal::Future)") + void witFutureWireFormat() { + // Explicit wire-format check — the Rust JsonVal enum is + // #[serde(tag = "t", content = "v")] and the tag string must match + // byte-for-byte. A tag rename here would silently break the JNI + // round-trip; guard against it. + final WasmosMarshalling.WitFuture wf = new WasmosMarshalling.WitFuture( + 42L, "type-hint"); + final String wire = WasmosMarshalling.marshalArgs(new Object[] { wf }); + assertTrue(wire.contains("\"Future\""), + "WitFuture wire form must use tag=\"Future\"; wire=" + wire); + assertTrue(wire.contains("\"table_id\":42"), + "wire form must expose table_id verbatim; wire=" + wire); + assertTrue(wire.contains("\"type_name\":\"type-hint\""), + "wire form must expose type_name verbatim; wire=" + wire); + } + + @Test + @DisplayName("WitStream round-trips tableId + typeIdentifier") + void witStreamRoundtrip() { + // Return-direction shape: WitStream parked by the Rust decoder, + // handed to Java, then round-tripped through the marshalling layer. + // tableId must survive byte-for-byte — Java uses it to re-find the + // parked StreamAny or to close it. + final WasmosMarshalling.WitStream ws = new WasmosMarshalling.WitStream( + 9L, "StreamAny { id: TableId(5), ty: Guest(Stream(u32)) }"); + final Object round = roundTrip(ws); + assertInstanceOf(WasmosMarshalling.WitStream.class, round); + final WasmosMarshalling.WitStream back = (WasmosMarshalling.WitStream) round; + assertEquals(9L, back.tableId()); + assertEquals("StreamAny { id: TableId(5), ty: Guest(Stream(u32)) }", + back.typeIdentifier()); + assertEquals(ws, back); + assertEquals(ws.hashCode(), back.hashCode()); + } + + @Test + @DisplayName("WitStream with large tableId (u64-adjacent) round-trips") + void witStreamLargeTableId() { + final long largeId = 8_888_888_888L; + final WasmosMarshalling.WitStream ws = new WasmosMarshalling.WitStream(largeId, "S"); + final Object round = roundTrip(ws); + assertEquals(largeId, + ((WasmosMarshalling.WitStream) round).tableId()); + } + + @Test + @DisplayName("WitStream in a nested List round-trips through container marshalling") + void witStreamNestedInList() { + // list> return shape — validates the Stream writer + + // reader arms compose with container arms. + final List items = Arrays.asList( + new WasmosMarshalling.WitStream(1L, "S"), + new WasmosMarshalling.WitStream(2L, "S"), + new WasmosMarshalling.WitStream(3L, "S")); + final Object round = roundTrip(items); + assertInstanceOf(List.class, round); + final List back = (List) round; + assertEquals(3, back.size()); + assertEquals(new WasmosMarshalling.WitStream(1L, "S"), back.get(0)); + assertEquals(new WasmosMarshalling.WitStream(2L, "S"), back.get(1)); + assertEquals(new WasmosMarshalling.WitStream(3L, "S"), back.get(2)); + } + + @Test + @DisplayName("WitStream wire form uses \"Stream\" tag (matches Rust JsonVal::Stream)") + void witStreamWireFormat() { + // Explicit wire-format check — the Rust JsonVal enum is + // #[serde(tag = "t", content = "v")] and the tag string must match + // byte-for-byte. A tag rename here would silently break the JNI + // round-trip. + final WasmosMarshalling.WitStream ws = new WasmosMarshalling.WitStream( + 55L, "type-hint"); + final String wire = WasmosMarshalling.marshalArgs(new Object[] { ws }); + assertTrue(wire.contains("\"Stream\""), + "WitStream wire form must use tag=\"Stream\"; wire=" + wire); + assertTrue(wire.contains("\"table_id\":55"), + "wire form must expose table_id verbatim; wire=" + wire); + assertTrue(wire.contains("\"type_name\":\"type-hint\""), + "wire form must expose type_name verbatim; wire=" + wire); + } + + @Test + @DisplayName("WitErrorContext round-trips tableId + rep") + void witErrorContextRoundtrip() { + final WasmosMarshalling.WitErrorContext we = new WasmosMarshalling.WitErrorContext( + 4L, 12345L); + final Object round = roundTrip(we); + assertInstanceOf(WasmosMarshalling.WitErrorContext.class, round); + final WasmosMarshalling.WitErrorContext back = + (WasmosMarshalling.WitErrorContext) round; + assertEquals(4L, back.tableId()); + assertEquals(12345L, back.rep()); + assertEquals(we, back); + assertEquals(we.hashCode(), back.hashCode()); + } + + @Test + @DisplayName("WitErrorContext with u32-max rep round-trips (long-widened)") + void witErrorContextLargeRep() { + // wasmtime's rep is a u32; we widen to Java long on the wire. + // The 0xFFFFFFFF max must survive without silent truncation. + final long u32Max = 4_294_967_295L; + final WasmosMarshalling.WitErrorContext we = new WasmosMarshalling.WitErrorContext( + 1L, u32Max); + final Object round = roundTrip(we); + assertEquals(u32Max, + ((WasmosMarshalling.WitErrorContext) round).rep()); + } + + @Test + @DisplayName("WitErrorContext in a nested List round-trips through container marshalling") + void witErrorContextNestedInList() { + // list is an unusual but wire-valid shape — verify + // the ErrorContext writer + reader arms compose with List. Same + // regression guarantee as the WitFuture nested-list test. + final List items = Arrays.asList( + new WasmosMarshalling.WitErrorContext(1L, 1L), + new WasmosMarshalling.WitErrorContext(2L, 2L), + new WasmosMarshalling.WitErrorContext(3L, 3L)); + final Object round = roundTrip(items); + assertInstanceOf(List.class, round); + final List back = (List) round; + assertEquals(3, back.size()); + assertEquals(new WasmosMarshalling.WitErrorContext(1L, 1L), back.get(0)); + assertEquals(new WasmosMarshalling.WitErrorContext(2L, 2L), back.get(1)); + assertEquals(new WasmosMarshalling.WitErrorContext(3L, 3L), back.get(2)); + } + + @Test + @DisplayName("WitErrorContext wire form uses \"ErrorContext\" tag (matches Rust JsonVal::ErrorContext)") + void witErrorContextWireFormat() { + // Explicit wire-format check — the Rust JsonVal enum is + // #[serde(tag = "t", content = "v")] and the tag string must match + // byte-for-byte. A tag rename here would silently break the JNI + // round-trip. + final WasmosMarshalling.WitErrorContext we = new WasmosMarshalling.WitErrorContext( + 7L, 99L); + final String wire = WasmosMarshalling.marshalArgs(new Object[] { we }); + assertTrue(wire.contains("\"ErrorContext\""), + "WitErrorContext wire form must use tag=\"ErrorContext\"; wire=" + wire); + assertTrue(wire.contains("\"table_id\":7"), + "wire form must expose table_id verbatim; wire=" + wire); + assertTrue(wire.contains("\"rep\":99"), + "wire form must expose rep verbatim; wire=" + wire); + } + + @Test + @DisplayName("plain Map still routes to Record (not Map)") + void plainMapStillRoutesToRecord() { + // Regression guard for the tie-breaking rule: only WitMap should + // route to Map; a bare Map stays Record so existing + // record-shaped callers aren't disturbed. + final Map input = new LinkedHashMap<>(); + input.put("k", "v"); + final Object round = roundTrip(input); + assertInstanceOf(Map.class, round); + // The unmarshalled shape is a LinkedHashMap regardless; the + // distinction shows up on the wire as tag="Record" (unfortunately + // opaque from unmarshalResults). Sanity-check the actual wire + // string for the record tag. + final String wire = WasmosMarshalling.marshalArgs(new Object[] { input }); + assertTrue(wire.contains("\"Record\""), + "plain Map must marshal as Record, not Map; wire=" + wire); + } +} diff --git a/wasmos-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosProviderDemoTest.java b/wasmos-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosProviderDemoTest.java new file mode 100644 index 0000000..ae20fe8 --- /dev/null +++ b/wasmos-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosProviderDemoTest.java @@ -0,0 +1,114 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package ai.tegmentum.webassembly4j.provider.wasmos; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import ai.tegmentum.webassembly4j.api.Component; +import ai.tegmentum.webassembly4j.api.ComponentInstance; +import ai.tegmentum.webassembly4j.api.Engine; +import ai.tegmentum.webassembly4j.spi.EngineProvider; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.ServiceLoader; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * End-to-end demo — load the wasmos {@code composed_wasmtime.wasm} + * fixture through the wasmos-provider, call the {@code run} export, assert + * 42. This is the wasmos-provider MVP's north-star acceptance test: + * passing it means a caller using ONLY webassembly4j's public API + + * selecting {@code "wasmos"} as the provider can drive a full + * {@code guest-demo-portable + adapter-wasmtime} composition through the + * bundled Rust wasmos-runtime. + * + *

The reference Rust equivalent lives at + * {@code ~/git/wasmos/tests/e2e_smoke.rs} and is the shape we mirror + * end-to-end. + * + *

Fixture location: {@code ~/git/wasmos/tests/e2e-fixtures/composed_wasmtime.wasm}. + * If absent, {@link org.junit.jupiter.api.Assumptions#assumeTrue} skips the + * test — regenerate via the wasmos build instructions. + */ +class WasmosProviderDemoTest { + + private static final Path COMPOSED_FIXTURE = Path.of( + System.getProperty("user.home"), + "git", "wasmos", "tests", "e2e-fixtures", "composed_wasmtime.wasm"); + + @Test + @DisplayName("wasmos-provider is registered via ServiceLoader with id=\"wasmos\"") + void serviceLoaderPicksUpWasmosProvider() { + boolean found = false; + for (EngineProvider p : ServiceLoader.load(EngineProvider.class)) { + if ("wasmos".equals(p.descriptor().providerId())) { + found = true; + assertEquals("wasmos", p.descriptor().engineId()); + assertTrue(p.descriptor().tags().contains("wasmos")); + break; + } + } + assertTrue(found, + "expected an EngineProvider with providerId=\"wasmos\" on the classpath — " + + "check META-INF/services/ai.tegmentum.webassembly4j.spi.EngineProvider"); + } + + @Test + @DisplayName("composed guest.run() returns 42 through wasmos-provider + JNI wasmos-runtime") + void composedRunReturns42() throws Exception { + assumeTrue(Files.exists(COMPOSED_FIXTURE), + "composed_wasmtime.wasm fixture not present at " + COMPOSED_FIXTURE + + " — rebuild via wasmos e2e_smoke instructions"); + + final byte[] bytes = Files.readAllBytes(COMPOSED_FIXTURE); + assertTrue(bytes.length > 0, "fixture must be non-empty"); + + final EngineProvider provider = ServiceLoader.load(EngineProvider.class) + .stream() + .map(ServiceLoader.Provider::get) + .filter(p -> "wasmos".equals(p.descriptor().providerId())) + .findFirst() + .orElseThrow(() -> new AssertionError( + "wasmos provider not on classpath — see the ServiceLoader test above")); + + try (Engine engine = provider.create(null); + Component component = engine.loadComponent(bytes)) { + assertNotNull(component, "wasmos-provider should compile the composed component"); + + // Sanity check: the exported functions should include "run" (the + // guest-demo-portable's entry point). If this fails, the fixture + // was rebuilt with a different shape and the whole test needs + // updating. + final List exports = ((WasmosComponentAdapter) component) + .exportedFunctionNames(); + assertTrue(exports.contains("run"), + "composed component should export 'run' — got: " + exports); + + final ComponentInstance instance = component.instantiate(); + try { + final Object result = instance.invoke("run"); + assertEquals(42, ((Number) result).intValue(), + "composed guest-demo-portable.run() must return 42 " + + "(same as wasmos/tests/e2e_smoke.rs)"); + } finally { + if (instance instanceof WasmosComponentInstanceAdapter) { + ((WasmosComponentInstanceAdapter) instance).closeInternal(); + } + } + } + } +} diff --git a/wasmos-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosTypedInvokeTest.java b/wasmos-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosTypedInvokeTest.java new file mode 100644 index 0000000..26fb88a --- /dev/null +++ b/wasmos-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmos/WasmosTypedInvokeTest.java @@ -0,0 +1,402 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package ai.tegmentum.webassembly4j.provider.wasmos; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import ai.tegmentum.webassembly4j.api.Component; +import ai.tegmentum.webassembly4j.api.ComponentInstance; +import ai.tegmentum.webassembly4j.api.DefaultLinkingContext; +import ai.tegmentum.webassembly4j.api.DefaultWasiContext; +import ai.tegmentum.webassembly4j.api.Engine; +import ai.tegmentum.webassembly4j.api.HostFunctionDefinition; +import ai.tegmentum.webassembly4j.api.LinkingContext; +import ai.tegmentum.webassembly4j.api.ValueType; +import ai.tegmentum.webassembly4j.api.WasiContext; +import ai.tegmentum.webassembly4j.api.config.ComponentConfig; +import ai.tegmentum.webassembly4j.api.exception.UnsupportedFeatureException; +import ai.tegmentum.webassembly4j.spi.EngineProvider; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ServiceLoader; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * End-to-end typed-invoke tests — proves the Java args flow into Rust, + * wasmtime executes with them, and the Rust return path decodes back + * into a Java-natural object. + * + *

Uses {@code scalar-component.wasm} (mirrored into wasmos-provider's + * test resources from the endive fixture) — a minimal Component Model + * binary that exports {@code add: func(u32, u32) -> u32}. + * + *

Also verifies the linking-context + component-config plumbing — + * WASI-P2 opt-in, memory-limit passthrough, and the philosophical + * rejection of Java-hosted host functions with a helpful message. + */ +final class WasmosTypedInvokeTest { + + private static EngineProvider provider() { + return ServiceLoader.load(EngineProvider.class) + .stream() + .map(ServiceLoader.Provider::get) + .filter(p -> "wasmos".equals(p.descriptor().providerId())) + .findFirst() + .orElseThrow(() -> new AssertionError("wasmos provider not on classpath")); + } + + private static byte[] loadFixture(String name) throws IOException { + try (InputStream is = WasmosTypedInvokeTest.class + .getClassLoader() + .getResourceAsStream(name)) { + if (is == null) return null; + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + final byte[] buf = new byte[8192]; + int n; + while ((n = is.read(buf)) > 0) { + out.write(buf, 0, n); + } + return out.toByteArray(); + } + } + + private static void withInstance( + ComponentInstance instance, + InstanceRunnable body) throws Exception { + try { + body.run(instance); + } finally { + if (instance instanceof WasmosComponentInstanceAdapter) { + ((WasmosComponentInstanceAdapter) instance).closeInternal(); + } + } + } + + @FunctionalInterface + private interface InstanceRunnable { + void run(ComponentInstance instance) throws Exception; + } + + @Test + @DisplayName("scalar-component add(2, 3) returns 5 through typed JSON invoke path") + void scalarComponentAdd() throws Exception { + final byte[] bytes = loadFixture("scalar-component.wasm"); + assumeTrue(bytes != null, "scalar-component.wasm missing from test resources"); + + try (Engine engine = provider().create(null); + Component component = engine.loadComponent(bytes)) { + assertNotNull(component, "component compile should succeed"); + withInstance(component.instantiate(), instance -> { + assertTrue(instance.hasFunction("add"), + "scalar-component should export 'add'; got " + + instance.exportedFunctions()); + // u32 args go via WitU32 wrappers so the JSON schema sends + // proper U32-tagged JsonVals that wasmtime type-checks + // against the func signature. + final Object result = instance.invoke( + "add", + new WasmosMarshalling.WitU32(2L), + new WasmosMarshalling.WitU32(3L)); + assertEquals(5L, ((Number) result).longValue(), + "add(2, 3) should be 5; got " + result); + }); + } + } + + @Test + @DisplayName("componentInstantiate honors ComponentConfig.maxMemoryBytes") + void componentConfigMemoryPasses() throws Exception { + final byte[] bytes = loadFixture("scalar-component.wasm"); + assumeTrue(bytes != null, "scalar-component.wasm missing from test resources"); + + try (Engine engine = provider().create(null); + Component component = engine.loadComponent(bytes)) { + // A generous limit that the scalar guest can't come close to + // touching — we're validating the plumbing goes through, not + // stressing the limiter. + final ComponentConfig cfg = ComponentConfig.builder() + .maxMemoryBytes(64L * 1024 * 1024) // 64 MiB + .build(); + withInstance(component.instantiate(cfg), instance -> { + final Object result = instance.invoke( + "add", + new WasmosMarshalling.WitU32(10L), + new WasmosMarshalling.WitU32(20L)); + assertEquals(30L, ((Number) result).longValue()); + }); + } + } + + @Test + @DisplayName("LinkingContext with wasiContext instantiates cleanly (WASI-P2 plumbing)") + void linkingContextWithWasi() throws Exception { + final byte[] bytes = loadFixture("scalar-component.wasm"); + assumeTrue(bytes != null, "scalar-component.wasm missing from test resources"); + + try (Engine engine = provider().create(null); + Component component = engine.loadComponent(bytes)) { + final WasiContext wasi = DefaultWasiContext.builder() + .addArg("wasmos-provider-test") + .env("HELLO", "world") + .build(); + final LinkingContext ctx = DefaultLinkingContext.builder() + .wasiContext(wasi) + .build(); + withInstance(component.instantiate(ctx), instance -> { + // scalar-component doesn't touch WASI — we're validating the + // instantiation plumbing itself doesn't throw when a WasiCtx + // is configured. + final Object result = instance.invoke( + "add", + new WasmosMarshalling.WitU32(7L), + new WasmosMarshalling.WitU32(35L)); + assertEquals(42L, ((Number) result).longValue()); + }); + } + } + + @Test + @DisplayName("linking-context with Java-hosted host functions is rejected with a useful message") + void hostFunctionsRejected() throws Exception { + final byte[] bytes = loadFixture("scalar-component.wasm"); + assumeTrue(bytes != null, "scalar-component.wasm missing from test resources"); + + try (Engine engine = provider().create(null); + Component component = engine.loadComponent(bytes)) { + final LinkingContext ctx = DefaultLinkingContext.builder() + .addHostFunction(new HostFunctionDefinition( + "mod", + "fn", + new ValueType[0], + new ValueType[0], + args -> new Object[0])) + .build(); + final UnsupportedFeatureException ex = assertThrows( + UnsupportedFeatureException.class, + () -> component.instantiate(ctx)); + // The error message must guide the user to the wasmtime provider — + // that's the whole point of the philosophical rejection. + assertTrue(ex.getMessage().contains("wasmtime"), + "error should mention the \"wasmtime\" fallback provider; got: " + + ex.getMessage()); + } + } + + @Test + @DisplayName("ComponentConfig.fuelLimit is honored; instantiation succeeds and function runs") + void fuelLimitAccepted() throws Exception { + final byte[] bytes = loadFixture("scalar-component.wasm"); + assumeTrue(bytes != null, "scalar-component.wasm missing from test resources"); + + try (Engine engine = provider().create(null); + Component component = engine.loadComponent(bytes)) { + // Generous fuel budget — add(u32, u32) is a trivial op so any + // reasonable amount lets it complete. Focus is validating the + // set_fuel plumbing succeeds and doesn't blow up mid-invoke, + // not measuring exact fuel consumption. + final ComponentConfig cfg = ComponentConfig.builder() + .fuelLimit(1_000_000_000L) + .build(); + withInstance(component.instantiate(cfg), instance -> { + final Object result = instance.invoke( + "add", + new WasmosMarshalling.WitU32(11L), + new WasmosMarshalling.WitU32(22L)); + assertEquals(33L, ((Number) result).longValue()); + }); + } + } + + @Test + @DisplayName("Extended ComponentConfig fields (tables/instances/memories/table-elements) instantiate cleanly") + void extendedComponentConfigFields() throws Exception { + final byte[] bytes = loadFixture("scalar-component.wasm"); + assumeTrue(bytes != null, "scalar-component.wasm missing from test resources"); + + try (Engine engine = provider().create(null); + Component component = engine.loadComponent(bytes)) { + // All limits set to generous values that scalar-component + // won't come close to hitting. Validates the JNI signature + + // wire-through, not the limiter's fault behaviour (those + // would need a purpose-built component to exercise). + final ComponentConfig cfg = ComponentConfig.builder() + .maxTableElements(1024L) + .maxInstances(32L) + .maxTables(8L) + .maxMemories(4L) + .build(); + withInstance(component.instantiate(cfg), instance -> { + final Object result = instance.invoke( + "add", + new WasmosMarshalling.WitU32(1L), + new WasmosMarshalling.WitU32(2L)); + assertEquals(3L, ((Number) result).longValue()); + }); + } + } + + @Test + @DisplayName("Component.serialize() + Engine.deserializeComponent round-trip preserves behaviour") + void serializeRoundTrip() throws Exception { + final byte[] bytes = loadFixture("scalar-component.wasm"); + assumeTrue(bytes != null, "scalar-component.wasm missing from test resources"); + + final byte[] cached; + try (Engine engine = provider().create(null); + Component component = engine.loadComponent(bytes)) { + cached = component.serialize(); + assertNotNull(cached, "serialize() must return non-null AOT bytes"); + assertTrue(cached.length > 0, "serialized bytes must be non-empty"); + } + + // Fresh engine, deserialize the cached bytes rather than compile + // from the raw component source. Ensures the byte cache is + // engine-independent within a matching wasmtime Config. + try (Engine engine = provider().create(null)) { + final Component reloaded = + ((WasmosEngineAdapter) engine).deserializeComponent(cached); + try (Component c = reloaded) { + withInstance(c.instantiate(), instance -> { + final Object result = instance.invoke( + "add", + new WasmosMarshalling.WitU32(40L), + new WasmosMarshalling.WitU32(2L)); + assertEquals(42L, ((Number) result).longValue(), + "deserialized component must produce identical results"); + }); + } + } + } + + // --------------------------------------------------------------------- + // Val::Resource end-to-end: constructor -> increment (x2) -> get. The + // fixture is `counter-component.wasm`, built from + // `counter-fixture-src/` (see the README there for the rebuild recipe). + // Exercises the full round-trip: guest returns own, Java + // parks it in the Rust ResourceRegistry as a WitResource, then passes + // that WitResource back in as borrow for the method calls. + // --------------------------------------------------------------------- + + /** Fully-qualified export names — the resource lives inside an + * exported interface, so top-level `Instance::get_func` can't see it. + * The wasmos-provider native resolver splits on the first `#` and + * walks `get_export_index` twice. See + * `wasmos-provider/native/src/lib.rs::resolve_func`. */ + private static final String CTR_IFACE = "tegmentum:counter/counter-api@0.1.0"; + private static final String CTR_CONSTRUCTOR = CTR_IFACE + "#[constructor]counter"; + private static final String CTR_INCREMENT = CTR_IFACE + "#[method]counter.increment"; + private static final String CTR_GET = CTR_IFACE + "#[method]counter.get"; + + @Test + @DisplayName("counter-component: constructor -> increment x2 -> get returns 12 (Val::Resource E2E)") + void counterResourceRoundTrip() throws Exception { + final byte[] bytes = loadFixture("counter-component.wasm"); + assumeTrue(bytes != null, "counter-component.wasm missing from test resources"); + + try (Engine engine = provider().create(null); + Component component = engine.loadComponent(bytes)) { + assertNotNull(component, "counter-component should compile"); + withInstance(component.instantiate(), instance -> { + // Constructor returns an owned counter resource; wasmos-provider + // parks the ResourceAny in the per-instance ResourceRegistry and + // hands Java back a WitResource carrying { tableId, typeName, + // owned=true }. + final Object created = instance.invoke( + CTR_CONSTRUCTOR, + new WasmosMarshalling.WitU32(10L)); + assertNotNull(created, "constructor must return the counter handle"); + assertTrue(created instanceof WasmosMarshalling.WitResource, + "constructor should return a WitResource; got " + + created.getClass().getName() + " = " + created); + final WasmosMarshalling.WitResource owned = + (WasmosMarshalling.WitResource) created; + assertTrue(owned.owned(), + "guest return of own must be owned=true; got " + owned); + assertTrue(owned.tableId() >= 0, + "tableId must be a valid slot id; got " + owned.tableId()); + + // Increment twice — WIT `increment: func()` takes an implicit + // borrow of `self`, i.e. `borrow` in the ABI. We pass + // the same WitResource with owned=false so the Rust registry + // does `peek` (leaves it parked) rather than `take` (removes + // it), which lets us reuse the handle across calls. + final WasmosMarshalling.WitResource borrow = + new WasmosMarshalling.WitResource( + owned.tableId(), owned.typeIdentifier(), false); + + final Object r1 = instance.invoke(CTR_INCREMENT, borrow); + // increment returns () — the JSON path unwraps arity-0 to null. + assertEquals(null, r1, "increment returns unit; got " + r1); + + final Object r2 = instance.invoke(CTR_INCREMENT, borrow); + assertEquals(null, r2, "increment returns unit; got " + r2); + + // get — same borrow-shaped call, but returns u32. + final Object got = instance.invoke(CTR_GET, borrow); + assertNotNull(got, "get must return a value"); + // WIT u32 comes back as Long (WitU32 domain) — accept both + // Number widths defensively. + assertEquals(12L, ((Number) got).longValue(), + "counter started at 10, +1 +1 -> 12; got " + got); + }); + } + } + + @Test + @DisplayName("counter-component: WitResource handles remain valid across instantiation close") + void counterResourceDroppedOnInstanceClose() throws Exception { + // Closing the instance drops the ResourceRegistry alongside the + // Store — no leaks even if the caller never explicitly hands the + // resource back. Sanity-check that the close path doesn't throw + // when live resources are still parked. + final byte[] bytes = loadFixture("counter-component.wasm"); + assumeTrue(bytes != null, "counter-component.wasm missing from test resources"); + + try (Engine engine = provider().create(null); + Component component = engine.loadComponent(bytes)) { + withInstance(component.instantiate(), instance -> { + // Create a resource and immediately walk away — the close + // hook driven by withInstance should tear it down cleanly. + final Object created = instance.invoke( + CTR_CONSTRUCTOR, + new WasmosMarshalling.WitU32(0L)); + assertTrue(created instanceof WasmosMarshalling.WitResource, + "constructor should still return a WitResource: " + created); + }); + } + } + + @Test + @DisplayName("negative ComponentConfig field values are rejected with a helpful message") + void negativeConfigValuesRejected() throws Exception { + final byte[] bytes = loadFixture("scalar-component.wasm"); + assumeTrue(bytes != null, "scalar-component.wasm missing from test resources"); + + try (Engine engine = provider().create(null); + Component component = engine.loadComponent(bytes)) { + final ComponentConfig cfg = ComponentConfig.builder() + .fuelLimit(-5L) // negative collides with the -1 sentinel on the JNI boundary + .build(); + final IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> component.instantiate(cfg)); + assertTrue(ex.getMessage().contains("fuelLimit"), + "error should identify the offending field; got: " + ex.getMessage()); + } + } +} diff --git a/wasmos-provider/src/test/resources/counter-component.wasm b/wasmos-provider/src/test/resources/counter-component.wasm new file mode 100644 index 0000000..5ba6dfd Binary files /dev/null and b/wasmos-provider/src/test/resources/counter-component.wasm differ diff --git a/wasmos-provider/src/test/resources/counter-fixture-src/Cargo.toml b/wasmos-provider/src/test/resources/counter-fixture-src/Cargo.toml new file mode 100644 index 0000000..5f10595 --- /dev/null +++ b/wasmos-provider/src/test/resources/counter-fixture-src/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "counter-fixture" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen-rt = { version = "0.41", features = ["bitflags"] } +wee_alloc = "0.4" + +[package.metadata.component] +package = "tegmentum:counter" + +[package.metadata.component.target] +world = "counter-world" +path = "wit/counter.wit" + +[profile.release] +opt-level = "s" +strip = "debuginfo" +lto = true +codegen-units = 1 diff --git a/wasmos-provider/src/test/resources/counter-fixture-src/README.md b/wasmos-provider/src/test/resources/counter-fixture-src/README.md new file mode 100644 index 0000000..12e994f --- /dev/null +++ b/wasmos-provider/src/test/resources/counter-fixture-src/README.md @@ -0,0 +1,51 @@ +# counter-component.wasm — rebuild recipe + +Compiled artifact lives at `../counter-component.wasm`. Rebuild only when +the WIT surface (`wit/counter.wit`) changes, or when a wasmtime bump +breaks binary compatibility of the current fixture (unlikely: the +component is a plain resource-exporting component with no host imports). + +## Sources checked in + +- `wit/counter.wit` — the WIT interface. A single `counter-api` interface + exports a `counter` resource with constructor + two methods. +- `src/lib.rs` — the Rust guest that implements the resource. Uses + `#![no_std]` + `wee_alloc` so the built component has zero WASI + imports and comes in under 7 KiB. +- `Cargo.toml` — cargo-component metadata pinning the WIT world + + `wit-bindgen-rt` version. + +## Rebuild recipe + +Toolchain: cargo-component ≥ 0.21, rustup target `wasm32-unknown-unknown`, +`wasm-tools` on `PATH` (for validation only). + +``` +cd wasmos-provider/src/test/resources/counter-fixture-src +cargo component build --release --target wasm32-unknown-unknown +cp target/wasm32-unknown-unknown/release/counter_fixture.wasm \ + ../counter-component.wasm +wasm-tools validate ../counter-component.wasm +wasm-tools component wit ../counter-component.wasm # sanity check +``` + +## Why `wasm32-unknown-unknown` instead of `wasm32-wasip1` + +Building for `wasm32-wasip1` (cargo-component's default) drags in libstd +and pulls WASI-p2 imports into the component. That still works via +wasmos's wasi-p2 linker capability, but a WASI-free component keeps the +test's failure modes narrower — an instantiation error must be about the +resource surface, not a missing WASI import. + +## Interface path used from Java + +The E2E test invokes the resource methods by their fully-qualified +component export path: + +- `tegmentum:counter/counter-api@0.1.0#[constructor]counter` +- `tegmentum:counter/counter-api@0.1.0#[method]counter.increment` +- `tegmentum:counter/counter-api@0.1.0#[method]counter.get` + +The Rust side (`wasmos-provider/native/src/lib.rs::resolve_func`) splits +on the first `#` to walk `Instance::get_export_index` twice — nested +interface exports aren't visible via `Instance::get_func(name)` alone. diff --git a/wasmos-provider/src/test/resources/counter-fixture-src/src/lib.rs b/wasmos-provider/src/test/resources/counter-fixture-src/src/lib.rs new file mode 100644 index 0000000..186a93d --- /dev/null +++ b/wasmos-provider/src/test/resources/counter-fixture-src/src/lib.rs @@ -0,0 +1,59 @@ +#![no_std] + +extern crate wee_alloc; + +// Use `wee_alloc` — a tiny allocator suitable for wasm size-constrained +// components. The generated bindings and wit-bindgen-rt need a global +// allocator to satisfy `alloc` types (Vec, String) that show up in the +// resource machinery even if our own Rust code doesn't touch them. +#[global_allocator] +static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; + +use core::cell::Cell; + +// Generated bindings — cargo-component drops these into a `bindings` module +// keyed on the `[package.metadata.component]` metadata below. +#[allow(warnings)] +mod bindings; + +use bindings::exports::tegmentum::counter::counter_api::{Guest, GuestCounter}; + +struct Component; + +/// Minimal in-guest counter — the WIT `increment` receives a borrow, which +/// cargo-component's generated GuestCounter trait renders as `&self`; +/// interior mutability via `Cell` lets us update without needing `&mut`. +pub struct Counter { + value: Cell, +} + +impl GuestCounter for Counter { + fn new(initial: u32) -> Self { + Counter { + value: Cell::new(initial), + } + } + + fn increment(&self) { + self.value.set(self.value.get().wrapping_add(1)); + } + + fn get(&self) -> u32 { + self.value.get() + } +} + +impl Guest for Component { + type Counter = Counter; +} + +bindings::export!(Component with_types_in bindings); + +// Provide a minimal panic handler — we're `no_std` so libcore's default +// panic handler (which lives in `std`) isn't available. Aborting is fine +// for a test fixture: the counter methods are infallible and no path +// panics under normal use. +#[panic_handler] +fn panic(_info: &core::panic::PanicInfo) -> ! { + core::arch::wasm32::unreachable() +} diff --git a/wasmos-provider/src/test/resources/counter-fixture-src/wit/counter.wit b/wasmos-provider/src/test/resources/counter-fixture-src/wit/counter.wit new file mode 100644 index 0000000..14432df --- /dev/null +++ b/wasmos-provider/src/test/resources/counter-fixture-src/wit/counter.wit @@ -0,0 +1,13 @@ +package tegmentum:counter@0.1.0; + +interface counter-api { + resource counter { + constructor(initial: u32); + increment: func(); + get: func() -> u32; + } +} + +world counter-world { + export counter-api; +} diff --git a/wasmos-provider/src/test/resources/counter.wit b/wasmos-provider/src/test/resources/counter.wit new file mode 100644 index 0000000..14432df --- /dev/null +++ b/wasmos-provider/src/test/resources/counter.wit @@ -0,0 +1,13 @@ +package tegmentum:counter@0.1.0; + +interface counter-api { + resource counter { + constructor(initial: u32); + increment: func(); + get: func() -> u32; + } +} + +world counter-world { + export counter-api; +} diff --git a/wasmos-provider/src/test/resources/scalar-component.wasm b/wasmos-provider/src/test/resources/scalar-component.wasm new file mode 100644 index 0000000..a67fa30 Binary files /dev/null and b/wasmos-provider/src/test/resources/scalar-component.wasm differ diff --git a/wasmos-runtime-generated/pom.xml b/wasmos-runtime-generated/pom.xml new file mode 100644 index 0000000..5d925d8 --- /dev/null +++ b/wasmos-runtime-generated/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + + ai.tegmentum.webassembly4j + webassembly4j-parent + 2.5.2-SNAPSHOT + + + wasmos-runtime-generated + jar + + WebAssembly4J :: wasmos-runtime generated bindings + + Java bindings generated by webassembly4j-bindgen from + wasmos:runtime@0.2.0 — the portable, backend-agnostic runtime API + that wasmos adapters (adapter-wasmtime, adapter-wamr, adapter-js) + implement (see ~/git/wasmos). The initial slice binds the six + interfaces that make up the core runtime surface: + + * core — core-Wasm compile / instantiate / invoke + * component-model— rich CM management API + * run — one-shot fire-and-forget execution + * capabilities — cap-set definitions + * info — supports() / version() introspection + * types — portable value / capability / error types + + Plus the transitive tegmentum:common/errors dep that types.wit + pulls in. + + The remaining wasmos:runtime interfaces (http, http-streaming, + websocket, timers, provider-manifest) are excluded from this + slice until bindgen learns to map deeply-nested container + record fields cleanly — see the parent's task ledger. + + The content of this module is the generated source tree under + ai.tegmentum.wasmos.runtime.generated plus a local copy of + WitResult so the jar has no compile-time or runtime dependency + on webassembly4j-bindgen (which is a maven-plugin-packaged + build-time-only artifact). Providers that implement the + wasmos:runtime SPI depend on this module for the generated + surface + WitResult. + + Regenerate with: + + java -jar webassembly4j-bindgen/target/webassembly4j-bindgen-2.5.2-SNAPSHOT-cli.jar \ + -w ~/git/wasmos/wit/deps/tegmentum-common \ + -w ~/git/wasmos/wit/deps/wasmos-runtime/types.wit \ + -w ~/git/wasmos/wit/deps/wasmos-runtime/capabilities.wit \ + -w ~/git/wasmos/wit/deps/wasmos-runtime/core.wit \ + -w ~/git/wasmos/wit/deps/wasmos-runtime/component-model.wit \ + -w ~/git/wasmos/wit/deps/wasmos-runtime/run.wit \ + -w ~/git/wasmos/wit/deps/wasmos-runtime/info.wit \ + --package ai.tegmentum.wasmos.runtime.generated \ + --output wasmos-runtime-generated/src/main/java \ + --no-impl \ + --runtime-provider WasmosRuntime + + + + + ai.tegmentum.webassembly4j + webassembly4j-api + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 11 + + + + + diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Capabilities.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Capabilities.java new file mode 100644 index 0000000..bf54893 --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Capabilities.java @@ -0,0 +1,4 @@ +package ai.tegmentum.wasmos.runtime.generated; + +public interface Capabilities { +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Capability.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Capability.java new file mode 100644 index 0000000..94145f5 --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Capability.java @@ -0,0 +1,40 @@ +package ai.tegmentum.wasmos.runtime.generated; + +import java.util.Optional; + +public final class Capability { + private final String name; + + private final Optional scope; + + public Capability(String name, Optional scope) { + this.name = name; + this.scope = scope; + } + + public String name() { + return this.name; + } + + public Optional scope() { + return this.scope; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + Capability that = (Capability) obj; + return java.util.Objects.equals(this.name, that.name) && java.util.Objects.equals(this.scope, that.scope); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(name, scope); + } + + @Override + public String toString() { + return "Capability[name=" + name + ", scope=" + scope + "]"; + } +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/CapabilityEntry.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/CapabilityEntry.java new file mode 100644 index 0000000..bfbed5a --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/CapabilityEntry.java @@ -0,0 +1,55 @@ +package ai.tegmentum.wasmos.runtime.generated; + +import java.util.Optional; + +public final class CapabilityEntry { + private final String name; + + private final boolean native_; + + private final boolean available; + + private final Optional provider; + + public CapabilityEntry(String name, boolean native_, boolean available, + Optional provider) { + this.name = name; + this.native_ = native_; + this.available = available; + this.provider = provider; + } + + public String name() { + return this.name; + } + + public boolean native_() { + return this.native_; + } + + public boolean available() { + return this.available; + } + + public Optional provider() { + return this.provider; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + CapabilityEntry that = (CapabilityEntry) obj; + return java.util.Objects.equals(this.name, that.name) && this.native_ == that.native_ && this.available == that.available && java.util.Objects.equals(this.provider, that.provider); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(name, native_, available, provider); + } + + @Override + public String toString() { + return "CapabilityEntry[name=" + name + ", native_=" + native_ + ", available=" + available + ", provider=" + provider + "]"; + } +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/CapabilitySet.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/CapabilitySet.java new file mode 100644 index 0000000..21e696d --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/CapabilitySet.java @@ -0,0 +1,40 @@ +package ai.tegmentum.wasmos.runtime.generated; + +import java.util.List; + +public final class CapabilitySet { + private final List entries; + + private final String source; + + public CapabilitySet(List entries, String source) { + this.entries = entries; + this.source = source; + } + + public List entries() { + return this.entries; + } + + public String source() { + return this.source; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + CapabilitySet that = (CapabilitySet) obj; + return java.util.Objects.equals(this.entries, that.entries) && java.util.Objects.equals(this.source, that.source); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(entries, source); + } + + @Override + public String toString() { + return "CapabilitySet[entries=" + entries + ", source=" + source + "]"; + } +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Component.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Component.java new file mode 100644 index 0000000..48bdd99 --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Component.java @@ -0,0 +1,34 @@ +package ai.tegmentum.wasmos.runtime.generated; + +import ai.tegmentum.webassembly4j.bindgen.wit.WitResult; +import java.util.List; + +/** + * Resource type: component + * + *

This resource should be closed after use. + */ +public class Component implements AutoCloseable { + private final long handle; + + public Component(long handle) { + this.handle = handle; + } + + public long handle() { + return this.handle; + } + + @Override + public void close() { + WasmosRuntimeRegistry.runtime().componentClose(this.handle); + } + + public static WitResult fromBytes(Runtime runtime, List bytes) { + return WasmosRuntimeRegistry.runtime().componentFromBytes(runtime, bytes); + } + + public List exportNames() { + return WasmosRuntimeRegistry.runtime().componentExportNames(this.handle); + } +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/ComponentModel.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/ComponentModel.java new file mode 100644 index 0000000..2b4cb05 --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/ComponentModel.java @@ -0,0 +1,4 @@ +package ai.tegmentum.wasmos.runtime.generated; + +public interface ComponentModel { +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Core.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Core.java new file mode 100644 index 0000000..6776333 --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Core.java @@ -0,0 +1,4 @@ +package ai.tegmentum.wasmos.runtime.generated; + +public interface Core { +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/CoreInstance.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/CoreInstance.java new file mode 100644 index 0000000..4d9aa8f --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/CoreInstance.java @@ -0,0 +1,43 @@ +package ai.tegmentum.wasmos.runtime.generated; + +import ai.tegmentum.webassembly4j.bindgen.wit.WitResult; +import java.util.List; + +/** + * Resource type: core-instance + * + *

This resource should be closed after use. + */ +public class CoreInstance implements AutoCloseable { + private final long handle; + + public CoreInstance(long handle) { + this.handle = handle; + } + + public long handle() { + return this.handle; + } + + @Override + public void close() { + WasmosRuntimeRegistry.runtime().coreInstanceClose(this.handle); + } + + public static WitResult fromModule(Runtime runtime, Module module, + List caps) { + return WasmosRuntimeRegistry.runtime().coreInstanceFromModule(runtime, module, caps); + } + + public WitResult, Error> invoke(String exportName, List args) { + return WasmosRuntimeRegistry.runtime().coreInstanceInvoke(this.handle, exportName, args); + } + + public WitResult, Error> readMemory(int offset, int len) { + return WasmosRuntimeRegistry.runtime().coreInstanceReadMemory(this.handle, offset, len); + } + + public WitResult writeMemory(int offset, List bytes) { + return WasmosRuntimeRegistry.runtime().coreInstanceWriteMemory(this.handle, offset, bytes); + } +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Error.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Error.java new file mode 100644 index 0000000..51315dc --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Error.java @@ -0,0 +1,38 @@ +package ai.tegmentum.wasmos.runtime.generated; + +public final class Error { + private final ErrorCategory category; + + private final String message; + + public Error(ErrorCategory category, String message) { + this.category = category; + this.message = message; + } + + public ErrorCategory category() { + return this.category; + } + + public String message() { + return this.message; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + Error that = (Error) obj; + return java.util.Objects.equals(this.category, that.category) && java.util.Objects.equals(this.message, that.message); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(category, message); + } + + @Override + public String toString() { + return "Error[category=" + category + ", message=" + message + "]"; + } +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/ErrorCategory.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/ErrorCategory.java new file mode 100644 index 0000000..fe131c2 --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/ErrorCategory.java @@ -0,0 +1,19 @@ +package ai.tegmentum.wasmos.runtime.generated; + +public enum ErrorCategory { + NOT_FOUND, + + ALREADY_EXISTS, + + PERMISSION_DENIED, + + INVALID_ARGUMENT, + + RESOURCE_EXHAUSTED, + + IO, + + UNSUPPORTED, + + INTERNAL +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Errors.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Errors.java new file mode 100644 index 0000000..13822cb --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Errors.java @@ -0,0 +1,4 @@ +package ai.tegmentum.wasmos.runtime.generated; + +public interface Errors { +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Handles.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Handles.java new file mode 100644 index 0000000..c265b1e --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Handles.java @@ -0,0 +1,4 @@ +package ai.tegmentum.wasmos.runtime.generated; + +public interface Handles { +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Info.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Info.java new file mode 100644 index 0000000..bad3d20 --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Info.java @@ -0,0 +1,15 @@ +package ai.tegmentum.wasmos.runtime.generated; + +import java.util.Optional; + +public interface Info { + RuntimeInfo getInfo(); + + boolean supports(String feature); + + boolean native_(String feature); + + boolean available(String feature); + + Optional provider(String feature); +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Instance.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Instance.java new file mode 100644 index 0000000..59c5f9d --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Instance.java @@ -0,0 +1,35 @@ +package ai.tegmentum.wasmos.runtime.generated; + +import ai.tegmentum.webassembly4j.bindgen.wit.WitResult; +import java.util.List; + +/** + * Resource type: instance + * + *

This resource should be closed after use. + */ +public class Instance implements AutoCloseable { + private final long handle; + + public Instance(long handle) { + this.handle = handle; + } + + public long handle() { + return this.handle; + } + + @Override + public void close() { + WasmosRuntimeRegistry.runtime().instanceClose(this.handle); + } + + public static WitResult fromComponent(Runtime runtime, Component component, + List caps) { + return WasmosRuntimeRegistry.runtime().instanceFromComponent(runtime, component, caps); + } + + public WitResult, Error> invoke(String exportName, List args) { + return WasmosRuntimeRegistry.runtime().instanceInvoke(this.handle, exportName, args); + } +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Module.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Module.java new file mode 100644 index 0000000..864b238 --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Module.java @@ -0,0 +1,34 @@ +package ai.tegmentum.wasmos.runtime.generated; + +import ai.tegmentum.webassembly4j.bindgen.wit.WitResult; +import java.util.List; + +/** + * Resource type: module + * + *

This resource should be closed after use. + */ +public class Module implements AutoCloseable { + private final long handle; + + public Module(long handle) { + this.handle = handle; + } + + public long handle() { + return this.handle; + } + + @Override + public void close() { + WasmosRuntimeRegistry.runtime().moduleClose(this.handle); + } + + public static WitResult fromBytes(Runtime runtime, List bytes) { + return WasmosRuntimeRegistry.runtime().moduleFromBytes(runtime, bytes); + } + + public List exportNames() { + return WasmosRuntimeRegistry.runtime().moduleExportNames(this.handle); + } +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/ResourceLimits.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/ResourceLimits.java new file mode 100644 index 0000000..1562c29 --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/ResourceLimits.java @@ -0,0 +1,40 @@ +package ai.tegmentum.wasmos.runtime.generated; + +import java.util.Optional; + +public final class ResourceLimits { + private final Optional maxMemoryBytes; + + private final Optional cycleBudget; + + public ResourceLimits(Optional maxMemoryBytes, Optional cycleBudget) { + this.maxMemoryBytes = maxMemoryBytes; + this.cycleBudget = cycleBudget; + } + + public Optional maxMemoryBytes() { + return this.maxMemoryBytes; + } + + public Optional cycleBudget() { + return this.cycleBudget; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + ResourceLimits that = (ResourceLimits) obj; + return java.util.Objects.equals(this.maxMemoryBytes, that.maxMemoryBytes) && java.util.Objects.equals(this.cycleBudget, that.cycleBudget); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(maxMemoryBytes, cycleBudget); + } + + @Override + public String toString() { + return "ResourceLimits[maxMemoryBytes=" + maxMemoryBytes + ", cycleBudget=" + cycleBudget + "]"; + } +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Run.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Run.java new file mode 100644 index 0000000..2f0969b --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Run.java @@ -0,0 +1,8 @@ +package ai.tegmentum.wasmos.runtime.generated; + +import ai.tegmentum.webassembly4j.bindgen.wit.WitResult; +import java.util.List; + +public interface Run { + WitResult exec(List componentBytes, List caps); +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Runtime.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Runtime.java new file mode 100644 index 0000000..44c6826 --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Runtime.java @@ -0,0 +1,27 @@ +package ai.tegmentum.wasmos.runtime.generated; + +/** + * Resource type: runtime + * + *

This resource should be closed after use. + */ +public class Runtime implements AutoCloseable { + private final long handle; + + public Runtime(long handle) { + this.handle = handle; + } + + public long handle() { + return this.handle; + } + + @Override + public void close() { + WasmosRuntimeRegistry.runtime().runtimeClose(this.handle); + } + + public static Runtime create(ResourceLimits limits) { + return WasmosRuntimeRegistry.runtime().runtimeCreate(limits); + } +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/RuntimeInfo.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/RuntimeInfo.java new file mode 100644 index 0000000..d2d0dd2 --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/RuntimeInfo.java @@ -0,0 +1,47 @@ +package ai.tegmentum.wasmos.runtime.generated; + +import java.util.List; + +public final class RuntimeInfo { + private final String name; + + private final String version; + + private final List features; + + public RuntimeInfo(String name, String version, List features) { + this.name = name; + this.version = version; + this.features = features; + } + + public String name() { + return this.name; + } + + public String version() { + return this.version; + } + + public List features() { + return this.features; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + RuntimeInfo that = (RuntimeInfo) obj; + return java.util.Objects.equals(this.name, that.name) && java.util.Objects.equals(this.version, that.version) && java.util.Objects.equals(this.features, that.features); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(name, version, features); + } + + @Override + public String toString() { + return "RuntimeInfo[name=" + name + ", version=" + version + ", features=" + features + "]"; + } +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Types.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Types.java new file mode 100644 index 0000000..4de8c68 --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Types.java @@ -0,0 +1,4 @@ +package ai.tegmentum.wasmos.runtime.generated; + +public interface Types { +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Value.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Value.java new file mode 100644 index 0000000..849119c --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/Value.java @@ -0,0 +1,82 @@ +package ai.tegmentum.wasmos.runtime.generated; + +import java.util.List; + +public interface Value { + final class VI32 implements Value { + private final int value; + + public VI32(int value) { + this.value = value; + } + + public int value() { + return this.value; + } + } + + final class VI64 implements Value { + private final long value; + + public VI64(long value) { + this.value = value; + } + + public long value() { + return this.value; + } + } + + final class VF32 implements Value { + private final float value; + + public VF32(float value) { + this.value = value; + } + + public float value() { + return this.value; + } + } + + final class VF64 implements Value { + private final double value; + + public VF64(double value) { + this.value = value; + } + + public double value() { + return this.value; + } + } + + final class VString implements Value { + private final String value; + + public VString(String value) { + this.value = value; + } + + public String value() { + return this.value; + } + } + + final class VBytes implements Value { + private final List value; + + public VBytes(List value) { + this.value = value; + } + + public List value() { + return this.value; + } + } + + final class VNull implements Value { + public VNull() { + } + } +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/WasmosRuntime.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/WasmosRuntime.java new file mode 100644 index 0000000..90b35a7 --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/WasmosRuntime.java @@ -0,0 +1,50 @@ +package ai.tegmentum.wasmos.runtime.generated; + +import ai.tegmentum.webassembly4j.bindgen.wit.WitResult; +import java.util.List; + +/** + * Runtime-provider SPI for the generated bindings. + * + *

The embedder implements this interface once and installs the implementation + * via {@link WasmosRuntimeRegistry#install(WasmosRuntime)}. Generated resource method bodies dispatch + * through {@link WasmosRuntimeRegistry#runtime()} to reach the installed provider. + * + *

Emitted by webassembly4j-bindgen 2.0 (runtime-provider dispatch mode). + */ +public interface WasmosRuntime { + Runtime runtimeCreate(ResourceLimits limits); + + void runtimeClose(long handle); + + WitResult moduleFromBytes(Runtime runtime, List bytes); + + List moduleExportNames(long handle); + + void moduleClose(long handle); + + WitResult coreInstanceFromModule(Runtime runtime, Module module, + List caps); + + WitResult, Error> coreInstanceInvoke(long handle, String exportName, + List args); + + WitResult, Error> coreInstanceReadMemory(long handle, int offset, int len); + + WitResult coreInstanceWriteMemory(long handle, int offset, List bytes); + + void coreInstanceClose(long handle); + + WitResult componentFromBytes(Runtime runtime, List bytes); + + List componentExportNames(long handle); + + void componentClose(long handle); + + WitResult instanceFromComponent(Runtime runtime, Component component, + List caps); + + WitResult, Error> instanceInvoke(long handle, String exportName, List args); + + void instanceClose(long handle); +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/WasmosRuntimeRegistry.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/WasmosRuntimeRegistry.java new file mode 100644 index 0000000..5bb59fa --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/wasmos/runtime/generated/WasmosRuntimeRegistry.java @@ -0,0 +1,36 @@ +package ai.tegmentum.wasmos.runtime.generated; + +import java.util.Objects; + +/** + * Single-slot registry for the {@link WasmosRuntime} runtime-provider SPI. + * + *

Call {@link #install(WasmosRuntime)} once from the embedder's startup path, before + * invoking any generated resource method. Generated bodies reach the installed + * provider via {@link #runtime()}, which throws {@link IllegalStateException} if + * nothing was installed. + * + *

Emitted by webassembly4j-bindgen 2.0. + */ +public final class WasmosRuntimeRegistry { + private static volatile WasmosRuntime provider; + + private WasmosRuntimeRegistry() { + } + + public static void install(WasmosRuntime provider) { + WasmosRuntimeRegistry.provider = Objects.requireNonNull(provider, "provider"); + } + + public static void uninstall() { + WasmosRuntimeRegistry.provider = null; + } + + public static WasmosRuntime runtime() { + WasmosRuntime r = provider; + if (r == null) { + throw new IllegalStateException("WasmosRuntime not installed; call WasmosRuntimeRegistry.install(...) before invoking generated bindings"); + } + return r; + } +} diff --git a/wasmos-runtime-generated/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitResult.java b/wasmos-runtime-generated/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitResult.java new file mode 100644 index 0000000..ba37ff4 --- /dev/null +++ b/wasmos-runtime-generated/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitResult.java @@ -0,0 +1,127 @@ +package ai.tegmentum.webassembly4j.bindgen.wit; + +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +/** + * Runtime representation of the WIT {@code result} type. + * + *

The bindgen code generator references this class by its fully-qualified + * name (see {@link + * ai.tegmentum.webassembly4j.bindgen.generator.TypeMappingRegistry}); it must + * therefore exist as a real class on the consumer's classpath. The two + * factory methods {@link #ok(Object)} and {@link #err(Object)} construct + * either side; the resulting instance is immutable. + * + *

Generated code typically follows this pattern: + * + *

{@code
+ * WitResult result = parser.parse(language, source);
+ * if (result.isOk()) {
+ *     ParseResult tree = result.unwrap();
+ * } else {
+ *     String msg = result.unwrapErr();
+ * }
+ * }
+ * + * @param the success-payload type ({@link Void} when the WIT signature is + * {@code result<_, err>}) + * @param the error-payload type ({@link Void} when the WIT signature is + * {@code result} or {@code result}) + */ +public final class WitResult { + + private final T ok; + private final E err; + private final boolean isOk; + + private WitResult(final T ok, final E err, final boolean isOk) { + this.ok = ok; + this.err = err; + this.isOk = isOk; + } + + /** Construct a successful result. */ + public static WitResult ok(final T value) { + return new WitResult<>(value, null, true); + } + + /** Construct an error result. */ + public static WitResult err(final E value) { + return new WitResult<>(null, value, false); + } + + /** True if this is a success result. */ + public boolean isOk() { + return isOk; + } + + /** True if this is an error result. */ + public boolean isErr() { + return !isOk; + } + + /** Returns the success payload or throws when {@link #isErr()}. */ + public T unwrap() { + if (!isOk) { + throw new NoSuchElementException("WitResult is err: " + err); + } + return ok; + } + + /** Returns the error payload or throws when {@link #isOk()}. */ + public E unwrapErr() { + if (isOk) { + throw new NoSuchElementException("WitResult is ok: " + ok); + } + return err; + } + + /** Optional view of the success side. */ + public Optional ok() { + return isOk ? Optional.ofNullable(ok) : Optional.empty(); + } + + /** Optional view of the error side. */ + public Optional err() { + return isOk ? Optional.empty() : Optional.ofNullable(err); + } + + /** Map the success payload through {@code f}, leaving an error unchanged. */ + public WitResult map(final Function f) { + Objects.requireNonNull(f, "f"); + return isOk ? WitResult.ok(f.apply(ok)) : WitResult.err(err); + } + + /** Map the error payload through {@code f}, leaving a success unchanged. */ + public WitResult mapErr(final Function f) { + Objects.requireNonNull(f, "f"); + return isOk ? WitResult.ok(ok) : WitResult.err(f.apply(err)); + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof WitResult)) { + return false; + } + final WitResult other = (WitResult) o; + return isOk == other.isOk + && Objects.equals(ok, other.ok) + && Objects.equals(err, other.err); + } + + @Override + public int hashCode() { + return Objects.hash(ok, err, isOk); + } + + @Override + public String toString() { + return isOk ? "Ok(" + ok + ")" : "Err(" + err + ")"; + } +} diff --git a/wasmtime4j-provider/pom.xml b/wasmtime4j-provider/pom.xml index ca160ba..9a0d88f 100644 --- a/wasmtime4j-provider/pom.xml +++ b/wasmtime4j-provider/pom.xml @@ -8,7 +8,7 @@ ai.tegmentum.webassembly4j webassembly4j-parent - 1.5.0 + 2.5.2-SNAPSHOT wasmtime4j-provider @@ -44,4 +44,69 @@ test + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.6.4 + + + build-counter-component + generate-test-resources + + exec + + + cargo + ${project.basedir}/src/test/rust/counter_component + + component + build + --release + --target + wasm32-wasip2 + + ${skip.rust.build} + + + + stage-counter-component + generate-test-resources + + exec + + + cp + + ${project.basedir}/src/test/rust/counter_component/target/wasm32-wasip2/release/counter_component.wasm + ${project.basedir}/src/test/resources/counter_component.wasm + + ${skip.rust.build} + + + + + + + + + + false + diff --git a/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/BridgingSparqlExtensionDispatch.java b/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/BridgingSparqlExtensionDispatch.java new file mode 100644 index 0000000..2850f07 --- /dev/null +++ b/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/BridgingSparqlExtensionDispatch.java @@ -0,0 +1,497 @@ +package ai.tegmentum.webassembly4j.provider.wasmtime; + +import ai.tegmentum.wasmtime4j.wit.WitList; +import ai.tegmentum.wasmtime4j.wit.WitString; +import ai.tegmentum.wasmtime4j.wit.WitValue; +import ai.tegmentum.webassembly4j.api.ComponentInstance; +import ai.tegmentum.webassembly4j.api.WitCallableResource; +import ai.tegmentum.webassembly4j.api.exception.ExecutionException; + +import java.util.List; +import java.util.Objects; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Transitional helper that lets one JVM plugin dispatch against SPARQL extension guests speaking + * either the new sparql-extension ABI ({@code tegmentum:webfunction@0.1.0}) or the old flat-export + * ABI (per-crate world exporting {@code evaluate} / {@code aggregate-step} / + * {@code aggregate-finish} at world root). + * + *

Motivation: the substrate is committed to the new ABI (see + * {@code webfunction-wit/docs/design/custom-function-registry.md}) but 177 of 181 guest crates in + * {@code ~/git/webfunctions/crates/} still export the old shape. Plugins that migrate dispatch + * without a bridging layer trap every {@code wf:call} against an unmigrated guest. This helper + * absorbs the try-new-fall-back-to-old branch so every JVM plugin gets bridging for free, and + * every plugin retires the bridging by deleting one construction site the day the last old-shape + * guest migrates. See {@code webfunction-wit/docs/design/bridging-dispatch.md}. + * + *

Lifecycle: one instance per {@link ComponentInstance}. Capability detection runs at + * construction time — the helper probes the instance's export surface once and caches the result + * so subsequent dispatches skip the fallback path. Every detection logs at {@link Level#FINE} + * so operators can watch the migration curve. + * + *

Thread-safety: dispatch calls are as thread-safe as the underlying + * {@link ComponentInstance}, which in wasmtime4j means single-threaded per instance. This class + * adds no locking of its own. + * + *

Transitional. Delete when every guest in {@code ~/git/webfunctions/crates/} exports the + * sparql-extension world. + */ +public final class BridgingSparqlExtensionDispatch { + + private static final Logger LOG = Logger.getLogger(BridgingSparqlExtensionDispatch.class.getName()); + + /** + * New-shape filter call — the {@code call} function on the {@code extension} interface of + * package {@code tegmentum:webfunction@0.1.0}. Interface-scoped exports use the + * {@code /@#} form on wasmtime4j. + */ + static final String NEW_SHAPE_FILTER_EXPORT = + "tegmentum:webfunction/extension@0.1.0#call"; + + /** + * New-shape filter interface — the parent interface hosting {@link #NEW_SHAPE_FILTER_EXPORT}. + * Used as a shape-detection fallback for older wasmtime4j runtimes whose {@code hasFunction} + * probe doesn't recognize interface-qualified export names (fixed in wasmtime4j 46.0.1-1.4.6 + * via commit 30f7fc5b, but downstream plugins on older wasmtime4j jars still hit the + * limitation). + */ + static final String NEW_SHAPE_FILTER_INTERFACE = + "tegmentum:webfunction/extension@0.1.0"; + + /** + * Old-shape filter call — a bare {@code evaluate} function exported at the guest world's root. + */ + static final String OLD_SHAPE_FILTER_EXPORT = "evaluate"; + + /** + * New-shape aggregate constructor — {@code new-aggregate: func(name: string)} on the + * {@code aggregate} interface, returning an {@code own} handle. + */ + static final String NEW_SHAPE_AGGREGATE_CTOR = + "tegmentum:webfunction/aggregate@0.1.0#new-aggregate"; + + /** + * New-shape aggregate interface — the parent interface hosting {@link + * #NEW_SHAPE_AGGREGATE_CTOR}. Same fallback rationale as {@link #NEW_SHAPE_FILTER_INTERFACE}. + */ + static final String NEW_SHAPE_AGGREGATE_INTERFACE = + "tegmentum:webfunction/aggregate@0.1.0"; + + /** + * New-shape aggregate methods. The plugin-side driver calls {@code step} and {@code finish} + * on the {@link WitCallableResource} returned by {@link #newAggregate(String)}; these + * constants name the methods the resource dispatches to when the guest is new-shape. + */ + static final String NEW_SHAPE_AGGREGATE_STEP = + "tegmentum:webfunction/aggregate@0.1.0#[method]aggregate-state.step"; + + static final String NEW_SHAPE_AGGREGATE_FINISH = + "tegmentum:webfunction/aggregate@0.1.0#[method]aggregate-state.finish"; + + /** + * Old-shape aggregate exports — bare {@code aggregate-step} + {@code aggregate-finish} at + * world root. The shim {@link WitCallableResource} returned by + * {@link #newAggregate(String)} routes {@code step} / {@code finish} method calls back to + * these bare exports. + */ + static final String OLD_SHAPE_AGGREGATE_STEP = "aggregate-step"; + static final String OLD_SHAPE_AGGREGATE_FINISH = "aggregate-finish"; + + /** Neutral method names the shim recognizes. See {@link ShimAggregateResource}. */ + static final String SHIM_METHOD_STEP = "step"; + static final String SHIM_METHOD_FINISH = "finish"; + + private final ComponentInstance instance; + private final boolean filterIsNewShape; + private final boolean aggregateIsNewShape; + + /** + * Bind bridging to a specific component instance and detect its ABI shape once. Both + * capability probes run at construction time; the results are cached for the lifetime of + * this dispatcher. + * + * @param instance the underlying component instance; must be non-null and open + */ + public BridgingSparqlExtensionDispatch(final ComponentInstance instance) { + this.instance = Objects.requireNonNull(instance, "instance"); + // Detection: try the qualified-export probe first (fast, exact — a passing + // `hasFunction` guarantees the invocation path will succeed). Fall back to + // `exportsInterface` when it misses — on wasmtime4j runtimes older than + // 46.0.1-1.4.6 the qualified-name probe returns false even when the export + // is present, so without the interface-level fallback new-shape guests get + // mis-detected as old-shape and dispatch traps on absent flat exports. The + // filter side has carried this fallback at the plugin layer since the + // limitation was diagnosed; hoisting it into the bridging helper lets the + // aggregate side (which the plugins do not work around) reach new-shape + // guests too, and lets the plugin-side filter workaround retire. + this.filterIsNewShape = detectNewShape( + instance, NEW_SHAPE_FILTER_EXPORT, NEW_SHAPE_FILTER_INTERFACE); + this.aggregateIsNewShape = detectNewShape( + instance, NEW_SHAPE_AGGREGATE_CTOR, NEW_SHAPE_AGGREGATE_INTERFACE); + if (LOG.isLoggable(Level.FINE)) { + LOG.log( + Level.FINE, + "Bridging dispatch: filter shape = {0}, aggregate shape = {1}", + new Object[] { + filterIsNewShape ? "new (sparql-extension@0.1.0)" : "old (flat evaluate)", + aggregateIsNewShape ? "new (aggregate-state resource)" : "old (bare aggregate-step/finish)" + }); + } + } + + /** + * Returns whether the underlying instance dispatches its filter function calls through the + * new sparql-extension ABI. Exposed for observability and for plugin-side callers whose + * downstream marshalling differs between shapes (new-shape returns a single {@code term}; + * old-shape returns a {@code list}). + */ + public boolean filterIsNewShape() { + return filterIsNewShape; + } + + /** + * Returns whether the underlying instance dispatches its aggregate lifecycle through the + * new sparql-extension ABI. + */ + public boolean aggregateIsNewShape() { + return aggregateIsNewShape; + } + + /** + * Dispatch a filter call against either the new or the old ABI. The choice is made once at + * construction time (see {@link #filterIsNewShape()}); this method reads the cached + * decision and calls the appropriate underlying export. + * + *

New-shape signature: + * {@code call(name: string, args: list) -> result}. The + * {@code functionName} is marshalled as the {@code name} argument; {@code args} become the + * {@code list}. + * + *

Old-shape signature: {@code evaluate(args: list) -> result}. + * The {@code functionName} is discarded — old-shape guests are per-function crates and + * their identity comes from which artifact was loaded. + * + * @param functionName the SPARQL-level function name (e.g. {@code "upper"}) + * @param args the filter arguments as provider {@code WitValue}s + * @return the provider's raw WitValue result (shape differs by ABI; see method contract) + * @throws ExecutionException if the underlying invocation fails + */ + public WitValue callFilter(final String functionName, final List args) { + Objects.requireNonNull(functionName, "functionName"); + Objects.requireNonNull(args, "args"); + if (args.isEmpty()) { + // WitList.of rejects an empty list because element type isn't inferrable from an + // empty varargs — SPARQL filter functions with zero terms are also degenerate at + // the language layer. Callers with a real need (a pre-typed WitList.empty) should + // pass through the bridge(newShapePath, oldShapePath, args) method directly. + throw new IllegalArgumentException( + "callFilter args list must be non-empty; use bridge() directly if you have " + + "a pre-typed WitList"); + } + final WitValue argList = WitList.of(args); + if (filterIsNewShape) { + return (WitValue) instance.invokeWit( + NEW_SHAPE_FILTER_EXPORT, witString(functionName), argList); + } + // Old-shape: bare `evaluate` takes just the arg list. Any old-shape crate that needs + // to distinguish by name is broken by construction — the crate IS the function. + return (WitValue) instance.invokeWit(OLD_SHAPE_FILTER_EXPORT, argList); + } + + /** + * Return a {@link WitCallableResource} that models the aggregate lifecycle. For new-shape + * guests, this is the real resource returned by {@code new-aggregate(name)}. For old-shape + * guests, this is a shim whose {@code step} / {@code finish} method calls dispatch back to + * the guest's flat {@code aggregate-step} / {@code aggregate-finish} exports. + * + *

Either way, the plugin-side driver calls + * {@code resource.invokeMethodWit("step", args)} and + * {@code resource.invokeMethodWit("finish")} — the bridging hides which ABI the guest + * actually speaks. + * + *

Old-shape caveat: the shim discards the {@code mult: u64} row-multiplicity argument + * the flat aggregate-step signature carries (defaults to {@code 1}) because the new-shape + * ABI does not model row multiplicity. Guests that need multiplicity semantics under the + * old shape must migrate to new-shape or bypass the bridging. + * + * @param aggregateName the SPARQL-level aggregate name (e.g. {@code "sum"}) + * @return an open {@link WitCallableResource} ready for {@code step} / {@code finish} calls + * @throws ExecutionException if new-shape ctor invocation fails + */ + public WitCallableResource newAggregate(final String aggregateName) { + Objects.requireNonNull(aggregateName, "aggregateName"); + if (aggregateIsNewShape) { + // new-aggregate: func(name: string) -> result. + // The wire return is a WitResult; asCallableResource wants the ok-arm + // resource handle directly (a WitOwn/WitBorrow/WitResource, per the + // provider adapter's contract). Unwrap here so callers don't have to + // know the ctor is result-typed — the shim's error surface stays a + // plain ExecutionException carrying the guest's error message. + final Object handle = instance.invokeWit( + NEW_SHAPE_AGGREGATE_CTOR, witString(aggregateName)); + final WitCallableResource raw = + instance.asCallableResource(unwrapResultOk(handle, NEW_SHAPE_AGGREGATE_CTOR)); + // Wrap so callers can dispatch with the neutral method names + // ("step"/"finish") the shim also accepts — the wasmtime resource + // machinery underneath wants the fully-qualified WIT method export + // name and doesn't know about the neutral vocabulary. + return new NeutralMethodNameResource(raw); + } + return new ShimAggregateResource(instance); + } + + /** + * Unwrap the ok arm of a {@link ai.tegmentum.wasmtime4j.wit.WitResult}. Non-result values pass + * through unchanged — old-shape or degenerate guest returns don't get wrapped in a result on + * the wire, and this method is only load-bearing on the new-shape ctor path where the WIT + * signature ({@code result}) forces the wrapper. An err arm becomes + * an {@link ExecutionException} carrying the guest's message; a null/absent ok payload throws + * the same to name the export that surfaced the empty result. + */ + private static Object unwrapResultOk(final Object handle, final String exportName) { + if (!(handle instanceof ai.tegmentum.wasmtime4j.wit.WitResult)) { + return handle; + } + final ai.tegmentum.wasmtime4j.wit.WitResult wr = + (ai.tegmentum.wasmtime4j.wit.WitResult) handle; + if (wr.isErr()) { + final String message = wr.getErr() + .map(v -> v instanceof WitString ? ((WitString) v).getValue() : v.toString()) + .orElse("component returned err with no payload"); + throw new ExecutionException(exportName + " returned err: " + message); + } + return wr.getOk().orElseThrow(() -> new ExecutionException( + exportName + " returned ok with no payload")); + } + + /** + * Shape-detection helper. Returns true when the guest exports the new-shape entry either + * as a qualified function ({@code hasFunction}) or transitively via its containing WIT + * interface ({@code exportsInterface}). + * + *

The two probes are not redundant: {@code hasFunction} is the exact answer but requires + * wasmtime4j 46.0.1-1.4.6+ to recognize interface-qualified names, and downstream plugins + * pin older wasmtime4j jars in ways this helper cannot control. {@code exportsInterface} + * catches every case where the qualified probe misses. + */ + private static boolean detectNewShape( + final ComponentInstance instance, + final String qualifiedExport, + final String interfaceName) { + return instance.hasFunction(qualifiedExport) || instance.exportsInterface(interfaceName); + } + + /** + * Adapter over {@link WitString#of(String)}, which declares a checked + * {@link ai.tegmentum.wasmtime4j.exception.ValidationException} for the null-arg case that + * cannot occur here (we {@link Objects#requireNonNull} the string on entry to each dispatch + * method). Converting to the API-level {@link ExecutionException} keeps the public method + * signatures free of provider-specific checked exceptions. + */ + private static WitString witString(final String value) { + try { + return WitString.of(value); + } catch (final ai.tegmentum.wasmtime4j.exception.ValidationException e) { + throw new ExecutionException("Invalid WIT string: " + value, e); + } + } + + /** + * Generic bridging entry point. Try the new-shape export path first; if the underlying + * instance does not export it, fall back to the old-shape path. Callers whose dispatch + * shape isn't covered by {@link #callFilter} or {@link #newAggregate} — the Stardog + * plugin's {@code cardinality-estimate} overlay is the running example — compose against + * this method rather than duplicate the try/fallback pattern. + * + *

Detection is performed lazily on every call (unlike the filter/aggregate probes done + * at construction) because callers may bridge many distinct export paths and pre-probing + * every one would grow the constructor arbitrarily. Callers who bridge the same path + * repeatedly should cache the result themselves. + * + * @param newShapePath the interface-scoped export name to try first + * @param oldShapePath the bare export name to fall back to + * @param args the arguments to pass through + * @return the provider's raw WitValue result + * @throws ExecutionException if both paths are absent or invocation fails + */ + public WitValue bridge( + final String newShapePath, + final String oldShapePath, + final Object... args) { + Objects.requireNonNull(newShapePath, "newShapePath"); + Objects.requireNonNull(oldShapePath, "oldShapePath"); + if (instance.hasFunction(newShapePath)) { + return (WitValue) instance.invokeWit(newShapePath, args); + } + if (instance.hasFunction(oldShapePath)) { + if (LOG.isLoggable(Level.FINE)) { + LOG.log( + Level.FINE, + "Bridging dispatch: {0} not exported; falling back to {1}", + new Object[] {newShapePath, oldShapePath}); + } + return (WitValue) instance.invokeWit(oldShapePath, args); + } + throw new ExecutionException( + "Neither new-shape export '" + newShapePath + + "' nor old-shape export '" + oldShapePath + + "' is present on the component instance"); + } + + /** + * Adapter over a real wasmtime {@link WitCallableResource} that also accepts the neutral + * method names ({@code "step"} / {@code "finish"}) the {@link ShimAggregateResource} accepts. + * Translates them to the fully qualified WIT method-export paths + * ({@link #NEW_SHAPE_AGGREGATE_STEP} / {@link #NEW_SHAPE_AGGREGATE_FINISH}) that + * {@code invokeResourceMethodWit} expects on wasmtime. Fully-qualified names pass through + * unchanged so a caller that isn't going through the bridging vocabulary still works. + */ + static final class NeutralMethodNameResource implements WitCallableResource { + + private final WitCallableResource delegate; + + NeutralMethodNameResource(final WitCallableResource delegate) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + } + + @Override + public String resourceTypeName() { + return delegate.resourceTypeName(); + } + + @Override + public Object invokeMethod(final String methodExportName, final Object... args) { + return delegate.invokeMethod(translate(methodExportName), args); + } + + @Override + public Object invokeMethodWit(final String methodExportName, final Object... args) { + return delegate.invokeMethodWit(translate(methodExportName), args); + } + + private static String translate(final String methodExportName) { + if (SHIM_METHOD_STEP.equals(methodExportName)) { + return NEW_SHAPE_AGGREGATE_STEP; + } + if (SHIM_METHOD_FINISH.equals(methodExportName)) { + return NEW_SHAPE_AGGREGATE_FINISH; + } + return methodExportName; + } + + @Override + public boolean isClosed() { + return delegate.isClosed(); + } + + @Override + public void close() { + delegate.close(); + } + } + + /** + * Shim {@link WitCallableResource} that fronts a pair of flat exports + * ({@code aggregate-step} / {@code aggregate-finish}) as if they were methods on a WIT + * resource. The shim owns no native handle — {@link #close()} is a no-op because old-shape + * aggregates keep their accumulator state on the component instance itself, not on a + * per-group resource. + */ + static final class ShimAggregateResource implements WitCallableResource { + + private final ComponentInstance instance; + private volatile boolean closed; + + ShimAggregateResource(final ComponentInstance instance) { + this.instance = Objects.requireNonNull(instance, "instance"); + } + + @Override + public String resourceTypeName() { + // No native resource type — this is a synthesized handle over flat exports. + return ""; + } + + @Override + public Object invokeMethod(final String methodExportName, final Object... args) { + final Object result = invokeMethodWit(methodExportName, args); + if (result instanceof WitValue) { + return ((WitValue) result).toJava(); + } + return result; + } + + @Override + public Object invokeMethodWit(final String methodExportName, final Object... args) { + ensureOpen(); + // Accept both the neutral shim method names ("step" / "finish") and the fully + // qualified new-shape method paths a plugin might pass through unchanged when it + // isn't aware which ABI it's holding. The latter path lets a plugin that thinks + // it's talking to a new-shape resource work against the shim without branching. + if (SHIM_METHOD_STEP.equals(methodExportName) + || NEW_SHAPE_AGGREGATE_STEP.equals(methodExportName)) { + return dispatchStep(args); + } + if (SHIM_METHOD_FINISH.equals(methodExportName) + || NEW_SHAPE_AGGREGATE_FINISH.equals(methodExportName)) { + return dispatchFinish(); + } + throw new ExecutionException( + "Shim aggregate resource does not recognize method '" + methodExportName + + "'; expected 'step' or 'finish'"); + } + + private Object dispatchStep(final Object[] args) { + // Old-shape signature: aggregate-step(args: list, mult: u64) -> result<_, string>. + // Default mult to 1 — new-shape has no multiplicity, and per the memo any guest + // that needs multiplicity must migrate off the bridging. + if (args == null || args.length == 0) { + throw new IllegalArgumentException( + "Shim aggregate step requires at least one argument (the row's terms)"); + } + final Object argList; + if (args.length == 1 && args[0] instanceof WitList) { + argList = args[0]; + } else { + final java.util.List collected = new java.util.ArrayList<>(args.length); + for (final Object a : args) { + if (!(a instanceof WitValue)) { + throw new ExecutionException( + "Shim aggregate step expects WitValue args; got " + + (a == null ? "null" : a.getClass().getName())); + } + collected.add((WitValue) a); + } + argList = WitList.of(collected); + } + return instance.invokeWit( + OLD_SHAPE_AGGREGATE_STEP, + argList, + ai.tegmentum.wasmtime4j.wit.WitU64.of(1L)); + } + + private Object dispatchFinish() { + return instance.invokeWit(OLD_SHAPE_AGGREGATE_FINISH); + } + + @Override + public boolean isClosed() { + return closed; + } + + @Override + public void close() { + // Idempotent — old-shape has no resource to drop, but we still flip the flag so + // downstream code that treats the resource as consumed sees the expected state. + closed = true; + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("Shim aggregate resource has been closed"); + } + } + } +} diff --git a/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeCallableResource.java b/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeCallableResource.java new file mode 100644 index 0000000..a192461 --- /dev/null +++ b/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeCallableResource.java @@ -0,0 +1,107 @@ +package ai.tegmentum.webassembly4j.provider.wasmtime; + +import ai.tegmentum.wasmtime4j.component.ComponentInstance; +import ai.tegmentum.wasmtime4j.exception.WasmException; +import ai.tegmentum.wasmtime4j.wit.WitResource; +import ai.tegmentum.wasmtime4j.wit.WitValue; +import ai.tegmentum.webassembly4j.api.WitCallableResource; +import ai.tegmentum.webassembly4j.api.exception.ExecutionException; + +import java.util.Objects; + +/** + * Provider-neutral callable-resource surface for wasmtime — binds a wasmtime {@link WitResource} + * to the {@link ComponentInstance} it came from, so downstream code can dispatch a method on + * the resource without threading the receiver + instance pair by hand. + * + *

Instantiated via {@link WasmtimeComponentInstanceAdapter#asCallableResource(Object)}. + */ +final class WasmtimeCallableResource implements WitCallableResource { + + private final WitResource resource; + private final ComponentInstance instance; + private volatile boolean closed; + + WasmtimeCallableResource(final WitResource resource, final ComponentInstance instance) { + this.resource = Objects.requireNonNull(resource, "resource"); + this.instance = Objects.requireNonNull(instance, "instance"); + } + + @Override + public String resourceTypeName() { + return resource.getResourceTypeName(); + } + + @Override + public Object invokeMethod(final String methodExportName, final Object... args) { + ensureOpen(); + try { + return instance.invokeResourceMethod(resource, methodExportName, marshalArgs(args)); + } catch (final WasmException e) { + throw new ExecutionException( + "Failed to invoke resource method '" + methodExportName + "'", e); + } + } + + @Override + public Object invokeMethodWit(final String methodExportName, final Object... args) { + ensureOpen(); + try { + final WitValue result = + instance.invokeResourceMethodWit(resource, methodExportName, marshalArgs(args)); + return result; + } catch (final WasmException e) { + throw new ExecutionException( + "Failed to invoke resource method '" + methodExportName + "'", e); + } + } + + @Override + public boolean isClosed() { + return closed; + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + try { + instance.dropResource(resource); + } catch (final UnsupportedOperationException e) { + // A pure-Java WitResource has no native backing; drop is a no-op — we already + // flipped `closed`, so further invokeMethod calls will throw the usual "closed" + // error rather than silently continuing to talk to a phantom native handle. + } catch (final WasmException e) { + throw new ExecutionException("Failed to drop resource", e); + } + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("Resource has been closed"); + } + } + + /** + * Reuse the adapter's argument marshaller so an {@link Object} arg that isn't already a + * {@code WitValue} is coerced by the same rules as {@link + * WasmtimeComponentInstanceAdapter#invoke(String, Object...)}. + */ + private static Object[] marshalArgs(final Object[] args) { + if (args == null || args.length == 0) { + return new Object[0]; + } + final Object[] witArgs = new Object[args.length]; + for (int i = 0; i < args.length; i++) { + try { + witArgs[i] = WasmtimeComponentInstanceAdapter.toWitValue(args[i]); + } catch (final WasmException e) { + throw new ExecutionException( + "Argument " + i + " is not a valid WIT value", e); + } + } + return witArgs; + } +} diff --git a/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeCallerAdapter.java b/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeCallerAdapter.java new file mode 100644 index 0000000..0292274 --- /dev/null +++ b/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeCallerAdapter.java @@ -0,0 +1,473 @@ +package ai.tegmentum.webassembly4j.provider.wasmtime; + +import ai.tegmentum.wasmtime4j.WasmFunction; +import ai.tegmentum.wasmtime4j.WasmGlobal; +import ai.tegmentum.wasmtime4j.WasmMemory; +import ai.tegmentum.wasmtime4j.WasmRuntime; +import ai.tegmentum.wasmtime4j.WasmTable; +import ai.tegmentum.webassembly4j.api.Caller; +import ai.tegmentum.webassembly4j.api.ExternImportDefinition; +import ai.tegmentum.webassembly4j.api.Function; +import ai.tegmentum.webassembly4j.api.FunctionImport; +import ai.tegmentum.webassembly4j.api.Global; +import ai.tegmentum.webassembly4j.api.GlobalImport; +import ai.tegmentum.webassembly4j.api.Instance; +import ai.tegmentum.webassembly4j.api.LinkingContext; +import ai.tegmentum.webassembly4j.api.Memory; +import ai.tegmentum.webassembly4j.api.MemoryImport; +import ai.tegmentum.webassembly4j.api.Module; +import ai.tegmentum.webassembly4j.api.Table; +import ai.tegmentum.webassembly4j.api.TableImport; +import ai.tegmentum.webassembly4j.api.exception.ExecutionException; +import ai.tegmentum.webassembly4j.api.exception.LinkingException; + +import java.util.Optional; + +/** + * Bridges wasmtime4j's caller-scoped host-callback handle + * ({@link ai.tegmentum.wasmtime4j.func.Caller}) into the webassembly4j + * {@link Caller} api. Instances are constructed by + * {@link WasmtimeModuleAdapter} when it dispatches a + * {@link ai.tegmentum.webassembly4j.api.CallerAwareHostFunctionDefinition} + * and are only valid for the duration of the host callback that received + * them — all scoped mutation methods route through the underlying + * wasmtime4j Caller, which generation-checks use-after-return and throws + * {@link IllegalStateException} on stale handles. + * + *

Because {@code ai.tegmentum.wasmtime4j.func.Caller} exposes no public + * accessor for the caller's underlying {@link ai.tegmentum.wasmtime4j.Store}, + * this adapter carries only the {@link WasmRuntime} + engine references + * needed to build caller-scoped modules and linkers; the store binding is + * kept implicit in the native caller handle and used only via the borrow- + * safe wasmtime4j scoped-mutation entrypoints. See charter + * {@code f-webassembly4j-caller-aware-host-function-charter-2026-07-26.md} + * for the reconciliation with the r.2 wasmtime4j implementation. + */ +final class WasmtimeCallerAdapter implements Caller { + + private final ai.tegmentum.wasmtime4j.func.Caller nativeCaller; + private final WasmRuntime runtime; + private final ai.tegmentum.wasmtime4j.config.EngineConfig engineConfig; + + WasmtimeCallerAdapter(ai.tegmentum.wasmtime4j.func.Caller nativeCaller, + WasmRuntime runtime, + ai.tegmentum.wasmtime4j.config.EngineConfig engineConfig) { + this.nativeCaller = nativeCaller; + this.runtime = runtime; + this.engineConfig = engineConfig; + } + + @Override + public T data() { + return nativeCaller.data(); + } + + @Override + public Optional getMemory(String name) { + return nativeCaller.getMemory(name) + .map(mem -> new WasmtimeMemoryAdapter(mem, name)); + } + + @Override + public Optional getTable(String name) { + return nativeCaller.getTable(name) + .map(tbl -> new WasmtimeTableAdapter(tbl, name)); + } + + @Override + public Optional getFunction(String name) { + return nativeCaller.getFunction(name).map(WasmtimeFunctionAdapter::new); + } + + @Override + public Optional getGlobal(String name) { + return nativeCaller.getGlobal(name).map(WasmtimeGlobalAdapter::new); + } + + /** + * Compile a module using the caller's engine. Returns a caller-scoped + * {@link WasmtimeModuleAdapter} — it carries no owning {@link + * ai.tegmentum.wasmtime4j.Store}, so the caller MUST route the + * subsequent instantiation through {@link #instantiate(Module, LinkingContext)} + * rather than calling the module's own {@code instantiate()} methods + * (those throw for caller-scoped modules). + */ + @Override + public Module compileModule(byte[] wasmBytes) { + try { + ai.tegmentum.wasmtime4j.Module nativeModule = nativeCaller.compileModule(wasmBytes); + ai.tegmentum.wasmtime4j.Engine callerEngine = nativeCaller.engine(); + return WasmtimeModuleAdapter.callerScoped( + runtime, callerEngine, nativeModule, engineConfig); + } catch (IllegalStateException e) { + throw e; + } catch (ai.tegmentum.wasmtime4j.exception.WasmException e) { + throw new ExecutionException( + "Caller-scoped compileModule failed: " + e.getMessage(), e); + } catch (UnsupportedOperationException e) { + throw e; + } + } + + /** + * Instantiate a module in the caller's store via the borrow-safe + * scoped-instantiate path. The module's imports are resolved through a + * transient {@link ai.tegmentum.wasmtime4j.Linker} built from the + * caller's engine; the pre-linked + * {@link ai.tegmentum.wasmtime4j.InstancePre} is then passed to + * {@link ai.tegmentum.wasmtime4j.func.Caller#instantiate(ai.tegmentum.wasmtime4j.InstancePre)} + * which uses {@code caller.as_context_mut()} instead of re-acquiring + * the store lock. + * + *

Only the host-function and caller-aware-host-function slices of + * the passed {@link LinkingContext} are wired via the transient linker + * — extern imports (memories, tables, globals) require a live store + * binding that isn't reachable from a wasmtime4j Caller, so passing + * non-empty {@code externImports()} throws {@link LinkingException}. + * The typical JIT-install-loop case (compile + instantiate against + * user host imports, then table-install into the caller's outer + * table) is unaffected by this restriction. + */ + @Override + public Instance instantiate(Module module, LinkingContext imports) { + if (module == null) { + throw new IllegalArgumentException("module must not be null"); + } + if (!(module instanceof WasmtimeModuleAdapter)) { + throw new LinkingException( + "Caller.instantiate: module is not backed by a" + + " wasmtime4j-provider Module (got " + + module.getClass().getName() + ")"); + } + ai.tegmentum.wasmtime4j.Module nativeModule = + ((WasmtimeModuleAdapter) module).nativeModule(); + ai.tegmentum.wasmtime4j.Engine callerEngine = nativeCaller.engine(); + try { + ai.tegmentum.wasmtime4j.Linker linker = runtime.createLinker(callerEngine); + if (imports != null) { + defineCallerScopedExternImports(linker, imports.externImports()); + WasmtimeModuleAdapter.defineHostFunctions(linker, imports.hostFunctions()); + // Nested caller-aware host functions on an inner module + // instantiated inside a callback are permitted — they + // will fire against the inner instance's own callback + // context when it invokes them. + for (ai.tegmentum.webassembly4j.api.CallerAwareHostFunctionDefinition def + : imports.callerAwareHostFunctions()) { + // Route through the same package-private helper used + // by WasmtimeModuleAdapter — the nested caller adapter + // is minted per-invocation from the wasmtime4j callback + // context, not from the outer caller. + defineNestedCallerAware(linker, def); + } + } + ai.tegmentum.wasmtime4j.InstancePre pre = linker.instantiatePre(nativeModule); + ai.tegmentum.wasmtime4j.Instance nativeInstance = nativeCaller.instantiate(pre); + return new WasmtimeInstanceAdapter(nativeInstance, runtime, callerEngine); + } catch (IllegalStateException e) { + throw e; + } catch (UnsupportedOperationException e) { + throw e; + } catch (ai.tegmentum.wasmtime4j.exception.WasmException e) { + throw new LinkingException( + "Caller-scoped instantiate failed: " + e.getMessage(), e); + } + } + + /** + * Wire {@code MemoryImport} / {@code TableImport} / {@code GlobalImport} + * defs from the caller into a transient linker, using the caller's live + * store context via + * {@link ai.tegmentum.wasmtime4j.func.Caller#linkerDefineMemory} / + * {@code linkerDefineTable} / {@code linkerDefineGlobal}. Mirrors + * {@code WasmtimeModuleAdapter.defineExternImports} but routes through + * the scoped-caller path so no {@code Store} handle is needed — + * F-Wasmtime4j-Caller-Scoped-Instantiate-Extern-Imports r.1 landed + * these ops on the wasmtime4j Caller interface at 1.5.2. + * + *

{@link FunctionImport} remains unsupported here — the api-layer + * {@link ai.tegmentum.webassembly4j.api.Function Function} interface has + * no {@code unwrap(Class)} method, matching the same limitation as the + * outer path. + */ + private void defineCallerScopedExternImports( + ai.tegmentum.wasmtime4j.Linker linker, + java.util.List externImports) + throws ai.tegmentum.wasmtime4j.exception.WasmException { + if (externImports == null || externImports.isEmpty()) { + return; + } + for (ExternImportDefinition def : externImports) { + if (def instanceof MemoryImport) { + MemoryImport mi = (MemoryImport) def; + if (mi.memory() instanceof WasmtimeMemoryAdapter + && ((WasmtimeMemoryAdapter) mi.memory()).callerExportName() != null) { + nativeCaller.linkerDefineMemoryFromExport( + linker, + mi.moduleName(), + mi.name(), + ((WasmtimeMemoryAdapter) mi.memory()).callerExportName()); + } else { + WasmMemory nativeMemory = + mi.memory().unwrap(WasmMemory.class) + .orElseThrow(() -> new LinkingException( + "MemoryImport " + mi.moduleName() + "::" + mi.name() + + " memory is not a wasmtime4j-backed instance")); + nativeCaller.linkerDefineMemory( + linker, mi.moduleName(), mi.name(), nativeMemory); + } + } else if (def instanceof TableImport) { + TableImport ti = (TableImport) def; + if (ti.table() instanceof WasmtimeTableAdapter + && ((WasmtimeTableAdapter) ti.table()).callerExportName() != null) { + nativeCaller.linkerDefineTableFromExport( + linker, + ti.moduleName(), + ti.name(), + ((WasmtimeTableAdapter) ti.table()).callerExportName()); + } else { + WasmTable nativeTable = + ti.table().unwrap(WasmTable.class) + .orElseThrow(() -> new LinkingException( + "TableImport " + ti.moduleName() + "::" + ti.name() + + " table is not a wasmtime4j-backed instance")); + nativeCaller.linkerDefineTable( + linker, ti.moduleName(), ti.name(), nativeTable); + } + } else if (def instanceof GlobalImport) { + GlobalImport gi = (GlobalImport) def; + WasmGlobal nativeGlobal = + gi.global().unwrap(WasmGlobal.class) + .orElseThrow(() -> new LinkingException( + "GlobalImport " + gi.moduleName() + "::" + gi.name() + + " global is not a wasmtime4j-backed instance")); + nativeCaller.linkerDefineGlobal(linker, gi.moduleName(), gi.name(), nativeGlobal); + } else if (def instanceof FunctionImport) { + FunctionImport fi = (FunctionImport) def; + throw new UnsupportedOperationException( + "FunctionImport " + fi.moduleName() + "::" + fi.name() + + " is not yet supported by wasmtime4j-provider caller-scoped" + + " instantiate — the api-layer Function interface has no" + + " unwrap(Class) accessor. Use LinkingContext addHostFunction" + + " or addCallerAwareHostFunction instead."); + } else { + throw new LinkingException( + "Unknown ExternImportDefinition variant: " + + (def == null ? "null" : def.getClass().getName())); + } + } + } + + /** + * Register a caller-aware host function on the transient linker built + * for the caller-scoped inner instantiate. Mirrors the wiring in + * {@link WasmtimeModuleAdapter#defineCallerAwareHostFunctions} but is + * inlined here so this path doesn't need a WasmtimeModuleAdapter + * instance (there is no api-level module yet at the caller-scope). + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private void defineNestedCallerAware( + ai.tegmentum.wasmtime4j.Linker linker, + ai.tegmentum.webassembly4j.api.CallerAwareHostFunctionDefinition def) + throws ai.tegmentum.wasmtime4j.exception.WasmException { + ai.tegmentum.wasmtime4j.WasmValueType[] paramTypes = + convertToWasmTypes(def.parameterTypes()); + ai.tegmentum.wasmtime4j.WasmValueType[] resultTypes = + convertToWasmTypes(def.resultTypes()); + ai.tegmentum.wasmtime4j.type.FunctionType funcType = + new ai.tegmentum.wasmtime4j.type.FunctionType(paramTypes, resultTypes); + + ai.tegmentum.wasmtime4j.func.HostFunction impl = + new ai.tegmentum.wasmtime4j.func.HostFunction.CallerAwareHostFunction( + (ai.tegmentum.wasmtime4j.func.HostFunction + .MultiValueHostFunctionWithCaller) (innerCaller, + wasmArgs) -> { + WasmtimeCallerAdapter adapter = new WasmtimeCallerAdapter( + innerCaller, runtime, engineConfig); + Object[] javaArgs = extractWasmValues(wasmArgs); + ai.tegmentum.webassembly4j.api.CallerAwareHostFunction fn = + (ai.tegmentum.webassembly4j.api.CallerAwareHostFunction) + def.function(); + @SuppressWarnings("unchecked") + Object[] results = fn.execute(adapter, javaArgs); + if (results == null || results.length == 0) { + return new ai.tegmentum.wasmtime4j.WasmValue[0]; + } + return convertToWasmValues(results, def.resultTypes()); + }); + linker.defineHostFunction(def.moduleName(), def.functionName(), funcType, impl); + } + + private static ai.tegmentum.wasmtime4j.WasmValueType[] convertToWasmTypes( + ai.tegmentum.webassembly4j.api.ValueType[] types) { + ai.tegmentum.wasmtime4j.WasmValueType[] result = + new ai.tegmentum.wasmtime4j.WasmValueType[types.length]; + for (int i = 0; i < types.length; i++) { + result[i] = convertToWasmType(types[i]); + } + return result; + } + + private static ai.tegmentum.wasmtime4j.WasmValueType convertToWasmType( + ai.tegmentum.webassembly4j.api.ValueType type) { + switch (type) { + case I32: return ai.tegmentum.wasmtime4j.WasmValueType.I32; + case I64: return ai.tegmentum.wasmtime4j.WasmValueType.I64; + case F32: return ai.tegmentum.wasmtime4j.WasmValueType.F32; + case F64: return ai.tegmentum.wasmtime4j.WasmValueType.F64; + case V128: return ai.tegmentum.wasmtime4j.WasmValueType.V128; + case FUNCREF: return ai.tegmentum.wasmtime4j.WasmValueType.FUNCREF; + case EXTERNREF: return ai.tegmentum.wasmtime4j.WasmValueType.EXTERNREF; + default: throw new IllegalArgumentException("Unknown type: " + type); + } + } + + private static Object[] extractWasmValues(ai.tegmentum.wasmtime4j.WasmValue[] wasmValues) { + Object[] result = new Object[wasmValues.length]; + for (int i = 0; i < wasmValues.length; i++) { + switch (wasmValues[i].getType()) { + case I32: result[i] = wasmValues[i].asInt(); break; + case I64: result[i] = wasmValues[i].asLong(); break; + case F32: result[i] = wasmValues[i].asFloat(); break; + case F64: result[i] = wasmValues[i].asDouble(); break; + default: result[i] = wasmValues[i]; break; + } + } + return result; + } + + private static ai.tegmentum.wasmtime4j.WasmValue[] convertToWasmValues( + Object[] values, ai.tegmentum.webassembly4j.api.ValueType[] types) { + ai.tegmentum.wasmtime4j.WasmValue[] result = + new ai.tegmentum.wasmtime4j.WasmValue[values.length]; + for (int i = 0; i < values.length; i++) { + Number num = (Number) values[i]; + switch (types[i]) { + case I32: result[i] = + ai.tegmentum.wasmtime4j.WasmValue.i32(num.intValue()); break; + case I64: result[i] = + ai.tegmentum.wasmtime4j.WasmValue.i64(num.longValue()); break; + case F32: result[i] = + ai.tegmentum.wasmtime4j.WasmValue.f32(num.floatValue()); break; + case F64: result[i] = + ai.tegmentum.wasmtime4j.WasmValue.f64(num.doubleValue()); break; + default: throw new IllegalArgumentException("Unsupported return type: " + types[i]); + } + } + return result; + } + + @Override + public int growTable(Table table, int delta, Object init) { + if (table == null) { + throw new IllegalArgumentException("table must not be null"); + } + WasmTable nativeTable = table.unwrap(WasmTable.class) + .orElseThrow(() -> new LinkingException( + "Caller.growTable: table is not backed by a wasmtime4j" + + " WasmTable")); + Object nativeInit = unwrapTableSlotValue(init); + try { + return nativeCaller.growTable(nativeTable, delta, nativeInit); + } catch (IllegalStateException e) { + throw e; + } catch (UnsupportedOperationException e) { + throw e; + } catch (ai.tegmentum.wasmtime4j.exception.WasmException e) { + throw new ExecutionException( + "Caller-scoped growTable failed: " + e.getMessage(), e); + } + } + + @Override + public void setTableElement(Table table, int index, Object value) { + if (table == null) { + throw new IllegalArgumentException("table must not be null"); + } + WasmTable nativeTable = table.unwrap(WasmTable.class) + .orElseThrow(() -> new LinkingException( + "Caller.setTableElement: table is not backed by a" + + " wasmtime4j WasmTable")); + Object nativeValue = unwrapTableSlotValue(value); + try { + nativeCaller.setTableElement(nativeTable, index, nativeValue); + } catch (IllegalStateException e) { + throw e; + } catch (UnsupportedOperationException e) { + throw e; + } catch (ai.tegmentum.wasmtime4j.exception.WasmException e) { + throw new ExecutionException( + "Caller-scoped setTableElement failed: " + e.getMessage(), e); + } + } + + @Override + public long growMemory(Memory memory, long deltaPages) { + if (memory == null) { + throw new IllegalArgumentException("memory must not be null"); + } + WasmMemory nativeMemory = memory.unwrap(WasmMemory.class) + .orElseThrow(() -> new LinkingException( + "Caller.growMemory: memory is not backed by a wasmtime4j" + + " WasmMemory")); + try { + return nativeCaller.growMemory(nativeMemory, deltaPages); + } catch (IllegalStateException e) { + throw e; + } catch (UnsupportedOperationException e) { + throw e; + } catch (ai.tegmentum.wasmtime4j.exception.WasmException e) { + throw new ExecutionException( + "Caller-scoped growMemory failed: " + e.getMessage(), e); + } + } + + @Override + public byte[] readMemory(String memoryName, long offset, int length) { + try { + return nativeCaller.readMemory(memoryName, offset, length); + } catch (ai.tegmentum.wasmtime4j.exception.WasmException e) { + throw new ExecutionException( + "Caller-scoped readMemory failed: " + e.getMessage(), e); + } + } + + @Override + public void writeMemory(String memoryName, long offset, byte[] bytes) { + try { + nativeCaller.writeMemory(memoryName, offset, bytes); + } catch (ai.tegmentum.wasmtime4j.exception.WasmException e) { + throw new ExecutionException( + "Caller-scoped writeMemory failed: " + e.getMessage(), e); + } + } + + @Override + @SuppressWarnings("unchecked") + public Optional unwrap(Class nativeType) { + if (nativeType.isInstance(nativeCaller)) { + return Optional.of((U) nativeCaller); + } + return Optional.empty(); + } + + /** + * Coerce a Java-side table slot value into the raw native handle + * expected by wasmtime4j's caller-scoped table mutation entrypoints. + * Nulls pass through unchanged (representing a null funcref / + * externref); webassembly4j {@link Function} handles are unwrapped to + * their underlying {@link WasmFunction}. Anything else is passed + * through and will be validated by the native layer. + */ + private static Object unwrapTableSlotValue(Object value) { + if (value == null) { + return null; + } + if (value instanceof Function) { + return ((Function) value).unwrap(WasmFunction.class) + .orElseThrow(() -> new LinkingException( + "Caller table slot Function value is not backed by a" + + " wasmtime4j WasmFunction")); + } + return value; + } +} diff --git a/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeComponentAdapter.java b/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeComponentAdapter.java index 7911445..ce9e541 100644 --- a/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeComponentAdapter.java +++ b/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeComponentAdapter.java @@ -2,12 +2,22 @@ import ai.tegmentum.wasmtime4j.WasmRuntime; import ai.tegmentum.wasmtime4j.component.ComponentEngine; +import ai.tegmentum.wasmtime4j.component.ComponentHostFunction; import ai.tegmentum.wasmtime4j.component.ComponentLinker; +import ai.tegmentum.wasmtime4j.component.ComponentVal; +import ai.tegmentum.wasmtime4j.wasi.DirPerms; +import ai.tegmentum.wasmtime4j.wasi.FilePerms; +import ai.tegmentum.wasmtime4j.wasi.WasiPreview2Config; import ai.tegmentum.webassembly4j.api.ComponentInstance; import ai.tegmentum.webassembly4j.api.LinkingContext; +import ai.tegmentum.webassembly4j.api.WasiContext; +import ai.tegmentum.webassembly4j.api.WasiNnConfig; +import ai.tegmentum.webassembly4j.api.WitHostFunctionDefinition; +import ai.tegmentum.webassembly4j.api.config.ComponentConfig; import ai.tegmentum.webassembly4j.api.exception.InstantiationException; import ai.tegmentum.webassembly4j.api.exception.UnsupportedFeatureException; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -19,51 +29,102 @@ final class WasmtimeComponentAdapter implements ai.tegmentum.webassembly4j.api.C private final ai.tegmentum.wasmtime4j.Engine engine; private final ComponentEngine componentEngine; private final ai.tegmentum.wasmtime4j.component.Component nativeComponent; - private final ai.tegmentum.wasmtime4j.Store store; + // Stores are created per instantiation (so ComponentConfig limits can be applied) and + // closed together with the component. + private final List stores = new ArrayList<>(); WasmtimeComponentAdapter(WasmRuntime runtime, ai.tegmentum.wasmtime4j.Engine engine, ComponentEngine componentEngine, - ai.tegmentum.wasmtime4j.component.Component nativeComponent, - ai.tegmentum.wasmtime4j.Store store) { + ai.tegmentum.wasmtime4j.component.Component nativeComponent) { this.runtime = runtime; this.engine = engine; this.componentEngine = componentEngine; this.nativeComponent = nativeComponent; - this.store = store; } @Override public ComponentInstance instantiate() { - try { - ComponentLinker linker = ComponentLinker.create(engine); - linker.enableWasiPreview2(); - ai.tegmentum.wasmtime4j.component.ComponentInstance nativeInstance = - linker.instantiate(store, nativeComponent); - return new WasmtimeComponentInstanceAdapter(nativeInstance); - } catch (ai.tegmentum.wasmtime4j.exception.WasmException e) { - throw new InstantiationException( - "Failed to instantiate WebAssembly component", e); - } + return doInstantiate(null, null); } @Override public ComponentInstance instantiate(LinkingContext linkingContext) { - if (linkingContext == null) { - return instantiate(); - } + return doInstantiate(linkingContext, null); + } + + @Override + public ComponentInstance instantiate(ComponentConfig config) { + return doInstantiate(null, config); + } + + @Override + public ComponentInstance instantiate(LinkingContext linkingContext, ComponentConfig config) { + return doInstantiate(linkingContext, config); + } - if (!linkingContext.hostFunctions().isEmpty()) { + private ComponentInstance doInstantiate(LinkingContext linkingContext, ComponentConfig config) { + if (linkingContext != null && !linkingContext.hostFunctions().isEmpty()) { throw new UnsupportedFeatureException( "Core host functions are not supported for component instantiation. " + "Use unwrap() to access the native ComponentLinker for WIT-level imports."); } - try { + ai.tegmentum.wasmtime4j.Store store = newStore(config); ComponentLinker linker = ComponentLinker.create(engine); - if (linkingContext.wasiContext() != null) { - linker.enableWasiPreview2(); + // Memory cap + epoch deadline can't be read back from the store (unlike fuel), so carry + // them to the instantiation explicitly. The component runs on wasmtime4j's shared + // component engine (metered for fuel + epoch), so these apply regardless of this + // engine's own config. -1 = unlimited. + if (config != null) { + long maxMemory = config.maxMemoryBytes().isPresent() + ? config.maxMemoryBytes().getAsLong() : -1L; + long epoch = config.epochDeadline().isPresent() + ? config.epochDeadline().getAsLong() : -1L; + linker.setComponentResourceLimits(maxMemory, epoch); + } + + WasiContext wasi = linkingContext != null ? linkingContext.wasiContext() : null; + // Always route through the (config) overload — the no-arg variant leaves the + // linker's stored wasiConfig null, and JniComponentLinker.instantiate falls back to + // the stub linker path (Fix 12 tracking) which returns a phantom instance ID and the + // subsequent invoke fails with "Instance ID N not found in engine". A default config + // (no preopens, no env, deny-network) yields a WASI context that resolves the guest's + // imports without granting anything. + WasiPreview2Config p2 = (wasi != null) + ? toWasiPreview2Config(wasi) + : WasiPreview2Config.builder().build(); + linker.enableWasiPreview2(p2); + + // WASI-NN opt-in: translate the framework-neutral WasiNnConfig into + // wasmtime4j's native WasiNnConfig and register wasi:nn on the linker + // before instantiation. Providers without wasi:nn surface via the + // native linker throwing WasmException; we let that propagate as + // InstantiationException below. + WasiNnConfig nn = linkingContext != null ? linkingContext.wasiNnConfig() : null; + if (nn != null) { + linker.enableWasiNn(toNativeWasiNnConfig(nn)); + } + + // Register any WIT-typed host imports declared on the linking context. Each is + // wrapped in a native ComponentHostFunction that just delegates — the caller + // (whose api-level function takes/returns Object[]) already speaks the provider's + // native value type (ComponentVal here); a cross-provider WIT value abstraction + // is intentionally out of scope for this version. + if (linkingContext != null) { + for (WitHostFunctionDefinition def : linkingContext.witHostFunctions()) { + final WitHostFunctionDefinition d = def; + ComponentHostFunction impl = args -> { + Object[] argsArr = new Object[args.size()]; + for (int i = 0; i < args.size(); i++) argsArr[i] = args.get(i); + Object[] results = d.function().execute(argsArr); + java.util.List out = new java.util.ArrayList<>(results.length); + for (Object r : results) out.add((ComponentVal) r); + return out; + }; + linker.defineFunction(def.witPath(), impl); + } } ai.tegmentum.wasmtime4j.component.ComponentInstance nativeInstance = @@ -71,10 +132,156 @@ public ComponentInstance instantiate(LinkingContext linkingContext) { return new WasmtimeComponentInstanceAdapter(nativeInstance); } catch (ai.tegmentum.wasmtime4j.exception.WasmException e) { throw new InstantiationException( - "Failed to instantiate WebAssembly component with linking context", e); + "Failed to instantiate WebAssembly component", e); } } + /** Create a store, applying ComponentConfig resource limits (memory/table/instances + fuel/epoch). */ + private ai.tegmentum.wasmtime4j.Store newStore(ComponentConfig config) + throws ai.tegmentum.wasmtime4j.exception.WasmException { + ai.tegmentum.wasmtime4j.Store store; + if (config != null && hasStoreLimits(config)) { + ai.tegmentum.wasmtime4j.config.StoreLimits.Builder lim = + ai.tegmentum.wasmtime4j.config.StoreLimits.builder(); + config.maxMemoryBytes().ifPresent(lim::memorySize); + config.maxTableElements().ifPresent(lim::tableElements); + config.maxInstances().ifPresent(lim::instances); + config.maxTables().ifPresent(lim::tables); + config.maxMemories().ifPresent(lim::memories); + lim.trapOnGrowFailure(config.trapOnGrowFailure()); + store = runtime.createStore(engine, lim.build()); + } else { + store = engine.createStore(); + } + // Fuel: when the engine meters fuel, the store's remaining fuel is what the component + // provider carries down as the per-instance compute cap. Set the requested cap, or + // MAX_VALUE ("unlimited") when none is given — otherwise a metered store defaults to 0 fuel + // and the component would trap on its first instruction. + if (engine.isFuelEnabled()) { + long fuel = (config != null && config.fuelLimit().isPresent()) + ? config.fuelLimit().getAsLong() + : Long.MAX_VALUE; + store.setFuel(fuel); + } + if (config != null && config.epochDeadline().isPresent() + && engine.isEpochInterruptionEnabled()) { + store.setEpochDeadline(config.epochDeadline().getAsLong()); + } + stores.add(store); + return store; + } + + private static boolean hasStoreLimits(ComponentConfig c) { + return c.maxMemoryBytes().isPresent() || c.maxTableElements().isPresent() + || c.maxInstances().isPresent() || c.maxTables().isPresent() + || c.maxMemories().isPresent(); + } + + /** + * Translate the provider-agnostic WasiContext into wasmtime4j's WasiPreview2Config. + * Package-private for unit testing the mapping. + */ + static WasiPreview2Config toWasiPreview2Config(WasiContext wasi) { + WasiPreview2Config.Builder b = WasiPreview2Config.builder(); + if (wasi.args() != null && !wasi.args().isEmpty()) { + b.args(wasi.args().toArray(new String[0])); + } + if (wasi.env() != null && !wasi.env().isEmpty()) { + b.env(wasi.env()); + } + if (wasi.inheritStdin()) { + b.inheritStdin(); + } + if (wasi.inheritStdout()) { + b.inheritStdout(); + } + if (wasi.inheritStderr()) { + b.inheritStderr(); + } + // Preopens: host path == guest path. A dir listed in readOnlyPreopenDirs is granted read + // permissions only (the guest cannot create/write/delete within it); all others are + // read-write. (Guest-path remap remains a follow-on.) + if (wasi.preopenDirs() != null) { + java.util.Set readOnly = wasi.readOnlyPreopenDirs() == null + ? java.util.Collections.emptySet() + : new java.util.HashSet<>(wasi.readOnlyPreopenDirs()); + java.util.Map guestPaths = wasi.preopenGuestPaths() == null + ? java.util.Collections.emptyMap() + : wasi.preopenGuestPaths(); + for (String dir : wasi.preopenDirs()) { + String guest = guestPaths.getOrDefault(dir, dir); // default: guest path == host path + if (readOnly.contains(dir)) { + b.preopenDir(Paths.get(dir), guest, DirPerms.readOnly(), FilePerms.readOnly()); + } else { + b.preopenDir(Paths.get(dir), guest, DirPerms.all(), FilePerms.all()); + } + } + } + // Network egress: deny-by-default unless the policy grants it. When granted, allow TCP/UDP but + // gate every connection through a SocketAddrCheck that permits ONLY egress (connect / outgoing + // datagram) to an allow-listed endpoint and denies every bind/listen use — no ingress, ever. + if (wasi.allowNetwork() && wasi.egressRules() != null && !wasi.egressRules().isEmpty()) { + final java.util.List rules = + new ArrayList<>(wasi.egressRules()); + b.allowNetwork(true).allowTcp(true).allowUdp(true); + b.socketAddrCheck((address, use) -> { + final boolean tcp; + switch (use) { + case TCP_CONNECT: + tcp = true; + break; + case UDP_CONNECT: + case UDP_OUTGOING_DATAGRAM: + tcp = false; + break; + default: + return false; // deny all *_BIND — egress only + } + for (ai.tegmentum.webassembly4j.api.NetworkEgressRule r : rules) { + if (r.matches(address, tcp)) { + return true; + } + } + return false; + }); + } + // Filesystem-denial observability: if the policy supplied an engine-neutral FsAccessObserver, + // bridge it to wasmtime4j's own observer so a denied component open-at/stat-at on the preopen + // path surfaces the raw guest path + classified reason. Observe-only — it cannot change the + // enforcement outcome (wasmtime has already refused the open by the time this fires). + if (wasi.fsAccessObserver() != null && wasi.fsAccessObserver().isPresent()) { + final ai.tegmentum.webassembly4j.api.FsAccessObserver neutral = wasi.fsAccessObserver().get(); + b.fsAccessObserver( + (path, operation, reason, errorCode) -> + neutral.onDenied(path, operation, reason, errorCode)); + } + // wasi:http opt-in: forward the framework-neutral flag to wasmtime4j's WasiPreview2Config, + // which drives ComponentLinker.enableWasiHttp() after preview 2 is enabled. Default off; + // publishing the imports does not itself grant egress — the network egress rules above still + // gate what the guest can reach. + if (wasi.wasiHttpEnabled()) { + b.wasiHttp(true); + } + return b.build(); + } + + /** + * Translate the provider-neutral WasiNnConfig into wasmtime4j's native + * WasiNnConfig. Named-model entries are copied via the native builder + * (which itself clones the byte array). Package-private for unit test. + */ + static ai.tegmentum.wasmtime4j.wasi.nn.WasiNnConfig toNativeWasiNnConfig(WasiNnConfig config) { + if (config == null || config.namedModels().isEmpty()) { + return ai.tegmentum.wasmtime4j.wasi.nn.WasiNnConfig.defaults(); + } + ai.tegmentum.wasmtime4j.wasi.nn.WasiNnConfig.Builder b = + ai.tegmentum.wasmtime4j.wasi.nn.WasiNnConfig.builder(); + for (java.util.Map.Entry e : config.namedModels().entrySet()) { + b.registerModel(e.getKey(), e.getValue()); + } + return b.build(); + } + @Override public List exportedInterfaces() { try { @@ -123,14 +330,85 @@ public byte[] serialize() { } } + /** + * Reports whether the loaded wasmtime4j native library was compiled with the + * {@code wasi-nn} cargo feature. Delegates via reflection to wasmtime4j's + * static probe (which itself queries a compile-time constant baked into the + * native), so the answer is cheap and stable for the process lifetime — no + * exception-catching semantics required. When {@code false}, callers must + * not pass a {@link WasiNnConfig} through + * {@link ai.tegmentum.webassembly4j.api.DefaultLinkingContext.Builder#enableWasiNn} + * — the native enable-nn path would throw "WASI-NN support not compiled in" + * during {@link #instantiate(ai.tegmentum.webassembly4j.api.LinkingContext)}. + * + *

Reflection is used to keep {@code wasmtime4j-jni} at runtime scope in + * this provider's pom, matching pre-2.4.1 layering (Panama backend users + * can substitute the JNI implementation without a compile-time coupling). + */ + @Override + public boolean supportsWasiNn() { + return WasiNnProbeHolder.AVAILABLE; + } + + /** + * ADR-011 native-CM pass-through hook. Returns the underlying wasmtime4j + * {@link ai.tegmentum.wasmtime4j.component.Component} when requested, so + * pass-through embedders (e.g. wasi-rs {@code WasmtimeCmEmbedderRuntime}) + * can call {@code componentType()} and walk per-interface function names to + * bind {@code linker.defineFunction} thunks by declaration order — the + * introspection path is not exposed on the framework-neutral + * {@link ai.tegmentum.webassembly4j.api.Component} interface today. + * + *

Also returns {@link ai.tegmentum.wasmtime4j.Engine} when asked, so + * callers can co-locate the wasmtime4j {@link ai.tegmentum.wasmtime4j.Store} + * and {@link ai.tegmentum.wasmtime4j.component.ComponentLinker} against + * the same engine instance the component was compiled on. + */ + @Override + public java.util.Optional extension(Class extensionType) { + if (extensionType == ai.tegmentum.wasmtime4j.component.Component.class) { + return java.util.Optional.of(extensionType.cast(nativeComponent)); + } + if (extensionType == ai.tegmentum.wasmtime4j.Engine.class) { + return java.util.Optional.of(extensionType.cast(engine)); + } + if (extensionType == ai.tegmentum.wasmtime4j.component.ComponentEngine.class) { + return java.util.Optional.of(extensionType.cast(componentEngine)); + } + return java.util.Optional.empty(); + } + + /** Reflection-lookup holder — computed once, then a constant boolean read. */ + private static final class WasiNnProbeHolder { + private static final boolean AVAILABLE = probeNative(); + + private static boolean probeNative() { + try { + Class cls = Class.forName("ai.tegmentum.wasmtime4j.jni.JniComponentLinker"); + Object result = cls.getMethod("wasiNnAvailable").invoke(null); + return Boolean.TRUE.equals(result); + } catch (ReflectiveOperationException | LinkageError e) { + // JNI class missing (Panama-only classpath) or older wasmtime4j + // without the probe method — treat as no wasi:nn support so + // callers stay on the safe path. + return false; + } + } + } + @Override public void close() { + for (ai.tegmentum.wasmtime4j.Store s : stores) { + try { + s.close(); + } catch (RuntimeException ignored) { + // best effort + } + } nativeComponent.close(); - store.close(); try { componentEngine.close(); } catch (java.io.IOException e) { - // ComponentEngine.close() is a Closeable; wrap if it ever throws throw new ai.tegmentum.webassembly4j.api.exception.WebAssemblyException( "Failed to close component engine", e); } diff --git a/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeComponentInstanceAdapter.java b/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeComponentInstanceAdapter.java index 88e02ad..0fc9f4b 100644 --- a/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeComponentInstanceAdapter.java +++ b/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeComponentInstanceAdapter.java @@ -1,11 +1,28 @@ package ai.tegmentum.webassembly4j.provider.wasmtime; +import ai.tegmentum.wasmtime4j.wit.WitBool; +import ai.tegmentum.wasmtime4j.wit.WitBorrow; +import ai.tegmentum.wasmtime4j.wit.WitChar; +import ai.tegmentum.wasmtime4j.wit.WitFloat32; +import ai.tegmentum.wasmtime4j.wit.WitFloat64; +import ai.tegmentum.wasmtime4j.wit.WitList; +import ai.tegmentum.wasmtime4j.wit.WitOwn; +import ai.tegmentum.wasmtime4j.wit.WitResource; +import ai.tegmentum.wasmtime4j.wit.WitS16; +import ai.tegmentum.wasmtime4j.wit.WitS32; +import ai.tegmentum.wasmtime4j.wit.WitS64; +import ai.tegmentum.wasmtime4j.wit.WitS8; +import ai.tegmentum.wasmtime4j.wit.WitString; +import ai.tegmentum.wasmtime4j.wit.WitU8; +import ai.tegmentum.wasmtime4j.wit.WitValue; import ai.tegmentum.webassembly4j.api.ComponentInstance; import ai.tegmentum.webassembly4j.api.Function; import ai.tegmentum.webassembly4j.api.Global; import ai.tegmentum.webassembly4j.api.Memory; import ai.tegmentum.webassembly4j.api.Table; +import ai.tegmentum.webassembly4j.api.WitCallableResource; import ai.tegmentum.webassembly4j.api.exception.ExecutionException; +import ai.tegmentum.webassembly4j.api.exception.UnsupportedFeatureException; import java.util.ArrayList; import java.util.Collections; @@ -24,14 +41,138 @@ final class WasmtimeComponentInstanceAdapter implements ComponentInstance { @Override public Object invoke(String functionName, Object... args) { + final Object[] witArgs = marshalArgs(functionName, args); try { - return nativeInstance.invoke(functionName, args); + return nativeInstance.invoke(functionName, witArgs); } catch (ai.tegmentum.wasmtime4j.exception.WasmException e) { throw new ExecutionException( "Failed to invoke component function: " + functionName, e); } } + @Override + public Object invokeWit(String functionName, Object... args) { + final Object[] witArgs = marshalArgs(functionName, args); + try { + return nativeInstance.invokeWit(functionName, witArgs); + } catch (ai.tegmentum.wasmtime4j.exception.WasmException e) { + throw new ExecutionException( + "Failed to invoke component function: " + functionName, e); + } + } + + private static Object[] marshalArgs(String functionName, Object[] args) { + final Object[] witArgs = new Object[args.length]; + for (int i = 0; i < args.length; i++) { + try { + witArgs[i] = toWitValue(args[i]); + } catch (UnsupportedFeatureException e) { + throw new UnsupportedFeatureException( + "Argument " + i + " of '" + functionName + "': " + e.getMessage(), e); + } catch (ai.tegmentum.wasmtime4j.exception.WasmException e) { + throw new ExecutionException( + "Argument " + i + " of '" + functionName + "' is not a valid WIT value", e); + } + } + return witArgs; + } + + /** + * Marshal a natural Java argument into a {@link WitValue}, fulfilling the + * {@link ComponentInstance#invoke} contract. Values already of type {@code WitValue} pass + * through unchanged — the escape hatch for WIT types whose target type can't be inferred from a + * bare Java value (option/result/record/variant/enum/flags, and exact integer widths beyond the + * defaults below). + * + *

Mapping: Boolean→bool, Byte→s8, Short→s16, Integer→s32, Long→s64, Float→f32, Double→f64, + * Character→char, String→string, {@code byte[]}→list<u8>, non-empty homogeneous List→list + * (elements marshalled recursively). Integer/Long default to the signed widths; pass an explicit + * {@code WitValue} (e.g. {@code WitU64.of(..)}) for unsigned or narrower-than-natural widths. + */ + static WitValue toWitValue(Object arg) + throws ai.tegmentum.wasmtime4j.exception.WasmException { + if (arg instanceof WitValue) { + return (WitValue) arg; + } + if (arg instanceof Boolean) { + return WitBool.of((Boolean) arg); + } + if (arg instanceof Byte) { + return WitS8.of((Byte) arg); + } + if (arg instanceof Short) { + return WitS16.of((Short) arg); + } + if (arg instanceof Integer) { + return WitS32.of((Integer) arg); + } + if (arg instanceof Long) { + return WitS64.of((Long) arg); + } + if (arg instanceof Float) { + return WitFloat32.of((Float) arg); + } + if (arg instanceof Double) { + return WitFloat64.of((Double) arg); + } + if (arg instanceof Character) { + return WitChar.of((Character) arg); + } + if (arg instanceof String) { + return WitString.of((String) arg); + } + if (arg instanceof byte[]) { + byte[] bytes = (byte[]) arg; + List elems = new ArrayList<>(bytes.length); + for (byte b : bytes) { + elems.add(WitU8.of(b)); + } + return WitList.of(elems); + } + if (arg instanceof List) { + List list = (List) arg; + if (list.isEmpty()) { + throw new UnsupportedFeatureException( + "cannot infer element type of an empty list; pass a typed " + + "WitList.empty(elementType)"); + } + List elems = new ArrayList<>(list.size()); + for (Object e : list) { + elems.add(toWitValue(e)); + } + return WitList.of(elems); + } + throw new UnsupportedFeatureException( + "cannot marshal Java type " + + (arg == null ? "null" : arg.getClass().getName()) + + " to a WIT value; build a WitValue explicitly " + + "(option/result/record/variant/enum/flags and null require an explicit type)"); + } + + @Override + public WitCallableResource asCallableResource(Object resource) { + // The wasmtime WIT deserializer returns owned/borrowed resource handles as WitOwn / + // WitBorrow (discriminators 22/23), NOT WitResource — the latter is a higher-level + // convenience wrapper. The invokeResourceMethodWit / dropResource entry points on + // wasmtime4j's ComponentInstance want WitResource though, so translate here rather + // than making every caller do it (and rather than expanding WitCallableResource's + // input contract, which is intentionally provider-neutral). + final WitResource wr; + if (resource instanceof WitResource) { + wr = (WitResource) resource; + } else if (resource instanceof WitOwn) { + wr = WitResource.fromHandle(((WitOwn) resource).getHandle()); + } else if (resource instanceof WitBorrow) { + wr = WitResource.fromHandle(((WitBorrow) resource).getHandle()); + } else { + throw new IllegalArgumentException( + "Wasmtime provider expects a wasmtime4j WitResource / WitOwn / WitBorrow;" + + " got " + + (resource == null ? "null" : resource.getClass().getName())); + } + return new WasmtimeCallableResource(wr, nativeInstance); + } + @Override public boolean hasFunction(String name) { return nativeInstance.hasFunction(name); @@ -87,6 +228,27 @@ public Optional global(String name) { return Optional.empty(); } + @Override + public void consumeFuel(long amount) { + if (amount < 0L) { + throw new IllegalArgumentException("amount must be non-negative, got " + amount); + } + try { + nativeInstance.consumeFuel(amount); + } catch (ai.tegmentum.wasmtime4j.exception.WasmException e) { + throw new ExecutionException("Failed to consume fuel: " + e.getMessage(), e); + } + } + + @Override + public long fuelConsumed() { + try { + return nativeInstance.fuelConsumed(); + } catch (ai.tegmentum.wasmtime4j.exception.WasmException e) { + throw new ExecutionException("Failed to read fuel consumed: " + e.getMessage(), e); + } + } + @Override @SuppressWarnings("unchecked") public Optional unwrap(Class nativeType) { diff --git a/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeEngineAdapter.java b/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeEngineAdapter.java index 22f7ee1..c1dd6a9 100644 --- a/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeEngineAdapter.java +++ b/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeEngineAdapter.java @@ -184,15 +184,44 @@ public Module loadModule(byte[] bytes) { } } + /** + * Cross-module store sharing per F-Webassembly4j-Cross-Module-Store-Sharing + * charter 2026-07-26 (Option A). Reuses {@code shareStoreWith}'s native + * {@link ai.tegmentum.wasmtime4j.Store} for the new module so any + * memory/table/global imports referring to exports of the shared instance + * satisfy wasmtime's store-invariant check. + */ + @Override + public Module loadModule(byte[] bytes, + ai.tegmentum.webassembly4j.api.Instance shareStoreWith) { + if (shareStoreWith == null) { + throw new IllegalArgumentException("shareStoreWith must not be null"); + } + ai.tegmentum.wasmtime4j.Instance nativeInst = shareStoreWith + .unwrap(ai.tegmentum.wasmtime4j.Instance.class) + .orElseThrow(() -> new IllegalArgumentException( + "shareStoreWith is not backed by a wasmtime4j Instance")); + ai.tegmentum.wasmtime4j.Store sharedStore = nativeInst.getStore(); + try { + ai.tegmentum.wasmtime4j.Module nativeModule = engine.compileModule(bytes); + return new WasmtimeModuleAdapter(runtime, engine, nativeModule, sharedStore, + nativeConfig); + } catch (ai.tegmentum.wasmtime4j.exception.WasmException e) { + throw new ai.tegmentum.webassembly4j.api.exception.WebAssemblyException( + "Failed to load WebAssembly module (with shared store)", e); + } + } + @Override public Component loadComponent(byte[] bytes) { try { ComponentEngine componentEngine = runtime.createComponentEngine(); ai.tegmentum.wasmtime4j.component.Component nativeComponent = componentEngine.compileComponent(bytes); - ai.tegmentum.wasmtime4j.Store store = engine.createStore(); + // The store is created per-instantiation (in the adapter) so ComponentConfig + // resource limits can be applied; see WasmtimeComponentAdapter. return new WasmtimeComponentAdapter( - runtime, engine, componentEngine, nativeComponent, store); + runtime, engine, componentEngine, nativeComponent); } catch (ai.tegmentum.wasmtime4j.exception.WasmException e) { throw new ai.tegmentum.webassembly4j.api.exception.WebAssemblyException( "Failed to load WebAssembly component", e); diff --git a/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeFunctionAdapter.java b/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeFunctionAdapter.java index a88d8a7..82ca4e7 100644 --- a/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeFunctionAdapter.java +++ b/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeFunctionAdapter.java @@ -10,6 +10,7 @@ import ai.tegmentum.webassembly4j.api.exception.TrapException; import java.util.List; +import java.util.Optional; final class WasmtimeFunctionAdapter implements Function { @@ -22,6 +23,15 @@ private enum FastPath { V_V, V_I, I_I, II_I, I_V, J_J, GENERIC } this.nativeFunction = nativeFunction; } + @Override + @SuppressWarnings("unchecked") + public Optional unwrap(Class nativeType) { + if (nativeType.isInstance(nativeFunction)) { + return Optional.of((T) nativeFunction); + } + return Optional.empty(); + } + private FastPath resolveFastPath() { FastPath fp = cachedFastPath; if (fp != null) { diff --git a/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeMemoryAdapter.java b/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeMemoryAdapter.java index 2878eb5..9a8390a 100644 --- a/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeMemoryAdapter.java +++ b/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeMemoryAdapter.java @@ -11,9 +11,27 @@ final class WasmtimeMemoryAdapter implements Memory { private static final int PAGE_SIZE = 65536; private final WasmMemory nativeMemory; + /** + * Non-null when this adapter was minted by {@code WasmtimeCallerAdapter.getMemory(name)} + * from within a callback frame. Signals that the underlying native handle is a raw + * {@code Box} (not registry-wrapped), so + * {@code WasmtimeCallerAdapter.defineCallerScopedExternImports} must route through the + * caller-scoped {@code linkerDefineMemoryFromExport} path instead of the handle-based + * path — the registry lookup would otherwise fail. + */ + private final String callerExportName; WasmtimeMemoryAdapter(WasmMemory nativeMemory) { + this(nativeMemory, null); + } + + WasmtimeMemoryAdapter(WasmMemory nativeMemory, String callerExportName) { this.nativeMemory = nativeMemory; + this.callerExportName = callerExportName; + } + + String callerExportName() { + return callerExportName; } @Override diff --git a/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeModuleAdapter.java b/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeModuleAdapter.java index d3db099..540d3a2 100644 --- a/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeModuleAdapter.java +++ b/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeModuleAdapter.java @@ -4,19 +4,27 @@ import ai.tegmentum.wasmtime4j.WasmRuntime; import ai.tegmentum.wasmtime4j.WasmValue; import ai.tegmentum.wasmtime4j.WasmValueType; +import ai.tegmentum.wasmtime4j.func.HostFunction; import ai.tegmentum.wasmtime4j.type.ExportType; import ai.tegmentum.wasmtime4j.type.FuncType; import ai.tegmentum.wasmtime4j.type.FunctionType; import ai.tegmentum.wasmtime4j.type.ImportType; import ai.tegmentum.wasmtime4j.type.WasmType; import ai.tegmentum.wasmtime4j.type.WasmTypeKind; +import ai.tegmentum.webassembly4j.api.CallerAwareHostFunction; +import ai.tegmentum.webassembly4j.api.CallerAwareHostFunctionDefinition; import ai.tegmentum.webassembly4j.api.ExportDescriptor; +import ai.tegmentum.webassembly4j.api.ExternImportDefinition; import ai.tegmentum.webassembly4j.api.ExternType; +import ai.tegmentum.webassembly4j.api.FunctionImport; +import ai.tegmentum.webassembly4j.api.GlobalImport; import ai.tegmentum.webassembly4j.api.HostFunctionDefinition; import ai.tegmentum.webassembly4j.api.ImportDescriptor; import ai.tegmentum.webassembly4j.api.Instance; import ai.tegmentum.webassembly4j.api.LinkingContext; +import ai.tegmentum.webassembly4j.api.MemoryImport; import ai.tegmentum.webassembly4j.api.Module; +import ai.tegmentum.webassembly4j.api.TableImport; import ai.tegmentum.webassembly4j.api.ValueType; import ai.tegmentum.webassembly4j.api.exception.LinkingException; @@ -32,6 +40,7 @@ final class WasmtimeModuleAdapter implements Module { private final ai.tegmentum.wasmtime4j.Module nativeModule; private final ai.tegmentum.wasmtime4j.Store store; private final ai.tegmentum.wasmtime4j.config.EngineConfig engineConfig; + private final boolean callerScoped; WasmtimeModuleAdapter(WasmRuntime runtime, ai.tegmentum.wasmtime4j.Engine engine, @@ -43,10 +52,46 @@ final class WasmtimeModuleAdapter implements Module { this.nativeModule = nativeModule; this.store = store; this.engineConfig = engineConfig; + this.callerScoped = false; + } + + private WasmtimeModuleAdapter(WasmRuntime runtime, + ai.tegmentum.wasmtime4j.Engine engine, + ai.tegmentum.wasmtime4j.Module nativeModule, + ai.tegmentum.wasmtime4j.config.EngineConfig engineConfig) { + this.runtime = runtime; + this.engine = engine; + this.nativeModule = nativeModule; + this.store = null; + this.engineConfig = engineConfig; + this.callerScoped = true; + } + + /** + * Construct a caller-scoped Module wrapper — used only by + * {@link WasmtimeCallerAdapter#compileModule(byte[])} to hand back a + * Module that borrows the caller's implicit store. Caller-scoped + * modules must be instantiated via + * {@link WasmtimeCallerAdapter#instantiate(Module, LinkingContext)}; + * their own {@code instantiate()} paths throw + * {@link IllegalStateException} because they have no owning store. + */ + static WasmtimeModuleAdapter callerScoped( + WasmRuntime runtime, + ai.tegmentum.wasmtime4j.Engine engine, + ai.tegmentum.wasmtime4j.Module nativeModule, + ai.tegmentum.wasmtime4j.config.EngineConfig engineConfig) { + return new WasmtimeModuleAdapter(runtime, engine, nativeModule, engineConfig); } @Override public Instance instantiate() { + if (callerScoped) { + throw new IllegalStateException( + "This Module was produced by Caller.compileModule and has no" + + " owning store. Instantiate it via" + + " Caller.instantiate(Module, LinkingContext) instead."); + } try { Linker linker = runtime.createLinker(engine); ai.tegmentum.wasmtime4j.Instance nativeInstance = @@ -60,33 +105,33 @@ public Instance instantiate() { @Override public Instance instantiate(LinkingContext linkingContext) { + if (callerScoped) { + throw new IllegalStateException( + "This Module was produced by Caller.compileModule and has no" + + " owning store. Instantiate it via" + + " Caller.instantiate(Module, LinkingContext) instead."); + } if (linkingContext == null) { return instantiate(); } List hostFunctions = linkingContext.hostFunctions(); - if (hostFunctions.isEmpty() && linkingContext.wasiContext() == null) { + List externImports = linkingContext.externImports(); + List callerAwareHostFunctions = + linkingContext.callerAwareHostFunctions(); + if (hostFunctions.isEmpty() + && linkingContext.wasiContext() == null + && externImports.isEmpty() + && callerAwareHostFunctions.isEmpty()) { return instantiate(); } try { Linker linker = runtime.createLinker(engine); - for (HostFunctionDefinition def : hostFunctions) { - FunctionType funcType = new FunctionType( - convertToWasmTypes(def.parameterTypes()), - convertToWasmTypes(def.resultTypes())); - - linker.defineHostFunction(def.moduleName(), def.functionName(), funcType, - wasmArgs -> { - Object[] javaArgs = extractWasmValues(wasmArgs); - Object[] results = def.function().execute(javaArgs); - if (results == null || results.length == 0) { - return new WasmValue[0]; - } - return convertToWasmValues(results, def.resultTypes()); - }); - } + defineHostFunctions(linker, hostFunctions); + + defineCallerAwareHostFunctions(linker, callerAwareHostFunctions); ai.tegmentum.webassembly4j.api.WasiContext wasiCtx = linkingContext.wasiContext(); if (wasiCtx != null) { @@ -98,6 +143,8 @@ public Instance instantiate(LinkingContext linkingContext) { runtime.addWasiToLinker(wasiLinker, nativeWasi); } + defineExternImports(linker, externImports); + ai.tegmentum.wasmtime4j.Instance nativeInstance = linker.instantiate(store, nativeModule); return new WasmtimeInstanceAdapter(nativeInstance, runtime, engine); @@ -106,6 +153,129 @@ public Instance instantiate(LinkingContext linkingContext) { } } + /** + * Wire non-caller-aware host functions into the linker. Extracted so it + * can be shared with the caller-scoped instantiate path in + * {@link WasmtimeCallerAdapter}. + */ + static void defineHostFunctions(Linker linker, + List hostFunctions) + throws ai.tegmentum.wasmtime4j.exception.WasmException { + for (HostFunctionDefinition def : hostFunctions) { + FunctionType funcType = new FunctionType( + convertToWasmTypes(def.parameterTypes()), + convertToWasmTypes(def.resultTypes())); + + linker.defineHostFunction(def.moduleName(), def.functionName(), funcType, + wasmArgs -> { + Object[] javaArgs = extractWasmValues(wasmArgs); + Object[] results = def.function().execute(javaArgs); + if (results == null || results.length == 0) { + return new WasmValue[0]; + } + return convertToWasmValues(results, def.resultTypes()); + }); + } + } + + /** + * Wire caller-aware host functions into the linker. Each definition is + * registered as a wasmtime4j {@code CallerAwareHostFunction} — the + * subclass of {@link HostFunction} whose {@code execute} runs with the + * native {@link ai.tegmentum.wasmtime4j.func.Caller} available. The + * native caller is looked up via wasmtime4j's + * {@code CallerContextProvider} SPI (already wired for JNI in + * wasmtime4j r.2), wrapped in a {@link WasmtimeCallerAdapter}, and + * handed to the user's {@link CallerAwareHostFunction} implementation. + */ + void defineCallerAwareHostFunctions( + Linker linker, + List callerAwareHostFunctions) + throws ai.tegmentum.wasmtime4j.exception.WasmException { + for (CallerAwareHostFunctionDefinition def : callerAwareHostFunctions) { + FunctionType funcType = new FunctionType( + convertToWasmTypes(def.parameterTypes()), + convertToWasmTypes(def.resultTypes())); + + @SuppressWarnings({"unchecked", "rawtypes"}) + HostFunction impl = new HostFunction.CallerAwareHostFunction( + (HostFunction.MultiValueHostFunctionWithCaller) (nativeCaller, + wasmArgs) -> { + @SuppressWarnings({"rawtypes"}) + WasmtimeCallerAdapter callerAdapter = new WasmtimeCallerAdapter( + nativeCaller, runtime, engineConfig); + Object[] javaArgs = extractWasmValues(wasmArgs); + @SuppressWarnings({"unchecked", "rawtypes"}) + CallerAwareHostFunction fn = + (CallerAwareHostFunction) def.function(); + @SuppressWarnings("unchecked") + Object[] results = fn.execute(callerAdapter, javaArgs); + if (results == null || results.length == 0) { + return new WasmValue[0]; + } + return convertToWasmValues(results, def.resultTypes()); + }); + + linker.defineHostFunction(def.moduleName(), def.functionName(), funcType, impl); + } + } + + /** + * Wire typed {@link ExternImportDefinition} values into the wasmtime + * linker. Each variant is unwrapped to its native wasmtime4j handle; + * cross-provider values (memories/tables/globals produced by a different + * provider adapter) throw {@link LinkingException} with clear coordinates. + * {@link FunctionImport} is not yet supported and throws + * {@link UnsupportedOperationException} — the api-layer {@link + * ai.tegmentum.webassembly4j.api.Function Function} interface currently + * has no {@code unwrap(Class)} method, so there is no way to reach the + * underlying {@code WasmFunction}. + */ + private void defineExternImports(Linker linker, + List externImports) + throws ai.tegmentum.wasmtime4j.exception.WasmException { + for (ExternImportDefinition def : externImports) { + if (def instanceof MemoryImport) { + MemoryImport mi = (MemoryImport) def; + ai.tegmentum.wasmtime4j.WasmMemory nativeMemory = + mi.memory().unwrap(ai.tegmentum.wasmtime4j.WasmMemory.class) + .orElseThrow(() -> new LinkingException( + "MemoryImport " + mi.moduleName() + "::" + mi.name() + + " memory is not a wasmtime4j-backed instance")); + linker.defineMemory(store, mi.moduleName(), mi.name(), nativeMemory); + } else if (def instanceof TableImport) { + TableImport ti = (TableImport) def; + ai.tegmentum.wasmtime4j.WasmTable nativeTable = + ti.table().unwrap(ai.tegmentum.wasmtime4j.WasmTable.class) + .orElseThrow(() -> new LinkingException( + "TableImport " + ti.moduleName() + "::" + ti.name() + + " table is not a wasmtime4j-backed instance")); + linker.defineTable(store, ti.moduleName(), ti.name(), nativeTable); + } else if (def instanceof GlobalImport) { + GlobalImport gi = (GlobalImport) def; + ai.tegmentum.wasmtime4j.WasmGlobal nativeGlobal = + gi.global().unwrap(ai.tegmentum.wasmtime4j.WasmGlobal.class) + .orElseThrow(() -> new LinkingException( + "GlobalImport " + gi.moduleName() + "::" + gi.name() + + " global is not a wasmtime4j-backed instance")); + linker.defineGlobal(store, gi.moduleName(), gi.name(), nativeGlobal); + } else if (def instanceof FunctionImport) { + FunctionImport fi = (FunctionImport) def; + throw new UnsupportedOperationException( + "FunctionImport " + fi.moduleName() + "::" + fi.name() + + " is not yet supported by wasmtime4j-provider — the" + + " api-layer Function interface has no unwrap(Class)" + + " accessor. Use LinkingContext addHostFunction instead," + + " or wait for the follow-up charter that adds" + + " Function.unwrap."); + } else { + throw new LinkingException( + "Unknown ExternImportDefinition variant: " + + (def == null ? "null" : def.getClass().getName())); + } + } + } + @Override public List exports() { List nativeExports = nativeModule.getExports(); @@ -190,15 +360,39 @@ private static ValueType convertFromWasmValueType(WasmValueType type) { public Optional extension(Class extensionType) { if (extensionType == ai.tegmentum.webassembly4j.api.capability.FuelController.class && engine.isFuelEnabled()) { + if (callerScoped) { + // Caller-scoped modules borrow the caller's store; the + // FuelController on the caller's store is reached via the + // Caller api, not through this Module handle. + return Optional.empty(); + } return Optional.of((T) new WasmtimeFuelController(store)); } return Optional.empty(); } + /** + * Provider escape — returns the underlying native handle when the + * caller wants to reach the wasmtime4j-native Module directly (used by + * {@link WasmtimeCallerAdapter#instantiate(Module, LinkingContext)} to + * bridge back to the wasmtime4j linker). + */ + @SuppressWarnings("unchecked") + Optional unwrap(Class nativeType) { + if (nativeType.isInstance(nativeModule)) { + return Optional.of((T) nativeModule); + } + return Optional.empty(); + } + ai.tegmentum.wasmtime4j.Store store() { return store; } + ai.tegmentum.wasmtime4j.Module nativeModule() { + return nativeModule; + } + private static void configureNativeWasi(ai.tegmentum.wasmtime4j.wasi.WasiContext nativeWasi, ai.tegmentum.webassembly4j.api.WasiContext wasiCtx) { List args = wasiCtx.args(); @@ -278,6 +472,8 @@ private static WasmValue[] convertToWasmValues(Object[] values, ValueType[] type @Override public void close() { nativeModule.close(); - store.close(); + if (store != null && !callerScoped) { + store.close(); + } } } diff --git a/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeTableAdapter.java b/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeTableAdapter.java index 43d49cf..ef2dee0 100644 --- a/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeTableAdapter.java +++ b/wasmtime4j-provider/src/main/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeTableAdapter.java @@ -4,13 +4,27 @@ import ai.tegmentum.webassembly4j.api.Table; import java.util.Optional; +import java.util.OptionalInt; final class WasmtimeTableAdapter implements Table { private final WasmTable nativeTable; + /** + * See {@code WasmtimeMemoryAdapter.callerExportName} — same rationale. + */ + private final String callerExportName; WasmtimeTableAdapter(WasmTable nativeTable) { + this(nativeTable, null); + } + + WasmtimeTableAdapter(WasmTable nativeTable, String callerExportName) { this.nativeTable = nativeTable; + this.callerExportName = callerExportName; + } + + String callerExportName() { + return callerExportName; } @Override @@ -18,6 +32,49 @@ public int size() { return nativeTable.getSize(); } + @Override + public OptionalInt maxSize() { + // ai.tegmentum.wasmtime4j.WasmTable#getMaxSize Javadoc: "returns -1 + // if unlimited". Treat any negative as unbounded to be defensive + // against future extensions of the sentinel space. + int max = nativeTable.getMaxSize(); + return max < 0 ? OptionalInt.empty() : OptionalInt.of(max); + } + + @Override + public Object get(int index) { + checkIndex(index); + return nativeTable.get(index); + } + + @Override + public void set(int index, Object value) { + checkIndex(index); + nativeTable.set(index, value); + } + + /** + * The native wasmtime4j JniTable surfaces out-of-bounds access as a + * generic {@link RuntimeException} wrapping a {@code WasmRuntimeException} + * (see {@code JniTable#get}/{@code JniTable#set}), and it turns negative + * indices into {@link IllegalArgumentException} via + * {@code Validation.requireNonNegative}. The api-layer Table contract + * uniformly promises {@link IndexOutOfBoundsException}, so we bounds-check + * ourselves before delegation to give consumers a portable exception type. + */ + private void checkIndex(int index) { + int currentSize = nativeTable.getSize(); + if (index < 0 || index >= currentSize) { + throw new IndexOutOfBoundsException( + "Table index " + index + " out of bounds for size " + currentSize); + } + } + + @Override + public int grow(int delta, Object init) { + return nativeTable.grow(delta, init); + } + @Override @SuppressWarnings("unchecked") public Optional unwrap(Class nativeType) { diff --git a/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/BridgingSparqlExtensionDispatchTest.java b/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/BridgingSparqlExtensionDispatchTest.java new file mode 100644 index 0000000..8cbebf4 --- /dev/null +++ b/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/BridgingSparqlExtensionDispatchTest.java @@ -0,0 +1,533 @@ +package ai.tegmentum.webassembly4j.provider.wasmtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ai.tegmentum.wasmtime4j.wit.WitList; +import ai.tegmentum.wasmtime4j.wit.WitString; +import ai.tegmentum.wasmtime4j.wit.WitU64; +import ai.tegmentum.wasmtime4j.wit.WitValue; +import ai.tegmentum.webassembly4j.api.Component; +import ai.tegmentum.webassembly4j.api.ComponentInstance; +import ai.tegmentum.webassembly4j.api.DefaultLinkingContext; +import ai.tegmentum.webassembly4j.api.DefaultWasiContext; +import ai.tegmentum.webassembly4j.api.Engine; +import ai.tegmentum.webassembly4j.api.Function; +import ai.tegmentum.webassembly4j.api.Global; +import ai.tegmentum.webassembly4j.api.Memory; +import ai.tegmentum.webassembly4j.api.Table; +import ai.tegmentum.webassembly4j.api.WitCallableResource; +import ai.tegmentum.webassembly4j.api.exception.ExecutionException; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; + +/** + * Round-trip verification for {@link BridgingSparqlExtensionDispatch}. Constructs two fake + * {@link ComponentInstance} implementations — one advertising the new sparql-extension exports, + * one advertising only the old flat exports — and asserts the same dispatch surface routes both + * to the ABI-appropriate underlying call with identical caller-visible results. + * + *

Rationale for fakes over real components: the bridging is a pure dispatch selector on top + * of {@link ComponentInstance#invokeWit(String, Object...)} + {@link + * ComponentInstance#hasFunction(String)}. Real component fixtures would additionally test the + * wasmtime4j FFI, which is out of scope here and already covered by + * {@link WasmtimeCallableResourceTest}. The fake records every dispatched call so we can assert + * the exact export path the bridging chose per ABI shape — the fixture approach would only be + * able to assert the observable result, not the path taken to produce it. + */ +class BridgingSparqlExtensionDispatchTest { + + @Test + @DisplayName("callFilter — new-shape guest dispatches through extension@0.1.0#call") + void newShapeFilterCall() throws Exception { + final RecordingComponentInstance instance = RecordingComponentInstance.newShape(); + final BridgingSparqlExtensionDispatch dispatch = + new BridgingSparqlExtensionDispatch(instance); + + assertTrue(dispatch.filterIsNewShape(), + "detection at construction should recognize the new-shape filter export"); + + final WitValue result = + dispatch.callFilter("upper", args(WitString.of("hello"))); + + assertEquals(1, instance.calls.size(), + "exactly one underlying invokeWit call per callFilter"); + assertEquals( + BridgingSparqlExtensionDispatch.NEW_SHAPE_FILTER_EXPORT, + instance.calls.get(0).exportName, + "new-shape callFilter must route through the extension@0.1.0#call path"); + // Args to the new-shape ABI: [name: string, args: list] + assertEquals(2, instance.calls.get(0).args.length); + assertTrue(instance.calls.get(0).args[0] instanceof WitString); + assertTrue(instance.calls.get(0).args[1] instanceof WitList); + assertEquals("HELLO", ((WitString) result).getValue(), + "fake returns the uppercased argument to prove the caller can consume the result"); + } + + @Test + @DisplayName("callFilter — old-shape guest falls back to bare evaluate export") + void oldShapeFilterCall() throws Exception { + final RecordingComponentInstance instance = RecordingComponentInstance.oldShape(); + final BridgingSparqlExtensionDispatch dispatch = + new BridgingSparqlExtensionDispatch(instance); + + assertFalse(dispatch.filterIsNewShape(), + "old-shape guest should not detect as new-shape"); + + final WitValue result = + dispatch.callFilter("upper", args(WitString.of("hello"))); + + assertEquals(1, instance.calls.size()); + assertEquals( + BridgingSparqlExtensionDispatch.OLD_SHAPE_FILTER_EXPORT, + instance.calls.get(0).exportName, + "old-shape callFilter must fall back to bare evaluate"); + // Args to the old-shape ABI: [args: list]. Function name is discarded. + assertEquals(1, instance.calls.get(0).args.length); + assertTrue(instance.calls.get(0).args[0] instanceof WitList); + assertEquals("HELLO", ((WitString) result).getValue(), + "both ABIs surface the same caller-visible result through the bridging"); + } + + @Test + @DisplayName("callFilter — same helper interface, same result across both ABIs") + void bothAbiSurfacesReturnIdenticalResults() throws Exception { + final RecordingComponentInstance newShape = RecordingComponentInstance.newShape(); + final RecordingComponentInstance oldShape = RecordingComponentInstance.oldShape(); + + final WitValue fromNew = new BridgingSparqlExtensionDispatch(newShape) + .callFilter("upper", args(WitString.of("hello"))); + final WitValue fromOld = new BridgingSparqlExtensionDispatch(oldShape) + .callFilter("upper", args(WitString.of("hello"))); + + assertNotSame(fromNew, fromOld, "distinct instances produce distinct return values"); + assertEquals(((WitString) fromNew).getValue(), ((WitString) fromOld).getValue(), + "the whole point of the bridging: caller sees identical results regardless " + + "of which ABI the guest speaks"); + } + + @Test + @DisplayName("callFilter — empty args rejected with clear diagnostic") + void emptyArgsRejected() { + final BridgingSparqlExtensionDispatch dispatch = + new BridgingSparqlExtensionDispatch(RecordingComponentInstance.newShape()); + final IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> dispatch.callFilter("noop", Collections.emptyList())); + assertTrue(ex.getMessage().contains("non-empty"), + "diagnostic should point at the non-empty precondition; got: " + ex.getMessage()); + } + + @Test + @DisplayName("newAggregate — new-shape guest returns the real WitCallableResource") + void newShapeAggregate() { + final RecordingComponentInstance instance = RecordingComponentInstance.newShape(); + final BridgingSparqlExtensionDispatch dispatch = + new BridgingSparqlExtensionDispatch(instance); + + assertTrue(dispatch.aggregateIsNewShape()); + + try (WitCallableResource agg = dispatch.newAggregate("sum")) { + assertNotNull(agg); + assertEquals(1, instance.calls.size(), + "new-aggregate ctor call should be the only invokeWit so far"); + assertEquals( + BridgingSparqlExtensionDispatch.NEW_SHAPE_AGGREGATE_CTOR, + instance.calls.get(0).exportName); + } + } + + @Test + @DisplayName("newAggregate — old-shape guest returns a shim that routes step/finish " + + "to bare aggregate-step/aggregate-finish") + void oldShapeAggregateShim() { + final RecordingComponentInstance instance = RecordingComponentInstance.oldShape(); + final BridgingSparqlExtensionDispatch dispatch = + new BridgingSparqlExtensionDispatch(instance); + + assertFalse(dispatch.aggregateIsNewShape()); + + try (WitCallableResource agg = dispatch.newAggregate("sum")) { + // No underlying call yet — the shim is constructed in Java without touching the + // component instance. This mirrors old-shape semantics where accumulator state + // lives on the instance itself, not on a per-group resource. + assertEquals(0, instance.calls.size(), + "shim construction should not touch the underlying instance"); + + agg.invokeMethodWit("step", WitU64.of(42L)); + assertEquals(1, instance.calls.size()); + assertEquals( + BridgingSparqlExtensionDispatch.OLD_SHAPE_AGGREGATE_STEP, + instance.calls.get(0).exportName, + "shim.step must dispatch to bare aggregate-step"); + // Old-shape signature: aggregate-step(args: list, mult: u64). Shim + // supplies mult=1 as documented. + assertEquals(2, instance.calls.get(0).args.length); + assertTrue(instance.calls.get(0).args[1] instanceof WitU64, + "shim must append the multiplicity argument the old-shape signature requires"); + + agg.invokeMethodWit("finish"); + assertEquals(2, instance.calls.size()); + assertEquals( + BridgingSparqlExtensionDispatch.OLD_SHAPE_AGGREGATE_FINISH, + instance.calls.get(1).exportName, + "shim.finish must dispatch to bare aggregate-finish"); + } + } + + @Test + @DisplayName("shim aggregate — close() is idempotent and further calls throw") + void shimAggregateCloseSemantics() { + final RecordingComponentInstance instance = RecordingComponentInstance.oldShape(); + final BridgingSparqlExtensionDispatch dispatch = + new BridgingSparqlExtensionDispatch(instance); + final WitCallableResource agg = dispatch.newAggregate("sum"); + + assertFalse(agg.isClosed()); + agg.close(); + assertTrue(agg.isClosed()); + // Second close is a no-op; try-with-resources double-close must not blow up. + agg.close(); + // Post-close invocation should be rejected — same contract as the real WitCallableResource. + assertThrows(IllegalStateException.class, + () -> agg.invokeMethodWit("step", WitU64.of(1L))); + } + + @Test + @DisplayName("bridge() — generic fallback picks the old-shape path when new-shape absent") + void bridgeGenericFallback() { + final RecordingComponentInstance instance = RecordingComponentInstance.oldShape(); + // Register an extra bare export the general bridge should find. + instance.exports.add("cardinality_estimate"); + instance.returnValues.put("cardinality_estimate", WitU64.of(100L)); + + final BridgingSparqlExtensionDispatch dispatch = + new BridgingSparqlExtensionDispatch(instance); + final WitValue result = dispatch.bridge( + "stardog:webfunction/planner@0.3.0#cardinality-estimate", + "cardinality_estimate"); + + assertEquals("cardinality_estimate", instance.calls.get(0).exportName, + "bridge() should fall back to the bare path when the interface-scoped one is absent"); + assertNotNull(result); + } + + @Test + @DisplayName("bridge() — throws when neither shape is exported") + void bridgeNeitherShape() { + final RecordingComponentInstance instance = RecordingComponentInstance.oldShape(); + final BridgingSparqlExtensionDispatch dispatch = + new BridgingSparqlExtensionDispatch(instance); + final ExecutionException ex = assertThrows(ExecutionException.class, + () -> dispatch.bridge("no-such-new", "no-such-old")); + assertTrue(ex.getMessage().contains("no-such-new") + && ex.getMessage().contains("no-such-old"), + "diagnostic should name both attempted paths; got: " + ex.getMessage()); + } + + /** + * Regression: {@link ComponentInstance#hasFunction(String)} must agree with the invocation + * path on interface-qualified export names ({@code :@#}). + * + *

Before the wasmtime4j fix this probe returned {@code false} for interface-scoped + * exports even when {@code invokeWit} of the same qualified name succeeded — which broke + * {@link BridgingSparqlExtensionDispatch#filterIsNewShape} at construction time (the + * constructor asked {@code hasFunction("tegmentum:webfunction/extension@0.1.0#call")} and + * always mis-detected new-shape guests as old-shape). Rather than reuse the webfunction + * artifact this test drives the counter component fixture that already backs the callable + * resource smoke test — it exports {@link #COUNTER_VALUE_METHOD} solely through the + * {@code counter-api} interface, so a passing probe here proves the qualified-name lookup + * on the underlying wasmtime4j {@code ComponentInstance} is fixed. A negative-control + * check on a fake export name guards against the "always returns true" failure mode. + */ + @Test + @EnabledIf("runtimeAvailable") + @DisplayName("regression: hasFunction agrees with invokeWit on interface-qualified names") + void hasFunctionRecognizesInterfaceQualifiedExport() throws Exception { + final byte[] wasm = loadCounterComponent(); + try (Engine engine = WasmtimeEngineAdapter.create(null); + Component component = engine.loadComponent(wasm)) { + final ComponentInstance instance = component.instantiate(wasiLinking()); + + assertTrue( + instance.hasFunction(COUNTER_VALUE_METHOD), + "hasFunction must recognize the interface-qualified export " + + COUNTER_VALUE_METHOD + + " that the counter guest exports only via counter-api; if this " + + "regressed, BridgingSparqlExtensionDispatch#filterIsNewShape will " + + "silently fall back to old-shape dispatch for new-shape guests"); + assertTrue( + instance.hasFunction(COUNTER_CTOR_EXPORT), + "and it must also see the interface-scoped constructor export"); + assertFalse( + instance.hasFunction( + "tegmentum:test-counter/counter-api@0.1.0#[method]counter.does-not-exist"), + "sanity check: an unknown qualified name still returns false — otherwise the " + + "fix would be masking every probe with an unconditional true"); + } + } + + // --- helpers ------------------------------------------------------------------------- + + static boolean runtimeAvailable() { + return new WasmtimeProvider().availability().available(); + } + + private static final String COUNTER_INTERFACE = + "tegmentum:test-counter/counter-api@0.1.0"; + private static final String COUNTER_CTOR_EXPORT = + COUNTER_INTERFACE + "#[constructor]counter"; + private static final String COUNTER_VALUE_METHOD = + COUNTER_INTERFACE + "#[method]counter.value"; + + private static DefaultLinkingContext wasiLinking() { + return DefaultLinkingContext.builder() + .wasiContext(DefaultWasiContext.builder().build()) + .build(); + } + + private static byte[] loadCounterComponent() throws IOException { + try (InputStream is = + BridgingSparqlExtensionDispatchTest.class.getResourceAsStream( + "/counter_component.wasm")) { + assertNotNull( + is, + "counter_component.wasm not on classpath — the Maven build copies it into " + + "src/test/resources/ from src/test/rust/counter_component/"); + final ByteArrayOutputStream buf = new ByteArrayOutputStream(); + final byte[] chunk = new byte[8192]; + int n; + while ((n = is.read(chunk)) != -1) { + buf.write(chunk, 0, n); + } + return buf.toByteArray(); + } + } + + private static List args(final WitValue... values) throws Exception { + return Arrays.asList(values); + } + + /** + * Fake ComponentInstance that records every {@code invokeWit} call and returns a + * canned per-export result. Two factory presets: + * + *

    + *
  • {@link #newShape()} — advertises the sparql-extension interface exports (filter + + * aggregate) and returns uppercased results plus a stub resource handle. + *
  • {@link #oldShape()} — advertises only bare {@code evaluate} / {@code aggregate-step} / + * {@code aggregate-finish} and returns the same uppercased result on evaluate. + *
+ */ + private static final class RecordingComponentInstance implements ComponentInstance { + + final Set exports; + final List calls = new ArrayList<>(); + final java.util.Map returnValues = new java.util.HashMap<>(); + + private RecordingComponentInstance(final Set exports) { + this.exports = exports; + } + + static RecordingComponentInstance newShape() { + final Set exports = new HashSet<>(); + exports.add(BridgingSparqlExtensionDispatch.NEW_SHAPE_FILTER_EXPORT); + exports.add(BridgingSparqlExtensionDispatch.NEW_SHAPE_AGGREGATE_CTOR); + final RecordingComponentInstance inst = new RecordingComponentInstance(exports); + // "upper" filter behavior: return uppercased first arg. + inst.uppercaseOn(BridgingSparqlExtensionDispatch.NEW_SHAPE_FILTER_EXPORT, /*argIdx*/ 1); + // Aggregate ctor: return a stub resource-shaped Object the shim + // asCallableResource can wrap. We return a marker string here because + // asCallableResource is overridden in this fake to short-circuit. + inst.returnValues.put( + BridgingSparqlExtensionDispatch.NEW_SHAPE_AGGREGATE_CTOR, + "STUB_RESOURCE"); + return inst; + } + + static RecordingComponentInstance oldShape() { + final Set exports = new HashSet<>(); + exports.add(BridgingSparqlExtensionDispatch.OLD_SHAPE_FILTER_EXPORT); + exports.add(BridgingSparqlExtensionDispatch.OLD_SHAPE_AGGREGATE_STEP); + exports.add(BridgingSparqlExtensionDispatch.OLD_SHAPE_AGGREGATE_FINISH); + final RecordingComponentInstance inst = new RecordingComponentInstance(exports); + // Old-shape evaluate takes args as arg[0]. + inst.uppercaseOn(BridgingSparqlExtensionDispatch.OLD_SHAPE_FILTER_EXPORT, /*argIdx*/ 0); + inst.returnValues.put(BridgingSparqlExtensionDispatch.OLD_SHAPE_AGGREGATE_STEP, null); + inst.returnValues.put(BridgingSparqlExtensionDispatch.OLD_SHAPE_AGGREGATE_FINISH, null); + return inst; + } + + /** + * Configure the fake so that calling {@code exportName} returns a WitString containing + * the uppercased value of the first WitString found inside the {@code argIdx}-th arg + * (unwrapping {@link WitList} as needed). + */ + private void uppercaseOn(final String exportName, final int argIdx) { + returnValues.put(exportName, new UppercaseBehavior(argIdx)); + } + + @Override + public Object invoke(final String functionName, final Object... args) { + return invokeWit(functionName, args); + } + + @Override + public Object invokeWit(final String functionName, final Object... args) { + calls.add(new RecordedCall(functionName, args)); + final Object canned = returnValues.get(functionName); + if (canned instanceof UppercaseBehavior) { + return ((UppercaseBehavior) canned).apply(args); + } + return canned; + } + + @Override + public WitCallableResource asCallableResource(final Object resource) { + // In the fake, we don't have real wasmtime resource machinery. Return a passive + // resource handle for the new-shape aggregate ctor test. + return new StubCallableResource((String) resource); + } + + @Override + public boolean hasFunction(final String name) { + return exports.contains(name); + } + + @Override + public List exportedFunctions() { + return new ArrayList<>(exports); + } + + @Override + public List exportedInterfaces() { + return Collections.emptyList(); + } + + @Override + public boolean exportsInterface(final String name) { + return false; + } + + @Override + public Optional function(final String name) { + return Optional.empty(); + } + + @Override + public Optional memory(final String name) { + return Optional.empty(); + } + + @Override + public Optional

table(final String name) { + return Optional.empty(); + } + + @Override + public Optional global(final String name) { + return Optional.empty(); + } + + @Override + public Optional unwrap(final Class nativeType) { + return Optional.empty(); + } + } + + private static final class RecordedCall { + final String exportName; + final Object[] args; + + RecordedCall(final String exportName, final Object[] args) { + this.exportName = exportName; + this.args = args; + } + } + + /** + * Behavior descriptor: unwrap the WitString inside args[argIdx] (unwrapping WitList as + * needed) and return its uppercased value as a fresh WitString. + */ + private static final class UppercaseBehavior { + final int argIdx; + + UppercaseBehavior(final int argIdx) { + this.argIdx = argIdx; + } + + Object apply(final Object[] args) { + final Object slot = args[argIdx]; + final WitString found; + if (slot instanceof WitList) { + final List elems = ((WitList) slot).getElements(); + found = (WitString) elems.get(0); + } else if (slot instanceof WitString) { + found = (WitString) slot; + } else { + throw new IllegalStateException( + "test fake expects WitList or WitString at arg " + argIdx + + "; got " + slot); + } + try { + return WitString.of(found.getValue().toUpperCase()); + } catch (final ai.tegmentum.wasmtime4j.exception.ValidationException e) { + throw new AssertionError("test-fake uppercased string cannot be invalid", e); + } + } + } + + /** + * Minimal WitCallableResource used only to prove the new-shape aggregate path routes + * through {@code asCallableResource} — the real resource machinery is covered by + * {@link WasmtimeCallableResourceTest}. + */ + private static final class StubCallableResource implements WitCallableResource { + private final String tag; + private boolean closed; + + StubCallableResource(final String tag) { + this.tag = tag; + } + + @Override + public String resourceTypeName() { + return tag; + } + + @Override + public Object invokeMethod(final String methodExportName, final Object... args) { + return null; + } + + @Override + public Object invokeMethodWit(final String methodExportName, final Object... args) { + return null; + } + + @Override + public boolean isClosed() { + return closed; + } + + @Override + public void close() { + closed = true; + } + } +} diff --git a/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeCallableResourceTest.java b/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeCallableResourceTest.java new file mode 100644 index 0000000..37c5ae9 --- /dev/null +++ b/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeCallableResourceTest.java @@ -0,0 +1,218 @@ +package ai.tegmentum.webassembly4j.provider.wasmtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ai.tegmentum.wasmtime4j.wit.WitOwn; +import ai.tegmentum.webassembly4j.api.Component; +import ai.tegmentum.webassembly4j.api.ComponentInstance; +import ai.tegmentum.webassembly4j.api.DefaultLinkingContext; +import ai.tegmentum.webassembly4j.api.DefaultWasiContext; +import ai.tegmentum.webassembly4j.api.Engine; +import ai.tegmentum.webassembly4j.api.WitCallableResource; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; +import org.junit.jupiter.api.io.TempDir; + +/** + * End-to-end verification for the {@link WitCallableResource} surface — the deferred half of + * Follow-up 2. Exercises the full resource lifecycle through the wasmtime4j provider using a + * synthetic counter component (see {@code src/test/rust/counter_component/}): + * + *
    + *
  • constructor invocation → wasmtime {@link WitOwn} + *
  • {@link ComponentInstance#asCallableResource(Object)} → {@link WitCallableResource} + *
  • method invocation on the callable (state accumulates across calls) + *
  • {@link WitCallableResource#close()} idempotency + post-close rejection + *
  • try-with-resources triggers a drop + *
+ * + *

Drop timing (the exact hazard Follow-up 2 flagged) is verified via a file marker the Rust + * guest writes into a preopened directory from both its constructor and its Drop impl — Java + * cannot capture WASI stderr through a {@code System.setErr} swap because WASI writes bypass the + * Java PrintStream and land on FD 2 directly. + */ +class WasmtimeCallableResourceTest { + + private static final String COUNTER_INTERFACE = + "tegmentum:test-counter/counter-api@0.1.0"; + private static final String CTOR_EXPORT = COUNTER_INTERFACE + "#[constructor]counter"; + private static final String INCREMENT_METHOD = + COUNTER_INTERFACE + "#[method]counter.increment"; + private static final String VALUE_METHOD = COUNTER_INTERFACE + "#[method]counter.value"; + + static boolean runtimeAvailable() { + return new WasmtimeProvider().availability().available(); + } + + private static byte[] loadCounterComponent() throws IOException { + try (InputStream is = + WasmtimeCallableResourceTest.class.getResourceAsStream("/counter_component.wasm")) { + assertNotNull( + is, + "counter_component.wasm not on classpath — run " + + "`cargo component build --release --target wasm32-wasip2` in " + + "src/test/rust/counter_component/ and copy the artifact into " + + "src/test/resources/ (the Maven build does this automatically)."); + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + byte[] chunk = new byte[8192]; + int n; + while ((n = is.read(chunk)) != -1) { + buf.write(chunk, 0, n); + } + return buf.toByteArray(); + } + } + + /** + * A component built with cargo-component (any wit-bindgen ≥ 0.30) pulls in {@code wasi:cli} + + * {@code wasi:io} imports as its ambient environment because {@code eprintln!} requires + * stdout/stderr, and cargo-component's proxy adapter also pulls {@code wasi:filesystem}. The + * linking context therefore always includes a WASI context — an empty one is enough for the + * pure lifecycle tests since we never touch the filesystem or network. Tests that verify Drop + * pass a preopen so the guest can leave a file marker. + */ + private static DefaultLinkingContext wasiLinking() { + return DefaultLinkingContext.builder() + .wasiContext(DefaultWasiContext.builder().build()) + .build(); + } + + private static DefaultLinkingContext wasiLinkingWithLifecycleDir(final Path hostDir) { + return DefaultLinkingContext.builder() + .wasiContext( + DefaultWasiContext.builder() + .inheritStderr(true) + .preopenDir(hostDir.toString(), "/lifecycle", true) + .build()) + .build(); + } + + @Test + @EnabledIf("runtimeAvailable") + @DisplayName("constructor → invokeMethod × 3 → value returns 3 → close is idempotent") + void resourceLifecycleAcrossInterfaceScopedMethods() throws Exception { + byte[] wasm = loadCounterComponent(); + try (Engine engine = WasmtimeEngineAdapter.create(null); + Component component = engine.loadComponent(wasm)) { + ComponentInstance instance = component.instantiate(wasiLinking()); + + Object rawHandle = instance.invokeWit(CTOR_EXPORT); + assertNotNull(rawHandle, "constructor should return an own handle"); + // The wasmtime WIT deserializer surfaces `own` as WitOwn (discriminator 22), + // not WitResource. WasmtimeComponentInstanceAdapter#asCallableResource accepts + // both — testing this here makes that contract explicit. + assertTrue( + rawHandle instanceof WitOwn, + "wasmtime provider should hand back a WitOwn for own; got " + + rawHandle.getClass().getName()); + + try (WitCallableResource counter = instance.asCallableResource(rawHandle)) { + assertFalse(counter.isClosed(), "freshly-wrapped resource must be open"); + + for (int i = 0; i < 3; i++) { + Object result = counter.invokeMethod(INCREMENT_METHOD); + assertEquals( + null, + result, + "increment is void; expected null from invokeMethod, got " + result); + } + + Object value = counter.invokeMethod(VALUE_METHOD); + assertEquals( + 3, + ((Number) value).intValue(), + "value must be 3 after three increments — proves receiver + method " + + "dispatch actually landed on the same resource across calls"); + + counter.close(); + assertTrue(counter.isClosed(), "close() must flip isClosed"); + // Second close is a no-op per the WitCallableResource contract — asserting it + // doesn't blow up covers the try-with-resources double-close path too. + counter.close(); + } + } + } + + @Test + @EnabledIf("runtimeAvailable") + @DisplayName("closed resource: further invokeMethod throws IllegalStateException") + void invokingAfterCloseThrows() throws Exception { + byte[] wasm = loadCounterComponent(); + try (Engine engine = WasmtimeEngineAdapter.create(null); + Component component = engine.loadComponent(wasm)) { + ComponentInstance instance = component.instantiate(wasiLinking()); + + Object rawHandle = instance.invokeWit(CTOR_EXPORT); + WitCallableResource counter = instance.asCallableResource(rawHandle); + counter.close(); + + assertTrue(counter.isClosed()); + // The interface contract: post-close invokeMethod must throw (either explicit + // "resource has been closed" or an execution exception once the native handle is + // gone). Both are honest failure modes — the resource is unusable either way. + assertThrows(RuntimeException.class, () -> counter.invokeMethod(INCREMENT_METHOD)); + assertThrows(RuntimeException.class, () -> counter.invokeMethod(VALUE_METHOD)); + } + } + + @Test + @EnabledIf("runtimeAvailable") + @DisplayName("try-with-resources close observably reaches the guest's Drop impl") + void closeInvokesGuestSideDrop(@TempDir Path lifecycleDir) throws Exception { + byte[] wasm = loadCounterComponent(); + + // The Rust guest writes a lifecycle-marker file to a preopened directory in both the + // constructor and its Drop impl. WASI stderr is also inherited so the JVM's console + // still shows the diagnostic lines, but we don't assert on stderr — Java's + // `System.setErr` only replaces the PrintStream, not the process's file descriptor 2, + // so WASI writes bypass any Java-side redirect. The file marker is the durable signal + // that survives the FD-2 bypass and proves Drop actually landed on the guest — which + // is the exact concern Follow-up 2 called out. + try (Engine engine = WasmtimeEngineAdapter.create(null); + Component component = engine.loadComponent(wasm)) { + ComponentInstance instance = + component.instantiate(wasiLinkingWithLifecycleDir(lifecycleDir)); + + Object rawHandle = instance.invokeWit(CTOR_EXPORT); + try (WitCallableResource counter = instance.asCallableResource(rawHandle)) { + counter.invokeMethod(INCREMENT_METHOD); + counter.invokeMethod(INCREMENT_METHOD); + // Leaving the block triggers close() → dropResource → guest Drop. + } + } + + Path marker = lifecycleDir.resolve("marker.log"); + String directoryContents; + try (Stream entries = Files.list(lifecycleDir)) { + directoryContents = entries.map(Path::toString) + .reduce((a, b) -> a + ", " + b) + .orElse("(empty)"); + } + assertTrue( + Files.exists(marker), + "guest should have written a lifecycle marker at " + marker + + "; directory contents: " + directoryContents); + String contents = Files.readString(marker, StandardCharsets.UTF_8); + // Constructor line proves the WASI preopen wiring works at all; drop line proves the + // close on the callable resource actually reached the guest's Drop. + assertTrue( + contents.contains("constructor=0"), + "marker file should record the constructor call; got:\n" + contents); + assertTrue( + contents.contains("drop=2"), + "marker file should record the Drop call with final value = 2 " + + "(after two increments); got:\n" + contents); + } +} diff --git a/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeCallerAwareHostFunctionTest.java b/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeCallerAwareHostFunctionTest.java new file mode 100644 index 0000000..5c2f38c --- /dev/null +++ b/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeCallerAwareHostFunctionTest.java @@ -0,0 +1,144 @@ +package ai.tegmentum.webassembly4j.provider.wasmtime; + +import ai.tegmentum.webassembly4j.api.Caller; +import ai.tegmentum.webassembly4j.api.CallerAwareHostFunction; +import ai.tegmentum.webassembly4j.api.DefaultLinkingContext; +import ai.tegmentum.webassembly4j.api.Engine; +import ai.tegmentum.webassembly4j.api.Function; +import ai.tegmentum.webassembly4j.api.Instance; +import ai.tegmentum.webassembly4j.api.LinkingContext; +import ai.tegmentum.webassembly4j.api.Module; +import ai.tegmentum.webassembly4j.api.Table; +import ai.tegmentum.webassembly4j.api.ValueType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Exercises the caller-aware host function pipeline end-to-end against + * the wasmtime4j provider. The api additions land at r.1-1 (Caller, + * CallerAwareHostFunction, CallerAwareHostFunctionDefinition, + * LinkingContext.callerAwareHostFunctions, DefaultLinkingContext.Builder + * .addCallerAwareHostFunction); the provider bridge lands at r.1-2 + + * r.1-3 (WasmtimeCallerAdapter + WasmtimeModuleAdapter caller-aware + * dispatch). This test proves the wire end-to-end and confirms that the + * generation-counter use-after-return safety propagates from the + * wasmtime4j jni-side generation check to the api-side Caller handle. + */ +class WasmtimeCallerAwareHostFunctionTest { + + // Hand-authored wasm module (compiled from): + // + // (module + // (import "env" "trigger_grow" (func $trigger_grow (param i32))) + // (memory (export "memory") 1) + // (table (export "table") 1 funcref) + // (func (export "run") + // i32.const 1 + // call $trigger_grow)) + // + // 91 bytes. Kept inline to match the pattern used by + // WasmtimeLinkingContextExternImportsTest so the test needs no build-time + // wasm-authoring toolchain. run() intentionally takes no params (constant + // 1 is loaded inline) to sidestep a pre-existing WasmtimeFunctionAdapter + // fast-path bug where the (i32) -> () signature routes through + // callVoid() and drops the argument — out of scope for this charter. + private static final byte[] CALLER_AWARE_MODULE = new byte[] { + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x60, + 0x01, 0x7f, 0x00, 0x60, 0x00, 0x00, 0x02, 0x14, 0x01, 0x03, 0x65, 0x6e, + 0x76, 0x0c, 0x74, 0x72, 0x69, 0x67, 0x67, 0x65, 0x72, 0x5f, 0x67, 0x72, + 0x6f, 0x77, 0x00, 0x00, 0x03, 0x02, 0x01, 0x01, 0x04, 0x04, 0x01, 0x70, + 0x00, 0x01, 0x05, 0x03, 0x01, 0x00, 0x01, 0x07, 0x18, 0x03, 0x06, 0x6d, + 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x02, 0x00, 0x05, 0x74, 0x61, 0x62, 0x6c, + 0x65, 0x01, 0x00, 0x03, 0x72, 0x75, 0x6e, 0x00, 0x01, 0x0a, 0x08, 0x01, + 0x06, 0x00, 0x41, 0x01, 0x10, 0x00, 0x0b + }; + + static boolean runtimeAvailable() { + return new WasmtimeProvider().availability().available(); + } + + @Test + @EnabledIf("runtimeAvailable") + void callerAwareHostFunctionGrowsCallerTableFromCallback() { + AtomicReference> capturedCaller = new AtomicReference<>(); + AtomicReference

capturedTable = new AtomicReference<>(); + AtomicInteger observedGrowReturn = new AtomicInteger(Integer.MIN_VALUE); + AtomicInteger observedArg = new AtomicInteger(Integer.MIN_VALUE); + + // The callback restricts itself to caller-scoped ops only. + // Calling regular WasmTable/Memory accessors (which acquire the + // store lock) from inside the callback SIGSEGVs — the whole point + // of the caller-aware pattern is to route mutation through the + // borrow-safe scoped entrypoints, so the test does the same. + CallerAwareHostFunction triggerGrow = (caller, args) -> { + capturedCaller.set(caller); + observedArg.set(((Number) args[0]).intValue()); + + Table t = caller.getTable("table").orElseThrow( + () -> new AssertionError("callback failed to reach caller table")); + capturedTable.set(t); + + int prevSize = caller.growTable(t, observedArg.get(), null); + observedGrowReturn.set(prevSize); + return new Object[0]; + }; + + try (Engine engine = WasmtimeEngineAdapter.create(null); + Module module = engine.loadModule(CALLER_AWARE_MODULE)) { + + LinkingContext ctx = DefaultLinkingContext.builder() + .addCallerAwareHostFunction("env", "trigger_grow", + new ValueType[] { ValueType.I32 }, + new ValueType[0], + triggerGrow) + .build(); + + Instance instance = module.instantiate(ctx); + Table tableBefore = instance.table("table").orElseThrow( + () -> new AssertionError("run: table export missing")); + int sizeBefore = tableBefore.size(); + assertEquals(1, sizeBefore, "wasm-declared table starts at size 1"); + + Function run = instance.function("run").orElseThrow( + () -> new AssertionError("run: run export missing")); + + // Guest calls trigger_grow(1) — callback grows the table by 1. + run.invoke(); + + // Callback executed. + assertEquals(1, observedArg.get(), + "callback observed the guest-side i32 argument"); + assertEquals(1, observedGrowReturn.get(), + "growTable return value is previous size on success"); + + // Post-callback: instance's table now reports the grown size. + Table tableAfter = instance.table("table").orElseThrow(); + assertEquals(2, tableAfter.size(), + "table grew by 1 in the caller-scoped path"); + + // Use-after-return: retained Caller handle now throws when + // any scoped method is invoked. Reuse the callback's captured + // Table wrapper for the argument (already unwrap-friendly), + // and expect the wasmtime4j r.2 generation counter to reject + // the stale caller before any native dereference happens. + Caller stale = capturedCaller.get(); + Table staleTable = capturedTable.get(); + assertNotNull(stale, "capturedCaller set inside the callback"); + assertNotNull(staleTable, "capturedTable set inside the callback"); + + IllegalStateException ise = assertThrows(IllegalStateException.class, () -> + stale.growTable(staleTable, 1, null), + "wasmtime4j r.2 generation counter rejects use-after-return"); + assertTrue(ise.getMessage().toLowerCase().contains("caller"), + "message identifies the caller-generation issue: " + ise.getMessage()); + } + } +} diff --git a/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeComponentAdapterWasiTest.java b/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeComponentAdapterWasiTest.java new file mode 100644 index 0000000..704fba1 --- /dev/null +++ b/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeComponentAdapterWasiTest.java @@ -0,0 +1,91 @@ +package ai.tegmentum.webassembly4j.provider.wasmtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ai.tegmentum.wasmtime4j.wasi.WasiPreview2Config; +import ai.tegmentum.webassembly4j.api.DefaultWasiContext; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Fix 1 unit test: a WasiContext attached to component instantiation must be forwarded into the + * component's WasiPreview2Config (preopens / env / stdio), not dropped. Pure mapping test — no + * native library required (WasiPreview2Config is a config holder). + */ +class WasmtimeComponentAdapterWasiTest { + + @Test + @DisplayName("WasiContext (env/stdio/preopens) is forwarded into the component WASI config") + void forwardsWasiContext() { + DefaultWasiContext wasi = DefaultWasiContext.builder() + .env("SVLN_FIX1", "ok") + .env("LOG_LEVEL", "INFO") + .inheritStdout(true) + .preopenDir("/fiji-jdk") + .preopenDir("/cp") + .build(); + + WasiPreview2Config cfg = WasmtimeComponentAdapter.toWasiPreview2Config(wasi); + + // env forwarded (was silently dropped before Fix 1). + assertEquals("ok", cfg.getEnv().get("SVLN_FIX1")); + assertEquals("INFO", cfg.getEnv().get("LOG_LEVEL")); + + // stdio inheritance forwarded. + assertTrue(cfg.isInheritStdout(), "inheritStdout should be forwarded"); + assertFalse(cfg.isInheritStderr(), "inheritStderr was not set"); + + // preopens forwarded (host == guest for now). + Set guests = new HashSet<>(); + for (WasiPreview2Config.PreopenDir d : cfg.getPreopenDirs()) { + guests.add(d.getGuestPath()); + } + assertEquals(2, cfg.getPreopenDirs().size()); + assertTrue(guests.contains("/fiji-jdk")); + assertTrue(guests.contains("/cp")); + + // Capability-minimal default: network denied unless explicitly granted. + assertFalse(cfg.isAllowNetwork(), "network must default to denied"); + } + + @Test + @DisplayName("Empty WasiContext yields an empty, network-denied WASI config") + void emptyContextIsDenyAll() { + WasiPreview2Config cfg = + WasmtimeComponentAdapter.toWasiPreview2Config(DefaultWasiContext.builder().build()); + assertTrue(cfg.getEnv().isEmpty()); + assertTrue(cfg.getPreopenDirs().isEmpty()); + assertFalse(cfg.isAllowNetwork()); + assertFalse(cfg.isInheritStdout()); + assertFalse(cfg.isWasiHttp(), "wasi:http must default off"); + } + + @Test + @DisplayName("WasiContext.wasiHttp(true) is forwarded to WasiPreview2Config.isWasiHttp()") + void forwardsWasiHttpEnabled() { + DefaultWasiContext wasi = DefaultWasiContext.builder().wasiHttp(true).build(); + + WasiPreview2Config cfg = WasmtimeComponentAdapter.toWasiPreview2Config(wasi); + + assertTrue(cfg.isWasiHttp(), + "wasiHttpEnabled=true on WasiContext must forward to WasiPreview2Config.isWasiHttp()"); + // Publishing the imports is orthogonal to network-egress: the flag alone must not open the + // network. Callers must still opt in via allowNetwork + egressRules. + assertFalse(cfg.isAllowNetwork(), + "wasi:http opt-in must not implicitly grant network egress"); + } + + @Test + @DisplayName("WasiContext.wasiHttp(false) leaves the flag off") + void wasiHttpFalseIsForwarded() { + DefaultWasiContext wasi = DefaultWasiContext.builder().wasiHttp(false).build(); + + WasiPreview2Config cfg = WasmtimeComponentAdapter.toWasiPreview2Config(wasi); + + assertFalse(cfg.isWasiHttp()); + } +} diff --git a/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeComponentFuelAccountingTest.java b/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeComponentFuelAccountingTest.java new file mode 100644 index 0000000..5412966 --- /dev/null +++ b/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeComponentFuelAccountingTest.java @@ -0,0 +1,183 @@ +package ai.tegmentum.webassembly4j.provider.wasmtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ai.tegmentum.webassembly4j.api.Component; +import ai.tegmentum.webassembly4j.api.ComponentInstance; +import ai.tegmentum.webassembly4j.api.DefaultLinkingContext; +import ai.tegmentum.webassembly4j.api.DefaultWasiContext; +import ai.tegmentum.webassembly4j.api.Engine; +import ai.tegmentum.webassembly4j.api.config.ComponentConfig; +import ai.tegmentum.webassembly4j.api.config.WebAssemblyConfig; +import ai.tegmentum.webassembly4j.api.exception.ExecutionException; +import ai.tegmentum.webassembly4j.provider.wasmtime.config.WasmtimeConfig; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; + +/** + * Exercises {@link ComponentInstance#consumeFuel(long)} + {@link ComponentInstance#fuelConsumed()} + * end-to-end through the wasmtime4j provider — the wasmtime4j 1.4.7 accessors that let the host + * charge fuel against the same wasmtime store the guest consumes, and read back the amount spent. + * + *

Reuses the checked-in counter component (see {@code src/test/rust/counter_component/}) — this + * test only cares that the component instantiates + invocations actually burn fuel; the counter + * semantics themselves are covered by {@link WasmtimeCallableResourceTest}. + */ +class WasmtimeComponentFuelAccountingTest { + + private static final String COUNTER_INTERFACE = "tegmentum:test-counter/counter-api@0.1.0"; + private static final String CTOR_EXPORT = COUNTER_INTERFACE + "#[constructor]counter"; + private static final String INCREMENT_METHOD = + COUNTER_INTERFACE + "#[method]counter.increment"; + + static boolean runtimeAvailable() { + return new WasmtimeProvider().availability().available(); + } + + private static byte[] loadCounterComponent() throws IOException { + try (InputStream is = + WasmtimeComponentFuelAccountingTest.class.getResourceAsStream( + "/counter_component.wasm")) { + assertNotNull(is, "counter_component.wasm not on classpath"); + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + byte[] chunk = new byte[8192]; + int n; + while ((n = is.read(chunk)) != -1) { + buf.write(chunk, 0, n); + } + return buf.toByteArray(); + } + } + + /** + * A cargo-component-built component imports {@code wasi:cli} / {@code wasi:io} for its + * ambient environment, so instantiation always needs a WASI context — an empty one is enough + * since these tests never touch the filesystem or network. + */ + private static DefaultLinkingContext wasiLinking() { + return DefaultLinkingContext.builder() + .wasiContext(DefaultWasiContext.builder().build()) + .build(); + } + + /** + * Enable fuel metering on the wasmtime engine so the provider actually pushes the + * {@link ComponentConfig#fuelLimit()} down as a hard cap on the store the guest runs against. + * Without this the provider treats the config as advisory only (see + * {@code WasmtimeComponentAdapter#newStore} — the {@code setFuel} call is gated on + * {@code engine.isFuelEnabled()}) and consumeFuel wouldn't have a real cap to enforce. + */ + private static Engine engineWithFuel() { + WebAssemblyConfig cfg = WebAssemblyConfig.builder() + .engineConfig(WasmtimeConfig.builder().consumeFuel(true).build()) + .build(); + return WasmtimeEngineAdapter.create(cfg); + } + + @Test + @EnabledIf("runtimeAvailable") + @DisplayName("consumeFuel(K) then fuelConsumed() returns K (against a fresh instance)") + void consumeFuelAndReadItBack() throws Exception { + byte[] wasm = loadCounterComponent(); + final long fuelCap = 1_000_000L; + try (Engine engine = engineWithFuel(); + Component component = engine.loadComponent(wasm)) { + ComponentInstance instance = + component.instantiate( + wasiLinking(), ComponentConfig.builder().fuelLimit(fuelCap).build()); + + // Baseline is captured at instantiation; nothing has burned fuel yet. + assertEquals( + 0L, + instance.fuelConsumed(), + "fresh instance should report zero fuel consumed against the just-set baseline"); + + instance.consumeFuel(1234L); + assertEquals( + 1234L, + instance.fuelConsumed(), + "fuelConsumed() must reflect the exact host-side deduction"); + + instance.consumeFuel(766L); + assertEquals( + 2000L, + instance.fuelConsumed(), + "successive consumeFuel calls must be additive against the baseline"); + } + } + + @Test + @EnabledIf("runtimeAvailable") + @DisplayName("consumeFuel beyond the store's remaining fuel throws (no partial deduction)") + void consumeFuelBeyondRemainingThrows() throws Exception { + byte[] wasm = loadCounterComponent(); + // Big enough for cargo-component's wasi:cli init (~16 KiB of fuel), then leaves plenty + // of headroom for the consumeFuel arithmetic below. + final long fuelCap = 100_000L; + try (Engine engine = engineWithFuel(); + Component component = engine.loadComponent(wasm)) { + ComponentInstance instance = + component.instantiate( + wasiLinking(), ComponentConfig.builder().fuelLimit(fuelCap).build()); + + // Burn 4000 units first so the failure isn't just "asked for > cap on a fresh store" + // — it's "asked for > remaining after a real prior deduction". + instance.consumeFuel(4_000L); + assertEquals(4_000L, instance.fuelConsumed()); + + // Ask for far more than what remains and observe the failure. Use a value larger than + // the WHOLE cap so the test doesn't have to reason about exactly how much fuel wasm + // init consumed. + final long tooMuch = fuelCap + 1_000L; + ExecutionException ex = + assertThrows( + ExecutionException.class, + () -> instance.consumeFuel(tooMuch), + "deducting more than the remaining fuel must throw"); + assertTrue( + ex.getMessage().toLowerCase().contains("insufficient") + || (ex.getCause() != null + && ex.getCause() + .getMessage() + .toLowerCase() + .contains("insufficient")), + "message should identify the insufficiency; got: " + ex.getMessage()); + assertEquals( + 4_000L, + instance.fuelConsumed(), + "a failed consumeFuel must leave the store's remaining fuel unchanged"); + } + } + + @Test + @EnabledIf("runtimeAvailable") + @DisplayName("guest invocation actually burns store fuel — fuelConsumed reflects it") + void guestInvocationBurnsStoreFuel() throws Exception { + byte[] wasm = loadCounterComponent(); + final long fuelCap = 10_000_000L; + try (Engine engine = engineWithFuel(); + Component component = engine.loadComponent(wasm)) { + ComponentInstance instance = + component.instantiate( + wasiLinking(), ComponentConfig.builder().fuelLimit(fuelCap).build()); + + // Drive a real component call. Not asserting the resource plumbing here (that's the + // callable-resource test); the value returned is a WitOwn — we only care that a wasm + // call executed, so that fuelConsumed reads a positive amount afterwards. + Object handle = instance.invokeWit(CTOR_EXPORT); + assertNotNull(handle); + + long afterCtor = instance.fuelConsumed(); + assertTrue( + afterCtor > 0L, + "constructor invocation should burn some wasm fuel; got " + afterCtor); + } + } +} diff --git a/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeComponentInstanceMarshalTest.java b/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeComponentInstanceMarshalTest.java new file mode 100644 index 0000000..69e53da --- /dev/null +++ b/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeComponentInstanceMarshalTest.java @@ -0,0 +1,79 @@ +package ai.tegmentum.webassembly4j.provider.wasmtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import ai.tegmentum.wasmtime4j.wit.WitBool; +import ai.tegmentum.wasmtime4j.wit.WitFloat32; +import ai.tegmentum.wasmtime4j.wit.WitFloat64; +import ai.tegmentum.wasmtime4j.wit.WitList; +import ai.tegmentum.wasmtime4j.wit.WitS16; +import ai.tegmentum.wasmtime4j.wit.WitS32; +import ai.tegmentum.wasmtime4j.wit.WitS64; +import ai.tegmentum.wasmtime4j.wit.WitS8; +import ai.tegmentum.wasmtime4j.wit.WitString; +import ai.tegmentum.wasmtime4j.wit.WitValue; +import ai.tegmentum.webassembly4j.api.exception.UnsupportedFeatureException; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Fix 10 unit test: the high-level {@code ComponentInstance.invoke} must marshal natural Java + * argument types into {@link WitValue} (its javadoc contract), not require callers to pre-build + * WitValue. Pure mapping test — no native library required. + */ +class WasmtimeComponentInstanceMarshalTest { + + @Test + @DisplayName("scalars + string map to their natural WIT value types") + void scalarsAndStrings() throws Exception { + assertEquals(Boolean.TRUE, + WasmtimeComponentInstanceAdapter.toWitValue(true) instanceof WitBool); + assertEquals("hi", ((WitString) WasmtimeComponentInstanceAdapter.toWitValue("hi")).toJava()); + + // Integer -> s32, Long -> s64 (signed defaults). + assertEquals(WitS32.class, WasmtimeComponentInstanceAdapter.toWitValue(42).getClass()); + assertEquals(WitS64.class, WasmtimeComponentInstanceAdapter.toWitValue(42L).getClass()); + assertEquals(WitS8.class, WasmtimeComponentInstanceAdapter.toWitValue((byte) 1).getClass()); + assertEquals(WitS16.class, + WasmtimeComponentInstanceAdapter.toWitValue((short) 1).getClass()); + assertEquals(WitFloat32.class, + WasmtimeComponentInstanceAdapter.toWitValue(1.5f).getClass()); + assertEquals(WitFloat64.class, + WasmtimeComponentInstanceAdapter.toWitValue(1.5d).getClass()); + } + + @Test + @DisplayName("WitValue arguments pass through unchanged (escape hatch)") + void witValuePassthrough() throws Exception { + WitValue prebuilt = WitString.of("already-wit"); + assertSame(prebuilt, WasmtimeComponentInstanceAdapter.toWitValue(prebuilt)); + } + + @Test + @DisplayName("byte[] and homogeneous lists become list values") + void listsAndBytes() throws Exception { + WitValue bytes = WasmtimeComponentInstanceAdapter.toWitValue(new byte[] {1, 2, 3}); + assertEquals(WitList.class, bytes.getClass()); + + WitValue strings = WasmtimeComponentInstanceAdapter.toWitValue(List.of("a", "b")); + assertEquals(WitList.class, strings.getClass()); + } + + @Test + @DisplayName("unmarshallable / ambiguous types fail with guidance, not a cryptic native error") + void unsupportedTypesAreRejected() { + // Composite types whose WIT type can't be inferred from a bare Java value. + assertThrows(UnsupportedFeatureException.class, + () -> WasmtimeComponentInstanceAdapter.toWitValue(Map.of("k", "v"))); + // null (could be option none — needs an explicit type). + assertThrows(UnsupportedFeatureException.class, + () -> WasmtimeComponentInstanceAdapter.toWitValue((Object) null)); + // empty list — element type unknowable. + assertThrows(UnsupportedFeatureException.class, + () -> WasmtimeComponentInstanceAdapter.toWitValue(List.of())); + } +} diff --git a/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeCrossModuleStoreSharingTest.java b/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeCrossModuleStoreSharingTest.java new file mode 100644 index 0000000..06718cc --- /dev/null +++ b/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeCrossModuleStoreSharingTest.java @@ -0,0 +1,107 @@ +package ai.tegmentum.webassembly4j.provider.wasmtime; + +import ai.tegmentum.webassembly4j.api.DefaultLinkingContext; +import ai.tegmentum.webassembly4j.api.Engine; +import ai.tegmentum.webassembly4j.api.Function; +import ai.tegmentum.webassembly4j.api.Instance; +import ai.tegmentum.webassembly4j.api.LinkingContext; +import ai.tegmentum.webassembly4j.api.Memory; +import ai.tegmentum.webassembly4j.api.Module; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Exercises {@link Engine#loadModule(byte[], Instance)} — the cross-module + * store-sharing overload added by F-Webassembly4j-Cross-Module-Store-Sharing + * charter r.1 (2026-07-26). + * + *

Contrast with {@link WasmtimeLinkingContextExternImportsTest} which uses + * provider escape ({@code ((WasmtimeModuleAdapter) module).store()}) to + * manufacture same-store memory/table objects. This test uses the api-layer + * overload only — no provider escape. + * + *

Scenario: OUTER module exports memory; CONSUMER module (loaded via the + * new overload against OUTER's instance) imports env.memory and provides + * store/load helpers. Consumer's helpers operate on OUTER's memory — proves + * cross-module store sharing works end-to-end through the api layer. + */ +class WasmtimeCrossModuleStoreSharingTest { + + // (module (memory (export "memory") 1)) + private static final byte[] PRODUCER_MEMORY_MODULE = new byte[] { + (byte)0x00, (byte)0x61, (byte)0x73, (byte)0x6d, (byte)0x01, (byte)0x00, (byte)0x00, (byte)0x00, + (byte)0x05, (byte)0x03, (byte)0x01, (byte)0x00, (byte)0x01, (byte)0x07, (byte)0x0a, (byte)0x01, + (byte)0x06, (byte)0x6d, (byte)0x65, (byte)0x6d, (byte)0x6f, (byte)0x72, (byte)0x79, (byte)0x02, + (byte)0x00 + }; + + // (module + // (import "env" "memory" (memory 1)) + // (func (export "store_i32") (param i32 i32) local.get 0 local.get 1 i32.store) + // (func (export "load_i32") (param i32) (result i32) local.get 0 i32.load)) + // Copied byte-identical from WasmtimeLinkingContextExternImportsTest. + private static final byte[] IMPORT_MEMORY_MODULE = new byte[] { + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x0b, 0x02, 0x60, + 0x02, 0x7f, 0x7f, 0x00, 0x60, 0x01, 0x7f, 0x01, 0x7f, 0x02, 0x0f, 0x01, + 0x03, 0x65, 0x6e, 0x76, 0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x02, + 0x00, 0x01, 0x03, 0x03, 0x02, 0x00, 0x01, 0x07, 0x18, 0x02, 0x09, 0x73, + 0x74, 0x6f, 0x72, 0x65, 0x5f, 0x69, 0x33, 0x32, 0x00, 0x00, 0x08, 0x6c, + 0x6f, 0x61, 0x64, 0x5f, 0x69, 0x33, 0x32, 0x00, 0x01, 0x0a, 0x13, 0x02, + 0x09, 0x00, 0x20, 0x00, 0x20, 0x01, 0x36, 0x02, 0x00, 0x0b, 0x07, 0x00, + 0x20, 0x00, 0x28, 0x02, 0x00, 0x0b + }; + + static boolean runtimeAvailable() { + try { + try (Engine e = WasmtimeEngineAdapter.create(null)) { + return true; + } + } catch (Throwable t) { + return false; + } + } + + @Test + @EnabledIf("runtimeAvailable") + void crossModuleStoreSharingViaEngineLoadModuleOverload() { + try (Engine engine = WasmtimeEngineAdapter.create(null); + Module producer = engine.loadModule(PRODUCER_MEMORY_MODULE)) { + + // Instantiate producer to get an Instance whose store the consumer will share. + Instance producerInstance = producer.instantiate(); + Memory sharedMemory = producerInstance.memory("memory") + .orElseThrow(() -> new AssertionError("producer memory export missing")); + + // Load consumer via the new engine-agnostic overload — reuses producer's store. + try (Module consumer = engine.loadModule(IMPORT_MEMORY_MODULE, producerInstance)) { + LinkingContext ctx = DefaultLinkingContext.builder() + .addMemoryImport("env", "memory", sharedMemory) + .build(); + + Instance consumerInstance = consumer.instantiate(ctx); + Function storeFn = consumerInstance.function("store_i32").orElseThrow(); + Function loadFn = consumerInstance.function("load_i32").orElseThrow(); + + storeFn.invoke(0, 0xdeadbeef); + assertEquals(0xdeadbeef, ((Number) loadFn.invoke(0)).intValue(), + "consumer store/load round-trips through PRODUCER's memory"); + + // Host-side write through the shared api-layer Memory is also visible. + sharedMemory.write(4, new byte[] { 0x11, 0x22, 0x33, 0x44 }); + assertEquals(0x44332211, ((Number) loadFn.invoke(4)).intValue(), + "host-side write to shared memory visible to consumer"); + } + } + } + + @Test + void rejectsNullShareStoreWith() { + try (Engine engine = WasmtimeEngineAdapter.create(null)) { + assertThrows(IllegalArgumentException.class, + () -> engine.loadModule(PRODUCER_MEMORY_MODULE, null)); + } + } +} diff --git a/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeLinkingContextExternImportsTest.java b/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeLinkingContextExternImportsTest.java new file mode 100644 index 0000000..db0f588 --- /dev/null +++ b/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeLinkingContextExternImportsTest.java @@ -0,0 +1,311 @@ +package ai.tegmentum.webassembly4j.provider.wasmtime; + +import ai.tegmentum.wasmtime4j.WasmValue; +import ai.tegmentum.wasmtime4j.WasmValueType; +import ai.tegmentum.webassembly4j.api.DefaultLinkingContext; +import ai.tegmentum.webassembly4j.api.Engine; +import ai.tegmentum.webassembly4j.api.ExternImportDefinition; +import ai.tegmentum.webassembly4j.api.Function; +import ai.tegmentum.webassembly4j.api.FunctionImport; +import ai.tegmentum.webassembly4j.api.Global; +import ai.tegmentum.webassembly4j.api.GlobalImport; +import ai.tegmentum.webassembly4j.api.Instance; +import ai.tegmentum.webassembly4j.api.LinkingContext; +import ai.tegmentum.webassembly4j.api.Memory; +import ai.tegmentum.webassembly4j.api.MemoryImport; +import ai.tegmentum.webassembly4j.api.Module; +import ai.tegmentum.webassembly4j.api.Table; +import ai.tegmentum.webassembly4j.api.TableImport; +import ai.tegmentum.webassembly4j.api.ValueType; +import ai.tegmentum.webassembly4j.api.exception.LinkingException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Exercises {@link LinkingContext#externImports()} wiring on the wasmtime + * provider. Covers each variant declared by the api layer: + * {@link MemoryImport}, {@link TableImport}, {@link GlobalImport}, and the + * intentionally-unsupported {@link FunctionImport}. + */ +class WasmtimeLinkingContextExternImportsTest { + + // (module + // (import "env" "memory" (memory 1)) + // (func (export "store_i32") (param i32 i32) local.get 0 local.get 1 i32.store) + // (func (export "load_i32") (param i32) (result i32) local.get 0 i32.load)) + private static final byte[] IMPORT_MEMORY_MODULE = new byte[] { + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x0b, 0x02, 0x60, + 0x02, 0x7f, 0x7f, 0x00, 0x60, 0x01, 0x7f, 0x01, 0x7f, 0x02, 0x0f, 0x01, + 0x03, 0x65, 0x6e, 0x76, 0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x02, + 0x00, 0x01, 0x03, 0x03, 0x02, 0x00, 0x01, 0x07, 0x18, 0x02, 0x09, 0x73, + 0x74, 0x6f, 0x72, 0x65, 0x5f, 0x69, 0x33, 0x32, 0x00, 0x00, 0x08, 0x6c, + 0x6f, 0x61, 0x64, 0x5f, 0x69, 0x33, 0x32, 0x00, 0x01, 0x0a, 0x13, 0x02, + 0x09, 0x00, 0x20, 0x00, 0x20, 0x01, 0x36, 0x02, 0x00, 0x0b, 0x07, 0x00, + 0x20, 0x00, 0x28, 0x02, 0x00, 0x0b + }; + + // (module + // (type $i32_ret (func (result i32))) + // (import "env" "table" (table 4 funcref)) + // (func (export "call_slot") (param i32) (result i32) + // (call_indirect (type $i32_ret) (local.get 0)))) + private static final byte[] IMPORT_TABLE_MODULE = new byte[] { + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x0a, 0x02, 0x60, + 0x00, 0x01, 0x7f, 0x60, 0x01, 0x7f, 0x01, 0x7f, 0x02, 0x0f, 0x01, 0x03, + 0x65, 0x6e, 0x76, 0x05, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x01, 0x70, 0x00, + 0x04, 0x03, 0x02, 0x01, 0x01, 0x07, 0x0d, 0x01, 0x09, 0x63, 0x61, 0x6c, + 0x6c, 0x5f, 0x73, 0x6c, 0x6f, 0x74, 0x00, 0x00, 0x0a, 0x09, 0x01, 0x07, + 0x00, 0x20, 0x00, 0x11, 0x00, 0x00, 0x0b + }; + + // (module + // (import "env" "counter" (global $g (mut i32))) + // (func (export "read") (result i32) global.get $g) + // (func (export "bump") + // global.get $g i32.const 1 i32.add global.set $g)) + // + // bump is 0-arg to sidestep a pre-existing i32-in/void-out fast-path + // shortcut in WasmtimeFunctionAdapter (out of scope for this charter). + private static final byte[] IMPORT_GLOBAL_MODULE = new byte[] { + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x60, + 0x00, 0x01, 0x7f, 0x60, 0x00, 0x00, 0x02, 0x10, 0x01, 0x03, 0x65, 0x6e, + 0x76, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x03, 0x7f, 0x01, + 0x03, 0x03, 0x02, 0x00, 0x01, 0x07, 0x0f, 0x02, 0x04, 0x72, 0x65, 0x61, + 0x64, 0x00, 0x00, 0x04, 0x62, 0x75, 0x6d, 0x70, 0x00, 0x01, 0x0a, 0x10, + 0x02, 0x04, 0x00, 0x23, 0x00, 0x0b, 0x09, 0x00, 0x23, 0x00, 0x41, 0x01, + 0x6a, 0x24, 0x00, 0x0b + }; + + static boolean runtimeAvailable() { + return new WasmtimeProvider().availability().available(); + } + + // ---------- api-layer builder + accessor ----------------------------------------- + + @Test + void builderCollectsAllFourVariants() { + Memory memory = new StubMemory(); + Table table = new StubTable(); + Global global = new StubGlobal(); + Function function = new StubFunction(); + + DefaultLinkingContext ctx = DefaultLinkingContext.builder() + .addMemoryImport("env", "memory", memory) + .addTableImport("env", "table", table) + .addGlobalImport("env", "counter", global) + .addFunctionImport("env", "log", function) + .build(); + + List imports = ctx.externImports(); + assertEquals(4, imports.size(), "all four typed variants land in externImports()"); + assertTrue(imports.get(0) instanceof MemoryImport); + assertTrue(imports.get(1) instanceof TableImport); + assertTrue(imports.get(2) instanceof GlobalImport); + assertTrue(imports.get(3) instanceof FunctionImport); + + MemoryImport mi = (MemoryImport) imports.get(0); + assertEquals("env", mi.moduleName()); + assertEquals("memory", mi.name()); + assertSame(memory, mi.memory()); + } + + @Test + void builderAcceptsPreBuiltExternImport() { + MemoryImport pre = new MemoryImport("env", "memory", new StubMemory()); + DefaultLinkingContext ctx = DefaultLinkingContext.builder() + .addExternImport(pre) + .build(); + assertEquals(1, ctx.externImports().size()); + assertSame(pre, ctx.externImports().get(0)); + } + + @Test + void defaultLinkingContextReturnsEmptyExternImportsByDefault() { + // Anonymous impl reveals the interface default is the empty list. + LinkingContext ctx = new LinkingContext() { }; + assertEquals(0, ctx.externImports().size()); + } + + // ---------- provider wiring: end-to-end shared imports on same store ------------- + + @Test + @EnabledIf("runtimeAvailable") + void memoryImportSharedOnSameStore() { + try (Engine engine = WasmtimeEngineAdapter.create(null); + Module module = engine.loadModule(IMPORT_MEMORY_MODULE)) { + + // Manufacture a WasmMemory bound to the module's own native store so + // wasmtime accepts it as a valid import at instantiate time. + ai.tegmentum.wasmtime4j.Store nativeStore = ((WasmtimeModuleAdapter) module).store(); + ai.tegmentum.wasmtime4j.WasmMemory nativeMemory; + try { + nativeMemory = nativeStore.createMemory(1, 1); + } catch (ai.tegmentum.wasmtime4j.exception.WasmException e) { + throw new AssertionError("createMemory failed", e); + } + Memory sharedMemory = new WasmtimeMemoryAdapter(nativeMemory); + + LinkingContext ctx = DefaultLinkingContext.builder() + .addMemoryImport("env", "memory", sharedMemory) + .build(); + + Instance instance = module.instantiate(ctx); + Function storeFn = instance.function("store_i32").orElseThrow(); + Function loadFn = instance.function("load_i32").orElseThrow(); + + storeFn.invoke(0, 0xdeadbeef); + assertEquals(0xdeadbeef, loadFn.invoke(0)); + + // Host-side write is visible to the guest through the same memory. + sharedMemory.write(4, new byte[] { 0x11, 0x22, 0x33, 0x44 }); + assertEquals(0x44332211, loadFn.invoke(4)); + } + } + + @Test + @EnabledIf("runtimeAvailable") + void tableImportSharedOnSameStore() { + try (Engine engine = WasmtimeEngineAdapter.create(null); + Module module = engine.loadModule(IMPORT_TABLE_MODULE)) { + + ai.tegmentum.wasmtime4j.Store nativeStore = ((WasmtimeModuleAdapter) module).store(); + ai.tegmentum.wasmtime4j.WasmTable nativeTable; + try { + nativeTable = nativeStore.createTable(WasmValueType.FUNCREF, 4, 4); + } catch (ai.tegmentum.wasmtime4j.exception.WasmException e) { + throw new AssertionError("createTable failed", e); + } + Table sharedTable = new WasmtimeTableAdapter(nativeTable); + + LinkingContext ctx = DefaultLinkingContext.builder() + .addTableImport("env", "table", sharedTable) + .build(); + + Instance instance = module.instantiate(ctx); + // The instantiate itself is the observable success signal; the + // imported table is empty (null funcrefs) so calling call_slot + // would trap. Assert the table handle was actually wired by + // checking size flows through the api-layer adapter. + assertNotNull(instance); + assertEquals(4, sharedTable.size()); + } + } + + @Test + @EnabledIf("runtimeAvailable") + void globalImportSharedOnSameStore() { + try (Engine engine = WasmtimeEngineAdapter.create(null); + Module module = engine.loadModule(IMPORT_GLOBAL_MODULE)) { + + ai.tegmentum.wasmtime4j.Store nativeStore = ((WasmtimeModuleAdapter) module).store(); + ai.tegmentum.wasmtime4j.WasmGlobal nativeGlobal; + try { + nativeGlobal = nativeStore.createMutableGlobal(WasmValueType.I32, WasmValue.i32(41)); + } catch (ai.tegmentum.wasmtime4j.exception.WasmException e) { + throw new AssertionError("createMutableGlobal failed", e); + } + Global sharedGlobal = new WasmtimeGlobalAdapter(nativeGlobal); + + LinkingContext ctx = DefaultLinkingContext.builder() + .addGlobalImport("env", "counter", sharedGlobal) + .build(); + + Instance instance = module.instantiate(ctx); + Function read = instance.function("read").orElseThrow(); + Function bump = instance.function("bump").orElseThrow(); + + assertEquals(41, read.invoke()); + bump.invoke(); + assertEquals(42, read.invoke()); + + // Guest-side mutation is visible on the host global handle. + assertEquals(42, sharedGlobal.get()); + + // Host-side mutation is visible in the guest. + sharedGlobal.set(100); + assertEquals(100, read.invoke()); + } + } + + // ---------- provider wiring: error paths ------------------------------------------ + + @Test + @EnabledIf("runtimeAvailable") + void functionImportRaisesUnsupportedOperation() { + try (Engine engine = WasmtimeEngineAdapter.create(null); + Module module = engine.loadModule(IMPORT_MEMORY_MODULE)) { + + LinkingContext ctx = DefaultLinkingContext.builder() + .addFunctionImport("env", "log", new StubFunction()) + .build(); + + UnsupportedOperationException e = assertThrows(UnsupportedOperationException.class, + () -> module.instantiate(ctx)); + assertTrue(e.getMessage().contains("FunctionImport"), + "message identifies the variant"); + assertTrue(e.getMessage().contains("env"), + "message includes the moduleName"); + assertTrue(e.getMessage().contains("log"), + "message includes the import name"); + } + } + + @Test + @EnabledIf("runtimeAvailable") + void memoryImportFromWrongProviderRaisesLinkingException() { + try (Engine engine = WasmtimeEngineAdapter.create(null); + Module module = engine.loadModule(IMPORT_MEMORY_MODULE)) { + + LinkingContext ctx = DefaultLinkingContext.builder() + .addMemoryImport("env", "memory", new StubMemory()) + .build(); + + LinkingException e = assertThrows(LinkingException.class, + () -> module.instantiate(ctx)); + assertTrue(e.getMessage().contains("env")); + assertTrue(e.getMessage().contains("memory")); + assertTrue(e.getMessage().contains("wasmtime4j-backed")); + } + } + + // ---------- stubs for non-native cases ------------------------------------------- + + private static final class StubMemory implements Memory { + @Override public long byteSize() { return 0; } + @Override public java.nio.ByteBuffer asByteBuffer() { + return java.nio.ByteBuffer.allocate(0); + } + @Override public void write(long offset, byte[] bytes) { } + @Override public byte[] read(long offset, int length) { return new byte[length]; } + @Override public Optional unwrap(Class nativeType) { return Optional.empty(); } + } + + private static final class StubTable implements Table { + @Override public int size() { return 0; } + @Override public Optional unwrap(Class nativeType) { return Optional.empty(); } + } + + private static final class StubGlobal implements Global { + @Override public ValueType type() { return ValueType.I32; } + @Override public Object get() { return 0; } + @Override public void set(Object value) { } + @Override public boolean mutable() { return false; } + @Override public Optional unwrap(Class nativeType) { return Optional.empty(); } + } + + private static final class StubFunction implements Function { + @Override public ValueType[] parameterTypes() { return new ValueType[0]; } + @Override public ValueType[] resultTypes() { return new ValueType[0]; } + @Override public Object invoke(Object... args) { return null; } + } +} diff --git a/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeSupportsWasiNnTest.java b/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeSupportsWasiNnTest.java new file mode 100644 index 0000000..2629311 --- /dev/null +++ b/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeSupportsWasiNnTest.java @@ -0,0 +1,45 @@ +package ai.tegmentum.webassembly4j.provider.wasmtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import ai.tegmentum.wasmtime4j.jni.JniComponentLinker; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Pins the {@link WasmtimeComponentAdapter#supportsWasiNn()} probe to the truth + * reported by the loaded wasmtime4j native. The provider override delegates to + * {@link JniComponentLinker#wasiNnAvailable()}, so the two must agree — a drift + * would silently return a stale (probably {@code true}) answer and re-open the + * regression this probe exists to close. + * + *

The test intentionally does not assert the boolean is {@code true} or + * {@code false} in absolute terms: whether the native was built with the + * {@code wasi-nn} cargo feature depends on the classifier the reactor + * consumed, and this repo's default build ships without it. + */ +class WasmtimeSupportsWasiNnTest { + + @Test + @DisplayName("provider probe returns the loaded native's wasi:nn truth") + void supportsWasiNnMirrorsNative() { + // The override is a straight delegation — no component / engine state is read + // — so a null-inflated adapter is safe here and lets us exercise the real + // override without paying for a compile+instantiate. + WasmtimeComponentAdapter adapter = + new WasmtimeComponentAdapter(null, null, null, null); + + // Compute the native truth defensively — an older cached wasmtime4j native + // on this workstation's java.library.path may miss the nativeWasiNnAvailable + // symbol; that surfaces as UnsatisfiedLinkError, which the reflection-based + // provider override treats as "no support" (returns false). Either way the + // adapter answer must match the raw JNI answer for the classpath in use. + boolean nativeAnswer; + try { + nativeAnswer = JniComponentLinker.wasiNnAvailable(); + } catch (UnsatisfiedLinkError | ExceptionInInitializerError err) { + nativeAnswer = false; + } + assertEquals(nativeAnswer, adapter.supportsWasiNn()); + } +} diff --git a/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeTableAdapterTest.java b/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeTableAdapterTest.java new file mode 100644 index 0000000..1695ef6 --- /dev/null +++ b/wasmtime4j-provider/src/test/java/ai/tegmentum/webassembly4j/provider/wasmtime/WasmtimeTableAdapterTest.java @@ -0,0 +1,224 @@ +package ai.tegmentum.webassembly4j.provider.wasmtime; + +import ai.tegmentum.webassembly4j.api.Engine; +import ai.tegmentum.webassembly4j.api.Instance; +import ai.tegmentum.webassembly4j.api.Module; +import ai.tegmentum.webassembly4j.api.Table; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; + +import java.util.OptionalInt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Exercises the four spec-standard {@link Table} operations added by the + * Table-Interface-Completion charter (r.1): {@code maxSize}, {@code get}, + * {@code set}, {@code grow}. These delegate to the native + * {@code ai.tegmentum.wasmtime4j.WasmTable}. + * + *

The api-layer {@link Engine} intentionally exposes no WAT entry + * point, so fixture modules are supplied as hand-encoded raw wasm; they + * are small enough (three tables + one empty function) that this is more + * legible than dragging a wasm-text tool through the test dependency + * graph. + */ +class WasmtimeTableAdapterTest { + + static boolean runtimeAvailable() { + return new WasmtimeProvider().availability().available(); + } + + // ---------------- Hand-encoded WASM fixtures ---------------- + // + // Encoding a funcref table + one function is small enough to keep as + // literal byte arrays. Encoded and cross-checked against wat2wasm: + // (module + // (table (export "t") $LIMITS funcref) + // (func $f (export "f"))) + // + // Sections used: + // 1 Type : ()->() + // 3 Function: [0] + // 4 Table : [limits, funcref] + // 7 Export : "t" (table 0), "f" (func 0) + // 10 Code : one empty function + + /** funcref table with min=3, max=10. */ + private static final byte[] TABLE_BOUNDED = new byte[] { + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // magic + version + // Type: 1 * ()->() + 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, + // Function: 1 func with type 0 + 0x03, 0x02, 0x01, 0x00, + // Table: 1 * funcref, min=3, max=10 + 0x04, 0x05, 0x01, 0x70, 0x01, 0x03, 0x0a, + // Export: "t" (table 0), "f" (func 0) + 0x07, 0x09, 0x02, + 0x01, 0x74, 0x01, 0x00, + 0x01, 0x66, 0x00, 0x00, + // Code: empty body + 0x0a, 0x04, 0x01, 0x02, 0x00, 0x0b + }; + + /** funcref table with min=2, no max. */ + private static final byte[] TABLE_UNBOUNDED = new byte[] { + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, + 0x03, 0x02, 0x01, 0x00, + // Table: 1 * funcref, min=2, no max (flags=0x00) + 0x04, 0x04, 0x01, 0x70, 0x00, 0x02, + 0x07, 0x09, 0x02, + 0x01, 0x74, 0x01, 0x00, + 0x01, 0x66, 0x00, 0x00, + 0x0a, 0x04, 0x01, 0x02, 0x00, 0x0b + }; + + /** funcref table with min=1, max=1 (no room to grow). */ + private static final byte[] TABLE_MAX_ONE = new byte[] { + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, + 0x03, 0x02, 0x01, 0x00, + // Table: 1 * funcref, min=1, max=1 + 0x04, 0x05, 0x01, 0x70, 0x01, 0x01, 0x01, + 0x07, 0x09, 0x02, + 0x01, 0x74, 0x01, 0x00, + 0x01, 0x66, 0x00, 0x00, + 0x0a, 0x04, 0x01, 0x02, 0x00, 0x0b + }; + + // ---------------- Tests ---------------- + + @Test + @EnabledIf("runtimeAvailable") + void sizeReportsInitialMinimum() { + try (Engine engine = WasmtimeEngineAdapter.create(null); + Module module = engine.loadModule(TABLE_BOUNDED)) { + Instance instance = module.instantiate(); + Table table = instance.table("t").orElseThrow(); + assertEquals(3, table.size()); + } + } + + @Test + @EnabledIf("runtimeAvailable") + void maxSizeBoundedReturnsDeclaredMax() { + try (Engine engine = WasmtimeEngineAdapter.create(null); + Module module = engine.loadModule(TABLE_BOUNDED)) { + Instance instance = module.instantiate(); + Table table = instance.table("t").orElseThrow(); + OptionalInt max = table.maxSize(); + assertTrue(max.isPresent(), "bounded table must report a max"); + assertEquals(10, max.getAsInt()); + } + } + + @Test + @EnabledIf("runtimeAvailable") + void maxSizeUnboundedReturnsEmpty() { + try (Engine engine = WasmtimeEngineAdapter.create(null); + Module module = engine.loadModule(TABLE_UNBOUNDED)) { + Instance instance = module.instantiate(); + Table table = instance.table("t").orElseThrow(); + assertFalse(table.maxSize().isPresent(), + "unbounded table must report empty max"); + } + } + + @Test + @EnabledIf("runtimeAvailable") + void getUninitializedSlotReturnsNull() { + try (Engine engine = WasmtimeEngineAdapter.create(null); + Module module = engine.loadModule(TABLE_BOUNDED)) { + Instance instance = module.instantiate(); + Table table = instance.table("t").orElseThrow(); + // funcref slots default to null (uninitialized) until set + assertNull(table.get(0)); + } + } + + @Test + @EnabledIf("runtimeAvailable") + void setThenGetRoundTripsFuncref() { + try (Engine engine = WasmtimeEngineAdapter.create(null); + Module module = engine.loadModule(TABLE_BOUNDED)) { + Instance instance = module.instantiate(); + Table table = instance.table("t").orElseThrow(); + + // The api-layer Function does not expose unwrap; reach the native + // WasmFunction (a legal funcref value) via the native Instance. + ai.tegmentum.wasmtime4j.Instance nativeInstance = instance + .unwrap(ai.tegmentum.wasmtime4j.Instance.class) + .orElseThrow(() -> new AssertionError( + "Expected to unwrap ai.tegmentum.wasmtime4j.Instance")); + Object funcref = nativeInstance.getFunction("f") + .orElseThrow(() -> new AssertionError( + "Expected exported function 'f'")); + + table.set(1, funcref); + Object slot = table.get(1); + assertNotNull(slot, "post-set slot must not be null"); + } + } + + @Test + @EnabledIf("runtimeAvailable") + void growReturnsPreGrowSizeAndIncreasesSize() { + try (Engine engine = WasmtimeEngineAdapter.create(null); + Module module = engine.loadModule(TABLE_BOUNDED)) { + Instance instance = module.instantiate(); + Table table = instance.table("t").orElseThrow(); + + int preGrow = table.size(); + int returned = table.grow(2, null); + assertEquals(preGrow, returned, "grow must return pre-grow size"); + assertEquals(preGrow + 2, table.size(), + "post-grow size must equal preGrow + delta"); + } + } + + @Test + @EnabledIf("runtimeAvailable") + void growBeyondMaxReturnsMinusOne() { + try (Engine engine = WasmtimeEngineAdapter.create(null); + Module module = engine.loadModule(TABLE_MAX_ONE)) { + Instance instance = module.instantiate(); + Table table = instance.table("t").orElseThrow(); + assertEquals(1, table.size()); + int returned = table.grow(5, null); + assertEquals(-1, returned, + "grow past declared max must return -1"); + assertEquals(1, table.size(), + "failed grow must leave size unchanged"); + } + } + + @Test + @EnabledIf("runtimeAvailable") + void getOutOfBoundsThrows() { + try (Engine engine = WasmtimeEngineAdapter.create(null); + Module module = engine.loadModule(TABLE_BOUNDED)) { + Instance instance = module.instantiate(); + Table table = instance.table("t").orElseThrow(); + assertThrows(IndexOutOfBoundsException.class, + () -> table.get(table.size())); + } + } + + @Test + @EnabledIf("runtimeAvailable") + void setOutOfBoundsThrows() { + try (Engine engine = WasmtimeEngineAdapter.create(null); + Module module = engine.loadModule(TABLE_BOUNDED)) { + Instance instance = module.instantiate(); + Table table = instance.table("t").orElseThrow(); + assertThrows(IndexOutOfBoundsException.class, + () -> table.set(table.size(), null)); + } + } +} diff --git a/wasmtime4j-provider/src/test/resources/counter_component.wasm b/wasmtime4j-provider/src/test/resources/counter_component.wasm new file mode 100644 index 0000000..93e2b02 Binary files /dev/null and b/wasmtime4j-provider/src/test/resources/counter_component.wasm differ diff --git a/wasmtime4j-provider/src/test/rust/counter_component/.gitignore b/wasmtime4j-provider/src/test/rust/counter_component/.gitignore new file mode 100644 index 0000000..f87c388 --- /dev/null +++ b/wasmtime4j-provider/src/test/rust/counter_component/.gitignore @@ -0,0 +1,4 @@ +target/ +Cargo.lock +# Regenerated by cargo-component on every build. +src/bindings.rs diff --git a/wasmtime4j-provider/src/test/rust/counter_component/Cargo.toml b/wasmtime4j-provider/src/test/rust/counter_component/Cargo.toml new file mode 100644 index 0000000..774affb --- /dev/null +++ b/wasmtime4j-provider/src/test/rust/counter_component/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "counter_component" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen-rt = { version = "0.36", features = ["bitflags"] } + +[package.metadata.component] +package = "tegmentum:test-counter" + +[package.metadata.component.target] +path = "wit" +world = "counter-world" + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +strip = true diff --git a/wasmtime4j-provider/src/test/rust/counter_component/src/lib.rs b/wasmtime4j-provider/src/test/rust/counter_component/src/lib.rs new file mode 100644 index 0000000..f8b7854 --- /dev/null +++ b/wasmtime4j-provider/src/test/rust/counter_component/src/lib.rs @@ -0,0 +1,91 @@ +// Synthetic counter component — end-to-end verification for the +// webassembly4j WitCallableResource surface. +// +// The exported `counter-api` interface owns a `counter` resource with a +// no-arg constructor, an `increment` method, and a `value` method that +// returns the current count. Instance state lives in a RefCell (a WASM +// component is single-threaded, so there is no cross-thread contention) +// so `increment` can mutate through the `&self` receiver that wit-bindgen's +// generated `GuestCounter` trait wants. +// +// The constructor + `Drop` impl for CounterState: +// 1. eprintln! to stderr — surfaces through wasi:cli/stderr and, when the +// host inherits stderr, lands on the JVM process's stderr. Useful for +// diagnostics but hard to capture from a JUnit test because a Java +// `System.setErr` swap does NOT redirect fd 2. +// 2. std::fs::write to a preopened path — writes a marker file so the +// Java smoke test can prove Drop actually fired without needing to +// capture fd 2 or run the JVM as a subprocess. +// +// Both are best-effort (Drop can't return errors), which is why the tests +// treat the file as the primary signal and the stderr line as advisory. + +use std::cell::RefCell; + +#[allow(warnings)] +mod bindings; + +use bindings::exports::tegmentum::test_counter::counter_api::{Guest, GuestCounter}; + +/// Preopen path (guest view) where lifecycle markers land. The host smoke +/// test grants a real temp dir at this guest path and reads the marker back +/// to prove constructor / drop actually reached the guest. +const LIFECYCLE_MARKER_DIR: &str = "/lifecycle"; + +struct Component; + +pub struct CounterState { + count: RefCell, +} + +impl Guest for Component { + type Counter = CounterState; +} + +impl GuestCounter for CounterState { + fn new() -> Self { + eprintln!("counter_component: constructor called"); + write_marker("constructor", "0"); + CounterState { + count: RefCell::new(0), + } + } + + fn increment(&self) { + let mut c = self.count.borrow_mut(); + *c += 1; + } + + fn value(&self) -> u32 { + *self.count.borrow() + } +} + +impl Drop for CounterState { + fn drop(&mut self) { + let final_value = *self.count.borrow(); + eprintln!( + "counter_component: drop called (final value = {})", + final_value + ); + write_marker("drop", &final_value.to_string()); + } +} + +/// Best-effort write of "=\n" to the lifecycle marker file. Silently +/// swallows errors — Drop can't return them, and the host may not have granted a +/// preopen (e.g. the plain lifecycle test doesn't need file evidence). +fn write_marker(kind: &str, value: &str) { + let path = format!("{}/marker.log", LIFECYCLE_MARKER_DIR); + let line = format!("{}={}\n", kind, value); + let _ = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .and_then(|mut f| { + use std::io::Write; + f.write_all(line.as_bytes()) + }); +} + +bindings::export!(Component with_types_in bindings); diff --git a/wasmtime4j-provider/src/test/rust/counter_component/wit/counter.wit b/wasmtime4j-provider/src/test/rust/counter_component/wit/counter.wit new file mode 100644 index 0000000..bcccc5c --- /dev/null +++ b/wasmtime4j-provider/src/test/rust/counter_component/wit/counter.wit @@ -0,0 +1,13 @@ +package tegmentum:test-counter@0.1.0; + +interface counter-api { + resource counter { + constructor(); + increment: func(); + value: func() -> u32; + } +} + +world counter-world { + export counter-api; +} diff --git a/webassembly4j-api/pom.xml b/webassembly4j-api/pom.xml index c771269..cb9f27e 100644 --- a/webassembly4j-api/pom.xml +++ b/webassembly4j-api/pom.xml @@ -8,7 +8,7 @@ ai.tegmentum.webassembly4j webassembly4j-parent - 1.5.0 + 2.5.2-SNAPSHOT webassembly4j-api diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/Caller.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/Caller.java new file mode 100644 index 0000000..452c6c3 --- /dev/null +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/Caller.java @@ -0,0 +1,228 @@ +package ai.tegmentum.webassembly4j.api; + +import java.util.Optional; + +/** + * Scoped access to the caller's instance during a host callback. Providers + * that support caller-aware host functions expose the caller via this + * interface; the reference is only valid for the callback duration. + * + *

Consumers use this to safely read exports of the calling instance, + * compile / instantiate additional modules against the caller's engine and + * store, and mutate caller-visible tables and memories from within the + * callback frame without triggering reentrant store-lock corruption. Any + * attempt to invoke a method after the callback returns throws + * {@link IllegalStateException} (providers enforce this via a generation + * counter kept in the underlying store). + * + *

Not every provider surfaces every capability. Providers that do not + * support caller-aware host functions at all return an empty list from + * {@link LinkingContext#callerAwareHostFunctions()} consumption paths and + * never construct a {@code Caller}. Providers that support some but not all + * scoped methods raise {@link UnsupportedOperationException} on the + * unsupported entrypoints. + * + *

Design alignment: wasmos {@code host:wasmtime@0.10.0} WIT

+ * + *

webassembly4j is a portable, provider-agnostic engine abstraction — + * the same purpose wasmos's {@code host:wasmtime@0.10.0} WIT interface + * serves for the WIT / component-model ecosystem. This {@code Caller} + * interface's method shape mirrors the equivalent + * {@code host:wasmtime@0.10.0} WIT primitives (see + * {@code ~/git/wasmos/wit/}: {@code engine.wit}, {@code store.wit}, + * {@code module-compile.wit}, {@code linker.wit}, {@code instance.wit}, + * {@code refs.wit}). The two projects independently converged on the + * same primitive set — validation that the surface is broadly useful + * for any host that wants to drive dynamic composition from a callback + * frame, not JIT-loader-bespoke. + * + *

Per {@code doctrine-wasmtime4j-caller-scoped-api-aligned-with-wasmos-wit-2026-07-27} + * (which applies at the webassembly4j layer, not wasmtime4j — the JNI + * layer stays wasmtime-specific): future caller-scoped additions to this + * interface MUST mirror the equivalent method in wasmos WIT (kebab-case + * → camelCase, {@code borrow} removed since the caller carries + * its own implicit store binding, {@code result} → {@code T} + * return + throws, {@code option} → {@code Optional}, + * {@code list} → {@code byte[]}). Deviations must be documented in + * the method's Javadoc. Discoverable via {@code grep "@see wasmos"} across + * this file. + * + * @param the type of caller-associated data (provider-specific store + * data slot; typically {@code Void} when no data is used) + * @since 2.5.2 + */ +public interface Caller { + + /** + * Returns the caller-associated data (provider-specific; may be null when + * the underlying store carries no user data). + */ + T data(); + + /** + * Returns an exported memory of the calling instance by name. + * + * @see wasmos wit://instance.wit + * — mirror of {@code host:wasmtime@0.10.0/instance.instance.get-memory} + * ({@code func(store: borrow<store>, name: string) -> option<wasm-memory>}). + */ + Optional getMemory(String name); + + /** + * Returns an exported table of the calling instance by name. + * + * @see wasmos wit://instance.wit + * — mirror of {@code host:wasmtime@0.10.0/instance.instance.get-table}. + */ + Optional

getTable(String name); + + /** + * Returns an exported function of the calling instance by name. + * + * @see wasmos wit://instance.wit + * — mirror of {@code host:wasmtime@0.10.0/instance.instance.get-func}. + */ + Optional getFunction(String name); + + /** + * Returns an exported global of the calling instance by name. + * + * @see wasmos wit://instance.wit + * — mirror of {@code host:wasmtime@0.10.0/instance.instance.get-global}. + */ + Optional getGlobal(String name); + + /** + * Compile a {@link Module} using the caller's engine. Safe inside a + * callback — providers route through the caller-scoped path so the + * resulting module shares the caller's engine and can be instantiated + * into the caller's store without cross-engine errors. + * + * @param wasmBytes WebAssembly binary bytes (or WAT text on providers + * that accept it) + * @return the compiled module + * @throws IllegalStateException if the callback has returned + * @see wasmos wit://module-compile.wit + * — mirror of {@code host:wasmtime@0.10.0/module-compile.from-binary} + * ({@code func(engine: borrow<engine>, bytes: list<u8>) -> result<module, error>}); + * Java-side takes only bytes since the engine is implicit in the caller + * binding. + */ + Module compileModule(byte[] wasmBytes); + + /** + * Instantiate a module in the caller's store using the given imports. + * Safe inside a callback — the provider borrows the caller's live store + * context instead of re-entering the store lock. + * + * @param module the module to instantiate; must have been produced by + * the same provider (typically via + * {@link #compileModule(byte[])}) + * @param imports linking context providing imports; may be null for a + * no-import instantiation + * @return the newly created instance + * @throws IllegalStateException if the callback has returned + * @see wasmos wit://linker.wit + * — mirror of {@code host:wasmtime@0.10.0/linker.linker.instantiate} + * ({@code func(store: borrow<store>, module: borrow<module>) + * -> result<instance, error>}); Java-side collapses linker construction + * and {@code define-memory/table/global/func} calls into a single + * {@link LinkingContext} argument. + */ + Instance instantiate(Module module, LinkingContext imports); + + /** + * Grow a caller-visible table by {@code delta} slots, filling new slots + * with {@code init}. Provider-specific rules apply to the {@code init} + * value's type (typically funcref must be a {@link Function} handle or + * null; externref rules vary). + * + * @return the previous size on success, {@code -1} on failure + * @throws IllegalStateException if the callback has returned + * @see wasmos wit://instance.wit + * — mirror of {@code host:wasmtime@0.10.0/instance.wasm-table.grow} + * ({@code func(store: borrow<store>, delta: u64, init: val) -> result<u64, error>}); + * added upstream via F-Wasmos-Guest-JIT-Loader r.2 (2026-07-27). + * Java-side returns {@code int} + {@code -1}-on-failure sentinel + * rather than {@code result} since {@code int} matches the WIT + * table's practical index range and the pre-existing method signature. + */ + int growTable(Table table, int delta, Object init); + + /** + * Write a value into a caller-visible table slot. + * + * @throws IllegalStateException if the callback has returned + * @see wasmos wit://instance.wit + * — mirror of {@code host:wasmtime@0.10.0/instance.wasm-table.set} + * ({@code func(store: borrow<store>, index: u64, value: val) -> result<_, error>}); + * added upstream via F-Wasmos-Guest-JIT-Loader r.2 (2026-07-27). + */ + void setTableElement(Table table, int index, Object value); + + /** + * Grow a caller-visible memory by {@code deltaPages} 64 KiB pages. + * + * @return the previous size in pages on success, {@code -1} on failure + * @throws IllegalStateException if the callback has returned + * @see wasmos wit://instance.wit + * — mirror of {@code host:wasmtime@0.10.0/instance.wasm-memory.grow} + * ({@code func(store: borrow<store>, delta: u64) -> result<u64, error>}). + */ + long growMemory(Memory memory, long deltaPages); + + /** + * Read a byte range from a caller-exported memory using the caller-scoped + * native path (safe from within a host-callback frame). + * + *

Preferable to {@code getMemory(name).get().read(...)} from inside a + * callback because the api-layer {@link Memory} adapter's native handle + * may not be registered from the callback frame, causing native failures. + * This method routes through the provider's scoped-caller memory-read + * primitive (wasmtime4j-provider: uses {@code caller.get_export(name) + * .into_memory() + Memory::read(&mut ctx, ...)}). + * + * @param memoryName name of the caller's exported memory (e.g. "memory") + * @param offset byte offset into memory + * @param length number of bytes to read + * @return the byte range + * @throws IllegalStateException if the callback has returned + * @throws UnsupportedOperationException if the backend has not implemented + * scoped memory I/O + * @see wasmos wit://instance.wit + * — mirror of {@code host:wasmtime@0.10.0/instance.wasm-memory.read} + * ({@code func(store: borrow<store>, offset: u64, length: u64) + * -> result<list<u8>, error>}); Java-side takes {@code memoryName} + * lookup rather than {@code borrow<wasm-memory>} since the caller + * resolves the export by name internally. + * @since 2.5.2 + */ + default byte[] readMemory(String memoryName, long offset, int length) { + throw new UnsupportedOperationException( + "readMemory not implemented on this Caller backend"); + } + + /** + * Write a byte array into a caller-exported memory using the caller-scoped + * native path. Same safety guarantees as {@link #readMemory}. + * + * @throws IllegalStateException if the callback has returned + * @throws UnsupportedOperationException if the backend has not implemented + * scoped memory I/O + * @see wasmos wit://instance.wit + * — mirror of {@code host:wasmtime@0.10.0/instance.wasm-memory.write} + * ({@code func(store: borrow<store>, offset: u64, data: list<u8>) + * -> result<_, error>}). + * @since 2.5.2 + */ + default void writeMemory(String memoryName, long offset, byte[] bytes) { + throw new UnsupportedOperationException( + "writeMemory not implemented on this Caller backend"); + } + + /** + * Provider escape hatch — returns the underlying provider-specific + * caller handle when {@code nativeType} is assignable from it. + */ + Optional unwrap(Class nativeType); +} diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/CallerAwareHostFunction.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/CallerAwareHostFunction.java new file mode 100644 index 0000000..5582f84 --- /dev/null +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/CallerAwareHostFunction.java @@ -0,0 +1,30 @@ +package ai.tegmentum.webassembly4j.api; + +/** + * Host function that receives a {@link Caller} handle giving scoped access + * to the calling instance's exports, engine, and store for the duration of + * the callback. Providers that support this pattern route the caller from + * their native runtime; providers that do not throw + * {@link UnsupportedOperationException} at instantiation time when a + * {@link LinkingContext} carries any + * {@link CallerAwareHostFunctionDefinition} — see + * {@link LinkingContext#callerAwareHostFunctions()}. + * + * @param the type of caller-associated data (typically {@code Void} + * when no store data is used) + * @since 2.5.2 + */ +@FunctionalInterface +public interface CallerAwareHostFunction { + + /** + * Execute the host function with scoped access to the calling instance. + * + * @param caller scoped access to the calling instance; only valid for + * the duration of this call + * @param args arguments coerced from wasm values by the provider + * @return result values coerced back into wasm values by the provider; + * must match the declared result types + */ + Object[] execute(Caller caller, Object... args); +} diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/CallerAwareHostFunctionDefinition.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/CallerAwareHostFunctionDefinition.java new file mode 100644 index 0000000..8959fd6 --- /dev/null +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/CallerAwareHostFunctionDefinition.java @@ -0,0 +1,53 @@ +package ai.tegmentum.webassembly4j.api; + +/** + * Definition of a caller-aware host function to link into a module's + * imports. Parallel to {@link HostFunctionDefinition} but wraps a + * {@link CallerAwareHostFunction} whose implementation receives a + * {@link Caller} handle for scoped access to the calling instance. + * + *

Providers that do not support caller-aware host functions ignore + * entries of this type — see + * {@link LinkingContext#callerAwareHostFunctions()}. + * + * @since 2.5.2 + */ +public final class CallerAwareHostFunctionDefinition { + + private final String moduleName; + private final String functionName; + private final ValueType[] parameterTypes; + private final ValueType[] resultTypes; + private final CallerAwareHostFunction function; + + public CallerAwareHostFunctionDefinition(String moduleName, String functionName, + ValueType[] parameterTypes, + ValueType[] resultTypes, + CallerAwareHostFunction function) { + this.moduleName = moduleName; + this.functionName = functionName; + this.parameterTypes = parameterTypes.clone(); + this.resultTypes = resultTypes.clone(); + this.function = function; + } + + public String moduleName() { + return moduleName; + } + + public String functionName() { + return functionName; + } + + public ValueType[] parameterTypes() { + return parameterTypes.clone(); + } + + public ValueType[] resultTypes() { + return resultTypes.clone(); + } + + public CallerAwareHostFunction function() { + return function; + } +} diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/Component.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/Component.java index 87bcaa0..3215132 100644 --- a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/Component.java +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/Component.java @@ -91,6 +91,20 @@ default Optional extension(Class extensionType) { return Optional.empty(); } + /** + * @return {@code true} if this component's provider ships a native library + * built with wasi:nn support. Callers should probe before configuring + * a {@link LinkingContext} with + * {@link DefaultLinkingContext.Builder#enableWasiNn(WasiNnConfig)}; + * a false return means {@link #instantiate(LinkingContext)} will + * throw at wasi:nn linker-enable time. Default {@code false} so + * providers that don't override see the safe (no-support) answer. + * @since 2.4.1 + */ + default boolean supportsWasiNn() { + return false; + } + @Override void close(); } diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/ComponentInstance.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/ComponentInstance.java index a754cf0..8848ec5 100644 --- a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/ComponentInstance.java +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/ComponentInstance.java @@ -42,6 +42,59 @@ public interface ComponentInstance extends Instance { */ Object invoke(String functionName, Object... args); + /** + * Typed variant of {@link #invoke} that returns the raw WIT value tree from the + * provider instead of unwrapping it to a natural Java shape via + * {@code WitValue.toJava()}. Symmetric with the input side, which already + * requires typed values for records/variants/options/results. + * + *

Callers doing round-trip WIT marshalling (typed in, typed out) should prefer + * this over {@link #invoke} — it saves an extra pass through {@code toJava()} + * and preserves precise WIT typing that the Java-shape unwrap loses (e.g. u64 + * width, enum discriminants, option's inner type). + * + *

The return type is {@code Object} rather than a stronger provider-specific + * WIT type to keep this interface provider-neutral; the concrete type is the + * provider's WIT value tree (for wasmtime, {@code ai.tegmentum.wasmtime4j.wit.WitValue}). + * + *

Default implementation delegates to {@link #invoke}. Providers with a + * typed native invoke path should override. + * + * @param functionName the exported function name + * @param args the function arguments + * @return the provider's WIT value, or {@code null} for void functions + * @throws ai.tegmentum.webassembly4j.api.exception.ExecutionException if invocation fails + */ + default Object invokeWit(String functionName, Object... args) { + return invoke(functionName, args); + } + + /** + * Adopts a resource-shaped value returned from a prior {@link #invokeWit} call and + * returns a {@link WitCallableResource} bound to this instance, so method invocations + * on the resource can be written as {@code res.invokeMethod("m", args...)} rather than + * manually threading the receiver through {@link #invokeWit}. + * + *

The passed value must be the provider's WIT resource type (for wasmtime, + * {@code ai.tegmentum.wasmtime4j.wit.WitResource}). The returned handle owns the + * lifecycle: closing it drops the underlying store-side resource. + * + *

Default implementation throws — providers that host resources through a native + * store (currently only wasmtime4j) override. + * + * @param resource the provider-specific resource value (typically the return of an + * {@link #invokeWit} call whose signature declared {@code own}) + * @return a callable handle bound to this instance + * @throws UnsupportedFeatureException if the provider does not host callable resources + * @throws IllegalArgumentException if {@code resource} is not of the provider's expected + * resource type or has no native backing + * @since 2.4.0 + */ + default WitCallableResource asCallableResource(Object resource) { + throw new UnsupportedFeatureException( + "Provider does not host callable resources; wrap the receiver manually via invokeWit"); + } + /** * Returns whether this instance exports a function with the given name. */ @@ -104,4 +157,48 @@ default T runConcurrent(ConcurrentTask task) { throw new UnsupportedFeatureException( "Concurrent component execution not supported"); } + + /** + * Decrement the underlying store's remaining fuel by {@code amount}. + * + *

Lets the host charge fuel against the same budget the guest consumes so a Java-side + * "toll" is enforced against real store fuel — the guest traps once the store is exhausted, + * just as if the fuel had been burned by wasm instructions. Complements + * {@link #fuelConsumed()}: what the host debits here shows up in the actual-consumed reading. + * + *

Providers whose engine is not fuel-metered (or that don't host a store per instance) + * throw {@link UnsupportedFeatureException} by default. Providers that support fuel override. + * + * @param amount the units of fuel to deduct (must be non-negative) + * @throws UnsupportedFeatureException if the provider does not support store-level fuel + * accounting + * @throws IllegalArgumentException if {@code amount} is negative + * @throws ai.tegmentum.webassembly4j.api.exception.ExecutionException if the deduction + * would exceed the currently-remaining fuel (no partial deduction) or the underlying + * fuel operation fails + * @since 2.4.3 + */ + default void consumeFuel(long amount) { + throw new UnsupportedFeatureException( + "Provider does not support store-level fuel accounting"); + } + + /** + * Report the amount of fuel consumed against the store since its fuel was last set. + * + *

Set-fuel points are: instantiation (from the caller's fuel cap) and, for providers with + * per-call fuel resets, the start of each invocation. This method returns + * {@code baseline - remaining} relative to the most recent set-fuel — the actual fuel burned + * (wasm instructions plus any {@link #consumeFuel(long)} tolls) since then. + * + *

Providers whose engine is not fuel-metered return {@code -1} (unsupported sentinel). + * Providers that support fuel override. + * + * @return the fuel consumed since the last {@code set_fuel}, or {@code -1} when the provider + * does not support store-level fuel accounting + * @since 2.4.3 + */ + default long fuelConsumed() { + return -1L; + } } diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/DefaultLinkingContext.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/DefaultLinkingContext.java index deb2a98..50ba79c 100644 --- a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/DefaultLinkingContext.java +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/DefaultLinkingContext.java @@ -9,14 +9,27 @@ public final class DefaultLinkingContext implements LinkingContext { private final WasiContext wasiContext; + private final WasiNnConfig wasiNnConfig; private final Map imports; private final List hostFunctions; + private final List witHostFunctions; + private final List externImports; + private final List callerAwareHostFunctions; - private DefaultLinkingContext(WasiContext wasiContext, Map imports, - List hostFunctions) { + private DefaultLinkingContext(WasiContext wasiContext, WasiNnConfig wasiNnConfig, + Map imports, + List hostFunctions, + List witHostFunctions, + List externImports, + List callerAwareHostFunctions) { this.wasiContext = wasiContext; + this.wasiNnConfig = wasiNnConfig; this.imports = Collections.unmodifiableMap(new LinkedHashMap<>(imports)); this.hostFunctions = Collections.unmodifiableList(new ArrayList<>(hostFunctions)); + this.witHostFunctions = Collections.unmodifiableList(new ArrayList<>(witHostFunctions)); + this.externImports = Collections.unmodifiableList(new ArrayList<>(externImports)); + this.callerAwareHostFunctions = Collections.unmodifiableList( + new ArrayList<>(callerAwareHostFunctions)); } @Override @@ -24,11 +37,31 @@ public WasiContext wasiContext() { return wasiContext; } + @Override + public WasiNnConfig wasiNnConfig() { + return wasiNnConfig; + } + @Override public List hostFunctions() { return hostFunctions; } + @Override + public List witHostFunctions() { + return witHostFunctions; + } + + @Override + public List externImports() { + return externImports; + } + + @Override + public List callerAwareHostFunctions() { + return callerAwareHostFunctions; + } + public Map imports() { return imports; } @@ -40,8 +73,13 @@ public static Builder builder() { public static final class Builder { private WasiContext wasiContext; + private WasiNnConfig wasiNnConfig; private final Map imports = new LinkedHashMap<>(); private final List hostFunctions = new ArrayList<>(); + private final List witHostFunctions = new ArrayList<>(); + private final List externImports = new ArrayList<>(); + private final List callerAwareHostFunctions = + new ArrayList<>(); private Builder() { } @@ -51,6 +89,38 @@ public Builder wasiContext(WasiContext wasiContext) { return this; } + /** + * Enable WASI-NN on the resulting linking context with the given + * configuration. Providers that support wasi:nn (currently + * wasmtime4j-provider) translate this to their native enablement call + * before component instantiation. Passing {@link WasiNnConfig#defaults()} + * requests the provider's default backend set (ORT/ONNX under the + * current wasmtime4j pin). Passing {@code null} clears the request. + * + * @param config the WASI-NN configuration; {@code null} disables + */ + public Builder enableWasiNn(WasiNnConfig config) { + this.wasiNnConfig = config; + return this; + } + + /** + * Puts a value into an untyped import map. + * + * @deprecated The stored map is not exposed through the + * {@link LinkingContext} interface, so no in-tree provider consumes + * it — calls have no observable effect on instantiation. Use the + * typed methods + * {@link #addMemoryImport(String, String, Memory)}, + * {@link #addTableImport(String, String, Table)}, + * {@link #addGlobalImport(String, String, Global)}, + * {@link #addFunctionImport(String, String, Function)}, or + * {@link #addExternImport(ExternImportDefinition)} instead. The + * method is retained for source-compatibility with existing + * callers; its behaviour (a no-op with respect to instantiation) + * is unchanged. + */ + @Deprecated public Builder addImport(String name, Object value) { this.imports.put(name, value); return this; @@ -69,8 +139,120 @@ public Builder addHostFunction(HostFunctionDefinition definition) { return this; } + /** + * Register a WIT-typed host function under {@code witPath} — the standard WIT + * import path form ({@code "package:name/interface#function"} or {@code + * "#function"} for a root import). See {@link WitHostFunctionDefinition}. + */ + public Builder addWitHostFunction(String witPath, WitHostFunction function) { + this.witHostFunctions.add(new WitHostFunctionDefinition(witPath, function)); + return this; + } + + public Builder addWitHostFunction(WitHostFunctionDefinition definition) { + this.witHostFunctions.add(definition); + return this; + } + + /** + * Wire an already-constructed {@link ExternImportDefinition}. Useful + * when a caller has built the variant elsewhere or is threading a + * heterogeneous list through their code. + * + * @since 2.5.2 + */ + public Builder addExternImport(ExternImportDefinition definition) { + this.externImports.add(definition); + return this; + } + + /** + * Wire an imported linear memory under {@code moduleName::name}. The + * importing module must declare + * {@code (import "" "" (memory ...))}. + * + * @since 2.5.2 + */ + public Builder addMemoryImport(String moduleName, String name, Memory memory) { + this.externImports.add(new MemoryImport(moduleName, name, memory)); + return this; + } + + /** + * Wire an imported table under {@code moduleName::name}. The importing + * module must declare + * {@code (import "" "" (table ...))}. + * + * @since 2.5.2 + */ + public Builder addTableImport(String moduleName, String name, Table table) { + this.externImports.add(new TableImport(moduleName, name, table)); + return this; + } + + /** + * Wire an imported global under {@code moduleName::name}. The importing + * module must declare + * {@code (import "" "" (global ...))}. + * + * @since 2.5.2 + */ + public Builder addGlobalImport(String moduleName, String name, Global global) { + this.externImports.add(new GlobalImport(moduleName, name, global)); + return this; + } + + /** + * Wire an imported function under {@code moduleName::name}. The + * importing module must declare + * {@code (import "" "" (func ...))}. + * + *

Note: the wasmtime4j-provider does not yet implement this variant + * — see {@link FunctionImport}. For today's function-import needs use + * {@link #addHostFunction(String, String, ValueType[], ValueType[], HostFunction)} + * instead. + * + * @since 2.5.2 + */ + public Builder addFunctionImport(String moduleName, String name, Function function) { + this.externImports.add(new FunctionImport(moduleName, name, function)); + return this; + } + + /** + * Register a caller-aware host function under {@code moduleName::functionName}. + * The provider passes a {@link Caller} handle scoped to the callback + * frame — see {@link CallerAwareHostFunction}. + * + *

Providers that do not support the caller-aware pattern raise + * {@link ai.tegmentum.webassembly4j.api.exception.UnsupportedFeatureException} + * at {@code instantiate} time when the resulting list is non-empty. + * + * @since 2.5.2 + */ + public Builder addCallerAwareHostFunction(String moduleName, String functionName, + ValueType[] parameterTypes, + ValueType[] resultTypes, + CallerAwareHostFunction function) { + this.callerAwareHostFunctions.add(new CallerAwareHostFunctionDefinition( + moduleName, functionName, parameterTypes, resultTypes, function)); + return this; + } + + /** + * Register a pre-built {@link CallerAwareHostFunctionDefinition}. + * + * @since 2.5.2 + */ + public Builder addCallerAwareHostFunction(CallerAwareHostFunctionDefinition definition) { + this.callerAwareHostFunctions.add(definition); + return this; + } + public DefaultLinkingContext build() { - return new DefaultLinkingContext(wasiContext, imports, hostFunctions); + return new DefaultLinkingContext( + wasiContext, wasiNnConfig, imports, hostFunctions, witHostFunctions, + externImports, callerAwareHostFunctions); } } } diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/DefaultWasiContext.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/DefaultWasiContext.java index ff0e13b..93c858d 100644 --- a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/DefaultWasiContext.java +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/DefaultWasiContext.java @@ -14,16 +14,32 @@ public final class DefaultWasiContext implements WasiContext { private final boolean inheritStdout; private final boolean inheritStderr; private final List preopenDirs; + private final List readOnlyPreopenDirs; + private final Map preopenGuestPaths; + private final boolean allowNetwork; + private final List egressRules; + private final boolean wasiHttpEnabled; private DefaultWasiContext(List args, Map env, boolean inheritStdin, boolean inheritStdout, - boolean inheritStderr, List preopenDirs) { + boolean inheritStderr, List preopenDirs, + List readOnlyPreopenDirs, + Map preopenGuestPaths, + boolean allowNetwork, List egressRules, + boolean wasiHttpEnabled) { this.args = Collections.unmodifiableList(new ArrayList<>(args)); this.env = Collections.unmodifiableMap(new LinkedHashMap<>(env)); this.inheritStdin = inheritStdin; this.inheritStdout = inheritStdout; this.inheritStderr = inheritStderr; this.preopenDirs = Collections.unmodifiableList(new ArrayList<>(preopenDirs)); + this.readOnlyPreopenDirs = + Collections.unmodifiableList(new ArrayList<>(readOnlyPreopenDirs)); + this.preopenGuestPaths = + Collections.unmodifiableMap(new LinkedHashMap<>(preopenGuestPaths)); + this.allowNetwork = allowNetwork; + this.egressRules = Collections.unmodifiableList(new ArrayList<>(egressRules)); + this.wasiHttpEnabled = wasiHttpEnabled; } @Override @@ -56,6 +72,31 @@ public List preopenDirs() { return preopenDirs; } + @Override + public List readOnlyPreopenDirs() { + return readOnlyPreopenDirs; + } + + @Override + public Map preopenGuestPaths() { + return preopenGuestPaths; + } + + @Override + public boolean allowNetwork() { + return allowNetwork; + } + + @Override + public List egressRules() { + return egressRules; + } + + @Override + public boolean wasiHttpEnabled() { + return wasiHttpEnabled; + } + public static Builder builder() { return new Builder(); } @@ -68,6 +109,11 @@ public static final class Builder { private boolean inheritStdout; private boolean inheritStderr; private final List preopenDirs = new ArrayList<>(); + private final List readOnlyPreopenDirs = new ArrayList<>(); + private final Map preopenGuestPaths = new LinkedHashMap<>(); + private boolean allowNetwork; + private final List egressRules = new ArrayList<>(); + private boolean wasiHttpEnabled; private Builder() { } @@ -110,14 +156,63 @@ public Builder inheritStdio(boolean inherit) { return this; } + /** Grant a read-write preopen (the default). */ public Builder preopenDir(String dir) { + return preopenDir(dir, true); + } + + /** + * Grant a preopen with an explicit access mode. {@code writable=false} preopens the + * directory read-only — the guest can read but not create/write/delete within it. + */ + public Builder preopenDir(String dir, boolean writable) { this.preopenDirs.add(dir); + if (!writable) { + this.readOnlyPreopenDirs.add(dir); + } + return this; + } + + /** + * Grant a preopen of host directory {@code hostDir} that the guest sees at {@code guestPath} + * (host layout not leaked). {@code writable=false} preopens it read-only. + */ + public Builder preopenDir(String hostDir, String guestPath, boolean writable) { + this.preopenDirs.add(hostDir); + this.preopenGuestPaths.put(hostDir, guestPath); + if (!writable) { + this.readOnlyPreopenDirs.add(hostDir); + } + return this; + } + + /** Allow (or deny) the guest to open outbound network connections. Default deny. */ + public Builder allowNetwork(boolean allow) { + this.allowNetwork = allow; + return this; + } + + /** Add an egress allow-list rule (implies {@link #allowNetwork(boolean) allowNetwork(true)}). */ + public Builder allowEgress(NetworkEgressRule rule) { + this.egressRules.add(rule); + this.allowNetwork = true; + return this; + } + + /** + * Ask the provider to publish the {@code wasi:http} import surface for this instantiation. + * Default {@code false}. Publishing the imports does not by itself grant network access — the + * usual {@link #allowNetwork(boolean)} / egress rules still gate what the guest can reach. + */ + public Builder wasiHttp(boolean enable) { + this.wasiHttpEnabled = enable; return this; } public DefaultWasiContext build() { return new DefaultWasiContext(args, env, inheritStdin, inheritStdout, - inheritStderr, preopenDirs); + inheritStderr, preopenDirs, readOnlyPreopenDirs, preopenGuestPaths, + allowNetwork, egressRules, wasiHttpEnabled); } } } diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/Engine.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/Engine.java index 39a5cc4..5620e10 100644 --- a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/Engine.java +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/Engine.java @@ -18,6 +18,34 @@ public interface Engine extends AutoCloseable { Module loadModule(byte[] bytes); + /** + * Loads a WebAssembly module using bytes, sharing the underlying store with + * the given related instance. The returned module can be instantiated with + * a {@link LinkingContext} whose extern-imports reference exports of the + * related instance without triggering store-mismatch failures at the + * provider layer. + * + *

Use when the returned module needs to import memory, table, global, + * or function objects that were created in {@code shareStoreWith}'s store + * — e.g. JIT-emitted-module install, host-plus-plugin composition, + * incremental linking scenarios. See spec at + * {@code doctrine/specs/f-webassembly4j-cross-module-store-sharing-charter-2026-07-26.md}. + * + *

Providers that do not support cross-module store sharing throw + * {@link UnsupportedOperationException} by default. Consumers may probe via + * try/catch or via {@link EngineCapabilities}. + * + * @param bytes the module bytes + * @param shareStoreWith an existing instance whose store the new module + * should share; must be an {@link Instance} produced + * by this same engine + * @return the loaded module bound to {@code shareStoreWith}'s store + */ + default Module loadModule(byte[] bytes, Instance shareStoreWith) { + throw new UnsupportedOperationException( + "Engine.loadModule(byte[], Instance) not implemented by this provider"); + } + /** * Loads a WebAssembly module from a file path. * diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/ExternImportDefinition.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/ExternImportDefinition.java new file mode 100644 index 0000000..f92c575 --- /dev/null +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/ExternImportDefinition.java @@ -0,0 +1,43 @@ +package ai.tegmentum.webassembly4j.api; + +/** + * A typed extern-import definition attached to a {@link LinkingContext}. + * Providers consume these during {@link Module#instantiate(LinkingContext)} + * to wire cross-instance shared imports (memory, table, global, function). + * + *

Common Wasm-embedding patterns: WASI shim wiring (memory sharing across + * a component boundary), function-table plug-in registration, cross-module + * global state, guest-callable host functions passed through as first-class + * imports. + * + *

The concrete implementations are a fixed set matching the WebAssembly + * spec's {@code ExternType} — {@link MemoryImport}, {@link TableImport}, + * {@link GlobalImport}, {@link FunctionImport}. Sealing is not modelled at + * the language level because this package targets Java 8; callers should + * treat the set of implementations as closed and use {@code instanceof} + * dispatch. Add-on Wasm proposals (typed function references, GC ref types) + * may add variants in a future charter. + * + *

Provider support policy: a provider that cannot wire a given variant + * (for example because the underlying value cannot be + * {@link Memory#unwrap(Class) unwrapped} to a native handle) must throw at + * {@code instantiate} time with a clear message identifying the variant and + * import coordinates. Providers that support none of these variants should + * behave as if {@link LinkingContext#externImports()} were empty. + * + * @since 2.5.2 + */ +public interface ExternImportDefinition { + + /** + * The importing module namespace as declared in the WebAssembly + * import statement (e.g. {@code "env"}). + */ + String moduleName(); + + /** + * The import field name as declared in the WebAssembly import statement + * (e.g. {@code "memory"} in {@code (import "env" "memory" ...)}). + */ + String name(); +} diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/FsAccessObserver.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/FsAccessObserver.java new file mode 100644 index 0000000..639fe78 --- /dev/null +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/FsAccessObserver.java @@ -0,0 +1,42 @@ +package ai.tegmentum.webassembly4j.api; + +/** + * Engine-neutral, observe-only callback notified when a component's path-based filesystem access is + * DENIED on the capability-confined (WASI preopen) instantiation path. + * + *

This is a pure observability hook. It CANNOT change enforcement: by the time it fires the + * runtime has already performed the real {@code open-at} / {@code stat-at} and produced the real + * error, which flows back to the guest unchanged regardless of what this method does. Its sole + * purpose is to surface the raw guest-supplied path together with the classified failure reason so + * the host can log, meter, or alert on denied filesystem access that would otherwise be invisible on + * the component/preopen path (where the runtime refuses opens internally, with no host callback on + * the enforcement path itself). + * + *

The interface is intentionally engine-agnostic — it names only {@code java.lang} types and does + * not depend on any particular WebAssembly runtime. A provider that supports a native denial hook + * (for example the wasmtime provider, which bridges this to wasmtime4j's own observer) adapts this + * to its runtime; providers without such a hook simply never invoke it. + * + *

Implementations are invoked synchronously on the guest's calling thread while the denied + * operation is being serviced. They should be fast and must not assume they can influence the + * outcome. A provider is expected to isolate the guest from any exception this method throws. + * + * @since 2.3.0 + */ +@FunctionalInterface +public interface FsAccessObserver { + + /** + * Called when a path-based filesystem operation was refused by WASI enforcement. + * + * @param path the raw guest-supplied path the operation targeted (relative to the descriptor it + * was resolved against; never {@code null}) + * @param operation the operation that was denied, e.g. {@code "open-at"} or {@code "stat-at"} + * @param reason the classified failure reason: the kebab-case WASI {@code error-code} name, e.g. + * {@code "not-permitted"}, {@code "no-entry"}, {@code "access"}; {@code "unknown"} if the + * failure could not be classified to an error code + * @param errorCode the numeric discriminant of {@code reason} within the WASI {@code error-code} + * enum, or {@code -1} when unclassified + */ + void onDenied(String path, String operation, String reason, int errorCode); +} diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/Function.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/Function.java index 21b3f07..0650255 100644 --- a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/Function.java +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/Function.java @@ -1,5 +1,7 @@ package ai.tegmentum.webassembly4j.api; +import java.util.Optional; + public interface Function { ValueType[] parameterTypes(); @@ -63,4 +65,23 @@ default Object[] invokeMultiple(final Object[]... argSets) { } return results; } + + /** + * Provider escape hatch — returns the underlying native function object if + * this instance is backed by one assignable to {@code nativeType}. Analogous + * to {@link Table#unwrap}, {@link Memory#unwrap}, and other api-layer types. + * + *

Consumers should PREFER the engine-agnostic methods on this interface + * (invoke, typed, invokeMultiple, parameterTypes/resultTypes). Use unwrap + * only when a spec-standard capability is unavailable at the api layer — + * for example, extracting a funcref to install into a Table for dynamic + * dispatch scenarios. + * + * @param nativeType the native type to unwrap to + * @param the native type + * @return the underlying native function if assignable, otherwise empty + */ + default Optional unwrap(Class nativeType) { + return Optional.empty(); + } } diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/FunctionImport.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/FunctionImport.java new file mode 100644 index 0000000..f4e5223 --- /dev/null +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/FunctionImport.java @@ -0,0 +1,66 @@ +package ai.tegmentum.webassembly4j.api; + +import java.util.Objects; + +/** + * An {@link ExternImportDefinition} variant that wires a {@link Function} + * instance as an imported function. The importing module must declare + * {@code (import "" "" (func ...))} in its Wasm binary. + * + *

Provider support: as of {@code 2.5.2} the wasmtime4j-provider does + * not implement this variant and will throw + * {@link UnsupportedOperationException} at + * {@link Module#instantiate(LinkingContext) instantiate} time. Wiring + * requires an {@code unwrap(Class)} method on {@link Function}, which is + * scheduled for a follow-up charter. For today's function-import needs use + * {@link DefaultLinkingContext.Builder#addHostFunction(HostFunctionDefinition) + * addHostFunction} instead. + * + * @since 2.5.2 + */ +public final class FunctionImport implements ExternImportDefinition { + + private final String moduleName; + private final String name; + private final Function function; + + public FunctionImport(String moduleName, String name, Function function) { + this.moduleName = Objects.requireNonNull(moduleName, "moduleName"); + this.name = Objects.requireNonNull(name, "name"); + this.function = Objects.requireNonNull(function, "function"); + } + + @Override + public String moduleName() { + return moduleName; + } + + @Override + public String name() { + return name; + } + + public Function function() { + return function; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof FunctionImport)) return false; + FunctionImport other = (FunctionImport) o; + return moduleName.equals(other.moduleName) + && name.equals(other.name) + && function.equals(other.function); + } + + @Override + public int hashCode() { + return Objects.hash(moduleName, name, function); + } + + @Override + public String toString() { + return "FunctionImport[" + moduleName + "::" + name + "]"; + } +} diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/GlobalImport.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/GlobalImport.java new file mode 100644 index 0000000..933cab5 --- /dev/null +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/GlobalImport.java @@ -0,0 +1,61 @@ +package ai.tegmentum.webassembly4j.api; + +import java.util.Objects; + +/** + * An {@link ExternImportDefinition} variant that wires a {@link Global} + * instance as an imported global. The importing module must declare + * {@code (import "" "" (global ...))} in its Wasm binary. + * + *

The provided {@code global} must be {@link Global#unwrap(Class) unwrappable} + * to the target provider's native global type. Cross-provider wiring is not + * supported. + * + * @since 2.5.2 + */ +public final class GlobalImport implements ExternImportDefinition { + + private final String moduleName; + private final String name; + private final Global global; + + public GlobalImport(String moduleName, String name, Global global) { + this.moduleName = Objects.requireNonNull(moduleName, "moduleName"); + this.name = Objects.requireNonNull(name, "name"); + this.global = Objects.requireNonNull(global, "global"); + } + + @Override + public String moduleName() { + return moduleName; + } + + @Override + public String name() { + return name; + } + + public Global global() { + return global; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof GlobalImport)) return false; + GlobalImport other = (GlobalImport) o; + return moduleName.equals(other.moduleName) + && name.equals(other.name) + && global.equals(other.global); + } + + @Override + public int hashCode() { + return Objects.hash(moduleName, name, global); + } + + @Override + public String toString() { + return "GlobalImport[" + moduleName + "::" + name + "]"; + } +} diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/LinkingContext.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/LinkingContext.java index 4ba46cd..4a9c246 100644 --- a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/LinkingContext.java +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/LinkingContext.java @@ -9,7 +9,81 @@ default List hostFunctions() { return Collections.emptyList(); } + /** + * WIT-typed host functions to link into a component's imports. Providers may + * ignore these when instantiating a core module. See {@link WitHostFunction} + * for the value-type contract. + */ + default List witHostFunctions() { + return Collections.emptyList(); + } + default WasiContext wasiContext() { return null; } + + /** + * Optional WASI-NN configuration. When non-null, providers that support + * wasi:nn enable the interface on their component linker before + * instantiation; providers that do not surface + * {@link ai.tegmentum.webassembly4j.api.exception.UnsupportedFeatureException}. + * Defaults to {@code null} (WASI-NN disabled). + */ + default WasiNnConfig wasiNnConfig() { + return null; + } + + /** + * @return {@code true} if this linking context's provider ships a native + * library built with wasi:nn support. Callers should probe before + * invoking {@link DefaultLinkingContext.Builder#enableWasiNn(WasiNnConfig)}; + * a false return means enableWasiNn will throw at instantiation time. + * Default {@code false} so providers that don't override see the safe + * (no-support) answer. + * @since 2.4.1 + */ + default boolean supportsWasiNn() { + return false; + } + + /** + * Typed extern-import definitions to wire during + * {@link Module#instantiate(LinkingContext)}. Providers consume these to + * satisfy imports of memories, tables, globals, and functions from + * external instances (WASI shim wiring, cross-instance shared memory, + * function-table plug-in registration, etc.). + * + *

Providers that do not support a given variant should throw at + * {@code instantiate} time. Providers that do not support any variant + * behave as if this list were empty. + * + * @return an unmodifiable list of extern-import definitions; empty by + * default so existing implementations remain valid. + * @since 2.5.2 + */ + default List externImports() { + return Collections.emptyList(); + } + + /** + * Caller-aware host functions to link into a module's imports. Each + * definition wraps a {@link CallerAwareHostFunction} whose implementation + * receives a {@link Caller} handle for scoped access to the calling + * instance's exports, engine, and store — safe from within a host + * callback frame (see + * {@code doctrine/specs/f-webassembly4j-caller-aware-host-function-charter-2026-07-26.md}). + * + *

Providers that do not support caller-aware host functions throw + * {@link ai.tegmentum.webassembly4j.api.exception.UnsupportedFeatureException} + * at {@code instantiate} time when this list is non-empty; providers + * that support it consume the list alongside {@link #hostFunctions()}, + * {@link #wasiContext()}, and {@link #externImports()}. + * + * @return an unmodifiable list of caller-aware host function definitions; + * empty by default so existing implementations remain valid. + * @since 2.5.2 + */ + default List callerAwareHostFunctions() { + return Collections.emptyList(); + } } diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/MemoryImport.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/MemoryImport.java new file mode 100644 index 0000000..8e960ca --- /dev/null +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/MemoryImport.java @@ -0,0 +1,62 @@ +package ai.tegmentum.webassembly4j.api; + +import java.util.Objects; + +/** + * An {@link ExternImportDefinition} variant that wires a {@link Memory} + * instance as an imported linear memory. The importing module must declare + * {@code (import "" "" (memory ...))} in its Wasm binary. + * + *

The provided {@code memory} must be + * {@link Memory#unwrap(Class) unwrappable} to the target provider's native + * memory type. Cross-provider wiring is not supported: a memory produced by + * one provider cannot in general be handed to another provider's linker. + * + * @since 2.5.2 + */ +public final class MemoryImport implements ExternImportDefinition { + + private final String moduleName; + private final String name; + private final Memory memory; + + public MemoryImport(String moduleName, String name, Memory memory) { + this.moduleName = Objects.requireNonNull(moduleName, "moduleName"); + this.name = Objects.requireNonNull(name, "name"); + this.memory = Objects.requireNonNull(memory, "memory"); + } + + @Override + public String moduleName() { + return moduleName; + } + + @Override + public String name() { + return name; + } + + public Memory memory() { + return memory; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof MemoryImport)) return false; + MemoryImport other = (MemoryImport) o; + return moduleName.equals(other.moduleName) + && name.equals(other.name) + && memory.equals(other.memory); + } + + @Override + public int hashCode() { + return Objects.hash(moduleName, name, memory); + } + + @Override + public String toString() { + return "MemoryImport[" + moduleName + "::" + name + "]"; + } +} diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/NetworkEgressRule.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/NetworkEgressRule.java new file mode 100644 index 0000000..98be109 --- /dev/null +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/NetworkEgressRule.java @@ -0,0 +1,120 @@ +package ai.tegmentum.webassembly4j.api; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.Objects; + +/** + * A provider-agnostic network egress allow-list rule: permit outbound connections to a literal IP or + * CIDR range, on a given port (0 = any), over TCP or UDP. Hostnames are resolved to IPs by the policy + * compiler before a rule is built, so a rule always matches on address, not name — keeping DNS out of + * the guest. Matching is total and fail-closed: any address the rule cannot positively match yields + * {@code false}. + * + *

Egress only by construction: this type expresses connect permission; the provider that + * consumes it must deny every bind/listen use regardless of the rules. + */ +public final class NetworkEgressRule { + + private final String cidrOrIp; + private final int prefixLen; // -1 ⇒ exact IP (no CIDR) + private final byte[] network; // resolved network address bytes, or exact IP bytes + private final int port; // 0 ⇒ any + private final boolean tcp; + + private NetworkEgressRule(String cidrOrIp, int prefixLen, byte[] network, int port, boolean tcp) { + this.cidrOrIp = cidrOrIp; + this.prefixLen = prefixLen; + this.network = network; + this.port = port; + this.tcp = tcp; + } + + /** + * Build a rule from a literal IP ({@code 93.184.216.34}) or CIDR ({@code 10.0.0.0/8}), a port + * (0 = any), and a protocol. Throws {@link IllegalArgumentException} if the address/CIDR is + * malformed — a compile-time error the policy layer surfaces as a hard failure (never a permissive + * default). + */ + public static NetworkEgressRule of(String cidrOrIp, int port, boolean tcp) { + Objects.requireNonNull(cidrOrIp, "cidrOrIp"); + if (port < 0 || port > 65535) { + throw new IllegalArgumentException("port out of range: " + port); + } + String addrPart = cidrOrIp; + int prefix = -1; + int slash = cidrOrIp.indexOf('/'); + if (slash >= 0) { + addrPart = cidrOrIp.substring(0, slash); + try { + prefix = Integer.parseInt(cidrOrIp.substring(slash + 1)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("malformed CIDR prefix: " + cidrOrIp, e); + } + } + byte[] bytes; + try { + bytes = InetAddress.getByName(addrPart).getAddress(); // numeric only (no DNS for a literal) + } catch (UnknownHostException e) { + throw new IllegalArgumentException("malformed IP/CIDR address: " + cidrOrIp, e); + } + if (slash >= 0 && (prefix < 0 || prefix > bytes.length * 8)) { + throw new IllegalArgumentException("CIDR prefix out of range for address: " + cidrOrIp); + } + return new NetworkEgressRule(cidrOrIp, prefix, bytes, port, tcp); + } + + /** + * Whether an outbound {@code addr} over the given protocol is permitted by this rule. Proto must + * match, port must match (or the rule is any-port), and the address must fall within the rule's + * IP/CIDR. Returns {@code false} on any mismatch or resolution anomaly. + */ + public boolean matches(InetSocketAddress addr, boolean isTcp) { + if (addr == null || isTcp != tcp) { + return false; + } + if (port != 0 && addr.getPort() != port) { + return false; + } + InetAddress ia = addr.getAddress(); + if (ia == null) { + return false; // unresolved — fail closed + } + byte[] target = ia.getAddress(); + if (target.length != network.length) { + return false; // v4 vs v6 mismatch + } + int bits = prefixLen < 0 ? target.length * 8 : prefixLen; + int fullBytes = bits / 8; + for (int i = 0; i < fullBytes; i++) { + if (target[i] != network[i]) { + return false; + } + } + int remBits = bits % 8; + if (remBits != 0) { + int mask = 0xFF << (8 - remBits) & 0xFF; + if ((target[fullBytes] & mask) != (network[fullBytes] & mask)) { + return false; + } + } + return true; + } + + /** The protocol this rule permits: true = TCP, false = UDP. */ + public boolean tcp() { + return tcp; + } + + /** The port this rule permits, or 0 for any port. */ + public int port() { + return port; + } + + @Override + public String toString() { + return "NetworkEgressRule{" + cidrOrIp + ":" + (port == 0 ? "*" : port) + + "/" + (tcp ? "tcp" : "udp") + "}"; + } +} diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/Table.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/Table.java index 7112c08..6940c6d 100644 --- a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/Table.java +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/Table.java @@ -1,10 +1,83 @@ package ai.tegmentum.webassembly4j.api; import java.util.Optional; +import java.util.OptionalInt; +/** + * A WebAssembly table exported by (or imported into) an instance. + * + *

Beyond {@link #size()} and the escape hatch {@link #unwrap(Class)}, + * this interface exposes the four WebAssembly-spec table operations + * ({@code table.get}, {@code table.set}, {@code table.grow}, and the + * {@code max} field of {@code table.type()}) so that api-layer consumers + * can perform basic dynamic table management without dropping to a + * provider-specific native type. + * + *

Provider support

+ * + *

{@link #maxSize()}, {@link #get(int)}, {@link #set(int, Object)}, and + * {@link #grow(int, Object)} carry {@code default} bodies that throw + * {@link UnsupportedOperationException}. Provider adapters override the + * ones they can honour. Consumers that need to be portable across + * providers should either catch {@link UnsupportedOperationException} at + * the call site or gate on {@link EngineCapabilities} where an equivalent + * capability flag exists; note that this interface itself does NOT + * currently publish a per-operation capability probe — {@code try/catch} + * remains the portable escape. + * + *

Element wrapping

+ * + *

The {@code Object} return / accept type on {@link #get(int)} and + * {@link #set(int, Object)} is provider-specific until a spec-based + * {@code Ref} abstraction lands (deferred). For {@code funcref}-typed + * tables, a {@code null} slot is legal and denotes "uninitialized". + */ public interface Table { int size(); + /** + * @return the maximum table size, or {@link OptionalInt#empty()} if the + * table has no declared maximum (unbounded). + */ + default OptionalInt maxSize() { + throw new UnsupportedOperationException( + "Table.maxSize() not implemented by this provider"); + } + + /** + * @return the element at {@code index}, or {@code null} if the slot is + * uninitialized. Element wrapping is provider-specific until a + * spec-based {@code Ref} abstraction lands. + * @throws IndexOutOfBoundsException if {@code index >= size()} + */ + default Object get(int index) { + throw new UnsupportedOperationException( + "Table.get(int) not implemented by this provider"); + } + + /** + * Sets the element at {@code index}. {@code value} must be assignable to + * the table's element type; {@code null} is accepted for funcref-typed + * tables as an uninitialized slot. + * + * @throws IndexOutOfBoundsException if {@code index >= size()} + */ + default void set(int index, Object value) { + throw new UnsupportedOperationException( + "Table.set(int, Object) not implemented by this provider"); + } + + /** + * Grows the table by {@code delta} elements initialized to {@code init}. + * + * @return the pre-grow size (which is also the index of the first newly- + * added slot), or -1 if growth would exceed {@link #maxSize()}. + */ + default int grow(int delta, Object init) { + throw new UnsupportedOperationException( + "Table.grow(int, Object) not implemented by this provider"); + } + Optional unwrap(Class nativeType); } diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/TableImport.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/TableImport.java new file mode 100644 index 0000000..81d449a --- /dev/null +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/TableImport.java @@ -0,0 +1,61 @@ +package ai.tegmentum.webassembly4j.api; + +import java.util.Objects; + +/** + * An {@link ExternImportDefinition} variant that wires a {@link Table} + * instance as an imported table. The importing module must declare + * {@code (import "" "" (table ...))} in its Wasm binary. + * + *

The provided {@code table} must be {@link Table#unwrap(Class) unwrappable} + * to the target provider's native table type. Cross-provider wiring is not + * supported. + * + * @since 2.5.2 + */ +public final class TableImport implements ExternImportDefinition { + + private final String moduleName; + private final String name; + private final Table table; + + public TableImport(String moduleName, String name, Table table) { + this.moduleName = Objects.requireNonNull(moduleName, "moduleName"); + this.name = Objects.requireNonNull(name, "name"); + this.table = Objects.requireNonNull(table, "table"); + } + + @Override + public String moduleName() { + return moduleName; + } + + @Override + public String name() { + return name; + } + + public Table table() { + return table; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof TableImport)) return false; + TableImport other = (TableImport) o; + return moduleName.equals(other.moduleName) + && name.equals(other.name) + && table.equals(other.table); + } + + @Override + public int hashCode() { + return Objects.hash(moduleName, name, table); + } + + @Override + public String toString() { + return "TableImport[" + moduleName + "::" + name + "]"; + } +} diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/WasiContext.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/WasiContext.java index 138bb12..010ad92 100644 --- a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/WasiContext.java +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/WasiContext.java @@ -3,6 +3,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; public interface WasiContext { @@ -29,4 +30,70 @@ default boolean inheritStderr() { default List preopenDirs() { return Collections.emptyList(); } + + /** + * The subset of {@link #preopenDirs()} that is granted read-only. A directory listed here is + * preopened with read permissions only — the guest cannot create, write, or delete within it. + * Directories not listed are read-write (the default), preserving prior behaviour. + */ + default List readOnlyPreopenDirs() { + return Collections.emptyList(); + } + + /** + * Optional host→guest path remaps for preopens. For a host directory in {@link #preopenDirs()}, + * the value here is the path the guest sees it at; absent means the guest sees the host path + * unchanged (host == guest). Lets a policy expose, say, host {@code /var/data/app} as guest + * {@code /data} without leaking the host layout. + */ + default Map preopenGuestPaths() { + return Collections.emptyMap(); + } + + /** + * Whether the guest may open outbound network connections. Default {@code false} (deny-by-default); + * only true when a policy grants egress. Even when true, {@link #egressRules()} is the allow-list + * that gates which endpoints are reachable — an empty rule set with {@code allowNetwork()==true} + * denies everything. + */ + default boolean allowNetwork() { + return false; + } + + /** + * The network egress allow-list: outbound connections are permitted only to an endpoint matching one + * of these rules. Empty ⇒ deny-all. The provider must additionally deny every bind/listen use, so + * these rules can only ever grant egress, never ingress. + */ + default List egressRules() { + return Collections.emptyList(); + } + + /** + * An optional observe-only hook notified when the guest's path-based filesystem access is denied on + * the capability-confined (WASI preopen) instantiation path. Empty by default (no observation). When + * present, a provider that supports a native denial hook installs it so a denied {@code open-at} / + * {@code stat-at} surfaces the raw guest path and the classified reason to the host — pure + * observability, it never changes enforcement. Providers without such a hook ignore it. + */ + default Optional fsAccessObserver() { + return Optional.empty(); + } + + /** + * Whether the {@code wasi:http/outgoing-handler + types} import surface should be published on the + * component linker for this instantiation. Default {@code false}: no behavioural change for existing + * embedders. When {@code true}, a provider that supports {@code wasi:http} enables the surface so a + * guest that imports {@code wasi:http/outgoing-handler} can instantiate without a bespoke host call; + * providers that do not support {@code wasi:http} may either surface {@link + * ai.tegmentum.webassembly4j.api.exception.UnsupportedFeatureException} or silently no-op (documented + * per-provider). + * + *

This flag only publishes the imports. It does not itself grant network egress — the + * provider's usual {@link #allowNetwork()} / {@link #egressRules()} gates still apply to any HTTP + * connection the guest attempts. + */ + default boolean wasiHttpEnabled() { + return false; + } } diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/WasiNnConfig.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/WasiNnConfig.java new file mode 100644 index 0000000..fe1ec1b --- /dev/null +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/WasiNnConfig.java @@ -0,0 +1,104 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ai.tegmentum.webassembly4j.api; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Provider-neutral configuration for enabling WASI-NN on a component + * {@link LinkingContext}. Passed to + * {@link DefaultLinkingContext.Builder#enableWasiNn(WasiNnConfig)}; providers that + * support wasi:nn translate this to their native config and register the interface + * on their linker. Providers without wasi:nn support surface + * {@link ai.tegmentum.webassembly4j.api.exception.UnsupportedFeatureException} when + * a config is present. + * + *

An empty config (the {@link #defaults()} instance) requests the provider's + * default backend set — typically ORT/ONNX under the current wasmtime4j pin. That + * mirrors the zero-arg {@code enableWasiNn()} on wasmtime4j's native linker. + * + *

Extensibility. Named-model registry entries are additive; a caller + * that supplies models today gets no behaviour change from a provider that has not + * yet wired the registry through (wasmtime4j {@code 46.0.1-1.4.0} silently ignores + * them). Future additions (preferred backend enumeration, per-graph execution + * targets) land as additive setters. + * + *

Thread safety. Instances are immutable after {@link Builder#build()} + * and safe to share across threads. + */ +public final class WasiNnConfig { + + private static final WasiNnConfig DEFAULTS = new WasiNnConfig(Collections.emptyMap()); + + private final Map namedModels; + + private WasiNnConfig(Map namedModels) { + this.namedModels = Collections.unmodifiableMap(new LinkedHashMap<>(namedModels)); + } + + /** + * Returns a shared, empty configuration equivalent to the zero-arg + * {@code enableWasiNn()} on the underlying provider linker. + */ + public static WasiNnConfig defaults() { + return DEFAULTS; + } + + /** Starts a builder for a customised configuration. */ + public static Builder builder() { + return new Builder(); + } + + /** + * Returns the (read-only) named-model registry entries supplied to this + * config. Providers translate these into their native wasi:nn registry when + * the plumbing is present; entries are silently ignored otherwise. + */ + public Map namedModels() { + return namedModels; + } + + /** Builder for {@link WasiNnConfig}. */ + public static final class Builder { + private final Map namedModels = new LinkedHashMap<>(); + + private Builder() { + } + + /** + * Register a model by name so guests can look it up via + * {@code wasi:nn/graph.load-by-name}. Model bytes are copied at build + * time; the caller's array may be mutated afterwards without affecting + * linker behaviour. + */ + public Builder registerModel(String name, byte[] modelBytes) { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("model name cannot be null or empty"); + } + if (modelBytes == null) { + throw new IllegalArgumentException("modelBytes cannot be null"); + } + namedModels.put(name, modelBytes.clone()); + return this; + } + + public WasiNnConfig build() { + return new WasiNnConfig(namedModels); + } + } +} diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/WitCallableResource.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/WitCallableResource.java new file mode 100644 index 0000000..78b5cd2 --- /dev/null +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/WitCallableResource.java @@ -0,0 +1,73 @@ +package ai.tegmentum.webassembly4j.api; + +import ai.tegmentum.webassembly4j.api.exception.ExecutionException; + +/** + * A callable handle to a Component Model resource returned from a component invocation. + * + *

Component-model resource methods (a WIT {@code resource X { m: func(...) }}) surface as + * ordinary exports whose first parameter is the resource handle. This interface hides that + * mechanical detail — the receiver is bound; the caller writes {@code res.invokeMethod("m", + * args...)} directly. Symmetric with {@link ComponentInstance#invoke(String, Object...)} on + * the argument-shape side. + * + *

Ownership: the receiver is passed by borrow on every call — the resource is not + * consumed and remains valid for further invocations until {@link #close()} drops it. + * Close the handle exactly once per {@code own} received from the component; leaking it + * leaks a slot in the underlying store's resource table, and dropping it twice is an error. + * + *

Implements {@link AutoCloseable} for try-with-resources ergonomics. + * + * @since 2.4.0 + */ +public interface WitCallableResource extends AutoCloseable { + + /** + * Returns the resource type name (e.g. {@code "wasi:io/streams/input-stream"}), if the + * provider tracks it. Providers may return an empty string if the type name was not + * carried across the FFI boundary. + * + * @return the resource type name, or an empty string if unknown + */ + String resourceTypeName(); + + /** + * Invokes a method on this resource with the receiver prepended as arg 0, unwrapping the + * result via the same Java-shape convention as {@link ComponentInstance#invoke(String, + * Object...)}. + * + * @param methodExportName the method's export path (a top-level + * {@code [method].} or an interface-scoped + * {@code #[method].}) + * @param args the additional args to pass after the receiver + * @return the method result unwrapped to a natural Java shape, or {@code null} for void + * methods + * @throws ExecutionException if invocation fails + */ + Object invokeMethod(String methodExportName, Object... args); + + /** + * Typed variant of {@link #invokeMethod} that returns the provider's WIT value tree + * directly. Symmetric with {@link ComponentInstance#invokeWit(String, Object...)}. + * + * @param methodExportName the method's export path + * @param args the additional args to pass after the receiver + * @return the provider's WIT value, or {@code null} for void methods + * @throws ExecutionException if invocation fails + */ + Object invokeMethodWit(String methodExportName, Object... args); + + /** + * Whether this resource has already been {@link #close() closed}. Further calls to + * {@link #invokeMethod} / {@link #invokeMethodWit} on a closed resource throw. + */ + boolean isClosed(); + + /** + * Drops this resource, releasing it in the underlying store and removing it from the + * provider's resource registry. Idempotent — calling {@code close()} twice is a no-op on + * the second call. + */ + @Override + void close(); +} diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/WitHostFunction.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/WitHostFunction.java new file mode 100644 index 0000000..e792548 --- /dev/null +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/WitHostFunction.java @@ -0,0 +1,34 @@ +package ai.tegmentum.webassembly4j.api; + +/** + * Host implementation of a WIT-typed function imported by a component. + * + *

Unlike {@link HostFunction}, whose arguments and results are core-module i32/i64 + * values, WIT host functions exchange richer typed values with the guest — records, + * variants, options, results, lists, etc. + * + *

Concrete value type is provider-specific. The {@code Object[]} entries are + * the values in the provider's native WIT representation: + *

    + *
  • Wasmtime provider: + * {@code ai.tegmentum.wasmtime4j.component.ComponentVal} + * (built via {@code ComponentValFactory.INSTANCE.createXxx(...)}).
  • + *
+ * A cross-provider WIT value abstraction is not yet part of this API; write host + * functions against the provider you plan to use. + * + *

Registered on a {@link LinkingContext} via {@link + * DefaultLinkingContext.Builder#addWitHostFunction(String, WitHostFunction)}. + */ +@FunctionalInterface +public interface WitHostFunction { + + /** + * Invoked when the component calls the imported WIT function. + * + * @param args provider-native WIT value tree, one entry per declared parameter. + * @return provider-native WIT value tree, matching the declared results tuple. + * A function with no return value returns an empty array. + */ + Object[] execute(Object[] args); +} diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/WitHostFunctionDefinition.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/WitHostFunctionDefinition.java new file mode 100644 index 0000000..00f0ff9 --- /dev/null +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/WitHostFunctionDefinition.java @@ -0,0 +1,52 @@ +package ai.tegmentum.webassembly4j.api; + +import java.util.Objects; + +/** + * A WIT-typed host function to be linked into a component's imports. + * + *

Immutable value. Register via {@link + * DefaultLinkingContext.Builder#addWitHostFunction(String, WitHostFunction)} or the + * two-arg overload that takes a pre-built definition. + * + *

The {@code witPath} uses the standard WIT import path form — + * {@code "package:name/interface#function"} for an interface-scoped import (e.g. + * {@code "stardog:webfunction/mapping-dictionary#get"}) or {@code "#function"} for + * a root-level import — matching the format the native component linker expects. + */ +public final class WitHostFunctionDefinition { + + private final String witPath; + private final WitHostFunction function; + + public WitHostFunctionDefinition(final String witPath, final WitHostFunction function) { + this.witPath = Objects.requireNonNull(witPath, "witPath"); + this.function = Objects.requireNonNull(function, "function"); + } + + public String witPath() { + return witPath; + } + + public WitHostFunction function() { + return function; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (!(o instanceof WitHostFunctionDefinition)) return false; + final WitHostFunctionDefinition other = (WitHostFunctionDefinition) o; + return witPath.equals(other.witPath) && function.equals(other.function); + } + + @Override + public int hashCode() { + return Objects.hash(witPath, function); + } + + @Override + public String toString() { + return "WitHostFunctionDefinition{witPath='" + witPath + "'}"; + } +} diff --git a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/config/EngineConfig.java b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/config/EngineConfig.java index 55b7d66..3b11329 100644 --- a/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/config/EngineConfig.java +++ b/webassembly4j-api/src/main/java/ai/tegmentum/webassembly4j/api/config/EngineConfig.java @@ -6,7 +6,7 @@ *

The {@code webassembly4j-api} module is intentionally free of provider-specific * types, so this interface carries no methods. Each provider artifact ships its own * concrete implementation that exposes the tunables for that engine — for example - * {@code WasmtimeConfig}, {@code WamrConfig}, or {@code ChicoryConfig}. Obtain one + * {@code WasmtimeConfig}, {@code WamrConfig}, or {@code EndiveConfig}. Obtain one * from the provider's builder and hand it to * {@link WebAssemblyConfigBuilder#engineConfig(EngineConfig)}:

* diff --git a/webassembly4j-api/src/main/java22/ai/tegmentum/webassembly4j/api/gc/GcStructType.java b/webassembly4j-api/src/main/java22/ai/tegmentum/webassembly4j/api/gc/GcStructType.java index aef4ddb..3b811b5 100644 --- a/webassembly4j-api/src/main/java22/ai/tegmentum/webassembly4j/api/gc/GcStructType.java +++ b/webassembly4j-api/src/main/java22/ai/tegmentum/webassembly4j/api/gc/GcStructType.java @@ -27,6 +27,15 @@ public record Field(String name, GcFieldType type, boolean mutable) { Objects.requireNonNull(type, "type"); } + /** + * Alias for {@link #mutable()} preserved for binary compatibility with the + * base-release class variant, whose accessor is named {@code isMutable()}. + * Consumers compiled against the base variant call {@code isMutable()}; + * without this method the java22 record overlay throws + * {@link NoSuchMethodError} at runtime on Java 22+ JVMs. + */ + public boolean isMutable() { return mutable; } + @Override public String toString() { return (name != null ? name : "") + ": " + type + (mutable ? " (mut)" : ""); diff --git a/webassembly4j-api/src/test/java/ai/tegmentum/webassembly4j/api/DefaultLinkingContextTest.java b/webassembly4j-api/src/test/java/ai/tegmentum/webassembly4j/api/DefaultLinkingContextTest.java index d74d49b..0be5d16 100644 --- a/webassembly4j-api/src/test/java/ai/tegmentum/webassembly4j/api/DefaultLinkingContextTest.java +++ b/webassembly4j-api/src/test/java/ai/tegmentum/webassembly4j/api/DefaultLinkingContextTest.java @@ -76,4 +76,16 @@ void hostFunctionsAreUnmodifiable() { () -> ctx.hostFunctions().add(new HostFunctionDefinition( "m", "f", new ValueType[0], new ValueType[0], args -> null))); } + + @Test + void supportsWasiNnDefaultsToFalse() { + // The provider-agnostic default is false — DefaultLinkingContext does not + // know which provider the caller will hand it to, so it can't report the + // (provider-specific) native truth. Providers that support wasi:nn override + // supportsWasiNn on their Component (e.g. WasmtimeComponentAdapter). This + // test pins the safe default so a caller that forgets to probe the actual + // provider gets a false answer and skips enableWasiNn. + LinkingContext ctx = DefaultLinkingContext.builder().build(); + assertFalse(ctx.supportsWasiNn()); + } } diff --git a/webassembly4j-benchmarks/pom.xml b/webassembly4j-benchmarks/pom.xml index 3c2f970..9ecb05a 100644 --- a/webassembly4j-benchmarks/pom.xml +++ b/webassembly4j-benchmarks/pom.xml @@ -8,7 +8,7 @@ ai.tegmentum.webassembly4j webassembly4j-parent - 1.5.0 + 2.5.2-SNAPSHOT webassembly4j-benchmarks @@ -68,7 +68,7 @@ ai.tegmentum.webassembly4j - chicory4j-provider + endive4j-provider ${project.version} @@ -103,9 +103,9 @@ runtime - + - com.dylibso.chicory + run.endive compiler @@ -154,7 +154,7 @@ org.codehaus.mojo exec-maven-plugin - 3.1.0 + 3.6.4 default-cli @@ -227,7 +227,7 @@ org.apache.maven.plugins maven-shade-plugin - 3.5.1 + 3.6.2 package diff --git a/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/BenchmarkReportGenerator.java b/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/BenchmarkReportGenerator.java index f798696..785030b 100644 --- a/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/BenchmarkReportGenerator.java +++ b/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/BenchmarkReportGenerator.java @@ -12,7 +12,7 @@ public final class BenchmarkReportGenerator { private static final String[] VARIANT_ORDER = { "WASMTIME_JNI", "WASMTIME_PANAMA", "WAMR_JNI", "WAMR_PANAMA", - "WAMR_LLVM_JIT_JNI", "WAMR_LLVM_JIT_PANAMA", "GRAALWASM", "CHICORY" + "WAMR_LLVM_JIT_JNI", "WAMR_LLVM_JIT_PANAMA", "GRAALWASM", "ENDIVE" }; private static final Map VARIANT_LABELS = new LinkedHashMap<>(); @@ -24,7 +24,7 @@ public final class BenchmarkReportGenerator { VARIANT_LABELS.put("WAMR_LLVM_JIT_JNI", "WAMR LLVM JNI"); VARIANT_LABELS.put("WAMR_LLVM_JIT_PANAMA", "WAMR LLVM Panama"); VARIANT_LABELS.put("GRAALWASM", "GraalWasm"); - VARIANT_LABELS.put("CHICORY", "Chicory"); + VARIANT_LABELS.put("ENDIVE", "Endive"); } public static void main(String[] args) throws IOException { diff --git a/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/ChartReportGenerator.java b/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/ChartReportGenerator.java index 5d35ebb..f830e87 100644 --- a/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/ChartReportGenerator.java +++ b/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/ChartReportGenerator.java @@ -29,7 +29,7 @@ public final class ChartReportGenerator { private static final String[] VARIANT_ORDER = { "WASMTIME_JNI", "WASMTIME_PANAMA", "WAMR_JNI", "WAMR_PANAMA", - "WAMR_LLVM_JIT_JNI", "WAMR_LLVM_JIT_PANAMA", "GRAALWASM", "CHICORY" + "WAMR_LLVM_JIT_JNI", "WAMR_LLVM_JIT_PANAMA", "GRAALWASM", "ENDIVE" }; private static final Map VARIANT_LABELS = new LinkedHashMap<>(); @@ -41,7 +41,7 @@ public final class ChartReportGenerator { VARIANT_LABELS.put("WAMR_LLVM_JIT_JNI", "WAMR LLVM JNI"); VARIANT_LABELS.put("WAMR_LLVM_JIT_PANAMA", "WAMR LLVM Panama"); VARIANT_LABELS.put("GRAALWASM", "GraalWasm"); - VARIANT_LABELS.put("CHICORY", "Chicory"); + VARIANT_LABELS.put("ENDIVE", "Endive"); } private ChartReportGenerator() {} diff --git a/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/ConcurrentLoadBenchmark.java b/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/ConcurrentLoadBenchmark.java index c903080..a61d55d 100644 --- a/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/ConcurrentLoadBenchmark.java +++ b/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/ConcurrentLoadBenchmark.java @@ -29,7 +29,7 @@ @Measurement(iterations = 5) public class ConcurrentLoadBenchmark { - @Param({"WASMTIME_JNI", "WASMTIME_PANAMA", "WAMR_JNI", "WAMR_PANAMA", "WAMR_LLVM_JIT_JNI", "WAMR_LLVM_JIT_PANAMA", "GRAALWASM", "CHICORY", "CHICORY_COMPILED"}) + @Param({"WASMTIME_JNI", "WASMTIME_PANAMA", "WAMR_JNI", "WAMR_PANAMA", "WAMR_LLVM_JIT_JNI", "WAMR_LLVM_JIT_PANAMA", "GRAALWASM", "ENDIVE", "ENDIVE_COMPILED"}) private String variant; private Engine engine; diff --git a/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/EndToEndBenchmark.java b/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/EndToEndBenchmark.java index a51cc3e..254d621 100644 --- a/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/EndToEndBenchmark.java +++ b/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/EndToEndBenchmark.java @@ -16,7 +16,7 @@ @Measurement(iterations = 5) public class EndToEndBenchmark { - @Param({"WASMTIME_JNI", "WASMTIME_PANAMA", "WAMR_JNI", "WAMR_PANAMA", "WAMR_LLVM_JIT_JNI", "WAMR_LLVM_JIT_PANAMA", "GRAALWASM", "CHICORY", "CHICORY_COMPILED"}) + @Param({"WASMTIME_JNI", "WASMTIME_PANAMA", "WAMR_JNI", "WAMR_PANAMA", "WAMR_LLVM_JIT_JNI", "WAMR_LLVM_JIT_PANAMA", "GRAALWASM", "ENDIVE", "ENDIVE_COMPILED"}) private String variant; private EngineVariant engineVariant; diff --git a/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/EngineVariant.java b/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/EngineVariant.java index 0e96d06..696cf7d 100644 --- a/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/EngineVariant.java +++ b/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/EngineVariant.java @@ -1,7 +1,7 @@ package ai.tegmentum.webassembly4j.benchmarks; import ai.tegmentum.webassembly4j.api.config.EngineConfig; -import ai.tegmentum.webassembly4j.provider.chicory.config.ChicoryConfig; +import ai.tegmentum.webassembly4j.provider.endive.config.EndiveConfig; import ai.tegmentum.webassembly4j.provider.wamr.config.WamrConfig; import ai.tegmentum.webassembly4j.provider.wamr.config.WamrRunningMode; @@ -13,7 +13,7 @@ *
    *
  • {@code STANDARD} - native runtimes (Wasmtime, WAMR) that don't benefit from GraalVM JVMCI
  • *
  • {@code GRAALVM} - GraalWasm, which requires GraalVM CE/EE for JIT compilation
  • - *
  • {@code ANY} - pure-Java runtimes (Chicory) that benefit from comparison on both JDKs
  • + *
  • {@code ANY} - pure-Java runtimes (Endive) that benefit from comparison on both JDKs
  • *
*/ public enum EngineVariant { @@ -28,9 +28,9 @@ public enum EngineVariant { WAMR_LLVM_JIT_PANAMA("wamr", "wamr4j.runtime", "panama", () -> WamrConfig.builder().runningMode(WamrRunningMode.LLVM_JIT).build(), JdkAffinity.STANDARD), GRAALWASM("graalwasm", null, null, null, JdkAffinity.GRAALVM), - CHICORY("chicory", null, null, null, JdkAffinity.ANY), - CHICORY_COMPILED("chicory", null, null, - () -> ChicoryConfig.builder().executionMode(ChicoryConfig.ExecutionMode.COMPILE).build(), + ENDIVE("endive", null, null, null, JdkAffinity.ANY), + ENDIVE_COMPILED("endive", null, null, + () -> EndiveConfig.builder().executionMode(EndiveConfig.ExecutionMode.COMPILE).build(), JdkAffinity.ANY); public enum JdkAffinity { diff --git a/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/FunctionInvocationBenchmark.java b/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/FunctionInvocationBenchmark.java index d664a55..3e87fc7 100644 --- a/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/FunctionInvocationBenchmark.java +++ b/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/FunctionInvocationBenchmark.java @@ -16,7 +16,7 @@ @Measurement(iterations = 5) public class FunctionInvocationBenchmark { - @Param({"WASMTIME_JNI", "WASMTIME_PANAMA", "WAMR_JNI", "WAMR_PANAMA", "WAMR_LLVM_JIT_JNI", "WAMR_LLVM_JIT_PANAMA", "GRAALWASM", "CHICORY", "CHICORY_COMPILED"}) + @Param({"WASMTIME_JNI", "WASMTIME_PANAMA", "WAMR_JNI", "WAMR_PANAMA", "WAMR_LLVM_JIT_JNI", "WAMR_LLVM_JIT_PANAMA", "GRAALWASM", "ENDIVE", "ENDIVE_COMPILED"}) private String variant; private Engine engine; diff --git a/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/HostFunctionBenchmark.java b/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/HostFunctionBenchmark.java index 5279589..525702d 100644 --- a/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/HostFunctionBenchmark.java +++ b/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/HostFunctionBenchmark.java @@ -18,7 +18,7 @@ @Measurement(iterations = 5) public class HostFunctionBenchmark { - @Param({"WASMTIME_JNI", "WASMTIME_PANAMA", "WAMR_JNI", "WAMR_PANAMA", "WAMR_LLVM_JIT_JNI", "WAMR_LLVM_JIT_PANAMA", "GRAALWASM", "CHICORY", "CHICORY_COMPILED"}) + @Param({"WASMTIME_JNI", "WASMTIME_PANAMA", "WAMR_JNI", "WAMR_PANAMA", "WAMR_LLVM_JIT_JNI", "WAMR_LLVM_JIT_PANAMA", "GRAALWASM", "ENDIVE", "ENDIVE_COMPILED"}) private String variant; private Engine engine; diff --git a/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/InstantiationBenchmark.java b/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/InstantiationBenchmark.java index bc2c162..22d101b 100644 --- a/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/InstantiationBenchmark.java +++ b/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/InstantiationBenchmark.java @@ -17,7 +17,7 @@ @Measurement(iterations = 5) public class InstantiationBenchmark { - @Param({"WASMTIME_JNI", "WASMTIME_PANAMA", "WAMR_JNI", "WAMR_PANAMA", "WAMR_LLVM_JIT_JNI", "WAMR_LLVM_JIT_PANAMA", "GRAALWASM", "CHICORY", "CHICORY_COMPILED"}) + @Param({"WASMTIME_JNI", "WASMTIME_PANAMA", "WAMR_JNI", "WAMR_PANAMA", "WAMR_LLVM_JIT_JNI", "WAMR_LLVM_JIT_PANAMA", "GRAALWASM", "ENDIVE", "ENDIVE_COMPILED"}) private String variant; private Engine engine; diff --git a/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/MemoryOperationsBenchmark.java b/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/MemoryOperationsBenchmark.java index bf21d97..24e277e 100644 --- a/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/MemoryOperationsBenchmark.java +++ b/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/MemoryOperationsBenchmark.java @@ -17,7 +17,7 @@ @Measurement(iterations = 5) public class MemoryOperationsBenchmark { - @Param({"WASMTIME_JNI", "WASMTIME_PANAMA", "WAMR_JNI", "WAMR_PANAMA", "WAMR_LLVM_JIT_JNI", "WAMR_LLVM_JIT_PANAMA", "GRAALWASM", "CHICORY", "CHICORY_COMPILED"}) + @Param({"WASMTIME_JNI", "WASMTIME_PANAMA", "WAMR_JNI", "WAMR_PANAMA", "WAMR_LLVM_JIT_JNI", "WAMR_LLVM_JIT_PANAMA", "GRAALWASM", "ENDIVE", "ENDIVE_COMPILED"}) private String variant; @Param({"64", "1024", "4096", "65536"}) diff --git a/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/ModuleLoadBenchmark.java b/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/ModuleLoadBenchmark.java index d600ac7..5f3aebb 100644 --- a/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/ModuleLoadBenchmark.java +++ b/webassembly4j-benchmarks/src/main/java/ai/tegmentum/webassembly4j/benchmarks/ModuleLoadBenchmark.java @@ -14,7 +14,7 @@ @Measurement(iterations = 5) public class ModuleLoadBenchmark { - @Param({"WASMTIME_JNI", "WASMTIME_PANAMA", "WAMR_JNI", "WAMR_PANAMA", "WAMR_LLVM_JIT_JNI", "WAMR_LLVM_JIT_PANAMA", "GRAALWASM", "CHICORY", "CHICORY_COMPILED"}) + @Param({"WASMTIME_JNI", "WASMTIME_PANAMA", "WAMR_JNI", "WAMR_PANAMA", "WAMR_LLVM_JIT_JNI", "WAMR_LLVM_JIT_PANAMA", "GRAALWASM", "ENDIVE", "ENDIVE_COMPILED"}) private String variant; private Engine engine; diff --git a/webassembly4j-bindgen/docs/design-2.0.md b/webassembly4j-bindgen/docs/design-2.0.md new file mode 100644 index 0000000..fcfa994 --- /dev/null +++ b/webassembly4j-bindgen/docs/design-2.0.md @@ -0,0 +1,175 @@ +# webassembly4j-bindgen 2.0 — SPI-dispatching resource bodies + +- Status: **Landed** +- Date: 2026-07-23 +- Depends on: bindgen 1.x (resource shape emission, world hoisting) +- Consumes: [ADR-005](../../wasm-cm/docs/decisions/ADR-005-webassembly4j-spi.md), + [ADR-006](../../wasm-cm/docs/decisions/ADR-006-wasmos-embedder-wit-surface.md) + +## Problem + +bindgen 1.x emitted the SHAPE half of resource-typed Java bindings — +class declarations, `long handle` fields, `AutoCloseable`, method +signatures, `create(...)` factories for WIT constructors — but every +method body was `throw new UnsupportedOperationException(...)`. + +Under ADR-006 the intent is that every embedder writes exactly one +`wasmos_start` native call and consumes generated bindings for +everything else. With bindings whose bodies throw, an embedder has to +hand-write a bridge from the generated surface to a real runtime +(`WasmosEmbedderConnector` under wasi-p2-rs: 392 LOC). That bridge +subclassed the generated resources, cached its own handles, mapped +error codes by hand, and routed callbacks — everything the ADR says +should NOT exist per-embedder. + +## Decision + +Add an opt-in **runtime-provider SPI** mode to the generator. When +enabled, resource method bodies dispatch through a generated SPI +interface instead of throwing. Each embedder writes one implementation +of that SPI (once) and installs it into a generated registry at +startup; the hand-written per-method bridge disappears. + +### Configuration + +- New `BindgenConfig` field: `runtimeProviderName` (String, nullable). + When null: legacy behavior (bodies throw). When set to `"Foo"`: SPI + mode emits `Foo` + `FooRegistry` and rewrites resource bodies to + dispatch through `FooRegistry.runtime()`. +- New CLI option: `--runtime-provider NAME`. +- Deterministic: `Foo` and `FooRegistry` are emitted with a fixed + method ordering derived from WIT declaration order. Same input WIT + + same `runtimeProviderName` → byte-identical output. + +### Generated surface + +For every resource `X` with methods `m1`, `m2`, ... and the intrinsic +`close`, the SPI interface gets: + +- If `X` has a WIT constructor: `X Create()` — + returns the fully-constructed resource; the impl chooses how to mint + the handle. +- For each instance method `m(P) -> R`: `R (long handle, P)`. +- For each static method `m(P) -> R`: `R (P)`. +- Intrinsic `close`: `void Close(long handle)`. + +Where `` is the resource name in camelCase and `` is the +method name in PascalCase — the concatenation is unambiguous and +avoids collisions across resources that share a method name. + +The generated body for an instance method becomes: + +```java +public WitResult, Error> callExport(String name, List args) { + return EmbedderRuntimeRegistry.runtime() + .runtimeInstanceCallExport(this.handle, name, args); +} +``` + +The static-factory body for a WIT constructor: + +```java +public static HostProvider create(String interfaceName, int numFuncs) { + return EmbedderRuntimeRegistry.runtime() + .hostProviderCreate(interfaceName, numFuncs); +} +``` + +Note that constructor factories return the resource **type** (not a +`long`), so the SPI impl is the sole locus that knows how to wrap a +runtime-issued handle into the generated class. This keeps the +`long handle`-holding constructor `protected` — no external caller +should ever construct a resource with an unbounded raw handle. + +### Registry + +`Registry` is a tiny final class with: + +- `install( provider)` — install the SPI impl; overwrite is + allowed for test setups. +- `runtime()` — return the installed impl or throw `IllegalStateException` + with an actionable message. +- `uninstall()` — for test cleanup. + +The registry uses a `volatile` field, not a full `ThreadLocal` or per- +`RuntimeInstance` provider. Rationale: an embedder installs one +provider for the lifetime of a JVM; testing multiple providers +side-by-side would require the registry to grow (out of scope for +2.0). If that need lands, extending the registry with a thread-local +override is additive. + +### Callback binding + +`EmbedderCallbacks` stays as it was — an interface the embedder +implements. The connector's constructor threads the callback impl into +the SPI provider impl; no generator support is needed. This keeps the +generator free of any "how to wire the imported interface" strategy +choice, which is deliberately embedder-specific (e.g. Endive uses +`LinkingContext`; wasmtime4j uses `Linker`; graalwasm4j uses +`ImportObject`). + +## Consequences + +**On the generator:** + +- One new config field, one new CLI flag. +- One new file (`RuntimeProviderCodeGenerator`) that emits the two SPI + classes. +- One targeted change to `ModernCodeGenerator.generateResource` / + `generateResourceMethod` — bodies now branch on + `config.getRuntimeProviderName()`. Legacy mode still emits the + throwing bodies unchanged. +- No changes to type mapping, WIT parsing, world hoisting, or + determinism guarantees. + +**On the wasmos-embedder-generated module:** + +- Regenerated with `--runtime-provider EmbedderRuntime`. New files: + `EmbedderRuntime.java`, `EmbedderRuntimeRegistry.java`. +- `RuntimeInstance` / `HostProvider` bodies now dispatch (no throws). + +**On WasmosEmbedderConnector (wasi-p2-rs):** + +- Drops from 392 LOC to ~180 LOC. The old subclasses + (`RuntimeInstanceHandle`, `HostProviderHandle`) delete outright — no + override needed once dispatch flows through the registry. The + connector becomes a thin bootstrap wrapper: load the guest bytes, + install the SPI impl, expose convenience `byte[]` variants of the + `List`-taking generated calls. + +**What 2.0 does not do (deferred):** + +- The `introspect` / `verify-world` SPI methods dispatch through the + registry, but the wasm-cm runtime-guest does not yet expose an + export that would service them from an instance handle. The provider + impl throws `UnsupportedOperationException` for those two, with a + precise message. Bindgen has done its job; the shortfall is at the + runtime-guest surface, tracked as a follow-on in ADR-005/ADR-006. +- Own/borrow lifecycle beyond `close()` — resources today are + reference-counted at the runtime-guest level; the generated Java + surface doesn't distinguish `own` from `borrow` in method + signatures (they compile to the same generated Java type). A future + bump can add the distinction if a WIT world exercises it. +- Multiple providers per JVM — the registry is single-slot volatile. + Additive change if needed. + +## Alternatives considered + +**Emit resource bodies that delegate to a `Function`/lambda field.** A +per-instance dispatcher instead of a static registry. Rejected: it +forces the SPI impl to construct every resource with a delegate, +undoing the "just wrap a handle" shape of the generated ctor. + +**Generate a full-fat abstract class the embedder subclasses.** In +this model the resource IS abstract; the concrete class the SPI impl +extends. Rejected: the sandwich test's public API is `RuntimeInstance` +(the generated class). If it were abstract, every call site would need +the concrete subclass in its imports — a subtler leak than "call the +registry once at startup." + +**Emit dispatch bodies unconditionally (no config flag).** Rejected: +consumers who want the SHAPE half only (introspection tools, +type-only tests) would be forced to write a dummy provider. +Backward compatibility with 1.x consumers matters — the throwing +bodies are documented, load-bearing behavior for the shape tests that +already exist. diff --git a/webassembly4j-bindgen/pom.xml b/webassembly4j-bindgen/pom.xml index 7e6f116..227c8b0 100644 --- a/webassembly4j-bindgen/pom.xml +++ b/webassembly4j-bindgen/pom.xml @@ -8,7 +8,7 @@ ai.tegmentum.webassembly4j webassembly4j-parent - 1.5.0 + 2.5.2-SNAPSHOT webassembly4j-bindgen @@ -23,16 +23,16 @@ 11 - 3.8.1 - 3.8.1 - 3.6.4 + 3.9.16 + 3.9.16 + 3.16.0 2.2.1 1.13.0 - 4.7.5 + 4.7.7 @@ -89,7 +89,7 @@ org.apache.maven.plugin-testing maven-plugin-testing-harness - 3.3.0 + 3.5.1 test @@ -100,7 +100,7 @@ org.apache.maven.plugins maven-plugin-plugin - 3.15.2 + 3.16.0 webassembly4j-bindgen true @@ -157,7 +157,7 @@ org.apache.maven.plugins maven-shade-plugin - 3.5.1 + 3.6.2 package @@ -191,7 +191,7 @@ org.apache.maven.plugins maven-invoker-plugin - 3.2.2 + 3.10.1 true ${project.build.directory}/it diff --git a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/BindgenConfig.java b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/BindgenConfig.java index ee801e4..b3b86e2 100644 --- a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/BindgenConfig.java +++ b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/BindgenConfig.java @@ -50,6 +50,7 @@ public final class BindgenConfig { private final boolean generateBuilders; private final boolean generateImplementations; private final boolean generateServiceLoader; + private final String runtimeProviderName; private BindgenConfig(final Builder builder) { this.codeStyle = builder.codeStyle; @@ -61,6 +62,7 @@ private BindgenConfig(final Builder builder) { this.generateBuilders = builder.generateBuilders; this.generateImplementations = builder.generateImplementations; this.generateServiceLoader = builder.generateServiceLoader; + this.runtimeProviderName = builder.runtimeProviderName; } /** @@ -153,6 +155,21 @@ public boolean isGenerateServiceLoader() { return generateServiceLoader; } + /** + * Returns the name of the runtime-provider SPI to emit, or {@code null} to use the legacy + * "throwing-body" resource dispatch (bindgen 1.x behaviour). + * + *

When non-null (bindgen 2.0 SPI-dispatch mode), the generator additionally emits a + * {@code } interface and a {@code Registry} class in the target package; resource + * method bodies dispatch through that registry. See + * {@code docs/design-2.0.md} for the full contract. + * + * @return the runtime-provider interface name, or {@code null} if not configured + */ + public String getRuntimeProviderName() { + return runtimeProviderName; + } + /** * Checks if there are WIT sources configured. * @@ -206,7 +223,8 @@ public boolean equals(final Object obj) { && Objects.equals(packageName, that.packageName) && Objects.equals(outputDirectory, that.outputDirectory) && Objects.equals(witSources, that.witSources) - && Objects.equals(wasmSources, that.wasmSources); + && Objects.equals(wasmSources, that.wasmSources) + && Objects.equals(runtimeProviderName, that.runtimeProviderName); } @Override @@ -220,7 +238,8 @@ public int hashCode() { generateJavadoc, generateBuilders, generateImplementations, - generateServiceLoader); + generateServiceLoader, + runtimeProviderName); } @Override @@ -247,6 +266,8 @@ public String toString() { + generateImplementations + ", generateServiceLoader=" + generateServiceLoader + + ", runtimeProviderName=" + + (runtimeProviderName == null ? "" : runtimeProviderName) + '}'; } @@ -262,6 +283,7 @@ public static final class Builder { private boolean generateBuilders = true; private boolean generateImplementations = true; private boolean generateServiceLoader = true; + private String runtimeProviderName; private Builder() {} @@ -386,6 +408,24 @@ public Builder generateServiceLoader(final boolean generate) { return this; } + /** + * Sets the name of the runtime-provider SPI to emit (bindgen 2.0 mode). + * + *

Pass {@code null} (the default) for legacy behaviour: resource method bodies throw + * {@link UnsupportedOperationException}. Pass a non-null identifier (e.g. {@code + * "EmbedderRuntime"}) to emit a {@code } SPI interface plus a {@code Registry} + * class, and rewrite resource method bodies to dispatch through the registry. + * + *

Determinism is preserved in both modes; SPI method ordering follows WIT declaration order. + * + * @param name the runtime-provider interface name, or {@code null} to disable SPI dispatch + * @return this builder + */ + public Builder runtimeProviderName(final String name) { + this.runtimeProviderName = name; + return this; + } + /** * Builds the BindgenConfig instance. * diff --git a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/CodeGenerator.java b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/CodeGenerator.java index e1652e1..9504945 100644 --- a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/CodeGenerator.java +++ b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/CodeGenerator.java @@ -27,11 +27,10 @@ import ai.tegmentum.webassembly4j.bindgen.model.BindgenParameter; import ai.tegmentum.webassembly4j.bindgen.model.BindgenType; import ai.tegmentum.webassembly4j.bindgen.model.BindgenVariantCase; -import ai.tegmentum.webassembly4j.bindgen.wit.WitInterfaceParser; - +import ai.tegmentum.webassembly4j.bindgen.wit.WitDocument; import ai.tegmentum.webassembly4j.bindgen.wit.WitFunction; -import ai.tegmentum.webassembly4j.bindgen.wit.WitInterfaceDefinition; import ai.tegmentum.webassembly4j.bindgen.wit.WitParameter; +import ai.tegmentum.webassembly4j.bindgen.wit.WitParser; import ai.tegmentum.webassembly4j.bindgen.wit.WitType; import ai.tegmentum.webassembly4j.bindgen.wit.WitTypeCategory; import java.io.IOException; @@ -99,14 +98,22 @@ public CodeGenerator(final BindgenConfig config) throws BindgenException { public List generate() throws BindgenException { List allSources = new ArrayList<>(); - // Process WIT sources + // Process WIT sources. Multiple WIT files that share a package (typical + // for WIT packages split by interface, e.g. wasmos:runtime@0.2.0 across + // 11 files) must be MERGED into one BindgenModel before code emission — + // otherwise per-file javaCodeGenerator invocations each emit their own + // .java, and the last write silently overwrites the + // earlier ones. Merging preserves every interface's resource dispatchers + // in the SPI and every type/interface/function in the output. if (config.hasWitSources()) { LOGGER.info("Processing WIT sources..."); + BindgenModel combined = null; for (Path witPath : config.getWitSources()) { BindgenModel model = parseWitSource(witPath); - List sources = javaCodeGenerator.generate(model); - allSources.addAll(sources); - LOGGER.fine("Generated " + sources.size() + " sources from " + witPath); + combined = (combined == null) ? model : combined.merge(model); + } + if (combined != null) { + allSources.addAll(javaCodeGenerator.generate(combined)); } } @@ -144,7 +151,16 @@ public void generateAndWrite() throws BindgenException { if (config.hasWitSources()) { for (Path witPath : config.getWitSources()) { BindgenModel model = parseWitSource(witPath); - allInterfaces.addAll(model.getInterfaces()); + for (BindgenInterface iface : model.getInterfaces()) { + // Skip interfaces whose Impl + BindingProvider weren't emitted + // upstream (e.g. an interface whose Java class-name collides + // with a nested type). Registering a non-existent provider in + // META-INF/services would fail ServiceLoader.load at runtime. + if (JavaCodeGenerator.shouldSkipInterface(iface)) { + continue; + } + allInterfaces.add(iface); + } } } if (!allInterfaces.isEmpty()) { @@ -168,39 +184,83 @@ private BindgenModel parseWitSource(final Path witPath) throws BindgenException final String packageName = config.getPackageName() != null ? config.getPackageName() : "generated"; - final WitInterfaceParser parser = new WitInterfaceParser(); - final WitInterfaceDefinition definition = parser.parseInterface(witText, packageName); - - final BindgenModel.Builder modelBuilder = - BindgenModel.builder().name(definition.getName()).sourceFile(witPath.toString()); - - // Convert WIT types to bindgen types - final Map convertedTypes = new HashMap<>(); - for (Map.Entry entry : definition.getTypes().entrySet()) { - final BindgenType bindgenType = convertWitType(entry.getKey(), entry.getValue()); - convertedTypes.put(entry.getKey(), bindgenType); + // One-pass native parse: token stream + recursive descent covers + // package / world / multi-interface / use / import / export / + // resource-body / type-alias / record / variant / enum / flags / + // list / option / result / tuple / borrow / own. The old + // regex-parser + WitWorldPreprocessor + WitResourceBodyParser + // stack (task 56) has retired; this call takes its place. + final WitDocument document = WitParser.parse(witText); + + if (document.getInterfaces().isEmpty()) { + throw new BindgenException( + "WIT file " + witPath + " contains no interface or world declarations"); } - // Convert WIT functions to bindgen functions - final List convertedFunctions = new ArrayList<>(); - for (Map.Entry entry : definition.getFunctions().entrySet()) { - final BindgenFunction bindgenFunc = convertWitFunction(entry.getValue(), convertedTypes); - convertedFunctions.add(bindgenFunc); - } + final BindgenModel.Builder modelBuilder = + BindgenModel.builder() + .name(witPath.getFileName().toString()) + .sourceFile(witPath.toString()); + + for (final WitDocument.ParsedInterface pi : document.getInterfaces()) { + // LinkedHashMap preserves WIT declaration order for cross-JVM + // deterministic generation. See the note on + // WitDocument.ParsedInterface for why order is load-bearing. + final Map convertedTypes = new java.util.LinkedHashMap<>(); + for (final Map.Entry entry : pi.getTypes().entrySet()) { + BindgenType bindgenType = convertWitType(entry.getKey(), entry.getValue()); + if (bindgenType.getKind() == BindgenType.Kind.RESOURCE + && pi.getResourceMethods().containsKey(entry.getKey())) { + final BindgenType.Builder rebuilt = + BindgenType.builder() + .name(bindgenType.getName()) + .kind(BindgenType.Kind.RESOURCE) + .documentation(bindgenType.getDocumentation().orElse(null)); + // Resource-body methods flow through the same WitFunction + // translation as interface-level functions — the WitFunctions + // carry the isConstructor / isStatic flags set at parse time, + // and convertWitFunction lifts them onto BindgenFunction. + // Single translation path, no side-channel. + for (final WitFunction m : pi.getResourceMethods().get(entry.getKey())) { + rebuilt.addResourceMethod(convertWitFunction(m, convertedTypes)); + } + bindgenType = rebuilt.build(); + } + convertedTypes.put(entry.getKey(), bindgenType); + } - // Build interface containing types and functions - final BindgenInterface.Builder ifaceBuilder = - BindgenInterface.builder().name(definition.getName()).packageName(packageName); + final List convertedFunctions = new ArrayList<>(); + for (final Map.Entry entry : pi.getFunctions().entrySet()) { + convertedFunctions.add(convertWitFunction(entry.getValue(), convertedTypes)); + } - for (BindgenFunction func : convertedFunctions) { - ifaceBuilder.addFunction(func); - } - for (BindgenType type : convertedTypes.values()) { - ifaceBuilder.addType(type); + if (pi.isWorld()) { + // A hoisted `world X { ... }` block declares types + resources at + // the top level (per ADR-006). It has no functions of its own — + // its `import`/`export` clauses reference already-declared + // interfaces. Emit the types as top-level model types so the + // generator produces one Java file per type/resource without an + // empty carrier interface. Any function that did slip in gets + // added as a top-level function. + for (final BindgenType type : convertedTypes.values()) { + modelBuilder.addType(type); + } + for (final BindgenFunction func : convertedFunctions) { + modelBuilder.addFunction(func); + } + } else { + final BindgenInterface.Builder ifaceBuilder = + BindgenInterface.builder().name(pi.getName()).packageName(packageName); + for (final BindgenFunction func : convertedFunctions) { + ifaceBuilder.addFunction(func); + } + for (final BindgenType type : convertedTypes.values()) { + ifaceBuilder.addType(type); + } + modelBuilder.addInterface(ifaceBuilder.build()); + } } - modelBuilder.addInterface(ifaceBuilder.build()); - return modelBuilder.build(); } catch (final IOException e) { throw new BindgenException("Failed to read WIT file: " + witPath, e); @@ -209,6 +269,108 @@ private BindgenModel parseWitSource(final Path witPath) throws BindgenException } } + /** + * Resolve a raw WIT type expression string (e.g. {@code list}, + * {@code borrow}, {@code result}) + * into a bindgen type. Names that match already-known types resolve as + * references; primitives resolve as primitives; container expressions + * (list/option/result) recurse. Anything else lands as an opaque reference. + */ + private BindgenType resolveTypeExpression( + final String expression, final Map knownTypes) { + String expr = expression.trim(); + // Unwrap borrow and own — resource-lifetime qualifiers are ABI + // concerns; the generated Java surface only cares about the referenced + // type. + while (true) { + if (expr.startsWith("borrow<") && expr.endsWith(">")) { + expr = expr.substring("borrow<".length(), expr.length() - 1).trim(); + } else if (expr.startsWith("own<") && expr.endsWith(">")) { + expr = expr.substring("own<".length(), expr.length() - 1).trim(); + } else { + break; + } + } + if (knownTypes.containsKey(expr)) { + return BindgenType.reference(expr); + } + // Primitive? + if (isKnownPrimitive(expr)) { + return BindgenType.primitive(expr); + } + // list + if (expr.startsWith("list<") && expr.endsWith(">")) { + final String inner = expr.substring(5, expr.length() - 1).trim(); + return BindgenType.list(resolveTypeExpression(inner, knownTypes)); + } + // option + if (expr.startsWith("option<") && expr.endsWith(">")) { + final String inner = expr.substring(7, expr.length() - 1).trim(); + return BindgenType.option(resolveTypeExpression(inner, knownTypes)); + } + // result, result<_, E>, result, result<_>, result + if (expr.equals("result")) { + return BindgenType.result(null, null); + } + if (expr.startsWith("result<") && expr.endsWith(">")) { + final String inner = expr.substring(7, expr.length() - 1).trim(); + if (inner.isEmpty()) { + return BindgenType.result(null, null); + } + final int comma = topLevelComma(inner); + final String okPart = comma < 0 ? inner : inner.substring(0, comma).trim(); + final String errPart = comma < 0 ? "" : inner.substring(comma + 1).trim(); + final BindgenType ok = + okPart.isEmpty() || okPart.equals("_") + ? null + : resolveTypeExpression(okPart, knownTypes); + final BindgenType err = + errPart.isEmpty() || errPart.equals("_") + ? null + : resolveTypeExpression(errPart, knownTypes); + return BindgenType.result(ok, err); + } + return BindgenType.reference(expr); + } + + private static int topLevelComma(final String s) { + int angle = 0; + for (int i = 0; i < s.length(); i++) { + final char c = s.charAt(i); + if (c == '<') { + angle++; + } else if (c == '>') { + angle = Math.max(0, angle - 1); + } else if (c == ',' && angle == 0) { + return i; + } + } + return -1; + } + + private static boolean isKnownPrimitive(final String name) { + switch (name) { + case "bool": + case "s8": + case "s16": + case "s32": + case "s64": + case "u8": + case "u16": + case "u32": + case "u64": + case "f32": + case "f64": + case "float32": + case "float64": + case "char": + case "string": + return true; + default: + return false; + } + } + /** * Converts a WIT type to a bindgen type. * @@ -372,6 +534,13 @@ private BindgenType convertVariantType(final String name, final WitType witType) /** * Converts a WIT function to a bindgen function. * + *

Handles both interface-level functions and resource-body methods — + * both come out of {@link ai.tegmentum.webassembly4j.bindgen.wit.WitParser} + * with the same {@link WitFunction} shape, and the + * {@link WitFunction#isConstructor()} / {@link WitFunction#isStatic()} + * flags flow directly onto the {@link BindgenFunction} without a separate + * translation path. + * * @param witFunction the WIT function * @param knownTypes previously converted types for resolving references * @return the bindgen function @@ -382,6 +551,8 @@ private BindgenFunction convertWitFunction( BindgenFunction.builder() .name(witFunction.getName()) .async(witFunction.isAsync()) + .constructor(witFunction.isConstructor()) + .staticMethod(witFunction.isStatic()) .documentation(witFunction.getDocumentation().orElse(null)); // Convert parameters @@ -418,7 +589,22 @@ private BindgenFunction convertWitFunction( /** * Resolves a type from known types or converts it from WIT. * - * @param name the type name + *

Handles three shapes: + * + *

    + *
  1. {@code name} matches a previously converted type — emit a reference. + *
  2. {@code witType} is a raw type-expression placeholder produced by + * {@link ai.tegmentum.webassembly4j.bindgen.wit.WitParser} for a + * named reference not yet in the type table (a RESOURCE-kinded + * WitType whose name is the raw WIT source of the type expression) + * — resolve via {@link #resolveTypeExpression} so container forms + * like {@code list} / {@code borrow} / {@code result} + * land correctly. + *
  3. Otherwise, fall through to the normal WIT-to-bindgen conversion. + *
+ * + * @param name the type name (or raw expression, for resource-method + * placeholders) * @param witType the WIT type * @param knownTypes previously converted types * @return the bindgen type @@ -429,6 +615,17 @@ private BindgenType resolveOrConvertType( if (known != null) { return BindgenType.reference(name); } + // Detect a WitParser-produced unresolved named-reference placeholder: + // the parser emits every non-primitive named type as + // WitType.resource(rawExpr, rawExpr), so `witType` is RESOURCE-kinded + // with a name matching the untranslated WIT source. Route those through + // resolveTypeExpression so container forms already resolved as + // WitType.list / WitType.option etc. stay resolved via convertWitType, + // while genuine unknown-resource references fall back to + // BindgenType.reference — matching pre-refactor behaviour. + if (witType != null && witType.getKind().getCategory() == WitTypeCategory.RESOURCE) { + return resolveTypeExpression(name, knownTypes); + } return convertWitType(name, witType); } diff --git a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/cli/BindgenCli.java b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/cli/BindgenCli.java index d160630..8bb8c64 100644 --- a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/cli/BindgenCli.java +++ b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/cli/BindgenCli.java @@ -22,10 +22,13 @@ import ai.tegmentum.webassembly4j.bindgen.CodeStyle; import ai.tegmentum.webassembly4j.bindgen.GeneratedSource; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.concurrent.Callable; +import java.util.stream.Stream; import picocli.CommandLine; import picocli.CommandLine.Command; import picocli.CommandLine.Option; @@ -101,6 +104,24 @@ public final class BindgenCli implements Callable { description = "Disable builder generation (for LEGACY style)") private boolean noBuilders; + @Option( + names = {"--no-impl"}, + description = + "Disable *Impl and *BindingProvider generation. Use when the WIT world describes an" + + " abstract embedder surface (bindgen consumers write their own dispatch instead" + + " of using the marshalling-based Impl scaffolding).") + private boolean noImpl; + + @Option( + names = {"--runtime-provider"}, + description = + "Emit a runtime-provider SPI interface + registry (bindgen 2.0 dispatch mode). Resource" + + " method bodies will delegate to the installed provider instead of throwing" + + " UnsupportedOperationException. Give the SPI interface name (e.g." + + " 'EmbedderRuntime'). See docs/design-2.0.md.", + paramLabel = "NAME") + private String runtimeProviderName; + @Option( names = {"--verbose", "-v"}, description = "Enable verbose output") @@ -141,6 +162,9 @@ public Integer call() { .wasmSources(validWasmSources) .generateJavadoc(!noJavadoc) .generateBuilders(!noBuilders) + .generateImplementations(!noImpl) + .generateServiceLoader(!noImpl) + .runtimeProviderName(runtimeProviderName) .build(); if (verbose) { @@ -199,12 +223,18 @@ private List validateSources(final List sources, final String extens } if (source.isDirectory()) { - // Collect all matching files from directory - File[] files = source.listFiles((dir, name) -> name.endsWith(extension)); - if (files != null) { - for (File file : files) { - validPaths.add(file.toPath()); - } + // Walk recursively so a WIT layout that nests dep packages under + // `deps//*.wit` (the wit-bindgen / wac convention) can be picked + // up in one go — `-w wit/deps` pulls in every package the top-level + // WIT `use`s, so combined-model merging (see CodeGenerator.generate) + // has the full type closure available for reference resolution. + try (Stream walk = Files.walk(source.toPath())) { + walk.filter(Files::isRegularFile) + .filter(p -> p.getFileName().toString().endsWith(extension)) + .sorted() + .forEach(validPaths::add); + } catch (IOException e) { + System.err.println("Warning: Failed to walk directory " + source + ": " + e.getMessage()); } } else if (source.getName().endsWith(extension)) { validPaths.add(source.toPath()); diff --git a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/ImplementationCodeGenerator.java b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/ImplementationCodeGenerator.java index 172f85b..1a4df58 100644 --- a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/ImplementationCodeGenerator.java +++ b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/ImplementationCodeGenerator.java @@ -30,16 +30,20 @@ import com.squareup.javapoet.FieldSpec; import com.squareup.javapoet.JavaFile; import com.squareup.javapoet.MethodSpec; +import com.squareup.javapoet.ParameterizedTypeName; import com.squareup.javapoet.TypeName; import com.squareup.javapoet.TypeSpec; import com.squareup.javapoet.TypeVariableName; +import com.squareup.javapoet.WildcardTypeName; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.Set; import javax.lang.model.element.Modifier; /** @@ -163,17 +167,27 @@ GeneratedSource generateBindingProvider(BindgenInterface iface) throws BindgenEx .addModifiers(Modifier.PUBLIC, Modifier.FINAL) .addSuperinterface(WASM_BINDING_PROVIDER_CLASS); - // supports() method + // supports() method — parameter type is Class to match the SPI + // signature verbatim. Emitting a raw `Class` here triggered + // -Xlint:rawtypes; downstream projects that promote raw-type warnings + // to errors could not compile the generated file. + final TypeName classWildcard = + ParameterizedTypeName.get(ClassName.get(Class.class), WildcardTypeName.subtypeOf(Object.class)); providerBuilder.addMethod(MethodSpec.methodBuilder("supports") .addModifiers(Modifier.PUBLIC) .addAnnotation(Override.class) .returns(boolean.class) - .addParameter(ClassName.get(Class.class), "iface") + .addParameter(classWildcard, "iface") .addStatement("return iface == $T.class", interfaceClass) .build()); - // create() method + // create() method — parameter type is Class, matching the SPI's + // ` T create(Class, ...)` exactly. Before the fix the parameter + // was raw `Class`, so under erasure the override signature clashed + // with the SPI's parametrised one ("name clash: same erasure, yet + // neither overrides the other") and javac rejected the class. TypeVariableName typeVar = TypeVariableName.get("T"); + final TypeName classOfT = ParameterizedTypeName.get(ClassName.get(Class.class), typeVar); providerBuilder.addMethod(MethodSpec.methodBuilder("create") .addModifiers(Modifier.PUBLIC) .addAnnotation(Override.class) @@ -182,7 +196,7 @@ GeneratedSource generateBindingProvider(BindgenInterface iface) throws BindgenEx .build()) .addTypeVariable(typeVar) .returns(typeVar) - .addParameter(ClassName.get(Class.class), "iface") + .addParameter(classOfT, "iface") .addParameter(INSTANCE_CLASS, "instance") .addParameter(MODULE_CLASS, "module") .addParameter(ENGINE_CLASS, "engine") @@ -335,14 +349,45 @@ private void generateDirectBody(MethodSpec.Builder method, BindgenFunction funct private void generateMarshallingBody(MethodSpec.Builder method, BindgenFunction function, String fieldName, TypeName returnType, boolean hasReturn) { BindgenType returnBindgenType = hasReturn ? function.getReturnType().get() : null; - boolean complexReturn = MarshallingStrategy.requiresMarshalling(returnBindgenType); + final boolean isResultReturn = + returnBindgenType != null && returnBindgenType.getKind() == BindgenType.Kind.RESULT; - method.addStatement("java.util.List args = new java.util.ArrayList<>()"); + boolean complexReturn = MarshallingStrategy.requiresMarshalling(returnBindgenType); - // Allocate return pointer for complex returns + // The marshalling body used to hard-code the local names `args`, + // `retptr`, and `result`. Any WIT function that happens to declare a + // parameter with one of those names (`embedder-callbacks.host-call` in + // wasmos:host/embedder does exactly this for `args: list`) collided + // with the redeclared local and javac rejected the generated file + // (`variable args is already defined`). Compute a fresh name for every + // internal local against the parameter set. `$` cannot appear in a + // JavaNaming-produced parameter name so the disambiguated form is + // guaranteed unique. + Set reserved = new HashSet<>(); + for (BindgenParameter param : function.getParameters()) { + reserved.add(JavaNaming.toParameterName(param.getName())); + } + String argsLocal = uniqueLocalName("args", reserved); + reserved.add(argsLocal); + String retptrLocal = uniqueLocalName("retptr", reserved); + reserved.add(retptrLocal); + String resultLocal = uniqueLocalName("result", reserved); + reserved.add(resultLocal); + + method.addStatement("java.util.List $L = new java.util.ArrayList<>()", argsLocal); + + // Allocate return pointer for complex returns. For result the + // Canonical-ABI layout is `[tag: u8][pad][payload]` — payload alignment + // is max(align(T), align(E)); total size = payloadOffset + max(size(T), + // size(E)) rounded up to alignment. Falling back to the historical + // (size=8, align=4) reservation for other complex returns is safe for + // (ptr, len) pairs, i64 primitives, and single u32 handles — the + // pre-refactor shape. if (complexReturn) { - method.addStatement("int retptr = marshal.allocator().allocate(8, 4)"); - method.addStatement("args.add(retptr)"); + final int retSize = MarshallingStrategy.retptrSize(returnBindgenType); + final int retAlign = MarshallingStrategy.retptrAlignment(returnBindgenType); + method.addStatement("int $L = marshal.allocator().allocate($L, $L)", retptrLocal, retSize, retAlign); + method.addStatement("$L.add($L)", argsLocal, retptrLocal); } // Lower each parameter @@ -351,28 +396,88 @@ private void generateMarshallingBody(MethodSpec.Builder method, BindgenFunction BindgenType paramType = param.getType(); if (MarshallingStrategy.requiresMarshalling(paramType)) { - method.addCode(MarshallingStrategy.lowerArgument(paramType, paramName, "args")); + method.addCode(MarshallingStrategy.lowerArgument(paramType, paramName, argsLocal)); } else { - method.addStatement("args.add($L)", paramName); + method.addStatement("$L.add($L)", argsLocal, paramName); } } // Invoke if (!hasReturn || complexReturn) { - method.addStatement("$L.invoke(args.toArray())", fieldName); + method.addStatement("$L.invoke($L.toArray())", fieldName, argsLocal); } else { - method.addStatement("Object result = $L.invoke(args.toArray())", fieldName); + method.addStatement("Object $L = $L.invoke($L.toArray())", resultLocal, fieldName, argsLocal); } // Lift return if (hasReturn) { - if (complexReturn) { - CodeBlock liftCode = MarshallingStrategy.liftReturn(returnBindgenType, "retptr"); - method.addStatement("return $L", liftCode); + if (isResultReturn) { + // Canonical-ABI discriminant lift: read tag byte at retptr, branch + // on 0 (ok) vs 1 (err), lift the payload from the aligned payload + // slot, and return a WitResult of the appropriate side. Payload + // types that lack an in-memory lifter throw an + // UnsupportedOperationException in that arm — the dispatch shape is + // real; per-arm marshalling gaps are honest. + method.addCode( + MarshallingStrategy.resultDispatch(returnBindgenType, retptrLocal, function.getName())); + } else if (complexReturn) { + emitLiftedReturn(method, returnBindgenType, retptrLocal, function.getName()); } else { - CodeBlock liftCode = MarshallingStrategy.liftReturn(returnBindgenType, "result"); - method.addStatement("return $L", liftCode); + emitLiftedReturn(method, returnBindgenType, resultLocal, function.getName()); + } + } + } + + /** + * Emit either {@code return ;} when a lifter is wired, or + * {@code throw new UnsupportedOperationException(...)} when + * {@link MarshallingStrategy#liftReturn} bails out. Before this helper the + * two return-lift sites blindly spliced the returned CodeBlock into + * {@code method.addStatement("return $L", liftCode)}, so an unwired kind + * produced {@code return /* TODO: lift REFERENCE */;} — a comment in + * value position that javac rejected with + * {@code incompatible types: missing return value}. Now the throw path + * mirrors {@link MarshallingStrategy#resultDispatch}: compileable body, + * loud runtime failure, actionable message. + */ + private static void emitLiftedReturn( + MethodSpec.Builder method, + BindgenType returnType, + String valueVar, + String methodName) { + final java.util.Optional lifted = + MarshallingStrategy.liftReturn(returnType, valueVar); + if (lifted.isPresent()) { + method.addStatement("return $L", lifted.get()); + return; + } + final String kind = returnType == null ? "null" : returnType.getKind().toString(); + final String name = returnType == null ? "" : " (" + returnType.getName() + ")"; + method.addStatement( + "throw new $T($S)", + UnsupportedOperationException.class, + "TODO(bindgen): lift " + kind + name + " return from " + + methodName + " not yet wired — see MarshallingStrategy#liftReturn"); + } + + /** + * Pick a Java local-variable name that starts with {@code desired} and does + * not appear in {@code reserved}. Disambiguation uses a {@code $}-separated + * numeric suffix, which cannot appear in a name produced by + * {@link JavaNaming#toParameterName(String)} — so the returned name is + * guaranteed distinct from every generated parameter identifier. + */ + private static String uniqueLocalName(String desired, Set reserved) { + if (!reserved.contains(desired)) { + return desired; + } + int suffix = 0; + while (true) { + String candidate = desired + "$" + suffix; + if (!reserved.contains(candidate)) { + return candidate; } + suffix++; } } diff --git a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/JavaCodeGenerator.java b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/JavaCodeGenerator.java index 91a683b..1bc74c1 100644 --- a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/JavaCodeGenerator.java +++ b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/JavaCodeGenerator.java @@ -34,6 +34,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.logging.Logger; import javax.lang.model.element.Modifier; /** @@ -44,6 +45,8 @@ */ public abstract class JavaCodeGenerator { + private static final Logger LOGGER = Logger.getLogger(JavaCodeGenerator.class.getName()); + protected final BindgenConfig config; protected final TypeMappingRegistry typeRegistry; @@ -73,24 +76,140 @@ public List generate(final BindgenModel model) throws BindgenEx sources.add(generateType(type)); } + // Filter out interfaces whose Java class-name would collide with a + // nested type's Java class-name (same package = same simple name). + // WIT permits `interface X { record X { ... } }` — the two land in the + // same Java package as one `X.java`, and the record class overwrites + // the interface, breaking `implements X` in the generated Impl with + // "interface expected here" (ProviderManifestImpl in wasmos:runtime). + // See {@link #shouldSkipInterface}. + final List emittedInterfaces = new ArrayList<>(); + for (BindgenInterface iface : model.getInterfaces()) { + if (shouldSkipInterface(iface)) { + continue; + } + emittedInterfaces.add(iface); + } + // Generate interfaces for (BindgenInterface iface : model.getInterfaces()) { - sources.add(generateInterface(iface)); - // Generate types defined within interfaces + final boolean skipInterface = shouldSkipInterface(iface); + if (!skipInterface) { + sources.add(generateInterface(iface)); + } + // Generate types defined within interfaces regardless — the record + // that caused the collision is still the intended output; only the + // now-empty interface + Impl scaffolding is dropped. for (BindgenType type : iface.getTypes()) { sources.add(generateType(type)); } } // Generate implementation classes if enabled - if (config.isGenerateImplementations() && !model.getInterfaces().isEmpty()) { + if (config.isGenerateImplementations() && !emittedInterfaces.isEmpty()) { ImplementationCodeGenerator implGen = new ImplementationCodeGenerator(config); - sources.addAll(implGen.generate(model.getInterfaces())); + sources.addAll(implGen.generate(emittedInterfaces)); + } + + // Bindgen 2.0: emit runtime-provider SPI + registry when configured. + // The SPI dispatches every method on every resource type in the model + // (world-level + interface-level), so the embedder writes one impl and + // generated resource bodies delegate to the installed provider. + if (config.getRuntimeProviderName() != null && !config.getRuntimeProviderName().isEmpty()) { + final List resources = new ArrayList<>(); + for (BindgenType type : model.getTypes()) { + if (type.getKind() == BindgenType.Kind.RESOURCE) { + resources.add(type); + } + } + for (BindgenInterface iface : model.getInterfaces()) { + for (BindgenType type : iface.getTypes()) { + if (type.getKind() == BindgenType.Kind.RESOURCE) { + resources.add(type); + } + } + } + if (!resources.isEmpty()) { + final RuntimeProviderCodeGenerator spiGen = new RuntimeProviderCodeGenerator(config); + sources.add(spiGen.generateInterface(resources)); + sources.add(spiGen.generateRegistry()); + } } return sources; } + /** + * True when {@code iface} declares a nested type whose Java class-name would + * be identical to the interface's own Java class-name in the target package. + * + *

WIT allows an interface and a nested record/variant/enum to share the + * same identifier — {@code interface provider-manifest { record + * provider-manifest {...} }} in + * {@code wasmos:runtime/provider-manifest.wit} is a live example. Java + * cannot: both compile to {@code ProviderManifest.java} in the same + * package, and the second overwrites the first. The generated Impl then + * fails to compile with "interface expected here" against a class name. + * + *

When the interface has zero functions (it exists purely as a + * namespace for the nested types), skipping the interface + its Impl + + * BindingProvider is a safe, name-preserving fix — the nested types are + * still emitted as top-level classes in the package. + * + *

When the interface has functions, dropping it silently would delete + * user-visible API. This method throws {@link BindgenException} in that + * case so the caller sees a specific error directing them to rename the + * WIT identifier. A follow-on change may promote this to a real rename + * pass (append "Record" / "Variant" to the nested type's Java class); the + * current fix defers that surface change until it becomes required. + * + * @param iface the interface to check + * @return true when the interface should be skipped from code emission + * @throws BindgenException when the collision is present AND the interface + * carries functions (a rename pass, not a skip, is required) + */ + public static boolean shouldSkipInterface(final BindgenInterface iface) throws BindgenException { + final String ifaceClassName = JavaNaming.toClassName(iface.getName()); + BindgenType collidingType = null; + for (BindgenType nested : iface.getTypes()) { + final String nestedClassName = JavaNaming.toClassName(nested.getName()); + if (ifaceClassName.equals(nestedClassName)) { + collidingType = nested; + break; + } + } + if (collidingType == null) { + return false; + } + if (!iface.getFunctions().isEmpty()) { + throw new BindgenException( + "WIT interface '" + + iface.getName() + + "' contains a nested " + + collidingType.getKind().name().toLowerCase() + + " with the same identifier — both would map to '" + + ifaceClassName + + "' in the same Java package, and the interface carries " + + iface.getFunctions().size() + + " function(s) that cannot be silently dropped. Rename either the" + + " interface or the nested type in the WIT source (recommended:" + + " rename the nested type, since it is the source of the collision)."); + } + LOGGER.warning( + "WIT interface '" + + iface.getName() + + "' contains a nested " + + collidingType.getKind().name().toLowerCase() + + " with the same identifier — both would map to '" + + ifaceClassName + + "' in the same Java package. The interface declares no functions," + + " so it is effectively a namespace for the nested type; skipping" + + " the empty interface + its Impl + its BindingProvider. Rename the" + + " nested type in WIT if you want the interface to keep its own" + + " Java class."); + return true; + } + /** * Generates a Java type for the given bindgen type. * @@ -391,6 +510,18 @@ protected TypeName mapType(final BindgenType type) { TypeName okType = type.getOkType().map(this::mapType).orElse(null); TypeName errorType = type.getErrorType().map(this::mapType).orElse(null); return typeRegistry.mapResult(okType, errorType); + case TUPLE: + // Recurse into every element so a nested container (e.g. the + // `list` inside `tuple>`) is mapped to a real + // Java type instead of being echoed back as raw WIT source. Before + // this branch existed, TUPLE fell through to the REFERENCE default + // and mapGeneratedType was handed the whole `tuple<...>` string, + // which JavaPoet embedded verbatim into the emitted field type. + List tupleElements = new ArrayList<>(type.getTupleElements().size()); + for (BindgenType elem : type.getTupleElements()) { + tupleElements.add(mapType(elem)); + } + return typeRegistry.mapTuple(tupleElements); case REFERENCE: return typeRegistry.mapGeneratedType(type.getReferencedTypeName().get()); default: diff --git a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/MarshallingStrategy.java b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/MarshallingStrategy.java index 1588959..d4cf018 100644 --- a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/MarshallingStrategy.java +++ b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/MarshallingStrategy.java @@ -19,6 +19,7 @@ import ai.tegmentum.webassembly4j.bindgen.model.BindgenType; import com.squareup.javapoet.ClassName; import com.squareup.javapoet.CodeBlock; +import java.util.Optional; /** * Maps bindgen types to JavaPoet code fragments for lowering (Java to WASM) @@ -28,6 +29,8 @@ public final class MarshallingStrategy { private static final ClassName STRING_CODEC = ClassName.get("ai.tegmentum.webassembly4j.runtime.marshal", "StringCodec"); + private static final ClassName WIT_RESULT_CLASS = + ClassName.get("ai.tegmentum.webassembly4j.bindgen.wit", "WitResult"); private MarshallingStrategy() { } @@ -91,18 +94,51 @@ public static CodeBlock lowerArgument(BindgenType type, String javaVar, String a } return CodeBlock.of("$L.add($L);\n", argsListVar, javaVar); - case LIST: + case LIST: { + // Pre-fix, this branch was hard-coded to `(byte[]) (Object) javaVar`. + // That works only when the interface signature is `byte[]` — but + // TypeMappingRegistry.mapList maps `list` to `List` (a + // parameterised List, not a Java array), so the emitted cast + // failed with "incompatible types: List cannot be converted + // to byte[]" on every real caller. Choose the lowering shape from + // the WIT element type. + // + // * `list` / `list`: unbox the `List` into a fresh + // byte[], allocate + write, push (ptr, len) — the on-wire + // Canonical-ABI shape for a list. + // * any other list: no in-memory lowerer wired yet. Emit a + // comment + pass the raw List through so the class compiles; + // the invoke will still fail at runtime (Function.invoke can't + // unbox a Java List into WASM operands), but the failure is + // loud and specific instead of a compile-time cast error that + // blocks the whole generated tree. + final BindgenType element = type.getElementType().orElse(null); + final boolean isByteList = + element != null + && element.getKind() == BindgenType.Kind.PRIMITIVE + && isByteWidePrimitive(element.getName()); + if (isByteList) { + return CodeBlock.builder() + .addStatement("byte[] $L_bytes = new byte[$L.size()]", javaVar, javaVar) + .beginControlFlow("for (int $L_i = 0; $L_i < $L.size(); $L_i++)", + javaVar, javaVar, javaVar, javaVar) + .addStatement("$L_bytes[$L_i] = $L.get($L_i)", javaVar, javaVar, javaVar, javaVar) + .endControlFlow() + .addStatement("int $L_ptr = marshal.allocator().allocate($L_bytes.length, 1)", + javaVar, javaVar) + .addStatement("marshal.memory().write($L_ptr, $L_bytes)", javaVar, javaVar) + .addStatement("$L.add($L_ptr)", argsListVar, javaVar) + .addStatement("$L.add($L_bytes.length)", argsListVar, javaVar) + .build(); + } + final String elementKind = + element == null ? "unknown" : element.getKind() + "(" + element.getName() + ")"; return CodeBlock.builder() - .addStatement("byte[] $L_bytes = ($L instanceof byte[]) ? (byte[]) (Object) $L : null", - javaVar, javaVar, javaVar) - .beginControlFlow("if ($L_bytes != null)", javaVar) - .addStatement("int $L_ptr = marshal.allocator().allocate($L_bytes.length, 1)", - javaVar, javaVar) - .addStatement("marshal.memory().write($L_ptr, $L_bytes)", javaVar, javaVar) - .addStatement("$L.add($L_ptr)", argsListVar, javaVar) - .addStatement("$L.add($L_bytes.length)", argsListVar, javaVar) - .endControlFlow() + .add("// TODO(bindgen): lower list<$L> — only list/list has an " + + "in-memory lowerer wired today\n", elementKind) + .addStatement("$L.add($L)", argsListVar, javaVar) .build(); + } case ENUM: return CodeBlock.of("$L.add($L.ordinal());\n", argsListVar, javaVar); @@ -119,63 +155,512 @@ public static CodeBlock lowerArgument(BindgenType type, String javaVar, String a /** * Generates code to lift a WASM return value to a Java value. * + *

For primitives and enums {@code valueVar} names the {@code Object} + * returned by {@code Function.invoke}; for complex types it names the + * return-pointer local in linear memory. + * + *

Returns {@link Optional#empty()} when the bindgen type has no + * in-memory (or in-register) lifter wired yet — references, options, + * variants, records, resources, and functions all fall through today. + * The caller is expected to fall back to a statement-level placeholder + * (an {@link UnsupportedOperationException} throw) in that case rather + * than splicing a comment into an expression position. Before this + * signature change the default arm returned {@code CodeBlock.of("/* TODO + * ... */")}, which the {@code ImplementationCodeGenerator} spliced + * into {@code return $L;} — producing {@code return /* TODO + * ... */;}, a comment where a value is required. That failed with + * {@code incompatible types: missing return value} on every wasmos + * interface returning a REFERENCE or OPTION (InfoImpl.getInfo, + * InfoImpl.provider). Mirrors the {@link #liftFromOffset} contract. + * * @param type the bindgen type - * @param retptrVar the variable name holding the return pointer in linear memory - * @return the lifting code block (an expression) + * @param valueVar the variable name (result Object, or retptr for complex types) + * @return the lifting-expression code block, or empty when unsupported */ - public static CodeBlock liftReturn(BindgenType type, String retptrVar) { + public static Optional liftReturn(BindgenType type, String valueVar) { if (type == null) { - return CodeBlock.of("null"); + return Optional.of(CodeBlock.of("null")); } switch (type.getKind()) { case PRIMITIVE: if ("string".equals(type.getName())) { - return CodeBlock.of("marshal.reader().readString($L)", retptrVar); + return Optional.of(CodeBlock.of("marshal.reader().readString($L)", valueVar)); } - return liftPrimitive(type); + return Optional.of(liftPrimitive(type, valueVar)); case LIST: - return CodeBlock.of("marshal.reader().readBytes($L)", retptrVar); + // list lifts as byte[]; other list element kinds have no + // in-memory lifter yet. Bail to the fallback throw when the element + // isn't u8-shaped so we don't silently mis-lift. + { + final BindgenType element = type.getElementType().orElse(null); + if (element == null + || (element.getKind() == BindgenType.Kind.PRIMITIVE + && isByteWidePrimitive(element.getName()))) { + return Optional.of(CodeBlock.of("marshal.reader().readBytes($L)", valueVar)); + } + return Optional.empty(); + } case ENUM: - return CodeBlock.of("/* TODO: lift enum from ordinal */"); + case FLAGS: + case RECORD: + case VARIANT: + case OPTION: + case RESULT: + case TUPLE: + case REFERENCE: + case RESOURCE: + case FUNCTION: + default: + return Optional.empty(); + } + } + + /** + * Emit an expression that lifts a Canonical-ABI-encoded {@code type} from a + * concrete linear-memory offset expression. Returns {@link Optional#empty()} + * when the payload kind has no in-memory lifter wired up yet (records, + * variants, references, options, nested results). The caller is expected to + * fall back to a statement-level placeholder (an + * {@link UnsupportedOperationException} throw) in that case rather than + * splicing a comment into an expression position. + * + * @param type the bindgen type of the value to lift + * @param offsetExpr a Java expression whose value is the byte offset in + * linear memory where the value's Canonical-ABI representation begins + * @return the lifted-value expression, or empty when unsupported + */ + public static Optional liftFromOffset(final BindgenType type, final String offsetExpr) { + if (type == null) { + return Optional.empty(); + } + switch (type.getKind()) { + case PRIMITIVE: + return Optional.of(liftPrimitiveFromOffset(type, offsetExpr)); + case LIST: + // Canonical ABI encodes list as (ptr, len). readBytes assumes u8; + // that's fine for the wasmos world's list uses but would need + // per-element lifting for list. Guardrail-conservative: bail + // to the fallback throw when the element isn't u8-shaped so we don't + // silently mis-lift. + { + final BindgenType element = type.getElementType().orElse(null); + if (element != null + && element.getKind() == BindgenType.Kind.PRIMITIVE + && isByteWidePrimitive(element.getName())) { + return Optional.of(CodeBlock.of("marshal.reader().readBytes($L)", offsetExpr)); + } + return Optional.empty(); + } + case ENUM: + case FLAGS: + case RECORD: + case VARIANT: + case OPTION: + case RESULT: + case TUPLE: + case REFERENCE: + case RESOURCE: + case FUNCTION: + default: + return Optional.empty(); + } + } + + /** + * Emit the dispatch statements that lift a {@code result} return + * whose Canonical-ABI representation lives at {@code retptrLocal}. The + * emitted block reads the discriminant byte (0 = ok, 1 = err), branches, + * and returns a {@link ai.tegmentum.webassembly4j.bindgen.wit.WitResult} + * of the appropriate side. + * + *

Layout follows the Canonical ABI's variant rule for a 2-case variant: + * + *

+   *   [tag: u8][pad ...][payload: max(size(T), size(E))]
+   * 
+ * + * where {@code payload} is aligned to {@code max(align(T), align(E))} and + * the tag/pad total up to that alignment. + * + *

When a payload type has no in-memory lifter wired up (records, + * references, options, nested results — see {@link #liftFromOffset}), the + * corresponding arm emits an {@link UnsupportedOperationException} throw + * with a specific TODO tag. The dispatch shape is real; the payload gap + * is honest. + * + * @param resultType the RESULT-kinded bindgen return type + * @param retptrLocal the Java identifier that holds the retptr int + * @param methodName the outer method's WIT name — folded into the TODO + * message so post-mortems don't have to grep across the generator + * @return statements that discharge the return with a real dispatch + */ + public static CodeBlock resultDispatch( + final BindgenType resultType, final String retptrLocal, final String methodName) { + if (resultType == null || resultType.getKind() != BindgenType.Kind.RESULT) { + throw new IllegalArgumentException( + "resultDispatch requires a RESULT-kinded bindgen type, got: " + resultType); + } + final BindgenType okType = resultType.getOkType().orElse(null); + final BindgenType errType = resultType.getErrorType().orElse(null); + final int payloadOffset = resultPayloadOffset(okType, errType); + // Unique local for the discriminant tag. The outer generator already + // guarantees `retptrLocal` doesn't collide with a WIT parameter; the + // tag local piggy-backs on the same convention (`$` cannot appear in a + // JavaNaming-produced parameter name). + final String tagLocal = "tag$"; + final CodeBlock.Builder out = CodeBlock.builder(); + out.addStatement("int $L = marshal.reader().readU8($L)", tagLocal, retptrLocal); + out.beginControlFlow("if ($L == 0)", tagLocal); + emitArm(out, okType, retptrLocal, payloadOffset, methodName, /* ok= */ true); + out.nextControlFlow("else"); + emitArm(out, errType, retptrLocal, payloadOffset, methodName, /* ok= */ false); + out.endControlFlow(); + return out.build(); + } + + /** + * Return the linear-memory allocation size (bytes) an in-memory retptr for + * {@code returnType} must reserve. Callers use this to size the + * {@code allocator().allocate(size, align)} pre-call step so the payload + * fits under the Canonical-ABI layout. Types with no computed layout get + * a conservative 8-byte fallback that matches the pre-refactor default. + */ + public static int retptrSize(final BindgenType returnType) { + if (returnType == null) { + return 8; + } + if (returnType.getKind() == BindgenType.Kind.RESULT) { + final BindgenType ok = returnType.getOkType().orElse(null); + final BindgenType err = returnType.getErrorType().orElse(null); + final int payloadOffset = resultPayloadOffset(ok, err); + final int payloadSize = Math.max(sizeOf(ok), sizeOf(err)); + final int total = payloadOffset + payloadSize; + // Round up to alignment so downstream lifts don't read past the slot. + final int align = resultAlignment(ok, err); + return alignUp(total, align); + } + // Non-result complex returns keep the historical 8-byte reservation. + return 8; + } + + /** + * Return the alignment (in bytes) an in-memory retptr for {@code returnType} + * must satisfy. Matches {@link #retptrSize}'s sizing choices. + */ + public static int retptrAlignment(final BindgenType returnType) { + if (returnType == null) { + return 4; + } + if (returnType.getKind() == BindgenType.Kind.RESULT) { + final BindgenType ok = returnType.getOkType().orElse(null); + final BindgenType err = returnType.getErrorType().orElse(null); + return resultAlignment(ok, err); + } + return 4; + } + + private static void emitArm( + final CodeBlock.Builder out, + final BindgenType payloadType, + final String retptrLocal, + final int payloadOffset, + final String methodName, + final boolean okArm) { + final String factory = okArm ? "ok" : "err"; + if (payloadType == null) { + // Unit payload — WitResult..ok(null) / err(null). The + // generator's inferred WitResult / type parameters + // pick up on the null. + out.addStatement("return $T.$L(null)", WIT_RESULT_CLASS, factory); + return; + } + final String offsetExpr = + payloadOffset == 0 ? retptrLocal : "(" + retptrLocal + " + " + payloadOffset + ")"; + final Optional lifted = liftFromOffset(payloadType, offsetExpr); + if (lifted.isPresent()) { + out.addStatement("return $T.$L($L)", WIT_RESULT_CLASS, factory, lifted.get()); + return; + } + // Payload has no in-memory lifter yet. Throw so the class compiles and + // the specific gap is discoverable via the exception message. + out.addStatement( + "throw new $T($S)", + UnsupportedOperationException.class, + "TODO(bindgen): " + + factory + + "-arm payload lift for " + + payloadType.getKind() + + " (" + + payloadType.getName() + + ") not yet wired in result for " + + methodName + + " — see MarshallingStrategy#liftFromOffset"); + } + + /** + * Byte offset of the payload slot within a Canonical-ABI-encoded + * {@code result}. Discriminant is a single byte at offset 0; payload + * is aligned to {@code max(align(T), align(E), 1)}. + */ + static int resultPayloadOffset(final BindgenType okType, final BindgenType errType) { + final int align = resultAlignment(okType, errType); + return alignUp(1, align); + } + + static int resultAlignment(final BindgenType okType, final BindgenType errType) { + return Math.max(1, Math.max(alignOf(okType), alignOf(errType))); + } + private static int alignUp(final int value, final int alignment) { + if (alignment <= 1) { + return value; + } + return (value + alignment - 1) & ~(alignment - 1); + } + + /** + * Canonical-ABI {@code alignment} of {@code type} in bytes. Conservative for + * unknown / not-yet-implemented shapes: returns 4, matching the historical + * retptr alignment and safe for both handles and (ptr, len) pairs. + */ + static int alignOf(final BindgenType type) { + if (type == null) { + return 1; + } + switch (type.getKind()) { + case PRIMITIVE: + return primitiveAlign(type.getName()); + case ENUM: + // ENUM discriminant width follows discriminant_size(n_cases). Data- + // free layout so alignment == size. + return enumDiscriminantWidth(type); + case FLAGS: + // FLAGS pack into 1/2/4-byte ints depending on flag count. + return flagsWidth(type); + case LIST: + case RESOURCE: + case REFERENCE: + // list = (ptr:i32, len:i32), align 4. Resource handles + opaque + // references also lift as i32. + return 4; + case OPTION: + case RESULT: + case RECORD: + case VARIANT: + case TUPLE: + case FUNCTION: default: - return CodeBlock.of("/* TODO: lift $L */", type.getKind()); + return 4; + } + } + + /** + * Canonical-ABI {@code size} of {@code type} in bytes. Conservative for + * unknown / not-yet-implemented shapes: returns 8, which is safe for both + * (ptr, len) pairs and 64-bit primitives. + */ + static int sizeOf(final BindgenType type) { + if (type == null) { + return 0; + } + switch (type.getKind()) { + case PRIMITIVE: + return primitiveSize(type.getName()); + case ENUM: + return enumDiscriminantWidth(type); + case FLAGS: + return flagsWidth(type); + case LIST: + case RESOURCE: + case REFERENCE: + return type.getKind() == BindgenType.Kind.LIST ? 8 : 4; + case OPTION: + case RESULT: + case RECORD: + case VARIANT: + case TUPLE: + case FUNCTION: + default: + return 8; + } + } + + private static int primitiveAlign(final String name) { + switch (name.toLowerCase()) { + case "bool": + case "s8": + case "u8": + return 1; + case "s16": + case "u16": + return 2; + case "s32": + case "u32": + case "i32": + case "f32": + case "float32": + case "char": + return 4; + case "s64": + case "u64": + case "i64": + case "f64": + case "float64": + return 8; + case "string": + return 4; // (ptr:i32, len:i32) + default: + return 4; + } + } + + private static int primitiveSize(final String name) { + switch (name.toLowerCase()) { + case "bool": + case "s8": + case "u8": + return 1; + case "s16": + case "u16": + return 2; + case "s32": + case "u32": + case "i32": + case "f32": + case "float32": + case "char": + return 4; + case "s64": + case "u64": + case "i64": + case "f64": + case "float64": + return 8; + case "string": + return 8; // (ptr:i32, len:i32) + default: + return 4; + } + } + + private static int enumDiscriminantWidth(final BindgenType type) { + final int n = + type.getEnumValues() != null && !type.getEnumValues().isEmpty() + ? type.getEnumValues().size() + : 2; + if (n <= 256) { + return 1; + } + if (n <= 65536) { + return 2; + } + return 4; + } + + private static int flagsWidth(final BindgenType type) { + final int n = + type.getEnumValues() != null && !type.getEnumValues().isEmpty() + ? type.getEnumValues().size() + : 1; + if (n <= 8) { + return 1; + } + if (n <= 16) { + return 2; + } + return 4; + } + + private static boolean isByteWidePrimitive(final String name) { + if (name == null) { + return false; + } + switch (name.toLowerCase()) { + case "s8": + case "u8": + return true; + default: + return false; + } + } + + /** + * Generates code to lift a primitive value from an in-memory offset. Used by + * the result-dispatch generator when the ok/err payload is a primitive. + */ + private static CodeBlock liftPrimitiveFromOffset(final BindgenType type, final String offsetExpr) { + final String name = type.getName().toLowerCase(); + switch (name) { + case "bool": + return CodeBlock.of("marshal.reader().readBool($L)", offsetExpr); + case "s8": + case "u8": + return CodeBlock.of("(byte) marshal.reader().readU8($L)", offsetExpr); + case "s16": + case "u16": + // readI16 doesn't exist yet; sign-extend from a 32-bit read of the + // low 4 bytes. Correct only when the store side zero-extended (u16) + // or sign-extended (s16); Canonical ABI does exactly that for 16-bit + // slots in variant payloads. + return CodeBlock.of("(short) marshal.reader().readI32($L)", offsetExpr); + case "s32": + case "u32": + case "i32": + case "char": + return CodeBlock.of("marshal.reader().readI32($L)", offsetExpr); + case "s64": + case "u64": + case "i64": + return CodeBlock.of("marshal.reader().readI64($L)", offsetExpr); + case "f32": + case "float32": + return CodeBlock.of("marshal.reader().readF32($L)", offsetExpr); + case "f64": + case "float64": + return CodeBlock.of("marshal.reader().readF64($L)", offsetExpr); + case "string": + return CodeBlock.of("marshal.reader().readString($L)", offsetExpr); + default: + return CodeBlock.of("marshal.reader().readI32($L)", offsetExpr); } } /** * Generates code to lift a primitive WASM return value. + * + * @param type the primitive bindgen type + * @param resultVar the local variable holding the boxed {@code Object} returned by invoke */ - private static CodeBlock liftPrimitive(BindgenType type) { + private static CodeBlock liftPrimitive(BindgenType type, String resultVar) { String name = type.getName().toLowerCase(); switch (name) { case "bool": - return CodeBlock.of("((($T) result).intValue() != 0)", Number.class); + return CodeBlock.of("((($T) $L).intValue() != 0)", Number.class, resultVar); case "s8": case "u8": - return CodeBlock.of("(($T) result).byteValue()", Number.class); + return CodeBlock.of("(($T) $L).byteValue()", Number.class, resultVar); case "s16": case "u16": - return CodeBlock.of("(($T) result).shortValue()", Number.class); + return CodeBlock.of("(($T) $L).shortValue()", Number.class, resultVar); case "s32": case "u32": case "i32": - return CodeBlock.of("(($T) result).intValue()", Number.class); + return CodeBlock.of("(($T) $L).intValue()", Number.class, resultVar); case "s64": case "u64": case "i64": - return CodeBlock.of("(($T) result).longValue()", Number.class); + return CodeBlock.of("(($T) $L).longValue()", Number.class, resultVar); case "f32": case "float32": - return CodeBlock.of("(($T) result).floatValue()", Number.class); + return CodeBlock.of("(($T) $L).floatValue()", Number.class, resultVar); case "f64": case "float64": - return CodeBlock.of("(($T) result).doubleValue()", Number.class); + return CodeBlock.of("(($T) $L).doubleValue()", Number.class, resultVar); default: - return CodeBlock.of("result"); + return CodeBlock.of("$L", resultVar); } } } diff --git a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/ModernCodeGenerator.java b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/ModernCodeGenerator.java index 058d99c..ab1972b 100644 --- a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/ModernCodeGenerator.java +++ b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/ModernCodeGenerator.java @@ -18,6 +18,8 @@ import ai.tegmentum.webassembly4j.bindgen.BindgenConfig; import ai.tegmentum.webassembly4j.bindgen.model.BindgenField; +import ai.tegmentum.webassembly4j.bindgen.model.BindgenFunction; +import ai.tegmentum.webassembly4j.bindgen.model.BindgenParameter; import ai.tegmentum.webassembly4j.bindgen.model.BindgenType; import ai.tegmentum.webassembly4j.bindgen.model.BindgenVariantCase; import ai.tegmentum.webassembly4j.bindgen.util.JavaNaming; @@ -205,10 +207,23 @@ protected TypeSpec generateResource(final BindgenType type) { // Add handle field classBuilder.addField(TypeName.LONG, "handle", Modifier.PRIVATE, Modifier.FINAL); - // Add constructor + // Emit the handle-holding constructor. When the WIT resource declared + // its own `constructor(...)`, the visibility depends on dispatch mode: + // * Legacy (throwing bodies): protected — external callers must go + // through the WIT `create(...)` factory; only subclasses (host + // adapters) may wrap an existing raw handle. + // * Bindgen 2.0 SPI-dispatch: public — the SPI impl lives outside + // the generated package and must construct resources to fulfil + // the `WitResult instantiate(...)` / + // `Resource create(...)` contracts. The registry entry-point is + // the enforcement boundary, not the ctor's visibility. + final boolean hasWitConstructor = + type.getResourceMethods().stream().anyMatch(BindgenFunction::isConstructor); + final Modifier ctorVis = + (hasWitConstructor && !isSpiDispatchMode()) ? Modifier.PROTECTED : Modifier.PUBLIC; classBuilder.addMethod( MethodSpec.constructorBuilder() - .addModifiers(Modifier.PUBLIC) + .addModifiers(ctorVis) .addParameter(TypeName.LONG, "handle") .addStatement("this.handle = handle") .build()); @@ -221,14 +236,149 @@ protected TypeSpec generateResource(final BindgenType type) { .addStatement("return this.handle") .build()); - // Add close method - classBuilder.addMethod( + // Add close method. In bindgen 2.0 SPI-dispatch mode the body routes + // through the installed runtime provider; in the legacy mode it stays a + // no-op (matches bindgen 1.x behaviour that host adapters override). + final MethodSpec.Builder closeBuilder = MethodSpec.methodBuilder("close") .addModifiers(Modifier.PUBLIC) - .addAnnotation(Override.class) - .addComment("Resource cleanup - to be implemented by runtime") - .build()); + .addAnnotation(Override.class); + if (isSpiDispatchMode()) { + closeBuilder.addStatement( + "$T.runtime().$L(this.handle)", registryClass(), RuntimeProviderCodeGenerator.dispatchCloseName(type.getName())); + } else { + closeBuilder.addComment("Resource cleanup - to be implemented by runtime"); + } + classBuilder.addMethod(closeBuilder.build()); + + // Emit the resource's WIT-declared methods. In bindgen 2.0 SPI-dispatch + // mode bodies delegate to the installed runtime provider; in the legacy + // mode they throw UnsupportedOperationException with a "not yet wired" + // message, matching bindgen 1.x behaviour that the shape tests pin down. + for (final BindgenFunction method : type.getResourceMethods()) { + classBuilder.addMethod(generateResourceMethod(type.getName(), className, method)); + } return classBuilder.build(); } + + private MethodSpec generateResourceMethod( + final String resourceWitName, final String className, final BindgenFunction method) { + final boolean spi = isSpiDispatchMode(); + if (method.isConstructor()) { + // Emit as a static factory `create` — matches the WIT constructor + // shape (constructs a new resource from primitive args) but doesn't + // collide with Java's ctor rules (return type, no name). + MethodSpec.Builder factory = + MethodSpec.methodBuilder("create") + .addModifiers(Modifier.PUBLIC, Modifier.STATIC) + .returns(ClassName.get(config.getPackageName(), className)); + for (BindgenParameter p : method.getParameters()) { + factory.addParameter(mapType(p.getType()), JavaNaming.toParameterName(p.getName())); + } + if (spi) { + factory.addStatement( + "return $T.runtime().$L($L)", + registryClass(), + RuntimeProviderCodeGenerator.dispatchConstructorName(resourceWitName), + parameterForwardList(method)); + } else { + factory.addStatement( + "throw new $T($S)", + UnsupportedOperationException.class, + "wasmos:host/embedder constructor dispatch not yet wired"); + } + return factory.build(); + } + final String name = JavaNaming.toMethodName(method.getName()); + MethodSpec.Builder mb = MethodSpec.methodBuilder(name).addModifiers(Modifier.PUBLIC); + if (method.isStatic()) { + mb.addModifiers(Modifier.STATIC); + } + for (BindgenParameter p : method.getParameters()) { + mb.addParameter(mapType(p.getType()), JavaNaming.toParameterName(p.getName())); + } + final String dispatchName = + RuntimeProviderCodeGenerator.dispatchMethodName(resourceWitName, method.getName()); + if (method.hasReturnType()) { + mb.returns(mapType(method.getReturnType().get())); + if (spi) { + if (method.isStatic()) { + mb.addStatement( + "return $T.runtime().$L($L)", + registryClass(), + dispatchName, + parameterForwardList(method)); + } else { + mb.addStatement( + "return $T.runtime().$L(this.handle$L)", + registryClass(), + dispatchName, + parameterForwardListPrefixed(method)); + } + } else { + mb.addStatement( + "throw new $T($S)", + UnsupportedOperationException.class, + "wasmos:host/embedder method dispatch not yet wired"); + } + } else { + if (spi) { + if (method.isStatic()) { + mb.addStatement( + "$T.runtime().$L($L)", + registryClass(), + dispatchName, + parameterForwardList(method)); + } else { + mb.addStatement( + "$T.runtime().$L(this.handle$L)", + registryClass(), + dispatchName, + parameterForwardListPrefixed(method)); + } + } else { + mb.addStatement( + "throw new $T($S)", + UnsupportedOperationException.class, + "wasmos:host/embedder method dispatch not yet wired"); + } + } + return mb.build(); + } + + private boolean isSpiDispatchMode() { + final String spi = config.getRuntimeProviderName(); + return spi != null && !spi.isEmpty(); + } + + private ClassName registryClass() { + return ClassName.get(config.getPackageName(), config.getRuntimeProviderName() + "Registry"); + } + + private static String parameterForwardList(final BindgenFunction method) { + final StringBuilder sb = new StringBuilder(); + boolean first = true; + for (BindgenParameter p : method.getParameters()) { + if (!first) { + sb.append(", "); + } + sb.append(JavaNaming.toParameterName(p.getName())); + first = false; + } + return sb.toString(); + } + + /** + * Same as {@link #parameterForwardList} but always prefixed with a leading ", " so it can be + * concatenated after {@code this.handle} in the dispatch call site. Empty when the method takes + * no user-supplied parameters. + */ + private static String parameterForwardListPrefixed(final BindgenFunction method) { + final String list = parameterForwardList(method); + if (list.isEmpty()) { + return ""; + } + return ", " + list; + } } diff --git a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/RuntimeProviderCodeGenerator.java b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/RuntimeProviderCodeGenerator.java new file mode 100644 index 0000000..4d717aa --- /dev/null +++ b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/RuntimeProviderCodeGenerator.java @@ -0,0 +1,274 @@ +/* + * Copyright 2026 Tegmentum AI. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tegmentum.webassembly4j.bindgen.generator; + +import ai.tegmentum.webassembly4j.bindgen.BindgenConfig; +import ai.tegmentum.webassembly4j.bindgen.GeneratedSource; +import ai.tegmentum.webassembly4j.bindgen.model.BindgenFunction; +import ai.tegmentum.webassembly4j.bindgen.model.BindgenParameter; +import ai.tegmentum.webassembly4j.bindgen.model.BindgenType; +import ai.tegmentum.webassembly4j.bindgen.util.JavaNaming; +import com.squareup.javapoet.ClassName; +import com.squareup.javapoet.JavaFile; +import com.squareup.javapoet.MethodSpec; +import com.squareup.javapoet.TypeName; +import com.squareup.javapoet.TypeSpec; +import java.util.List; +import java.util.Objects; +import javax.lang.model.element.Modifier; + +/** + * Emits the bindgen-2.0 runtime-provider SPI: a {@code } interface with one dispatch method + * per resource method, plus a {@code Registry} class that stores and hands out the installed + * provider. + * + *

Method-name convention: {@code } in camelCase, with a {@code long + * handle} parameter prepended to instance-method dispatch signatures. Constructor factories keep + * the resource-typed return so the SPI impl is the sole locus that knows how to wrap a + * runtime-issued raw handle in the generated class. See {@code docs/design-2.0.md}. + * + *

Determinism: methods are emitted in the same order as the model's iteration (WIT declaration + * order), matching the convention already enforced by the resource generator. + */ +public final class RuntimeProviderCodeGenerator { + + private static final String CLOSE_METHOD_NAME = "close"; + + private final BindgenConfig config; + private final TypeMappingRegistry typeRegistry; + + /** + * Creates a new runtime-provider generator. + * + * @param config the bindgen configuration (must have a non-null {@code runtimeProviderName}) + */ + public RuntimeProviderCodeGenerator(final BindgenConfig config) { + this.config = Objects.requireNonNull(config, "config"); + if (config.getRuntimeProviderName() == null || config.getRuntimeProviderName().isEmpty()) { + throw new IllegalArgumentException( + "runtimeProviderName must be set to use RuntimeProviderCodeGenerator"); + } + this.typeRegistry = new TypeMappingRegistry(config.getCodeStyle(), config.getPackageName()); + } + + /** + * Generate the SPI interface for the supplied resource types. + * + * @param resources resource types to expose in the SPI (in declaration order) + * @return the generated interface source + */ + public GeneratedSource generateInterface(final List resources) { + final String spiName = config.getRuntimeProviderName(); + final TypeSpec.Builder ifaceBuilder = + TypeSpec.interfaceBuilder(spiName).addModifiers(Modifier.PUBLIC); + if (config.isGenerateJavadoc()) { + ifaceBuilder.addJavadoc( + "Runtime-provider SPI for the generated bindings.\n\n" + + "

The embedder implements this interface once and installs the implementation\n" + + "via {@link $LRegistry#install($L)}. Generated resource method bodies dispatch\n" + + "through {@link $LRegistry#runtime()} to reach the installed provider.\n\n" + + "

Emitted by webassembly4j-bindgen 2.0 (runtime-provider dispatch mode).\n", + spiName, spiName, spiName); + } + + for (final BindgenType resource : resources) { + emitResourceDispatchers(ifaceBuilder, resource); + } + + final JavaFile file = + JavaFile.builder(config.getPackageName(), ifaceBuilder.build()) + .skipJavaLangImports(true) + .indent(" ") + .build(); + return new GeneratedSource(file); + } + + /** + * Generate the {@code Registry} carrier class. + * + * @return the generated registry source + */ + public GeneratedSource generateRegistry() { + final String spiName = config.getRuntimeProviderName(); + final String registryName = spiName + "Registry"; + final ClassName spiType = ClassName.get(config.getPackageName(), spiName); + + final TypeSpec.Builder classBuilder = + TypeSpec.classBuilder(registryName).addModifiers(Modifier.PUBLIC, Modifier.FINAL); + if (config.isGenerateJavadoc()) { + classBuilder.addJavadoc( + "Single-slot registry for the {@link $L} runtime-provider SPI.\n\n" + + "

Call {@link #install($L)} once from the embedder's startup path, before\n" + + "invoking any generated resource method. Generated bodies reach the installed\n" + + "provider via {@link #runtime()}, which throws {@link IllegalStateException} if\n" + + "nothing was installed.\n\n" + + "

Emitted by webassembly4j-bindgen 2.0.\n", + spiName, spiName); + } + + // Private constructor. + classBuilder.addMethod( + MethodSpec.constructorBuilder().addModifiers(Modifier.PRIVATE).build()); + + // volatile field. + classBuilder.addField( + com.squareup.javapoet.FieldSpec.builder( + spiType, "provider", Modifier.PRIVATE, Modifier.STATIC, Modifier.VOLATILE) + .build()); + + classBuilder.addMethod( + MethodSpec.methodBuilder("install") + .addModifiers(Modifier.PUBLIC, Modifier.STATIC) + .returns(TypeName.VOID) + .addParameter(spiType, "provider") + .addStatement("$T.provider = $T.requireNonNull(provider, $S)", ClassName.get(config.getPackageName(), registryName), ClassName.get(Objects.class), "provider") + .build()); + + classBuilder.addMethod( + MethodSpec.methodBuilder("uninstall") + .addModifiers(Modifier.PUBLIC, Modifier.STATIC) + .returns(TypeName.VOID) + .addStatement("$T.provider = null", ClassName.get(config.getPackageName(), registryName)) + .build()); + + classBuilder.addMethod( + MethodSpec.methodBuilder("runtime") + .addModifiers(Modifier.PUBLIC, Modifier.STATIC) + .returns(spiType) + .addStatement("$T r = provider", spiType) + .beginControlFlow("if (r == null)") + .addStatement( + "throw new $T($S)", + IllegalStateException.class, + spiName + + " not installed; call " + + registryName + + ".install(...) before invoking generated bindings") + .endControlFlow() + .addStatement("return r") + .build()); + + final JavaFile file = + JavaFile.builder(config.getPackageName(), classBuilder.build()) + .skipJavaLangImports(true) + .indent(" ") + .build(); + return new GeneratedSource(file); + } + + /** + * The camel-cased prefix used for every dispatch method on the resource. E.g. + * "runtime-instance" → "runtimeInstance". + */ + static String dispatchMethodPrefix(final String resourceWitName) { + return JavaNaming.toFieldName(resourceWitName); + } + + /** + * The SPI dispatch method name for a resource method. + * + *

E.g. resource "runtime-instance" + method "call-export" → "runtimeInstanceCallExport". + */ + static String dispatchMethodName(final String resourceWitName, final String methodWitName) { + final String prefix = dispatchMethodPrefix(resourceWitName); + final String capMethod = JavaNaming.toClassName(methodWitName); + return prefix + capMethod; + } + + /** The SPI dispatch method name for a WIT constructor. */ + static String dispatchConstructorName(final String resourceWitName) { + return dispatchMethodPrefix(resourceWitName) + "Create"; + } + + /** The SPI dispatch method name for the intrinsic {@code close()}. */ + static String dispatchCloseName(final String resourceWitName) { + return dispatchMethodPrefix(resourceWitName) + "Close"; + } + + private void emitResourceDispatchers(final TypeSpec.Builder ifaceBuilder, final BindgenType resource) { + final String resourceWit = resource.getName(); + final ClassName resourceType = + ClassName.get(config.getPackageName(), JavaNaming.toClassName(resourceWit)); + + for (final BindgenFunction method : resource.getResourceMethods()) { + if (method.isConstructor()) { + ifaceBuilder.addMethod(dispatchForConstructor(resourceWit, method, resourceType)); + } else { + ifaceBuilder.addMethod(dispatchForMethod(resourceWit, method)); + } + } + + // Always emit a close dispatcher for every resource, even if the WIT declared + // no explicit close() method — every generated resource has an + // AutoCloseable.close() body that dispatches through the SPI. + ifaceBuilder.addMethod( + MethodSpec.methodBuilder(dispatchCloseName(resourceWit)) + .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT) + .returns(TypeName.VOID) + .addParameter(TypeName.LONG, "handle") + .build()); + } + + private MethodSpec dispatchForConstructor( + final String resourceWit, final BindgenFunction ctor, final ClassName resourceType) { + final MethodSpec.Builder mb = + MethodSpec.methodBuilder(dispatchConstructorName(resourceWit)) + .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT) + .returns(resourceType); + for (final BindgenParameter p : ctor.getParameters()) { + mb.addParameter(mapType(p.getType()), JavaNaming.toParameterName(p.getName())); + } + return mb.build(); + } + + private MethodSpec dispatchForMethod(final String resourceWit, final BindgenFunction method) { + final MethodSpec.Builder mb = + MethodSpec.methodBuilder(dispatchMethodName(resourceWit, method.getName())) + .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT); + if (!method.isStatic()) { + mb.addParameter(TypeName.LONG, "handle"); + } + for (final BindgenParameter p : method.getParameters()) { + mb.addParameter(mapType(p.getType()), JavaNaming.toParameterName(p.getName())); + } + if (method.hasReturnType()) { + mb.returns(mapType(method.getReturnType().get())); + } else { + mb.returns(TypeName.VOID); + } + return mb.build(); + } + + private TypeName mapType(final BindgenType type) { + switch (type.getKind()) { + case PRIMITIVE: + return typeRegistry.mapWitPrimitive(type.getName()); + case LIST: + return typeRegistry.mapList(mapType(type.getElementType().get())); + case OPTION: + return typeRegistry.mapOption(mapType(type.getElementType().get())); + case RESULT: + final TypeName ok = type.getOkType().map(this::mapType).orElse(null); + final TypeName err = type.getErrorType().map(this::mapType).orElse(null); + return typeRegistry.mapResult(ok, err); + case REFERENCE: + return typeRegistry.mapGeneratedType(type.getReferencedTypeName().get()); + default: + return typeRegistry.mapGeneratedType(type.getName()); + } + } +} diff --git a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/TypeMappingRegistry.java b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/TypeMappingRegistry.java index e09f583..254b493 100644 --- a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/TypeMappingRegistry.java +++ b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/generator/TypeMappingRegistry.java @@ -22,6 +22,7 @@ import com.squareup.javapoet.ParameterizedTypeName; import com.squareup.javapoet.TypeName; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -160,6 +161,28 @@ public TypeName mapResult(final TypeName okType, final TypeName errorType) { /** * Creates a tuple type for a WIT tuple. * + *

Mapping strategy: + * + *

    + *
  • 0 elements → {@link Void} (WIT unit / empty tuple). + *
  • 1 element → the element itself (a single-slot tuple is + * structurally equal to the value; no wrapper needed). + *
  • 2 elements → {@link java.util.Map.Entry Map.Entry<K, V>} — a JDK + * type, so the generated source has zero additional dependency and + * compiles standalone. Covers the {@code list>>} headers shape used by wasmos http/websocket. + *
  • N > 2 elements → parameterized {@code {basePackage}.tuple.TupleN} + * placeholder. bindgen does not yet emit those classes; consumers + * who need larger tuples must supply their own {@code TupleN} + * records in that package. + *
+ * + *

The prior implementation dropped generic parameters (emitting the raw + * {@code Tuple2} class name), which also failed to compile — but silently, + * because bindgen's TUPLE-in-mapType branch was missing, so the raw name + * was never even reached from a record field. Now that mapType routes + * TUPLE through here, the output must be a real parameterized Java type. + * * @param elementTypes the element types * @return the tuple type name */ @@ -171,9 +194,19 @@ public TypeName mapTuple(final List elementTypes) { if (elementTypes.size() == 1) { return elementTypes.get(0); } - // Use a generated tuple type or built-in pair for 2 elements - String tupleName = "Tuple" + elementTypes.size(); - return ClassName.get(basePackage + ".tuple", tupleName); + final TypeName[] boxed = new TypeName[elementTypes.size()]; + for (int i = 0; i < elementTypes.size(); i++) { + boxed[i] = elementTypes.get(i).box(); + } + if (elementTypes.size() == 2) { + return ParameterizedTypeName.get(ClassName.get(Map.Entry.class), boxed); + } + // Use a placeholder tuple type parameterised over the element types. Real + // support for arities > 2 needs a companion TupleN generator; see the + // Javadoc above. Emit parameterised form so at least the type parameters + // don't leak as raw class references when consumers plug in their own. + final String tupleName = "Tuple" + elementTypes.size(); + return ParameterizedTypeName.get(ClassName.get(basePackage + ".tuple", tupleName), boxed); } /** diff --git a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/model/BindgenType.java b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/model/BindgenType.java index 80ee8e2..a549915 100644 --- a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/model/BindgenType.java +++ b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/model/BindgenType.java @@ -69,6 +69,7 @@ public enum Kind { private final BindgenType errorType; private final List tupleElements; private final String referencedTypeName; + private final List resourceMethods; private BindgenType(final Builder builder) { this.name = builder.name; @@ -82,6 +83,8 @@ private BindgenType(final Builder builder) { this.errorType = builder.errorType; this.tupleElements = Collections.unmodifiableList(new ArrayList<>(builder.tupleElements)); this.referencedTypeName = builder.referencedTypeName; + this.resourceMethods = + Collections.unmodifiableList(new ArrayList<>(builder.resourceMethods)); } /** @@ -257,6 +260,20 @@ public Optional getReferencedTypeName() { return Optional.ofNullable(referencedTypeName); } + /** + * Returns the methods attached to this resource type. + * + *

Populated only for {@link Kind#RESOURCE} types parsed from WIT resource + * declarations that carry a constructor / static / instance method body. The + * list is empty for every other kind and for resources declared without a + * body. + * + * @return the ordered list of resource methods (may be empty) + */ + public List getResourceMethods() { + return resourceMethods; + } + /** * Checks if this is a primitive type. * @@ -328,6 +345,7 @@ public static final class Builder { private BindgenType errorType; private List tupleElements = new ArrayList<>(); private String referencedTypeName; + private List resourceMethods = new ArrayList<>(); private Builder() {} @@ -401,6 +419,16 @@ public Builder referencedTypeName(final String typeName) { return this; } + public Builder resourceMethods(final List methods) { + this.resourceMethods = new ArrayList<>(methods); + return this; + } + + public Builder addResourceMethod(final BindgenFunction method) { + this.resourceMethods.add(method); + return this; + } + public BindgenType build() { return new BindgenType(this); } diff --git a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/DefaultWitInterfaceDefinition.java b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/DefaultWitInterfaceDefinition.java new file mode 100644 index 0000000..3349881 --- /dev/null +++ b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/DefaultWitInterfaceDefinition.java @@ -0,0 +1,162 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ai.tegmentum.webassembly4j.bindgen.wit; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Default {@link WitInterfaceDefinition} implementation. + * + *

Extracted from the retired {@code WitInterfaceParser} inner class so both + * the new {@link WitParser}-based path and the legacy {@link WitInterfaceParser} + * facade can share one implementation. Backed by + * {@link LinkedHashMap}-based unmodifiable maps to preserve WIT declaration + * order — cross-JVM deterministic ordering keeps generated Java source + * byte-identical. + */ +final class DefaultWitInterfaceDefinition implements WitInterfaceDefinition { + + private final String name; + private final String version; + private final String packageName; + private final Map functions; + private final Map types; + private final List imports; + private final List exports; + private final String witText; + + DefaultWitInterfaceDefinition( + final String name, + final String version, + final String packageName, + final Map functions, + final Map types, + final List imports, + final List exports, + final String witText) { + this.name = name; + this.version = version; + this.packageName = packageName; + this.functions = Collections.unmodifiableMap(new LinkedHashMap<>(functions)); + this.types = Collections.unmodifiableMap(new LinkedHashMap<>(types)); + this.imports = List.copyOf(imports); + this.exports = List.copyOf(exports); + this.witText = witText; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getVersion() { + return version; + } + + @Override + public String getPackageName() { + return packageName; + } + + @Override + public List getFunctionNames() { + return new ArrayList<>(functions.keySet()); + } + + @Override + public Map getFunctions() { + return functions; + } + + @Override + public List getTypeNames() { + return new ArrayList<>(types.keySet()); + } + + @Override + public Map getTypes() { + return types; + } + + @Override + public Set getDependencies() { + return Set.copyOf(imports); + } + + @Override + public WitCompatibilityResult isCompatibleWith(final WitInterfaceDefinition other) { + if (!name.equals(other.getName())) { + return new WitCompatibilityResult( + false, + "Interface names do not match: " + name + " vs " + other.getName(), + Set.of(), + Set.of()); + } + + final Set ourFunctions = Set.copyOf(functions.keySet()); + final Set otherFunctions = Set.copyOf(other.getFunctionNames()); + final Set ourTypes = Set.copyOf(types.keySet()); + final Set otherTypes = Set.copyOf(other.getTypeNames()); + + final Set missingFunctions = new HashSet<>(ourFunctions); + missingFunctions.removeAll(otherFunctions); + final Set extraFunctions = new HashSet<>(otherFunctions); + extraFunctions.removeAll(ourFunctions); + + final Set missingTypes = new HashSet<>(ourTypes); + missingTypes.removeAll(otherTypes); + final Set extraTypes = new HashSet<>(otherTypes); + extraTypes.removeAll(ourTypes); + + final Set allMissing = new HashSet<>(); + missingFunctions.forEach(f -> allMissing.add("function:" + f)); + missingTypes.forEach(t -> allMissing.add("type:" + t)); + + final Set allExtra = new HashSet<>(); + extraFunctions.forEach(f -> allExtra.add("function:" + f)); + extraTypes.forEach(t -> allExtra.add("type:" + t)); + + final boolean compatible = allMissing.isEmpty() && allExtra.isEmpty(); + final String message = + compatible + ? "Interfaces are compatible" + : "Interfaces differ: missing=" + allMissing + ", extra=" + allExtra; + + return new WitCompatibilityResult(compatible, message, allMissing, allExtra); + } + + @Override + public String getWitText() { + return witText; + } + + @Override + public List getImportNames() { + return imports; + } + + @Override + public List getExportNames() { + return exports; + } +} diff --git a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitDocument.java b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitDocument.java new file mode 100644 index 0000000..190e530 --- /dev/null +++ b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitDocument.java @@ -0,0 +1,151 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ai.tegmentum.webassembly4j.bindgen.wit; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * The parsed result of a full WIT source file. + * + *

Emitted by {@link WitParser}, this replaces the wrapper-chain output + * (retired {@code WitWorldPreprocessor.Interface} + separate resource-body + * parse) with a single flat representation the {@code CodeGenerator} can + * consume directly. Interfaces and worlds are surfaced as + * {@link ParsedInterface} entries in WIT declaration order with + * {@link ParsedInterface#isWorld()} distinguishing world entries from + * plain interfaces. + * + *

Resources declared inside an interface or world body are visible in + * two ways on the same entry: the resource {@code type} lands in + * {@link ParsedInterface#getTypes()} with a {@code RESOURCE} kind, and its + * methods land in {@link ParsedInterface#getResourceMethods()} keyed by + * resource name. This single structural view removes the second + * (WitResourceBodyParser) parse pass the previous stack required. + */ +public final class WitDocument { + + private final Optional packageName; + private final Optional packageVersion; + private final List interfaces; + + WitDocument( + final Optional packageName, + final Optional packageVersion, + final List interfaces) { + this.packageName = packageName; + this.packageVersion = packageVersion; + this.interfaces = List.copyOf(interfaces); + } + + /** The declared {@code package} name (e.g. {@code wasmos:host}), if present. */ + public Optional getPackageName() { + return packageName; + } + + /** The declared {@code @version} after the package name, if present. */ + public Optional getPackageVersion() { + return packageVersion; + } + + /** + * Every interface and world in the source, in WIT declaration order. + * Worlds carry {@link ParsedInterface#isWorld()} true; the caller + * decides whether to hoist their types onto the model root or emit + * them under a Java interface carrier. + */ + public List getInterfaces() { + return interfaces; + } + + /** + * A single interface or hoisted world, with all its types, top-level + * functions, and per-resource method lists resolved. + */ + public static final class ParsedInterface { + + private final String name; + private final boolean world; + private final Map types; + private final Map functions; + private final Map> resourceMethods; + + ParsedInterface( + final String name, + final boolean world, + final Map types, + final Map functions, + final Map> resourceMethods) { + this.name = name; + this.world = world; + // Preserve WIT declaration order across JVMs — see the note on + // WitInterfaceParser.parseTypes for why LinkedHashMap-backed + // unmodifiable maps are load-bearing for byte-identical output. + this.types = Collections.unmodifiableMap(new LinkedHashMap<>(types)); + this.functions = Collections.unmodifiableMap(new LinkedHashMap<>(functions)); + final Map> copy = new LinkedHashMap<>(); + for (final Map.Entry> e : resourceMethods.entrySet()) { + copy.put(e.getKey(), List.copyOf(e.getValue())); + } + this.resourceMethods = Collections.unmodifiableMap(copy); + } + + /** The interface / world name as written in WIT. */ + public String getName() { + return name; + } + + /** + * True when this entry was projected from a {@code world X { ... }} + * block. Worlds' import / export / use clauses reference already- + * declared interfaces and don't contribute new type surface; the + * caller typically hoists a world's types to top-level model types + * rather than wrap them in an empty Java carrier. + */ + public boolean isWorld() { + return world; + } + + /** + * All types declared in the body, keyed by WIT name. Includes + * records, variants, enums, flags, aliases, and resources. + */ + public Map getTypes() { + return types; + } + + /** + * Top-level functions declared directly in the body (not resource + * methods). + */ + public Map getFunctions() { + return functions; + } + + /** + * Methods declared inside each resource body, keyed by resource name. + * {@link WitFunction#isConstructor()} / {@link WitFunction#isStatic()} + * carry the resource-method shape onto the same translation path used + * for top-level functions. + */ + public Map> getResourceMethods() { + return resourceMethods; + } + } +} diff --git a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitFunction.java b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitFunction.java index 4da022b..e6fe4ab 100644 --- a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitFunction.java +++ b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitFunction.java @@ -32,10 +32,15 @@ public final class WitFunction { private final List parameters; private final List returnTypes; private final boolean isAsync; + private final boolean isConstructor; + private final boolean isStatic; private final Optional documentation; /** - * Creates a new WIT function definition. + * Creates a new plain-function WIT function definition. Neither a + * constructor nor a static method; use + * {@link #resourceMethod(String, List, List, boolean, boolean, boolean, Optional)} + * for those. * * @param name the function name * @param parameters the function parameters @@ -49,10 +54,45 @@ public WitFunction( final List returnTypes, final boolean isAsync, final Optional documentation) { + this(name, parameters, returnTypes, isAsync, false, false, documentation); + } + + /** + * Creates a new WIT function definition with resource-method flags carried + * through translation. Used by {@link WitParser} so the constructor / + * static / instance distinction survives into the downstream + * {@link ai.tegmentum.webassembly4j.bindgen.model.BindgenFunction} without + * a separate translation path. + */ + public static WitFunction resourceMethod( + final String name, + final List parameters, + final List returnTypes, + final boolean isConstructor, + final boolean isStatic, + final boolean isAsync, + final Optional documentation) { + if (isConstructor && isStatic) { + throw new IllegalArgumentException( + "resource method " + name + " cannot be both a constructor and a static method"); + } + return new WitFunction(name, parameters, returnTypes, isAsync, isConstructor, isStatic, documentation); + } + + private WitFunction( + final String name, + final List parameters, + final List returnTypes, + final boolean isAsync, + final boolean isConstructor, + final boolean isStatic, + final Optional documentation) { this.name = Objects.requireNonNull(name); this.parameters = List.copyOf(Objects.requireNonNull(parameters)); this.returnTypes = List.copyOf(Objects.requireNonNull(returnTypes)); this.isAsync = isAsync; + this.isConstructor = isConstructor; + this.isStatic = isStatic; this.documentation = Objects.requireNonNull(documentation); } @@ -92,6 +132,26 @@ public boolean isAsync() { return isAsync; } + /** + * True when this WIT function represents a {@code constructor(...)} + * declaration inside a {@code resource} body. Populated during parsing + * (see {@link WitParser}) so downstream translation can lift + * the flag onto {@link ai.tegmentum.webassembly4j.bindgen.model.BindgenFunction} + * without a separate resource-method path. + */ + public boolean isConstructor() { + return isConstructor; + } + + /** + * True when this WIT function represents a {@code name: static func(...)} + * declaration inside a {@code resource} body. Mutually exclusive with + * {@link #isConstructor()}. + */ + public boolean isStatic() { + return isStatic; + } + /** * Gets the function documentation. * diff --git a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitInterfaceParser.java b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitInterfaceParser.java index 9010d5b..f5bdc13 100644 --- a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitInterfaceParser.java +++ b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitInterfaceParser.java @@ -17,830 +17,64 @@ package ai.tegmentum.webassembly4j.bindgen.wit; import ai.tegmentum.webassembly4j.bindgen.BindgenException; - import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Locale; -import java.util.Map; import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; /** - * Parser for WebAssembly Interface Type (WIT) definitions. + * Single-interface facade over {@link WitParser}, kept for backward-compatible + * use by tests and older callers that hand this parser a bare + * {@code interface X { ... }} fragment. * - *

This class provides comprehensive parsing capabilities for WIT interface definitions, - * including type definitions, function signatures, and interface metadata. + *

The regex-based single-interface parser, its {@code WitWorldPreprocessor} + * type-hoisting pass, and its {@code WitResourceBodyParser} resource-shape + * pass are all retired — the token-stream + recursive-descent {@link + * WitParser} covers the full WIT grammar those wrappers used to patch + * (package / world with type-hoisting / multi-interface / use / import / + * export / resource bodies). Everything real should call {@link + * WitParser#parse(String)} directly and consume the returned {@link + * WitDocument}. This facade only survives so the legacy single-interface + * tests keep exercising the same code path via a familiar entrypoint. * * @since 1.0.0 */ public final class WitInterfaceParser { - private static final Pattern INTERFACE_PATTERN = - Pattern.compile("interface\\s+([a-zA-Z0-9_-]+)\\s*\\{([\\s\\S]*)\\}", Pattern.DOTALL); - - // Simplified pattern to prevent ReDoS - uses possessive quantifiers - private static final Pattern FUNCTION_PATTERN = - Pattern.compile("([a-zA-Z0-9_-]++)\\s*:\\s*func\\s*\\(([^)]*)\\)\\s*(?:->\\s*([^;]+))?"); - - private static final Pattern TYPE_PATTERN = - Pattern.compile("type\\s+([a-zA-Z0-9_-]+)\\s*=\\s*([^;]+)", Pattern.DOTALL); - - private static final Pattern RECORD_PATTERN = - Pattern.compile("record\\s*\\{([\\s\\S]*)\\}", Pattern.DOTALL); - - private static final Pattern VARIANT_PATTERN = - Pattern.compile("variant\\s*\\{([\\s\\S]*)\\}", Pattern.DOTALL); - - private static final Pattern ENUM_PATTERN = - Pattern.compile("enum\\s*\\{([\\s\\S]*)\\}", Pattern.DOTALL); - - private static final Pattern FLAGS_PATTERN = - Pattern.compile("flags\\s*\\{([\\s\\S]*)\\}", Pattern.DOTALL); - - // Standalone declarations: `record name { fields }` etc. — the - // canonical WIT syntax used by wit-bindgen output and most - // real-world WIT files. The `type X = ...` alias forms above are - // also valid but less common in practice. Non-greedy braces are - // safe because records/variants/enums/flags don't nest braces in - // their bodies. - private static final Pattern RECORD_DECLARATION_PATTERN = - Pattern.compile("record\\s+([a-zA-Z0-9_-]+)\\s*\\{([\\s\\S]*?)\\}", Pattern.DOTALL); - - private static final Pattern VARIANT_DECLARATION_PATTERN = - Pattern.compile("variant\\s+([a-zA-Z0-9_-]+)\\s*\\{([\\s\\S]*?)\\}", Pattern.DOTALL); - - private static final Pattern ENUM_DECLARATION_PATTERN = - Pattern.compile("enum\\s+([a-zA-Z0-9_-]+)\\s*\\{([\\s\\S]*?)\\}", Pattern.DOTALL); - - private static final Pattern FLAGS_DECLARATION_PATTERN = - Pattern.compile("flags\\s+([a-zA-Z0-9_-]+)\\s*\\{([\\s\\S]*?)\\}", Pattern.DOTALL); - - private static final int MAX_WIT_TEXT_LENGTH = 1024 * 1024; // 1MB limit - - private final Map typeCache; - - /** Creates a new WIT interface parser. */ - public WitInterfaceParser() { - this.typeCache = new HashMap<>(); - initializePrimitiveTypes(); - } + /** Creates a new WIT interface parser facade. */ + public WitInterfaceParser() {} /** - * Parses a WIT interface definition from text. + * Parse a single WIT interface fragment. * - * @param witText the WIT interface text - * @param packageName the package name + * @param witText the WIT source — must contain exactly one + * {@code interface X { ... }} block + * @param packageName the package name to attach to the returned + * definition * @return the parsed interface definition - * @throws BindgenException if parsing fails + * @throws BindgenException if parsing fails or the fragment doesn't + * resolve to exactly one interface */ public WitInterfaceDefinition parseInterface(final String witText, final String packageName) throws BindgenException { Objects.requireNonNull(witText, "witText"); Objects.requireNonNull(packageName, "packageName"); - - // Prevent ReDoS by rejecting excessively large WIT definitions - if (witText.length() > MAX_WIT_TEXT_LENGTH) { - throw new BindgenException( - "WIT definition exceeds maximum length of " + MAX_WIT_TEXT_LENGTH + " characters"); - } - - try { - // Parse the interface declaration - final Matcher interfaceMatcher = INTERFACE_PATTERN.matcher(witText); - if (!interfaceMatcher.find()) { - throw new BindgenException("Invalid WIT interface: no interface declaration found"); - } - - final String interfaceName = interfaceMatcher.group(1); - final String interfaceBody = interfaceMatcher.group(2); - - // Parse types, functions, and exports - final Map types = parseTypes(interfaceBody); - final Map functions = parseFunctions(interfaceBody, types); - final List exports = parseExports(interfaceBody); - - return new WitInterfaceDefinitionImpl( - interfaceName, - "1.0", // Default version - packageName, - functions, - types, - List.of(), - exports, - witText); - - } catch (final Exception e) { - throw new BindgenException("Failed to parse WIT interface", e); - } - } - - /** - * Parses type definitions from WIT interface body. - * - * @param interfaceBody the interface body text - * @return map of type name to type definition - * @throws BindgenException if parsing fails - */ - private Map parseTypes(final String interfaceBody) throws BindgenException { - final Map types = new HashMap<>(); - - // Pass 1: standalone declarations (`record name { ... }`, - // `variant name { ... }`, `enum name { ... }`, - // `flags name { ... }`). This is the canonical WIT syntax — - // running it first lets the second pass resolve aliases that - // reference them. - collectStandalone( - RECORD_DECLARATION_PATTERN, - interfaceBody, - types, - (name, body) -> parseRecordType(name, body)); - collectStandalone( - VARIANT_DECLARATION_PATTERN, - interfaceBody, - types, - (name, body) -> parseVariantType(name, body)); - collectStandalone( - ENUM_DECLARATION_PATTERN, - interfaceBody, - types, - (name, body) -> parseEnumType(name, body)); - collectStandalone( - FLAGS_DECLARATION_PATTERN, - interfaceBody, - types, - (name, body) -> parseFlagsType(name, body)); - - // Pass 2: `type X = ...;` alias declarations. - final Matcher typeMatcher = TYPE_PATTERN.matcher(interfaceBody); - - while (typeMatcher.find()) { - final String typeName = typeMatcher.group(1); - final String typeDefinition = typeMatcher.group(2).trim(); - - final WitType witType = parseTypeDefinition(typeName, typeDefinition); - types.put(typeName, witType); - typeCache.put(typeName, witType); - } - - return types; - } - - /** - * Functional interface for parsing a standalone declaration's body. Matches the - * {@code parseRecordType} / {@code parseVariantType} / {@code parseEnumType} / - * {@code parseFlagsType} signatures so they compose with {@link - * #collectStandalone(Pattern, String, Map, StandaloneBodyParser)}. - */ - @FunctionalInterface - private interface StandaloneBodyParser { - WitType parse(String typeName, String body) throws BindgenException; - } - - /** - * Run a standalone-declaration pattern (e.g. {@code record name { fields }}) across - * the interface body and register every match into {@code types} and the resolution - * cache. - */ - private void collectStandalone( - final Pattern pattern, - final String interfaceBody, - final Map types, - final StandaloneBodyParser bodyParser) - throws BindgenException { - final Matcher m = pattern.matcher(interfaceBody); - while (m.find()) { - final String typeName = m.group(1); - final String body = m.group(2); - final WitType witType = bodyParser.parse(typeName, body); - types.put(typeName, witType); - typeCache.put(typeName, witType); - } - } - - /** - * Parses a single type definition. - * - * @param typeName the type name - * @param definition the type definition - * @return the parsed WIT type - * @throws BindgenException if parsing fails - */ - private WitType parseTypeDefinition(final String typeName, final String definition) - throws BindgenException { - final String trimmed = definition.trim(); - - // Check for primitive types - try { - final WitPrimitiveType primitive = WitPrimitiveType.fromString(trimmed); - return WitType.primitive(primitive); - } catch (final IllegalArgumentException e) { - // Not a primitive type, continue parsing - } - - // Check for record type - final Matcher recordMatcher = RECORD_PATTERN.matcher(trimmed); - if (recordMatcher.matches()) { - return parseRecordType(typeName, recordMatcher.group(1)); - } - - // Check for variant type - final Matcher variantMatcher = VARIANT_PATTERN.matcher(trimmed); - if (variantMatcher.matches()) { - return parseVariantType(typeName, variantMatcher.group(1)); - } - - // Check for enum type - final Matcher enumMatcher = ENUM_PATTERN.matcher(trimmed); - if (enumMatcher.matches()) { - return parseEnumType(typeName, enumMatcher.group(1)); - } - - // Check for flags type - final Matcher flagsMatcher = FLAGS_PATTERN.matcher(trimmed); - if (flagsMatcher.matches()) { - return parseFlagsType(typeName, flagsMatcher.group(1)); - } - - // Check for list type - if (trimmed.startsWith("list<") && trimmed.endsWith(">")) { - final String elementTypeName = trimmed.substring(5, trimmed.length() - 1).trim(); - final WitType elementType = resolveType(elementTypeName); - return WitType.list(elementType); - } - - // Check for option type - if (trimmed.startsWith("option<") && trimmed.endsWith(">")) { - final String innerTypeName = trimmed.substring(7, trimmed.length() - 1).trim(); - final WitType innerType = resolveType(innerTypeName); - return WitType.option(innerType); - } - - // Check for result type - if (trimmed.startsWith("result<") && trimmed.endsWith(">")) { - return parseResultType(trimmed); - } - - // Check for resource type - if (trimmed.startsWith("resource")) { - return WitType.resource(typeName, typeName); - } - - throw new BindgenException("Unknown type definition: " + definition); - } - - /** - * Parses a record type definition. - * - * @param typeName the type name - * @param fieldsText the fields definition text - * @return the parsed record type - * @throws BindgenException if parsing fails - */ - private WitType parseRecordType(final String typeName, final String fieldsText) - throws BindgenException { - final Map fields = new HashMap<>(); - final List fieldDefs = splitTopLevel(stripComments(fieldsText), ','); - - for (final String fieldDef : fieldDefs) { - final String trimmed = fieldDef.trim(); - if (trimmed.isEmpty()) { - continue; - } - - // Split on the FIRST colon — type expressions like - // `list` don't contain colons but type aliases that - // resolve to records aren't allowed inline either, so the - // first `:` is the field/type separator. - final int colon = trimmed.indexOf(':'); - if (colon < 0) { - throw new BindgenException("Invalid record field definition: " + fieldDef); - } - final String fieldName = trimmed.substring(0, colon).trim(); - final String fieldTypeName = trimmed.substring(colon + 1).trim(); - final WitType fieldType = resolveType(fieldTypeName); - fields.put(fieldName, fieldType); - } - - return WitType.record(typeName, fields); - } - - /** - * Strip WIT doc-comment lines ({@code ///…}) and plain {@code //…} comments - * from declaration bodies. Mutating the body before field/case splitting avoids - * comma-inside-comment surprises. - */ - private static String stripComments(final String body) { - final StringBuilder out = new StringBuilder(body.length()); - for (final String line : body.split("\n", -1)) { - final int commentStart = indexOfLineComment(line); - if (commentStart < 0) { - out.append(line).append('\n'); - } else { - out.append(line, 0, commentStart).append('\n'); - } - } - return out.toString(); - } - - /** - * Find the first {@code //} that starts a WIT line comment, respecting - * "…" strings so a {@code //} inside a string literal isn't misread. - */ - private static int indexOfLineComment(final String line) { - boolean inString = false; - for (int i = 0; i < line.length() - 1; i++) { - final char c = line.charAt(i); - if (c == '"' && (i == 0 || line.charAt(i - 1) != '\\')) { - inString = !inString; - continue; - } - if (!inString && c == '/' && line.charAt(i + 1) == '/') { - return i; - } - } - return -1; - } - - /** - * Split {@code text} on top-level occurrences of {@code separator}, respecting - * angle-bracket nesting ({@code list}), paren nesting - * ({@code case(payload)}), and double-quoted strings. - */ - private static List splitTopLevel(final String text, final char separator) { - final List out = new ArrayList<>(); - int depthAngle = 0; - int depthParen = 0; - boolean inString = false; - final StringBuilder current = new StringBuilder(); - for (int i = 0; i < text.length(); i++) { - final char c = text.charAt(i); - if (c == '"' && (i == 0 || text.charAt(i - 1) != '\\')) { - inString = !inString; - current.append(c); - continue; - } - if (!inString) { - if (c == '<') { - depthAngle++; - } else if (c == '>') { - depthAngle = Math.max(0, depthAngle - 1); - } else if (c == '(') { - depthParen++; - } else if (c == ')') { - depthParen = Math.max(0, depthParen - 1); - } - } - if (!inString && depthAngle == 0 && depthParen == 0 && c == separator) { - out.add(current.toString()); - current.setLength(0); - } else { - current.append(c); - } - } - if (current.length() > 0) { - out.add(current.toString()); - } - return out; - } - - /** - * Parses a variant type definition. - * - * @param typeName the type name - * @param casesText the cases definition text - * @return the parsed variant type - * @throws BindgenException if parsing fails - */ - private WitType parseVariantType(final String typeName, final String casesText) - throws BindgenException { - final Map> cases = new HashMap<>(); - final List caseDefs = splitTopLevel(stripComments(casesText), ','); - - for (final String caseDef : caseDefs) { - final String trimmed = caseDef.trim(); - if (trimmed.isEmpty()) { - continue; - } - - if (trimmed.contains("(")) { - // Case with payload - final int parenIndex = trimmed.indexOf('('); - final String caseName = trimmed.substring(0, parenIndex).trim(); - final String payloadType = - trimmed.substring(parenIndex + 1, trimmed.lastIndexOf(')')).trim(); - final WitType caseType = resolveType(payloadType); - cases.put(caseName, Optional.of(caseType)); - } else { - // Case without payload - cases.put(trimmed, Optional.empty()); - } - } - - return WitType.variant(typeName, cases); - } - - /** - * Parses an enum type definition. - * - * @param typeName the type name - * @param valuesText the values definition text - * @return the parsed enum type - * @throws BindgenException if parsing fails - */ - private WitType parseEnumType(final String typeName, final String valuesText) - throws BindgenException { - final List values = new ArrayList<>(); - final List valueDefs = splitTopLevel(stripComments(valuesText), ','); - - for (final String valueDef : valueDefs) { - final String trimmed = valueDef.trim(); - if (!trimmed.isEmpty()) { - values.add(trimmed); - } - } - - return WitType.enumType(typeName, values); - } - - /** - * Parses a flags type definition. - * - * @param typeName the type name - * @param flagsText the flags definition text - * @return the parsed flags type - * @throws BindgenException if parsing fails - */ - private WitType parseFlagsType(final String typeName, final String flagsText) - throws BindgenException { - final List flags = new ArrayList<>(); - final List flagDefs = splitTopLevel(stripComments(flagsText), ','); - - for (final String flagDef : flagDefs) { - final String trimmed = flagDef.trim(); - if (!trimmed.isEmpty()) { - flags.add(trimmed); - } - } - - return WitType.flags(typeName, flags); - } - - /** - * Parses a result type definition. - * - * @param definition the result type definition - * @return the parsed result type - * @throws BindgenException if parsing fails - */ - private WitType parseResultType(final String definition) throws BindgenException { - final String content = definition.substring(7, definition.length() - 1).trim(); - - if (content.isEmpty()) { - return WitType.result(Optional.empty(), Optional.empty()); - } - - final String[] parts = content.split(","); - if (parts.length > 2) { - throw new BindgenException("Result type can have at most two type parameters"); - } - - Optional okType = Optional.empty(); - Optional errorType = Optional.empty(); - - if (parts.length >= 1 && !parts[0].trim().equals("_")) { - okType = Optional.of(resolveType(parts[0].trim())); - } - - if (parts.length == 2 && !parts[1].trim().equals("_")) { - errorType = Optional.of(resolveType(parts[1].trim())); - } - - return WitType.result(okType, errorType); - } - - /** - * Parses function definitions from WIT interface body. - * - * @param interfaceBody the interface body text - * @param types the available types - * @return map of function name to function definition - * @throws BindgenException if parsing fails - */ - private Map parseFunctions( - final String interfaceBody, final Map types) throws BindgenException { - final Map functions = new HashMap<>(); - final Matcher functionMatcher = FUNCTION_PATTERN.matcher(interfaceBody); - - while (functionMatcher.find()) { - final String functionName = functionMatcher.group(1); - final String parametersText = functionMatcher.group(2); - final String returnTypeText = functionMatcher.group(3); - - final List parameters = parseParameters(parametersText); - final List returnTypes = parseReturnTypes(returnTypeText); - - final WitFunction function = - new WitFunction( - functionName, - parameters, - returnTypes, - false, // Not async by default - Optional.empty()); - - functions.put(functionName, function); - } - - return functions; - } - - /** - * Parses function parameters. - * - * @param parametersText the parameters definition text - * @return list of parsed parameters - * @throws BindgenException if parsing fails - */ - private List parseParameters(final String parametersText) throws BindgenException { - final List parameters = new ArrayList<>(); - - if (parametersText == null || parametersText.trim().isEmpty()) { - return parameters; - } - - final String[] paramDefs = splitRespectingBraces(parametersText); - for (final String paramDef : paramDefs) { - final String trimmed = paramDef.trim(); - if (trimmed.isEmpty()) { - continue; - } - - final String[] parts = trimmed.split(":"); - if (parts.length != 2) { - throw new BindgenException("Invalid parameter definition: " + paramDef); - } - - final String paramName = parts[0].trim(); - final String paramTypeName = parts[1].trim(); - final WitType paramType = resolveType(paramTypeName); - - parameters.add( - new WitParameter( - paramName, - paramType, - false, // Not optional by default - Optional.empty())); - } - - return parameters; - } - - /** - * Parses function return types. - * - * @param returnTypeText the return type definition text - * @return list of parsed return types - * @throws BindgenException if parsing fails - */ - private List parseReturnTypes(final String returnTypeText) throws BindgenException { - final List returnTypes = new ArrayList<>(); - - if (returnTypeText == null || returnTypeText.trim().isEmpty()) { - return returnTypes; - } - - // For now, assume single return type - final WitType returnType = resolveType(returnTypeText.trim()); - returnTypes.add(returnType); - - return returnTypes; - } - - /** - * Parses export declarations (simplified implementation). - * - * @param interfaceBody the interface body text - * @return list of export names - */ - private List parseExports(final String interfaceBody) { - final List exports = new ArrayList<>(); - // Simplified - exports are typically function names - final Matcher functionMatcher = FUNCTION_PATTERN.matcher(interfaceBody); - while (functionMatcher.find()) { - exports.add(functionMatcher.group(1)); - } - return exports; - } - - /** - * Resolves a type name to a WIT type. - * - * @param typeName the type name - * @return the resolved type - * @throws BindgenException if type cannot be resolved - */ - private WitType resolveType(final String typeName) throws BindgenException { - // Check cache first - final WitType cachedType = typeCache.get(typeName); - if (cachedType != null) { - return cachedType; - } - - // Try to parse as primitive type - try { - final WitPrimitiveType primitive = WitPrimitiveType.fromString(typeName); - return WitType.primitive(primitive); - } catch (final IllegalArgumentException e) { - // Not a primitive type - } - - // Try to parse as inline type definition (list, option, etc.) - try { - return parseTypeDefinition(typeName, typeName); - } catch (final BindgenException e) { - // Not a parseable type definition - } - - throw new BindgenException("Unknown type: " + typeName); - } - - /** Initializes primitive types in the type cache. */ - private void initializePrimitiveTypes() { - for (final WitPrimitiveType primitive : WitPrimitiveType.values()) { - final WitType type = WitType.primitive(primitive); - typeCache.put(primitive.getWitTypeName(), type); - typeCache.put(primitive.name().toLowerCase(Locale.ROOT), type); - } - } - - /** - * Splits a comma-separated string while respecting braces and angle brackets. - * - * @param text the text to split - * @return array of split parts - */ - private static String[] splitRespectingBraces(final String text) { - final List parts = new ArrayList<>(); - final StringBuilder current = new StringBuilder(); - int braceDepth = 0; - int angleDepth = 0; - - for (final char c : text.toCharArray()) { - if (c == '{') { - braceDepth++; - current.append(c); - } else if (c == '}') { - braceDepth--; - current.append(c); - } else if (c == '<') { - angleDepth++; - current.append(c); - } else if (c == '>') { - angleDepth--; - current.append(c); - } else if (c == ',' && braceDepth == 0 && angleDepth == 0) { - parts.add(current.toString()); - current.setLength(0); - } else { - current.append(c); - } - } - - if (current.length() > 0) { - parts.add(current.toString()); - } - - return parts.toArray(new String[0]); - } - - /** Implementation of WitInterfaceDefinition. */ - private static final class WitInterfaceDefinitionImpl implements WitInterfaceDefinition { - private final String name; - private final String version; - private final String packageName; - private final Map functions; - private final Map types; - private final List imports; - private final List exports; - private final String witText; - - public WitInterfaceDefinitionImpl( - final String name, - final String version, - final String packageName, - final Map functions, - final Map types, - final List imports, - final List exports, - final String witText) { - this.name = name; - this.version = version; - this.packageName = packageName; - this.functions = Map.copyOf(functions); - this.types = Map.copyOf(types); - this.imports = List.copyOf(imports); - this.exports = List.copyOf(exports); - this.witText = witText; - } - - @Override - public String getName() { - return name; - } - - @Override - public String getVersion() { - return version; - } - - @Override - public String getPackageName() { - return packageName; - } - - @Override - public List getFunctionNames() { - return new ArrayList<>(functions.keySet()); - } - - @Override - public Map getFunctions() { - return functions; - } - - @Override - public List getTypeNames() { - return new ArrayList<>(types.keySet()); - } - - @Override - public Map getTypes() { - return types; - } - - @Override - public Set getDependencies() { - return Set.copyOf(imports); - } - - @Override - public WitCompatibilityResult isCompatibleWith(final WitInterfaceDefinition other) { - if (!name.equals(other.getName())) { - return new WitCompatibilityResult( - false, - "Interface names do not match: " + name + " vs " + other.getName(), - Set.of(), - Set.of()); - } - - final Set ourFunctions = Set.copyOf(functions.keySet()); - final Set otherFunctions = Set.copyOf(other.getFunctionNames()); - final Set ourTypes = Set.copyOf(types.keySet()); - final Set otherTypes = Set.copyOf(other.getTypeNames()); - - final Set missingFunctions = new java.util.HashSet<>(ourFunctions); - missingFunctions.removeAll(otherFunctions); - final Set extraFunctions = new java.util.HashSet<>(otherFunctions); - extraFunctions.removeAll(ourFunctions); - - final Set missingTypes = new java.util.HashSet<>(ourTypes); - missingTypes.removeAll(otherTypes); - final Set extraTypes = new java.util.HashSet<>(otherTypes); - extraTypes.removeAll(ourTypes); - - final Set allMissing = new java.util.HashSet<>(); - missingFunctions.forEach(f -> allMissing.add("function:" + f)); - missingTypes.forEach(t -> allMissing.add("type:" + t)); - - final Set allExtra = new java.util.HashSet<>(); - extraFunctions.forEach(f -> allExtra.add("function:" + f)); - extraTypes.forEach(t -> allExtra.add("type:" + t)); - - final boolean compatible = allMissing.isEmpty() && allExtra.isEmpty(); - final String message = - compatible - ? "Interfaces are compatible" - : "Interfaces differ: missing=" + allMissing + ", extra=" + allExtra; - - return new WitCompatibilityResult(compatible, message, allMissing, allExtra); - } - - @Override - public String getWitText() { - return witText; - } - - @Override - public List getImportNames() { - return imports; - } - - @Override - public List getExportNames() { - return exports; - } + final WitDocument doc = WitParser.parse(witText); + if (doc.getInterfaces().isEmpty()) { + throw new BindgenException("Invalid WIT interface: no interface declaration found"); + } + // Legacy single-interface entrypoint — take the first interface (or world) + // in declaration order. Multi-interface sources are handled directly + // through WitParser today; this path just picks the top one. + final WitDocument.ParsedInterface pi = doc.getInterfaces().get(0); + final List exports = new ArrayList<>(pi.getFunctions().keySet()); + return new DefaultWitInterfaceDefinition( + pi.getName(), + "1.0", + packageName, + pi.getFunctions(), + pi.getTypes(), + List.of(), + exports, + witText); } } diff --git a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitLexer.java b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitLexer.java new file mode 100644 index 0000000..742bd76 --- /dev/null +++ b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitLexer.java @@ -0,0 +1,287 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ai.tegmentum.webassembly4j.bindgen.wit; + +import ai.tegmentum.webassembly4j.bindgen.BindgenException; +import java.util.ArrayList; +import java.util.List; + +/** + * Character-stream lexer for WebAssembly Interface Type (WIT) source. + * + *

Produces a flat {@link WitToken} list from a WIT source string. Handles + * whitespace, {@code //} line comments (including doc-comment {@code ///} + * lines), block comments {@code /* … *}{@code /}, identifiers with WIT + * kebab-case rules, punctuation, {@code ->} arrow, and version literals + * after {@code @}. + * + *

The lexer is intentionally permissive about identifier shape — it + * accepts anything the WIT grammar recognises as an identifier and lets + * {@link WitParser} enforce semantic constraints. Keywords are surfaced as + * plain {@link WitToken.Kind#IDENT} tokens so the parser can branch on the + * lexeme text. + * + *

Together with {@link WitParser}, this replaces the retired + * {@code WitInterfaceParser} regex chain plus its {@code WitWorldPreprocessor} + * and {@code WitResourceBodyParser} wrappers — a single recursive-descent + * walk over a proper token stream now covers the WIT constructs the wrappers + * used to patch (package / world / multi-interface / use / import / export / + * resource bodies). + */ +final class WitLexer { + + private final String source; + private int pos; + private int line = 1; + private int column = 1; + + WitLexer(final String source) { + this.source = source; + } + + /** + * Tokenise the input in one pass. + * + * @return the token stream, terminated by an {@link WitToken.Kind#EOF} token + * @throws BindgenException if the input contains an unterminated string, + * unterminated block comment, or an unrecognised character + */ + List tokenize() throws BindgenException { + final List tokens = new ArrayList<>(); + while (pos < source.length()) { + skipWhitespaceAndComments(); + if (pos >= source.length()) { + break; + } + final int startLine = line; + final int startCol = column; + final char c = source.charAt(pos); + if (c == '{') { + advance(); + tokens.add(new WitToken(WitToken.Kind.LBRACE, "{", startLine, startCol)); + } else if (c == '}') { + advance(); + tokens.add(new WitToken(WitToken.Kind.RBRACE, "}", startLine, startCol)); + } else if (c == '(') { + advance(); + tokens.add(new WitToken(WitToken.Kind.LPAREN, "(", startLine, startCol)); + } else if (c == ')') { + advance(); + tokens.add(new WitToken(WitToken.Kind.RPAREN, ")", startLine, startCol)); + } else if (c == '<') { + advance(); + tokens.add(new WitToken(WitToken.Kind.LT, "<", startLine, startCol)); + } else if (c == '>') { + advance(); + tokens.add(new WitToken(WitToken.Kind.GT, ">", startLine, startCol)); + } else if (c == ',') { + advance(); + tokens.add(new WitToken(WitToken.Kind.COMMA, ",", startLine, startCol)); + } else if (c == ';') { + advance(); + tokens.add(new WitToken(WitToken.Kind.SEMI, ";", startLine, startCol)); + } else if (c == ':') { + advance(); + tokens.add(new WitToken(WitToken.Kind.COLON, ":", startLine, startCol)); + } else if (c == '=') { + advance(); + tokens.add(new WitToken(WitToken.Kind.EQ, "=", startLine, startCol)); + } else if (c == '.') { + advance(); + tokens.add(new WitToken(WitToken.Kind.DOT, ".", startLine, startCol)); + } else if (c == '/') { + advance(); + tokens.add(new WitToken(WitToken.Kind.SLASH, "/", startLine, startCol)); + } else if (c == '@') { + advance(); + tokens.add(new WitToken(WitToken.Kind.AT, "@", startLine, startCol)); + // Everything after @ up to the next whitespace / structural + // char is the version literal (e.g. `0.1.0`, `1.2.3-alpha`). + // Kept as an opaque string; downstream doesn't need semver + // semantics. Emitted as its own token so the parser branches + // on AT before consuming the VERSION. + final int vLine = line; + final int vCol = column; + if (pos < source.length() && isVersionChar(source.charAt(pos))) { + tokens.add(readVersion(vLine, vCol)); + } + } else if (c == '_') { + // Bare underscore is a valid placeholder in `result<_, err>`. + // If followed by an ident char it's part of an identifier. + if (pos + 1 < source.length() && isIdentChar(source.charAt(pos + 1))) { + tokens.add(readIdent(startLine, startCol)); + } else { + advance(); + tokens.add(new WitToken(WitToken.Kind.UNDERSCORE, "_", startLine, startCol)); + } + } else if (c == '-') { + // `->` is the func return arrow. A lone `-` doesn't appear in + // WIT except inside kebab-case identifiers (`host-provider`), + // and identifiers start with a letter, so a `-` at token-start + // is either the arrow or an error. + if (pos + 1 < source.length() && source.charAt(pos + 1) == '>') { + advance(); + advance(); + tokens.add(new WitToken(WitToken.Kind.ARROW, "->", startLine, startCol)); + } else { + throw error("unexpected '-' at token start (expected '->')", startLine, startCol); + } + } else if (c == '"') { + tokens.add(readString(startLine, startCol)); + } else if (isIdentStart(c)) { + tokens.add(readIdent(startLine, startCol)); + } else if (Character.isDigit(c)) { + tokens.add(readInt(startLine, startCol)); + } else { + throw error("unexpected character '" + c + "'", startLine, startCol); + } + } + tokens.add(new WitToken(WitToken.Kind.EOF, "", line, column)); + return tokens; + } + + private void skipWhitespaceAndComments() throws BindgenException { + while (pos < source.length()) { + final char c = source.charAt(pos); + if (Character.isWhitespace(c)) { + advance(); + } else if (c == '/' && pos + 1 < source.length() && source.charAt(pos + 1) == '/') { + // Line comment — includes `///` doc comments. + while (pos < source.length() && source.charAt(pos) != '\n') { + advance(); + } + } else if (c == '/' && pos + 1 < source.length() && source.charAt(pos + 1) == '*') { + skipBlockComment(); + } else { + return; + } + } + } + + private void skipBlockComment() throws BindgenException { + final int startLine = line; + final int startCol = column; + advance(); + advance(); + int depth = 1; + while (pos < source.length() && depth > 0) { + if (pos + 1 < source.length() + && source.charAt(pos) == '/' + && source.charAt(pos + 1) == '*') { + advance(); + advance(); + depth++; + } else if (pos + 1 < source.length() + && source.charAt(pos) == '*' + && source.charAt(pos + 1) == '/') { + advance(); + advance(); + depth--; + } else { + advance(); + } + } + if (depth > 0) { + throw error("unterminated block comment", startLine, startCol); + } + } + + private WitToken readIdent(final int startLine, final int startCol) { + // Handle explicit-identifier `%name` prefix (WIT syntax for using a + // keyword as an identifier). The `%` is stripped; the parser sees the + // raw name. + boolean explicit = false; + if (source.charAt(pos) == '%') { + explicit = true; + advance(); + } + final int start = pos; + while (pos < source.length() && isIdentChar(source.charAt(pos))) { + advance(); + } + final String text = source.substring(start, pos); + // Guard against a lone `%` with no ident after it. + if (explicit && text.isEmpty()) { + // Fall through — the parser will report an error on the empty ident. + } + return new WitToken(WitToken.Kind.IDENT, text, startLine, startCol); + } + + private WitToken readInt(final int startLine, final int startCol) { + final int start = pos; + while (pos < source.length() && Character.isDigit(source.charAt(pos))) { + advance(); + } + return new WitToken(WitToken.Kind.INT, source.substring(start, pos), startLine, startCol); + } + + private WitToken readString(final int startLine, final int startCol) throws BindgenException { + advance(); // consume opening quote + final StringBuilder sb = new StringBuilder(); + while (pos < source.length()) { + final char c = source.charAt(pos); + if (c == '\\' && pos + 1 < source.length()) { + sb.append(source.charAt(pos + 1)); + advance(); + advance(); + } else if (c == '"') { + advance(); + return new WitToken(WitToken.Kind.STRING, sb.toString(), startLine, startCol); + } else { + sb.append(c); + advance(); + } + } + throw error("unterminated string literal", startLine, startCol); + } + + private WitToken readVersion(final int startLine, final int startCol) { + final int start = pos; + while (pos < source.length() && isVersionChar(source.charAt(pos))) { + advance(); + } + return new WitToken(WitToken.Kind.VERSION, source.substring(start, pos), startLine, startCol); + } + + private static boolean isVersionChar(final char c) { + // Semver-ish body: digits, dots, hyphens, plus, letters (pre-release + // tags like `1.2.3-alpha.1+build`). + return Character.isLetterOrDigit(c) || c == '.' || c == '-' || c == '+'; + } + + private static boolean isIdentStart(final char c) { + return Character.isLetter(c) || c == '_' || c == '%'; + } + + private static boolean isIdentChar(final char c) { + return Character.isLetterOrDigit(c) || c == '_' || c == '-'; + } + + private void advance() { + final char c = source.charAt(pos); + pos++; + if (c == '\n') { + line++; + column = 1; + } else { + column++; + } + } + + private BindgenException error(final String message, final int atLine, final int atCol) { + return new BindgenException("WIT lex error at " + atLine + ":" + atCol + ": " + message); + } +} diff --git a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitParser.java b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitParser.java new file mode 100644 index 0000000..8609bf9 --- /dev/null +++ b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitParser.java @@ -0,0 +1,809 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ai.tegmentum.webassembly4j.bindgen.wit; + +import ai.tegmentum.webassembly4j.bindgen.BindgenException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Logger; + +/** + * Recursive-descent parser for the WebAssembly Interface Type (WIT) grammar. + * + *

Replaces the previous regex parser + {@code WitWorldPreprocessor} + + * {@code WitResourceBodyParser} wrapper chain with a single walk over a + * {@link WitLexer}-produced token stream. Consumes: + * + *

    + *
  • {@code package name[:sub][@version];} declarations + *
  • {@code world { ... }} blocks with type items directly in the + * body (post-hoist WIT shape per ADR-006). {@code import} / {@code + * export} / {@code use} clauses inside a world are accepted and + * discarded — they reference already-declared interfaces and don't + * contribute new type surface. + *
  • {@code interface { ... }} blocks + *
  • {@code use [:][/][.{}];} statements + *
  • {@code import : func(...) -> ...;} / {@code import : + * ;} / {@code import [;]} + *
  • The matching {@code export} shapes + *
  • Resource bodies: {@code resource { constructor(...); + * : func(...); [static] : func(...); }}. Methods + * land on the same {@link WitFunction} type as top-level functions + * with {@link WitFunction#isConstructor()} / {@link + * WitFunction#isStatic()} flags set — one translation path serves + * both. + *
  • Type aliases: {@code type = ;} + *
  • Standalone type declarations: {@code record}, {@code variant}, + * {@code enum}, {@code flags} + *
  • Type expressions: primitives, {@code list}, {@code option}, + * {@code result} (both operands optional), {@code tuple}, {@code borrow}, {@code own} + *
+ * + *

Types are emitted in WIT declaration order — the {@code + * LinkedHashMap} backing on {@link WitDocument.ParsedInterface#getTypes()} + * preserves this for byte-identical cross-JVM generated output. + */ +public final class WitParser { + + private static final Logger LOGGER = Logger.getLogger(WitParser.class.getName()); + private static final int MAX_WIT_TEXT_LENGTH = 1024 * 1024; // 1 MB + + private final List tokens; + private int pos; + + private WitParser(final List tokens) { + this.tokens = tokens; + } + + /** + * Parse a full WIT source document. + * + * @param source the WIT source text + * @return the parsed document + * @throws BindgenException if parsing fails + */ + public static WitDocument parse(final String source) throws BindgenException { + if (source == null) { + throw new BindgenException("WIT source is null"); + } + if (source.length() > MAX_WIT_TEXT_LENGTH) { + throw new BindgenException( + "WIT source exceeds maximum length of " + MAX_WIT_TEXT_LENGTH + " characters"); + } + final List tokens = new WitLexer(source).tokenize(); + final WitParser parser = new WitParser(tokens); + return parser.parseDocument(); + } + + // --------------------------------------------------------------------------- + // Top-level document + // --------------------------------------------------------------------------- + + private WitDocument parseDocument() throws BindgenException { + Optional packageName = Optional.empty(); + Optional packageVersion = Optional.empty(); + final List interfaces = new ArrayList<>(); + + while (!atEnd()) { + if (matchesIdent("package")) { + final String[] pkg = parsePackageDecl(); + packageName = Optional.ofNullable(pkg[0]); + packageVersion = pkg[1] == null ? Optional.empty() : Optional.of(pkg[1]); + } else if (matchesIdent("interface")) { + interfaces.add(parseInterfaceOrWorld(/* world= */ false)); + } else if (matchesIdent("world")) { + interfaces.add(parseInterfaceOrWorld(/* world= */ true)); + } else { + throw error("unexpected token at document top-level: " + peek().text); + } + } + return new WitDocument(packageName, packageVersion, interfaces); + } + + private String[] parsePackageDecl() throws BindgenException { + expectIdent("package"); + final StringBuilder name = new StringBuilder(expectIdentToken().text); + // Optional `:sub` (e.g. `wasmos:host`). + while (peek().kind == WitToken.Kind.COLON) { + advance(); + name.append(':').append(expectIdentToken().text); + } + // Optional `/path` fragment path (rare in package decls but permitted). + while (peek().kind == WitToken.Kind.SLASH) { + advance(); + name.append('/').append(expectIdentToken().text); + } + String version = null; + if (peek().kind == WitToken.Kind.AT) { + advance(); + version = expect(WitToken.Kind.VERSION).text; + } + expect(WitToken.Kind.SEMI); + return new String[] {name.toString(), version}; + } + + // --------------------------------------------------------------------------- + // Interface / world body + // --------------------------------------------------------------------------- + + private WitDocument.ParsedInterface parseInterfaceOrWorld(final boolean world) + throws BindgenException { + expectIdent(world ? "world" : "interface"); + final String name = expectIdentToken().text; + expect(WitToken.Kind.LBRACE); + + final Map types = new LinkedHashMap<>(); + final Map functions = new LinkedHashMap<>(); + final Map> resourceMethods = new LinkedHashMap<>(); + + while (peek().kind != WitToken.Kind.RBRACE && !atEnd()) { + parseInterfaceItem(types, functions, resourceMethods); + } + expect(WitToken.Kind.RBRACE); + return new WitDocument.ParsedInterface(name, world, types, functions, resourceMethods); + } + + private void parseInterfaceItem( + final Map types, + final Map functions, + final Map> resourceMethods) + throws BindgenException { + final WitToken t = peek(); + if (t.kind != WitToken.Kind.IDENT) { + throw error("expected declaration, got " + t.kind); + } + final String kw = t.text; + switch (kw) { + case "use": + skipUseClause(); + return; + case "import": + case "export": + // Import/export clauses inside a world (or interface, though rare) + // don't declare new types on their own — they reference already- + // declared interfaces or functions. We consume-and-discard the + // clause and, when it's an inline `import name: func(...) -> ...`, + // register the function so a downstream generator sees it. + parseImportExportClause(functions); + return; + case "type": + parseTypeAlias(types); + return; + case "record": + parseRecordDecl(types); + return; + case "variant": + parseVariantDecl(types); + return; + case "enum": + parseEnumDecl(types); + return; + case "flags": + parseFlagsDecl(types); + return; + case "resource": + parseResourceDecl(types, resourceMethods); + return; + default: + // Anything else must be `name: func(...)` or `name: ` — + // a plain function declaration. + parseFunctionDecl(functions); + } + } + + // ---- use / import / export ----------------------------------------------- + + private void skipUseClause() throws BindgenException { + expectIdent("use"); + // Consume until the terminating semicolon at depth 0. `use` clauses + // may contain `{a, b, c}` item lists so we track brace depth. + int depth = 0; + while (!atEnd()) { + final WitToken t = peek(); + if (t.kind == WitToken.Kind.LBRACE) { + depth++; + advance(); + } else if (t.kind == WitToken.Kind.RBRACE) { + depth--; + advance(); + } else if (t.kind == WitToken.Kind.SEMI && depth == 0) { + advance(); + return; + } else { + advance(); + } + } + throw error("unterminated 'use' clause"); + } + + private void parseImportExportClause(final Map functions) + throws BindgenException { + // Consume `import` / `export`. + advance(); + // Two shapes: + // 1. `import ;` — bare interface reference + // 2. `import : ;` — inline binding + // 3. `import : interface { ... }` — inline interface (dropped) + // (import / export take the same grammar.) + if (peek().kind != WitToken.Kind.IDENT) { + // Anything else — swallow the rest of the clause. + skipToSemicolon(); + return; + } + final String name = expectIdentToken().text; + if (peek().kind == WitToken.Kind.SEMI) { + advance(); + return; + } + if (peek().kind != WitToken.Kind.COLON) { + // Something we don't understand — best-effort skip. + skipToSemicolon(); + return; + } + expect(WitToken.Kind.COLON); + // If it's `func(...)`, register it as a function (matches how the + // regex parser used to walk `name: func(...)` inside a world body + // before the wrappers stripped it). + if (matchesIdent("func")) { + final WitFunction fn = parseFuncSignature(name); + functions.put(name, fn); + expect(WitToken.Kind.SEMI); + return; + } + if (matchesIdent("interface")) { + // `import name: interface { ... }` — drop the inline body wholesale. + advance(); // consume `interface` + expect(WitToken.Kind.LBRACE); + int depth = 1; + while (!atEnd() && depth > 0) { + if (peek().kind == WitToken.Kind.LBRACE) { + depth++; + } else if (peek().kind == WitToken.Kind.RBRACE) { + depth--; + } + advance(); + } + // Optional trailing semicolon. + if (peek().kind == WitToken.Kind.SEMI) { + advance(); + } + return; + } + // `import name: ;` — swallow the type expression, we + // don't need it downstream. + skipToSemicolon(); + } + + private void skipToSemicolon() { + int paren = 0; + int angle = 0; + int brace = 0; + while (!atEnd()) { + final WitToken t = peek(); + if (t.kind == WitToken.Kind.LPAREN) { + paren++; + } else if (t.kind == WitToken.Kind.RPAREN) { + paren = Math.max(0, paren - 1); + } else if (t.kind == WitToken.Kind.LT) { + angle++; + } else if (t.kind == WitToken.Kind.GT) { + angle = Math.max(0, angle - 1); + } else if (t.kind == WitToken.Kind.LBRACE) { + brace++; + } else if (t.kind == WitToken.Kind.RBRACE) { + if (brace == 0) { + return; + } + brace--; + } else if (t.kind == WitToken.Kind.SEMI && paren == 0 && angle == 0 && brace == 0) { + advance(); + return; + } + advance(); + } + } + + // ---- type declarations --------------------------------------------------- + + private void parseTypeAlias(final Map types) throws BindgenException { + expectIdent("type"); + final String name = expectIdentToken().text; + expect(WitToken.Kind.EQ); + // Peek to see if it's a compound: `type X = record { ... }` / + // `type X = variant { ... }` etc. Otherwise it's a type expression. + if (matchesIdent("record")) { + advance(); + types.put(name, parseRecordBody(name)); + } else if (matchesIdent("variant")) { + advance(); + types.put(name, parseVariantBody(name)); + } else if (matchesIdent("enum")) { + advance(); + types.put(name, parseEnumBody(name)); + } else if (matchesIdent("flags")) { + advance(); + types.put(name, parseFlagsBody(name)); + } else if (matchesIdent("resource")) { + advance(); + // `type X = resource;` — bare-resource alias (rare but legal). + types.put(name, WitType.resource(name, name)); + } else { + final WitType expr = parseTypeExpression(types); + // If the returned type is a primitive/list/option/result/tuple, name + // it with the alias name so the generator emits it under that name. + // For record/variant/enum/flags/resource references we keep the + // referent type — the alias name isn't the same as the referent. + types.put(name, renameIfPrimitiveLike(name, expr)); + } + // Old regex parser accepted `type X = Y` without trailing `;` at + // end of body — keep that tolerance. + consumeOptionalSemi(); + } + + private WitType renameIfPrimitiveLike(final String aliasName, final WitType src) { + final WitTypeCategory cat = src.getKind().getCategory(); + switch (cat) { + case PRIMITIVE: + case LIST: + case OPTION: + case RESULT: + case TUPLE: + case RESOURCE: + // Container / primitive aliases keep the alias name where possible. + // We rebuild only for primitives (WitType.primitive() carries its + // own textual name); list/option/result/tuple were built with + // container names by parseTypeExpression already. + if (cat == WitTypeCategory.PRIMITIVE) { + // Represent an alias-to-primitive as-is; the alias name is what + // matters for downstream deduplication. + return src; + } + return src; + default: + return src; + } + } + + private void parseRecordDecl(final Map types) throws BindgenException { + expectIdent("record"); + final String name = expectIdentToken().text; + types.put(name, parseRecordBody(name)); + } + + private WitType parseRecordBody(final String name) throws BindgenException { + expect(WitToken.Kind.LBRACE); + final Map fields = new LinkedHashMap<>(); + while (peek().kind != WitToken.Kind.RBRACE && !atEnd()) { + final String fieldName = expectIdentToken().text; + expect(WitToken.Kind.COLON); + final WitType fieldType = parseTypeExpression(null); + fields.put(fieldName, fieldType); + // Trailing comma is optional; both `,` and end-of-body terminate. + if (peek().kind == WitToken.Kind.COMMA) { + advance(); + } + } + expect(WitToken.Kind.RBRACE); + return WitType.record(name, fields); + } + + private void parseVariantDecl(final Map types) throws BindgenException { + expectIdent("variant"); + final String name = expectIdentToken().text; + types.put(name, parseVariantBody(name)); + } + + private WitType parseVariantBody(final String name) throws BindgenException { + expect(WitToken.Kind.LBRACE); + final Map> cases = new LinkedHashMap<>(); + while (peek().kind != WitToken.Kind.RBRACE && !atEnd()) { + final String caseName = expectIdentToken().text; + Optional payload = Optional.empty(); + if (peek().kind == WitToken.Kind.LPAREN) { + advance(); + payload = Optional.of(parseTypeExpression(null)); + expect(WitToken.Kind.RPAREN); + } + cases.put(caseName, payload); + if (peek().kind == WitToken.Kind.COMMA) { + advance(); + } + } + expect(WitToken.Kind.RBRACE); + return WitType.variant(name, cases); + } + + private void parseEnumDecl(final Map types) throws BindgenException { + expectIdent("enum"); + final String name = expectIdentToken().text; + types.put(name, parseEnumBody(name)); + } + + private WitType parseEnumBody(final String name) throws BindgenException { + expect(WitToken.Kind.LBRACE); + final List values = new ArrayList<>(); + while (peek().kind != WitToken.Kind.RBRACE && !atEnd()) { + values.add(expectIdentToken().text); + if (peek().kind == WitToken.Kind.COMMA) { + advance(); + } + } + expect(WitToken.Kind.RBRACE); + return WitType.enumType(name, values); + } + + private void parseFlagsDecl(final Map types) throws BindgenException { + expectIdent("flags"); + final String name = expectIdentToken().text; + types.put(name, parseFlagsBody(name)); + } + + private WitType parseFlagsBody(final String name) throws BindgenException { + expect(WitToken.Kind.LBRACE); + final List flags = new ArrayList<>(); + while (peek().kind != WitToken.Kind.RBRACE && !atEnd()) { + flags.add(expectIdentToken().text); + if (peek().kind == WitToken.Kind.COMMA) { + advance(); + } + } + expect(WitToken.Kind.RBRACE); + return WitType.flags(name, flags); + } + + // ---- resource declarations ----------------------------------------------- + + private void parseResourceDecl( + final Map types, final Map> resourceMethods) + throws BindgenException { + expectIdent("resource"); + final String name = expectIdentToken().text; + if (peek().kind == WitToken.Kind.SEMI) { + // `resource X;` — no body. + advance(); + types.put(name, WitType.resource(name, name)); + return; + } + expect(WitToken.Kind.LBRACE); + final List methods = new ArrayList<>(); + while (peek().kind != WitToken.Kind.RBRACE && !atEnd()) { + methods.add(parseResourceMethod(name)); + // WIT tolerates both `;` and `,` between resource-body items, plus + // a trailing separator before `}`. + if (peek().kind == WitToken.Kind.SEMI || peek().kind == WitToken.Kind.COMMA) { + advance(); + } + } + expect(WitToken.Kind.RBRACE); + types.put(name, WitType.resource(name, name)); + resourceMethods.put(name, methods); + } + + private WitFunction parseResourceMethod(final String resourceName) throws BindgenException { + if (matchesIdent("constructor")) { + advance(); + expect(WitToken.Kind.LPAREN); + final List params = parseFuncParameters(); + expect(WitToken.Kind.RPAREN); + return WitFunction.resourceMethod( + "constructor", + params, + List.of(), + /* isConstructor= */ true, + /* isStatic= */ false, + /* isAsync= */ false, + Optional.empty()); + } + final String methodName = expectIdentToken().text; + expect(WitToken.Kind.COLON); + boolean isStatic = false; + if (matchesIdent("static")) { + advance(); + isStatic = true; + } + expectIdent("func"); + expect(WitToken.Kind.LPAREN); + final List params = parseFuncParameters(); + expect(WitToken.Kind.RPAREN); + final List returnTypes = parseOptionalReturnTypes(); + return WitFunction.resourceMethod( + methodName, params, returnTypes, false, isStatic, false, Optional.empty()); + } + + // ---- function declarations ------------------------------------------------ + + private void parseFunctionDecl(final Map functions) throws BindgenException { + final String name = expectIdentToken().text; + expect(WitToken.Kind.COLON); + // Optional `async` keyword before `func`. + boolean isAsync = false; + if (matchesIdent("async")) { + advance(); + isAsync = true; + } + if (!matchesIdent("func")) { + // `name: ;` — not a function, but a typed binding. We + // don't have a first-class binding-type slot; skip it so the + // caller doesn't crash. This matches the tolerance of the old + // wrapper chain. + skipToSemicolon(); + return; + } + final WitFunction fn = parseFuncSignature(name); + functions.put(name, fn.getName().equals(name) ? withAsync(fn, isAsync) : fn); + // Trailing `;` is required by strict WIT but the old regex parser + // tolerated its absence at end-of-body. Preserve that laxness so + // legacy fixtures (`add: func(...) -> s32\n}`) keep parsing. + consumeOptionalSemi(); + } + + private void consumeOptionalSemi() { + if (peek().kind == WitToken.Kind.SEMI) { + advance(); + } + } + + private WitFunction withAsync(final WitFunction fn, final boolean async) { + if (!async) { + return fn; + } + return new WitFunction(fn.getName(), fn.getParameters(), fn.getReturnTypes(), true, fn.getDocumentation()); + } + + private WitFunction parseFuncSignature(final String name) throws BindgenException { + expectIdent("func"); + expect(WitToken.Kind.LPAREN); + final List params = parseFuncParameters(); + expect(WitToken.Kind.RPAREN); + final List returnTypes = parseOptionalReturnTypes(); + return new WitFunction(name, params, returnTypes, false, Optional.empty()); + } + + private List parseFuncParameters() throws BindgenException { + final List params = new ArrayList<>(); + while (peek().kind != WitToken.Kind.RPAREN && !atEnd()) { + final String pname = expectIdentToken().text; + expect(WitToken.Kind.COLON); + final WitType ptype = parseTypeExpression(null); + params.add(new WitParameter(pname, ptype, false, Optional.empty())); + if (peek().kind == WitToken.Kind.COMMA) { + advance(); + } + } + return params; + } + + private List parseOptionalReturnTypes() throws BindgenException { + if (peek().kind != WitToken.Kind.ARROW) { + return List.of(); + } + advance(); + final WitType retType = parseTypeExpression(null); + return List.of(retType); + } + + // --------------------------------------------------------------------------- + // Type expressions + // --------------------------------------------------------------------------- + + /** + * Parse a type expression: primitive, container ({@code list}, + * {@code option}, {@code result}, {@code tuple}), + * lifetime-qualified ({@code borrow}, {@code own}), or named + * reference (record / variant / enum / flags / resource / alias). + * + *

Named references are emitted as {@code WitType.resource(name, name)} + * placeholders when they don't match a primitive — downstream + * translation ({@code CodeGenerator.resolveOrConvertType}) reroutes them + * to a {@code BindgenType.reference} once the type table is materialised. + * This mirrors what the old regex parser did for unknown names and + * keeps the byte-identical generator output. + * + * @param scope currently unused; reserved for future scope-aware lookup + * (records-referencing-records etc.). Pass {@code null} at call sites. + */ + private WitType parseTypeExpression(final Map scope) throws BindgenException { + final WitToken t = peek(); + if (t.kind != WitToken.Kind.IDENT) { + throw error("expected type expression, got " + t.kind); + } + final String head = t.text; + switch (head) { + case "list": + advance(); + expect(WitToken.Kind.LT); + final WitType listInner = parseTypeExpression(scope); + expect(WitToken.Kind.GT); + return WitType.list(listInner); + case "option": + advance(); + expect(WitToken.Kind.LT); + final WitType optionInner = parseTypeExpression(scope); + expect(WitToken.Kind.GT); + return WitType.option(optionInner); + case "stream": + case "future": + // WIT component-model async types (WASI 0.2+). Bindgen doesn't model + // async semantics — codegen has no runtime plumbing for stream/future + // dispatch — so we lossy-coerce the SHAPE and emit a warning: + // stream → list (sequence of T; sync collection stand-in) + // future → option (possibly-present T; sync option stand-in) + // Semantics change silently in the generated Java, but the alternative + // (crashing the whole parse — as happened before this coercion was + // added — kills every OTHER interface in the same file too). Users + // needing real async wrap the coerced types at their layer. + { + final String asyncKind = head; + advance(); + expect(WitToken.Kind.LT); + final WitType asyncInner = parseTypeExpression(scope); + expect(WitToken.Kind.GT); + LOGGER.warning( + () -> asyncKind + " coerced to " + + ("stream".equals(asyncKind) ? "list" : "option") + + " — bindgen does not model WIT component-model async types"); + return "stream".equals(asyncKind) + ? WitType.list(asyncInner) + : WitType.option(asyncInner); + } + case "result": + advance(); + return parseResultTail(scope); + case "tuple": + advance(); + expect(WitToken.Kind.LT); + final List elements = new ArrayList<>(); + while (peek().kind != WitToken.Kind.GT && !atEnd()) { + elements.add(parseTypeExpression(scope)); + if (peek().kind == WitToken.Kind.COMMA) { + advance(); + } + } + expect(WitToken.Kind.GT); + return WitType.tuple(elements); + case "borrow": + case "own": + advance(); + expect(WitToken.Kind.LT); + final WitType inner = parseTypeExpression(scope); + expect(WitToken.Kind.GT); + // Lifetime qualifiers unwrap at the generator level. + return inner; + case "record": + // Inline anonymous record — legal in a type-expression position + // (uncommon but permitted by the WIT grammar). Use a synthetic + // name; downstream generation names it from context. + advance(); + return parseRecordBody(""); + case "variant": + advance(); + return parseVariantBody(""); + case "enum": + advance(); + return parseEnumBody(""); + case "flags": + advance(); + return parseFlagsBody(""); + default: + break; + } + // Primitive lookup. + final WitPrimitiveType prim = tryPrimitive(head); + if (prim != null) { + advance(); + return WitType.primitive(prim); + } + // Named reference — emit a resource placeholder so downstream lookup + // can convert it against the interface's type table. + advance(); + return WitType.resource(head, head); + } + + private WitType parseResultTail(final Map scope) throws BindgenException { + if (peek().kind != WitToken.Kind.LT) { + // `result` with no type parameters — both slots empty. + return WitType.result(Optional.empty(), Optional.empty()); + } + advance(); // consume `<` + Optional okType = Optional.empty(); + Optional errType = Optional.empty(); + // First slot: either `_` (unit) or a type expression. + if (peek().kind == WitToken.Kind.UNDERSCORE) { + advance(); + } else if (peek().kind == WitToken.Kind.GT) { + // `result<>` — empty tuple, both slots empty. + } else { + okType = Optional.of(parseTypeExpression(scope)); + } + if (peek().kind == WitToken.Kind.COMMA) { + advance(); + if (peek().kind == WitToken.Kind.UNDERSCORE) { + advance(); + } else if (peek().kind != WitToken.Kind.GT) { + errType = Optional.of(parseTypeExpression(scope)); + } + } + expect(WitToken.Kind.GT); + return WitType.result(okType, errType); + } + + private static WitPrimitiveType tryPrimitive(final String name) { + try { + return WitPrimitiveType.fromString(name.toLowerCase(Locale.ROOT)); + } catch (final IllegalArgumentException e) { + return null; + } + } + + // --------------------------------------------------------------------------- + // Token stream helpers + // --------------------------------------------------------------------------- + + private WitToken peek() { + return tokens.get(pos); + } + + private WitToken advance() { + final WitToken t = tokens.get(pos); + if (pos < tokens.size() - 1) { + pos++; + } + return t; + } + + private boolean atEnd() { + return peek().kind == WitToken.Kind.EOF; + } + + private boolean matchesIdent(final String text) { + final WitToken t = peek(); + return t.kind == WitToken.Kind.IDENT && t.text.equals(text); + } + + private WitToken expect(final WitToken.Kind kind) throws BindgenException { + final WitToken t = peek(); + if (t.kind != kind) { + throw error("expected " + kind + " but got " + t.kind + " (\"" + t.text + "\")"); + } + return advance(); + } + + private WitToken expectIdentToken() throws BindgenException { + final WitToken t = peek(); + if (t.kind != WitToken.Kind.IDENT) { + throw error("expected identifier but got " + t.kind + " (\"" + t.text + "\")"); + } + return advance(); + } + + private void expectIdent(final String text) throws BindgenException { + final WitToken t = peek(); + if (t.kind != WitToken.Kind.IDENT || !t.text.equals(text)) { + throw error("expected keyword '" + text + "' but got \"" + t.text + "\""); + } + advance(); + } + + private BindgenException error(final String message) { + final WitToken t = peek(); + return new BindgenException("WIT parse error at " + t.line + ":" + t.column + ": " + message); + } +} diff --git a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitToken.java b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitToken.java new file mode 100644 index 0000000..25466de --- /dev/null +++ b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitToken.java @@ -0,0 +1,75 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ai.tegmentum.webassembly4j.bindgen.wit; + +/** + * A single lexical token emitted by {@link WitLexer}. + * + *

Keywords (e.g. {@code interface}, {@code world}, {@code resource}) + * are surfaced as {@link Kind#IDENT} tokens; {@link WitParser} matches + * them by textual equality. Punctuation and structural tokens carry + * their own kinds so the parser can branch on kind without re-lexing. + * + *

Line + column are 1-based and preserved for diagnostic messages. + */ +final class WitToken { + + /** Distinct token kinds produced by the lexer. */ + enum Kind { + /** An identifier or keyword — text carries the exact lexeme. */ + IDENT, + /** Integer literal (used inside {@code @version} strings). */ + INT, + /** Version literal after {@code @}, e.g. {@code 1.2.3} or {@code 0.1.0-pre}. */ + VERSION, + /** {@code "..."} — carries the unquoted content. */ + STRING, + LBRACE, + RBRACE, + LPAREN, + RPAREN, + LT, + GT, + COMMA, + SEMI, + COLON, + EQ, + ARROW, + DOT, + SLASH, + AT, + UNDERSCORE, + /** End-of-input sentinel. */ + EOF, + } + + final Kind kind; + final String text; + final int line; + final int column; + + WitToken(final Kind kind, final String text, final int line, final int column) { + this.kind = kind; + this.text = text; + this.line = line; + this.column = column; + } + + @Override + public String toString() { + return kind + "(" + text + ")@" + line + ":" + column; + } +} diff --git a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitTypeKind.java b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitTypeKind.java index b76f628..f57c24e 100644 --- a/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitTypeKind.java +++ b/webassembly4j-bindgen/src/main/java/ai/tegmentum/webassembly4j/bindgen/wit/WitTypeKind.java @@ -15,6 +15,8 @@ */ package ai.tegmentum.webassembly4j.bindgen.wit; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -342,7 +344,10 @@ private static final class RecordTypeKind extends WitTypeKind { private final Map fields; private RecordTypeKind(final Map fields) { - this.fields = Map.copyOf(Objects.requireNonNull(fields, "fields")); + // LinkedHashMap-backed unmodifiable map preserves WIT declaration + // order across JVMs; Map.copyOf randomizes iteration order. + this.fields = + Collections.unmodifiableMap(new LinkedHashMap<>(Objects.requireNonNull(fields, "fields"))); } @Override @@ -395,7 +400,10 @@ private static final class VariantTypeKind extends WitTypeKind { private final Map> cases; private VariantTypeKind(final Map> cases) { - this.cases = Map.copyOf(Objects.requireNonNull(cases, "cases")); + // LinkedHashMap-backed unmodifiable map preserves WIT case + // declaration order across JVMs; Map.copyOf randomizes iteration. + this.cases = + Collections.unmodifiableMap(new LinkedHashMap<>(Objects.requireNonNull(cases, "cases"))); } @Override diff --git a/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/cli/BindgenCliTest.java b/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/cli/BindgenCliTest.java index aaa4479..245ea1c 100644 --- a/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/cli/BindgenCliTest.java +++ b/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/cli/BindgenCliTest.java @@ -562,6 +562,40 @@ void shouldAcceptMultipleWitSources(@TempDir Path tempDir) throws Exception { assertEquals(0, exitCode); } + + @Test + @DisplayName("--wit on a directory picks up nested .wit files recursively") + void shouldRecursivelyExpandDirectorySource(@TempDir Path tempDir) throws Exception { + // Mirror the wit-bindgen / wac layout: top-level `wit/foo.wit` plus a + // `wit/deps//*.wit` subdir carrying transitive package deps. A + // single `--wit wit/` invocation must pull both in so combined-model + // merging has the full type closure available for cross-package `use` + // resolution (wasmos:runtime references tegmentum:common/errors this way). + Path root = tempDir.resolve("wit"); + Path deps = root.resolve("deps").resolve("mypkg"); + Files.createDirectories(deps); + Files.writeString(root.resolve("top.wit"), VALID_WIT); + Files.writeString( + deps.resolve("nested.wit"), + "interface nested-api {\n echo: func(s: string) -> string\n}"); + Path outputDir = tempDir.resolve("output"); + + int exitCode = + new CommandLine(new BindgenCli()) + .execute( + "--wit", root.toString(), + "--package", "com.example", + "--output", outputDir.toString()); + + assertEquals(0, exitCode, "CLI must succeed with recursive directory expansion"); + // Both interfaces must have been generated. + Path pkgDir = outputDir.resolve("com").resolve("example"); + assertTrue(Files.exists(pkgDir.resolve("Test.java")), + "top-level `test` interface must generate Test.java"); + assertTrue(Files.exists(pkgDir.resolve("NestedApi.java")), + "nested `nested-api` interface must generate NestedApi.java — " + + "directory expansion was not recursive"); + } } @Nested diff --git a/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/generator/ImplementationCodeGeneratorBugsTest.java b/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/generator/ImplementationCodeGeneratorBugsTest.java new file mode 100644 index 0000000..bf362c7 --- /dev/null +++ b/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/generator/ImplementationCodeGeneratorBugsTest.java @@ -0,0 +1,831 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ai.tegmentum.webassembly4j.bindgen.generator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import ai.tegmentum.webassembly4j.bindgen.BindgenConfig; +import ai.tegmentum.webassembly4j.bindgen.BindgenException; +import ai.tegmentum.webassembly4j.bindgen.GeneratedSource; +import ai.tegmentum.webassembly4j.bindgen.model.BindgenFunction; +import ai.tegmentum.webassembly4j.bindgen.model.BindgenInterface; +import ai.tegmentum.webassembly4j.bindgen.model.BindgenType; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.ToolProvider; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Regression coverage for the two blocking bindgen bugs surfaced by the + * wasmos:host/embedder@0.1.0 shape check. + * + *

Each test constructs a bindgen interface that reproduces one bug, then + * feeds the generated {@code *Impl} source (plus a small hand-written stub of + * the runtime types the generator references) to the platform + * {@link JavaCompiler}. A javac error means the generator regressed. + * + *

    + *
  1. Bug 1 — the marshalling body redeclared internal locals ({@code args}, + * {@code retptr}, {@code result}) that collide with a WIT parameter of + * the same name. Reproducer: + * {@code host-call(provider-handle, func-idx, args: string)}, matching + * {@code embedder-callbacks.host-call} in the wasmos world (the wire + * type there is {@code list}; we use {@code string} in the repro to + * exercise the same collision without depending on the separate + * {@code List} lowering bug). + *
  2. Bug 2 — a function returning {@code result} used to emit a + * literal {@code return /* TODO: lift RESULT */;} — a comment, not + * an expression. Reproducer: {@code call-export(name) -> + * result}, matching the shape of + * {@code runtime-instance.call-export}. + *
+ * + *

The stub surface below covers only the pieces {@code + * ImplementationCodeGenerator} + {@code MarshallingStrategy} name today: + * {@code Instance / Module / Engine / Function} from the api module and + * {@code MarshalContext / MemoryReader / MemoryAllocator / Memory / + * StringCodec} from the runtime module. If the generator starts naming a new + * type, that will surface as a javac error here — which is the point. + */ +@DisplayName("ImplementationCodeGenerator bindgen bugs") +class ImplementationCodeGeneratorBugsTest { + + private static final String PACKAGE = "ai.tegmentum.bindgen.generated"; + + private ImplementationCodeGenerator generator() { + return new ImplementationCodeGenerator( + BindgenConfig.builder() + .packageName(PACKAGE) + .outputDirectory(Path.of("target/test-output")) + .addWitSource(Path.of("test.wit")) + .generateImplementations(true) + .generateServiceLoader(false) + .build()); + } + + @Test + @DisplayName( + "bug 1: WIT parameter named `args` no longer collides with the internal args local") + void paramNamedArgsDoesNotCollide() throws Exception { + // Matches wasmos:host/embedder embedder-callbacks.host-call, minus the + // list arg type (see class javadoc). + final BindgenInterface iface = + BindgenInterface.builder() + .name("embedder-callbacks") + .addFunction( + BindgenFunction.builder() + .name("host-call") + .addParameter("provider-handle", BindgenType.primitive("u32")) + .addParameter("func-idx", BindgenType.primitive("u32")) + .addParameter("args", BindgenType.primitive("string")) + .build()) + .build(); + + final GeneratedSource impl = generator().generate(Collections.singletonList(iface)).get(0); + assertEquals("EmbedderCallbacksImpl", impl.getClassName()); + final String content = impl.getContent(); + + // The internal ArrayList must have a name distinct from the `args` param. + assertFalse( + content.contains("java.util.List args ="), + "internal args local must not shadow the WIT parameter named `args`\n" + content); + assertTrue( + content.contains("java.util.List args$0"), + "expected disambiguated internal args local, got:\n" + content); + // The disambiguated local threads through the invoke site too. + assertTrue( + content.contains("args$0.toArray()"), + "expected the invoke site to use the disambiguated args local; got:\n" + content); + + compileImpl(iface, impl); + } + + @Test + @DisplayName( + "bug 1 (companion): a param named `result` disambiguates the internal result local too") + void paramNamedResultDoesNotCollide() throws Exception { + // The generator declares an `Object result` local when the return type + // needs marshalling but is not stored via a retptr (the "primitive + // returned but string parameter present" branch). Confirm that a param + // literally named `result` no longer shadows it. + final BindgenInterface iface = + BindgenInterface.builder() + .name("reporter") + .addFunction( + BindgenFunction.builder() + .name("report") + .addParameter("result", BindgenType.primitive("string")) + .returnType(BindgenType.primitive("i32")) + .build()) + .build(); + + final GeneratedSource impl = generator().generate(Collections.singletonList(iface)).get(0); + final String content = impl.getContent(); + assertFalse( + content.contains("Object result ="), + "internal `result` local must not shadow the WIT parameter named `result`\n" + content); + assertTrue( + content.contains("Object result$0 ="), + "expected disambiguated result local; got:\n" + content); + + compileImpl(iface, impl); + } + + @Test + @DisplayName( + "bug 2: a result return dispatches on the Canonical-ABI discriminant") + void resultReturnEmitsCompileableBody() throws Exception { + // call-export: func(name: string) -> result + // The ok arm is a `string` (in-memory lifter wired); the err arm is a + // record reference — payload lifting for records isn't in this cut, so + // that arm falls back to a specific-tag throw. The dispatch shape is + // real. + final BindgenInterface iface = + BindgenInterface.builder() + .name("runtime-instance") + .addFunction( + BindgenFunction.builder() + .name("call-export") + .addParameter("name", BindgenType.primitive("string")) + .returnType( + BindgenType.result( + BindgenType.primitive("string"), BindgenType.reference("error"))) + .build()) + .build(); + + final GeneratedSource impl = generator().generate(Collections.singletonList(iface)).get(0); + final String content = impl.getContent(); + + // Pre-fix the body emitted this literal — the fix must not. + assertFalse( + content.contains("return /* TODO: lift RESULT */"), + "result body must not emit a bare TODO comment in return position:\n" + content); + // The previous placeholder was a whole-body throw. Real dispatch has an + // if/else on the tag; the whole-body throw is gone. + assertFalse( + content.contains("TODO(bindgen): result"), + "expected the whole-body TODO throw to be replaced by real dispatch:\n" + content); + // Canonical-ABI discriminant read from the retptr, then branch on it. + assertTrue( + content.contains("marshal.reader().readU8("), + "expected a u8 discriminant read from the retptr:\n" + content); + assertTrue( + content.contains("if (tag$ == 0)"), + "expected an `if (tag$ == 0)` dispatch on the discriminant:\n" + content); + // Ok arm lifts a string from `retptr + payloadOffset` (payload alignment + // for `string` is 4, so payload offset is 4). + assertTrue( + content.contains("WitResult.ok(marshal.reader().readString"), + "expected the ok arm to lift a string via readString:\n" + content); + // Err arm can't lift a record payload today; specific-tag throw + // documents the gap without pretending the arm works. + assertTrue( + content.contains("err-arm payload lift for REFERENCE"), + "expected the err arm to throw with a REFERENCE-payload TODO:\n" + content); + // The method signature is still generated correctly. + assertTrue( + content.contains("public WitResult callExport"), + "expected the WitResult return type on callExport:\n" + content); + + compileImpl(iface, impl); + } + + @Test + @DisplayName( + "bug 2 (companion): a result<_, E> return returns WitResult.ok(null) in the ok arm") + void resultReturnWithUnitOkPayload() throws Exception { + // verify-world: func(expected-wit: string) -> result<_, error> + // Ok payload is unit; the ok arm returns WitResult.ok(null). Err arm + // still falls back to a REFERENCE-payload throw. + final BindgenInterface iface = + BindgenInterface.builder() + .name("runtime-instance") + .addFunction( + BindgenFunction.builder() + .name("verify-world") + .addParameter("expected-wit", BindgenType.primitive("string")) + .returnType(BindgenType.result(null, BindgenType.reference("error"))) + .build()) + .build(); + + final GeneratedSource impl = generator().generate(Collections.singletonList(iface)).get(0); + final String content = impl.getContent(); + + assertTrue( + content.contains("return WitResult.ok(null)"), + "expected the unit-ok arm to return WitResult.ok(null):\n" + content); + assertTrue( + content.contains("public WitResult verifyWorld"), + "expected the WitResult return type on verifyWorld:\n" + content); + + compileImpl(iface, impl); + } + + @Test + @DisplayName( + "bug 3 (task #39): a WIT `list` parameter lowers a `List` into linear memory") + void listU8ParamLowersFromListByte() throws Exception { + // Pre-fix, the LIST branch of MarshallingStrategy.lowerArgument was + // hard-coded to `(byte[]) (Object) javaVar` — but the interface + // signature is `List` (TypeMappingRegistry.mapList wraps u8 in a + // parametrised List). javac rejected the cast with "incompatible + // types: List cannot be converted to byte[]", which blocked + // every wasmos:runtime interface that carried a bytes payload + // (http.fetch, http-streaming.fetch, run.exec, websocket.send). + // Reproducer: fetch(body: list) — matches http-streaming/http. + final BindgenInterface iface = + BindgenInterface.builder() + .name("http-body") + .addFunction( + BindgenFunction.builder() + .name("send") + .addParameter("body", BindgenType.list(BindgenType.primitive("u8"))) + .build()) + .build(); + + final GeneratedSource impl = generator().generate(Collections.singletonList(iface)).get(0); + final String content = impl.getContent(); + + // The broken pre-fix cast must be gone. + assertFalse( + content.contains("(byte[]) (Object) body"), + "list lowering must not cast a List to byte[]:\n" + content); + assertFalse( + content.contains("body instanceof byte[]"), + "list lowering must not `instanceof byte[]` a List:\n" + content); + // The replacement path unboxes the List into a fresh byte[]. + assertTrue( + content.contains("byte[] body_bytes = new byte[body.size()]"), + "expected List → byte[] unbox, got:\n" + content); + assertTrue( + content.contains("body_bytes[body_i] = body.get(body_i)"), + "expected element-by-element unbox loop, got:\n" + content); + // (ptr, len) still lands in the args list — the on-wire shape for + // list under the Canonical ABI. + assertTrue( + content.contains("marshal.memory().write(body_ptr, body_bytes)"), + "expected byte[] write into linear memory, got:\n" + content); + assertTrue( + content.contains("args.add(body_ptr)") && content.contains("args.add(body_bytes.length)"), + "expected (ptr, len) push onto args list, got:\n" + content); + + compileImpl(iface, impl); + } + + @Test + @DisplayName( + "bug 3 (task #39): a WIT `list` for a non-byte element compiles as a TODO instead of a byte[] cast") + void listNonBytesParamDoesNotAssumeByteArray() throws Exception { + // The same LIST branch bug also blew up on non-u8 element types — + // run.exec had `caps: list` and produced + // `List` in the signature. The cast to byte[] fails the + // same way. The fix leaves an honest TODO comment for element kinds + // with no in-memory lowerer wired, and passes the raw List through + // so the class compiles. Reproducer uses `list` — a JDK + // element type keeps the compile-check harness self-contained. + final BindgenInterface iface = + BindgenInterface.builder() + .name("runner") + .addFunction( + BindgenFunction.builder() + .name("exec") + .addParameter("names", BindgenType.list(BindgenType.primitive("string"))) + .build()) + .build(); + + final GeneratedSource impl = generator().generate(Collections.singletonList(iface)).get(0); + final String content = impl.getContent(); + + assertFalse( + content.contains("(byte[]) (Object) names"), + "list must not assume the value is byte[]:\n" + content); + assertTrue( + content.contains("TODO(bindgen): lower list<"), + "expected a TODO marker naming the unlowered element kind, got:\n" + content); + // Falls back to passing the raw list — compileable, runtime-visible. + assertTrue( + content.contains("args.add(names)"), + "expected the raw List to be pushed onto args as a fallback, got:\n" + content); + + compileImpl(iface, impl); + } + + @Test + @DisplayName( + "bug 4 (task #39): the *BindingProvider `create` override uses Class, not raw Class") + void bindingProviderCreateUsesClassOfT() throws Exception { + // Pre-fix, generateBindingProvider emitted the `create` param as raw + // `Class iface` — the SPI signature is ` T create(Class iface, …)`, + // so under erasure the override collided with the parametrised + // super-method ("name clash: same erasure, yet neither overrides the + // other") and the class was rejected as `not abstract`. The `supports` + // override had the same raw-type shape, which downstream projects with + // -Xlint:rawtypes / -Werror could not build. Every interface in + // wasmos:runtime (13 of them) tripped this — 39 of the 45 compile + // errors in the repro were variants of this one bug. + final BindgenInterface iface = + BindgenInterface.builder() + .name("calculator") + .addFunction( + BindgenFunction.builder() + .name("add") + .addParameter("a", BindgenType.primitive("i32")) + .addParameter("b", BindgenType.primitive("i32")) + .returnType(BindgenType.primitive("i32")) + .build()) + .build(); + + // The class-level `generator()` helper disables provider emission + // (mirrors most bugs-file tests, which only exercise Impl bodies). + // This test targets *BindingProvider specifically, so flip that on. + final ImplementationCodeGenerator providerGen = + new ImplementationCodeGenerator( + BindgenConfig.builder() + .packageName(PACKAGE) + .outputDirectory(Path.of("target/test-output")) + .addWitSource(Path.of("test.wit")) + .generateImplementations(true) + .generateServiceLoader(true) + .build()); + final List sources = providerGen.generate(Collections.singletonList(iface)); + GeneratedSource providerSource = null; + for (GeneratedSource s : sources) { + if (s.getClassName().equals("CalculatorBindingProvider")) { + providerSource = s; + } + } + assertNotNull(providerSource, "CalculatorBindingProvider must be generated"); + final String content = providerSource.getContent(); + + // No raw Class parameters — either as `Class iface` on a signature + // line, or as an unparameterised import-form landing next to iface. + assertFalse( + content.contains("Class iface"), + "provider must not name a raw `Class iface` parameter:\n" + content); + // supports() must take Class to mirror the SPI verbatim. + assertTrue( + content.contains("supports(Class iface)"), + "expected `supports(Class iface)`, got:\n" + content); + // create() must take Class so the override lands under the SPI's + // parametrised super-method. + assertTrue( + content.contains(" T create(Class iface,"), + "expected ` T create(Class iface, …)`, got:\n" + content); + } + + @Test + @DisplayName( + "bug 5: a REFERENCE return with no in-memory lifter throws instead of splicing a bare TODO comment") + void referenceReturnEmitsThrowNotBareComment() throws Exception { + // Matches wasmos:runtime info.get-info — signature is `get-info: func() + // -> runtime-info` (a REFERENCE return). Pre-fix, MarshallingStrategy + // .liftReturn's default arm returned `CodeBlock.of("/* TODO: lift + // REFERENCE */")`. The caller spliced that into `return $L;`, producing + // `return /* TODO: lift REFERENCE */;` — a bare comment where javac + // demands an expression. Every wasmos interface returning an unlifted + // complex type (InfoImpl.getInfo, InfoImpl.provider) tripped this. The + // fix mirrors resultDispatch's fallback: emit an + // UnsupportedOperationException throw so the class compiles and the gap + // is explicit at runtime. + final BindgenInterface iface = + BindgenInterface.builder() + .name("info") + .addFunction( + BindgenFunction.builder() + .name("get-info") + .returnType(BindgenType.reference("runtime-info")) + .build()) + .build(); + + final GeneratedSource impl = generator().generate(Collections.singletonList(iface)).get(0); + final String content = impl.getContent(); + + assertFalse( + content.contains("return /* TODO: lift REFERENCE */"), + "reference return must not emit a bare TODO comment in return position:\n" + content); + assertTrue( + content.contains("throw new UnsupportedOperationException"), + "expected an UnsupportedOperationException throw for the unlifted REFERENCE return:\n" + + content); + assertTrue( + content.contains("TODO(bindgen): lift REFERENCE"), + "expected a REFERENCE-tagged TODO in the throw message so post-mortems can grep:\n" + + content); + assertTrue( + content.contains("MarshallingStrategy#liftReturn"), + "expected the throw message to point at MarshallingStrategy#liftReturn:\n" + content); + + compileImpl(iface, impl); + } + + @Test + @DisplayName( + "bug 5 (companion): an OPTION return with no in-memory lifter throws instead of splicing a bare TODO comment") + void optionReturnEmitsThrowNotBareComment() throws Exception { + // Matches wasmos:runtime info.provider — signature is `provider: + // func(feature: string) -> option`. Same default-arm bug as the + // REFERENCE case: liftReturn returned a comment, the caller spliced it + // into a return statement, javac rejected the file. OPTION exercises + // the same fallback path as REFERENCE. + final BindgenInterface iface = + BindgenInterface.builder() + .name("info") + .addFunction( + BindgenFunction.builder() + .name("provider") + .addParameter("feature", BindgenType.primitive("string")) + .returnType(BindgenType.option(BindgenType.primitive("string"))) + .build()) + .build(); + + final GeneratedSource impl = generator().generate(Collections.singletonList(iface)).get(0); + final String content = impl.getContent(); + + assertFalse( + content.contains("return /* TODO: lift OPTION */"), + "option return must not emit a bare TODO comment in return position:\n" + content); + assertTrue( + content.contains("throw new UnsupportedOperationException"), + "expected an UnsupportedOperationException throw for the unlifted OPTION return:\n" + + content); + assertTrue( + content.contains("TODO(bindgen): lift OPTION"), + "expected an OPTION-tagged TODO in the throw message:\n" + content); + + compileImpl(iface, impl); + } + + @Test + @DisplayName( + "bug 2 (companion): a result return lifts the ok arm from the payload offset") + void resultReturnPrimitiveOkPayload() throws Exception { + // Simplest ok-arm shape: primitive payload lifts via a bare + // readI32 at the aligned payload offset. Unit err arm returns null. + final BindgenInterface iface = + BindgenInterface.builder() + .name("counter") + .addFunction( + BindgenFunction.builder() + .name("current") + .addParameter("name", BindgenType.primitive("string")) + .returnType(BindgenType.result(BindgenType.primitive("i32"), null)) + .build()) + .build(); + + final GeneratedSource impl = generator().generate(Collections.singletonList(iface)).get(0); + final String content = impl.getContent(); + + assertTrue( + content.contains("WitResult.ok(marshal.reader().readI32"), + "expected the ok arm to lift an int via readI32:\n" + content); + assertTrue( + content.contains("return WitResult.err(null)"), + "expected the unit-err arm to return WitResult.err(null):\n" + content); + + compileImpl(iface, impl); + } + + // ------------------------------------------------------------------- + // javac harness — compile the generated Impl against a hand-written stub + // of the runtime surface it references, so the test can prove + // compileability without dragging in the api / runtime modules as test + // dependencies of bindgen (a coupling that would go the wrong direction). + // ------------------------------------------------------------------- + + private static final String STUB_SOURCE = + String.join( + "\n", + "package " + PACKAGE + ";", + "import java.util.Optional;", + "// api package stubs — signatures cribbed from webassembly4j-api and", + "// webassembly4j-runtime. Every symbol the generator names must exist", + "// here or javac drops the whole test. New reader methods land here", + "// alongside the generator changes that reference them.", + "class ApiStubs {", + " public interface Instance {", + " Optional function(String name);", + " }", + " public interface Module { void close(); }", + " public interface Engine { void close(); }", + " public interface Function { Object invoke(Object... args); }", + " public interface Memory { void write(long offset, byte[] bytes); }", + " public interface MemoryAllocator { int allocate(int size, int alignment); }", + " public static class MemoryReader {", + " public String readString(int offset) { return null; }", + " public byte[] readBytes(int offset) { return null; }", + " public int readU8(int offset) { return 0; }", + " public boolean readBool(int offset) { return false; }", + " public int readI32(int offset) { return 0; }", + " public long readI64(int offset) { return 0L; }", + " public float readF32(int offset) { return 0f; }", + " public double readF64(int offset) { return 0d; }", + " }", + " public static class MarshalContext {", + " public static MarshalContext fromInstance(Instance i) { return new MarshalContext(); }", + " public Memory memory() { return null; }", + " public MemoryAllocator allocator() { return null; }", + " public MemoryReader reader() { return null; }", + " }", + " public static class StringCodec {", + " public static int[] encode(String s, Memory m, MemoryAllocator a) { return new int[2]; }", + " }", + "}"); + + private static void compileImpl(BindgenInterface iface, GeneratedSource impl) throws Exception { + final JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertNotNull(compiler, "JDK javac not available; run tests on a JDK not a JRE"); + + // Rewrite the generated Impl to point at our in-test stub package so + // javac can resolve every symbol. The rewrite is text-level (safe: the + // generator writes fully-qualified imports in a stable, deterministic + // form). + final String rewrittenImpl = + impl.getContent() + .replace( + "import ai.tegmentum.webassembly4j.api.Instance;", + "import " + PACKAGE + ".ApiStubs.Instance;") + .replace( + "import ai.tegmentum.webassembly4j.api.Module;", + "import " + PACKAGE + ".ApiStubs.Module;") + .replace( + "import ai.tegmentum.webassembly4j.api.Engine;", + "import " + PACKAGE + ".ApiStubs.Engine;") + .replace( + "import ai.tegmentum.webassembly4j.api.Function;", + "import " + PACKAGE + ".ApiStubs.Function;") + .replace( + "import ai.tegmentum.webassembly4j.runtime.marshal.MarshalContext;", + "import " + PACKAGE + ".ApiStubs.MarshalContext;") + .replace( + "import ai.tegmentum.webassembly4j.runtime.marshal.StringCodec;", + "import " + PACKAGE + ".ApiStubs.StringCodec;"); + + final List units = new ArrayList<>(); + units.add(sourceFile(PACKAGE + "." + impl.getClassName(), rewrittenImpl)); + units.add(stubInterfaceFor(iface)); + // Sibling record referenced by bug 2's Impl in the error-side of + // WitResult<..., Error>. Idempotent for tests that don't reference it. + units.add(sourceFile(PACKAGE + ".Error", "package " + PACKAGE + "; public class Error {}")); + // Stub every REFERENCE type the interface pulls in (as a param, return, + // or result-arm payload). New in bug 5 coverage — REFERENCE returns + // reach the compileImpl harness now that liftReturn no longer swallows + // them into a bare comment. + final java.util.Set referencedTypes = new java.util.HashSet<>(); + for (BindgenFunction f : iface.getFunctions()) { + for (var p : f.getParameters()) { + collectReferencedTypes(p.getType(), referencedTypes); + } + if (f.hasReturnType()) { + collectReferencedTypes(f.getReturnType().get(), referencedTypes); + } + } + referencedTypes.remove("Error"); // already added above + for (String ref : referencedTypes) { + units.add(sourceFile(PACKAGE + "." + ref, "package " + PACKAGE + "; public class " + ref + " {}")); + } + units.add(sourceFile(PACKAGE + ".ApiStubs", STUB_SOURCE)); + + final StringWriter diagnostics = new StringWriter(); + final String cp = System.getProperty("java.class.path"); + final boolean ok; + try (var fm = compiler.getStandardFileManager(null, null, StandardCharsets.UTF_8)) { + ok = + compiler + .getTask( + new PrintWriter(diagnostics), + fm, + null, + List.of("-classpath", cp, "-proc:none"), + null, + units) + .call(); + } + if (!ok) { + fail( + "javac failed on generated " + + impl.getClassName() + + ":\n" + + diagnostics + + "\n---SOURCE---\n" + + rewrittenImpl); + } + } + + /** + * Build a minimal interface source that matches the parent surface the + * generated {@code *Impl} implements. Signatures mirror what + * {@link ImplementationCodeGenerator#generateMethod} would produce so the + * {@code @Override} annotations resolve. + */ + private static JavaFileObject stubInterfaceFor(BindgenInterface iface) { + final StringBuilder sb = new StringBuilder(); + sb.append("package ").append(PACKAGE).append(";\n"); + sb.append("import java.util.List;\n"); + sb.append("import java.util.Optional;\n"); + sb.append("import ai.tegmentum.webassembly4j.bindgen.wit.WitResult;\n"); + sb.append("public interface ").append(pascal(iface.getName())).append(" {\n"); + for (BindgenFunction f : iface.getFunctions()) { + sb.append(" ").append(signature(f)).append(";\n"); + } + sb.append("}\n"); + return sourceFile(PACKAGE + "." + pascal(iface.getName()), sb.toString()); + } + + private static String signature(BindgenFunction f) { + final StringBuilder sb = new StringBuilder(); + sb.append(returnDecl(f)).append(' ').append(camel(f.getName())).append('('); + boolean first = true; + for (var p : f.getParameters()) { + if (!first) { + sb.append(", "); + } + first = false; + sb.append(typeDecl(p.getType())).append(' ').append(camel(p.getName())); + } + sb.append(')'); + return sb.toString(); + } + + private static String returnDecl(BindgenFunction f) { + if (!f.hasReturnType()) { + return "void"; + } + return typeDecl(f.getReturnType().get()); + } + + /** + * Walk a bindgen type tree and add the Pascal-cased Java class-name of + * every REFERENCE leaf to {@code sink}. Used by {@link #compileImpl} to + * stub the sibling classes the generated Impl expects on the classpath. + */ + private static void collectReferencedTypes(BindgenType t, java.util.Set sink) { + if (t == null) { + return; + } + switch (t.getKind()) { + case REFERENCE: + sink.add(pascal(t.getReferencedTypeName().orElse(t.getName()))); + break; + case LIST: + case OPTION: + collectReferencedTypes(t.getElementType().orElse(null), sink); + break; + case RESULT: + collectReferencedTypes(t.getOkType().orElse(null), sink); + collectReferencedTypes(t.getErrorType().orElse(null), sink); + break; + default: + break; + } + } + + private static String typeDecl(BindgenType t) { + switch (t.getKind()) { + case PRIMITIVE: + return primitiveDecl(t.getName()); + case LIST: + return "List<" + boxDecl(t.getElementType().get()) + ">"; + case OPTION: + return "Optional<" + boxDecl(t.getElementType().get()) + ">"; + case RESULT: + String ok = t.getOkType().map(ImplementationCodeGeneratorBugsTest::boxDecl).orElse("Void"); + String err = + t.getErrorType().map(ImplementationCodeGeneratorBugsTest::boxDecl).orElse("Void"); + return "WitResult<" + ok + ", " + err + ">"; + case REFERENCE: + return pascal(t.getReferencedTypeName().orElse(t.getName())); + default: + return "Object"; + } + } + + private static String boxDecl(BindgenType t) { + if (t.getKind() == BindgenType.Kind.PRIMITIVE) { + switch (t.getName().toLowerCase()) { + case "u8": + case "s8": + return "Byte"; + case "u16": + case "s16": + return "Short"; + case "u32": + case "s32": + case "i32": + case "char": + return "Integer"; + case "u64": + case "s64": + case "i64": + return "Long"; + case "f32": + case "float32": + return "Float"; + case "f64": + case "float64": + return "Double"; + case "bool": + return "Boolean"; + case "string": + return "String"; + default: + return "Object"; + } + } + return typeDecl(t); + } + + private static String primitiveDecl(String name) { + switch (name.toLowerCase()) { + case "u8": + case "s8": + return "byte"; + case "u16": + case "s16": + return "short"; + case "u32": + case "s32": + case "i32": + case "char": + return "int"; + case "u64": + case "s64": + case "i64": + return "long"; + case "f32": + case "float32": + return "float"; + case "f64": + case "float64": + return "double"; + case "bool": + return "boolean"; + case "string": + return "String"; + default: + throw new IllegalArgumentException("unhandled primitive: " + name); + } + } + + private static String pascal(String kebab) { + final StringBuilder sb = new StringBuilder(); + boolean up = true; + for (int i = 0; i < kebab.length(); i++) { + final char c = kebab.charAt(i); + if (c == '-' || c == '_') { + up = true; + } else if (up) { + sb.append(Character.toUpperCase(c)); + up = false; + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static String camel(String kebab) { + final String p = pascal(kebab); + if (p.isEmpty()) { + return p; + } + return Character.toLowerCase(p.charAt(0)) + p.substring(1); + } + + private static JavaFileObject sourceFile(String qualifiedName, String content) { + return new SimpleJavaFileObject( + URI.create("string:///" + qualifiedName.replace('.', '/') + ".java"), + JavaFileObject.Kind.SOURCE) { + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) { + return content; + } + }; + } +} diff --git a/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/generator/InterfaceNestedTypeCollisionTest.java b/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/generator/InterfaceNestedTypeCollisionTest.java new file mode 100644 index 0000000..f70e4db --- /dev/null +++ b/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/generator/InterfaceNestedTypeCollisionTest.java @@ -0,0 +1,205 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ai.tegmentum.webassembly4j.bindgen.generator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ai.tegmentum.webassembly4j.bindgen.BindgenConfig; +import ai.tegmentum.webassembly4j.bindgen.BindgenException; +import ai.tegmentum.webassembly4j.bindgen.CodeStyle; +import ai.tegmentum.webassembly4j.bindgen.GeneratedSource; +import ai.tegmentum.webassembly4j.bindgen.model.BindgenField; +import ai.tegmentum.webassembly4j.bindgen.model.BindgenFunction; +import ai.tegmentum.webassembly4j.bindgen.model.BindgenInterface; +import ai.tegmentum.webassembly4j.bindgen.model.BindgenModel; +import ai.tegmentum.webassembly4j.bindgen.model.BindgenType; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Regression coverage for the bindgen bug where WIT permits (but Java + * forbids) an interface and a nested type sharing the same identifier. + * + *

Live example: {@code wasmos:runtime/provider-manifest.wit} declares + * {@code interface provider-manifest { record provider-manifest {...} }}. + * Both compile to {@code ProviderManifest.java} in the same target Java + * package. The generator wrote the interface, then the record overwrote + * it, and the emitted {@code ProviderManifestImpl implements + * ProviderManifest} failed to compile with "interface expected here" + * because {@code ProviderManifest} was now a class, not an interface. + * + *

Resolution — {@link JavaCodeGenerator#shouldSkipInterface}: + * + *

    + *
  • Interface has zero functions: emit the nested types, drop the + * empty interface + its Impl + its BindingProvider. The interface + * was effectively a namespace; nothing user-visible is lost. + *
  • Interface has functions: throw {@link BindgenException}. Silently + * dropping methods would delete API. The message directs the user + * to rename either identifier in the WIT source. + *
+ */ +@DisplayName("Interface / nested-type name-collision handling") +class InterfaceNestedTypeCollisionTest { + + private static final String PACKAGE = "ai.tegmentum.bindgen.generated.collision"; + + private static BindgenConfig config() { + return BindgenConfig.builder() + .packageName(PACKAGE) + .outputDirectory(Path.of("target/test-output")) + .codeStyle(CodeStyle.MODERN) + .addWitSource(Path.of("test.wit")) + .generateImplementations(true) + .generateServiceLoader(true) + .build(); + } + + /** Build the {@code interface provider-manifest { record provider-manifest {...} }} shape. */ + private static BindgenInterface collidingInterface(boolean withFunction) { + final BindgenType record = + BindgenType.builder() + .name("provider-manifest") + .kind(BindgenType.Kind.RECORD) + .addField(new BindgenField("priority", BindgenType.primitive("u32"))) + .build(); + final BindgenInterface.Builder builder = + BindgenInterface.builder().name("provider-manifest").addType(record); + if (withFunction) { + builder.addFunction( + BindgenFunction.builder() + .name("dummy") + .returnType(BindgenType.primitive("u32")) + .build()); + } + return builder.build(); + } + + @Test + @DisplayName( + "namespace-shaped interface (no functions) with a colliding nested record: emit the record," + + " drop the interface + Impl + BindingProvider") + void collidingNamespaceInterfaceIsSkipped() throws Exception { + final BindgenModel model = + BindgenModel.builder().addInterface(collidingInterface(/* withFunction= */ false)).build(); + + final ModernCodeGenerator generator = new ModernCodeGenerator(config()); + final List sources = generator.generate(model); + + // Bucket by class name so we can assert presence/absence. + final Map byName = new HashMap<>(); + for (final GeneratedSource source : sources) { + // Class-name collisions between separate GeneratedSources land in the + // same bucket; overwrites here mirror the on-disk overwrite pattern + // that the pre-fix generator produced. + byName.put(source.getClassName(), source); + } + + // Positive pin — the record is still emitted. + assertTrue( + byName.containsKey("ProviderManifest"), + "the nested record must still be generated: " + byName.keySet()); + assertTrue( + byName.get("ProviderManifest").getContent().contains("private final int priority"), + "expected the record class body (priority field), got:\n" + + byName.get("ProviderManifest").getContent()); + + // Negative pins — Impl + BindingProvider both drop because the parent + // interface was skipped (nothing to implement). + assertFalse( + byName.containsKey("ProviderManifestImpl"), + "the empty-namespace interface must not emit an Impl: " + byName.keySet()); + assertFalse( + byName.containsKey("ProviderManifestBindingProvider"), + "the empty-namespace interface must not emit a BindingProvider: " + byName.keySet()); + + // Only ONE source with class name ProviderManifest — the record wins + // the slot and the interface is dropped entirely (no overwrite race). + int count = 0; + for (final GeneratedSource source : sources) { + if ("ProviderManifest".equals(source.getClassName())) { + count++; + } + } + assertEquals( + 1, + count, + "expected exactly one ProviderManifest source (the record), got: " + count); + } + + @Test + @DisplayName( + "interface with functions and a colliding nested record: hard-fail (rename required)") + void collidingInterfaceWithFunctionsThrows() { + final BindgenModel model = + BindgenModel.builder().addInterface(collidingInterface(/* withFunction= */ true)).build(); + + final ModernCodeGenerator generator; + try { + generator = new ModernCodeGenerator(config()); + } catch (final Exception e) { + throw new AssertionError("generator construction should not throw", e); + } + final BindgenException e = + assertThrows(BindgenException.class, () -> generator.generate(model)); + final String msg = e.getMessage(); + assertNotNull(msg, "the exception must carry a message"); + // The message must name the interface, the collision, and steer the + // user to a fix (rename either identifier in WIT). + assertTrue( + msg.contains("provider-manifest"), + "expected the WIT identifier in the error message, got: " + msg); + assertTrue( + msg.contains("Rename") || msg.contains("rename"), + "expected the error to direct the user to rename, got: " + msg); + } + + @Test + @DisplayName("non-colliding interface + nested record: both emit normally (baseline)") + void nonCollidingInterfaceStillGenerates() throws Exception { + // Distinct identifiers — same shape as the colliding case but the + // record's name differs. Confirms the skip logic only fires on real + // collisions and doesn't regress the common case. + final BindgenType record = + BindgenType.builder() + .name("manifest-body") + .kind(BindgenType.Kind.RECORD) + .addField(new BindgenField("priority", BindgenType.primitive("u32"))) + .build(); + final BindgenInterface iface = + BindgenInterface.builder().name("provider-manifest").addType(record).build(); + final BindgenModel model = BindgenModel.builder().addInterface(iface).build(); + + final ModernCodeGenerator generator = new ModernCodeGenerator(config()); + final List sources = generator.generate(model); + + final Map byName = new HashMap<>(); + for (final GeneratedSource source : sources) { + byName.put(source.getClassName(), source); + } + assertTrue(byName.containsKey("ProviderManifest"), "interface still emits"); + assertTrue(byName.containsKey("ManifestBody"), "record still emits"); + assertTrue(byName.containsKey("ProviderManifestBindingProvider"), "provider still emits"); + } +} diff --git a/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/generator/NestedContainerFieldTest.java b/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/generator/NestedContainerFieldTest.java new file mode 100644 index 0000000..49f63dc --- /dev/null +++ b/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/generator/NestedContainerFieldTest.java @@ -0,0 +1,120 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ai.tegmentum.webassembly4j.bindgen.generator; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ai.tegmentum.webassembly4j.bindgen.BindgenConfig; +import ai.tegmentum.webassembly4j.bindgen.CodeGenerator; +import ai.tegmentum.webassembly4j.bindgen.CodeStyle; +import ai.tegmentum.webassembly4j.bindgen.GeneratedSource; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Regression: a record field with a deeply-nested container type — for + * example {@code list>>}, the shape wasmos:runtime + * uses for HTTP/WebSocket headers — must have every inner container mapped + * through the type mapper. Before the fix, TUPLE was not handled by + * {@code JavaCodeGenerator.mapType}: it fell through to the REFERENCE + * default, which handed {@code mapGeneratedType} the raw WIT text + * {@code "tuple>"} — JavaPoet embedded that verbatim as + * {@code Tuple>}, and the generated code failed to + * compile with {@code symbol: class u8} / {@code symbol: class string}. + * + *

The fix threads TUPLE through {@code TypeMappingRegistry.mapTuple}, + * which now emits parameterised {@code java.util.Map.Entry} for + * 2-tuples (JDK type, self-contained) and a parameterised {@code TupleN} + * placeholder for larger tuples. + */ +@DisplayName("bindgen record fields with deeply-nested container types") +class NestedContainerFieldTest { + + private static final String PACKAGE = "com.example.fixture"; + + private static Path tupleWit; + + @BeforeAll + static void locateFixture() throws URISyntaxException { + final URL resource = + NestedContainerFieldTest.class.getClassLoader().getResource("tuple-fixture/tuple.wit"); + assertNotNull(resource, "tuple-fixture/tuple.wit missing from test resources"); + tupleWit = Path.of(URLDecoder.decode(resource.toURI().getPath(), StandardCharsets.UTF_8)); + } + + @Test + @DisplayName( + "record field list>> generates real Java, not raw WIT text") + void nestedListTupleListRecordFieldGeneratesValidJava(@TempDir Path outputDir) throws Exception { + final BindgenConfig config = + BindgenConfig.builder() + .packageName(PACKAGE) + .outputDirectory(outputDir) + .codeStyle(CodeStyle.MODERN) + .addWitSource(tupleWit) + .generateImplementations(false) + .generateServiceLoader(false) + .build(); + + final CodeGenerator generator = new CodeGenerator(config); + final List sources = generator.generate(); + + final Map byClass = new HashMap<>(); + for (final GeneratedSource source : sources) { + byClass.put(source.getClassName(), source.getContent()); + } + + assertTrue(byClass.containsKey("Request"), "Request record must be generated"); + final String request = byClass.get("Request"); + + // Regression pins: none of these raw WIT tokens should leak into Java + // source. Before the fix `Tuple>` appeared verbatim + // as the field type on the emitted record. + assertFalse( + request.contains("Tuple):\n" + request); + assertFalse( + request.contains("list"), + "raw WIT container text leaked into generated Java (list):\n" + request); + assertFalse( + request.contains(" string "), + "lowercase primitive `string` leaked as an identifier — should be java.lang.String:\n" + + request); + assertFalse( + request.contains(" u8 "), + "primitive `u8` leaked as an identifier — should be byte / java.lang.Byte:\n" + request); + + // Positive pin: the field's Java type must be a real parameterised + // List>>. We match on the boxed form + // because record fields box their generic params. + assertTrue( + request.contains("List>>") + || request.contains("List>>"), + "expected List>> field type, got:\n" + request); + } +} diff --git a/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/generator/TypeMappingRegistryTest.java b/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/generator/TypeMappingRegistryTest.java index 12d8634..61c4b24 100644 --- a/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/generator/TypeMappingRegistryTest.java +++ b/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/generator/TypeMappingRegistryTest.java @@ -375,21 +375,32 @@ void shouldReturnElementTypeForSingleElement() { } @Test - @DisplayName("should return Tuple2 for two-element tuple") - void shouldReturnTuple2ForTwoElements() { + @DisplayName("should return parameterised Map.Entry for two-element tuple") + void shouldReturnMapEntryForTwoElements() { + // 2-tuples map to java.util.Map.Entry — a JDK type, so the + // generated source compiles with no extra runtime dependency. Covers + // the wasmos `list>>` headers shape. TypeName result = registry.mapTuple(Arrays.asList(TypeName.INT, TypeName.LONG)); - assertEquals(TEST_PACKAGE + ".tuple.Tuple2", result.toString()); + assertEquals( + "java.util.Map.Entry", result.toString()); } @Test - @DisplayName("should return TupleN for N-element tuple") + @DisplayName("should return parameterised TupleN for N-element tuple") void shouldReturnTupleNForNElements() { + // For arities > 2 we still emit the TupleN placeholder — but + // parameterised, so the type parameters aren't lost. Consumers who + // need larger tuples supply their own {basePackage}.tuple.TupleN. TypeName result = registry.mapTuple( Arrays.asList(TypeName.INT, TypeName.LONG, TypeName.FLOAT, TypeName.DOUBLE)); - assertEquals(TEST_PACKAGE + ".tuple.Tuple4", result.toString()); + assertEquals( + TEST_PACKAGE + + ".tuple.Tuple4", + result.toString()); } @Test diff --git a/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/wasmos/WasmosEmbedderShapeTest.java b/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/wasmos/WasmosEmbedderShapeTest.java new file mode 100644 index 0000000..a922948 --- /dev/null +++ b/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/wasmos/WasmosEmbedderShapeTest.java @@ -0,0 +1,255 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ai.tegmentum.webassembly4j.bindgen.wasmos; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import ai.tegmentum.webassembly4j.bindgen.BindgenConfig; +import ai.tegmentum.webassembly4j.bindgen.BindgenException; +import ai.tegmentum.webassembly4j.bindgen.CodeGenerator; +import ai.tegmentum.webassembly4j.bindgen.CodeStyle; +import ai.tegmentum.webassembly4j.bindgen.GeneratedSource; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import javax.tools.JavaCompiler; +import javax.tools.ToolProvider; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Shape-check test for the {@code wasmos:host/embedder@0.1.0} WIT world. + * + *

This is the JVM lane's proof-of-shape per ADR-006: bindgen consumes the + * embedder WIT and produces a Java surface a JVM embedder can compile + * against without hand-writing anything except the native {@code + * wasmos_start} shim. + * + *

The test: + * + *

    + *
  1. Runs {@link CodeGenerator} against the fixture copy of {@code + * embedder.wit} (kept under {@code src/test/resources/wasmos-embedder/}). + * Implementation classes are disabled here because the generated + * {@code *Impl} bodies reference the {@code webassembly4j-api} and + * {@code webassembly4j-runtime} types (Instance / Module / Engine / + * Function / MarshalContext) which aren't test dependencies of the + * bindgen module. The generator-level bugs the impl bodies used to hit + * (parameter/local collision on {@code args}, comment-in-return for + * {@code result}) are covered by + * {@link ai.tegmentum.webassembly4j.bindgen.generator.ImplementationCodeGeneratorBugsTest}, + * which compiles the generated Impl against inline stubs of those + * types. A full impl-on shape check is a separate wiring task + * (test-scope coupling to the api/runtime modules). + *
  2. Asserts each file in the expected surface exists and contains the + * right members. + *
  3. Feeds every generated file to the platform {@link JavaCompiler} and + * fails on any javac diagnostic. + *
+ */ +@DisplayName("wasmos:host/embedder@0.1.0 shape check") +class WasmosEmbedderShapeTest { + + private static final String WIT_RESOURCE = "wasmos-embedder/embedder.wit"; + private static final String PACKAGE = "ai.tegmentum.wasmos.embedder.generated"; + + private static Path witFixture; + + @BeforeAll + static void locateFixture() throws URISyntaxException { + final URL resource = WasmosEmbedderShapeTest.class.getClassLoader().getResource(WIT_RESOURCE); + assertNotNull(resource, "fixture " + WIT_RESOURCE + " missing from test resources"); + witFixture = Path.of(URLDecoder.decode(resource.toURI().getPath(), StandardCharsets.UTF_8)); + } + + @Test + @DisplayName("bindgen generates the expected embedder surface and it compiles") + void generatesAndCompiles(@TempDir Path outputDir) throws Exception { + final BindgenConfig config = + BindgenConfig.builder() + .packageName(PACKAGE) + .outputDirectory(outputDir) + .codeStyle(CodeStyle.MODERN) + .addWitSource(witFixture) + .generateImplementations(false) + .generateServiceLoader(false) + .build(); + + final CodeGenerator generator = new CodeGenerator(config); + final List sources = generator.generate(); + // ADR-006 hoisted the embedder-api wrapper into the world root, so the + // empty `EmbedderApi` carrier is gone. The world now projects six + // top-level types/resources + the `embedder-callbacks` interface. + assertTrue(sources.size() >= 6, "expected at least 6 generated sources, got " + sources.size()); + + final Map byClass = toSourceMap(sources); + + // Sanity: every top-level type / resource / interface is present. + assertTrue(byClass.containsKey("ErrorCode"), "ErrorCode enum missing"); + assertTrue(byClass.containsKey("Error"), "Error record missing"); + assertTrue(byClass.containsKey("ImportSatisfaction"), "ImportSatisfaction record missing"); + assertTrue(byClass.containsKey("HostProvider"), "HostProvider resource missing"); + assertTrue(byClass.containsKey("RuntimeInstance"), "RuntimeInstance resource missing"); + assertTrue(byClass.containsKey("EmbedderCallbacks"), "EmbedderCallbacks interface missing"); + // The hoisted world must NOT re-emit an empty `EmbedderApi` (or `Embedder`) + // carrier — that's the whole point of the hoist. + assertTrue( + !byClass.containsKey("EmbedderApi"), + "EmbedderApi carrier must not be generated after the world-level hoist"); + assertTrue( + !byClass.containsKey("Embedder"), + "empty world carrier `Embedder` must not be generated"); + + // ErrorCode: all 7 WIT variants. + final String errorCode = byClass.get("ErrorCode"); + for (final String v : + List.of( + "INVALID_COMPONENT", + "UNSATISFIED_IMPORT", + "MISSING_EXPORT", + "HOST_CALLBACK_FAILED", + "TRAP", + "WIT_MISMATCH", + "OTHER")) { + assertTrue(errorCode.contains(v), "ErrorCode missing variant " + v); + } + + // Error record: has both fields. + final String errorType = byClass.get("Error"); + assertTrue(errorType.contains("ErrorCode code"), "Error missing 'code' field"); + assertTrue(errorType.contains("String message"), "Error missing 'message' field"); + + // RuntimeInstance: static instantiate + instance methods. + final String rti = byClass.get("RuntimeInstance"); + assertTrue( + rti.contains("public static") && rti.contains("instantiate("), + "RuntimeInstance.instantiate must be public static"); + assertTrue(rti.contains("callExport("), "RuntimeInstance.callExport missing"); + assertTrue(rti.contains("introspect()"), "RuntimeInstance.introspect missing"); + assertTrue(rti.contains("verifyWorld("), "RuntimeInstance.verifyWorld missing"); + assertTrue(rti.contains("implements AutoCloseable"), "RuntimeInstance must be AutoCloseable"); + + // HostProvider: static create(interfaceName, numFuncs) from the WIT constructor. + final String hp = byClass.get("HostProvider"); + assertTrue( + hp.contains("public static HostProvider create(String interfaceName, int numFuncs)"), + "HostProvider must expose a create(String, int) factory for the WIT constructor"); + assertTrue(hp.contains("implements AutoCloseable"), "HostProvider must be AutoCloseable"); + + // EmbedderCallbacks: three abstract methods. + final String cb = byClass.get("EmbedderCallbacks"); + assertTrue(cb.contains("public interface EmbedderCallbacks"), "EmbedderCallbacks must be interface"); + assertTrue(cb.contains("hostCall("), "EmbedderCallbacks missing hostCall"); + assertTrue(cb.contains("nowMonotonicNs()"), "EmbedderCallbacks missing nowMonotonicNs"); + assertTrue(cb.contains("readRandom("), "EmbedderCallbacks missing readRandom"); + + // Determinism: same input, same generated content. + final CodeGenerator generator2 = new CodeGenerator(config); + final Map secondPass = toSourceMap(generator2.generate()); + assertEquals(byClass.keySet(), secondPass.keySet(), "class set must be deterministic"); + for (final String className : byClass.keySet()) { + assertEquals(byClass.get(className), secondPass.get(className), "content drift in " + className); + } + + // Compile every generated file with the platform javac. This is what + // gives the shape check teeth — regex assertions only prove the text is + // there, not that the whole surface is a valid Java program. + for (final GeneratedSource src : sources) { + src.writeTo(outputDir); + } + compileGeneratedSurface(outputDir); + } + + private static Map toSourceMap(final List sources) { + final Map map = new HashMap<>(); + for (final GeneratedSource src : sources) { + map.put(src.getClassName(), src.getContent()); + } + return map; + } + + private static void compileGeneratedSurface(final Path outputDir) throws Exception { + final JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertNotNull( + compiler, + "No JavaCompiler available on the running JDK — run the test suite on a JDK, not a JRE."); + + final List javaFiles = new ArrayList<>(); + try (Stream paths = Files.walk(outputDir)) { + paths + .filter(p -> p.toString().endsWith(".java")) + .forEach(p -> javaFiles.add(p.toFile())); + } + if (javaFiles.isEmpty()) { + fail("No generated .java files found under " + outputDir); + } + + final Path classDir = outputDir.resolve("classes"); + Files.createDirectories(classDir); + + // Provide the classpath: WitResult (in bindgen module classes) is + // referenced by the generated code. Reuse the running test's classpath. + final String cp = System.getProperty("java.class.path"); + final List options = new ArrayList<>(); + options.add("-d"); + options.add(classDir.toString()); + options.add("-source"); + options.add("11"); + options.add("-target"); + options.add("11"); + options.add("-classpath"); + options.add(cp); + + final ByteArrayOutputStream errBuf = new ByteArrayOutputStream(); + final StringWriter diagnosticSink = new StringWriter(); + final int rc; + try (var fileManager = compiler.getStandardFileManager(null, null, StandardCharsets.UTF_8)) { + final Iterable compilationUnits = + fileManager.getJavaFileObjectsFromFiles(javaFiles); + rc = + compiler + .getTask( + new PrintWriter(diagnosticSink), fileManager, null, options, null, compilationUnits) + .call() + ? 0 + : 1; + } + if (rc != 0) { + fail( + "javac failed on generated surface:\n" + + diagnosticSink + + "\n" + + errBuf.toString(StandardCharsets.UTF_8)); + } + } +} diff --git a/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/wasmos/WasmosEmbedderSpiDispatchTest.java b/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/wasmos/WasmosEmbedderSpiDispatchTest.java new file mode 100644 index 0000000..d39f064 --- /dev/null +++ b/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/wasmos/WasmosEmbedderSpiDispatchTest.java @@ -0,0 +1,276 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ai.tegmentum.webassembly4j.bindgen.wasmos; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import ai.tegmentum.webassembly4j.bindgen.BindgenConfig; +import ai.tegmentum.webassembly4j.bindgen.CodeGenerator; +import ai.tegmentum.webassembly4j.bindgen.CodeStyle; +import ai.tegmentum.webassembly4j.bindgen.GeneratedSource; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import javax.tools.JavaCompiler; +import javax.tools.ToolProvider; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Bindgen 2.0 SPI-dispatch shape check for the {@code wasmos:host/embedder@0.1.0} WIT world. + * + *

Verifies that when {@code runtimeProviderName} is set, the generator: + * + *

    + *
  1. Emits a new {@code EmbedderRuntime} interface + {@code EmbedderRuntimeRegistry} class in + * the same target package. + *
  2. Rewrites every resource method body to dispatch through the registry (no more {@code throw + * new UnsupportedOperationException}). + *
  3. Still compiles cleanly through the platform javac. + *
  4. Produces byte-identical output across two consecutive runs (determinism). + *
+ */ +@DisplayName("bindgen 2.0 SPI dispatch: wasmos:host/embedder@0.1.0") +class WasmosEmbedderSpiDispatchTest { + + private static final String WIT_RESOURCE = "wasmos-embedder/embedder.wit"; + private static final String PACKAGE = "ai.tegmentum.wasmos.embedder.generated"; + private static final String SPI_NAME = "EmbedderRuntime"; + + private static Path witFixture; + + @BeforeAll + static void locateFixture() throws URISyntaxException { + final URL resource = + WasmosEmbedderSpiDispatchTest.class.getClassLoader().getResource(WIT_RESOURCE); + assertNotNull(resource, "fixture " + WIT_RESOURCE + " missing from test resources"); + witFixture = Path.of(URLDecoder.decode(resource.toURI().getPath(), StandardCharsets.UTF_8)); + } + + @Test + @DisplayName("SPI mode emits interface + registry and dispatches resource bodies") + void spiModeGeneratesDispatchingSurface(@TempDir Path outputDir) throws Exception { + final BindgenConfig config = + BindgenConfig.builder() + .packageName(PACKAGE) + .outputDirectory(outputDir) + .codeStyle(CodeStyle.MODERN) + .addWitSource(witFixture) + .generateImplementations(false) + .generateServiceLoader(false) + .runtimeProviderName(SPI_NAME) + .build(); + + final CodeGenerator generator = new CodeGenerator(config); + final List sources = generator.generate(); + final Map byClass = toSourceMap(sources); + + // SPI interface + registry both present. + assertTrue(byClass.containsKey(SPI_NAME), SPI_NAME + " interface must be generated"); + assertTrue( + byClass.containsKey(SPI_NAME + "Registry"), + SPI_NAME + "Registry class must be generated"); + + final String iface = byClass.get(SPI_NAME); + assertTrue( + iface.contains("public interface " + SPI_NAME), + SPI_NAME + " must be a public interface"); + // Method-per-resource-method: HostProvider constructor + close. + assertTrue(iface.contains("HostProvider hostProviderCreate("), "SPI missing hostProviderCreate"); + assertTrue(iface.contains("void hostProviderClose(long handle)"), "SPI missing hostProviderClose"); + // Method-per-resource-method: RuntimeInstance static + instance methods + close. + assertTrue( + iface.contains("runtimeInstanceInstantiate("), "SPI missing runtimeInstanceInstantiate"); + assertTrue( + iface.contains("runtimeInstanceCallExport(long handle,"), + "SPI missing runtimeInstanceCallExport(long, ...)"); + assertTrue( + iface.contains("runtimeInstanceIntrospect(long handle)"), + "SPI missing runtimeInstanceIntrospect(long)"); + assertTrue( + iface.contains("runtimeInstanceVerifyWorld(long handle,"), + "SPI missing runtimeInstanceVerifyWorld(long, ...)"); + assertTrue( + iface.contains("void runtimeInstanceClose(long handle)"), + "SPI missing runtimeInstanceClose(long)"); + + final String registry = byClass.get(SPI_NAME + "Registry"); + assertTrue( + registry.contains("public static void install(" + SPI_NAME + " provider)"), + "Registry missing install(SPI)"); + assertTrue( + registry.contains("public static " + SPI_NAME + " runtime()"), + "Registry missing runtime()"); + assertTrue(registry.contains("public static void uninstall()"), "Registry missing uninstall()"); + assertTrue( + registry.contains("private static volatile"), + "Registry provider field must be private static volatile"); + + // RuntimeInstance dispatches, doesn't throw. + final String rti = byClass.get("RuntimeInstance"); + assertNotNull(rti, "RuntimeInstance still emitted in SPI mode"); + assertFalse( + rti.contains("throw new UnsupportedOperationException"), + "RuntimeInstance must not throw UnsupportedOperationException in SPI mode:\n" + rti); + assertTrue( + rti.contains(SPI_NAME + "Registry.runtime().runtimeInstanceInstantiate("), + "RuntimeInstance.instantiate must dispatch through registry"); + assertTrue( + rti.contains(SPI_NAME + "Registry.runtime().runtimeInstanceCallExport(this.handle,"), + "RuntimeInstance.callExport must pass this.handle to registry"); + assertTrue( + rti.contains(SPI_NAME + "Registry.runtime().runtimeInstanceClose(this.handle)"), + "RuntimeInstance.close must dispatch through registry"); + + // HostProvider constructor factory dispatches, doesn't throw. + final String hp = byClass.get("HostProvider"); + assertNotNull(hp, "HostProvider still emitted in SPI mode"); + assertFalse( + hp.contains("throw new UnsupportedOperationException"), + "HostProvider must not throw UnsupportedOperationException in SPI mode:\n" + hp); + assertTrue( + hp.contains(SPI_NAME + "Registry.runtime().hostProviderCreate("), + "HostProvider.create must dispatch through registry"); + assertTrue( + hp.contains(SPI_NAME + "Registry.runtime().hostProviderClose(this.handle)"), + "HostProvider.close must dispatch through registry"); + // SPI-mode contract: even for resources with a WIT constructor, the + // handle-holding ctor must be public so an out-of-package SPI impl can + // construct wrapped resources. Legacy mode makes it protected. + assertTrue( + hp.contains("public HostProvider(long handle)"), + "HostProvider(long) must be public in SPI mode for out-of-package impls"); + + // Determinism: same input, same generated content. + final CodeGenerator generator2 = new CodeGenerator(config); + final Map secondPass = toSourceMap(generator2.generate()); + assertEquals(byClass.keySet(), secondPass.keySet(), "class set must be deterministic"); + for (final String className : byClass.keySet()) { + assertEquals( + byClass.get(className), secondPass.get(className), "content drift in " + className); + } + + // Compile the whole SPI-mode surface with platform javac. + for (final GeneratedSource src : sources) { + src.writeTo(outputDir); + } + compileGeneratedSurface(outputDir); + } + + @Test + @DisplayName("legacy mode (no runtimeProviderName) still emits throwing bodies") + void legacyModeUnchanged(@TempDir Path outputDir) throws Exception { + final BindgenConfig config = + BindgenConfig.builder() + .packageName(PACKAGE) + .outputDirectory(outputDir) + .codeStyle(CodeStyle.MODERN) + .addWitSource(witFixture) + .generateImplementations(false) + .generateServiceLoader(false) + .build(); + + final CodeGenerator generator = new CodeGenerator(config); + final Map byClass = toSourceMap(generator.generate()); + assertFalse(byClass.containsKey(SPI_NAME), "SPI interface must NOT emit in legacy mode"); + assertFalse( + byClass.containsKey(SPI_NAME + "Registry"), + "SPI registry must NOT emit in legacy mode"); + assertTrue( + byClass.get("RuntimeInstance").contains("throw new UnsupportedOperationException"), + "Legacy RuntimeInstance bodies must still throw"); + } + + private static Map toSourceMap(final List sources) { + final Map map = new HashMap<>(); + for (final GeneratedSource src : sources) { + map.put(src.getClassName(), src.getContent()); + } + return map; + } + + private static void compileGeneratedSurface(final Path outputDir) throws Exception { + final JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertNotNull(compiler, "No JavaCompiler available on the running JDK."); + + final List javaFiles = new ArrayList<>(); + try (Stream paths = Files.walk(outputDir)) { + paths.filter(p -> p.toString().endsWith(".java")).forEach(p -> javaFiles.add(p.toFile())); + } + if (javaFiles.isEmpty()) { + fail("No generated .java files found under " + outputDir); + } + + final Path classDir = outputDir.resolve("classes"); + Files.createDirectories(classDir); + + final String cp = System.getProperty("java.class.path"); + final List options = new ArrayList<>(); + options.add("-d"); + options.add(classDir.toString()); + options.add("-source"); + options.add("11"); + options.add("-target"); + options.add("11"); + options.add("-classpath"); + options.add(cp); + + final ByteArrayOutputStream errBuf = new ByteArrayOutputStream(); + final StringWriter diagnosticSink = new StringWriter(); + final int rc; + try (var fileManager = compiler.getStandardFileManager(null, null, StandardCharsets.UTF_8)) { + final Iterable compilationUnits = + fileManager.getJavaFileObjectsFromFiles(javaFiles); + rc = + compiler + .getTask( + new PrintWriter(diagnosticSink), + fileManager, + null, + options, + null, + compilationUnits) + .call() + ? 0 + : 1; + } + if (rc != 0) { + fail( + "javac failed on SPI-mode generated surface:\n" + + diagnosticSink + + "\n" + + errBuf.toString(StandardCharsets.UTF_8)); + } + } +} diff --git a/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/wasmos/WasmosRuntimeSpiMergeTest.java b/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/wasmos/WasmosRuntimeSpiMergeTest.java new file mode 100644 index 0000000..4e74f71 --- /dev/null +++ b/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/wasmos/WasmosRuntimeSpiMergeTest.java @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ai.tegmentum.webassembly4j.bindgen.wasmos; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ai.tegmentum.webassembly4j.bindgen.BindgenConfig; +import ai.tegmentum.webassembly4j.bindgen.CodeGenerator; +import ai.tegmentum.webassembly4j.bindgen.CodeStyle; +import ai.tegmentum.webassembly4j.bindgen.GeneratedSource; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Regression: when {@code --runtime-provider} is set and the WIT surface is + * spread across multiple files sharing a package, the generated SPI interface + * MUST contain dispatch methods for every resource across every file — not + * just the last file processed. + * + *

Before the fix, {@code CodeGenerator.generate()} ran the java code + * generator per-file, so each pass emitted its own {@code SplitRuntime.java} + * and the last write overwrote the earlier ones. Downstream: multi-file WIT + * packages (like {@code wasmos:runtime@0.2.0}, which is 11 files) lost every + * interface's methods except the last one's from the merged SPI. + */ +@DisplayName("bindgen 2.0 SPI merge across multi-file WIT packages") +class WasmosRuntimeSpiMergeTest { + + private static final String PACKAGE = "ai.tegmentum.wasmos.split.generated"; + private static final String SPI_NAME = "SplitRuntime"; + + private static Path alphaWit; + private static Path betaWit; + + @BeforeAll + static void locateFixtures() throws URISyntaxException { + alphaWit = resolveFixture("wasmos-runtime-split/alpha.wit"); + betaWit = resolveFixture("wasmos-runtime-split/beta.wit"); + } + + private static Path resolveFixture(final String name) throws URISyntaxException { + final URL resource = WasmosRuntimeSpiMergeTest.class.getClassLoader().getResource(name); + assertNotNull(resource, "fixture " + name + " missing from test resources"); + return Path.of(URLDecoder.decode(resource.toURI().getPath(), StandardCharsets.UTF_8)); + } + + @Test + @DisplayName("SPI interface contains methods from both alpha.wit and beta.wit") + void spiMergesResourcesAcrossFiles(@TempDir Path outputDir) throws Exception { + final BindgenConfig config = + BindgenConfig.builder() + .packageName(PACKAGE) + .outputDirectory(outputDir) + .codeStyle(CodeStyle.MODERN) + .addWitSource(alphaWit) + .addWitSource(betaWit) + .generateImplementations(false) + .generateServiceLoader(false) + .runtimeProviderName(SPI_NAME) + .build(); + + final CodeGenerator generator = new CodeGenerator(config); + final List sources = generator.generate(); + + final Map byClass = new HashMap<>(); + for (final GeneratedSource source : sources) { + byClass.put(source.getClassName(), source.getContent()); + } + + assertTrue(byClass.containsKey(SPI_NAME), SPI_NAME + " interface must be generated"); + assertTrue( + byClass.containsKey(SPI_NAME + "Registry"), + SPI_NAME + "Registry class must be generated"); + + final String iface = byClass.get(SPI_NAME); + assertTrue( + iface.contains("alphaCreate("), + "SPI missing alphaCreate — alpha.wit interface was dropped:\n" + iface); + assertTrue(iface.contains("alphaPing(long handle)"), "SPI missing alphaPing"); + assertTrue(iface.contains("void alphaClose(long handle)"), "SPI missing alphaClose"); + assertTrue( + iface.contains("betaCreate("), + "SPI missing betaCreate — beta.wit interface was dropped:\n" + iface); + assertTrue(iface.contains("betaEcho(long handle,"), "SPI missing betaEcho"); + assertTrue(iface.contains("void betaClose(long handle)"), "SPI missing betaClose"); + } +} diff --git a/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/wit/WitParserTest.java b/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/wit/WitParserTest.java new file mode 100644 index 0000000..d3023c0 --- /dev/null +++ b/webassembly4j-bindgen/src/test/java/ai/tegmentum/webassembly4j/bindgen/wit/WitParserTest.java @@ -0,0 +1,471 @@ +/* + * Copyright 2026 Tegmentum AI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ai.tegmentum.webassembly4j.bindgen.wit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ai.tegmentum.webassembly4j.bindgen.BindgenException; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Native-parser suite for {@link WitParser}. + * + *

Covers every WIT construct the retired + * {@code WitInterfaceParser} + {@code WitWorldPreprocessor} + + * {@code WitResourceBodyParser} wrapper chain used to patch (bug 4 on the + * bindgen bug board): + * + *

    + *
  • {@code package foo:bar@1.2.3;} + *
  • {@code world { ... }} with type items directly in body + *
  • {@code interface { ... }} inside packages or worlds + *
  • {@code use} clauses (skipped, don't confuse the item scanner) + *
  • {@code import} / {@code export} clauses (bare + inline func) + *
  • Resource bodies: constructor + static + instance methods, with + * flags carried on the emitted {@link WitFunction} + *
  • Type aliases and standalone record / variant / enum / flags + *
  • Container types: list / option / result (both operands + * optional) / tuple / borrow / own + *
+ * + *

The suite drives {@link WitParser#parse(String)} directly and asserts + * on the {@link WitDocument} shape so regressions on any of these + * constructs surface here first. + */ +@DisplayName("WitParser — native WIT grammar") +class WitParserTest { + + // --------------------------------------------------------------------------- + // package + world + interface top-level shape + // --------------------------------------------------------------------------- + + @Test + @DisplayName("package declaration with @version parses and is exposed") + void packageWithVersion() throws BindgenException { + final String src = "package wasmos:host@0.1.0;\ninterface x { }"; + final WitDocument doc = WitParser.parse(src); + assertTrue(doc.getPackageName().isPresent(), "package name captured"); + assertEquals("wasmos:host", doc.getPackageName().get()); + assertTrue(doc.getPackageVersion().isPresent(), "package version captured"); + assertEquals("0.1.0", doc.getPackageVersion().get()); + assertEquals(1, doc.getInterfaces().size()); + } + + @Test + @DisplayName("package without version parses") + void packageWithoutVersion() throws BindgenException { + final String src = "package foo:bar;\ninterface x { }"; + final WitDocument doc = WitParser.parse(src); + assertEquals("foo:bar", doc.getPackageName().orElse("")); + assertFalse(doc.getPackageVersion().isPresent(), "no version"); + } + + @Test + @DisplayName("multiple interfaces in one file are surfaced in declaration order") + void multipleInterfaces() throws BindgenException { + final String src = + "package p:q;\n" + + "interface a { foo: func() -> u32; }\n" + + "interface b { bar: func() -> u32; }\n" + + "interface c { baz: func() -> u32; }\n"; + final WitDocument doc = WitParser.parse(src); + assertEquals(3, doc.getInterfaces().size()); + assertEquals("a", doc.getInterfaces().get(0).getName()); + assertEquals("b", doc.getInterfaces().get(1).getName()); + assertEquals("c", doc.getInterfaces().get(2).getName()); + } + + @Test + @DisplayName("world with type-hoisted body parses as an isWorld() entry") + void worldWithHoistedTypes() throws BindgenException { + final String src = + "package p:q;\n" + + "world root {\n" + + " enum color { red, green, blue }\n" + + " record point { x: u32, y: u32 }\n" + + "}\n"; + final WitDocument doc = WitParser.parse(src); + assertEquals(1, doc.getInterfaces().size()); + final WitDocument.ParsedInterface w = doc.getInterfaces().get(0); + assertTrue(w.isWorld(), "world flag must be set"); + assertEquals(2, w.getTypes().size()); + assertTrue(w.getTypes().containsKey("color")); + assertTrue(w.getTypes().containsKey("point")); + } + + // --------------------------------------------------------------------------- + // use / import / export + // --------------------------------------------------------------------------- + + @Test + @DisplayName("use clause is skipped without confusing subsequent items") + void useClauseSkipped() throws BindgenException { + final String src = + "interface i {\n" + + " use foo:bar/baz.{alpha, beta};\n" + + " hello: func() -> string;\n" + + "}\n"; + final WitDocument doc = WitParser.parse(src); + final WitDocument.ParsedInterface pi = doc.getInterfaces().get(0); + assertEquals(1, pi.getFunctions().size()); + assertTrue(pi.getFunctions().containsKey("hello")); + } + + @Test + @DisplayName("bare import/export interface reference is accepted") + void bareImportExport() throws BindgenException { + final String src = + "world w {\n" + + " import some-callbacks;\n" + + " export some-api;\n" + + " record r { a: u32 }\n" + + "}\n"; + final WitDocument doc = WitParser.parse(src); + final WitDocument.ParsedInterface w = doc.getInterfaces().get(0); + assertTrue(w.isWorld()); + // Bare imports/exports don't add types; the record does. + assertEquals(1, w.getTypes().size()); + assertTrue(w.getTypes().containsKey("r")); + } + + @Test + @DisplayName("import name: func(...) -> ... registers the function") + void inlineImportFunc() throws BindgenException { + final String src = + "world w {\n" + " import ping: func(msg: string) -> u32;\n" + "}\n"; + final WitDocument doc = WitParser.parse(src); + final WitDocument.ParsedInterface w = doc.getInterfaces().get(0); + assertTrue(w.getFunctions().containsKey("ping")); + final WitFunction fn = w.getFunctions().get("ping"); + assertEquals(1, fn.getParameters().size()); + assertEquals(1, fn.getReturnTypes().size()); + } + + // --------------------------------------------------------------------------- + // resource bodies (constructor / static / instance) + // --------------------------------------------------------------------------- + + @Test + @DisplayName("resource with constructor + static + instance methods parses with flags") + void resourceMethodsCarryFlags() throws BindgenException { + final String src = + "interface i {\n" + + " resource host-provider {\n" + + " constructor(name: string, count: u32);\n" + + " make: static func(seed: u32) -> host-provider;\n" + + " call: func(idx: u32) -> u32;\n" + + " }\n" + + "}\n"; + final WitDocument doc = WitParser.parse(src); + final WitDocument.ParsedInterface pi = doc.getInterfaces().get(0); + final List methods = pi.getResourceMethods().get("host-provider"); + assertNotNull(methods, "resource methods captured"); + assertEquals(3, methods.size()); + assertTrue(methods.get(0).isConstructor(), "constructor flag"); + assertFalse(methods.get(0).isStatic()); + assertFalse(methods.get(1).isConstructor()); + assertTrue(methods.get(1).isStatic(), "static flag on second method"); + assertFalse(methods.get(2).isConstructor()); + assertFalse(methods.get(2).isStatic(), "third is a plain instance method"); + // Resource itself lands in the types map with RESOURCE kind. + assertTrue(pi.getTypes().containsKey("host-provider")); + assertEquals( + WitTypeCategory.RESOURCE, pi.getTypes().get("host-provider").getKind().getCategory()); + } + + @Test + @DisplayName("empty resource {} produces an empty method list") + void emptyResourceBody() throws BindgenException { + final String src = "interface i { resource opaque { } }"; + final WitDocument doc = WitParser.parse(src); + final WitDocument.ParsedInterface pi = doc.getInterfaces().get(0); + assertTrue(pi.getResourceMethods().containsKey("opaque")); + assertTrue(pi.getResourceMethods().get("opaque").isEmpty()); + } + + @Test + @DisplayName("resource with no body (`resource X;`) still declares the type") + void resourceWithoutBody() throws BindgenException { + final String src = "interface i { resource nothing; }"; + final WitDocument doc = WitParser.parse(src); + final WitDocument.ParsedInterface pi = doc.getInterfaces().get(0); + assertTrue(pi.getTypes().containsKey("nothing")); + assertEquals( + WitTypeCategory.RESOURCE, pi.getTypes().get("nothing").getKind().getCategory()); + } + + // --------------------------------------------------------------------------- + // type aliases + standalone declarations + // --------------------------------------------------------------------------- + + @Test + @DisplayName("standalone record + type-alias record both parse") + void recordShapes() throws BindgenException { + final String src = + "interface i {\n" + + " record standalone { a: u32, b: string }\n" + + " type aliased = record { x: f64, y: f64 };\n" + + "}\n"; + final WitDocument doc = WitParser.parse(src); + final Map types = doc.getInterfaces().get(0).getTypes(); + assertEquals(2, types.size()); + assertEquals(WitTypeCategory.RECORD, types.get("standalone").getKind().getCategory()); + assertEquals(WitTypeCategory.RECORD, types.get("aliased").getKind().getCategory()); + } + + @Test + @DisplayName("variant cases with and without payload parse") + void variantWithMixedCases() throws BindgenException { + final String src = + "interface i {\n" + + " variant msg { empty, text(string), bytes(list) }\n" + + "}\n"; + final WitDocument doc = WitParser.parse(src); + final WitType v = doc.getInterfaces().get(0).getTypes().get("msg"); + assertEquals(WitTypeCategory.VARIANT, v.getKind().getCategory()); + assertEquals(3, v.getKind().getVariantCases().size()); + assertFalse(v.getKind().getVariantCases().get("empty").isPresent()); + assertTrue(v.getKind().getVariantCases().get("text").isPresent()); + assertTrue(v.getKind().getVariantCases().get("bytes").isPresent()); + } + + @Test + @DisplayName("enum and flags parse into ordered value lists") + void enumAndFlags() throws BindgenException { + final String src = + "interface i {\n" + + " enum color { red, green, blue }\n" + + " flags perm { read, write, execute }\n" + + "}\n"; + final WitDocument doc = WitParser.parse(src); + final Map types = doc.getInterfaces().get(0).getTypes(); + assertEquals(List.of("red", "green", "blue"), types.get("color").getKind().getEnumValues()); + assertEquals( + List.of("read", "write", "execute"), types.get("perm").getKind().getFlags()); + } + + // --------------------------------------------------------------------------- + // container types + // --------------------------------------------------------------------------- + + @Test + @DisplayName("list / option / result / tuple / borrow / own parse in signatures") + void containerTypesInFunc() throws BindgenException { + final String src = + "interface i {\n" + + " resource handle { }\n" + + " f: func(a: list, b: option, c: borrow, d: own) " + + "-> result, string>;\n" + + "}\n"; + final WitDocument doc = WitParser.parse(src); + final WitFunction fn = doc.getInterfaces().get(0).getFunctions().get("f"); + assertNotNull(fn, "function parsed"); + assertEquals(4, fn.getParameters().size()); + assertEquals(1, fn.getReturnTypes().size()); + final WitType retType = fn.getReturnTypes().get(0); + assertEquals(WitTypeCategory.RESULT, retType.getKind().getCategory()); + } + + @Test + @DisplayName("result<_, err> and result both parse") + void resultWithPlaceholders() throws BindgenException { + final String src = + "interface i {\n" + + " a: func() -> result<_, string>;\n" + + " b: func() -> result;\n" + + " c: func() -> result;\n" + + "}\n"; + final WitDocument doc = WitParser.parse(src); + final WitDocument.ParsedInterface pi = doc.getInterfaces().get(0); + assertEquals(3, pi.getFunctions().size()); + for (final String name : List.of("a", "b", "c")) { + final WitFunction fn = pi.getFunctions().get(name); + assertEquals(WitTypeCategory.RESULT, fn.getReturnTypes().get(0).getKind().getCategory()); + } + } + + @Test + @DisplayName("nested list> and list> parse") + void nestedContainers() throws BindgenException { + final String src = + "interface i {\n" + + " a: func() -> list>;\n" + + " b: func() -> list>;\n" + + "}\n"; + final WitDocument doc = WitParser.parse(src); + assertEquals(2, doc.getInterfaces().get(0).getFunctions().size()); + } + + @Test + @DisplayName("stream and future parse (coerced to list / option)") + void asyncTypesCoerced() throws BindgenException { + // WASI 0.2+ async types. Before the coercion, `stream` at a record + // field or function signature threw BindgenException at parse time and + // killed every other declaration in the same file (see the wasmos + // http-streaming.wit case). The coercion keeps the surrounding file + // parseable at the cost of documented semantic loss. + final String src = + "interface i {\n" + + " record streaming-response {\n" + + " status: u16,\n" + + " body: stream,\n" + + " }\n" + + " wait-one: func() -> future;\n" + + " stream-of-strings: func() -> stream;\n" + + "}\n"; + final WitDocument doc = WitParser.parse(src); + final WitDocument.ParsedInterface pi = doc.getInterfaces().get(0); + + // The record survived — body field's stream coerced to list. + final WitType record = pi.getTypes().get("streaming-response"); + assertNotNull(record, "record with stream field must parse"); + assertEquals(WitTypeCategory.RECORD, record.getKind().getCategory()); + final WitType bodyField = record.getKind().getRecordFields().get("body"); + assertNotNull(bodyField, "body field present"); + assertEquals(WitTypeCategory.LIST, bodyField.getKind().getCategory(), + "stream must coerce to LIST category"); + + // Both functions parsed. + assertEquals(2, pi.getFunctions().size()); + assertEquals( + WitTypeCategory.OPTION, + pi.getFunctions().get("wait-one").getReturnTypes().get(0).getKind().getCategory(), + "future must coerce to OPTION category"); + assertEquals( + WitTypeCategory.LIST, + pi.getFunctions().get("stream-of-strings").getReturnTypes().get(0).getKind().getCategory(), + "stream must coerce to LIST category"); + } + + // --------------------------------------------------------------------------- + // resource + type-expression interplay + // --------------------------------------------------------------------------- + + @Test + @DisplayName("record field can reference a previously declared record without forward-ref") + void recordFieldReferencesRecord() throws BindgenException { + final String src = + "interface i {\n" + + " record inner { v: u32 }\n" + + " record outer { part: inner }\n" + + "}\n"; + final WitDocument doc = WitParser.parse(src); + final WitType outer = doc.getInterfaces().get(0).getTypes().get("outer"); + assertEquals(WitTypeCategory.RECORD, outer.getKind().getCategory()); + final WitType partType = outer.getKind().getRecordFields().get("part"); + assertEquals("inner", partType.getName()); + } + + @Test + @DisplayName("world with import embedder-callbacks; and resource + record body works end-to-end") + void hoistedWorldWithResource() throws BindgenException { + final String src = + "package wasmos:host@0.1.0;\n" + + "interface embedder-callbacks {\n" + + " ping: func() -> u32;\n" + + "}\n" + + "world embedder {\n" + + " enum code { ok, err }\n" + + " record error { code: code, message: string }\n" + + " resource runtime-instance {\n" + + " instantiate: static func(bytes: list) -> result;\n" + + " call: func(name: string, args: list) -> result, error>;\n" + + " }\n" + + " import embedder-callbacks;\n" + + "}\n"; + final WitDocument doc = WitParser.parse(src); + assertEquals(2, doc.getInterfaces().size()); + final WitDocument.ParsedInterface world = doc.getInterfaces().get(1); + assertTrue(world.isWorld()); + assertTrue(world.getTypes().containsKey("code")); + assertTrue(world.getTypes().containsKey("error")); + assertTrue(world.getTypes().containsKey("runtime-instance")); + final List methods = world.getResourceMethods().get("runtime-instance"); + assertNotNull(methods); + assertEquals(2, methods.size()); + assertTrue(methods.get(0).isStatic(), "instantiate is static"); + assertFalse(methods.get(1).isStatic()); + } + + // --------------------------------------------------------------------------- + // determinism + errors + // --------------------------------------------------------------------------- + + @Test + @DisplayName("same input produces the same interface + type order across parses") + void deterministicOutput() throws BindgenException { + final String src = + "interface i {\n" + + " enum e { a, b }\n" + + " record r { x: u32 }\n" + + " resource h { }\n" + + " first: func();\n" + + " second: func() -> u32;\n" + + "}\n"; + final WitDocument a = WitParser.parse(src); + final WitDocument b = WitParser.parse(src); + assertEquals( + List.copyOf(a.getInterfaces().get(0).getTypes().keySet()), + List.copyOf(b.getInterfaces().get(0).getTypes().keySet())); + assertEquals( + List.copyOf(a.getInterfaces().get(0).getFunctions().keySet()), + List.copyOf(b.getInterfaces().get(0).getFunctions().keySet())); + } + + @Test + @DisplayName("garbage input throws BindgenException") + void garbageThrows() { + assertThrows( + BindgenException.class, () -> WitParser.parse("this is not valid wit source")); + } + + @Test + @DisplayName("line comments and doc comments are stripped without affecting parse") + void commentsIgnored() throws BindgenException { + final String src = + "// leading line comment\n" + + "package p:q;\n" + + "/// doc comment on interface\n" + + "interface i {\n" + + " // between-item comment\n" + + " foo: func() -> u32;\n" + + " /// doc on next item\n" + + " bar: func() -> u32;\n" + + "}\n"; + final WitDocument doc = WitParser.parse(src); + assertEquals(2, doc.getInterfaces().get(0).getFunctions().size()); + } + + @Test + @DisplayName("block comments are stripped without affecting parse") + void blockCommentsIgnored() throws BindgenException { + final String src = + "interface i {\n" + + " /* multi-line\n" + + " block comment */\n" + + " foo: func() -> u32;\n" + + "}\n"; + final WitDocument doc = WitParser.parse(src); + assertEquals(1, doc.getInterfaces().get(0).getFunctions().size()); + } +} diff --git a/webassembly4j-bindgen/src/test/resources/tuple-fixture/tuple.wit b/webassembly4j-bindgen/src/test/resources/tuple-fixture/tuple.wit new file mode 100644 index 0000000..e3a4902 --- /dev/null +++ b/webassembly4j-bindgen/src/test/resources/tuple-fixture/tuple.wit @@ -0,0 +1,14 @@ +// Regression fixture for the deeply-nested container-in-record bug. +// A record field with type `list>>` — the shape +// used by http/http-streaming/websocket in wasmos:runtime — used to +// generate `List>>` (the inner types leak +// through as raw WIT source instead of being mapped to Java types). +// This fixture pins the fix so the surface compiles. +package tegmentum:tuple-fixture@0.1.0; + +interface headers-api { + record request { + headers: list>>, + } + put: func(req: request); +} diff --git a/webassembly4j-bindgen/src/test/resources/wasmos-embedder/embedder.wit b/webassembly4j-bindgen/src/test/resources/wasmos-embedder/embedder.wit new file mode 100644 index 0000000..770dd33 --- /dev/null +++ b/webassembly4j-bindgen/src/test/resources/wasmos-embedder/embedder.wit @@ -0,0 +1,201 @@ +// wasmos:host/embedder — v0.1.0 +// +// The embedder-facing WIT world exported by wasmcm_runtime_guest.wasm. +// Per ADR-006, this is the single surface any embedder (JVM, Rust +// CLI, Node, browser, Python, embedded) integrates against; the +// only hand-written native code per embedder is a `wasmos_start` +// shim that boots the runtime guest on that embedder's Wasm engine. +// Everything else is generated by per-language bindgen consuming +// this world. +// +// Shape: +// - The `world embedder` hoists types and resources directly, so +// generated bindings expose them as top-level classes/records +// without an empty carrier interface (initial v0.1.0 draft had +// an `embedder-api` wrapper; hoisted after webassembly4j-bindgen's +// first output revealed the wrapper was dead weight). +// - `embedder-callbacks` stays as an interface — it's imported +// (the host implements it) and interface-per-import is the +// conventional shape. + +package wasmos:host@0.1.0; + +interface embedder-callbacks { + // ----------------------------------------------------------------- + // Host-implemented import dispatcher. + // + // Called by the guest during `runtime-instance.call-export` + // execution whenever a running component invokes a function on an + // imported interface that was satisfied by a host `host-provider`. + // The embedder is responsible for looking up + // (provider-handle, func-idx) in its registered-provider table + // and invoking the language-level implementation with `args` + // decoded and results encoded per the Canonical ABI (generated + // bindings own the typed encoding). + // + // An error string maps back to `error-code::host-callback-failed` + // on the guest side. + // ----------------------------------------------------------------- + + host-call: func( + provider-handle: u32, + func-idx: u32, + args: list, + ) -> result, string>; + + // ----------------------------------------------------------------- + // Syscall-shaped primitives the runtime guest itself needs from + // the embedder. Distinct from `wasi:clocks` / `wasi:random` + // interfaces implemented by wasi-p2 components — those live one + // layer up and route through `host-call`. These are the runtime + // guest's *own* dependencies. + // ----------------------------------------------------------------- + + // Monotonic nanoseconds since an unspecified epoch. Used by the + // runtime guest for deadline tracking, resource-table generation + // counters, and diagnostic timestamps. No monotonicity guarantee + // across `wasmos_start` calls in the same host process. + now-monotonic-ns: func() -> u64; + + // Fill a buffer with `len` cryptographically random bytes. Used + // by the runtime guest for resource-handle randomization. If + // the host cannot satisfy the request (e.g. missing entropy on + // an embedded target) it returns an empty list; guest treats + // that as unavailable and falls back to deterministic seeding. + read-random: func(len: u32) -> list; +} + +world embedder { + // ----------------------------------------------------------------- + // Errors. + // + // Every fallible export returns `result` with a coded + // reason and a free-form message. Codes let generated bindings + // map cleanly to language-level exception types; the message is + // for diagnostics. + // ----------------------------------------------------------------- + + enum error-code { + // The bytes passed to `runtime-instance.instantiate` did not + // parse or validate as a Component Model binary. + invalid-component, + // A component import name was not satisfied by any entry in + // the `imports` list passed to `instantiate`. + unsatisfied-import, + // `call-export` was invoked with a name not present in the + // instantiated component's exports. + missing-export, + // A host-side dispatcher (via `embedder-callbacks.host-call`) + // returned an error. The `error.message` carries the + // host-side reason string verbatim. + host-callback-failed, + // Guest execution trapped (unreachable, out-of-bounds, + // integer overflow, div-by-zero, etc.). + trap, + // `verify-world` found a structural mismatch against the + // expected WIT source. + wit-mismatch, + // Anything else. Prefer a specific variant when the category + // is known. + other, + } + + record error { + code: error-code, + message: string, + } + + // ----------------------------------------------------------------- + // Host-provided implementations of imported WIT interfaces. + // + // Before `instantiate`, the host registers each WIT interface it + // will satisfy by constructing a `host-provider` — a triple of + // (interface name, function count, opaque handle). The `handle` + // is then referenced from the `imports` list to bind that + // interface. Dispatch of individual function calls goes through + // `embedder-callbacks.host-call`, keyed on (handle, func-idx). + // ----------------------------------------------------------------- + + resource host-provider { + // Register a host-implemented WIT interface. `interface-name` + // is the fully-qualified name (e.g. `wasip2:host/primitives@0.1.0`) + // and `num-funcs` is the count of functions in the interface + // as seen by the guest. The number is used for bounds-checking + // on dispatch; mismatches surface as + // `error-code::host-callback-failed`. + constructor(interface-name: string, num-funcs: u32); + } + + // ----------------------------------------------------------------- + // Import satisfaction records. + // + // Passed as a list to `runtime-instance.instantiate`. Each entry + // pairs an import's interface name (as it appears in the component + // binary) with the `host-provider` handle that satisfies it. + // Ordering does not matter; the runtime matches on name. + // ----------------------------------------------------------------- + + record import-satisfaction { + interface-name: string, + provider: borrow, + } + + // ----------------------------------------------------------------- + // Runtime instance. + // + // An instantiated Component Model component with all imports + // satisfied and ready to receive export invocations. Owned by + // the caller; drop the resource to release the underlying + // runtime state. + // ----------------------------------------------------------------- + + resource runtime-instance { + // Load, validate, and instantiate a Component Model binary + // with the supplied import satisfactions. Returns the + // instance on success or a coded `error` on failure. + // + // The `component-bytes` list is copied into runtime-owned + // memory; the caller may free its buffer immediately after + // this call returns. + instantiate: static func( + component-bytes: list, + imports: list, + ) -> result; + + // Invoke an exported function by fully-qualified name. + // + // `args` is a Canonical-ABI-encoded byte string; the calling + // language's generated bindings own the typed-to-bytes + // encoding. The `list` return is the encoded return + // value in the same shape. Errors surface as coded `error`. + // + // Interacts with the M10 generated adapter cache: hot paths + // dispatch through pre-compiled adapters when available and + // fall back to the interpreter otherwise. Neither behaviour + // is observable through this surface. + call-export: func( + name: string, + args: list, + ) -> result, error>; + + // Dump the instantiated component's WIT surface (imports, + // exports, resource types, records, variants, etc.) as a + // JSON string. Format matches `wasmcm inspect --wit + // --format=json` today; expected to be documented as a + // stable schema before this ADR promotes to Accepted. + introspect: func() -> string; + + // Verify the instantiated component's world structurally + // matches `expected-wit` (a WIT-source string). Returns an + // empty ok on match, or `error` with code `wit-mismatch` + // and a message describing the first divergence. + // + // Subtype rules mirror `wasmcm validate --against`: record + // and variant width subtyping, tuple positional exact, + // join-of-variants flattening, resource identity via + // qualified names + generational handles. + verify-world: func(expected-wit: string) -> result<_, error>; + } + + import embedder-callbacks; +} diff --git a/webassembly4j-bindgen/src/test/resources/wasmos-runtime-split/alpha.wit b/webassembly4j-bindgen/src/test/resources/wasmos-runtime-split/alpha.wit new file mode 100644 index 0000000..d9cb04d --- /dev/null +++ b/webassembly4j-bindgen/src/test/resources/wasmos-runtime-split/alpha.wit @@ -0,0 +1,8 @@ +package wasmos:split-fixture@0.1.0; + +interface alpha-api { + resource alpha { + constructor(seed: u32); + ping: func() -> u32; + } +} diff --git a/webassembly4j-bindgen/src/test/resources/wasmos-runtime-split/beta.wit b/webassembly4j-bindgen/src/test/resources/wasmos-runtime-split/beta.wit new file mode 100644 index 0000000..dcb546a --- /dev/null +++ b/webassembly4j-bindgen/src/test/resources/wasmos-runtime-split/beta.wit @@ -0,0 +1,8 @@ +package wasmos:split-fixture@0.1.0; + +interface beta-api { + resource beta { + constructor(label: string); + echo: func(msg: string) -> string; + } +} diff --git a/webassembly4j-component-builder/pom.xml b/webassembly4j-component-builder/pom.xml index 342b2e1..40ff7a6 100644 --- a/webassembly4j-component-builder/pom.xml +++ b/webassembly4j-component-builder/pom.xml @@ -8,7 +8,7 @@ ai.tegmentum.webassembly4j webassembly4j-parent - 1.5.0 + 2.5.2-SNAPSHOT webassembly4j-component-builder @@ -23,13 +23,13 @@ 17 - 3.8.1 - 3.8.1 - 3.6.4 + 3.9.16 + 3.9.16 + 3.16.0 2.2.1 - 4.7.5 + 4.7.7 1.13.0 @@ -93,7 +93,7 @@ org.apache.maven.plugins maven-plugin-plugin - 3.15.2 + 3.16.0 webassembly4j-component true @@ -149,7 +149,7 @@ org.apache.maven.plugins maven-shade-plugin - 3.5.1 + 3.6.2 package diff --git a/webassembly4j-pool/pom.xml b/webassembly4j-pool/pom.xml index d5032c2..9fa88f9 100644 --- a/webassembly4j-pool/pom.xml +++ b/webassembly4j-pool/pom.xml @@ -8,7 +8,7 @@ ai.tegmentum.webassembly4j webassembly4j-parent - 1.5.0 + 2.5.2-SNAPSHOT webassembly4j-pool diff --git a/webassembly4j-runtime/pom.xml b/webassembly4j-runtime/pom.xml index 87bfa1d..49247ef 100644 --- a/webassembly4j-runtime/pom.xml +++ b/webassembly4j-runtime/pom.xml @@ -8,7 +8,7 @@ ai.tegmentum.webassembly4j webassembly4j-parent - 1.5.0 + 2.5.2-SNAPSHOT webassembly4j-runtime @@ -36,7 +36,7 @@ ai.tegmentum.webassembly4j - chicory4j-provider + endive4j-provider ${project.version} test diff --git a/webassembly4j-runtime/src/main/java/ai/tegmentum/webassembly4j/runtime/marshal/MemoryReader.java b/webassembly4j-runtime/src/main/java/ai/tegmentum/webassembly4j/runtime/marshal/MemoryReader.java index b3789e7..9e590de 100644 --- a/webassembly4j-runtime/src/main/java/ai/tegmentum/webassembly4j/runtime/marshal/MemoryReader.java +++ b/webassembly4j-runtime/src/main/java/ai/tegmentum/webassembly4j/runtime/marshal/MemoryReader.java @@ -42,6 +42,22 @@ public boolean readBool(int offset) { return scratchArray[0] != 0; } + /** + * Reads an unsigned 8-bit integer from the given offset. + * + *

Returned as an {@code int} in the range {@code 0..255} — the natural + * shape for a Canonical-ABI discriminant read on {@code variant} / {@code + * option} / {@code result} lifts, which stash the tag in a single byte at + * the payload's base offset. Callers pattern-match on the return. + * + * @param offset the byte offset in linear memory + * @return the byte value as an unsigned int (0..255) + */ + public int readU8(int offset) { + memory.read(offset, 1, scratchArray, 0); + return scratchArray[0] & 0xFF; + } + /** * Reads a 32-bit integer from the given offset (little-endian). * diff --git a/webassembly4j-runtime/src/test/java/ai/tegmentum/webassembly4j/runtime/FastPathBenchmarkTest.java b/webassembly4j-runtime/src/test/java/ai/tegmentum/webassembly4j/runtime/FastPathBenchmarkTest.java index c405ab7..e62f8d9 100644 --- a/webassembly4j-runtime/src/test/java/ai/tegmentum/webassembly4j/runtime/FastPathBenchmarkTest.java +++ b/webassembly4j-runtime/src/test/java/ai/tegmentum/webassembly4j/runtime/FastPathBenchmarkTest.java @@ -7,7 +7,7 @@ import org.junit.jupiter.api.Test; /** - * Benchmarks for function signatures that hit vs miss the Chicory FastPath. + * Benchmarks for function signatures that hit vs miss the Endive FastPath. */ class FastPathBenchmarkTest { diff --git a/webassembly4j-runtime/src/test/java/ai/tegmentum/webassembly4j/runtime/WasmRuntimeTest.java b/webassembly4j-runtime/src/test/java/ai/tegmentum/webassembly4j/runtime/WasmRuntimeTest.java index b9d3fbe..11caa65 100644 --- a/webassembly4j-runtime/src/test/java/ai/tegmentum/webassembly4j/runtime/WasmRuntimeTest.java +++ b/webassembly4j-runtime/src/test/java/ai/tegmentum/webassembly4j/runtime/WasmRuntimeTest.java @@ -195,7 +195,7 @@ void loadWithHostFunctions() { @Test void builderWithEngineSelection() { int result = WasmRuntime.builder() - .engine("chicory") + .engine("endive") .call(ADD_MODULE, "add", Integer.class, 3, 4); assertEquals(7, result); } @@ -203,7 +203,7 @@ void builderWithEngineSelection() { @Test void builderLoadInterface() { try (Calculator calc = WasmRuntime.builder() - .engine("chicory") + .engine("endive") .load(Calculator.class, ADD_MODULE)) { assertEquals(7, calc.add(3, 4)); } catch (Exception e) { @@ -226,7 +226,7 @@ void builderWithHostObjects() { @Test void builderCompile() { try (WasmModule module = WasmRuntime.builder() - .engine("chicory") + .engine("endive") .compile(ADD_MODULE)) { assertEquals(7, module.call("add", Integer.class, 3, 4)); } diff --git a/webassembly4j-spi/pom.xml b/webassembly4j-spi/pom.xml index bb207d0..9e27950 100644 --- a/webassembly4j-spi/pom.xml +++ b/webassembly4j-spi/pom.xml @@ -8,7 +8,7 @@ ai.tegmentum.webassembly4j webassembly4j-parent - 1.5.0 + 2.5.2-SNAPSHOT webassembly4j-spi diff --git a/webassembly4j-spi/src/test/java/ai/tegmentum/webassembly4j/spi/internal/DefaultProviderSelectorTest.java b/webassembly4j-spi/src/test/java/ai/tegmentum/webassembly4j/spi/internal/DefaultProviderSelectorTest.java index 4f62649..bdc3d33 100644 --- a/webassembly4j-spi/src/test/java/ai/tegmentum/webassembly4j/spi/internal/DefaultProviderSelectorTest.java +++ b/webassembly4j-spi/src/test/java/ai/tegmentum/webassembly4j/spi/internal/DefaultProviderSelectorTest.java @@ -69,13 +69,13 @@ void explainsWhyEachProviderWasRejected() { @Test void filtersByEngineId() { EngineProvider wasmtime = stubProvider("wasmtime", "wasmtime-ffm", 200, 11, true); - EngineProvider chicory = stubProvider("chicory", "chicory", 200, 11, true); - ProviderContext ctx = context("chicory", null); + EngineProvider endive = stubProvider("endive", "endive", 200, 11, true); + ProviderContext ctx = context("endive", null); - ProviderSelectionResult result = selector.select(Arrays.asList(wasmtime, chicory), ctx); + ProviderSelectionResult result = selector.select(Arrays.asList(wasmtime, endive), ctx); assertTrue(result.selectedProvider().isPresent()); - assertEquals("chicory", result.selectedProvider().get().descriptor().providerId()); + assertEquals("endive", result.selectedProvider().get().descriptor().providerId()); } @Test diff --git a/webassembly4j-spring/pom.xml b/webassembly4j-spring/pom.xml index 488a6b8..f372057 100644 --- a/webassembly4j-spring/pom.xml +++ b/webassembly4j-spring/pom.xml @@ -8,7 +8,7 @@ ai.tegmentum.webassembly4j webassembly4j-parent - 1.5.0 + 2.5.2-SNAPSHOT webassembly4j-spring diff --git a/webassembly4j-testing/pom.xml b/webassembly4j-testing/pom.xml index 9e4fa1f..c3a4cd1 100644 --- a/webassembly4j-testing/pom.xml +++ b/webassembly4j-testing/pom.xml @@ -8,7 +8,7 @@ ai.tegmentum.webassembly4j webassembly4j-parent - 1.5.0 + 2.5.2-SNAPSHOT webassembly4j-testing diff --git a/webassembly4j-testing/src/main/java/ai/tegmentum/webassembly4j/testing/WasmTestExtension.java b/webassembly4j-testing/src/main/java/ai/tegmentum/webassembly4j/testing/WasmTestExtension.java index 349b5fc..79c85bd 100644 --- a/webassembly4j-testing/src/main/java/ai/tegmentum/webassembly4j/testing/WasmTestExtension.java +++ b/webassembly4j-testing/src/main/java/ai/tegmentum/webassembly4j/testing/WasmTestExtension.java @@ -46,7 +46,7 @@ public Stream provideTestTemplateInvocationContex if (engines.isEmpty()) { throw new IllegalStateException( "No WebAssembly engines found on the classpath. " - + "Add a provider dependency (e.g., wasmtime4j-provider, chicory4j-provider)."); + + "Add a provider dependency (e.g., wasmtime4j-provider, endive4j-provider)."); } return engines.stream().map(factory -> createInvocationContext(factory, context)); }