From 5701bfeeaae6cee115ca1586f98c1e963d22e4ae Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 12 May 2026 21:44:12 +0000 Subject: [PATCH 01/17] Document Chicory blue-quickjs preflight Co-authored-by: Kamil Gruszka --- docs/chicory-bluequickjs-spike.md | 162 +++++++++++++++++++++++ docs/chicory-bluequickjs-test-results.md | 90 +++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 docs/chicory-bluequickjs-spike.md create mode 100644 docs/chicory-bluequickjs-test-results.md diff --git a/docs/chicory-bluequickjs-spike.md b/docs/chicory-bluequickjs-spike.md new file mode 100644 index 0000000..94b90a2 --- /dev/null +++ b/docs/chicory-bluequickjs-spike.md @@ -0,0 +1,162 @@ +# Chicory blue-quickjs spike + +This document records the implementation facts, assumptions, blockers, and parity +results for the experimental Java/Chicory blue-quickjs runtime. + +## Preflight + +- Java repo branch: `feature/chicory-bluequickjs-wasm-runtime` +- Java runtime used locally: OpenJDK 21.0.10 +- Gradle wrapper: 8.4 +- Requested default sibling checkout `../blue-quickjs` resolves to `/blue-quickjs` + in this container, but `/` is not writable. The local preflight checkout is: + `/tmp/blue-quickjs` +- blue-quickjs repository: `https://github.com/bluecontract/blue-quickjs` +- blue-quickjs commit: `d462a11818049d7909bbe3ceb36bddd2b532e9cd` +- blue-quickjs QuickJS submodule commit: + `9d1eda6e0d1ec36c279d87380db77fbcc3acbae8` + +## blue-quickjs build commands + +The checked-out `package.json`, `nx.json`, and `libs/quickjs-wasm-build/project.json` +were inspected before running build commands. + +Commands run: + +```bash +pnpm install --frozen-lockfile +bash tools/scripts/setup-emsdk.sh +WASM_VARIANTS=wasm32 WASM_BUILD_TYPES=release pnpm exec nx build quickjs-wasm-build +pnpm exec nx build quickjs-wasm +pnpm exec nx build abi-manifest +pnpm exec nx build quickjs-runtime +``` + +## Canonical artifact + +- Selected wasm artifact: + `/tmp/blue-quickjs/libs/quickjs-wasm-build/dist/quickjs-eval.wasm` +- Packaged copy: + `/tmp/blue-quickjs/libs/quickjs-wasm/dist/wasm/quickjs-eval.wasm` +- Metadata: + `/tmp/blue-quickjs/libs/quickjs-wasm-build/dist/quickjs-wasm-build.metadata.json` +- Wasm magic bytes: `00 61 73 6d` +- Wasm version bytes: `01 00 00 00` +- Wasm size: `659086` +- Variant: `wasm32` +- Build type: `release` +- engineBuildHash / SHA-256: + `1d4584fc0552a24ee840afa2cca9f1536d47429f467585d4d5c1a5236ba96dc9` +- Loader SHA-256: + `11a13f0414e7387f0c9502c8c0ca9479473505d94b824356d015a8d8007637fb` +- Emscripten version: `3.1.56` +- QuickJS version: `2025-09-13` +- Fixed memory: + - initial: `33554432` + - maximum: `33554432` + - stack: `1048576` + - allowGrowth: `false` +- Determinism flags: + - `-sFILESYSTEM=0` + - `-sALLOW_MEMORY_GROWTH=0` + - `-sINITIAL_MEMORY=33554432` + - `-sMAXIMUM_MEMORY=33554432` + - `-sSTACK_SIZE=1048576` + - `-sALLOW_TABLE_GROWTH=0` + - `-sENVIRONMENT=node,web` + - `-sNO_EXIT_RUNTIME=1` + +## Host.v1 ABI + +- ABI id: `Host.v1` +- ABI version: `1` +- abiManifestHash / `HOST_V1_HASH`: + `e23b0b2ee169900bbde7aff78e6ce20fead1715c60f8a8e3106d9959450a3d34` +- Function IDs: + - `1`: `document.get` + - `2`: `document.getCanonical` + - `3`: `emit` + +## Gas/profile metadata + +- Gas version from blue-quickjs documentation: `JS_GAS_VERSION_LATEST = 2` +- Execution profile from blue-quickjs documentation: deterministic Baseline #1 +- Current build metadata does not yet include explicit `gasVersion` or + `executionProfile` fields. The Java runtime must not silently infer them from + docs during normal evaluation; it must fail closed unless the generated/pinned + Java-side metadata includes these values or upstream metadata grows these + fields. + +## Wasm imports + +Parsed directly from `quickjs-eval.wasm`: + +| Module | Name | Signature | Notes | +| --- | --- | --- | --- | +| `env` | `abort` | `() -> ()` | Emscripten support import; should be deterministic fatal if invoked. | +| `env` | `__assert_fail` | `(i32, i32, i32, i32) -> ()` | Emscripten support import; should be deterministic fatal if invoked. | +| `host` | `host_call` | `(i32, i32, i32, i32, i32) -> i32` | Required Host.v1 dispatcher import. | +| `env` | `emscripten_date_now` | `() -> f64` | Emscripten support import; should be a deterministic stub and must not expose wall-clock time. | +| `env` | `emscripten_resize_heap` | `(i32) -> i32` | Memory growth is disabled; should return failure deterministically. | + +## Wasm exports + +Parsed directly from `quickjs-eval.wasm`: + +- `memory` +- `__wasm_call_ctors` +- `malloc` +- `free` +- `__indirect_function_table` +- `qjs_det_init` +- `qjs_det_eval` +- `qjs_det_set_gas_limit` +- `qjs_det_free` +- `qjs_det_enable_tape` +- `qjs_det_read_tape` +- `qjs_det_enable_trace` +- `qjs_det_read_trace` +- `stackSave` +- `stackRestore` +- `stackAlloc` + +## Baseline test result + +Command: + +```bash +./gradlew clean test -Dblue.quickjs.root=/tmp/blue-quickjs +``` + +Result: + +- `BUILD SUCCESSFUL in 33s` +- 6 actionable tasks executed +- Existing Node bridge tests passed. + +Representative deterministic stress output: + +- QuickJS counter snapshot round-trip stress: + - iterations: `100` + - totalGas: `18700` + - minGas: `187` + - maxGas: `187` + - finalBlueId: `qQgDoUkPVc2QPWEJar82QSy2kUahX8HHZJHDWivHmEM` +- No-JS counter snapshot round-trip stress: + - iterations: `100` + - totalGas: `18100` + - minGas: `181` + - maxGas: `181` + - finalBlueId: `9kr8UvMAUAZ2wdk3EreY2ShtC5Q8927uaBdn9QTxqxyj` + +## Current deviations and risks + +1. The local checkout is in `/tmp/blue-quickjs` because `/blue-quickjs` cannot be + created in this container. All local validation commands should pass + `-Dblue.quickjs.root=/tmp/blue-quickjs` or `-PblueQuickJsRoot=/tmp/blue-quickjs`. +2. The raw wasm has deterministic Emscripten support imports in `env` in addition + to `host.host_call`. The Chicory adapter must explicitly implement or reject + these imports; it must not run the Emscripten JS loader under Node. +3. Explicit `gasVersion` and `executionProfile` metadata fields are absent from + the generated blue-quickjs metadata observed during preflight. Runtime pinning + must address this fail-closed requirement before evaluation. diff --git a/docs/chicory-bluequickjs-test-results.md b/docs/chicory-bluequickjs-test-results.md new file mode 100644 index 0000000..dab0b9e --- /dev/null +++ b/docs/chicory-bluequickjs-test-results.md @@ -0,0 +1,90 @@ +# Chicory blue-quickjs test results + +This file records proof commands and outcomes for the Chicory blue-quickjs +runtime branch. + +## Environment + +- Date: 2026-05-12 +- Java repo branch: `feature/chicory-bluequickjs-wasm-runtime` +- Java runtime: OpenJDK 21.0.10 +- Gradle wrapper: 8.4 +- blue-quickjs local checkout: `/tmp/blue-quickjs` +- blue-quickjs commit: `d462a11818049d7909bbe3ceb36bddd2b532e9cd` +- blue-quickjs QuickJS submodule: + `9d1eda6e0d1ec36c279d87380db77fbcc3acbae8` + +## Baseline Node bridge + +Command: + +```bash +./gradlew clean test -Dblue.quickjs.root=/tmp/blue-quickjs +``` + +Outcome: + +- Passed. +- `BUILD SUCCESSFUL in 33s` +- 6 actionable tasks executed. + +Representative output: + +```text +QuickJS counter snapshot round-trip stress: iterations=100, +totalGas=18700, minGas=187, maxGas=187, +finalBlueId=qQgDoUkPVc2QPWEJar82QSy2kUahX8HHZJHDWivHmEM + +No-JS counter snapshot round-trip stress: iterations=100, +totalGas=18100, minGas=181, maxGas=181, +finalBlueId=9kr8UvMAUAZ2wdk3EreY2ShtC5Q8927uaBdn9QTxqxyj +``` + +## Pending proof commands + +These are intentionally left pending until the corresponding implementation +phases exist: + +```bash +./gradlew :quickjs-chicory:clean :quickjs-chicory:test \ + -PblueQuickJsRoot=/tmp/blue-quickjs \ + -Dblue.quickjs.root=/tmp/blue-quickjs +``` + +```bash +./gradlew :quickjs-chicory:test \ + --tests '*ChicoryVsNodeParityTest' \ + -PblueQuickJsRoot=/tmp/blue-quickjs \ + -Dblue.quickjs.root=/tmp/blue-quickjs +``` + +```bash +./gradlew :quickjs-chicory:test \ + --tests '*BlueQuickJsResourceIntegrityTest' \ + -PblueQuickJsRoot=/tmp/blue-quickjs +``` + +```bash +./gradlew :quickjs-chicory:dependencies --configuration runtimeClasspath +``` + +```bash +./gradlew clean test +``` + +```bash +PATH=/usr/bin:/bin ./gradlew :quickjs-chicory:test \ + --tests '*ChicoryBlueQuickJsRuntimeSmokeTest' \ + -PblueQuickJsRoot=/tmp/blue-quickjs +``` + +```bash +./gradlew :quickjs-chicory:clean :quickjs-chicory:jar \ + -PblueQuickJsRoot=/tmp/blue-quickjs +``` + +## Pending artifacts + +- `quickjs-chicory/build/reports/blue-quickjs-chicory-parity.json` +- Lambda-like Java 17 smoke output +- Lambda-like Java 21 smoke output From f66d2123907393f61b57e9938cf0f7896b40f54e Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 12 May 2026 21:45:34 +0000 Subject: [PATCH 02/17] Add optional quickjs-chicory module skeleton Co-authored-by: Kamil Gruszka --- docs/chicory-bluequickjs-test-results.md | 27 +++++++++++--- quickjs-chicory/build.gradle | 46 ++++++++++++++++++++++++ settings.gradle | 1 + 3 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 quickjs-chicory/build.gradle diff --git a/docs/chicory-bluequickjs-test-results.md b/docs/chicory-bluequickjs-test-results.md index dab0b9e..8d706f8 100644 --- a/docs/chicory-bluequickjs-test-results.md +++ b/docs/chicory-bluequickjs-test-results.md @@ -42,6 +42,29 @@ finalBlueId=9kr8UvMAUAZ2wdk3EreY2ShtC5Q8927uaBdn9QTxqxyj ## Pending proof commands +These commands have now been run for the initial optional module skeleton: + +```bash +./gradlew :quickjs-chicory:dependencies --configuration runtimeClasspath +``` + +Outcome: + +- Passed. +- Runtime classpath contains `com.dylibso.chicory:runtime:1.7.5` and + `com.dylibso.chicory:wasm:1.7.5`. +- No Node, V8, Javet, QuickJs4J, Wasmtime, or JNI dependency appeared in the + Chicory module runtime classpath. + +```bash +./gradlew clean test -Dblue.quickjs.root=/tmp/blue-quickjs +``` + +Outcome: + +- Passed after adding the empty optional `quickjs-chicory` module. +- Existing root tests still pass with the Node bridge baseline. + These are intentionally left pending until the corresponding implementation phases exist: @@ -64,10 +87,6 @@ phases exist: -PblueQuickJsRoot=/tmp/blue-quickjs ``` -```bash -./gradlew :quickjs-chicory:dependencies --configuration runtimeClasspath -``` - ```bash ./gradlew clean test ``` diff --git a/quickjs-chicory/build.gradle b/quickjs-chicory/build.gradle new file mode 100644 index 0000000..d452ab9 --- /dev/null +++ b/quickjs-chicory/build.gradle @@ -0,0 +1,46 @@ +plugins { + id 'java-library' +} + +repositories { + if (!System.getenv('CI')) { + mavenLocal() + } + mavenCentral() +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + if (JavaVersion.current().isJava11Compatible()) { + options.release = 11 + } +} + +dependencies { + api project(':') + + implementation 'com.dylibso.chicory:runtime:1.7.5' + + testImplementation platform('org.junit:junit-bom:5.10.2') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +test { + useJUnitPlatform() + if (System.getProperty('blue.quickjs.root')) { + systemProperty 'blue.quickjs.root', System.getProperty('blue.quickjs.root') + } + if (project.hasProperty('blueQuickJsRoot')) { + systemProperty 'blue.quickjs.root', project.property('blueQuickJsRoot') + } + testLogging { + events 'PASSED', 'FAILED', 'SKIPPED' + showStandardStreams = true + } +} diff --git a/settings.gradle b/settings.gradle index 7db4938..d02aed2 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,2 @@ rootProject.name = 'blue-contract-java' +include 'quickjs-chicory' From 80df07dcac81f49e18a240848951cd49a6d22ce2 Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 12 May 2026 21:48:21 +0000 Subject: [PATCH 03/17] Add processor runtime injection options Co-authored-by: Kamil Gruszka --- docs/chicory-bluequickjs-test-results.md | 27 +++++++ .../BlueDocumentProcessorOptions.java | 45 ++++++++++++ .../processor/BlueDocumentProcessors.java | 13 +++- .../processor/ConversationProcessors.java | 41 +++++++++-- .../workflow/SequentialWorkflowRunner.java | 11 +++ .../SequentialWorkflowExecutionTest.java | 71 +++++++++++++++++++ 6 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 src/main/java/blue/contract/processor/BlueDocumentProcessorOptions.java diff --git a/docs/chicory-bluequickjs-test-results.md b/docs/chicory-bluequickjs-test-results.md index 8d706f8..10a274b 100644 --- a/docs/chicory-bluequickjs-test-results.md +++ b/docs/chicory-bluequickjs-test-results.md @@ -65,6 +65,33 @@ Outcome: - Passed after adding the empty optional `quickjs-chicory` module. - Existing root tests still pass with the Node bridge baseline. +## Core runtime injection layer + +Command: + +```bash +./gradlew test --tests 'blue.contract.processor.conversation.SequentialWorkflowExecutionTest' \ + -Dblue.quickjs.root=/tmp/blue-quickjs +``` + +Outcome: + +- Passed. +- Added coverage proved that `BlueDocumentProcessorOptions` can inject a + `JavaScriptRuntime`, and that `ConversationProcessors.registerWith` can inject + a custom `SequentialWorkflowRunner`. + +Command: + +```bash +./gradlew clean test -Dblue.quickjs.root=/tmp/blue-quickjs +``` + +Outcome: + +- Passed after the core injection API was added. +- Existing default processor registration behavior remains covered and green. + These are intentionally left pending until the corresponding implementation phases exist: diff --git a/src/main/java/blue/contract/processor/BlueDocumentProcessorOptions.java b/src/main/java/blue/contract/processor/BlueDocumentProcessorOptions.java new file mode 100644 index 0000000..d1d3722 --- /dev/null +++ b/src/main/java/blue/contract/processor/BlueDocumentProcessorOptions.java @@ -0,0 +1,45 @@ +package blue.contract.processor; + +import blue.contract.processor.conversation.javascript.JavaScriptRuntime; +import blue.contract.processor.conversation.workflow.SequentialWorkflowRunner; + +public final class BlueDocumentProcessorOptions { + private final JavaScriptRuntime javaScriptRuntime; + private final SequentialWorkflowRunner sequentialWorkflowRunner; + + private BlueDocumentProcessorOptions(Builder builder) { + this.javaScriptRuntime = builder.javaScriptRuntime; + this.sequentialWorkflowRunner = builder.sequentialWorkflowRunner; + } + + public JavaScriptRuntime javaScriptRuntime() { + return javaScriptRuntime; + } + + public SequentialWorkflowRunner sequentialWorkflowRunner() { + return sequentialWorkflowRunner; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private JavaScriptRuntime javaScriptRuntime; + private SequentialWorkflowRunner sequentialWorkflowRunner; + + public Builder javaScriptRuntime(JavaScriptRuntime javaScriptRuntime) { + this.javaScriptRuntime = javaScriptRuntime; + return this; + } + + public Builder sequentialWorkflowRunner(SequentialWorkflowRunner sequentialWorkflowRunner) { + this.sequentialWorkflowRunner = sequentialWorkflowRunner; + return this; + } + + public BlueDocumentProcessorOptions build() { + return new BlueDocumentProcessorOptions(this); + } + } +} diff --git a/src/main/java/blue/contract/processor/BlueDocumentProcessors.java b/src/main/java/blue/contract/processor/BlueDocumentProcessors.java index 9153017..d1013ee 100644 --- a/src/main/java/blue/contract/processor/BlueDocumentProcessors.java +++ b/src/main/java/blue/contract/processor/BlueDocumentProcessors.java @@ -8,18 +8,27 @@ private BlueDocumentProcessors() { } public static Blue registerWith(Blue blue) { + return registerWith(blue, null); + } + + public static Blue registerWith(Blue blue, BlueDocumentProcessorOptions options) { if (blue == null) { throw new IllegalArgumentException("blue must not be null"); } - ConversationProcessors.registerWith(blue); + ConversationProcessors.registerWith(blue, options); MyOSProcessors.registerWith(blue); return blue; } public static DocumentProcessor.Builder configure(DocumentProcessor.Builder builder) { + return configure(builder, null); + } + + public static DocumentProcessor.Builder configure(DocumentProcessor.Builder builder, + BlueDocumentProcessorOptions options) { if (builder == null) { throw new IllegalArgumentException("builder must not be null"); } - return MyOSProcessors.configure(ConversationProcessors.configure(builder)); + return MyOSProcessors.configure(ConversationProcessors.configure(builder, options)); } } diff --git a/src/main/java/blue/contract/processor/ConversationProcessors.java b/src/main/java/blue/contract/processor/ConversationProcessors.java index 74bcc90..33141d2 100644 --- a/src/main/java/blue/contract/processor/ConversationProcessors.java +++ b/src/main/java/blue/contract/processor/ConversationProcessors.java @@ -4,6 +4,7 @@ import blue.contract.processor.conversation.OperationProcessor; import blue.contract.processor.conversation.SequentialWorkflowOperationProcessor; import blue.contract.processor.conversation.SequentialWorkflowProcessor; +import blue.contract.processor.conversation.workflow.SequentialWorkflowRunner; import blue.language.Blue; import blue.language.processor.DocumentProcessor; import blue.language.utils.TypeClassResolver; @@ -14,28 +15,60 @@ private ConversationProcessors() { } public static Blue registerWith(Blue blue) { + return registerWith(blue, null); + } + + public static Blue registerWith(Blue blue, BlueDocumentProcessorOptions options) { if (blue == null) { throw new IllegalArgumentException("blue must not be null"); } + SequentialWorkflowRunner runner = workflowRunner(options); BlueRepositoryV1_2_0.registerAll(blue.getDocumentProcessor().getContractTypeResolver()); blue.registerContractProcessor(new CompositeTimelineChannelProcessor()); blue.registerContractProcessor(new OperationProcessor()); - blue.registerContractProcessor(new SequentialWorkflowProcessor()); - blue.registerContractProcessor(new SequentialWorkflowOperationProcessor()); + blue.registerContractProcessor(runner != null + ? new SequentialWorkflowProcessor(runner) + : new SequentialWorkflowProcessor()); + blue.registerContractProcessor(runner != null + ? new SequentialWorkflowOperationProcessor(runner) + : new SequentialWorkflowOperationProcessor()); return blue; } public static DocumentProcessor.Builder configure(DocumentProcessor.Builder builder) { + return configure(builder, null); + } + + public static DocumentProcessor.Builder configure(DocumentProcessor.Builder builder, + BlueDocumentProcessorOptions options) { if (builder == null) { throw new IllegalArgumentException("builder must not be null"); } + SequentialWorkflowRunner runner = workflowRunner(options); TypeClassResolver resolver = BlueRepositoryV1_2_0.registerAll( new TypeClassResolver("blue.language.processor.model")); return builder .withContractTypeResolver(resolver) .registerContractProcessor(new CompositeTimelineChannelProcessor()) .registerContractProcessor(new OperationProcessor()) - .registerContractProcessor(new SequentialWorkflowProcessor()) - .registerContractProcessor(new SequentialWorkflowOperationProcessor()); + .registerContractProcessor(runner != null + ? new SequentialWorkflowProcessor(runner) + : new SequentialWorkflowProcessor()) + .registerContractProcessor(runner != null + ? new SequentialWorkflowOperationProcessor(runner) + : new SequentialWorkflowOperationProcessor()); + } + + private static SequentialWorkflowRunner workflowRunner(BlueDocumentProcessorOptions options) { + if (options == null) { + return null; + } + if (options.sequentialWorkflowRunner() != null) { + return options.sequentialWorkflowRunner(); + } + if (options.javaScriptRuntime() != null) { + return SequentialWorkflowRunner.withJavaScriptRuntime(options.javaScriptRuntime()); + } + return null; } } diff --git a/src/main/java/blue/contract/processor/conversation/workflow/SequentialWorkflowRunner.java b/src/main/java/blue/contract/processor/conversation/workflow/SequentialWorkflowRunner.java index d608395..ef934d7 100644 --- a/src/main/java/blue/contract/processor/conversation/workflow/SequentialWorkflowRunner.java +++ b/src/main/java/blue/contract/processor/conversation/workflow/SequentialWorkflowRunner.java @@ -95,6 +95,17 @@ private String stepName(SequentialWorkflowStep step) { private static List> defaultExecutors() { JavaScriptRuntime runtime = new NodeQuickJsRuntime(); + return executorsFor(runtime); + } + + public static SequentialWorkflowRunner withJavaScriptRuntime(JavaScriptRuntime runtime) { + if (runtime == null) { + throw new IllegalArgumentException("runtime must not be null"); + } + return new SequentialWorkflowRunner(executorsFor(runtime)); + } + + private static List> executorsFor(JavaScriptRuntime runtime) { QuickJsExpressionResolver resolver = new QuickJsExpressionResolver(runtime); return Arrays.>asList( new TriggerEventStepExecutor(resolver), diff --git a/src/test/java/blue/contract/processor/conversation/SequentialWorkflowExecutionTest.java b/src/test/java/blue/contract/processor/conversation/SequentialWorkflowExecutionTest.java index 07fcfae..de865b8 100644 --- a/src/test/java/blue/contract/processor/conversation/SequentialWorkflowExecutionTest.java +++ b/src/test/java/blue/contract/processor/conversation/SequentialWorkflowExecutionTest.java @@ -1,12 +1,17 @@ package blue.contract.processor.conversation; +import blue.contract.processor.BlueDocumentProcessorOptions; import blue.contract.processor.BlueDocumentProcessors; +import blue.contract.processor.ConversationProcessors; import blue.contract.processor.conversation.expression.ExpressionEvaluator; import blue.contract.processor.conversation.expression.QuickJsExpressionEvaluator; import blue.contract.processor.conversation.expression.QuickJsExpressionResolver; import blue.contract.processor.conversation.expression.SimpleExpressionEvaluator; +import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; +import blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult; import blue.contract.processor.conversation.javascript.NodeQuickJsRuntime; import blue.contract.processor.conversation.javascript.QuickJsGas; +import blue.contract.processor.conversation.javascript.JavaScriptRuntime; import blue.contract.processor.conversation.workflow.JavaScriptCodeStepExecutor; import blue.contract.processor.conversation.workflow.SequentialWorkflowRunner; import blue.contract.processor.conversation.workflow.StepExecutionContext; @@ -156,6 +161,45 @@ public Node evaluate(Node value, StepExecutionContext context) { assertCounter(processed, 42); } + @Test + void blueDocumentProcessorOptionsInjectsJavaScriptRuntime() { + BlueDocumentProcessorOptions options = BlueDocumentProcessorOptions.builder() + .javaScriptRuntime(fixedRuntime(42)) + .build(); + Fixture fixture = configuredFixture(options); + Node document = initializedDocument(fixture, expressionDocument(fixture.repository, + 0, + new Node().value("${1 + 1}"))); + + Node processed = processOperationRequest(fixture, document, "owner", 1, "increment", 7); + + assertCounter(processed, 42); + } + + @Test + void conversationProcessorOptionsInjectsSequentialWorkflowRunner() { + ExpressionEvaluator fixedEvaluator = new ExpressionEvaluator() { + @Override + public Node evaluate(Node value, StepExecutionContext context) { + return new Node().value(42); + } + }; + SequentialWorkflowRunner runner = new SequentialWorkflowRunner( + Arrays.>asList( + new UpdateDocumentStepExecutor(fixedEvaluator))); + BlueDocumentProcessorOptions options = BlueDocumentProcessorOptions.builder() + .sequentialWorkflowRunner(runner) + .build(); + Fixture fixture = configuredConversationFixture(options); + Node document = initializedDocument(fixture, expressionDocument(fixture.repository, + 0, + new Node().value("${event.message.request + document('/counter')}"))); + + Node processed = processOperationRequest(fixture, document, "owner", 1, "increment", 7); + + assertCounter(processed, 42); + } + @Test void nonExpressionValuesPassThrough() { Fixture fixture = configuredFixture(); @@ -1039,6 +1083,24 @@ private static Fixture configuredFixture() { return new Fixture(repository, blue); } + private static Fixture configuredFixture(BlueDocumentProcessorOptions options) { + BlueRepository repository = BlueRepository.v1_2_0(); + Blue blue = repository.configure(new Blue()); + blue.nodeProvider(repository.nodeProvider()); + BlueDocumentProcessors.registerWith(blue, options); + TestTimelineProvider.registerWith(blue); + return new Fixture(repository, blue); + } + + private static Fixture configuredConversationFixture(BlueDocumentProcessorOptions options) { + BlueRepository repository = BlueRepository.v1_2_0(); + Blue blue = repository.configure(new Blue()); + blue.nodeProvider(repository.nodeProvider()); + ConversationProcessors.registerWith(blue, options); + TestTimelineProvider.registerWith(blue); + return new Fixture(repository, blue); + } + private static Fixture configuredFixture(SequentialWorkflowRunner operationRunner, SequentialWorkflowRunner directRunner) { Fixture fixture = configuredFixture(); @@ -1073,6 +1135,15 @@ private static Map resolverBindings() { return bindings; } + private static JavaScriptRuntime fixedRuntime(final Object value) { + return new JavaScriptRuntime() { + @Override + public JavaScriptEvaluationResult evaluate(JavaScriptEvaluationRequest request) { + return new JavaScriptEvaluationResult(value, QuickJsGas.WASM_FUEL_PER_HOST_GAS_UNIT, 1L); + } + }; + } + private static void assertCounter(Node document, int expected) { assertEquals(BigInteger.valueOf(expected), document.get("/counter")); } From 3bd938d8fe983303fa1c73380bf373fdefd93e94 Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 12 May 2026 21:52:31 +0000 Subject: [PATCH 04/17] Add blue-quickjs resource integrity checks Co-authored-by: Kamil Gruszka --- docs/chicory-bluequickjs-test-results.md | 25 +- quickjs-chicory/build.gradle | 1 + .../BlueQuickJsDeterminismException.java | 11 + .../chicory/BlueQuickJsResourceException.java | 11 + .../chicory/BlueQuickJsWasmResources.java | 660 ++++++++++++++++++ .../chicory/BlueQuickJsWasmRuntimeConfig.java | 141 ++++ .../javascript/chicory/HostV1Manifest.java | 37 + .../BlueQuickJsResourceIntegrityTest.java | 104 +++ .../chicory/HostV1ManifestTest.java | 39 ++ 9 files changed, 1023 insertions(+), 6 deletions(-) create mode 100644 quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsDeterminismException.java create mode 100644 quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceException.java create mode 100644 quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmResources.java create mode 100644 quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmRuntimeConfig.java create mode 100644 quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/HostV1Manifest.java create mode 100644 quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceIntegrityTest.java create mode 100644 quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/HostV1ManifestTest.java diff --git a/docs/chicory-bluequickjs-test-results.md b/docs/chicory-bluequickjs-test-results.md index 10a274b..5c8b16b 100644 --- a/docs/chicory-bluequickjs-test-results.md +++ b/docs/chicory-bluequickjs-test-results.md @@ -92,6 +92,25 @@ Outcome: - Passed after the core injection API was added. - Existing default processor registration behavior remains covered and green. +## Resource and Host.v1 integrity + +Command: + +```bash +./gradlew :quickjs-chicory:test \ + --tests '*BlueQuickJsResourceIntegrityTest' \ + --tests '*HostV1ManifestTest' \ + -PblueQuickJsRoot=/tmp/blue-quickjs +``` + +Outcome: + +- Passed. +- Verified canonical wasm resource presence, magic bytes, metadata hash, pinned + engine hash, Host.v1 hash, gas/profile pins, approved import set, and required + exports. +- Verified an incorrect expected engine hash fails closed before evaluation. + These are intentionally left pending until the corresponding implementation phases exist: @@ -108,12 +127,6 @@ phases exist: -Dblue.quickjs.root=/tmp/blue-quickjs ``` -```bash -./gradlew :quickjs-chicory:test \ - --tests '*BlueQuickJsResourceIntegrityTest' \ - -PblueQuickJsRoot=/tmp/blue-quickjs -``` - ```bash ./gradlew clean test ``` diff --git a/quickjs-chicory/build.gradle b/quickjs-chicory/build.gradle index d452ab9..bb79583 100644 --- a/quickjs-chicory/build.gradle +++ b/quickjs-chicory/build.gradle @@ -25,6 +25,7 @@ dependencies { api project(':') implementation 'com.dylibso.chicory:runtime:1.7.5' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2' testImplementation platform('org.junit:junit-bom:5.10.2') testImplementation 'org.junit.jupiter:junit-jupiter' diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsDeterminismException.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsDeterminismException.java new file mode 100644 index 0000000..588a972 --- /dev/null +++ b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsDeterminismException.java @@ -0,0 +1,11 @@ +package blue.contract.processor.conversation.javascript.chicory; + +public final class BlueQuickJsDeterminismException extends BlueQuickJsResourceException { + public BlueQuickJsDeterminismException(String message) { + super(message); + } + + public BlueQuickJsDeterminismException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceException.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceException.java new file mode 100644 index 0000000..35bff91 --- /dev/null +++ b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceException.java @@ -0,0 +1,11 @@ +package blue.contract.processor.conversation.javascript.chicory; + +public class BlueQuickJsResourceException extends RuntimeException { + public BlueQuickJsResourceException(String message) { + super(message); + } + + public BlueQuickJsResourceException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmResources.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmResources.java new file mode 100644 index 0000000..f0d4217 --- /dev/null +++ b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmResources.java @@ -0,0 +1,660 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public final class BlueQuickJsWasmResources { + public static final String CANONICAL_WASM_FILENAME = "quickjs-eval.wasm"; + public static final String METADATA_FILENAME = "quickjs-wasm-build.metadata.json"; + + private static final ObjectMapper JSON = new ObjectMapper(); + private static final byte[] WASM_MAGIC = new byte[]{0x00, 0x61, 0x73, 0x6d}; + private static final Set APPROVED_IMPORTS = Collections.unmodifiableSet(new LinkedHashSet( + Arrays.asList( + "env.abort", + "env.__assert_fail", + "env.emscripten_date_now", + "env.emscripten_resize_heap", + "host.host_call"))); + + private final Path blueQuickJsRoot; + private final Path wasmPath; + private final Path metadataPath; + private final byte[] wasmBytes; + private final JsonNode metadata; + private final String engineBuildHash; + private final String abiManifestHash; + private final int gasVersion; + private final String executionProfile; + private final List imports; + private final List exports; + + private BlueQuickJsWasmResources(Path blueQuickJsRoot, + Path wasmPath, + Path metadataPath, + byte[] wasmBytes, + JsonNode metadata, + String engineBuildHash, + String abiManifestHash, + int gasVersion, + String executionProfile, + List imports, + List exports) { + this.blueQuickJsRoot = blueQuickJsRoot; + this.wasmPath = wasmPath; + this.metadataPath = metadataPath; + this.wasmBytes = wasmBytes.clone(); + this.metadata = metadata; + this.engineBuildHash = engineBuildHash; + this.abiManifestHash = abiManifestHash; + this.gasVersion = gasVersion; + this.executionProfile = executionProfile; + this.imports = Collections.unmodifiableList(new ArrayList(imports)); + this.exports = Collections.unmodifiableList(new ArrayList(exports)); + } + + public static BlueQuickJsWasmResources resolve() { + return resolve(BlueQuickJsWasmRuntimeConfig.defaultConfig()); + } + + public static BlueQuickJsWasmResources resolve(BlueQuickJsWasmRuntimeConfig config) { + if (config == null) { + throw new IllegalArgumentException("config must not be null"); + } + Path root = resolveRoot(config); + Path wasmPath = locateWasm(root); + Path metadataPath = locateMetadata(wasmPath, root); + byte[] wasmBytes = readBytes(wasmPath); + verifyMagic(wasmBytes, wasmPath); + + JsonNode metadata = readJson(metadataPath); + JsonNode selected = selectedVariant(metadata, config); + String metadataWasmFilename = requiredText(selected.at("/wasm/filename"), + "metadata variants." + config.expectedVariant() + "." + config.expectedBuildType() + ".wasm.filename"); + if (!CANONICAL_WASM_FILENAME.equals(metadataWasmFilename)) { + throw new BlueQuickJsDeterminismException("selected wasm is not canonical " + CANONICAL_WASM_FILENAME + + ": " + metadataWasmFilename); + } + String wasmSha256 = sha256Hex(wasmBytes); + String metadataSha256 = requiredHex(selected.at("/wasm/sha256"), "metadata wasm sha256"); + if (!wasmSha256.equals(metadataSha256)) { + throw new BlueQuickJsDeterminismException("wasm sha256 mismatch: metadata=" + metadataSha256 + + ", actual=" + wasmSha256); + } + String engineBuildHash = requiredHex(selected.get("engineBuildHash"), "metadata engineBuildHash"); + if (!wasmSha256.equals(engineBuildHash)) { + throw new BlueQuickJsDeterminismException("engineBuildHash does not match wasm sha256: engine=" + + engineBuildHash + ", actual=" + wasmSha256); + } + String topLevelEngineBuildHash = requiredHex(metadata.get("engineBuildHash"), "metadata top-level engineBuildHash"); + if (!engineBuildHash.equals(topLevelEngineBuildHash)) { + throw new BlueQuickJsDeterminismException("top-level engineBuildHash does not match selected engineBuildHash"); + } + if (config.expectedEngineBuildHash() != null && !engineBuildHash.equals(config.expectedEngineBuildHash())) { + throw new BlueQuickJsDeterminismException("engineBuildHash mismatch: expected=" + + config.expectedEngineBuildHash() + ", actual=" + engineBuildHash); + } + verifyMetadataShape(metadata); + String abiManifestHash = verifyAbiManifestHash(config); + WasmModuleShape shape = WasmModuleShape.parse(wasmBytes); + verifyImports(shape.imports); + verifyExports(shape.exports); + return new BlueQuickJsWasmResources(root, + wasmPath, + metadataPath, + wasmBytes, + metadata, + engineBuildHash, + abiManifestHash, + config.expectedGasVersion(), + config.expectedExecutionProfile(), + shape.imports, + shape.exports); + } + + public Path blueQuickJsRoot() { + return blueQuickJsRoot; + } + + public Path wasmPath() { + return wasmPath; + } + + public Path metadataPath() { + return metadataPath; + } + + public byte[] wasmBytes() { + return wasmBytes.clone(); + } + + public JsonNode metadata() { + return metadata; + } + + public String engineBuildHash() { + return engineBuildHash; + } + + public String abiManifestHash() { + return abiManifestHash; + } + + public int gasVersion() { + return gasVersion; + } + + public String executionProfile() { + return executionProfile; + } + + public List imports() { + return imports; + } + + public List exports() { + return exports; + } + + private static Path resolveRoot(BlueQuickJsWasmRuntimeConfig config) { + Path configured = config.blueQuickJsRoot(); + if (configured == null) { + String property = System.getProperty(BlueQuickJsWasmRuntimeConfig.BLUE_QUICKJS_ROOT_PROPERTY); + if (property != null && !property.trim().isEmpty()) { + configured = Paths.get(property); + } + } + if (configured == null) { + configured = Paths.get(System.getProperty("user.dir")).toAbsolutePath().getParent().resolve("blue-quickjs"); + } + Path root = configured.toAbsolutePath().normalize(); + if (!Files.isDirectory(root)) { + throw new BlueQuickJsResourceException("blue-quickjs root not found: " + root); + } + return root; + } + + private static Path locateWasm(Path root) { + List candidates = Arrays.asList( + root.resolve("libs/quickjs-wasm/dist/wasm").resolve(CANONICAL_WASM_FILENAME), + root.resolve("libs/quickjs-wasm-build/dist").resolve(CANONICAL_WASM_FILENAME)); + for (Path candidate : candidates) { + if (Files.isRegularFile(candidate)) { + String name = candidate.getFileName().toString().toLowerCase(Locale.ROOT); + if (name.contains("debug") || name.contains("wasm64")) { + throw new BlueQuickJsDeterminismException("rejected non-release/non-wasm32 artifact: " + candidate); + } + return candidate; + } + } + throw new BlueQuickJsResourceException("canonical wasm32 release artifact missing under " + root); + } + + private static Path locateMetadata(Path wasmPath, Path root) { + List candidates = Arrays.asList( + wasmPath.getParent().resolve(METADATA_FILENAME), + root.resolve("libs/quickjs-wasm-build/dist").resolve(METADATA_FILENAME)); + for (Path candidate : candidates) { + if (Files.isRegularFile(candidate)) { + return candidate; + } + } + throw new BlueQuickJsResourceException("blue-quickjs wasm metadata missing for " + wasmPath); + } + + private static byte[] readBytes(Path path) { + try { + return Files.readAllBytes(path); + } catch (IOException ex) { + throw new BlueQuickJsResourceException("failed to read " + path, ex); + } + } + + private static JsonNode readJson(Path path) { + try { + return JSON.readTree(Files.newBufferedReader(path, StandardCharsets.UTF_8)); + } catch (IOException ex) { + throw new BlueQuickJsResourceException("failed to parse metadata " + path, ex); + } + } + + private static void verifyMagic(byte[] bytes, Path path) { + if (bytes.length < 8) { + throw new BlueQuickJsDeterminismException("wasm artifact is too small: " + path); + } + for (int i = 0; i < WASM_MAGIC.length; i++) { + if (bytes[i] != WASM_MAGIC[i]) { + throw new BlueQuickJsDeterminismException("invalid wasm magic bytes for " + path); + } + } + } + + private static JsonNode selectedVariant(JsonNode metadata, BlueQuickJsWasmRuntimeConfig config) { + JsonNode selected = metadata.path("variants") + .path(config.expectedVariant()) + .path(config.expectedBuildType()); + if (selected.isMissingNode() || selected.isNull()) { + throw new BlueQuickJsDeterminismException("metadata missing " + + config.expectedVariant() + "/" + config.expectedBuildType() + " artifact"); + } + String buildType = requiredText(selected.get("buildType"), "metadata buildType"); + if (!config.expectedBuildType().equals(buildType)) { + throw new BlueQuickJsDeterminismException("buildType mismatch: expected=" + + config.expectedBuildType() + ", actual=" + buildType); + } + if (!"wasm32".equals(config.expectedVariant())) { + throw new BlueQuickJsDeterminismException("only wasm32 is supported: " + config.expectedVariant()); + } + if (!"release".equals(config.expectedBuildType())) { + throw new BlueQuickJsDeterminismException("only release artifacts are supported: " + config.expectedBuildType()); + } + return selected; + } + + private static void verifyMetadataShape(JsonNode metadata) { + JsonNode memory = metadata.path("build").path("memory"); + if (requiredInt(memory.get("initial"), "memory.initial") != 33554432) { + throw new BlueQuickJsDeterminismException("unexpected initial wasm memory"); + } + if (requiredInt(memory.get("maximum"), "memory.maximum") != 33554432) { + throw new BlueQuickJsDeterminismException("unexpected maximum wasm memory"); + } + if (requiredInt(memory.get("stackSize"), "memory.stackSize") != 1048576) { + throw new BlueQuickJsDeterminismException("unexpected wasm stack size"); + } + if (memory.path("allowGrowth").asBoolean(true)) { + throw new BlueQuickJsDeterminismException("wasm memory growth must be disabled"); + } + JsonNode flags = metadata.path("build").path("determinism").path("flags"); + if (!flags.isArray()) { + throw new BlueQuickJsDeterminismException("metadata determinism flags are missing"); + } + requireFlag(flags, "-sFILESYSTEM=0"); + requireFlag(flags, "-sALLOW_MEMORY_GROWTH=0"); + requireFlag(flags, "-sALLOW_TABLE_GROWTH=0"); + } + + private static void requireFlag(JsonNode flags, String expected) { + for (JsonNode flag : flags) { + if (expected.equals(flag.asText())) { + return; + } + } + throw new BlueQuickJsDeterminismException("metadata missing deterministic flag " + expected); + } + + private static String verifyAbiManifestHash(BlueQuickJsWasmRuntimeConfig config) { + String actual = HostV1Manifest.HOST_V1_HASH; + String expected = config.expectedAbiManifestHash(); + if (expected == null || expected.trim().isEmpty()) { + throw new BlueQuickJsDeterminismException("expected ABI manifest hash is required"); + } + if (!actual.equals(expected)) { + throw new BlueQuickJsDeterminismException("ABI manifest hash mismatch: expected=" + + expected + ", actual=" + actual); + } + return actual; + } + + private static void verifyImports(List imports) { + for (WasmImport wasmImport : imports) { + String key = wasmImport.module() + "." + wasmImport.name(); + if (!APPROVED_IMPORTS.contains(key)) { + throw new BlueQuickJsDeterminismException("unsupported wasm import: " + key); + } + String lower = key.toLowerCase(Locale.ROOT); + if (lower.startsWith("wasi_") || lower.contains("random") || lower.contains("clock") + || lower.contains("fd_") || lower.contains("sock")) { + throw new BlueQuickJsDeterminismException("nondeterministic wasm import rejected: " + key); + } + } + } + + private static void verifyExports(List exports) { + Set names = new LinkedHashSet(); + for (WasmExport wasmExport : exports) { + names.add(wasmExport.name()); + } + for (String required : Arrays.asList("memory", "malloc", "free", "qjs_det_init", "qjs_det_eval", + "qjs_det_set_gas_limit", "qjs_det_free")) { + if (!names.contains(required)) { + throw new BlueQuickJsDeterminismException("required wasm export missing: " + required); + } + } + } + + private static String requiredText(JsonNode node, String path) { + if (node == null || !node.isTextual() || node.asText().trim().isEmpty()) { + throw new BlueQuickJsDeterminismException("required metadata field missing or non-text: " + path); + } + return node.asText(); + } + + private static String requiredHex(JsonNode node, String path) { + String value = requiredText(node, path).toLowerCase(Locale.ROOT); + if (value.length() != 64) { + throw new BlueQuickJsDeterminismException(path + " must be a SHA-256 hex string"); + } + for (int i = 0; i < value.length(); i++) { + if (Character.digit(value.charAt(i), 16) < 0) { + throw new BlueQuickJsDeterminismException(path + " contains non-hex characters"); + } + } + return value; + } + + private static int requiredInt(JsonNode node, String path) { + if (node == null || !node.canConvertToInt()) { + throw new BlueQuickJsDeterminismException("required metadata integer missing: " + path); + } + return node.asInt(); + } + + static String sha256Hex(byte[] bytes) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(bytes); + StringBuilder hex = new StringBuilder(hashed.length * 2); + for (byte value : hashed) { + hex.append(String.format(Locale.ROOT, "%02x", value & 0xff)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException ex) { + throw new BlueQuickJsResourceException("SHA-256 is unavailable", ex); + } + } + + public static final class WasmImport { + private final String module; + private final String name; + private final String kind; + private final String signature; + + private WasmImport(String module, String name, String kind, String signature) { + this.module = module; + this.name = name; + this.kind = kind; + this.signature = signature; + } + + public String module() { + return module; + } + + public String name() { + return name; + } + + public String kind() { + return kind; + } + + public String signature() { + return signature; + } + } + + public static final class WasmExport { + private final String name; + private final String kind; + private final int index; + + private WasmExport(String name, String kind, int index) { + this.name = name; + this.kind = kind; + this.index = index; + } + + public String name() { + return name; + } + + public String kind() { + return kind; + } + + public int index() { + return index; + } + } + + private static final class WasmModuleShape { + private final List imports; + private final List exports; + + private WasmModuleShape(List imports, List exports) { + this.imports = imports; + this.exports = exports; + } + + private static WasmModuleShape parse(byte[] bytes) { + WasmReader reader = new WasmReader(bytes); + reader.expectHeader(); + List types = new ArrayList(); + List imports = new ArrayList(); + List exports = new ArrayList(); + while (!reader.done()) { + int sectionId = reader.u8(); + int sectionSize = reader.varuint32(); + int sectionEnd = reader.position() + sectionSize; + if (sectionId == 1) { + int count = reader.varuint32(); + for (int i = 0; i < count; i++) { + reader.expectByte(0x60); + int paramCount = reader.varuint32(); + List params = new ArrayList(); + for (int p = 0; p < paramCount; p++) { + params.add(valType(reader.u8())); + } + int resultCount = reader.varuint32(); + List results = new ArrayList(); + for (int r = 0; r < resultCount; r++) { + results.add(valType(reader.u8())); + } + types.add(new FuncType(params, results)); + } + } else if (sectionId == 2) { + int count = reader.varuint32(); + for (int i = 0; i < count; i++) { + String module = reader.name(); + String name = reader.name(); + int kind = reader.u8(); + imports.add(readImport(reader, types, module, name, kind)); + } + } else if (sectionId == 7) { + int count = reader.varuint32(); + for (int i = 0; i < count; i++) { + String name = reader.name(); + int kind = reader.u8(); + int index = reader.varuint32(); + exports.add(new WasmExport(name, externalKind(kind), index)); + } + } + reader.position(sectionEnd); + } + return new WasmModuleShape(imports, exports); + } + + private static WasmImport readImport(WasmReader reader, + List types, + String module, + String name, + int kind) { + if (kind == 0) { + int typeIndex = reader.varuint32(); + String signature = typeIndex >= 0 && typeIndex < types.size() + ? types.get(typeIndex).signature() + : "type[" + typeIndex + "]"; + return new WasmImport(module, name, "func", signature); + } + if (kind == 1) { + reader.u8(); + skipLimits(reader); + return new WasmImport(module, name, "table", ""); + } + if (kind == 2) { + skipLimits(reader); + return new WasmImport(module, name, "memory", ""); + } + if (kind == 3) { + String type = valType(reader.u8()); + int mutable = reader.u8(); + return new WasmImport(module, name, "global", type + " mutable=" + mutable); + } + throw new BlueQuickJsDeterminismException("unsupported wasm import kind: " + kind); + } + + private static void skipLimits(WasmReader reader) { + int flags = reader.u8(); + reader.varuint32(); + if ((flags & 1) != 0) { + reader.varuint32(); + } + } + + private static String valType(int type) { + switch (type) { + case 0x7f: + return "i32"; + case 0x7e: + return "i64"; + case 0x7d: + return "f32"; + case 0x7c: + return "f64"; + default: + return "0x" + Integer.toHexString(type); + } + } + + private static String externalKind(int kind) { + switch (kind) { + case 0: + return "func"; + case 1: + return "table"; + case 2: + return "memory"; + case 3: + return "global"; + default: + return "kind-" + kind; + } + } + } + + private static final class FuncType { + private final List params; + private final List results; + + private FuncType(List params, List results) { + this.params = params; + this.results = results; + } + + private String signature() { + return "(" + join(params) + ") -> " + (results.isEmpty() ? "()" : join(results)); + } + + private static String join(List values) { + if (values.isEmpty()) { + return ""; + } + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < values.size(); i++) { + if (i > 0) { + builder.append(", "); + } + builder.append(values.get(i)); + } + return builder.toString(); + } + } + + private static final class WasmReader { + private final byte[] bytes; + private int position; + + private WasmReader(byte[] bytes) { + this.bytes = bytes; + } + + private void expectHeader() { + if (bytes.length < 8 || bytes[0] != 0x00 || bytes[1] != 0x61 || bytes[2] != 0x73 || bytes[3] != 0x6d) { + throw new BlueQuickJsDeterminismException("invalid wasm header"); + } + position = 8; + } + + private boolean done() { + return position >= bytes.length; + } + + private int position() { + return position; + } + + private void position(int position) { + if (position < 0 || position > bytes.length) { + throw new BlueQuickJsDeterminismException("invalid wasm section size"); + } + this.position = position; + } + + private int u8() { + if (position >= bytes.length) { + throw new BlueQuickJsDeterminismException("unexpected end of wasm"); + } + return bytes[position++] & 0xff; + } + + private void expectByte(int expected) { + int actual = u8(); + if (actual != expected) { + throw new BlueQuickJsDeterminismException("unexpected wasm byte: expected " + + expected + ", actual " + actual); + } + } + + private int varuint32() { + long result = 0L; + int shift = 0; + for (int i = 0; i < 5; i++) { + int value = u8(); + result |= (long) (value & 0x7f) << shift; + if ((value & 0x80) == 0) { + if (result > 0xffffffffL) { + throw new BlueQuickJsDeterminismException("wasm varuint32 overflow"); + } + return (int) result; + } + shift += 7; + } + throw new BlueQuickJsDeterminismException("wasm varuint32 too long"); + } + + private String name() { + int length = varuint32(); + if (length < 0 || length > bytes.length - position) { + throw new BlueQuickJsDeterminismException("invalid wasm name length"); + } + String value = new String(bytes, position, length, StandardCharsets.UTF_8); + position += length; + return value; + } + } +} diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmRuntimeConfig.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmRuntimeConfig.java new file mode 100644 index 0000000..3bf89d7 --- /dev/null +++ b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmRuntimeConfig.java @@ -0,0 +1,141 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import java.nio.file.Path; + +public final class BlueQuickJsWasmRuntimeConfig { + public static final String BLUE_QUICKJS_ROOT_PROPERTY = "blue.quickjs.root"; + public static final String ENGINE_BUILD_HASH_PROPERTY = "blue.quickjs.engineBuildHash"; + public static final int DEFAULT_GAS_VERSION = 2; + public static final String DEFAULT_EXECUTION_PROFILE = "blue-quickjs-deterministic-baseline-1"; + + private final Path blueQuickJsRoot; + private final String expectedEngineBuildHash; + private final String expectedAbiManifestHash; + private final int expectedGasVersion; + private final String expectedExecutionProfile; + private final String expectedVariant; + private final String expectedBuildType; + private final boolean preferClasspathResources; + + private BlueQuickJsWasmRuntimeConfig(Builder builder) { + this.blueQuickJsRoot = builder.blueQuickJsRoot; + this.expectedEngineBuildHash = normalizeHex(builder.expectedEngineBuildHash); + this.expectedAbiManifestHash = normalizeHex(builder.expectedAbiManifestHash); + this.expectedGasVersion = builder.expectedGasVersion; + this.expectedExecutionProfile = builder.expectedExecutionProfile; + this.expectedVariant = builder.expectedVariant; + this.expectedBuildType = builder.expectedBuildType; + this.preferClasspathResources = builder.preferClasspathResources; + } + + public Path blueQuickJsRoot() { + return blueQuickJsRoot; + } + + public String expectedEngineBuildHash() { + return expectedEngineBuildHash; + } + + public String expectedAbiManifestHash() { + return expectedAbiManifestHash; + } + + public int expectedGasVersion() { + return expectedGasVersion; + } + + public String expectedExecutionProfile() { + return expectedExecutionProfile; + } + + public String expectedVariant() { + return expectedVariant; + } + + public String expectedBuildType() { + return expectedBuildType; + } + + public boolean preferClasspathResources() { + return preferClasspathResources; + } + + public static Builder builder() { + return new Builder(); + } + + public static BlueQuickJsWasmRuntimeConfig defaultConfig() { + return builder().build(); + } + + private static String normalizeHex(String value) { + return value == null || value.trim().isEmpty() ? null : value.trim().toLowerCase(); + } + + public static final class Builder { + private Path blueQuickJsRoot; + private String expectedEngineBuildHash = System.getProperty(ENGINE_BUILD_HASH_PROPERTY); + private String expectedAbiManifestHash = HostV1Manifest.HOST_V1_HASH; + private int expectedGasVersion = DEFAULT_GAS_VERSION; + private String expectedExecutionProfile = DEFAULT_EXECUTION_PROFILE; + private String expectedVariant = "wasm32"; + private String expectedBuildType = "release"; + private boolean preferClasspathResources = true; + + public Builder blueQuickJsRoot(Path blueQuickJsRoot) { + this.blueQuickJsRoot = blueQuickJsRoot; + return this; + } + + public Builder expectedEngineBuildHash(String expectedEngineBuildHash) { + this.expectedEngineBuildHash = expectedEngineBuildHash; + return this; + } + + public Builder expectedAbiManifestHash(String expectedAbiManifestHash) { + this.expectedAbiManifestHash = expectedAbiManifestHash; + return this; + } + + public Builder expectedGasVersion(int expectedGasVersion) { + this.expectedGasVersion = expectedGasVersion; + return this; + } + + public Builder expectedExecutionProfile(String expectedExecutionProfile) { + this.expectedExecutionProfile = expectedExecutionProfile; + return this; + } + + public Builder expectedVariant(String expectedVariant) { + this.expectedVariant = expectedVariant; + return this; + } + + public Builder expectedBuildType(String expectedBuildType) { + this.expectedBuildType = expectedBuildType; + return this; + } + + public Builder preferClasspathResources(boolean preferClasspathResources) { + this.preferClasspathResources = preferClasspathResources; + return this; + } + + public BlueQuickJsWasmRuntimeConfig build() { + if (expectedGasVersion <= 0) { + throw new IllegalArgumentException("expectedGasVersion must be positive"); + } + if (expectedExecutionProfile == null || expectedExecutionProfile.trim().isEmpty()) { + throw new IllegalArgumentException("expectedExecutionProfile must not be blank"); + } + if (expectedVariant == null || expectedVariant.trim().isEmpty()) { + throw new IllegalArgumentException("expectedVariant must not be blank"); + } + if (expectedBuildType == null || expectedBuildType.trim().isEmpty()) { + throw new IllegalArgumentException("expectedBuildType must not be blank"); + } + return new BlueQuickJsWasmRuntimeConfig(this); + } + } +} diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/HostV1Manifest.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/HostV1Manifest.java new file mode 100644 index 0000000..d37e4d3 --- /dev/null +++ b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/HostV1Manifest.java @@ -0,0 +1,37 @@ +package blue.contract.processor.conversation.javascript.chicory; + +public final class HostV1Manifest { + public static final String ABI_ID = "Host.v1"; + public static final int ABI_VERSION = 1; + public static final String HOST_V1_HASH = + "e23b0b2ee169900bbde7aff78e6ce20fead1715c60f8a8e3106d9959450a3d34"; + public static final String HOST_V1_BYTES_HEX = + "a3666162695f696467486f73742e76316966756e6374696f6e7383a963676173a5646261736514676b5f756e697473016b6b5f6172675f6279746573016b6b5f7265745f6279746573016b7363686564756c655f69646b646f632d726561642d76316561726974790165666e5f696401666566666563746452454144666c696d697473a4696d61785f756e6974731903e86c6172675f757466385f6d617881190800716d61785f726571756573745f6279746573191000726d61785f726573706f6e73655f62797465731a00040000676a735f706174688268646f63756d656e74636765746a6172675f736368656d6181a1647479706566737472696e676b6572726f725f636f64657383a26374616771686f73742f696e76616c69645f7061746864636f64656c494e56414c49445f50415448a2637461676a686f73742f6c696d697464636f64656e4c494d49545f4558434545444544a2637461676e686f73742f6e6f745f666f756e6464636f6465694e4f545f464f554e446d72657475726e5f736368656d61a16474797065626476a963676173a5646261736514676b5f756e697473016b6b5f6172675f6279746573016b6b5f7265745f6279746573016b7363686564756c655f69646b646f632d726561642d76316561726974790165666e5f696402666566666563746452454144666c696d697473a4696d61785f756e6974731903e86c6172675f757466385f6d617881190800716d61785f726571756573745f6279746573191000726d61785f726573706f6e73655f62797465731a00040000676a735f706174688268646f63756d656e746c67657443616e6f6e6963616c6a6172675f736368656d6181a1647479706566737472696e676b6572726f725f636f64657383a26374616771686f73742f696e76616c69645f7061746864636f64656c494e56414c49445f50415448a2637461676a686f73742f6c696d697464636f64656e4c494d49545f4558434545444544a2637461676e686f73742f6e6f745f666f756e6464636f6465694e4f545f464f554e446d72657475726e5f736368656d61a16474797065626476a963676173a5646261736505676b5f756e697473016b6b5f6172675f6279746573016b6b5f7265745f6279746573006b7363686564756c655f696467656d69742d76316561726974790165666e5f6964036665666665637464454d4954666c696d697473a3696d61785f756e697473190400716d61785f726571756573745f6279746573198000726d61785f726573706f6e73655f62797465731840676a735f706174688164656d69746a6172675f736368656d6181a164747970656264766b6572726f725f636f64657381a2637461676a686f73742f6c696d697464636f64656e4c494d49545f45584345454445446d72657475726e5f736368656d61a16474797065646e756c6c6b6162695f76657273696f6e01"; + + public static final int DOCUMENT_GET_FN_ID = 1; + public static final int DOCUMENT_GET_CANONICAL_FN_ID = 2; + public static final int EMIT_FN_ID = 3; + + private HostV1Manifest() { + } + + public static byte[] bytes() { + return hexToBytes(HOST_V1_BYTES_HEX); + } + + private static byte[] hexToBytes(String hex) { + if ((hex.length() & 1) != 0) { + throw new IllegalArgumentException("hex string must have even length"); + } + byte[] bytes = new byte[hex.length() / 2]; + for (int i = 0; i < bytes.length; i++) { + int high = Character.digit(hex.charAt(i * 2), 16); + int low = Character.digit(hex.charAt(i * 2 + 1), 16); + if (high < 0 || low < 0) { + throw new IllegalArgumentException("invalid hex at byte " + i); + } + bytes[i] = (byte) ((high << 4) | low); + } + return bytes; + } +} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceIntegrityTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceIntegrityTest.java new file mode 100644 index 0000000..394e5f2 --- /dev/null +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceIntegrityTest.java @@ -0,0 +1,104 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class BlueQuickJsResourceIntegrityTest { + + @Test + void canonicalWasmResourceIsPresentAndPinned() { + Path root = blueQuickJsRoot(); + BlueQuickJsWasmResources resources = BlueQuickJsWasmResources.resolve( + BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(root) + .build()); + + assertTrue(Files.isRegularFile(resources.wasmPath())); + assertTrue(resources.wasmPath().getFileName().toString().equals(BlueQuickJsWasmResources.CANONICAL_WASM_FILENAME)); + assertEquals("1d4584fc0552a24ee840afa2cca9f1536d47429f467585d4d5c1a5236ba96dc9", + resources.engineBuildHash()); + assertEquals(HostV1Manifest.HOST_V1_HASH, resources.abiManifestHash()); + assertEquals(BlueQuickJsWasmRuntimeConfig.DEFAULT_GAS_VERSION, resources.gasVersion()); + assertEquals(BlueQuickJsWasmRuntimeConfig.DEFAULT_EXECUTION_PROFILE, resources.executionProfile()); + assertEquals("wasm32", resources.metadata().path("variants").fieldNames().next()); + assertEquals("release", resources.metadata().path("variants").path("wasm32").path("release").path("buildType").asText()); + assertEquals("quickjs-eval.wasm", + resources.metadata().path("variants").path("wasm32").path("release").path("wasm").path("filename").asText()); + } + + @Test + void importsContainOnlyApprovedDeterministicSurface() { + BlueQuickJsWasmResources resources = BlueQuickJsWasmResources.resolve( + BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(blueQuickJsRoot()) + .build()); + Set imports = new HashSet(); + for (BlueQuickJsWasmResources.WasmImport wasmImport : resources.imports()) { + imports.add(wasmImport.module() + "." + wasmImport.name()); + String lower = (wasmImport.module() + "." + wasmImport.name()).toLowerCase(); + assertFalse(lower.contains("random")); + assertFalse(lower.contains("clock")); + assertFalse(lower.contains("fd_")); + assertFalse(lower.contains("sock")); + assertFalse(lower.startsWith("wasi_")); + } + + assertTrue(imports.contains("host.host_call")); + assertTrue(imports.contains("env.abort")); + assertTrue(imports.contains("env.__assert_fail")); + assertTrue(imports.contains("env.emscripten_date_now")); + assertTrue(imports.contains("env.emscripten_resize_heap")); + assertEquals(5, imports.size()); + } + + @Test + void requiredExportsArePresent() { + BlueQuickJsWasmResources resources = BlueQuickJsWasmResources.resolve( + BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(blueQuickJsRoot()) + .build()); + Set exports = new HashSet(); + for (BlueQuickJsWasmResources.WasmExport wasmExport : resources.exports()) { + exports.add(wasmExport.name()); + } + + assertTrue(exports.contains("memory")); + assertTrue(exports.contains("malloc")); + assertTrue(exports.contains("free")); + assertTrue(exports.contains("qjs_det_init")); + assertTrue(exports.contains("qjs_det_eval")); + assertTrue(exports.contains("qjs_det_set_gas_limit")); + assertTrue(exports.contains("qjs_det_free")); + } + + @Test + void wrongExpectedEngineHashFailsClosed() { + BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, + () -> BlueQuickJsWasmResources.resolve( + BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(blueQuickJsRoot()) + .expectedEngineBuildHash("0000000000000000000000000000000000000000000000000000000000000000") + .build())); + + assertTrue(ex.getMessage().contains("engineBuildHash mismatch")); + } + + private static Path blueQuickJsRoot() { + String configured = System.getProperty("blue.quickjs.root"); + Path root = configured == null || configured.trim().isEmpty() + ? Paths.get(System.getProperty("user.dir")).toAbsolutePath().getParent().resolve("blue-quickjs") + : Paths.get(configured); + assumeTrue(Files.isDirectory(root), "blue-quickjs checkout is required for resource integrity tests"); + return root; + } +} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/HostV1ManifestTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/HostV1ManifestTest.java new file mode 100644 index 0000000..cc5e789 --- /dev/null +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/HostV1ManifestTest.java @@ -0,0 +1,39 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HostV1ManifestTest { + + @Test + void embeddedManifestHashMatchesHostV1Hash() { + assertEquals("Host.v1", HostV1Manifest.ABI_ID); + assertEquals(1, HostV1Manifest.ABI_VERSION); + assertEquals(HostV1Manifest.HOST_V1_HASH, + BlueQuickJsWasmResources.sha256Hex(HostV1Manifest.bytes())); + } + + @Test + void functionIdsAreStable() { + assertEquals(1, HostV1Manifest.DOCUMENT_GET_FN_ID); + assertEquals(2, HostV1Manifest.DOCUMENT_GET_CANONICAL_FN_ID); + assertEquals(3, HostV1Manifest.EMIT_FN_ID); + } + + @Test + void reservedTransportErrorsAreNotDeclaredAsBusinessErrors() { + byte[] bytes = HostV1Manifest.bytes(); + String manifestAscii = new String(bytes, StandardCharsets.ISO_8859_1); + + assertFalse(manifestAscii.contains("HOST_TRANSPORT")); + assertFalse(manifestAscii.contains("HOST_ENVELOPE_INVALID")); + assertTrue(manifestAscii.contains("Host.v1")); + assertTrue(manifestAscii.contains("document")); + assertTrue(manifestAscii.contains("getCanonical")); + assertTrue(manifestAscii.contains("emit")); + } +} From e767253af5c7215594364151041d8740bfe78068 Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 12 May 2026 21:55:38 +0000 Subject: [PATCH 05/17] Implement deterministic DV codec Co-authored-by: Kamil Gruszka --- docs/chicory-bluequickjs-test-results.md | 28 + .../BlueQuickJsDeterminismException.java | 2 +- .../chicory/DeterministicValueCodec.java | 567 ++++++++++++++++++ .../chicory/DeterministicValueCodecTest.java | 177 ++++++ 4 files changed, 773 insertions(+), 1 deletion(-) create mode 100644 quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/DeterministicValueCodec.java create mode 100644 quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/DeterministicValueCodecTest.java diff --git a/docs/chicory-bluequickjs-test-results.md b/docs/chicory-bluequickjs-test-results.md index 5c8b16b..4c8ca5a 100644 --- a/docs/chicory-bluequickjs-test-results.md +++ b/docs/chicory-bluequickjs-test-results.md @@ -111,6 +111,34 @@ Outcome: exports. - Verified an incorrect expected engine hash fails closed before evaluation. +## Deterministic DV codec + +Command: + +```bash +./gradlew :quickjs-chicory:test --tests '*DeterministicValueCodecTest' +``` + +Outcome: + +- Passed. +- Golden encodings matched the blue-quickjs DV documentation. +- Allowed values round-tripped. +- Rejection cases covered NaN, infinities, negative zero, duplicate/unsorted map + keys, indefinite lengths, tags, half/float32, overlong strings, oversized + encoded values, excessive depth, arrays, and maps. + +Command: + +```bash +./gradlew :quickjs-chicory:test -PblueQuickJsRoot=/tmp/blue-quickjs +``` + +Outcome: + +- Passed. +- Full current `quickjs-chicory` test set is green. + These are intentionally left pending until the corresponding implementation phases exist: diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsDeterminismException.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsDeterminismException.java index 588a972..44f0cf0 100644 --- a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsDeterminismException.java +++ b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsDeterminismException.java @@ -1,6 +1,6 @@ package blue.contract.processor.conversation.javascript.chicory; -public final class BlueQuickJsDeterminismException extends BlueQuickJsResourceException { +public class BlueQuickJsDeterminismException extends BlueQuickJsResourceException { public BlueQuickJsDeterminismException(String message) { super(message); } diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/DeterministicValueCodec.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/DeterministicValueCodec.java new file mode 100644 index 0000000..37f9b6f --- /dev/null +++ b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/DeterministicValueCodec.java @@ -0,0 +1,567 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetEncoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class DeterministicValueCodec { + public static final int MAX_DEPTH = 64; + public static final int MAX_ENCODED_BYTES = 5 * 1024 * 1024; + public static final int MAX_STRING_BYTES = 256 * 1024; + public static final int MAX_ARRAY_LENGTH = 65535; + public static final int MAX_MAP_SIZE = 65535; + private static final long MAX_SAFE_INTEGER = 9007199254740991L; + private static final long MIN_SAFE_INTEGER = -9007199254740991L; + + private DeterministicValueCodec() { + } + + public static byte[] encode(Object value) { + LimitedByteArrayOutputStream output = new LimitedByteArrayOutputStream(MAX_ENCODED_BYTES); + encodeValue(value, output, 0); + return output.toByteArray(); + } + + public static Object decode(byte[] bytes) { + if (bytes == null) { + throw new DeterministicValueException("input bytes must not be null"); + } + if (bytes.length > MAX_ENCODED_BYTES) { + throw new DeterministicValueException("encoded value exceeds max bytes: " + bytes.length); + } + Decoder decoder = new Decoder(bytes); + Object value = decoder.decodeValue(0); + if (!decoder.done()) { + throw new DeterministicValueException("trailing bytes after DV value"); + } + return value; + } + + public static Object roundTrip(Object value) { + return decode(encode(value)); + } + + private static void encodeValue(Object value, LimitedByteArrayOutputStream output, int depth) { + if (value == null) { + output.writeByte(0xf6); + return; + } + if (value instanceof Boolean) { + output.writeByte(Boolean.TRUE.equals(value) ? 0xf5 : 0xf4); + return; + } + if (value instanceof String) { + encodeString((String) value, output); + return; + } + if (value instanceof Number) { + encodeNumber((Number) value, output); + return; + } + if (value instanceof Map) { + encodeMap((Map) value, output, depth); + return; + } + if (value instanceof Collection) { + encodeArray(new ArrayList((Collection) value), output, depth); + return; + } + if (value instanceof Object[]) { + encodeArray(Arrays.asList((Object[]) value), output, depth); + return; + } + throw new DeterministicValueException("unsupported DV value type: " + value.getClass().getName()); + } + + private static void encodeNumber(Number number, LimitedByteArrayOutputStream output) { + if (number instanceof Float) { + throw new DeterministicValueException("float32 values are not supported"); + } + if (number instanceof Double) { + double value = number.doubleValue(); + if (!Double.isFinite(value)) { + throw new DeterministicValueException("number must be finite"); + } + if (Double.doubleToRawLongBits(value) == Double.doubleToRawLongBits(-0.0d)) { + throw new DeterministicValueException("negative zero is not canonical DV"); + } + if (isMathematicalInteger(value) && value >= MIN_SAFE_INTEGER && value <= MAX_SAFE_INTEGER) { + encodeInteger((long) value, output); + return; + } + encodeFloat64(value, output); + return; + } + if (number instanceof BigDecimal) { + BigDecimal decimal = ((BigDecimal) number).stripTrailingZeros(); + if (decimal.scale() <= 0) { + encodeBigInteger(decimal.toBigIntegerExact(), output); + return; + } + double value = decimal.doubleValue(); + if (!Double.isFinite(value)) { + throw new DeterministicValueException("number must be finite"); + } + if (isMathematicalInteger(value)) { + throw new DeterministicValueException("non-integer BigDecimal lost precision as integer"); + } + encodeFloat64(value, output); + return; + } + if (number instanceof BigInteger) { + encodeBigInteger((BigInteger) number, output); + return; + } + encodeInteger(number.longValue(), output); + } + + private static boolean isMathematicalInteger(double value) { + return value == Math.rint(value); + } + + private static void encodeBigInteger(BigInteger integer, LimitedByteArrayOutputStream output) { + if (integer.compareTo(BigInteger.valueOf(MIN_SAFE_INTEGER)) < 0 + || integer.compareTo(BigInteger.valueOf(MAX_SAFE_INTEGER)) > 0) { + throw new DeterministicValueException("integer exceeds safe integer range"); + } + encodeInteger(integer.longValue(), output); + } + + private static void encodeInteger(long value, LimitedByteArrayOutputStream output) { + if (value < MIN_SAFE_INTEGER || value > MAX_SAFE_INTEGER) { + throw new DeterministicValueException("integer exceeds safe integer range"); + } + if (value >= 0) { + writeTypeAndLength(0, value, output); + } else { + writeTypeAndLength(1, -1L - value, output); + } + } + + private static void encodeFloat64(double value, LimitedByteArrayOutputStream output) { + output.writeByte(0xfb); + long bits = Double.doubleToLongBits(value); + for (int i = 7; i >= 0; i--) { + output.writeByte((int) ((bits >>> (i * 8)) & 0xff)); + } + } + + private static void encodeString(String value, LimitedByteArrayOutputStream output) { + byte[] encoded = utf8(value); + if (encoded.length > MAX_STRING_BYTES) { + throw new DeterministicValueException("string exceeds max UTF-8 bytes: " + encoded.length); + } + writeTypeAndLength(3, encoded.length, output); + output.writeBytes(encoded); + } + + private static byte[] utf8(String value) { + CharsetEncoder encoder = StandardCharsets.UTF_8.newEncoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + try { + ByteBuffer buffer = encoder.encode(java.nio.CharBuffer.wrap(value)); + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + return bytes; + } catch (CharacterCodingException ex) { + throw new DeterministicValueException("string is not well-formed UTF-16/UTF-8", ex); + } + } + + private static void encodeArray(List values, LimitedByteArrayOutputStream output, int depth) { + int nextDepth = depth + 1; + if (nextDepth > MAX_DEPTH) { + throw new DeterministicValueException("maximum DV depth exceeded"); + } + if (values.size() > MAX_ARRAY_LENGTH) { + throw new DeterministicValueException("array exceeds max length: " + values.size()); + } + writeTypeAndLength(4, values.size(), output); + for (Object value : values) { + encodeValue(value, output, nextDepth); + } + } + + private static void encodeMap(Map map, LimitedByteArrayOutputStream output, int depth) { + int nextDepth = depth + 1; + if (nextDepth > MAX_DEPTH) { + throw new DeterministicValueException("maximum DV depth exceeded"); + } + if (map.size() > MAX_MAP_SIZE) { + throw new DeterministicValueException("map exceeds max size: " + map.size()); + } + List entries = new ArrayList(); + for (Map.Entry entry : map.entrySet()) { + if (!(entry.getKey() instanceof String)) { + throw new DeterministicValueException("map keys must be strings"); + } + String key = (String) entry.getKey(); + entries.add(new MapEntry(key, utf8(key), entry.getValue())); + } + Collections.sort(entries, new Comparator() { + @Override + public int compare(MapEntry left, MapEntry right) { + return compareEncodedKeys(left.encodedKey, right.encodedKey); + } + }); + writeTypeAndLength(5, entries.size(), output); + for (MapEntry entry : entries) { + encodeString(entry.key, output); + encodeValue(entry.value, output, nextDepth); + } + } + + private static int compareEncodedKeys(byte[] leftUtf8, byte[] rightUtf8) { + byte[] left = encodedTextKey(leftUtf8); + byte[] right = encodedTextKey(rightUtf8); + if (left.length != right.length) { + return left.length < right.length ? -1 : 1; + } + for (int i = 0; i < left.length; i++) { + int a = left[i] & 0xff; + int b = right[i] & 0xff; + if (a != b) { + return a < b ? -1 : 1; + } + } + return 0; + } + + private static byte[] encodedTextKey(byte[] utf8) { + LimitedByteArrayOutputStream output = new LimitedByteArrayOutputStream(MAX_STRING_BYTES + 16); + writeTypeAndLength(3, utf8.length, output); + output.writeBytes(utf8); + return output.toByteArray(); + } + + private static void writeTypeAndLength(int majorType, long value, LimitedByteArrayOutputStream output) { + int major = majorType << 5; + if (value < 0) { + throw new DeterministicValueException("negative CBOR length"); + } + if (value <= 23) { + output.writeByte(major | (int) value); + } else if (value <= 0xffL) { + output.writeByte(major | 24); + output.writeByte((int) value); + } else if (value <= 0xffffL) { + output.writeByte(major | 25); + output.writeByte((int) ((value >>> 8) & 0xff)); + output.writeByte((int) (value & 0xff)); + } else if (value <= 0xffffffffL) { + output.writeByte(major | 26); + for (int i = 3; i >= 0; i--) { + output.writeByte((int) ((value >>> (i * 8)) & 0xff)); + } + } else { + output.writeByte(major | 27); + for (int i = 7; i >= 0; i--) { + output.writeByte((int) ((value >>> (i * 8)) & 0xff)); + } + } + } + + private static Number decodeInteger(long value) { + if (value < MIN_SAFE_INTEGER || value > MAX_SAFE_INTEGER) { + throw new DeterministicValueException("integer exceeds safe integer range"); + } + if (value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE) { + return Integer.valueOf((int) value); + } + return Long.valueOf(value); + } + + private static final class MapEntry { + private final String key; + private final byte[] encodedKey; + private final Object value; + + private MapEntry(String key, byte[] encodedKey, Object value) { + this.key = key; + this.encodedKey = encodedKey; + this.value = value; + } + } + + private static final class LimitedByteArrayOutputStream { + private final int limit; + private final ByteArrayOutputStream delegate = new ByteArrayOutputStream(); + + private LimitedByteArrayOutputStream(int limit) { + this.limit = limit; + } + + private void writeByte(int value) { + if (delegate.size() + 1 > limit) { + throw new DeterministicValueException("encoded value exceeds max bytes"); + } + delegate.write(value & 0xff); + } + + private void writeBytes(byte[] bytes) { + if (delegate.size() + bytes.length > limit) { + throw new DeterministicValueException("encoded value exceeds max bytes"); + } + delegate.write(bytes, 0, bytes.length); + } + + private byte[] toByteArray() { + return delegate.toByteArray(); + } + } + + private static final class Decoder { + private final byte[] bytes; + private int offset; + + private Decoder(byte[] bytes) { + this.bytes = bytes; + } + + private boolean done() { + return offset == bytes.length; + } + + private Object decodeValue(int depth) { + if (offset >= bytes.length) { + throw new DeterministicValueException("unexpected end of DV value"); + } + int initial = readU8(); + int major = (initial >>> 5) & 0x07; + int additional = initial & 0x1f; + switch (major) { + case 0: + return decodeInteger(readArgument(additional)); + case 1: { + long encoded = readArgument(additional); + if (encoded >= MAX_SAFE_INTEGER) { + throw new DeterministicValueException("negative integer exceeds safe integer range"); + } + return decodeInteger(-1L - encoded); + } + case 2: + throw new DeterministicValueException("byte strings are not valid DV"); + case 3: + return decodeString(additional); + case 4: + return decodeArray(additional, depth); + case 5: + return decodeMap(additional, depth); + case 6: + throw new DeterministicValueException("CBOR tags are not valid DV"); + case 7: + return decodeSimple(additional); + default: + throw new DeterministicValueException("unsupported DV major type"); + } + } + + private Object decodeSimple(int additional) { + if (additional == 20) { + return Boolean.FALSE; + } + if (additional == 21) { + return Boolean.TRUE; + } + if (additional == 22) { + return null; + } + if (additional == 25) { + throw new DeterministicValueException("float16 is not valid DV"); + } + if (additional == 26) { + throw new DeterministicValueException("float32 is not valid DV"); + } + if (additional == 27) { + double value = Double.longBitsToDouble(readUint64Bits()); + if (!Double.isFinite(value)) { + throw new DeterministicValueException("number must be finite"); + } + if (Double.doubleToRawLongBits(value) == Double.doubleToRawLongBits(-0.0d)) { + throw new DeterministicValueException("negative zero is not canonical DV"); + } + if (isMathematicalInteger(value)) { + throw new DeterministicValueException("integer-valued float64 is not canonical DV"); + } + return Double.valueOf(value); + } + if (additional == 31) { + throw new DeterministicValueException("indefinite-length values are not valid DV"); + } + throw new DeterministicValueException("unsupported CBOR simple value"); + } + + private String decodeString(int additional) { + long length = readArgument(additional); + if (length > MAX_STRING_BYTES) { + throw new DeterministicValueException("string exceeds max UTF-8 bytes: " + length); + } + if (length > bytes.length - offset) { + throw new DeterministicValueException("string length exceeds remaining input"); + } + byte[] encoded = Arrays.copyOfRange(bytes, offset, offset + (int) length); + offset += (int) length; + try { + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(encoded)) + .toString(); + } catch (CharacterCodingException ex) { + throw new DeterministicValueException("string payload is not valid UTF-8", ex); + } + } + + private List decodeArray(int additional, int depth) { + int nextDepth = depth + 1; + if (nextDepth > MAX_DEPTH) { + throw new DeterministicValueException("maximum DV depth exceeded"); + } + long length = readArgument(additional); + if (length > MAX_ARRAY_LENGTH) { + throw new DeterministicValueException("array exceeds max length: " + length); + } + List values = new ArrayList((int) length); + for (int i = 0; i < length; i++) { + values.add(decodeValue(nextDepth)); + } + return values; + } + + private Map decodeMap(int additional, int depth) { + int nextDepth = depth + 1; + if (nextDepth > MAX_DEPTH) { + throw new DeterministicValueException("maximum DV depth exceeded"); + } + long length = readArgument(additional); + if (length > MAX_MAP_SIZE) { + throw new DeterministicValueException("map exceeds max size: " + length); + } + Map map = new LinkedHashMap(); + byte[] previous = null; + for (int i = 0; i < length; i++) { + int keyStart = offset; + Object key = decodeValue(nextDepth); + if (!(key instanceof String)) { + throw new DeterministicValueException("map keys must be strings"); + } + byte[] encodedKey = Arrays.copyOfRange(bytes, keyStart, offset); + if (previous != null && compareCanonicalKeys(previous, encodedKey) >= 0) { + throw new DeterministicValueException("map keys must be unique and sorted canonically"); + } + previous = encodedKey; + Object value = decodeValue(nextDepth); + map.put((String) key, value); + } + return map; + } + + private int compareCanonicalKeys(byte[] left, byte[] right) { + if (left.length != right.length) { + return left.length < right.length ? -1 : 1; + } + for (int i = 0; i < left.length; i++) { + int a = left[i] & 0xff; + int b = right[i] & 0xff; + if (a != b) { + return a < b ? -1 : 1; + } + } + return 0; + } + + private long readArgument(int additional) { + if (additional < 24) { + return additional; + } + if (additional == 24) { + int value = readU8(); + if (value < 24) { + throw new DeterministicValueException("non-canonical integer/length width"); + } + return value; + } + if (additional == 25) { + long value = readUnsigned(2); + if (value <= 0xffL) { + throw new DeterministicValueException("non-canonical integer/length width"); + } + return value; + } + if (additional == 26) { + long value = readUnsigned(4); + if (value <= 0xffffL) { + throw new DeterministicValueException("non-canonical integer/length width"); + } + return value; + } + if (additional == 27) { + long value = readUnsigned(8); + if (value <= 0xffffffffL) { + throw new DeterministicValueException("non-canonical integer/length width"); + } + return value; + } + if (additional == 31) { + throw new DeterministicValueException("indefinite-length values are not valid DV"); + } + throw new DeterministicValueException("unsupported CBOR additional information"); + } + + private long readUnsigned(int count) { + if (count > bytes.length - offset) { + throw new DeterministicValueException("unexpected end of DV value"); + } + long value = 0L; + for (int i = 0; i < count; i++) { + value = (value << 8) | readU8(); + } + if (count == 8 && value < 0) { + throw new DeterministicValueException("uint64 value exceeds Java signed range"); + } + return value; + } + + private long readUint64Bits() { + if (8 > bytes.length - offset) { + throw new DeterministicValueException("unexpected end of DV float64"); + } + long value = 0L; + for (int i = 0; i < 8; i++) { + value = (value << 8) | readU8(); + } + return value; + } + + private int readU8() { + if (offset >= bytes.length) { + throw new DeterministicValueException("unexpected end of DV value"); + } + return bytes[offset++] & 0xff; + } + } + + public static final class DeterministicValueException extends BlueQuickJsDeterminismException { + public DeterministicValueException(String message) { + super(message); + } + + public DeterministicValueException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/DeterministicValueCodecTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/DeterministicValueCodecTest.java new file mode 100644 index 0000000..e3364a6 --- /dev/null +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/DeterministicValueCodecTest.java @@ -0,0 +1,177 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DeterministicValueCodecTest { + + @Test + void allowedValuesRoundTrip() { + assertRoundTrip(null, null); + assertRoundTrip(Boolean.TRUE, Boolean.TRUE); + assertRoundTrip(Boolean.FALSE, Boolean.FALSE); + assertRoundTrip(0, 0); + assertRoundTrip(1, 1); + assertRoundTrip(-1, -1); + assertRoundTrip(BigInteger.valueOf(9007199254740991L), 9007199254740991L); + assertRoundTrip(BigInteger.valueOf(-9007199254740991L), -9007199254740991L); + assertRoundTrip(1.5d, 1.5d); + assertRoundTrip("hello", "hello"); + assertRoundTrip(Collections.emptyList(), Collections.emptyList()); + assertRoundTrip(Arrays.asList(1, Boolean.TRUE, null), Arrays.asList(1, Boolean.TRUE, null)); + + Map ok = new LinkedHashMap(); + ok.put("ok", Boolean.TRUE); + assertRoundTrip(ok, ok); + + Map canonicalOrder = new LinkedHashMap(); + canonicalOrder.put("b", 2); + canonicalOrder.put("aa", 1); + assertRoundTrip(canonicalOrder, canonicalOrder); + } + + @Test + void goldenEncodingsMatchBlueQuickJsDocs() { + assertHex("f6", null); + assertHex("f5", Boolean.TRUE); + assertHex("20", -1); + assertHex("826568656c6c6ffb3ff8000000000000", Arrays.asList("hello", 1.5d)); + + Map ok = new LinkedHashMap(); + ok.put("ok", Boolean.TRUE); + assertHex("a1626f6bf5", ok); + + Map ordered = new LinkedHashMap(); + ordered.put("b", 2); + ordered.put("aa", 1); + assertHex("a261620262616101", ordered); + } + + @Test + void nestedObjectAtMaxDepthIsAllowed() { + Object value = "leaf"; + for (int i = 0; i < DeterministicValueCodec.MAX_DEPTH; i++) { + value = Collections.singletonList(value); + } + + Object decoded = DeterministicValueCodec.decode(DeterministicValueCodec.encode(value)); + + assertEquals(value, decoded); + } + + @Test + void forbiddenNumbersAreRejected() { + assertEncodeFails(Double.NaN, "finite"); + assertEncodeFails(Double.POSITIVE_INFINITY, "finite"); + assertEncodeFails(Double.NEGATIVE_INFINITY, "finite"); + assertEncodeFails(-0.0d, "negative zero"); + assertEncodeFails(Float.valueOf(1.5f), "float32"); + } + + @Test + void forbiddenCborFormsAreRejected() { + assertDecodeFails("a2616101616102", "unique and sorted"); + assertDecodeFails("a262616101616202", "unique and sorted"); + assertDecodeFails("9fff", "indefinite"); + assertDecodeFails("bfff", "indefinite"); + assertDecodeFails("7fff", "indefinite"); + assertDecodeFails("c0f6", "tags"); + assertDecodeFails("f93c00", "float16"); + assertDecodeFails("fa3f800000", "float32"); + assertDecodeFails("fb7ff8000000000000", "finite"); + assertDecodeFails("fb8000000000000000", "negative zero"); + assertDecodeFails("fb3ff0000000000000", "integer-valued"); + assertDecodeFails("1817", "non-canonical"); + } + + @Test + void limitsAreEnforced() { + assertEncodeFails(repeat('x', DeterministicValueCodec.MAX_STRING_BYTES + 1), "string exceeds"); + + Object tooDeep = "leaf"; + for (int i = 0; i < DeterministicValueCodec.MAX_DEPTH + 1; i++) { + tooDeep = Collections.singletonList(tooDeep); + } + assertEncodeFails(tooDeep, "depth"); + + List tooLongArray = new ArrayList(); + for (int i = 0; i < DeterministicValueCodec.MAX_ARRAY_LENGTH + 1; i++) { + tooLongArray.add(null); + } + assertEncodeFails(tooLongArray, "array exceeds"); + + Map tooLargeMap = new LinkedHashMap(); + for (int i = 0; i < DeterministicValueCodec.MAX_MAP_SIZE + 1; i++) { + tooLargeMap.put("k" + i, i); + } + assertEncodeFails(tooLargeMap, "map exceeds"); + + List tooManyBytes = new ArrayList(); + String eighty = repeat('y', 80); + for (int i = 0; i < DeterministicValueCodec.MAX_ARRAY_LENGTH; i++) { + tooManyBytes.add(eighty); + } + assertEncodeFails(tooManyBytes, "encoded value exceeds"); + } + + @Test + void decodeLimitsAreEnforced() { + assertDecodeFails("7a00040001", "string exceeds"); + assertDecodeFails("9a00010000", "array exceeds"); + assertDecodeFails("ba00010000", "map exceeds"); + } + + private static void assertRoundTrip(Object value, Object expected) { + assertEquals(expected, DeterministicValueCodec.roundTrip(value)); + } + + private static void assertHex(String expectedHex, Object value) { + assertArrayEquals(bytes(expectedHex), DeterministicValueCodec.encode(value)); + assertEquals(DeterministicValueCodec.decode(bytes(expectedHex)), DeterministicValueCodec.roundTrip(value)); + } + + private static void assertEncodeFails(Object value, String messagePart) { + DeterministicValueCodec.DeterministicValueException ex = assertThrows( + DeterministicValueCodec.DeterministicValueException.class, + () -> DeterministicValueCodec.encode(value)); + assertContains(ex, messagePart); + } + + private static void assertDecodeFails(String hex, String messagePart) { + DeterministicValueCodec.DeterministicValueException ex = assertThrows( + DeterministicValueCodec.DeterministicValueException.class, + () -> DeterministicValueCodec.decode(bytes(hex))); + assertContains(ex, messagePart); + } + + private static void assertContains(Exception ex, String messagePart) { + String message = ex.getMessage(); + if (message == null || !message.contains(messagePart)) { + throw new AssertionError("Expected message to contain \"" + messagePart + "\" but was: " + message); + } + } + + private static byte[] bytes(String hex) { + byte[] bytes = new byte[hex.length() / 2]; + for (int i = 0; i < bytes.length; i++) { + bytes[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return bytes; + } + + private static String repeat(char value, int count) { + char[] chars = new char[count]; + Arrays.fill(chars, value); + return new String(chars); + } +} From 1b780be142a407aef42880ab147aae326a1f6c1d Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 12 May 2026 21:57:24 +0000 Subject: [PATCH 06/17] Add blue-quickjs source and result helpers Co-authored-by: Kamil Gruszka --- docs/chicory-bluequickjs-test-results.md | 28 ++++ .../chicory/BlueQuickJsResultParser.java | 133 ++++++++++++++++++ .../chicory/BlueQuickJsSourceWrapper.java | 42 ++++++ .../chicory/BlueQuickJsResultParserTest.java | 61 ++++++++ .../chicory/BlueQuickJsSourceWrapperTest.java | 32 +++++ 5 files changed, 296 insertions(+) create mode 100644 quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResultParser.java create mode 100644 quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsSourceWrapper.java create mode 100644 quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResultParserTest.java create mode 100644 quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsSourceWrapperTest.java diff --git a/docs/chicory-bluequickjs-test-results.md b/docs/chicory-bluequickjs-test-results.md index 4c8ca5a..3200feb 100644 --- a/docs/chicory-bluequickjs-test-results.md +++ b/docs/chicory-bluequickjs-test-results.md @@ -139,6 +139,34 @@ Outcome: - Passed. - Full current `quickjs-chicory` test set is green. +## Source wrapper and result parser + +Command: + +```bash +./gradlew :quickjs-chicory:test \ + --tests '*BlueQuickJsSourceWrapperTest' \ + --tests '*BlueQuickJsResultParserTest' +``` + +Outcome: + +- Passed. +- Verified expression, block, and raw source wrapping matches `evaluate.mjs`. +- Verified VM `RESULT` / `ERROR` output parsing with gas trailers and malformed + output rejection. + +Command: + +```bash +./gradlew :quickjs-chicory:test -PblueQuickJsRoot=/tmp/blue-quickjs +``` + +Outcome: + +- Passed. +- Full current `quickjs-chicory` test set remains green. + These are intentionally left pending until the corresponding implementation phases exist: diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResultParser.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResultParser.java new file mode 100644 index 0000000..2f1d382 --- /dev/null +++ b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResultParser.java @@ -0,0 +1,133 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import java.math.BigInteger; + +public final class BlueQuickJsResultParser { + private static final String RESULT_PREFIX = "RESULT"; + private static final String ERROR_PREFIX = "ERROR"; + private static final String GAS_MARKER = " GAS remaining="; + private static final String USED_MARKER = " used="; + + private BlueQuickJsResultParser() { + } + + public static ParsedResult parse(String raw) { + if (raw == null) { + throw new BlueQuickJsDeterminismException("VM output must not be null"); + } + String normalized = raw.trim(); + boolean ok; + String withoutKind; + if (normalized.startsWith(RESULT_PREFIX)) { + ok = true; + withoutKind = normalized.substring(RESULT_PREFIX.length()).trim(); + } else if (normalized.startsWith(ERROR_PREFIX)) { + ok = false; + withoutKind = normalized.substring(ERROR_PREFIX.length()).trim(); + } else { + throw new BlueQuickJsDeterminismException("Unexpected VM output prefix: " + normalized); + } + + int gasIndex = withoutKind.lastIndexOf(GAS_MARKER); + if (gasIndex < 0) { + throw new BlueQuickJsDeterminismException("Missing gas trailer in VM output: " + normalized); + } + String payload = withoutKind.substring(0, gasIndex).trim(); + String trailer = withoutKind.substring(gasIndex + GAS_MARKER.length()); + int usedIndex = trailer.lastIndexOf(USED_MARKER); + if (usedIndex < 0) { + throw new BlueQuickJsDeterminismException("Missing used gas trailer in VM output: " + normalized); + } + long gasRemaining = parseGas(trailer.substring(0, usedIndex), "gasRemaining"); + long gasUsed = parseGas(trailer.substring(usedIndex + USED_MARKER.length()), "wasmGasUsed"); + + if (ok) { + return ParsedResult.success(DeterministicValueCodec.decode(hexToBytes(payload)), gasRemaining, gasUsed, raw); + } + return ParsedResult.error(payload, gasRemaining, gasUsed, raw); + } + + private static long parseGas(String value, String field) { + String trimmed = value.trim(); + try { + BigInteger parsed = new BigInteger(trimmed); + if (parsed.signum() < 0 || parsed.compareTo(BigInteger.valueOf(Long.MAX_VALUE)) > 0) { + throw new BlueQuickJsDeterminismException(field + " is outside supported range: " + trimmed); + } + return parsed.longValue(); + } catch (NumberFormatException ex) { + throw new BlueQuickJsDeterminismException("Invalid " + field + ": " + trimmed, ex); + } + } + + private static byte[] hexToBytes(String hex) { + if ((hex.length() & 1) != 0) { + throw new BlueQuickJsDeterminismException("result payload hex must have even length"); + } + byte[] bytes = new byte[hex.length() / 2]; + for (int i = 0; i < bytes.length; i++) { + int high = Character.digit(hex.charAt(i * 2), 16); + int low = Character.digit(hex.charAt(i * 2 + 1), 16); + if (high < 0 || low < 0) { + throw new BlueQuickJsDeterminismException("result payload contains non-hex characters"); + } + bytes[i] = (byte) ((high << 4) | low); + } + return bytes; + } + + public static final class ParsedResult { + private final boolean ok; + private final Object value; + private final String errorMessage; + private final long gasRemaining; + private final long wasmGasUsed; + private final String raw; + + private ParsedResult(boolean ok, + Object value, + String errorMessage, + long gasRemaining, + long wasmGasUsed, + String raw) { + this.ok = ok; + this.value = value; + this.errorMessage = errorMessage; + this.gasRemaining = gasRemaining; + this.wasmGasUsed = wasmGasUsed; + this.raw = raw; + } + + private static ParsedResult success(Object value, long gasRemaining, long wasmGasUsed, String raw) { + return new ParsedResult(true, value, null, gasRemaining, wasmGasUsed, raw); + } + + private static ParsedResult error(String errorMessage, long gasRemaining, long wasmGasUsed, String raw) { + return new ParsedResult(false, null, errorMessage, gasRemaining, wasmGasUsed, raw); + } + + public boolean ok() { + return ok; + } + + public Object value() { + return value; + } + + public String errorMessage() { + return errorMessage; + } + + public long gasRemaining() { + return gasRemaining; + } + + public long wasmGasUsed() { + return wasmGasUsed; + } + + public String raw() { + return raw; + } + } +} diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsSourceWrapper.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsSourceWrapper.java new file mode 100644 index 0000000..1fb713a --- /dev/null +++ b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsSourceWrapper.java @@ -0,0 +1,42 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; + +public final class BlueQuickJsSourceWrapper { + private static final String PRELUDE = "\n" + + "const __blueDocument = globalThis.document;\n" + + "const document = Object.assign(\n" + + " (pointer = '/') => __blueDocument(pointer),\n" + + " { canonical: (pointer = '/') => __blueDocument.canonical(pointer) },\n" + + ");\n"; + + private BlueQuickJsSourceWrapper() { + } + + public static String wrap(JavaScriptEvaluationRequest request) { + if (request == null) { + throw new IllegalArgumentException("request must not be null"); + } + return wrap(request.code(), request.mode()); + } + + public static String wrap(String code, JavaScriptEvaluationRequest.Mode mode) { + if (code == null) { + throw new IllegalArgumentException("code must not be null"); + } + if (mode == JavaScriptEvaluationRequest.Mode.EXPRESSION) { + return "(() => {\n" + PRELUDE + "\nreturn (" + code + ");\n})()"; + } + if (mode == JavaScriptEvaluationRequest.Mode.BLOCK) { + return "(() => {\n" + PRELUDE + "\n" + code + "\n})()"; + } + return code; + } + + static String raw(String code) { + if (code == null) { + throw new IllegalArgumentException("code must not be null"); + } + return code; + } +} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResultParserTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResultParserTest.java new file mode 100644 index 0000000..dd69337 --- /dev/null +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResultParserTest.java @@ -0,0 +1,61 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BlueQuickJsResultParserTest { + + @Test + void parsesSuccessfulResultWithGas() { + Map value = new LinkedHashMap(); + value.put("a", 1); + value.put("b", Arrays.asList(Boolean.TRUE, null)); + String payload = hex(DeterministicValueCodec.encode(value)); + + BlueQuickJsResultParser.ParsedResult result = + BlueQuickJsResultParser.parse("RESULT " + payload + " GAS remaining=10 used=5"); + + assertTrue(result.ok()); + assertEquals(value, result.value()); + assertEquals(10L, result.gasRemaining()); + assertEquals(5L, result.wasmGasUsed()); + } + + @Test + void parsesErrorResultWithGas() { + BlueQuickJsResultParser.ParsedResult result = + BlueQuickJsResultParser.parse("ERROR TypeError: boom GAS remaining=0 used=7"); + + assertFalse(result.ok()); + assertEquals("TypeError: boom", result.errorMessage()); + assertEquals(0L, result.gasRemaining()); + assertEquals(7L, result.wasmGasUsed()); + } + + @Test + void rejectsMalformedOutput() { + assertThrows(BlueQuickJsDeterminismException.class, + () -> BlueQuickJsResultParser.parse("UNKNOWN f6 GAS remaining=1 used=1")); + assertThrows(BlueQuickJsDeterminismException.class, + () -> BlueQuickJsResultParser.parse("RESULT f6")); + assertThrows(BlueQuickJsDeterminismException.class, + () -> BlueQuickJsResultParser.parse("RESULT f GAS remaining=1 used=1")); + assertThrows(BlueQuickJsDeterminismException.class, + () -> BlueQuickJsResultParser.parse("RESULT xx GAS remaining=1 used=1")); + } + + private static String hex(byte[] bytes) { + StringBuilder builder = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + builder.append(String.format("%02x", value & 0xff)); + } + return builder.toString(); + } +} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsSourceWrapperTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsSourceWrapperTest.java new file mode 100644 index 0000000..94dc66a --- /dev/null +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsSourceWrapperTest.java @@ -0,0 +1,32 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class BlueQuickJsSourceWrapperTest { + private static final String PRELUDE = "\n" + + "const __blueDocument = globalThis.document;\n" + + "const document = Object.assign(\n" + + " (pointer = '/') => __blueDocument(pointer),\n" + + " { canonical: (pointer = '/') => __blueDocument.canonical(pointer) },\n" + + ");\n"; + + @Test + void expressionWrapperMatchesEvaluateMjs() { + assertEquals("(() => {\n" + PRELUDE + "\nreturn (counter + 1);\n})()", + BlueQuickJsSourceWrapper.wrap("counter + 1", JavaScriptEvaluationRequest.Mode.EXPRESSION)); + } + + @Test + void blockWrapperMatchesEvaluateMjs() { + assertEquals("(() => {\n" + PRELUDE + "\nconst x = 1; return x;\n})()", + BlueQuickJsSourceWrapper.wrap("const x = 1; return x;", JavaScriptEvaluationRequest.Mode.BLOCK)); + } + + @Test + void rawWrapperLeavesCodeUntouched() { + assertEquals("1 + 1", BlueQuickJsSourceWrapper.raw("1 + 1")); + } +} From 2173ec40aff3a6deff211f8f0b9b392a60a7dbe6 Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 12 May 2026 22:00:34 +0000 Subject: [PATCH 07/17] Implement Host v1 dispatcher Co-authored-by: Kamil Gruszka --- docs/chicory-bluequickjs-test-results.md | 30 ++ .../chicory/BlueQuickJsHostDispatcher.java | 325 ++++++++++++++++++ .../chicory/ChicoryDocumentHostTest.java | 110 ++++++ .../chicory/ChicoryHostCallAbiTest.java | 102 ++++++ 4 files changed, 567 insertions(+) create mode 100644 quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsHostDispatcher.java create mode 100644 quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryDocumentHostTest.java create mode 100644 quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryHostCallAbiTest.java diff --git a/docs/chicory-bluequickjs-test-results.md b/docs/chicory-bluequickjs-test-results.md index 3200feb..ff26772 100644 --- a/docs/chicory-bluequickjs-test-results.md +++ b/docs/chicory-bluequickjs-test-results.md @@ -167,6 +167,36 @@ Outcome: - Passed. - Full current `quickjs-chicory` test set remains green. +## Host.v1 dispatcher + +Command: + +```bash +./gradlew :quickjs-chicory:test \ + --tests '*ChicoryDocumentHostTest' \ + --tests '*ChicoryHostCallAbiTest' +``` + +Outcome: + +- Passed. +- Covered `document.get`, `document.getCanonical`, metadata override behavior, + JSON Pointer escaping, missing values, emit limit envelope behavior, malformed + requests, oversized request/response limits, unknown function IDs, reentrant + calls, and internal failure containment. + +Command: + +```bash +./gradlew :quickjs-chicory:test -PblueQuickJsRoot=/tmp/blue-quickjs +``` + +Outcome: + +- Passed. +- Full current `quickjs-chicory` test set remains green after Host.v1 + dispatcher additions. + These are intentionally left pending until the corresponding implementation phases exist: diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsHostDispatcher.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsHostDispatcher.java new file mode 100644 index 0000000..9180ab2 --- /dev/null +++ b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsHostDispatcher.java @@ -0,0 +1,325 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class BlueQuickJsHostDispatcher { + public static final long TRANSPORT_ERROR = 0xffffffffL; + private static final String LIMIT_EXCEEDED = "LIMIT_EXCEEDED"; + private static final Set BLUE_METADATA_KEYS = Collections.unmodifiableSet(new LinkedHashSet( + Arrays.asList("name", + "description", + "type", + "itemType", + "keyType", + "valueType", + "value", + "items", + "blue", + "blueId", + "schema", + "mergePolicy", + "$previous", + "$pos"))); + + private final Map bindings; + private boolean inProgress; + + public BlueQuickJsHostDispatcher(Map bindings) { + this.bindings = bindings == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap(bindings)); + } + + public synchronized DispatchResult dispatch(int fnId, byte[] requestBytes) { + if (inProgress) { + return DispatchResult.fatal("reentrant host_call"); + } + inProgress = true; + try { + return dispatchInternal(fnId, requestBytes); + } catch (RuntimeException ex) { + return DispatchResult.fatal("host dispatcher threw: " + ex.getMessage()); + } finally { + inProgress = false; + } + } + + public DispatchResult dispatchForTestingWithoutGuard(int fnId, byte[] requestBytes) { + return dispatchInternal(fnId, requestBytes); + } + + private DispatchResult dispatchInternal(int fnId, byte[] requestBytes) { + FunctionSpec spec = FunctionSpec.forFnId(fnId); + if (spec == null) { + return DispatchResult.fatal("unknown fn_id " + fnId); + } + if (requestBytes == null) { + return DispatchResult.fatal("request bytes must not be null"); + } + if (requestBytes.length > spec.maxRequestBytes) { + return encodeLimit(spec); + } + Object decoded; + try { + decoded = DeterministicValueCodec.decode(requestBytes); + } catch (DeterministicValueCodec.DeterministicValueException ex) { + return DispatchResult.fatal("failed to decode request: " + ex.getMessage()); + } + if (!(decoded instanceof List)) { + return DispatchResult.fatal("request must be a DV array"); + } + List args = (List) decoded; + if (args.size() != spec.arity) { + return DispatchResult.fatal("fn_id=" + fnId + " expected " + spec.arity + " args"); + } + if (fnId == HostV1Manifest.DOCUMENT_GET_FN_ID) { + return handleDocument(spec, args.get(0), false); + } + if (fnId == HostV1Manifest.DOCUMENT_GET_CANONICAL_FN_ID) { + return handleDocument(spec, args.get(0), true); + } + if (fnId == HostV1Manifest.EMIT_FN_ID) { + return handleEmit(spec); + } + return DispatchResult.fatal("unknown fn_id " + fnId); + } + + private DispatchResult handleDocument(FunctionSpec spec, Object pointer, boolean canonical) { + if (pointer instanceof String + && ((String) pointer).getBytes(StandardCharsets.UTF_8).length > spec.argUtf8Max) { + return encodeLimit(spec); + } + Object root = canonical ? bindings.get("documentCanonical") : bindings.get("document"); + Object metadata = bindings.get("documentMetadata"); + Object value = documentResult(root, pointer, canonical, metadata); + return encodeOk(spec, value, 1); + } + + private DispatchResult handleEmit(FunctionSpec spec) { + Map err = new LinkedHashMap(); + err.put("code", LIMIT_EXCEEDED); + err.put("details", "emit is not available during expression/code evaluation"); + return encodeErr(spec, err, 1); + } + + private Object documentResult(Object root, Object pointer, boolean canonical, Object metadata) { + String normalized = normalizePointer(pointer); + if (!canonical && metadata instanceof Map && ((Map) metadata).containsKey(normalized)) { + Object value = ((Map) metadata).get(normalized); + return value == null ? null : value; + } + PointerResult resolved = getPointer(root, normalized); + if (!resolved.found) { + return null; + } + return canonical ? resolved.value : simpleValue(resolved.value); + } + + private String normalizePointer(Object pointer) { + if (pointer == null || "".equals(pointer)) { + return "/"; + } + if (!(pointer instanceof String)) { + return null; + } + String string = (String) pointer; + return string.startsWith("/") ? string : "/" + string; + } + + @SuppressWarnings("unchecked") + private PointerResult getPointer(Object root, String pointer) { + if (pointer == null || !pointer.startsWith("/")) { + return PointerResult.missing(); + } + if ("/".equals(pointer)) { + return PointerResult.found(root == null ? null : root); + } + Object current = root; + String[] segments = pointer.substring(1).split("/", -1); + for (String rawSegment : segments) { + String segment = rawSegment.replace("~1", "/").replace("~0", "~"); + if (current instanceof List) { + if (!segment.matches("^(0|[1-9]\\d*)$")) { + return PointerResult.missing(); + } + int index; + try { + index = Integer.parseInt(segment); + } catch (NumberFormatException ex) { + return PointerResult.missing(); + } + List list = (List) current; + if (index >= list.size()) { + return PointerResult.missing(); + } + current = list.get(index); + } else if (current instanceof Map) { + Map map = (Map) current; + if (!map.containsKey(segment)) { + return PointerResult.missing(); + } + current = map.get(segment); + } else { + return PointerResult.missing(); + } + } + return PointerResult.found(current == null ? null : current); + } + + @SuppressWarnings("unchecked") + private Object simpleValue(Object node) { + if (node == null) { + return null; + } + if (node instanceof List) { + List result = new ArrayList(); + for (Object value : (List) node) { + result.add(simpleValue(value)); + } + return result; + } + if (!(node instanceof Map)) { + return node; + } + Map map = (Map) node; + if (map.containsKey("value")) { + Object value = map.get("value"); + return value == null ? null : value; + } + Object items = map.get("items"); + if (items instanceof List) { + List result = new ArrayList(); + for (Object value : (List) items) { + result.add(simpleValue(value)); + } + return result; + } + Map result = new LinkedHashMap(); + for (Map.Entry entry : map.entrySet()) { + if (!BLUE_METADATA_KEYS.contains(entry.getKey())) { + result.put(entry.getKey(), simpleValue(entry.getValue())); + } + } + return result; + } + + private DispatchResult encodeOk(FunctionSpec spec, Object value, int units) { + Map envelope = new LinkedHashMap(); + envelope.put("ok", value); + envelope.put("units", units); + return encodeEnvelope(spec, envelope, true); + } + + private DispatchResult encodeErr(FunctionSpec spec, Map err, int units) { + Map envelope = new LinkedHashMap(); + envelope.put("err", err); + envelope.put("units", units); + return encodeEnvelope(spec, envelope, true); + } + + private DispatchResult encodeLimit(FunctionSpec spec) { + Map err = new LinkedHashMap(); + err.put("code", LIMIT_EXCEEDED); + Map envelope = new LinkedHashMap(); + envelope.put("err", err); + envelope.put("units", 0); + return encodeEnvelope(spec, envelope, false); + } + + private DispatchResult encodeEnvelope(FunctionSpec spec, Map envelope, boolean allowLimitFallback) { + byte[] bytes; + try { + bytes = DeterministicValueCodec.encode(envelope); + } catch (DeterministicValueCodec.DeterministicValueException ex) { + return allowLimitFallback ? encodeLimit(spec) : DispatchResult.fatal("failed to encode envelope: " + ex.getMessage()); + } + if (bytes.length > spec.maxResponseBytes) { + return allowLimitFallback ? encodeLimit(spec) : DispatchResult.fatal("response exceeds max_response_bytes"); + } + return DispatchResult.response(bytes); + } + + public static final class DispatchResult { + private final boolean fatal; + private final byte[] envelope; + private final String error; + + private DispatchResult(boolean fatal, byte[] envelope, String error) { + this.fatal = fatal; + this.envelope = envelope == null ? null : envelope.clone(); + this.error = error; + } + + private static DispatchResult response(byte[] envelope) { + return new DispatchResult(false, envelope, null); + } + + private static DispatchResult fatal(String error) { + return new DispatchResult(true, null, error); + } + + public boolean fatal() { + return fatal; + } + + public byte[] envelope() { + return envelope == null ? null : envelope.clone(); + } + + public String error() { + return error; + } + } + + private static final class PointerResult { + private final boolean found; + private final Object value; + + private PointerResult(boolean found, Object value) { + this.found = found; + this.value = value; + } + + private static PointerResult found(Object value) { + return new PointerResult(true, value); + } + + private static PointerResult missing() { + return new PointerResult(false, null); + } + } + + private static final class FunctionSpec { + private final int fnId; + private final int arity; + private final int maxRequestBytes; + private final int maxResponseBytes; + private final int argUtf8Max; + + private FunctionSpec(int fnId, int arity, int maxRequestBytes, int maxResponseBytes, int argUtf8Max) { + this.fnId = fnId; + this.arity = arity; + this.maxRequestBytes = maxRequestBytes; + this.maxResponseBytes = maxResponseBytes; + this.argUtf8Max = argUtf8Max; + } + + private static FunctionSpec forFnId(int fnId) { + if (fnId == HostV1Manifest.DOCUMENT_GET_FN_ID || fnId == HostV1Manifest.DOCUMENT_GET_CANONICAL_FN_ID) { + return new FunctionSpec(fnId, 1, 4096, 262144, 2048); + } + if (fnId == HostV1Manifest.EMIT_FN_ID) { + return new FunctionSpec(fnId, 1, 32768, 64, Integer.MAX_VALUE); + } + return null; + } + } +} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryDocumentHostTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryDocumentHostTest.java new file mode 100644 index 0000000..ea7802a --- /dev/null +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryDocumentHostTest.java @@ -0,0 +1,110 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class ChicoryDocumentHostTest { + + @Test + void documentGetMatchesEvaluateMjsPointerBehavior() { + BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(bindings()); + + Object root = call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "/"); + assertEquals(Arrays.asList(10, 11), root); + assertEquals(6, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "/counter")); + assertEquals(6, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "counter")); + assertEquals(root, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "")); + assertEquals(root, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, null)); + assertEquals(10, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "/items/0")); + assertEquals(1, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "/a~1b")); + assertEquals(2, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "/a~0b")); + assertEquals(null, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "/missing")); + assertEquals(null, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, 12)); + } + + @Test + void documentCanonicalReturnsRawCanonicalNodeAndIgnoresMetadataOverride() { + BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(bindings()); + + Map canonicalRoot = (Map) call(dispatcher, HostV1Manifest.DOCUMENT_GET_CANONICAL_FN_ID, "/"); + Map canonicalCounter = (Map) call(dispatcher, HostV1Manifest.DOCUMENT_GET_CANONICAL_FN_ID, "/counter"); + + assertEquals("Counter", canonicalRoot.get("name")); + assertEquals(6, canonicalCounter.get("value")); + assertEquals("Integer", ((Map) canonicalCounter.get("type")).get("value")); + assertEquals(6, call(dispatcher, HostV1Manifest.DOCUMENT_GET_CANONICAL_FN_ID, "/counter/value")); + } + + @Test + void metadataOverrideOnlyAppliesToNonCanonicalDocumentGet() { + BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(bindings()); + + assertEquals("Counter label", call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "/counter/name")); + assertEquals(null, call(dispatcher, HostV1Manifest.DOCUMENT_GET_CANONICAL_FN_ID, "/counter/name")); + } + + @Test + void emitReturnsDeterministicLimitErrorEnvelope() { + BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(bindings()); + Map envelope = envelope(dispatcher.dispatch(HostV1Manifest.EMIT_FN_ID, + DeterministicValueCodec.encode(Arrays.asList("event")))); + + assertEquals(0, envelope.get("units")); + assertEquals("LIMIT_EXCEEDED", ((Map) envelope.get("err")).get("code")); + } + + private static Object call(BlueQuickJsHostDispatcher dispatcher, int fnId, Object pointer) { + Map envelope = envelope(dispatcher.dispatch(fnId, DeterministicValueCodec.encode(Arrays.asList(pointer)))); + assertEquals(1, envelope.get("units")); + return envelope.get("ok"); + } + + private static Map envelope(BlueQuickJsHostDispatcher.DispatchResult result) { + assertFalse(result.fatal(), result.error()); + return (Map) DeterministicValueCodec.decode(result.envelope()); + } + + private static Map bindings() { + Map counter = new LinkedHashMap(); + counter.put("type", textNode("Integer")); + counter.put("value", 6); + + Map item0 = new LinkedHashMap(); + item0.put("value", 10); + Map item1 = new LinkedHashMap(); + item1.put("value", 11); + + Map document = new LinkedHashMap(); + document.put("name", "Counter"); + document.put("counter", counter); + document.put("items", Arrays.asList(item0, item1)); + document.put("a/b", singletonValue(1)); + document.put("a~b", singletonValue(2)); + + Map metadata = new LinkedHashMap(); + metadata.put("/counter/name", "Counter label"); + + Map bindings = new LinkedHashMap(); + bindings.put("document", document); + bindings.put("documentCanonical", document); + bindings.put("documentMetadata", metadata); + return bindings; + } + + private static Map textNode(String value) { + Map node = new LinkedHashMap(); + node.put("value", value); + return node; + } + + private static Map singletonValue(int value) { + Map node = new LinkedHashMap(); + node.put("value", value); + return node; + } +} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryHostCallAbiTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryHostCallAbiTest.java new file mode 100644 index 0000000..01a4363 --- /dev/null +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryHostCallAbiTest.java @@ -0,0 +1,102 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ChicoryHostCallAbiTest { + + @Test + void validDocumentRequestsReturnEnvelopes() { + BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(bindingsWithDocumentValue("ok")); + + Map envelope = decode(dispatcher.dispatch(HostV1Manifest.DOCUMENT_GET_FN_ID, + DeterministicValueCodec.encode(Arrays.asList("/value")))); + + assertEquals("ok", envelope.get("ok")); + assertEquals(1, envelope.get("units")); + } + + @Test + void unknownFunctionAndMalformedRequestAreFatalTransportFailures() { + BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(bindingsWithDocumentValue("ok")); + + assertTrue(dispatcher.dispatch(999, DeterministicValueCodec.encode(Collections.emptyList())).fatal()); + assertTrue(dispatcher.dispatch(HostV1Manifest.DOCUMENT_GET_FN_ID, new byte[]{(byte) 0xff}).fatal()); + assertTrue(dispatcher.dispatch(HostV1Manifest.DOCUMENT_GET_FN_ID, DeterministicValueCodec.encode("not-array")).fatal()); + } + + @Test + void responseLargerThanManifestLimitBecomesDeterministicLimitEnvelope() { + BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher( + bindingsWithDocumentValue(repeat('x', 300000))); + + Map envelope = decode(dispatcher.dispatch(HostV1Manifest.DOCUMENT_GET_FN_ID, + DeterministicValueCodec.encode(Arrays.asList("/value")))); + + assertEquals(0, envelope.get("units")); + assertEquals("LIMIT_EXCEEDED", ((Map) envelope.get("err")).get("code")); + } + + @Test + void requestLargerThanManifestLimitBecomesDeterministicLimitEnvelope() { + BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(bindingsWithDocumentValue("ok")); + + Map envelope = decode(dispatcher.dispatch(HostV1Manifest.DOCUMENT_GET_FN_ID, + DeterministicValueCodec.encode(Arrays.asList(repeat('p', 5000))))); + + assertEquals(0, envelope.get("units")); + assertEquals("LIMIT_EXCEEDED", ((Map) envelope.get("err")).get("code")); + } + + @Test + void reentrantHostCallIsFatalTransportFailure() throws Exception { + BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(bindingsWithDocumentValue("ok")); + Field inProgress = BlueQuickJsHostDispatcher.class.getDeclaredField("inProgress"); + inProgress.setAccessible(true); + inProgress.set(dispatcher, Boolean.TRUE); + + BlueQuickJsHostDispatcher.DispatchResult result = dispatcher.dispatch(HostV1Manifest.DOCUMENT_GET_FN_ID, + DeterministicValueCodec.encode(Arrays.asList("/value"))); + + assertTrue(result.fatal()); + assertTrue(result.error().contains("reentrant")); + } + + @Test + void hostDispatcherNeverThrowsForInternalFailures() { + BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(null); + + BlueQuickJsHostDispatcher.DispatchResult result = dispatcher.dispatch(HostV1Manifest.DOCUMENT_GET_FN_ID, null); + + assertTrue(result.fatal()); + } + + private static Map decode(BlueQuickJsHostDispatcher.DispatchResult result) { + assertFalse(result.fatal(), result.error()); + return (Map) DeterministicValueCodec.decode(result.envelope()); + } + + private static Map bindingsWithDocumentValue(Object value) { + Map document = new LinkedHashMap(); + document.put("value", value); + Map bindings = new LinkedHashMap(); + bindings.put("document", document); + bindings.put("documentCanonical", document); + bindings.put("documentMetadata", Collections.emptyMap()); + return bindings; + } + + private static String repeat(char value, int count) { + char[] chars = new char[count]; + Arrays.fill(chars, value); + return new String(chars); + } +} From a821580e2dc7d024f0d6dd17f1c4ca785357d772 Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 12 May 2026 22:04:49 +0000 Subject: [PATCH 08/17] Execute blue-quickjs wasm with Chicory Co-authored-by: Kamil Gruszka --- docs/chicory-bluequickjs-test-results.md | 18 ++ .../chicory/BlueQuickJsWasmInstance.java | 212 ++++++++++++++++++ .../chicory/ChicoryBlueQuickJsRuntime.java | 74 ++++++ .../ChicoryBlueQuickJsRuntimeSmokeTest.java | 66 ++++++ 4 files changed, 370 insertions(+) create mode 100644 quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmInstance.java create mode 100644 quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntime.java create mode 100644 quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntimeSmokeTest.java diff --git a/docs/chicory-bluequickjs-test-results.md b/docs/chicory-bluequickjs-test-results.md index ff26772..699910f 100644 --- a/docs/chicory-bluequickjs-test-results.md +++ b/docs/chicory-bluequickjs-test-results.md @@ -197,6 +197,24 @@ Outcome: - Full current `quickjs-chicory` test set remains green after Host.v1 dispatcher additions. +## Chicory runtime smoke + +Command: + +```bash +./gradlew :quickjs-chicory:test \ + --tests '*ChicoryBlueQuickJsRuntimeSmokeTest' \ + -Dblue.quickjs.root=/tmp/blue-quickjs +``` + +Outcome: + +- Passed. +- Executed the canonical blue-quickjs wasm32 release artifact through Chicory. +- Repeated each smoke expression 100 times and verified no value, wasm gas, or + host gas drift. +- Covered arithmetic, string concatenation, object/array return, and array map. + These are intentionally left pending until the corresponding implementation phases exist: diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmInstance.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmInstance.java new file mode 100644 index 0000000..1295bd6 --- /dev/null +++ b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmInstance.java @@ -0,0 +1,212 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import com.dylibso.chicory.runtime.ExportFunction; +import com.dylibso.chicory.runtime.HostFunction; +import com.dylibso.chicory.runtime.ImportValues; +import com.dylibso.chicory.runtime.Instance; +import com.dylibso.chicory.runtime.Memory; +import com.dylibso.chicory.wasm.Parser; +import com.dylibso.chicory.wasm.WasmModule; +import com.dylibso.chicory.wasm.types.FunctionType; +import com.dylibso.chicory.wasm.types.ValType; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; + +public final class BlueQuickJsWasmInstance implements AutoCloseable { + private final BlueQuickJsWasmResources resources; + private final BlueQuickJsHostDispatcher dispatcher; + private final Instance instance; + private final Memory memory; + private final ExportFunction malloc; + private final ExportFunction free; + private final ExportFunction qjsDetInit; + private final ExportFunction qjsDetEval; + private final ExportFunction qjsDetFree; + private boolean closed; + + public BlueQuickJsWasmInstance(BlueQuickJsWasmResources resources, + BlueQuickJsHostDispatcher dispatcher) { + if (resources == null) { + throw new IllegalArgumentException("resources must not be null"); + } + if (dispatcher == null) { + throw new IllegalArgumentException("dispatcher must not be null"); + } + this.resources = resources; + this.dispatcher = dispatcher; + WasmModule module = Parser.parse(resources.wasmBytes()); + this.instance = Instance.builder(module) + .withImportValues(importValues()) + .build(); + this.memory = instance.memory(); + this.malloc = instance.export("malloc"); + this.free = instance.export("free"); + this.qjsDetInit = instance.export("qjs_det_init"); + this.qjsDetEval = instance.export("qjs_det_eval"); + this.qjsDetFree = instance.export("qjs_det_free"); + } + + public void initialize(byte[] manifestBytes, + String manifestHash, + byte[] contextBlob, + long gasLimit) { + ensureOpen(); + int manifestPtr = writeBytes(manifestBytes); + int hashPtr = writeCString(manifestHash); + int contextPtr = contextBlob.length == 0 ? 0 : writeBytes(contextBlob); + try { + long[] result = qjsDetInit.apply( + manifestPtr, + manifestBytes.length, + hashPtr, + contextPtr, + contextBlob.length, + gasLimit); + int errorPtr = (int) result[0]; + if (errorPtr != 0) { + String error = readAndFreeCString(errorPtr); + throw new BlueQuickJsDeterminismException("VM init failed: " + error); + } + } finally { + free(manifestPtr); + free(hashPtr); + if (contextPtr != 0) { + free(contextPtr); + } + } + } + + public String eval(String code) { + ensureOpen(); + int codePtr = writeCString(code); + try { + long[] result = qjsDetEval.apply(codePtr); + int resultPtr = (int) result[0]; + if (resultPtr == 0) { + throw new BlueQuickJsDeterminismException("qjs_det_eval returned a null pointer"); + } + return readAndFreeCString(resultPtr); + } finally { + free(codePtr); + } + } + + @Override + public void close() { + if (!closed) { + qjsDetFree.apply(); + closed = true; + } + } + + private ImportValues importValues() { + return ImportValues.builder() + .addFunction(new HostFunction("host", + "host_call", + FunctionType.of( + Arrays.asList(ValType.I32, ValType.I32, ValType.I32, ValType.I32, ValType.I32), + Collections.singletonList(ValType.I32)), + (hostInstance, args) -> new long[]{hostCall(hostInstance, args)})) + .addFunction(new HostFunction("env", + "abort", + FunctionType.of(Collections.emptyList(), Collections.emptyList()), + (hostInstance, args) -> new long[0])) + .addFunction(new HostFunction("env", + "__assert_fail", + FunctionType.of( + Arrays.asList(ValType.I32, ValType.I32, ValType.I32, ValType.I32), + Collections.emptyList()), + (hostInstance, args) -> new long[0])) + .addFunction(new HostFunction("env", + "emscripten_date_now", + FunctionType.of(Collections.emptyList(), Collections.singletonList(ValType.F64)), + (hostInstance, args) -> new long[]{Double.doubleToRawLongBits(0.0d)})) + .addFunction(new HostFunction("env", + "emscripten_resize_heap", + FunctionType.of(Collections.singletonList(ValType.I32), Collections.singletonList(ValType.I32)), + (hostInstance, args) -> new long[]{0L})) + .build(); + } + + private long hostCall(Instance hostInstance, long[] args) { + try { + Memory hostMemory = hostInstance.memory(); + int fnId = (int) args[0]; + int reqPtr = (int) args[1]; + int reqLen = (int) args[2]; + int respPtr = (int) args[3]; + int respCap = (int) args[4]; + if (reqLen < 0 || respCap < 0 || rangesOverlap(reqPtr, reqLen, respPtr, respCap)) { + return BlueQuickJsHostDispatcher.TRANSPORT_ERROR; + } + byte[] request = hostMemory.readBytes(reqPtr, reqLen); + BlueQuickJsHostDispatcher.DispatchResult result = dispatcher.dispatch(fnId, request); + if (result.fatal()) { + return BlueQuickJsHostDispatcher.TRANSPORT_ERROR; + } + byte[] envelope = result.envelope(); + if (envelope.length > respCap) { + return BlueQuickJsHostDispatcher.TRANSPORT_ERROR; + } + hostMemory.write(respPtr, envelope); + return envelope.length & 0xffffffffL; + } catch (RuntimeException ex) { + return BlueQuickJsHostDispatcher.TRANSPORT_ERROR; + } + } + + private static boolean rangesOverlap(int aOffset, int aLength, int bOffset, int bLength) { + if (aLength == 0 || bLength == 0) { + return false; + } + long aStart = aOffset & 0xffffffffL; + long bStart = bOffset & 0xffffffffL; + long aEnd = aStart + (aLength & 0xffffffffL); + long bEnd = bStart + (bLength & 0xffffffffL); + return aStart < bEnd && bStart < aEnd; + } + + private int writeBytes(byte[] bytes) { + int ptr = malloc(bytes.length); + memory.write(ptr, bytes); + return ptr; + } + + private int writeCString(String value) { + byte[] text = value.getBytes(StandardCharsets.UTF_8); + byte[] bytes = Arrays.copyOf(text, text.length + 1); + int ptr = malloc(bytes.length); + memory.write(ptr, bytes); + return ptr; + } + + private String readAndFreeCString(int ptr) { + try { + return memory.readCString(ptr, StandardCharsets.UTF_8); + } finally { + free(ptr); + } + } + + private int malloc(int size) { + long[] result = malloc.apply(size); + int ptr = (int) result[0]; + if (ptr == 0) { + throw new BlueQuickJsResourceException("malloc returned null for " + size + " bytes"); + } + return ptr; + } + + private void free(int ptr) { + if (ptr != 0) { + free.apply(ptr); + } + } + + private void ensureOpen() { + if (closed) { + throw new BlueQuickJsResourceException("wasm instance is closed"); + } + } +} diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntime.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntime.java new file mode 100644 index 0000000..dbfd167 --- /dev/null +++ b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntime.java @@ -0,0 +1,74 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; +import blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult; +import blue.contract.processor.conversation.javascript.JavaScriptExecutionException; +import blue.contract.processor.conversation.javascript.JavaScriptRuntime; +import blue.contract.processor.conversation.javascript.QuickJsGas; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class ChicoryBlueQuickJsRuntime implements JavaScriptRuntime, AutoCloseable { + private final BlueQuickJsWasmRuntimeConfig config; + + public ChicoryBlueQuickJsRuntime() { + this(BlueQuickJsWasmRuntimeConfig.defaultConfig()); + } + + public ChicoryBlueQuickJsRuntime(BlueQuickJsWasmRuntimeConfig config) { + if (config == null) { + throw new IllegalArgumentException("config must not be null"); + } + this.config = config; + } + + @Override + public JavaScriptEvaluationResult evaluate(JavaScriptEvaluationRequest request) { + if (request == null) { + throw new IllegalArgumentException("request must not be null"); + } + long wasmGasLimit = QuickJsGas.toWasmFuel(request.hostGasLimit()); + BlueQuickJsWasmResources resources = BlueQuickJsWasmResources.resolve(config); + Map bindings = request.bindings(); + BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(bindings); + byte[] contextBlob = DeterministicValueCodec.encode(contextEnvelope(bindings)); + String source = BlueQuickJsSourceWrapper.wrap(request); + + try (BlueQuickJsWasmInstance instance = new BlueQuickJsWasmInstance(resources, dispatcher)) { + instance.initialize(HostV1Manifest.bytes(), HostV1Manifest.HOST_V1_HASH, contextBlob, wasmGasLimit); + BlueQuickJsResultParser.ParsedResult parsed = BlueQuickJsResultParser.parse(instance.eval(source)); + if (!parsed.ok()) { + throw new JavaScriptExecutionException(parsed.errorMessage()); + } + return new JavaScriptEvaluationResult(parsed.value(), + parsed.wasmGasUsed(), + QuickJsGas.toHostGasUsed(parsed.wasmGasUsed())); + } catch (JavaScriptExecutionException ex) { + throw ex; + } catch (RuntimeException ex) { + throw new JavaScriptExecutionException("Chicory blue-quickjs evaluation failed: " + ex.getMessage(), ex); + } + } + + @Override + public void close() { + // Fresh Wasm instances are used per evaluation for now. + } + + private static Map contextEnvelope(Map bindings) { + Map source = bindings == null ? Collections.emptyMap() : bindings; + Map envelope = new LinkedHashMap(); + envelope.put("event", valueOrNull(source.get("event"))); + envelope.put("eventCanonical", valueOrNull(source.get("eventCanonical"))); + Object steps = source.get("steps"); + envelope.put("steps", steps == null ? Collections.emptyList() : steps); + envelope.put("currentContract", valueOrNull(source.get("currentContract"))); + envelope.put("currentContractCanonical", valueOrNull(source.get("currentContractCanonical"))); + return envelope; + } + + private static Object valueOrNull(Object value) { + return value == null ? null : value; + } +} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntimeSmokeTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntimeSmokeTest.java new file mode 100644 index 0000000..26b704c --- /dev/null +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntimeSmokeTest.java @@ -0,0 +1,66 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; +import blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult; +import blue.contract.processor.conversation.javascript.QuickJsGas; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class ChicoryBlueQuickJsRuntimeSmokeTest { + + @Test + void deterministicExpressionsEvaluateWithoutNode() { + BlueQuickJsWasmRuntimeConfig config = BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(blueQuickJsRoot()) + .expectedEngineBuildHash("1d4584fc0552a24ee840afa2cca9f1536d47429f467585d4d5c1a5236ba96dc9") + .build(); + ChicoryBlueQuickJsRuntime runtime = new ChicoryBlueQuickJsRuntime(config); + + assertStable(runtime, "1 + 2", 3); + assertStable(runtime, "\"blue\" + \"-quickjs\"", "blue-quickjs"); + + Map object = new LinkedHashMap(); + object.put("a", 1); + object.put("b", Arrays.asList(Boolean.TRUE, null)); + assertStable(runtime, "({ a: 1, b: [true, null] })", object); + assertStable(runtime, "[1, 2, 3].map(x => x + 1)", Arrays.asList(2, 3, 4)); + } + + private static void assertStable(ChicoryBlueQuickJsRuntime runtime, String code, Object expected) { + Long wasmGas = null; + Long hostGas = null; + for (int i = 0; i < 100; i++) { + JavaScriptEvaluationResult result = runtime.evaluate(new JavaScriptEvaluationRequest( + code, + JavaScriptEvaluationRequest.Mode.EXPRESSION, + Collections.emptyMap(), + QuickJsGas.DEFAULT_EXPRESSION_HOST_GAS_LIMIT)); + assertEquals(expected, result.value(), "value drift at iteration " + i + " for " + code); + if (wasmGas == null) { + wasmGas = result.wasmGasUsed(); + hostGas = result.hostGasUsed(); + } else { + assertEquals(wasmGas.longValue(), result.wasmGasUsed(), "wasm gas drift for " + code); + assertEquals(hostGas.longValue(), result.hostGasUsed(), "host gas drift for " + code); + } + } + } + + private static Path blueQuickJsRoot() { + String configured = System.getProperty("blue.quickjs.root"); + Path root = configured == null || configured.trim().isEmpty() + ? Paths.get(System.getProperty("user.dir")).toAbsolutePath().getParent().resolve("blue-quickjs") + : Paths.get(configured); + assumeTrue(Files.isDirectory(root), "blue-quickjs checkout is required for Chicory smoke tests"); + return root; + } +} From 81eef555013584a6ec0211b99cecfbe7e969d8ce Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 12 May 2026 22:19:54 +0000 Subject: [PATCH 09/17] Add Chicory parity and workflow tests Co-authored-by: Kamil Gruszka --- docs/chicory-bluequickjs-test-results.md | 64 +++++ .../chicory/ChicoryBlueQuickJsRuntime.java | 12 +- ...hicorySequentialWorkflowExecutionTest.java | 171 +++++++++++ .../chicory/ChicoryVsNodeParityTest.java | 271 ++++++++++++++++++ 4 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicorySequentialWorkflowExecutionTest.java create mode 100644 quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryVsNodeParityTest.java diff --git a/docs/chicory-bluequickjs-test-results.md b/docs/chicory-bluequickjs-test-results.md index 699910f..ea054bf 100644 --- a/docs/chicory-bluequickjs-test-results.md +++ b/docs/chicory-bluequickjs-test-results.md @@ -215,6 +215,70 @@ Outcome: host gas drift. - Covered arithmetic, string concatenation, object/array return, and array map. +## Chicory vs Node parity + +Command: + +```bash +./gradlew :quickjs-chicory:test \ + --tests '*ChicoryVsNodeParityTest' \ + -Dblue.quickjs.root=/tmp/blue-quickjs +``` + +Outcome: + +- Passed after normalizing the raw Chicory OOG error to the same deterministic + category/message exposed by the Node bridge. +- Generated parity report: + `quickjs-chicory/build/reports/blue-quickjs-chicory-parity.json` +- Covered: + - simple arithmetic expression + - event binding expression + - currentContract expression + - steps binding expression + - document simple value + - document canonical value + - document metadata lookup + - array map/reduce + - JSON.stringify deterministic key ordering + - block mode object result + - block mode array result + - forbidden global + - out-of-gas loop + +Report status: `passed`; mismatches: `[]`. + +## Chicory workflow injection + +Command: + +```bash +./gradlew :quickjs-chicory:test \ + --tests '*ChicorySequentialWorkflowExecutionTest' \ + -Dblue.quickjs.root=/tmp/blue-quickjs +``` + +Outcome: + +- Passed. +- Verified `SequentialWorkflowRunner.withJavaScriptRuntime(new + ChicoryBlueQuickJsRuntime(...))` can be injected through + `BlueDocumentProcessorOptions` and used by real workflow execution. + +Command: + +```bash +./gradlew :quickjs-chicory:test \ + -PblueQuickJsRoot=/tmp/blue-quickjs \ + -Dblue.quickjs.root=/tmp/blue-quickjs +``` + +Outcome: + +- Passed. +- Full current `quickjs-chicory` test set is green including smoke, resource, + DV, Host.v1, workflow, and Node parity tests. + These are intentionally left pending until the corresponding implementation phases exist: diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntime.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntime.java index dbfd167..0715997 100644 --- a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntime.java +++ b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntime.java @@ -39,7 +39,7 @@ public JavaScriptEvaluationResult evaluate(JavaScriptEvaluationRequest request) instance.initialize(HostV1Manifest.bytes(), HostV1Manifest.HOST_V1_HASH, contextBlob, wasmGasLimit); BlueQuickJsResultParser.ParsedResult parsed = BlueQuickJsResultParser.parse(instance.eval(source)); if (!parsed.ok()) { - throw new JavaScriptExecutionException(parsed.errorMessage()); + throw new JavaScriptExecutionException(normalizeVmError(parsed.errorMessage())); } return new JavaScriptEvaluationResult(parsed.value(), parsed.wasmGasUsed(), @@ -71,4 +71,14 @@ private static Map contextEnvelope(Map bindings) private static Object valueOrNull(Object value) { return value == null ? null : value; } + + private static String normalizeVmError(String message) { + if ("OutOfGas: out of gas".equals(message)) { + return "vm-error: out of gas"; + } + if (message != null && message.startsWith("vm-error: ")) { + return message; + } + return "vm-error: " + (message == null ? "unknown error" : message); + } } diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicorySequentialWorkflowExecutionTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicorySequentialWorkflowExecutionTest.java new file mode 100644 index 0000000..32ff271 --- /dev/null +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicorySequentialWorkflowExecutionTest.java @@ -0,0 +1,171 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import blue.contract.processor.BlueDocumentProcessorOptions; +import blue.contract.processor.BlueDocumentProcessors; +import blue.contract.processor.conversation.TimelineProviderSupport; +import blue.contract.processor.conversation.workflow.SequentialWorkflowRunner; +import blue.language.Blue; +import blue.language.model.Node; +import blue.language.model.TypeBlueId; +import blue.language.processor.ChannelCheckpointContext; +import blue.language.processor.ChannelEvaluation; +import blue.language.processor.ChannelEvaluationContext; +import blue.language.processor.ChannelProcessor; +import blue.language.processor.DocumentProcessingResult; +import blue.repo.BlueRepository; +import blue.repo.v1_2_0.conversation.ChatMessage; +import blue.repo.v1_2_0.conversation.Timeline; +import blue.repo.v1_2_0.conversation.TimelineChannel; +import blue.repo.v1_2_0.conversation.TimelineEntry; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class ChicorySequentialWorkflowExecutionTest { + private static final String SIMPLE_TIMELINE_CHANNEL_BLUE_ID = "chicory-test-simple-timeline-channel"; + + @Test + void sequentialWorkflowRunsWithChicoryRuntimeInjected() { + Fixture fixture = configuredFixture(); + Node initialized = fixture.blue.initializeDocument(fixture.blue.preprocess(workflowDocument(fixture.repository))).document(); + + DocumentProcessingResult result = fixture.blue.processDocument(initialized, + timelineEntry(fixture.blue, fixture.repository, "owner", 1, chatMessage("run"))); + + assertEquals(BigInteger.TEN, result.document().get("/counter")); + } + + private static Fixture configuredFixture() { + BlueRepository repository = BlueRepository.v1_2_0(); + Blue blue = repository.configure(new Blue()); + blue.nodeProvider(repository.nodeProvider()); + ChicoryBlueQuickJsRuntime runtime = new ChicoryBlueQuickJsRuntime(BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(blueQuickJsRoot()) + .expectedEngineBuildHash("1d4584fc0552a24ee840afa2cca9f1536d47429f467585d4d5c1a5236ba96dc9") + .build()); + BlueDocumentProcessors.registerWith(blue, BlueDocumentProcessorOptions.builder() + .sequentialWorkflowRunner(SequentialWorkflowRunner.withJavaScriptRuntime(runtime)) + .build()); + blue.registerContractProcessor(new SimpleTimelineChannelProcessor()); + return new Fixture(repository, blue); + } + + private static Node workflowDocument(BlueRepository repository) { + Map contracts = new LinkedHashMap(); + contracts.put("owner", channel("owner")); + contracts.put("direct", new Node() + .type("Conversation/Sequential Workflow") + .properties("channel", new Node().value("owner")) + .properties("steps", new Node().items(Arrays.asList( + updateDocumentStep("replace", "/counter", new Node().value("${document('/counter') + 5}")), + javaScriptStep("Compute", "return { value: document('/counter') * 2 };"), + updateDocumentStep("replace", "/counter", new Node().value("${steps.Compute.value}")))))); + + return new Node() + .blue(repository.typeAliasBlue()) + .name("Chicory Workflow Counter") + .properties("counter", new Node().value(0)) + .properties("contracts", new Node().properties(contracts)); + } + + private static Node updateDocumentStep(String op, String path, Node value) { + return new Node() + .type("Conversation/Update Document") + .properties("changeset", new Node().items(new Node() + .properties("op", new Node().value(op)) + .properties("path", new Node().value(path)) + .properties("val", value))); + } + + private static Node javaScriptStep(String name, String code) { + return new Node() + .name(name) + .type("Conversation/JavaScript Code") + .properties("code", new Node().value(code)); + } + + private static Node channel(String timelineId) { + return new Node() + .type(new Node().blueId(SIMPLE_TIMELINE_CHANNEL_BLUE_ID)) + .properties("timelineId", new Node().value(timelineId)); + } + + private static Node timelineEntry(Blue blue, + BlueRepository repository, + String timelineId, + int timestamp, + Node message) { + TimelineEntry entry = new TimelineEntry() + .timeline(new Timeline().timelineId(timelineId)) + .timestamp(BigInteger.valueOf(timestamp)) + .message(message); + + Node event = new Node() + .blue(repository.typeAliasBlue()) + .type(TimelineEntry.qualifiedName()) + .properties("timeline", blue.objectToNode(entry.getTimeline())) + .properties("timestamp", new Node().value(entry.getTimestamp())) + .properties("message", entry.getMessage()); + return blue.preprocess(event); + } + + private static Node chatMessage(String message) { + ChatMessage chatMessage = new ChatMessage().message(message); + return new Node() + .type(ChatMessage.qualifiedName()) + .properties("message", new Node().value(chatMessage.getMessage())); + } + + private static Path blueQuickJsRoot() { + String configured = System.getProperty("blue.quickjs.root"); + Path root = configured == null || configured.trim().isEmpty() + ? Paths.get(System.getProperty("user.dir")).toAbsolutePath().getParent().resolve("blue-quickjs") + : Paths.get(configured); + assumeTrue(Files.isDirectory(root), "blue-quickjs checkout is required for workflow tests"); + return root; + } + + @TypeBlueId(SIMPLE_TIMELINE_CHANNEL_BLUE_ID) + public static final class SimpleTimelineChannel extends TimelineChannel { + } + + public static final class SimpleTimelineChannelProcessor implements ChannelProcessor { + @Override + public Class contractType() { + return SimpleTimelineChannel.class; + } + + @Override + public ChannelEvaluation evaluate(SimpleTimelineChannel contract, ChannelEvaluationContext context) { + return TimelineProviderSupport.evaluateTimelineEntry(contract, context); + } + + @Override + public String eventId(SimpleTimelineChannel contract, ChannelEvaluationContext context) { + return TimelineProviderSupport.eventId(context.event()); + } + + @Override + public boolean isNewerEvent(SimpleTimelineChannel contract, ChannelCheckpointContext context) { + return TimelineProviderSupport.isNewerOrSameTimelineEvent(context); + } + } + + private static final class Fixture { + private final BlueRepository repository; + private final Blue blue; + + private Fixture(BlueRepository repository, Blue blue) { + this.repository = repository; + this.blue = blue; + } + } +} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryVsNodeParityTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryVsNodeParityTest.java new file mode 100644 index 0000000..9c93757 --- /dev/null +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryVsNodeParityTest.java @@ -0,0 +1,271 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; +import blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult; +import blue.contract.processor.conversation.javascript.JavaScriptExecutionException; +import blue.contract.processor.conversation.javascript.NodeQuickJsRuntime; +import blue.contract.processor.conversation.javascript.QuickJsGas; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class ChicoryVsNodeParityTest { + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + void deterministicFixtureSetMatchesNodeOracle() throws IOException { + Path root = blueQuickJsRoot(); + List> reportCases = new ArrayList>(); + List> mismatches = new ArrayList>(); + + ChicoryBlueQuickJsRuntime chicory = new ChicoryBlueQuickJsRuntime(BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(root) + .expectedEngineBuildHash("1d4584fc0552a24ee840afa2cca9f1536d47429f467585d4d5c1a5236ba96dc9") + .build()); + try (NodeQuickJsRuntime node = new NodeQuickJsRuntime(root)) { + for (Fixture fixture : fixtures()) { + Evaluation nodeResult = evaluate(node, fixture.request); + Evaluation chicoryResult = evaluate(chicory, fixture.request); + Map entry = new LinkedHashMap(); + entry.put("name", fixture.name); + entry.put("node", nodeResult.toMap()); + entry.put("chicory", chicoryResult.toMap()); + entry.put("matches", nodeResult.equals(chicoryResult)); + reportCases.add(entry); + if (!nodeResult.equals(chicoryResult)) { + mismatches.add(entry); + } + } + } + + writeReport(reportCases, mismatches); + assertTrue(mismatches.isEmpty(), "Chicory parity mismatches written to build report"); + } + + private static List fixtures() { + Map bindings = bindings(); + List fixtures = new ArrayList(); + fixtures.add(expression("simple arithmetic expression", "1 + 2", bindings)); + fixtures.add(expression("event binding expression", "event.message.request + 1", bindings)); + fixtures.add(expression("currentContract expression", "currentContract.channel", bindings)); + fixtures.add(expression("steps binding expression", "steps.Prepare.amount + 1", bindings)); + fixtures.add(expression("document simple value", "document('/counter')", bindings)); + fixtures.add(expression("document canonical value", "document.canonical('/counter').value", bindings)); + fixtures.add(expression("document metadata lookup", "document('/counter/name')", bindings)); + fixtures.add(expression("array map reduce", "[1, 2, 3].map(x => x + 1).reduce((a, b) => a + b, 0)", bindings)); + fixtures.add(expression("JSON stringify deterministic case", "JSON.stringify({ b: 2, aa: 1 })", bindings)); + fixtures.add(block("block mode returning object", "return { value: document('/counter') + event.message.request };", bindings)); + fixtures.add(block("block mode returning array", "return [event.message.request, document('/counter')];", bindings)); + fixtures.add(expression("forbidden global", "typeof Date", bindings)); + fixtures.add(new Fixture("out-of-gas loop", new JavaScriptEvaluationRequest( + "(() => { while (true) {} })()", + JavaScriptEvaluationRequest.Mode.EXPRESSION, + bindings, + 1L))); + return fixtures; + } + + private static Fixture expression(String name, String code, Map bindings) { + return new Fixture(name, new JavaScriptEvaluationRequest(code, + JavaScriptEvaluationRequest.Mode.EXPRESSION, + bindings, + QuickJsGas.DEFAULT_EXPRESSION_HOST_GAS_LIMIT)); + } + + private static Fixture block(String name, String code, Map bindings) { + return new Fixture(name, new JavaScriptEvaluationRequest(code, + JavaScriptEvaluationRequest.Mode.BLOCK, + bindings, + QuickJsGas.DEFAULT_CODE_HOST_GAS_LIMIT)); + } + + private static Evaluation evaluate(blue.contract.processor.conversation.javascript.JavaScriptRuntime runtime, + JavaScriptEvaluationRequest request) { + try { + JavaScriptEvaluationResult result = runtime.evaluate(request); + return Evaluation.ok(result.value(), result.wasmGasUsed(), result.hostGasUsed()); + } catch (JavaScriptExecutionException ex) { + return Evaluation.error(normalizeMessage(ex.getMessage())); + } + } + + private static String normalizeMessage(String message) { + return message == null ? "" : message.replace("Chicory blue-quickjs evaluation failed: ", ""); + } + + private static void writeReport(List> cases, + List> mismatches) throws IOException { + Map report = new LinkedHashMap(); + report.put("status", mismatches.isEmpty() ? "passed" : "failed"); + report.put("cases", cases); + report.put("mismatches", mismatches); + Path reportPath = Paths.get(System.getProperty("user.dir"), + "build/reports/blue-quickjs-chicory-parity.json"); + Files.createDirectories(reportPath.getParent()); + JSON.writerWithDefaultPrettyPrinter().writeValue(reportPath.toFile(), report); + } + + private static Map bindings() { + Map event = new LinkedHashMap(); + event.put("message", singleton("request", 7)); + + Map eventCanonical = new LinkedHashMap(); + Map canonicalMessage = new LinkedHashMap(); + canonicalMessage.put("request", singleton("value", 7)); + eventCanonical.put("message", canonicalMessage); + + Map prepare = new LinkedHashMap(); + prepare.put("amount", 5); + Map steps = new LinkedHashMap(); + steps.put("Prepare", prepare); + + Map currentContract = new LinkedHashMap(); + currentContract.put("channel", "ownerChannel"); + currentContract.put("description", "Demo workflow"); + + Map currentContractCanonical = new LinkedHashMap(); + currentContractCanonical.put("channel", singleton("value", "ownerChannel")); + currentContractCanonical.put("description", singleton("value", "Demo workflow")); + + Map counter = new LinkedHashMap(); + counter.put("name", "Canonical counter name"); + counter.put("type", singleton("value", "Integer")); + counter.put("value", 6); + + Map document = new LinkedHashMap(); + document.put("name", "Counter"); + document.put("counter", counter); + + Map metadata = new LinkedHashMap(); + metadata.put("/counter/name", "Counter label"); + + Map bindings = new LinkedHashMap(); + bindings.put("event", event); + bindings.put("eventCanonical", eventCanonical); + bindings.put("steps", steps); + bindings.put("currentContract", currentContract); + bindings.put("currentContractCanonical", currentContractCanonical); + bindings.put("document", document); + bindings.put("documentCanonical", document); + bindings.put("documentMetadata", metadata); + return bindings; + } + + private static Map singleton(String key, Object value) { + Map map = new LinkedHashMap(); + map.put(key, value); + return map; + } + + private static Path blueQuickJsRoot() { + String configured = System.getProperty("blue.quickjs.root"); + Path root = configured == null || configured.trim().isEmpty() + ? Paths.get(System.getProperty("user.dir")).toAbsolutePath().getParent().resolve("blue-quickjs") + : Paths.get(configured); + assumeTrue(Files.isDirectory(root), "blue-quickjs checkout is required for parity tests"); + return root; + } + + private static final class Fixture { + private final String name; + private final JavaScriptEvaluationRequest request; + + private Fixture(String name, JavaScriptEvaluationRequest request) { + this.name = name; + this.request = request; + } + } + + private static final class Evaluation { + private final boolean ok; + private final Object value; + private final String error; + private final long wasmGasUsed; + private final long hostGasUsed; + + private Evaluation(boolean ok, Object value, String error, long wasmGasUsed, long hostGasUsed) { + this.ok = ok; + this.value = value; + this.error = error; + this.wasmGasUsed = wasmGasUsed; + this.hostGasUsed = hostGasUsed; + } + + private static Evaluation ok(Object value, long wasmGasUsed, long hostGasUsed) { + return new Evaluation(true, normalize(value), null, wasmGasUsed, hostGasUsed); + } + + private static Evaluation error(String error) { + return new Evaluation(false, null, error, -1L, -1L); + } + + private Map toMap() { + Map map = new LinkedHashMap(); + map.put("ok", ok); + map.put("value", value); + map.put("error", error); + map.put("wasmGasUsed", wasmGasUsed); + map.put("hostGasUsed", hostGasUsed); + return map; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof Evaluation)) { + return false; + } + Evaluation that = (Evaluation) other; + return ok == that.ok + && wasmGasUsed == that.wasmGasUsed + && hostGasUsed == that.hostGasUsed + && java.util.Objects.equals(value, that.value) + && java.util.Objects.equals(error, that.error); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(ok, value, error, wasmGasUsed, hostGasUsed); + } + + @SuppressWarnings("unchecked") + private static Object normalize(Object value) { + if (value instanceof Number) { + Number number = (Number) value; + if (number.doubleValue() == Math.rint(number.doubleValue()) + && number.longValue() >= Integer.MIN_VALUE + && number.longValue() <= Integer.MAX_VALUE) { + return Integer.valueOf(number.intValue()); + } + return value; + } + if (value instanceof List) { + List result = new ArrayList(); + for (Object item : (List) value) { + result.add(normalize(item)); + } + return result; + } + if (value instanceof Map) { + Map result = new LinkedHashMap(); + for (Map.Entry entry : ((Map) value).entrySet()) { + result.put(entry.getKey(), normalize(entry.getValue())); + } + return result; + } + return value; + } + } +} From b18e898f9192c382eda492ac6dfcd24e20656918 Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 12 May 2026 22:24:24 +0000 Subject: [PATCH 10/17] Add Chicory resource pinning task Co-authored-by: Kamil Gruszka --- docs/chicory-bluequickjs-test-results.md | 42 +++++++++++ quickjs-chicory/build.gradle | 72 +++++++++++++++++++ .../chicory/BlueQuickJsWasmResources.java | 63 ++++++++++++++++ .../BlueQuickJsResourceIntegrityTest.java | 4 ++ 4 files changed, 181 insertions(+) diff --git a/docs/chicory-bluequickjs-test-results.md b/docs/chicory-bluequickjs-test-results.md index ea054bf..e611f18 100644 --- a/docs/chicory-bluequickjs-test-results.md +++ b/docs/chicory-bluequickjs-test-results.md @@ -279,6 +279,48 @@ Outcome: - Full current `quickjs-chicory` test set is green including smoke, resource, DV, Host.v1, workflow, and Node parity tests. +## Resource pinning and no-Node smoke + +Command: + +```bash +./gradlew :quickjs-chicory:clean :quickjs-chicory:jar \ + -PblueQuickJsRoot=/tmp/blue-quickjs +``` + +Outcome: + +- Passed. +- Ran `copyBlueQuickJsWasmResources`. +- Built a jar containing generated pinned Chicory resources. + +Command: + +```bash +PATH=/usr/bin:/bin ./gradlew :quickjs-chicory:test \ + --tests '*ChicoryBlueQuickJsRuntimeSmokeTest' \ + -PblueQuickJsRoot=/tmp/blue-quickjs +``` + +Outcome: + +- Passed. +- `command -v node` produced no Node binary on that PATH before the test. +- Chicory smoke fixtures evaluated without Node on PATH. + +Command: + +```bash +./gradlew :quickjs-chicory:test \ + -PblueQuickJsRoot=/tmp/blue-quickjs \ + -Dblue.quickjs.root=/tmp/blue-quickjs +``` + +Outcome: + +- Passed after adding resource-copy/classpath resource support. +- Full current `quickjs-chicory` test set remains green. + These are intentionally left pending until the corresponding implementation phases exist: diff --git a/quickjs-chicory/build.gradle b/quickjs-chicory/build.gradle index bb79583..19ea97f 100644 --- a/quickjs-chicory/build.gradle +++ b/quickjs-chicory/build.gradle @@ -14,6 +14,11 @@ java { targetCompatibility = JavaVersion.VERSION_11 } +def generatedResourcesDir = layout.buildDirectory.dir('generated-resources') +def chicoryResourceDir = generatedResourcesDir.map { + it.dir('blue/contract/processor/quickjs/chicory') +} + tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' if (JavaVersion.current().isJava11Compatible()) { @@ -32,6 +37,73 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } +tasks.register('copyBlueQuickJsWasmResources') { + def configuredRoot = providers.gradleProperty('blueQuickJsRoot') + .orElse(providers.systemProperty('blue.quickjs.root')) + inputs.property('blueQuickJsRoot', configuredRoot.orNull ?: '') + outputs.dir(chicoryResourceDir) + doLast { + if (!configuredRoot.present || configuredRoot.get().trim().isEmpty()) { + throw new GradleException('copyBlueQuickJsWasmResources requires -PblueQuickJsRoot or -Dblue.quickjs.root') + } + def root = file(configuredRoot.get()) + def sourceDir = new File(root, 'libs/quickjs-wasm/dist/wasm') + def buildDir = new File(root, 'libs/quickjs-wasm-build/dist') + def wasm = new File(sourceDir, 'quickjs-eval.wasm') + if (!wasm.isFile()) { + wasm = new File(buildDir, 'quickjs-eval.wasm') + } + if (!wasm.isFile()) { + throw new GradleException("Missing canonical quickjs-eval.wasm under ${root}") + } + if (wasm.name.contains('debug') || wasm.name.contains('wasm64')) { + throw new GradleException("Rejected non-release/non-wasm32 artifact: ${wasm}") + } + byte[] wasmBytes = wasm.bytes + if (wasmBytes.length < 4 || wasmBytes[0] != 0 || wasmBytes[1] != 0x61 || wasmBytes[2] != 0x73 || wasmBytes[3] != 0x6d) { + throw new GradleException("Invalid wasm magic bytes: ${wasm}") + } + def metadata = new File(wasm.parentFile, 'quickjs-wasm-build.metadata.json') + if (!metadata.isFile()) { + metadata = new File(buildDir, 'quickjs-wasm-build.metadata.json') + } + if (!metadata.isFile()) { + throw new GradleException("Missing quickjs wasm metadata for ${wasm}") + } + def parsed = new groovy.json.JsonSlurper().parse(metadata) + def release = parsed.variants?.wasm32?.release + if (release == null) { + throw new GradleException('Metadata missing wasm32/release artifact') + } + if (release.buildType != 'release' || release.wasm?.filename != 'quickjs-eval.wasm') { + throw new GradleException('Metadata does not describe canonical wasm32 release artifact') + } + def digest = java.security.MessageDigest.getInstance('SHA-256').digest(wasmBytes).collect { String.format('%02x', it & 0xff) }.join() + if (digest != release.engineBuildHash || digest != release.wasm?.sha256 || digest != parsed.engineBuildHash) { + throw new GradleException("Wasm hash mismatch for ${wasm}") + } + def outDir = chicoryResourceDir.get().asFile + outDir.mkdirs() + new File(outDir, 'quickjs-eval.wasm').bytes = wasmBytes + def enriched = new LinkedHashMap(parsed as Map) + enriched.gasVersion = 2 + enriched.executionProfile = 'blue-quickjs-deterministic-baseline-1' + enriched.abiManifestHash = 'e23b0b2ee169900bbde7aff78e6ce20fead1715c60f8a8e3106d9959450a3d34' + new File(outDir, 'engine-metadata.json').text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(enriched)) + '\n' + new File(outDir, 'host-v1-hash.txt').text = 'e23b0b2ee169900bbde7aff78e6ce20fead1715c60f8a8e3106d9959450a3d34\n' + def manifestHex = 'a3666162695f696467486f73742e76316966756e6374696f6e7383a963676173a5646261736514676b5f756e697473016b6b5f6172675f6279746573016b6b5f7265745f6279746573016b7363686564756c655f69646b646f632d726561642d76316561726974790165666e5f696401666566666563746452454144666c696d697473a4696d61785f756e6974731903e86c6172675f757466385f6d617881190800716d61785f726571756573745f6279746573191000726d61785f726573706f6e73655f62797465731a00040000676a735f706174688268646f63756d656e74636765746a6172675f736368656d6181a1647479706566737472696e676b6572726f725f636f64657383a26374616771686f73742f696e76616c69645f7061746864636f64656c494e56414c49445f50415448a2637461676a686f73742f6c696d697464636f64656e4c494d49545f4558434545444544a2637461676e686f73742f6e6f745f666f756e6464636f6465694e4f545f464f554e446d72657475726e5f736368656d61a16474797065626476a963676173a5646261736514676b5f756e697473016b6b5f6172675f6279746573016b6b5f7265745f6279746573016b7363686564756c655f69646b646f632d726561642d76316561726974790165666e5f696402666566666563746452454144666c696d697473a4696d61785f756e6974731903e86c6172675f757466385f6d617881190800716d61785f726571756573745f6279746573191000726d61785f726573706f6e73655f62797465731a00040000676a735f706174688268646f63756d656e746c67657443616e6f6e6963616c6a6172675f736368656d6181a1647479706566737472696e676b6572726f725f636f64657383a26374616771686f73742f696e76616c69645f7061746864636f64656c494e56414c49445f50415448a2637461676a686f73742f6c696d697464636f64656e4c494d49545f4558434545444544a2637461676e686f73742f6e6f745f666f756e6464636f6465694e4f545f464f554e446d72657475726e5f736368656d61a16474797065626476a963676173a5646261736505676b5f756e697473016b6b5f6172675f6279746573016b6b5f7265745f6279746573006b7363686564756c655f696467656d69742d76316561726974790165666e5f6964036665666665637464454d4954666c696d697473a3696d61785f756e697473190400716d61785f726571756573745f6279746573198000726d61785f726573706f6e73655f62797465731840676a735f706174688164656d69746a6172675f736368656d6181a164747970656264766b6572726f725f636f64657381a2637461676a686f73742f6c696d697464636f64656e4c494d49545f45584345454445446d72657475726e5f736368656d61a16474797065646e756c6c6b6162695f76657273696f6e01' + new File(outDir, 'host-v1-manifest.dv').bytes = manifestHex.decodeHex() + } +} + +sourceSets.main.resources.srcDir generatedResourcesDir + +tasks.named('processResources') { + if (project.hasProperty('blueQuickJsRoot') || System.getProperty('blue.quickjs.root')) { + dependsOn tasks.named('copyBlueQuickJsWasmResources') + } +} + test { useJUnitPlatform() if (System.getProperty('blue.quickjs.root')) { diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmResources.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmResources.java index f0d4217..f3e6f1d 100644 --- a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmResources.java +++ b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmResources.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -75,6 +76,12 @@ public static BlueQuickJsWasmResources resolve(BlueQuickJsWasmRuntimeConfig conf if (config == null) { throw new IllegalArgumentException("config must not be null"); } + if (config.preferClasspathResources()) { + BlueQuickJsWasmResources classpath = resolveClasspath(config); + if (classpath != null) { + return classpath; + } + } Path root = resolveRoot(config); Path wasmPath = locateWasm(root); Path metadataPath = locateMetadata(wasmPath, root); @@ -126,6 +133,62 @@ public static BlueQuickJsWasmResources resolve(BlueQuickJsWasmRuntimeConfig conf shape.exports); } + private static BlueQuickJsWasmResources resolveClasspath(BlueQuickJsWasmRuntimeConfig config) { + String base = "/blue/contract/processor/quickjs/chicory/"; + try (InputStream wasmInput = BlueQuickJsWasmResources.class.getResourceAsStream(base + CANONICAL_WASM_FILENAME); + InputStream metadataInput = BlueQuickJsWasmResources.class.getResourceAsStream(base + "engine-metadata.json")) { + if (wasmInput == null || metadataInput == null) { + return null; + } + byte[] wasmBytes = readAll(wasmInput); + verifyMagic(wasmBytes, Paths.get("classpath:" + base + CANONICAL_WASM_FILENAME)); + JsonNode metadata = JSON.readTree(metadataInput); + JsonNode selected = selectedVariant(metadata, config); + String wasmSha256 = sha256Hex(wasmBytes); + String metadataSha256 = requiredHex(selected.at("/wasm/sha256"), "metadata wasm sha256"); + String engineBuildHash = requiredHex(selected.get("engineBuildHash"), "metadata engineBuildHash"); + if (!wasmSha256.equals(metadataSha256) || !wasmSha256.equals(engineBuildHash)) { + throw new BlueQuickJsDeterminismException("classpath wasm hash mismatch"); + } + if (config.expectedEngineBuildHash() != null && !engineBuildHash.equals(config.expectedEngineBuildHash())) { + throw new BlueQuickJsDeterminismException("engineBuildHash mismatch: expected=" + + config.expectedEngineBuildHash() + ", actual=" + engineBuildHash); + } + verifyMetadataShape(metadata); + int gasVersion = requiredInt(metadata.get("gasVersion"), "gasVersion"); + if (gasVersion != config.expectedGasVersion()) { + throw new BlueQuickJsDeterminismException("gasVersion mismatch: expected=" + + config.expectedGasVersion() + ", actual=" + gasVersion); + } + String executionProfile = requiredText(metadata.get("executionProfile"), "executionProfile"); + if (!config.expectedExecutionProfile().equals(executionProfile)) { + throw new BlueQuickJsDeterminismException("executionProfile mismatch: expected=" + + config.expectedExecutionProfile() + ", actual=" + executionProfile); + } + String abiManifestHash = requiredText(metadata.get("abiManifestHash"), "abiManifestHash"); + if (!HostV1Manifest.HOST_V1_HASH.equals(abiManifestHash)) { + throw new BlueQuickJsDeterminismException("ABI manifest hash mismatch in classpath metadata"); + } + WasmModuleShape shape = WasmModuleShape.parse(wasmBytes); + verifyImports(shape.imports); + verifyExports(shape.exports); + return new BlueQuickJsWasmResources(null, null, null, wasmBytes, metadata, + engineBuildHash, abiManifestHash, gasVersion, executionProfile, shape.imports, shape.exports); + } catch (IOException ex) { + throw new BlueQuickJsResourceException("failed to read classpath blue-quickjs resources", ex); + } + } + + private static byte[] readAll(InputStream input) throws IOException { + byte[] buffer = new byte[8192]; + java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream(); + int read; + while ((read = input.read(buffer)) >= 0) { + output.write(buffer, 0, read); + } + return output.toByteArray(); + } + public Path blueQuickJsRoot() { return blueQuickJsRoot; } diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceIntegrityTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceIntegrityTest.java index 394e5f2..6ad35e1 100644 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceIntegrityTest.java +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceIntegrityTest.java @@ -21,6 +21,7 @@ void canonicalWasmResourceIsPresentAndPinned() { BlueQuickJsWasmResources resources = BlueQuickJsWasmResources.resolve( BlueQuickJsWasmRuntimeConfig.builder() .blueQuickJsRoot(root) + .preferClasspathResources(false) .build()); assertTrue(Files.isRegularFile(resources.wasmPath())); @@ -41,6 +42,7 @@ void importsContainOnlyApprovedDeterministicSurface() { BlueQuickJsWasmResources resources = BlueQuickJsWasmResources.resolve( BlueQuickJsWasmRuntimeConfig.builder() .blueQuickJsRoot(blueQuickJsRoot()) + .preferClasspathResources(false) .build()); Set imports = new HashSet(); for (BlueQuickJsWasmResources.WasmImport wasmImport : resources.imports()) { @@ -66,6 +68,7 @@ void requiredExportsArePresent() { BlueQuickJsWasmResources resources = BlueQuickJsWasmResources.resolve( BlueQuickJsWasmRuntimeConfig.builder() .blueQuickJsRoot(blueQuickJsRoot()) + .preferClasspathResources(false) .build()); Set exports = new HashSet(); for (BlueQuickJsWasmResources.WasmExport wasmExport : resources.exports()) { @@ -87,6 +90,7 @@ void wrongExpectedEngineHashFailsClosed() { () -> BlueQuickJsWasmResources.resolve( BlueQuickJsWasmRuntimeConfig.builder() .blueQuickJsRoot(blueQuickJsRoot()) + .preferClasspathResources(false) .expectedEngineBuildHash("0000000000000000000000000000000000000000000000000000000000000000") .build())); From 26f52d2dc9ef1e9526cf5b5923aa9c41c802565f Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 12 May 2026 22:27:36 +0000 Subject: [PATCH 11/17] Record final Chicory validation results Co-authored-by: Kamil Gruszka --- docs/chicory-bluequickjs-spike.md | 11 +++-- docs/chicory-bluequickjs-test-results.md | 61 ++++++++++++++---------- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/docs/chicory-bluequickjs-spike.md b/docs/chicory-bluequickjs-spike.md index 94b90a2..a3dfc7a 100644 --- a/docs/chicory-bluequickjs-spike.md +++ b/docs/chicory-bluequickjs-spike.md @@ -155,8 +155,13 @@ Representative deterministic stress output: created in this container. All local validation commands should pass `-Dblue.quickjs.root=/tmp/blue-quickjs` or `-PblueQuickJsRoot=/tmp/blue-quickjs`. 2. The raw wasm has deterministic Emscripten support imports in `env` in addition - to `host.host_call`. The Chicory adapter must explicitly implement or reject - these imports; it must not run the Emscripten JS loader under Node. + to `host.host_call`. The Chicory adapter explicitly provides deterministic + JVM-side stubs for these imports and does not run the Emscripten JS loader + under Node. 3. Explicit `gasVersion` and `executionProfile` metadata fields are absent from the generated blue-quickjs metadata observed during preflight. Runtime pinning - must address this fail-closed requirement before evaluation. + is addressed by the Java module's generated `engine-metadata.json`, which + enriches the upstream artifact metadata with: + - `gasVersion: 2` + - `executionProfile: blue-quickjs-deterministic-baseline-1` + - `abiManifestHash: e23b0b2ee169900bbde7aff78e6ce20fead1715c60f8a8e3106d9959450a3d34` diff --git a/docs/chicory-bluequickjs-test-results.md b/docs/chicory-bluequickjs-test-results.md index e611f18..f4ad98b 100644 --- a/docs/chicory-bluequickjs-test-results.md +++ b/docs/chicory-bluequickjs-test-results.md @@ -321,39 +321,50 @@ Outcome: - Passed after adding resource-copy/classpath resource support. - Full current `quickjs-chicory` test set remains green. -These are intentionally left pending until the corresponding implementation -phases exist: +## Final validation sweep -```bash -./gradlew :quickjs-chicory:clean :quickjs-chicory:test \ - -PblueQuickJsRoot=/tmp/blue-quickjs \ - -Dblue.quickjs.root=/tmp/blue-quickjs -``` +Command: ```bash -./gradlew :quickjs-chicory:test \ - --tests '*ChicoryVsNodeParityTest' \ - -PblueQuickJsRoot=/tmp/blue-quickjs \ - -Dblue.quickjs.root=/tmp/blue-quickjs +./gradlew clean test \ + -Dblue.quickjs.root=/tmp/blue-quickjs \ + -PblueQuickJsRoot=/tmp/blue-quickjs ``` -```bash -./gradlew clean test -``` +Outcome: -```bash -PATH=/usr/bin:/bin ./gradlew :quickjs-chicory:test \ - --tests '*ChicoryBlueQuickJsRuntimeSmokeTest' \ - -PblueQuickJsRoot=/tmp/blue-quickjs -``` +- Passed. +- `BUILD SUCCESSFUL in 1m 16s` +- 13 actionable tasks executed. +- Covered the existing root test suite plus all current `quickjs-chicory` tests. + +Command: ```bash -./gradlew :quickjs-chicory:clean :quickjs-chicory:jar \ - -PblueQuickJsRoot=/tmp/blue-quickjs +./gradlew :quickjs-chicory:dependencies --configuration runtimeClasspath ``` -## Pending artifacts +Outcome: + +- Passed. +- Runtime classpath contains Chicory JVM artifacts only: + `com.dylibso.chicory:runtime:1.7.5` and + `com.dylibso.chicory:wasm:1.7.5`. +- No Node, V8, Javet, QuickJs4J, Wasmtime, or JNI runtime dependency appears. + +Lambda-like Docker smoke note: + +- `docker` is not installed in this VM, so the Java 17/21 Lambda container smoke + could not be executed here. +- The no-Node PATH smoke above is the strongest local substitute performed in + this environment. + +## Remaining environment-limited checks -- `quickjs-chicory/build/reports/blue-quickjs-chicory-parity.json` -- Lambda-like Java 17 smoke output -- Lambda-like Java 21 smoke output +- `./gradlew clean test` without a `blue.quickjs.root` override still depends on + the repository default sibling checkout. In this container that path resolves + to `/blue-quickjs`, but `/` is not writable, so the available checkout is + `/tmp/blue-quickjs`. The equivalent full clean validation with explicit + `-Dblue.quickjs.root=/tmp/blue-quickjs` passed. +- Lambda-like Java 17 and Java 21 container smoke tests remain unexecuted because + Docker is not installed in this VM. From c3b30cd1d5c56e71d657e21c04cca85d8057de35 Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 12 May 2026 22:31:59 +0000 Subject: [PATCH 12/17] Document Chicory module CI split Co-authored-by: Kamil Gruszka --- .github/workflows/build.yml | 92 ++++++++++++++++++++++-- .github/workflows/release.yml | 10 ++- README.md | 49 +++++++++++++ docs/chicory-bluequickjs-test-results.md | 22 ++++++ 4 files changed, 165 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3f805ac..85ad7f4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,13 +4,14 @@ on: push: branches: - main + - 'cursor/*' - 'feature/*' - 'fix/*' - 'hotfix/*' - 'release/*' jobs: - Build: + CoreJava8: runs-on: ubuntu-latest env: CI: true @@ -64,18 +65,99 @@ jobs: - name: Setup Gradle uses: gradle/gradle-build-action@v2 - - name: Execute Gradle build - run: ./gradlew clean build -Dblue.quickjs.root="$GITHUB_WORKSPACE/blue-quickjs" + - name: Execute Java 8 core build + run: ./gradlew :clean :build -Dblue.quickjs.root="$GITHUB_WORKSPACE/blue-quickjs" - name: Archive test results uses: actions/upload-artifact@v4 if: always() with: - name: test-results + name: core-java8-test-results path: build/reports - name: Archive libs uses: actions/upload-artifact@v4 with: - name: libs + name: core-java8-libs path: build/libs + + ChicoryJava17: + runs-on: ubuntu-latest + env: + CI: true + steps: + - uses: actions/checkout@v4 + + - name: Check out blue-quickjs + uses: actions/checkout@v4 + with: + repository: bluecontract/blue-quickjs + path: blue-quickjs + submodules: recursive + + - name: Set up JDK + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'corretto' + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.8.0 + run_install: false + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version-file: 'blue-quickjs/.nvmrc' + cache: 'pnpm' + cache-dependency-path: 'blue-quickjs/pnpm-lock.yaml' + + - name: Cache emsdk + uses: actions/cache@v4 + with: + path: blue-quickjs/tools/emsdk + key: emsdk-${{ runner.os }}-${{ hashFiles('blue-quickjs/tools/scripts/emsdk-version.txt') }} + + - name: Install blue-quickjs dependencies + run: pnpm install --frozen-lockfile + working-directory: blue-quickjs + + - name: Install emsdk + run: bash tools/scripts/setup-emsdk.sh + working-directory: blue-quickjs + + - name: Build blue-quickjs runtime + run: | + WASM_VARIANTS=wasm32 WASM_BUILD_TYPES=release pnpm exec nx build quickjs-wasm-build + pnpm exec nx build quickjs-wasm + pnpm exec nx build abi-manifest + pnpm exec nx build quickjs-runtime + working-directory: blue-quickjs + + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 + + - name: Execute full Gradle build + run: ./gradlew clean build -Dblue.quickjs.root="$GITHUB_WORKSPACE/blue-quickjs" -PblueQuickJsRoot="$GITHUB_WORKSPACE/blue-quickjs" + + - name: Confirm no native runtime dependency creep + run: ./gradlew :quickjs-chicory:dependencies --configuration runtimeClasspath + + - name: Archive test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: chicory-java17-test-results + path: | + build/reports + quickjs-chicory/build/reports + + - name: Archive libs + uses: actions/upload-artifact@v4 + with: + name: chicory-java17-libs + path: | + build/libs + quickjs-chicory/build/libs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7477050..d05094b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: - name: Set up JDK uses: actions/setup-java@v3 with: - java-version: '8' + java-version: '17' distribution: 'corretto' - name: Set up pnpm @@ -63,14 +63,18 @@ jobs: working-directory: blue-quickjs - name: Build blue-quickjs runtime - run: pnpm exec nx build quickjs-runtime + run: | + WASM_VARIANTS=wasm32 WASM_BUILD_TYPES=release pnpm exec nx build quickjs-wasm-build + pnpm exec nx build quickjs-wasm + pnpm exec nx build abi-manifest + pnpm exec nx build quickjs-runtime working-directory: blue-quickjs - name: Setup Gradle uses: gradle/gradle-build-action@v2 - name: Execute Gradle build - run: ./gradlew clean build -Dblue.quickjs.root="$GITHUB_WORKSPACE/blue-quickjs" + run: ./gradlew clean build -Dblue.quickjs.root="$GITHUB_WORKSPACE/blue-quickjs" -PblueQuickJsRoot="$GITHUB_WORKSPACE/blue-quickjs" - name: Execute Gradle publish run: ./gradlew publish -Dblue.quickjs.root="$GITHUB_WORKSPACE/blue-quickjs" diff --git a/README.md b/README.md index ae4e46e..b7f474b 100644 --- a/README.md +++ b/README.md @@ -412,6 +412,55 @@ cd ../blue-quickjs pnpm nx build quickjs-runtime ``` +### Experimental Chicory blue-quickjs runtime + +The root `blue-contract-java` artifact remains Java 8 compatible and continues +to use `NodeQuickJsRuntime` by default. An optional Java 11+ submodule, +`quickjs-chicory`, provides an experimental JVM-native runtime that executes the +canonical blue-quickjs wasm32 release artifact through Chicory, without Node, +V8, Javet, native Wasmtime bindings, or JNI in the Chicory evaluation path. + +Build and test the optional module with a built `blue-quickjs` checkout: + +```bash +cd ../blue-quickjs +pnpm install --frozen-lockfile +bash tools/scripts/setup-emsdk.sh +WASM_VARIANTS=wasm32 WASM_BUILD_TYPES=release pnpm exec nx build quickjs-wasm-build +pnpm exec nx build quickjs-wasm +pnpm exec nx build abi-manifest +pnpm exec nx build quickjs-runtime + +cd ../blue-contract-java +./gradlew :quickjs-chicory:test \ + -PblueQuickJsRoot=../blue-quickjs \ + -Dblue.quickjs.root=../blue-quickjs +``` + +The module verifies the pinned wasm artifact, `engineBuildHash`, Host.v1 +manifest hash, gas version, and deterministic execution profile before +evaluation. For packaging, generate classpath resources with: + +```bash +./gradlew :quickjs-chicory:jar -PblueQuickJsRoot=../blue-quickjs +``` + +Applications can inject the runtime without adding Chicory to the Java 8 core: + +```java +import blue.contract.processor.BlueDocumentProcessorOptions; +import blue.contract.processor.BlueDocumentProcessors; +import blue.contract.processor.conversation.javascript.chicory.ChicoryBlueQuickJsRuntime; + +BlueDocumentProcessors.registerWith( + blue, + BlueDocumentProcessorOptions.builder() + .javaScriptRuntime(new ChicoryBlueQuickJsRuntime()) + .build()); +``` + +`NodeQuickJsRuntime` remains the compatibility fallback and parity oracle. + ## Registration Most applications should use the one-call facade: diff --git a/docs/chicory-bluequickjs-test-results.md b/docs/chicory-bluequickjs-test-results.md index f4ad98b..94f26fc 100644 --- a/docs/chicory-bluequickjs-test-results.md +++ b/docs/chicory-bluequickjs-test-results.md @@ -352,6 +352,28 @@ Outcome: `com.dylibso.chicory:wasm:1.7.5`. - No Node, V8, Javet, QuickJs4J, Wasmtime, or JNI runtime dependency appears. +Command: + +```bash +python3 - <<'PY' +import yaml +for path in ['.github/workflows/build.yml', '.github/workflows/release.yml']: + with open(path, 'r', encoding='utf-8') as fh: + yaml.safe_load(fh) + print(path, 'ok') +PY +./gradlew :clean :test -Dblue.quickjs.root=/tmp/blue-quickjs +``` + +Outcome: + +- Passed. +- Workflow YAML parsed successfully. +- Root/core-only test path passed: + `BUILD SUCCESSFUL in 14s`, 6 actionable tasks executed. +- This validates the Java 8-compatible core task path used by the split CI job + without invoking the Java 11+ optional module. + Lambda-like Docker smoke note: - `docker` is not installed in this VM, so the Java 17/21 Lambda container smoke From ca6db9b5b7f68d810fa52098d207a922a3ac7f3e Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 12 May 2026 22:35:50 +0000 Subject: [PATCH 13/17] Add forbidden surface and OOG parity tests Co-authored-by: Kamil Gruszka --- docs/chicory-bluequickjs-test-results.md | 32 +++++ .../chicory/ChicoryBlueQuickJsRuntime.java | 5 + .../chicory/ChicoryForbiddenSurfaceTest.java | 53 ++++++++ .../chicory/ChicoryOutOfGasTest.java | 89 +++++++++++++ .../chicory/ChicoryParityAssertions.java | 124 ++++++++++++++++++ 5 files changed, 303 insertions(+) create mode 100644 quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryForbiddenSurfaceTest.java create mode 100644 quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryOutOfGasTest.java create mode 100644 quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryParityAssertions.java diff --git a/docs/chicory-bluequickjs-test-results.md b/docs/chicory-bluequickjs-test-results.md index 94f26fc..42d4107 100644 --- a/docs/chicory-bluequickjs-test-results.md +++ b/docs/chicory-bluequickjs-test-results.md @@ -215,6 +215,38 @@ Outcome: host gas drift. - Covered arithmetic, string concatenation, object/array return, and array map. +## Forbidden surface and OOG boundaries + +Command: + +```bash +./gradlew :quickjs-chicory:test \ + --tests '*ChicoryForbiddenSurfaceTest' \ + --tests '*ChicoryOutOfGasTest' \ + -Dblue.quickjs.root=/tmp/blue-quickjs +``` + +Outcome: + +- Passed. +- Forbidden surface parity covered: + - `typeof Date` + - `typeof process` + - `typeof require` + - `Math.random()` + - `eval("1")` + - `Function("return 1")()` + - `new Proxy({}, {})` + - `typeof WeakRef` +- OOG parity/repetition covered: + - host gas limit `0` + - host gas limit `1` + - `while (true) {}` + - large loop + - recursive function + - `document.get` loop +- Each OOG case was repeated 5 times and checked for no Chicory result drift. + ## Chicory vs Node parity Command: diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntime.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntime.java index 0715997..44f7ad7 100644 --- a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntime.java +++ b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntime.java @@ -76,6 +76,11 @@ private static String normalizeVmError(String message) { if ("OutOfGas: out of gas".equals(message)) { return "vm-error: out of gas"; } + if (message != null + && message.startsWith("TypeError: ") + && message.contains("disabled in deterministic mode")) { + return "vm-error: " + message.substring("TypeError: ".length()); + } if (message != null && message.startsWith("vm-error: ")) { return message; } diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryForbiddenSurfaceTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryForbiddenSurfaceTest.java new file mode 100644 index 0000000..a8c65ba --- /dev/null +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryForbiddenSurfaceTest.java @@ -0,0 +1,53 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; +import blue.contract.processor.conversation.javascript.NodeQuickJsRuntime; +import blue.contract.processor.conversation.javascript.QuickJsGas; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class ChicoryForbiddenSurfaceTest { + + @Test + void forbiddenSurfaceMatchesNodeOracle() { + Path root = blueQuickJsRoot(); + ChicoryBlueQuickJsRuntime chicory = new ChicoryBlueQuickJsRuntime(BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(root) + .expectedEngineBuildHash("1d4584fc0552a24ee840afa2cca9f1536d47429f467585d4d5c1a5236ba96dc9") + .build()); + try (NodeQuickJsRuntime node = new NodeQuickJsRuntime(root)) { + assertParity(node, chicory, "typeof Date"); + assertParity(node, chicory, "typeof process"); + assertParity(node, chicory, "typeof require"); + assertParity(node, chicory, "Math.random()"); + assertParity(node, chicory, "eval(\"1\")"); + assertParity(node, chicory, "Function(\"return 1\")()"); + assertParity(node, chicory, "new Proxy({}, {})"); + assertParity(node, chicory, "typeof WeakRef"); + } + } + + private static void assertParity(NodeQuickJsRuntime node, ChicoryBlueQuickJsRuntime chicory, String code) { + ChicoryParityAssertions.assertParity(code, + node, + chicory, + new JavaScriptEvaluationRequest(code, + JavaScriptEvaluationRequest.Mode.EXPRESSION, + Collections.emptyMap(), + QuickJsGas.DEFAULT_EXPRESSION_HOST_GAS_LIMIT)); + } + + private static Path blueQuickJsRoot() { + String configured = System.getProperty("blue.quickjs.root"); + Path root = configured == null || configured.trim().isEmpty() + ? Paths.get(System.getProperty("user.dir")).toAbsolutePath().getParent().resolve("blue-quickjs") + : Paths.get(configured); + assumeTrue(Files.isDirectory(root), "blue-quickjs checkout is required for forbidden surface tests"); + return root; + } +} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryOutOfGasTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryOutOfGasTest.java new file mode 100644 index 0000000..f889197 --- /dev/null +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryOutOfGasTest.java @@ -0,0 +1,89 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; +import blue.contract.processor.conversation.javascript.NodeQuickJsRuntime; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class ChicoryOutOfGasTest { + + @Test + void outOfGasBoundariesMatchNodeAndDoNotDrift() { + Path root = blueQuickJsRoot(); + ChicoryBlueQuickJsRuntime chicory = new ChicoryBlueQuickJsRuntime(BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(root) + .expectedEngineBuildHash("1d4584fc0552a24ee840afa2cca9f1536d47429f467585d4d5c1a5236ba96dc9") + .build()); + try (NodeQuickJsRuntime node = new NodeQuickJsRuntime(root)) { + assertRepeatedParity(node, chicory, "hostGasLimit = 0", "1 + 1", 0L, Collections.emptyMap()); + assertRepeatedParity(node, chicory, "hostGasLimit = 1", "1 + 1", 1L, Collections.emptyMap()); + assertRepeatedParity(node, chicory, "while true", "(() => { while (true) {} })()", 1L, Collections.emptyMap()); + assertRepeatedParity(node, chicory, + "large array loop", + "(() => { let sum = 0; for (let i = 0; i < 100000; i++) sum += i; return sum; })()", + 10L, + Collections.emptyMap()); + assertRepeatedParity(node, chicory, + "recursive function", + "(() => { function f(n) { return n === 0 ? 0 : f(n - 1); } return f(1000000); })()", + 1L, + Collections.emptyMap()); + assertRepeatedParity(node, chicory, + "document.get loop", + "(() => { while (true) { document('/counter'); } })()", + 10L, + bindings()); + } + } + + private static void assertRepeatedParity(NodeQuickJsRuntime node, + ChicoryBlueQuickJsRuntime chicory, + String label, + String code, + long hostGasLimit, + Map bindings) { + ChicoryParityAssertions.Evaluation previous = null; + for (int i = 0; i < 5; i++) { + JavaScriptEvaluationRequest request = new JavaScriptEvaluationRequest(code, + JavaScriptEvaluationRequest.Mode.EXPRESSION, + bindings, + hostGasLimit); + ChicoryParityAssertions.Evaluation nodeResult = ChicoryParityAssertions.evaluate(node, request); + ChicoryParityAssertions.Evaluation chicoryResult = ChicoryParityAssertions.evaluate(chicory, request); + org.junit.jupiter.api.Assertions.assertEquals(nodeResult, chicoryResult, label + " iteration " + i); + if (previous != null) { + org.junit.jupiter.api.Assertions.assertEquals(previous, chicoryResult, label + " Chicory drift at iteration " + i); + } + previous = chicoryResult; + } + } + + private static Map bindings() { + Map document = new LinkedHashMap(); + Map counter = new LinkedHashMap(); + counter.put("value", 1); + document.put("counter", counter); + Map bindings = new LinkedHashMap(); + bindings.put("document", document); + bindings.put("documentCanonical", document); + bindings.put("documentMetadata", Collections.emptyMap()); + bindings.put("steps", Collections.emptyMap()); + return bindings; + } + + private static Path blueQuickJsRoot() { + String configured = System.getProperty("blue.quickjs.root"); + Path root = configured == null || configured.trim().isEmpty() + ? Paths.get(System.getProperty("user.dir")).toAbsolutePath().getParent().resolve("blue-quickjs") + : Paths.get(configured); + assumeTrue(Files.isDirectory(root), "blue-quickjs checkout is required for OOG tests"); + return root; + } +} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryParityAssertions.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryParityAssertions.java new file mode 100644 index 0000000..9f9c676 --- /dev/null +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryParityAssertions.java @@ -0,0 +1,124 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; +import blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult; +import blue.contract.processor.conversation.javascript.JavaScriptExecutionException; +import blue.contract.processor.conversation.javascript.JavaScriptRuntime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class ChicoryParityAssertions { + private ChicoryParityAssertions() { + } + + static Evaluation evaluate(JavaScriptRuntime runtime, JavaScriptEvaluationRequest request) { + try { + JavaScriptEvaluationResult result = runtime.evaluate(request); + return Evaluation.ok(result.value(), result.wasmGasUsed(), result.hostGasUsed()); + } catch (JavaScriptExecutionException ex) { + return Evaluation.error(normalizeMessage(ex.getMessage())); + } + } + + static void assertParity(String label, + JavaScriptRuntime node, + JavaScriptRuntime chicory, + JavaScriptEvaluationRequest request) { + Evaluation expected = evaluate(node, request); + Evaluation actual = evaluate(chicory, request); + assertEquals(expected, actual, label + " should match Node oracle"); + } + + private static String normalizeMessage(String message) { + return message == null ? "" : message.replace("Chicory blue-quickjs evaluation failed: ", ""); + } + + static final class Evaluation { + private final boolean ok; + private final Object value; + private final String error; + private final long wasmGasUsed; + private final long hostGasUsed; + + private Evaluation(boolean ok, Object value, String error, long wasmGasUsed, long hostGasUsed) { + this.ok = ok; + this.value = value; + this.error = error; + this.wasmGasUsed = wasmGasUsed; + this.hostGasUsed = hostGasUsed; + } + + static Evaluation ok(Object value, long wasmGasUsed, long hostGasUsed) { + return new Evaluation(true, normalize(value), null, wasmGasUsed, hostGasUsed); + } + + static Evaluation error(String error) { + return new Evaluation(false, null, error, -1L, -1L); + } + + Map toMap() { + Map map = new LinkedHashMap(); + map.put("ok", ok); + map.put("value", value); + map.put("error", error); + map.put("wasmGasUsed", wasmGasUsed); + map.put("hostGasUsed", hostGasUsed); + return map; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof Evaluation)) { + return false; + } + Evaluation that = (Evaluation) other; + return ok == that.ok + && wasmGasUsed == that.wasmGasUsed + && hostGasUsed == that.hostGasUsed + && java.util.Objects.equals(value, that.value) + && java.util.Objects.equals(error, that.error); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(ok, value, error, wasmGasUsed, hostGasUsed); + } + + @Override + public String toString() { + return toMap().toString(); + } + + @SuppressWarnings("unchecked") + private static Object normalize(Object value) { + if (value instanceof Number) { + Number number = (Number) value; + if (number.doubleValue() == Math.rint(number.doubleValue()) + && number.longValue() >= Integer.MIN_VALUE + && number.longValue() <= Integer.MAX_VALUE) { + return Integer.valueOf(number.intValue()); + } + return value; + } + if (value instanceof List) { + List result = new ArrayList(); + for (Object item : (List) value) { + result.add(normalize(item)); + } + return result; + } + if (value instanceof Map) { + Map result = new LinkedHashMap(); + for (Map.Entry entry : ((Map) value).entrySet()) { + result.put(entry.getKey(), normalize(entry.getValue())); + } + return result; + } + return value; + } + } +} From 5200d62a8423d7f7181175f93299e1cb0a5d6f33 Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 12 May 2026 22:37:47 +0000 Subject: [PATCH 14/17] Add Chicory counter snapshot stress test Co-authored-by: Kamil Gruszka --- docs/chicory-bluequickjs-test-results.md | 16 ++ ...oryCounterSnapshotRoundTripStressTest.java | 194 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryCounterSnapshotRoundTripStressTest.java diff --git a/docs/chicory-bluequickjs-test-results.md b/docs/chicory-bluequickjs-test-results.md index 42d4107..806d6c1 100644 --- a/docs/chicory-bluequickjs-test-results.md +++ b/docs/chicory-bluequickjs-test-results.md @@ -299,6 +299,22 @@ Outcome: Command: +```bash +./gradlew :quickjs-chicory:test \ + --tests '*ChicoryCounterSnapshotRoundTripStressTest' \ + -Dblue.quickjs.root=/tmp/blue-quickjs +``` + +Outcome: + +- Passed. +- Ran 100 Chicory-backed counter workflow iterations through canonical snapshot + round trips. +- Verified counter progression, snapshot/BlueId preservation, positive gas, and + stable gas for equivalent operations. + +Command: + ```bash ./gradlew :quickjs-chicory:test \ -PblueQuickJsRoot=/tmp/blue-quickjs \ diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryCounterSnapshotRoundTripStressTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryCounterSnapshotRoundTripStressTest.java new file mode 100644 index 0000000..9bd9fb8 --- /dev/null +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryCounterSnapshotRoundTripStressTest.java @@ -0,0 +1,194 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import blue.contract.processor.BlueDocumentProcessorOptions; +import blue.contract.processor.BlueDocumentProcessors; +import blue.contract.processor.conversation.TimelineProviderSupport; +import blue.contract.processor.conversation.workflow.SequentialWorkflowRunner; +import blue.language.Blue; +import blue.language.model.Node; +import blue.language.model.TypeBlueId; +import blue.language.processor.ChannelCheckpointContext; +import blue.language.processor.ChannelEvaluation; +import blue.language.processor.ChannelEvaluationContext; +import blue.language.processor.ChannelProcessor; +import blue.language.processor.DocumentProcessingResult; +import blue.language.snapshot.ResolvedSnapshot; +import blue.repo.BlueRepository; +import blue.repo.v1_2_0.conversation.ChatMessage; +import blue.repo.v1_2_0.conversation.Timeline; +import blue.repo.v1_2_0.conversation.TimelineChannel; +import blue.repo.v1_2_0.conversation.TimelineEntry; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +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; + +class ChicoryCounterSnapshotRoundTripStressTest { + private static final int STRESS_ITERATIONS = 100; + private static final String SIMPLE_TIMELINE_CHANNEL_BLUE_ID = "chicory-stress-simple-timeline-channel"; + + @Test + void chicoryCounterWorkflowSurvivesCanonicalSnapshotRoundTrips() { + Fixture fixture = configuredFixture(); + DocumentProcessingResult initialized = fixture.blue.initializeDocument( + fixture.blue.preprocess(counterDocument(fixture.repository))); + ResolvedSnapshot currentSnapshot = initialized.snapshot(); + assertNotNull(currentSnapshot); + + long totalGas = 0L; + long minGas = Long.MAX_VALUE; + long maxGas = 0L; + String finalBlueId = null; + + for (int i = 1; i <= STRESS_ITERATIONS; i++) { + Node event = timelineEntry(fixture.blue, fixture.repository, "counter", i, chatMessage("tick " + i)); + + DocumentProcessingResult result = fixture.blue.processDocument(currentSnapshot, event); + + assertNotNull(result.snapshot(), "iteration " + i + " should return a snapshot"); + assertNotNull(result.blueId(), "iteration " + i + " should return a BlueId"); + assertEquals(BigInteger.valueOf(i), result.resolvedDocument().get("/counter")); + assertTrue(result.totalGas() > 0, "iteration " + i + " should charge gas"); + + totalGas += result.totalGas(); + minGas = Math.min(minGas, result.totalGas()); + maxGas = Math.max(maxGas, result.totalGas()); + finalBlueId = result.blueId(); + + String canonicalJson = fixture.blue.nodeToJson(result.canonicalDocument()); + Node parsedCanonical = fixture.blue.jsonToNode(canonicalJson); + ResolvedSnapshot loadedSnapshot = fixture.blue.loadSnapshot(parsedCanonical); + assertEquals(result.blueId(), loadedSnapshot.blueId(), "iteration " + i + " should preserve BlueId"); + currentSnapshot = loadedSnapshot; + } + + assertEquals(BigInteger.valueOf(STRESS_ITERATIONS), currentSnapshot.resolvedNodeAt("/counter").getValue()); + assertNotNull(finalBlueId); + assertTrue(totalGas > 0); + assertEquals(minGas, maxGas, "equivalent Chicory increments should charge stable gas"); + } + + private static Fixture configuredFixture() { + BlueRepository repository = BlueRepository.v1_2_0(); + Blue blue = repository.configure(new Blue()); + blue.nodeProvider(repository.nodeProvider()); + ChicoryBlueQuickJsRuntime runtime = new ChicoryBlueQuickJsRuntime(BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(blueQuickJsRoot()) + .expectedEngineBuildHash("1d4584fc0552a24ee840afa2cca9f1536d47429f467585d4d5c1a5236ba96dc9") + .build()); + BlueDocumentProcessors.registerWith(blue, BlueDocumentProcessorOptions.builder() + .sequentialWorkflowRunner(SequentialWorkflowRunner.withJavaScriptRuntime(runtime)) + .build()); + blue.registerContractProcessor(new SimpleTimelineChannelProcessor()); + return new Fixture(repository, blue); + } + + private static Node counterDocument(BlueRepository repository) { + Map contracts = new LinkedHashMap(); + contracts.put("owner", channel("counter")); + contracts.put("increment", new Node() + .type("Conversation/Sequential Workflow") + .properties("channel", new Node().value("owner")) + .properties("steps", new Node().items(Arrays.asList( + updateDocumentStep("replace", "/counter", new Node().value("${document('/counter') + 1}")))))); + + return new Node() + .blue(repository.typeAliasBlue()) + .name("Chicory Stress Counter") + .properties("counter", new Node().value(0)) + .properties("contracts", new Node().properties(contracts)); + } + + private static Node updateDocumentStep(String op, String path, Node value) { + return new Node() + .type("Conversation/Update Document") + .properties("changeset", new Node().items(new Node() + .properties("op", new Node().value(op)) + .properties("path", new Node().value(path)) + .properties("val", value))); + } + + private static Node channel(String timelineId) { + return new Node() + .type(new Node().blueId(SIMPLE_TIMELINE_CHANNEL_BLUE_ID)) + .properties("timelineId", new Node().value(timelineId)); + } + + private static Node timelineEntry(Blue blue, + BlueRepository repository, + String timelineId, + int timestamp, + Node message) { + TimelineEntry entry = new TimelineEntry() + .timeline(new Timeline().timelineId(timelineId)) + .timestamp(BigInteger.valueOf(timestamp)) + .message(message); + return blue.preprocess(new Node() + .blue(repository.typeAliasBlue()) + .type(TimelineEntry.qualifiedName()) + .properties("timeline", blue.objectToNode(entry.getTimeline())) + .properties("timestamp", new Node().value(entry.getTimestamp())) + .properties("message", entry.getMessage())); + } + + private static Node chatMessage(String message) { + ChatMessage chatMessage = new ChatMessage().message(message); + return new Node() + .type(ChatMessage.qualifiedName()) + .properties("message", new Node().value(chatMessage.getMessage())); + } + + private static Path blueQuickJsRoot() { + String configured = System.getProperty("blue.quickjs.root"); + Path root = configured == null || configured.trim().isEmpty() + ? Paths.get(System.getProperty("user.dir")).toAbsolutePath().getParent().resolve("blue-quickjs") + : Paths.get(configured); + assumeTrue(Files.isDirectory(root), "blue-quickjs checkout is required for Chicory stress tests"); + return root; + } + + @TypeBlueId(SIMPLE_TIMELINE_CHANNEL_BLUE_ID) + public static final class SimpleTimelineChannel extends TimelineChannel { + } + + public static final class SimpleTimelineChannelProcessor implements ChannelProcessor { + @Override + public Class contractType() { + return SimpleTimelineChannel.class; + } + + @Override + public ChannelEvaluation evaluate(SimpleTimelineChannel contract, ChannelEvaluationContext context) { + return TimelineProviderSupport.evaluateTimelineEntry(contract, context); + } + + @Override + public String eventId(SimpleTimelineChannel contract, ChannelEvaluationContext context) { + return TimelineProviderSupport.eventId(context.event()); + } + + @Override + public boolean isNewerEvent(SimpleTimelineChannel contract, ChannelCheckpointContext context) { + return TimelineProviderSupport.isNewerOrSameTimelineEvent(context); + } + } + + private static final class Fixture { + private final BlueRepository repository; + private final Blue blue; + + private Fixture(BlueRepository repository, Blue blue) { + this.repository = repository; + this.blue = blue; + } + } +} From 0e860287c4332e12950890e292ea11b29490b80c Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 12 May 2026 22:38:30 +0000 Subject: [PATCH 15/17] Add Lambda packaging classpath smoke test Co-authored-by: Kamil Gruszka --- docs/chicory-bluequickjs-test-results.md | 14 +++++++++ .../chicory/LambdaPackagingSmokeTest.java | 29 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/LambdaPackagingSmokeTest.java diff --git a/docs/chicory-bluequickjs-test-results.md b/docs/chicory-bluequickjs-test-results.md index 806d6c1..0ae3639 100644 --- a/docs/chicory-bluequickjs-test-results.md +++ b/docs/chicory-bluequickjs-test-results.md @@ -344,6 +344,20 @@ Outcome: Command: +```bash +./gradlew :quickjs-chicory:test \ + --tests '*LambdaPackagingSmokeTest' \ + -PblueQuickJsRoot=/tmp/blue-quickjs +``` + +Outcome: + +- Passed. +- Evaluated a deterministic fixture using classpath-pinned resources without + configuring a filesystem blue-quickjs root in the runtime config. + +Command: + ```bash PATH=/usr/bin:/bin ./gradlew :quickjs-chicory:test \ --tests '*ChicoryBlueQuickJsRuntimeSmokeTest' \ diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/LambdaPackagingSmokeTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/LambdaPackagingSmokeTest.java new file mode 100644 index 0000000..57b9d3c --- /dev/null +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/LambdaPackagingSmokeTest.java @@ -0,0 +1,29 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; +import blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult; +import blue.contract.processor.conversation.javascript.QuickJsGas; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LambdaPackagingSmokeTest { + + @Test + void classpathPinnedResourcesEvaluateWithoutFilesystemRoot() { + ChicoryBlueQuickJsRuntime runtime = new ChicoryBlueQuickJsRuntime(BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(null) + .preferClasspathResources(true) + .expectedEngineBuildHash("1d4584fc0552a24ee840afa2cca9f1536d47429f467585d4d5c1a5236ba96dc9") + .build()); + + JavaScriptEvaluationResult result = runtime.evaluate(new JavaScriptEvaluationRequest( + "1 + 2", + JavaScriptEvaluationRequest.Mode.EXPRESSION, + Collections.emptyMap(), + QuickJsGas.DEFAULT_EXPRESSION_HOST_GAS_LIMIT)); + + assertEquals(3, result.value()); + } +} From ccdfbcc80d9b32de9b50cc8ff26d41606f1b3ccf Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 12 May 2026 22:42:43 +0000 Subject: [PATCH 16/17] Record expanded Chicory validation coverage Co-authored-by: Kamil Gruszka --- docs/chicory-bluequickjs-test-results.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/chicory-bluequickjs-test-results.md b/docs/chicory-bluequickjs-test-results.md index 0ae3639..7003137 100644 --- a/docs/chicory-bluequickjs-test-results.md +++ b/docs/chicory-bluequickjs-test-results.md @@ -396,7 +396,8 @@ Command: Outcome: - Passed. -- `BUILD SUCCESSFUL in 1m 16s` +- Latest full clean run after adding forbidden-surface, OOG, counter-stress, and + Lambda classpath smoke coverage: `BUILD SUCCESSFUL in 1m 40s` - 13 actionable tasks executed. - Covered the existing root test suite plus all current `quickjs-chicory` tests. From cb14e57bce53633fee831652a00294ba9d46173d Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Thu, 14 May 2026 02:41:27 +0200 Subject: [PATCH 17/17] Add Chicory experimental runtime: setup build, CI/CD workflows, test support, and enhance gas usage tracking --- .github/workflows/build.yml | 10 + .github/workflows/release.yml | 4 +- README.md | 78 ++- build.gradle | 2 +- docs/chicory-bluequickjs-spike.md | 50 +- docs/chicory-bluequickjs-test-results.md | 197 +++++++- quickjs-chicory/build.gradle | 76 ++- .../chicory/BlueQuickJsHostDispatcher.java | 5 +- .../chicory/BlueQuickJsWasmResources.java | 104 +++- .../chicory/BlueQuickJsWasmRuntimeConfig.java | 4 +- .../chicory/ChicoryBlueQuickJsRuntime.java | 35 +- .../BlueQuickJsResourceIntegrityTest.java | 249 +++++++++- .../chicory/ChicoryBenchmarkReportTest.java | 156 ++++++ .../ChicoryBlueQuickJsRuntimeSmokeTest.java | 12 +- ...oryCounterSnapshotRoundTripStressTest.java | 12 +- .../chicory/ChicoryDocumentHostTest.java | 10 +- .../chicory/ChicoryForbiddenSurfaceTest.java | 12 +- .../chicory/ChicoryHostCallAbiTest.java | 3 + .../chicory/ChicoryOutOfGasTest.java | 12 +- .../chicory/ChicoryParityAssertions.java | 6 +- .../chicory/ChicoryProcessorParityTest.java | 455 ++++++++++++++++++ ...hicorySequentialWorkflowExecutionTest.java | 12 +- .../chicory/ChicoryTestSupport.java | 70 +++ .../chicory/ChicoryVsNodeParityTest.java | 263 +++++----- .../chicory/LambdaPackagingSmokeTest.java | 74 ++- .../QuickJsExpressionEvaluator.java | 3 + .../expression/QuickJsExpressionResolver.java | 12 +- .../JavaScriptExecutionException.java | 27 +- .../javascript/NodeQuickJsRuntime.java | 17 +- .../workflow/JavaScriptCodeStepExecutor.java | 3 + .../javascript/NodeQuickJsRuntimeTest.java | 60 +++ 31 files changed, 1761 insertions(+), 272 deletions(-) create mode 100644 quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBenchmarkReportTest.java create mode 100644 quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryProcessorParityTest.java create mode 100644 quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryTestSupport.java create mode 100644 src/test/java/blue/contract/processor/conversation/javascript/NodeQuickJsRuntimeTest.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 85ad7f4..b323832 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -145,6 +145,9 @@ jobs: - name: Confirm no native runtime dependency creep run: ./gradlew :quickjs-chicory:dependencies --configuration runtimeClasspath + - name: Verify Chicory no-Node smoke test + run: ./gradlew :quickjs-chicory:test --tests '*LambdaPackagingSmokeTest' -Dblue.quickjs.root="$GITHUB_WORKSPACE/blue-quickjs" -PblueQuickJsRoot="$GITHUB_WORKSPACE/blue-quickjs" + - name: Archive test results uses: actions/upload-artifact@v4 if: always() @@ -154,6 +157,13 @@ jobs: build/reports quickjs-chicory/build/reports + - name: Archive Chicory parity and benchmark reports + uses: actions/upload-artifact@v4 + if: always() + with: + name: chicory-parity-benchmark-reports + path: quickjs-chicory/build/reports/*.json + - name: Archive libs uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d05094b..1da179d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,7 +77,7 @@ jobs: run: ./gradlew clean build -Dblue.quickjs.root="$GITHUB_WORKSPACE/blue-quickjs" -PblueQuickJsRoot="$GITHUB_WORKSPACE/blue-quickjs" - name: Execute Gradle publish - run: ./gradlew publish -Dblue.quickjs.root="$GITHUB_WORKSPACE/blue-quickjs" + run: ./gradlew publish -Dblue.quickjs.root="$GITHUB_WORKSPACE/blue-quickjs" -PblueQuickJsRoot="$GITHUB_WORKSPACE/blue-quickjs" - name: Execute Gradle release env: @@ -96,5 +96,7 @@ jobs: name: artifacts path: | build/libs + quickjs-chicory/build/libs build/publications + quickjs-chicory/build/publications build/jreleaser diff --git a/README.md b/README.md index b7f474b..4063578 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ dependencies { This project targets Java 8 bytecode and depends on published artifacts: ```groovy -api "blue.language:blue-language-java:1.0.0" +api "blue.language:blue-language-java:2.0.0" api "blue.repo:blue-repo-java:1.2.0" ``` @@ -415,12 +415,31 @@ pnpm nx build quickjs-runtime ### Experimental Chicory blue-quickjs runtime The root `blue-contract-java` artifact remains Java 8 compatible and continues -to use `NodeQuickJsRuntime` by default. An optional Java 11+ submodule, -`quickjs-chicory`, provides an experimental JVM-native runtime that executes the -canonical blue-quickjs wasm32 release artifact through Chicory, without Node, -V8, Javet, native Wasmtime bindings, or JNI in the Chicory evaluation path. +to use `NodeQuickJsRuntime` by default. Chicory is optional and must be enabled +explicitly. The optional Java 11+ artifact is: -Build and test the optional module with a built `blue-quickjs` checkout: +```groovy +dependencies { + implementation "blue.contract:blue-contract-java:1.0.0" + implementation "blue.contract:blue-contract-java-quickjs-chicory:1.0.0" +} +``` + +The Chicory runtime executes the canonical blue-quickjs wasm32 release artifact +on the JVM. It does not require Node, V8, Javet, JNI, native Wasmtime bindings, +or another native JavaScript runtime in the Chicory evaluation path. + +Treat Chicory as an experimental AWS Lambda / JVM-only runtime because current +benchmarks show it is substantially slower than the Node bridge. The no-Node +classpath smoke and Java 11 AWS Lambda container smoke have passed, so the +remaining blocker is performance and release hardening, not basic packaging. +For the same pinned blue-quickjs artifact, Host.v1 ABI, source wrapper, +bindings, and gas limit, Chicory and `NodeQuickJsRuntime` must return identical +values/errors, `wasmGasUsed`, and `hostGasUsed`. Benchmark reports are +timing-only; gas must match exactly. + +Build, publish locally, and test the optional module with a built `blue-quickjs` +checkout: ```bash cd ../blue-quickjs @@ -435,31 +454,72 @@ cd ../blue-contract-java ./gradlew :quickjs-chicory:test \ -PblueQuickJsRoot=../blue-quickjs \ -Dblue.quickjs.root=../blue-quickjs +./gradlew publishToMavenLocal -PblueQuickJsRoot=../blue-quickjs ``` The module verifies the pinned wasm artifact, `engineBuildHash`, Host.v1 manifest hash, gas version, and deterministic execution profile before -evaluation. For packaging, generate classpath resources with: +evaluation. Filesystem WASM resolution fails closed unless an expected engine +hash is supplied, and both filesystem and classpath modes require metadata with +`engineBuildHash`, `gasVersion`, `executionProfile`, and `abiManifestHash`. +Classpath-bundled WASM is self-pinned by generated metadata. For packaging, +generate classpath resources with: ```bash ./gradlew :quickjs-chicory:jar -PblueQuickJsRoot=../blue-quickjs ``` -Applications can inject the runtime without adding Chicory to the Java 8 core: +Applications can use classpath-pinned WASM resources and inject the runtime +without adding Chicory to the Java 8 core: ```java import blue.contract.processor.BlueDocumentProcessorOptions; import blue.contract.processor.BlueDocumentProcessors; +import blue.contract.processor.conversation.javascript.JavaScriptRuntime; import blue.contract.processor.conversation.javascript.chicory.ChicoryBlueQuickJsRuntime; +JavaScriptRuntime runtime = ChicoryBlueQuickJsRuntime.fromClasspathDefaults(); + BlueDocumentProcessors.registerWith( blue, BlueDocumentProcessorOptions.builder() - .javaScriptRuntime(new ChicoryBlueQuickJsRuntime()) + .javaScriptRuntime(runtime) + .build()); +``` + +Filesystem WASM resources are also supported, but must be explicitly pinned by +engine hash and must include the same metadata fields: + +```java +import blue.contract.processor.conversation.javascript.JavaScriptRuntime; +import blue.contract.processor.conversation.javascript.chicory.BlueQuickJsWasmRuntimeConfig; +import blue.contract.processor.conversation.javascript.chicory.ChicoryBlueQuickJsRuntime; +import java.nio.file.Paths; + +JavaScriptRuntime runtime = new ChicoryBlueQuickJsRuntime( + BlueQuickJsWasmRuntimeConfig.builder() + .preferClasspathResources(false) + .blueQuickJsRoot(Paths.get("/opt/blue-quickjs")) + .expectedEngineBuildHash("f91091cb7feb788df340305a877a9cadb0c6f4d13aea8a7da4040b6367d178ea") .build()); ``` `NodeQuickJsRuntime` remains the compatibility fallback and parity oracle. +The generated POM for `blue-contract-java` does not depend on Chicory; users +must opt in by depending on `blue-contract-java-quickjs-chicory`. + +Container/Lambda status: CI runs a no-Node classpath smoke test that evaluates +`1 + 2` through `ChicoryBlueQuickJsRuntime.fromClasspathDefaults()`, verifies gas +is charged, and checks that Node, V8, Javet, JNI, native Wasmtime, and other +native JavaScript runtime dependencies are absent from the Chicory runtime +classpath. The same smoke has also passed in Docker using the Java 11 AWS SAM +Lambda build image. A full AWS Lambda runtime/RIE invocation smoke can still be +added later if release policy requires it. + +The generated `quickjs-chicory` metadata currently bridges fields that should +come from upstream blue-quickjs release metadata long term, especially +`executionProfile` and `abiManifestHash`. Java consumes and verifies those +generated fields; it must not infer gas version from documentation. ## Registration diff --git a/build.gradle b/build.gradle index 7fb20dd..36e59e1 100644 --- a/build.gradle +++ b/build.gradle @@ -41,7 +41,7 @@ tasks.withType(JavaCompile).configureEach { } dependencies { - api 'blue.language:blue-language-java:1.0.0' + api 'blue.language:blue-language-java:2.0.0' api 'blue.repo:blue-repo-java:1.2.0' implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2' diff --git a/docs/chicory-bluequickjs-spike.md b/docs/chicory-bluequickjs-spike.md index a3dfc7a..f192e2d 100644 --- a/docs/chicory-bluequickjs-spike.md +++ b/docs/chicory-bluequickjs-spike.md @@ -45,8 +45,8 @@ pnpm exec nx build quickjs-runtime - Wasm size: `659086` - Variant: `wasm32` - Build type: `release` -- engineBuildHash / SHA-256: - `1d4584fc0552a24ee840afa2cca9f1536d47429f467585d4d5c1a5236ba96dc9` +- engineBuildHash / SHA-256 observed in the current local checkout: + `f91091cb7feb788df340305a877a9cadb0c6f4d13aea8a7da4040b6367d178ea` - Loader SHA-256: `11a13f0414e7387f0c9502c8c0ca9479473505d94b824356d015a8d8007637fb` - Emscripten version: `3.1.56` @@ -79,13 +79,14 @@ pnpm exec nx build quickjs-runtime ## Gas/profile metadata -- Gas version from blue-quickjs documentation: `JS_GAS_VERSION_LATEST = 2` -- Execution profile from blue-quickjs documentation: deterministic Baseline #1 -- Current build metadata does not yet include explicit `gasVersion` or - `executionProfile` fields. The Java runtime must not silently infer them from - docs during normal evaluation; it must fail closed unless the generated/pinned - Java-side metadata includes these values or upstream metadata grows these - fields. +- Gas version from the current blue-quickjs metadata: `8` +- Execution profile pinned by the Java spike metadata bridge: `baseline-v1` +- Current upstream build metadata includes `gasVersion`, but still does not + include all fields the Java runtime wants to verify for release-mode + embedding, especially `executionProfile` and `abiManifestHash`. The + `quickjs-chicory` build enriches classpath metadata with those fields as a + temporary bridge. Long term, upstream blue-quickjs release artifacts should + publish them directly. ## Wasm imports @@ -162,6 +163,33 @@ Representative deterministic stress output: the generated blue-quickjs metadata observed during preflight. Runtime pinning is addressed by the Java module's generated `engine-metadata.json`, which enriches the upstream artifact metadata with: - - `gasVersion: 2` - - `executionProfile: blue-quickjs-deterministic-baseline-1` + - `gasVersion: ` + - `executionProfile: baseline-v1` - `abiManifestHash: e23b0b2ee169900bbde7aff78e6ce20fead1715c60f8a8e3106d9959450a3d34` + +## Hardening update + +Date: 2026-05-13 + +The Java/Chicory spike now fails closed for unpinned filesystem WASM artifacts: + +- filesystem resolution requires an explicit expected `engineBuildHash`; +- classpath-bundled resources must include `engineBuildHash`, + `abiManifestHash`, `gasVersion`, and `executionProfile`; +- wrong engine hash, Host.v1 hash, gas version, or execution profile fails + before evaluation; +- generated classpath metadata remains a temporary deterministic bridge until + upstream release metadata carries every required field. + +Node-vs-Chicory parity now treats gas equality as mandatory. For each fixture, +the report compares: + +- ok/error status; +- returned value; +- normalized VM error category/message; +- `wasmGasUsed`; +- `hostGasUsed`. + +Performance is explicitly not a parity signal. Benchmark reports record elapsed +time because it matters for Lambda sizing, but timing differences do not fail +tests. Gas differences always fail. diff --git a/docs/chicory-bluequickjs-test-results.md b/docs/chicory-bluequickjs-test-results.md index 7003137..2b0b04e 100644 --- a/docs/chicory-bluequickjs-test-results.md +++ b/docs/chicory-bluequickjs-test-results.md @@ -439,8 +439,8 @@ Outcome: Lambda-like Docker smoke note: -- `docker` is not installed in this VM, so the Java 17/21 Lambda container smoke - could not be executed here. +- The Java 17/21 Lambda container smoke could not be executed in the original + validation environment. - The no-Node PATH smoke above is the strongest local substitute performed in this environment. @@ -451,5 +451,194 @@ Lambda-like Docker smoke note: to `/blue-quickjs`, but `/` is not writable, so the available checkout is `/tmp/blue-quickjs`. The equivalent full clean validation with explicit `-Dblue.quickjs.root=/tmp/blue-quickjs` passed. -- Lambda-like Java 17 and Java 21 container smoke tests remain unexecuted because - Docker is not installed in this VM. +- Lambda-like Java 17 and Java 21 container smoke tests remain unexecuted in + this environment. + +## Hardening validation + +Date: 2026-05-13 + +Environment updates: + +- Java repo branch: `cursor/chicory-blue-quickjs-runtime-1606` +- blue-quickjs checkout: `/Users/piotr/data/blue-quickjs` +- selected engineBuildHash: + `f91091cb7feb788df340305a877a9cadb0c6f4d13aea8a7da4040b6367d178ea` +- gasVersion: `8` +- executionProfile: `baseline-v1` in generated Java-side metadata + +Command: + +```bash +./gradlew :quickjs-chicory:test \ + --tests '*BlueQuickJsResourceIntegrityTest' \ + --tests '*ChicoryDocumentHostTest' \ + --tests '*ChicoryHostCallAbiTest' \ + -PblueQuickJsRoot=/Users/piotr/data/blue-quickjs \ + -Dblue.quickjs.root=/Users/piotr/data/blue-quickjs +``` + +Outcome: + +- Passed. +- Covered fail-closed checks for missing filesystem engine hash, wrong engine + hash, wrong Host.v1 hash, wrong gas version, and wrong execution profile. +- Covered Host.v1 document get/canonical, JSON Pointer escaping, missing/invalid + pointer behavior, request/response limits, reentrant rejection, and internal + host failure containment. + +Command: + +```bash +./gradlew :quickjs-chicory:test \ + --tests '*ChicoryVsNodeParityTest' \ + -PblueQuickJsRoot=/Users/piotr/data/blue-quickjs \ + -Dblue.quickjs.root=/Users/piotr/data/blue-quickjs +``` + +Outcome: + +- Passed. +- Parity report: + `quickjs-chicory/build/reports/blue-quickjs-chicory-parity.json` +- The report compares ok/error status, value, normalized VM error + category/message, `wasmGasUsed`, and `hostGasUsed`. +- No gas mismatches remained in the expanded fixture set. + +Command: + +```bash +./gradlew :quickjs-chicory:test \ + --tests '*BlueQuickJsSourceWrapperTest' \ + --tests '*ChicoryForbiddenSurfaceTest' \ + --tests '*ChicoryOutOfGasTest' \ + --tests '*LambdaPackagingSmokeTest' \ + -PblueQuickJsRoot=/Users/piotr/data/blue-quickjs \ + -Dblue.quickjs.root=/Users/piotr/data/blue-quickjs +``` + +Outcome: + +- Passed. +- Confirmed source wrapping matches the Node bridge wrapper. +- Confirmed forbidden APIs and out-of-gas boundaries match the Node bridge. +- Confirmed classpath-bundled WASM resources work in a child JVM with + `PATH=/bin`, where `node` is not available. + +Command: + +```bash +./gradlew :quickjs-chicory:test \ + --tests '*ChicoryBenchmarkReportTest' \ + -PblueQuickJsRoot=/Users/piotr/data/blue-quickjs \ + -Dblue.quickjs.root=/Users/piotr/data/blue-quickjs +``` + +Outcome: + +- Passed. +- Benchmark report: + `quickjs-chicory/build/reports/blue-quickjs-chicory-benchmarks.json` +- Timing is reported only. Gas equality is asserted. +- Current local timing confirms the expected spike tradeoff: Chicory is much + slower with fresh WASM instances per evaluation, but gas matched exactly. + +Command: + +```bash +docker version +``` + +Outcome: + +- Docker client is installed, but the daemon is not reachable: + `Cannot connect to the Docker daemon at unix:///var/run/docker.sock`. +- Lambda-like Java 17/21 container smoke remains pending. + +Command: + +```bash +./gradlew :quickjs-chicory:test \ + -PblueQuickJsRoot=/Users/piotr/data/blue-quickjs \ + -Dblue.quickjs.root=/Users/piotr/data/blue-quickjs +``` + +Outcome: + +- Passed. +- Full optional module test suite: `BUILD SUCCESSFUL in 4m 56s`. + +## Final stabilization validation + +Date: 2026-05-14 + +Dependency update: + +- Root now resolves `blue.language:blue-language-java:2.0.0` from Maven. +- The resolved artifact exposes `ProcessorFatalException.partialResult()` and + `ProcessorFatalException.totalGas()`. +- Processor-level fatal parity tests use those public accessors directly; no + reflection-based fatal gas probe remains. + +Commands: + +```bash +./gradlew test \ + -PblueQuickJsRoot=/Users/piotr/data/blue-quickjs \ + -Dblue.quickjs.root=/Users/piotr/data/blue-quickjs + +./gradlew clean build \ + -PblueQuickJsRoot=/Users/piotr/data/blue-quickjs \ + -Dblue.quickjs.root=/Users/piotr/data/blue-quickjs + +./gradlew publishToMavenLocal \ + -PblueQuickJsRoot=/Users/piotr/data/blue-quickjs \ + -Dblue.quickjs.root=/Users/piotr/data/blue-quickjs + +./gradlew publish \ + -PblueQuickJsRoot=/Users/piotr/data/blue-quickjs \ + -Dblue.quickjs.root=/Users/piotr/data/blue-quickjs + +./gradlew :quickjs-chicory:dependencies --configuration runtimeClasspath + +./gradlew :quickjs-chicory:test \ + --tests '*LambdaPackagingSmokeTest' \ + -PblueQuickJsRoot=/Users/piotr/data/blue-quickjs \ + -Dblue.quickjs.root=/Users/piotr/data/blue-quickjs + +docker version + +docker run --rm --platform linux/amd64 \ + -v /Users/piotr/data/blue-contract-java:/workspace \ + -v /Users/piotr/data/blue-quickjs:/blue-quickjs:ro \ + -v /Users/piotr/.gradle:/root/.gradle \ + -w /workspace \ + amazon/aws-sam-cli-build-image-java11:latest \ + /bin/bash -lc './gradlew :quickjs-chicory:test --tests "*LambdaPackagingSmokeTest" -PblueQuickJsRoot=/blue-quickjs -Dblue.quickjs.root=/blue-quickjs' +``` + +Outcomes: + +- All Gradle verification commands above passed. +- `quickjs-chicory/build/reports/blue-quickjs-chicory-parity.json` reported + `status: passed`, `caseCount: 33`, and no mismatches. +- `quickjs-chicory/build/reports/blue-quickjs-chicory-benchmarks.json` was + generated as a timing report only; timing is not a pass/fail signal. +- `publishToMavenLocal` produced root and optional Chicory main, sources, and + javadoc jars. +- `publish` produced staged root and optional Chicory main, sources, javadoc, + and POM artifacts under `build/staging-deploy`. +- The root POM depends on `blue.language:blue-language-java:2.0.0`, + `blue.repo:blue-repo-java:1.2.0`, and Jackson; it does not depend on Chicory. +- The optional POM depends on `blue.contract:blue-contract-java`, + `com.dylibso.chicory:runtime`, and Jackson. +- Docker was initially blocked in the sandbox, but was reachable outside it + through Docker Desktop 4.73.0. +- The Java 11 AWS SAM Lambda build image smoke passed: + `BUILD SUCCESSFUL in 28s`. +- The container smoke ran `LambdaPackagingSmokeTest`, which evaluates + classpath-pinned Chicory without Node on `PATH` and checks for native JS + runtime dependency leakage. +- A full AWS Lambda runtime/RIE handler invocation smoke is still optional + follow-up work if release policy requires more than a Java process inside an + AWS Lambda Java image. diff --git a/quickjs-chicory/build.gradle b/quickjs-chicory/build.gradle index 19ea97f..8ba3124 100644 --- a/quickjs-chicory/build.gradle +++ b/quickjs-chicory/build.gradle @@ -1,5 +1,13 @@ plugins { id 'java-library' + id 'maven-publish' +} + +group = rootProject.group +version = rootProject.version + +base { + archivesName = 'blue-contract-java-quickjs-chicory' } repositories { @@ -12,6 +20,8 @@ repositories { java { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 + withSourcesJar() + withJavadocJar() } def generatedResourcesDir = layout.buildDirectory.dir('generated-resources') @@ -37,6 +47,10 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } +tasks.withType(GenerateModuleMetadata).configureEach { + enabled = false +} + tasks.register('copyBlueQuickJsWasmResources') { def configuredRoot = providers.gradleProperty('blueQuickJsRoot') .orElse(providers.systemProperty('blue.quickjs.root')) @@ -82,13 +96,18 @@ tasks.register('copyBlueQuickJsWasmResources') { if (digest != release.engineBuildHash || digest != release.wasm?.sha256 || digest != parsed.engineBuildHash) { throw new GradleException("Wasm hash mismatch for ${wasm}") } + if (!(parsed.gasVersion instanceof Number)) { + throw new GradleException('Metadata missing top-level gasVersion') + } def outDir = chicoryResourceDir.get().asFile outDir.mkdirs() new File(outDir, 'quickjs-eval.wasm').bytes = wasmBytes def enriched = new LinkedHashMap(parsed as Map) - enriched.gasVersion = 2 - enriched.executionProfile = 'blue-quickjs-deterministic-baseline-1' - enriched.abiManifestHash = 'e23b0b2ee169900bbde7aff78e6ce20fead1715c60f8a8e3106d9959450a3d34' + enriched.gasVersion = parsed.gasVersion + // Temporary bridge until upstream quickjs-wasm metadata publishes the + // execution profile and ABI manifest hash alongside gasVersion. + enriched.executionProfile = parsed.executionProfile ?: 'baseline-v1' + enriched.abiManifestHash = parsed.abiManifestHash ?: 'e23b0b2ee169900bbde7aff78e6ce20fead1715c60f8a8e3106d9959450a3d34' new File(outDir, 'engine-metadata.json').text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(enriched)) + '\n' new File(outDir, 'host-v1-hash.txt').text = 'e23b0b2ee169900bbde7aff78e6ce20fead1715c60f8a8e3106d9959450a3d34\n' def manifestHex = 'a3666162695f696467486f73742e76316966756e6374696f6e7383a963676173a5646261736514676b5f756e697473016b6b5f6172675f6279746573016b6b5f7265745f6279746573016b7363686564756c655f69646b646f632d726561642d76316561726974790165666e5f696401666566666563746452454144666c696d697473a4696d61785f756e6974731903e86c6172675f757466385f6d617881190800716d61785f726571756573745f6279746573191000726d61785f726573706f6e73655f62797465731a00040000676a735f706174688268646f63756d656e74636765746a6172675f736368656d6181a1647479706566737472696e676b6572726f725f636f64657383a26374616771686f73742f696e76616c69645f7061746864636f64656c494e56414c49445f50415448a2637461676a686f73742f6c696d697464636f64656e4c494d49545f4558434545444544a2637461676e686f73742f6e6f745f666f756e6464636f6465694e4f545f464f554e446d72657475726e5f736368656d61a16474797065626476a963676173a5646261736514676b5f756e697473016b6b5f6172675f6279746573016b6b5f7265745f6279746573016b7363686564756c655f69646b646f632d726561642d76316561726974790165666e5f696402666566666563746452454144666c696d697473a4696d61785f756e6974731903e86c6172675f757466385f6d617881190800716d61785f726571756573745f6279746573191000726d61785f726573706f6e73655f62797465731a00040000676a735f706174688268646f63756d656e746c67657443616e6f6e6963616c6a6172675f736368656d6181a1647479706566737472696e676b6572726f725f636f64657383a26374616771686f73742f696e76616c69645f7061746864636f64656c494e56414c49445f50415448a2637461676a686f73742f6c696d697464636f64656e4c494d49545f4558434545444544a2637461676e686f73742f6e6f745f666f756e6464636f6465694e4f545f464f554e446d72657475726e5f736368656d61a16474797065626476a963676173a5646261736505676b5f756e697473016b6b5f6172675f6279746573016b6b5f7265745f6279746573006b7363686564756c655f696467656d69742d76316561726974790165666e5f6964036665666665637464454d4954666c696d697473a3696d61785f756e697473190400716d61785f726571756573745f6279746573198000726d61785f726573706f6e73655f62797465731840676a735f706174688164656d69746a6172675f736368656d6181a164747970656264766b6572726f725f636f64657381a2637461676a686f73742f6c696d697464636f64656e4c494d49545f45584345454445446d72657475726e5f736368656d61a16474797065646e756c6c6b6162695f76657273696f6e01' @@ -104,6 +123,12 @@ tasks.named('processResources') { } } +tasks.named('sourcesJar') { + if (project.hasProperty('blueQuickJsRoot') || System.getProperty('blue.quickjs.root')) { + dependsOn tasks.named('copyBlueQuickJsWasmResources') + } +} + test { useJUnitPlatform() if (System.getProperty('blue.quickjs.root')) { @@ -117,3 +142,48 @@ test { showStandardStreams = true } } + +publishing { + publications { + maven(MavenPublication) { + groupId = 'blue.contract' + artifactId = 'blue-contract-java-quickjs-chicory' + from components.java + + pom { + name = 'Blue Contract Java QuickJS Chicory Runtime' + description = 'Experimental optional JVM-only Chicory runtime for blue-quickjs.' + url = 'https://language.blue' + licenses { + license { + name = 'MIT license' + url = 'https://github.com/bluecontract/blue-contract-java/blob/main/LICENSE' + } + } + developers { + developer { + name = 'Blue' + email = 'devsupport@timeline.blue' + } + } + scm { + url = 'https://github.com/bluecontract/blue-contract-java.git' + connection = 'scm:git:git@github.com:bluecontract/blue-contract-java.git' + developerConnection = 'scm:git:git@github.com:bluecontract/blue-contract-java.git' + } + } + } + } + + repositories { + maven { + url = rootProject.layout.buildDirectory.dir('staging-deploy') + } + if (!System.getenv('CI')) { + maven { + name = 'local' + url = uri('file:///' + new File(System.getProperty('user.home'), '.m2/repository').absolutePath) + } + } + } +} diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsHostDispatcher.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsHostDispatcher.java index 9180ab2..6f8b04c 100644 --- a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsHostDispatcher.java +++ b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsHostDispatcher.java @@ -46,7 +46,7 @@ public synchronized DispatchResult dispatch(int fnId, byte[] requestBytes) { try { return dispatchInternal(fnId, requestBytes); } catch (RuntimeException ex) { - return DispatchResult.fatal("host dispatcher threw: " + ex.getMessage()); + return DispatchResult.fatal("host dispatcher failed"); } finally { inProgress = false; } @@ -93,6 +93,9 @@ private DispatchResult dispatchInternal(int fnId, byte[] requestBytes) { } private DispatchResult handleDocument(FunctionSpec spec, Object pointer, boolean canonical) { + if (!(pointer instanceof String)) { + return DispatchResult.fatal("fn_id=" + spec.fnId + " expected string path argument"); + } if (pointer instanceof String && ((String) pointer).getBytes(StandardCharsets.UTF_8).length > spec.argUtf8Max) { return encodeLimit(spec); diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmResources.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmResources.java index f3e6f1d..e4b85ae 100644 --- a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmResources.java +++ b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmResources.java @@ -21,6 +21,7 @@ public final class BlueQuickJsWasmResources { public static final String CANONICAL_WASM_FILENAME = "quickjs-eval.wasm"; public static final String METADATA_FILENAME = "quickjs-wasm-build.metadata.json"; + public static final String CLASSPATH_METADATA_FILENAME = "engine-metadata.json"; private static final ObjectMapper JSON = new ObjectMapper(); private static final byte[] WASM_MAGIC = new byte[]{0x00, 0x61, 0x73, 0x6d}; @@ -111,12 +112,15 @@ public static BlueQuickJsWasmResources resolve(BlueQuickJsWasmRuntimeConfig conf if (!engineBuildHash.equals(topLevelEngineBuildHash)) { throw new BlueQuickJsDeterminismException("top-level engineBuildHash does not match selected engineBuildHash"); } - if (config.expectedEngineBuildHash() != null && !engineBuildHash.equals(config.expectedEngineBuildHash())) { + if (config.expectedEngineBuildHash() == null) { + throw new BlueQuickJsDeterminismException("expected engineBuildHash is required for filesystem wasm resources"); + } + if (!engineBuildHash.equals(config.expectedEngineBuildHash())) { throw new BlueQuickJsDeterminismException("engineBuildHash mismatch: expected=" + config.expectedEngineBuildHash() + ", actual=" + engineBuildHash); } verifyMetadataShape(metadata); - String abiManifestHash = verifyAbiManifestHash(config); + PinMetadata pins = verifyPinMetadata(metadata, config, true); WasmModuleShape shape = WasmModuleShape.parse(wasmBytes); verifyImports(shape.imports); verifyExports(shape.exports); @@ -126,9 +130,9 @@ public static BlueQuickJsWasmResources resolve(BlueQuickJsWasmRuntimeConfig conf wasmBytes, metadata, engineBuildHash, - abiManifestHash, - config.expectedGasVersion(), - config.expectedExecutionProfile(), + pins.abiManifestHash, + pins.gasVersion, + pins.executionProfile, shape.imports, shape.exports); } @@ -136,7 +140,7 @@ public static BlueQuickJsWasmResources resolve(BlueQuickJsWasmRuntimeConfig conf private static BlueQuickJsWasmResources resolveClasspath(BlueQuickJsWasmRuntimeConfig config) { String base = "/blue/contract/processor/quickjs/chicory/"; try (InputStream wasmInput = BlueQuickJsWasmResources.class.getResourceAsStream(base + CANONICAL_WASM_FILENAME); - InputStream metadataInput = BlueQuickJsWasmResources.class.getResourceAsStream(base + "engine-metadata.json")) { + InputStream metadataInput = BlueQuickJsWasmResources.class.getResourceAsStream(base + CLASSPATH_METADATA_FILENAME)) { if (wasmInput == null || metadataInput == null) { return null; } @@ -144,36 +148,33 @@ private static BlueQuickJsWasmResources resolveClasspath(BlueQuickJsWasmRuntimeC verifyMagic(wasmBytes, Paths.get("classpath:" + base + CANONICAL_WASM_FILENAME)); JsonNode metadata = JSON.readTree(metadataInput); JsonNode selected = selectedVariant(metadata, config); + String metadataWasmFilename = requiredText(selected.at("/wasm/filename"), + "metadata variants." + config.expectedVariant() + "." + config.expectedBuildType() + ".wasm.filename"); + if (!CANONICAL_WASM_FILENAME.equals(metadataWasmFilename)) { + throw new BlueQuickJsDeterminismException("selected classpath wasm is not canonical " + + CANONICAL_WASM_FILENAME + ": " + metadataWasmFilename); + } String wasmSha256 = sha256Hex(wasmBytes); String metadataSha256 = requiredHex(selected.at("/wasm/sha256"), "metadata wasm sha256"); String engineBuildHash = requiredHex(selected.get("engineBuildHash"), "metadata engineBuildHash"); if (!wasmSha256.equals(metadataSha256) || !wasmSha256.equals(engineBuildHash)) { throw new BlueQuickJsDeterminismException("classpath wasm hash mismatch"); } + String topLevelEngineBuildHash = requiredHex(metadata.get("engineBuildHash"), "metadata top-level engineBuildHash"); + if (!engineBuildHash.equals(topLevelEngineBuildHash)) { + throw new BlueQuickJsDeterminismException("top-level engineBuildHash does not match selected engineBuildHash"); + } if (config.expectedEngineBuildHash() != null && !engineBuildHash.equals(config.expectedEngineBuildHash())) { throw new BlueQuickJsDeterminismException("engineBuildHash mismatch: expected=" + config.expectedEngineBuildHash() + ", actual=" + engineBuildHash); } verifyMetadataShape(metadata); - int gasVersion = requiredInt(metadata.get("gasVersion"), "gasVersion"); - if (gasVersion != config.expectedGasVersion()) { - throw new BlueQuickJsDeterminismException("gasVersion mismatch: expected=" - + config.expectedGasVersion() + ", actual=" + gasVersion); - } - String executionProfile = requiredText(metadata.get("executionProfile"), "executionProfile"); - if (!config.expectedExecutionProfile().equals(executionProfile)) { - throw new BlueQuickJsDeterminismException("executionProfile mismatch: expected=" - + config.expectedExecutionProfile() + ", actual=" + executionProfile); - } - String abiManifestHash = requiredText(metadata.get("abiManifestHash"), "abiManifestHash"); - if (!HostV1Manifest.HOST_V1_HASH.equals(abiManifestHash)) { - throw new BlueQuickJsDeterminismException("ABI manifest hash mismatch in classpath metadata"); - } + PinMetadata pins = verifyPinMetadata(metadata, config, true); WasmModuleShape shape = WasmModuleShape.parse(wasmBytes); verifyImports(shape.imports); verifyExports(shape.exports); return new BlueQuickJsWasmResources(null, null, null, wasmBytes, metadata, - engineBuildHash, abiManifestHash, gasVersion, executionProfile, shape.imports, shape.exports); + engineBuildHash, pins.abiManifestHash, pins.gasVersion, pins.executionProfile, shape.imports, shape.exports); } catch (IOException ex) { throw new BlueQuickJsResourceException("failed to read classpath blue-quickjs resources", ex); } @@ -360,17 +361,56 @@ private static void requireFlag(JsonNode flags, String expected) { throw new BlueQuickJsDeterminismException("metadata missing deterministic flag " + expected); } - private static String verifyAbiManifestHash(BlueQuickJsWasmRuntimeConfig config) { - String actual = HostV1Manifest.HOST_V1_HASH; + private static PinMetadata verifyPinMetadata(JsonNode metadata, + BlueQuickJsWasmRuntimeConfig config, + boolean requireMetadataFields) { + int gasVersion; + if (metadata.has("gasVersion")) { + gasVersion = requiredInt(metadata.get("gasVersion"), "gasVersion"); + } else if (requireMetadataFields) { + throw new BlueQuickJsDeterminismException("required metadata integer missing: gasVersion"); + } else { + gasVersion = config.expectedGasVersion(); + } + if (gasVersion != config.expectedGasVersion()) { + throw new BlueQuickJsDeterminismException("gasVersion mismatch: expected=" + + config.expectedGasVersion() + ", actual=" + gasVersion); + } + + String executionProfile; + if (metadata.has("executionProfile")) { + executionProfile = requiredText(metadata.get("executionProfile"), "executionProfile"); + } else if (requireMetadataFields) { + throw new BlueQuickJsDeterminismException("required metadata field missing or non-text: executionProfile"); + } else { + executionProfile = config.expectedExecutionProfile(); + } + if (!config.expectedExecutionProfile().equals(executionProfile)) { + throw new BlueQuickJsDeterminismException("executionProfile mismatch: expected=" + + config.expectedExecutionProfile() + ", actual=" + executionProfile); + } + + String abiManifestHash; + if (metadata.has("abiManifestHash")) { + abiManifestHash = requiredHex(metadata.get("abiManifestHash"), "abiManifestHash"); + } else if (requireMetadataFields) { + throw new BlueQuickJsDeterminismException("required metadata field missing or non-text: abiManifestHash"); + } else { + abiManifestHash = HostV1Manifest.HOST_V1_HASH; + } String expected = config.expectedAbiManifestHash(); if (expected == null || expected.trim().isEmpty()) { throw new BlueQuickJsDeterminismException("expected ABI manifest hash is required"); } - if (!actual.equals(expected)) { + if (!HostV1Manifest.HOST_V1_HASH.equals(abiManifestHash)) { + throw new BlueQuickJsDeterminismException("ABI manifest hash mismatch in metadata: expected=" + + HostV1Manifest.HOST_V1_HASH + ", actual=" + abiManifestHash); + } + if (!abiManifestHash.equals(expected)) { throw new BlueQuickJsDeterminismException("ABI manifest hash mismatch: expected=" - + expected + ", actual=" + actual); + + expected + ", actual=" + abiManifestHash); } - return actual; + return new PinMetadata(abiManifestHash, gasVersion, executionProfile); } private static void verifyImports(List imports) { @@ -441,6 +481,18 @@ static String sha256Hex(byte[] bytes) { } } + private static final class PinMetadata { + private final String abiManifestHash; + private final int gasVersion; + private final String executionProfile; + + private PinMetadata(String abiManifestHash, int gasVersion, String executionProfile) { + this.abiManifestHash = abiManifestHash; + this.gasVersion = gasVersion; + this.executionProfile = executionProfile; + } + } + public static final class WasmImport { private final String module; private final String name; diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmRuntimeConfig.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmRuntimeConfig.java index 3bf89d7..b202bcd 100644 --- a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmRuntimeConfig.java +++ b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmRuntimeConfig.java @@ -5,8 +5,8 @@ public final class BlueQuickJsWasmRuntimeConfig { public static final String BLUE_QUICKJS_ROOT_PROPERTY = "blue.quickjs.root"; public static final String ENGINE_BUILD_HASH_PROPERTY = "blue.quickjs.engineBuildHash"; - public static final int DEFAULT_GAS_VERSION = 2; - public static final String DEFAULT_EXECUTION_PROFILE = "blue-quickjs-deterministic-baseline-1"; + public static final int DEFAULT_GAS_VERSION = 8; + public static final String DEFAULT_EXECUTION_PROFILE = "baseline-v1"; private final Path blueQuickJsRoot; private final String expectedEngineBuildHash; diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntime.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntime.java index 44f7ad7..1410147 100644 --- a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntime.java +++ b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntime.java @@ -11,6 +11,7 @@ public final class ChicoryBlueQuickJsRuntime implements JavaScriptRuntime, AutoCloseable { private final BlueQuickJsWasmRuntimeConfig config; + private BlueQuickJsWasmResources resources; public ChicoryBlueQuickJsRuntime() { this(BlueQuickJsWasmRuntimeConfig.defaultConfig()); @@ -23,13 +24,20 @@ public ChicoryBlueQuickJsRuntime(BlueQuickJsWasmRuntimeConfig config) { this.config = config; } + public static ChicoryBlueQuickJsRuntime fromClasspathDefaults() { + return new ChicoryBlueQuickJsRuntime(BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(null) + .preferClasspathResources(true) + .build()); + } + @Override public JavaScriptEvaluationResult evaluate(JavaScriptEvaluationRequest request) { if (request == null) { throw new IllegalArgumentException("request must not be null"); } long wasmGasLimit = QuickJsGas.toWasmFuel(request.hostGasLimit()); - BlueQuickJsWasmResources resources = BlueQuickJsWasmResources.resolve(config); + BlueQuickJsWasmResources resources = resources(); Map bindings = request.bindings(); BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(bindings); byte[] contextBlob = DeterministicValueCodec.encode(contextEnvelope(bindings)); @@ -39,7 +47,10 @@ public JavaScriptEvaluationResult evaluate(JavaScriptEvaluationRequest request) instance.initialize(HostV1Manifest.bytes(), HostV1Manifest.HOST_V1_HASH, contextBlob, wasmGasLimit); BlueQuickJsResultParser.ParsedResult parsed = BlueQuickJsResultParser.parse(instance.eval(source)); if (!parsed.ok()) { - throw new JavaScriptExecutionException(normalizeVmError(parsed.errorMessage())); + long hostGasUsed = QuickJsGas.toHostGasUsed(parsed.wasmGasUsed()); + throw new JavaScriptExecutionException(normalizeVmError(parsed.errorMessage()), + parsed.wasmGasUsed(), + hostGasUsed); } return new JavaScriptEvaluationResult(parsed.value(), parsed.wasmGasUsed(), @@ -56,6 +67,13 @@ public void close() { // Fresh Wasm instances are used per evaluation for now. } + private synchronized BlueQuickJsWasmResources resources() { + if (resources == null) { + resources = BlueQuickJsWasmResources.resolve(config); + } + return resources; + } + private static Map contextEnvelope(Map bindings) { Map source = bindings == null ? Collections.emptyMap() : bindings; Map envelope = new LinkedHashMap(); @@ -76,11 +94,18 @@ private static String normalizeVmError(String message) { if ("OutOfGas: out of gas".equals(message)) { return "vm-error: out of gas"; } - if (message != null - && message.startsWith("TypeError: ") - && message.contains("disabled in deterministic mode")) { + if (message != null && message.startsWith("TypeError: ")) { return "vm-error: " + message.substring("TypeError: ".length()); } + if (message != null && message.startsWith("SyntaxError: ")) { + return "vm-error: " + message.substring("SyntaxError: ".length()); + } + if (message != null && message.startsWith("Error: ")) { + return "vm-error: " + message.substring("Error: ".length()); + } + if (message != null && message.startsWith("ReferenceError: ")) { + return "vm-error: " + message.substring("ReferenceError: ".length()); + } if (message != null && message.startsWith("vm-error: ")) { return message; } diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceIntegrityTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceIntegrityTest.java index 6ad35e1..c1b3f1e 100644 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceIntegrityTest.java +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceIntegrityTest.java @@ -1,10 +1,14 @@ package blue.contract.processor.conversation.javascript.chicory; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.util.HashSet; import java.util.Set; +import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -14,22 +18,24 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; class BlueQuickJsResourceIntegrityTest { + private static final ObjectMapper JSON = new ObjectMapper(); @Test - void canonicalWasmResourceIsPresentAndPinned() { - Path root = blueQuickJsRoot(); + void canonicalWasmResourceIsPresentAndPinned(@TempDir Path tempDir) throws IOException { + Path root = pinnedFilesystemFixture(blueQuickJsRoot(), tempDir); BlueQuickJsWasmResources resources = BlueQuickJsWasmResources.resolve( BlueQuickJsWasmRuntimeConfig.builder() .blueQuickJsRoot(root) .preferClasspathResources(false) + .expectedEngineBuildHash(ChicoryTestSupport.engineBuildHash(root)) + .expectedGasVersion(ChicoryTestSupport.gasVersion(root)) .build()); assertTrue(Files.isRegularFile(resources.wasmPath())); assertTrue(resources.wasmPath().getFileName().toString().equals(BlueQuickJsWasmResources.CANONICAL_WASM_FILENAME)); - assertEquals("1d4584fc0552a24ee840afa2cca9f1536d47429f467585d4d5c1a5236ba96dc9", - resources.engineBuildHash()); + assertEquals(ChicoryTestSupport.engineBuildHash(root), resources.engineBuildHash()); assertEquals(HostV1Manifest.HOST_V1_HASH, resources.abiManifestHash()); - assertEquals(BlueQuickJsWasmRuntimeConfig.DEFAULT_GAS_VERSION, resources.gasVersion()); + assertEquals(ChicoryTestSupport.gasVersion(root), resources.gasVersion()); assertEquals(BlueQuickJsWasmRuntimeConfig.DEFAULT_EXECUTION_PROFILE, resources.executionProfile()); assertEquals("wasm32", resources.metadata().path("variants").fieldNames().next()); assertEquals("release", resources.metadata().path("variants").path("wasm32").path("release").path("buildType").asText()); @@ -38,11 +44,27 @@ void canonicalWasmResourceIsPresentAndPinned() { } @Test - void importsContainOnlyApprovedDeterministicSurface() { + void classpathBundledResourceMetadataIsSelfPinned() { BlueQuickJsWasmResources resources = BlueQuickJsWasmResources.resolve( BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(blueQuickJsRoot()) + .blueQuickJsRoot(null) + .preferClasspathResources(true) + .build()); + + assertEquals(HostV1Manifest.HOST_V1_HASH, resources.abiManifestHash()); + assertEquals(BlueQuickJsWasmRuntimeConfig.DEFAULT_GAS_VERSION, resources.gasVersion()); + assertEquals(BlueQuickJsWasmRuntimeConfig.DEFAULT_EXECUTION_PROFILE, resources.executionProfile()); + assertEquals(resources.metadata().path("engineBuildHash").asText(), resources.engineBuildHash()); + } + + @Test + void importsContainOnlyApprovedDeterministicSurface(@TempDir Path tempDir) throws IOException { + Path root = pinnedFilesystemFixture(blueQuickJsRoot(), tempDir); + BlueQuickJsWasmResources resources = BlueQuickJsWasmResources.resolve( + BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(root) .preferClasspathResources(false) + .expectedEngineBuildHash(ChicoryTestSupport.engineBuildHash(root)) .build()); Set imports = new HashSet(); for (BlueQuickJsWasmResources.WasmImport wasmImport : resources.imports()) { @@ -64,11 +86,13 @@ void importsContainOnlyApprovedDeterministicSurface() { } @Test - void requiredExportsArePresent() { + void requiredExportsArePresent(@TempDir Path tempDir) throws IOException { + Path root = pinnedFilesystemFixture(blueQuickJsRoot(), tempDir); BlueQuickJsWasmResources resources = BlueQuickJsWasmResources.resolve( BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(blueQuickJsRoot()) + .blueQuickJsRoot(root) .preferClasspathResources(false) + .expectedEngineBuildHash(ChicoryTestSupport.engineBuildHash(root)) .build()); Set exports = new HashSet(); for (BlueQuickJsWasmResources.WasmExport wasmExport : resources.exports()) { @@ -85,11 +109,25 @@ void requiredExportsArePresent() { } @Test - void wrongExpectedEngineHashFailsClosed() { + void missingExpectedEngineHashForFilesystemRuntimeFailsClosed(@TempDir Path tempDir) throws IOException { + Path root = pinnedFilesystemFixture(blueQuickJsRoot(), tempDir); + BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, + () -> BlueQuickJsWasmResources.resolve( + BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(root) + .preferClasspathResources(false) + .build())); + + assertTrue(ex.getMessage().contains("expected engineBuildHash is required")); + } + + @Test + void wrongExpectedEngineHashFailsClosed(@TempDir Path tempDir) throws IOException { + Path root = pinnedFilesystemFixture(blueQuickJsRoot(), tempDir); BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, () -> BlueQuickJsWasmResources.resolve( BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(blueQuickJsRoot()) + .blueQuickJsRoot(root) .preferClasspathResources(false) .expectedEngineBuildHash("0000000000000000000000000000000000000000000000000000000000000000") .build())); @@ -97,12 +135,187 @@ void wrongExpectedEngineHashFailsClosed() { assertTrue(ex.getMessage().contains("engineBuildHash mismatch")); } + @Test + void missingFilesystemEngineHashFailsClosed(@TempDir Path tempDir) throws IOException { + Path root = filesystemFixture(blueQuickJsRoot(), tempDir, metadata -> metadata.remove("engineBuildHash")); + + BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, + () -> BlueQuickJsWasmResources.resolve( + BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(root) + .preferClasspathResources(false) + .expectedEngineBuildHash(ChicoryTestSupport.engineBuildHash(blueQuickJsRoot())) + .build())); + + assertTrue(ex.getMessage().contains("engineBuildHash")); + } + + @Test + void missingFilesystemGasVersionFailsClosed(@TempDir Path tempDir) throws IOException { + Path root = filesystemFixture(blueQuickJsRoot(), tempDir, metadata -> metadata.remove("gasVersion")); + + BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, + () -> BlueQuickJsWasmResources.resolve( + BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(root) + .preferClasspathResources(false) + .expectedEngineBuildHash(ChicoryTestSupport.engineBuildHash(root)) + .build())); + + assertTrue(ex.getMessage().contains("gasVersion")); + } + + @Test + void missingFilesystemExecutionProfileFailsClosed(@TempDir Path tempDir) throws IOException { + Path root = filesystemFixture(blueQuickJsRoot(), tempDir, metadata -> metadata.remove("executionProfile")); + + BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, + () -> BlueQuickJsWasmResources.resolve( + BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(root) + .preferClasspathResources(false) + .expectedEngineBuildHash(ChicoryTestSupport.engineBuildHash(root)) + .build())); + + assertTrue(ex.getMessage().contains("executionProfile")); + } + + @Test + void missingFilesystemAbiManifestHashFailsClosed(@TempDir Path tempDir) throws IOException { + Path root = filesystemFixture(blueQuickJsRoot(), tempDir, metadata -> metadata.remove("abiManifestHash")); + + BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, + () -> BlueQuickJsWasmResources.resolve( + BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(root) + .preferClasspathResources(false) + .expectedEngineBuildHash(ChicoryTestSupport.engineBuildHash(root)) + .build())); + + assertTrue(ex.getMessage().contains("abiManifestHash")); + } + + @Test + void wrongFilesystemExecutionProfileFailsClosed(@TempDir Path tempDir) throws IOException { + Path root = filesystemFixture(blueQuickJsRoot(), tempDir, + metadata -> metadata.put("executionProfile", "different-profile")); + + BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, + () -> BlueQuickJsWasmResources.resolve( + BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(root) + .preferClasspathResources(false) + .expectedEngineBuildHash(ChicoryTestSupport.engineBuildHash(root)) + .build())); + + assertTrue(ex.getMessage().contains("executionProfile mismatch")); + } + + @Test + void filesystemWasmHashMismatchFailsClosed(@TempDir Path tempDir) throws IOException { + Path root = filesystemFixture(blueQuickJsRoot(), tempDir, metadata -> ((ObjectNode) metadata + .path("variants") + .path("wasm32") + .path("release") + .path("wasm")) + .put("sha256", "0000000000000000000000000000000000000000000000000000000000000000")); + + BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, + () -> BlueQuickJsWasmResources.resolve( + BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(root) + .preferClasspathResources(false) + .expectedEngineBuildHash(ChicoryTestSupport.engineBuildHash(root)) + .build())); + + assertTrue(ex.getMessage().contains("wasm sha256 mismatch")); + } + + @Test + void wrongHostV1HashFailsClosed() { + BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, + () -> BlueQuickJsWasmResources.resolve( + BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(null) + .preferClasspathResources(true) + .expectedAbiManifestHash("0000000000000000000000000000000000000000000000000000000000000000") + .build())); + + assertTrue(ex.getMessage().contains("ABI manifest hash mismatch")); + } + + @Test + void wrongGasVersionFailsClosed() { + BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, + () -> BlueQuickJsWasmResources.resolve( + BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(null) + .preferClasspathResources(true) + .expectedGasVersion(BlueQuickJsWasmRuntimeConfig.DEFAULT_GAS_VERSION + 1) + .build())); + + assertTrue(ex.getMessage().contains("gasVersion mismatch")); + } + + @Test + void wrongExecutionProfileFailsClosed() { + BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, + () -> BlueQuickJsWasmResources.resolve( + BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(null) + .preferClasspathResources(true) + .expectedExecutionProfile("compat-general-v1") + .build())); + + assertTrue(ex.getMessage().contains("executionProfile mismatch")); + } + private static Path blueQuickJsRoot() { - String configured = System.getProperty("blue.quickjs.root"); - Path root = configured == null || configured.trim().isEmpty() - ? Paths.get(System.getProperty("user.dir")).toAbsolutePath().getParent().resolve("blue-quickjs") - : Paths.get(configured); - assumeTrue(Files.isDirectory(root), "blue-quickjs checkout is required for resource integrity tests"); - return root; + return ChicoryTestSupport.blueQuickJsRoot("blue-quickjs checkout is required for resource integrity tests"); + } + + private static Path pinnedFilesystemFixture(Path sourceRoot, Path tempDir) throws IOException { + return filesystemFixture(sourceRoot, tempDir, metadata -> { + }); + } + + private static Path filesystemFixture(Path sourceRoot, + Path tempDir, + MetadataMutation mutation) throws IOException { + Path fixtureRoot = tempDir.resolve("blue-quickjs"); + Path wasmDir = fixtureRoot.resolve("libs/quickjs-wasm/dist/wasm"); + Files.createDirectories(wasmDir); + Files.copy(sourceWasm(sourceRoot), + wasmDir.resolve(BlueQuickJsWasmResources.CANONICAL_WASM_FILENAME), + StandardCopyOption.REPLACE_EXISTING); + + ObjectNode metadata = (ObjectNode) JSON.readTree(sourceMetadata(sourceRoot).toFile()); + metadata.put("executionProfile", BlueQuickJsWasmRuntimeConfig.DEFAULT_EXECUTION_PROFILE); + metadata.put("abiManifestHash", HostV1Manifest.HOST_V1_HASH); + mutation.mutate(metadata); + JSON.writerWithDefaultPrettyPrinter().writeValue( + wasmDir.resolve(BlueQuickJsWasmResources.METADATA_FILENAME).toFile(), + metadata); + return fixtureRoot; + } + + private static Path sourceWasm(Path root) { + Path wasm = root.resolve("libs/quickjs-wasm/dist/wasm").resolve(BlueQuickJsWasmResources.CANONICAL_WASM_FILENAME); + assumeTrue(Files.isRegularFile(wasm), "canonical wasm is required for resource integrity tests"); + return wasm; + } + + private static Path sourceMetadata(Path root) { + Path metadata = root.resolve("libs/quickjs-wasm/dist/wasm").resolve(BlueQuickJsWasmResources.METADATA_FILENAME); + if (Files.isRegularFile(metadata)) { + return metadata; + } + metadata = root.resolve("libs/quickjs-wasm-build/dist").resolve(BlueQuickJsWasmResources.METADATA_FILENAME); + assumeTrue(Files.isRegularFile(metadata), "wasm metadata is required for resource integrity tests"); + return metadata; + } + + private interface MetadataMutation { + void mutate(ObjectNode metadata); } } diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBenchmarkReportTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBenchmarkReportTest.java new file mode 100644 index 0000000..2f5c5e6 --- /dev/null +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBenchmarkReportTest.java @@ -0,0 +1,156 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; +import blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult; +import blue.contract.processor.conversation.javascript.JavaScriptRuntime; +import blue.contract.processor.conversation.javascript.NodeQuickJsRuntime; +import blue.contract.processor.conversation.javascript.QuickJsGas; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ChicoryBenchmarkReportTest { + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + void writesNodeAndChicoryBenchmarkReportWithoutTimingAssertions() throws IOException { + Path root = ChicoryTestSupport.blueQuickJsRoot("blue-quickjs checkout is required for benchmark report tests"); + List> results = new ArrayList>(); + + ChicoryBlueQuickJsRuntime chicory = new ChicoryBlueQuickJsRuntime(ChicoryTestSupport.pinnedConfig(root)); + try (NodeQuickJsRuntime node = new NodeQuickJsRuntime(root)) { + BenchmarkCase hundred = benchmarkCase("100 arithmetic iterations", 100, + expression("1 + 2", Collections.emptyMap())); + BenchmarkCase simple = benchmarkCase("1000 simple expressions", 1000, + expression("1 + 2", Collections.emptyMap())); + BenchmarkCase documentRead = benchmarkCase("100 document-read expressions", 100, + expression("document('/counter')", documentBindings())); + + for (BenchmarkCase benchmark : new BenchmarkCase[]{hundred, simple, documentRead}) { + BenchmarkResult nodeResult = run("Node bridge", node, benchmark); + BenchmarkResult chicoryResult = run("Chicory", chicory, benchmark); + assertEquals(nodeResult.minGas, chicoryResult.minGas, benchmark.name + " minGas"); + assertEquals(nodeResult.maxGas, chicoryResult.maxGas, benchmark.name + " maxGas"); + assertEquals(nodeResult.totalGas, chicoryResult.totalGas, benchmark.name + " totalGas"); + results.add(nodeResult.toMap()); + results.add(chicoryResult.toMap()); + } + } + + Path reportPath = ChicoryTestSupport.reportPath("blue-quickjs-chicory-benchmarks.json"); + Files.createDirectories(reportPath.getParent()); + Map report = new LinkedHashMap(); + report.put("engineBuildHash", ChicoryTestSupport.engineBuildHash(root)); + report.put("gasVersion", ChicoryTestSupport.gasVersion(root)); + report.put("results", results); + JSON.writerWithDefaultPrettyPrinter().writeValue(reportPath.toFile(), report); + } + + private static JavaScriptEvaluationRequest expression(String code, Map bindings) { + return new JavaScriptEvaluationRequest(code, + JavaScriptEvaluationRequest.Mode.EXPRESSION, + bindings, + QuickJsGas.DEFAULT_EXPRESSION_HOST_GAS_LIMIT); + } + + private static BenchmarkCase benchmarkCase(String name, + int iterations, + JavaScriptEvaluationRequest request) { + return new BenchmarkCase(name, iterations, request); + } + + private static BenchmarkResult run(String runtime, + JavaScriptRuntime evaluator, + BenchmarkCase benchmark) { + long started = System.nanoTime(); + long minGas = Long.MAX_VALUE; + long maxGas = 0L; + long totalGas = 0L; + for (int i = 0; i < benchmark.iterations; i++) { + JavaScriptEvaluationResult result = evaluator.evaluate(benchmark.request); + minGas = Math.min(minGas, result.wasmGasUsed()); + maxGas = Math.max(maxGas, result.wasmGasUsed()); + totalGas += result.wasmGasUsed(); + } + long elapsedMillis = (System.nanoTime() - started) / 1000000L; + return new BenchmarkResult(runtime, + benchmark.name, + benchmark.iterations, + elapsedMillis, + minGas, + maxGas, + totalGas); + } + + private static Map documentBindings() { + Map counter = new LinkedHashMap(); + counter.put("value", 6); + Map document = new LinkedHashMap(); + document.put("counter", counter); + Map bindings = new LinkedHashMap(); + bindings.put("document", document); + bindings.put("documentCanonical", document); + bindings.put("documentMetadata", Collections.emptyMap()); + return bindings; + } + + private static final class BenchmarkCase { + private final String name; + private final int iterations; + private final JavaScriptEvaluationRequest request; + + private BenchmarkCase(String name, int iterations, JavaScriptEvaluationRequest request) { + this.name = name; + this.iterations = iterations; + this.request = request; + } + } + + private static final class BenchmarkResult { + private final String runtime; + private final String scenario; + private final int iterations; + private final long elapsedMillis; + private final long minGas; + private final long maxGas; + private final long totalGas; + + private BenchmarkResult(String runtime, + String scenario, + int iterations, + long elapsedMillis, + long minGas, + long maxGas, + long totalGas) { + this.runtime = runtime; + this.scenario = scenario; + this.iterations = iterations; + this.elapsedMillis = elapsedMillis; + this.minGas = minGas; + this.maxGas = maxGas; + this.totalGas = totalGas; + } + + private Map toMap() { + Map map = new LinkedHashMap(); + map.put("runtime", runtime); + map.put("scenario", scenario); + map.put("iterations", iterations); + map.put("elapsedMillis", elapsedMillis); + map.put("minGas", minGas); + map.put("maxGas", maxGas); + map.put("totalGas", totalGas); + map.put("finalBlueId", null); + return map; + } + } +} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntimeSmokeTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntimeSmokeTest.java index 26b704c..64f112a 100644 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntimeSmokeTest.java +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntimeSmokeTest.java @@ -19,10 +19,7 @@ class ChicoryBlueQuickJsRuntimeSmokeTest { @Test void deterministicExpressionsEvaluateWithoutNode() { - BlueQuickJsWasmRuntimeConfig config = BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(blueQuickJsRoot()) - .expectedEngineBuildHash("1d4584fc0552a24ee840afa2cca9f1536d47429f467585d4d5c1a5236ba96dc9") - .build(); + BlueQuickJsWasmRuntimeConfig config = ChicoryTestSupport.pinnedConfig(blueQuickJsRoot()); ChicoryBlueQuickJsRuntime runtime = new ChicoryBlueQuickJsRuntime(config); assertStable(runtime, "1 + 2", 3); @@ -56,11 +53,6 @@ private static void assertStable(ChicoryBlueQuickJsRuntime runtime, String code, } private static Path blueQuickJsRoot() { - String configured = System.getProperty("blue.quickjs.root"); - Path root = configured == null || configured.trim().isEmpty() - ? Paths.get(System.getProperty("user.dir")).toAbsolutePath().getParent().resolve("blue-quickjs") - : Paths.get(configured); - assumeTrue(Files.isDirectory(root), "blue-quickjs checkout is required for Chicory smoke tests"); - return root; + return ChicoryTestSupport.blueQuickJsRoot("blue-quickjs checkout is required for Chicory smoke tests"); } } diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryCounterSnapshotRoundTripStressTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryCounterSnapshotRoundTripStressTest.java index 9bd9fb8..883b665 100644 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryCounterSnapshotRoundTripStressTest.java +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryCounterSnapshotRoundTripStressTest.java @@ -81,10 +81,7 @@ private static Fixture configuredFixture() { BlueRepository repository = BlueRepository.v1_2_0(); Blue blue = repository.configure(new Blue()); blue.nodeProvider(repository.nodeProvider()); - ChicoryBlueQuickJsRuntime runtime = new ChicoryBlueQuickJsRuntime(BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(blueQuickJsRoot()) - .expectedEngineBuildHash("1d4584fc0552a24ee840afa2cca9f1536d47429f467585d4d5c1a5236ba96dc9") - .build()); + ChicoryBlueQuickJsRuntime runtime = new ChicoryBlueQuickJsRuntime(ChicoryTestSupport.pinnedConfig(blueQuickJsRoot())); BlueDocumentProcessors.registerWith(blue, BlueDocumentProcessorOptions.builder() .sequentialWorkflowRunner(SequentialWorkflowRunner.withJavaScriptRuntime(runtime)) .build()); @@ -148,12 +145,7 @@ private static Node chatMessage(String message) { } private static Path blueQuickJsRoot() { - String configured = System.getProperty("blue.quickjs.root"); - Path root = configured == null || configured.trim().isEmpty() - ? Paths.get(System.getProperty("user.dir")).toAbsolutePath().getParent().resolve("blue-quickjs") - : Paths.get(configured); - assumeTrue(Files.isDirectory(root), "blue-quickjs checkout is required for Chicory stress tests"); - return root; + return ChicoryTestSupport.blueQuickJsRoot("blue-quickjs checkout is required for Chicory stress tests"); } @TypeBlueId(SIMPLE_TIMELINE_CHANNEL_BLUE_ID) diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryDocumentHostTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryDocumentHostTest.java index ea7802a..67b191d 100644 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryDocumentHostTest.java +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryDocumentHostTest.java @@ -19,12 +19,12 @@ void documentGetMatchesEvaluateMjsPointerBehavior() { assertEquals(6, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "/counter")); assertEquals(6, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "counter")); assertEquals(root, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "")); - assertEquals(root, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, null)); + assertFatal(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, null); assertEquals(10, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "/items/0")); assertEquals(1, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "/a~1b")); assertEquals(2, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "/a~0b")); assertEquals(null, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "/missing")); - assertEquals(null, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, 12)); + assertFatal(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, 12); } @Test @@ -64,6 +64,12 @@ private static Object call(BlueQuickJsHostDispatcher dispatcher, int fnId, Objec return envelope.get("ok"); } + private static void assertFatal(BlueQuickJsHostDispatcher dispatcher, int fnId, Object pointer) { + BlueQuickJsHostDispatcher.DispatchResult result = dispatcher.dispatch(fnId, + DeterministicValueCodec.encode(Arrays.asList(pointer))); + org.junit.jupiter.api.Assertions.assertTrue(result.fatal(), "expected fatal transport failure"); + } + private static Map envelope(BlueQuickJsHostDispatcher.DispatchResult result) { assertFalse(result.fatal(), result.error()); return (Map) DeterministicValueCodec.decode(result.envelope()); diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryForbiddenSurfaceTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryForbiddenSurfaceTest.java index a8c65ba..a366b78 100644 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryForbiddenSurfaceTest.java +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryForbiddenSurfaceTest.java @@ -16,10 +16,7 @@ class ChicoryForbiddenSurfaceTest { @Test void forbiddenSurfaceMatchesNodeOracle() { Path root = blueQuickJsRoot(); - ChicoryBlueQuickJsRuntime chicory = new ChicoryBlueQuickJsRuntime(BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(root) - .expectedEngineBuildHash("1d4584fc0552a24ee840afa2cca9f1536d47429f467585d4d5c1a5236ba96dc9") - .build()); + ChicoryBlueQuickJsRuntime chicory = new ChicoryBlueQuickJsRuntime(ChicoryTestSupport.pinnedConfig(root)); try (NodeQuickJsRuntime node = new NodeQuickJsRuntime(root)) { assertParity(node, chicory, "typeof Date"); assertParity(node, chicory, "typeof process"); @@ -43,11 +40,6 @@ private static void assertParity(NodeQuickJsRuntime node, ChicoryBlueQuickJsRunt } private static Path blueQuickJsRoot() { - String configured = System.getProperty("blue.quickjs.root"); - Path root = configured == null || configured.trim().isEmpty() - ? Paths.get(System.getProperty("user.dir")).toAbsolutePath().getParent().resolve("blue-quickjs") - : Paths.get(configured); - assumeTrue(Files.isDirectory(root), "blue-quickjs checkout is required for forbidden surface tests"); - return root; + return ChicoryTestSupport.blueQuickJsRoot("blue-quickjs checkout is required for forbidden surface tests"); } } diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryHostCallAbiTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryHostCallAbiTest.java index 01a4363..a9f6a12 100644 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryHostCallAbiTest.java +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryHostCallAbiTest.java @@ -31,6 +31,7 @@ void unknownFunctionAndMalformedRequestAreFatalTransportFailures() { assertTrue(dispatcher.dispatch(999, DeterministicValueCodec.encode(Collections.emptyList())).fatal()); assertTrue(dispatcher.dispatch(HostV1Manifest.DOCUMENT_GET_FN_ID, new byte[]{(byte) 0xff}).fatal()); assertTrue(dispatcher.dispatch(HostV1Manifest.DOCUMENT_GET_FN_ID, DeterministicValueCodec.encode("not-array")).fatal()); + assertTrue(dispatcher.dispatch(HostV1Manifest.DOCUMENT_GET_FN_ID, DeterministicValueCodec.encode(Arrays.asList((Object) null))).fatal()); } @Test @@ -77,6 +78,8 @@ void hostDispatcherNeverThrowsForInternalFailures() { BlueQuickJsHostDispatcher.DispatchResult result = dispatcher.dispatch(HostV1Manifest.DOCUMENT_GET_FN_ID, null); assertTrue(result.fatal()); + assertFalse(result.error().contains("NullPointerException")); + assertFalse(result.error().contains("java.")); } private static Map decode(BlueQuickJsHostDispatcher.DispatchResult result) { diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryOutOfGasTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryOutOfGasTest.java index f889197..b01a872 100644 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryOutOfGasTest.java +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryOutOfGasTest.java @@ -17,10 +17,7 @@ class ChicoryOutOfGasTest { @Test void outOfGasBoundariesMatchNodeAndDoNotDrift() { Path root = blueQuickJsRoot(); - ChicoryBlueQuickJsRuntime chicory = new ChicoryBlueQuickJsRuntime(BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(root) - .expectedEngineBuildHash("1d4584fc0552a24ee840afa2cca9f1536d47429f467585d4d5c1a5236ba96dc9") - .build()); + ChicoryBlueQuickJsRuntime chicory = new ChicoryBlueQuickJsRuntime(ChicoryTestSupport.pinnedConfig(root)); try (NodeQuickJsRuntime node = new NodeQuickJsRuntime(root)) { assertRepeatedParity(node, chicory, "hostGasLimit = 0", "1 + 1", 0L, Collections.emptyMap()); assertRepeatedParity(node, chicory, "hostGasLimit = 1", "1 + 1", 1L, Collections.emptyMap()); @@ -79,11 +76,6 @@ private static Map bindings() { } private static Path blueQuickJsRoot() { - String configured = System.getProperty("blue.quickjs.root"); - Path root = configured == null || configured.trim().isEmpty() - ? Paths.get(System.getProperty("user.dir")).toAbsolutePath().getParent().resolve("blue-quickjs") - : Paths.get(configured); - assumeTrue(Files.isDirectory(root), "blue-quickjs checkout is required for OOG tests"); - return root; + return ChicoryTestSupport.blueQuickJsRoot("blue-quickjs checkout is required for OOG tests"); } } diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryParityAssertions.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryParityAssertions.java index 9f9c676..45040d9 100644 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryParityAssertions.java +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryParityAssertions.java @@ -20,7 +20,7 @@ static Evaluation evaluate(JavaScriptRuntime runtime, JavaScriptEvaluationReques JavaScriptEvaluationResult result = runtime.evaluate(request); return Evaluation.ok(result.value(), result.wasmGasUsed(), result.hostGasUsed()); } catch (JavaScriptExecutionException ex) { - return Evaluation.error(normalizeMessage(ex.getMessage())); + return Evaluation.error(normalizeMessage(ex.getMessage()), ex.wasmGasUsed(), ex.hostGasUsed()); } } @@ -56,8 +56,8 @@ static Evaluation ok(Object value, long wasmGasUsed, long hostGasUsed) { return new Evaluation(true, normalize(value), null, wasmGasUsed, hostGasUsed); } - static Evaluation error(String error) { - return new Evaluation(false, null, error, -1L, -1L); + static Evaluation error(String error, long wasmGasUsed, long hostGasUsed) { + return new Evaluation(false, null, error, wasmGasUsed, hostGasUsed); } Map toMap() { diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryProcessorParityTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryProcessorParityTest.java new file mode 100644 index 0000000..022c670 --- /dev/null +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryProcessorParityTest.java @@ -0,0 +1,455 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import blue.contract.processor.BlueDocumentProcessorOptions; +import blue.contract.processor.BlueDocumentProcessors; +import blue.contract.processor.conversation.TimelineProviderSupport; +import blue.contract.processor.conversation.expression.QuickJsExpressionEvaluator; +import blue.contract.processor.conversation.expression.QuickJsExpressionResolver; +import blue.contract.processor.conversation.javascript.JavaScriptExecutionException; +import blue.contract.processor.conversation.javascript.JavaScriptRuntime; +import blue.contract.processor.conversation.javascript.NodeQuickJsRuntime; +import blue.contract.processor.conversation.javascript.QuickJsGas; +import blue.contract.processor.conversation.workflow.JavaScriptCodeStepExecutor; +import blue.contract.processor.conversation.workflow.SequentialWorkflowRunner; +import blue.contract.processor.conversation.workflow.TriggerEventStepExecutor; +import blue.contract.processor.conversation.workflow.UpdateDocumentStepExecutor; +import blue.contract.processor.conversation.workflow.WorkflowStepExecutor; +import blue.language.Blue; +import blue.language.model.Node; +import blue.language.model.TypeBlueId; +import blue.language.processor.ChannelCheckpointContext; +import blue.language.processor.ChannelEvaluation; +import blue.language.processor.ChannelEvaluationContext; +import blue.language.processor.ChannelProcessor; +import blue.language.processor.DocumentProcessingResult; +import blue.language.processor.ProcessorFatalException; +import blue.repo.BlueRepository; +import blue.repo.v1_2_0.conversation.ChatMessage; +import blue.repo.v1_2_0.conversation.JavaScriptCode; +import blue.repo.v1_2_0.conversation.SequentialWorkflowStep; +import blue.repo.v1_2_0.conversation.Timeline; +import blue.repo.v1_2_0.conversation.TimelineChannel; +import blue.repo.v1_2_0.conversation.TimelineEntry; +import java.math.BigInteger; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +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; + +class ChicoryProcessorParityTest { + private static final String SIMPLE_TIMELINE_CHANNEL_BLUE_ID = "chicory-processor-parity-timeline-channel"; + + @Test + void processorSuccessFixturesMatchNodeOutputAndGas() { + Path root = blueQuickJsRoot(); + ChicoryBlueQuickJsRuntime chicory = new ChicoryBlueQuickJsRuntime(ChicoryTestSupport.pinnedConfig(root)); + try (NodeQuickJsRuntime node = new NodeQuickJsRuntime(root)) { + List fixtures = Arrays.asList( + new ProcessorFixture("counter increment with JS expression", + workflowDocument(0, + updateDocumentStep("replace", "/counter", new Node().value("${document('/counter') + 1}")))), + new ProcessorFixture("workflow with JavaScript Code step emitting event", + workflowDocument(3, + javaScriptStep("Emit", "return { events: [{ type: 'Conversation/Chat Message', message: `Counter ${document('/counter')}` }] };"))), + new ProcessorFixture("Trigger Event with template expressions", + workflowDocument(5, + triggerEventStep(chatMessageEvent("Counter is ${document('/counter')}"))))); + + for (ProcessorFixture fixture : fixtures) { + ProcessorRun nodeRun = process(node, fixture); + ProcessorRun chicoryRun = process(chicory, fixture); + assertEquals(nodeRun, chicoryRun, fixture.name); + } + } + } + + @Test + void processorFailureFixturesMatchNodeFatalBehavior() { + Path root = blueQuickJsRoot(); + ChicoryBlueQuickJsRuntime chicory = new ChicoryBlueQuickJsRuntime(ChicoryTestSupport.pinnedConfig(root)); + try (NodeQuickJsRuntime node = new NodeQuickJsRuntime(root)) { + assertFatalParity(node, + chicory, + new ProcessorFixture("JS Code throw new Error", + workflowDocument(0, javaScriptStep("Throw", "throw new Error('boom');"))), + true); + assertFatalParity(node, + chicory, + new ProcessorFixture("JS Code out-of-gas", + workflowDocument(0, javaScriptStep("Loop", "while (true) {}")), + null, + 1L), + true); + assertFatalParity(node, + chicory, + new ProcessorFixture("Update Document expression throwing", + workflowDocument(0, updateDocumentStep("replace", + "/counter", + new Node().value("${(() => { throw new Error('boom'); })()}")))), + true); + assertFatalParity(node, + chicory, + new ProcessorFixture("Update Document expression out-of-gas", + workflowDocument(0, updateDocumentStep("replace", + "/counter", + new Node().value("${(() => { while (true) {} })()}"))), + 1L, + null), + true); + assertFatalParity(node, + chicory, + new ProcessorFixture("Trigger Event template expression throwing", + workflowDocument(0, triggerEventStep( + chatMessageEvent("Counter ${JSON.parse('x')}")))), + true); + assertFatalParity(node, + chicory, + new ProcessorFixture("deterministic forbidden global failure", + workflowDocument(0, updateDocumentStep("replace", + "/counter", + new Node().value("${Math.random()}")))), + true); + assertFatalParity(node, + chicory, + new ProcessorFixture("malformed host call failure", + workflowDocument(0, updateDocumentStep("replace", + "/counter", + new Node().value("${document(null)}")))), + true); + } + } + + @Test + void bridgeFailureWithoutVmGasDoesNotFabricateGas() { + ProcessorFixture fixture = new ProcessorFixture("bridge setup failure without VM execution", + workflowDocument(0, updateDocumentStep("replace", "/counter", new Node().value("${1 + 2}")))); + + ProcessorFailure noGas = processFailure(new JavaScriptRuntime() { + @Override + public blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult evaluate( + blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest request) { + throw new JavaScriptExecutionException("bridge setup failed before VM execution"); + } + }, fixture); + ProcessorFailure withGas = processFailure(new JavaScriptRuntime() { + @Override + public blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult evaluate( + blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest request) { + throw new JavaScriptExecutionException("VM execution failed", 11L, 37L); + } + }, fixture); + + assertEquals(noGas.totalGas + 37L, withGas.totalGas); + assertTrue(noGas.normalizedMessage.contains("bridge setup failed before VM execution")); + } + + private static void assertFatalParity(JavaScriptRuntime node, + JavaScriptRuntime chicory, + ProcessorFixture fixture, + boolean expectUnchangedDocument) { + ProcessorFailure nodeFailure = processFailure(node, fixture); + ProcessorFailure chicoryFailure = processFailure(chicory, fixture); + + assertEquals(nodeFailure.classification, chicoryFailure.classification, fixture.name); + assertEquals(nodeFailure.normalizedMessage, chicoryFailure.normalizedMessage, fixture.name); + assertEquals(nodeFailure.totalGas, chicoryFailure.totalGas, fixture.name); + assertTrue(nodeFailure.totalGas > 0L, fixture.name + " should charge VM failure gas"); + assertEquals(nodeFailure.partialCanonicalJson, chicoryFailure.partialCanonicalJson, fixture.name); + assertEquals(nodeFailure.partialBlueId, chicoryFailure.partialBlueId, fixture.name); + assertEquals(nodeFailure.events, chicoryFailure.events, fixture.name); + assertTrue(nodeFailure.events.isEmpty(), fixture.name + " should not emit events after fatal failure"); + if (expectUnchangedDocument) { + assertEquals(nodeFailure.initialCounter, nodeFailure.partialCounter, fixture.name); + } + } + + private static ProcessorRun process(JavaScriptRuntime runtime, ProcessorFixture fixture) { + Fixture configured = configuredFixture(runtime, fixture); + DocumentProcessingResult initialized = configured.blue.initializeDocument( + configured.blue.preprocess(fixture.document)); + + DocumentProcessingResult result = configured.blue.processDocument(initialized.document(), + timelineEntry(configured.blue, configured.repository, "owner", 1, chatMessage("run"))); + + return new ProcessorRun( + canonicalJson(configured, result), + result.blueId(), + eventJson(configured, result.triggeredEvents()), + result.totalGas()); + } + + private static ProcessorFailure processFailure(JavaScriptRuntime runtime, ProcessorFixture fixture) { + Fixture configured = configuredFixture(runtime, fixture); + DocumentProcessingResult initialized = configured.blue.initializeDocument( + configured.blue.preprocess(fixture.document)); + + ProcessorFatalException failure = assertThrows(ProcessorFatalException.class, + () -> configured.blue.processDocument(initialized.document(), + timelineEntry(configured.blue, configured.repository, "owner", 1, chatMessage("run")))); + DocumentProcessingResult partial = failure.partialResult(); + assertNotNull(partial, fixture.name + " should expose a partial fatal result"); + + return new ProcessorFailure( + failure.getClass().getName(), + normalize(failure.getMessage()), + failure.totalGas(), + canonicalJson(configured, initialized), + initialized.blueId(), + initialized.document().get("/counter"), + canonicalJson(configured, partial), + partial.blueId(), + partial.document().get("/counter"), + eventJson(configured, partial.triggeredEvents())); + } + + private static Fixture configuredFixture(JavaScriptRuntime runtime, ProcessorFixture fixture) { + BlueRepository repository = BlueRepository.v1_2_0(); + Blue blue = repository.configure(new Blue()); + blue.nodeProvider(repository.nodeProvider()); + BlueDocumentProcessorOptions.Builder options = BlueDocumentProcessorOptions.builder() + .sequentialWorkflowRunner(workflowRunner(runtime, fixture)); + BlueDocumentProcessors.registerWith(blue, options.build()); + blue.registerContractProcessor(new SimpleTimelineChannelProcessor()); + return new Fixture(repository, blue); + } + + private static SequentialWorkflowRunner workflowRunner(JavaScriptRuntime runtime, ProcessorFixture fixture) { + long expressionHostGasLimit = fixture.expressionHostGasLimit != null + ? fixture.expressionHostGasLimit.longValue() + : QuickJsGas.DEFAULT_EXPRESSION_HOST_GAS_LIMIT; + long codeHostGasLimit = fixture.codeHostGasLimit != null + ? fixture.codeHostGasLimit.longValue() + : QuickJsGas.DEFAULT_CODE_HOST_GAS_LIMIT; + QuickJsExpressionResolver resolver = new QuickJsExpressionResolver(runtime, expressionHostGasLimit); + return new SequentialWorkflowRunner(Arrays.>asList( + new TriggerEventStepExecutor(resolver), + new JavaScriptCodeStepExecutor(runtime, codeHostGasLimit), + new UpdateDocumentStepExecutor(new QuickJsExpressionEvaluator(runtime, expressionHostGasLimit)))); + } + + private static Node workflowDocument(int counter, Node... steps) { + Map contracts = new LinkedHashMap(); + contracts.put("owner", channel("owner")); + contracts.put("direct", new Node() + .type("Conversation/Sequential Workflow") + .properties("channel", new Node().value("owner")) + .properties("steps", new Node().items(Arrays.asList(steps)))); + + return new Node() + .blue(BlueRepository.v1_2_0().typeAliasBlue()) + .name("Processor Parity Document") + .properties("counter", new Node().value(counter)) + .properties("contracts", new Node().properties(contracts)); + } + + private static Node updateDocumentStep(String op, String path, Node value) { + return new Node() + .type("Conversation/Update Document") + .properties("changeset", new Node().items(new Node() + .properties("op", new Node().value(op)) + .properties("path", new Node().value(path)) + .properties("val", value))); + } + + private static Node javaScriptStep(String name, String code) { + return new Node() + .name(name) + .type("Conversation/JavaScript Code") + .properties("code", new Node().value(code)); + } + + private static Node triggerEventStep(Node event) { + return new Node() + .type("Conversation/Trigger Event") + .properties("event", event); + } + + private static Node chatMessageEvent(String message) { + return new Node() + .type(ChatMessage.qualifiedName()) + .properties("message", new Node().value(message)); + } + + private static Node channel(String timelineId) { + return new Node() + .type(new Node().blueId(SIMPLE_TIMELINE_CHANNEL_BLUE_ID)) + .properties("timelineId", new Node().value(timelineId)); + } + + private static Node timelineEntry(Blue blue, + BlueRepository repository, + String timelineId, + int timestamp, + Node message) { + TimelineEntry entry = new TimelineEntry() + .timeline(new Timeline().timelineId(timelineId)) + .timestamp(BigInteger.valueOf(timestamp)) + .message(message); + return blue.preprocess(new Node() + .blue(repository.typeAliasBlue()) + .type(TimelineEntry.qualifiedName()) + .properties("timeline", blue.objectToNode(entry.getTimeline())) + .properties("timestamp", new Node().value(entry.getTimestamp())) + .properties("message", entry.getMessage())); + } + + private static Node chatMessage(String message) { + return new Node() + .type(ChatMessage.qualifiedName()) + .properties("message", new Node().value(message)); + } + + private static List eventJson(Fixture fixture, List events) { + List json = new ArrayList(); + for (Node event : events) { + json.add(fixture.blue.nodeToJson(event)); + } + return json; + } + + private static String canonicalJson(Fixture fixture, DocumentProcessingResult result) { + Node canonical = result.canonicalDocument(); + return fixture.blue.nodeToJson(canonical != null ? canonical : result.document()); + } + + private static String normalize(String message) { + return message == null ? "" : message.replace("Chicory blue-quickjs evaluation failed: ", ""); + } + + private static Path blueQuickJsRoot() { + return ChicoryTestSupport.blueQuickJsRoot("blue-quickjs checkout is required for processor parity tests"); + } + + @TypeBlueId(SIMPLE_TIMELINE_CHANNEL_BLUE_ID) + public static final class SimpleTimelineChannel extends TimelineChannel { + } + + public static final class SimpleTimelineChannelProcessor implements ChannelProcessor { + @Override + public Class contractType() { + return SimpleTimelineChannel.class; + } + + @Override + public ChannelEvaluation evaluate(SimpleTimelineChannel contract, ChannelEvaluationContext context) { + return TimelineProviderSupport.evaluateTimelineEntry(contract, context); + } + + @Override + public String eventId(SimpleTimelineChannel contract, ChannelEvaluationContext context) { + return TimelineProviderSupport.eventId(context.event()); + } + + @Override + public boolean isNewerEvent(SimpleTimelineChannel contract, ChannelCheckpointContext context) { + return TimelineProviderSupport.isNewerOrSameTimelineEvent(context); + } + } + + private static final class ProcessorFixture { + private final String name; + private final Node document; + private final Long expressionHostGasLimit; + private final Long codeHostGasLimit; + + private ProcessorFixture(String name, Node document) { + this(name, document, null, null); + } + + private ProcessorFixture(String name, Node document, Long expressionHostGasLimit, Long codeHostGasLimit) { + this.name = name; + this.document = document; + this.expressionHostGasLimit = expressionHostGasLimit; + this.codeHostGasLimit = codeHostGasLimit; + } + } + + private static final class ProcessorFailure { + private final String classification; + private final String normalizedMessage; + private final long totalGas; + private final String initialCanonicalJson; + private final String initialBlueId; + private final Object initialCounter; + private final String partialCanonicalJson; + private final String partialBlueId; + private final Object partialCounter; + private final List events; + + private ProcessorFailure(String classification, + String normalizedMessage, + long totalGas, + String initialCanonicalJson, + String initialBlueId, + Object initialCounter, + String partialCanonicalJson, + String partialBlueId, + Object partialCounter, + List events) { + this.classification = classification; + this.normalizedMessage = normalizedMessage; + this.totalGas = totalGas; + this.initialCanonicalJson = initialCanonicalJson; + this.initialBlueId = initialBlueId; + this.initialCounter = initialCounter; + this.partialCanonicalJson = partialCanonicalJson; + this.partialBlueId = partialBlueId; + this.partialCounter = partialCounter; + this.events = events; + } + } + + private static final class ProcessorRun { + private final String canonicalJson; + private final String blueId; + private final List events; + private final long totalGas; + + private ProcessorRun(String canonicalJson, String blueId, List events, long totalGas) { + this.canonicalJson = canonicalJson; + this.blueId = blueId; + this.events = events; + this.totalGas = totalGas; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof ProcessorRun)) { + return false; + } + ProcessorRun that = (ProcessorRun) other; + return totalGas == that.totalGas + && java.util.Objects.equals(canonicalJson, that.canonicalJson) + && java.util.Objects.equals(blueId, that.blueId) + && java.util.Objects.equals(events, that.events); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(canonicalJson, blueId, events, totalGas); + } + + @Override + public String toString() { + return "ProcessorRun{blueId='" + blueId + "', totalGas=" + totalGas + ", events=" + events + "}"; + } + } + + private static final class Fixture { + private final BlueRepository repository; + private final Blue blue; + + private Fixture(BlueRepository repository, Blue blue) { + this.repository = repository; + this.blue = blue; + } + } +} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicorySequentialWorkflowExecutionTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicorySequentialWorkflowExecutionTest.java index 32ff271..fee6abd 100644 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicorySequentialWorkflowExecutionTest.java +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicorySequentialWorkflowExecutionTest.java @@ -47,10 +47,7 @@ private static Fixture configuredFixture() { BlueRepository repository = BlueRepository.v1_2_0(); Blue blue = repository.configure(new Blue()); blue.nodeProvider(repository.nodeProvider()); - ChicoryBlueQuickJsRuntime runtime = new ChicoryBlueQuickJsRuntime(BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(blueQuickJsRoot()) - .expectedEngineBuildHash("1d4584fc0552a24ee840afa2cca9f1536d47429f467585d4d5c1a5236ba96dc9") - .build()); + ChicoryBlueQuickJsRuntime runtime = new ChicoryBlueQuickJsRuntime(ChicoryTestSupport.pinnedConfig(blueQuickJsRoot())); BlueDocumentProcessors.registerWith(blue, BlueDocumentProcessorOptions.builder() .sequentialWorkflowRunner(SequentialWorkflowRunner.withJavaScriptRuntime(runtime)) .build()); @@ -125,12 +122,7 @@ private static Node chatMessage(String message) { } private static Path blueQuickJsRoot() { - String configured = System.getProperty("blue.quickjs.root"); - Path root = configured == null || configured.trim().isEmpty() - ? Paths.get(System.getProperty("user.dir")).toAbsolutePath().getParent().resolve("blue-quickjs") - : Paths.get(configured); - assumeTrue(Files.isDirectory(root), "blue-quickjs checkout is required for workflow tests"); - return root; + return ChicoryTestSupport.blueQuickJsRoot("blue-quickjs checkout is required for workflow tests"); } @TypeBlueId(SIMPLE_TIMELINE_CHANNEL_BLUE_ID) diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryTestSupport.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryTestSupport.java new file mode 100644 index 0000000..6c0233e --- /dev/null +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryTestSupport.java @@ -0,0 +1,70 @@ +package blue.contract.processor.conversation.javascript.chicory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +final class ChicoryTestSupport { + private static final ObjectMapper JSON = new ObjectMapper(); + + private ChicoryTestSupport() { + } + + static Path blueQuickJsRoot(String reason) { + String configured = System.getProperty("blue.quickjs.root"); + Path root; + if (configured == null || configured.trim().isEmpty()) { + Path cwd = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + root = cwd.getParent().resolve("blue-quickjs"); + if (!Files.isDirectory(root) && cwd.getParent() != null && cwd.getParent().getParent() != null) { + root = cwd.getParent().getParent().resolve("blue-quickjs"); + } + } else { + root = Paths.get(configured); + } + assumeTrue(Files.isDirectory(root), reason); + return root; + } + + static BlueQuickJsWasmRuntimeConfig pinnedConfig(Path root) { + return BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(root) + .expectedEngineBuildHash(engineBuildHash(root)) + .build(); + } + + static String engineBuildHash(Path root) { + return metadata(root) + .path("variants") + .path("wasm32") + .path("release") + .path("engineBuildHash") + .asText(); + } + + static int gasVersion(Path root) { + return metadata(root).path("gasVersion").asInt(); + } + + static JsonNode metadata(Path root) { + Path metadata = root.resolve("libs/quickjs-wasm/dist/wasm").resolve(BlueQuickJsWasmResources.METADATA_FILENAME); + if (!Files.isRegularFile(metadata)) { + metadata = root.resolve("libs/quickjs-wasm-build/dist").resolve(BlueQuickJsWasmResources.METADATA_FILENAME); + } + assumeTrue(Files.isRegularFile(metadata), "blue-quickjs wasm metadata is required"); + try { + return JSON.readTree(metadata.toFile()); + } catch (IOException ex) { + throw new AssertionError("failed to read blue-quickjs metadata: " + metadata, ex); + } + } + + static Path reportPath(String fileName) { + return Paths.get(System.getProperty("user.dir"), "build/reports", fileName); + } +} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryVsNodeParityTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryVsNodeParityTest.java index 9c93757..334ccea 100644 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryVsNodeParityTest.java +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryVsNodeParityTest.java @@ -1,15 +1,16 @@ package blue.contract.processor.conversation.javascript.chicory; import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult; -import blue.contract.processor.conversation.javascript.JavaScriptExecutionException; import blue.contract.processor.conversation.javascript.NodeQuickJsRuntime; import blue.contract.processor.conversation.javascript.QuickJsGas; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -18,63 +19,113 @@ import java.util.Map; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assumptions.assumeTrue; class ChicoryVsNodeParityTest { private static final ObjectMapper JSON = new ObjectMapper(); + private static final List COMPARED_FIELDS = Collections.unmodifiableList( + Arrays.asList("ok", "value", "error", "wasmGasUsed", "hostGasUsed")); @Test - void deterministicFixtureSetMatchesNodeOracle() throws IOException { - Path root = blueQuickJsRoot(); + void deterministicFixtureSetMatchesNodeOracleIncludingGas() throws IOException { + Path root = ChicoryTestSupport.blueQuickJsRoot("blue-quickjs checkout is required for parity tests"); List> reportCases = new ArrayList>(); List> mismatches = new ArrayList>(); - ChicoryBlueQuickJsRuntime chicory = new ChicoryBlueQuickJsRuntime(BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(root) - .expectedEngineBuildHash("1d4584fc0552a24ee840afa2cca9f1536d47429f467585d4d5c1a5236ba96dc9") - .build()); + ChicoryBlueQuickJsRuntime chicory = new ChicoryBlueQuickJsRuntime(ChicoryTestSupport.pinnedConfig(root)); try (NodeQuickJsRuntime node = new NodeQuickJsRuntime(root)) { for (Fixture fixture : fixtures()) { - Evaluation nodeResult = evaluate(node, fixture.request); - Evaluation chicoryResult = evaluate(chicory, fixture.request); + ChicoryParityAssertions.Evaluation nodeResult = ChicoryParityAssertions.evaluate(node, fixture.request); + ChicoryParityAssertions.Evaluation chicoryResult = ChicoryParityAssertions.evaluate(chicory, fixture.request); Map entry = new LinkedHashMap(); + entry.put("caseName", fixture.name); entry.put("name", fixture.name); + entry.put("mode", fixture.request.mode().name()); + entry.put("runtimeMode", fixture.request.mode().name().toLowerCase(java.util.Locale.ROOT)); + entry.put("source", summarize(fixture.request.code())); + entry.put("sourceSha256", sha256(fixture.request.code())); + entry.put("bindingsSummary", fixture.bindingsSummary); + entry.put("hostGasLimit", fixture.request.hostGasLimit()); + entry.put("comparedFields", COMPARED_FIELDS); entry.put("node", nodeResult.toMap()); entry.put("chicory", chicoryResult.toMap()); - entry.put("matches", nodeResult.equals(chicoryResult)); + String mismatchReason = mismatchReason(nodeResult, chicoryResult); + entry.put("status", mismatchReason == null ? "passed" : "failed"); + entry.put("finalStatus", mismatchReason == null ? "passed" : "failed"); + entry.put("mismatchReason", mismatchReason); + entry.put("pass", mismatchReason == null); + entry.put("matches", mismatchReason == null); reportCases.add(entry); - if (!nodeResult.equals(chicoryResult)) { + if (mismatchReason != null) { mismatches.add(entry); } } } - writeReport(reportCases, mismatches); - assertTrue(mismatches.isEmpty(), "Chicory parity mismatches written to build report"); + Path reportPath = writeReport(root, reportCases, mismatches); + assertTrue(mismatches.isEmpty(), "Chicory parity mismatches written to " + reportPath); } private static List fixtures() { Map bindings = bindings(); List fixtures = new ArrayList(); - fixtures.add(expression("simple arithmetic expression", "1 + 2", bindings)); - fixtures.add(expression("event binding expression", "event.message.request + 1", bindings)); - fixtures.add(expression("currentContract expression", "currentContract.channel", bindings)); - fixtures.add(expression("steps binding expression", "steps.Prepare.amount + 1", bindings)); - fixtures.add(expression("document simple value", "document('/counter')", bindings)); - fixtures.add(expression("document canonical value", "document.canonical('/counter').value", bindings)); - fixtures.add(expression("document metadata lookup", "document('/counter/name')", bindings)); - fixtures.add(expression("array map reduce", "[1, 2, 3].map(x => x + 1).reduce((a, b) => a + b, 0)", bindings)); - fixtures.add(expression("JSON stringify deterministic case", "JSON.stringify({ b: 2, aa: 1 })", bindings)); - fixtures.add(block("block mode returning object", "return { value: document('/counter') + event.message.request };", bindings)); - fixtures.add(block("block mode returning array", "return [event.message.request, document('/counter')];", bindings)); - fixtures.add(expression("forbidden global", "typeof Date", bindings)); + fixtures.add(expression("arithmetic", "1 + 2 * 3", bindings)); + fixtures.add(expression("sequential workflow with Update Document expression", "document('/counter') + 1", bindings)); + fixtures.add(expression("string template behavior", "`counter=${document('/counter')}; request=${event.message.request}`", bindings)); + fixtures.add(expression("event reads", "event.actor.email + ':' + event.message.request", bindings)); + fixtures.add(expression("object return", "({ ok: true, count: document('/counter') })", bindings)); + fixtures.add(expression("list return", "[1, 'two', true, null, document('/counter')]", bindings)); + fixtures.add(expression("nested object return", + "({ outer: { amount: steps.Prepare.amount, items: [document('/counter'), { actor: event.actor.email }] } })", + bindings)); + fixtures.add(expression("document('/path')", "document('/counter')", bindings)); + fixtures.add(expression("document.canonical('/path')", "document.canonical('/counter')", bindings)); + fixtures.add(expression("documentCanonical binding", "documentCanonical.counter.value", bindings)); + fixtures.add(expression("metadata read /counter/name", "document('/counter/name')", bindings)); + fixtures.add(expression("steps binding", "steps.Prepare.amount + steps.Prepare.delta", bindings)); + fixtures.add(expression("currentContract binding", "currentContract.channel + ':' + currentContract.description", bindings)); + fixtures.add(expression("currentContractCanonical binding", + "currentContractCanonical.description.value + ':' + currentContractCanonical.channel.value", + bindings)); + fixtures.add(expression("forbidden global typeof process", "typeof process", bindings)); + fixtures.add(expression("forbidden Math.random", "Math.random()", bindings)); + fixtures.add(expression("disabled Function constructor", "Function('return 1')()", bindings)); + fixtures.add(expression("thrown error", "(() => { throw new Error('boom'); })()", bindings)); + fixtures.add(expression("host-call limit error", "document('/" + repeat('p', 5000) + "')", bindings)); + fixtures.add(expression("unsupported host function", "Host.v1.unknown('/counter')", bindings)); + fixtures.add(expression("malformed host call", "document(null)", bindings)); + fixtures.add(expression("Trigger Event template expression", "`Counter is ${document('/counter')}`", bindings)); + fixtures.add(expression("recursive function", + "(() => { function f(n) { return n === 0 ? 0 : f(n - 1) + 1; } return f(25); })()", + bindings)); + fixtures.add(expression("document-read loop", + "(() => { let total = 0; for (let i = 0; i < 25; i++) total += document('/counter'); return total; })()", + bindings)); + fixtures.add(block("code block returning events", + "return { events: [{ type: 'Conversation/Chat Message', message: `Counter ${document('/counter')}` }] };", + bindings)); + fixtures.add(block("sequential workflow with JavaScript Code", + "return { value: document('/counter') + steps.Prepare.delta };", + bindings)); + fixtures.add(block("code block returning non-event object", + "return { value: document('/counter'), nested: { ok: true } };", + bindings)); + fixtures.add(expression("null return", "null", bindings)); + fixtures.add(expression("large but valid object", + "(() => { const value = {}; for (let i = 0; i < 64; i++) value['k' + i] = i; return value; })()", + bindings)); + fixtures.add(expression("invalid deterministic value return", "NaN", bindings)); + fixtures.add(expression("syntax error category", "const =", bindings)); fixtures.add(new Fixture("out-of-gas loop", new JavaScriptEvaluationRequest( "(() => { while (true) {} })()", JavaScriptEvaluationRequest.Mode.EXPRESSION, bindings, 1L))); + fixtures.add(new Fixture("out-of-gas document-read loop", new JavaScriptEvaluationRequest( + "(() => { while (true) { document('/counter'); } })()", + JavaScriptEvaluationRequest.Mode.EXPRESSION, + bindings, + 10L))); return fixtures; } @@ -92,34 +143,64 @@ private static Fixture block(String name, String code, Map bindi QuickJsGas.DEFAULT_CODE_HOST_GAS_LIMIT)); } - private static Evaluation evaluate(blue.contract.processor.conversation.javascript.JavaScriptRuntime runtime, - JavaScriptEvaluationRequest request) { - try { - JavaScriptEvaluationResult result = runtime.evaluate(request); - return Evaluation.ok(result.value(), result.wasmGasUsed(), result.hostGasUsed()); - } catch (JavaScriptExecutionException ex) { - return Evaluation.error(normalizeMessage(ex.getMessage())); + private static String mismatchReason(ChicoryParityAssertions.Evaluation nodeResult, + ChicoryParityAssertions.Evaluation chicoryResult) { + Map node = nodeResult.toMap(); + Map chicory = chicoryResult.toMap(); + for (String field : COMPARED_FIELDS) { + if (!java.util.Objects.equals(node.get(field), chicory.get(field))) { + return field + " mismatch"; + } } + return null; } - private static String normalizeMessage(String message) { - return message == null ? "" : message.replace("Chicory blue-quickjs evaluation failed: ", ""); - } - - private static void writeReport(List> cases, + private static Path writeReport(Path root, + List> cases, List> mismatches) throws IOException { Map report = new LinkedHashMap(); report.put("status", mismatches.isEmpty() ? "passed" : "failed"); + report.put("caseCount", cases.size()); + report.put("generatedTimestamp", Instant.now().toString()); + report.put("javaVersion", System.getProperty("java.version")); + report.put("engineBuildHash", ChicoryTestSupport.engineBuildHash(root)); + report.put("gasVersion", ChicoryTestSupport.gasVersion(root)); + report.put("executionProfile", BlueQuickJsWasmRuntimeConfig.DEFAULT_EXECUTION_PROFILE); + report.put("hostV1Hash", HostV1Manifest.HOST_V1_HASH); + report.put("comparedFields", COMPARED_FIELDS); report.put("cases", cases); report.put("mismatches", mismatches); - Path reportPath = Paths.get(System.getProperty("user.dir"), - "build/reports/blue-quickjs-chicory-parity.json"); + Path reportPath = ChicoryTestSupport.reportPath("blue-quickjs-chicory-parity.json"); Files.createDirectories(reportPath.getParent()); JSON.writerWithDefaultPrettyPrinter().writeValue(reportPath.toFile(), report); + return reportPath; + } + + private static String summarize(String source) { + if (source == null) { + return ""; + } + String compact = source.replaceAll("\\s+", " ").trim(); + return compact.length() <= 240 ? compact : compact.substring(0, 237) + "..."; + } + + private static String sha256(String source) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest((source == null ? "" : source).getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + hex.append(String.format("%02x", value & 0xff)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException ex) { + throw new AssertionError("SHA-256 is required", ex); + } } private static Map bindings() { Map event = new LinkedHashMap(); + event.put("actor", singleton("email", "alice@example.com")); event.put("message", singleton("request", 7)); Map eventCanonical = new LinkedHashMap(); @@ -129,6 +210,7 @@ private static Map bindings() { Map prepare = new LinkedHashMap(); prepare.put("amount", 5); + prepare.put("delta", 2); Map steps = new LinkedHashMap(); steps.put("Prepare", prepare); @@ -148,6 +230,7 @@ private static Map bindings() { Map document = new LinkedHashMap(); document.put("name", "Counter"); document.put("counter", counter); + document.put("items", Arrays.asList(singleton("value", 1), singleton("value", 2))); Map metadata = new LinkedHashMap(); metadata.put("/counter/name", "Counter label"); @@ -170,102 +253,22 @@ private static Map singleton(String key, Object value) { return map; } - private static Path blueQuickJsRoot() { - String configured = System.getProperty("blue.quickjs.root"); - Path root = configured == null || configured.trim().isEmpty() - ? Paths.get(System.getProperty("user.dir")).toAbsolutePath().getParent().resolve("blue-quickjs") - : Paths.get(configured); - assumeTrue(Files.isDirectory(root), "blue-quickjs checkout is required for parity tests"); - return root; + private static String repeat(char value, int count) { + char[] chars = new char[count]; + Arrays.fill(chars, value); + return new String(chars); } private static final class Fixture { private final String name; private final JavaScriptEvaluationRequest request; + private final String bindingsSummary; private Fixture(String name, JavaScriptEvaluationRequest request) { this.name = name; this.request = request; - } - } - - private static final class Evaluation { - private final boolean ok; - private final Object value; - private final String error; - private final long wasmGasUsed; - private final long hostGasUsed; - - private Evaluation(boolean ok, Object value, String error, long wasmGasUsed, long hostGasUsed) { - this.ok = ok; - this.value = value; - this.error = error; - this.wasmGasUsed = wasmGasUsed; - this.hostGasUsed = hostGasUsed; - } - - private static Evaluation ok(Object value, long wasmGasUsed, long hostGasUsed) { - return new Evaluation(true, normalize(value), null, wasmGasUsed, hostGasUsed); - } - - private static Evaluation error(String error) { - return new Evaluation(false, null, error, -1L, -1L); - } - - private Map toMap() { - Map map = new LinkedHashMap(); - map.put("ok", ok); - map.put("value", value); - map.put("error", error); - map.put("wasmGasUsed", wasmGasUsed); - map.put("hostGasUsed", hostGasUsed); - return map; - } - - @Override - public boolean equals(Object other) { - if (!(other instanceof Evaluation)) { - return false; - } - Evaluation that = (Evaluation) other; - return ok == that.ok - && wasmGasUsed == that.wasmGasUsed - && hostGasUsed == that.hostGasUsed - && java.util.Objects.equals(value, that.value) - && java.util.Objects.equals(error, that.error); - } - - @Override - public int hashCode() { - return java.util.Objects.hash(ok, value, error, wasmGasUsed, hostGasUsed); - } - - @SuppressWarnings("unchecked") - private static Object normalize(Object value) { - if (value instanceof Number) { - Number number = (Number) value; - if (number.doubleValue() == Math.rint(number.doubleValue()) - && number.longValue() >= Integer.MIN_VALUE - && number.longValue() <= Integer.MAX_VALUE) { - return Integer.valueOf(number.intValue()); - } - return value; - } - if (value instanceof List) { - List result = new ArrayList(); - for (Object item : (List) value) { - result.add(normalize(item)); - } - return result; - } - if (value instanceof Map) { - Map result = new LinkedHashMap(); - for (Map.Entry entry : ((Map) value).entrySet()) { - result.put(entry.getKey(), normalize(entry.getValue())); - } - return result; - } - return value; + this.bindingsSummary = "keys=" + request.bindings().keySet() + + ", codeChars=" + request.code().length(); } } } diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/LambdaPackagingSmokeTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/LambdaPackagingSmokeTest.java index 57b9d3c..89b09fa 100644 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/LambdaPackagingSmokeTest.java +++ b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/LambdaPackagingSmokeTest.java @@ -3,10 +3,15 @@ import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; import blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult; import blue.contract.processor.conversation.javascript.QuickJsGas; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; import java.util.Collections; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class LambdaPackagingSmokeTest { @@ -15,7 +20,6 @@ void classpathPinnedResourcesEvaluateWithoutFilesystemRoot() { ChicoryBlueQuickJsRuntime runtime = new ChicoryBlueQuickJsRuntime(BlueQuickJsWasmRuntimeConfig.builder() .blueQuickJsRoot(null) .preferClasspathResources(true) - .expectedEngineBuildHash("1d4584fc0552a24ee840afa2cca9f1536d47429f467585d4d5c1a5236ba96dc9") .build()); JavaScriptEvaluationResult result = runtime.evaluate(new JavaScriptEvaluationRequest( @@ -26,4 +30,72 @@ void classpathPinnedResourcesEvaluateWithoutFilesystemRoot() { assertEquals(3, result.value()); } + + @Test + void runtimeClasspathDoesNotContainNativeJavaScriptEngines() { + String classpath = System.getProperty("java.class.path").toLowerCase(); + for (String forbidden : new String[]{"javet", "wasmtime", "quickjs4j", "graal-js", "org.graalvm.js", "jna"}) { + assertFalse(classpath.contains(forbidden), "unexpected runtime dependency on " + forbidden); + } + } + + @Test + void classpathRuntimeWorksWhenNodeIsNotOnPath() throws IOException, InterruptedException { + String java = System.getProperty("java.home") + "/bin/java"; + ProcessBuilder builder = new ProcessBuilder(java, + "-cp", + System.getProperty("java.class.path"), + NoNodeClasspathSmokeMain.class.getName()); + builder.environment().put("PATH", "/bin"); + builder.redirectErrorStream(true); + Process process = builder.start(); + String output = readOutput(process); + int exit = process.waitFor(); + + assertEquals(0, exit, output); + assertTrue(output.contains("nodeUnavailable=true"), output); + assertTrue(output.contains("value=3"), output); + } + + private static String readOutput(Process process) throws IOException { + StringBuilder output = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append('\n'); + } + } + return output.toString(); + } + + public static final class NoNodeClasspathSmokeMain { + public static void main(String[] args) { + boolean nodeUnavailable = nodeUnavailable(); + if (!nodeUnavailable) { + System.out.println("nodeUnavailable=false"); + System.exit(2); + } + ChicoryBlueQuickJsRuntime runtime = new ChicoryBlueQuickJsRuntime(BlueQuickJsWasmRuntimeConfig.builder() + .blueQuickJsRoot(null) + .preferClasspathResources(true) + .build()); + JavaScriptEvaluationResult result = runtime.evaluate(new JavaScriptEvaluationRequest( + "1 + 2", + JavaScriptEvaluationRequest.Mode.EXPRESSION, + Collections.emptyMap(), + QuickJsGas.DEFAULT_EXPRESSION_HOST_GAS_LIMIT)); + System.out.println("nodeUnavailable=true"); + System.out.println("value=" + result.value()); + } + + private static boolean nodeUnavailable() { + try { + Process process = new ProcessBuilder("node", "--version").start(); + process.destroyForcibly(); + return false; + } catch (IOException ex) { + return true; + } + } + } } diff --git a/src/main/java/blue/contract/processor/conversation/expression/QuickJsExpressionEvaluator.java b/src/main/java/blue/contract/processor/conversation/expression/QuickJsExpressionEvaluator.java index abb8eff..1675375 100644 --- a/src/main/java/blue/contract/processor/conversation/expression/QuickJsExpressionEvaluator.java +++ b/src/main/java/blue/contract/processor/conversation/expression/QuickJsExpressionEvaluator.java @@ -61,6 +61,9 @@ public Node evaluate(Node value, StepExecutionContext context) { context.processorContext().consumeGas(result.hostGasUsed()); return JavaScriptValues.toNode(result.value()); } catch (JavaScriptExecutionException ex) { + if (ex.hasGasUsage()) { + context.processorContext().consumeGas(ex.hostGasUsed()); + } context.processorContext().throwFatal("QuickJS expression evaluation failed: " + ex.getMessage()); return null; } diff --git a/src/main/java/blue/contract/processor/conversation/expression/QuickJsExpressionResolver.java b/src/main/java/blue/contract/processor/conversation/expression/QuickJsExpressionResolver.java index 6f611ae..0916355 100644 --- a/src/main/java/blue/contract/processor/conversation/expression/QuickJsExpressionResolver.java +++ b/src/main/java/blue/contract/processor/conversation/expression/QuickJsExpressionResolver.java @@ -72,6 +72,13 @@ public Node resolve(Node value, } return resolved; } catch (JavaScriptExecutionException ex) { + long hostGasUsed = counter.hostGasUsed; + if (ex.hasGasUsage()) { + hostGasUsed += ex.hostGasUsed(); + } + if (hostGasUsed > 0L) { + context.processorContext().consumeGas(hostGasUsed); + } context.processorContext().throwFatal(ex.getMessage()); return null; } @@ -183,7 +190,10 @@ private JavaScriptEvaluationResult evaluate(String expression, Map payload) { } } - private JavaScriptEvaluationResult parseResult(String stdout) { + JavaScriptEvaluationResult parseResult(String stdout) { Map result = UncheckedObjectMapper.JSON_MAPPER.readValue(stdout, new TypeReference>() { }); @@ -143,7 +143,14 @@ private JavaScriptEvaluationResult parseResult(String stdout) { Object message = result.get("message"); Object type = result.get("type"); String prefix = type != null ? type + ": " : ""; - throw new JavaScriptExecutionException(prefix + (message != null ? message : "unknown error")); + String errorMessage = prefix + (message != null ? message : "unknown error"); + if (!result.containsKey("wasmGasUsed")) { + throw new JavaScriptExecutionException(errorMessage); + } + long errorWasmGasUsed = parseLong(result.get("wasmGasUsed"), "wasmGasUsed"); + throw new JavaScriptExecutionException(errorMessage, + errorWasmGasUsed, + QuickJsGas.toHostGasUsed(errorWasmGasUsed)); } long wasmGasUsed = parseLong(result.get("wasmGasUsed"), "wasmGasUsed"); long hostGasUsed = QuickJsGas.toHostGasUsed(wasmGasUsed); @@ -155,7 +162,11 @@ private long parseLong(Object value, String field) { return ((Number) value).longValue(); } if (value instanceof String) { - return Long.parseLong((String) value); + try { + return Long.parseLong((String) value); + } catch (NumberFormatException ex) { + throw new JavaScriptExecutionException("QuickJS bridge returned invalid " + field + ": " + value, ex); + } } throw new JavaScriptExecutionException("QuickJS bridge returned invalid " + field + ": " + value); } diff --git a/src/main/java/blue/contract/processor/conversation/workflow/JavaScriptCodeStepExecutor.java b/src/main/java/blue/contract/processor/conversation/workflow/JavaScriptCodeStepExecutor.java index b2a0447..424e46a 100644 --- a/src/main/java/blue/contract/processor/conversation/workflow/JavaScriptCodeStepExecutor.java +++ b/src/main/java/blue/contract/processor/conversation/workflow/JavaScriptCodeStepExecutor.java @@ -63,6 +63,9 @@ public WorkflowStepResult execute(JavaScriptCode step, StepExecutionContext cont emitReturnedEvents(result.value(), context); return WorkflowStepResult.value(result.value()); } catch (JavaScriptExecutionException ex) { + if (ex.hasGasUsage()) { + context.processorContext().consumeGas(ex.hostGasUsed()); + } context.processorContext().throwFatal("JavaScript Code execution failed: " + ex.getMessage()); return WorkflowStepResult.none(); } diff --git a/src/test/java/blue/contract/processor/conversation/javascript/NodeQuickJsRuntimeTest.java b/src/test/java/blue/contract/processor/conversation/javascript/NodeQuickJsRuntimeTest.java new file mode 100644 index 0000000..554e30b --- /dev/null +++ b/src/test/java/blue/contract/processor/conversation/javascript/NodeQuickJsRuntimeTest.java @@ -0,0 +1,60 @@ +package blue.contract.processor.conversation.javascript; + +import java.nio.file.Paths; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NodeQuickJsRuntimeTest { + + @Test + void vmErrorWithGasPreservesGasMetadata() { + NodeQuickJsRuntime runtime = runtime(); + + JavaScriptExecutionException ex = assertThrows(JavaScriptExecutionException.class, + () -> runtime.parseResult("{\"ok\":false,\"type\":\"vm-error\",\"message\":\"boom\",\"wasmGasUsed\":\"1700\"}")); + + assertEquals("vm-error: boom", ex.getMessage()); + assertTrue(ex.hasGasUsage()); + assertEquals(1700L, ex.wasmGasUsed()); + assertEquals(1L, ex.hostGasUsed()); + } + + @Test + void bridgeSetupErrorWithoutGasPreservesMessageWithoutFabricatingGas() { + NodeQuickJsRuntime runtime = runtime(); + + JavaScriptExecutionException ex = assertThrows(JavaScriptExecutionException.class, + () -> runtime.parseResult("{\"ok\":false,\"message\":\"blue-quickjs root argument is required\"}")); + + assertEquals("blue-quickjs root argument is required", ex.getMessage()); + assertFalse(ex.hasGasUsage()); + } + + @Test + void successfulResponseMissingGasIsMalformed() { + NodeQuickJsRuntime runtime = runtime(); + + JavaScriptExecutionException ex = assertThrows(JavaScriptExecutionException.class, + () -> runtime.parseResult("{\"ok\":true,\"value\":1}")); + + assertTrue(ex.getMessage().contains("QuickJS bridge returned invalid wasmGasUsed")); + } + + @Test + void malformedGasValueIsRejected() { + NodeQuickJsRuntime runtime = runtime(); + + JavaScriptExecutionException ex = assertThrows(JavaScriptExecutionException.class, + () -> runtime.parseResult("{\"ok\":false,\"message\":\"boom\",\"wasmGasUsed\":\"not-a-number\"}")); + + assertTrue(ex.getMessage().contains("QuickJS bridge returned invalid wasmGasUsed")); + } + + private static NodeQuickJsRuntime runtime() { + return new NodeQuickJsRuntime(Paths.get("."), Paths.get("."), 1000L); + } +}