diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b12329d..d20d1a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,26 @@ jobs: - name: Offline tests run: cargo test --locked + java: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + java: ["21", "25"] + defaults: + run: + working-directory: java + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ matrix.java }} + cache: maven + cache-dependency-path: java/pom.xml + - name: Offline tests + run: mvn -B -ntp verify + bootstrap: runs-on: ubuntu-latest strategy: @@ -137,3 +157,14 @@ jobs: working-directory: rust env: JACQUARD_REQUIRE_LIVE: "1" + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + cache-dependency-path: java/pom.xml + - name: Run independent Java adapter against Core + run: mvn -B -ntp verify + working-directory: java + env: + JACQUARD_REQUIRE_LIVE: "1" diff --git a/AGENTS.md b/AGENTS.md index f5307e5..decc05c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,9 +47,9 @@ invent messages that Core has not specified. - Keep the first carrier serial, bounded, versioned, and fail-closed. - Preserve transport independence: process framing is one carrier, not the semantic definition of the boundary. -- The Python, TypeScript, Go, and Rust adapters each implement the pinned - fixtures independently. Keep pin constants and kit copies generated by - `scripts/core_pin.py`. +- The Python, TypeScript, Go, Rust, and Java adapters each implement the + pinned fixtures independently. Keep pin constants and kit copies generated + by `scripts/core_pin.py`. - Add tests with behavior. Use the standard library until a reviewed need justifies a dependency. - Work on branches and merge through pull requests. The empty repository seed diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d28122..90ade36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,21 @@ handler reports completion unknown. As a consequence `HandlerError` cannot implement `std::error::Error`, and a downstream `From` impl for a type that implements `Error` conflicts with it. +- Java adapter: an independent Maven project in `java/` + (`org.jacquardlang:jacquard-host`, Java 21+) with no third-party runtime + dependencies (on Unix it runs the system `mkfifo`). + It provides strict codecs, the session state machine, a bounded process + carrier for `jacquard host worker`, and a `Host` with a closed operation + registry. It carries the pinned kit as class-path resources and replays all + 21 recordings. It is protocol-level only, with no SDK layer. Java cannot + start the worker in its own process group or interrupt a pipe read, so the + carrier never blocks on a read, reads the worker's output through FIFOs it + creates with `mkfifo` on Unix (so the JDK's exit-time pipe drain cannot hold + the caller), kills the worker's process tree from a `ProcessHandle` + snapshot, and documents what that misses in `docs/LIMITS.md`. `scripts/core_pin.py` now generates + `java/src/main/java/org/jacquardlang/host/Compat.java` and keeps the Java kit + copy in sync, and CI runs the offline tests on Java 21 and 25 and the live + tests against the pinned Core. ## 0.2.0 — 2026-10-25 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f1f5727..a84494c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,9 @@ Contributions are licensed under [Apache 2.0](LICENSE), matching this repository `core-pin.json` names the one Core release all adapters target. Do not edit `src/jacquard_host/compat.py`, `typescript/src/compat.ts`, `go/protocol/compat.go`, -`rust/src/compat.rs`, the Go and Rust kit copies under `go/kit/data/` and -`rust/kit/`, or the generated block +`rust/src/compat.rs`, `java/src/main/java/org/jacquardlang/host/Compat.java`, +the Go, Rust, and Java kit copies under `go/kit/data/`, `rust/kit/`, and +`java/src/main/resources/org/jacquardlang/host/kit/`, or the generated block in `docs/COMPATIBILITY.md` by hand: change the pin with `python3 scripts/core_pin.py update --tag ...`, which also vendors the kit, and `python3 scripts/core_pin.py check` (run in CI) reports any drift. @@ -111,6 +112,30 @@ kit. `scripts/core_pin.py` maintains both; do not edit them by hand. The `jacquard-examples` Cargo example reads the rules and inputs from `examples/` directly, so there is no copy to keep in sync. +## Java + +The independent Java adapter lives in `java/` as Maven artifact +`org.jacquardlang:jacquard-host`. It has no third-party runtime dependencies +and does not call Python, Node.js, Go, or Rust; on Unix it runs the system +`mkfifo`. JUnit 5 is its only test dependency. Java 21+ and Maven 3.9+ are +required. + +```sh +cd java +mvn -B verify +# With the pinned Core configured (eval "$(python3 ../scripts/core_pin.py install)"): +JACQUARD_REQUIRE_LIVE=1 mvn -B verify +``` + +CI runs the offline tests on Java 21 and 25, and the live tests on Java 21 in +the shared pinned-Core job. The compiler runs with `-Xlint:all -Werror`. +`java/src/main/java/org/jacquardlang/host/Compat.java` is generated and the +kit under `java/src/main/resources/` is a byte copy of the pinned kit. +`scripts/core_pin.py` maintains both; do not edit them by hand. The `StandIn` +main class in the test sources is a fixture for the offline tests. Copy the +repository `NOTICE` to `java/NOTICE` when it changes (a test fails if they +differ). + ## Releasing A release is one commit on `main` with green CI. To cut one: @@ -118,7 +143,7 @@ A release is one commit on `main` with green CI. To cut one: 1. Open a pull request that dates the CHANGELOG entry and updates any version references in the docs. Keep the Python (`src/jacquard_host/__init__.py`), npm (`typescript/package.json`, `typescript/src/version.ts`), Rust - (`rust/Cargo.toml`), and release versions equal. + (`rust/Cargo.toml`), Java (`java/pom.xml`), and release versions equal. 2. After it merges, tag the merge commit twice, with annotated tags (`git tag -a`): `jacquard-host-X.Y.Z` for the repository and its assets, and `go/vX.Y.Z` @@ -131,8 +156,8 @@ A release is one commit on `main` with green CI. To cut one: 4. Publish a GitHub release for `jacquard-host-X.Y.Z` with the CHANGELOG entry as its notes and the assets attached. -Nothing is published to PyPI, npm, or crates.io. The Rust crate is used from -the repository tag. +Nothing is published to PyPI, npm, crates.io, or Maven Central. The Rust +crate is used from the repository tag, and the Java artifact is built from it. ## TypeScript diff --git a/MANIFEST.in b/MANIFEST.in index ebfedd3..581e7b4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -14,3 +14,6 @@ recursive-include go *.go *.md *.json *.jac include rust/Cargo.toml rust/Cargo.lock rust/rustfmt.toml rust/LICENSE rust/NOTICE recursive-include rust *.rs *.md *.json *.jac prune rust/target +include java/pom.xml java/LICENSE java/NOTICE +recursive-include java *.java *.md *.json *.jac +prune java/target diff --git a/NOTICE b/NOTICE index cccb047..09bc7d2 100644 --- a/NOTICE +++ b/NOTICE @@ -4,9 +4,10 @@ Copyright 2026 Jacquard Host contributors This product includes conformance artifacts from Jacquard Core, https://github.com/jbwinters/jacquard-lang, licensed under Apache License 2.0. The artifacts under src/jacquard_host/data/conformance/host-protocol-v0/, -and their byte-identical copies under go/kit/data/host-protocol-v0/ and -rust/kit/host-protocol-v0/, are copied verbatim from the commits recorded in -docs/COMPATIBILITY.md. +and their byte-identical copies under go/kit/data/host-protocol-v0/, +rust/kit/host-protocol-v0/, and +java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/, are +copied verbatim from the commits recorded in docs/COMPATIBILITY.md. Upstream notice: Jacquard diff --git a/README.md b/README.md index 538ffa3..da51585 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,10 @@ third-party runtime dependencies. The [TypeScript SDK](typescript/README.md), the [Go SDK](go/README.md), and the [Rust SDK](rust/README.md) implement the pinned protocol independently. The TypeScript SDK supports async service methods, the Go SDK maps native Go types and context cancellation, and the Rust -SDK maps native Rust types through `record!` and `variants!` declarations. +SDK maps native Rust types through `record!` and `variants!` declarations. A +protocol-level [Java adapter](java/README.md) (Java 21+) also implements the +pinned protocol independently. It has a closed operation registry and kit +replay, but no SDK layer yet. **Experimental:** the SDK targets Core's pinned `jacquard-host-v0` protocol. Core is installed separately. Each invocation runs one fresh, serial worker; @@ -113,8 +116,8 @@ participant collection require separate application work. See [CONTRIBUTING.md](CONTRIBUTING.md) for offline tests, installed-distribution checks, and live runs. CI tests Python 3.11–3.13, Node.js 22/24, Go 1.24 and -stable, and Rust 1.88 and stable. It separately installs the pinned Core release -for every adapter's live suite and the public examples. +stable, Rust 1.88 and stable, and Java 21 and 25. It separately installs the +pinned Core release for every adapter's live suite and the public examples. [Evidence](docs/EVIDENCE.md) records the checks and platform claims; [development experiments](docs/development/README.md) preserve the usability diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 26fe6f1..6de1279 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -6,11 +6,13 @@ Every adapter in this repository is pinned to one published Jacquard Core release, recorded once in [`core-pin.json`](../core-pin.json). The Python constants (`src/jacquard_host/compat.py`), the TypeScript constants (`typescript/src/compat.ts`), the Go constants (`go/protocol/compat.go`), the -Rust constants (`rust/src/compat.rs`), and the block below are generated from -that file by `scripts/core_pin.py sync`. `scripts/core_pin.py check` fails CI -when any of them drifts from the pin. It also fails when any vendored kit copy -drifts: the Python package data, Go's embedded `go/kit/data/`, or the Rust -crate's embedded `rust/kit/`. Adapters verify the kit's four +Rust constants (`rust/src/compat.rs`), the Java constants +(`java/src/main/java/org/jacquardlang/host/Compat.java`), and the block below +are generated from that file by `scripts/core_pin.py sync`. +`scripts/core_pin.py check` fails CI when any of them drifts from the pin. It +also fails when any vendored kit copy drifts: the Python package data, Go's +embedded `go/kit/data/`, the Rust crate's embedded `rust/kit/`, or the Java +class-path copy under `java/src/main/resources/`. Adapters verify the kit's four digests independently at load time, and the SDKs refuse a Core whose `--version` differs from the pinned version. @@ -41,7 +43,7 @@ Release assets verified by `scripts/core_pin.py install`: | macos-x86_64 | `jacquard-macos-x86_64.tar.gz` | `8efc54bcf7573cb7cfd35fa3e77a584680ce68de872bdb212ed8369a974ff967` | -Python, TypeScript, Go, and Rust replay the same 21 recordings against the pinned +Python, TypeScript, Go, Rust, and Java replay the same 21 recordings against the pinned worker, including Core's recorded `noncanonical-target-hash` divergence: the HB.1 vector expects E1603 while the shipped preflight classifies an uppercase target hash as a malformed scalar (E1601). The transcript is recorded as @@ -52,8 +54,8 @@ independent v0 evidence; it does not change the protocol version or declare v1. The adapters decode boundary descriptors in Core's order: the kind, then the exact fields, then one node of `max_value_nodes`, then any identity, then the list count. Malformed input therefore gets the same error code from every -adapter and from Core. For recording comparisons, Python, Go, and Rust sort object keys -by code point, while TypeScript sorts them by UTF-16 code unit. The two orders +adapter and from Core. For recording comparisons, Python, Go, Rust, and Java sort object +keys by code point, while TypeScript sorts them by UTF-16 code unit. The two orders differ only for keys outside the Basic Multilingual Plane. Every protocol key is ASCII, so the comparisons agree. @@ -114,6 +116,7 @@ python3 -m pip install . && python3 scripts/check_examples.py (cd typescript && npm ci && npm test && npm run test:live && npm run check:package) (cd go && go test -race ./... && go test -count=1 ./...) (cd rust && cargo test) +(cd java && JACQUARD_REQUIRE_LIVE=1 mvn -B verify) ``` `update` resolves the tag to its commit, copies the kit files from diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index a963f20..849544c 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -92,6 +92,21 @@ uses only the standard library. | Rust projects live | 3 further tests pass: all four `jacquard-examples` commands, including duplicate fulfillment, `--inspect`, explicit delivery, a failed delivery printing no count, and usage errors; the built command exiting 1 with E1613 for a failed publish; and the three fulfillment faults (E1612 after commit, E1613 on publish, E1612 after delivery) recovered explicitly across restart, each receipt delivered once | | Unchanged lanes | The adapter's 28 offline, descriptor, fallback, NOTICE, and 4 live tests, the 21-recording kit replay, `scripts/core_pin.py check`, the offline Python suite, and `scripts/check_distribution.py` pass | +## Independent Java adapter (2026-10-27) + +Recorded against the same `jacquard-core-0.3.0` release asset (linux-x86_64), +with Temurin 21.0.13 and 25.0.5 and Maven 3.9.16 on Linux 7.0. The adapter has +no third-party runtime dependencies (on Unix it runs the system `mkfifo`); +JUnit 5.14.4 is its only test dependency. + +| Lane | Result | +|---|---| +| Java offline (`mvn -B -ntp verify` without Core) | 60 tests pass on both JDKs, and the 5 live tests skip: 36 codec, session, replay, and host-fault tests, 6 process-tree tests, 8 descriptor tests (Linux only), 3 setup tests (Unix only), 5 writer-gate tests, and the NOTICE and README-example copy checks. Coverage: strict JSON, Unicode, and duplicate keys, including encoded surrogates and non-ASCII `\u` digits; code-point key order; exact Int and Real codecs; limits; Core-order descriptor checks; hello and selection; invoke-frame validation; session ordering and one response slot; the shared outcome node budget. Fault cases run against a stand-in JVM: malformed, extra, and noncanonical Core frames; EOF, truncated, silent, stalled, and nonzero workers; bounded stderr; write backpressure; frames sent before selection read under the selected limits (E1602); cancellation; interruption of the calling thread, reported as an abort with the interrupt status restored; local refusal before spawn; null arguments refused before spawn; registry ordering; frame encoding that stops at max_frame_bytes, so a handler result of 256 references to one 256 KiB text (within every value limit, over 384 MiB once escaped) is refused as an outside failure within 3 s, and a cancellation from another thread 100 ms after the handler starts does not hold the call past 3 s (before the fix the encoder built the whole payload first: several seconds past a 500 ms deadline, or an OutOfMemoryError; on the old encoder both tests exhaust the test JVM's heap); no handler starts when the call is cancelled, or the caller interrupted, while a 256 KiB effect request is being decoded, or on the handler thread just before its own check (a test seam stops the call there): the lifetime is checked before dispatch and again on the handler thread, the caller's interrupt included (before the fix the handler started 2 to 12 ms after a stop during decoding; without the thread's check, or without the interrupt in it, the seam tests fail); the caller records its interrupt for other threads before consuming it, and waits without clearing it, so a handler check that runs after the caller has consumed the interrupt still stops (before the fix the handler ran); validation charges every text against the frame and refuses a text longer in chars than max_text_bytes before scanning it, so 4 x 1022 references to one 256 KiB emoji text are refused as an outside failure within 500 ms of the handler's return (before the fix about 1.1 s, and longer for larger texts), and validation itself refuses that value with "payload exceeds max_frame_bytes" (on the old Budget it accepted it); a 32 Mi-char text is refused as an outside failure; replay stops reading, and reports a difference, once a worker sends more frames than the recording, so 300 frames of 600 KB after the terminal neither hold the replay past 3 s nor grow the heap by 64 MiB (before the fix replay kept every frame and exhausted the heap); handler answers (structured failures, checked and unchecked exceptions, errors, `null`, an out-of-range Int, and a message cut at a code point with a lone surrogate replaced). Process tree: a child, and a grandchild under a waiting shell, holding the pipes are killed at the deadline; a descendant that daemonized while the worker ran, and one left by a launcher that exited, are not found and survive; the exit wait obeys the deadline and reports SIGKILL; a slow clean exit succeeds; cleanup after an early protocol error is not held by an escaped descendant; a descendant holding stderr after a successful exit is not observed (the result is ok), while a nonzero exit is a carrier loss with status 74. Descriptors: no descriptor or adapter-thread growth across successful invocations, an escaped descendant, handler calls, and repeated expiry, cancellation, interruption, and truncated-frame failures; with the worker still alive and its child holding stdin without reading, expiry and cancellation each return within 3 s, kill the child, and end the blocked write and the stdin thread (both tests hang for the child's 30 s lifetime when the worker is killed with `Process.destroyForcibly()`); a reparented `yes` flooding stderr at the worker's own exit, at a 500 ms deadline, and at cancellation after 500 ms, and one flooding stdout, each return within 3 s of the deadline or cancellation, grow the heap by less than 64 MiB, and leave the writer without a reader so it exits (on the JDK's own pipes the same four return after 6.4 to 7.1 s, and the heap grows by up to 320 MiB); a write blocked by an escaped descendant holding stdin returns at the deadline, keeps exactly the stdin thread until the descendant is killed, then releases it and its descriptors. Setup: with a `mkfifo` on the `PATH` that never finishes, cancellation and interruption after 100 ms are reported as `aborted` (the interrupt status kept) and a 500 ms deadline as `deadline`, each within 2 s, leaving no temporary files and no `mkfifo` running (before the fix all three waited for `mkfifo` and reported `worker_spawn_failed`). Writer gate: a closed gate, a passed deadline, a cancellation, or an interrupted caller stops it, and it writes nothing then, and a gate closed during a frame stops it after the first 4096-byte chunk. Kit: all 21 recordings replay through the stand-in, absent expectations diverge as in the reference, and prose drift is reported without normalization | +| Java live (`JACQUARD_REQUIRE_LIVE=1 mvn -B -ntp verify` with `JACQUARD_BIN` and `JACQUARD_PRELUDE` set) | 65 tests pass on both JDKs, none skipped: the 60 offline tests and 5 live ones. All 21 recordings match against Core with no prose drift; pure and effectful invocations keep exact evidence, and a shutdown probe is acknowledged; the README example returns `ping!`; the four failure pairs, and checked exceptions, unchecked exceptions, and errors, are reported without retry or private detail; cooperative and abandoned handlers at a 500 ms deadline send no late response, and both see the context cancelled. With `JACQUARD_REQUIRE_LIVE=1` and no Core configured, the 5 live tests fail rather than skip | +| Compiler | `-Xlint:all -Werror` is clean on both JDKs | +| Pin consistency | `java/src/main/java/org/jacquardlang/host/Compat.java` is generated, and the class-path kit copy matches the pinned digests and the Python copy (`scripts/core_pin.py check`); `tests/test_core_pin.py` covers drift in both, and the offline Python suite passes | +| Distribution | `scripts/check_distribution.py` passes; the source archive carries `java/pom.xml`, `java/NOTICE`, the Java sources, and the kit copy | + ## Outcome frame node budget (2026-10-25) Core's `outcome` encodes the evidence's interface types and the result value @@ -180,6 +195,7 @@ reaches persistence has request ordinal 1 and one accepted `ok` observation. | Linux, Node.js 22 and 24 | CI lanes | Offline TypeScript tests and npm packaging on both; real-worker TypeScript tests and installed examples on Node 24 in the shared pinned-Core job | | Linux, Go 1.24 and stable | CI lanes | Offline Go tests with the race detector on both; live Go tests in the shared pinned-Core job | | Linux, Rust 1.88 and stable | CI lanes | fmt, clippy, and offline Rust tests on both; live Rust tests in the shared pinned-Core job | +| Linux, Java 21 and 25 | CI lanes | Offline Java tests on both; live Java tests on Java 21 in the shared pinned-Core job; offline and live suites also run locally on Temurin 21.0.13 and 25.0.5 | | Linux, Node.js 26.7 | exercised | TypeScript offline/live suites and tarball installation exercised locally; no broader runtime or OS claim | | macOS | development only | no CI; the descriptor-count assertions are skipped without `/proc` (the socket host tests skip, the kit and adapter tests skip only the count); SQLite WAL mode is recorded, not assumed | | Windows and others | no claim | see LIMITS.md | diff --git a/docs/LIMITS.md b/docs/LIMITS.md index 4889ae6..c2b5bfe 100644 --- a/docs/LIMITS.md +++ b/docs/LIMITS.md @@ -9,7 +9,7 @@ also updates this document. | First-party Jacquard programs only | Capability checks do not make hostile uploaded code safe. | A separate-process or equivalent isolation design, adversarial review, resource ceilings, and a narrower public threat model. | | Trusted host and operating system | A malicious or buggy adapter can misreport or perform actions outside Jacquard. | An authenticated, independently enforced host boundary and external security review; canonical hashes alone are insufficient. | | Application capability boundary, not OS sandbox | Declared and checked effects are review and application enforcement, not kernel isolation. | An explicit OS/WebAssembly/container isolation layer with its own evidence and deployment contract. | -| Experimental process carrier (Core's `v0`, evidence pack published) | Message framing may change and is not a permanent ABI promise; Python, TypeScript, Go, and Rust replay the pinned kit independently, while a stable protocol decision remains Core's. | Two independent adapters and the serial application pass the same frozen fixtures before Core declares protocol v1 stable. | +| Experimental process carrier (Core's `v0`, evidence pack published) | Message framing may change and is not a permanent ABI promise; Python, TypeScript, Go, Rust, and Java replay the pinned kit independently, while a stable protocol decision remains Core's. | Two independent adapters and the serial application pass the same frozen fixtures before Core declares protocol v1 stable. | | Handlers run inside the adapter's read loop | A Python operation handler that blocks holds the worker until it returns. The host deadline is one lifetime budget from spawn, enforced on pipe reads, writes, and exit waits; a slow handler spends the budget and the next carrier operation expires. It cannot interrupt Python handler code. | A handler timeout policy with its own cancel/unknown-completion evidence, once an application needs one. | | Go worker descendants that leave the process group | The Go host stops the worker's process group at expiry, and on Linux also after exit while a descendant still holds stderr; elsewhere, and for a descendant that called `setsid` (or otherwise left the group), it only stops reading the pipe. | An OS-level containment mechanism (for example a cgroup or job object) with its own evidence. | | Go handler cancellation is cooperative | At the deadline or on context cancellation the Go host kills the worker's process group (while the worker is unreaped, which Linux guarantees), closes its pipes, and reaps it, cancels the handler's context, and sends no late response; a handler that ignores its context keeps running in its goroutine until it returns. No outside action is stopped or rolled back. | A separately enforced operation lifetime with explicit completion and reconciliation evidence. | @@ -17,6 +17,10 @@ also updates this document. | Rust handler cancellation is cooperative | Each Rust handler runs on its own thread. At the deadline or on cancellation the host kills the worker's process group, closes its pipes, reaps it, and sends no late response; `HandlerContext::is_cancelled` turns true, and stays true once the invocation ends. A handler that ignores it keeps running on its thread until it returns. No outside action is stopped or rolled back. | A separately enforced operation lifetime with explicit completion and reconciliation evidence. | | Rust SDK field order is declared, not exported | `record!` and `variants!` list fields in the order the Rust code declares; the pinned CLI exports no field types, so a wrong order is not detected locally, and Core refuses it only where the swapped fields' types differ. | Core's structured host interface exporter, so bindings can be generated or checked. | | Rust SDK preparation is not cancellable | Each Core `--version`, `check`, `hash`, and `run` command runs in its own process group with a 30-second timeout and bounded output; `prepare` takes no `Cancel`. On Linux the group is also stopped when the command exits. A descendant that left the group, or on other platforms any descendant, that keeps Core's output open fails preparation after a one-second grace and keeps running; on Unix the reader threads stop and the pipes close, while on non-Unix targets a reader blocked on such a pipe stays until the descendant closes it. | A cancellable preparation API, once an application needs one. | +| Java worker process tree, not group | Java cannot start the worker in its own process group, so the Java host kills a process tree. At expiry, cancellation, or disposal of a running worker, it snapshots `Process.descendants()` and kills the worker and every process in the snapshot. A descendant that was already reparented keeps running: one whose parent exited, or one that daemonized. So does one started after the snapshot. The JDK reaps the worker as soon as it exits. So stdout ends once the worker has exited and its pipe is empty, rather than when the worker closes it, and a descendant still holding a pipe after the exit is neither detected nor stopped. The Go and Rust hosts report a descendant holding stderr after a successful exit as a carrier loss; the Java host reports the worker's own result. The JDK reports a signal as exit status 128 + its number, so only the host's own SIGKILL is reported as a signal. | An OS-level containment mechanism (for example a cgroup or job object) with its own evidence, or a JDK process-group API. | +| Java pipe I/O cannot be interrupted | On Unix the Java host creates the worker's stdout and stderr as FIFOs (it needs `mkfifo`) and holds their read ends itself, because the JDK's own process pipes drain on its reaper thread at exit, under the lock `available()` takes and into an unbounded buffer, for as long as a descendant writes. On other systems it uses the JDK's pipes, so a descendant that keeps writing after the worker exits holds the caller, and grows the heap, until it stops. The Java host never blocks on a read. It reads stdout and stderr on the caller's thread only after `available()` reports bytes, in polls of 0.1 to 5 ms that recheck the deadline, the `Cancel`, and the interrupt status. Writes run on one stdin thread in chunks of at most 4096 bytes. Each chunk starts only while input is open, the lifetime is live, and the caller is not interrupted; a chunk already started may complete, and reach the worker, until the caller's next poll kills it (at most 5 ms after a cancellation). A descendant that escaped the kill may hold the stdin pipe without reading. Then a blocked write, or the flush and close that end the thread, keeps the stdin thread, and the JDK's process reaper thread for that worker, until the descendant exits or reads. The caller still returns at the deadline. | A JDK API for interruptible or non-blocking process pipes, or a carrier that does not use process pipes. | +| Java handler cancellation is cooperative | Each Java handler runs on its own daemon thread. At the deadline, on cancellation, or when the calling thread is interrupted, the host kills the worker's process tree, closes its input, and sends no late response. `HandlerContext.isCancelled` turns true, and stays true once the invocation ends. A handler that ignores it keeps running on its thread until it returns. No outside action is stopped or rolled back. | A separately enforced operation lifetime with explicit completion and reconciliation evidence. | +| Java adapter is protocol-level only | Java callers pass exact identities, boundary types, and protocol values to a prepared store; there is no Java SDK for program preparation, native type mapping, or caching. Linux runs in CI. macOS has no CI; the offline descendant tests need `sh` and `sleep`, and the descriptor test needs `/proc`. Windows is not claimed. | A Java SDK with typed-binding and preparation evidence comparable to the other SDKs, and CI on each added platform. | | Node handler cancellation is cooperative | The Node adapter can expire while awaiting a Promise, signal the handler, and kill the worker; it cannot stop an outside action or preempt JavaScript that blocks the event loop. No late response or automatic retry follows expiration. | A separately enforced operation lifetime with explicit completion and reconciliation evidence. | | Socket host closes without a response on its own failures | A ceiling violation, early disconnect, or failed invocation ends the connection silently; clients see a reset, not an HTTP error. | An owner decision that the host may emit fixed minimal status lines, recorded here with the exact bytes and the rule that they carry no application meaning. | | Request bytes cross as Latin-1 `Text` | v0 has no bytes value; each byte is one codepoint 0..255 and a response codepoint above 255 is refused by the host. | A frozen bytes value in a later protocol version. | diff --git a/go/NOTICE b/go/NOTICE index cccb047..09bc7d2 100644 --- a/go/NOTICE +++ b/go/NOTICE @@ -4,9 +4,10 @@ Copyright 2026 Jacquard Host contributors This product includes conformance artifacts from Jacquard Core, https://github.com/jbwinters/jacquard-lang, licensed under Apache License 2.0. The artifacts under src/jacquard_host/data/conformance/host-protocol-v0/, -and their byte-identical copies under go/kit/data/host-protocol-v0/ and -rust/kit/host-protocol-v0/, are copied verbatim from the commits recorded in -docs/COMPATIBILITY.md. +and their byte-identical copies under go/kit/data/host-protocol-v0/, +rust/kit/host-protocol-v0/, and +java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/, are +copied verbatim from the commits recorded in docs/COMPATIBILITY.md. Upstream notice: Jacquard diff --git a/java/.gitignore b/java/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/java/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/java/LICENSE b/java/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/java/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/java/NOTICE b/java/NOTICE new file mode 100644 index 0000000..09bc7d2 --- /dev/null +++ b/java/NOTICE @@ -0,0 +1,14 @@ +Jacquard Host +Copyright 2026 Jacquard Host contributors + +This product includes conformance artifacts from Jacquard Core, +https://github.com/jbwinters/jacquard-lang, licensed under Apache License 2.0. +The artifacts under src/jacquard_host/data/conformance/host-protocol-v0/, +and their byte-identical copies under go/kit/data/host-protocol-v0/, +rust/kit/host-protocol-v0/, and +java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/, are +copied verbatim from the commits recorded in docs/COMPATIBILITY.md. + +Upstream notice: +Jacquard +Copyright 2026 Josh Winters diff --git a/java/README.md b/java/README.md new file mode 100644 index 0000000..f9173ec --- /dev/null +++ b/java/README.md @@ -0,0 +1,227 @@ +# Jacquard Host for Java + +An independent Java adapter for Jacquard Core's experimental `jacquard-host-v0` +protocol. It implements the pinned protocol on its own, without calling Python, +Node.js, Go, or Rust, and has no third-party runtime dependencies. Java 21+ +is required, and on Unix the `mkfifo` command must be on the JVM's `PATH` +(see "Process carrier" below). + +This is the protocol-level adapter, comparable to the Go `host` package and the +Rust `host` module. It includes strict codecs, the host session state machine, +and a bounded process carrier for `jacquard host worker`. It also provides a +`Host` with a closed operation registry and a replayer for Core's pinned +conformance kit. There is no SDK layer yet. It does not compile programs, map +native types, or cache stores. You prepare a store with the Core CLI and pass +exact identities and boundary types. + +**Experimental:** the protocol is Core's pinned `jacquard-host-v0`, and Core is +installed separately. Each invocation runs one fresh, serial worker. The host +and operating system are trusted. Linux is exercised; macOS should work but has +no CI; Windows is not claimed. See [limits](../docs/LIMITS.md). + +## Use + +The artifact is not published to a package registry. Build and install it from +a clone (`mvn -B install` in this directory), then depend on it: + +```xml + + org.jacquardlang + jacquard-host + 0.2.0 + +``` + +The example below invokes a checked function whose single operation is served +by a Java handler: + +```java +import java.time.Duration; +import java.util.List; + +import org.jacquardlang.host.HandlerException; +import org.jacquardlang.host.Host; +import org.jacquardlang.host.HostOptions; +import org.jacquardlang.host.InvocationException; +import org.jacquardlang.host.OperationRegistry; +import org.jacquardlang.host.carrier.Cancel; +import org.jacquardlang.host.carrier.Options; +import org.jacquardlang.host.protocol.BoundaryType; +import org.jacquardlang.host.protocol.Hash; +import org.jacquardlang.host.protocol.Interface; +import org.jacquardlang.host.protocol.Value; + +public final class Echo { + public static Value echo(String store, Hash target, Hash text, Hash world, Hash send) throws InvocationException { + // The command defaults to $JACQUARD_BIN, then `jacquard`. + Options carrier = Options.defaults().withDeadline(Duration.ofSeconds(5)); + Host host = new Host(store, new HostOptions(carrier, null)); + OperationRegistry registry = new OperationRegistry().bind(world, send, (context, args) -> { + if (context.isCancelled()) { + throw HandlerException.completionUnknown("cancelled"); + } + if (args.size() == 1 && args.get(0) instanceof Value.Text input) { + return new Value.Text(input.value() + "!"); + } + throw HandlerException.outsideFailed("expected one text argument"); + }); + BoundaryType textType = new BoundaryType.Nominal(text, List.of()); + Interface spec = new Interface(List.of(textType), textType, List.of(world)); + return host.invoke(target, spec, List.of(new Value.Text("ping")), registry, new Cancel()).valueOrThrow(); + } +} +``` + +`invoke` returns an `InvocationResult` in every case; only a null argument +throws (`NullPointerException`), before any worker starts. The result keeps +Core's frames, diagnostics, and evidence. It also keeps the host's frames, exit +status, bounded stderr, and host evidence (`jacquard-host-java-evidence-v0`). +`valueOrThrow` returns the value, or throws an `InvocationException`. The +exception's `failure()` is one of `DEADLINE_EXCEEDED`, `WORKER_LOST`, +`OPERATION_FAILED`, or `OTHER`. + +Values are `Value.Int` (Core's signed 63-bit integer, held in a `long`), +`Value.Text`, `Value.Real` (the exact binary64 bits, so NaN payloads and signed +zero survive), `Value.HashValue`, `Value.Tuple` (`Value.UNIT` is the empty +tuple), and `Value.Constructor`. A `long` outside `-2^62..2^62-1` has no +encoding and is refused with E1604; a string with a lone surrogate, which Java +strings can hold, is refused with E1601, as in the TypeScript adapter, unless +it is already too long for `max_text_bytes` or the frame, which is refused +with E1602 before it is scanned. + +## Handlers and cancellation + +A handler returns a value or throws a `HandlerException`. The four structured +categories are `unsupported`, `refused`, `outsideFailed`, and +`completionUnknown`. Any other exception or error a handler throws is reported +to Core as completion unknown, and its message is never sent. A returned value +with no encoding within the selected limits, including `null`, is reported as +an outside failure. A message longer than `max_host_message_bytes` is cut at a +code point, and a lone surrogate in it becomes U+FFFD. + +Each handler runs on its own daemon thread, bounded by the worker's lifetime +deadline. No handler starts once the call is cancelled, interrupted, or past +its lifetime: the host checks after decoding the request, and the handler +thread checks again, caller's interrupt included, just before calling the +handler. A stop that lands after that check is seen by the handler through +its context. At the deadline, when +the caller's `Cancel` is triggered, or when the calling thread is interrupted, +the host kills the worker's process tree and sends no late response. An +interrupt is reported as an abort, and the caller's interrupt status is +restored before `invoke` returns. Cancellation is cooperative. +`HandlerContext.isCancelled` turns true as soon as the call is cancelled, the +caller interrupted, or the lifetime passed, and stays true once the invocation +ends, but a handler that ignores it keeps running on its thread until it +returns. Nothing is retried. + +## Process carrier + +Java cannot start a process in its own process group, and a blocking read +from a pipe cannot be interrupted. The carrier is built around both: + +- **The host owns the output pipes on Unix.** The JDK's own process pipes + drain themselves on its process reaper thread when the process exits, under + the stream lock that `available()` and `close()` also take, for as long as a + descendant keeps writing, and into a buffer with no bound. So on Unix the + host creates two FIFOs with `mkfifo -m 600` in a private temporary + directory, opens each read-write (the open never waits, and the host always + holds a read end), redirects the worker's stdout and stderr to them, and + removes the directory once the worker starts. The JDK never touches those + descriptors, and output a descendant keeps writing stays in the kernel's + pipe buffer (64 KiB by default on Linux; a writer can enlarge it), where + the writer blocks, until the host disposes the worker and closes its ends; + the writer's next write then fails. If `mkfifo` is missing, fails, or takes + more than 5 s, the worker does not start and the invocation reports a + carrier loss (`worker_spawn_failed`). Making the FIFOs obeys the same deadline, + `Cancel`, and interrupt status as the rest of the call, which then reports + `deadline` or `aborted` and does not start the worker. On other systems the + JDK's pipes and their drain are used. +- **Reads never block.** stdout and stderr are read on the caller's thread, + only after `available()` reports bytes in the pipe. Between polls the + thread parks for 0.1 to 5 ms. Every wait, whether for a frame, a write, a + handler, or the exit, rechecks the one lifetime deadline, the `Cancel`, and + the interrupt status. No thread reads stdout or stderr, so none can be + left blocked on them, and at most 1 MiB of stderr is read per poll, of which + only the bounded prefix is kept. +- **Writes run on one stdin thread**, because a pipe write can block. The + caller waits for each write under the lifetime. Each frame is written in + chunks of at most 4096 bytes. Each chunk starts only while input is open, + the deadline has not passed, cancellation has not been requested, and the + calling thread is not interrupted. A + chunk already started may still complete, and the worker may read it, + until the caller's next poll kills the worker: at most one poll slice + (5 ms) after a cancellation, and less after the deadline. +- **Expiry kills a process tree, not a group.** At expiry, cancellation, or + disposal of a running worker, the host snapshots `Process.descendants()` + and calls `destroyForcibly()` (SIGKILL on Unix) on the worker's + `ProcessHandle` and on every process in the snapshot. It signals through the + handle because `Process.destroyForcibly()` also closes stdin on the calling + thread, which would wait behind a blocked write. Killing the worker and a + descendant that holds stdin ends such a write. A descendant that was + already reparented is not in the snapshot and keeps running: one whose + parent exited, or one that daemonized. So is one started after the + snapshot. +- **The JDK owns the exit.** It reaps the worker as soon as it exits. So + stdout ends when the worker has exited and its pipe is empty, not when the + worker closes stdout. A descendant still holding a pipe after the exit is + neither observed nor waited for. The + Go and Rust hosts report a descendant that keeps stderr open after a + successful exit as a carrier loss and, on Linux, stop it with the worker's + process group. The Java host reports the worker's own result, and the + descendant keeps running. +- **Signals are inferred.** The JDK reports a process ended by signal N as + exit status 128 + N. The host reports `signal() == "SIGKILL"` only when it + killed the worker itself and the status is 137; any other value is + reported as the exit status. + +What this guarantees: an invocation returns at its deadline plus bounded +cleanup. Each cleanup wait is capped at one second: for a killed worker's exit +to be recorded, for a still-running worker to exit at disposal, and for the +stdin thread to stop. So in the worst case `invoke` returns about three seconds +after the deadline. On Unix this holds even while a descendant that escaped +the kill floods stdout or stderr, and the flood does not reach the heap. The +carrier leaves no thread or pipe descriptor behind, with one exception: a +stdin write, or the flush and close that end the stdin thread, blocked on a +pipe whose read end is held by a descendant that escaped the kill and never +reads. That stdin thread stays blocked until the descendant exits or reads, +and so does the JDK's process reaper thread for that worker, which drains the +same stream under its lock. The caller is not held. On non-Unix systems the +JDK's output pipes are used, so a descendant that keeps writing after the +worker exits holds the caller, and grows the heap, until it stops. Separately, a handler that ignores cancellation keeps its own thread +until it returns (see above). Linux tests count descriptors and adapter threads +across successful and failed invocations, and check that a blocked write ends +when expiry or cancellation kills a live worker and the child holding its +stdin, and that a reparented descendant flooding stdout or stderr holds +neither the caller nor the heap, at a worker's own exit, at expiry, and at +cancellation. + +## Conformance kit + +`Kit.load()` verifies the class-path copy of the kit against the pinned +digests. `Replayer.replayKit` replays all 21 recordings, including the hostile +host inputs, against a worker. It reports any semantic differences and any +drift in non-normative diagnostic prose. + +## Development + +From this directory, with Maven 3.9+ and JDK 21 or later: + +```sh +mvn -B verify +# With the pinned Core configured (eval "$(python3 ../scripts/core_pin.py install)"): +JACQUARD_REQUIRE_LIVE=1 mvn -B verify +``` + +The live tests in `LiveTest` skip without `JACQUARD_BIN` and +`JACQUARD_PRELUDE` (set `JACQUARD_REQUIRE_LIVE` to make them fail instead). +The offline tests drive `StandIn`, a test-source main class that replays +recordings or simulates faulty workers in a fresh JVM. Its descendant modes +use `sh` and `sleep`, so those tests run on Unix only, and the descriptor test +runs on Linux only. Surefire starts a fresh JVM for each test class. + +`src/main/java/org/jacquardlang/host/Compat.java` is generated, and +`src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/` is a byte copy +of the pinned kit. `scripts/core_pin.py` maintains both; do not edit them by +hand. `NOTICE` must stay identical to the repository's `NOTICE`, and the +README example must stay identical to `src/test/java/org/jacquardlang/host/readme/Echo.java`; +tests check both. diff --git a/java/pom.xml b/java/pom.xml new file mode 100644 index 0000000..a1a8661 --- /dev/null +++ b/java/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + org.jacquardlang + jacquard-host + 0.2.0 + jar + + Jacquard Host for Java + Independent Java adapter for Jacquard Core's experimental jacquard-host-v0 protocol + https://github.com/jbwinters/jacquard-host + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0 + + + + + 21 + UTF-8 + UTF-8 + + 2026-10-27T00:00:00Z + + + + + + org.junit.jupiter + junit-jupiter + 5.14.4 + test + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.5.0 + + + org.apache.maven.plugins + maven-compiler-plugin + 3.16.0 + + + -Xlint:all + -Werror + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.6.1 + + + 1 + false + -Xmx512m + false + + + + org.apache.maven.plugins + maven-jar-plugin + 3.5.1 + + + + org.jacquardlang.host + + + + + + + diff --git a/java/src/main/java/org/jacquardlang/host/Compat.java b/java/src/main/java/org/jacquardlang/host/Compat.java new file mode 100644 index 0000000..c37c0d8 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/Compat.java @@ -0,0 +1,27 @@ +// Generated from core-pin.json by scripts/core_pin.py; do not edit by hand. + +package org.jacquardlang.host; + +import java.util.List; +import java.util.Map; + +/** The Jacquard Core release and host protocol this adapter is pinned to. */ +public final class Compat { + private Compat() {} + + public static final String PROTOCOL = "jacquard-host-v0"; + public static final String CARRIER = "stdio-u32-json-v0"; + public static final String CORE_VERSION = "0.3.0"; + public static final String CORE_TAG = "jacquard-core-0.3.0"; + public static final String CORE_COMMIT = "daf87fb4837cc69339116746818f64821dc4cb8e"; + + /** SHA-256 digests of the pinned conformance kit files. */ + public static final List> KIT_DIGESTS = List.of( + Map.entry("kit/kit.json", "a01b9401d7c2df4e17771b9c0d22f9ea5ce1a923dfb7d3396c27821c0e058a61"), + Map.entry("kit/transcripts.json", "ebaf590e3d1b7e8399f56de5ad85043ac3658416dd7f0af7b55b9f9f3ce93240"), + Map.entry("kit/fixtures.jac", "037bb8209ca0344ba0a2cd0fcc16e69f13c8f9b91e3404dc3f3161e351831d80"), + Map.entry("vectors.json", "9ba1bff8eee0b6dbea6475dc0c7e9cc6313d847ad80bd8d832457115eb1c738a")); + + /** Decisions Core recorded as pending; replay reproduces them. */ + public static final List PENDING_DECISIONS = List.of("noncanonical-target-hash"); +} diff --git a/java/src/main/java/org/jacquardlang/host/Failure.java b/java/src/main/java/org/jacquardlang/host/Failure.java new file mode 100644 index 0000000..78419ad --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/Failure.java @@ -0,0 +1,13 @@ +package org.jacquardlang.host; + +/** How an unsuccessful invocation failed. */ +public enum Failure { + /** The worker's lifetime deadline expired. */ + DEADLINE_EXCEEDED, + /** The carrier was lost: spawn failure, closed pipes, bad exit, or abort. */ + WORKER_LOST, + /** An outside operation failed or its completion is unknown. */ + OPERATION_FAILED, + /** Core reported a diagnostic, or a frame was refused. */ + OTHER +} diff --git a/java/src/main/java/org/jacquardlang/host/Handler.java b/java/src/main/java/org/jacquardlang/host/Handler.java new file mode 100644 index 0000000..44606c6 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/Handler.java @@ -0,0 +1,16 @@ +package org.jacquardlang.host; + +import java.util.List; + +import org.jacquardlang.host.protocol.Value; + +/** One effect operation's handler. */ +@FunctionalInterface +public interface Handler { + /** + * Performs the operation. Throw {@link HandlerException} for a structured + * failure; any other exception is reported as completion unknown, without + * its message. + */ + Value handle(HandlerContext context, List arguments) throws Exception; +} diff --git a/java/src/main/java/org/jacquardlang/host/HandlerContext.java b/java/src/main/java/org/jacquardlang/host/HandlerContext.java new file mode 100644 index 0000000..f30b753 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/HandlerContext.java @@ -0,0 +1,26 @@ +package org.jacquardlang.host; + +import java.util.function.BooleanSupplier; + +import org.jacquardlang.host.carrier.Cancel; + +/** What a handler sees: whether the invocation is still live. */ +public final class HandlerContext { + private final BooleanSupplier expired; + private final BooleanSupplier ended; + private final Cancel cancel; + + HandlerContext(BooleanSupplier expired, BooleanSupplier ended, Cancel cancel) { + this.expired = expired; + this.ended = ended; + this.cancel = cancel; + } + + /** + * Whether the worker expired, the caller cancelled, or the invocation + * ended. Handlers should stop; the host no longer waits for them. + */ + public boolean isCancelled() { + return expired.getAsBoolean() || ended.getAsBoolean() || cancel.isCancelled(); + } +} diff --git a/java/src/main/java/org/jacquardlang/host/HandlerException.java b/java/src/main/java/org/jacquardlang/host/HandlerException.java new file mode 100644 index 0000000..f2e141e --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/HandlerException.java @@ -0,0 +1,47 @@ +package org.jacquardlang.host; + +import java.util.Objects; + +import org.jacquardlang.host.protocol.FailureCategory; + +/** + * A handler's structured refusal or failure, reported to Core with its + * category and message. Any other exception a handler throws is reported as + * completion unknown, without its message. + */ +public final class HandlerException extends Exception { + private static final long serialVersionUID = 1L; + + private final FailureCategory category; + + /** A failure in one of the four permitted categories, with a message for Core. */ + public HandlerException(FailureCategory category, String message) { + super(Objects.requireNonNull(message, "message")); + this.category = Objects.requireNonNull(category, "category"); + } + + /** The failure category. */ + public FailureCategory category() { + return category; + } + + /** The host does not provide the operation (not started). */ + public static HandlerException unsupported(String message) { + return new HandlerException(FailureCategory.UNSUPPORTED_OPERATION, message); + } + + /** The host refused to authorize the operation (not started). */ + public static HandlerException refused(String message) { + return new HandlerException(FailureCategory.REFUSED_AUTHORITY, message); + } + + /** An outside action failed without effect. */ + public static HandlerException outsideFailed(String message) { + return new HandlerException(FailureCategory.OUTSIDE_FAILURE, message); + } + + /** An outside action's completion is unknown. */ + public static HandlerException completionUnknown(String message) { + return new HandlerException(FailureCategory.COMPLETION_UNKNOWN, message); + } +} diff --git a/java/src/main/java/org/jacquardlang/host/Host.java b/java/src/main/java/org/jacquardlang/host/Host.java new file mode 100644 index 0000000..754a839 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/Host.java @@ -0,0 +1,379 @@ +package org.jacquardlang.host; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.jacquardlang.host.carrier.Cancel; +import org.jacquardlang.host.carrier.CarrierException; +import org.jacquardlang.host.carrier.Options; +import org.jacquardlang.host.carrier.Worker; +import org.jacquardlang.host.json.Json; +import org.jacquardlang.host.json.JsonCodec; +import org.jacquardlang.host.protocol.CoreFrame; +import org.jacquardlang.host.protocol.EffectRequest; +import org.jacquardlang.host.protocol.FailureCategory; +import org.jacquardlang.host.protocol.Hash; +import org.jacquardlang.host.protocol.Interface; +import org.jacquardlang.host.protocol.Limits; +import org.jacquardlang.host.protocol.OperationEntry; +import org.jacquardlang.host.protocol.Protocol; +import org.jacquardlang.host.protocol.Session; +import org.jacquardlang.host.protocol.State; +import org.jacquardlang.host.protocol.Terminal; +import org.jacquardlang.host.protocol.Value; + +/** + * Invokes checked Jacquard terms in one prepared store through + * {@code jacquard-host-v0} and serves their effect requests from a closed + * registry of Java handlers. Each invocation runs one fresh, serial worker. + */ +public final class Host { + /** Host evidence schema recorded beside Core's frames. */ + public static final String EVIDENCE_SCHEMA = "jacquard-host-java-evidence-v0"; + + private final String store; + private final Limits limits; + private final Options options; + + /** + * Validates options before any worker starts. + * + * @throws IllegalArgumentException for invalid limits or carrier options + */ + public Host(String store, HostOptions options) { + this.store = Objects.requireNonNull(store, "store"); + Limits selected = options.limits() == null ? Limits.HARD : options.limits(); + try { + selected.validate(); + } catch (ProtocolException error) { + throw new IllegalArgumentException(error.getMessage(), error); + } + options.carrier().validate(); + this.limits = selected; + this.options = options.carrier(); + } + + /** The prepared store directory. */ + public String store() { + return store; + } + + /** The limits each session selects. */ + public Limits limits() { + return limits; + } + + /** + * Runs {@code target} with {@code args}. The invoke frame is validated + * before a worker starts. Cancelling {@code cancel}, or interrupting the + * calling thread, kills the worker; nothing is retried. + * + * @throws NullPointerException if any argument is null + */ + public InvocationResult invoke(Hash target, Interface spec, List args, OperationRegistry registry, Cancel cancel) { + Objects.requireNonNull(target, "target"); + Objects.requireNonNull(spec, "spec"); + Objects.requireNonNull(args, "args"); + Objects.requireNonNull(registry, "registry"); + Objects.requireNonNull(cancel, "cancel"); + List entries = registry.entries(); + try { + Map frame = Protocol.invokeFrame(target, spec, args, entries, limits); + JsonCodec.frameBytes(frame, limits.maxFrameBytes(), limits.maxJsonDepth()); + } catch (ProtocolException error) { + InvocationResult result = new InvocationResult(ResultKind.HOST_ERROR); + result.hostEvidence = Json.object( + "code", Json.of(error.code()), + "detail", Json.of(error.detail()), + "reason", Json.of("invoke_refused_locally"), + "schema", Json.of(EVIDENCE_SCHEMA)); + return result; + } + return run(session -> session.invoke(target, spec, args, entries), registry, cancel); + } + + /** + * Negotiates a session and shuts it down without invoking. + * + * @throws NullPointerException if {@code cancel} is null + */ + public InvocationResult shutdownProbe(Cancel cancel) { + Objects.requireNonNull(cancel, "cancel"); + return run(Session::shutdown, new OperationRegistry(), cancel); + } + + @FunctionalInterface + private interface Opening { + Map render(Session session) throws ProtocolException; + } + + /** Whether a host frame was being encoded or sent when a failure occurred. */ + private static final class Sending { + boolean active; + } + + private InvocationResult run(Opening first, OperationRegistry registry, Cancel cancel) { + Session session = new Session(); + InvocationResult result = new InvocationResult(ResultKind.CARRIER_LOSS); + Worker worker; + try { + worker = Worker.start(store, options, cancel); + } catch (IllegalArgumentException error) { + result.hostEvidence = Json.object( + "detail", Json.of(error.getMessage()), "reason", Json.of("invalid_options"), "schema", Json.of(EVIDENCE_SCHEMA)); + return result; + } + Sending sending = new Sending(); + AtomicBoolean ended = new AtomicBoolean(); + try { + drive(worker, session, result, sending, first, registry, cancel, ended); + } catch (CarrierException error) { + ResultKind kind; + String code; + String reason; + if (!error.isLoss()) { + code = error.protocolError().code(); + kind = sending.active ? ResultKind.HOST_ERROR : ResultKind.PROTOCOL_ERROR; + reason = sending.active ? "host_frame_refused" : "core_frame_refused"; + } else { + kind = ResultKind.CARRIER_LOSS; + code = "E1611"; + if (error.isTimeout()) { + reason = "deadline"; + } else if (worker.spawnError() != null) { + reason = "worker_spawn_failed"; + } else if (error.isAborted()) { + reason = "aborted"; + } else { + reason = "carrier_loss"; + } + } + result.kind = kind; + Long outstanding = session.pending(); + result.hostEvidence = Json.object( + "code", Json.of(code), + "detail", Json.of(error.getMessage()), + "outstanding_request", outstanding == null ? Json.NULL : Json.of(outstanding), + "reason", Json.of(reason), + "schema", Json.of(EVIDENCE_SCHEMA), + "state", Json.of(session.state().wireName())); + } finally { + worker.dispose(); + ended.set(true); + } + result.requests = List.copyOf(session.requests()); + result.exitStatus = worker.exitStatus(); + result.signal = worker.signal(); + result.stderr = worker.stderr(); + return result; + } + + private static void send(Worker worker, Map frame, Limits limits, InvocationResult result, Sending sending) + throws CarrierException { + sending.active = true; + worker.writeFrame(frame, limits); + result.hostFrames.add(frame); + sending.active = false; + } + + private static CarrierException protocol(ProtocolException error) { + return CarrierException.protocol(error); + } + + private void drive(Worker worker, Session session, InvocationResult result, Sending sending, Opening first, + OperationRegistry registry, Cancel cancel, AtomicBoolean ended) throws CarrierException { + Map hello = worker.readFrame(Limits.HARD); + if (hello == null) { + throw CarrierException.loss("worker exited before core_hello"); + } + result.coreFrames.add(hello); + Map selected; + try { + session.receive(hello); + selected = session.select(limits); + } catch (ProtocolException error) { + throw protocol(error); + } + send(worker, selected, Limits.HARD, result, sending); + worker.setStderrLimit(limits.maxStderrBytes()); + Map opening; + try { + opening = first.render(session); + } catch (ProtocolException error) { + throw protocol(error); + } + send(worker, opening, session.limits(), result, sending); + while (session.state() != State.CLOSED) { + Map frame = worker.readFrame(session.limits()); + if (frame == null) { + throw CarrierException.loss("worker exited before a terminal frame"); + } + result.coreFrames.add(frame); + CoreFrame parsed; + try { + parsed = session.receive(frame); + } catch (ProtocolException error) { + throw protocol(error); + } + switch (parsed) { + case EffectRequest request -> { + Map response = respond(worker, session, registry, request, cancel, ended); + send(worker, response, session.limits(), result, sending); + } + case Terminal.Outcome outcome -> { + result.kind = outcome.value() != null ? ResultKind.OK : ResultKind.ERROR; + result.value = outcome.value(); + result.diagnostics = outcome.diagnostics(); + result.terminal = outcome.terminal(); + result.evidence = outcome.evidence(); + } + case Terminal.Fatal fatal -> { + result.kind = ResultKind.FATAL; + result.diagnostics = fatal.diagnostics(); + } + case Terminal.ShutdownAck ignored -> result.kind = ResultKind.SHUTDOWN_ACK; + case null -> { + // core_hello was handled above. + } + } + } + worker.closeInput(); + Map extra = worker.readFrame(session.limits()); + if (extra != null) { + result.coreFrames.add(extra); + try { + session.receive(extra); + } catch (ProtocolException error) { + throw protocol(error); + } + } + worker.awaitExit(); + if (!Integer.valueOf(0).equals(worker.exitStatus())) { + throw CarrierException.loss("worker exited unsuccessfully after its terminal"); + } + } + + private sealed interface Answer {} + + private record Returned(Value value) implements Answer {} + + private record Failed(FailureCategory category, String message) implements Answer {} + + /** + * Test seam: runs on the handler thread just before its check, so a test + * can stop the call at exactly that point. Null outside tests. It is + * global, so tests that set it must not run in parallel in one JVM. + */ + static volatile Runnable handlerCheckHook; + + /** The call stopped before the handler began; the fault check after the wait reports why. */ + private static final Answer NOT_STARTED = new Failed(FailureCategory.OUTSIDE_FAILURE, "handler was not started"); + + /** + * Runs the handler on its own thread, bounded by the worker lifetime, and + * renders the response. A handler still running at the deadline is + * abandoned: no late response is sent and nothing is retried. + */ + private static Map respond(Worker worker, Session session, OperationRegistry registry, EffectRequest request, + Cancel cancel, AtomicBoolean ended) throws CarrierException { + Handler handler = registry.handler(request); + Answer answer; + if (handler == null) { + answer = new Failed(FailureCategory.UNSUPPORTED_OPERATION, "operation is not configured"); + } else { + // Decoding the request takes time; an outside action must not + // begin once the call is cancelled, the caller interrupted, or the + // lifetime passed. The caller checks before creating the handler + // thread, and the thread checks again before calling the handler. + CarrierException early = worker.fault(); + if (early != null) { + throw early; + } + HandlerContext context = new HandlerContext(worker::stopping, ended::get, cancel); + List arguments = request.arguments(); + CompletableFuture done = new CompletableFuture<>(); + Thread thread = new Thread(() -> { + Runnable hook = handlerCheckHook; + if (hook != null) { + hook.run(); + } + done.complete(worker.stopping() ? NOT_STARTED : call(handler, context, arguments)); + }, "jacquard-handler"); + thread.setDaemon(true); + boolean started; + try { + thread.start(); + started = true; + } catch (OutOfMemoryError error) { + started = false; + } + if (!started) { + // The handler never ran. + answer = new Failed(FailureCategory.OUTSIDE_FAILURE, "handler thread could not start"); + } else { + Answer awaited = worker.await(done); + answer = awaited != null ? awaited : new Failed(FailureCategory.COMPLETION_UNKNOWN, "handler threw an exception"); + } + } + CarrierException fault = worker.fault(); + if (fault != null) { + throw fault; + } + Failed failure; + try { + switch (answer) { + case Returned returned -> { + try { + session.okFrame(returned.value()); + return session.respondOk(returned.value()); + } catch (ProtocolException error) { + failure = new Failed(FailureCategory.OUTSIDE_FAILURE, "handler result has no encoding within the selected limits"); + } + } + case Failed failed -> failure = failed; + } + return session.respondFailure(failure.category(), bounded(failure.message(), session.limits().maxHostMessageBytes())); + } catch (ProtocolException error) { + throw protocol(error); + } + } + + /** Calls a handler, turning every exception into a failure answer. */ + private static Answer call(Handler handler, HandlerContext context, List arguments) { + try { + Value value = handler.handle(context, arguments); + return new Returned(value); + } catch (HandlerException failure) { + return new Failed(failure.category(), failure.getMessage()); + } catch (Throwable other) { + // Its message may carry private detail and is never sent. + return new Failed(FailureCategory.COMPLETION_UNKNOWN, "handler threw an exception"); + } + } + + /** + * At most {@code limit} UTF-8 bytes of {@code text}, cut at a code point + * boundary; a lone surrogate, which has no encoding, becomes U+FFFD. + */ + static String bounded(String text, int limit) { + StringBuilder out = new StringBuilder(); + int used = 0; + for (int i = 0; i < text.length(); ) { + int point = text.codePointAt(i); + i += Character.charCount(point); + if (point >= Character.MIN_SURROGATE && point <= Character.MAX_SURROGATE) { + point = 0xFFFD; + } + int size = point < 0x80 ? 1 : point < 0x800 ? 2 : point < 0x10000 ? 3 : 4; + if (used + size > limit) { + break; + } + out.appendCodePoint(point); + used += size; + } + return out.toString(); + } +} diff --git a/java/src/main/java/org/jacquardlang/host/HostOptions.java b/java/src/main/java/org/jacquardlang/host/HostOptions.java new file mode 100644 index 0000000..f75f95e --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/HostOptions.java @@ -0,0 +1,23 @@ +package org.jacquardlang.host; + +import org.jacquardlang.host.carrier.Options; +import org.jacquardlang.host.protocol.Limits; + +/** + * Options for a {@link Host}. + * + * @param carrier the worker command, environment, and lifetime deadline; null means defaults + * @param limits the session limits; null selects the hard limits + */ +public record HostOptions(Options carrier, Limits limits) { + public HostOptions { + if (carrier == null) { + carrier = Options.defaults(); + } + } + + /** Default carrier options and the hard limits. */ + public static HostOptions defaults() { + return new HostOptions(Options.defaults(), null); + } +} diff --git a/java/src/main/java/org/jacquardlang/host/InvocationException.java b/java/src/main/java/org/jacquardlang/host/InvocationException.java new file mode 100644 index 0000000..fe8a18f --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/InvocationException.java @@ -0,0 +1,35 @@ +package org.jacquardlang.host; + +import org.jacquardlang.host.json.Json; + +/** An unsuccessful invocation. */ +public final class InvocationException extends Exception { + private static final long serialVersionUID = 1L; + + private final transient InvocationResult result; + + InvocationException(InvocationResult result) { + super(result.message()); + this.result = result; + } + + /** The result, with Core's frames and the host's evidence. */ + public InvocationResult result() { + return result; + } + + /** The classification. */ + public Failure failure() { + if (Json.of("deadline").equals(result.hostEvidence().get("reason"))) { + return Failure.DEADLINE_EXCEEDED; + } + if (result.kind() == ResultKind.CARRIER_LOSS) { + return Failure.WORKER_LOST; + } + String terminal = result.terminal(); + if ("host_failure".equals(terminal) || "completion_unknown".equals(terminal)) { + return Failure.OPERATION_FAILED; + } + return Failure.OTHER; + } +} diff --git a/java/src/main/java/org/jacquardlang/host/InvocationResult.java b/java/src/main/java/org/jacquardlang/host/InvocationResult.java new file mode 100644 index 0000000..39d61b8 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/InvocationResult.java @@ -0,0 +1,140 @@ +package org.jacquardlang.host; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.jacquardlang.host.json.Json; +import org.jacquardlang.host.protocol.Diagnostic; +import org.jacquardlang.host.protocol.EffectRequest; +import org.jacquardlang.host.protocol.Value; + +/** Core's frames and evidence beside the host's observations. */ +public final class InvocationResult { + ResultKind kind; + Value value; + List diagnostics = List.of(); + String terminal; + Map evidence; + final List> coreFrames = new ArrayList<>(); + final List> hostFrames = new ArrayList<>(); + List requests = List.of(); + Integer exitStatus; + String signal; + byte[] stderr = new byte[0]; + Map hostEvidence = Map.of(); + + InvocationResult(ResultKind kind) { + this.kind = kind; + } + + /** The result kind. */ + public ResultKind kind() { + return kind; + } + + /** The value of a successful outcome, else null. */ + public Value value() { + return value; + } + + /** Core's diagnostics, for an error outcome or a fatal frame. */ + public List diagnostics() { + return diagnostics; + } + + /** Core's evidence terminal name for an outcome, else null. */ + public String terminal() { + return terminal; + } + + /** Core's evidence for an outcome, else null. */ + public Map evidence() { + return evidence; + } + + /** Every frame Core sent, in order. */ + public List> coreFrames() { + return Collections.unmodifiableList(coreFrames); + } + + /** Every frame the host sent, in order. */ + public List> hostFrames() { + return Collections.unmodifiableList(hostFrames); + } + + /** The effect requests Core made. */ + public List requests() { + return requests; + } + + /** The worker's exit status, when known and not ended by the host's SIGKILL. */ + public Integer exitStatus() { + return exitStatus; + } + + /** {@code "SIGKILL"} when the host killed the worker; see {@code Worker#signal()}. */ + public String signal() { + return signal; + } + + /** The retained stderr prefix. */ + public byte[] stderr() { + return Arrays.copyOf(stderr, stderr.length); + } + + /** The host's evidence ({@code jacquard-host-java-evidence-v0}); empty on success. */ + public Map hostEvidence() { + return hostEvidence; + } + + /** Whether Core returned a successful outcome. */ + public boolean ok() { + return kind == ResultKind.OK; + } + + private String evidenceText(String key) { + Json value = hostEvidence.get(key); + return value == null ? null : value.asString(); + } + + /** The first Core diagnostic code, else the host evidence code. */ + public String code() { + if (!diagnostics.isEmpty() && !diagnostics.get(0).code().isEmpty()) { + return diagnostics.get(0).code(); + } + String code = evidenceText("code"); + return code == null ? "" : code; + } + + /** A summary for people. */ + public String message() { + if (ok()) { + return "Jacquard invocation succeeded"; + } + String detail; + if (!diagnostics.isEmpty()) { + detail = diagnostics.get(0).summary() + " " + diagnostics.get(0).cause(); + } else { + String text = evidenceText("detail"); + detail = text == null ? kind.wireName() : text; + } + String code = code(); + return (code.isEmpty() ? kind.wireName() : code) + ": " + detail; + } + + /** The value, or an {@link InvocationException} classifying the failure. */ + public Value valueOrThrow() throws InvocationException { + if (ok()) { + return value; + } + throw new InvocationException(this); + } + + @Override + public String toString() { + return "InvocationResult[" + kind.wireName() + ", " + message() + "]"; + } +} diff --git a/java/src/main/java/org/jacquardlang/host/OperationRegistry.java b/java/src/main/java/org/jacquardlang/host/OperationRegistry.java new file mode 100644 index 0000000..8e5cdd6 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/OperationRegistry.java @@ -0,0 +1,42 @@ +package org.jacquardlang.host; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.TreeMap; + +import org.jacquardlang.host.protocol.EffectRequest; +import org.jacquardlang.host.protocol.Hash; +import org.jacquardlang.host.protocol.OperationEntry; + +/** A closed map from exact operation identities to handlers. */ +public final class OperationRegistry { + private final TreeMap handlers = new TreeMap<>(); + + /** An empty registry. */ + public OperationRegistry() {} + + /** + * Binds a handler to one effect operation identity. + * + * @throws IllegalArgumentException if the identity is already bound + */ + public synchronized OperationRegistry bind(Hash effect, Hash operation, Handler handler) { + OperationEntry key = new OperationEntry(effect, operation); + Objects.requireNonNull(handler, "handler"); + if (handlers.containsKey(key)) { + throw new IllegalArgumentException("operation identity is already bound"); + } + handlers.put(key, handler); + return this; + } + + /** The bound operations in protocol order. */ + public synchronized List entries() { + return new ArrayList<>(handlers.keySet()); + } + + synchronized Handler handler(EffectRequest request) { + return handlers.get(new OperationEntry(request.effect(), request.operation())); + } +} diff --git a/java/src/main/java/org/jacquardlang/host/ProtocolException.java b/java/src/main/java/org/jacquardlang/host/ProtocolException.java new file mode 100644 index 0000000..2b18beb --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/ProtocolException.java @@ -0,0 +1,46 @@ +package org.jacquardlang.host; + +/** A protocol refusal carrying its frozen v0 diagnostic code, such as E1601. */ +public final class ProtocolException extends Exception { + private static final long serialVersionUID = 1L; + + private final String code; + private final String detail; + + /** A refusal with a frozen code and a message without the code. */ + public ProtocolException(String code, String detail) { + super(code + ": " + detail); + this.code = code; + this.detail = detail; + } + + /** The frozen diagnostic code, such as {@code "E1601"}. */ + public String code() { + return code; + } + + /** The message without the code. */ + public String detail() { + return detail; + } + + /** E1601: malformed input. */ + public static ProtocolException malformed(String detail) { + return new ProtocolException("E1601", detail); + } + + /** E1602: a selected or hard limit is exceeded. */ + public static ProtocolException exceeded(String detail) { + return new ProtocolException("E1602", detail); + } + + /** E1604: a value or type with no v0 encoding. */ + public static ProtocolException unsupported(String detail) { + return new ProtocolException("E1604", detail); + } + + /** E1608: a message out of order for the session state. */ + public static ProtocolException order(String detail) { + return new ProtocolException("E1608", detail); + } +} diff --git a/java/src/main/java/org/jacquardlang/host/ResultKind.java b/java/src/main/java/org/jacquardlang/host/ResultKind.java new file mode 100644 index 0000000..76e15d2 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/ResultKind.java @@ -0,0 +1,23 @@ +package org.jacquardlang.host; + +/** The kind of an invocation's result. */ +public enum ResultKind { + OK("ok"), + ERROR("error"), + FATAL("fatal"), + SHUTDOWN_ACK("shutdown_ack"), + CARRIER_LOSS("carrier_loss"), + PROTOCOL_ERROR("protocol_error"), + HOST_ERROR("host_error"); + + private final String wireName; + + ResultKind(String wireName) { + this.wireName = wireName; + } + + /** The kind's name, as the other adapters spell it. */ + public String wireName() { + return wireName; + } +} diff --git a/java/src/main/java/org/jacquardlang/host/carrier/Cancel.java b/java/src/main/java/org/jacquardlang/host/carrier/Cancel.java new file mode 100644 index 0000000..effbfdd --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/carrier/Cancel.java @@ -0,0 +1,21 @@ +package org.jacquardlang.host.carrier; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** A cooperative cancellation token shared with the caller and handlers. */ +public final class Cancel { + private final AtomicBoolean cancelled = new AtomicBoolean(); + + /** A token that is not cancelled. */ + public Cancel() {} + + /** Requests cancellation. */ + public void cancel() { + cancelled.set(true); + } + + /** Whether cancellation was requested. */ + public boolean isCancelled() { + return cancelled.get(); + } +} diff --git a/java/src/main/java/org/jacquardlang/host/carrier/CarrierException.java b/java/src/main/java/org/jacquardlang/host/carrier/CarrierException.java new file mode 100644 index 0000000..7784f27 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/carrier/CarrierException.java @@ -0,0 +1,61 @@ +package org.jacquardlang.host.carrier; + +import org.jacquardlang.host.ProtocolException; + +/** + * A carrier failure: either a loss (the worker could not start, closed a + * pipe, exited unsuccessfully, was aborted, or outlived its deadline) or a + * frame that violated the protocol. + */ +public final class CarrierException extends Exception { + private static final long serialVersionUID = 1L; + + private final boolean timeout; + private final boolean aborted; + private final ProtocolException protocolError; + + private CarrierException(String message, boolean timeout, boolean aborted, ProtocolException protocolError) { + super(message, protocolError); + this.timeout = timeout; + this.aborted = aborted; + this.protocolError = protocolError; + } + + /** A carrier loss. */ + public static CarrierException loss(String message) { + return new CarrierException(message, false, false, null); + } + + static CarrierException timeout(String message) { + return new CarrierException(message, true, false, null); + } + + static CarrierException aborted(String message) { + return new CarrierException(message, false, true, null); + } + + /** A frame that violated the protocol. */ + public static CarrierException protocol(ProtocolException error) { + return new CarrierException(error.getMessage(), false, false, error); + } + + /** Whether this is a loss rather than a protocol violation. */ + public boolean isLoss() { + return protocolError == null; + } + + /** Whether the worker outlived its lifetime deadline. */ + public boolean isTimeout() { + return timeout; + } + + /** Whether the invocation was cancelled or the calling thread interrupted. */ + public boolean isAborted() { + return aborted; + } + + /** The protocol violation, or null for a loss. */ + public ProtocolException protocolError() { + return protocolError; + } +} diff --git a/java/src/main/java/org/jacquardlang/host/carrier/Gate.java b/java/src/main/java/org/jacquardlang/host/carrier/Gate.java new file mode 100644 index 0000000..b27933a --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/carrier/Gate.java @@ -0,0 +1,45 @@ +package org.jacquardlang.host.carrier; + +/** + * Decides whether a stdin chunk may start: never once input is closed, the + * lifetime deadline has passed, cancellation was requested, or the calling + * thread is interrupted, even before the host notices any of these. It is + * read from other threads, and is also the stop check for handler threads. + */ +final class Gate { + private volatile boolean closed; + private final long deadline; + private final Cancel cancel; + /** The thread that drives the worker; its interrupt stops the call. */ + private final Thread caller; + /** Set before the caller consumes its interrupt. */ + private volatile boolean callerInterrupted; + + Gate(long deadline, Cancel cancel, Thread caller) { + this.deadline = deadline; + this.cancel = cancel; + this.caller = caller; + } + + /** + * Whether the call is cancelled, interrupted, or past its lifetime. The + * interrupt status is read before the recorded interrupt, which is set + * before the status is cleared, so an interrupt is never missed. + */ + boolean stopped() { + return System.nanoTime() - deadline >= 0 || cancel.isCancelled() || caller.isInterrupted() || callerInterrupted; + } + + /** Records that the caller was interrupted; called before it clears its status. */ + void interrupted() { + callerInterrupted = true; + } + + boolean admits() { + return !closed && !stopped(); + } + + void close() { + closed = true; + } +} diff --git a/java/src/main/java/org/jacquardlang/host/carrier/Options.java b/java/src/main/java/org/jacquardlang/host/carrier/Options.java new file mode 100644 index 0000000..679f593 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/carrier/Options.java @@ -0,0 +1,59 @@ +package org.jacquardlang.host.carrier; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +/** + * Options for one worker lifetime. + * + * @param command the Core executable and leading arguments; {@code host worker --store DIR} + * is appended. Empty means {@code $JACQUARD_BIN}, then {@code jacquard}. + * @param env replaces the inherited environment when not null + * @param deadline bounds the whole lifetime: spawn, pipes, handlers, and exit; null means + * {@link Worker#DEFAULT_DEADLINE} + */ +public record Options(List command, Map env, Duration deadline) { + public Options { + command = command == null ? List.of() : List.copyOf(command); + env = env == null ? null : Map.copyOf(env); + } + + /** The default command, inherited environment, and default deadline. */ + public static Options defaults() { + return new Options(List.of(), null, null); + } + + /** These options with another command. */ + public Options withCommand(List value) { + return new Options(value, env, deadline); + } + + /** These options with a replacement environment. */ + public Options withEnv(Map value) { + return new Options(command, value, deadline); + } + + /** These options with another lifetime deadline. */ + public Options withDeadline(Duration value) { + return new Options(command, env, value); + } + + /** Refuses an empty command element or a deadline that is not positive. */ + public void validate() { + if (command.stream().anyMatch(String::isEmpty)) { + throw new IllegalArgumentException("command must be a nonempty executable argument list"); + } + if (deadline != null && (deadline.isZero() || deadline.isNegative())) { + throw new IllegalArgumentException("deadline must be positive"); + } + } + + List resolvedCommand() { + if (!command.isEmpty()) { + return command; + } + String bin = System.getenv("JACQUARD_BIN"); + return List.of(bin == null || bin.isEmpty() ? "jacquard" : bin); + } +} diff --git a/java/src/main/java/org/jacquardlang/host/carrier/Worker.java b/java/src/main/java/org/jacquardlang/host/carrier/Worker.java new file mode 100644 index 0000000..a743ffa --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/carrier/Worker.java @@ -0,0 +1,740 @@ +package org.jacquardlang.host.carrier; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.LockSupport; + +import org.jacquardlang.host.ProtocolException; +import org.jacquardlang.host.json.Json; +import org.jacquardlang.host.json.JsonCodec; +import org.jacquardlang.host.protocol.Limits; + +/** + * One bounded {@code stdio-u32-json-v0} worker lifetime. + * + *

Java cannot start a process in its own process group, and a read from a + * pipe cannot be interrupted. So stdout and stderr are never read with a + * blocking call: the caller's thread polls {@code available()} in short + * slices and reads only bytes already in the pipe, and every wait (for a + * frame, a write, a handler, or the exit) rechecks the single lifetime + * deadline, the cancellation token, and the thread's interrupt status. Writes + * run on one stdin thread ({@link Writer}) because a pipe write can block. + * + *

On Unix, stdout and stderr are FIFOs the host creates with + * {@code mkfifo} in a private directory and opens read-write before the + * spawn, and the worker's output is redirected to them; the directory is + * removed once the worker starts. The JDK's own process pipes drain + * themselves on its reaper thread when the process exits, under the stream + * lock that {@code available()} also takes, for as long as a descendant keeps + * writing, and into an unbounded buffer. The JDK does not touch a redirected + * file, so no host read can wait on that lock, and output a descendant keeps + * writing stays in the pipe until the host disposes the worker and closes its + * end. Elsewhere the JDK's pipes are used, with that drain. + * + *

At expiry, cancellation, or disposal while the worker runs, the worker's + * process tree is killed: a snapshot of {@link Process#descendants()} and + * the worker itself. A descendant that has already been reparented (because + * its parent exited or it daemonized) is not in the snapshot and keeps + * running. + * + *

The JDK reaps the worker itself as soon as it exits. So stdout ends when + * the worker has exited and its pipe is empty, and a descendant still holding + * a pipe after the exit is neither observed nor waited for. + * + *

A worker is used from one thread at a time. + */ +public final class Worker implements AutoCloseable { + /** The worker lifetime when {@link Options#deadline()} is null. */ + public static final Duration DEFAULT_DEADLINE = Duration.ofSeconds(30); + /** How long disposal waits for a killed worker to exit. */ + private static final long EXIT_GRACE = TimeUnit.SECONDS.toNanos(1); + /** The first and the longest pause between polls. */ + private static final long POLL_MIN = TimeUnit.MICROSECONDS.toNanos(100); + private static final long POLL_MAX = TimeUnit.MILLISECONDS.toNanos(5); + /** The most stderr one poll reads, so a fast writer cannot hold the caller. */ + private static final int STDERR_SLICE = 1 << 20; + /** The exit value the JDK reports for a process ended by SIGKILL. */ + private static final int SIGKILL_EXIT = 128 + 9; + /** Whether the worker's output goes through host-owned FIFOs. */ + private static final boolean FIFOS = !System.getProperty("os.name", "").startsWith("Windows"); + /** The longest {@code mkfifo} may take, within the lifetime. */ + private static final long MKFIFO_WAIT = TimeUnit.SECONDS.toNanos(5); + + private final Process process; + private final InputStream stdout; + private final InputStream stderrStream; + /** The host's read-write handles on the output FIFOs, or null. */ + private final Fifos fifos; + private final Writer writer; + private final Gate gate; + private final long expires; + private final Cancel cancel; + private final AtomicBoolean expired = new AtomicBoolean(); + private final byte[] chunk = new byte[8192]; + private byte[] stderr = new byte[0]; + private int stderrLength; + private int stderrLimit = Limits.HARD.maxStderrBytes(); + private boolean stderrEnded; + private CarrierException fault; + private boolean interrupted; + private boolean inputClosed; + private boolean killed; + private boolean exitRecorded; + private boolean disposed; + private final String spawnError; + private Integer exitStatus; + private String signal; + + private Worker(Process process, Fifos fifos, Gate gate, long expires, Cancel cancel, String spawnError) { + this.process = process; + this.fifos = fifos; + this.gate = gate; + this.expires = expires; + this.cancel = cancel; + this.spawnError = spawnError; + if (process == null) { + this.stdout = null; + this.stderrStream = null; + this.writer = null; + } else { + this.stdout = fifos != null ? fifos.stdoutStream() : process.getInputStream(); + this.stderrStream = fifos != null ? fifos.stderrStream() : process.getErrorStream(); + this.writer = new Writer(process.getOutputStream(), gate); + } + } + + /** + * The host's handles on the worker's stdout and stderr FIFOs. Each is + * opened read-write, so the open never waits for a writer and the host + * always holds a read end; reads go through a {@link FileInputStream} on + * the same descriptor, whose {@code available()} asks the pipe directly. + */ + private record Fifos(RandomAccessFile stdout, RandomAccessFile stderr, InputStream stdoutStream, InputStream stderrStream) { + static Fifos open(Path stdout, Path stderr) throws IOException { + RandomAccessFile out = new RandomAccessFile(stdout.toFile(), "rw"); + try { + RandomAccessFile err = new RandomAccessFile(stderr.toFile(), "rw"); + return new Fifos(out, err, new FileInputStream(out.getFD()), new FileInputStream(err.getFD())); + } catch (Throwable error) { + out.close(); + throw error; + } + } + + /** Closes both descriptors. Nothing else holds a lock on them. */ + void close() { + for (AutoCloseable handle : List.of(stdoutStream, stderrStream, stdout, stderr)) { + try { + handle.close(); + } catch (Exception ignored) { + // Already closed through the shared descriptor. + } + } + } + } + + /** + * Creates both FIFOs with one {@code mkfifo -m 600}. Returns false, with + * {@code mkfifo} killed, once the call is cancelled or interrupted or the + * lifetime ends; the caller then reports that instead of a spawn failure. + * An interrupt stays set for the caller. + * + * @throws IOException when {@code mkfifo} cannot run, fails, or takes + * longer than {@link #MKFIFO_WAIT} + */ + private static boolean makeFifos(Path stdout, Path stderr, long expires, Cancel cancel) throws IOException { + Process mkfifo; + try { + mkfifo = new ProcessBuilder("mkfifo", "-m", "600", stdout.toString(), stderr.toString()) + .redirectInput(ProcessBuilder.Redirect.from(new java.io.File("/dev/null"))) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start(); + } catch (IOException error) { + throw new IOException(error.getMessage() + "; the Java host needs mkfifo on Unix", error); + } + long end = System.nanoTime() + MKFIFO_WAIT; + while (true) { + if (stopped(expires, cancel)) { + mkfifo.toHandle().destroyForcibly(); + return false; + } + long left = end - System.nanoTime(); + if (left <= 0) { + mkfifo.toHandle().destroyForcibly(); + throw new IOException("mkfifo did not finish in time"); + } + try { + if (mkfifo.waitFor(Math.min(left, POLL_MAX), TimeUnit.NANOSECONDS)) { + break; + } + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + } + } + if (mkfifo.exitValue() != 0) { + throw new IOException("mkfifo exited with status " + mkfifo.exitValue()); + } + return true; + } + + /** Whether the call is cancelled or interrupted (the interrupt stays set) or the lifetime has ended. */ + private static boolean stopped(long expires, Cancel cancel) { + return cancel.isCancelled() || Thread.currentThread().isInterrupted() || System.nanoTime() - expires >= 0; + } + + private static void deleteQuietly(Path directory) { + if (directory == null) { + return; + } + for (String name : List.of("stdout", "stderr")) { + try { + Files.deleteIfExists(directory.resolve(name)); + } catch (IOException | RuntimeException ignored) { + // Left for the system's temporary-file cleanup. + } + } + try { + Files.deleteIfExists(directory); + } catch (IOException | RuntimeException ignored) { + // Left for the system's temporary-file cleanup. + } + } + + private static Worker failed(Gate gate, long expires, Cancel cancel, String spawnError, String message) { + Worker failed = new Worker(null, null, gate, expires, cancel, spawnError); + failed.fault = CarrierException.loss(message); + failed.exitRecorded = true; + return failed; + } + + /** + * A worker that was never spawned because the call was cancelled or + * interrupted, or its lifetime ended, during setup. Its first operation + * reports the abort or the deadline, as for a running worker. + */ + private static Worker stoppedBeforeSpawn(Gate gate, long expires, Cancel cancel) { + Worker stopped = new Worker(null, null, gate, expires, cancel, null); + stopped.exitRecorded = true; + stopped.fault(); + return stopped; + } + + /** + * Spawns the worker. A spawn failure is reported by the first operation. + * + * @throws IllegalArgumentException for invalid options + */ + public static Worker start(String store, Options options, Cancel cancel) { + options.validate(); + List command = new ArrayList<>(options.resolvedCommand()); + command.addAll(List.of("host", "worker", "--store", store)); + ProcessBuilder builder = new ProcessBuilder(command); + if (options.env() != null) { + Map environment = builder.environment(); + environment.clear(); + environment.putAll(options.env()); + } + Duration deadline = options.deadline() == null ? DEFAULT_DEADLINE : options.deadline(); + // Clamp to about 73 years so the nanosecond deadline cannot overflow. + long budget = deadline.compareTo(Duration.ofNanos(Long.MAX_VALUE / 4)) > 0 ? Long.MAX_VALUE / 4 : deadline.toNanos(); + long expires = System.nanoTime() + budget; + Gate gate = new Gate(expires, cancel, Thread.currentThread()); + Fifos fifos = null; + Path directory = null; + Process process = null; + try { + if (FIFOS) { + try { + directory = Files.createTempDirectory("jacquard-worker-", + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + Path out = directory.resolve("stdout"); + Path err = directory.resolve("stderr"); + if (!makeFifos(out, err, expires, cancel)) { + return stoppedBeforeSpawn(gate, expires, cancel); + } + fifos = Fifos.open(out, err); + builder.redirectOutput(ProcessBuilder.Redirect.to(out.toFile())); + builder.redirectError(ProcessBuilder.Redirect.to(err.toFile())); + } catch (IOException | RuntimeException error) { + return failed(gate, expires, cancel, String.valueOf(error.getMessage()), + "could not create the worker's output pipes (" + error.getMessage() + ")"); + } + } + if (stopped(expires, cancel)) { + return stoppedBeforeSpawn(gate, expires, cancel); + } + try { + process = builder.start(); + } catch (IOException | RuntimeException error) { + return failed(gate, expires, cancel, String.valueOf(error.getMessage()), + "could not start " + command.get(0) + " (" + error.getMessage() + "); check the configured Core installation"); + } + } finally { + if (process == null && fifos != null) { + // Any path that did not spawn, including an Error, closes the host's ends. + fifos.close(); + } + // The worker holds its ends and the host holds its own, so the + // names are no longer needed. + deleteQuietly(directory); + } + Worker worker; + try { + worker = new Worker(process, fifos, gate, expires, cancel, null); + } catch (Throwable error) { + // An Error after the spawn: kill the worker and close the host's ends. + process.toHandle().destroyForcibly(); + if (fifos != null) { + fifos.close(); + } + throw error; + } + try { + worker.writer.start(); + } catch (OutOfMemoryError error) { + // The thread could not start; disposal kills the worker. + worker.fault = CarrierException.loss("could not start the worker's stdin thread (" + error.getMessage() + ")"); + } + return worker; + } + + /** The spawn error, when the worker never started. */ + public String spawnError() { + return spawnError; + } + + /** The exit status once the worker has exited, unless the host's SIGKILL ended it. */ + public Integer exitStatus() { + return exitStatus; + } + + /** + * {@code "SIGKILL"} when the host killed the worker and the JDK reported + * exit value 137 (128 + 9); otherwise null. The JDK reports any other + * signal as exit status 128 + its number. + */ + public String signal() { + return signal; + } + + /** Whether the worker's lifetime ended by expiry, cancellation, or interruption. */ + public boolean isExpired() { + return expired.get(); + } + + /** + * Whether the call is cancelled, the caller interrupted, the worker + * expired, or its lifetime passed. Safe from any thread, and exact: it + * does not wait for the caller's next poll to notice the deadline. + */ + public boolean stopping() { + return expired.get() || gate.stopped(); + } + + /** Whether the stdin thread is still running (after disposal: stuck on a pipe a descendant holds). */ + public boolean writerAlive() { + return writer != null && writer.isAlive(); + } + + /** The carrier failure that ended the worker, if any; null otherwise. */ + public CarrierException fault() { + if (fault == null) { + takeInterrupt(); + if (cancel.isCancelled() || interrupted) { + expire(); + fault = CarrierException.aborted("invocation aborted; outside completion is unknown"); + } else if (System.nanoTime() - expires >= 0) { + expire(); + fault = CarrierException.timeout("worker lifetime deadline exceeded"); + } + } + return fault; + } + + private CarrierException faultOr(CarrierException fallback) { + CarrierException current = fault(); + return current != null ? current : fallback; + } + + /** Kills the worker's process tree and closes its input. */ + private void expire() { + expired.set(true); + killTree(); + closeInput(); + } + + /** + * Kills the worker and a snapshot of its descendants. The snapshot is + * taken only while the JDK still reports the worker alive, so it lists + * the worker's own descendants; each handle carries its start time, which + * the JDK checks before signalling. The worker is signalled through its + * {@link ProcessHandle}: {@link Process#destroyForcibly()} also closes the + * worker's stdin on the calling thread, which blocks behind a write the + * stdin thread has in progress, and so would never reach the descendants. + */ + private void killTree() { + if (process == null || !process.isAlive()) { + return; + } + List tree; + try { + tree = process.descendants().toList(); + } catch (RuntimeException error) { + tree = List.of(); + } + killed = true; + process.toHandle().destroyForcibly(); + for (ProcessHandle handle : tree) { + try { + handle.destroyForcibly(); + } catch (RuntimeException ignored) { + // Not permitted, or already gone. + } + } + } + + /** Parks for up to {@code nanos}, never past the lifetime deadline. */ + private void pause(long nanos) { + long slice = Math.min(nanos, expires - System.nanoTime()); + if (slice > 0 && !Thread.currentThread().isInterrupted()) { + LockSupport.parkNanos(slice); + } + } + + /** + * Consumes the caller's interrupt, recording it in the gate first: a stop + * check on another thread reads the interrupt status and then the gate, + * so it sees the interrupt at every moment from its arrival on. + */ + private boolean takeInterrupt() { + if (!Thread.currentThread().isInterrupted()) { + return false; + } + gate.interrupted(); + Thread.interrupted(); + interrupted = true; + return true; + } + + /** + * Waits for {@code future} until it completes, the deadline passes, or the + * invocation is cancelled. Returns null if the future failed. + */ + public T await(CompletableFuture future) throws CarrierException { + long idle = POLL_MIN; + while (true) { + CarrierException current = fault(); + if (current != null) { + throw current; + } + if (future.isDone()) { + try { + return future.join(); + } catch (CompletionException | CancellationException error) { + return null; + } + } + // Parking leaves the interrupt status set, so the stop check on + // other threads sees it until fault() records it. + pause(idle); + drainStderr(); + idle = Math.min(idle * 2, POLL_MAX); + } + } + + /** Selects how much stderr is retained. */ + public void setStderrLimit(int limit) { + stderrLimit = Math.max(0, limit); + stderrLength = Math.min(stderrLength, stderrLimit); + } + + /** The retained stderr prefix. */ + public byte[] stderr() { + return Arrays.copyOf(stderr, stderrLength); + } + + /** Reads whatever stderr is in the pipe, keeping a bounded prefix. Never blocks. */ + private void drainStderr() { + if (stderrStream == null || stderrEnded) { + return; + } + try { + for (int budget = STDERR_SLICE; budget > 0; ) { + int available = stderrStream.available(); + if (available <= 0) { + return; + } + int n = stderrStream.read(chunk, 0, Math.min(available, chunk.length)); + if (n < 0) { + stderrEnded = true; + return; + } + int keep = Math.min(n, stderrLimit - stderrLength); + if (keep > 0) { + if (stderrLength + keep > stderr.length) { + stderr = Arrays.copyOf(stderr, Math.min(stderrLimit, Math.max(stderrLength + keep, stderr.length * 2))); + } + System.arraycopy(chunk, 0, stderr, stderrLength, keep); + stderrLength += keep; + } + budget -= n; + } + } catch (IOException error) { + stderrEnded = true; + } + } + + /** + * One non-blocking step on stdout: bytes read, 0 when none are ready yet, + * or -1 at end of stream. The exit is observed before the pipe is checked, + * so all of the worker's own output has been read when -1 is returned. + */ + private int readStep(byte[] buffer, int offset, int length) { + boolean exited = !process.isAlive(); + try { + int available = stdout.available(); + if (available > 0) { + int n = stdout.read(buffer, offset, Math.min(available, length)); + return n < 0 ? -1 : n; + } + } catch (IOException error) { + return -1; + } + return exited ? -1 : 0; + } + + /** Fills {@code buffer}; returns its length, or {@code ~filled} at end of stream. */ + private int fill(byte[] buffer) throws CarrierException { + int filled = 0; + long idle = POLL_MIN; + while (filled < buffer.length) { + CarrierException current = fault(); + if (current != null) { + throw current; + } + int n = readStep(buffer, filled, buffer.length - filled); + if (n > 0) { + filled += n; + idle = POLL_MIN; + continue; + } + if (n < 0) { + return ~filled; + } + drainStderr(); + pause(idle); + idle = Math.min(idle * 2, POLL_MAX); + } + return filled; + } + + /** Reads one frame under {@code limits}. Returns null at a clean end of stream. */ + public Map readFrame(Limits limits) throws CarrierException { + CarrierException current = fault(); + if (current != null) { + throw current; + } + if (stdout == null) { + return null; + } + byte[] prefix = new byte[4]; + int got = fill(prefix); + if (got < 0) { + if (~got == 0) { + return null; + } + throw CarrierException.loss("worker closed stdout during a frame"); + } + long length = Integer.toUnsignedLong(ByteBuffer.wrap(prefix).getInt()); + if (length == 0 || length > limits.maxFrameBytes()) { + throw CarrierException.protocol(ProtocolException.exceeded("frame length exceeds max_frame_bytes")); + } + byte[] payload = new byte[(int) length]; + if (fill(payload) < 0) { + throw CarrierException.loss("worker closed stdout during a frame"); + } + // A frame that arrives in the final poll slice is not acted on. + current = fault(); + if (current != null) { + throw current; + } + try { + return JsonCodec.decodeObject(payload, limits.maxJsonDepth()); + } catch (ProtocolException error) { + throw CarrierException.protocol(error); + } + } + + /** Writes raw carrier bytes, waiting under the lifetime for the stdin thread. */ + public void writeBytes(byte[] bytes) throws CarrierException { + CarrierException current = fault(); + if (current != null) { + throw current; + } + if (writer == null || inputClosed) { + throw CarrierException.loss("worker write failed (worker input is closed)"); + } + var done = writer.submit(bytes); + if (done == null) { + throw faultOr(CarrierException.loss("worker write failed (worker input is closed)")); + } + // The stdin thread completes every job normally: null for success. + IOException error = await(done); + if (error != null) { + throw faultOr(CarrierException.loss("worker write failed (" + error.getMessage() + ")")); + } + current = fault(); + if (current != null) { + throw current; + } + } + + /** Encodes and writes one frame under the given limits. */ + public void writeFrame(Map frame, Limits limits) throws CarrierException { + byte[] bytes; + try { + bytes = JsonCodec.frameBytes(frame, limits.maxFrameBytes(), limits.maxJsonDepth()); + } catch (ProtocolException error) { + throw CarrierException.protocol(error); + } + writeBytes(bytes); + } + + /** + * Closes the worker's stdin. No further chunk starts; the stdin thread + * closes the pipe after a write in progress ends. + */ + public void closeInput() { + gate.close(); + inputClosed = true; + if (writer != null) { + writer.close(); + } + } + + /** + * Closes stdin and waits for the worker to exit, bounded by the deadline. + * Call only after stdout reached end of stream. + */ + public void awaitExit() throws CarrierException { + closeInput(); + if (process != null) { + long idle = POLL_MIN; + while (process.isAlive() && fault() == null) { + drainStderr(); + pause(idle); + idle = Math.min(idle * 2, POLL_MAX); + } + if (fault != null) { + // fault() killed the worker; wait briefly so the exit is recorded. + waitForExit(EXIT_GRACE); + } + recordExit(); + drainStderr(); + } + CarrierException current = fault(); + if (current != null) { + throw current; + } + } + + /** Waits up to {@code nanos} for the worker to exit, regardless of the deadline. */ + private void waitForExit(long nanos) { + long end = System.nanoTime() + nanos; + while (process.isAlive()) { + long left = end - System.nanoTime(); + if (left <= 0) { + return; + } + try { + process.waitFor(left, TimeUnit.NANOSECONDS); + } catch (InterruptedException error) { + // The wait cleared the status; record it as takeInterrupt would. + gate.interrupted(); + interrupted = true; + } + } + } + + private void recordExit() { + if (exitRecorded || process == null || process.isAlive()) { + return; + } + exitRecorded = true; + int value = process.exitValue(); + if (killed && value == SIGKILL_EXIT) { + signal = "SIGKILL"; + } else { + exitStatus = value; + } + } + + /** + * Kills a live worker's process tree, waits briefly for its exit, reads + * the stderr left in the pipe, closes the output pipes, and stops the + * stdin thread. Restores the caller's interrupt status if an interrupt + * aborted the lifetime. + */ + public void dispose() { + if (disposed) { + return; + } + disposed = true; + closeInput(); + if (process != null) { + if (process.isAlive()) { + killTree(); + waitForExit(EXIT_GRACE); + } + recordExit(); + drainStderr(); + if (fifos != null) { + // Closing the host's ends leaves a descendant still writing + // with no reader, so its next write fails. + fifos.close(); + } else { + closeQuietly(stdout); + closeQuietly(stderrStream); + } + } + if (writer != null) { + // The thread is idle, or its write fails once the killed worker's + // end of the pipe closes; a descendant that escaped the kill and + // holds that end keeps it blocked until it exits or reads. + writer.join(EXIT_GRACE); + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + + @Override + public void close() { + dispose(); + } + + private static void closeQuietly(InputStream stream) { + try { + stream.close(); + } catch (IOException ignored) { + // Nothing is waiting on it. + } + } +} diff --git a/java/src/main/java/org/jacquardlang/host/carrier/Writer.java b/java/src/main/java/org/jacquardlang/host/carrier/Writer.java new file mode 100644 index 0000000..37f9037 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/carrier/Writer.java @@ -0,0 +1,137 @@ +package org.jacquardlang.host.carrier; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +/** + * The worker's one stdin thread. A pipe write cannot be interrupted in Java, + * so writes run here and the caller waits for each under the worker's + * lifetime. Each frame is written in chunks of at most {@link #CHUNK} bytes, + * and the gate admits each chunk only while input is open, the lifetime has + * not ended, and cancellation has not been requested. A chunk already + * admitted may still complete after the gate closes, and the worker may read + * it, until the caller's next poll kills the worker (at most one poll slice). + * The flush and close that end the thread can block the same way while a + * descendant holds the pipe without reading. + */ +final class Writer { + /** The most bytes one admitted write may carry. */ + static final int CHUNK = 4096; + private static final Object CLOSE = new Object(); + + private record Job(byte[] bytes, CompletableFuture done) {} + + private final OutputStream out; + private final Gate gate; + private final LinkedBlockingQueue queue = new LinkedBlockingQueue<>(); + private final Thread thread; + private boolean finished; + + Writer(OutputStream out, Gate gate) { + this.out = out; + this.gate = gate; + this.thread = new Thread(this::run, "jacquard-worker-stdin"); + this.thread.setDaemon(true); + } + + void start() { + thread.start(); + } + + /** Queues one write; null once the thread has stopped. */ + synchronized CompletableFuture submit(byte[] bytes) { + if (finished) { + return null; + } + CompletableFuture done = new CompletableFuture<>(); + queue.add(new Job(bytes, done)); + return done; + } + + /** Asks the thread to close stdin after any queued write, and stop. */ + void close() { + queue.add(CLOSE); + } + + /** Waits up to {@code nanos} for the thread to stop; true once it has. */ + boolean join(long nanos) { + if (thread.getState() == Thread.State.NEW) { + return true; + } + boolean interrupted = false; + long end = System.nanoTime() + nanos; + try { + while (thread.isAlive()) { + long left = end - System.nanoTime(); + if (left <= 0) { + break; + } + try { + TimeUnit.NANOSECONDS.timedJoin(thread, left); + } catch (InterruptedException error) { + interrupted = true; + } + } + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + return !thread.isAlive(); + } + + boolean isAlive() { + return thread.isAlive(); + } + + private void run() { + try { + while (true) { + Object next = queue.take(); + if (next == CLOSE) { + return; + } + Job job = (Job) next; + IOException failure = write(job.bytes()); + job.done().complete(failure); + if (failure != null) { + return; + } + } + } catch (InterruptedException error) { + // Stop; nothing interrupts this thread except the JVM shutting down. + } finally { + try { + out.close(); + } catch (IOException ignored) { + // The worker may already have closed its end. + } + synchronized (this) { + finished = true; + for (Object left; (left = queue.poll()) != null; ) { + if (left instanceof Job job) { + job.done().complete(new IOException("worker input is closed")); + } + } + } + } + } + + private IOException write(byte[] bytes) { + try { + for (int offset = 0; offset < bytes.length; offset += CHUNK) { + if (!gate.admits()) { + return new IOException("worker input closed by the host"); + } + out.write(bytes, offset, Math.min(CHUNK, bytes.length - offset)); + out.flush(); + } + return null; + } catch (IOException error) { + return error; + } + } +} diff --git a/java/src/main/java/org/jacquardlang/host/json/Json.java b/java/src/main/java/org/jacquardlang/host/json/Json.java new file mode 100644 index 0000000..8b68919 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/json/Json.java @@ -0,0 +1,145 @@ +package org.jacquardlang.host.json; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.SortedMap; +import java.util.TreeMap; + +/** + * A decoded JSON value. Values are immutable; objects keep their keys in + * code-point order, the order the canonical encoding uses. + */ +public sealed interface Json permits Json.Null, Json.Bool, Json.Int, Json.Str, Json.Arr, Json.Obj { + /** The smallest Core {@code Int} and JSON integer: -2^62. */ + long INT_MIN = -(1L << 62); + /** The largest Core {@code Int} and JSON integer: 2^62 - 1. */ + long INT_MAX = (1L << 62) - 1; + + /** JSON {@code null}. */ + Null NULL = new Null(); + + /** Orders strings by Unicode code point, as Rust, Go, and Python do. */ + Comparator CODE_POINT_ORDER = (a, b) -> { + int i = 0; + int j = 0; + while (i < a.length() && j < b.length()) { + int x = a.codePointAt(i); + int y = b.codePointAt(j); + if (x != y) { + return Integer.compare(x, y); + } + i += Character.charCount(x); + j += Character.charCount(y); + } + return Boolean.compare(i < a.length(), j < b.length()); + }; + + /** JSON {@code null}. */ + record Null() implements Json { + @Override + public String toString() { + return "null"; + } + } + + /** A JSON boolean. */ + record Bool(boolean value) implements Json {} + + /** A JSON integer within Core's 63-bit range. */ + record Int(long value) implements Json {} + + /** A JSON string. */ + record Str(String value) implements Json { + public Str { + if (value == null) { + throw new NullPointerException("value"); + } + } + } + + /** A JSON array. */ + record Arr(List items) implements Json { + public Arr { + items = List.copyOf(items); + } + } + + /** A JSON object, keys in code-point order. */ + record Obj(Map fields) implements Json { + public Obj { + fields = sorted(fields); + } + } + + /** A string value. */ + static Json of(String value) { + return new Str(value); + } + + /** An integer value. */ + static Json of(long value) { + return new Int(value); + } + + /** A boolean value. */ + static Json of(boolean value) { + return new Bool(value); + } + + /** An array value. */ + static Json of(List items) { + return new Arr(items); + } + + /** An object value. */ + static Json of(Map fields) { + return new Obj(fields); + } + + /** + * Builds an immutable object from alternating keys and values, such as + * {@code object("kind", Json.of("int"), "value", Json.of("1"))}. + */ + static Map object(Object... pairs) { + if (pairs.length % 2 != 0) { + throw new IllegalArgumentException("object needs key/value pairs"); + } + TreeMap fields = new TreeMap<>(CODE_POINT_ORDER); + for (int i = 0; i < pairs.length; i += 2) { + fields.put((String) pairs[i], (Json) pairs[i + 1]); + } + return Collections.unmodifiableSortedMap(fields); + } + + /** An immutable copy of {@code fields} in code-point order. */ + static SortedMap sorted(Map fields) { + TreeMap copy = new TreeMap<>(CODE_POINT_ORDER); + for (Map.Entry entry : fields.entrySet()) { + copy.put(Objects.requireNonNull(entry.getKey()), Objects.requireNonNull(entry.getValue())); + } + return Collections.unmodifiableSortedMap(copy); + } + + /** The object's fields, or {@code null} if this is not an object. */ + default Map asObject() { + return this instanceof Obj object ? object.fields() : null; + } + + /** The string, or {@code null} if this is not a string. */ + default String asString() { + return this instanceof Str text ? text.value() : null; + } + + /** The items, or {@code null} if this is not an array. */ + default List asArray() { + return this instanceof Arr array ? array.items() : null; + } + + /** The integer, or {@code null} if this is not an integer. */ + default Long asInt() { + return this instanceof Int number ? number.value() : null; + } +} diff --git a/java/src/main/java/org/jacquardlang/host/json/JsonCodec.java b/java/src/main/java/org/jacquardlang/host/json/JsonCodec.java new file mode 100644 index 0000000..cc73bf1 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/json/JsonCodec.java @@ -0,0 +1,430 @@ +package org.jacquardlang.host.json; + +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import org.jacquardlang.host.ProtocolException; + +/** + * Strict UTF-8 JSON for the carrier: duplicate keys, lone surrogates, + * non-integer numbers, integers outside Core's 63-bit range, trailing data, + * and nesting deeper than the selected limit are refused. Encoding is + * canonical: object keys sorted by code point, no whitespace, and the same + * string escaping as JavaScript's {@code JSON.stringify}. + */ +public final class JsonCodec { + private JsonCodec() {} + + /** Decodes one JSON value under the strict rules. */ + public static Json decode(byte[] payload, int maxDepth) throws ProtocolException { + String text; + try { + text = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(payload)) + .toString(); + } catch (CharacterCodingException error) { + throw ProtocolException.malformed("payload is not valid UTF-8"); + } + Decoder decoder = new Decoder(text, maxDepth); + Json value = decoder.value(1); + decoder.whitespace(); + if (decoder.pos != text.length()) { + throw ProtocolException.malformed("payload is not one JSON value"); + } + return value; + } + + /** Decodes one JSON object. */ + public static Map decodeObject(byte[] payload, int maxDepth) throws ProtocolException { + if (decode(payload, maxDepth) instanceof Json.Obj object) { + return object.fields(); + } + throw ProtocolException.malformed("expected a JSON object"); + } + + private static final class Decoder { + private final String text; + private final int maxDepth; + private int pos; + + Decoder(String text, int maxDepth) { + this.text = text; + this.maxDepth = maxDepth; + } + + private int peek() { + return pos < text.length() ? text.charAt(pos) : -1; + } + + void whitespace() { + while (pos < text.length()) { + char c = text.charAt(pos); + if (c != ' ' && c != '\t' && c != '\r' && c != '\n') { + return; + } + pos++; + } + } + + Json value(int depth) throws ProtocolException { + whitespace(); + int next = peek(); + if (next == '"') { + return new Json.Str(quoted()); + } + if (next == '{' || next == '[') { + if (depth > maxDepth) { + throw ProtocolException.exceeded("JSON nesting exceeds max_json_depth"); + } + return container((char) next, depth); + } + if (text.startsWith("true", pos)) { + pos += 4; + return new Json.Bool(true); + } + if (text.startsWith("false", pos)) { + pos += 5; + return new Json.Bool(false); + } + if (text.startsWith("null", pos)) { + pos += 4; + return Json.NULL; + } + int start = pos; + if (peek() == '-') { + pos++; + } + int first = peek(); + if (first == '0') { + pos++; + } else if (first >= '1' && first <= '9') { + while (peek() >= '0' && peek() <= '9') { + pos++; + } + } else { + throw ProtocolException.malformed("invalid JSON scalar"); + } + String token = text.substring(start, pos); + if (token.length() > 20) { + throw ProtocolException.malformed("invalid JSON scalar"); + } + long number; + try { + number = Long.parseLong(token); + } catch (NumberFormatException error) { + throw ProtocolException.malformed("JSON integer is outside the 63-bit range"); + } + if (number < Json.INT_MIN || number > Json.INT_MAX) { + throw ProtocolException.malformed("JSON integer is outside the 63-bit range"); + } + return new Json.Int(number); + } + + private Json container(char open, int depth) throws ProtocolException { + pos++; + whitespace(); + char close = open == '{' ? '}' : ']'; + List items = new ArrayList<>(); + TreeMap fields = new TreeMap<>(Json.CODE_POINT_ORDER); + if (peek() == close) { + pos++; + return finish(open, items, fields); + } + while (pos < text.length()) { + if (open == '[') { + items.add(value(depth + 1)); + } else { + if (peek() != '"') { + throw ProtocolException.malformed("object key must be a string"); + } + String key = quoted(); + whitespace(); + if (fields.containsKey(key)) { + throw ProtocolException.malformed("duplicate JSON field name"); + } + if (peek() != ':') { + throw ProtocolException.malformed("expected a colon"); + } + pos++; + fields.put(key, value(depth + 1)); + } + whitespace(); + if (peek() == close) { + pos++; + return finish(open, items, fields); + } + if (peek() != ',') { + throw ProtocolException.malformed("expected a comma"); + } + pos++; + whitespace(); + } + throw ProtocolException.malformed("unterminated JSON container"); + } + + private static Json finish(char open, List items, TreeMap fields) { + return open == '{' ? new Json.Obj(fields) : new Json.Arr(items); + } + + private int hex4() { + if (pos + 4 > text.length()) { + return -1; + } + int value = 0; + for (int i = 0; i < 4; i++) { + int digit = Character.digit(text.charAt(pos + i), 16); + // Character.digit also accepts non-ASCII digits; JSON does not. + if (digit < 0 || text.charAt(pos + i) > 'f') { + return -1; + } + value = value * 16 + digit; + } + pos += 4; + return value; + } + + private String quoted() throws ProtocolException { + pos++; + StringBuilder out = new StringBuilder(); + while (true) { + if (pos >= text.length()) { + throw ProtocolException.malformed("unterminated JSON string"); + } + char c = text.charAt(pos); + if (c == '"') { + pos++; + return out.toString(); + } + if (c < 0x20) { + throw ProtocolException.malformed("invalid JSON string"); + } + if (c != '\\') { + // The payload was validated as UTF-8, so surrogates arrive paired. + out.append(c); + pos++; + continue; + } + pos++; + if (pos >= text.length()) { + throw ProtocolException.malformed("unterminated JSON string"); + } + char escape = text.charAt(pos++); + switch (escape) { + case '"' -> out.append('"'); + case '\\' -> out.append('\\'); + case '/' -> out.append('/'); + case 'b' -> out.append('\b'); + case 'f' -> out.append('\f'); + case 'n' -> out.append('\n'); + case 'r' -> out.append('\r'); + case 't' -> out.append('\t'); + case 'u' -> { + int unit = hex4(); + if (unit < 0) { + throw ProtocolException.malformed("invalid JSON string"); + } + if (unit >= 0xd800 && unit < 0xdc00) { + if (!text.startsWith("\\u", pos)) { + throw ProtocolException.malformed("a JSON string contains a lone surrogate"); + } + pos += 2; + int low = hex4(); + if (low < 0) { + throw ProtocolException.malformed("invalid JSON string"); + } + if (low < 0xdc00 || low >= 0xe000) { + throw ProtocolException.malformed("a JSON string contains a lone surrogate"); + } + out.append((char) unit).append((char) low); + } else if (unit >= 0xdc00 && unit < 0xe000) { + throw ProtocolException.malformed("a JSON string contains a lone surrogate"); + } else { + out.append((char) unit); + } + } + default -> throw ProtocolException.malformed("invalid JSON string"); + } + } + } + } + + /** Encodes a JSON value canonically. */ + public static String stringify(Json value, int maxDepth) throws ProtocolException { + Sink out = new Sink(Long.MAX_VALUE); + write(out, value, maxDepth, 1); + return out.text.toString(); + } + + /** + * The encoding so far and its UTF-8 length. Every append is counted, and + * the encoding stops once it passes {@code limit}, so a value whose + * encoding is far larger than a frame (one long text referenced many + * times, say) costs at most about one frame of memory and time. + */ + private static final class Sink { + final StringBuilder text = new StringBuilder(); + final long limit; + long bytes; + + Sink(long limit) { + this.limit = limit; + } + + /** Appends ASCII. */ + Sink append(String ascii) throws ProtocolException { + text.append(ascii); + return count(ascii.length()); + } + + Sink append(char c) throws ProtocolException { + text.append(c); + // A surrogate pair is four UTF-8 bytes: two for each half. + return count(c < 0x80 ? 1 : c < 0x800 || Character.isSurrogate(c) ? 2 : 3); + } + + private Sink count(int length) throws ProtocolException { + bytes += length; + if (bytes > limit) { + throw ProtocolException.exceeded("payload exceeds max_frame_bytes"); + } + return this; + } + } + + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private static void write(Sink out, Json value, int maxDepth, int depth) throws ProtocolException { + switch (value) { + case Json.Null ignored -> out.append("null"); + case Json.Bool flag -> out.append(flag.value() ? "true" : "false"); + case Json.Int number -> { + if (number.value() < Json.INT_MIN || number.value() > Json.INT_MAX) { + throw ProtocolException.malformed("JSON integer is outside the 63-bit range"); + } + out.append(Long.toString(number.value())); + } + case Json.Str text -> quote(out, text.value()); + case Json.Arr array -> { + if (depth > maxDepth) { + throw ProtocolException.exceeded("JSON nesting exceeds max_json_depth"); + } + out.append('['); + boolean first = true; + for (Json item : array.items()) { + if (!first) { + out.append(','); + } + first = false; + write(out, item, maxDepth, depth + 1); + } + out.append(']'); + } + case Json.Obj object -> { + if (depth > maxDepth) { + throw ProtocolException.exceeded("JSON nesting exceeds max_json_depth"); + } + out.append('{'); + boolean first = true; + for (Map.Entry entry : object.fields().entrySet()) { + if (!first) { + out.append(','); + } + first = false; + quote(out, entry.getKey()); + out.append(':'); + write(out, entry.getValue(), maxDepth, depth + 1); + } + out.append('}'); + } + } + } + + /** Whether {@code text} is a sequence of Unicode scalars (no lone surrogate). */ + public static boolean isScalar(String text) { + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (Character.isHighSurrogate(c)) { + if (i + 1 >= text.length() || !Character.isLowSurrogate(text.charAt(i + 1))) { + return false; + } + i++; + } else if (Character.isLowSurrogate(c)) { + return false; + } + } + return true; + } + + /** The UTF-8 length of a scalar string. */ + public static int utf8Length(String text) { + int length = 0; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c < 0x80) { + length += 1; + } else if (c < 0x800) { + length += 2; + } else if (Character.isHighSurrogate(c) && i + 1 < text.length() && Character.isLowSurrogate(text.charAt(i + 1))) { + length += 4; + i++; + } else { + length += 3; + } + } + return length; + } + + private static void quote(Sink out, String text) throws ProtocolException { + // Each char is at least one UTF-8 byte, so a text that cannot fit is + // refused before it is scanned. + if (out.bytes + text.length() + 2 > out.limit) { + throw ProtocolException.exceeded("payload exceeds max_frame_bytes"); + } + if (!isScalar(text)) { + throw ProtocolException.malformed("a JSON string contains a lone surrogate"); + } + out.append('"'); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + switch (c) { + case '"' -> out.append("\\\""); + case '\\' -> out.append("\\\\"); + case '\b' -> out.append("\\b"); + case '\f' -> out.append("\\f"); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + default -> { + if (c < 0x20) { + out.append("\\u00").append(HEX[c >> 4]).append(HEX[c & 0xf]); + } else { + out.append(c); + } + } + } + } + out.append('"'); + } + + /** + * Encodes one carrier frame: a big-endian u32 length and the payload, + * refusing payloads outside {@code 1..=maxBytes}. + */ + public static byte[] frameBytes(Map frame, int maxBytes, int maxDepth) throws ProtocolException { + Sink out = new Sink(maxBytes); + write(out, new Json.Obj(frame), maxDepth, 1); + byte[] payload = out.text.toString().getBytes(StandardCharsets.UTF_8); + if (payload.length == 0 || payload.length > maxBytes) { + throw ProtocolException.exceeded("payload exceeds max_frame_bytes"); + } + return ByteBuffer.allocate(4 + payload.length).putInt(payload.length).put(payload).array(); + } +} diff --git a/java/src/main/java/org/jacquardlang/host/kit/Kit.java b/java/src/main/java/org/jacquardlang/host/kit/Kit.java new file mode 100644 index 0000000..e165d27 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/kit/Kit.java @@ -0,0 +1,195 @@ +package org.jacquardlang.host.kit; + +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import org.jacquardlang.host.Compat; +import org.jacquardlang.host.ProtocolException; +import org.jacquardlang.host.json.Json; +import org.jacquardlang.host.json.JsonCodec; +import org.jacquardlang.host.protocol.Hash; + +/** + * The pinned {@code host-protocol-v0} conformance kit, carried as class-path + * resources and verified against the pinned digests when loaded. + * + * @param manifest the decoded {@code kit/kit.json} + * @param transcripts the recordings, in kit order + * @param identities the kit's named identities, such as {@code "kit-echo"} + */ +public record Kit(Map manifest, List transcripts, Map identities) { + /** The number of recordings in the pinned kit. */ + public static final int TRANSCRIPT_COUNT = 21; + + private static final List FILES = List.of("kit/kit.json", "kit/transcripts.json", "kit/fixtures.jac", "kit/README.md", "vectors.json"); + + public Kit { + manifest = Json.sorted(manifest); + transcripts = List.copyOf(transcripts); + identities = java.util.Collections.unmodifiableSortedMap(new TreeMap<>(identities)); + } + + /** One pinned kit file, such as {@code "kit/fixtures.jac"}, or null. */ + public static byte[] file(String name) { + if (!FILES.contains(name)) { + return null; + } + try (InputStream stream = Kit.class.getResourceAsStream("host-protocol-v0/" + name)) { + return stream == null ? null : stream.readAllBytes(); + } catch (IOException error) { + return null; + } + } + + private static String sha256(byte[] data) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(data)); + } catch (NoSuchAlgorithmException error) { + // Every Java platform is required to provide SHA-256. + throw new IllegalStateException(error); + } + } + + private static String text(Map object, String key) { + Json value = object.get(key); + return value == null || value.asString() == null ? "" : value.asString(); + } + + private static Json decodeFile(String name) throws KitException { + byte[] data = file(name); + if (data == null) { + throw new KitException("missing kit file " + name); + } + try { + return JsonCodec.decode(data, 256); + } catch (ProtocolException error) { + throw new KitException(name + ": " + error.getMessage()); + } + } + + /** Verifies the embedded files against the pinned digests and decodes them. */ + public static Kit load() throws KitException { + for (Map.Entry entry : Compat.KIT_DIGESTS) { + byte[] data = file(entry.getKey()); + if (data == null) { + throw new KitException("missing kit file " + entry.getKey()); + } + if (!sha256(data).equals(entry.getValue())) { + throw new KitException("pinned conformance digest mismatch: " + entry.getKey()); + } + } + Json manifestJson = decodeFile("kit/kit.json"); + Json entriesJson = decodeFile("kit/transcripts.json"); + if (!(manifestJson instanceof Json.Obj manifestObject) || !(entriesJson instanceof Json.Arr entries)) { + throw new KitException("kit files have an unexpected shape"); + } + Map manifest = manifestObject.fields(); + List pending = new ArrayList<>(); + Json decisions = manifest.get("pending_decisions"); + if (decisions != null && decisions.asArray() != null) { + for (Json decision : decisions.asArray()) { + pending.add(decision.asObject() == null ? "" : text(decision.asObject(), "name")); + } + } + if (!Json.of(Compat.PROTOCOL).equals(manifest.get("protocol")) + || !Json.of(Compat.CARRIER).equals(manifest.get("carrier")) + || !Json.of(Compat.CORE_VERSION).equals(manifest.get("core_version")) + || entries.items().size() != TRANSCRIPT_COUNT + || !pending.equals(Compat.PENDING_DECISIONS)) { + throw new KitException("kit metadata does not match the pin"); + } + TreeMap identities = new TreeMap<>(); + Json named = manifest.get("identities"); + if (named != null && named.asObject() != null) { + for (Map.Entry entry : named.asObject().entrySet()) { + String hex = entry.getValue().asString(); + try { + identities.put(entry.getKey(), Hash.parse(hex == null ? "" : hex)); + } catch (ProtocolException error) { + throw new KitException(error.getMessage()); + } + } + } + List transcripts = new ArrayList<>(); + for (Json entry : entries.items()) { + transcripts.add(parseTranscript(entry)); + } + return new Kit(manifest, transcripts, identities); + } + + private static List> objects(Map entry, String key, String name) throws KitException { + List> found = new ArrayList<>(); + Json list = entry.get(key); + if (list == null || list.asArray() == null) { + return found; + } + for (Json item : list.asArray()) { + if (item.asObject() == null) { + throw new KitException(name + ": " + key + " entry is not an object"); + } + found.add(item.asObject()); + } + return found; + } + + private static Transcript parseTranscript(Json value) throws KitException { + Map entry = value.asObject(); + if (entry == null) { + throw new KitException("transcript is not an object"); + } + String name = text(entry, "name"); + List> coreOutput = objects(entry, "core_output", name); + List hostInput = new ArrayList<>(); + for (Map step : objects(entry, "host_input", name)) { + Json raw = step.get("raw_hex"); + Json frame = step.get("frame"); + hostInput.add(new Step( + text(step, "direction"), + raw == null ? null : raw.asString(), + frame == null || frame.asObject() == null ? Map.of() : frame.asObject(), + Json.of(true).equals(step.get("after_terminal")))); + } + Json expect = entry.get("expect"); + Json observed = entry.get("observed"); + if (expect == null || expect.asObject() == null || observed == null || observed.asObject() == null || name.isEmpty()) { + throw new KitException("transcript is missing required fields"); + } + Json exit = entry.get("exit_status"); + return new Transcript( + name, + text(entry, "kind"), + text(entry, "phase"), + expect.asObject(), + observed.asObject(), + text(entry, "status"), + exit == null || exit.asInt() == null ? 0 : exit.asInt(), + coreOutput, + hostInput); + } + + /** The transcript with this name, or null. */ + public Transcript transcript(String name) { + for (Transcript transcript : transcripts) { + if (transcript.name().equals(name)) { + return transcript; + } + } + return null; + } + + /** The named identity; throws if the kit has none by that name. */ + public Hash identity(String name) { + Hash hash = identities.get(name); + if (hash == null) { + throw new IllegalArgumentException("the kit has no identity named " + name); + } + return hash; + } +} diff --git a/java/src/main/java/org/jacquardlang/host/kit/KitException.java b/java/src/main/java/org/jacquardlang/host/kit/KitException.java new file mode 100644 index 0000000..53e0ffe --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/kit/KitException.java @@ -0,0 +1,11 @@ +package org.jacquardlang.host.kit; + +/** The pinned kit could not be loaded or verified. */ +public final class KitException extends Exception { + private static final long serialVersionUID = 1L; + + /** A failure with its reason. */ + public KitException(String message) { + super(message); + } +} diff --git a/java/src/main/java/org/jacquardlang/host/kit/Replay.java b/java/src/main/java/org/jacquardlang/host/kit/Replay.java new file mode 100644 index 0000000..3eb1948 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/kit/Replay.java @@ -0,0 +1,27 @@ +package org.jacquardlang.host.kit; + +import java.util.List; +import java.util.Map; + +import org.jacquardlang.host.json.Json; + +/** + * The observed result of replaying one recording. + * + * @param status {@code "conforms"} or {@code "diverges"} + */ +public record Replay(String name, boolean matchesRecording, String status, List differences, List proseDrift, + Map summary, List> coreFrames, Integer exitStatus, String signal, byte[] stderr) { + public Replay { + differences = List.copyOf(differences); + proseDrift = List.copyOf(proseDrift); + summary = Json.sorted(summary); + coreFrames = List.copyOf(coreFrames); + stderr = stderr.clone(); + } + + @Override + public byte[] stderr() { + return stderr.clone(); + } +} diff --git a/java/src/main/java/org/jacquardlang/host/kit/Replayer.java b/java/src/main/java/org/jacquardlang/host/kit/Replayer.java new file mode 100644 index 0000000..cbab6f2 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/kit/Replayer.java @@ -0,0 +1,354 @@ +package org.jacquardlang.host.kit; + +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; + +import org.jacquardlang.host.ProtocolException; +import org.jacquardlang.host.carrier.Cancel; +import org.jacquardlang.host.carrier.CarrierException; +import org.jacquardlang.host.carrier.Options; +import org.jacquardlang.host.carrier.Worker; +import org.jacquardlang.host.json.Json; +import org.jacquardlang.host.json.JsonCodec; +import org.jacquardlang.host.protocol.Limits; + +/** + * Drives the kit's recordings, including the intentional hostile host + * inputs, against a worker through the Java carrier, and compares what Core + * sends with each recording, modulo non-normative diagnostic prose. + */ +public final class Replayer { + private Replayer() {} + + private static final List PROSE = List.of("summary", "cause", "next_step", "contrast"); + private static final String DIAGNOSTIC_SCHEMA = "jacquard-diagnostic-v1"; + + private static String kindOf(Map frame) { + Json kind = frame.get("kind"); + return kind == null ? null : kind.asString(); + } + + private static boolean isTerminal(Map frame) { + String kind = kindOf(frame); + return "outcome".equals(kind) || "fatal".equals(kind) || "shutdown_ack".equals(kind); + } + + private static boolean isDiagnostic(Map object) { + return Json.of(DIAGNOSTIC_SCHEMA).equals(object.get("schema")); + } + + private static Json stripProse(Json value) { + return switch (value) { + case Json.Arr array -> { + List items = new ArrayList<>(); + for (Json item : array.items()) { + items.add(stripProse(item)); + } + yield Json.of(items); + } + case Json.Obj object -> { + boolean diagnostic = isDiagnostic(object.fields()); + TreeMap fields = new TreeMap<>(Json.CODE_POINT_ORDER); + for (Map.Entry entry : object.fields().entrySet()) { + if (!(diagnostic && PROSE.contains(entry.getKey()))) { + fields.put(entry.getKey(), stripProse(entry.getValue())); + } + } + yield Json.of(fields); + } + default -> value; + }; + } + + private static List proseDrift(Json expected, Json actual, String path) { + List drift = new ArrayList<>(); + if (expected instanceof Json.Arr items && actual instanceof Json.Arr others) { + for (int i = 0; i < items.items().size(); i++) { + drift.addAll(proseDrift(items.items().get(i), i < others.items().size() ? others.items().get(i) : null, path + "/" + i)); + } + } else if (expected instanceof Json.Obj fields && actual instanceof Json.Obj others) { + if (isDiagnostic(fields.fields())) { + for (String key : PROSE) { + if (!canonicalOption(fields.fields().get(key)).equals(canonicalOption(others.fields().get(key)))) { + drift.add(path + "/" + key); + } + } + } else { + for (Map.Entry entry : fields.fields().entrySet()) { + drift.addAll(proseDrift(entry.getValue(), others.fields().get(entry.getKey()), path + "/" + entry.getKey())); + } + } + } + return drift; + } + + /** Mirrors JavaScript's {@code String()} for the JSON values {@code kit.ts} coerces. */ + public static String jsString(Json value) { + return switch (value) { + case Json.Str text -> text.value(); + case Json.Null ignored -> "null"; + case Json.Bool flag -> Boolean.toString(flag.value()); + case Json.Int number -> Long.toString(number.value()); + case Json.Arr array -> { + List parts = new ArrayList<>(); + for (Json item : array.items()) { + parts.add(item instanceof Json.Null ? "" : jsString(item)); + } + yield String.join(",", parts); + } + case Json.Obj ignored -> "[object Object]"; + }; + } + + /** + * Compares decoded frames. Frames are at most {@code max_json_depth} deep, + * so the 256 bound never refuses one; keys are sorted by code point, + * matching the kit's canonical JSON. + */ + private static String canonical(Json value) { + try { + return JsonCodec.stringify(value, 256); + } catch (ProtocolException error) { + return "unencodable: " + error.getMessage(); + } + } + + /** An absent prose field compares equal to null, as in {@code kit.ts}. */ + private static String canonicalOption(Json value) { + return canonical(value == null ? Json.NULL : value); + } + + private static Json frames(List> list) { + List items = new ArrayList<>(); + for (Map frame : list) { + items.add(Json.of(frame)); + } + return Json.of(items); + } + + private static byte[] hexBytes(String text) throws ReplayFailure { + if (text.length() % 2 != 0 || !text.chars().allMatch(c -> Character.digit(c, 16) >= 0 && c < 0x80)) { + throw new ReplayFailure("raw_hex is not hexadecimal"); + } + return HexFormat.of().parseHex(text); + } + + private static final class ReplayFailure extends Exception { + private static final long serialVersionUID = 1L; + + ReplayFailure(String message) { + super(message); + } + } + + private static final class Drive { + final List> observed = new ArrayList<>(); + /** The most frames kept: one past the recording's, enough to show a divergence. */ + final int limit; + boolean ended; + long skipped; + Json refusal = Json.NULL; + + Drive(int limit) { + this.limit = limit; + } + + /** + * Reads one frame. A worker that keeps sending past the recording is + * not read further, so it cannot fill memory before the deadline; + * disposal then kills it. + */ + boolean read(Worker worker) throws CarrierException, ReplayFailure { + if (observed.size() >= limit) { + throw new ReplayFailure("Core sent more frames than the recording"); + } + Map frame = worker.readFrame(Limits.HARD); + if (frame == null) { + return false; + } + ended |= isTerminal(frame); + observed.add(frame); + return true; + } + + void run(Worker worker, Transcript transcript) throws ReplayFailure { + try { + for (Step step : transcript.hostInput()) { + if (step.direction().equals("core_to_host")) { + read(worker); + } else if (step.rawHex() != null) { + worker.writeBytes(hexBytes(step.rawHex())); + worker.closeInput(); + } else if (step.afterTerminal() && ended) { + refusal = Json.of("E1608"); + } else if (ended) { + skipped++; + } else { + byte[] data; + try { + data = JsonCodec.frameBytes(step.frame(), Limits.HARD.maxFrameBytes(), Limits.HARD.maxJsonDepth()); + } catch (ProtocolException error) { + throw new ReplayFailure(error.getMessage()); + } + try { + worker.writeBytes(data); + } catch (CarrierException error) { + // Core may close its input before a hostile recording ends. + if (!(error.isLoss() && String.valueOf(error.getMessage()).startsWith("worker write failed"))) { + throw error; + } + } + } + } + worker.closeInput(); + while (read(worker)) { + // Read to end of stream. + } + worker.awaitExit(); + } catch (CarrierException error) { + throw new ReplayFailure(error.getMessage()); + } + } + } + + /** + * Drives one recording against the worker and compares what Core sends + * with the recording, modulo non-normative diagnostic prose. + */ + public static Replay replayOne(Transcript transcript, String store, Options options, Cancel cancel) { + List differences = new ArrayList<>(); + Worker worker; + try { + worker = Worker.start(store, options, cancel); + } catch (IllegalArgumentException error) { + differences.add(error.getMessage()); + return new Replay(transcript.name(), false, "diverges", differences, List.of(), Map.of(), List.of(), null, null, new byte[0]); + } + Drive drive = new Drive(transcript.coreOutput().size() + 1); + try { + drive.run(worker, transcript); + } catch (ReplayFailure error) { + differences.add(error.getMessage()); + } finally { + worker.dispose(); + } + List> observed = drive.observed; + Integer exitStatus = worker.exitStatus(); + + Map end = Map.of(); + for (Map frame : observed) { + if (isTerminal(frame)) { + end = frame; + break; + } + } + String endKind = kindOf(end); + Map result = null; + if ("outcome".equals(endKind) && end.get("result") != null) { + result = end.get("result").asObject(); + } + Json diagnostics = null; + if ("fatal".equals(endKind)) { + diagnostics = end.get("diagnostics"); + } else if (result != null && Json.of("error").equals(result.get("kind"))) { + diagnostics = result.get("diagnostics"); + } + Map diagnostic = Map.of(); + if (diagnostics != null && diagnostics.asArray() != null && !diagnostics.asArray().isEmpty() + && diagnostics.asArray().get(0).asObject() != null) { + diagnostic = diagnostics.asArray().get(0).asObject(); + } + Json primary = diagnostic.getOrDefault("code", Json.NULL); + Json terminalName; + if ("fatal".equals(endKind)) { + terminalName = Json.of("fatal"); + } else if ("shutdown_ack".equals(endKind)) { + terminalName = Json.of("shutdown"); + } else { + terminalName = Json.NULL; + Json evidence = end.get("evidence"); + if (evidence != null && evidence.asObject() != null) { + Json core = evidence.asObject().get("core"); + if (core != null && core.asObject() != null) { + terminalName = core.asObject().getOrDefault("terminal", Json.NULL); + } + } + } + long requests = observed.stream().filter(frame -> "effect_request".equals(kindOf(frame))).count(); + long terminals = observed.stream().filter(Replayer::isTerminal).count(); + Map summary = Json.object( + "effect_requests", Json.of(requests), + "host_inputs_skipped_after_terminal", Json.of(drive.skipped), + "host_local_refusal", drive.refusal, + "primary_code", primary, + "terminal", terminalName, + "terminal_frames", Json.of(terminals), + "terminal_kind", end.getOrDefault("kind", Json.NULL)); + Map expect = transcript.expect(); + boolean sameFrames = canonical(stripProse(frames(observed))).equals(canonical(stripProse(frames(transcript.coreOutput())))); + boolean conforms; + if (transcript.kind().equals("positive")) { + // As in kit.ts, an absent expectation never equals an observed value or null. + conforms = terminalName.equals(expect.get("terminal")) && exitIs(exitStatus, 0) && sameFrames; + for (Step step : transcript.hostInput()) { + if (Json.of("effect_failure").equals(step.frame().get("kind"))) { + Json message = step.frame().get("message"); + if (message != null) { + String expected = jsString(message); + Json cause = diagnostic.get("cause"); + conforms &= cause != null && cause.asString() != null && cause.asString().endsWith(expected); + } + break; + } + } + } else { + Json maximumField = expect.get("terminal_frames_max"); + Long maximum = maximumField == null ? null : maximumField.asInt(); + boolean forbid = Json.of(true).equals(expect.get("forbid_outside_action")); + int exit = Json.of("E1611").equals(expect.get("code")) ? 74 : 0; + conforms = Json.of(requests).equals(expect.get("effect_requests_before_failure")) + && maximum != null && terminals <= maximum + && (!forbid || requests == 0) + && exitIs(exitStatus, exit); + if (transcript.phase().equals("terminal")) { + conforms &= drive.refusal.equals(expect.get("code")) && terminalName.equals(Json.of("ok")); + } else { + conforms &= primary.equals(expect.get("code")); + } + } + String status = conforms ? "conforms" : "diverges"; + if (!status.equals(transcript.status())) { + differences.add("conformance status differs from the recording"); + } + if (!exitIs(exitStatus, transcript.exitStatus())) { + differences.add("exit status differs from the recording"); + } + if (!canonical(Json.of(summary)).equals(canonical(Json.of(transcript.observed())))) { + differences.add("observed summary differs from the recording"); + } + if (!sameFrames) { + differences.add("Core frames differ semantically from the recording"); + } + List drift = proseDrift(frames(transcript.coreOutput()), frames(observed), ""); + return new Replay(transcript.name(), differences.isEmpty(), status, differences, drift, summary, observed, exitStatus, + worker.signal(), worker.stderr()); + } + + private static boolean exitIs(Integer exitStatus, long code) { + return exitStatus != null && Objects.equals((long) exitStatus, code); + } + + /** Replays every recording in order, loading the pinned kit when {@code kit} is null. */ + public static List replayKit(String store, Options options, Kit kit, Cancel cancel) throws KitException { + Kit loaded = kit != null ? kit : Kit.load(); + List replays = new ArrayList<>(); + for (Transcript transcript : loaded.transcripts()) { + replays.add(replayOne(transcript, store, options, cancel)); + } + return replays; + } +} diff --git a/java/src/main/java/org/jacquardlang/host/kit/Step.java b/java/src/main/java/org/jacquardlang/host/kit/Step.java new file mode 100644 index 0000000..00905cb --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/kit/Step.java @@ -0,0 +1,19 @@ +package org.jacquardlang.host.kit; + +import java.util.Map; + +import org.jacquardlang.host.json.Json; + +/** + * One host-side action in a recording. + * + * @param direction {@code "core_to_host"} for a read, else a host write + * @param rawHex raw carrier bytes to write, or null + * @param frame the frame to write; empty for reads and raw writes + * @param afterTerminal whether the recording sends this after Core's terminal + */ +public record Step(String direction, String rawHex, Map frame, boolean afterTerminal) { + public Step { + frame = Json.sorted(frame); + } +} diff --git a/java/src/main/java/org/jacquardlang/host/kit/Transcript.java b/java/src/main/java/org/jacquardlang/host/kit/Transcript.java new file mode 100644 index 0000000..223ebf7 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/kit/Transcript.java @@ -0,0 +1,32 @@ +package org.jacquardlang.host.kit; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.jacquardlang.host.json.Json; + +/** One positive or hostile recording. */ +public record Transcript(String name, String kind, String phase, Map expect, Map observed, + String status, long exitStatus, List> coreOutput, List hostInput) { + public Transcript { + expect = Json.sorted(expect); + observed = Json.sorted(observed); + List> frames = new ArrayList<>(); + for (Map frame : coreOutput) { + frames.add(Json.sorted(frame)); + } + coreOutput = List.copyOf(frames); + hostInput = List.copyOf(hostInput); + } + + /** This recording with other expectations. */ + public Transcript withExpect(Map value) { + return new Transcript(name, kind, phase, value, observed, status, exitStatus, coreOutput, hostInput); + } + + /** This recording with other Core output. */ + public Transcript withCoreOutput(List> value) { + return new Transcript(name, kind, phase, expect, observed, status, exitStatus, value, hostInput); + } +} diff --git a/java/src/main/java/org/jacquardlang/host/protocol/BoundaryType.java b/java/src/main/java/org/jacquardlang/host/protocol/BoundaryType.java new file mode 100644 index 0000000..eb8fdae --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/protocol/BoundaryType.java @@ -0,0 +1,22 @@ +package org.jacquardlang.host.protocol; + +import java.util.List; +import java.util.Objects; + +/** A closed v0 boundary type. */ +public sealed interface BoundaryType permits BoundaryType.Nominal, BoundaryType.Tuple { + /** A type identity applied to arguments. */ + record Nominal(Hash identity, List arguments) implements BoundaryType { + public Nominal { + Objects.requireNonNull(identity, "identity"); + arguments = List.copyOf(arguments); + } + } + + /** A positional product type. */ + record Tuple(List items) implements BoundaryType { + public Tuple { + items = List.copyOf(items); + } + } +} diff --git a/java/src/main/java/org/jacquardlang/host/protocol/Budget.java b/java/src/main/java/org/jacquardlang/host/protocol/Budget.java new file mode 100644 index 0000000..997ba80 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/protocol/Budget.java @@ -0,0 +1,62 @@ +package org.jacquardlang.host.protocol; + +import org.jacquardlang.host.ProtocolException; +import org.jacquardlang.host.json.JsonCodec; + +/** + * Counts nodes and checks collection and text sizes for one frame. Text is + * charged against the frame too, so validating a value scans at most about + * one frame of text, however many times a long text is referenced. + */ +public final class Budget { + private final Limits limits; + private int nodes; + /** The UTF-8 bytes of every text checked so far. */ + private long textBytes; + + /** A fresh budget under the given limits. */ + public Budget(Limits limits) { + this.limits = limits; + } + + /** The limits this budget charges against. */ + public Limits limits() { + return limits; + } + + void consume() throws ProtocolException { + if (nodes >= limits.maxValueNodes()) { + throw ProtocolException.exceeded("frame exceeds max_value_nodes"); + } + nodes++; + } + + void count(int n, boolean arguments) throws ProtocolException { + if (n > limits.maxCollectionItems() || (arguments && n > limits.maxArguments())) { + throw ProtocolException.exceeded("collection exceeds selected limits"); + } + } + + void text(String text) throws ProtocolException { + // A string's UTF-8 length is at least its length in chars, so these + // refuse before scanning: the scan below covers at most one text and + // one frame. + if (text.length() > limits.maxTextBytes()) { + throw ProtocolException.exceeded("Text exceeds max_text_bytes"); + } + if (textBytes + text.length() > limits.maxFrameBytes()) { + throw ProtocolException.exceeded("payload exceeds max_frame_bytes"); + } + if (!JsonCodec.isScalar(text)) { + throw ProtocolException.malformed("a JSON string contains a lone surrogate"); + } + int length = JsonCodec.utf8Length(text); + if (length > limits.maxTextBytes()) { + throw ProtocolException.exceeded("Text exceeds max_text_bytes"); + } + textBytes += length; + if (textBytes > limits.maxFrameBytes()) { + throw ProtocolException.exceeded("payload exceeds max_frame_bytes"); + } + } +} diff --git a/java/src/main/java/org/jacquardlang/host/protocol/CancelReason.java b/java/src/main/java/org/jacquardlang/host/protocol/CancelReason.java new file mode 100644 index 0000000..ddfeb42 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/protocol/CancelReason.java @@ -0,0 +1,19 @@ +package org.jacquardlang.host.protocol; + +/** The reason a pending request is withdrawn. */ +public enum CancelReason { + CANCELLED("cancelled"), + TIMEOUT("timeout"), + HOST_SHUTDOWN("host_shutdown"); + + private final String wireName; + + CancelReason(String wireName) { + this.wireName = wireName; + } + + /** The wire name. */ + public String wireName() { + return wireName; + } +} diff --git a/java/src/main/java/org/jacquardlang/host/protocol/CoreFrame.java b/java/src/main/java/org/jacquardlang/host/protocol/CoreFrame.java new file mode 100644 index 0000000..950479c --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/protocol/CoreFrame.java @@ -0,0 +1,11 @@ +package org.jacquardlang.host.protocol; + +import java.util.Map; + +import org.jacquardlang.host.json.Json; + +/** A parsed Core frame after {@code core_hello}: an effect request or a terminal. */ +public sealed interface CoreFrame permits EffectRequest, Terminal { + /** The exact frame. */ + Map raw(); +} diff --git a/java/src/main/java/org/jacquardlang/host/protocol/Diagnostic.java b/java/src/main/java/org/jacquardlang/host/protocol/Diagnostic.java new file mode 100644 index 0000000..3f7f1aa --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/protocol/Diagnostic.java @@ -0,0 +1,16 @@ +package org.jacquardlang.host.protocol; + +import java.util.Map; + +import org.jacquardlang.host.json.Json; + +/** + * One {@code jacquard-diagnostic-v1} entry; {@code raw} keeps the exact + * object. {@code code} is empty when Core sent null. + */ +public record Diagnostic(String code, String domain, String severity, String summary, String cause, String nextStep, + Map raw) { + public Diagnostic { + raw = Json.sorted(raw); + } +} diff --git a/java/src/main/java/org/jacquardlang/host/protocol/EffectRequest.java b/java/src/main/java/org/jacquardlang/host/protocol/EffectRequest.java new file mode 100644 index 0000000..dd3e89e --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/protocol/EffectRequest.java @@ -0,0 +1,15 @@ +package org.jacquardlang.host.protocol; + +import java.util.List; +import java.util.Map; + +import org.jacquardlang.host.json.Json; + +/** Core asking the host to perform one operation. */ +public record EffectRequest(long ordinal, Hash effect, Hash operation, List arguments, Map raw) + implements CoreFrame { + public EffectRequest { + arguments = List.copyOf(arguments); + raw = Json.sorted(raw); + } +} diff --git a/java/src/main/java/org/jacquardlang/host/protocol/FailureCategory.java b/java/src/main/java/org/jacquardlang/host/protocol/FailureCategory.java new file mode 100644 index 0000000..df37a31 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/protocol/FailureCategory.java @@ -0,0 +1,27 @@ +package org.jacquardlang.host.protocol; + +/** The category of a host effect failure. */ +public enum FailureCategory { + UNSUPPORTED_OPERATION("unsupported_operation", "not_started"), + REFUSED_AUTHORITY("refused_authority", "not_started"), + OUTSIDE_FAILURE("outside_failure", "failed"), + COMPLETION_UNKNOWN("completion_unknown", "unknown"); + + private final String wireName; + private final String completion; + + FailureCategory(String wireName, String completion) { + this.wireName = wireName; + this.completion = completion; + } + + /** The wire name. */ + public String wireName() { + return wireName; + } + + /** The one completion the protocol permits with this category. */ + public String completion() { + return completion; + } +} diff --git a/java/src/main/java/org/jacquardlang/host/protocol/Hash.java b/java/src/main/java/org/jacquardlang/host/protocol/Hash.java new file mode 100644 index 0000000..8ed1403 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/protocol/Hash.java @@ -0,0 +1,59 @@ +package org.jacquardlang.host.protocol; + +import org.jacquardlang.host.ProtocolException; +import org.jacquardlang.host.json.Json; + +/** A HASH_V0 identity: exactly 64 lowercase hexadecimal digits. */ +public record Hash(String hex) implements Comparable { + private static final String MESSAGE = "HASH_V0 must be exactly 64 lowercase hexadecimal digits"; + + /** Validates; prefer {@link #parse}, which reports E1601. */ + public Hash { + if (!valid(hex)) { + throw new IllegalArgumentException(MESSAGE); + } + } + + /** Validates a HASH_V0 identity. */ + public static Hash parse(String text) throws ProtocolException { + if (!valid(text)) { + throw ProtocolException.malformed(MESSAGE); + } + return new Hash(text); + } + + static Hash fromJson(Json value) throws ProtocolException { + String text = value.asString(); + if (text == null) { + throw ProtocolException.malformed(MESSAGE); + } + return parse(text); + } + + static boolean lowerHex(String text, int length) { + if (text == null || text.length() != length) { + return false; + } + for (int i = 0; i < length; i++) { + char c = text.charAt(i); + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) { + return false; + } + } + return true; + } + + private static boolean valid(String text) { + return lowerHex(text, 64); + } + + @Override + public int compareTo(Hash other) { + return hex.compareTo(other.hex); + } + + @Override + public String toString() { + return hex; + } +} diff --git a/java/src/main/java/org/jacquardlang/host/protocol/Interface.java b/java/src/main/java/org/jacquardlang/host/protocol/Interface.java new file mode 100644 index 0000000..7ab25e5 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/protocol/Interface.java @@ -0,0 +1,13 @@ +package org.jacquardlang.host.protocol; + +import java.util.List; +import java.util.Objects; + +/** The checked callable signature sent with an invocation. */ +public record Interface(List parameters, BoundaryType result, List effects) { + public Interface { + parameters = List.copyOf(parameters); + Objects.requireNonNull(result, "result"); + effects = List.copyOf(effects); + } +} diff --git a/java/src/main/java/org/jacquardlang/host/protocol/Limits.java b/java/src/main/java/org/jacquardlang/host/protocol/Limits.java new file mode 100644 index 0000000..fa4b180 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/protocol/Limits.java @@ -0,0 +1,109 @@ +package org.jacquardlang.host.protocol; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.jacquardlang.host.ProtocolException; +import org.jacquardlang.host.json.Json; + +/** The v0 ceilings a host selects; each must lie in {@code 1..=} its hard value. */ +public record Limits( + int maxArguments, + int maxCollectionItems, + int maxDiagnosticBytes, + int maxDiagnostics, + int maxEffectRequests, + int maxEffects, + int maxFrameBytes, + int maxHostMessageBytes, + int maxJsonDepth, + int maxOperations, + int maxStderrBytes, + int maxTextBytes, + int maxValueNodes) { + + /** The frozen v0 ceilings Core advertises in {@code core_hello}. */ + public static final Limits HARD = new Limits(64, 1024, 65536, 32, 1024, 64, 1_048_576, 4096, 64, 256, 65536, 262_144, 4096); + + /** The largest integer the other adapters accept exactly in limits and ordinals. */ + static final long MAX_SAFE_INTEGER = (1L << 53) - 1; + + /** The wire names, in protocol (sorted) order. */ + public static final List NAMES = List.of( + "max_arguments", + "max_collection_items", + "max_diagnostic_bytes", + "max_diagnostics", + "max_effect_requests", + "max_effects", + "max_frame_bytes", + "max_host_message_bytes", + "max_json_depth", + "max_operations", + "max_stderr_bytes", + "max_text_bytes", + "max_value_nodes"); + + private int[] values() { + return new int[] { + maxArguments, maxCollectionItems, maxDiagnosticBytes, maxDiagnostics, maxEffectRequests, maxEffects, + maxFrameBytes, maxHostMessageBytes, maxJsonDepth, maxOperations, maxStderrBytes, maxTextBytes, maxValueNodes + }; + } + + private static Limits of(int[] v) { + return new Limits(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8], v[9], v[10], v[11], v[12]); + } + + /** These limits with the one named by its wire name, such as {@code "max_frame_bytes"}, replaced. */ + public Limits with(String name, int value) { + int index = NAMES.indexOf(name); + if (index < 0) { + throw new IllegalArgumentException("unknown limit " + name); + } + int[] v = values(); + v[index] = value; + return of(v); + } + + /** E1602 for a limit outside {@code 1..=} its hard ceiling. */ + public void validate() throws ProtocolException { + int[] v = values(); + int[] hard = HARD.values(); + for (int i = 0; i < v.length; i++) { + if (v[i] < 1 || v[i] > hard[i]) { + throw ProtocolException.exceeded("limit " + NAMES.get(i) + " exceeds its bounds"); + } + } + } + + /** The wire object for {@code host_select}. */ + public Map toJson() { + Map fields = new LinkedHashMap<>(); + int[] v = values(); + for (int i = 0; i < v.length; i++) { + fields.put(NAMES.get(i), Json.of(v[i])); + } + return Json.sorted(fields); + } + + /** Parses and validates an exact limits object. */ + public static Limits fromJson(Json value) throws ProtocolException { + Map fields = Protocol.exact(value, NAMES.toArray(String[]::new)); + int[] hard = HARD.values(); + int[] parsed = new int[hard.length]; + for (int i = 0; i < hard.length; i++) { + String name = NAMES.get(i); + Long number = fields.get(name).asInt(); + if (number == null || number < -MAX_SAFE_INTEGER || number > MAX_SAFE_INTEGER) { + throw ProtocolException.malformed("limit " + name + " must be an integer"); + } + if (number < 1 || number > hard[i]) { + throw ProtocolException.exceeded("limit " + name + " exceeds its bounds"); + } + parsed[i] = (int) (long) number; + } + return of(parsed); + } +} diff --git a/java/src/main/java/org/jacquardlang/host/protocol/OperationEntry.java b/java/src/main/java/org/jacquardlang/host/protocol/OperationEntry.java new file mode 100644 index 0000000..4f79eef --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/protocol/OperationEntry.java @@ -0,0 +1,17 @@ +package org.jacquardlang.host.protocol; + +import java.util.Objects; + +/** An exact effect operation granted for the invocation. */ +public record OperationEntry(Hash effect, Hash operation) implements Comparable { + public OperationEntry { + Objects.requireNonNull(effect, "effect"); + Objects.requireNonNull(operation, "operation"); + } + + @Override + public int compareTo(OperationEntry other) { + int order = effect.compareTo(other.effect); + return order != 0 ? order : operation.compareTo(other.operation); + } +} diff --git a/java/src/main/java/org/jacquardlang/host/protocol/Protocol.java b/java/src/main/java/org/jacquardlang/host/protocol/Protocol.java new file mode 100644 index 0000000..ee60145 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/protocol/Protocol.java @@ -0,0 +1,559 @@ +package org.jacquardlang.host.protocol; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.jacquardlang.host.Compat; +import org.jacquardlang.host.ProtocolException; +import org.jacquardlang.host.json.Json; +import org.jacquardlang.host.json.JsonCodec; + +/** + * Frozen v0 value and type codecs, the invoke frame, and Core frame + * validation. Descriptors are checked in Core's order: kind, exact fields, + * one node, identity, then counts. + */ +public final class Protocol { + private Protocol() {} + + /** The only invocation identity a v0 host session uses. */ + public static final String INVOCATION_ID = "0000000000000000"; + + private static final Set TERMINALS = Set.of("ok", "diagnostic", "cancelled", "host_failure", "completion_unknown"); + private static final Set CATEGORIES = Set.of( + "ok", "unsupported_operation", "refused_authority", "outside_failure", "completion_unknown", "cancelled", "timeout", + "host_shutdown"); + private static final Set COMPLETIONS = Set.of("completed", "not_started", "failed", "unknown"); + + static Map asObject(Json value) throws ProtocolException { + Map fields = value.asObject(); + if (fields == null) { + throw ProtocolException.malformed("expected a JSON object"); + } + return fields; + } + + /** The object's fields, refusing any key set other than exactly {@code keys}. */ + public static Map exact(Json value, String... keys) throws ProtocolException { + Map fields = asObject(value); + boolean ok = fields.size() == keys.length; + for (String key : keys) { + ok &= fields.containsKey(key); + } + if (!ok) { + throw ProtocolException.malformed("expected exactly: " + String.join(", ", keys)); + } + return fields; + } + + private static String asStr(Json value) throws ProtocolException { + String text = value.asString(); + if (text == null) { + throw ProtocolException.malformed("expected a string"); + } + return text; + } + + private static List asArray(Json value) throws ProtocolException { + List items = value.asArray(); + if (items == null) { + throw ProtocolException.malformed("expected an array"); + } + return items; + } + + private static String kind(Json value) throws ProtocolException { + Map fields = asObject(value); + Json kind = fields.get("kind"); + if (kind == null || kind.asString() == null) { + throw ProtocolException.malformed("a boundary descriptor needs one kind string"); + } + return kind.asString(); + } + + private static List counted(Json value, Budget budget, boolean arguments) throws ProtocolException { + List items = asArray(value); + budget.count(items.size(), arguments); + return items; + } + + /** Parses one wire boundary type. */ + public static BoundaryType decodeType(Json value, Budget budget) throws ProtocolException { + String kind = kind(value); + switch (kind) { + case "nominal" -> { + Map fields = exact(value, "arguments", "identity", "kind"); + budget.consume(); + Hash identity = Hash.fromJson(fields.get("identity")); + List arguments = new ArrayList<>(); + for (Json item : counted(fields.get("arguments"), budget, false)) { + arguments.add(decodeType(item, budget)); + } + return new BoundaryType.Nominal(identity, arguments); + } + case "tuple" -> { + Map fields = exact(value, "items", "kind"); + budget.consume(); + List items = new ArrayList<>(); + for (Json item : counted(fields.get("items"), budget, false)) { + items.add(decodeType(item, budget)); + } + return new BoundaryType.Tuple(items); + } + default -> throw ProtocolException.unsupported("unsupported boundary type kind"); + } + } + + /** Renders a boundary type, charging the budget. */ + public static Json encodeType(BoundaryType type, Budget budget) throws ProtocolException { + return encodeType(type, budget, 1); + } + + private static Json encodeType(BoundaryType type, Budget budget, int depth) throws ProtocolException { + if (depth > budget.limits().maxJsonDepth()) { + throw ProtocolException.exceeded("type nesting exceeds max_json_depth"); + } + budget.consume(); + List children = switch (type) { + case BoundaryType.Nominal nominal -> nominal.arguments(); + case BoundaryType.Tuple tuple -> tuple.items(); + }; + budget.count(children.size(), false); + List items = new ArrayList<>(); + for (BoundaryType child : children) { + items.add(encodeType(child, budget, depth + 2)); + } + return switch (type) { + case BoundaryType.Nominal nominal -> Json.of(Json.object( + "arguments", Json.of(items), "identity", Json.of(nominal.identity().hex()), "kind", Json.of("nominal"))); + case BoundaryType.Tuple ignored -> Json.of(Json.object("items", Json.of(items), "kind", Json.of("tuple"))); + }; + } + + private static long canonicalInt(String text) throws ProtocolException { + String digits = text.startsWith("-") ? text.substring(1) : text; + boolean canonical = !digits.isEmpty() + && digits.chars().allMatch(c -> c >= '0' && c <= '9') + && (digits.equals("0") || !digits.startsWith("0")) + && !text.equals("-0") + && text.length() <= 20; + if (!canonical) { + throw ProtocolException.malformed("Int must use canonical 63-bit decimal text"); + } + long value; + try { + value = Long.parseLong(text); + } catch (NumberFormatException error) { + throw ProtocolException.malformed("Int is outside the 63-bit range"); + } + if (value < Json.INT_MIN || value > Json.INT_MAX) { + throw ProtocolException.malformed("Int is outside the 63-bit range"); + } + return value; + } + + /** Parses one wire boundary value. */ + public static Value decodeValue(Json value, Budget budget) throws ProtocolException { + String kind = kind(value); + switch (kind) { + case "int", "text", "hash" -> { + Map fields = exact(value, "kind", "value"); + budget.consume(); + switch (kind) { + case "int" -> { + return new Value.Int(canonicalInt(asStr(fields.get("value")))); + } + case "text" -> { + String text = asStr(fields.get("value")); + budget.text(text); + return new Value.Text(text); + } + default -> { + return new Value.HashValue(Hash.fromJson(fields.get("value"))); + } + } + } + case "real" -> { + Map fields = exact(value, "bits", "kind"); + budget.consume(); + String bits = asStr(fields.get("bits")); + if (!Hash.lowerHex(bits, 16)) { + throw ProtocolException.malformed("Real must have exactly 16 lowercase hexadecimal digits"); + } + return new Value.Real(Long.parseUnsignedLong(bits, 16)); + } + case "tuple" -> { + Map fields = exact(value, "items", "kind"); + budget.consume(); + List items = new ArrayList<>(); + for (Json item : counted(fields.get("items"), budget, false)) { + items.add(decodeValue(item, budget)); + } + return new Value.Tuple(items); + } + case "constructor" -> { + Map fields = exact(value, "arguments", "identity", "kind"); + budget.consume(); + Hash identity = Hash.fromJson(fields.get("identity")); + List arguments = new ArrayList<>(); + for (Json item : counted(fields.get("arguments"), budget, true)) { + arguments.add(decodeValue(item, budget)); + } + return new Value.Constructor(identity, arguments); + } + default -> throw ProtocolException.unsupported("unsupported boundary value kind"); + } + } + + /** Renders a boundary value, charging the budget. */ + public static Json encodeValue(Value value, Budget budget) throws ProtocolException { + return encodeValue(value, budget, 1); + } + + private static String hex16(long bits) { + String hex = Long.toHexString(bits); + return "0".repeat(16 - hex.length()) + hex; + } + + private static Json encodeValue(Value value, Budget budget, int depth) throws ProtocolException { + if (value == null) { + throw ProtocolException.unsupported("null is not a boundary value"); + } + if (depth > budget.limits().maxJsonDepth()) { + throw ProtocolException.exceeded("value nesting exceeds max_json_depth"); + } + budget.consume(); + return Json.of(switch (value) { + case Value.Int number -> { + if (number.value() < Json.INT_MIN || number.value() > Json.INT_MAX) { + throw ProtocolException.unsupported("Int is outside the 63-bit range"); + } + yield Json.object("kind", Json.of("int"), "value", Json.of(Long.toString(number.value()))); + } + case Value.Text text -> { + budget.text(text.value()); + yield Json.object("kind", Json.of("text"), "value", Json.of(text.value())); + } + case Value.Real real -> Json.object("bits", Json.of(hex16(real.bits())), "kind", Json.of("real")); + case Value.HashValue hash -> Json.object("kind", Json.of("hash"), "value", Json.of(hash.hash().hex())); + case Value.Tuple tuple -> { + budget.count(tuple.items().size(), false); + List items = new ArrayList<>(); + for (Value item : tuple.items()) { + items.add(encodeValue(item, budget, depth + 2)); + } + yield Json.object("items", Json.of(items), "kind", Json.of("tuple")); + } + case Value.Constructor constructor -> { + budget.count(constructor.arguments().size(), true); + List items = new ArrayList<>(); + for (Value item : constructor.arguments()) { + items.add(encodeValue(item, budget, depth + 2)); + } + yield Json.object( + "arguments", Json.of(items), "identity", Json.of(constructor.identity().hex()), "kind", Json.of("constructor")); + } + }); + } + + private static int stringBytes(Json value) { + return switch (value) { + case Json.Str text -> JsonCodec.utf8Length(text.value()); + case Json.Arr array -> array.items().stream().mapToInt(Protocol::stringBytes).sum(); + case Json.Obj object -> object.fields().values().stream().mapToInt(Protocol::stringBytes).sum(); + default -> 0; + }; + } + + private static List diagnostics(Json value, Limits limits) throws ProtocolException { + List entries = asArray(value); + if (entries.isEmpty()) { + throw ProtocolException.malformed("diagnostics must be nonempty"); + } + if (entries.size() > limits.maxDiagnostics() || stringBytes(value) > limits.maxDiagnosticBytes()) { + throw ProtocolException.exceeded("diagnostics exceed selected limits"); + } + List parsed = new ArrayList<>(); + for (Json entry : entries) { + Map fields = asObject(entry); + List keys = new ArrayList<>(List.of("cause", "code", "domain", "next_step", "schema", "severity", "span", "summary")); + if (fields.containsKey("contrast")) { + keys.add("contrast"); + } + exact(entry, keys.toArray(String[]::new)); + if (!Json.of("jacquard-diagnostic-v1").equals(fields.get("schema"))) { + throw ProtocolException.malformed("unknown diagnostic schema"); + } + Json codeField = fields.get("code"); + String code = codeField instanceof Json.Null ? "" : asStr(codeField); + if (!(fields.get("span") instanceof Json.Null)) { + asObject(fields.get("span")); + } + Json contrast = fields.get("contrast"); + if (contrast != null && !(contrast instanceof Json.Null)) { + Map pair = exact(contrast, "intended", "mistaken"); + asStr(pair.get("intended")); + asStr(pair.get("mistaken")); + } + parsed.add(new Diagnostic( + code, + asStr(fields.get("domain")), + asStr(fields.get("severity")), + asStr(fields.get("summary")), + asStr(fields.get("cause")), + asStr(fields.get("next_step")), + fields)); + } + return parsed; + } + + private static List sortedHashes(Json value, int limit) throws ProtocolException { + List hashes = new ArrayList<>(); + for (Json item : asArray(value)) { + hashes.add(Hash.fromJson(item)); + } + if (hashes.size() > limit) { + throw ProtocolException.exceeded("effect set exceeds selected limits"); + } + if (!strictlySorted(hashes)) { + throw ProtocolException.malformed("effects must be a strictly sorted set"); + } + return hashes; + } + + private static boolean strictlySorted(List hashes) { + for (int i = 1; i < hashes.size(); i++) { + if (hashes.get(i).compareTo(hashes.get(i - 1)) <= 0) { + return false; + } + } + return true; + } + + private static Json hashList(List hashes) { + List items = new ArrayList<>(); + for (Hash hash : hashes) { + items.add(Json.of(hash.hex())); + } + return Json.of(items); + } + + /** Validates and renders the single v0 invoke frame. */ + public static Map invokeFrame(Hash target, Interface spec, List args, List operations, + Limits limits) throws ProtocolException { + if (args.size() != spec.parameters().size()) { + throw ProtocolException.malformed("argument count differs from parameter count"); + } + Budget budget = new Budget(limits); + budget.count(args.size(), true); + if (spec.effects().size() > limits.maxEffects()) { + throw ProtocolException.exceeded("effect set exceeds selected limits"); + } + if (!strictlySorted(spec.effects())) { + throw ProtocolException.malformed("effects must be a strictly sorted set"); + } + if (operations.size() > limits.maxOperations()) { + throw ProtocolException.exceeded("registry exceeds max_operations"); + } + List entries = new ArrayList<>(operations); + Collections.sort(entries); + List granted = new ArrayList<>(); + for (int index = 0; index < entries.size(); index++) { + OperationEntry entry = entries.get(index); + if (!spec.effects().contains(entry.effect())) { + throw ProtocolException.malformed("operation effect is outside the interface row"); + } + if (index > 0 && entries.get(index - 1).equals(entry)) { + throw ProtocolException.malformed("duplicate operation entry"); + } + granted.add(Json.of(Json.object( + "effect", Json.of(entry.effect().hex()), "mode", Json.of("once"), "operation", Json.of(entry.operation().hex())))); + } + List arguments = new ArrayList<>(); + for (Value arg : args) { + arguments.add(encodeValue(arg, budget)); + } + List parameters = new ArrayList<>(); + for (BoundaryType parameter : spec.parameters()) { + parameters.add(encodeType(parameter, budget)); + } + Json result = encodeType(spec.result(), budget); + return Json.object( + "arguments", Json.of(arguments), + "capabilities", Json.of(Json.object("effects", hashList(spec.effects()), "operations", Json.of(granted))), + "interface", Json.of(Json.object( + "effects", hashList(spec.effects()), "parameters", Json.of(parameters), "result", result)), + "invocation_id", Json.of(INVOCATION_ID), + "kind", Json.of("invoke"), + "protocol", Json.of(Compat.PROTOCOL), + "target", Json.of(Json.object("callable", Json.of(target.hex()), "kind", Json.of("store-term-v0")))); + } + + private static void hashes(Map fields, String... keys) throws ProtocolException { + for (String key : keys) { + Hash.fromJson(fields.get(key)); + } + } + + private record Evidence(Map evidence, String terminal) {} + + private static Evidence validateEvidence(Json value, Limits limits, Budget budget) throws ProtocolException { + Map evidence = exact(value, "core", "host_observations"); + Map core = exact(evidence.get("core"), + "capabilities", "effect_requests", "interface", "invocation_id", "schema", "target", "terminal"); + String terminal = core.get("terminal").asString(); + if (terminal == null || !TERMINALS.contains(terminal)) { + throw ProtocolException.malformed("unknown core evidence schema or terminal"); + } + if (!Json.of("jacquard-host-core-evidence-v0").equals(core.get("schema"))) { + throw ProtocolException.malformed("unknown core evidence schema or terminal"); + } + if (!Json.of(INVOCATION_ID).equals(core.get("invocation_id"))) { + throw ProtocolException.order("wrong evidence invocation"); + } + Map target = exact(core.get("target"), "callable", "kind"); + Hash.fromJson(target.get("callable")); + if (!Json.of("store-term-v0").equals(target.get("kind"))) { + throw ProtocolException.malformed("unknown target kind"); + } + Map spec = exact(core.get("interface"), "effects", "parameters", "result"); + sortedHashes(spec.get("effects"), limits.maxEffects()); + List parameters = asArray(spec.get("parameters")); + budget.count(parameters.size(), true); + for (Json parameter : parameters) { + decodeType(parameter, budget); + } + decodeType(spec.get("result"), budget); + Map capabilities = exact(core.get("capabilities"), "effects", "operations"); + sortedHashes(capabilities.get("effects"), limits.maxEffects()); + List operations = asArray(capabilities.get("operations")); + if (operations.size() > limits.maxOperations()) { + throw ProtocolException.exceeded("too many evidence operations"); + } + for (Json operation : operations) { + Map entry = exact(operation, "effect", "mode", "operation"); + hashes(entry, "effect", "operation"); + if (!Json.of("once").equals(entry.get("mode"))) { + throw ProtocolException.malformed("unknown operation mode"); + } + } + List requests = asArray(core.get("effect_requests")); + if (requests.size() > limits.maxEffectRequests()) { + throw ProtocolException.exceeded("too many evidence requests"); + } + for (int index = 0; index < requests.size(); index++) { + Map entry = exact(requests.get(index), "effect", "operation", "ordinal"); + hashes(entry, "effect", "operation"); + if (!Json.of(index + 1L).equals(entry.get("ordinal"))) { + throw ProtocolException.malformed("evidence ordinals must be consecutive"); + } + } + Map observations = exact(evidence.get("host_observations"), "responses", "schema"); + if (!Json.of("jacquard-host-observations-v0").equals(observations.get("schema"))) { + throw ProtocolException.malformed("unknown host observations schema"); + } + for (Json response : asArray(observations.get("responses"))) { + Map entry = exact(response, "category", "completion", "ordinal"); + String category = entry.get("category").asString(); + if (category == null || !CATEGORIES.contains(category)) { + throw ProtocolException.malformed("unknown observation category"); + } + String completion = entry.get("completion").asString(); + if (completion == null || !COMPLETIONS.contains(completion)) { + throw ProtocolException.malformed("unknown observation completion"); + } + Long ordinal = entry.get("ordinal").asInt(); + if (ordinal == null || ordinal < 1 || ordinal > requests.size()) { + throw ProtocolException.malformed("invalid observation ordinal"); + } + } + return new Evidence(evidence, terminal); + } + + private static void invocation(Map fields) throws ProtocolException { + if (!asStr(fields.get("invocation_id")).equals(INVOCATION_ID)) { + throw ProtocolException.order("frame names the wrong invocation"); + } + } + + /** Validates one Core frame after {@code core_hello}. */ + public static CoreFrame parseCoreFrame(Map frame, Limits limits) throws ProtocolException { + Json kindField = frame.get("kind"); + String kind = kindField == null || kindField.asString() == null ? "" : kindField.asString(); + if (kind.equals("core_hello")) { + throw ProtocolException.order("core_hello may only be the first frame"); + } + if (!Json.of(Compat.PROTOCOL).equals(frame.get("protocol"))) { + throw ProtocolException.malformed("unknown protocol"); + } + Json value = Json.of(frame); + switch (kind) { + case "fatal" -> { + exact(value, "diagnostics", "kind", "protocol"); + return new Terminal.Fatal(diagnostics(frame.get("diagnostics"), limits), frame); + } + case "shutdown_ack" -> { + exact(value, "kind", "protocol"); + return new Terminal.ShutdownAck(frame); + } + case "effect_request" -> { + exact(value, "arguments", "effect", "invocation_id", "kind", "mode", "operation", "protocol", "request_id"); + invocation(frame); + if (!Json.of("once").equals(frame.get("mode"))) { + throw ProtocolException.malformed("unknown operation mode"); + } + String id = asStr(frame.get("request_id")); + if (!Hash.lowerHex(id, 16)) { + throw ProtocolException.malformed("request_id must have 16 lowercase hexadecimal digits"); + } + long ordinal = Long.parseUnsignedLong(id, 16); + if (ordinal == 0) { + throw ProtocolException.order("request ordinals start at one"); + } + if (Long.compareUnsigned(ordinal, Limits.MAX_SAFE_INTEGER) > 0) { + throw ProtocolException.order("request ordinal is not consecutive"); + } + Budget budget = new Budget(limits); + List items = counted(frame.get("arguments"), budget, true); + Hash effect = Hash.fromJson(frame.get("effect")); + Hash operation = Hash.fromJson(frame.get("operation")); + List arguments = new ArrayList<>(); + for (Json item : items) { + arguments.add(decodeValue(item, budget)); + } + return new EffectRequest(ordinal, effect, operation, arguments, frame); + } + case "outcome" -> { + exact(value, "evidence", "invocation_id", "kind", "protocol", "result"); + invocation(frame); + // Core encodes evidence types and the result value against one budget. + Budget budget = new Budget(limits); + Evidence evidence = validateEvidence(frame.get("evidence"), limits, budget); + Map result = asObject(frame.get("result")); + Json resultKind = result.get("kind"); + String name = resultKind == null ? null : resultKind.asString(); + if ("ok".equals(name)) { + exact(frame.get("result"), "kind", "value"); + if (!evidence.terminal().equals("ok")) { + throw ProtocolException.malformed("ok result has a non-ok terminal"); + } + Value decoded = decodeValue(result.get("value"), budget); + return new Terminal.Outcome(decoded, List.of(), evidence.terminal(), evidence.evidence(), frame); + } + if ("error".equals(name)) { + exact(frame.get("result"), "diagnostics", "kind"); + if (evidence.terminal().equals("ok")) { + throw ProtocolException.malformed("error result has an ok terminal"); + } + List parsed = diagnostics(result.get("diagnostics"), limits); + return new Terminal.Outcome(null, parsed, evidence.terminal(), evidence.evidence(), frame); + } + throw ProtocolException.malformed("unknown result kind"); + } + default -> throw ProtocolException.malformed("unknown Core frame kind"); + } + } +} diff --git a/java/src/main/java/org/jacquardlang/host/protocol/Session.java b/java/src/main/java/org/jacquardlang/host/protocol/Session.java new file mode 100644 index 0000000..70931bf --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/protocol/Session.java @@ -0,0 +1,233 @@ +package org.jacquardlang.host.protocol; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import org.jacquardlang.host.Compat; +import org.jacquardlang.host.ProtocolException; +import org.jacquardlang.host.json.Json; +import org.jacquardlang.host.json.JsonCodec; + +/** + * The carrier-independent v0 host state machine: one hello, one selection, + * one invocation or shutdown, consecutive requests, and one terminal. A + * session is used from one thread at a time. + */ +public final class Session { + private State state = State.HELLO; + private Limits limits = Limits.HARD; + private final List requests = new ArrayList<>(); + private final List> responses = new ArrayList<>(); + private Terminal terminal; + private Long pending; + + /** A session awaiting {@code core_hello}. */ + public Session() {} + + /** The current state. */ + public State state() { + return state; + } + + /** The selected limits; the hard limits until selection. */ + public Limits limits() { + return limits; + } + + /** The effect requests received, in order. */ + public List requests() { + return Collections.unmodifiableList(requests); + } + + /** The responses rendered, in order. */ + public List> responses() { + return Collections.unmodifiableList(responses); + } + + /** Core's terminal frame, once received. */ + public Terminal terminal() { + return terminal; + } + + /** The ordinal of the request awaiting a response, or null. */ + public Long pending() { + return pending; + } + + /** Validates the next Core frame. Returns null for {@code core_hello}. */ + public CoreFrame receive(Map frame) throws ProtocolException { + if (state == State.CLOSED) { + throw ProtocolException.order("Core sent a frame after its terminal"); + } + if (state == State.HELLO) { + if (!Json.of("core_hello").equals(frame.get("kind"))) { + throw ProtocolException.order("first Core frame must be core_hello"); + } + Protocol.exact(Json.of(frame), "carrier", "kind", "limits", "versions"); + if (!Json.of(Compat.CARRIER).equals(frame.get("carrier"))) { + throw ProtocolException.malformed("unknown carrier"); + } + List versions = frame.get("versions").asArray(); + if (versions == null) { + throw ProtocolException.malformed("expected an array"); + } + if (versions.isEmpty()) { + throw ProtocolException.malformed("versions must be nonempty"); + } + boolean offered = false; + for (Json version : versions) { + String name = version.asString(); + if (name == null) { + throw ProtocolException.malformed("expected a string"); + } + offered |= name.equals(Compat.PROTOCOL); + } + if (!Limits.fromJson(frame.get("limits")).equals(Limits.HARD)) { + throw ProtocolException.malformed("hello must advertise exactly the frozen limits"); + } + if (!offered) { + throw new ProtocolException("E1600", "Core does not offer " + Compat.PROTOCOL); + } + state = State.NEGOTIATING; + return null; + } + CoreFrame parsed = Protocol.parseCoreFrame(frame, limits); + switch (parsed) { + case EffectRequest request -> { + if (state != State.RUNNING) { + throw ProtocolException.order("effect request without an available invocation slot"); + } + if (request.ordinal() != requests.size() + 1L) { + throw ProtocolException.order("request ordinal is not consecutive"); + } + if (request.ordinal() > limits.maxEffectRequests()) { + throw ProtocolException.exceeded("Core exceeded max_effect_requests"); + } + requests.add(request); + pending = request.ordinal(); + state = State.WAITING; + } + case Terminal end -> { + boolean allowed = switch (end) { + case Terminal.Outcome ignored -> EnumSet.of(State.RUNNING, State.WAITING, State.FINISHING).contains(state); + case Terminal.Fatal ignored -> EnumSet.of( + State.NEGOTIATING, State.SELECTED, State.SHUTTING_DOWN, State.RUNNING, State.FINISHING).contains(state); + case Terminal.ShutdownAck ignored -> state == State.SHUTTING_DOWN; + }; + if (!allowed) { + throw ProtocolException.order(switch (end) { + case Terminal.Outcome ignored -> "outcome without a live invocation"; + case Terminal.Fatal ignored -> "fatal while a response is outstanding"; + case Terminal.ShutdownAck ignored -> "shutdown_ack without shutdown"; + }); + } + pending = null; + terminal = end; + state = State.CLOSED; + } + } + return parsed; + } + + private void require(Set states) throws ProtocolException { + if (!states.contains(state)) { + throw ProtocolException.order("message is not permitted in state " + state.wireName()); + } + } + + /** Chooses the session limits. */ + public Map select(Limits selected) throws ProtocolException { + require(EnumSet.of(State.NEGOTIATING)); + selected.validate(); + limits = selected; + state = State.SELECTED; + return Json.object("kind", Json.of("host_select"), "limits", Json.of(selected.toJson()), "protocol", Json.of(Compat.PROTOCOL)); + } + + /** Renders the session's single invocation. */ + public Map invoke(Hash target, Interface spec, List args, List operations) + throws ProtocolException { + require(EnumSet.of(State.SELECTED)); + Map frame = Protocol.invokeFrame(target, spec, args, operations, limits); + state = State.RUNNING; + return frame; + } + + /** Ends a selected session without invoking. */ + public Map shutdown() throws ProtocolException { + require(EnumSet.of(State.SELECTED)); + state = State.SHUTTING_DOWN; + return Json.object("kind", Json.of("shutdown"), "protocol", Json.of(Compat.PROTOCOL)); + } + + private static String requestId(long ordinal) { + String hex = Long.toHexString(ordinal); + return "0".repeat(16 - hex.length()) + hex; + } + + private Map respond(Map fields, boolean terminalResponse) { + long ordinal = pending; + pending = null; + TreeMap frame = new TreeMap<>(Json.CODE_POINT_ORDER); + frame.putAll(fields); + frame.put("protocol", Json.of(Compat.PROTOCOL)); + frame.put("invocation_id", Json.of(Protocol.INVOCATION_ID)); + frame.put("request_id", Json.of(requestId(ordinal))); + Map rendered = Json.sorted(frame); + responses.add(rendered); + state = terminalResponse ? State.FINISHING : State.RUNNING; + return rendered; + } + + /** The {@code effect_ok} frame for a value, validated without changing state. */ + public Map okFrame(Value value) throws ProtocolException { + require(EnumSet.of(State.WAITING)); + Json encoded = Protocol.encodeValue(value, new Budget(limits)); + Map frame = Json.object( + "invocation_id", Json.of(Protocol.INVOCATION_ID), + "kind", Json.of("effect_ok"), + "protocol", Json.of(Compat.PROTOCOL), + "request_id", Json.of(requestId(pending)), + "value", encoded); + JsonCodec.frameBytes(frame, limits.maxFrameBytes(), limits.maxJsonDepth()); + return frame; + } + + /** Answers the pending request with a value. */ + public Map respondOk(Value value) throws ProtocolException { + require(EnumSet.of(State.WAITING)); + Json encoded = Protocol.encodeValue(value, new Budget(limits)); + return respond(Json.object("kind", Json.of("effect_ok"), "value", encoded), false); + } + + /** Answers the pending request with a permitted failure. */ + public Map respondFailure(FailureCategory category, String message) throws ProtocolException { + require(EnumSet.of(State.WAITING)); + if (!JsonCodec.isScalar(message)) { + throw ProtocolException.malformed("a JSON string contains a lone surrogate"); + } + if (JsonCodec.utf8Length(message) > limits.maxHostMessageBytes()) { + throw ProtocolException.exceeded("host message exceeds max_host_message_bytes"); + } + return respond(Json.object( + "category", Json.of(category.wireName()), + "completion", Json.of(category.completion()), + "kind", Json.of("effect_failure"), + "message", Json.of(message)), true); + } + + /** Withdraws the pending request. */ + public Map cancel(CancelReason reason, String completion) throws ProtocolException { + require(EnumSet.of(State.WAITING)); + if (!List.of("not_started", "failed", "unknown").contains(completion)) { + throw ProtocolException.malformed("unknown cancellation enum"); + } + return respond(Json.object( + "completion", Json.of(completion), "kind", Json.of("cancel"), "reason", Json.of(reason.wireName())), true); + } +} diff --git a/java/src/main/java/org/jacquardlang/host/protocol/State.java b/java/src/main/java/org/jacquardlang/host/protocol/State.java new file mode 100644 index 0000000..cfb1355 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/protocol/State.java @@ -0,0 +1,24 @@ +package org.jacquardlang.host.protocol; + +/** The host session state. */ +public enum State { + HELLO("hello"), + NEGOTIATING("negotiating"), + SELECTED("selected"), + SHUTTING_DOWN("shutting_down"), + RUNNING("running"), + WAITING("waiting"), + FINISHING("finishing"), + CLOSED("closed"); + + private final String wireName; + + State(String wireName) { + this.wireName = wireName; + } + + /** The state's protocol name. */ + public String wireName() { + return wireName; + } +} diff --git a/java/src/main/java/org/jacquardlang/host/protocol/Terminal.java b/java/src/main/java/org/jacquardlang/host/protocol/Terminal.java new file mode 100644 index 0000000..c69296d --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/protocol/Terminal.java @@ -0,0 +1,37 @@ +package org.jacquardlang.host.protocol; + +import java.util.List; +import java.util.Map; + +import org.jacquardlang.host.json.Json; + +/** Core's final frame. */ +public sealed interface Terminal extends CoreFrame permits Terminal.Outcome, Terminal.Fatal, Terminal.ShutdownAck { + /** + * An invocation outcome: a value (null for an error result), or + * diagnostics, with Core's evidence and terminal name. + */ + record Outcome(Value value, List diagnostics, String terminal, Map evidence, + Map raw) implements Terminal { + public Outcome { + diagnostics = List.copyOf(diagnostics); + evidence = Json.sorted(evidence); + raw = Json.sorted(raw); + } + } + + /** A session-level failure. */ + record Fatal(List diagnostics, Map raw) implements Terminal { + public Fatal { + diagnostics = List.copyOf(diagnostics); + raw = Json.sorted(raw); + } + } + + /** The acknowledgement of a host shutdown. */ + record ShutdownAck(Map raw) implements Terminal { + public ShutdownAck { + raw = Json.sorted(raw); + } + } +} diff --git a/java/src/main/java/org/jacquardlang/host/protocol/Value.java b/java/src/main/java/org/jacquardlang/host/protocol/Value.java new file mode 100644 index 0000000..abcfa62 --- /dev/null +++ b/java/src/main/java/org/jacquardlang/host/protocol/Value.java @@ -0,0 +1,58 @@ +package org.jacquardlang.host.protocol; + +import java.util.List; +import java.util.Objects; + +/** One v0 boundary value. */ +public sealed interface Value permits Value.Int, Value.Text, Value.Real, Value.HashValue, Value.Tuple, Value.Constructor { + /** The empty tuple, Jacquard's unit. */ + Tuple UNIT = new Tuple(List.of()); + + /** + * Core's signed 63-bit integer. A {@code long} outside + * {@code -2^62..2^62-1} can be held but has no encoding (E1604). + */ + record Int(long value) implements Value {} + + /** A Unicode scalar string; a lone surrogate has no encoding (E1601). */ + record Text(String value) implements Value { + public Text { + Objects.requireNonNull(value, "value"); + } + } + + /** Binary64 bits, preserving NaN payloads and signed zero. */ + record Real(long bits) implements Value { + /** The bits as a {@code double}. */ + public double toDouble() { + return Double.longBitsToDouble(bits); + } + } + + /** An explicit {@code Hash}. */ + record HashValue(Hash hash) implements Value { + public HashValue { + Objects.requireNonNull(hash, "hash"); + } + } + + /** A positional product; the empty tuple is unit. */ + record Tuple(List items) implements Value { + public Tuple { + items = List.copyOf(items); + } + } + + /** A constructor identity and its positional arguments. */ + record Constructor(Hash identity, List arguments) implements Value { + public Constructor { + Objects.requireNonNull(identity, "identity"); + arguments = List.copyOf(arguments); + } + } + + /** The {@code Real} with the exact bits of a {@code double}. */ + static Real real(double value) { + return new Real(Double.doubleToRawLongBits(value)); + } +} diff --git a/java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/kit/README.md b/java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/kit/README.md new file mode 100644 index 0000000..789bd02 --- /dev/null +++ b/java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/kit/README.md @@ -0,0 +1,96 @@ +# Host protocol v0 conformance kit + +This directory turns the frozen HB.1 schema/state vectors in +[`../vectors.json`](../vectors.json) into executable fixtures for the +`jacquard-host-v0` protocol over the `stdio-u32-json-v0` carrier. An adapter +in any language pins this kit, drives the installed `jacquard host worker`, +and must observe exactly the Core frames recorded here. + +## Files + +- `fixtures.jac`: first-party fixture declarations. Install them once with + the published recipe, `jacquard run fixtures.jac --store DIR`, using the + prelude shipped with the same Core release. +- `kit.json`: the manifest. It records the protocol and carrier names, the + Core version that produced the kit, the recipe, the exact identities of + every fixture member, the binding from each synthetic 64-digit vector + identity to a kit member, the hard limits, the fields an adapter owns, and + every pending decision. +- `transcripts.json`: one entry per positive and hostile vector. Each entry + lists the host frames in order (or the raw bytes for wire cases), the Core + frames the worker emitted, the exit status, the observed primary code and + terminal classification, the host's local refusal for the terminal-phase + case, and a status of `conforms` or `diverges`. + +## Binding + +The vectors use synthetic identities so that they can be frozen without a +store. The kit binds them to real content-addressed members: + +| Vector identity | Kit member | +|---|---| +| `aaaa…` | `kit-ready : () ->{} ()` | +| `dddd…` | `kit-echo : (Text) ->{KitWorld} Text` | +| `bbbb…` | the `KitWorld` effect | +| `cccc…` | its `send : (Text) -> Text` operation | +| `1111…` | the `Text` type | + +Every other synthetic identity in a hostile mutation (for example the extra +capability `eeee…` or the uppercase target) is left as written, because the +case exists to prove that Core rejects it. + +## How an adapter uses the kit + +1. Pin `protocol`, `carrier`, `core_version`, and the SHA-256 of `kit.json` + and `transcripts.json` in the adapter's compatibility record. +2. Build the store with the recipe and check that the identities in + `kit.json` resolve, so the adapter is talking to the same fixtures. +3. For each transcript, start one worker and play the recorded steps in + order: read one Core frame for each `core_to_host` step and write one host + frame (or the raw bytes) for each `host_to_core` step. The two negotiation + frames, `host_select` and `invoke`, are consecutive host steps. Never write + after a terminal frame has been read: a recorded host step that follows + the terminal is counted under `host_inputs_skipped_after_terminal`, and the + `after_terminal` step of the terminal-phase case must be refused locally + as E1608. Compare the Core frames, exit status, and observed summary with + the transcript. +4. Compare semantic JSON: key order and insignificant whitespace do not + matter. Diagnostic prose (`summary`, `cause`, `next_step`, `contrast`) is + Core-authored human text and is not normative; the schema, domain, code, + severity, and span are, and an `effect_failure` message must end the first + diagnostic's cause exactly. +5. Treat any divergence as a protocol or implementation finding. An adapter + must never normalise its own frames to make a transcript pass. + +Fields an adapter owns and may add outside the Core frames are named under +`host_owned` in `kit.json`; nothing inside a Core frame is host-owned. + +## The fake host + +`test/host_kit.ml` contains the deterministic fake host used to produce and +replay these transcripts. It spawns the installed worker over pipes, writes +host frames only when it is the host's turn, refuses to send after it has +observed a terminal frame (recording the local E1608 the protocol requires), +closes its input after raw wire cases, and records every Core frame, the exit +status, and the bounded operator text. It is evidence tooling, not an adapter +or embedding contract. + +## Regeneration and drift + +`dune exec test/gen_host_kit.exe` from the repository root rebuilds the store +with the recipe, replays every vector, and rewrites `kit.json` and +`transcripts.json`. `test/test_host_kit.ml` fails when a regenerated +transcript differs from the checked-in one, when a positive sequence stops +matching its bound HB.1 template, when a hostile case stops producing its +expected primary code, or when a divergence appears that is not listed under +`pending_decisions`. + +Changing a vector's expected code or shape remains a reviewed protocol +decision, never a mechanical refresh. Two kinds of drift are recorded rather +than resolved here: + +- `pending_decisions` lists `noncanonical-target-hash`, whose vector expects + E1603 while the shipped preflight classifies an uppercase hash as a + malformed scalar (E1601) under the frozen fail-fast order. +- `diagnostic_prose.drift` lists the outcome diagnostics whose vector prose + differs from the prose the shipped session layer emits for the same code. diff --git a/java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/kit/fixtures.jac b/java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/kit/fixtures.jac new file mode 100644 index 0000000..bea80b0 --- /dev/null +++ b/java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/kit/fixtures.jac @@ -0,0 +1,21 @@ +-- Conformance kit fixtures for jacquard-host-v0. +-- Install once with `jacquard run fixtures.jac --store DIR`; kit.json records +-- the resulting identities and binds them to the synthetic HB.1 vectors. +once effect KitWorld where { + send : (Text) -> Text +} + +kit-ready : () ->{} () +kit-ready() = () + +kit-echo : (Text) ->{KitWorld} Text +kit-echo(body) = send(body) + +kit-twice : (Text) ->{KitWorld} Text +kit-twice(body) = send(send(body)) + +kit-double : (Int) ->{} Int +kit-double(n) = mul(n, 2) + +kit-fail : (Int) ->{} Int +kit-fail(n) = div(n, 0) diff --git a/java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/kit/kit.json b/java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/kit/kit.json new file mode 100644 index 0000000..6927d67 --- /dev/null +++ b/java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/kit/kit.json @@ -0,0 +1,106 @@ +{ + "bindings": { + "1111111111111111111111111111111111111111111111111111111111111111": { + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "member": "text" + }, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "identity": "2af2f2e66770cf0597374a97b4c3988dc0c3f9b42d87f6f430be53525ce6a7ec", + "member": "kit-ready" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "identity": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "member": "kit-world" + }, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc": { + "identity": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722", + "member": "send" + }, + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd": { + "identity": "91d93eb6a5a3390035a17eb4d4ce24cf8e18369e458d0382f17328fd9df303d2", + "member": "kit-echo" + } + }, + "carrier": "stdio-u32-json-v0", + "core_version": "0.3.0", + "diagnostic_prose": { + "cause_suffix_rule": "an effect_failure message must end the first diagnostic's cause", + "compared": [ "schema", "domain", "code", "severity", "span" ], + "drift": [ + { + "path": "refused_outcome/result/diagnostics/0/summary", + "transcript": "refused-authority" + }, + { + "path": "refused_outcome/result/diagnostics/0/cause", + "transcript": "refused-authority" + }, + { + "path": "refused_outcome/result/diagnostics/0/next_step", + "transcript": "refused-authority" + }, + { + "path": "timeout_unknown_outcome/result/diagnostics/0/summary", + "transcript": "timeout-with-unknown-completion" + }, + { + "path": "timeout_unknown_outcome/result/diagnostics/0/cause", + "transcript": "timeout-with-unknown-completion" + }, + { + "path": "timeout_unknown_outcome/result/diagnostics/0/next_step", + "transcript": "timeout-with-unknown-completion" + } + ], + "normative": false + }, + "hard_limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "host_owned": [ + "operator text on the worker's standard error", + "the exact moment a carrier loss is observed", + "any run identifier, timestamp, peer identity, or receipt an adapter records outside the Core frames" + ], + "identities": { + "int": "907085f5670ab5835e5356feb10ae729496e3816b863ddbb21cfe289e7d34f0d", + "kit-double": "1e5b4939f0d7b13b0502fbb04d75659a7e01ee4927ad8a5f1c64637c90357c8f", + "kit-echo": "91d93eb6a5a3390035a17eb4d4ce24cf8e18369e458d0382f17328fd9df303d2", + "kit-fail": "d337c5d51261e63407923b4aaaf6c411bee7185711358523e9c77626d9152a36", + "kit-ready": "2af2f2e66770cf0597374a97b4c3988dc0c3f9b42d87f6f430be53525ce6a7ec", + "kit-twice": "2451609ed1af76f59a8ec47a9f3bd792e1e888e0ffaa6518e3f097a66339cd23", + "kit-world": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "send": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722", + "text": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3" + }, + "pending_decisions": [ + { + "expected": "E1603", + "name": "noncanonical-target-hash", + "observed": "E1601" + } + ], + "protocol": "jacquard-host-v0", + "recipe": { + "command": "jacquard run fixtures.jac --store DIR", + "fixtures": "fixtures.jac", + "prelude": "the prelude shipped with the same Core release", + "worker": "jacquard host worker --store DIR" + }, + "schema": "jacquard-host-kit-v0", + "transcript_count": 21, + "transcripts": "transcripts.json", + "vectors": "../vectors.json" +} diff --git a/java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/kit/transcripts.json b/java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/kit/transcripts.json new file mode 100644 index 0000000..a3ed58e --- /dev/null +++ b/java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/kit/transcripts.json @@ -0,0 +1,3066 @@ +[ + { + "name": "pure-success", + "kind": "positive", + "base": null, + "phase": null, + "sequence": [ + "core_hello", "host_select", "pure_invoke", "pure_outcome" + ], + "expect": { "terminal": "ok" }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { + "step": 1, + "direction": "host_to_core", + "template": "host_select", + "frame": { + "kind": "host_select", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "protocol": "jacquard-host-v0" + } + }, + { + "step": 2, + "direction": "host_to_core", + "template": "pure_invoke", + "frame": { + "arguments": [], + "capabilities": { "effects": [], "operations": [] }, + "interface": { + "effects": [], + "parameters": [], + "result": { "items": [], "kind": "tuple" } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "2af2f2e66770cf0597374a97b4c3988dc0c3f9b42d87f6f430be53525ce6a7ec", + "kind": "store-term-v0" + } + } + }, + { "step": 3, "direction": "core_to_host", "template": "pure_outcome" } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { + "evidence": { + "core": { + "capabilities": { "effects": [], "operations": [] }, + "effect_requests": [], + "interface": { + "effects": [], + "parameters": [], + "result": { "items": [], "kind": "tuple" } + }, + "invocation_id": "0000000000000000", + "schema": "jacquard-host-core-evidence-v0", + "target": { + "callable": "2af2f2e66770cf0597374a97b4c3988dc0c3f9b42d87f6f430be53525ce6a7ec", + "kind": "store-term-v0" + }, + "terminal": "ok" + }, + "host_observations": { + "responses": [], + "schema": "jacquard-host-observations-v0" + } + }, + "invocation_id": "0000000000000000", + "kind": "outcome", + "protocol": "jacquard-host-v0", + "result": { "kind": "ok", "value": { "items": [], "kind": "tuple" } } + } + ], + "exit_status": 0, + "observed": { + "terminal_kind": "outcome", + "primary_code": null, + "terminal": "ok", + "effect_requests": 0, + "terminal_frames": 1, + "host_local_refusal": null, + "host_inputs_skipped_after_terminal": 0 + }, + "prose_drift": [], + "status": "conforms" + }, + { + "name": "one-once-effect-success", + "kind": "positive", + "base": null, + "phase": null, + "sequence": [ + "core_hello", "host_select", "effect_invoke", "effect_request", + "effect_ok", "effect_outcome" + ], + "expect": { "terminal": "ok" }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { + "step": 1, + "direction": "host_to_core", + "template": "host_select", + "frame": { + "kind": "host_select", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "protocol": "jacquard-host-v0" + } + }, + { + "step": 2, + "direction": "host_to_core", + "template": "effect_invoke", + "frame": { + "arguments": [ { "kind": "text", "value": "ping" } ], + "capabilities": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "operations": [ + { + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "mode": "once", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722" + } + ] + }, + "interface": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "parameters": [ + { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + ], + "result": { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "91d93eb6a5a3390035a17eb4d4ce24cf8e18369e458d0382f17328fd9df303d2", + "kind": "store-term-v0" + } + } + }, + { + "step": 3, + "direction": "core_to_host", + "template": "effect_request" + }, + { + "step": 4, + "direction": "host_to_core", + "template": "effect_ok", + "frame": { + "invocation_id": "0000000000000000", + "kind": "effect_ok", + "protocol": "jacquard-host-v0", + "request_id": "0000000000000001", + "value": { "kind": "text", "value": "pong" } + } + }, + { + "step": 5, + "direction": "core_to_host", + "template": "effect_outcome" + } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { + "arguments": [ { "kind": "text", "value": "ping" } ], + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "invocation_id": "0000000000000000", + "kind": "effect_request", + "mode": "once", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722", + "protocol": "jacquard-host-v0", + "request_id": "0000000000000001" + }, + { + "evidence": { + "core": { + "capabilities": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "operations": [ + { + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "mode": "once", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722" + } + ] + }, + "effect_requests": [ + { + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722", + "ordinal": 1 + } + ], + "interface": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "parameters": [ + { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + ], + "result": { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + }, + "invocation_id": "0000000000000000", + "schema": "jacquard-host-core-evidence-v0", + "target": { + "callable": "91d93eb6a5a3390035a17eb4d4ce24cf8e18369e458d0382f17328fd9df303d2", + "kind": "store-term-v0" + }, + "terminal": "ok" + }, + "host_observations": { + "responses": [ + { "category": "ok", "completion": "completed", "ordinal": 1 } + ], + "schema": "jacquard-host-observations-v0" + } + }, + "invocation_id": "0000000000000000", + "kind": "outcome", + "protocol": "jacquard-host-v0", + "result": { + "kind": "ok", + "value": { "kind": "text", "value": "pong" } + } + } + ], + "exit_status": 0, + "observed": { + "terminal_kind": "outcome", + "primary_code": null, + "terminal": "ok", + "effect_requests": 1, + "terminal_frames": 1, + "host_local_refusal": null, + "host_inputs_skipped_after_terminal": 0 + }, + "prose_drift": [], + "status": "conforms" + }, + { + "name": "refused-authority", + "kind": "positive", + "base": null, + "phase": null, + "sequence": [ + "core_hello", "host_select", "effect_invoke", "effect_request", + "effect_refused", "refused_outcome" + ], + "expect": { "terminal": "host_failure" }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { + "step": 1, + "direction": "host_to_core", + "template": "host_select", + "frame": { + "kind": "host_select", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "protocol": "jacquard-host-v0" + } + }, + { + "step": 2, + "direction": "host_to_core", + "template": "effect_invoke", + "frame": { + "arguments": [ { "kind": "text", "value": "ping" } ], + "capabilities": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "operations": [ + { + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "mode": "once", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722" + } + ] + }, + "interface": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "parameters": [ + { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + ], + "result": { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "91d93eb6a5a3390035a17eb4d4ce24cf8e18369e458d0382f17328fd9df303d2", + "kind": "store-term-v0" + } + } + }, + { + "step": 3, + "direction": "core_to_host", + "template": "effect_request" + }, + { + "step": 4, + "direction": "host_to_core", + "template": "effect_refused", + "frame": { + "category": "refused_authority", + "completion": "not_started", + "invocation_id": "0000000000000000", + "kind": "effect_failure", + "message": "policy denied", + "protocol": "jacquard-host-v0", + "request_id": "0000000000000001" + } + }, + { + "step": 5, + "direction": "core_to_host", + "template": "refused_outcome" + } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { + "arguments": [ { "kind": "text", "value": "ping" } ], + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "invocation_id": "0000000000000000", + "kind": "effect_request", + "mode": "once", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722", + "protocol": "jacquard-host-v0", + "request_id": "0000000000000001" + }, + { + "evidence": { + "core": { + "capabilities": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "operations": [ + { + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "mode": "once", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722" + } + ] + }, + "effect_requests": [ + { + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722", + "ordinal": 1 + } + ], + "interface": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "parameters": [ + { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + ], + "result": { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + }, + "invocation_id": "0000000000000000", + "schema": "jacquard-host-core-evidence-v0", + "target": { + "callable": "91d93eb6a5a3390035a17eb4d4ce24cf8e18369e458d0382f17328fd9df303d2", + "kind": "store-term-v0" + }, + "terminal": "host_failure" + }, + "host_observations": { + "responses": [ + { + "category": "refused_authority", + "completion": "not_started", + "ordinal": 1 + } + ], + "schema": "jacquard-host-observations-v0" + } + }, + "invocation_id": "0000000000000000", + "kind": "outcome", + "protocol": "jacquard-host-v0", + "result": { + "diagnostics": [ + { + "cause": "The host reported an outside failure. Host detail: policy denied", + "code": "E1607", + "domain": "process", + "next_step": "Review the host's authority policy before starting a new invocation.", + "schema": "jacquard-diagnostic-v1", + "severity": "error", + "span": null, + "summary": "The host refused authority before starting the outside action." + } + ], + "kind": "error" + } + } + ], + "exit_status": 0, + "observed": { + "terminal_kind": "outcome", + "primary_code": "E1607", + "terminal": "host_failure", + "effect_requests": 1, + "terminal_frames": 1, + "host_local_refusal": null, + "host_inputs_skipped_after_terminal": 0 + }, + "prose_drift": [ + "refused_outcome/result/diagnostics/0/summary", + "refused_outcome/result/diagnostics/0/cause", + "refused_outcome/result/diagnostics/0/next_step" + ], + "status": "conforms" + }, + { + "name": "timeout-with-unknown-completion", + "kind": "positive", + "base": null, + "phase": null, + "sequence": [ + "core_hello", "host_select", "effect_invoke", "effect_request", + "timeout_unknown_cancel", "timeout_unknown_outcome" + ], + "expect": { "terminal": "completion_unknown" }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { + "step": 1, + "direction": "host_to_core", + "template": "host_select", + "frame": { + "kind": "host_select", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "protocol": "jacquard-host-v0" + } + }, + { + "step": 2, + "direction": "host_to_core", + "template": "effect_invoke", + "frame": { + "arguments": [ { "kind": "text", "value": "ping" } ], + "capabilities": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "operations": [ + { + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "mode": "once", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722" + } + ] + }, + "interface": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "parameters": [ + { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + ], + "result": { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "91d93eb6a5a3390035a17eb4d4ce24cf8e18369e458d0382f17328fd9df303d2", + "kind": "store-term-v0" + } + } + }, + { + "step": 3, + "direction": "core_to_host", + "template": "effect_request" + }, + { + "step": 4, + "direction": "host_to_core", + "template": "timeout_unknown_cancel", + "frame": { + "completion": "unknown", + "invocation_id": "0000000000000000", + "kind": "cancel", + "protocol": "jacquard-host-v0", + "reason": "timeout", + "request_id": "0000000000000001" + } + }, + { + "step": 5, + "direction": "core_to_host", + "template": "timeout_unknown_outcome" + } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { + "arguments": [ { "kind": "text", "value": "ping" } ], + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "invocation_id": "0000000000000000", + "kind": "effect_request", + "mode": "once", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722", + "protocol": "jacquard-host-v0", + "request_id": "0000000000000001" + }, + { + "evidence": { + "core": { + "capabilities": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "operations": [ + { + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "mode": "once", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722" + } + ] + }, + "effect_requests": [ + { + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722", + "ordinal": 1 + } + ], + "interface": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "parameters": [ + { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + ], + "result": { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + }, + "invocation_id": "0000000000000000", + "schema": "jacquard-host-core-evidence-v0", + "target": { + "callable": "91d93eb6a5a3390035a17eb4d4ce24cf8e18369e458d0382f17328fd9df303d2", + "kind": "store-term-v0" + }, + "terminal": "completion_unknown" + }, + "host_observations": { + "responses": [ + { + "category": "timeout", + "completion": "unknown", + "ordinal": 1 + } + ], + "schema": "jacquard-host-observations-v0" + } + }, + "invocation_id": "0000000000000000", + "kind": "outcome", + "protocol": "jacquard-host-v0", + "result": { + "diagnostics": [ + { + "cause": "The host ended the response slot without a successful value.", + "code": "E1612", + "domain": "process", + "next_step": "Reconcile host-owned evidence; do not automatically retry the action or invocation.", + "schema": "jacquard-diagnostic-v1", + "severity": "error", + "span": null, + "summary": "The outside action's completion is unknown." + } + ], + "kind": "error" + } + } + ], + "exit_status": 0, + "observed": { + "terminal_kind": "outcome", + "primary_code": "E1612", + "terminal": "completion_unknown", + "effect_requests": 1, + "terminal_frames": 1, + "host_local_refusal": null, + "host_inputs_skipped_after_terminal": 0 + }, + "prose_drift": [ + "timeout_unknown_outcome/result/diagnostics/0/summary", + "timeout_unknown_outcome/result/diagnostics/0/cause", + "timeout_unknown_outcome/result/diagnostics/0/next_step" + ], + "status": "conforms" + }, + { + "name": "selected-shutdown", + "kind": "positive", + "base": null, + "phase": null, + "sequence": [ "core_hello", "host_select", "shutdown", "shutdown_ack" ], + "expect": { "terminal": "shutdown" }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { + "step": 1, + "direction": "host_to_core", + "template": "host_select", + "frame": { + "kind": "host_select", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "protocol": "jacquard-host-v0" + } + }, + { + "step": 2, + "direction": "host_to_core", + "template": "shutdown", + "frame": { "kind": "shutdown", "protocol": "jacquard-host-v0" } + }, + { "step": 3, "direction": "core_to_host", "template": "shutdown_ack" } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { "kind": "shutdown_ack", "protocol": "jacquard-host-v0" } + ], + "exit_status": 0, + "observed": { + "terminal_kind": "shutdown_ack", + "primary_code": null, + "terminal": "shutdown", + "effect_requests": 0, + "terminal_frames": 1, + "host_local_refusal": null, + "host_inputs_skipped_after_terminal": 0 + }, + "prose_drift": [], + "status": "conforms" + }, + { + "name": "unknown-version", + "kind": "hostile", + "base": "pure-success", + "phase": "host_select", + "sequence": [ + "core_hello", "host_select", "pure_invoke", "pure_outcome" + ], + "expect": { + "code": "E1600", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { + "step": 1, + "direction": "host_to_core", + "template": "host_select", + "frame": { + "kind": "host_select", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "protocol": "jacquard-host-v1" + } + }, + { + "step": 2, + "direction": "host_to_core", + "template": "pure_invoke", + "frame": { + "arguments": [], + "capabilities": { "effects": [], "operations": [] }, + "interface": { + "effects": [], + "parameters": [], + "result": { "items": [], "kind": "tuple" } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "2af2f2e66770cf0597374a97b4c3988dc0c3f9b42d87f6f430be53525ce6a7ec", + "kind": "store-term-v0" + } + } + }, + { "step": 3, "direction": "core_to_host", "template": "pure_outcome" } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { + "diagnostics": [ + { + "cause": "The selected protocol version is not advertised.", + "code": "E1600", + "domain": "process", + "next_step": "Select jacquard-host-v0 with the advertised stdio-u32-json-v0 carrier.", + "schema": "jacquard-diagnostic-v1", + "severity": "error", + "span": null, + "summary": "The host selected an unsupported protocol version." + } + ], + "kind": "fatal", + "protocol": "jacquard-host-v0" + } + ], + "exit_status": 0, + "observed": { + "terminal_kind": "fatal", + "primary_code": "E1600", + "terminal": "fatal", + "effect_requests": 0, + "terminal_frames": 1, + "host_local_refusal": null, + "host_inputs_skipped_after_terminal": 0 + }, + "prose_drift": [], + "status": "conforms" + }, + { + "name": "unknown-invoke-field", + "kind": "hostile", + "base": "pure-success", + "phase": "invoke", + "sequence": [ + "core_hello", "host_select", "pure_invoke", "pure_outcome" + ], + "expect": { + "code": "E1601", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { + "step": 1, + "direction": "host_to_core", + "template": "host_select", + "frame": { + "kind": "host_select", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "protocol": "jacquard-host-v0" + } + }, + { + "step": 2, + "direction": "host_to_core", + "template": "pure_invoke", + "frame": { + "arguments": [], + "capabilities": { "effects": [], "operations": [] }, + "display_name": "mutable-name", + "interface": { + "effects": [], + "parameters": [], + "result": { "items": [], "kind": "tuple" } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "2af2f2e66770cf0597374a97b4c3988dc0c3f9b42d87f6f430be53525ce6a7ec", + "kind": "store-term-v0" + } + } + }, + { "step": 3, "direction": "core_to_host", "template": "pure_outcome" } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { + "diagnostics": [ + { + "cause": "The protocol envelope has a missing or unknown field.", + "code": "E1601", + "domain": "process", + "next_step": "Send one exact UTF-8 JSON object using the frozen jacquard-host-v0 envelope.", + "schema": "jacquard-diagnostic-v1", + "severity": "error", + "span": null, + "summary": "The host protocol frame is malformed." + } + ], + "kind": "fatal", + "protocol": "jacquard-host-v0" + } + ], + "exit_status": 0, + "observed": { + "terminal_kind": "fatal", + "primary_code": "E1601", + "terminal": "fatal", + "effect_requests": 0, + "terminal_frames": 1, + "host_local_refusal": null, + "host_inputs_skipped_after_terminal": 0 + }, + "prose_drift": [], + "status": "conforms" + }, + { + "name": "duplicate-json-key", + "kind": "hostile", + "base": "pure-success", + "phase": "framing", + "sequence": [ + "core_hello", "host_select", "pure_invoke", "pure_outcome" + ], + "expect": { + "code": "E1601", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { + "step": 1, + "direction": "host_to_core", + "raw_hex": "000000267b226b696e64223a22686f73745f73656c656374222c226b696e64223a22696e766f6b65227d" + }, + { + "step": 2, + "direction": "host_to_core", + "template": "pure_invoke", + "frame": { + "arguments": [], + "capabilities": { "effects": [], "operations": [] }, + "interface": { + "effects": [], + "parameters": [], + "result": { "items": [], "kind": "tuple" } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "2af2f2e66770cf0597374a97b4c3988dc0c3f9b42d87f6f430be53525ce6a7ec", + "kind": "store-term-v0" + } + } + }, + { "step": 3, "direction": "core_to_host", "template": "pure_outcome" } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { + "diagnostics": [ + { + "cause": "A JSON object contains a duplicate field name.", + "code": "E1601", + "domain": "process", + "next_step": "Send one exact UTF-8 JSON object using the frozen jacquard-host-v0 envelope.", + "schema": "jacquard-diagnostic-v1", + "severity": "error", + "span": null, + "summary": "The host protocol frame is malformed." + } + ], + "kind": "fatal", + "protocol": "jacquard-host-v0" + } + ], + "exit_status": 0, + "observed": { + "terminal_kind": "fatal", + "primary_code": "E1601", + "terminal": "fatal", + "effect_requests": 0, + "terminal_frames": 1, + "host_local_refusal": null, + "host_inputs_skipped_after_terminal": 0 + }, + "prose_drift": [], + "status": "conforms" + }, + { + "name": "invalid-utf8", + "kind": "hostile", + "base": "pure-success", + "phase": "framing", + "sequence": [ + "core_hello", "host_select", "pure_invoke", "pure_outcome" + ], + "expect": { + "code": "E1601", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { "step": 1, "direction": "host_to_core", "raw_hex": "000000037bff7d" }, + { + "step": 2, + "direction": "host_to_core", + "template": "pure_invoke", + "frame": { + "arguments": [], + "capabilities": { "effects": [], "operations": [] }, + "interface": { + "effects": [], + "parameters": [], + "result": { "items": [], "kind": "tuple" } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "2af2f2e66770cf0597374a97b4c3988dc0c3f9b42d87f6f430be53525ce6a7ec", + "kind": "store-term-v0" + } + } + }, + { "step": 3, "direction": "core_to_host", "template": "pure_outcome" } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { + "diagnostics": [ + { + "cause": "The frame payload is not valid Unicode scalar UTF-8.", + "code": "E1601", + "domain": "process", + "next_step": "Send one exact UTF-8 JSON object using the frozen jacquard-host-v0 envelope.", + "schema": "jacquard-diagnostic-v1", + "severity": "error", + "span": null, + "summary": "The host protocol frame is malformed." + } + ], + "kind": "fatal", + "protocol": "jacquard-host-v0" + } + ], + "exit_status": 0, + "observed": { + "terminal_kind": "fatal", + "primary_code": "E1601", + "terminal": "fatal", + "effect_requests": 0, + "terminal_frames": 1, + "host_local_refusal": null, + "host_inputs_skipped_after_terminal": 0 + }, + "prose_drift": [], + "status": "conforms" + }, + { + "name": "oversized-frame-prefix", + "kind": "hostile", + "base": "pure-success", + "phase": "framing", + "sequence": [ + "core_hello", "host_select", "pure_invoke", "pure_outcome" + ], + "expect": { + "code": "E1602", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { "step": 1, "direction": "host_to_core", "raw_hex": "00100001" }, + { + "step": 2, + "direction": "host_to_core", + "template": "pure_invoke", + "frame": { + "arguments": [], + "capabilities": { "effects": [], "operations": [] }, + "interface": { + "effects": [], + "parameters": [], + "result": { "items": [], "kind": "tuple" } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "2af2f2e66770cf0597374a97b4c3988dc0c3f9b42d87f6f430be53525ce6a7ec", + "kind": "store-term-v0" + } + } + }, + { "step": 3, "direction": "core_to_host", "template": "pure_outcome" } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { + "diagnostics": [ + { + "cause": "The frame payload length exceeds max_frame_bytes.", + "code": "E1602", + "domain": "process", + "next_step": "Choose positive advertised limits and keep the frame within the selected ceilings.", + "schema": "jacquard-diagnostic-v1", + "severity": "error", + "span": null, + "summary": "The host protocol limit was exceeded or cannot represent a mandatory result." + } + ], + "kind": "fatal", + "protocol": "jacquard-host-v0" + } + ], + "exit_status": 0, + "observed": { + "terminal_kind": "fatal", + "primary_code": "E1602", + "terminal": "fatal", + "effect_requests": 0, + "terminal_frames": 1, + "host_local_refusal": null, + "host_inputs_skipped_after_terminal": 0 + }, + "prose_drift": [], + "status": "conforms" + }, + { + "name": "nonpositive-selected-limit", + "kind": "hostile", + "base": "pure-success", + "phase": "host_select", + "sequence": [ + "core_hello", "host_select", "pure_invoke", "pure_outcome" + ], + "expect": { + "code": "E1602", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { + "step": 1, + "direction": "host_to_core", + "template": "host_select", + "frame": { + "kind": "host_select", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 0, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "protocol": "jacquard-host-v0" + } + }, + { + "step": 2, + "direction": "host_to_core", + "template": "pure_invoke", + "frame": { + "arguments": [], + "capabilities": { "effects": [], "operations": [] }, + "interface": { + "effects": [], + "parameters": [], + "result": { "items": [], "kind": "tuple" } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "2af2f2e66770cf0597374a97b4c3988dc0c3f9b42d87f6f430be53525ce6a7ec", + "kind": "store-term-v0" + } + } + }, + { "step": 3, "direction": "core_to_host", "template": "pure_outcome" } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { + "diagnostics": [ + { + "cause": "The selected max_frame_bytes is not positive and advertised.", + "code": "E1602", + "domain": "process", + "next_step": "Choose positive advertised limits and keep the frame within the selected ceilings.", + "schema": "jacquard-diagnostic-v1", + "severity": "error", + "span": null, + "summary": "The host protocol limit was exceeded or cannot represent a mandatory result." + } + ], + "kind": "fatal", + "protocol": "jacquard-host-v0" + } + ], + "exit_status": 0, + "observed": { + "terminal_kind": "fatal", + "primary_code": "E1602", + "terminal": "fatal", + "effect_requests": 0, + "terminal_frames": 1, + "host_local_refusal": null, + "host_inputs_skipped_after_terminal": 0 + }, + "prose_drift": [], + "status": "conforms" + }, + { + "name": "noncanonical-target-hash", + "kind": "hostile", + "base": "pure-success", + "phase": "invoke", + "sequence": [ + "core_hello", "host_select", "pure_invoke", "pure_outcome" + ], + "expect": { + "code": "E1603", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { + "step": 1, + "direction": "host_to_core", + "template": "host_select", + "frame": { + "kind": "host_select", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "protocol": "jacquard-host-v0" + } + }, + { + "step": 2, + "direction": "host_to_core", + "template": "pure_invoke", + "frame": { + "arguments": [], + "capabilities": { "effects": [], "operations": [] }, + "interface": { + "effects": [], + "parameters": [], + "result": { "items": [], "kind": "tuple" } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "kind": "store-term-v0" + } + } + }, + { "step": 3, "direction": "core_to_host", "template": "pure_outcome" } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { + "diagnostics": [ + { + "cause": "The target callable identity must be exactly 64 lowercase hexadecimal digits.", + "code": "E1601", + "domain": "process", + "next_step": "Send one exact UTF-8 JSON object using the frozen jacquard-host-v0 envelope.", + "schema": "jacquard-diagnostic-v1", + "severity": "error", + "span": null, + "summary": "The host protocol frame is malformed." + } + ], + "kind": "fatal", + "protocol": "jacquard-host-v0" + } + ], + "exit_status": 0, + "observed": { + "terminal_kind": "fatal", + "primary_code": "E1601", + "terminal": "fatal", + "effect_requests": 0, + "terminal_frames": 1, + "host_local_refusal": null, + "host_inputs_skipped_after_terminal": 0 + }, + "prose_drift": [], + "status": "diverges" + }, + { + "name": "interface-mismatch", + "kind": "hostile", + "base": "one-once-effect-success", + "phase": "invoke", + "sequence": [ + "core_hello", "host_select", "effect_invoke", "effect_request", + "effect_ok", "effect_outcome" + ], + "expect": { + "code": "E1603", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { + "step": 1, + "direction": "host_to_core", + "template": "host_select", + "frame": { + "kind": "host_select", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "protocol": "jacquard-host-v0" + } + }, + { + "step": 2, + "direction": "host_to_core", + "template": "effect_invoke", + "frame": { + "arguments": [ { "kind": "text", "value": "ping" } ], + "capabilities": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "operations": [ + { + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "mode": "once", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722" + } + ] + }, + "interface": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "parameters": [ { "items": [], "kind": "tuple" } ], + "result": { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "91d93eb6a5a3390035a17eb4d4ce24cf8e18369e458d0382f17328fd9df303d2", + "kind": "store-term-v0" + } + } + }, + { + "step": 3, + "direction": "core_to_host", + "template": "effect_request" + }, + { + "step": 4, + "direction": "host_to_core", + "template": "effect_ok", + "frame": { + "invocation_id": "0000000000000000", + "kind": "effect_ok", + "protocol": "jacquard-host-v0", + "request_id": "0000000000000001", + "value": { "kind": "text", "value": "pong" } + } + }, + { + "step": 5, + "direction": "core_to_host", + "template": "effect_outcome" + } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { + "diagnostics": [ + { + "cause": "The pinned interface disagrees with the checked target arrow.", + "code": "E1603", + "domain": "process", + "next_step": "Select one complete checked store-term closure and use its exact first-order interface.", + "schema": "jacquard-diagnostic-v1", + "severity": "error", + "span": null, + "summary": "The selected Jacquard target or pinned interface is invalid." + } + ], + "kind": "fatal", + "protocol": "jacquard-host-v0" + } + ], + "exit_status": 0, + "observed": { + "terminal_kind": "fatal", + "primary_code": "E1603", + "terminal": "fatal", + "effect_requests": 0, + "terminal_frames": 1, + "host_local_refusal": null, + "host_inputs_skipped_after_terminal": 1 + }, + "prose_drift": [], + "status": "conforms" + }, + { + "name": "unsupported-secret-value", + "kind": "hostile", + "base": "one-once-effect-success", + "phase": "invoke", + "sequence": [ + "core_hello", "host_select", "effect_invoke", "effect_request", + "effect_ok", "effect_outcome" + ], + "expect": { + "code": "E1604", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { + "step": 1, + "direction": "host_to_core", + "template": "host_select", + "frame": { + "kind": "host_select", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "protocol": "jacquard-host-v0" + } + }, + { + "step": 2, + "direction": "host_to_core", + "template": "effect_invoke", + "frame": { + "arguments": [ { "kind": "secret", "value": "must-not-cross" } ], + "capabilities": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "operations": [ + { + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "mode": "once", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722" + } + ] + }, + "interface": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "parameters": [ + { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + ], + "result": { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "91d93eb6a5a3390035a17eb4d4ce24cf8e18369e458d0382f17328fd9df303d2", + "kind": "store-term-v0" + } + } + }, + { + "step": 3, + "direction": "core_to_host", + "template": "effect_request" + }, + { + "step": 4, + "direction": "host_to_core", + "template": "effect_ok", + "frame": { + "invocation_id": "0000000000000000", + "kind": "effect_ok", + "protocol": "jacquard-host-v0", + "request_id": "0000000000000001", + "value": { "kind": "text", "value": "pong" } + } + }, + { + "step": 5, + "direction": "core_to_host", + "template": "effect_outcome" + } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { + "diagnostics": [ + { + "cause": "Boundary value kind \"secret\" is not supported in jacquard-host-v0.", + "code": "E1604", + "domain": "process", + "next_step": "Use only the frozen closed first-order type and lossless value descriptor subset.", + "schema": "jacquard-diagnostic-v1", + "severity": "error", + "span": null, + "summary": "A Jacquard type or value is unsupported at the v0 host boundary." + } + ], + "kind": "fatal", + "protocol": "jacquard-host-v0" + } + ], + "exit_status": 0, + "observed": { + "terminal_kind": "fatal", + "primary_code": "E1604", + "terminal": "fatal", + "effect_requests": 0, + "terminal_frames": 1, + "host_local_refusal": null, + "host_inputs_skipped_after_terminal": 1 + }, + "prose_drift": [], + "status": "conforms" + }, + { + "name": "extra-capability", + "kind": "hostile", + "base": "one-once-effect-success", + "phase": "invoke", + "sequence": [ + "core_hello", "host_select", "effect_invoke", "effect_request", + "effect_ok", "effect_outcome" + ], + "expect": { + "code": "E1605", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { + "step": 1, + "direction": "host_to_core", + "template": "host_select", + "frame": { + "kind": "host_select", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "protocol": "jacquard-host-v0" + } + }, + { + "step": 2, + "direction": "host_to_core", + "template": "effect_invoke", + "frame": { + "arguments": [ { "kind": "text", "value": "ping" } ], + "capabilities": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + ], + "operations": [ + { + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "mode": "once", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722" + } + ] + }, + "interface": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "parameters": [ + { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + ], + "result": { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "91d93eb6a5a3390035a17eb4d4ce24cf8e18369e458d0382f17328fd9df303d2", + "kind": "store-term-v0" + } + } + }, + { + "step": 3, + "direction": "core_to_host", + "template": "effect_request" + }, + { + "step": 4, + "direction": "host_to_core", + "template": "effect_ok", + "frame": { + "invocation_id": "0000000000000000", + "kind": "effect_ok", + "protocol": "jacquard-host-v0", + "request_id": "0000000000000001", + "value": { "kind": "text", "value": "pong" } + } + }, + { + "step": 5, + "direction": "core_to_host", + "template": "effect_outcome" + } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { + "diagnostics": [ + { + "cause": "Capability effects do not exactly equal the pinned interface row.", + "code": "E1605", + "domain": "process", + "next_step": "Use the exact checked effect set and a sorted unique registry of its public once operations.", + "schema": "jacquard-diagnostic-v1", + "severity": "error", + "span": null, + "summary": "The selected host capability or operation registry is invalid." + } + ], + "kind": "fatal", + "protocol": "jacquard-host-v0" + } + ], + "exit_status": 0, + "observed": { + "terminal_kind": "fatal", + "primary_code": "E1605", + "terminal": "fatal", + "effect_requests": 0, + "terminal_frames": 1, + "host_local_refusal": null, + "host_inputs_skipped_after_terminal": 1 + }, + "prose_drift": [], + "status": "conforms" + }, + { + "name": "multi-operation-registry-entry", + "kind": "hostile", + "base": "one-once-effect-success", + "phase": "invoke", + "sequence": [ + "core_hello", "host_select", "effect_invoke", "effect_request", + "effect_ok", "effect_outcome" + ], + "expect": { + "code": "E1605", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { + "step": 1, + "direction": "host_to_core", + "template": "host_select", + "frame": { + "kind": "host_select", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "protocol": "jacquard-host-v0" + } + }, + { + "step": 2, + "direction": "host_to_core", + "template": "effect_invoke", + "frame": { + "arguments": [ { "kind": "text", "value": "ping" } ], + "capabilities": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "operations": [ + { + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "mode": "multi", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722" + } + ] + }, + "interface": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "parameters": [ + { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + ], + "result": { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "91d93eb6a5a3390035a17eb4d4ce24cf8e18369e458d0382f17328fd9df303d2", + "kind": "store-term-v0" + } + } + }, + { + "step": 3, + "direction": "core_to_host", + "template": "effect_request" + }, + { + "step": 4, + "direction": "host_to_core", + "template": "effect_ok", + "frame": { + "invocation_id": "0000000000000000", + "kind": "effect_ok", + "protocol": "jacquard-host-v0", + "request_id": "0000000000000001", + "value": { "kind": "text", "value": "pong" } + } + }, + { + "step": 5, + "direction": "core_to_host", + "template": "effect_outcome" + } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { + "diagnostics": [ + { + "cause": "A registry entry does not select mode once.", + "code": "E1605", + "domain": "process", + "next_step": "Use the exact checked effect set and a sorted unique registry of its public once operations.", + "schema": "jacquard-diagnostic-v1", + "severity": "error", + "span": null, + "summary": "The selected host capability or operation registry is invalid." + } + ], + "kind": "fatal", + "protocol": "jacquard-host-v0" + } + ], + "exit_status": 0, + "observed": { + "terminal_kind": "fatal", + "primary_code": "E1605", + "terminal": "fatal", + "effect_requests": 0, + "terminal_frames": 1, + "host_local_refusal": null, + "host_inputs_skipped_after_terminal": 1 + }, + "prose_drift": [], + "status": "conforms" + }, + { + "name": "unconfigured-root-operation", + "kind": "hostile", + "base": "one-once-effect-success", + "phase": "evaluation", + "sequence": [ + "core_hello", "host_select", "effect_invoke", "effect_request", + "effect_ok", "effect_outcome" + ], + "expect": { + "code": "E1606", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { + "step": 1, + "direction": "host_to_core", + "template": "host_select", + "frame": { + "kind": "host_select", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "protocol": "jacquard-host-v0" + } + }, + { + "step": 2, + "direction": "host_to_core", + "template": "effect_invoke", + "frame": { + "arguments": [ { "kind": "text", "value": "ping" } ], + "capabilities": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "operations": [] + }, + "interface": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "parameters": [ + { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + ], + "result": { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "91d93eb6a5a3390035a17eb4d4ce24cf8e18369e458d0382f17328fd9df303d2", + "kind": "store-term-v0" + } + } + }, + { + "step": 3, + "direction": "core_to_host", + "template": "effect_request" + }, + { + "step": 4, + "direction": "host_to_core", + "template": "effect_ok", + "frame": { + "invocation_id": "0000000000000000", + "kind": "effect_ok", + "protocol": "jacquard-host-v0", + "request_id": "0000000000000001", + "value": { "kind": "text", "value": "pong" } + } + }, + { + "step": 5, + "direction": "core_to_host", + "template": "effect_outcome" + } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { + "evidence": { + "core": { + "capabilities": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "operations": [] + }, + "effect_requests": [], + "interface": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "parameters": [ + { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + ], + "result": { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + }, + "invocation_id": "0000000000000000", + "schema": "jacquard-host-core-evidence-v0", + "target": { + "callable": "91d93eb6a5a3390035a17eb4d4ce24cf8e18369e458d0382f17328fd9df303d2", + "kind": "store-term-v0" + }, + "terminal": "diagnostic" + }, + "host_observations": { + "responses": [], + "schema": "jacquard-host-observations-v0" + } + }, + "invocation_id": "0000000000000000", + "kind": "outcome", + "protocol": "jacquard-host-v0", + "result": { + "diagnostics": [ + { + "cause": "The root operation is absent from the configured registry.", + "code": "E1606", + "domain": "process", + "next_step": "Configure the exact operation before starting a new invocation.", + "schema": "jacquard-diagnostic-v1", + "severity": "error", + "span": null, + "summary": "The root operation has no configured host implementation." + } + ], + "kind": "error" + } + } + ], + "exit_status": 0, + "observed": { + "terminal_kind": "outcome", + "primary_code": "E1606", + "terminal": "diagnostic", + "effect_requests": 0, + "terminal_frames": 1, + "host_local_refusal": null, + "host_inputs_skipped_after_terminal": 1 + }, + "prose_drift": [], + "status": "conforms" + }, + { + "name": "wrong-response-id", + "kind": "hostile", + "base": "one-once-effect-success", + "phase": "effect_response", + "sequence": [ + "core_hello", "host_select", "effect_invoke", "effect_request", + "effect_ok", "effect_outcome" + ], + "expect": { + "code": "E1608", + "effect_requests_before_failure": 1, + "forbid_outside_action": false, + "terminal_frames_max": 1 + }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { + "step": 1, + "direction": "host_to_core", + "template": "host_select", + "frame": { + "kind": "host_select", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "protocol": "jacquard-host-v0" + } + }, + { + "step": 2, + "direction": "host_to_core", + "template": "effect_invoke", + "frame": { + "arguments": [ { "kind": "text", "value": "ping" } ], + "capabilities": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "operations": [ + { + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "mode": "once", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722" + } + ] + }, + "interface": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "parameters": [ + { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + ], + "result": { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "91d93eb6a5a3390035a17eb4d4ce24cf8e18369e458d0382f17328fd9df303d2", + "kind": "store-term-v0" + } + } + }, + { + "step": 3, + "direction": "core_to_host", + "template": "effect_request" + }, + { + "step": 4, + "direction": "host_to_core", + "template": "effect_ok", + "frame": { + "invocation_id": "0000000000000000", + "kind": "effect_ok", + "protocol": "jacquard-host-v0", + "request_id": "0000000000000002", + "value": { "kind": "text", "value": "pong" } + } + }, + { + "step": 5, + "direction": "core_to_host", + "template": "effect_outcome" + } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { + "arguments": [ { "kind": "text", "value": "ping" } ], + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "invocation_id": "0000000000000000", + "kind": "effect_request", + "mode": "once", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722", + "protocol": "jacquard-host-v0", + "request_id": "0000000000000001" + }, + { + "evidence": { + "core": { + "capabilities": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "operations": [ + { + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "mode": "once", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722" + } + ] + }, + "effect_requests": [ + { + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722", + "ordinal": 1 + } + ], + "interface": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "parameters": [ + { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + ], + "result": { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + }, + "invocation_id": "0000000000000000", + "schema": "jacquard-host-core-evidence-v0", + "target": { + "callable": "91d93eb6a5a3390035a17eb4d4ce24cf8e18369e458d0382f17328fd9df303d2", + "kind": "store-term-v0" + }, + "terminal": "diagnostic" + }, + "host_observations": { + "responses": [], + "schema": "jacquard-host-observations-v0" + } + }, + "invocation_id": "0000000000000000", + "kind": "outcome", + "protocol": "jacquard-host-v0", + "result": { + "diagnostics": [ + { + "cause": "The host response is malformed or disagrees with its checked result type.", + "code": "E1608", + "domain": "process", + "next_step": "Send only the next message permitted by the serial jacquard-host-v0 state machine.", + "schema": "jacquard-diagnostic-v1", + "severity": "error", + "span": null, + "summary": "The host protocol message is invalid in the current state." + } + ], + "kind": "error" + } + } + ], + "exit_status": 0, + "observed": { + "terminal_kind": "outcome", + "primary_code": "E1608", + "terminal": "diagnostic", + "effect_requests": 1, + "terminal_frames": 1, + "host_local_refusal": null, + "host_inputs_skipped_after_terminal": 0 + }, + "prose_drift": [], + "status": "conforms" + }, + { + "name": "message-after-outcome", + "kind": "hostile", + "base": "one-once-effect-success", + "phase": "terminal", + "sequence": [ + "core_hello", "host_select", "effect_invoke", "effect_request", + "effect_ok", "effect_outcome" + ], + "expect": { + "code": "E1608", + "effect_requests_before_failure": 1, + "forbid_outside_action": false, + "terminal_frames_max": 1 + }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { + "step": 1, + "direction": "host_to_core", + "template": "host_select", + "frame": { + "kind": "host_select", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "protocol": "jacquard-host-v0" + } + }, + { + "step": 2, + "direction": "host_to_core", + "template": "effect_invoke", + "frame": { + "arguments": [ { "kind": "text", "value": "ping" } ], + "capabilities": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "operations": [ + { + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "mode": "once", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722" + } + ] + }, + "interface": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "parameters": [ + { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + ], + "result": { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "91d93eb6a5a3390035a17eb4d4ce24cf8e18369e458d0382f17328fd9df303d2", + "kind": "store-term-v0" + } + } + }, + { + "step": 3, + "direction": "core_to_host", + "template": "effect_request" + }, + { + "step": 4, + "direction": "host_to_core", + "template": "effect_ok", + "frame": { + "invocation_id": "0000000000000000", + "kind": "effect_ok", + "protocol": "jacquard-host-v0", + "request_id": "0000000000000001", + "value": { "kind": "text", "value": "pong" } + } + }, + { + "step": 5, + "direction": "core_to_host", + "template": "effect_outcome" + }, + { + "step": 6, + "direction": "host_to_core", + "template": "late_effect_ok", + "frame": { + "invocation_id": "0000000000000000", + "kind": "effect_ok", + "protocol": "jacquard-host-v0", + "request_id": "0000000000000001", + "value": { "kind": "text", "value": "late" } + }, + "after_terminal": true + } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { + "arguments": [ { "kind": "text", "value": "ping" } ], + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "invocation_id": "0000000000000000", + "kind": "effect_request", + "mode": "once", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722", + "protocol": "jacquard-host-v0", + "request_id": "0000000000000001" + }, + { + "evidence": { + "core": { + "capabilities": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "operations": [ + { + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "mode": "once", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722" + } + ] + }, + "effect_requests": [ + { + "effect": "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0", + "operation": "3120a126f44773feba44f437ef0d2514e5e55764e1c0667f6434561f430c5722", + "ordinal": 1 + } + ], + "interface": { + "effects": [ + "44b60d80136d8f1366f32889d56ced087381a1ef31646d7ff173d5c36bbe4db0" + ], + "parameters": [ + { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + ], + "result": { + "arguments": [], + "identity": "ca456459bd7fbe8c9694388e1246c4333221817248d24707f05b7423828516e3", + "kind": "nominal" + } + }, + "invocation_id": "0000000000000000", + "schema": "jacquard-host-core-evidence-v0", + "target": { + "callable": "91d93eb6a5a3390035a17eb4d4ce24cf8e18369e458d0382f17328fd9df303d2", + "kind": "store-term-v0" + }, + "terminal": "ok" + }, + "host_observations": { + "responses": [ + { "category": "ok", "completion": "completed", "ordinal": 1 } + ], + "schema": "jacquard-host-observations-v0" + } + }, + "invocation_id": "0000000000000000", + "kind": "outcome", + "protocol": "jacquard-host-v0", + "result": { + "kind": "ok", + "value": { "kind": "text", "value": "pong" } + } + } + ], + "exit_status": 0, + "observed": { + "terminal_kind": "outcome", + "primary_code": null, + "terminal": "ok", + "effect_requests": 1, + "terminal_frames": 1, + "host_local_refusal": "E1608", + "host_inputs_skipped_after_terminal": 0 + }, + "prose_drift": [], + "status": "conforms" + }, + { + "name": "truncated-length-prefix", + "kind": "hostile", + "base": "pure-success", + "phase": "framing", + "sequence": [ + "core_hello", "host_select", "pure_invoke", "pure_outcome" + ], + "expect": { + "code": "E1611", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { "step": 1, "direction": "host_to_core", "raw_hex": "0000" }, + { + "step": 2, + "direction": "host_to_core", + "template": "pure_invoke", + "frame": { + "arguments": [], + "capabilities": { "effects": [], "operations": [] }, + "interface": { + "effects": [], + "parameters": [], + "result": { "items": [], "kind": "tuple" } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "2af2f2e66770cf0597374a97b4c3988dc0c3f9b42d87f6f430be53525ce6a7ec", + "kind": "store-term-v0" + } + } + }, + { "step": 3, "direction": "core_to_host", "template": "pure_outcome" } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { + "diagnostics": [ + { + "cause": "The carrier ended before the declared frame completed.", + "code": "E1611", + "domain": "process", + "next_step": "Treat the missing terminal exchange as host-owned carrier-failure evidence.", + "schema": "jacquard-diagnostic-v1", + "severity": "error", + "span": null, + "summary": "The host carrier was lost before a trustworthy frame completed." + } + ], + "kind": "fatal", + "protocol": "jacquard-host-v0" + } + ], + "exit_status": 74, + "observed": { + "terminal_kind": "fatal", + "primary_code": "E1611", + "terminal": "fatal", + "effect_requests": 0, + "terminal_frames": 1, + "host_local_refusal": null, + "host_inputs_skipped_after_terminal": 0 + }, + "prose_drift": [], + "status": "conforms" + }, + { + "name": "truncated-frame-payload", + "kind": "hostile", + "base": "pure-success", + "phase": "framing", + "sequence": [ + "core_hello", "host_select", "pure_invoke", "pure_outcome" + ], + "expect": { + "code": "E1611", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "host_input": [ + { "step": 0, "direction": "core_to_host", "template": "core_hello" }, + { "step": 1, "direction": "host_to_core", "raw_hex": "000000057b7d" }, + { + "step": 2, + "direction": "host_to_core", + "template": "pure_invoke", + "frame": { + "arguments": [], + "capabilities": { "effects": [], "operations": [] }, + "interface": { + "effects": [], + "parameters": [], + "result": { "items": [], "kind": "tuple" } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "2af2f2e66770cf0597374a97b4c3988dc0c3f9b42d87f6f430be53525ce6a7ec", + "kind": "store-term-v0" + } + } + }, + { "step": 3, "direction": "core_to_host", "template": "pure_outcome" } + ], + "core_output": [ + { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "versions": [ "jacquard-host-v0" ] + }, + { + "diagnostics": [ + { + "cause": "The carrier ended before the declared frame completed.", + "code": "E1611", + "domain": "process", + "next_step": "Treat the missing terminal exchange as host-owned carrier-failure evidence.", + "schema": "jacquard-diagnostic-v1", + "severity": "error", + "span": null, + "summary": "The host carrier was lost before a trustworthy frame completed." + } + ], + "kind": "fatal", + "protocol": "jacquard-host-v0" + } + ], + "exit_status": 74, + "observed": { + "terminal_kind": "fatal", + "primary_code": "E1611", + "terminal": "fatal", + "effect_requests": 0, + "terminal_frames": 1, + "host_local_refusal": null, + "host_inputs_skipped_after_terminal": 0 + }, + "prose_drift": [], + "status": "conforms" + } +] diff --git a/java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/vectors.json b/java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/vectors.json new file mode 100644 index 0000000..78591d3 --- /dev/null +++ b/java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/vectors.json @@ -0,0 +1,775 @@ +{ + "schema": "jacquard-host-vectors-v0", + "hard_limits": { + "max_arguments": 64, + "max_collection_items": 1024, + "max_diagnostic_bytes": 65536, + "max_diagnostics": 32, + "max_effect_requests": 1024, + "max_effects": 64, + "max_frame_bytes": 1048576, + "max_host_message_bytes": 4096, + "max_json_depth": 64, + "max_operations": 256, + "max_stderr_bytes": 65536, + "max_text_bytes": 262144, + "max_value_nodes": 4096 + }, + "templates": { + "core_hello": { + "direction": "core_to_host", + "message": { + "carrier": "stdio-u32-json-v0", + "kind": "core_hello", + "limits": {"$ref": "hard_limits"}, + "versions": ["jacquard-host-v0"] + } + }, + "host_select": { + "direction": "host_to_core", + "message": { + "kind": "host_select", + "limits": {"$ref": "hard_limits"}, + "protocol": "jacquard-host-v0" + } + }, + "pure_invoke": { + "direction": "host_to_core", + "message": { + "arguments": [], + "capabilities": {"effects": [], "operations": []}, + "interface": { + "effects": [], + "parameters": [], + "result": {"items": [], "kind": "tuple"} + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "kind": "store-term-v0" + } + } + }, + "pure_outcome": { + "direction": "core_to_host", + "message": { + "evidence": { + "core": { + "capabilities": {"effects": [], "operations": []}, + "effect_requests": [], + "interface": { + "effects": [], + "parameters": [], + "result": {"items": [], "kind": "tuple"} + }, + "invocation_id": "0000000000000000", + "schema": "jacquard-host-core-evidence-v0", + "target": { + "callable": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "kind": "store-term-v0" + }, + "terminal": "ok" + }, + "host_observations": { + "responses": [], + "schema": "jacquard-host-observations-v0" + } + }, + "invocation_id": "0000000000000000", + "kind": "outcome", + "protocol": "jacquard-host-v0", + "result": {"kind": "ok", "value": {"items": [], "kind": "tuple"}} + } + }, + "effect_invoke": { + "direction": "host_to_core", + "message": { + "arguments": [{"kind": "text", "value": "ping"}], + "capabilities": { + "effects": ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "operations": [ + { + "effect": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "mode": "once", + "operation": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + ] + }, + "interface": { + "effects": ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "parameters": [ + { + "arguments": [], + "identity": "1111111111111111111111111111111111111111111111111111111111111111", + "kind": "nominal" + } + ], + "result": { + "arguments": [], + "identity": "1111111111111111111111111111111111111111111111111111111111111111", + "kind": "nominal" + } + }, + "invocation_id": "0000000000000000", + "kind": "invoke", + "protocol": "jacquard-host-v0", + "target": { + "callable": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "kind": "store-term-v0" + } + } + }, + "effect_request": { + "direction": "core_to_host", + "message": { + "arguments": [{"kind": "text", "value": "ping"}], + "effect": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "invocation_id": "0000000000000000", + "kind": "effect_request", + "mode": "once", + "operation": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "protocol": "jacquard-host-v0", + "request_id": "0000000000000001" + } + }, + "effect_ok": { + "direction": "host_to_core", + "message": { + "invocation_id": "0000000000000000", + "kind": "effect_ok", + "protocol": "jacquard-host-v0", + "request_id": "0000000000000001", + "value": {"kind": "text", "value": "pong"} + } + }, + "effect_outcome": { + "direction": "core_to_host", + "message": { + "evidence": { + "core": { + "capabilities": { + "effects": ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "operations": [ + { + "effect": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "mode": "once", + "operation": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + ] + }, + "effect_requests": [ + { + "effect": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "operation": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "ordinal": 1 + } + ], + "interface": { + "effects": ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "parameters": [ + { + "arguments": [], + "identity": "1111111111111111111111111111111111111111111111111111111111111111", + "kind": "nominal" + } + ], + "result": { + "arguments": [], + "identity": "1111111111111111111111111111111111111111111111111111111111111111", + "kind": "nominal" + } + }, + "invocation_id": "0000000000000000", + "schema": "jacquard-host-core-evidence-v0", + "target": { + "callable": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "kind": "store-term-v0" + }, + "terminal": "ok" + }, + "host_observations": { + "responses": [{"category": "ok", "completion": "completed", "ordinal": 1}], + "schema": "jacquard-host-observations-v0" + } + }, + "invocation_id": "0000000000000000", + "kind": "outcome", + "protocol": "jacquard-host-v0", + "result": {"kind": "ok", "value": {"kind": "text", "value": "pong"}} + } + }, + "effect_refused": { + "direction": "host_to_core", + "message": { + "category": "refused_authority", + "completion": "not_started", + "invocation_id": "0000000000000000", + "kind": "effect_failure", + "message": "policy denied", + "protocol": "jacquard-host-v0", + "request_id": "0000000000000001" + } + }, + "refused_outcome": { + "direction": "core_to_host", + "message": { + "evidence": { + "core": { + "capabilities": { + "effects": ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "operations": [ + { + "effect": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "mode": "once", + "operation": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + ] + }, + "effect_requests": [ + { + "effect": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "operation": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "ordinal": 1 + } + ], + "interface": { + "effects": ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "parameters": [ + { + "arguments": [], + "identity": "1111111111111111111111111111111111111111111111111111111111111111", + "kind": "nominal" + } + ], + "result": { + "arguments": [], + "identity": "1111111111111111111111111111111111111111111111111111111111111111", + "kind": "nominal" + } + }, + "invocation_id": "0000000000000000", + "schema": "jacquard-host-core-evidence-v0", + "target": { + "callable": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "kind": "store-term-v0" + }, + "terminal": "host_failure" + }, + "host_observations": { + "responses": [ + {"category": "refused_authority", "completion": "not_started", "ordinal": 1} + ], + "schema": "jacquard-host-observations-v0" + } + }, + "invocation_id": "0000000000000000", + "kind": "outcome", + "protocol": "jacquard-host-v0", + "result": { + "diagnostics": [ + { + "schema": "jacquard-diagnostic-v1", + "domain": "process", + "code": "E1607", + "severity": "error", + "span": null, + "summary": "The host refused the requested authority.", + "cause": "The host refused operation cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc before starting an outside action: policy denied.", + "next_step": "Grant the required host authority or choose an invocation that does not request this operation." + } + ], + "kind": "error" + } + } + }, + "timeout_unknown_cancel": { + "direction": "host_to_core", + "message": { + "completion": "unknown", + "invocation_id": "0000000000000000", + "kind": "cancel", + "protocol": "jacquard-host-v0", + "reason": "timeout", + "request_id": "0000000000000001" + } + }, + "timeout_unknown_outcome": { + "direction": "core_to_host", + "message": { + "evidence": { + "core": { + "capabilities": { + "effects": ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "operations": [ + { + "effect": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "mode": "once", + "operation": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + ] + }, + "effect_requests": [ + { + "effect": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "operation": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "ordinal": 1 + } + ], + "interface": { + "effects": ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "parameters": [ + { + "arguments": [], + "identity": "1111111111111111111111111111111111111111111111111111111111111111", + "kind": "nominal" + } + ], + "result": { + "arguments": [], + "identity": "1111111111111111111111111111111111111111111111111111111111111111", + "kind": "nominal" + } + }, + "invocation_id": "0000000000000000", + "schema": "jacquard-host-core-evidence-v0", + "target": { + "callable": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "kind": "store-term-v0" + }, + "terminal": "completion_unknown" + }, + "host_observations": { + "responses": [{"category": "timeout", "completion": "unknown", "ordinal": 1}], + "schema": "jacquard-host-observations-v0" + } + }, + "invocation_id": "0000000000000000", + "kind": "outcome", + "protocol": "jacquard-host-v0", + "result": { + "diagnostics": [ + { + "schema": "jacquard-diagnostic-v1", + "domain": "process", + "code": "E1612", + "severity": "error", + "span": null, + "summary": "The outside action may have completed.", + "cause": "The host reported that operation cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc may have completed before its timeout cancellation was observed.", + "next_step": "Do not retry automatically; reconcile the outside system before deciding what to do next." + } + ], + "kind": "error" + } + } + }, + "shutdown": { + "direction": "host_to_core", + "message": {"kind": "shutdown", "protocol": "jacquard-host-v0"} + }, + "shutdown_ack": { + "direction": "core_to_host", + "message": {"kind": "shutdown_ack", "protocol": "jacquard-host-v0"} + }, + "late_effect_ok": { + "direction": "host_to_core", + "message": { + "invocation_id": "0000000000000000", + "kind": "effect_ok", + "protocol": "jacquard-host-v0", + "request_id": "0000000000000001", + "value": {"kind": "text", "value": "late"} + } + } + }, + "positive": [ + { + "name": "pure-success", + "sequence": ["core_hello", "host_select", "pure_invoke", "pure_outcome"], + "terminal": "ok" + }, + { + "name": "one-once-effect-success", + "sequence": [ + "core_hello", + "host_select", + "effect_invoke", + "effect_request", + "effect_ok", + "effect_outcome" + ], + "terminal": "ok" + }, + { + "name": "refused-authority", + "sequence": [ + "core_hello", + "host_select", + "effect_invoke", + "effect_request", + "effect_refused", + "refused_outcome" + ], + "terminal": "host_failure" + }, + { + "name": "timeout-with-unknown-completion", + "sequence": [ + "core_hello", + "host_select", + "effect_invoke", + "effect_request", + "timeout_unknown_cancel", + "timeout_unknown_outcome" + ], + "terminal": "completion_unknown" + }, + { + "name": "selected-shutdown", + "sequence": ["core_hello", "host_select", "shutdown", "shutdown_ack"], + "terminal": "shutdown" + } + ], + "terminal_mappings": [ + { + "category": "unsupported_operation", + "completion": "not_started", + "kind": "effect_failure", + "primary_code": "E1606", + "terminal": "host_failure" + }, + { + "category": "refused_authority", + "completion": "not_started", + "kind": "effect_failure", + "primary_code": "E1607", + "terminal": "host_failure" + }, + { + "category": "outside_failure", + "completion": "failed", + "kind": "effect_failure", + "primary_code": "E1613", + "terminal": "host_failure" + }, + { + "category": "completion_unknown", + "completion": "unknown", + "kind": "effect_failure", + "primary_code": "E1612", + "terminal": "completion_unknown" + }, + { + "completion": "not_started", + "kind": "cancel", + "primary_code": "E1614", + "reason": "cancelled", + "terminal": "cancelled" + }, + { + "completion": "failed", + "kind": "cancel", + "primary_code": "E1614", + "reason": "cancelled", + "terminal": "cancelled" + }, + { + "completion": "unknown", + "kind": "cancel", + "primary_code": "E1612", + "reason": "cancelled", + "terminal": "completion_unknown" + }, + { + "completion": "not_started", + "kind": "cancel", + "primary_code": "E1609", + "reason": "timeout", + "terminal": "cancelled" + }, + { + "completion": "failed", + "kind": "cancel", + "primary_code": "E1609", + "reason": "timeout", + "terminal": "cancelled" + }, + { + "completion": "unknown", + "kind": "cancel", + "primary_code": "E1612", + "reason": "timeout", + "terminal": "completion_unknown" + }, + { + "completion": "not_started", + "kind": "cancel", + "primary_code": "E1610", + "reason": "host_shutdown", + "terminal": "cancelled" + }, + { + "completion": "failed", + "kind": "cancel", + "primary_code": "E1610", + "reason": "host_shutdown", + "terminal": "cancelled" + }, + { + "completion": "unknown", + "kind": "cancel", + "primary_code": "E1612", + "reason": "host_shutdown", + "terminal": "completion_unknown" + } + ], + "hostile": [ + { + "base": "pure-success", + "expect": { + "code": "E1600", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "mutation": { + "frame": 1, + "op": "replace", + "path": "/protocol", + "value": "jacquard-host-v1" + }, + "name": "unknown-version", + "phase": "host_select" + }, + { + "base": "pure-success", + "expect": { + "code": "E1601", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "mutation": { + "frame": 2, + "op": "add", + "path": "/display_name", + "value": "mutable-name" + }, + "name": "unknown-invoke-field", + "phase": "invoke" + }, + { + "base": "pure-success", + "expect": { + "code": "E1601", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "mutation": { + "frame": 1, + "op": "raw_wire", + "wire_hex": "000000267b226b696e64223a22686f73745f73656c656374222c226b696e64223a22696e766f6b65227d" + }, + "name": "duplicate-json-key", + "phase": "framing" + }, + { + "base": "pure-success", + "expect": { + "code": "E1601", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "mutation": {"frame": 1, "op": "raw_wire", "wire_hex": "000000037bff7d"}, + "name": "invalid-utf8", + "phase": "framing" + }, + { + "base": "pure-success", + "expect": { + "code": "E1602", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "mutation": {"frame": 1, "op": "raw_wire", "wire_hex": "00100001"}, + "name": "oversized-frame-prefix", + "phase": "framing" + }, + { + "base": "pure-success", + "expect": { + "code": "E1602", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "mutation": { + "frame": 1, + "op": "replace", + "path": "/limits/max_frame_bytes", + "value": 0 + }, + "name": "nonpositive-selected-limit", + "phase": "host_select" + }, + { + "base": "pure-success", + "expect": { + "code": "E1603", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "mutation": { + "frame": 2, + "op": "replace", + "path": "/target/callable", + "value": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "name": "noncanonical-target-hash", + "phase": "invoke" + }, + { + "base": "one-once-effect-success", + "expect": { + "code": "E1603", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "mutation": { + "frame": 2, + "op": "replace", + "path": "/interface/parameters/0", + "value": {"items": [], "kind": "tuple"} + }, + "name": "interface-mismatch", + "phase": "invoke" + }, + { + "base": "one-once-effect-success", + "expect": { + "code": "E1604", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "mutation": { + "frame": 2, + "op": "replace", + "path": "/arguments/0", + "value": {"kind": "secret", "value": "must-not-cross"} + }, + "name": "unsupported-secret-value", + "phase": "invoke" + }, + { + "base": "one-once-effect-success", + "expect": { + "code": "E1605", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "mutation": { + "frame": 2, + "op": "add", + "path": "/capabilities/effects/1", + "value": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "name": "extra-capability", + "phase": "invoke" + }, + { + "base": "one-once-effect-success", + "expect": { + "code": "E1605", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "mutation": { + "frame": 2, + "op": "replace", + "path": "/capabilities/operations/0/mode", + "value": "multi" + }, + "name": "multi-operation-registry-entry", + "phase": "invoke" + }, + { + "base": "one-once-effect-success", + "expect": { + "code": "E1606", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "mutation": { + "frame": 2, + "op": "remove", + "path": "/capabilities/operations/0" + }, + "name": "unconfigured-root-operation", + "phase": "evaluation" + }, + { + "base": "one-once-effect-success", + "expect": { + "code": "E1608", + "effect_requests_before_failure": 1, + "forbid_outside_action": false, + "terminal_frames_max": 1 + }, + "mutation": { + "frame": 4, + "op": "replace", + "path": "/request_id", + "value": "0000000000000002" + }, + "name": "wrong-response-id", + "phase": "effect_response" + }, + { + "base": "one-once-effect-success", + "expect": { + "code": "E1608", + "effect_requests_before_failure": 1, + "forbid_outside_action": false, + "terminal_frames_max": 1 + }, + "mutation": {"op": "append", "template": "late_effect_ok"}, + "name": "message-after-outcome", + "phase": "terminal" + }, + { + "base": "pure-success", + "expect": { + "code": "E1611", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "mutation": {"frame": 1, "op": "raw_wire", "wire_hex": "0000"}, + "name": "truncated-length-prefix", + "phase": "framing" + }, + { + "base": "pure-success", + "expect": { + "code": "E1611", + "effect_requests_before_failure": 0, + "forbid_outside_action": true, + "terminal_frames_max": 1 + }, + "mutation": {"frame": 1, "op": "raw_wire", "wire_hex": "000000057b7d"}, + "name": "truncated-frame-payload", + "phase": "framing" + } + ] +} diff --git a/java/src/test/java/org/jacquardlang/host/DescriptorsTest.java b/java/src/test/java/org/jacquardlang/host/DescriptorsTest.java new file mode 100644 index 0000000..7d7ca60 --- /dev/null +++ b/java/src/test/java/org/jacquardlang/host/DescriptorsTest.java @@ -0,0 +1,294 @@ +package org.jacquardlang.host; + +import static org.jacquardlang.host.Support.descendant; +import static org.jacquardlang.host.Support.eventually; +import static org.jacquardlang.host.Support.faulty; +import static org.jacquardlang.host.Support.invokeReady; +import static org.jacquardlang.host.Support.kill; +import static org.jacquardlang.host.Support.kit; +import static org.jacquardlang.host.Support.reason; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.jacquardlang.host.carrier.Cancel; +import org.jacquardlang.host.carrier.Options; +import org.jacquardlang.host.kit.Kit; +import org.jacquardlang.host.protocol.BoundaryType; +import org.jacquardlang.host.protocol.Interface; +import org.jacquardlang.host.protocol.Value; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; + +/** + * Runs in its own test JVM (surefire does not reuse forks), so descriptor + * and thread counts see no other class's leftovers. It counts open + * descriptors and the adapter's own threads; JDK-owned threads (the process + * reaper pool, compiler threads) start and stop on their own and are not + * counted. + */ +@EnabledOnOs(OS.LINUX) +class DescriptorsTest { + private static long descriptors() throws IOException { + try (Stream entries = Files.list(Path.of("/proc/self/fd"))) { + return entries.count(); + } + } + + private static long adapterThreads() { + return Thread.getAllStackTraces().keySet().stream() + .filter(thread -> thread.isAlive() && thread.getName().startsWith("jacquard-")) + .count(); + } + + @Test + void invocationsReleaseTheirPipesAndThreads() throws Exception { + assertTrue(invokeReady(faulty("ok", Duration.ofSeconds(10), null)).ok()); // warm up lazily opened files + long before = descriptors(); + assertEquals(0, adapterThreads()); + for (int i = 0; i < 5; i++) { + InvocationResult result = invokeReady(faulty("ok", Duration.ofSeconds(10), null)); + assertTrue(result.ok(), result.toString()); + } + assertTrue(descriptors() <= before, "descriptors grew from " + before + " to " + descriptors()); + assertEquals(0, adapterThreads(), "adapter threads outlived their invocations"); + + // A daemonized descendant keeps holding stderr after the invocation + // returns; the host does not wait for it, and its pipes are closed. + InvocationResult escaped = invokeReady(faulty("escaped-descendant", Duration.ofSeconds(10), null)); + long pid = descendant("escaped-descendant", escaped.stderr()); + long afterEscaped = descriptors(); + long threadsAfterEscaped = adapterThreads(); + kill(pid); + assertTrue(escaped.ok(), escaped.toString()); + assertTrue(afterEscaped <= before, "escaped descendant: descriptors grew from " + before + " to " + afterEscaped); + assertEquals(0, threadsAfterEscaped, "escaped descendant: adapter threads remain"); + + // Handlers run on their own threads, which end with the handler. + Kit kit = kit(); + BoundaryType text = new BoundaryType.Nominal(kit.identity("text"), List.of()); + Interface spec = new Interface(List.of(text), text, List.of(kit.identity("kit-world"))); + OperationRegistry registry = new OperationRegistry().bind(kit.identity("kit-world"), kit.identity("send"), + (context, args) -> new Value.Text("pong")); + Host replaying = new Host("unused", + new HostOptions(Support.standin("replay", "one-once-effect-success", Duration.ofSeconds(20)), null)); + for (int i = 0; i < 3; i++) { + InvocationResult result = replaying.invoke(kit.identity("kit-echo"), spec, List.of(new Value.Text("ping")), registry, + new Cancel()); + assertTrue(result.ok(), result.toString()); + } + assertTrue(eventually(() -> adapterThreads() == 0), "handler threads remain"); + assertTrue(descriptors() <= before, "handlers: descriptors grew from " + before + " to " + descriptors()); + + // Failure paths release the same resources: expiry, cancellation, + // an interrupted caller, and a truncated frame. + for (int i = 0; i < 3; i++) { + assertEquals("deadline", reason(invokeReady(faulty("silent", Duration.ofMillis(300), null)))); + Cancel cancelled = new Cancel(); + cancelled.cancel(); + InvocationResult aborted = faulty("silent", Duration.ofSeconds(10), null).invoke(kit.identity("kit-ready"), + Support.unit(), List.of(), new OperationRegistry(), cancelled); + assertTrue(!aborted.ok(), aborted.toString()); + Thread.currentThread().interrupt(); + InvocationResult interrupted = invokeReady(faulty("silent", Duration.ofSeconds(10), null)); + assertTrue(Thread.interrupted(), "the interrupt status was not restored"); + assertTrue(!interrupted.ok(), interrupted.toString()); + assertTrue(!invokeReady(faulty("truncated", Duration.ofSeconds(10), null)).ok()); + } + assertTrue(eventually(() -> adapterThreads() == 0), "failure paths: adapter threads remain"); + assertTrue(eventually(() -> { + try { + return descriptors() <= before; + } catch (IOException error) { + return false; + } + }), "failure paths: descriptors grew from " + before); + } + + @Test + void aStderrFloodAfterTheWorkerExitsDoesNotHoldTheCaller() throws Exception { + assertFloodDoesNotHold("(yes jacquard-flood >&2 & echo $! > \"$PIDFILE\"); sleep 0.3; exit 0", Duration.ofSeconds(10), null); + } + + @Test + void aStderrFloodDoesNotHoldExpiry() throws Exception { + assertFloodDoesNotHold("(yes jacquard-flood >&2 & echo $! > \"$PIDFILE\"); exec sleep 30", Duration.ofMillis(500), null); + } + + @Test + void aStderrFloodDoesNotHoldCancellation() throws Exception { + assertFloodDoesNotHold("(yes jacquard-flood >&2 & echo $! > \"$PIDFILE\"); exec sleep 30", Duration.ofSeconds(20), + Duration.ofMillis(500)); + } + + @Test + void aStdoutFloodDoesNotHoldTheCaller() throws Exception { + assertFloodDoesNotHold("(yes jacquard-flood & echo $! > \"$PIDFILE\"); exec sleep 30", Duration.ofSeconds(10), null); + } + + /** + * The worker leaves a reparented descendant writing to its stdout or + * stderr as fast as it can; the kill cannot find it. The JDK's own + * process pipes drain such a writer on the reaper thread, under the lock + * {@code available()} takes, into an unbounded buffer. The invocation must + * return within its bound, keep no flood in the heap, and leave the + * writer without a reader once it returns. + */ + private void assertFloodDoesNotHold(String script, Duration deadline, Duration cancelAfter) throws Exception { + assertTrue(invokeReady(faulty("ok", Duration.ofSeconds(10), null)).ok()); // warm up + long before = descriptors(); + Path dir = Files.createTempDirectory("jacquard-flood-"); + Path pidFile = dir.resolve("pid"); + Options options = new Options(List.of("sh", "-c", script), + new java.util.HashMap<>(Map.of("PATH", System.getenv("PATH"), "PIDFILE", pidFile.toString())), deadline); + Runtime runtime = Runtime.getRuntime(); + System.gc(); + long heapBefore = runtime.totalMemory() - runtime.freeMemory(); + Cancel cancel = new Cancel(); + Thread trigger = null; + if (cancelAfter != null) { + trigger = Thread.ofPlatform().daemon().start(() -> { + try { + Thread.sleep(cancelAfter.toMillis()); + } catch (InterruptedException ignored) { + return; + } + cancel.cancel(); + }); + } + long start = System.nanoTime(); + InvocationResult result = new Host("unused", new HostOptions(options, null)).shutdownProbe(cancel); + Duration elapsed = Duration.ofNanos(System.nanoTime() - start); + long heapAfter = runtime.totalMemory() - runtime.freeMemory(); + if (trigger != null) { + trigger.join(); + } + assertTrue(eventually(() -> Files.exists(pidFile)), "the flood never started"); + long pid = Long.parseLong(Files.readString(pidFile).trim()); + try { + assertTrue(!result.ok(), result.toString()); + Duration bound = (cancelAfter != null ? cancelAfter : deadline.compareTo(Duration.ofSeconds(1)) < 0 ? deadline : Duration.ZERO) + .plusSeconds(3); + assertTrue(elapsed.compareTo(bound) < 0, "returned after " + elapsed); + assertTrue(heapAfter - heapBefore < 64L << 20, "the heap grew by " + ((heapAfter - heapBefore) >> 20) + " MiB"); + // The host closed its end, so the writer's next write fails. + assertTrue(eventually(() -> !Support.alive(pid)), "the flood still has a reader"); + } finally { + kill(pid); + Files.deleteIfExists(pidFile); + Files.deleteIfExists(dir); + } + assertTrue(eventually(() -> adapterThreads() == 0), "adapter threads remain"); + assertTrue(eventually(() -> { + try { + return descriptors() <= before; + } catch (IOException error) { + return false; + } + }), "descriptors grew from " + before); + } + + @Test + void expiryWithALiveWorkerKillsTheChildHoldingABlockedWrite() throws Exception { + assertExpiryKillsTheBlockingChild(Duration.ofMillis(500), null); + } + + @Test + void cancellationWithALiveWorkerKillsTheChildHoldingABlockedWrite() throws Exception { + assertExpiryKillsTheBlockingChild(Duration.ofSeconds(20), Duration.ofMillis(500)); + } + + /** + * The worker stays alive and its child holds stdin without reading, so + * the invoke frame blocks the stdin thread. Expiry, or cancellation after + * {@code cancelAfter}, must return promptly and kill both, which ends the + * blocked write. + */ + private void assertExpiryKillsTheBlockingChild(Duration deadline, Duration cancelAfter) throws Exception { + assertTrue(invokeReady(faulty("ok", Duration.ofSeconds(10), null)).ok()); // warm up + long before = descriptors(); + Kit kit = kit(); + BoundaryType text = new BoundaryType.Nominal(kit.identity("text"), List.of()); + Interface spec = new Interface(List.of(text), text, List.of()); + Cancel cancel = new Cancel(); + Thread trigger = null; + if (cancelAfter != null) { + trigger = Thread.ofPlatform().daemon().start(() -> { + try { + Thread.sleep(cancelAfter.toMillis()); + } catch (InterruptedException ignored) { + return; + } + cancel.cancel(); + }); + } + long start = System.nanoTime(); + InvocationResult result = faulty("stdin-child-alive", deadline, null) + .invoke(kit.identity("kit-ready"), spec, List.of(new Value.Text("x".repeat(220_000))), new OperationRegistry(), cancel); + Duration elapsed = Duration.ofNanos(System.nanoTime() - start); + if (trigger != null) { + trigger.join(); + } + long pid = descendant("stdin-child-alive", result.stderr()); + try { + assertTrue(!result.ok(), result.toString()); + Duration bound = (cancelAfter != null ? cancelAfter : deadline).plusSeconds(3); + assertTrue(elapsed.compareTo(bound) < 0, "returned after " + elapsed); + assertTrue(eventually(() -> !Support.alive(pid)), "the child holding stdin survived"); + } finally { + kill(pid); + } + assertTrue(eventually(() -> adapterThreads() == 0), "the stdin thread outlived the kill"); + assertTrue(eventually(() -> { + try { + return descriptors() <= before; + } catch (IOException error) { + return false; + } + }), "descriptors grew from " + before); + } + + @Test + void aWriteBlockedByAnEscapedDescendantDoesNotHoldTheCaller() throws Exception { + assertTrue(invokeReady(faulty("ok", Duration.ofSeconds(10), null)).ok()); // warm up + long before = descriptors(); + // The worker exits after the selection while its child holds stdin + // and never reads, so the invoke frame (larger than a pipe buffer) + // cannot be written. The child was reparented before the deadline, + // so the kill does not find it. + Kit kit = kit(); + BoundaryType text = new BoundaryType.Nominal(kit.identity("text"), List.of()); + Interface spec = new Interface(List.of(text), text, List.of()); + long start = System.nanoTime(); + InvocationResult result = faulty("stdin-descendant", Duration.ofSeconds(3), null) + .invoke(kit.identity("kit-ready"), spec, List.of(new Value.Text("x".repeat(220_000))), new OperationRegistry(), new Cancel()); + Duration elapsed = Duration.ofNanos(System.nanoTime() - start); + long pid = descendant("stdin-descendant", result.stderr()); + try { + assertEquals("deadline", reason(result), result.toString()); + assertTrue(elapsed.compareTo(Duration.ofSeconds(6)) < 0, "returned after " + elapsed); + // The documented limit: the stdin thread stays blocked while the + // descendant holds the pipe. + assertEquals(1, adapterThreads(), "the blocked stdin thread"); + } finally { + kill(pid); + } + // Once the descendant exits, the write fails and the thread ends. + assertTrue(eventually(() -> adapterThreads() == 0), "the stdin thread outlived the descendant"); + assertTrue(eventually(() -> { + try { + return descriptors() <= before; + } catch (IOException error) { + return false; + } + }), "descriptors grew from " + before); + } +} diff --git a/java/src/test/java/org/jacquardlang/host/LiveTest.java b/java/src/test/java/org/jacquardlang/host/LiveTest.java new file mode 100644 index 0000000..e99a735 --- /dev/null +++ b/java/src/test/java/org/jacquardlang/host/LiveTest.java @@ -0,0 +1,237 @@ +package org.jacquardlang.host; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.stream.Stream; + +import org.jacquardlang.host.carrier.Cancel; +import org.jacquardlang.host.carrier.Options; +import org.jacquardlang.host.json.Json; +import org.jacquardlang.host.kit.Kit; +import org.jacquardlang.host.kit.Replay; +import org.jacquardlang.host.kit.Replayer; +import org.jacquardlang.host.protocol.BoundaryType; +import org.jacquardlang.host.protocol.Hash; +import org.jacquardlang.host.protocol.Interface; +import org.jacquardlang.host.protocol.Value; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Live tests against the real Core named by {@code JACQUARD_BIN} and + * {@code JACQUARD_PRELUDE}. Each test is skipped without them, and fails + * instead when {@code JACQUARD_REQUIRE_LIVE} is set, as CI does. + */ +class LiveTest { + private Path root; + private String store; + private Map env; + private String binary; + private Kit kit; + + @BeforeEach + void prepare() throws Exception { + String bin = System.getenv("JACQUARD_BIN"); + String prelude = System.getenv("JACQUARD_PRELUDE"); + if (bin == null || bin.isEmpty() || prelude == null || prelude.isEmpty()) { + if (System.getenv("JACQUARD_REQUIRE_LIVE") != null) { + fail("live tests need JACQUARD_BIN and JACQUARD_PRELUDE"); + } + assumeTrue(false, "live tests need JACQUARD_BIN and JACQUARD_PRELUDE"); + } + binary = bin; + kit = Support.kit(); + root = Files.createTempDirectory("jacquard-host-java-live-"); + Path source = root.resolve("fixtures.jac"); + Files.write(source, Kit.file("kit/fixtures.jac")); + store = root.resolve("store").toString(); + env = new HashMap<>(System.getenv()); + env.put("JACQUARD_PRELUDE", prelude); + // Output goes to a file, so a hung install cannot hold a pipe read + // past the timeout. + Path log = root.resolve("install.log"); + ProcessBuilder builder = new ProcessBuilder(binary, "run", source.toString(), "--store", store) + .redirectErrorStream(true) + .redirectOutput(log.toFile()); + builder.environment().clear(); + builder.environment().putAll(env); + Process process = builder.start(); + if (!process.waitFor(60, TimeUnit.SECONDS)) { + process.toHandle().descendants().forEach(ProcessHandle::destroyForcibly); + process.destroyForcibly(); + fail("installing fixtures timed out"); + } + assertEquals(0, process.exitValue(), "installing fixtures: " + Files.readString(log, StandardCharsets.UTF_8)); + } + + @AfterEach + void remove() throws IOException { + if (root == null) { + return; + } + try (Stream paths = Files.walk(root)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } + } + + private Options options(Duration deadline) { + return new Options(List.of(binary), env, deadline); + } + + private Host host(Duration deadline) { + return new Host(store, new HostOptions(options(deadline), null)); + } + + private Hash id(String name) { + return kit.identity(name); + } + + private Interface echo() { + BoundaryType text = new BoundaryType.Nominal(id("text"), List.of()); + return new Interface(List.of(text), text, List.of(id("kit-world"))); + } + + private OperationRegistry registry(Handler handler) { + return new OperationRegistry().bind(id("kit-world"), id("send"), handler); + } + + private InvocationResult invokeEcho(Host host, OperationRegistry registry) { + return host.invoke(id("kit-echo"), echo(), List.of(new Value.Text("ping")), registry, new Cancel()); + } + + @Test + void liveCoreMatchesAllRecordings() throws Exception { + List replays = Replayer.replayKit(store, options(null), kit, new Cancel()); + for (Replay replay : replays) { + assertTrue(replay.differences().isEmpty() && replay.proseDrift().isEmpty(), + replay.name() + ": " + replay.differences() + ", prose drift " + replay.proseDrift()); + } + assertEquals(Kit.TRANSCRIPT_COUNT, replays.size()); + } + + @Test + void livePureAndEffectfulInvocationsRetainEvidence() throws Exception { + Host host = host(null); + Interface unit = new Interface(List.of(), new BoundaryType.Tuple(List.of()), List.of()); + InvocationResult pure = host.invoke(id("kit-ready"), unit, List.of(), new OperationRegistry(), new Cancel()); + assertEquals(0, pure.exitStatus(), pure.toString()); + assertEquals(Value.UNIT, pure.valueOrThrow()); + AtomicInteger calls = new AtomicInteger(); + OperationRegistry registry = registry((context, args) -> { + calls.incrementAndGet(); + if (args.get(0) instanceof Value.Text text) { + return new Value.Text(text.value() + "!"); + } + throw HandlerException.outsideFailed("expected text"); + }); + InvocationResult result = invokeEcho(host, registry); + assertEquals(1, result.requests().size()); + assertEquals(Json.of("effect_ok"), result.hostFrames().get(2).get("kind")); + assertNotNull(result.evidence()); + assertEquals(Json.of("ok"), result.evidence().get("core").asObject().get("terminal")); + assertEquals(new Value.Text("ping!"), result.valueOrThrow()); + assertEquals(1, calls.get()); + InvocationResult probe = host.shutdownProbe(new Cancel()); + assertEquals(ResultKind.SHUTDOWN_ACK, probe.kind(), probe.toString()); + } + + @Test + void liveReadmeExampleRuns() throws Exception { + // The README example takes its command from JACQUARD_BIN and inherits + // this environment, which carries JACQUARD_PRELUDE. + assertEquals(new Value.Text("ping!"), + org.jacquardlang.host.readme.Echo.echo(store, id("kit-echo"), id("text"), id("kit-world"), id("send"))); + } + + @Test + void liveFailurePairsAreNeverRetried() throws Exception { + List, String>> cases = List.of( + Map.entry(HandlerException::unsupported, "E1606"), + Map.entry(HandlerException::refused, "E1607"), + Map.entry(HandlerException::outsideFailed, "E1613"), + Map.entry(HandlerException::completionUnknown, "E1612")); + for (Map.Entry, String> entry : cases) { + AtomicInteger calls = new AtomicInteger(); + InvocationResult result = invokeEcho(host(null), registry((context, args) -> { + calls.incrementAndGet(); + throw entry.getKey().apply("synthetic failure"); + })); + String expected = entry.getValue(); + assertEquals(expected, result.code(), result.toString()); + assertEquals(1, calls.get(), expected); + assertTrue(result.diagnostics().get(0).cause().endsWith("synthetic failure"), expected + ": " + result.diagnostics().get(0).cause()); + } + Map others = Map.of( + "checked exception", (context, args) -> { + throw new IOException("private detail"); + }, + "unchecked exception", (context, args) -> { + throw new IllegalStateException("private detail"); + }, + "error", (context, args) -> { + throw new AssertionError("private detail"); + }); + for (Map.Entry entry : others.entrySet()) { + InvocationResult result = invokeEcho(host(null), registry(entry.getValue())); + assertTrue(result.code().equals("E1612") && !result.message().contains("private detail"), entry.getKey() + ": " + result); + assertEquals(Failure.OPERATION_FAILED, assertThrows(InvocationException.class, result::valueOrThrow).failure(), entry.getKey()); + } + } + + @Test + void liveHandlerDeadlineKillsTheWorkerWithoutALateResponse() throws Exception { + for (boolean cooperative : new boolean[] {true, false}) { + AtomicInteger calls = new AtomicInteger(); + LinkedBlockingQueue finished = new LinkedBlockingQueue<>(); + OperationRegistry registry = registry((context, args) -> { + calls.incrementAndGet(); + if (cooperative) { + // Waits for cancellation. + while (!context.isCancelled()) { + Thread.sleep(5); + } + } else { + // Ignores cancellation; the host abandons it. + Thread.sleep(1000); + } + finished.add(context.isCancelled()); + return new Value.Text("late"); + }); + long start = System.nanoTime(); + InvocationResult result = invokeEcho(host(Duration.ofMillis(500)), registry); + Duration elapsed = Duration.ofNanos(System.nanoTime() - start); + assertTrue(Json.of("deadline").equals(result.hostEvidence().get("reason")) + && result.code().equals("E1611") + && calls.get() == 1 + && result.hostFrames().size() == 2 + && elapsed.compareTo(Duration.ofSeconds(2)) < 0, + "cooperative=" + cooperative + ": " + result.hostEvidence() + " frames=" + result.hostFrames().size() + " after " + elapsed); + Boolean cancelled = finished.poll(3, TimeUnit.SECONDS); + assertNotNull(cancelled, "handler did not finish"); + assertTrue(cancelled, "cooperative=" + cooperative + ": handler context was not cancelled"); + assertFalse(result.ok()); + } + } +} diff --git a/java/src/test/java/org/jacquardlang/host/NoticeTest.java b/java/src/test/java/org/jacquardlang/host/NoticeTest.java new file mode 100644 index 0000000..27b1568 --- /dev/null +++ b/java/src/test/java/org/jacquardlang/host/NoticeTest.java @@ -0,0 +1,24 @@ +package org.jacquardlang.host; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +/** + * A jar or source copy of java/ carries java/NOTICE but not the repository's + * root NOTICE, so the two must stay identical. + */ +class NoticeTest { + @Test + void noticeMatchesTheRepositoryNotice() throws Exception { + Path root = Path.of("..", "NOTICE"); + if (!Files.isRegularFile(root)) { + return; // outside a checkout + } + assertArrayEquals(Files.readAllBytes(root), Files.readAllBytes(Path.of("NOTICE")), + "java/NOTICE differs from the repository NOTICE; copy it again"); + } +} diff --git a/java/src/test/java/org/jacquardlang/host/OfflineTest.java b/java/src/test/java/org/jacquardlang/host/OfflineTest.java new file mode 100644 index 0000000..fb3cbb0 --- /dev/null +++ b/java/src/test/java/org/jacquardlang/host/OfflineTest.java @@ -0,0 +1,799 @@ +package org.jacquardlang.host; + +import static org.jacquardlang.host.Support.code; +import static org.jacquardlang.host.Support.faulty; +import static org.jacquardlang.host.Support.hash; +import static org.jacquardlang.host.Support.invokeReady; +import static org.jacquardlang.host.Support.json; +import static org.jacquardlang.host.Support.kit; +import static org.jacquardlang.host.Support.reason; +import static org.jacquardlang.host.Support.standin; +import static org.jacquardlang.host.Support.unit; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicReference; + +import org.jacquardlang.host.carrier.Cancel; +import org.jacquardlang.host.carrier.Options; +import org.jacquardlang.host.json.Json; +import org.jacquardlang.host.json.JsonCodec; +import org.jacquardlang.host.kit.Kit; +import org.jacquardlang.host.kit.Replay; +import org.jacquardlang.host.kit.Replayer; +import org.jacquardlang.host.kit.Step; +import org.jacquardlang.host.kit.Transcript; +import org.jacquardlang.host.protocol.BoundaryType; +import org.jacquardlang.host.protocol.Budget; +import org.jacquardlang.host.protocol.FailureCategory; +import org.jacquardlang.host.protocol.Hash; +import org.jacquardlang.host.protocol.Interface; +import org.jacquardlang.host.protocol.Limits; +import org.jacquardlang.host.protocol.OperationEntry; +import org.jacquardlang.host.protocol.Protocol; +import org.jacquardlang.host.protocol.Session; +import org.jacquardlang.host.protocol.Value; +import org.junit.jupiter.api.Test; + +/** + * Offline tests: strict codecs, the session state machine, kit replay + * through the Java carrier, and host fault handling against the stand-in. + */ +class OfflineTest { + private static final Limits HARD = Limits.HARD; + + private static Budget budget() { + return new Budget(HARD); + } + + @Test + void nullArgumentsAreRefusedBeforeAWorkerStarts() { + Host host = faulty("ok", Duration.ofSeconds(10), null); + assertThrows(NullPointerException.class, () -> host.shutdownProbe(null)); + assertThrows(NullPointerException.class, + () -> host.invoke(kit().identity("kit-ready"), unit(), List.of(), new OperationRegistry(), null)); + } + + @Test + void strictJsonRefusals() throws Exception { + for (String text : List.of( + "{\"a\":1,\"a\":2}", + "{\"x\":{\"a\":1,\"a\":2}}", + "{\"x\":1.0}", + "{\"x\":1e0}", + "{\"x\":NaN}", + "{\"x\":Infinity}", + "{\"x\":\"\\ud800\"}", + "{\"x\":\"\\udc00\"}", + "{\"x\":\"\\ud800A\"}", + "{}{}", + "[]", + "{\"x\":4611686018427387904}", + "{\"x\":01}", + "{\"x\":true,}", + "{\"x\":[1,]}", + "{\"x\":\"\001\"}", + "{\"x\":\"\\q\"}", + "{\"x\":-}", + "{\"x\":\"\\u+abc\"}", + "{\"x\":\"\\u\uff10\uff10\uff10\uff10\"}")) { + code("E1601", text, () -> JsonCodec.decodeObject(text.getBytes(StandardCharsets.UTF_8), 64)); + } + code("E1601", "invalid UTF-8", () -> JsonCodec.decodeObject(new byte[] {0x7b, 0x22, (byte) 0xff, 0x22, 0x3a, 0x30, 0x7d}, 64)); + // An encoded surrogate (CESU-8) is not UTF-8. + code("E1601", "encoded surrogate", + () -> JsonCodec.decodeObject(new byte[] {0x7b, 0x22, (byte) 0xed, (byte) 0xa0, (byte) 0x80, 0x22, 0x3a, 0x30, 0x7d}, 64)); + Map parsed = JsonCodec.decodeObject( + "{\"a\":4611686018427387903,\"b\":-4611686018427387904,\"c\":\"😀é\"}".getBytes(StandardCharsets.UTF_8), 64); + assertEquals(Json.of(Json.INT_MAX), parsed.get("a")); + assertEquals(Json.of(Json.INT_MIN), parsed.get("b")); + assertEquals(Json.of("😀é"), parsed.get("c")); + assertEquals(Json.of("😀"), JsonCodec.decodeObject("{\"x\":\"\\ud83d\\ude00\"}".getBytes(StandardCharsets.UTF_8), 64).get("x")); + Json value = Json.of(Json.object( + "z", Json.of(List.of(Json.NULL, Json.of(true), Json.of(-1))), + "a", Json.of("q\"\\\n\001😀"), + "__proto__", Json.of(Map.of()))); + assertEquals("{\"__proto__\":{},\"a\":\"q\\\"\\\\\\n\\u0001😀\",\"z\":[null,true,-1]}", JsonCodec.stringify(value, 64)); + code("E1601", "63-bit encode", () -> JsonCodec.stringify(Json.of(Json.object("x", Json.of(Json.INT_MAX + 1))), 64)); + // Keys sort by code point, not by UTF-16 unit: U+FFFF precedes U+1F600. + assertEquals("{\"\uffff\":1,\"😀\":2}", JsonCodec.stringify(Json.of(Json.object("😀", Json.of(2), "\uffff", Json.of(1))), 64)); + code("E1601", "lone surrogate encode", () -> JsonCodec.stringify(Json.of("\ud800"), 64)); + } + + @Test + void depthFrameAndLimits() throws Exception { + code("E1602", "decode depth", () -> JsonCodec.decodeObject("{\"a\":[[]]}".getBytes(StandardCharsets.UTF_8), 2)); + code("E1602", "frame bytes", () -> JsonCodec.frameBytes(Json.object("a", Json.of("xx")), 4, 64)); + assertArrayEquals("\0\0\0\7{\"a\":1}".getBytes(StandardCharsets.UTF_8), JsonCodec.frameBytes(Json.object("a", Json.of(1)), 64, 64)); + code("E1602", "zero limit", () -> HARD.with("max_arguments", 0).validate()); + Map extra = new TreeMap<>(HARD.toJson()); + extra.put("extra", Json.of(1)); + code("E1601", "extra limit", () -> Limits.fromJson(Json.of(extra))); + Map bool = new TreeMap<>(HARD.toJson()); + bool.put("max_arguments", Json.of(true)); + code("E1601", "boolean limit", () -> Limits.fromJson(Json.of(bool))); + Map huge = new TreeMap<>(HARD.toJson()); + huge.put("max_arguments", Json.of(1L << 60)); + code("E1601", "limit beyond the exact integer range", () -> Limits.fromJson(Json.of(huge))); + assertEquals(HARD, Limits.fromJson(Json.of(HARD.toJson()))); + } + + @Test + void valuesRoundtripExactly() throws Exception { + List values = List.of( + new Value.Int(0), + new Value.Int(Json.INT_MIN), + new Value.Int(Json.INT_MAX), + new Value.Text("hé😀"), + new Value.HashValue(hash('a')), + new Value.Tuple(List.of(new Value.Int(3), new Value.Text("x"))), + new Value.Constructor(hash('b'), List.of(new Value.Int(3))), + new Value.Real(0x7ff0_0000_0000_0001L), + Value.real(-0.0), + Value.real(Double.POSITIVE_INFINITY), + Value.UNIT); + for (Value value : values) { + Json first = Protocol.encodeValue(value, budget()); + Value decoded = Protocol.decodeValue(first, budget()); + assertEquals(value, decoded); + Json second = Protocol.encodeValue(decoded, budget()); + assertEquals(JsonCodec.stringify(first, 64), JsonCodec.stringify(second, 64)); + } + assertEquals(new Value.Real(0x8000_0000_0000_0000L), Value.real(-0.0)); + assertEquals("{\"bits\":\"8000000000000000\",\"kind\":\"real\"}", JsonCodec.stringify(Protocol.encodeValue(Value.real(-0.0), budget()), 64)); + for (String text : List.of("-0", "00", "+1", "4611686018427387904", "-4611686018427387905", " 1", "99999999999999999999")) { + Json wire = Json.of(Json.object("kind", Json.of("int"), "value", Json.of(text))); + code("E1601", text, () -> Protocol.decodeValue(wire, budget())); + } + code("E1604", "secret", () -> Protocol.decodeValue(json("{\"kind\":\"secret\",\"value\":\"x\"}"), budget())); + code("E1604", "Int range", () -> Protocol.encodeValue(new Value.Int(Json.INT_MAX + 1), budget())); + code("E1604", "Int range below", () -> Protocol.encodeValue(new Value.Int(Long.MIN_VALUE), budget())); + code("E1601", "lone surrogate Text", () -> Protocol.encodeValue(new Value.Text("a\udc00"), budget())); + code("E1602", "Text bytes", () -> Protocol.encodeValue(new Value.Text("é".repeat(131_073)), budget())); + String upper = "{\"kind\":\"nominal\",\"identity\":\"" + "A".repeat(64) + "\",\"arguments\":[]}"; + code("E1601", "uppercase hash", () -> Protocol.decodeType(json(upper), budget())); + Limits small = HARD.with("max_value_nodes", 1); + code("E1602", "value nodes", + () -> Protocol.decodeValue(json("{\"kind\":\"tuple\",\"items\":[{\"kind\":\"int\",\"value\":\"1\"}]}"), new Budget(small))); + code("E1601", "uppercase real", () -> Protocol.decodeValue(json("{\"kind\":\"real\",\"bits\":\"7FF0000000000001\"}"), budget())); + code("E1601", "short hash", () -> Hash.parse("A")); + assertThrows(IllegalArgumentException.class, () -> new Hash("A")); + } + + @Test + void decodersFollowCoresCheckOrder() { + String item = "{\"kind\":\"int\",\"value\":\"1\"}"; + String many = String.join(",", java.util.Collections.nCopies(65, item)); + // Core: exact fields, one node, identity, then the count. + String bad = "{\"kind\":\"constructor\",\"identity\":\"bad\",\"arguments\":[" + many + "]}"; + code("E1601", "constructor identity before count", () -> Protocol.decodeValue(json(bad), budget())); + String good = "{\"kind\":\"constructor\",\"identity\":\"" + "a".repeat(64) + "\",\"arguments\":[" + many + "]}"; + code("E1602", "constructor argument count", () -> Protocol.decodeValue(json(good), budget())); + // A malformed descriptor is refused as malformed even when the budget is spent. + Limits spent = HARD.with("max_value_nodes", 0); + String nominal = "{\"kind\":\"nominal\",\"identity\":\"" + "a".repeat(64) + "\"}"; + code("E1601", "exact fields before the node", () -> Protocol.decodeType(json(nominal), new Budget(spent))); + code("E1604", "kind before the node", () -> Protocol.decodeValue(json("{\"kind\":\"secret\"}"), new Budget(spent))); + code("E1601", "missing kind", () -> Protocol.decodeValue(json("{\"value\":\"1\"}"), budget())); + } + + private static Map hello() { + return Json.object( + "carrier", Json.of(Compat.CARRIER), + "kind", Json.of("core_hello"), + "limits", Json.of(HARD.toJson()), + "versions", Json.of(List.of(Json.of(Compat.PROTOCOL)))); + } + + private static Map with(Map frame, String key, Json value) { + Map changed = new TreeMap<>(frame); + changed.put(key, value); + return changed; + } + + @Test + void helloAndSelection() throws Exception { + code("E1601", "extra hello field", () -> new Session().receive(with(hello(), "extra", Json.of(1)))); + code("E1600", "unknown version", + () -> new Session().receive(with(hello(), "versions", Json.of(List.of(Json.of("jacquard-host-v9")))))); + code("E1601", "lowered hello limits", + () -> new Session().receive(with(hello(), "limits", Json.of(HARD.with("max_arguments", 1).toJson())))); + code("E1608", "first frame", () -> new Session().receive(Json.object("kind", Json.of("fatal"), "protocol", Json.of(Compat.PROTOCOL)))); + Session session = new Session(); + assertNull(session.receive(hello())); + code("E1608", "invoke before select", () -> session.invoke(hash('a'), unit(), List.of(), List.of())); + code("E1602", "select above ceiling", () -> session.select(HARD.with("max_frame_bytes", HARD.maxFrameBytes() + 1))); + } + + @Test + void invokeFrameValidation() throws Exception { + Hash a = hash('a'); + Hash b = hash('b'); + Interface unsorted = new Interface(List.of(), new BoundaryType.Tuple(List.of()), List.of(b, a)); + code("E1601", "unsorted effects", () -> Protocol.invokeFrame(a, unsorted, List.of(), List.of(), HARD)); + Interface spec = new Interface(List.of(), new BoundaryType.Tuple(List.of()), List.of(a)); + code("E1601", "operation outside row", + () -> Protocol.invokeFrame(a, spec, List.of(), List.of(new OperationEntry(b, a)), HARD)); + code("E1601", "duplicate operation", + () -> Protocol.invokeFrame(a, spec, List.of(), List.of(new OperationEntry(a, b), new OperationEntry(a, b)), HARD)); + code("E1601", "argument count", () -> Protocol.invokeFrame(a, spec, List.of(new Value.Int(1)), List.of(), HARD)); + Map frame = Protocol.invokeFrame(a, spec, List.of(), List.of(new OperationEntry(a, b)), HARD); + assertEquals( + "{\"effects\":[\"" + a + "\"],\"operations\":[{\"effect\":\"" + a + "\",\"mode\":\"once\",\"operation\":\"" + b + "\"}]}", + JsonCodec.stringify(frame.get("capabilities"), 64)); + } + + @Test + void jsStringMatchesJavascriptCoercion() { + assertEquals("x", Replayer.jsString(Json.of("x"))); + assertEquals("null", Replayer.jsString(Json.NULL)); + assertEquals("5", Replayer.jsString(Json.of(5))); + assertEquals("true", Replayer.jsString(Json.of(true))); + assertEquals("1,,a,b", Replayer.jsString(json("[1,null,[\"a\",\"b\"]]"))); + assertEquals("[object Object]", Replayer.jsString(json("{\"a\":1}"))); + } + + @Test + void pinnedKitLoads() { + Kit kit = kit(); + assertEquals(Kit.TRANSCRIPT_COUNT, kit.transcripts().size()); + assertTrue(kit.identities().containsKey("kit-echo")); + assertNotNull(Kit.file("kit/fixtures.jac")); + assertNotNull(Kit.file("kit/README.md")); + assertNull(Kit.file("../NOTICE")); + } + + @Test + void outcomeEvidenceAndResultShareOneFrameBudget() throws Exception { + Kit kit = kit(); + Map outcome = kit.transcripts().get(0).coreOutput().get(1); + Protocol.parseCoreFrame(outcome, HARD.with("max_value_nodes", 2)); + code("E1602", "pure outcome", () -> Protocol.parseCoreFrame(outcome, HARD.with("max_value_nodes", 1))); + // An error outcome spends its two evidence types only. + List> refused = kit.transcript("refused-authority").coreOutput(); + Map errorOutcome = refused.get(refused.size() - 1); + Protocol.parseCoreFrame(errorOutcome, HARD.with("max_value_nodes", 2)); + code("E1602", "error outcome", () -> Protocol.parseCoreFrame(errorOutcome, HARD.with("max_value_nodes", 1))); + } + + @Test + void sessionEnforcesOneInvocationAndLocalTerminalRefusal() throws Exception { + Kit kit = kit(); + Transcript pure = kit.transcripts().get(0); + Session session = new Session(); + session.receive(pure.coreOutput().get(0)); + session.select(HARD); + session.invoke(kit.identity("kit-ready"), unit(), List.of(), List.of()); + code("E1608", "second invoke", () -> session.invoke(hash('a'), unit(), List.of(), List.of())); + session.receive(pure.coreOutput().get(1)); + code("E1608", "shutdown after terminal", session::shutdown); + code("E1608", "frame after terminal", () -> session.receive(pure.coreOutput().get(1))); + } + + @Test + void requestOrderingAndOneResponseSlot() throws Exception { + Transcript transcript = kit().transcript("one-once-effect-success"); + Session session = new Session(); + session.receive(transcript.coreOutput().get(0)); + session.select(HARD); + Map invoke = null; + for (Step step : transcript.hostInput()) { + if (Json.of("invoke").equals(step.frame().get("kind"))) { + invoke = step.frame(); + break; + } + } + assertNotNull(invoke); + Map wire = invoke.get("interface").asObject(); + List parameters = new ArrayList<>(); + for (Json parameter : wire.get("parameters").asArray()) { + parameters.add(Protocol.decodeType(parameter, budget())); + } + List effects = new ArrayList<>(); + for (Json effect : wire.get("effects").asArray()) { + effects.add(Hash.parse(effect.asString())); + } + Interface spec = new Interface(parameters, Protocol.decodeType(wire.get("result"), budget()), effects); + List operations = new ArrayList<>(); + for (Json entry : invoke.get("capabilities").asObject().get("operations").asArray()) { + Map fields = entry.asObject(); + operations.add(new OperationEntry(Hash.parse(fields.get("effect").asString()), Hash.parse(fields.get("operation").asString()))); + } + Hash callable = Hash.parse(invoke.get("target").asObject().get("callable").asString()); + session.invoke(callable, spec, List.of(new Value.Text("ping")), operations); + Map request = transcript.coreOutput().get(1); + code("E1601", "numeric invocation id", () -> session.receive(with(request, "invocation_id", Json.of(0)))); + code("E1608", "other invocation", () -> session.receive(with(request, "invocation_id", Json.of("ffffffffffffffff")))); + code("E1608", "skipped ordinal", () -> session.receive(with(request, "request_id", Json.of("0000000000000002")))); + code("E1608", "ordinal beyond the exact range", () -> session.receive(with(request, "request_id", Json.of("ffffffffffffffff")))); + session.receive(request); + code("E1608", "second request while waiting", () -> session.receive(request)); + session.respondOk(new Value.Text("pong")); + code("E1608", "second response", () -> session.respondOk(new Value.Text("again"))); + } + + private static Replay replayStandin(Transcript transcript, String original) { + return Replayer.replayOne(transcript, "unused", standin("replay", original, Duration.ofSeconds(20)), new Cancel()); + } + + @Test + void allRecordingsReplayThroughTheJavaCarrier() { + for (Transcript transcript : kit().transcripts()) { + Replay replay = replayStandin(transcript, transcript.name()); + assertTrue(replay.differences().isEmpty() && replay.proseDrift().isEmpty() && replay.status().equals(transcript.status()), + transcript.name() + ": differences " + replay.differences() + ", prose drift " + replay.proseDrift() + ", status " + + replay.status()); + } + } + + @Test + void replayTreatsAbsentExpectationsLikeTheReference() { + Kit kit = kit(); + Transcript missing = kit.transcript("pure-success").withExpect(Map.of()); + assertEquals("diverges", replayStandin(missing, "pure-success").status(), "absent terminal expectation conformed"); + Map expect = new TreeMap<>(kit.transcript("unknown-version").expect()); + expect.remove("terminal_frames_max"); + Transcript unbounded = kit.transcript("unknown-version").withExpect(expect); + assertEquals("diverges", replayStandin(unbounded, "unknown-version").status(), "missing terminal_frames_max conformed"); + } + + private static Json summaryOf(Map frame) { + return frame.get("result").asObject().get("diagnostics").asArray().get(0).asObject().get("summary"); + } + + @Test + void proseDriftIsReportedWithoutNormalizing() { + Transcript original = kit().transcript("refused-authority"); + List> output = new ArrayList<>(original.coreOutput()); + Map last = output.get(output.size() - 1); + Map result = new TreeMap<>(last.get("result").asObject()); + List diagnostics = new ArrayList<>(result.get("diagnostics").asArray()); + Map diagnostic = new TreeMap<>(diagnostics.get(0).asObject()); + diagnostic.put("summary", Json.of(diagnostic.get("summary").asString() + " changed")); + diagnostics.set(0, Json.of(diagnostic)); + result.put("diagnostics", Json.of(diagnostics)); + output.set(output.size() - 1, with(last, "result", Json.of(result))); + Replay replay = replayStandin(original.withCoreOutput(output), original.name()); + assertTrue(replay.differences().isEmpty() && replay.proseDrift().size() == 1, replay.differences() + " " + replay.proseDrift()); + List> frames = replay.coreFrames(); + assertEquals(summaryOf(original.coreOutput().get(original.coreOutput().size() - 1)), summaryOf(frames.get(frames.size() - 1))); + } + + @Test + void malformedExtraAndNoncanonicalCoreFramesFailClosed() { + for (String[] pair : new String[][] {{"unknown-field", "E1601"}, {"invalid-hash", "E1601"}, {"extra", "E1608"}}) { + InvocationResult result = invokeReady(faulty(pair[0], Duration.ofSeconds(10), null)); + assertTrue(result.kind() == ResultKind.PROTOCOL_ERROR && result.code().equals(pair[1]), pair[0] + ": " + result); + } + } + + @Test + void carrierFailuresRetainHostEvidence() { + for (String mode : List.of("exit", "truncated", "silent", "stall", "nonzero")) { + boolean expired = mode.equals("silent") || mode.equals("stall"); + // Deadlines leave room for the stand-in JVM to start. + InvocationResult result = invokeReady(faulty(mode, expired ? Duration.ofSeconds(2) : Duration.ofSeconds(10), null)); + assertTrue(result.kind() == ResultKind.CARRIER_LOSS && result.code().equals("E1611") + && Json.of(Host.EVIDENCE_SCHEMA).equals(result.hostEvidence().get("schema")), mode + ": " + result.hostEvidence()); + boolean sawOutcome = result.coreFrames().stream().anyMatch(frame -> Json.of("outcome").equals(frame.get("kind"))); + assertEquals(mode.equals("stall") || mode.equals("nonzero"), sawOutcome, mode + ": outcome observed"); + assertEquals(expired, "deadline".equals(reason(result)), mode + ": " + reason(result)); + InvocationException error = assertThrows(InvocationException.class, result::valueOrThrow); + assertEquals(expired ? Failure.DEADLINE_EXCEEDED : Failure.WORKER_LOST, error.failure(), mode); + } + } + + @Test + void framesAfterSelectionAreReadUnderTheSelectedLimits() { + InvocationResult result = invokeReady(faulty("oversized-early", Duration.ofSeconds(10), HARD.with("max_frame_bytes", 1024))); + assertTrue(result.kind() == ResultKind.PROTOCOL_ERROR && result.code().equals("E1602"), result.toString()); + } + + @Test + void stderrIsDrainedAndRetentionObeysTheSelectedCeiling() { + InvocationResult result = invokeReady(faulty("stderr", Duration.ofSeconds(10), HARD.with("max_stderr_bytes", 17))); + assertTrue(result.ok() && result.stderr().length == 17, result + ", stderr " + result.stderr().length + " bytes"); + assertEquals("x".repeat(17), new String(result.stderr(), StandardCharsets.US_ASCII)); + } + + @Test + void backpressureSpendsTheSameLifetime() { + Kit kit = kit(); + BoundaryType text = new BoundaryType.Nominal(kit.identity("text"), List.of()); + Interface spec = new Interface(List.of(text), text, List.of()); + long start = System.nanoTime(); + Host host = faulty("no-read", Duration.ofSeconds(2), null); + InvocationResult result = host.invoke(kit.identity("kit-ready"), spec, List.of(new Value.Text("x".repeat(220_000))), + new OperationRegistry(), new Cancel()); + Duration elapsed = Duration.ofNanos(System.nanoTime() - start); + assertTrue("deadline".equals(reason(result)) && elapsed.compareTo(Duration.ofSeconds(5)) < 0, reason(result) + " after " + elapsed); + assertEquals(Failure.DEADLINE_EXCEEDED, assertThrows(InvocationException.class, result::valueOrThrow).failure()); + } + + @Test + void cancellationAbortsWithoutRetry() { + Cancel cancel = new Cancel(); + Thread trigger = new Thread(() -> { + try { + Thread.sleep(300); + } catch (InterruptedException ignored) { + return; + } + cancel.cancel(); + }); + trigger.start(); + InvocationResult result = faulty("silent", Duration.ofSeconds(10), null) + .invoke(kit().identity("kit-ready"), unit(), List.of(), new OperationRegistry(), cancel); + assertEquals("aborted", reason(result)); + assertEquals(Failure.WORKER_LOST, assertThrows(InvocationException.class, result::valueOrThrow).failure()); + } + + @Test + void interruptionAbortsAndTheInterruptIsRestored() throws Exception { + Thread caller = Thread.currentThread(); + Thread trigger = new Thread(() -> { + try { + Thread.sleep(300); + } catch (InterruptedException ignored) { + return; + } + caller.interrupt(); + }); + trigger.start(); + long start = System.nanoTime(); + InvocationResult result = invokeReady(faulty("silent", Duration.ofSeconds(10), null)); + boolean restored = Thread.interrupted(); + trigger.join(); + assertEquals("aborted", reason(result), result.toString()); + assertTrue(restored, "the caller's interrupt status was not restored"); + assertTrue(Duration.ofNanos(System.nanoTime() - start).compareTo(Duration.ofSeconds(5)) < 0); + } + + @Test + void localRefusalStartsNoWorkerAndInvalidOptionsAreRejected() { + Options missing = Options.defaults().withCommand(List.of("/no/such/executable")); + Host refused = new Host("unused", new HostOptions(missing, HARD.with("max_frame_bytes", 20))); + InvocationResult result = invokeReady(refused); + assertTrue(result.kind() == ResultKind.HOST_ERROR && result.code().equals("E1602") && result.exitStatus() == null, result.toString()); + assertEquals("invoke_refused_locally", reason(result)); + Host spawn = new Host("unused", new HostOptions(missing, null)); + InvocationResult failed = invokeReady(spawn); + assertEquals("worker_spawn_failed", reason(failed), failed.toString()); + assertTrue(failed.message().contains("/no/such/executable"), failed.message()); + for (HostOptions options : List.of( + new HostOptions(Options.defaults().withDeadline(Duration.ZERO), null), + new HostOptions(Options.defaults().withDeadline(Duration.ofSeconds(-1)), null), + new HostOptions(Options.defaults().withCommand(List.of("")), null), + new HostOptions(null, HARD.with("max_arguments", 0)))) { + assertThrows(IllegalArgumentException.class, () -> new Host("unused", options), "accepted " + options); + } + } + + @Test + void registryRefusesDuplicatesAndSortsEntries() { + Hash a = hash('a'); + Hash b = hash('b'); + OperationRegistry registry = new OperationRegistry(); + registry.bind(b, a, (context, args) -> Value.UNIT); + registry.bind(a, b, (context, args) -> Value.UNIT); + assertThrows(IllegalArgumentException.class, () -> registry.bind(a, b, (context, args) -> Value.UNIT)); + List entries = registry.entries(); + assertEquals(a, entries.get(0).effect()); + assertEquals(b, entries.get(1).effect()); + } + + /** Runs one handler against the stand-in replaying {@code one-once-effect-success}. */ + private static Map response(Handler handler) { + Kit kit = kit(); + BoundaryType text = new BoundaryType.Nominal(kit.identity("text"), List.of()); + Interface spec = new Interface(List.of(text), text, List.of(kit.identity("kit-world"))); + OperationRegistry registry = new OperationRegistry().bind(kit.identity("kit-world"), kit.identity("send"), handler); + Host host = new Host("unused", new HostOptions(standin("replay", "one-once-effect-success", Duration.ofSeconds(20)), null)); + InvocationResult result = host.invoke(kit.identity("kit-echo"), spec, List.of(new Value.Text("ping")), registry, new Cancel()); + // The stand-in replays its recorded outcome whatever the host answers. + assertTrue(result.ok(), result.toString()); + assertEquals(3, result.hostFrames().size(), result.hostFrames().toString()); + return result.hostFrames().get(2); + } + + private static void failure(Map frame, FailureCategory category, String message) { + assertEquals(Json.of("effect_failure"), frame.get("kind"), frame.toString()); + assertEquals(Json.of(category.wireName()), frame.get("category"), frame.toString()); + assertEquals(Json.of(category.completion()), frame.get("completion"), frame.toString()); + assertEquals(Json.of(message), frame.get("message"), frame.toString()); + } + + @Test + void handlerAnswersMapToTheirFailureCategories() { + AtomicReference> seen = new AtomicReference<>(); + Map ok = response((context, args) -> { + seen.set(args); + return new Value.Text("pong"); + }); + assertEquals(List.of(new Value.Text("ping")), seen.get()); + assertEquals(Json.of("effect_ok"), ok.get("kind")); + failure(response((context, args) -> { + throw HandlerException.refused("no authority"); + }), FailureCategory.REFUSED_AUTHORITY, "no authority"); + failure(response((context, args) -> { + throw new IllegalStateException("private detail"); + }), FailureCategory.COMPLETION_UNKNOWN, "handler threw an exception"); + failure(response((context, args) -> { + throw new java.io.IOException("private detail"); + }), FailureCategory.COMPLETION_UNKNOWN, "handler threw an exception"); + failure(response((context, args) -> { + throw new StackOverflowError(); + }), FailureCategory.COMPLETION_UNKNOWN, "handler threw an exception"); + failure(response((context, args) -> null), FailureCategory.OUTSIDE_FAILURE, + "handler result has no encoding within the selected limits"); + failure(response((context, args) -> new Value.Int(Long.MAX_VALUE)), FailureCategory.OUTSIDE_FAILURE, + "handler result has no encoding within the selected limits"); + // Messages are cut to max_host_message_bytes at a code point, and a lone + // surrogate becomes U+FFFD. + failure(response((context, args) -> { + throw HandlerException.outsideFailed("\ud800" + "é".repeat(3000)); + }), FailureCategory.OUTSIDE_FAILURE, "\ufffd" + "é".repeat(2046)); + assertFalse(Host.bounded("😀", 3).contains("\ud83d")); + } + + /** + * 256 references to one text at the text ceiling: within the node, + * collection, and text limits, but over 384 MiB once escaped. + */ + private static Value hugeResult() { + return new Value.Tuple(java.util.Collections.nCopies(256, new Value.Text("\u0001".repeat(262_144)))); + } + + @Test + void aResultFarOverTheFrameCeilingIsRefusedWithoutEncodingItAll() { + long start = System.nanoTime(); + Map frame = response((context, args) -> hugeResult()); + long elapsed = (System.nanoTime() - start) / 1_000_000; + failure(frame, FailureCategory.OUTSIDE_FAILURE, "handler result has no encoding within the selected limits"); + assertTrue(elapsed < 3000, "returned after " + elapsed + " ms"); + } + + @Test + void cancellationWhileAHugeResultIsEncodedEndsTheCallPromptly() { + Kit kit = kit(); + BoundaryType text = new BoundaryType.Nominal(kit.identity("text"), List.of()); + Interface spec = new Interface(List.of(text), text, List.of(kit.identity("kit-world"))); + Cancel cancel = new Cancel(); + OperationRegistry registry = new OperationRegistry().bind(kit.identity("kit-world"), kit.identity("send"), (context, args) -> { + // Cancel from elsewhere once the result is being encoded. A + // bounded encoder has finished by then; an unbounded one is still + // encoding seconds later, and only then sees the cancellation. + Thread.ofPlatform().daemon().start(() -> { + try { + Thread.sleep(100); + } catch (InterruptedException ignored) { + return; + } + cancel.cancel(); + }); + return hugeResult(); + }); + Host host = new Host("unused", new HostOptions(standin("replay", "one-once-effect-success", Duration.ofSeconds(20)), null)); + long start = System.nanoTime(); + InvocationResult result = host.invoke(kit.identity("kit-echo"), spec, List.of(new Value.Text("ping")), registry, cancel); + long elapsed = (System.nanoTime() - start) / 1_000_000; + assertTrue(result.ok() || "aborted".equals(reason(result)), result.toString()); + assertTrue(elapsed < 3000, "returned after " + elapsed + " ms"); + } + + /** Whether the caller is decoding a string, which a 256 KiB request makes long. */ + private static boolean decoding(StackTraceElement[] stack) { + for (StackTraceElement frame : stack) { + if (frame.getClassName().equals(JsonCodec.class.getName() + "$Decoder") && frame.getMethodName().equals("quoted")) { + return true; + } + } + return false; + } + + /** + * Cancels the call, or interrupts the caller, once its stack matches + * {@code when}, and checks that the handler then never started. Retries + * until the stop lands there. + */ + private static void noHandlerAfterAStop(boolean interrupt, java.util.function.Predicate when) throws Exception { + Kit kit = kit(); + BoundaryType text = new BoundaryType.Nominal(kit.identity("text"), List.of()); + Interface spec = new Interface(List.of(text), text, List.of(kit.identity("kit-world"))); + for (int attempt = 0; attempt < 10; attempt++) { + Cancel cancel = new Cancel(); + java.util.concurrent.atomic.AtomicLong stoppedAt = new java.util.concurrent.atomic.AtomicLong(); + java.util.concurrent.atomic.AtomicLong startedAt = new java.util.concurrent.atomic.AtomicLong(); + OperationRegistry registry = new OperationRegistry().bind(kit.identity("kit-world"), kit.identity("send"), (context, args) -> { + startedAt.set(System.nanoTime()); + return Value.UNIT; + }); + Thread caller = Thread.currentThread(); + java.util.concurrent.atomic.AtomicBoolean done = new java.util.concurrent.atomic.AtomicBoolean(); + Thread watcher = Thread.ofPlatform().daemon().start(() -> { + while (!done.get()) { + if (when.test(caller.getStackTrace())) { + stoppedAt.set(System.nanoTime()); + if (interrupt) { + caller.interrupt(); + } else { + cancel.cancel(); + } + return; + } + } + }); + Host host = new Host("unused", new HostOptions(standin("large-request", "", Duration.ofSeconds(20)), null)); + InvocationResult result = host.invoke(kit.identity("kit-echo"), spec, List.of(new Value.Text("ping")), registry, cancel); + done.set(true); + watcher.join(); + boolean interrupted = Thread.interrupted(); + if (stoppedAt.get() == 0) { + continue; + } + assertEquals(0, startedAt.get(), "the handler started " + (startedAt.get() - stoppedAt.get()) / 1_000_000 + " ms after the stop"); + assertEquals("aborted", reason(result), result.toString()); + assertEquals(interrupt, interrupted); + return; + } + throw new AssertionError("the stop never landed where the test aimed it"); + } + + @Test + void cancellationWhileTheRequestIsDecodedStartsNoHandler() throws Exception { + noHandlerAfterAStop(false, OfflineTest::decoding); + } + + @Test + void interruptionWhileTheRequestIsDecodedStartsNoHandler() throws Exception { + noHandlerAfterAStop(true, OfflineTest::decoding); + } + + /** + * Cancels the call, or interrupts the caller, from the handler thread just + * before its check, and checks that the handler never ran and the call + * was aborted. + */ + private static void noHandlerAfterAStopAtTheHandlerCheck(boolean interrupt) { + noHandlerAfterAStopAtTheHandlerCheck(interrupt, false); + } + + /** + * As above; with {@code consumed}, the check waits until the caller has + * consumed its interrupt, so only the host's record of it remains. + */ + private static void noHandlerAfterAStopAtTheHandlerCheck(boolean interrupt, boolean consumed) { + Kit kit = kit(); + BoundaryType text = new BoundaryType.Nominal(kit.identity("text"), List.of()); + Interface spec = new Interface(List.of(text), text, List.of(kit.identity("kit-world"))); + Cancel cancel = new Cancel(); + Thread caller = Thread.currentThread(); + java.util.concurrent.atomic.AtomicBoolean ran = new java.util.concurrent.atomic.AtomicBoolean(); + java.util.concurrent.atomic.AtomicBoolean stopped = new java.util.concurrent.atomic.AtomicBoolean(); + OperationRegistry registry = new OperationRegistry().bind(kit.identity("kit-world"), kit.identity("send"), (context, args) -> { + ran.set(true); + return Value.UNIT; + }); + Runnable stop = () -> { + stopped.set(true); + if (interrupt) { + caller.interrupt(); + long give = System.nanoTime() + 5_000_000_000L; + while (consumed && caller.isInterrupted() && System.nanoTime() < give) { + Thread.onSpinWait(); + } + } else { + cancel.cancel(); + } + }; + Host.handlerCheckHook = stop; + InvocationResult result; + try { + Host host = new Host("unused", new HostOptions(standin("replay", "one-once-effect-success", Duration.ofSeconds(20)), null)); + result = host.invoke(kit.identity("kit-echo"), spec, List.of(new Value.Text("ping")), registry, cancel); + } finally { + Host.handlerCheckHook = null; + } + boolean interrupted = Thread.interrupted(); + assertTrue(stopped.get(), "the seam never ran"); + assertFalse(ran.get(), "the handler ran after the stop"); + assertEquals("aborted", reason(result), result.toString()); + assertEquals(interrupt, interrupted); + } + + @Test + void aCancellationJustBeforeTheHandlerIsCalledRunsNoHandler() { + noHandlerAfterAStopAtTheHandlerCheck(false); + } + + @Test + void anInterruptionJustBeforeTheHandlerIsCalledRunsNoHandler() { + noHandlerAfterAStopAtTheHandlerCheck(true); + } + + @Test + void anInterruptTheCallerHasAlreadyConsumedStillRunsNoHandler() { + for (int i = 0; i < 20; i++) { + noHandlerAfterAStopAtTheHandlerCheck(true, true); + } + } + + @Test + void validatingManyReferencesToALongTextIsBounded() { + // 4 x 1022 references to one text of 65536 emoji (262144 UTF-8 + // bytes): within the node, collection, and text limits, and about + // 1 GiB of text to scan if each reference were checked in full. + Value.Text text = new Value.Text("😀".repeat(65_536)); + Value value = new Value.Tuple(java.util.Collections.nCopies(4, new Value.Tuple(java.util.Collections.nCopies(1022, text)))); + java.util.concurrent.atomic.AtomicLong returned = new java.util.concurrent.atomic.AtomicLong(); + Map frame = response((context, args) -> { + returned.set(System.nanoTime()); + return value; + }); + long elapsed = (System.nanoTime() - returned.get()) / 1_000_000; + failure(frame, FailureCategory.OUTSIDE_FAILURE, "handler result has no encoding within the selected limits"); + assertTrue(elapsed < 500, "answered " + elapsed + " ms after the handler returned"); + // Deterministically: validation itself charges the texts against the + // frame and stops, rather than checking every reference in full and + // leaving the refusal to the encoder. + ProtocolException refused = assertThrows(ProtocolException.class, + () -> org.jacquardlang.host.protocol.Protocol.encodeValue(value, new org.jacquardlang.host.protocol.Budget(HARD))); + assertEquals("payload exceeds max_frame_bytes", refused.detail()); + } + + @Test + void aTextFarOverTheTextCeilingIsRefusedPromptly() { + Value.Text text = new Value.Text("é".repeat(32 << 20)); + java.util.concurrent.atomic.AtomicLong returned = new java.util.concurrent.atomic.AtomicLong(); + Map frame = response((context, args) -> { + returned.set(System.nanoTime()); + return text; + }); + long elapsed = (System.nanoTime() - returned.get()) / 1_000_000; + failure(frame, FailureCategory.OUTSIDE_FAILURE, "handler result has no encoding within the selected limits"); + assertTrue(elapsed < 500, "answered " + elapsed + " ms after the handler returned"); + } + + @Test + void replayStopsReadingAWorkerThatSendsPastTheRecording() { + Transcript pure = kit().transcripts().get(0); + Runtime runtime = Runtime.getRuntime(); + System.gc(); + long heapBefore = runtime.totalMemory() - runtime.freeMemory(); + long start = System.nanoTime(); + Replay replay = Replayer.replayOne(pure, "unused", standin("flood-after-terminal", "", Duration.ofSeconds(10)), new Cancel()); + long elapsed = (System.nanoTime() - start) / 1_000_000; + System.gc(); + long heapAfter = runtime.totalMemory() - runtime.freeMemory(); + assertFalse(replay.matchesRecording(), replay.toString()); + assertTrue(replay.differences().contains("Core sent more frames than the recording"), replay.differences().toString()); + assertTrue(elapsed < 3000, "returned after " + elapsed + " ms"); + assertTrue(heapAfter - heapBefore < 64L << 20, "the heap grew by " + ((heapAfter - heapBefore) >> 20) + " MiB"); + } + + @Test + void theEncoderStopsAtTheFrameCeiling() throws ProtocolException { + Map frame = Map.of("k", Json.of("\u0001".repeat(1000))); + ProtocolException refused = assertThrows(ProtocolException.class, () -> JsonCodec.frameBytes(frame, 6007, 64)); + assertEquals("payload exceeds max_frame_bytes", refused.detail()); + // {"k":"…"} is 8 bytes plus 6 per escaped U+0001: exactly 6008. + assertEquals(4 + 6008, JsonCodec.frameBytes(frame, 6008, 64).length); + assertEquals("\"\\u0001\\u001f\"", JsonCodec.stringify(Json.of("\u0001\u001f"), 64)); + // A text that cannot fit is refused by length, before the scan that + // would find its lone surrogate. + ProtocolException long_ = assertThrows(ProtocolException.class, + () -> JsonCodec.frameBytes(Map.of("k", Json.of("\ud800" + "x".repeat(7000))), 6008, 64)); + assertEquals("payload exceeds max_frame_bytes", long_.detail()); + } +} diff --git a/java/src/test/java/org/jacquardlang/host/ProcessTreeTest.java b/java/src/test/java/org/jacquardlang/host/ProcessTreeTest.java new file mode 100644 index 0000000..b96426c --- /dev/null +++ b/java/src/test/java/org/jacquardlang/host/ProcessTreeTest.java @@ -0,0 +1,126 @@ +package org.jacquardlang.host; + +import static org.jacquardlang.host.Support.alive; +import static org.jacquardlang.host.Support.descendant; +import static org.jacquardlang.host.Support.dies; +import static org.jacquardlang.host.Support.faulty; +import static org.jacquardlang.host.Support.invokeReady; +import static org.jacquardlang.host.Support.kill; +import static org.jacquardlang.host.Support.reason; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; + +/** + * The worker's process tree at expiry and after exit. The stand-in's + * descendants use {@code sleep} and {@code sh}, so these run on Unix only. + */ +@DisabledOnOs(OS.WINDOWS) +class ProcessTreeTest { + private static Duration since(long start) { + return Duration.ofNanos(System.nanoTime() - start); + } + + @Test + void deadlineStopsTheWorkersProcessTree() throws Exception { + // A child, and a grandchild under a waiting shell, hold the pipes + // while the worker stays silent. + for (String mode : List.of("child-silent", "grandchild-silent")) { + long start = System.nanoTime(); + InvocationResult result = invokeReady(faulty(mode, Duration.ofSeconds(2), null)); + assertTrue(since(start).compareTo(Duration.ofSeconds(5)) < 0 && "deadline".equals(reason(result)), + mode + ": " + since(start) + " " + result.hostEvidence()); + dies(mode, descendant(mode, result.stderr())); + } + } + + @Test + void descendantsReparentedBeforeTheKillAreNotFound() throws Exception { + // A descendant that daemonized while the worker ran is outside the + // snapshot: the host still returns at the deadline, but it survives. + long start = System.nanoTime(); + InvocationResult result = invokeReady(faulty("daemon-silent", Duration.ofSeconds(2), null)); + long pid = descendant("daemon-silent", result.stderr()); + try { + assertTrue(since(start).compareTo(Duration.ofSeconds(5)) < 0 && "deadline".equals(reason(result)), result.toString()); + assertTrue(alive(pid), "the daemonized descendant was found after all; update LIMITS.md"); + } finally { + kill(pid); + } + // A launcher that exits at once, leaving a descendant holding stdout: + // stdout ends when the launcher exits, and the descendant survives. + start = System.nanoTime(); + result = invokeReady(faulty("descendant", Duration.ofSeconds(10), null)); + pid = descendant("descendant", result.stderr()); + try { + assertTrue(since(start).compareTo(Duration.ofSeconds(5)) < 0, "returned after " + since(start)); + assertEquals("carrier_loss", reason(result), result.toString()); + assertTrue(result.message().contains("worker exited before core_hello"), result.message()); + assertTrue(alive(pid), "the reparented descendant was found after all; update LIMITS.md"); + } finally { + kill(pid); + } + } + + @Test + void theExitWaitObeysTheDeadline() { + InvocationResult ok = invokeReady(faulty("ok", Duration.ofSeconds(10), null)); + assertTrue(ok.ok() && Integer.valueOf(0).equals(ok.exitStatus()) && ok.signal() == null, ok.toString()); + // The worker sends its terminal, closes stdout, and never exits. + long start = System.nanoTime(); + InvocationResult result = invokeReady(faulty("hang-after-close", Duration.ofSeconds(2), null)); + assertEquals("deadline", reason(result), result.toString()); + assertTrue(since(start).compareTo(Duration.ofSeconds(5)) < 0, "returned after " + since(start)); + assertEquals("SIGKILL", result.signal()); + assertNull(result.exitStatus()); + } + + @Test + void slowCleanExitSucceeds() { + long start = System.nanoTime(); + InvocationResult result = invokeReady(faulty("slow-exit", Duration.ofSeconds(10), null)); + assertTrue(result.ok() && since(start).compareTo(Duration.ofMillis(1500)) >= 0, result + " after " + since(start)); + } + + @Test + void cleanupAfterAnEarlyErrorIsNotHeldByAnEscapedDescendant() { + // The worker sends a malformed hello and exits; its daemonized + // descendant keeps stderr open, which the host does not wait for. + long start = System.nanoTime(); + InvocationResult result = invokeReady(faulty("escaped-descendant-unknown-field", Duration.ofSeconds(10), null)); + Duration elapsed = since(start); + kill(descendant("escaped-descendant-unknown-field", result.stderr())); + assertTrue(result.kind() == ResultKind.PROTOCOL_ERROR && result.code().equals("E1601"), result.toString()); + assertTrue(elapsed.compareTo(Duration.ofSeconds(5)) < 0, "cleanup took " + elapsed); + } + + @Test + void descendantsLeftAfterTheWorkerExitsAreNotObserved() throws Exception { + // Unlike the Go and Rust hosts, the JDK reaps the worker and closes its + // pipes at exit, so a descendant still holding stderr is not seen: the + // result follows the worker's own output and exit status. + for (String mode : List.of("stderr-descendant", "escaped-descendant")) { + long start = System.nanoTime(); + InvocationResult result = invokeReady(faulty(mode, Duration.ofSeconds(10), null)); + long pid = descendant(mode, result.stderr()); + try { + assertTrue(result.ok() && Integer.valueOf(0).equals(result.exitStatus()), mode + ": " + result); + assertTrue(since(start).compareTo(Duration.ofSeconds(5)) < 0, mode + ": returned after " + since(start)); + assertTrue(alive(pid), mode + ": the descendant was stopped after all; update LIMITS.md"); + } finally { + kill(pid); + } + } + InvocationResult failed = invokeReady(faulty("stderr-descendant-nonzero", Duration.ofSeconds(10), null)); + kill(descendant("stderr-descendant-nonzero", failed.stderr())); + assertTrue(failed.kind() == ResultKind.CARRIER_LOSS && failed.code().equals("E1611"), failed.toString()); + assertEquals(74, failed.exitStatus()); + } +} diff --git a/java/src/test/java/org/jacquardlang/host/ReadmeTest.java b/java/src/test/java/org/jacquardlang/host/ReadmeTest.java new file mode 100644 index 0000000..ad31e0c --- /dev/null +++ b/java/src/test/java/org/jacquardlang/host/ReadmeTest.java @@ -0,0 +1,28 @@ +package org.jacquardlang.host; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +/** + * The README's example is the compiled test source {@code readme/Echo.java} + * (which LiveTest runs against Core), minus its package line. + */ +class ReadmeTest { + @Test + void theReadmeExampleIsTheCompiledOne() throws Exception { + String readme = Files.readString(Path.of("README.md"), StandardCharsets.UTF_8); + int start = readme.indexOf("```java\n"); + assertTrue(start >= 0, "README.md has no java example"); + int end = readme.indexOf("```", start + 8); + String example = readme.substring(start + 8, end); + String source = Files.readString(Path.of("src/test/java/org/jacquardlang/host/readme/Echo.java"), StandardCharsets.UTF_8); + String body = source.substring(source.indexOf('\n', source.indexOf("package ")) + 1).stripLeading(); + assertEquals(body, example, "README.md's example differs from src/test/java/org/jacquardlang/host/readme/Echo.java"); + } +} diff --git a/java/src/test/java/org/jacquardlang/host/SetupProbe.java b/java/src/test/java/org/jacquardlang/host/SetupProbe.java new file mode 100644 index 0000000..9275e49 --- /dev/null +++ b/java/src/test/java/org/jacquardlang/host/SetupProbe.java @@ -0,0 +1,51 @@ +package org.jacquardlang.host; + +import java.time.Duration; +import java.util.List; + +import org.jacquardlang.host.carrier.Cancel; +import org.jacquardlang.host.carrier.Options; + +/** + * Runs one shutdown probe in its own JVM, so {@link SetupTest} can put a slow + * {@code mkfifo} first on this JVM's {@code PATH}. Prints the reason, the + * elapsed milliseconds, whether the caller is still interrupted, and how + * many of its child processes (the hanging {@code mkfifo}) are still alive. + */ +public final class SetupProbe { + private SetupProbe() {} + + public static void main(String[] args) throws Exception { + String mode = args[0]; + Cancel cancel = new Cancel(); + Thread caller = Thread.currentThread(); + if (!mode.equals("deadline")) { + Thread.ofPlatform().daemon().start(() -> { + try { + Thread.sleep(100); + } catch (InterruptedException ignored) { + return; + } + if (mode.equals("interrupt")) { + caller.interrupt(); + } else { + cancel.cancel(); + } + }); + } + Duration deadline = mode.equals("deadline") ? Duration.ofMillis(500) : Duration.ofSeconds(20); + // The worker never starts: setup must finish or stop first. + Options options = new Options(List.of("/nonexistent/jacquard"), null, deadline); + long start = System.nanoTime(); + InvocationResult result = new Host("unused", new HostOptions(options, null)).shutdownProbe(cancel); + long elapsed = (System.nanoTime() - start) / 1_000_000; + boolean interrupted = Thread.interrupted(); + // A forcible kill is asynchronous; allow it a moment to land. + long settle = System.nanoTime() + 2_000_000_000L; + long children; + while ((children = ProcessHandle.current().children().filter(ProcessHandle::isAlive).count()) > 0 && System.nanoTime() < settle) { + Thread.sleep(10); + } + System.out.println(Support.reason(result) + " " + elapsed + " " + interrupted + " " + children); + } +} diff --git a/java/src/test/java/org/jacquardlang/host/SetupTest.java b/java/src/test/java/org/jacquardlang/host/SetupTest.java new file mode 100644 index 0000000..e6aa700 --- /dev/null +++ b/java/src/test/java/org/jacquardlang/host/SetupTest.java @@ -0,0 +1,92 @@ +package org.jacquardlang.host; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +/** + * Cancellation, interruption, and expiry while the output FIFOs are being + * made. A {@code mkfifo} that never finishes stands in for a slow one; the + * call must still end promptly and report why, not a spawn failure. + */ +@DisabledOnOs(OS.WINDOWS) +class SetupTest { + @TempDir + Path scratch; + + /** + * Runs {@link SetupProbe} with a hanging {@code mkfifo}; returns its + * reason, milliseconds, and interrupt flag, having checked that the + * {@code mkfifo} was killed. + */ + private String[] probe(String mode) throws Exception { + Path bin = Files.createDirectory(scratch.resolve("bin")); + Path shim = bin.resolve("mkfifo"); + Files.writeString(shim, "#!/bin/sh\nexec sleep 30\n"); + Files.setPosixFilePermissions(shim, PosixFilePermissions.fromString("rwx------")); + Path tmp = Files.createDirectory(scratch.resolve("tmp")); + String java = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + String classpath = Support.location(SetupProbe.class) + File.pathSeparator + Support.location(Host.class); + List command = new ArrayList<>(List.of(java, "-Xshare:auto", "-XX:TieredStopAtLevel=1", "-XX:+UseSerialGC", + "-Xmx64m", "-Djava.io.tmpdir=" + tmp, "-cp", classpath, SetupProbe.class.getName(), mode)); + ProcessBuilder builder = new ProcessBuilder(command).redirectErrorStream(true).redirectOutput(scratch.resolve("out").toFile()); + builder.environment().put("PATH", bin + File.pathSeparator + System.getenv("PATH")); + Process process = builder.start(); + try { + assertTrue(process.waitFor(60, TimeUnit.SECONDS), "probe did not finish"); + } finally { + process.toHandle().descendants().forEach(ProcessHandle::destroyForcibly); + process.destroyForcibly(); + } + String output = Files.readString(scratch.resolve("out"), StandardCharsets.UTF_8).strip(); + assertEquals(0, process.exitValue(), output); + try (Stream left = Files.list(tmp)) { + assertEquals(List.of(), left.toList(), "setup left files behind"); + } + String[] fields = output.lines().reduce((first, second) -> second).orElse("").split(" "); + assertEquals(4, fields.length, output); + assertEquals("0", fields[3], "mkfifo was left running"); + return fields; + } + + private void assertPrompt(String[] fields, long limit) { + long elapsed = Long.parseLong(fields[1]); + assertTrue(elapsed < limit, "returned after " + elapsed + " ms"); + } + + @Test + void cancellationDuringSetupEndsTheCallAsAborted() throws Exception { + String[] fields = probe("cancel"); + assertEquals("aborted", fields[0]); + assertPrompt(fields, 2000); + } + + @Test + void interruptionDuringSetupEndsTheCallAsAbortedAndKeepsTheInterrupt() throws Exception { + String[] fields = probe("interrupt"); + assertEquals("aborted", fields[0]); + assertEquals("true", fields[2]); + assertPrompt(fields, 2000); + } + + @Test + void expiryDuringSetupIsReportedAsTheDeadline() throws Exception { + String[] fields = probe("deadline"); + assertEquals("deadline", fields[0]); + assertPrompt(fields, 2000); + } +} diff --git a/java/src/test/java/org/jacquardlang/host/StandIn.java b/java/src/test/java/org/jacquardlang/host/StandIn.java new file mode 100644 index 0000000..c1eefb7 --- /dev/null +++ b/java/src/test/java/org/jacquardlang/host/StandIn.java @@ -0,0 +1,329 @@ +package org.jacquardlang.host; + +import java.io.DataInputStream; +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import org.jacquardlang.host.json.Json; +import org.jacquardlang.host.json.JsonCodec; +import org.jacquardlang.host.kit.Kit; +import org.jacquardlang.host.kit.Step; +import org.jacquardlang.host.kit.Transcript; + +/** + * A recorded or faulty worker for the offline tests. It implements no Core + * semantics and is not part of the adapter's interface. The descendant modes + * use {@code sleep} and {@code sh}, so they are Unix-only. + */ +public final class StandIn { + /** Names the stand-in behaviour. */ + static final String MODE_VAR = "JACQUARD_JAVA_STANDIN"; + /** Names the transcript replayed in {@code replay} mode. */ + static final String CASE_VAR = "JACQUARD_JAVA_STANDIN_CASE"; + + private static final OutputStream OUT = new FileOutputStream(FileDescriptor.out); + private static final OutputStream ERR = new FileOutputStream(FileDescriptor.err); + private static final DataInputStream IN = new DataInputStream(new FileInputStream(FileDescriptor.in)); + + private StandIn() {} + + public static void main(String[] args) throws Exception { + String mode = env(MODE_VAR); + switch (mode) { + case "exit" -> System.exit(0); + case "silent" -> block(); + case "truncated" -> { + OUT.write(new byte[] {0, 0, 0}); + OUT.flush(); + System.exit(0); + } + case "descendant" -> { + // Leave a descendant holding stdout, like a launcher that does not exec. + Process child = spawn(List.of("sleep", "30"), ProcessBuilder.Redirect.INHERIT, ProcessBuilder.Redirect.DISCARD); + report(child.pid()); + System.exit(0); + } + case "child-silent" -> { + // A child holding stdout and stderr while the worker stays alive. + Process child = spawn(List.of("sleep", "30"), ProcessBuilder.Redirect.INHERIT, ProcessBuilder.Redirect.INHERIT); + report(child.pid()); + block(); + } + case "grandchild-silent" -> { + // A grandchild holding the pipes under a shell that waits for it. + spawn(List.of("sh", "-c", "sleep 30 & echo $! >&2; wait"), ProcessBuilder.Redirect.INHERIT, + ProcessBuilder.Redirect.INHERIT); + block(); + } + case "daemon-silent" -> { + // A descendant reparented while the worker is alive: its shell exits at once. + daemonize(">/dev/null"); + block(); + } + case "stderr-descendant", "stderr-descendant-nonzero" -> { + // Answer normally, but leave a child holding only stderr. + Process child = spawn(List.of("sleep", "30"), ProcessBuilder.Redirect.DISCARD, ProcessBuilder.Redirect.INHERIT); + report(child.pid()); + System.exit(fault(kit(), mode.equals("stderr-descendant") ? "ok" : "nonzero")); + } + case "escaped-descendant", "escaped-descendant-unknown-field" -> { + // A daemonized descendant holds stderr; it is not the worker's + // descendant by the time the host could look for it. + daemonize(">/dev/null"); + System.exit(fault(kit(), mode.equals("escaped-descendant") ? "ok" : "unknown-field")); + } + case "stdin-descendant" -> { + // A child holds stdin and never reads; the worker reads the + // selection and exits, so the invoke write can only block. + ProcessBuilder builder = new ProcessBuilder("sleep", "30") + .redirectInput(ProcessBuilder.Redirect.INHERIT) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD); + report(builder.start().pid()); + Kit kit = kit(); + send(kit.transcripts().get(0).coreOutput().get(0)); + readFrame(); + System.exit(0); + } + case "stdin-child-alive" -> { + // A child holds stdin and never reads, and the worker stays + // alive without reading, so the invoke write blocks while the + // worker is still alive at expiry. + ProcessBuilder builder = new ProcessBuilder("sleep", "30") + .redirectInput(ProcessBuilder.Redirect.INHERIT) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD); + report(builder.start().pid()); + Kit kit = kit(); + send(kit.transcripts().get(0).coreOutput().get(0)); + readFrame(); + block(); + } + case "replay" -> System.exit(replay(kit(), env(CASE_VAR))); + case "large-request" -> { + // An effect request whose one argument is a text at the text + // ceiling, so the host spends a while decoding it. + Transcript effect = transcript(kit(), "one-once-effect-success"); + send(effect.coreOutput().get(0)); + readFrame(); + readFrame(); + Map request = new TreeMap<>(effect.coreOutput().get(1)); + request.put("arguments", Json.of(List.of(Json.of(Json.object("kind", Json.of("text"), "value", Json.of("x".repeat(262_144))))))); + send(request); + block(); + } + case "flood-after-terminal" -> { + // The pure recording, then valid frames of about 600 KB each. + replay(kit(), kit().transcripts().get(0).name()); + List numbers = new ArrayList<>(); + for (int i = 0; i < 100_000; i++) { + numbers.add(Json.of(i)); + } + Map padding = Json.object("padding", Json.of(numbers)); + for (int i = 0; i < 300; i++) { + send(padding); + } + } + case "" -> System.exit(2); + default -> System.exit(fault(kit(), mode)); + } + } + + private static String env(String name) { + String value = System.getenv(name); + return value == null ? "" : value; + } + + private static Process spawn(List command, ProcessBuilder.Redirect stdout, ProcessBuilder.Redirect stderr) { + try { + return new ProcessBuilder(command) + .redirectInput(ProcessBuilder.Redirect.from(new java.io.File("/dev/null"))) + .redirectOutput(stdout) + .redirectError(stderr) + .start(); + } catch (IOException error) { + System.exit(3); + return null; + } + } + + /** Starts {@code sleep 30} through a shell that exits at once, reporting its pid on stderr. */ + private static void daemonize(String stdout) throws InterruptedException { + Process shell = spawn(List.of("sh", "-c", "sleep 30 &2"), + ProcessBuilder.Redirect.INHERIT, ProcessBuilder.Redirect.INHERIT); + shell.waitFor(); + } + + private static void report(long pid) throws IOException { + ERR.write(Long.toString(pid).getBytes(StandardCharsets.US_ASCII)); + ERR.flush(); + } + + private static Kit kit() { + try { + return Kit.load(); + } catch (Exception error) { + System.exit(3); + return null; + } + } + + private static void send(Map frame) { + try { + byte[] payload = JsonCodec.stringify(Json.of(frame), 256).getBytes(StandardCharsets.UTF_8); + OUT.write(ByteBuffer.allocate(4 + payload.length).putInt(payload.length).put(payload).array()); + OUT.flush(); + } catch (Exception error) { + // The host may have closed the pipe. + } + } + + private static boolean discard(long count) { + try { + // Read rather than skip: FileInputStream.skip seeks, which a pipe refuses. + byte[] chunk = new byte[8192]; + for (long left = count; left > 0; ) { + int n = IN.read(chunk, 0, (int) Math.min(chunk.length, left)); + if (n < 0) { + return false; + } + left -= n; + } + return true; + } catch (IOException error) { + return false; + } + } + + private static boolean readFrame() { + try { + int length = IN.readInt(); + return discard(Integer.toUnsignedLong(length)); + } catch (IOException error) { + return false; + } + } + + /** The named recording, or null. */ + private static Transcript transcript(Kit kit, String name) { + Transcript transcript = null; + for (Transcript candidate : kit.transcripts()) { + if (candidate.name().equals(name)) { + transcript = candidate; + } + } + return transcript; + } + + private static int replay(Kit kit, String name) { + Transcript transcript = transcript(kit, name); + if (transcript == null) { + return 3; + } + int index = 0; + boolean raw = false; + for (Step step : transcript.hostInput()) { + if (step.direction().equals("core_to_host")) { + Map frame = transcript.coreOutput().get(index++); + send(frame); + Json kind = frame.get("kind"); + String sent = kind == null ? "" : kind.asString(); + if ("outcome".equals(sent) || "fatal".equals(sent) || "shutdown_ack".equals(sent)) { + return (int) transcript.exitStatus(); + } + } else if (step.rawHex() != null) { + discard(step.rawHex().length() / 2); + raw = true; + } else if (!step.afterTerminal() && !raw) { + readFrame(); + } + } + return (int) transcript.exitStatus(); + } + + private static void block() throws InterruptedException { + while (true) { + Thread.sleep(3_600_000); + } + } + + private static int fault(Kit kit, String mode) throws Exception { + Transcript pure = kit.transcripts().get(0); + Map hello = new TreeMap<>(pure.coreOutput().get(0)); + Map outcome = pure.coreOutput().get(1); + if (mode.equals("stderr")) { + byte[] noise = new byte[2 * 1024 * 1024]; + Arrays.fill(noise, (byte) 'x'); + ERR.write(noise); + ERR.flush(); + } + if (mode.equals("unknown-field")) { + hello.put("extra", Json.of(1)); + } + send(hello); + if (mode.equals("oversized-early")) { + // Without waiting for host_select, send a frame larger than the + // selected max_frame_bytes but within the hard limit. + send(Json.object("padding", Json.of("x".repeat(4096)))); + } + if (mode.equals("no-read")) { + block(); + } + int count = 1; + while (readFrame()) { + if (count == 2) { + if (mode.equals("invalid-hash")) { + outcome = withCallable(outcome, "A".repeat(64)); + } + send(outcome); + if (mode.equals("extra")) { + send(outcome); + } + if (mode.equals("stall")) { + block(); + } + closeStdout(); + if (mode.equals("slow-exit")) { + Thread.sleep(1500); + } + if (mode.equals("hang-after-close")) { + block(); + } + return mode.equals("nonzero") ? 74 : 0; + } + count++; + } + return 0; + } + + private static Map withCallable(Map outcome, String callable) { + Map evidence = new TreeMap<>(outcome.get("evidence").asObject()); + Map core = new TreeMap<>(evidence.get("core").asObject()); + Map target = new TreeMap<>(core.get("target").asObject()); + target.put("callable", Json.of(callable)); + core.put("target", Json.of(target)); + evidence.put("core", Json.of(core)); + Map changed = new TreeMap<>(outcome); + changed.put("evidence", Json.of(evidence)); + return changed; + } + + /** + * Closes descriptor 1 so the host's pipe sees end of stream before exit: + * the JDK replaces a closed standard descriptor with /dev/null. + */ + private static void closeStdout() throws IOException { + OUT.flush(); + OUT.close(); + } +} diff --git a/java/src/test/java/org/jacquardlang/host/Support.java b/java/src/test/java/org/jacquardlang/host/Support.java new file mode 100644 index 0000000..d05b9ed --- /dev/null +++ b/java/src/test/java/org/jacquardlang/host/Support.java @@ -0,0 +1,148 @@ +package org.jacquardlang.host; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.File; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.jacquardlang.host.carrier.Cancel; +import org.jacquardlang.host.carrier.Options; +import org.jacquardlang.host.json.Json; +import org.jacquardlang.host.json.JsonCodec; +import org.jacquardlang.host.kit.Kit; +import org.jacquardlang.host.protocol.BoundaryType; +import org.jacquardlang.host.protocol.Hash; +import org.jacquardlang.host.protocol.Interface; +import org.jacquardlang.host.protocol.Limits; + +/** Shared helpers for the tests. */ +final class Support { + private Support() {} + + /** Runs code expected to fail with a protocol refusal. */ + @FunctionalInterface + interface Refusable { + void run() throws Exception; + } + + static void code(String expected, String context, Refusable body) { + try { + body.run(); + } catch (ProtocolException error) { + assertEquals(expected, error.code(), context + ": " + error.getMessage()); + return; + } catch (Exception error) { + fail(context + ": expected " + expected + ", got " + error); + } + fail(context + ": expected " + expected + ", got success"); + } + + static Json json(String text) { + try { + return JsonCodec.decode(text.getBytes(StandardCharsets.UTF_8), 64); + } catch (ProtocolException error) { + throw new AssertionError(error); + } + } + + static Hash hash(char c) { + return new Hash(String.valueOf(c).repeat(64)); + } + + static Interface unit() { + return new Interface(List.of(), new BoundaryType.Tuple(List.of()), List.of()); + } + + static Kit kit() { + try { + return Kit.load(); + } catch (Exception error) { + throw new AssertionError(error); + } + } + + static String location(Class type) { + try { + return Path.of(type.getProtectionDomain().getCodeSource().getLocation().toURI()).toString(); + } catch (URISyntaxException error) { + throw new AssertionError(error); + } + } + + /** The stand-in worker command: this JVM, tuned for a quick start. */ + static List standinCommand() { + String java = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + String classpath = location(StandIn.class) + File.pathSeparator + location(Host.class); + return List.of(java, "-Xshare:auto", "-XX:TieredStopAtLevel=1", "-XX:+UseSerialGC", "-Xmx64m", "-XX:-UsePerfData", + "-cp", classpath, StandIn.class.getName()); + } + + static Options standin(String mode, String transcript, Duration deadline) { + Map env = new HashMap<>(System.getenv()); + env.put(StandIn.MODE_VAR, mode); + env.put(StandIn.CASE_VAR, transcript); + return new Options(standinCommand(), env, deadline); + } + + static Host faulty(String mode, Duration deadline, Limits limits) { + return new Host("unused", new HostOptions(standin(mode, "", deadline), limits)); + } + + static InvocationResult invokeReady(Host host) { + return host.invoke(kit().identity("kit-ready"), unit(), List.of(), new OperationRegistry(), new Cancel()); + } + + static String reason(InvocationResult result) { + Json reason = result.hostEvidence().get("reason"); + return reason == null ? null : reason.asString(); + } + + /** The pid a stand-in printed on stderr. */ + static long descendant(String mode, byte[] stderr) { + String text = new String(stderr, StandardCharsets.UTF_8).trim(); + try { + return Long.parseLong(text); + } catch (NumberFormatException error) { + throw new AssertionError(mode + ": stand-in stderr " + text); + } + } + + static boolean alive(long pid) { + return ProcessHandle.of(pid).map(ProcessHandle::isAlive).orElse(false); + } + + static void kill(long pid) { + ProcessHandle.of(pid).ifPresent(ProcessHandle::destroyForcibly); + } + + /** Waits up to two seconds for {@code pid} to exit, killing it and failing otherwise. */ + static void dies(String mode, long pid) throws InterruptedException { + long deadline = System.nanoTime() + Duration.ofSeconds(2).toNanos(); + while (alive(pid)) { + if (System.nanoTime() > deadline) { + kill(pid); + fail(mode + ": descendant survived the worker"); + } + Thread.sleep(10); + } + } + + /** Waits up to two seconds for a condition. */ + static boolean eventually(java.util.function.BooleanSupplier condition) throws InterruptedException { + long deadline = System.nanoTime() + Duration.ofSeconds(2).toNanos(); + while (!condition.getAsBoolean()) { + if (System.nanoTime() > deadline) { + return false; + } + Thread.sleep(10); + } + return true; + } +} diff --git a/java/src/test/java/org/jacquardlang/host/carrier/GateTest.java b/java/src/test/java/org/jacquardlang/host/carrier/GateTest.java new file mode 100644 index 0000000..dce0bdf --- /dev/null +++ b/java/src/test/java/org/jacquardlang/host/carrier/GateTest.java @@ -0,0 +1,111 @@ +package org.jacquardlang.host.carrier; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +/** The stdin thread admits each chunk only while input is open and the lifetime is live. */ +class GateTest { + private static final long MINUTE = TimeUnit.MINUTES.toNanos(1); + + private static IOException write(Writer writer, String text) throws Exception { + return writer.submit(text.getBytes(StandardCharsets.UTF_8)).get(5, TimeUnit.SECONDS); + } + + @Test + void aClosedGateAdmitsNoWrite() throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Gate gate = new Gate(System.nanoTime() + MINUTE, new Cancel(), Thread.currentThread()); + Writer writer = new Writer(out, gate); + writer.start(); + assertNull(write(writer, "before")); + gate.close(); + IOException refused = write(writer, "after"); + assertNotNull(refused); + assertEquals("worker input closed by the host", refused.getMessage()); + assertTrue(writer.join(TimeUnit.SECONDS.toNanos(5)), "the stdin thread did not stop after a refused write"); + assertEquals("before", out.toString(StandardCharsets.UTF_8)); + // A stopped thread accepts no further job. + assertNull(writer.submit(new byte[] {1})); + } + + @Test + void noWriteStartsAfterTheDeadlineOrCancellation() throws Exception { + for (boolean cancelled : new boolean[] {false, true}) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Cancel cancel = new Cancel(); + if (cancelled) { + cancel.cancel(); + } + Gate gate = new Gate(cancelled ? System.nanoTime() + MINUTE : System.nanoTime(), cancel, Thread.currentThread()); + Writer writer = new Writer(out, gate); + writer.start(); + assertNotNull(write(writer, "late")); + assertEquals(0, out.size()); + assertTrue(writer.join(TimeUnit.SECONDS.toNanos(5))); + } + } + + @Test + void anInterruptedCallerStopsTheGate() throws Exception { + Thread caller = Thread.ofPlatform().unstarted(() -> {}); + Gate gate = new Gate(System.nanoTime() + MINUTE, new Cancel(), Thread.currentThread()); + assertTrue(gate.admits()); + Thread.currentThread().interrupt(); + try { + assertTrue(gate.stopped()); + assertFalse(gate.admits()); + } finally { + Thread.interrupted(); + } + assertTrue(gate.admits()); + assertFalse(new Gate(System.nanoTime() + MINUTE, new Cancel(), caller).stopped()); + } + + @Test + void aGateClosedDuringAWriteStopsBeforeTheNextChunk() throws Exception { + Gate gate = new Gate(System.nanoTime() + MINUTE, new Cancel(), Thread.currentThread()); + ByteArrayOutputStream out = new ByteArrayOutputStream() { + @Override + public synchronized void write(byte[] bytes, int offset, int length) { + super.write(bytes, offset, length); + // Close as soon as the first chunk lands, as expiry would. + gate.close(); + } + }; + Writer writer = new Writer(out, gate); + writer.start(); + byte[] frame = new byte[3 * Writer.CHUNK]; + IOException refused = writer.submit(frame).get(5, TimeUnit.SECONDS); + assertNotNull(refused); + assertEquals(Writer.CHUNK, out.size()); + assertArrayEquals(new byte[Writer.CHUNK], out.toByteArray()); + } + + @Test + void closingStopsAnIdleThreadAndClosesTheStream() throws Exception { + boolean[] closed = {false}; + ByteArrayOutputStream out = new ByteArrayOutputStream() { + @Override + public void close() { + closed[0] = true; + } + }; + Writer writer = new Writer(out, new Gate(System.nanoTime() + MINUTE, new Cancel(), Thread.currentThread())); + writer.start(); + writer.close(); + assertTrue(writer.join(TimeUnit.SECONDS.toNanos(5))); + assertTrue(closed[0]); + assertFalse(writer.isAlive()); + } +} diff --git a/java/src/test/java/org/jacquardlang/host/readme/Echo.java b/java/src/test/java/org/jacquardlang/host/readme/Echo.java new file mode 100644 index 0000000..0233644 --- /dev/null +++ b/java/src/test/java/org/jacquardlang/host/readme/Echo.java @@ -0,0 +1,36 @@ +package org.jacquardlang.host.readme; + +import java.time.Duration; +import java.util.List; + +import org.jacquardlang.host.HandlerException; +import org.jacquardlang.host.Host; +import org.jacquardlang.host.HostOptions; +import org.jacquardlang.host.InvocationException; +import org.jacquardlang.host.OperationRegistry; +import org.jacquardlang.host.carrier.Cancel; +import org.jacquardlang.host.carrier.Options; +import org.jacquardlang.host.protocol.BoundaryType; +import org.jacquardlang.host.protocol.Hash; +import org.jacquardlang.host.protocol.Interface; +import org.jacquardlang.host.protocol.Value; + +public final class Echo { + public static Value echo(String store, Hash target, Hash text, Hash world, Hash send) throws InvocationException { + // The command defaults to $JACQUARD_BIN, then `jacquard`. + Options carrier = Options.defaults().withDeadline(Duration.ofSeconds(5)); + Host host = new Host(store, new HostOptions(carrier, null)); + OperationRegistry registry = new OperationRegistry().bind(world, send, (context, args) -> { + if (context.isCancelled()) { + throw HandlerException.completionUnknown("cancelled"); + } + if (args.size() == 1 && args.get(0) instanceof Value.Text input) { + return new Value.Text(input.value() + "!"); + } + throw HandlerException.outsideFailed("expected one text argument"); + }); + BoundaryType textType = new BoundaryType.Nominal(text, List.of()); + Interface spec = new Interface(List.of(textType), textType, List.of(world)); + return host.invoke(target, spec, List.of(new Value.Text("ping")), registry, new Cancel()).valueOrThrow(); + } +} diff --git a/rust/NOTICE b/rust/NOTICE index cccb047..09bc7d2 100644 --- a/rust/NOTICE +++ b/rust/NOTICE @@ -4,9 +4,10 @@ Copyright 2026 Jacquard Host contributors This product includes conformance artifacts from Jacquard Core, https://github.com/jbwinters/jacquard-lang, licensed under Apache License 2.0. The artifacts under src/jacquard_host/data/conformance/host-protocol-v0/, -and their byte-identical copies under go/kit/data/host-protocol-v0/ and -rust/kit/host-protocol-v0/, are copied verbatim from the commits recorded in -docs/COMPATIBILITY.md. +and their byte-identical copies under go/kit/data/host-protocol-v0/, +rust/kit/host-protocol-v0/, and +java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/, are +copied verbatim from the commits recorded in docs/COMPATIBILITY.md. Upstream notice: Jacquard diff --git a/scripts/check_distribution.py b/scripts/check_distribution.py index 13c45e1..977c91f 100644 --- a/scripts/check_distribution.py +++ b/scripts/check_distribution.py @@ -36,7 +36,7 @@ def main() -> None: # Only our freshly built archive is extracted, never an external input. archive.extractall(root / "source", filter="data") source = next((root / "source").iterdir()) - for relative in ("LICENSE", "NOTICE", "go/NOTICE", "rust/NOTICE", "CONTRIBUTING.md", "CHANGELOG.md", "docs/SETUP.md", "scripts/check_examples.py", + for relative in ("LICENSE", "NOTICE", "go/NOTICE", "rust/NOTICE", "java/NOTICE", "java/pom.xml", "CONTRIBUTING.md", "CHANGELOG.md", "docs/SETUP.md", "scripts/check_examples.py", "examples/python_sdk/app.py", "examples/python_sdk/exercise.py"): assert (source / relative).is_file(), f"sdist missing {relative}" for project in ("quickstart", "triage", "planner", "fulfillment"): diff --git a/scripts/core_pin.py b/scripts/core_pin.py index de35377..3abf994 100644 --- a/scripts/core_pin.py +++ b/scripts/core_pin.py @@ -38,9 +38,11 @@ PIN = REPO / "core-pin.json" SCHEMA = "jacquard-host-core-pin-v1" KIT_DIR = REPO / "src" / "jacquard_host" / "data" / "conformance" / "host-protocol-v0" -# Go and Rust embed their own byte-identical copies; go:embed cannot reach -# outside the module, and a published crate contains only its own directory. -KIT_DIRS = (KIT_DIR, REPO / "go" / "kit" / "data" / "host-protocol-v0", REPO / "rust" / "kit" / "host-protocol-v0") +# Go, Rust, and Java embed their own byte-identical copies; go:embed cannot +# reach outside the module, a published crate contains only its own directory, +# and a jar carries its kit as class-path resources. +KIT_DIRS = (KIT_DIR, REPO / "go" / "kit" / "data" / "host-protocol-v0", REPO / "rust" / "kit" / "host-protocol-v0", + REPO / "java" / "src" / "main" / "resources" / "org" / "jacquardlang" / "host" / "kit" / "host-protocol-v0") KIT_FILES = ("kit/kit.json", "kit/transcripts.json", "kit/fixtures.jac", "vectors.json") # Copied with the kit but not part of the digest pin; prose may be refreshed. KIT_EXTRAS = ("kit/README.md",) @@ -230,11 +232,43 @@ def render_rust(pin: dict) -> str: """ +def render_java(pin: dict) -> str: + release, kit = pin["release"], pin["kit"] + digests = ",\n".join(f' Map.entry("{name}", "{kit["digests"][name]}")' for name in KIT_FILES) + pending = ", ".join(f'"{name}"' for name in kit["pending_decisions"]) + return f"""// Generated from core-pin.json by scripts/core_pin.py; do not edit by hand. + +package org.jacquardlang.host; + +import java.util.List; +import java.util.Map; + +/** The Jacquard Core release and host protocol this adapter is pinned to. */ +public final class Compat {{ + private Compat() {{}} + + public static final String PROTOCOL = "{pin["protocol"]}"; + public static final String CARRIER = "{pin["carrier"]}"; + public static final String CORE_VERSION = "{release["version"]}"; + public static final String CORE_TAG = "{release["tag"]}"; + public static final String CORE_COMMIT = "{release["commit"]}"; + + /** SHA-256 digests of the pinned conformance kit files. */ + public static final List> KIT_DIGESTS = List.of( +{digests}); + + /** Decisions Core recorded as pending; replay reproduces them. */ + public static final List PENDING_DECISIONS = List.of({pending}); +}} +""" + + GENERATED = { "src/jacquard_host/compat.py": render_python, "typescript/src/compat.ts": render_typescript, "go/protocol/compat.go": render_go, "rust/src/compat.rs": render_rust, + "java/src/main/java/org/jacquardlang/host/Compat.java": render_java, } BLOCKS = { "docs/COMPATIBILITY.md": render_compatibility, diff --git a/tests/test_core_pin.py b/tests/test_core_pin.py index 52926a0..3032389 100644 --- a/tests/test_core_pin.py +++ b/tests/test_core_pin.py @@ -67,6 +67,13 @@ def test_hand_edits_and_kit_tampering_are_reported(self) -> None: found = core_pin.problems(self.pin, root) self.assertTrue(any("rust/kit" in item and "vectors.json" in item for item in found), found) self.assertTrue(any("compat.rs" in item for item in found), found) + java_kit = root / core_pin.KIT_DIRS[3].relative_to(REPO) / "kit/transcripts.json" + java_kit.write_bytes(java_kit.read_bytes().replace(b"jacquard-host-v0", b"jacquard-host-v9", 1)) + compat_java = root / "java/src/main/java/org/jacquardlang/host/Compat.java" + compat_java.write_text(compat_java.read_text().replace(self.pin["kit"]["pending_decisions"][0], "drifted")) + found = core_pin.problems(self.pin, root) + self.assertTrue(any("java/src/main/resources" in item and "transcripts.json" in item for item in found), found) + self.assertTrue(any("Compat.java" in item for item in found), found) def test_sync_rewrites_only_generated_regions(self) -> None: root = self.copy() @@ -211,6 +218,9 @@ def setUp(self) -> None: self.rust_kit = Path(tempfile.mkdtemp(prefix="core-rust-kit-")) self.addCleanup(shutil.rmtree, self.rust_kit) shutil.copytree(core_pin.KIT_DIR, self.rust_kit, dirs_exist_ok=True) + self.java_kit = Path(tempfile.mkdtemp(prefix="core-java-kit-")) + self.addCleanup(shutil.rmtree, self.java_kit) + shutil.copytree(core_pin.KIT_DIR, self.java_kit, dirs_exist_ok=True) self.files = {name: (core_pin.KIT_DIR / name).read_bytes() for name in (*core_pin.KIT_FILES, *core_pin.KIT_EXTRAS)} def run_update(self, manifest_changes: dict, assets: dict) -> dict: @@ -221,7 +231,7 @@ def run_update(self, manifest_changes: dict, assets: dict) -> dict: with patch.object(core_pin, "tag_commit", return_value="e" * 40), \ patch.object(core_pin, "kit_file", side_effect=lambda repo, commit, path, checkout: files[path.split("/", 2)[2]]), \ patch.object(core_pin, "release_assets", return_value=assets) as self.fetched: - return core_pin.update(self.pin, "jacquard-core-9.9.9", None, (self.kit, self.go_kit, self.rust_kit)) + return core_pin.update(self.pin, "jacquard-core-9.9.9", None, (self.kit, self.go_kit, self.rust_kit, self.java_kit)) def test_a_release_is_adopted_with_its_kit_and_assets(self) -> None: assets = {"linux-x86_64": {"name": "jacquard-linux-x86_64.tar.gz", "sha256": "c" * 64}} @@ -232,6 +242,7 @@ def test_a_release_is_adopted_with_its_kit_and_assets(self) -> None: self.assertEqual(new["kit"]["digests"]["kit/kit.json"], core_pin.sha256(written)) self.assertEqual((self.go_kit / "kit/kit.json").read_bytes(), written) self.assertEqual((self.rust_kit / "kit/kit.json").read_bytes(), written) + self.assertEqual((self.java_kit / "kit/kit.json").read_bytes(), written) self.assertEqual([path.name for path in self.kit.rglob(".*.core-pin")], []) def test_a_failed_replacement_restores_every_kit_file(self) -> None: