commit 0e2e548fae8f21c43454f3a0de6c7dd12295cc7f
parent 8501bcd20465d6f883660fe49239006875a7e945
Author: James Eversole <james@eversole.co>
Date: Sat, 24 Oct 2026 10:15:25 -0500
Expose Haskell runtime to native bindings and add embedding experiments
Diffstat:
54 files changed, 4109 insertions(+), 1143 deletions(-)
diff --git a/demos/python-filecheck/README.md b/demos/python-filecheck/README.md
@@ -0,0 +1,140 @@
+# Python file-integrity application
+
+`tricu-filecheck` records and verifies SHA-256 manifests. Python provides the CLI,
+JSON schema and complete-file manifest publication; a packaged tricu worker
+performs file inspection, reads and incremental hashing through native IO-v2.
+One Python `FileChecker` instance loads its worker once and reuses its context.
+
+## Build and run
+
+```sh
+nix build .#tricu-filecheck --no-link
+APP=$(nix path-info .#tricu-filecheck)
+WORK=$(mktemp -d)
+printf 'hello\n' > "$WORK/hello.txt"
+
+# Both commands interpret file paths relative to the manifest's directory.
+"$APP/bin/tricu-filecheck" record "$WORK/checks.json" hello.txt
+"$APP/bin/tricu-filecheck" verify "$WORK/checks.json"
+
+printf 'changed\n' > "$WORK/hello.txt"
+"$APP/bin/tricu-filecheck" verify "$WORK/checks.json"
+# Exit 1, with status "changed" and expected/actual digests in JSON.
+```
+
+Each invocation prints one JSON report. Exit codes are:
+
+- `0`: every entry succeeded or matched; recording published the manifest.
+- `1`: verification found changed contents, with no file-operation errors.
+- `2`: file IO, budget, manifest, package or embedding failure. Argparse usage
+ errors also exit 2 and print usage to stderr.
+
+File-operation errors include the IO-v2 category, operation, detail and progress.
+Verification continues through ordinary per-file failures and distinguishes them
+from changed contents. Recording publishes only when every requested file succeeds.
+Existing manifests remain intact: publication uses a fully written, synchronized
+staging file and a no-replace hard link, then removes the staging name. The
+manifest filesystem must support hard links. Directory durability across crashes
+is subject to the host filesystem; the CLI does not synchronize the directory.
+To record a replacement, choose a new manifest name or explicitly remove the old
+manifest first. The tool never edits input files.
+
+`--max-bytes N` selects the per-file limit (default 64 MiB; supported range
+0..2^63-1). Empty files work with a zero limit. The worker prechecks file type/size,
+opens a scoped reader and SHA-256 context, and consumes chunks of at most 4096
+bytes. It checks the opened handle's size and the observed read position. A
+concurrently growing file can consume one excess chunk before rejection. The
+byte count comes from the handle, avoiding a retained source-level accumulator.
+The limit bounds file consumption, not heap use or execution time.
+
+## Manifest contract
+
+```json
+{
+ "schema": "tricu.filecheck.v1",
+ "algorithm": "sha256",
+ "files": [
+ {
+ "path": "hello.txt",
+ "bytes": 6,
+ "sha256": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
+ }
+ ]
+}
+```
+
+The UTF-8 JSON document is at most 1 MiB and contains 1..4096 entries. Field sets
+are exact, keys and path strings are unique, byte counts are integers in
+0..2^63-1, and hashes are 64 lowercase hexadecimal characters. Paths are nonempty,
+relative Unicode strings without NULs. Spaces, newlines and Unicode names are
+preserved. Both record arguments and stored paths resolve relative to the
+manifest's parent, independently of the process working directory. Lexically
+different paths may name the same file and receive separate entries.
+
+These are observations under OS process authority, not authenticated manifests,
+snapshots or filesystem confinement. Symlinks follow normal OS resolution; `..`
+can reach outside the manifest directory. Moving a manifest changes its relative
+root. A file can change while read, producing a digest of the bytes observed;
+metadata checks do not pin later path operations. Use trusted manifests and
+stable inputs where consistency matters. Permission checks run as the embedding
+process. External supervision is required for hard time/memory limits and races
+that substitute blocking special files after preflight.
+
+## Embed the application in Python
+
+Select the `tricu-filecheck` Python package in a Nix Python environment via
+`python3.withPackages`, then:
+
+```python
+from tricu_filecheck import FileChecker, FileProblem
+
+with FileChecker() as checker:
+ for path in ["one.bin", "two.bin"]:
+ try:
+ digest = checker.hash_file(path, max_bytes=1024 * 1024)
+ print(path, digest.bytes, digest.sha256)
+ except FileProblem as error:
+ print(path, error.problem.category, error.problem.operation)
+```
+
+API paths follow the caller's working directory; the manifest-relative convention
+belongs to the CLI. `FileChecker` is a sequential, synchronous session. Each call
+creates a Python handle scope; arguments, application, task results and traversal
+handles are released after decoding, while the worker remains rooted for the
+next call. Returned dataclasses contain only Python integers and strings. Ordinary
+IO failures raise `FileProblem` with a structured `Problem`. Unexpected task
+outcomes, cleanup diagnostics or result shapes raise `ProtocolError`; the native
+binding retains its own `TricuError` boundary failures.
+
+## Distribution and validation
+
+Nix compiles `worker.tri` with the packaged stable compiler at build time. The
+Python wheel contains `worker.arboricx` and a generated SHA-256 pin. Startup checks
+the pin and the native bundle verifier before execution. Runtime needs the Python
+packages and Haskell shared-library closure. It reads neither tricu source nor
+compiler data and creates no compiler cache. Missing/corrupt worker data fails
+without source lookup or compilation fallback. Package integrity still depends
+on the distributor: a checksum stored in the same package is not a signature.
+
+```sh
+nix build .#tricu-filecheck-test --no-link
+CHECK=$(nix path-info .#tricu-filecheck-test)
+cat "$CHECK/report.json"
+```
+
+Acceptance runs the installed CLI from an unrelated read-only working directory,
+with an empty read-only home, no native-library override and no runtime tools on PATH. It
+checks the runtime closure excludes the compiler executable and build source,
+compares full digests/byte counts against independent Python hashing, and covers
+Unicode/newline paths, symlinks, same-length mutations, missing/unreadable files,
+non-files, budget boundaries, strict manifests, no-replace publication and
+missing/corrupt package data.
+
+A persistent Python session performs eight warmups, 128 successful and 128
+expected-error calls, then hashes a 1-MiB file. It verifies the same worker/context
+remain loaded and descriptor counts stay stable. Python file opens are prohibited
+during the repeated successful hash calls. Reports include peak RSS and elapsed
+time; a separate process guard enforces a 512-MiB sampled RSS limit and a
+90-second wall limit. These are bounded acceptance observations, not universal
+resource guarantees. This application check is opt-in; Python ownership-scope
+controls run in the embedding library's basic gate.
diff --git a/demos/python-filecheck/pyproject.toml b/demos/python-filecheck/pyproject.toml
@@ -0,0 +1,19 @@
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "tricu-filecheck"
+version = "2026.10.24"
+description = "File integrity manifests with a tricu IO worker embedded in Python"
+requires-python = ">=3.10"
+dependencies = ["tricu-runtime"]
+
+[project.scripts]
+tricu-filecheck = "tricu_filecheck.cli:main"
+
+[tool.setuptools]
+packages = ["tricu_filecheck"]
+
+[tool.setuptools.package-data]
+tricu_filecheck = ["worker.arboricx"]
diff --git a/demos/python-filecheck/tricu_filecheck/__init__.py b/demos/python-filecheck/tricu_filecheck/__init__.py
@@ -0,0 +1,92 @@
+"""File integrity work in tricu, with Python-owned manifests and presentation."""
+from dataclasses import dataclass
+import hashlib
+from importlib.resources import files
+import os
+import re
+
+from tricu import Library
+from ._bundle_pin import BUNDLE_SHA256
+
+DEFAULT_MAX_BYTES = 64 * 1024 * 1024
+
+
+@dataclass(frozen=True)
+class FileDigest:
+ bytes: int
+ sha256: str
+
+
+@dataclass(frozen=True)
+class Problem:
+ category: str
+ operation: str
+ detail: str
+ progress: str
+
+
+class FileProblem(Exception):
+ def __init__(self, problem):
+ self.problem = problem
+ super().__init__(f'{problem.category}: {problem.operation}: {problem.detail}')
+
+
+class ProtocolError(RuntimeError):
+ pass
+
+
+class FileChecker:
+ """One loaded bundle/context, reused for sequential calls until close()."""
+ def __init__(self):
+ bundle = files(__package__).joinpath('worker.arboricx').read_bytes()
+ if hashlib.sha256(bundle).hexdigest() != BUNDLE_SHA256:
+ raise ProtocolError('packaged worker bundle checksum mismatch')
+ self.context = Library().context()
+ try:
+ self.worker = self.context.load_bundle(bundle, 'hashFile')
+ except BaseException:
+ self.context.close()
+ raise
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_):
+ self.close()
+
+ def close(self):
+ self.context.close()
+
+ def _list(self, scope, value, count):
+ result = []
+ for _ in range(count):
+ if self.context.kind(value) != 2:
+ raise ProtocolError('worker returned an improper list')
+ result.append(scope.child(value, 0))
+ value = scope.child(value, 1)
+ if self.context.kind(value) != 0:
+ raise ProtocolError('worker returned excess list fields')
+ return result
+
+ def hash_file(self, path, max_bytes=DEFAULT_MAX_BYTES):
+ if type(max_bytes) is not int or not 0 <= max_bytes <= 2**63-1:
+ raise ValueError('max_bytes must be an integer in 0..2**63-1')
+ path = os.fspath(path)
+ if not isinstance(path, str):
+ raise TypeError('path must be Unicode text')
+ with self.context.scope() as roots:
+ program = roots.apply_many(self.worker, roots.string(path), roots.number(max_bytes))
+ outcome, result, cleanup = roots.run_io(program)
+ if outcome != 0 or self.context.kind(cleanup) != 0:
+ # Terminal reasons remain opaque. Ordinary file-operation errors
+ # are Results decoded below, independently of task outcomes.
+ raise ProtocolError(f'worker task outcome {outcome} or cleanup diagnostics')
+ tag, payload = self._list(roots, result, 2)
+ if not self.context.to_bool(tag):
+ fields = self._list(roots, payload, 4)
+ raise FileProblem(Problem(*(self.context.to_string(field) for field in fields)))
+ length, digest = self._list(roots, payload, 2)
+ value = FileDigest(self.context.to_number(length), self.context.to_string(digest))
+ if value.bytes > max_bytes or not re.fullmatch('[0-9a-f]{64}', value.sha256):
+ raise ProtocolError('invalid worker digest result')
+ return value
diff --git a/demos/python-filecheck/tricu_filecheck/cli.py b/demos/python-filecheck/tricu_filecheck/cli.py
@@ -0,0 +1,126 @@
+"""Record/check JSON manifests; file bytes are read and hashed only by tricu."""
+import argparse
+from dataclasses import asdict
+import json
+import os
+from pathlib import Path
+import re
+import sys
+import tempfile
+
+from . import FileChecker, FileProblem, DEFAULT_MAX_BYTES
+
+SCHEMA = 'tricu.filecheck.v1'
+MAX_MANIFEST_BYTES = 1024 * 1024
+MAX_FILES = 4096
+
+
+def path_text(text):
+ if not isinstance(text, str) or not text or '\0' in text or Path(text).is_absolute():
+ raise ValueError('file paths must be nonempty relative Unicode paths without NUL')
+ text.encode('utf8')
+ return text
+
+
+def unique_object(pairs):
+ value = {}
+ for key, item in pairs:
+ if key in value:
+ raise ValueError('duplicate JSON key: '+key)
+ value[key] = item
+ return value
+
+
+def validate_manifest(value):
+ if not isinstance(value, dict) or set(value) != {'schema', 'algorithm', 'files'} or value['schema'] != SCHEMA or value['algorithm'] != 'sha256':
+ raise ValueError('unsupported manifest schema')
+ if not isinstance(value['files'], list) or not 1 <= len(value['files']) <= MAX_FILES:
+ raise ValueError(f'manifest must contain 1..{MAX_FILES} files')
+ seen = set()
+ for entry in value['files']:
+ if not isinstance(entry, dict) or set(entry) != {'path', 'bytes', 'sha256'}:
+ raise ValueError('invalid manifest entry')
+ name = path_text(entry['path'])
+ if name in seen:
+ raise ValueError('duplicate manifest path: '+name)
+ seen.add(name)
+ if type(entry['bytes']) is not int or not 0 <= entry['bytes'] <= 2**63-1:
+ raise ValueError('invalid manifest byte count')
+ if not isinstance(entry['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', entry['sha256']):
+ raise ValueError('invalid manifest SHA-256')
+ return value
+
+
+def load_manifest(path):
+ with path.open('rb') as stream:
+ payload = stream.read(MAX_MANIFEST_BYTES+1)
+ if len(payload) > MAX_MANIFEST_BYTES:
+ raise ValueError('manifest exceeds 1 MiB')
+ return validate_manifest(json.loads(payload.decode('utf8'), object_pairs_hook=unique_object))
+
+
+def publish_manifest(path, manifest):
+ payload = (json.dumps(manifest, ensure_ascii=False, indent=2)+'\n').encode('utf8')
+ if len(payload) > MAX_MANIFEST_BYTES:
+ raise ValueError('manifest exceeds 1 MiB')
+ # Complete file publication with no replacement. Errors never truncate an
+ # existing manifest. Hard-link support on this filesystem is required.
+ with tempfile.NamedTemporaryFile(dir=path.parent, prefix='.filecheck-') as stage:
+ stage.write(payload)
+ stage.flush()
+ os.fsync(stage.fileno())
+ os.link(stage.name, path)
+
+
+def inspect_files(checker, parent, entries, limit, verify):
+ rows = []
+ for entry in entries:
+ name = entry['path']
+ try:
+ actual = asdict(checker.hash_file(parent/name, limit))
+ if verify:
+ expected = {key: entry[key] for key in ('bytes', 'sha256')}
+ rows.append(dict(path=name, status='ok' if actual == expected else 'changed', expected=expected, actual=actual))
+ else:
+ rows.append(dict(path=name, status='ok', **actual))
+ except FileProblem as error:
+ rows.append(dict(path=name, status='error', error=asdict(error.problem)))
+ return rows
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ subparsers = parser.add_subparsers(dest='command', required=True)
+ for command in ('record', 'verify'):
+ sub = subparsers.add_parser(command)
+ sub.add_argument('manifest', type=Path)
+ sub.add_argument('--max-bytes', type=int, default=DEFAULT_MAX_BYTES)
+ if command == 'record':
+ sub.add_argument('paths', nargs='+', help='paths relative to the manifest directory')
+ args = parser.parse_args()
+ try:
+ if not 0 <= args.max_bytes <= 2**63-1:
+ raise ValueError('max-bytes must be in 0..2**63-1')
+ manifest = args.manifest.absolute()
+ if args.command == 'record':
+ names = [path_text(name) for name in args.paths]
+ if len(names) > MAX_FILES or len(names) != len(set(names)):
+ raise ValueError(f'provide 1..{MAX_FILES} distinct paths')
+ entries = [dict(path=name) for name in names]
+ else:
+ entries = load_manifest(manifest)['files']
+ with FileChecker() as checker:
+ rows = inspect_files(checker, manifest.parent, entries, args.max_bytes, args.command == 'verify')
+ code = 2 if any(row['status'] == 'error' for row in rows) else 1 if any(row['status'] == 'changed' for row in rows) else 0
+ if args.command == 'record' and code == 0:
+ output = dict(schema=SCHEMA, algorithm='sha256', files=[{key: row[key] for key in ('path', 'bytes', 'sha256')} for row in rows])
+ publish_manifest(manifest, validate_manifest(output))
+ print(json.dumps(dict(ok=code == 0, files=rows), ensure_ascii=False))
+ return code
+ except (OSError, ValueError, RuntimeError) as error:
+ print(json.dumps(dict(ok=False, error=str(error)), ensure_ascii=True))
+ return 2
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/demos/python-filecheck/worker.tri b/demos/python-filecheck/worker.tri
@@ -0,0 +1,37 @@
+!import base as B
+!import native_math as N
+!import io_v2 as IO
+!export hashFile
+
+limitError = B.err ["invalid-bound" "filecheck/hash" "file exceeds byte limit" "none"] t
+
+-- Read position comes from the file handle, keeping the loop free of a lazy
+-- byte-count accumulator. A growing file can consume one excess chunk on error.
+hashLoop reader digest limit = do IO.onOk
+ received <- IO.readSome reader 4096
+ position <- IO.filePosition reader
+ case N.lte position limit of
+ (t t) = case received of
+ ("eof", t) = do IO.onOk
+ hash <- IO.finishDigest digest
+ IO.pure (B.ok [position hash] t)
+ ("data", bytes) = do IO.onOk
+ IO.updateDigest digest bytes
+ hashLoop reader digest limit
+ _ = IO.fail "Invalid filecheck read reply"
+ _ = IO.pure limitError
+
+hashFile path limit = IO.io <| do IO.onOk
+ status <- IO.statPath path
+ case status of
+ ["file" size] = case N.lte size limit of
+ (t t) = IO.scope (owner : do IO.onOk
+ reader <- IO.openReader owner path
+ size <- IO.fileSize reader
+ case N.lte size limit of
+ (t t) = do IO.onOk
+ digest <- IO.openDigest owner "sha256"
+ hashLoop reader digest limit
+ _ = IO.pure limitError)
+ _ = IO.pure limitError
+ _ = IO.pure (B.err ["invalid-argument" "filecheck/hash" "expected regular file" "none"] t)
diff --git a/demos/workflow-planning/README.md b/demos/workflow-planning/README.md
@@ -0,0 +1,164 @@
+# Inspectable workflow laboratory
+
+One pipeline is an ordinary tricu value. Two tricu interpreters consume it:
+`explain` produces text; `execute` produces a pure request/reply interaction.
+A third function structurally inserts approval before a selected stage. Python
+validates the declared plan, grants capabilities, executes local artifact effects,
+and supplies replies. The pipeline never calls the built-in native IO driver.
+
+This is a bounded experiment with a sequential, static declaration—not a CI
+service. Its “deployment” creates a local `release.txt`, with no shell, network,
+credentials or remote target.
+
+## Try it
+
+```sh
+nix build .#tricu-workflow-demo --no-link
+APP=$(nix path-info .#tricu-workflow-demo)
+
+"$APP/bin/tricu-workflow-demo" explain
+"$APP/bin/tricu-workflow-demo" explain --approval
+```
+
+The pipeline in [`pipeline.tri`](pipeline.tri) composes library functions:
+
+```tricu
+pipeline text =
+ [(W.writeArtifact "build" "candidate.txt" text)
+ (W.checkArtifact "test" "candidate.txt" text)
+ (W.publishArtifact "deploy" "candidate.txt" "release.txt")]
+```
+
+`--approval` applies `requireApproval "deploy"` to that value. The explanation is:
+
+```text
+build: write candidate.txt
+test: check candidate.txt
+approval required: deploy
+deploy: publish candidate.txt -> release.txt
+```
+
+JSON output also includes every action argument, its approval annotation, the
+normalized definition identity, and the executable bundle hash. The text summary
+omits artifact contents; those remain visible in the JSON stages.
+
+Execution requires an existing workspace and explicitly granted operations:
+
+```sh
+WORK=$(mktemp -d)
+"$APP/bin/tricu-workflow-demo" execute --approval --workspace "$WORK" \
+ --allow write --allow check --allow publish
+# Exit 1: build/test succeed, approval is denied, and no release is created.
+```
+
+For an approved run, use a fresh workspace and the exact `definition_id` from the
+protected explanation:
+
+```sh
+WORK=$(mktemp -d)
+PLAN_ID=PASTE_THE_REVIEWED_DEFINITION_ID
+"$APP/bin/tricu-workflow-demo" execute --approval --workspace "$WORK" \
+ --allow write --allow check --allow publish --approve "$PLAN_ID:deploy"
+cat "$WORK/release.txt"
+```
+
+Replace the `PLAN_ID` assignment with the inspected hash. A changed `--message`
+alters the definition identity; use the same message when explaining/executing.
+Existing artifacts are never overwritten, so each attempt should use a fresh
+workspace. Exit 0 means completed, 1 means a requested action failed/was denied,
+and 2 means an invalid package, plan, request protocol, or command. Execution
+reports include ordered action outcomes and a failure stage/reason when relevant.
+Argparse usage errors print to stderr; other reports are JSON.
+
+## What the representation enables
+
+[`workflow.tri`](workflow.tri) defines stage constructors, interpreters and the
+approval transformation. Plans are lists of:
+
+```text
+["stage" name action]
+["approval" name stage]
+```
+
+Actions are `["write" basename text]`, `["check" basename text]`, or
+`["publish" source-basename destination-basename]`. An approval wraps exactly one
+stage with the same name. Repeating the transformation preserves an existing
+wrapper; transforming a missing target is rejected by the host adapter.
+
+The execution interpreter yields:
+
+```text
+["request" name action continuation]
+["done"]
+["failed" name reason]
+```
+
+An approval is requested as `["approve"]`. Python applies each continuation to
+`["ok"]` or `["error" reason]`. A refusal terminates the tricu interpretation;
+later actions are not requested. Continuations stay as callable tree values in
+the Haskell runtime; no serialization of closures or native callbacks is needed.
+Temporary arguments, observations and continuations use Python handle scopes.
+
+This representation exposes declared workflow structure. It does not analyze raw
+executable trees or infer arbitrary program behavior. This grammar has no dynamic
+expansion, parallel scheduling, dependency DAGs, retries or resumable execution.
+A more expressive planner would need to distinguish its currently known structure
+from future work, rather than promising a complete static preview.
+
+## Identity and authority are separate
+
+The definition ID is SHA-256 over the domain `tricu.workflow-demo.plan.v1`, a NUL
+separator, and compact sorted-key UTF-8 JSON for the validated stage projection.
+Every name, action argument and approval flag contributes. It identifies this
+normalized declaration—not the raw executable tree, an execution, or a snapshot
+of workspace contents. The executable bundle hash is reported separately.
+
+An explanation grants no authority. The executor checks:
+
+- The complete declaration before any effect: 1..16 distinct stages, known action
+ shapes, simple basenames and UTF-8 artifact text of at most 64 KiB.
+- Every execution request against the inspected order, stage name and complete
+ action arguments. Extra, reordered or undeclared requests fail before dispatch.
+- An independent capability allowlist for write/check/publish. Default is empty.
+- A grant matching this definition ID and stage at each approval request.
+- For **every** publication, a successfully completed approval boundary in this
+ run. A grant alone, a missing wrapper, or a previous run's approval is insufficient.
+
+Thus the transform changes the visible control structure, while executor policy
+remains authoritative. `--approve` is an explicit local caller decision, not a
+signed approval, authenticated reviewer, durable ticket or interactive review UI.
+
+The Linux executor anchors operations to an opened directory descriptor, accepts
+only simple basenames, refuses symlink reads, rejects nonregular input artifacts,
+limits reads/text to 64 KiB, and creates outputs exclusively. “Publish” copies an
+artifact into a new local filename; it is not an atomic deployment or transaction.
+Errors can leave partial output files, and earlier successful effects are retained.
+Protocol errors after effects likewise do not undo them.
+
+Use a trusted workspace. A file can change between checking and publication;
+approval binds the declaration, not observed artifact bytes or environment state.
+Definition identity is neither a cache key for arbitrary effects nor an execution
+provenance record. Process/memory exhaustion and nontermination require external
+supervision. Pure requests with restricted dispatch are useful authority
+boundaries, but this demo is not a hardened untrusted-plugin sandbox.
+
+## Reproduce the checks
+
+```sh
+nix build .#tricu-workflow-demo-test --no-link
+CHECK=$(nix path-info .#tricu-workflow-demo-test)
+cat "$CHECK/report.json"
+```
+
+Nix compiles the tricu modules at build time. The Python wheel contains the
+portable bundle and a generated checksum; startup verifies both its checksum and
+the native bundle format. The runtime closure excludes the compiler executable
+and checkout source snapshot. These source-compiling checks are opt-in.
+
+Independent expectations check stages, explanation, definition hashes and action
+traces. Negative controls cover missing/stale/wrong-stage grants, changed payloads,
+missing capabilities/boundaries, invalid declarations, forged requests and early
+completion, symlinks/nonregular files, closed executors, and 32 repeated runs with
+fresh approvals and stable descriptor counts. CLI checks run outside the checkout
+with a read-only working directory/home and no runtime tools on PATH. Tests also
+prohibit the native IO entrypoint during explanation and successful execution.
diff --git a/demos/workflow-planning/pipeline.tri b/demos/workflow-planning/pipeline.tri
@@ -0,0 +1,16 @@
+!import workflow as W
+!export pipeline
+!export requireApproval
+!export explain
+!export execute
+
+-- The payload is an explicit definition input, reflected in the plan identity.
+-- These ordinary library calls build an inspectable pipeline value.
+pipeline text =
+ [(W.writeArtifact "build" "candidate.txt" text)
+ (W.checkArtifact "test" "candidate.txt" text)
+ (W.publishArtifact "deploy" "candidate.txt" "release.txt")]
+
+requireApproval target stages = W.requireApproval target stages
+explain stages = W.explain stages
+execute stages = W.execute stages
diff --git a/demos/workflow-planning/pyproject.toml b/demos/workflow-planning/pyproject.toml
@@ -0,0 +1,19 @@
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "tricu-workflow-demo"
+version = "2026.10.24"
+description = "Inspectable tricu pipeline values with host-authorized local effects"
+requires-python = ">=3.10"
+dependencies = ["tricu-runtime"]
+
+[project.scripts]
+tricu-workflow-demo = "tricu_workflow_demo.cli:main"
+
+[tool.setuptools]
+packages = ["tricu_workflow_demo"]
+
+[tool.setuptools.package-data]
+tricu_workflow_demo = ["pipeline.arboricx"]
diff --git a/demos/workflow-planning/tricu_workflow_demo/__init__.py b/demos/workflow-planning/tricu_workflow_demo/__init__.py
@@ -0,0 +1,248 @@
+"""A static pipeline laboratory, not a CI executor or hard resource sandbox."""
+from dataclasses import asdict, dataclass
+import hashlib
+from importlib.resources import files
+import json
+import os
+from pathlib import Path
+import re
+import stat
+
+from tricu import Library
+from ._bundle_pin import BUNDLE_SHA256
+
+PROTOCOL = 'tricu.workflow-demo.plan.v1'
+MAX_TEXT = 65536
+
+
+class ProtocolError(RuntimeError):
+ pass
+
+
+class Denied(RuntimeError):
+ pass
+
+
+@dataclass(frozen=True)
+class Stage:
+ name: str
+ action: tuple
+ approval: bool = False
+
+
+def identifier(value):
+ if not isinstance(value, str) or not re.fullmatch('[a-z][a-z0-9-]{0,31}', value):
+ raise ProtocolError('invalid stage name')
+ return value
+
+
+def basename(value):
+ if not re.fullmatch('[a-z][a-z0-9._-]{0,63}', value):
+ raise ProtocolError('artifact names must be simple basenames')
+ return value
+
+
+def validate_action(action):
+ if len(action) != 3 or action[0] not in ('write', 'check', 'publish'):
+ raise ProtocolError('unknown or malformed action')
+ basename(action[1])
+ if action[0] == 'publish':
+ basename(action[2])
+ elif len(action[2].encode('utf8')) > MAX_TEXT:
+ raise ProtocolError('artifact text exceeds 64 KiB')
+ return tuple(action)
+
+
+def definition_id(stages):
+ payload = json.dumps([asdict(stage) for stage in stages], ensure_ascii=False,
+ sort_keys=True, separators=(',', ':')).encode('utf8')
+ return hashlib.sha256(PROTOCOL.encode()+b'\0'+payload).hexdigest()
+
+
+class Session:
+ def __init__(self):
+ payload = files(__package__).joinpath('pipeline.arboricx').read_bytes()
+ if hashlib.sha256(payload).hexdigest() != BUNDLE_SHA256:
+ raise ProtocolError('packaged pipeline checksum mismatch')
+ self.context = Library().context()
+ try:
+ self.exports = {name: self.context.load_bundle(payload, name)
+ for name in ('pipeline', 'requireApproval', 'explain', 'execute')}
+ except BaseException:
+ self.context.close()
+ raise
+
+ def __enter__(self): return self
+ def __exit__(self, *_): self.close()
+ def close(self): self.context.close()
+
+ def _list(self, roots, value, limit):
+ result = []
+ while self.context.kind(value) == 2:
+ if len(result) >= limit:
+ raise ProtocolError('list exceeds workflow protocol bound')
+ result.append(roots.child(value, 0))
+ value = roots.child(value, 1)
+ if self.context.kind(value) != 0:
+ raise ProtocolError('improper workflow list')
+ return result
+
+ def _strings(self, roots, value, limit):
+ return [self.context.to_string(item) for item in self._list(roots, value, limit)]
+
+ def stages(self, roots, plan):
+ stages = []
+ for value in self._list(roots, plan, 16):
+ fields = self._list(roots, value, 3)
+ if len(fields) != 3:
+ raise ProtocolError('invalid stage shape')
+ tag, name = (self.context.to_string(v) for v in fields[:2])
+ guarded = tag == 'approval'
+ if guarded:
+ inner = self._list(roots, fields[2], 3)
+ if len(inner) != 3 or self.context.to_string(inner[0]) != 'stage' or self.context.to_string(inner[1]) != name:
+ raise ProtocolError('approval must wrap its named stage once')
+ fields = inner
+ elif tag != 'stage':
+ raise ProtocolError('unknown stage tag')
+ stages.append(Stage(identifier(name), validate_action(self._strings(roots, fields[2], 3)), guarded))
+ if not stages or len({stage.name for stage in stages}) != len(stages):
+ raise ProtocolError('pipeline requires distinct stage names')
+ return stages
+
+ def plan(self, roots, text, approval=False):
+ if len(text.encode('utf8')) > MAX_TEXT:
+ raise ValueError('payload exceeds 64 KiB')
+ plan = roots.apply(self.exports['pipeline'], roots.string(text))
+ if approval:
+ plan = self.guard(roots, plan, 'deploy')
+ return plan
+
+ def guard(self, roots, plan, target):
+ if target not in {stage.name for stage in self.stages(roots, plan)}:
+ raise ProtocolError('approval target not found')
+ return roots.apply_many(self.exports['requireApproval'], roots.string(target), plan)
+
+ def describe(self, roots, plan):
+ stages = self.stages(roots, plan)
+ explanation = self.context.to_string(roots.apply(self.exports['explain'], plan))
+ return dict(protocol=PROTOCOL, definition_id=definition_id(stages),
+ executable_bundle_sha256=BUNDLE_SHA256,
+ stages=[asdict(stage) for stage in stages], explanation=explanation)
+
+ def _start(self, roots, plan):
+ return roots.apply(self.exports['execute'], plan)
+
+ def execute(self, roots, plan, executor):
+ stages = self.stages(roots, plan)
+ identity = definition_id(stages)
+ expected = []
+ for stage in stages:
+ if stage.approval:
+ expected.append((stage.name, ('approve',)))
+ expected.append((stage.name, stage.action))
+ cursor, events, failure = 0, [], None
+ approved = set() # A grant alone cannot manufacture a completed gate.
+ message = self._start(roots, plan)
+ while True:
+ parts = self._list(roots, message, 4)
+ if not parts:
+ raise ProtocolError('empty execution message')
+ tag = self.context.to_string(parts[0])
+ if failure is not None:
+ if len(parts) != 3 or tag != 'failed' or self._strings(roots, message, 3)[1:] != list(failure):
+ raise ProtocolError('interpreter continued after a rejected action')
+ return dict(outcome='failed', definition_id=identity, events=events,
+ failed_stage=failure[0], reason=failure[1])
+ if tag == 'done' and len(parts) == 1 and cursor == len(expected):
+ return dict(outcome='completed', definition_id=identity, events=events)
+ if tag != 'request' or len(parts) != 4 or cursor >= len(expected):
+ raise ProtocolError('unexpected execution message')
+ name = self.context.to_string(parts[1])
+ action = tuple(self._strings(roots, parts[2], 3))
+ if (name, action) != expected[cursor]:
+ raise ProtocolError('execution request differs from inspected declaration')
+ cursor += 1
+ try:
+ if action == ('approve',):
+ executor.approve(identity, name)
+ approved.add(name)
+ else:
+ executor.perform(name, action, approved)
+ response = ['ok']
+ status = 'ok'
+ except (Denied, OSError, ValueError) as error:
+ status = 'denied' if isinstance(error, Denied) else 'error'
+ failure = (name, str(error))
+ response = ['error', str(error)]
+ events.append(dict(stage=name, action=list(action), status=status))
+ reply = roots.leaf()
+ for text in reversed(response):
+ reply = roots.fork(roots.string(text), reply)
+ message = roots.apply(parts[3], reply)
+
+
+class WorkspaceExecutor:
+ """Explicit authority over bounded, create-only local artifact operations."""
+ def __init__(self, workspace, capabilities=(), grants=()):
+ self.capabilities = frozenset(capabilities)
+ if not self.capabilities <= {'write', 'check', 'publish'}:
+ raise ValueError('unknown capability')
+ self.grants = frozenset(grants) # (definition ID, stage name)
+ self.fd = os.open(Path(workspace), os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
+
+ def __enter__(self): return self
+ def __exit__(self, *_): self.close()
+ def close(self):
+ if self.fd is not None:
+ os.close(self.fd)
+ self.fd = None
+
+ def _active(self):
+ if self.fd is None:
+ raise Denied('executor is closed')
+
+ def approve(self, identity, stage):
+ self._active()
+ if (identity, stage) not in self.grants:
+ raise Denied('no approval grant for this definition and stage')
+
+ def _read(self, name):
+ fd = os.open(basename(name), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=self.fd)
+ try:
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
+ raise ValueError('artifact is not a regular file')
+ payload = bytearray()
+ while len(payload) <= MAX_TEXT:
+ part = os.read(fd, min(4096, MAX_TEXT+1-len(payload)))
+ if not part:
+ break
+ payload.extend(part)
+ if len(payload) > MAX_TEXT:
+ raise ValueError('artifact exceeds 64 KiB')
+ return bytes(payload)
+ finally:
+ os.close(fd)
+
+ def _write(self, name, payload):
+ if len(payload) > MAX_TEXT:
+ raise ValueError('artifact exceeds 64 KiB')
+ fd = os.open(basename(name), os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
+ 0o600, dir_fd=self.fd)
+ with os.fdopen(fd, 'wb') as stream:
+ stream.write(payload)
+
+ def perform(self, name, action, approved):
+ self._active()
+ kind, source, argument = validate_action(action)
+ if kind not in self.capabilities:
+ raise Denied('capability not granted: '+kind)
+ if kind == 'publish' and name not in approved:
+ raise Denied('publication requires a completed approval boundary')
+ if kind == 'write':
+ self._write(source, argument.encode('utf8'))
+ elif kind == 'check':
+ if self._read(source) != argument.encode('utf8'):
+ raise ValueError('artifact check failed')
+ else:
+ self._write(argument, self._read(source))
diff --git a/demos/workflow-planning/tricu_workflow_demo/cli.py b/demos/workflow-planning/tricu_workflow_demo/cli.py
@@ -0,0 +1,48 @@
+import argparse
+import json
+import sys
+
+from . import Session, WorkspaceExecutor, ProtocolError, identifier
+
+
+def grant(text):
+ identity, separator, stage = text.partition(':')
+ if not separator or len(identity) != 64 or any(c not in '0123456789abcdef' for c in identity):
+ raise argparse.ArgumentTypeError('approval must be DEFINITION_SHA256:STAGE')
+ try:
+ identifier(stage)
+ except ProtocolError as error:
+ raise argparse.ArgumentTypeError(str(error)) from error
+ return identity, stage
+
+
+def main():
+ parser = argparse.ArgumentParser(description='Inspectable pipelines with explicitly authorized local effects')
+ parser.add_argument('command', choices=['explain', 'execute'])
+ parser.add_argument('--message', default='hello pipeline\n')
+ parser.add_argument('--approval', action='store_true', help='insert approval before deploy')
+ parser.add_argument('--workspace')
+ parser.add_argument('--allow', action='append', choices=['write', 'check', 'publish'], default=[])
+ parser.add_argument('--approve', type=grant, action='append', default=[])
+ args = parser.parse_args()
+ if args.command == 'execute' and not args.workspace:
+ parser.error('execute requires an existing --workspace')
+ if args.command == 'explain' and (args.workspace or args.allow or args.approve):
+ parser.error('explain accepts no execution authority')
+ try:
+ with Session() as session, session.context.scope() as roots:
+ plan = session.plan(roots, args.message, args.approval)
+ if args.command == 'explain':
+ result = session.describe(roots, plan)
+ else:
+ with WorkspaceExecutor(args.workspace, args.allow, args.approve) as executor:
+ result = session.execute(roots, plan, executor)
+ print(json.dumps(result, ensure_ascii=False))
+ return 0 if result.get('outcome', 'completed') == 'completed' else 1
+ except (OSError, ValueError, RuntimeError) as error:
+ print(json.dumps(dict(outcome='error', reason=str(error)), ensure_ascii=True))
+ return 2
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/demos/workflow-planning/workflow.tri b/demos/workflow-planning/workflow.tri
@@ -0,0 +1,44 @@
+!import base as B
+!export writeArtifact
+!export checkArtifact
+!export publishArtifact
+!export requireApproval
+!export explain
+!export execute
+
+join values = B.concatMap B.id values
+
+writeArtifact name path text = ["stage" name ["write" path text]]
+checkArtifact name path text = ["stage" name ["check" path text]]
+publishArtifact name source destination = ["stage" name ["publish" source destination]]
+
+-- A structural transformation over declared stages, preserving their actions.
+-- Existing approval wrappers remain intact, making repeat application idempotent.
+guard target whole{["stage" name action]} = case B.equal? target name of
+ (t t) = ["approval" name whole]
+ _ = whole
+guard _ whole = whole
+requireApproval target stages = B.map (guard target) stages
+
+explainStage ["stage" name ["write" path _]] = join [name ": write " path "\n"]
+explainStage ["stage" name ["check" path _]] = join [name ": check " path "\n"]
+explainStage ["stage" name ["publish" source destination]] =
+ join [name ": publish " source " -> " destination "\n"]
+explainStage ["approval" name stage] =
+ join ["approval required: " name "\n" (explainStage stage)]
+explainStage _ = "invalid stage\n"
+explain stages = join (B.map explainStage stages)
+
+-- Pure interaction values: the interpreter requests an action, then consumes a
+-- host reply. Only the host executes effects and decides approvals.
+resume ["ok"] _ next = next t
+resume ["error" reason] name _ = ["failed" name reason]
+resume _ name _ = ["failed" name "Malformed host reply"]
+runStage ["stage" name action] next =
+ ["request" name action (reply : resume reply name next)]
+runStage ["approval" name stage] next =
+ ["request" name ["approve"] (reply : resume reply name (_ : runStage stage next))]
+runStage _ _ = ["failed" "" "Malformed stage"]
+execute [] = ["done"]
+execute [stage | rest] = runStage stage (_ : execute rest)
+execute _ = ["failed" "" "Malformed pipeline"]
diff --git a/docs/build-and-run.md b/docs/build-and-run.md
@@ -2,7 +2,9 @@
Run these commands from the extracted tricu directory. You need [Nix](https://nixos.org/download/)
with flakes enabled. Native development uses the Haskell host; builds and tests
-run through Nix. The [Zig runtime and owned-handle C ABI](../ext/zig/README.md)
+run through Nix. The [native Haskell C interface](../ext/haskell/README.md)
+provides embedding through C, Python, [native JavaScript](../ext/js/README.md),
+and [PHP](../ext/php/README.md). The [Zig runtime and owned-handle C ABI](../ext/zig/README.md)
have their own Nix validation targets. The
[cross-runtime reproduction workflow](compiler-rebuilding.md.html#isolated-cross-runtime-fixed-point-reproduction)
verifies identical compiler generations on Haskell and Zig.
@@ -162,6 +164,27 @@ GC-sampled live heap and RSS; upload guards remain128MiB RSS and16MiB live heap.
Large downloads retain substantial whole-value cost. The checks run the native
entrypoint directly. Container-engine integration requires a separate check.
+## Embed an installed Python application
+
+The [file-integrity application](../demos/python-filecheck/README.md) packages a
+compiled tricu worker with a Python CLI. It streams files through IO-v2, records
+and verifies JSON manifests, and reuses a loaded worker for sequential API calls.
+Runtime needs neither compiler data nor source lookup.
+
+```sh
+nix build .#tricu-filecheck .#tricu-filecheck-test --no-link
+```
+
+## Explore workflow values
+
+The [workflow laboratory](../demos/workflow-planning/README.md) defines one static
+pipeline in tricu, explains it, structurally inserts approval, and interprets it
+through a Python host with explicit local artifact authority.
+
+```sh
+nix build .#tricu-workflow-demo .#tricu-workflow-demo-test --no-link
+```
+
## Explicit compiler selection
For development or a pinned alternate compiler, import a caller-selected bundle:
diff --git a/docs/host-abi.md b/docs/host-abi.md
@@ -1,5 +1,9 @@
# tricu Host ABI
+For native embedding, see the [Haskell C interface](../ext/haskell/README.md).
+Its context/handle ABI transports runtime trees; the value envelopes below are
+program-level encodings interpreted by the portable runners.
+
The portable Arboricx runners in `lib/arboricx/arboricx.tri` wrap successful
results in a tagged value envelope. Hosts decode the selected canonical payload.
This result envelope is separate from the IO action protocol interpreted by
diff --git a/ext/haskell/BoundaryTests.hs b/ext/haskell/BoundaryTests.hs
@@ -0,0 +1,60 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+-- Inject bottoms through the Haskell-side registry, never a production C hook.
+module Main where
+import qualified Embedding as E
+import Control.Monad (unless)
+import Control.Monad.IO.Class (liftIO)
+import Control.Concurrent
+import Runtime (T(..), ofString, ofNumber)
+import Foreign
+import Data.Word
+import Control.Exception (evaluate)
+
+assert :: Bool -> IO ()
+assert ok = unless ok (fail "embedding boundary assertion failed")
+main :: IO ()
+main = alloca $ \ctxOut -> alloca $ \out -> do
+ E.contextNew ctxOut >>= assert . (== 0)
+ ctx <- peek ctxOut
+ E.withContext ctx (E.publish out (error "boundary poison")) >>= assert . (== 0)
+ poison <- peek out
+ poke out 987
+ E.reduce ctx poison out >>= assert . (== 5)
+ peek out >>= assert . (== 987)
+ -- Even application of a bottom function is suspended by the boundary.
+ E.application ctx poison poison out >>= assert . (== 0)
+ suspended <- peek out
+ E.release ctx suspended >>= assert . (== 0)
+ E.leaf ctx out >>= assert . (== 0)
+ leaf <- peek out
+ E.fork ctx leaf leaf out >>= assert . (== 0)
+ constant <- peek out
+ E.application ctx constant poison out >>= assert . (== 0)
+ discarded <- peek out
+ E.reduce ctx discarded out >>= assert . (== 0)
+ value <- peek out
+ E.toU64 ctx value out >>= assert . (== 0)
+ peek out >>= assert . (== (0 :: Word64))
+ -- Terminal task diagnostics remain opaque across the native boundary.
+ E.withContext ctx (E.publish out
+ (Fork (ofString "tricuIO") (Fork (ofNumber 2)
+ (Fork (ofNumber 2) (error "opaque diagnostic"))))) >>= assert . (== 0)
+ program <- peek out
+ alloca $ \tag -> alloca $ \reason -> alloca $ \cleanup -> do
+ E.runIO ctx program tag reason cleanup >>= assert . (== 0)
+ peek tag >>= assert . (== 1)
+ failed <- peek reason
+ E.reduce ctx failed out >>= assert . (== 5)
+ -- An interrupted call restores the context lock and contains its exception.
+ entered <- newEmptyMVar
+ done <- newEmptyMVar
+ thread <- forkIO $ E.withContext ctx (do
+ liftIO (putMVar entered () >> threadDelay 10000000)
+ pure (pure ())) >>= putMVar done
+ takeMVar entered
+ killThread thread
+ takeMVar done >>= assert . (== 5)
+ E.leaf ctx out >>= assert . (== 0)
+ E.contextFree ctx >>= assert . (== 0)
+ _ <- evaluate poison
+ pure ()
diff --git a/ext/haskell/README.md b/ext/haskell/README.md
@@ -0,0 +1,197 @@
+# Native Haskell embedding
+
+The Haskell shared library exposes tricu trees and the native IO-v2 driver through
+[`include/tricu.h`](include/tricu.h), embedding ABI 1. It uses the same
+`Runtime.Tree`, value codecs, bundle verifier and IO driver as the Haskell
+executable. The Zig host remains independently available. Native consumers use the
+[Python](#python-example), [JavaScript](../js/README.md), and
+[PHP](../php/README.md) bindings.
+
+## Build and validate
+
+```sh
+nix build .#tricu-haskell-ffi --no-link
+EMBED=$(nix path-info .#tricu-haskell-ffi)
+
+# Compiled IO-v2 copier and a two-module portable compiler job
+nix build .#tricu-haskell-ffi-integration --no-link
+CHECK=$(nix path-info .#tricu-haskell-ffi-integration)
+cat "$CHECK/integration.json" "$CHECK/compiler.json"
+
+# Common value and IO conformance through all three installed native bindings
+nix build .#tricu-native-bindings-integration --no-link
+BINDINGS=$(nix path-info .#tricu-native-bindings-integration)
+cat "$BINDINGS/report.json"
+```
+
+The library build runs a linked C client, Haskell-side exception/demand controls,
+and fresh-process Python `ctypes` checks before and after installation.
+`nix flake check` includes this basic gate and consumer-level Python/JS/PHP
+conformance. Source-compiling acceptance is opt-in. Shared JSON fixtures supply
+independent expected values; each language runs malformed-input, ownership and
+IO-outcome controls. The opt-in binding integration additionally executes the
+compiled copier, failure cleanup and transferred-resource cancellation in all
+three languages.
+
+Native embedding is validated on x86-64 Linux with the pinned Nix/GHC toolchain.
+The artifact installs `lib/libtricu-haskell.so`, `include/tricu.h`, and
+`share/tricu/tricu.py`. On Linux its Haskell shared-library dependencies are
+installed beside it; native dependencies retain their Nix runtime paths. Keep the
+complete Nix closure available. Copying just the main `.so` is insufficient.
+Other native targets and browser/WASM embedding require their own acceptance.
+
+## Python example
+
+The library artifact installs `share/tricu/tricu.py`. The `tricu-python` Nix
+package also provides the installable `tricu-runtime` Python module; select it in
+a Python environment through `python3.withPackages`. Its installed module finds
+the packaged native library automatically. With checkout/PyPI-style module
+installation, set `TRICU_LIB` to an explicitly built library path. Publication to
+PyPI is separate from the tracked packaging recipe.
+
+`Library(path)` selects explicitly; `Library()` checks `TRICU_LIB` and installed
+library locations. One library is kept loaded per process. `Context.number` and
+`to_number` use exact Python integers in the uint64 range. `to_bool` checks boolean
+shapes, and `child` returns an owned handle. Strings and bytes retain embedded
+NULs. Context managers close contexts deterministically.
+
+```sh
+PYTHONPATH="$EMBED/share/tricu" python3 - "$EMBED/lib/libtricu-haskell.so" <<'PY'
+import sys
+from pathlib import Path
+from tricu import Library
+
+library = Library(sys.argv[1])
+with library.context() as ctx:
+ identity = ctx.load_bundle(Path('test/fixtures/id.arboricx').read_bytes(), 'id')
+ argument = ctx.string('hello from Haskell')
+ result = ctx.apply(identity, argument)
+ print(ctx.to_string(result))
+PY
+```
+
+For a complete installed consumer, see the
+[Python file-integrity application](../../demos/python-filecheck/README.md).
+Its tricu worker is compiled at build time and packaged with the Python application.
+
+### Scoped handles and curried application
+
+For a reusable context, `Context.scope()` owns temporary roots created through
+that scope. Constructors, retain, reduction, child inspection, bundle loading and
+application return tracked roots; `run_io` tracks both result roots. Borrowed
+inputs, including a worker loaded directly through the context, retain their
+original owners. Observations use the context's ordinary decoding methods.
+
+```python
+with ctx.scope() as roots:
+ argument = roots.string('hello')
+ result = roots.apply_many(worker, argument)
+ text = ctx.to_string(result)
+# argument and result are released; text is an ordinary Python string.
+```
+
+`apply_many(function, *arguments)` borrows its inputs and returns one owned curried
+application, releasing private intermediate handles. With no arguments it returns
+an independently retained function handle. `scope.own(handle)` adopts an already
+owned root without retaining it; `detach` transfers ownership to the caller, and
+`release` explicitly retires a scoped root early. Duplicate adoption is rejected.
+Scope exit releases remaining roots in reverse order, also on exceptions. A
+context closed first has already released all its roots. Cleanup attempts every
+remaining release and preserves an existing body exception if cleanup also fails.
+Handles created directly through the context remain caller-owned.
+
+`Context.run_io(program)` returns `(outcome, value_handle, cleanup_handle)`.
+Outcome `0` means completed, `1` failed, and `2` cancelled. A completed value may
+itself be an ordinary error Result. Failure/cancellation reasons remain opaque
+lazy trees; cleanup is a list of IO-v2 problem values. Both returned handles are
+owned, including an empty cleanup list. Python boundary failures raise
+`TricuError` with the native status and diagnostic text.
+
+## Lifecycle and ownership
+
+Call `tricu_init` before any other library entrypoint. Initialization is
+process-wide and idempotent, owns GHC runtime startup, and uses one default GHC
+capability. It ignores `GHCRTS` and host command-line RTS arguments and disables
+GHC application signal-handler installation, preserving the host's SIGINT/SIGPIPE
+handlers. This entrypoint is for processes that have not already initialized
+GHC. Keep one copy of the library loaded until process exit. Linux builds mark the
+library `NODELETE` so request-scoped loaders cannot unmap an active GHC runtime;
+repeated dlopen/dlclose and persistent PHP-request controls validate that lifetime. Host applications
+that already embed GHC or need several Haskell libraries require coordinated RTS
+integration. Runtime shutdown, unloading, and using inherited contexts after
+`fork()` are outside this interface; start a fresh process instead.
+
+Contexts and handles are validated unsigned 64-bit IDs in a process-wide,
+nonreusing namespace. Handles belong to one context. Each successful handle output
+owns a root, including child inspection, retain and reduction. Inputs are borrowed.
+`retain` returns a different handle to the same Haskell value. Release removes
+that root; context destruction releases all remaining roots. Garbage collection
+and resident-memory reclamation are managed by GHC. Long-lived clients should
+release intermediate handles instead of accumulating them until context teardown.
+
+Input buffers are copied during the call. UTF-8 input is strict scalar text;
+embedded NULs and U+FEFF are ordinary string contents. Outputs are length-delimited,
+independent native allocations. Free each exactly once with `tricu_buffer_free`,
+even empty buffers. Output buffers may outlive their originating context.
+Python's binding copies these into owned Python bytes and frees the native buffer.
+
+Calls on a context serialize, including evaluation and IO. Different contexts can
+make concurrent calls through the threaded RTS. Context destruction waits for an
+active call; subsequent calls return invalid-context. Calls provide no callbacks
+or reentrant entry. Native IO uses OS process authority and shares process
+standard streams, environment and other process resources; contexts are not
+security isolation. See the [IO-v2 contract](../../test/io-v2/README.md), especially
+standard-stream ownership and per-run lease exclusion.
+
+## Demand and errors
+
+Application creates a lazy suspension. `reduce` demands weak-head normal form;
+constructor inspection and decoding demand the parts they observe. Constructors
+and retain preserve lazy children. Handles represent values rather than explicit
+application nodes. Decoding uses the existing Haskell value codecs. Bundle loading
+verifies the Arboricx container and requires an explicit UTF-8 export name.
+
+The additive `tricu_to_bool` entrypoint decodes `Leaf` as false and `Stem Leaf` as
+true, returning a type failure for other shapes. Bindings require the selected
+library to provide this entrypoint along with the rest of the header.
+
+Boundary statuses distinguish invalid context, invalid handle, invalid input,
+codec/type failure, Haskell exception, and identity exhaustion. Context-local
+`last_error` returns diagnostic UTF-8 bytes, initially empty and retained across
+successful calls. Task outcomes use the separate IO result outputs above.
+Concurrent callers needing the matching last-error text should coordinate the
+failed call and diagnostic retrieval.
+
+Outputs publish after fallible preparation and remain unchanged on failure.
+Pointers must refer to valid storage, and output regions must not overlap.
+Exceptions are contained at the foreign boundary; completed IO effects and
+progress of shared lazy evaluation remain observable after a failed call.
+
+Reduction, observation and IO can run indefinitely. The interface supplies no
+fuel, hard deadlines, caller cancellation or per-context memory quotas. Fatal RTS
+failures, process signals and arbitrary heap exhaustion are not recoverable status
+guarantees. Use supervised subprocesses and OS resource limits for untrusted or
+bounded work. Context destruction waits for computation; it cannot interrupt it.
+
+## Validation scope
+
+Basic checks cover constructors, portable identity/append bundles, full byte and
+Unicode results, ownership, stale/foreign handles, malformed inputs, output
+publication, concurrent entry, repeated context teardown, contained exceptions,
+lazy application/discard, and opaque task diagnostics. Installed-library tests
+exercise the packaged runtime paths in a new Python process.
+
+The integration target compiles the existing IO-v2 copier and checks repeated
+64-KiB binary copies, missing-source and missing-parent Results, transferred-resource
+cancellation, and descriptor counts on Linux. Its
+compiler probe runs a cold two-module build through the shared library and through
+the CLI. It compares every library-published object against the CLI store,
+authenticates the receipt, requires byte-identical program bundles, and executes
+the result as `41` through the library.
+
+Reports include small-call and bulk-codec timings and fresh-process compiler-job
+timings. These are correctness smokes with measurements, not speed thresholds or
+isolated reducer comparisons: the CLI also imports packaged compiler data and
+packages its program bundle. Nix may reuse reports; rerun the tracked Python
+scripts against built artifacts for fresh observations. The compiler probe is an
+application build, separate from compiler-generation/fixed-point acceptance.
diff --git a/ext/haskell/bindings/check.py b/ext/haskell/bindings/check.py
@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+"""Compare consumer results against shared independent expectations."""
+import argparse
+import json
+from pathlib import Path
+import subprocess
+
+p=argparse.ArgumentParser(description=__doc__)
+p.add_argument('--library',required=True)
+p.add_argument('--source',type=Path,required=True)
+p.add_argument('--python',required=True)
+p.add_argument('--node',required=True)
+p.add_argument('--php',required=True)
+p.add_argument('--copier',required=True)
+p.add_argument('--report',type=Path,required=True)
+a=p.parse_args()
+spec=a.source/'ext/haskell/bindings/fixtures.json'
+expected=json.loads(spec.read_text())['expected']
+args=[a.library,str(a.source/'test/fixtures'),str(spec),a.copier]
+commands={
+ 'python':[a.python,str(a.source/'ext/haskell/bindings/python.py')],
+ 'js':[a.node,str(a.source/'ext/js/test/runner.mjs')],
+ 'php':[a.php,str(a.source/'ext/php/tests/conformance.php')],
+}
+reports={}
+for language,command in commands.items():
+ try:
+ result=subprocess.run(command+args,check=True,text=True,capture_output=True,timeout=90)
+ except subprocess.CalledProcessError as error:
+ print(error.stdout, error.stderr, flush=True)
+ raise
+ actual=json.loads(result.stdout)
+ assert actual==expected,(language,actual,expected)
+ reports[language]=actual
+# Each consumer independently asserts IO files/outcomes/resource cleanup before
+# reporting. Timeouts supervise the foreign process, not the reducer itself.
+a.report.write_text(json.dumps({'passed':list(reports),'results':reports},indent=2,ensure_ascii=False)+'\n')
+print('Native binding conformance passed: '+', '.join(reports))
diff --git a/ext/haskell/bindings/fixtures.json b/ext/haskell/bindings/fixtures.json
@@ -0,0 +1,86 @@
+{
+ "numbers": [
+ "0",
+ "1",
+ "42",
+ "9007199254740993",
+ "9223372036854775808",
+ "18446744073709551615"
+ ],
+ "strings": [
+ "",
+ "hello\u0000world",
+ "tree 🌳 café\r\n"
+ ],
+ "bytesHex": [
+ "",
+ "00ff8041",
+ "00010203ff"
+ ],
+ "invalidUtf8Hex": [
+ "ff",
+ "c080",
+ "eda080",
+ "f4908080"
+ ],
+ "identity": "portable 🌳\u0000value",
+ "append": [
+ "Hello, ",
+ "world!"
+ ],
+ "copyHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff",
+ "copyRepeats": 256,
+ "expected": {
+ "numbers": [
+ "0",
+ "1",
+ "42",
+ "9007199254740993",
+ "9223372036854775808",
+ "18446744073709551615"
+ ],
+ "strings": [
+ "",
+ "hello\u0000world",
+ "tree 🌳 café\r\n"
+ ],
+ "bytesHex": [
+ "",
+ "00ff8041",
+ "00010203ff"
+ ],
+ "identity": "portable 🌳\u0000value",
+ "append": "Hello, world!",
+ "booleans": [
+ true,
+ false
+ ],
+ "outcomes": [
+ {
+ "outcome": 0,
+ "payload": "opaque payload",
+ "cleanupKind": 0
+ },
+ {
+ "outcome": 1,
+ "payload": "opaque payload",
+ "cleanupKind": 0
+ },
+ {
+ "outcome": 2,
+ "payload": "opaque payload",
+ "cleanupKind": 0
+ }
+ ],
+ "negativeStatuses": [
+ 2,
+ 2,
+ 4,
+ 4,
+ 3,
+ 3,
+ 3
+ ],
+ "notQKind": 2
+ }
+}
diff --git a/ext/haskell/bindings/python.py b/ext/haskell/bindings/python.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+"""Consumer-level conformance using the installed Python binding."""
+import json
+from pathlib import Path
+import sys
+from tricu import Library, TricuError
+
+library, fixture_dir, specification, *copier = sys.argv[1:]
+f = json.loads(Path(specification).read_text())
+fixtures = Path(fixture_dir)
+report = {}
+with Library(library).context() as ctx, Library(library).context() as other:
+ report['numbers'] = [str(ctx.to_number(ctx.number(int(n)))) for n in f['numbers']]
+ report['strings'] = [ctx.to_string(ctx.string(s)) for s in f['strings']]
+ report['bytesHex'] = [ctx.to_bytes(ctx.bytes(bytes.fromhex(h))).hex() for h in f['bytesHex']]
+ def load(file, name):
+ return ctx.load_bundle((fixtures/file).read_bytes(), name)
+ worker = load('id.arboricx', 'id')
+ argument = ctx.string(f['identity'])
+ application = ctx.apply(worker, argument)
+ ctx.release(worker)
+ ctx.release(argument)
+ report['identity'] = ctx.to_string(ctx.reduce(application))
+ append = load('append.arboricx', 'append')
+ for s in f['append']:
+ append = ctx.apply(append, ctx.string(s))
+ report['append'] = ctx.to_string(append)
+ report['booleans'] = [ctx.to_bool(load(name+'.arboricx', name)) for name in ('true', 'false')]
+ report['notQKind'] = ctx.kind(ctx.reduce(load('notQ.arboricx', 'not?')))
+ report['outcomes'] = []
+ for code in (0, 2, 9):
+ program = ctx.fork(ctx.string('tricuIO'), ctx.fork(ctx.number(2),
+ ctx.fork(ctx.number(code), ctx.string('opaque payload'))))
+ outcome, value, cleanup = ctx.run_io(program)
+ report['outcomes'].append(dict(outcome=outcome,payload=ctx.to_string(value),cleanupKind=ctx.kind(cleanup)))
+ ctx.release(program); ctx.release(value); ctx.release(cleanup)
+ root = ctx.string('owned')
+ alias = ctx.retain(root)
+ assert root != alias
+ ctx.release(root)
+ assert ctx.to_string(alias) == 'owned'
+ wrong = ctx.stem(ctx.stem(ctx.leaf()))
+ operations = [lambda: ctx.release(root), lambda: other.retain(alias),
+ lambda: ctx.to_number(wrong), lambda: ctx.to_bool(wrong),
+ lambda: ctx.load_bundle(b'bad bundle','id'),
+ lambda: load('id.arboricx','missing'), lambda: ctx.run_io(ctx.leaf())]
+ report['negativeStatuses'] = []
+ for operation in operations:
+ try: operation()
+ except TricuError as e:
+ assert str(e)
+ report['negativeStatuses'].append(e.status)
+ else: raise AssertionError('invalid operation accepted')
+ for h in f['invalidUtf8Hex']:
+ try: ctx.string(bytes.fromhex(h).decode('utf8'))
+ except UnicodeDecodeError: pass
+ else: raise AssertionError('malformed UTF-8 accepted')
+ for value in (-1,2**64,1.5):
+ try: ctx.number(value)
+ except ValueError: pass
+ else: raise AssertionError('invalid number accepted')
+ if copier:
+ # Existing tracked IO acceptance also checks scope cleanup and descriptor
+ # counts, using the public binding rather than raw ctypes declarations.
+ sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
+ from test_embedding import copier as check_copier
+ check_copier(ctx.library, Path(copier[0]), bytes.fromhex(f['copyHex'])*f['copyRepeats'])
+assert report == f['expected'], (report,f['expected'])
+print(json.dumps(report,ensure_ascii=False,sort_keys=True))
diff --git a/ext/haskell/build.nix b/ext/haskell/build.nix
@@ -0,0 +1,66 @@
+{ pkgs, source }:
+let
+ ghc = pkgs.haskellPackages.ghcWithPackages (p: [
+ p.aeson p.base16-bytestring p.cryptonite p.memory p.vector
+ p.network p.stm p.temporary p.mtl
+ ]);
+in pkgs.stdenv.mkDerivation {
+ pname = "tricu-haskell-embedding";
+ version = "2026.10.24";
+ src = source;
+ nativeBuildInputs = [ ghc pkgs.python3 ]
+ ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.patchelf ];
+ buildPhase = ''
+ runHook preBuild
+ mkdir -p objects
+ ghc -O2 -dynamic -shared -fPIC -flink-rts -threaded \
+ ${pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isLinux "-optl-Wl,-z,nodelete"} \
+ -XOverloadedStrings -XScopedTypeVariables -XMultiWayIf -XLambdaCase \
+ -Wall -isrc -iext/haskell/src -outputdir objects \
+ -optc-Iext/haskell/include \
+ ext/haskell/src/Embedding.hs ext/haskell/src/init.c \
+ -o libtricu-haskell${pkgs.stdenv.hostPlatform.extensions.sharedLibrary}
+ runHook postBuild
+ '';
+ doCheck = true;
+ checkPhase = ''
+ runHook preCheck
+ ghc -O2 -dynamic -threaded \
+ -XOverloadedStrings -XScopedTypeVariables -XMultiWayIf -XLambdaCase \
+ -isrc -iext/haskell/src -outputdir boundary-objects \
+ ext/haskell/BoundaryTests.hs -o boundary-tests
+ timeout 30s ./boundary-tests
+ $CC -Wall -Wextra -Werror ext/haskell/tests.c -Iext/haskell/include \
+ -L. -ltricu-haskell -Wl,-rpath,$PWD -o c-embedding-tests
+ GHCRTS=--tricu-test-invalid timeout 30s ./c-embedding-tests
+ ${pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isLinux ''
+ $CC -Wall -Wextra -Werror ext/haskell/reload.c -Iext/haskell/include -ldl -o reload-tests
+ timeout 30s ./reload-tests "$PWD/libtricu-haskell.so"
+ ''}
+ PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=ext/haskell/python timeout 60s python3 ext/haskell/test_embedding.py \
+ ./libtricu-haskell${pkgs.stdenv.hostPlatform.extensions.sharedLibrary} test/fixtures > embedding-report.json
+ cat embedding-report.json
+ PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=ext/haskell/python timeout 30s python3 \
+ ext/haskell/test_scopes.py ./libtricu-haskell${pkgs.stdenv.hostPlatform.extensions.sharedLibrary} test/fixtures
+ runHook postCheck
+ '';
+ installPhase = ''
+ runHook preInstall
+ mkdir -p $out/lib $out/include $out/share/tricu
+ cp libtricu-haskell* $out/lib/
+ cp ext/haskell/include/tricu.h $out/include/
+ cp ext/haskell/python/tricu.py $out/share/tricu/
+ cp embedding-report.json $out/share/tricu/
+ ${pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isLinux ''
+ python3 ext/haskell/package_runtime.py $out/lib/libtricu-haskell.so
+ ''}
+ runHook postInstall
+ '';
+ # Verify the installed runtime paths in a fresh foreign process, too.
+ doInstallCheck = true;
+ installCheckPhase = ''
+ PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=$out/share/tricu timeout 60s python3 \
+ ext/haskell/test_embedding.py \
+ $out/lib/libtricu-haskell${pkgs.stdenv.hostPlatform.extensions.sharedLibrary} test/fixtures
+ '';
+}
diff --git a/ext/haskell/compiler_probe.py b/ext/haskell/compiler_probe.py
@@ -0,0 +1,117 @@
+#!/usr/bin/env python3
+"""Bounded two-module compiler job via ctypes, checked against the native CLI.
+
+Reports fresh-process end-to-end timings, not isolated reducer timings. The CLI
+also imports packaged compiler data and packages the application bundle.
+"""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+import re
+import resource
+import subprocess
+import sys
+import time
+from tricu import Library
+from test_embedding import envelope
+
+
+def embedded(args):
+ # A subprocess is the resource boundary, independently of the embedding API.
+ resource.setrlimit(resource.RLIMIT_AS, (1024*1024*1024, 1024*1024*1024))
+ resource.setrlimit(resource.RLIMIT_CPU, (120, 120))
+ start = time.perf_counter()
+ data = args.compiler.read_bytes()
+ identity = hashlib.sha256(data).hexdigest()
+ library = Library(args.library)
+ with library.context() as ctx:
+ worker = ctx.load_bundle(data, 'sourceBuildGraph')
+ for text in (str(args.source.resolve()), str(args.store.resolve()), identity, 'app'):
+ argument = ctx.string(text)
+ applied = ctx.apply(worker, argument)
+ ctx.release(worker)
+ ctx.release(argument)
+ worker = applied
+ program = envelope(ctx, worker)
+ outcome, result, cleanup = ctx.run_io(program)
+ assert outcome == 0 and ctx.kind(cleanup) == 0
+ assert ctx.kind(ctx.child(result, 0)) == 1, 'compiler returned error'
+ pair = ctx.child(ctx.child(result, 1), 0)
+ manifest = ctx.to_string(ctx.child(pair, 0))
+ receipt = ctx.to_string(ctx.child(pair, 1))
+ assert re.fullmatch('[0-9a-f]{64}', manifest)
+ assert re.fullmatch('[0-9a-f]{64}', receipt)
+ report = dict(seconds=time.perf_counter()-start, manifest=manifest, receipt=receipt,
+ peak_rss_native_units=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)
+ print(json.dumps(report))
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('library', type=Path)
+ parser.add_argument('compiler', type=Path)
+ parser.add_argument('--tricu', type=Path)
+ parser.add_argument('--directory', type=Path)
+ parser.add_argument('--child', action='store_true')
+ parser.add_argument('--source', type=Path)
+ parser.add_argument('--store', type=Path)
+ args = parser.parse_args()
+ if args.child:
+ embedded(args)
+ return
+ if args.tricu is None or args.directory is None:
+ parser.error('--tricu and --directory are required')
+ directory = args.directory.resolve()
+ directory.mkdir(parents=True, exist_ok=False)
+ source = directory/'source'
+ source.mkdir()
+ (source/'dep.tri').write_text('!export answer\nanswer = 41\n')
+ (source/'app.tri').write_text('!import dep as D\n!export main\nmain = D.answer\n')
+ ffi_store, cli_store = directory/'ffi-store', directory/'cli-store'
+ ffi_store.mkdir()
+ cli_bundle, ffi_bundle = directory/'cli.arboricx', directory/'ffi.arboricx'
+ reports = {}
+ for name in ('cli', 'ffi'):
+ start = time.perf_counter()
+ if name == 'cli':
+ command = [args.tricu, 'arboricx', 'compile', '--file', source/'app.tri',
+ '--store', cli_store, '--output', cli_bundle,
+ '+RTS', '-N1', '-M1024m', '-RTS']
+ else:
+ command = [sys.executable, Path(__file__).resolve(), args.library, args.compiler,
+ '--child', '--source', source, '--store', ffi_store]
+ completed = subprocess.run(list(map(str, command)), text=True,
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ timeout=120, check=True)
+ (directory/(name+'.log')).write_text(completed.stdout+completed.stderr)
+ reports[name] = dict(process_seconds=time.perf_counter()-start)
+ if name == 'ffi':
+ reports[name].update(json.loads(completed.stdout))
+ manifest, receipt = reports['ffi']['manifest'], reports['ffi']['receipt']
+ # Every source-owned published object must match the CLI job byte for byte.
+ # CLI also imports its packaged compiler, so its inventory is a superset.
+ objects = list((ffi_store/'objects').glob('*/*'))
+ assert objects, 'no compiler publications'
+ for path in objects:
+ relative = path.relative_to(ffi_store)
+ assert path.read_bytes() == (cli_store/relative).read_bytes(), relative
+ receipt_bytes = (ffi_store/'objects'/receipt[:3]/receipt).read_bytes()
+ assert hashlib.sha256(b'arboricx.build-receipt.v1\0'+receipt_bytes).hexdigest() == receipt
+ subprocess.run(list(map(str, [args.tricu, 'arboricx', 'export', '--module', manifest,
+ '--store', ffi_store, '--output', ffi_bundle])), check=True, timeout=30)
+ assert ffi_bundle.read_bytes() == cli_bundle.read_bytes(), 'program bundle mismatch'
+ with Library(args.library).context() as ctx:
+ worker = ctx.load_bundle(ffi_bundle.read_bytes(), 'main')
+ assert ctx.to_number(ctx.reduce(worker)) == 41
+ reports['published_objects_compared'] = len(objects)
+ reports['bundle_sha256'] = hashlib.sha256(ffi_bundle.read_bytes()).hexdigest()
+ reports['compiler_sha256'] = hashlib.sha256(args.compiler.read_bytes()).hexdigest()
+ reports['library_sha256'] = hashlib.sha256(args.library.read_bytes()).hexdigest()
+ reports['scope'] = 'cold two-module application build; not a compiler generation'
+ (directory/'report.json').write_text(json.dumps(reports, indent=2)+'\n')
+ print(json.dumps(reports, sort_keys=True))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/ext/haskell/include/tricu.h b/ext/haskell/include/tricu.h
@@ -0,0 +1,66 @@
+#ifndef TRICU_H
+#define TRICU_H
+#include <stddef.h>
+#include <stdint.h>
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* Native Haskell embedding ABI 1. See ext/haskell/README.md for lifecycle,
+ * demand, concurrency and resource limits. Every output is unchanged on failure.
+ * All pointers must be valid; distinct output parameters must not overlap.
+ * Input buffers are borrowed for the call; NULL is allowed only at length zero.
+ * Every successful handle output owns one root; inputs are borrowed. */
+typedef uint64_t tricu_context;
+typedef uint64_t tricu_handle;
+typedef uint32_t tricu_status;
+enum {
+ TRICU_OK = 0, TRICU_INVALID_CONTEXT = 1, TRICU_INVALID_HANDLE = 2,
+ TRICU_INVALID_DATA = 3, TRICU_TYPE_ERROR = 4,
+ TRICU_EXCEPTION = 5, TRICU_ID_EXHAUSTED = 6
+};
+enum { TRICU_LEAF = 0, TRICU_STEM = 1, TRICU_FORK = 2 };
+enum { TRICU_COMPLETED = 0, TRICU_FAILED = 1, TRICU_CANCELLED = 2 };
+
+/* Call once before other entrypoints; repeated calls are harmless. Owns RTS
+ * initialization: use in a process that has not already initialized GHC.
+ * Keep the library loaded until process exit. No shutdown entrypoint. */
+void tricu_init(void);
+uint32_t tricu_abi_version(void);
+tricu_status tricu_context_new(tricu_context *out);
+tricu_status tricu_context_free(tricu_context ctx);
+tricu_status tricu_retain(tricu_context ctx, tricu_handle value, tricu_handle *out);
+tricu_status tricu_release(tricu_context ctx, tricu_handle value);
+tricu_status tricu_leaf(tricu_context ctx, tricu_handle *out);
+tricu_status tricu_stem(tricu_context ctx, tricu_handle child, tricu_handle *out);
+tricu_status tricu_fork(tricu_context ctx, tricu_handle left, tricu_handle right, tricu_handle *out);
+/* Application constructs a suspension; reduce demands only its outer constructor.
+ * Inspection and decoding also demand the portions they observe. */
+tricu_status tricu_apply(tricu_context ctx, tricu_handle fn, tricu_handle arg, tricu_handle *out);
+tricu_status tricu_reduce(tricu_context ctx, tricu_handle value, tricu_handle *out);
+tricu_status tricu_kind(tricu_context ctx, tricu_handle value, uint32_t *out);
+tricu_status tricu_child(tricu_context ctx, tricu_handle value, uint32_t index, tricu_handle *out);
+tricu_status tricu_from_u64(tricu_context ctx, uint64_t value, tricu_handle *out);
+tricu_status tricu_to_bool(tricu_context ctx, tricu_handle value, uint32_t *out);
+tricu_status tricu_to_u64(tricu_context ctx, tricu_handle value, uint64_t *out);
+tricu_status tricu_from_bytes(tricu_context ctx, const uint8_t *bytes, size_t len, tricu_handle *out);
+tricu_status tricu_from_utf8(tricu_context ctx, const uint8_t *bytes, size_t len, tricu_handle *out);
+tricu_status tricu_to_bytes(tricu_context ctx, tricu_handle value, uint8_t **out, size_t *len);
+tricu_status tricu_to_utf8(tricu_context ctx, tricu_handle value, uint8_t **out, size_t *len);
+/* Verified Arboricx bundle; explicit UTF-8 export name, no implicit selection. */
+tricu_status tricu_load_bundle(tricu_context ctx, const uint8_t *bytes, size_t len,
+ const uint8_t *name, size_t name_len, tricu_handle *out);
+/* Runs a canonical tricuIO v2 envelope with OS process authority. Task outcome
+ * is data: value is completion value or opaque failure/cancellation reason;
+ * cleanup is a list of IO-v2 problems. All three outputs publish atomically. */
+tricu_status tricu_run_io(tricu_context ctx, tricu_handle program,
+ uint32_t *outcome, tricu_handle *value, tricu_handle *cleanup);
+/* Last boundary error, retained across successes. UTF-8, length-delimited.
+ * Empty initially. Returned buffers are independent of contexts and must be freed
+ * exactly once with tricu_buffer_free, including empty buffers. */
+tricu_status tricu_last_error(tricu_context ctx, uint8_t **out, size_t *len);
+void tricu_buffer_free(uint8_t *buffer);
+#ifdef __cplusplus
+}
+#endif
+#endif
diff --git a/ext/haskell/package_runtime.py b/ext/haskell/package_runtime.py
@@ -0,0 +1,29 @@
+#!/usr/bin/env python3
+"""Install the ELF library's loaded Haskell runtime dependencies beside it.
+
+GHC's package environment points at a compiler-sized symlink farm. The embedding
+needs the resolved shared objects, not that development environment. Native
+libraries retain their ordinary Nix runtime paths. Run only inside the Nix build.
+"""
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+
+library = Path(sys.argv[1]).resolve()
+loaded = subprocess.check_output(['ldd', str(library)], text=True)
+native_paths = set()
+haskell = []
+for line in loaded.splitlines():
+ parts = line.split()
+ if len(parts) >= 3 and parts[1] == '=>' and parts[2].startswith('/'):
+ path = Path(parts[2]).resolve()
+ if parts[0].startswith('libHS'):
+ target = library.parent/parts[0]
+ shutil.copyfile(path, target)
+ haskell.append(target)
+ else:
+ native_paths.add(str(path.parent))
+rpath = ':'.join(['$ORIGIN', *sorted(native_paths)])
+for path in [library, *haskell]:
+ subprocess.run(['patchelf', '--set-rpath', rpath, str(path)], check=True)
diff --git a/ext/haskell/python/pyproject.toml b/ext/haskell/python/pyproject.toml
@@ -0,0 +1,12 @@
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "tricu-runtime"
+version = "2026.10.24"
+description = "Lazy tree calculus and tricu IO through the native Haskell C ABI"
+requires-python = ">=3.10"
+
+[tool.setuptools]
+py-modules = ["tricu"]
diff --git a/ext/haskell/python/tricu.py b/ext/haskell/python/tricu.py
@@ -0,0 +1,298 @@
+"""Small ctypes binding for native Haskell ABI 1; handles are owned integers.
+
+Use Context as a context manager. Release intermediate handles in long-lived
+contexts; close releases all remaining roots. Returned Python bytes own their data.
+"""
+import ctypes as C
+from pathlib import Path
+import os
+from threading import Lock
+
+_loaded_path = None
+_loaded_native = None
+_load_lock = Lock()
+
+
+def find_library():
+ override = os.environ.get("TRICU_LIB")
+ if override:
+ path = Path(override).resolve()
+ if not path.is_file():
+ raise FileNotFoundError(f"TRICU_LIB not found: {path}")
+ return path
+ for directory in (Path(__file__).parent, Path(__file__).parents[2]/"lib"):
+ for name in ("libtricu-haskell.so", "libtricu-haskell.dylib", "tricu-haskell.dll"):
+ path = directory/name
+ if path.is_file():
+ return path.resolve()
+ raise FileNotFoundError("Set TRICU_LIB to the Haskell embedding library")
+
+U64, U32, SIZE = C.c_uint64, C.c_uint32, C.c_size_t
+PTR = C.c_void_p
+P64, P32 = C.POINTER(U64), C.POINTER(U32)
+
+
+class TricuError(RuntimeError):
+ def __init__(self, status, message):
+ super().__init__(f"tricu status {status}: {message}")
+ self.status = status
+
+
+class Library:
+ def __init__(self, path=None):
+ global _loaded_path, _loaded_native
+ resolved = Path(path).resolve() if path is not None else find_library()
+ with _load_lock:
+ if _loaded_path is not None and resolved != _loaded_path:
+ raise RuntimeError('One Haskell embedding library per process')
+ if _loaded_native is None:
+ _loaded_native = C.CDLL(str(resolved))
+ _loaded_path = resolved
+ self.native = _loaded_native
+ signatures = {
+ 'init': (None, []), 'abi_version': (U32, []),
+ 'context_new': (U32, [P64]), 'context_free': (U32, [U64]),
+ 'retain': (U32, [U64, U64, P64]), 'release': (U32, [U64, U64]),
+ 'leaf': (U32, [U64, P64]), 'stem': (U32, [U64, U64, P64]),
+ 'fork': (U32, [U64, U64, U64, P64]),
+ 'apply': (U32, [U64, U64, U64, P64]),
+ 'reduce': (U32, [U64, U64, P64]), 'kind': (U32, [U64, U64, P32]),
+ 'child': (U32, [U64, U64, U32, P64]),
+ 'to_bool': (U32, [U64, U64, P32]),
+ 'from_u64': (U32, [U64, U64, P64]), 'to_u64': (U32, [U64, U64, P64]),
+ 'from_bytes': (U32, [U64, PTR, SIZE, P64]),
+ 'from_utf8': (U32, [U64, PTR, SIZE, P64]),
+ 'to_bytes': (U32, [U64, U64, C.POINTER(PTR), C.POINTER(SIZE)]),
+ 'to_utf8': (U32, [U64, U64, C.POINTER(PTR), C.POINTER(SIZE)]),
+ 'load_bundle': (U32, [U64, PTR, SIZE, PTR, SIZE, P64]),
+ 'run_io': (U32, [U64, U64, P32, P64, P64]),
+ 'last_error': (U32, [U64, C.POINTER(PTR), C.POINTER(SIZE)]),
+ 'buffer_free': (None, [PTR]),
+ }
+ for name, (result, args) in signatures.items():
+ fn = getattr(self.native, 'tricu_' + name)
+ fn.restype, fn.argtypes = result, args
+ self.native.tricu_init()
+ if self.native.tricu_abi_version() != 1:
+ raise RuntimeError('unsupported Haskell embedding ABI')
+
+ def context(self):
+ return Context(self)
+
+
+class Context:
+ def __init__(self, library):
+ self.library, self.native = library, library.native
+ out = U64()
+ status = self.native.tricu_context_new(C.byref(out))
+ if status:
+ raise TricuError(status, 'creating context')
+ self.identity = out.value
+
+ def _check(self, status):
+ if status:
+ ptr, size = PTR(), SIZE()
+ diagnostic = self.native.tricu_last_error(self.identity, C.byref(ptr), C.byref(size))
+ message = self._copy(ptr, size).decode('utf-8') if not diagnostic else 'invalid context'
+ raise TricuError(status, message)
+
+ def _copy(self, ptr, size):
+ try:
+ return C.string_at(ptr, size.value)
+ finally:
+ self.native.tricu_buffer_free(ptr)
+
+ def _out(self, name, *args, output=U64):
+ out = output()
+ self._check(getattr(self.native, 'tricu_' + name)(self.identity, *args, C.byref(out)))
+ return out.value
+
+ def close(self):
+ if self.identity:
+ self._check(self.native.tricu_context_free(self.identity))
+ self.identity = 0
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_):
+ self.close()
+
+ def scope(self):
+ """Own temporary handles created/adopted through the returned scope."""
+ return HandleScope(self)
+
+ def apply_many(self, function, *arguments):
+ """Borrow inputs; return one owned curried application (or retained fn)."""
+ current = self.retain(function)
+ try:
+ for argument in arguments:
+ following = self.apply(current, argument)
+ self.release(current)
+ current = following
+ return current
+ except BaseException:
+ self.release(current)
+ raise
+
+ def release(self, handle):
+ self._check(self.native.tricu_release(self.identity, handle))
+
+ def retain(self, handle):
+ return self._out('retain', handle)
+
+ def leaf(self):
+ return self._out('leaf')
+
+ def stem(self, child):
+ return self._out('stem', child)
+
+ def fork(self, left, right):
+ return self._out('fork', left, right)
+
+ def apply(self, fn, arg):
+ return self._out('apply', fn, arg)
+
+ def reduce(self, value):
+ return self._out('reduce', value)
+
+ def kind(self, value):
+ return self._out('kind', value, output=U32)
+
+ def child(self, value, index):
+ if not isinstance(index, int) or not 0 <= index <= 0xffffffff:
+ raise ValueError('invalid child index')
+ return self._out('child', value, index)
+
+ def number(self, value):
+ if not isinstance(value, int) or not 0 <= value < 2**64:
+ raise ValueError('number must fit uint64')
+ return self._out('from_u64', value)
+
+ def to_bool(self, handle):
+ return bool(self._out('to_bool', handle, output=U32))
+
+ def to_number(self, handle):
+ return self._out('to_u64', handle)
+
+ def bytes(self, value):
+ return self._out('from_bytes', value, len(value))
+
+ def string(self, value):
+ encoded = value.encode('utf-8')
+ return self._out('from_utf8', encoded, len(encoded))
+
+ def _buffer(self, name, handle):
+ ptr, size = PTR(), SIZE()
+ self._check(getattr(self.native, 'tricu_' + name)(
+ self.identity, handle, C.byref(ptr), C.byref(size)))
+ return self._copy(ptr, size)
+
+ def to_bytes(self, handle):
+ return self._buffer('to_bytes', handle)
+
+ def to_string(self, handle):
+ return self._buffer('to_utf8', handle).decode('utf-8')
+
+ def load_bundle(self, data, name):
+ encoded = name.encode('utf-8')
+ return self._out('load_bundle', data, len(data), encoded, len(encoded))
+
+ def run_io(self, program):
+ """Return (outcome, value_handle, cleanup_handle); payloads stay lazy."""
+ outcome, value, cleanup = U32(), U64(), U64()
+ self._check(self.native.tricu_run_io(self.identity, program,
+ C.byref(outcome), C.byref(value), C.byref(cleanup)))
+ return outcome.value, value.value, cleanup.value
+
+
+class HandleScope:
+ """Deterministic roots for one synchronous operation in a reusable context.
+
+ Context methods remain usable independently. Only handles returned by this
+ scope's constructors/operations or explicitly adopted with own() belong here.
+ Inputs are borrowed. detach() transfers a root back to the caller.
+ """
+ def __init__(self, context):
+ self.context = context
+ self._handles = {}
+ self._closed = False
+
+ def __enter__(self):
+ self._check_open()
+ return self
+
+ def __exit__(self, kind, error, traceback):
+ try:
+ self.close()
+ except Exception as cleanup:
+ if error is None:
+ raise
+ if hasattr(error, 'add_note'):
+ error.add_note(f'Handle scope cleanup failed: {cleanup}')
+
+ def _check_open(self):
+ if self._closed:
+ raise RuntimeError('handle scope is closed')
+
+ def own(self, handle):
+ """Adopt one existing independently owned root, without retaining it."""
+ self._check_open()
+ if handle in self._handles:
+ raise ValueError('handle already owned by scope')
+ self._handles[handle] = None
+ return handle
+
+ def detach(self, handle):
+ self._check_open()
+ if handle not in self._handles:
+ raise ValueError('handle is not owned by scope')
+ del self._handles[handle]
+ return handle
+
+ def release(self, handle):
+ self._check_open()
+ if handle not in self._handles:
+ raise ValueError('handle is not owned by scope')
+ self.context.release(handle)
+ del self._handles[handle]
+
+ def close(self):
+ if self._closed:
+ return
+ self._closed = True
+ handles, self._handles = self._handles, {}
+ first_error = None
+ if self.context.identity:
+ for handle in reversed(handles):
+ try:
+ self.context.release(handle)
+ except Exception as error:
+ if first_error is None:
+ first_error = error
+ if first_error is not None:
+ raise first_error
+
+ def _create(self, method, *args):
+ self._check_open()
+ return self.own(getattr(self.context, method)(*args))
+
+ def leaf(self): return self._create('leaf')
+ def stem(self, child): return self._create('stem', child)
+ def fork(self, left, right): return self._create('fork', left, right)
+ def retain(self, value): return self._create('retain', value)
+ def reduce(self, value): return self._create('reduce', value)
+ def child(self, value, index): return self._create('child', value, index)
+ def number(self, value): return self._create('number', value)
+ def string(self, value): return self._create('string', value)
+ def bytes(self, value): return self._create('bytes', value)
+ def apply(self, function, argument): return self._create('apply', function, argument)
+ def apply_many(self, function, *arguments): return self._create('apply_many', function, *arguments)
+ def load_bundle(self, data, name): return self._create('load_bundle', data, name)
+
+ def run_io(self, program):
+ self._check_open()
+ outcome, value, cleanup = self.context.run_io(program)
+ self.own(value)
+ self.own(cleanup)
+ return outcome, value, cleanup
diff --git a/ext/haskell/reload.c b/ext/haskell/reload.c
@@ -0,0 +1,24 @@
+#include "tricu.h"
+#include <assert.h>
+#include <dlfcn.h>
+#include <stdint.h>
+int main(int argc, char **argv) {
+ assert(argc == 2);
+ uint64_t previous = 0;
+ for (int i = 0; i < 50; ++i) {
+ void *library = dlopen(argv[1], RTLD_NOW | RTLD_LOCAL);
+ assert(library);
+ void (*init)(void) = dlsym(library, "tricu_init");
+ tricu_status (*create)(tricu_context *) = dlsym(library, "tricu_context_new");
+ tricu_status (*destroy)(tricu_context) = dlsym(library, "tricu_context_free");
+ assert(init && create && destroy);
+ init();
+ tricu_context context;
+ assert(create(&context) == TRICU_OK);
+ assert(context > previous); /* The same process runtime survives dlclose. */
+ previous = context;
+ assert(destroy(context) == TRICU_OK);
+ assert(dlclose(library) == 0);
+ }
+ return 0;
+}
diff --git a/ext/haskell/src/Embedding.hs b/ext/haskell/src/Embedding.hs
@@ -0,0 +1,265 @@
+{-# LANGUAGE ForeignFunctionInterface #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+-- Native boundary only: evaluation and IO live in the existing host modules.
+module Embedding where
+
+import Control.Concurrent.MVar
+import Control.Exception
+import Control.Monad (unless)
+import Control.Monad.State.Strict
+import Data.IORef
+import qualified Data.Map.Strict as M
+import qualified Data.Map.Lazy as L
+import qualified Data.ByteString as BS
+import qualified Data.Text as Text
+import qualified Data.Text.Encoding as Text
+import qualified Data.Vector as V
+import Data.Word
+import Foreign hiding (void, toBool)
+import Foreign.C.Types
+import System.IO.Unsafe (unsafePerformIO)
+import Runtime hiding (toBytes)
+import qualified Runtime.Value as Value
+import qualified Wire
+import qualified IODriver
+import qualified IOV2.Driver as IO
+import qualified IOV2.Protocol as Protocol
+
+-- A process-wide nonreusing namespace covers contexts AND roots. No raw stable
+-- pointer crosses the boundary; released, foreign and stale IDs are map misses.
+{-# NOINLINE identities #-}
+identities :: IORef Integer
+identities = unsafePerformIO (newIORef 1)
+{-# NOINLINE contexts #-}
+contexts :: MVar (M.Map Word64 (MVar (Maybe Context)))
+contexts = unsafePerformIO (newMVar M.empty)
+-- Force map updates while keeping their tree values lazy: release removes the
+-- root now, and application publication never forces the suspended computation.
+data Context = Context { roots :: !(L.Map Word64 T), lastError :: !BS.ByteString }
+data Fault = Fault Word32 String deriving Show
+instance Exception Fault
+
+boundaryStatus :: SomeException -> IO Word32
+boundaryStatus e = pure $ case fromException e of
+ Just (Fault code _) -> code
+ Nothing -> 5
+
+type Call = StateT Context IO
+fault :: Word32 -> String -> IO a
+fault code message = throwIO (Fault code message)
+require :: Bool -> String -> IO ()
+require condition message = unless condition (fault 3 message)
+newIdentity :: IO Word64
+newIdentity = do
+ n <- atomicModifyIORef' identities (\n -> (n+1,n))
+ if n > toInteger (maxBound :: Word64) then fault 6 "identity namespace exhausted"
+ else pure (fromInteger n)
+
+-- Context locking serializes lazy evaluation as well as registry changes.
+-- Destroy marks the cell closed before removing its name; racing lookups cannot
+-- resurrect it. Exceptions roll back root-table publication, not IO effects or
+-- evaluation already performed on shared Haskell thunks.
+withContext :: Word64 -> Call (IO ()) -> IO Word32
+withContext identity action = handle boundaryStatus $ mask $ \restore -> do
+ found <- withMVar contexts (pure . M.lookup identity)
+ case found of
+ Nothing -> pure 1
+ Just cell -> modifyMVar cell $ \current -> case current of
+ Nothing -> pure (Nothing, 1)
+ Just context -> do
+ result <- try $ do
+ (commit, updated) <- restore (runStateT action context)
+ commit
+ pure updated
+ case result of
+ Right updated -> pure (Just updated, 0)
+ Left (e :: SomeException) -> do
+ let (code, message) = case fromException e of
+ Just (Fault c m) -> (c,m)
+ Nothing -> (5, displayException e)
+ -- Force bounded diagnostic bytes inside the exception handler.
+ let bytes = Text.encodeUtf8 (Text.pack (take 4096 message))
+ _ <- evaluate (BS.length bytes)
+ pure (Just context {lastError = bytes}, code)
+
+lookupRoot :: Word64 -> Call T
+lookupRoot identity = do
+ table <- gets roots
+ maybe (liftIO (fault 2 "invalid or foreign handle")) pure (L.lookup identity table)
+publish :: Ptr Word64 -> T -> Call (IO ())
+publish out tree = do
+ liftIO (require (out /= nullPtr) "null output")
+ identity <- liftIO newIdentity
+ modify' (\c -> c { roots = L.insert identity tree (roots c) })
+ pure (poke out identity)
+readBytes :: Ptr Word8 -> CSize -> IO BS.ByteString
+readBytes ptr len = do
+ require (toInteger len <= toInteger (maxBound :: Int)) "buffer too large"
+ require (ptr /= nullPtr || len == 0) "null input buffer"
+ if len == 0 then pure BS.empty else BS.packCStringLen (castPtr ptr, fromIntegral len)
+checked :: Word32 -> Either String a -> IO a
+checked code = either (fault code) pure
+readText :: Ptr Word8 -> CSize -> IO Text.Text
+readText ptr len = readBytes ptr len >>= checked 3 . either (Left . show) Right . Text.decodeUtf8'
+outputBuffer :: Ptr (Ptr Word8) -> Ptr CSize -> BS.ByteString -> IO ()
+outputBuffer out len bytes = do
+ require (out /= nullPtr && len /= nullPtr && castPtr out /= len) "invalid buffer outputs"
+ -- Allocate even for empty output, giving every successful buffer one owner.
+ bracketOnError (mallocBytes (max 1 (BS.length bytes))) free $ \ptr -> do
+ BS.useAsCStringLen bytes $ \(source,n) -> copyBytes ptr (castPtr source) n
+ poke out ptr
+ poke len (fromIntegral (BS.length bytes))
+
+abiVersion :: IO Word32
+abiVersion = pure 1
+contextNew :: Ptr Word64 -> IO Word32
+contextNew out = handle boundaryStatus $ mask_ $ do
+ if out == nullPtr then pure 3 else do
+ identity <- newIdentity
+ cell <- newMVar (Just (Context L.empty BS.empty))
+ modifyMVar_ contexts (pure . M.insert identity cell)
+ poke out identity
+ pure 0
+contextFree :: Word64 -> IO Word32
+contextFree identity = handle boundaryStatus $ mask_ $ do
+ found <- withMVar contexts (pure . M.lookup identity)
+ case found of
+ Nothing -> pure 1
+ Just cell -> do
+ status <- modifyMVar cell $ \current -> pure (Nothing, maybe 1 (const 0) current)
+ modifyMVar_ contexts (pure . M.delete identity)
+ pure status
+retain :: Word64 -> Word64 -> Ptr Word64 -> IO Word32
+retain ctx value out = withContext ctx (lookupRoot value >>= publish out)
+release :: Word64 -> Word64 -> IO Word32
+release ctx value = withContext ctx $ do
+ _ <- lookupRoot value
+ modify' (\c -> c {roots = L.delete value (roots c)})
+ pure (pure ())
+leaf :: Word64 -> Ptr Word64 -> IO Word32
+leaf ctx out = withContext ctx (publish out Leaf)
+stem :: Word64 -> Word64 -> Ptr Word64 -> IO Word32
+stem ctx value out = withContext ctx (lookupRoot value >>= publish out . Stem)
+binary :: (T -> T -> T) -> Word64 -> Word64 -> Word64 -> Ptr Word64 -> IO Word32
+binary constructor ctx left right out = withContext ctx $ do
+ a <- lookupRoot left
+ b <- lookupRoot right
+ publish out (constructor a b)
+fork, application :: Word64 -> Word64 -> Word64 -> Ptr Word64 -> IO Word32
+fork = binary Fork
+application = binary apply
+reduce :: Word64 -> Word64 -> Ptr Word64 -> IO Word32
+reduce ctx value out = withContext ctx $ do
+ liftIO (require (out /= nullPtr) "null output")
+ tree <- lookupRoot value >>= liftIO . evaluate
+ publish out tree
+kind :: Word64 -> Word64 -> Ptr Word32 -> IO Word32
+kind ctx value out = withContext ctx $ do
+ liftIO (require (out /= nullPtr) "null output")
+ tree <- lookupRoot value
+ tag <- liftIO $ evaluate $ case tree of Leaf -> 0; Stem _ -> 1; Fork _ _ -> 2
+ pure (poke out tag)
+child :: Word64 -> Word64 -> Word32 -> Ptr Word64 -> IO Word32
+child ctx value index out = withContext ctx $ do
+ tree <- lookupRoot value
+ case (tree,index) of
+ (Stem a,0) -> publish out a
+ (Fork a _,0) -> publish out a
+ (Fork _ b,1) -> publish out b
+ _ -> liftIO (fault 4 "constructor has no such child")
+fromU64 :: Word64 -> Word64 -> Ptr Word64 -> IO Word32
+fromU64 ctx n out = withContext ctx (publish out (ofNumber (toInteger n)))
+toBool :: Word64 -> Word64 -> Ptr Word32 -> IO Word32
+toBool ctx value out = withContext ctx $ do
+ liftIO (require (out /= nullPtr) "null output")
+ tree <- lookupRoot value
+ tag <- case tree of
+ Leaf -> pure 0
+ Stem Leaf -> pure 1
+ _ -> liftIO (fault 4 "invalid boolean")
+ pure (poke out tag)
+toU64 :: Word64 -> Word64 -> Ptr Word64 -> IO Word32
+toU64 ctx value out = withContext ctx $ do
+ liftIO (require (out /= nullPtr) "null output")
+ tree <- lookupRoot value
+ n <- liftIO (checked 4 (toNumber tree))
+ liftIO $ do
+ require (n <= toInteger (maxBound :: Word64)) "number exceeds uint64"
+ pure (poke out (fromInteger n))
+fromBytes, fromUtf8 :: Word64 -> Ptr Word8 -> CSize -> Ptr Word64 -> IO Word32
+fromBytes ctx ptr len out = withContext ctx $ do
+ bytes <- liftIO (readBytes ptr len)
+ publish out (ofBytes bytes)
+fromUtf8 ctx ptr len out = withContext ctx $ do
+ text <- liftIO (readText ptr len)
+ publish out (ofString (Text.unpack text))
+toBytes, toUtf8 :: Word64 -> Word64 -> Ptr (Ptr Word8) -> Ptr CSize -> IO Word32
+toBytes ctx value out len = withContext ctx $ do
+ tree <- lookupRoot value
+ bytes <- liftIO (checked 4 (toBytesValue tree))
+ _ <- liftIO (evaluate (BS.length bytes))
+ pure (outputBuffer out len bytes)
+ where toBytesValue = Value.toBytes
+toUtf8 ctx value out len = withContext ctx $ do
+ tree <- lookupRoot value
+ text <- liftIO (checked 4 (toString tree))
+ let bytes = Text.encodeUtf8 (Text.pack text)
+ _ <- liftIO (evaluate (BS.length bytes))
+ pure (outputBuffer out len bytes)
+loadBundle :: Word64 -> Ptr Word8 -> CSize -> Ptr Word8 -> CSize -> Ptr Word64 -> IO Word32
+loadBundle ctx ptr len namePtr nameLen out = withContext ctx $ do
+ bytes <- liftIO (readBytes ptr len)
+ name <- liftIO (readText namePtr nameLen)
+ bundle <- liftIO (checked 3 (Wire.decodeBundle bytes))
+ liftIO (checked 3 (Wire.verifyBundle bundle))
+ exported <- case filter ((== name) . Wire.exportName) (Wire.manifestExports (Wire.bundleManifest bundle)) of
+ [value] -> pure value
+ _ -> liftIO (fault 3 "bundle export not found")
+ liftIO (require (Wire.exportKind exported == "term" && Wire.exportAbi exported == "arboricx.abi.tree.v1") "unsupported export ABI")
+ let terms = Wire.reconstructBundleTerms (Wire.bundleNodes bundle)
+ publish out (terms V.! fromIntegral (Wire.exportRoot exported))
+runIO :: Word64 -> Word64 -> Ptr Word32 -> Ptr Word64 -> Ptr Word64 -> IO Word32
+runIO ctx program outcome out cleanup = withContext ctx $ do
+ liftIO (require (outcome /= nullPtr && out /= nullPtr && cleanup /= nullPtr &&
+ castPtr outcome /= out && castPtr outcome /= cleanup && out /= cleanup) "invalid IO outputs")
+ tree <- lookupRoot program
+ (version,action) <- liftIO (checked 3 (IODriver.checkIOSentinel tree))
+ liftIO (require (version == 2) "unsupported IO version")
+ result <- liftIO (IO.runDetailed action)
+ let (tag,value,problems) = case result of
+ IO.Completed v -> (0,v,[])
+ IO.Failed v ps -> (1,v,ps)
+ IO.Cancelled v ps -> (2,v,ps)
+ -- Reserve both IDs before publishing any output. Neither payload is forced.
+ valueId <- liftIO newIdentity
+ cleanupId <- liftIO newIdentity
+ modify' (\c -> c {roots = L.insert valueId value $
+ L.insert cleanupId (ofList (map Protocol.problemTree problems)) (roots c)})
+ pure (poke outcome tag >> poke out valueId >> poke cleanup cleanupId)
+lastErrorBytes :: Word64 -> Ptr (Ptr Word8) -> Ptr CSize -> IO Word32
+lastErrorBytes ctx out len = withContext ctx $ do
+ bytes <- gets lastError
+ pure (outputBuffer out len bytes)
+
+foreign export ccall "tricu_abi_version" abiVersion :: IO Word32
+foreign export ccall "tricu_context_new" contextNew :: Ptr Word64 -> IO Word32
+foreign export ccall "tricu_context_free" contextFree :: Word64 -> IO Word32
+foreign export ccall "tricu_retain" retain :: Word64 -> Word64 -> Ptr Word64 -> IO Word32
+foreign export ccall "tricu_release" release :: Word64 -> Word64 -> IO Word32
+foreign export ccall "tricu_leaf" leaf :: Word64 -> Ptr Word64 -> IO Word32
+foreign export ccall "tricu_stem" stem :: Word64 -> Word64 -> Ptr Word64 -> IO Word32
+foreign export ccall "tricu_fork" fork :: Word64 -> Word64 -> Word64 -> Ptr Word64 -> IO Word32
+foreign export ccall "tricu_apply" application :: Word64 -> Word64 -> Word64 -> Ptr Word64 -> IO Word32
+foreign export ccall "tricu_reduce" reduce :: Word64 -> Word64 -> Ptr Word64 -> IO Word32
+foreign export ccall "tricu_kind" kind :: Word64 -> Word64 -> Ptr Word32 -> IO Word32
+foreign export ccall "tricu_child" child :: Word64 -> Word64 -> Word32 -> Ptr Word64 -> IO Word32
+foreign export ccall "tricu_from_u64" fromU64 :: Word64 -> Word64 -> Ptr Word64 -> IO Word32
+foreign export ccall "tricu_to_bool" toBool :: Word64 -> Word64 -> Ptr Word32 -> IO Word32
+foreign export ccall "tricu_to_u64" toU64 :: Word64 -> Word64 -> Ptr Word64 -> IO Word32
+foreign export ccall "tricu_from_bytes" fromBytes :: Word64 -> Ptr Word8 -> CSize -> Ptr Word64 -> IO Word32
+foreign export ccall "tricu_from_utf8" fromUtf8 :: Word64 -> Ptr Word8 -> CSize -> Ptr Word64 -> IO Word32
+foreign export ccall "tricu_to_bytes" toBytes :: Word64 -> Word64 -> Ptr (Ptr Word8) -> Ptr CSize -> IO Word32
+foreign export ccall "tricu_to_utf8" toUtf8 :: Word64 -> Word64 -> Ptr (Ptr Word8) -> Ptr CSize -> IO Word32
+foreign export ccall "tricu_load_bundle" loadBundle :: Word64 -> Ptr Word8 -> CSize -> Ptr Word8 -> CSize -> Ptr Word64 -> IO Word32
+foreign export ccall "tricu_run_io" runIO :: Word64 -> Word64 -> Ptr Word32 -> Ptr Word64 -> Ptr Word64 -> IO Word32
+foreign export ccall "tricu_last_error" lastErrorBytes :: Word64 -> Ptr (Ptr Word8) -> Ptr CSize -> IO Word32
diff --git a/ext/haskell/src/init.c b/ext/haskell/src/init.c
@@ -0,0 +1,21 @@
+#include <Rts.h>
+#include <pthread.h>
+#include <stdlib.h>
+#include "tricu.h"
+
+static pthread_once_t initialized = PTHREAD_ONCE_INIT;
+static void initialize(void) {
+ int argc = 1;
+ char name[] = "tricu-embedded";
+ char *arguments[] = {name, NULL};
+ char **argv = arguments;
+ RtsConfig config = defaultRtsConfig;
+ config.rts_hs_main = 0;
+ config.rts_opts_enabled = RtsOptsIgnoreAll;
+ config.rts_opts = "-N1 --install-signal-handlers=no";
+ hs_init_ghc(&argc, &argv, config);
+}
+void tricu_init(void) {
+ if (pthread_once(&initialized, initialize) != 0) abort();
+}
+void tricu_buffer_free(uint8_t *buffer) { free(buffer); }
diff --git a/ext/haskell/test_embedding.py b/ext/haskell/test_embedding.py
@@ -0,0 +1,230 @@
+#!/usr/bin/env python3
+"""Fresh-process ctypes ABI checks. Optional compiled copier exercises real IO."""
+import argparse
+import ctypes as C
+import concurrent.futures
+import json
+from pathlib import Path
+import tempfile
+import struct
+import time
+from tricu import Library, TricuError, U64, U32, PTR, SIZE
+
+
+def expect(status, operation):
+ try:
+ operation()
+ except TricuError as e:
+ assert e.status == status, e
+ else:
+ raise AssertionError(f'expected status {status}')
+
+
+def envelope(ctx, action, version=2):
+ return ctx.fork(ctx.string('tricuIO'), ctx.fork(ctx.number(version), action))
+
+
+def checks(lib, fixtures):
+ native = lib.native
+ assert native.tricu_context_new(None) == 3
+ with lib.context() as ctx, lib.context() as other:
+ assert ctx.identity != other.identity
+ leaf = ctx.leaf()
+ stem = ctx.stem(leaf)
+ fork = ctx.fork(leaf, stem)
+ assert [ctx.kind(x) for x in (leaf, stem, fork)] == [0, 1, 2]
+ child = ctx.child(fork, 1)
+ ctx.release(fork)
+ assert ctx.kind(child) == 1
+ alias = ctx.retain(child)
+ assert alias != child
+ ctx.release(child)
+ expect(2, lambda: ctx.kind(child))
+ expect(2, lambda: other.kind(alias))
+ expect(4, lambda: ctx.child(leaf, 0))
+ expect(4, lambda: ctx.to_number(stem))
+ expect(4, lambda: ctx.to_bytes(stem))
+ expect(2, lambda: ctx.release(0))
+ assert ctx.kind(alias) == 1
+ for value in (0, 1, 42, 2**64-1):
+ assert ctx.to_number(ctx.number(value)) == value
+ # This raw tree is 2**64, not a representable uint64.
+ huge = ctx.number(1)
+ for _ in range(64):
+ huge = ctx.fork(leaf, huge)
+ expect(3, lambda: ctx.to_number(huge))
+ for body in (b'', b'\x00a\xff\x80', bytes(range(256))*16):
+ assert ctx.to_bytes(ctx.bytes(body)) == body
+ for text in ('', 'hello\x00world', 'tree 🌳 café\r\n\ufeff'):
+ assert ctx.to_string(ctx.string(text)) == text
+ for bad in (b'\xff', b'\xc0\x80', b'\xed\xa0\x80', b'\xf4\x90\x80\x80'):
+ output = U64(12345)
+ assert native.tricu_from_utf8(ctx.identity, bad, len(bad), C.byref(output)) == 3
+ assert output.value == 12345
+ # Invalid scalar value inside an otherwise proper string.
+ surrogate = ctx.fork(ctx.number(0xd800), leaf)
+ expect(4, lambda: ctx.to_string(surrogate))
+ expect(3, lambda: ctx.load_bundle(b'bad bundle', 'id'))
+ data = (fixtures/'id.arboricx').read_bytes()
+ expect(3, lambda: ctx.load_bundle(data, 'missing'))
+ corrupt = bytearray(data)
+ node_offset = struct.unpack('>IHHIQQI', data[64:96])[4]
+ corrupt[node_offset+12] = 255
+ expect(3, lambda: ctx.load_bundle(bytes(corrupt), 'id'))
+ worker = ctx.load_bundle(data, 'id')
+ value = ctx.string('from a portable bundle')
+ application = ctx.apply(worker, value)
+ ctx.release(worker)
+ ctx.release(value)
+ assert ctx.to_string(ctx.reduce(application)) == 'from a portable bundle'
+ append = ctx.load_bundle((fixtures/'append.arboricx').read_bytes(), 'append')
+ assert ctx.to_bytes(ctx.apply(ctx.apply(append, ctx.bytes(b'abc')), ctx.bytes(b'def'))) == b'abcdef'
+ for code, outcome in ((0, 0), (2, 1), (9, 2)):
+ program = envelope(ctx, ctx.fork(ctx.number(code), ctx.string('payload')))
+ actual, value, cleanup = ctx.run_io(program)
+ assert actual == outcome and ctx.to_string(value) == 'payload'
+ assert ctx.kind(cleanup) == 0
+ expect(3, lambda: ctx.run_io(leaf))
+ expect(3, lambda: ctx.run_io(envelope(ctx, leaf, version=1)))
+ # Malformed action is a task-level failure, not a native boundary error.
+ outcome, diagnostic, cleanup = ctx.run_io(envelope(ctx, leaf))
+ assert outcome == 1 and ctx.kind(diagnostic) == 2 and ctx.kind(cleanup) == 0
+ # IO outputs are checked before execution and all remain unchanged.
+ outcome, value, cleanup = U32(999), U64(998), U64(997)
+ assert native.tricu_run_io(ctx.identity, leaf, C.byref(outcome), C.byref(value), None) == 3
+ assert (outcome.value, value.value, cleanup.value) == (999, 998, 997)
+ assert native.tricu_run_io(ctx.identity, leaf, C.byref(outcome), C.byref(value), C.byref(value)) == 3
+ assert (outcome.value, value.value) == (999, 998)
+ # Failed calls preserve both buffer outputs, too.
+ ptr, size = PTR(123), SIZE(456)
+ assert native.tricu_to_bytes(ctx.identity, stem, C.byref(ptr), C.byref(size)) == 4
+ assert ptr.value == 123 and size.value == 456
+ assert native.tricu_from_bytes(ctx.identity, None, 1, C.byref(value)) == 3
+ assert value.value == 998
+ # Foreign buffers are independent of context roots and context lifetime.
+ assert native.tricu_to_utf8(ctx.identity, alias, C.byref(ptr), C.byref(size)) == 4
+ text = ctx.string('survives destruction')
+ assert native.tricu_to_utf8(ctx.identity, text, C.byref(ptr), C.byref(size)) == 0
+ old = ctx.identity
+ ctx.close()
+ try:
+ assert C.string_at(ptr, size.value) == b'survives destruction'
+ finally:
+ native.tricu_buffer_free(ptr)
+ assert native.tricu_context_free(old) == 1
+ assert native.tricu_leaf(old, C.byref(value)) == 1 and value.value == 998
+ for _ in range(100):
+ with lib.context() as ctx:
+ assert ctx.to_number(ctx.number(42)) == 42
+ # Calls release Python's GIL; both context isolation and same-context
+ # serialization are exercised with concurrent native entry.
+ def job(shared=None):
+ if shared is not None:
+ for _ in range(100):
+ handle = shared.number(17)
+ assert shared.to_number(handle) == 17
+ shared.release(handle)
+ else:
+ with lib.context() as context:
+ job(context)
+ with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
+ list(pool.map(lambda _: job(), range(8)))
+ with lib.context() as ctx:
+ list(pool.map(lambda _: job(ctx), range(8)))
+
+
+def copier(lib, bundle, body=None):
+ with tempfile.TemporaryDirectory(prefix='tricu-ffi-') as directory:
+ source, target = Path(directory)/'in', Path(directory)/'out'
+ if body is None:
+ body = bytes(range(256))*256
+ source.write_bytes(body)
+ with lib.context() as ctx:
+ worker = ctx.load_bundle(bundle.read_bytes(), 'copy')
+ descriptors = None
+ for _ in range(3):
+ program = ctx.apply(ctx.apply(worker, ctx.string(str(source))), ctx.string(str(target)))
+ outcome, result, cleanup = ctx.run_io(program)
+ assert outcome == 0 and ctx.kind(cleanup) == 0
+ # copy returns an ordinary successful Result wrapping unit.
+ assert ctx.kind(ctx.child(result, 0)) == 1
+ assert target.read_bytes() == body
+ if Path('/proc/self/fd').is_dir():
+ count = len(list(Path('/proc/self/fd').iterdir()))
+ if descriptors is not None:
+ assert count == descriptors, 'file descriptors retained across IO runs'
+ descriptors = count
+ ctx.release(program)
+ ctx.release(result)
+ ctx.release(cleanup)
+ # Structured child cancellation settles transferred reader/writer
+ # resources before returning the child's outcome as ordinary data.
+ cancelled = ctx.load_bundle(bundle.read_bytes(), 'cancelledCopy')
+ program = ctx.apply(ctx.apply(cancelled, ctx.string(str(source))), ctx.string(str(target)))
+ outcome, result, cleanup = ctx.run_io(program)
+ assert outcome == 0 and ctx.kind(cleanup) == 0
+ assert ctx.to_string(ctx.child(result, 0)) == 'cancelled'
+ rest = ctx.child(result, 1)
+ assert ctx.to_string(ctx.child(rest, 0)) == 'requested'
+ assert target.read_bytes() == body[:4096]
+ # Reader acquisition succeeds, then writer acquisition fails. Scope
+ # cleanup must release the acquired reader before returning.
+ missing = Path(directory)/'missing-parent'/'out'
+ program = ctx.apply(ctx.apply(worker, ctx.string(str(source))), ctx.string(str(missing)))
+ outcome, result, cleanup = ctx.run_io(program)
+ assert outcome == 0 and ctx.kind(ctx.child(result, 0)) == 0
+ assert not missing.exists()
+ if descriptors is not None:
+ assert len(list(Path('/proc/self/fd').iterdir())) == descriptors
+ source.unlink()
+ target.unlink()
+ program = ctx.apply(ctx.apply(worker, ctx.string(str(source))), ctx.string(str(target)))
+ outcome, result, cleanup = ctx.run_io(program)
+ assert outcome == 0 and ctx.kind(ctx.child(result, 0)) == 0
+ assert not target.exists()
+ # Reopen/rename/delete after successful and failed runs; resources settle
+ # before run_io returns, independently of context destruction.
+ target.write_bytes(b'again')
+ target.rename(source)
+ assert source.read_bytes() == b'again'
+
+
+def measurements(lib, fixtures):
+ report = {}
+ with lib.context() as ctx:
+ start = time.perf_counter()
+ for _ in range(10000):
+ root = ctx.number(42)
+ assert ctx.to_number(root) == 42
+ ctx.release(root)
+ report['small_roundtrips_10000_seconds'] = time.perf_counter()-start
+ worker = ctx.load_bundle((fixtures/'id.arboricx').read_bytes(), 'id')
+ body = bytes(range(256))*256
+ start = time.perf_counter()
+ for _ in range(3):
+ value = ctx.bytes(body)
+ application = ctx.apply(worker, value)
+ result = ctx.reduce(application)
+ assert ctx.to_bytes(result) == body
+ for handle in (value, application, result):
+ ctx.release(handle)
+ report['bytes_identity_65536_x3_seconds'] = time.perf_counter()-start
+ return report
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('library', type=Path)
+ parser.add_argument('fixtures', type=Path)
+ parser.add_argument('--copy-bundle', type=Path)
+ args = parser.parse_args()
+ lib = Library(args.library)
+ checks(lib, args.fixtures)
+ if args.copy_bundle:
+ copier(lib, args.copy_bundle)
+ print(json.dumps({'checks': 'passed', 'copier': bool(args.copy_bundle),
+ 'measurements': measurements(lib, args.fixtures)}, sort_keys=True))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/ext/haskell/test_scopes.py b/ext/haskell/test_scopes.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+"""Ownership scopes and curried application against the public native binding."""
+from pathlib import Path
+import sys
+from tricu import Library, TricuError
+
+
+def invalid(ctx, handle):
+ try:
+ ctx.kind(handle)
+ except TricuError as error:
+ assert error.status == 2, error
+ else:
+ raise AssertionError('temporary root survived its scope')
+
+
+with Library(sys.argv[1]).context() as ctx:
+ worker = ctx.load_bundle((Path(sys.argv[2])/'id.arboricx').read_bytes(), 'id')
+ with ctx.scope() as roots:
+ value = roots.string('scoped 🌳\0value')
+ result = roots.apply_many(worker, value)
+ assert ctx.to_string(result) == 'scoped 🌳\0value'
+ leaf = roots.leaf()
+ stem = roots.stem(leaf)
+ child = roots.child(stem, 0)
+ retained = roots.retain(child)
+ assert ctx.to_bool(stem) is True
+ roots.release(retained)
+ invalid(ctx, retained)
+ try:
+ roots.own(child)
+ except ValueError:
+ pass
+ else:
+ raise AssertionError('duplicate adoption accepted')
+ detached = roots.detach(value)
+ for handle in (result, leaf, stem, child):
+ invalid(ctx, handle)
+ assert ctx.to_string(detached) == 'scoped 🌳\0value'
+ ctx.release(detached)
+ roots.close()
+ try:
+ roots.leaf()
+ except RuntimeError:
+ pass
+ else:
+ raise AssertionError('closed scope accepted a constructor')
+ # Zero-argument application returns a new owned root, borrowing the function.
+ alias = ctx.apply_many(worker)
+ assert alias != worker
+ ctx.release(alias)
+ with ctx.scope() as roots:
+ argument = roots.number(42)
+ assert ctx.to_number(roots.apply_many(worker, argument)) == 42
+ # A late argument failure must release each intermediate application.
+ made = []
+ original_apply = ctx.apply
+ def capture(function, value):
+ result = original_apply(function, value)
+ made.append(result)
+ return result
+ ctx.apply = capture
+ try:
+ ctx.apply_many(worker, argument, 0)
+ except TricuError as error:
+ assert error.status == 2
+ else:
+ raise AssertionError('invalid late argument accepted')
+ finally:
+ ctx.apply = original_apply
+ assert made
+ for handle in made:
+ invalid(ctx, handle)
+ assert ctx.to_number(argument) == 42
+ try:
+ with ctx.scope() as roots:
+ temporary = roots.leaf()
+ raise ValueError('application failure')
+ except ValueError as error:
+ assert str(error) == 'application failure'
+ invalid(ctx, temporary)
+ # Cleanup attempts every root, and preserves an existing body exception.
+ try:
+ with ctx.scope() as roots:
+ first = roots.leaf()
+ last = roots.leaf()
+ ctx.release(last)
+ raise ValueError('original failure')
+ except ValueError as error:
+ assert str(error) == 'original failure'
+ invalid(ctx, first)
+ # A scope adopts both IO result roots, including an empty cleanup list.
+ with ctx.scope() as roots:
+ program = roots.fork(roots.string('tricuIO'), roots.fork(roots.number(2), roots.fork(roots.number(0), roots.number(7))))
+ outcome, result, cleanup = roots.run_io(program)
+ assert outcome == 0 and ctx.to_number(result) == 7 and ctx.kind(cleanup) == 0
+ for handle in (program, result, cleanup):
+ invalid(ctx, handle)
+ assert ctx.kind(worker) == 2
+ with ctx.scope() as roots:
+ roots.leaf()
+ ctx.close() # Context destruction already releases every scope root.
+print('Python handle scopes passed')
diff --git a/ext/haskell/tests.c b/ext/haskell/tests.c
@@ -0,0 +1,51 @@
+#include "tricu.h"
+#include <assert.h>
+#include <string.h>
+#include <signal.h>
+static volatile sig_atomic_t observed_signal;
+static void host_signal(int sig) { observed_signal = sig; }
+int main(void) {
+ signal(SIGINT, host_signal);
+ signal(SIGPIPE, host_signal);
+ tricu_init();
+ tricu_init();
+ raise(SIGINT);
+ assert(observed_signal == SIGINT);
+ raise(SIGPIPE);
+ assert(observed_signal == SIGPIPE);
+ assert(tricu_abi_version() == 1);
+ tricu_context ctx;
+ tricu_handle value, alias, result;
+ assert(tricu_context_new(&ctx) == TRICU_OK);
+ assert(tricu_from_u64(ctx, UINT64_MAX, &value) == TRICU_OK);
+ assert(tricu_retain(ctx, value, &alias) == TRICU_OK);
+ assert(value != alias);
+ assert(tricu_release(ctx, value) == TRICU_OK);
+ result = 123;
+ assert(tricu_reduce(ctx, value, &result) == TRICU_INVALID_HANDLE);
+ assert(result == 123);
+ uint32_t boolean = 999;
+ assert(tricu_to_bool(ctx, alias, &boolean) == TRICU_TYPE_ERROR);
+ assert(boolean == 999);
+ assert(tricu_leaf(ctx, &result) == TRICU_OK);
+ assert(tricu_to_bool(ctx, result, &boolean) == TRICU_OK && boolean == 0);
+ assert(tricu_stem(ctx, result, &value) == TRICU_OK);
+ assert(tricu_to_bool(ctx, value, &boolean) == TRICU_OK && boolean == 1);
+ uint64_t number;
+ assert(tricu_to_u64(ctx, alias, &number) == TRICU_OK);
+ assert(number == UINT64_MAX);
+ uint8_t *error;
+ size_t length;
+ assert(tricu_last_error(ctx, &error, &length) == TRICU_OK);
+ assert(length > 0);
+ tricu_buffer_free(error);
+ assert(tricu_from_utf8(ctx, (const uint8_t *)"a\0b", 3, &value) == TRICU_OK);
+ uint8_t *bytes;
+ assert(tricu_to_utf8(ctx, value, &bytes, &length) == TRICU_OK);
+ assert(length == 3 && memcmp(bytes, "a\0b", 3) == 0);
+ assert(tricu_context_free(ctx) == TRICU_OK);
+ assert(memcmp(bytes, "a\0b", 3) == 0);
+ tricu_buffer_free(bytes);
+ assert(tricu_context_free(ctx) == TRICU_INVALID_CONTEXT);
+ return 0;
+}
diff --git a/ext/js/README.md b/ext/js/README.md
@@ -0,0 +1,64 @@
+# Native JavaScript binding
+
+Node.js uses Koffi to call the [Haskell embedding ABI](../haskell/README.md).
+Use Node.js 22 or newer. The binding is synchronous: reduction and IO block the
+calling JavaScript thread. Use a Worker or supervised process where appropriate.
+The browser/WASM adapters have their own execution interfaces.
+
+```sh
+nix build .#tricu-js .#tricu-js-tests --no-link
+JS=$(nix path-info .#tricu-js)
+"$JS/bin/arboricx-run" run test/fixtures/id.arboricx id hello
+"$JS/bin/arboricx-run" run test/fixtures/true.arboricx true --format bool
+```
+
+Import `Library` from `$JS/lib/src/lib.js`:
+
+```js
+const library = new Library();
+const ctx = library.context();
+try {
+ const argument = ctx.string('hello\0🌳');
+ const alias = ctx.retain(argument);
+ ctx.release(argument);
+ console.log(ctx.toString(alias));
+} finally {
+ ctx.close();
+}
+```
+
+`Library(path)` selects an explicit library. Otherwise discovery uses `TRICU_LIB`
+or the library installed beside the module. Paths resolve through symlinks, and
+one library is kept loaded per process. Preserve the complete native runtime
+closure; the Nix package supplies it automatically.
+
+Contexts and handles are `bigint` IDs. `number` accepts exact safe JS integers or
+`bigint` in the uint64 range; `toNumber` returns **bigint**, including small values.
+`string` rejects unpaired surrogates, and strings preserve embedded NULs.
+`bytes`/`toBytes` exchange octet buffers. `kind` returns 0/1/2 for Leaf/Stem/Fork;
+`child` returns an owned handle. `apply` creates a suspension, and `reduce` demands
+its outer constructor. Observation/codecs also demand the parts they observe.
+
+`loadBundle(bytes, exportName)` verifies a bundle and selects an explicit export.
+`runIO(program)` runs the built-in native IO-v2 driver and returns
+`{outcome, value, cleanup}`. Outcome 0/1/2 means completed/failed/cancelled; value
+and cleanup are independently owned handles. Failure reasons stay lazy. Ordinary
+error Results can be completion values. Boundary failures throw `TricuError` with
+numeric `status` and diagnostic text.
+
+Every handle returned by a successful call is owned. Release intermediates in
+long-lived contexts. `close` releases all remaining roots and is idempotent;
+`Symbol.dispose` also closes. Decoded buffers are copied into JS-owned buffers and
+the native allocation is freed in `finally`.
+
+The CLI requires `run BUNDLE EXPORT`, accepts text arguments, and takes explicit
+`--format string|number|bool|bytes` (default string). Bytes render as hex. `--io`
+selects IO execution; terminal failure/cancellation exits nonzero with the reason
+left opaque. `--` ends option parsing. Integer arguments are available through the
+programmatic `number` method. Explicit formats describe the caller's intended
+interpretation because tree encodings overlap.
+
+The native bindings use Haskell value/IO semantics. Zig fuel, manual collection,
+kernel-root helpers, implicit default-export selection, heuristic type decoding,
+and the old `inspect` CLI are retired from this binding. Tests use independent
+expected values and retain malformed-bundle, missing-export and ownership controls.
diff --git a/ext/js/package.json b/ext/js/package.json
@@ -1,7 +1,7 @@
{
"name": "arboricx-runtime",
"version": "0.1.0",
- "description": "Arboricx portable bundle runtime — JavaScript host via libarboricx FFI",
+ "description": "Arboricx portable bundle runtime — native JavaScript binding via the Haskell C ABI",
"type": "module",
"main": "src/lib.js",
"bin": {
diff --git a/ext/js/src/cli.js b/ext/js/src/cli.js
@@ -1,104 +1,37 @@
#!/usr/bin/env node
-/**
- * cli.js — Arboricx JS host shell via libarboricx C ABI.
- *
- * Usage:
- * node cli.js inspect <bundle.arboricx>
- * node cli.js run <bundle.arboricx> [args...]
- */
-
+/** Explicit export and result format; positional application arguments are text. */
import { readFileSync } from 'node:fs';
-import {
- init,
- free,
- loadBundleDefault,
- reduce,
- app,
- ofNumber,
- ofString,
- decode,
- decodeType,
- findLib,
-} from './lib.js';
-
-// ── Commands ─────────────────────────────────────────────────────────────────
-
-function cmdInspect(bundlePath) {
- const ctx = init();
+import { Library } from './lib.js';
+
+const [command, bundle, name, ...args] = process.argv.slice(2);
+if (command !== 'run' || !bundle || !name) {
+ console.error('Usage: arboricx-run run BUNDLE EXPORT [--io] [--format string|number|bool|bytes] [--] [text arguments...]');
+ process.exitCode = 1;
+} else {
+ let ctx;
try {
- const bundle = readFileSync(bundlePath);
- console.log(`Bundle: ${bundlePath}`);
- console.log(`Size: ${bundle.length} bytes\n`);
-
- const term = loadBundleDefault(ctx, bundle);
- const result = reduce(ctx, term);
-
- const type = decodeType(ctx, result);
- let value;
- try {
- value = decode(ctx, result);
- } catch {
- value = '(raw tree)';
- }
-
- console.log(`Type: ${type}`);
- console.log(`Value: ${value}`);
- } catch (e) {
- console.error(`Error: ${e.message}`);
- process.exit(1);
- } finally {
- free(ctx);
- }
-}
-
-function cmdRun(bundlePath, args) {
- const ctx = init();
- try {
- const bundle = readFileSync(bundlePath);
- let term = loadBundleDefault(ctx, bundle);
-
- for (const arg of args) {
- const argTree = /^\d+$/.test(arg) ? ofNumber(ctx, BigInt(arg)) : ofString(ctx, arg);
- term = app(ctx, term, argTree);
- }
-
- const result = reduce(ctx, term);
- console.log(decode(ctx, result));
- } catch (e) {
- console.error(`Error: ${e.message}`);
- process.exit(1);
- } finally {
- free(ctx);
- }
-}
-
-// ── Main ─────────────────────────────────────────────────────────────────────
-
-const args = process.argv.slice(2);
-const command = args[0];
-
-switch (command) {
- case 'inspect': {
- if (args.length < 2) {
- console.error('Usage: node cli.js inspect <bundle.arboricx>');
- process.exit(1);
+ ctx = new Library().context();
+ let term = ctx.loadBundle(readFileSync(bundle), name), io = false, format = 'string', options = true;
+ for (let i = 0; i < args.length; i++) {
+ if (options && args[i] === '--') { options = false; continue; }
+ if (options && args[i] === '--io') { io = true; continue; }
+ if (options && args[i] === '--format') { format = args[++i]; continue; }
+ if (options && args[i].startsWith('--')) throw new Error(`unknown option: ${args[i]}`);
+ const argument = ctx.string(args[i]);
+ const next = ctx.apply(term, argument);
+ ctx.release(term); ctx.release(argument); term = next;
}
- cmdInspect(args[1]);
- break;
- }
- case 'run': {
- if (args.length < 2) {
- console.error('Usage: node cli.js run <bundle.arboricx> [args...]');
- process.exit(1);
+ if (!['string', 'number', 'bool', 'bytes'].includes(format)) throw new Error('invalid result format');
+ if (io) {
+ const result = ctx.runIO(term);
+ if (result.outcome !== 0) throw new Error(`IO task ${result.outcome === 1 ? 'failed' : 'cancelled'} (reason left opaque)`);
+ term = result.value;
}
- cmdRun(args[1], args.slice(2));
- break;
- }
- default:
- console.log('Arboricx JS Host (via libarboricx FFI)');
- console.log('');
- console.log('Usage:');
- console.log(' node cli.js inspect <bundle.arboricx>');
- console.log(' node cli.js run <bundle.arboricx> [args...]');
- break;
+ const result = {string: () => ctx.toString(term), number: () => ctx.toNumber(term).toString(),
+ bool: () => String(ctx.toBool(term)), bytes: () => ctx.toBytes(term).toString('hex')}[format]();
+ console.log(result);
+ } catch (error) {
+ console.error(`Error: ${error.message}`);
+ process.exitCode = 1;
+ } finally { ctx?.close(); }
}
diff --git a/ext/js/src/lib.js b/ext/js/src/lib.js
@@ -1,244 +1,151 @@
-/**
- * lib.js — FFI wrapper around libarboricx.so via koffi.
- *
- * Exports low-level C ABI bindings and high-level helpers.
- */
-
-import { existsSync } from 'node:fs';
-import { dirname, join, resolve } from 'node:path';
+/** Native tree calculus and IO via Haskell's C ABI. Roots are owned bigint IDs. */
+import { existsSync, realpathSync } from 'node:fs';
+import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import koffi from 'koffi';
-const __dirname = dirname(fileURLToPath(import.meta.url));
-
-koffi.opaque('arb_ctx_t');
-
-// ── Library discovery ───────────────────────────────────────────────────────
-
export function findLib() {
- const env = process.env.ARBORICX_LIB;
- if (env) {
- if (existsSync(env)) return env;
- throw new Error(`ARBORICX_LIB set but file not found: ${env}`);
+ if (process.env.TRICU_LIB) {
+ if (!existsSync(process.env.TRICU_LIB)) throw new Error(`TRICU_LIB not found: ${process.env.TRICU_LIB}`);
+ return realpathSync(process.env.TRICU_LIB);
}
-
- const candidates = [
- resolve(__dirname, 'libarboricx.so'),
- 'libarboricx.so',
- './libarboricx.so',
- '/usr/local/lib/libarboricx.so',
- '/usr/lib/libarboricx.so',
- ];
-
- for (const p of candidates) {
- if (existsSync(p)) return p;
+ const directory = dirname(fileURLToPath(import.meta.url));
+ for (const name of ['libtricu-haskell.so', 'libtricu-haskell.dylib', 'tricu-haskell.dll']) {
+ const candidate = join(directory, name);
+ if (existsSync(candidate)) return realpathSync(candidate);
}
-
- throw new Error('libarboricx.so not found. Set ARBORICX_LIB to its full path.');
-}
-
-// ── FFI setup ───────────────────────────────────────────────────────────────
-
-let _lib = null;
-let _libPath = null;
-
-function ensureLib() {
- if (_lib) return _lib;
- const path = findLib();
- _lib = koffi.load(path);
- _libPath = path;
- return _lib;
-}
-
-export function loadLib(path) {
- if (_lib && _libPath === path) return;
- _lib = koffi.load(path);
- _libPath = path;
-}
-
-function getLib() {
- if (_lib) return _lib;
- return ensureLib();
-}
-
-// ── Context lifecycle ───────────────────────────────────────────────────────
-
-export function init(libPath) {
- if (libPath) loadLib(libPath);
- const lib = getLib();
- if (lib.func('uint32_t arb_abi_version(void)')() !== 3) throw new Error('Arboricx ABI v3 required');
- const ctx = lib.func('arb_ctx_t *arboricx_init(void)')();
- if (!ctx) throw new Error('arboricx_init failed');
- return ctx;
-}
-
-export function free(ctx) {
- getLib().func('void arboricx_free(arb_ctx_t *ctx)')(ctx);
-}
-
-// Each handle returned below is owned until release() or context teardown.
-// Handles are always bigint; converting one to Number can lose identity bits.
-function owned(signature, ...args) {
- const out = [0n];
- const declaration = signature.replace(/^uint64_t /, 'int ').replace(/\)$/, ', _Out_ uint64_t *out)');
- const status = getLib().func(declaration)(...args, out);
- if (status !== 0) throw new Error(`${signature.split('(')[0]}: status ${status}`);
- return BigInt(out[0]);
-}
-
-export function retain(ctx, handle) {
- return owned('uint64_t arb_retain(arb_ctx_t *ctx, uint64_t handle)', ctx, handle);
-}
-export function release(ctx, handle) {
- const status = getLib().func('int arb_release(arb_ctx_t *ctx, uint64_t handle)')(ctx, handle);
- if (status !== 0) throw new Error(`arb_release: status ${status}`);
-}
-export function collect(ctx) {
- const status = getLib().func('int arb_collect(arb_ctx_t *ctx)')(ctx);
- if (status !== 0) throw new Error(`arb_collect: status ${status}`);
-}
-
-// ── Bundle loading ──────────────────────────────────────────────────────────
-
-export function loadBundle(ctx, bytes, name) {
- const result = owned('uint64_t arb_load_bundle(arb_ctx_t *ctx, _In_ uint8_t *bytes, size_t len, const char *name)', ctx, bytes, bytes.length, name);
- return result;
-}
-
-export function loadBundleDefault(ctx, bytes) {
- const result = owned('uint64_t arb_load_bundle_default(arb_ctx_t *ctx, _In_ uint8_t *bytes, size_t len)', ctx, bytes, bytes.length);
- return result;
-}
-
-// ── Reduction ───────────────────────────────────────────────────────────────
-
-export function reduce(ctx, root, fuel = 1_000_000_000n) {
- return owned('uint64_t arb_reduce(arb_ctx_t *ctx, uint64_t root, uint64_t fuel)', ctx, root, BigInt(fuel));
-}
-
-// ── Tree construction ───────────────────────────────────────────────────────
-
-export function leaf(ctx) {
- return owned('uint64_t arb_leaf(arb_ctx_t *ctx)', ctx);
-}
-
-export function stem(ctx, child) {
- return owned('uint64_t arb_stem(arb_ctx_t *ctx, uint64_t child)', ctx, child);
-}
-
-export function fork(ctx, left, right) {
- return owned('uint64_t arb_fork(arb_ctx_t *ctx, uint64_t left, uint64_t right)', ctx, left, right);
+ throw new Error('Set TRICU_LIB to the Haskell embedding library');
}
-export function app(ctx, func, arg) {
- return owned('uint64_t arb_app(arb_ctx_t *ctx, uint64_t func, uint64_t arg)', ctx, func, arg);
-}
-
-// ── Codec constructors ──────────────────────────────────────────────────────
-
-export function ofNumber(ctx, n) {
- const big = typeof n === 'bigint' ? n : BigInt(n);
- return owned('uint64_t arb_of_number(arb_ctx_t *ctx, uint64_t n)', ctx, big);
-}
-
-export function ofString(ctx, s) {
- return owned('uint64_t arb_of_string(arb_ctx_t *ctx, const char *s)', ctx, s);
-}
-
-export function ofBytes(ctx, bytes) {
- return owned('uint64_t arb_of_bytes(arb_ctx_t *ctx, _In_ uint8_t *bytes, size_t len)', ctx, bytes, bytes.length);
-}
-
-export function ofList(ctx, items) {
- const arr = new BigUint64Array(items.map(BigInt));
- return owned('uint64_t arb_of_list(arb_ctx_t *ctx, _In_ uint64_t *items, size_t len)', ctx, arr, arr.length);
-}
-
-// ── Codec destructors ───────────────────────────────────────────────────────
-
-export function toNumber(ctx, root) {
- const out = [0];
- const ok = getLib().func('int arb_to_number(arb_ctx_t *ctx, uint64_t root, _Out_ uint64_t *out)')(ctx, root, out);
- if (ok !== 0) throw new Error('arb_to_number failed');
- return typeof out[0] === 'bigint' ? Number(out[0]) : out[0];
-}
-
-export function toString(ctx, root) {
- const ptrOut = [null];
- const lenOut = [0];
- const ok = getLib().func('int arb_to_string(arb_ctx_t *ctx, uint64_t root, _Out_ uint8_t **out_ptr, _Out_ size_t *out_len)')(ctx, root, ptrOut, lenOut);
- if (ok !== 0) throw new Error('arb_to_string failed');
-
- const bytes = koffi.decode(ptrOut[0], 'uint8_t', lenOut[0]);
- const str = Buffer.from(bytes).toString('utf-8');
- getLib().func('void arboricx_free_buf(arb_ctx_t *ctx, uint8_t *ptr, size_t len)')(ctx, ptrOut[0], lenOut[0]);
- return str;
-}
-
-export function toBytes(ctx, root) {
- const ptrOut = [null];
- const lenOut = [0];
- const ok = getLib().func('int arb_to_bytes(arb_ctx_t *ctx, uint64_t root, _Out_ uint8_t **out_ptr, _Out_ size_t *out_len)')(ctx, root, ptrOut, lenOut);
- if (ok !== 0) throw new Error('arb_to_bytes failed');
-
- const bytes = Buffer.from(koffi.decode(ptrOut[0], 'uint8_t', lenOut[0]));
- getLib().func('void arboricx_free_buf(arb_ctx_t *ctx, uint8_t *ptr, size_t len)')(ctx, ptrOut[0], lenOut[0]);
- return bytes;
-}
-
-export function toBool(ctx, root) {
- const out = [0];
- const ok = getLib().func('int arb_to_bool(arb_ctx_t *ctx, uint64_t root, _Out_ int *out)')(ctx, root, out);
- if (ok !== 0) throw new Error('arb_to_bool failed');
- return out[0] !== 0;
-}
-
-// ── Result unwrapping ───────────────────────────────────────────────────────
-
-export function unwrapResult(ctx, root) {
- const outOk = [0];
- const outValue = [0];
- const outRest = [0];
- const ok = getLib().func('int arb_unwrap_result(arb_ctx_t *ctx, uint64_t root, _Out_ int *out_ok, _Out_ uint64_t *out_value, _Out_ uint64_t *out_rest)')(ctx, root, outOk, outValue, outRest);
- if (ok !== 0) throw new Error('arb_unwrap_result failed');
- return { ok: outOk[0] !== 0, value: BigInt(outValue[0]), rest: BigInt(outRest[0]) };
+export class TricuError extends Error {
+ constructor(status, message) {
+ super(`tricu status ${status}: ${message}`);
+ this.status = status;
+ }
}
-export function unwrapHostValue(ctx, root) {
- const outTag = [0n];
- const outPayload = [0];
- const ok = getLib().func('int arb_unwrap_host_value(arb_ctx_t *ctx, uint64_t root, _Out_ uint64_t *out_tag, _Out_ uint64_t *out_payload)')(ctx, root, outTag, outPayload);
- if (ok !== 0) throw new Error('arb_unwrap_host_value failed');
- return { tag: outTag[0], payload: BigInt(outPayload[0]) };
+// One process-wide library, kept loaded for the lifetime of the process.
+let loaded;
+function native(path) {
+ const resolved = realpathSync(path);
+ if (loaded) {
+ if (loaded.path !== resolved) throw new Error('One Haskell embedding library per process');
+ return loaded.api;
+ }
+ const lib = koffi.load(resolved);
+ const declarations = {
+ init: 'void tricu_init(void)',
+ abi_version: 'uint32_t tricu_abi_version(void)',
+ context_new: 'uint32_t tricu_context_new(_Out_ uint64_t *out)',
+ context_free: 'uint32_t tricu_context_free(uint64_t ctx)',
+ release: 'uint32_t tricu_release(uint64_t ctx, uint64_t value)',
+ buffer_free: 'void tricu_buffer_free(void *buffer)',
+ run_io: 'uint32_t tricu_run_io(uint64_t ctx, uint64_t program, _Out_ uint32_t *outcome, _Out_ uint64_t *value, _Out_ uint64_t *cleanup)',
+ load_bundle: 'uint32_t tricu_load_bundle(uint64_t ctx, const uint8_t *bytes, size_t len, const uint8_t *name, size_t name_len, _Out_ uint64_t *out)',
+ last_error: 'uint32_t tricu_last_error(uint64_t ctx, _Out_ void **out, _Out_ size_t *len)',
+ };
+ for (const name of ['retain', 'stem', 'reduce']) declarations[name] =
+ `uint32_t tricu_${name}(uint64_t ctx, uint64_t value, _Out_ uint64_t *out)`;
+ for (const name of ['fork', 'apply']) declarations[name] =
+ `uint32_t tricu_${name}(uint64_t ctx, uint64_t a, uint64_t b, _Out_ uint64_t *out)`;
+ declarations.leaf = 'uint32_t tricu_leaf(uint64_t ctx, _Out_ uint64_t *out)';
+ declarations.child = 'uint32_t tricu_child(uint64_t ctx, uint64_t value, uint32_t index, _Out_ uint64_t *out)';
+ for (const name of ['kind', 'to_bool']) declarations[name] =
+ `uint32_t tricu_${name}(uint64_t ctx, uint64_t value, _Out_ uint32_t *out)`;
+ for (const name of ['from_u64', 'to_u64']) declarations[name] =
+ `uint32_t tricu_${name}(uint64_t ctx, uint64_t value, _Out_ uint64_t *out)`;
+ for (const name of ['bytes', 'utf8']) {
+ declarations[`from_${name}`] = `uint32_t tricu_from_${name}(uint64_t ctx, const uint8_t *bytes, size_t len, _Out_ uint64_t *out)`;
+ declarations[`to_${name}`] = `uint32_t tricu_to_${name}(uint64_t ctx, uint64_t value, _Out_ void **out, _Out_ size_t *len)`;
+ }
+ const api = Object.fromEntries(Object.entries(declarations).map(([name, declaration]) => [name, lib.func(declaration)]));
+ api.init();
+ if (api.abi_version() !== 1) throw new Error('Haskell embedding ABI 1 required');
+ loaded = { path: resolved, lib, api };
+ return api;
}
-// ── Kernel ──────────────────────────────────────────────────────────────────
-
-export function kernelRoot(ctx) {
- return owned('uint64_t arb_kernel_root(arb_ctx_t *ctx)', ctx);
+export class Library {
+ constructor(path = findLib()) { this.api = native(path); }
+ context() { return new Context(this); }
}
-// ── High-level helpers ──────────────────────────────────────────────────────
-
-export function decode(ctx, root) {
- try {
- return toBool(ctx, root) ? 'true' : 'false';
- } catch {
- try {
- return toString(ctx, root);
- } catch {
- try {
- return String(toNumber(ctx, root));
- } catch {
- throw new Error('could not decode result');
- }
+export class Context {
+ constructor(library) {
+ this.api = library.api;
+ const out = [0n];
+ const status = this.api.context_new(out);
+ if (status) throw new TricuError(status, 'creating context');
+ this.identity = BigInt(out[0]);
+ }
+ _copy(pointer, length) {
+ try { return Buffer.from(koffi.decode(pointer, 'uint8_t', Number(length))); }
+ finally { this.api.buffer_free(pointer); }
+ }
+ _check(status) {
+ if (!status) return;
+ const pointer = [null], length = [0];
+ const diagnostic = this.api.last_error(this.identity, pointer, length);
+ const message = diagnostic ? 'invalid context' : this._copy(pointer[0], length[0]).toString('utf8');
+ throw new TricuError(status, message);
+ }
+ _out(name, ...args) {
+ const out = [0n];
+ this._check(this.api[name](this.identity, ...args, out));
+ return BigInt(out[0]);
+ }
+ close() {
+ if (this.identity !== 0n) {
+ this._check(this.api.context_free(this.identity));
+ this.identity = 0n;
}
}
-}
-
-export function decodeType(ctx, root) {
- try { toBool(ctx, root); return 'bool'; } catch {}
- try { toString(ctx, root); return 'string'; } catch {}
- try { toNumber(ctx, root); return 'number'; } catch {}
- return 'unknown (raw tree)';
+ [Symbol.dispose]() { this.close(); }
+ release(value) { this._check(this.api.release(this.identity, value)); }
+ retain(value) { return this._out('retain', value); }
+ leaf() { return this._out('leaf'); }
+ stem(value) { return this._out('stem', value); }
+ fork(a, b) { return this._out('fork', a, b); }
+ apply(fn, arg) { return this._out('apply', fn, arg); }
+ reduce(value) { return this._out('reduce', value); }
+ child(value, index) {
+ if (!Number.isInteger(index) || index < 0 || index > 0xffffffff) throw new RangeError('invalid child index');
+ return this._out('child', value, index);
+ }
+ kind(value) { return Number(this._out('kind', value)); }
+ number(value) {
+ if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new RangeError('use bigint for large integers');
+ const integer = BigInt(value);
+ if (integer < 0n || integer > 0xffffffffffffffffn) throw new RangeError('number must fit uint64');
+ return this._out('from_u64', integer);
+ }
+ toNumber(value) { return this._out('to_u64', value); }
+ toBool(value) { return this._out('to_bool', value) !== 0n; }
+ bytes(value) {
+ const bytes = Buffer.from(value);
+ return this._out('from_bytes', bytes, bytes.length);
+ }
+ string(value) {
+ // Buffer UTF-8 encoding otherwise silently replaces unpaired surrogates.
+ if (!value.isWellFormed()) throw new TypeError('string contains an unpaired surrogate');
+ const bytes = Buffer.from(value, 'utf8');
+ return this._out('from_utf8', bytes, bytes.length);
+ }
+ _buffer(name, value) {
+ const pointer = [null], length = [0];
+ this._check(this.api[name](this.identity, value, pointer, length));
+ return this._copy(pointer[0], length[0]);
+ }
+ toBytes(value) { return this._buffer('to_bytes', value); }
+ toString(value) { return this._buffer('to_utf8', value).toString('utf8'); }
+ loadBundle(value, name) {
+ if (!name.isWellFormed()) throw new TypeError('invalid export name');
+ const bytes = Buffer.from(value), encoded = Buffer.from(name, 'utf8');
+ return this._out('load_bundle', bytes, bytes.length, encoded, encoded.length);
+ }
+ runIO(program) {
+ const outcome = [0], value = [0n], cleanup = [0n];
+ this._check(this.api.run_io(this.identity, program, outcome, value, cleanup));
+ return { outcome: outcome[0], value: BigInt(value[0]), cleanup: BigInt(cleanup[0]) };
+ }
}
diff --git a/ext/js/test/bundle.test.js b/ext/js/test/bundle.test.js
@@ -1,93 +0,0 @@
-import { readFileSync } from 'node:fs';
-import { strictEqual, ok, throws } from 'node:assert';
-import { describe, it } from 'node:test';
-import {
- findLib,
- init,
- free,
- loadBundle,
- loadBundleDefault,
- kernelRoot,
-} from '../src/lib.js';
-
-const fixtureDir = '../../test/fixtures';
-const libPath = findLib();
-
-describe('library discovery', () => {
- it('findLib returns an existing .so path', () => {
- ok(libPath.endsWith('.so') || libPath.endsWith('.dylib') || libPath.endsWith('.dll'));
- ok(readFileSync(libPath));
- });
-});
-
-describe('context lifecycle', () => {
- it('init creates a valid context', () => {
- const ctx = init(libPath);
- ok(ctx);
- free(ctx);
- });
-
- it('kernel root is available', () => {
- const ctx = init(libPath);
- try {
- const root = kernelRoot(ctx);
- ok(root > 0, 'kernel root should be a positive handle');
- } finally {
- free(ctx);
- }
- });
-});
-
-describe('bundle loading', () => {
- it('loadBundleDefault loads id.arboricx', () => {
- const ctx = init(libPath);
- try {
- const bundle = readFileSync(`${fixtureDir}/id.arboricx`);
- const root = loadBundleDefault(ctx, bundle);
- ok(root > 0, 'loaded root should be a positive handle');
- } finally {
- free(ctx);
- }
- });
-
- it('loadBundleDefault loads true.arboricx', () => {
- const ctx = init(libPath);
- try {
- const bundle = readFileSync(`${fixtureDir}/true.arboricx`);
- const root = loadBundleDefault(ctx, bundle);
- ok(root > 0);
- } finally {
- free(ctx);
- }
- });
-
- it('loadBundle loads named export from id.arboricx', () => {
- const ctx = init(libPath);
- try {
- const bundle = readFileSync(`${fixtureDir}/id.arboricx`);
- const root = loadBundle(ctx, bundle, 'id');
- ok(root > 0);
- } finally {
- free(ctx);
- }
- });
-
- it('loadBundle fails for missing export name', () => {
- const ctx = init(libPath);
- try {
- const bundle = readFileSync(`${fixtureDir}/id.arboricx`);
- throws(() => loadBundle(ctx, bundle, 'nonexistent'), /status 5/);
- } finally {
- free(ctx);
- }
- });
-
- it('loadBundleDefault fails for invalid bytes', () => {
- const ctx = init(libPath);
- try {
- throws(() => loadBundleDefault(ctx, Buffer.from('not a bundle')), /status 5/);
- } finally {
- free(ctx);
- }
- });
-});
diff --git a/ext/js/test/conformance.mjs b/ext/js/test/conformance.mjs
@@ -0,0 +1,88 @@
+import { readFileSync, writeFileSync, mkdtempSync, rmSync, existsSync, readdirSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { strict as assert } from 'node:assert';
+import { Library, TricuError } from '../src/lib.js';
+
+export function conformance(path, fixtureDir, specification, copier) {
+ const f = JSON.parse(readFileSync(specification, 'utf8'));
+ const library = new Library(path), ctx = library.context(), other = library.context();
+ const report = {};
+ const load = (file, name) => ctx.loadBundle(readFileSync(join(fixtureDir, file)), name);
+ try {
+ report.numbers = f.numbers.map(n => ctx.toNumber(ctx.number(BigInt(n))).toString());
+ report.strings = f.strings.map(s => ctx.toString(ctx.string(s)));
+ report.bytesHex = f.bytesHex.map(h => ctx.toBytes(ctx.bytes(Buffer.from(h,'hex'))).toString('hex'));
+ const worker = load('id.arboricx','id'), argument = ctx.string(f.identity);
+ assert.equal(typeof worker, 'bigint');
+ const application = ctx.apply(worker, argument);
+ ctx.release(worker); ctx.release(argument);
+ report.identity = ctx.toString(ctx.reduce(application));
+ let append = load('append.arboricx','append');
+ for (const text of f.append) append = ctx.apply(append,ctx.string(text));
+ report.append = ctx.toString(append);
+ report.booleans = ['true','false'].map(name => ctx.toBool(load(name+'.arboricx',name)));
+ report.notQKind = ctx.kind(ctx.reduce(load('notQ.arboricx','not?')));
+ report.outcomes = [];
+ for (const code of [0,2,9]) {
+ const program = ctx.fork(ctx.string('tricuIO'),ctx.fork(ctx.number(2),ctx.fork(ctx.number(code),ctx.string('opaque payload'))));
+ const {outcome,value,cleanup} = ctx.runIO(program);
+ report.outcomes.push({outcome,payload:ctx.toString(value),cleanupKind:ctx.kind(cleanup)});
+ for (const handle of [program,value,cleanup]) ctx.release(handle);
+ }
+ const root = ctx.string('owned'), alias = ctx.retain(root);
+ assert.notEqual(root,alias);
+ ctx.release(root);
+ assert.equal(ctx.toString(alias),'owned');
+ const wrong = ctx.stem(ctx.stem(ctx.leaf()));
+ report.negativeStatuses = [() => ctx.release(root), () => other.retain(alias),
+ () => ctx.toNumber(wrong), () => ctx.toBool(wrong),
+ () => ctx.loadBundle(Buffer.from('bad bundle'),'id'), () => load('id.arboricx','missing'),
+ () => ctx.runIO(ctx.leaf())].map(operation => {
+ try { operation(); } catch (error) {
+ assert.ok(error instanceof TricuError); assert.ok(error.message.length); return error.status;
+ }
+ throw new Error('invalid operation accepted');
+ });
+ assert.throws(() => ctx.string('\ud800'), /surrogate/);
+ for (const number of [-1n,2n**64n,9007199254740992,1.5]) assert.throws(() => ctx.number(number));
+ if (copier) checkCopier(library,copier,f);
+ assert.deepEqual(report,f.expected);
+ } finally { other.close(); ctx.close(); }
+ assert.throws(() => ctx.leaf(), e => e instanceof TricuError && e.status === 1);
+ ctx.close(); // idempotent
+ for (let i=0;i<100;i++) { const fresh=library.context(); fresh.close(); }
+ return report;
+}
+
+function checkCopier(library,bundle,f) {
+ const dir=mkdtempSync(join(tmpdir(),'tricu-js-')),ctx=library.context();
+ const source=join(dir,'in'),target=join(dir,'out'),body=Buffer.from(f.copyHex.repeat(f.copyRepeats),'hex');
+ const bytes=readFileSync(bundle),worker=ctx.loadBundle(bytes,'copy');
+ const run = (fn,destination=target) => ctx.runIO(ctx.apply(ctx.apply(fn,ctx.string(source)),ctx.string(destination)));
+ const fdCount = () => existsSync('/proc/self/fd') ? readdirSync('/proc/self/fd').length : null;
+ try {
+ writeFileSync(source,body);
+ let baseline;
+ for (let i=0;i<3;i++) {
+ const r=run(worker);
+ assert.equal(r.outcome,0); assert.equal(ctx.kind(r.cleanup),0);
+ assert.equal(ctx.toBool(ctx.child(r.value,0)),true);
+ assert.deepEqual(readFileSync(target),body);
+ if (i===0) baseline=fdCount(); else assert.equal(fdCount(),baseline);
+ ctx.release(r.value); ctx.release(r.cleanup);
+ }
+ const cancelled=run(ctx.loadBundle(bytes,'cancelledCopy'));
+ assert.equal(cancelled.outcome,0);
+ assert.equal(ctx.toString(ctx.child(cancelled.value,0)),'cancelled');
+ assert.equal(ctx.toString(ctx.child(ctx.child(cancelled.value,1),0)),'requested');
+ assert.deepEqual(readFileSync(target),body.subarray(0,4096));
+ const failed=run(worker,join(dir,'missing-parent','out'));
+ assert.equal(failed.outcome,0); assert.equal(ctx.toBool(ctx.child(failed.value,0)),false);
+ assert.equal(fdCount(),baseline);
+ rmSync(source); rmSync(target);
+ const missing=run(worker);
+ assert.equal(missing.outcome,0); assert.equal(ctx.toBool(ctx.child(missing.value,0)),false);
+ assert.equal(existsSync(target),false);
+ } finally { ctx.close(); rmSync(dir,{recursive:true,force:true}); }
+}
diff --git a/ext/js/test/native.test.js b/ext/js/test/native.test.js
@@ -0,0 +1,6 @@
+import { it } from 'node:test';
+import { conformance } from './conformance.mjs';
+import { findLib } from '../src/lib.js';
+it('Haskell native binding conformance and ownership', () => {
+ conformance(findLib(), '../../test/fixtures', '../haskell/bindings/fixtures.json', process.env.TRICU_COPY_BUNDLE);
+});
diff --git a/ext/js/test/ownership.test.js b/ext/js/test/ownership.test.js
@@ -1,27 +0,0 @@
-import { strictEqual, throws } from 'node:assert';
-import { it } from 'node:test';
-import { init, free, leaf, stem, retain, release, collect, toBool } from '../src/lib.js';
-
-it('owned bigint handles survive collection and reject released/cross-context use', () => {
- const ctx = init(), other = init();
- try {
- const t = leaf(ctx), alias = retain(ctx, t);
- strictEqual(typeof t, 'bigint');
- const truth = stem(ctx, t);
- release(ctx, t);
- collect(ctx);
- strictEqual(toBool(ctx, alias), false);
- strictEqual(toBool(ctx, truth), true);
- throws(() => release(ctx, t), /status 1/);
- throws(() => retain(other, alias), /status 1/);
- release(ctx, alias);
- release(ctx, truth);
- collect(ctx);
- const replacement = leaf(ctx);
- throws(() => toBool(ctx, t), /failed/);
- release(ctx, replacement);
- } finally {
- free(other);
- free(ctx);
- }
-});
diff --git a/ext/js/test/reduce.test.js b/ext/js/test/reduce.test.js
@@ -1,113 +0,0 @@
-import { readFileSync } from 'node:fs';
-import { strictEqual, ok } from 'node:assert';
-import { describe, it } from 'node:test';
-import {
- findLib,
- init,
- free,
- leaf,
- stem,
- fork,
- app,
- reduce,
- toBool,
- toString,
- toNumber,
- loadBundleDefault,
- ofString,
- ofNumber,
-} from '../src/lib.js';
-
-const libPath = findLib();
-
-describe('tree construction', () => {
- it('leaf returns a positive handle', () => {
- const ctx = init(libPath);
- try {
- const idx = leaf(ctx);
- ok(idx > 0);
- } finally {
- free(ctx);
- }
- });
-
- it('stem wraps a child', () => {
- const ctx = init(libPath);
- try {
- const l = leaf(ctx);
- const s = stem(ctx, l);
- ok(s > 0);
- ok(s !== l);
- } finally {
- free(ctx);
- }
- });
-
- it('fork combines left and right', () => {
- const ctx = init(libPath);
- try {
- const a = leaf(ctx);
- const b = leaf(ctx);
- const f = fork(ctx, a, b);
- ok(f > 0);
- ok(f !== a && f !== b);
- } finally {
- free(ctx);
- }
- });
-});
-
-describe('reduction — booleans', () => {
- it('true.arboricx reduces to boolean true', () => {
- const ctx = init(libPath);
- try {
- const bundle = readFileSync('../../test/fixtures/true.arboricx');
- const root = loadBundleDefault(ctx, bundle);
- const result = reduce(ctx, root, 1_000_000n);
- strictEqual(toBool(ctx, result), true);
- } finally {
- free(ctx);
- }
- });
-
- it('false.arboricx reduces to boolean false', () => {
- const ctx = init(libPath);
- try {
- const bundle = readFileSync('../../test/fixtures/false.arboricx');
- const root = loadBundleDefault(ctx, bundle);
- const result = reduce(ctx, root, 1_000_000n);
- strictEqual(toBool(ctx, result), false);
- } finally {
- free(ctx);
- }
- });
-});
-
-describe('reduction — id', () => {
- it('id applied to string returns the string', () => {
- const ctx = init(libPath);
- try {
- const bundle = readFileSync('../../test/fixtures/id.arboricx');
- const idRoot = loadBundleDefault(ctx, bundle);
- const arg = ofString(ctx, 'hello');
- const applied = app(ctx, idRoot, arg);
- const result = reduce(ctx, applied, 1_000_000n);
- strictEqual(toString(ctx, result), 'hello');
- } finally {
- free(ctx);
- }
- });
-});
-
-describe('reduction — numbers', () => {
- it('ofNumber round-trips through toNumber', () => {
- const ctx = init(libPath);
- try {
- const num = ofNumber(ctx, 42);
- strictEqual(toNumber(ctx, num), 42);
- } finally {
- free(ctx);
- }
- });
-});
-
diff --git a/ext/js/test/run-bundle.test.js b/ext/js/test/run-bundle.test.js
@@ -1,125 +0,0 @@
-import { readFileSync } from 'node:fs';
-import { strictEqual, ok, throws } from 'node:assert';
-import { describe, it } from 'node:test';
-import {
- findLib,
- init,
- free,
- loadBundleDefault,
- loadBundle,
- reduce,
- app,
- ofString,
- ofNumber,
- toBool,
- toString,
- decode,
- decodeType,
-} from '../src/lib.js';
-
-const fixtureDir = '../../test/fixtures';
-const libPath = findLib();
-
-describe('run bundle — booleans', () => {
- it('true.arboricx evaluates to true', () => {
- const ctx = init(libPath);
- try {
- const bundle = readFileSync(`${fixtureDir}/true.arboricx`);
- const root = loadBundleDefault(ctx, bundle);
- const result = reduce(ctx, root);
- strictEqual(toBool(ctx, result), true);
- strictEqual(decodeType(ctx, result), 'bool');
- strictEqual(decode(ctx, result), 'true');
- } finally {
- free(ctx);
- }
- });
-
- it('false.arboricx evaluates to false', () => {
- const ctx = init(libPath);
- try {
- const bundle = readFileSync(`${fixtureDir}/false.arboricx`);
- const root = loadBundleDefault(ctx, bundle);
- const result = reduce(ctx, root);
- strictEqual(toBool(ctx, result), false);
- strictEqual(decodeType(ctx, result), 'bool');
- strictEqual(decode(ctx, result), 'false');
- } finally {
- free(ctx);
- }
- });
-});
-
-describe('run bundle — id', () => {
- it('id applied to string returns the string', () => {
- const ctx = init(libPath);
- try {
- const bundle = readFileSync(`${fixtureDir}/id.arboricx`);
- const idRoot = loadBundleDefault(ctx, bundle);
- const arg = ofString(ctx, 'hello');
- const applied = app(ctx, idRoot, arg);
- const result = reduce(ctx, applied);
- strictEqual(toString(ctx, result), 'hello');
- strictEqual(decodeType(ctx, result), 'string');
- } finally {
- free(ctx);
- }
- });
-});
-
-describe('run bundle — append', () => {
- it('append "hello " "world" = "hello world"', () => {
- const ctx = init(libPath);
- try {
- const bundle = readFileSync(`${fixtureDir}/append.arboricx`);
- let term = loadBundleDefault(ctx, bundle);
- term = app(ctx, term, ofString(ctx, 'hello '));
- term = app(ctx, term, ofString(ctx, 'world'));
- const result = reduce(ctx, term);
- strictEqual(toString(ctx, result), 'hello world');
- } finally {
- free(ctx);
- }
- });
-});
-
-describe('run bundle — notQ', () => {
- it('notQ loads and reduces without error', () => {
- const ctx = init(libPath);
- try {
- const bundle = readFileSync(`${fixtureDir}/notQ.arboricx`);
- const root = loadBundleDefault(ctx, bundle);
- const result = reduce(ctx, root);
- ok(result > 0);
- } finally {
- free(ctx);
- }
- });
-});
-
-describe('run bundle — named export', () => {
- it('loadBundle selects named export', () => {
- const ctx = init(libPath);
- try {
- const bundle = readFileSync(`${fixtureDir}/id.arboricx`);
- const root = loadBundle(ctx, bundle, 'id');
- ok(root > 0);
- // id is a function; apply it before reducing
- const applied = app(ctx, root, ofString(ctx, 'test'));
- const result = reduce(ctx, applied);
- strictEqual(toString(ctx, result), 'test');
- } finally {
- free(ctx);
- }
- });
-
- it('missing export throws', () => {
- const ctx = init(libPath);
- try {
- const bundle = readFileSync(`${fixtureDir}/id.arboricx`);
- throws(() => loadBundle(ctx, bundle, 'nonexistent'), /status 5/);
- } finally {
- free(ctx);
- }
- });
-});
diff --git a/ext/js/test/runner.mjs b/ext/js/test/runner.mjs
@@ -0,0 +1,2 @@
+import { conformance } from './conformance.mjs';
+console.log(JSON.stringify(conformance(...process.argv.slice(2))));
diff --git a/ext/php/README.md b/ext/php/README.md
@@ -0,0 +1,69 @@
+# Native PHP binding
+
+64-bit PHP with the FFI extension uses the
+[Haskell embedding ABI](../haskell/README.md). The Nix package selects that runtime
+and extension:
+
+```sh
+nix build .#tricu-php .#tricu-php-tests --no-link
+PHP=$(nix path-info .#tricu-php)
+"$PHP/bin/tricu-php" run test/fixtures/id.arboricx id hello
+"$PHP/bin/tricu-php" run test/fixtures/false.arboricx false --format bool
+```
+
+Require `$PHP/share/tricu-php/src/ffi.php`, then use `Arboricx\Library`:
+
+```php
+$ctx = (new Arboricx\Library())->context();
+try {
+ $number = $ctx->number('18446744073709551615');
+ echo $ctx->toNumber($number); // exact decimal string
+} finally {
+ $ctx->close();
+}
+```
+
+`Library($path)` selects a library explicitly. Default discovery checks `TRICU_LIB`
+and the installed library beside the PHP sources. The header comes from the
+installed package or the repository's Haskell interface. A selected process uses
+one runtime-library path. Keep the complete native runtime closure; Nix retains it
+automatically.
+
+Handles are opaque `FFI\CData` uint64 scalars. Pass them back to context methods;
+PHP integer casts are unnecessary. `number` accepts nonnegative PHP integers or
+canonical decimal uint64 strings. `toNumber` always returns an exact decimal
+string, including small numbers. This preserves unsigned values beyond PHP's
+signed integer range. PHP strings carry both byte buffers (`bytes`/`toBytes`) and
+strict UTF-8 text (`string`/`toString`), preserving embedded NULs.
+
+`loadBundle($bytes, $exportName)` verifies a bundle and selects an explicit export.
+`apply` suspends application; `reduce` demands only the outer constructor.
+`kind` returns 0/1/2 for Leaf/Stem/Fork, and `child` returns an owned handle.
+`toBool` checks the canonical boolean shapes. `runIO` returns an array with
+`outcome`, `value`, and `cleanup`: outcome 0/1/2 means completed/failed/cancelled,
+and both payload handles are owned. Task reasons stay opaque until observed;
+ordinary error Results may be completion values. Boundary failures throw
+`Arboricx\TricuError` with numeric `status` and diagnostic text.
+
+Release intermediate handles in long-lived contexts. Explicit `close` releases all
+remaining roots and is idempotent. Destruction also releases abandoned contexts.
+Returned byte/text buffers are copied to PHP strings and freed in `finally`.
+Native calls are synchronous. IO uses OS process authority; use trusted programs
+or supervised processes with resource limits.
+
+The Linux library is pinned in memory across `dlclose`, matching GHC's process
+lifetime even when PHP releases a request-scoped FFI object. Checks cover CLI
+execution and repeated uploads/errors in one PHP built-in server process. Other
+PHP server configurations, including FPM/Apache process lifecycles, require their
+own acceptance; execute-after-fork reuse of initialized GHC is outside the contract.
+
+The `public/` example runs pure uploaded bundles with an explicit export and result
+format. It supplies no request-controlled native IO execution. Arbitrary reduction
+can exhaust resources or fail to terminate: this example requires external
+supervision and is not an untrusted-computation sandbox.
+
+The CLI requires `run BUNDLE EXPORT`, accepts text arguments, and supports `--io`
+and `--format string|number|bool|bytes` (default string; bytes render as hex).
+`--` ends option parsing. Terminal task failure/cancellation exits nonzero with
+its reason left opaque. Native Zig fuel/collection helpers, implicit export
+selection, heuristic decoding, and the old `inspect` CLI are retired here.
diff --git a/ext/php/public/eval.php b/ext/php/public/eval.php
@@ -1,53 +1,29 @@
<?php
-
declare(strict_types=1);
-
-error_reporting(E_ALL);
-ini_set('display_errors', '1');
-
-if (!extension_loaded('ffi')) {
- http_response_code(500);
- echo "Error: PHP FFI extension is not loaded.\n";
- echo "If you are using the Nix build, run the included server script:\n";
- echo " ./result/bin/tricu-php-server\n";
- exit;
-}
-
-require __DIR__ . '/../src/common.php';
-
-use function Arboricx\{ctx_init, ctx_free, loadBundleDefault, ofNumber, ofString, app, reduce, decode, findLib, readBundle};
-
+require __DIR__.'/../src/common.php';
+use Arboricx\Library;
+use function Arboricx\{readBundle, render};
header('Content-Type: text/plain; charset=utf-8');
-
try {
- if (!isset($_FILES['bundle']) || $_FILES['bundle']['error'] !== UPLOAD_ERR_OK) {
- throw new \RuntimeException('Bundle upload failed.');
- }
-
- $args = [];
- for ($i = 0; $i < 5; $i++) {
- $v = $_POST["arg$i"] ?? '';
- if ($v !== '') {
- $args[] = $v;
- }
- }
-
- $libPath = findLib();
- $ctx = ctx_init($libPath);
+ if (!isset($_FILES['bundle']) || $_FILES['bundle']['error'] !== UPLOAD_ERR_OK) throw new RuntimeException('Bundle upload failed');
+ $name = $_POST['export'] ?? '';
+ if ($name === '') throw new RuntimeException('Export name is required');
+ $ctx = (new Library())->context();
try {
- $term = loadBundleDefault($ctx, readBundle($_FILES['bundle']['tmp_name']));
-
- foreach ($args as $arg) {
- $argTree = preg_match('/^\d+$/', $arg) ? ofNumber($ctx, (int)$arg) : ofString($ctx, $arg);
- $term = app($ctx, $term, $argTree);
+ $term = $ctx->loadBundle(readBundle($_FILES['bundle']['tmp_name']), $name);
+ for ($i = 0; $i < 5; $i++) {
+ $text = $_POST["arg$i"] ?? '';
+ if ($text !== '') {
+ $argument = $ctx->string($text);
+ $next = $ctx->apply($term, $argument);
+ $ctx->release($term); $ctx->release($argument); $term = $next;
+ }
}
-
- $result = reduce($ctx, $term, 1_000_000_000);
- echo decode($ctx, $result);
- } finally {
- ctx_free($ctx);
- }
-} catch (\Throwable $e) {
- http_response_code(500);
- echo 'Error: ' . $e->getMessage();
+ // Uploaded bundles are evaluated as pure trees. Native IO is available
+ // only to explicitly trusted embedding applications through runIO().
+ echo render($ctx, $term, $_POST['format'] ?? 'string');
+ } finally { $ctx->close(); }
+} catch (Throwable $error) {
+ http_response_code(400);
+ echo 'Error: '.$error->getMessage();
}
diff --git a/ext/php/public/index.php b/ext/php/public/index.php
@@ -15,6 +15,8 @@ declare(strict_types=1);
<label>Bundle (.arboricx)<br>
<input type="file" name="bundle" accept=".arboricx" required></label>
</p>
+ <p><label>Export name <input name="export" required value="id"></label></p>
+ <p><label>Result format <select name="format"><option>string</option><option>number</option><option>bool</option><option>bytes</option></select></label></p>
<?php for ($i = 0; $i < 5; $i++): ?>
<p>
<label>Arg <?= $i + 1 ?> <small>(ignored if empty)</small><br>
diff --git a/ext/php/run.php b/ext/php/run.php
@@ -1,103 +1,38 @@
#!/usr/bin/env php
<?php
-
declare(strict_types=1);
+require __DIR__.'/src/common.php';
+use Arboricx\Library;
+use function Arboricx\{readBundle, render};
-/**
- * run.php — Arboricx PHP host shell via libarboricx C ABI.
- *
- * Usage:
- * php run.php run <bundle.arboricx> [args...]
- * php run.php inspect <bundle.arboricx>
- */
-
-require __DIR__ . '/src/common.php';
-
-use function Arboricx\{ctx_init, ctx_free, loadBundleDefault, ofNumber, ofString, app, reduce, toString, toBool, toNumber, findLib, decode, decodeType, readBundle};
-
-// ── Commands ─────────────────────────────────────────────────────────────────
-
-function bail(string $msg): void
-{
- fwrite(STDERR, "Error: $msg\n");
+if ($argc < 4 || $argv[1] !== 'run') {
+ fwrite(STDERR, "Usage: tricu-php run BUNDLE EXPORT [--io] [--format string|number|bool|bytes] [--] [text arguments...]\n");
exit(1);
}
-
-function cmdRun(string $libPath, string $bundlePath, array $args): void
-{
- $ctx = ctx_init($libPath);
- try {
- $term = loadBundleDefault($ctx, readBundle($bundlePath));
-
- foreach ($args as $arg) {
- $argTree = preg_match('/^\d+$/', $arg) ? ofNumber($ctx, (int)$arg) : ofString($ctx, $arg);
- $term = app($ctx, $term, $argTree);
- }
-
- $result = reduce($ctx, $term, 1_000_000_000);
- echo decode($ctx, $result) . "\n";
- } catch (\Throwable $e) {
- bail($e->getMessage());
- } finally {
- ctx_free($ctx);
+$ctx = null;
+$status = 0;
+try {
+ $ctx = (new Library())->context();
+ $term = $ctx->loadBundle(readBundle($argv[2]), $argv[3]);
+ $io = false; $format = 'string'; $options = true;
+ for ($i = 4; $i < $argc; $i++) {
+ if ($options && $argv[$i] === '--') { $options = false; continue; }
+ if ($options && $argv[$i] === '--io') { $io = true; continue; }
+ if ($options && $argv[$i] === '--format') { $format = $argv[++$i] ?? ''; continue; }
+ if ($options && str_starts_with($argv[$i], '--')) throw new RuntimeException('unknown option: '.$argv[$i]);
+ $arg = $ctx->string($argv[$i]);
+ $next = $ctx->apply($term, $arg);
+ $ctx->release($term); $ctx->release($arg); $term = $next;
}
-}
-
-function cmdInspect(string $libPath, string $bundlePath): void
-{
- $ctx = ctx_init($libPath);
- try {
- $bundle = readBundle($bundlePath);
- echo "Bundle: $bundlePath\nSize: " . strlen($bundle) . " bytes\n\nResult:\n";
-
- $term = loadBundleDefault($ctx, $bundle);
- $result = reduce($ctx, $term, 1_000_000_000);
-
- $type = decodeType($ctx, $result);
- try {
- $value = decode($ctx, $result);
- } catch (\RuntimeException $e) {
- $value = '(raw tree)';
- }
- echo " Type: $type\n Value: $value\n";
- } catch (\Throwable $e) {
- bail($e->getMessage());
- } finally {
- ctx_free($ctx);
+ if (!in_array($format, ['string', 'number', 'bool', 'bytes'], true)) throw new RuntimeException('invalid result format');
+ if ($io) {
+ $result = $ctx->runIO($term);
+ if ($result['outcome'] !== 0) throw new RuntimeException('IO task '.($result['outcome'] === 1 ? 'failed' : 'cancelled').' (reason left opaque)');
+ $term = $result['value'];
}
-}
-
-// ── Main ─────────────────────────────────────────────────────────────────────
-
-$argv = $_SERVER['argv'] ?? [];
-$argc = $_SERVER['argc'] ?? 0;
-
-if ($argc < 2) {
- echo "Arboricx PHP Host Shell (via libarboricx C ABI)\n\nUsage:\n";
- echo " php run.php run <bundle.arboricx> [args...]\n";
- echo " php run.php inspect <bundle.arboricx>\n";
- exit(0);
-}
-
-$libPath = findLib();
-$command = $argv[1];
-
-switch ($command) {
- case 'run':
- if ($argc < 3) {
- fwrite(STDERR, "Usage: php run.php run <bundle.arboricx> [args...]\n");
- exit(1);
- }
- cmdRun($libPath, $argv[2], array_slice($argv, 3));
- break;
- case 'inspect':
- if ($argc < 3) {
- fwrite(STDERR, "Usage: php run.php inspect <bundle.arboricx>\n");
- exit(1);
- }
- cmdInspect($libPath, $argv[2]);
- break;
- default:
- fwrite(STDERR, "Unknown command: $command\nUsage: php run.php run|inspect ...\n");
- exit(1);
-}
+ echo render($ctx, $term, $format)."\n";
+} catch (Throwable $error) {
+ fwrite(STDERR, 'Error: '.$error->getMessage()."\n");
+ $status = 1;
+} finally { $ctx?->close(); }
+exit($status);
diff --git a/ext/php/src/common.php b/ext/php/src/common.php
@@ -1,81 +1,22 @@
<?php
-
declare(strict_types=1);
-
namespace Arboricx;
-
-require __DIR__ . '/ffi.php';
-
-use function Arboricx\{ctx_init, ctx_free, loadBundleDefault, ofNumber, ofString, app, reduce, toString, toBool, toNumber};
-
-function findLib(): string
-{
- $env = getenv('ARBORICX_LIB');
- if ($env !== false && file_exists($env)) {
- return $env;
- }
-
- $paths = [
- __DIR__ . '/../../zig/zig-out/lib/libarboricx.so',
- __DIR__ . '/../libarboricx.so',
- '/usr/local/lib/libarboricx.so',
- '/usr/lib/libarboricx.so',
- './libarboricx.so',
- ];
- foreach ($paths as $p) {
- if (file_exists($p)) {
- return $p;
- }
- }
-
- throw new \RuntimeException('libarboricx.so not found. Set ARBORICX_LIB to its full path.');
-}
-
-function decode(\FFI\CData $ctx, int $root): string
-{
- try {
- return toBool($ctx, $root) ? 'true' : 'false';
- } catch (\Throwable $e) {
- try {
- return toString($ctx, $root);
- } catch (\Throwable $e2) {
- try {
- return (string) toNumber($ctx, $root);
- } catch (\Throwable $e3) {
- throw new \RuntimeException('could not decode result');
- }
- }
- }
-}
-
-function decodeType(\FFI\CData $ctx, int $root): string
-{
- try {
- toBool($ctx, $root);
- return 'bool';
- } catch (\Throwable $e) {
- try {
- toString($ctx, $root);
- return 'string';
- } catch (\Throwable $e2) {
- try {
- toNumber($ctx, $root);
- return 'number';
- } catch (\Throwable $e3) {
- return 'unknown (raw tree)';
- }
- }
- }
-}
+require_once __DIR__.'/ffi.php';
function readBundle(string $path): string
{
- if (!file_exists($path)) {
- throw new \RuntimeException("bundle not found: $path");
- }
$bytes = file_get_contents($path);
- if ($bytes === false) {
- throw new \RuntimeException("could not read bundle: $path");
- }
+ if ($bytes === false) throw new \RuntimeException("Cannot read bundle: $path");
return $bytes;
}
+
+function render(Context $ctx, \FFI\CData $value, string $format): string
+{
+ return match ($format) {
+ 'string' => $ctx->toString($value),
+ 'number' => $ctx->toNumber($value),
+ 'bool' => $ctx->toBool($value) ? 'true' : 'false',
+ 'bytes' => bin2hex($ctx->toBytes($value)),
+ default => throw new \InvalidArgumentException('invalid result format'),
+ };
+}
diff --git a/ext/php/src/ffi.php b/ext/php/src/ffi.php
@@ -1,168 +1,201 @@
<?php
-
declare(strict_types=1);
-
namespace Arboricx;
-/**
- * FFI wrapper around libarboricx.so.
- *
- * Loads the shared library and exposes typed wrappers for the C ABI.
- */
-final class ArboricxFFI
+final class TricuError extends \RuntimeException
{
- private static ?\FFI $ffi = null;
-
- public static function init(string $libPath): void
- {
- if (self::$ffi !== null) {
- return;
- }
-
- // Nix output layout first, then repo layout.
- $candidates = [
- __DIR__ . '/../arboricx.h',
- __DIR__ . '/../../zig/include/arboricx.h',
- ];
- $headerRaw = false;
- foreach ($candidates as $path) {
- if (!is_file($path)) continue;
- $headerRaw = file_get_contents($path);
- if ($headerRaw !== false) break;
- }
- if ($headerRaw === false) {
- throw new \RuntimeException('Cannot read arboricx.h');
- }
-
- // PHP FFI only parses plain C declarations.
- $header = $headerRaw;
- $header = preg_replace('/#.*\n/', "\n", $header);
- $header = preg_replace('/extern\s+"C"\s*\{/', '', $header);
- $header = preg_replace('/^\}\s*$/m', '', $header);
- $header = preg_replace('/\n\s*\n+/', "\n", $header);
-
- self::$ffi = \FFI::cdef($header, $libPath);
- if (self::$ffi->arb_abi_version() !== 3) {
- throw new \RuntimeException('Arboricx ABI v3 required');
- }
- }
-
- public static function ffi(): \FFI
+ public function __construct(public readonly int $status, string $message)
{
- if (self::$ffi === null) {
- throw new \RuntimeException('ArboricxFFI not initialized. Call ArboricxFFI::init($libPath) first.');
- }
- return self::$ffi;
+ parent::__construct("tricu status $status: $message");
}
}
-function ctx_init(string $libPath): \FFI\CData
+function findLib(): string
{
- ArboricxFFI::init($libPath);
- $ctx = ArboricxFFI::ffi()->arboricx_init();
- if ($ctx === null) {
- throw new \RuntimeException('arboricx_init failed');
+ $override = getenv('TRICU_LIB');
+ if ($override !== false && $override !== '') {
+ $path = realpath($override);
+ if ($path === false || !is_file($path)) throw new \RuntimeException("TRICU_LIB not found: $override");
+ return $path;
}
- return $ctx;
-}
-
-function ctx_free(\FFI\CData $ctx): void
-{
- ArboricxFFI::ffi()->arboricx_free($ctx);
-}
-
-function owned(string $name, mixed ...$args): int
-{
- $ffi = ArboricxFFI::ffi();
- $out = $ffi->new('arb_handle_t');
- $status = $ffi->$name(...[...$args, \FFI::addr($out)]);
- if ($status !== 0) throw new \RuntimeException("$name: status $status");
- return (int) $out->cdata;
-}
-
-function retain(\FFI\CData $ctx, int $handle): int
-{
- return owned('arb_retain', $ctx, $handle);
-}
-
-function release(\FFI\CData $ctx, int $handle): void
-{
- $status = ArboricxFFI::ffi()->arb_release($ctx, $handle);
- if ($status !== 0) throw new \RuntimeException("arb_release: status $status");
-}
-
-function collect(\FFI\CData $ctx): void
-{
- $status = ArboricxFFI::ffi()->arb_collect($ctx);
- if ($status !== 0) throw new \RuntimeException("arb_collect: status $status");
-}
-
-function app(\FFI\CData $ctx, int $func, int $arg): int
-{
- return owned('arb_app', $ctx, $func, $arg);
-}
-
-function reduce(\FFI\CData $ctx, int $root, int $fuel = 1_000_000_000): int
-{
- return owned('arb_reduce', $ctx, $root, $fuel);
-}
-
-function ofNumber(\FFI\CData $ctx, int $n): int
-{
- return owned('arb_of_number', $ctx, $n);
-}
-
-function ofString(\FFI\CData $ctx, string $s): int
-{
- return owned('arb_of_string', $ctx, $s);
-}
-
-function toNumber(\FFI\CData $ctx, int $root): int
-{
- $out = ArboricxFFI::ffi()->new('uint64_t');
- $ok = ArboricxFFI::ffi()->arb_to_number($ctx, $root, \FFI::addr($out));
- if ($ok !== 0) {
- throw new \RuntimeException('arb_to_number failed');
+ foreach (['libtricu-haskell.so', 'libtricu-haskell.dylib', 'tricu-haskell.dll'] as $name) {
+ $path = __DIR__ . '/../' . $name;
+ if (is_file($path)) return realpath($path);
}
- return (int) $out->cdata;
-}
-
-function toString(\FFI\CData $ctx, int $root): string
-{
- $ptr = ArboricxFFI::ffi()->new('uint8_t*');
- $len = ArboricxFFI::ffi()->new('size_t');
- $ok = ArboricxFFI::ffi()->arb_to_string($ctx, $root, \FFI::addr($ptr), \FFI::addr($len));
- if ($ok !== 0) {
- throw new \RuntimeException('arb_to_string failed');
- }
- $length = (int) $len->cdata;
- $result = '';
- for ($i = 0; $i < $length; $i++) {
- $result .= chr($ptr[$i]);
- }
- ArboricxFFI::ffi()->arboricx_free_buf($ctx, $ptr, $length);
- return $result;
+ throw new \RuntimeException('Set TRICU_LIB to the Haskell embedding library');
}
-function toBool(\FFI\CData $ctx, int $root): bool
+final class Library
{
- $out = ArboricxFFI::ffi()->new('int');
- $ok = ArboricxFFI::ffi()->arb_to_bool($ctx, $root, \FFI::addr($out));
- if ($ok !== 0) {
- throw new \RuntimeException('arb_to_bool failed');
+ private static ?\FFI $native = null;
+ private static ?string $path = null;
+ public readonly \FFI $api;
+ public function __construct(?string $path = null)
+ {
+ if (PHP_INT_SIZE !== 8) throw new \RuntimeException('A 64-bit PHP build is required');
+ $resolved = realpath($path ?? findLib());
+ if ($resolved === false) throw new \RuntimeException('Library not found');
+ if (self::$native !== null && self::$path !== $resolved) {
+ throw new \RuntimeException('One Haskell embedding library per process');
+ }
+ if (self::$native === null) {
+ $header = false;
+ foreach ([__DIR__.'/../tricu.h', __DIR__.'/../../haskell/include/tricu.h'] as $candidate) {
+ if (is_file($candidate)) { $header = file_get_contents($candidate); break; }
+ }
+ if ($header === false) throw new \RuntimeException('Cannot read tricu.h');
+ $header = preg_replace('/^#.*$/m', '', $header);
+ $header = preg_replace('/extern\s+"C"\s*\{/', '', $header);
+ $header = preg_replace('/^\}\s*$/m', '', $header);
+ $api = \FFI::cdef($header, $resolved);
+ $api->tricu_init();
+ if ($api->tricu_abi_version() !== 1) throw new \RuntimeException('Haskell embedding ABI 1 required');
+ self::$native = $api;
+ self::$path = $resolved;
+ }
+ $this->api = self::$native;
}
- return (bool) $out->cdata;
+ public function context(): Context { return new Context($this); }
}
-function loadBundleDefault(\FFI\CData $ctx, string $bytes): int
+final class Context
{
- $cdata = ArboricxFFI::ffi()->new('uint8_t[' . strlen($bytes) . ']');
- for ($i = 0; $i < strlen($bytes); $i++) {
- $cdata[$i] = ord($bytes[$i]);
+ private readonly \FFI $api;
+ private readonly \FFI\CData $identity;
+ private bool $closed = false;
+ public function __construct(Library $library)
+ {
+ $this->api = $library->api;
+ $this->identity = $this->api->new('tricu_context');
+ $status = $this->api->tricu_context_new(\FFI::addr($this->identity));
+ if ($status !== 0) throw new TricuError($status, 'creating context');
+ }
+ public function close(): void
+ {
+ if (!$this->closed) {
+ $this->check($this->api->tricu_context_free($this->identity->cdata));
+ $this->closed = true;
+ }
+ }
+ // Explicit close is preferred; destruction also releases abandoned contexts.
+ public function __destruct() { if (!$this->closed) $this->api->tricu_context_free($this->identity->cdata); }
+ private function check(int $status): void
+ {
+ if ($status === 0) return;
+ $ptr = $this->api->new('uint8_t *');
+ $len = $this->api->new('size_t');
+ $diagnostic = $this->api->tricu_last_error($this->identity->cdata, \FFI::addr($ptr), \FFI::addr($len));
+ $message = 'invalid context';
+ if ($diagnostic === 0) {
+ try { $message = \FFI::string($this->api->cast('char *', $ptr), (int)$len->cdata); }
+ finally { $this->api->tricu_buffer_free($ptr); }
+ }
+ throw new TricuError($status, $message);
}
- $result = owned('arb_load_bundle_default', $ctx, $cdata, strlen($bytes));
- if ($result === 0) {
- throw new \RuntimeException('arb_load_bundle_default failed');
+ private function out(string $name, mixed ...$arguments): \FFI\CData
+ {
+ // FFI scalar calls take PHP scalar values, not CData boxes. For uint64,
+ // PHP exposes the same 64 bits as a signed int; FFI converts those bits
+ // back to uint64 without a floating-point intermediary.
+ $arguments = array_map(fn($arg) => $arg instanceof \FFI\CData &&
+ \FFI::typeof($arg)->getKind() === \FFI\CType::TYPE_UINT64 ? $arg->cdata : $arg, $arguments);
+ $out = $this->api->new('uint64_t');
+ $this->check($this->api->$name($this->identity->cdata, ...[...$arguments, \FFI::addr($out)]));
+ return $out;
+ }
+ public function release(\FFI\CData $value): void { $this->check($this->api->tricu_release($this->identity->cdata, $value->cdata)); }
+ public function retain(\FFI\CData $value): \FFI\CData { return $this->out('tricu_retain', $value); }
+ public function leaf(): \FFI\CData { return $this->out('tricu_leaf'); }
+ public function stem(\FFI\CData $value): \FFI\CData { return $this->out('tricu_stem', $value); }
+ public function fork(\FFI\CData $a, \FFI\CData $b): \FFI\CData { return $this->out('tricu_fork', $a, $b); }
+ public function apply(\FFI\CData $fn, \FFI\CData $arg): \FFI\CData { return $this->out('tricu_apply', $fn, $arg); }
+ public function reduce(\FFI\CData $value): \FFI\CData { return $this->out('tricu_reduce', $value); }
+ public function child(\FFI\CData $value, int $index): \FFI\CData
+ {
+ if ($index < 0 || $index > 0xffffffff) throw new \RangeException('invalid child index');
+ return $this->out('tricu_child', $value, $index);
+ }
+ public function kind(\FFI\CData $value): int
+ {
+ $out = $this->api->new('uint32_t');
+ $this->check($this->api->tricu_kind($this->identity->cdata, $value->cdata, \FFI::addr($out)));
+ return $out->cdata;
+ }
+ public function toBool(\FFI\CData $value): bool
+ {
+ $out = $this->api->new('uint32_t');
+ $this->check($this->api->tricu_to_bool($this->identity->cdata, $value->cdata, \FFI::addr($out)));
+ return $out->cdata !== 0;
+ }
+ public function number(int|string $value): \FFI\CData
+ {
+ $text = (string)$value;
+ if (!preg_match('/^(0|[1-9][0-9]*)$/D', $text) || strlen($text) > 20 ||
+ (strlen($text) === 20 && strcmp($text, '18446744073709551615') > 0)) {
+ throw new \RangeException('number must fit uint64');
+ }
+ // PHP integers are signed. Marshal full-range uint64 through bytes,
+ // never through a floating-point or overflowing PHP integer conversion.
+ $bytes = array_fill(0, 8, 0);
+ foreach (str_split($text) as $digit) {
+ $carry = (int)$digit;
+ for ($i = 0; $i < 8; $i++) {
+ $n = $bytes[$i]*10+$carry;
+ $bytes[$i] = $n & 255;
+ $carry = $n >> 8;
+ }
+ }
+ if (pack('S', 1) !== "\x01\x00") $bytes = array_reverse($bytes);
+ $scalar = $this->api->new('uint64_t');
+ \FFI::memcpy(\FFI::addr($scalar), pack('C*', ...$bytes), 8);
+ return $this->out('tricu_from_u64', $scalar);
+ }
+ public function toNumber(\FFI\CData $value): string
+ {
+ $out = $this->out('tricu_to_u64', $value);
+ $bytes = array_values(unpack('C*', \FFI::string($this->api->cast('char *', \FFI::addr($out)), 8)));
+ if (pack('S', 1) === "\x01\x00") $bytes = array_reverse($bytes);
+ $decimal = '0';
+ foreach ($bytes as $byte) {
+ $carry = $byte;
+ $next = '';
+ for ($i = strlen($decimal)-1; $i >= 0; $i--) {
+ $n = (int)$decimal[$i]*256+$carry;
+ $next = (string)($n % 10).$next;
+ $carry = intdiv($n, 10);
+ }
+ while ($carry > 0) { $next = (string)($carry % 10).$next; $carry = intdiv($carry, 10); }
+ $decimal = ltrim($next, '0') ?: '0';
+ }
+ return $decimal;
+ }
+ private function input(string $value): \FFI\CData
+ {
+ $buffer = $this->api->new('uint8_t['.max(1, strlen($value)).']');
+ if ($value !== '') \FFI::memcpy($buffer, $value, strlen($value));
+ return $buffer;
+ }
+ public function bytes(string $value): \FFI\CData { return $this->out('tricu_from_bytes', $this->input($value), strlen($value)); }
+ public function string(string $value): \FFI\CData { return $this->out('tricu_from_utf8', $this->input($value), strlen($value)); }
+ private function buffer(string $name, \FFI\CData $value): string
+ {
+ $ptr = $this->api->new('uint8_t *'); $len = $this->api->new('size_t');
+ $this->check($this->api->$name($this->identity->cdata, $value->cdata, \FFI::addr($ptr), \FFI::addr($len)));
+ try { return \FFI::string($this->api->cast('char *', $ptr), (int)$len->cdata); }
+ finally { $this->api->tricu_buffer_free($ptr); }
+ }
+ public function toBytes(\FFI\CData $value): string { return $this->buffer('tricu_to_bytes', $value); }
+ public function toString(\FFI\CData $value): string { return $this->buffer('tricu_to_utf8', $value); }
+ public function loadBundle(string $bytes, string $name): \FFI\CData
+ {
+ return $this->out('tricu_load_bundle', $this->input($bytes), strlen($bytes), $this->input($name), strlen($name));
+ }
+ public function runIO(\FFI\CData $program): array
+ {
+ $outcome = $this->api->new('uint32_t');
+ $value = $this->api->new('tricu_handle'); $cleanup = $this->api->new('tricu_handle');
+ $this->check($this->api->tricu_run_io($this->identity->cdata, $program->cdata,
+ \FFI::addr($outcome), \FFI::addr($value), \FFI::addr($cleanup)));
+ return ['outcome' => $outcome->cdata, 'value' => $value, 'cleanup' => $cleanup];
}
- return $result;
}
diff --git a/ext/php/tests/conformance.php b/ext/php/tests/conformance.php
@@ -0,0 +1,91 @@
+<?php
+declare(strict_types=1);
+ini_set('display_errors', 'stderr');
+require __DIR__.'/../src/ffi.php';
+use Arboricx\{Library, TricuError, Context};
+function check(bool $ok, string $message='conformance failed'): void { if (!$ok) throw new RuntimeException($message); }
+function expectStatus(int $status, callable $operation): void {
+ try { $operation(); } catch (TricuError $error) { check($error->status === $status, $error->getMessage()); return; }
+ throw new RuntimeException('invalid operation accepted');
+}
+[$script,$libraryPath,$fixtureDir,$specification] = $argv;
+$f = json_decode(file_get_contents($specification),true,flags:JSON_THROW_ON_ERROR);
+$library = new Library($libraryPath); $ctx = $library->context(); $other = $library->context();
+$load = fn(string $file,string $name) => $ctx->loadBundle(file_get_contents("$fixtureDir/$file"),$name);
+$report = [];
+try {
+ $report['numbers'] = array_map(fn($n) => $ctx->toNumber($ctx->number($n)),$f['numbers']);
+ $report['strings'] = array_map(fn($s) => $ctx->toString($ctx->string($s)),$f['strings']);
+ $report['bytesHex'] = array_map(fn($h) => bin2hex($ctx->toBytes($ctx->bytes(hex2bin($h)))),$f['bytesHex']);
+ $worker=$load('id.arboricx','id'); $argument=$ctx->string($f['identity']);
+ $application=$ctx->apply($worker,$argument); $ctx->release($worker); $ctx->release($argument);
+ $report['identity']=$ctx->toString($ctx->reduce($application));
+ $append=$load('append.arboricx','append');
+ foreach ($f['append'] as $s) $append=$ctx->apply($append,$ctx->string($s));
+ $report['append']=$ctx->toString($append);
+ $report['booleans']=array_map(fn($name) => $ctx->toBool($load($name.'.arboricx',$name)),['true','false']);
+ $report['notQKind']=$ctx->kind($ctx->reduce($load('notQ.arboricx','not?')));
+ $report['outcomes']=[];
+ foreach ([0,2,9] as $code) {
+ $program=$ctx->fork($ctx->string('tricuIO'),$ctx->fork($ctx->number(2),$ctx->fork($ctx->number($code),$ctx->string('opaque payload'))));
+ $r=$ctx->runIO($program);
+ $report['outcomes'][]=['outcome'=>$r['outcome'],'payload'=>$ctx->toString($r['value']),'cleanupKind'=>$ctx->kind($r['cleanup'])];
+ $ctx->release($program); $ctx->release($r['value']); $ctx->release($r['cleanup']);
+ }
+ $root=$ctx->string('owned'); $alias=$ctx->retain($root);
+ check($root->cdata !== $alias->cdata); $ctx->release($root); check($ctx->toString($alias)==='owned');
+ $wrong=$ctx->stem($ctx->stem($ctx->leaf()));
+ $report['negativeStatuses']=[];
+ foreach ([fn()=>$ctx->release($root),fn()=>$other->retain($alias),fn()=>$ctx->toNumber($wrong),fn()=>$ctx->toBool($wrong),
+ fn()=>$ctx->loadBundle('bad bundle','id'),fn()=>$load('id.arboricx','missing'),fn()=>$ctx->runIO($ctx->leaf())] as $operation) {
+ try { $operation(); throw new LogicException('invalid operation accepted'); }
+ catch (TricuError $error) { check($error->getMessage()!==''); $report['negativeStatuses'][]=$error->status; }
+ }
+ foreach ($f['invalidUtf8Hex'] as $h) expectStatus(3,fn()=>$ctx->string(hex2bin($h)));
+ foreach ([-1,'18446744073709551616','1.5'] as $n) {
+ try { $ctx->number($n); throw new LogicException('invalid number accepted'); }
+ catch (RangeException $error) {}
+ }
+ if (isset($argv[4])) checkCopier($library,$argv[4],$f);
+ // PHP array equality ignores object-key order while retaining value types.
+ check($report == $f['expected'],json_encode($report));
+} finally { $other->close(); $ctx->close(); }
+expectStatus(1,fn()=>$ctx->leaf()); $ctx->close();
+for ($i=0;$i<100;$i++) { $fresh=$library->context(); $fresh->close(); }
+echo json_encode($report,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE)."\n";
+
+function checkCopier(Library $library,string $bundle,array $f): void
+{
+ $dir=sys_get_temp_dir().'/tricu-php-'.bin2hex(random_bytes(8));
+ mkdir($dir); $source="$dir/in"; $target="$dir/out";
+ $body=str_repeat(hex2bin($f['copyHex']),$f['copyRepeats']);
+ $ctx=$library->context(); $bytes=file_get_contents($bundle); $worker=$ctx->loadBundle($bytes,'copy');
+ $run=fn($fn,$destination=null)=>$ctx->runIO($ctx->apply($ctx->apply($fn,$ctx->string($source)),$ctx->string($destination??$target)));
+ $count=fn()=>is_dir('/proc/self/fd') ? count(scandir('/proc/self/fd')) : null;
+ try {
+ file_put_contents($source,$body); $baseline=null;
+ for ($i=0;$i<3;$i++) {
+ $r=$run($worker);
+ check($r['outcome']===0 && $ctx->kind($r['cleanup'])===0);
+ check($ctx->toBool($ctx->child($r['value'],0))===true);
+ check(file_get_contents($target)===$body);
+ if ($i===0) $baseline=$count(); else check($count()===$baseline,'descriptor leak');
+ $ctx->release($r['value']); $ctx->release($r['cleanup']);
+ }
+ $r=$run($ctx->loadBundle($bytes,'cancelledCopy'));
+ check($r['outcome']===0 && $ctx->toString($ctx->child($r['value'],0))==='cancelled');
+ check($ctx->toString($ctx->child($ctx->child($r['value'],1),0))==='requested');
+ check(file_get_contents($target)===substr($body,0,4096));
+ $r=$run($worker,"$dir/missing-parent/out");
+ check($r['outcome']===0 && $ctx->toBool($ctx->child($r['value'],0))===false);
+ check($count()===$baseline,'descriptor leak on failure');
+ unlink($source); unlink($target);
+ $r=$run($worker);
+ check($r['outcome']===0 && $ctx->toBool($ctx->child($r['value'],0))===false);
+ check(!file_exists($target));
+ } finally {
+ $ctx->close();
+ foreach (glob("$dir/*") as $file) unlink($file);
+ rmdir($dir);
+ }
+}
diff --git a/ext/php/tests/ownership.php b/ext/php/tests/ownership.php
@@ -1,27 +0,0 @@
-<?php
-declare(strict_types=1);
-require __DIR__ . '/../src/ffi.php';
-
-$ctx = Arboricx\ctx_init(getenv('ARBORICX_LIB'));
-$other = Arboricx\ctx_init(getenv('ARBORICX_LIB'));
-try {
- $h = Arboricx\ofString($ctx, 'owned');
- $alias = Arboricx\retain($ctx, $h);
- Arboricx\release($ctx, $h);
- Arboricx\collect($ctx);
- if (Arboricx\toString($ctx, $alias) !== 'owned') throw new RuntimeException('lost root');
- foreach ([fn() => Arboricx\release($ctx, $h), fn() => Arboricx\retain($other, $alias)] as $invalid) {
- try {
- $invalid();
- throw new LogicException('invalid handle accepted');
- } catch (RuntimeException $error) {
- if (!str_contains($error->getMessage(), 'status 1')) throw $error;
- }
- }
- Arboricx\release($ctx, $alias);
- Arboricx\collect($ctx);
- echo "PHP ownership checks passed\n";
-} finally {
- Arboricx\ctx_free($other);
- Arboricx\ctx_free($ctx);
-}
diff --git a/ext/php/tests/web.py b/ext/php/tests/web.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+"""Repeated requests in one PHP process exercise request-scoped FFI teardown."""
+import os
+from pathlib import Path
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+import urllib.request
+import urllib.error
+
+php, public, fixture = sys.argv[1:]
+with socket.socket() as sock:
+ sock.bind(('127.0.0.1',0))
+ port=sock.getsockname()[1]
+with tempfile.TemporaryFile() as log:
+ server=subprocess.Popen([php,'-d','ffi.enable=true','-S',f'127.0.0.1:{port}','-t',public],stdout=log,stderr=log,
+ env={k:v for k,v in os.environ.items() if k!='PHP_CLI_SERVER_WORKERS'})
+ def post(bundle,name='id',format='string',argument='hello 🌳\x00world'):
+ boundary='tricu-binding-test-boundary'
+ fields={'export':name,'format':format,'arg0':argument}
+ body=b''
+ for key,value in fields.items():
+ body+=f'--{boundary}\r\nContent-Disposition: form-data; name="{key}"\r\n\r\n'.encode()+value.encode()+b'\r\n'
+ body+=f'--{boundary}\r\nContent-Disposition: form-data; name="bundle"; filename="test.arboricx"\r\nContent-Type: application/octet-stream\r\n\r\n'.encode()
+ body+=bundle+f'\r\n--{boundary}--\r\n'.encode()
+ request=urllib.request.Request(f'http://127.0.0.1:{port}/eval.php',body,
+ {'Content-Type':f'multipart/form-data; boundary={boundary}'})
+ try:
+ with urllib.request.urlopen(request,timeout=5) as response:
+ return response.status,response.read()
+ except urllib.error.HTTPError as error:
+ return error.code,error.read()
+ try:
+ for _ in range(100):
+ if server.poll() is not None: raise RuntimeError('PHP server exited during startup')
+ try:
+ with socket.create_connection(('127.0.0.1',port),timeout=.1): break
+ except OSError: time.sleep(.02)
+ else: raise RuntimeError('PHP server startup timed out')
+ bundle=Path(fixture).read_bytes()
+ for i in range(12):
+ assert post(bundle)==(200,'hello 🌳\x00world'.encode())
+ assert post(bundle,name='missing')[0]==400
+ assert post(b'bad bundle')[0]==400
+ assert post(bundle,format='invalid')[0]==400
+ assert server.poll() is None, 'PHP process died between requests'
+ assert post(bundle)==(200,'hello 🌳\x00world'.encode())
+ print('PHP request lifecycle passed in one persistent process')
+ except BaseException:
+ log.seek(0); sys.stderr.write(log.read().decode(errors='replace')); raise
+ finally:
+ server.terminate()
+ try: server.wait(timeout=5)
+ except subprocess.TimeoutExpired: server.kill(); server.wait()
diff --git a/ext/zig/README.md b/ext/zig/README.md
@@ -5,7 +5,8 @@ owned-handle C ABI. Build and validate it with Nix:
```sh
nix build .#tricu-zig .#tricu-zig-tests --no-link
-nix build .#tricu-js-tests .#tricu-php-tests --no-link
+# Native language-binding checks use the Haskell embedding library.
+nix build .#tricu-js-tests .#tricu-php-tests .#tricu-python-tests --no-link
```
The executable and shared library build in **ReleaseSafe**, with Zig runtime
@@ -172,8 +173,10 @@ U+FEFF; source-file reading owns initial-BOM rejection.
## ABI v3 ownership
-`include/arboricx.h` defines ABI v3. `arb_abi_version()` returns 3; the JS, PHP and
-Python test bindings check this version before using handles.
+`include/arboricx.h` defines Zig ABI v3. `arb_abi_version()` returns 3; Zig's
+Python test harness checks this version before using handles. The native
+[JS](../js/README.md), [PHP](../php/README.md), and
+[Python](../haskell/README.md) consumer bindings use the Haskell embedding ABI.
* A handle is an opaque 64-bit integer identifying an owned root. Each successful
handle output owns one root. Constructors borrow their inputs.
diff --git a/flake.nix b/flake.nix
@@ -322,100 +322,156 @@
'';
};
+ tricuHaskell = import ./ext/haskell/build.nix {
+ inherit pkgs;
+ source = tricuSource;
+ };
+ tricuPython = pkgs.python3Packages.buildPythonPackage {
+ pname = "tricu-runtime";
+ version = "2026.10.24";
+ src = ./ext/haskell/python;
+ pyproject = true;
+ build-system = [ pkgs.python3Packages.setuptools ];
+ postInstall = ''
+ ln -s ${tricuHaskell}/lib/libtricu-haskell${pkgs.stdenv.hostPlatform.extensions.sharedLibrary} \
+ $out/${pkgs.python3.sitePackages}/libtricu-haskell${pkgs.stdenv.hostPlatform.extensions.sharedLibrary}
+ '';
+ pythonImportsCheck = [ "tricu" ];
+ };
+ pythonWithTricu = pkgs.python3.withPackages (_: [ tricuPython ]);
+
+ tricuFilecheckBundle = pkgs.runCommand "tricu-filecheck-worker" {
+ nativeBuildInputs = [ tricuPackage ];
+ } ''
+ mkdir -p $out workspace
+ cp ${tricuSource}/lib/base.tri workspace/base.tri
+ cp ${tricuSource}/lib/math/native.tri workspace/native_math.tri
+ cp ${tricuSource}/lib/io-v2.tri workspace/io_v2.tri
+ cp ${tricuSource}/demos/python-filecheck/worker.tri workspace/worker.tri
+ tricu arboricx compile --file workspace/worker.tri --store build-store \
+ --output $out/worker.arboricx --compile-timeout 120 +RTS -N1 -M1024m -RTS
+ sha256sum $out/worker.arboricx | cut -d' ' -f1 > $out/worker.sha256
+ '';
+ tricuFilecheck = pkgs.python3Packages.buildPythonPackage {
+ pname = "tricu-filecheck";
+ version = "2026.10.24";
+ src = ./demos/python-filecheck;
+ pyproject = true;
+ build-system = [ pkgs.python3Packages.setuptools ];
+ dependencies = [ tricuPython ];
+ postPatch = ''
+ cp ${tricuFilecheckBundle}/worker.arboricx tricu_filecheck/worker.arboricx
+ printf 'BUNDLE_SHA256 = "%s"\n' "$(cat ${tricuFilecheckBundle}/worker.sha256)" \
+ > tricu_filecheck/_bundle_pin.py
+ '';
+ pythonImportsCheck = [ "tricu_filecheck" ];
+ };
+ pythonWithFilecheck = pkgs.python3.withPackages (_: [ tricuFilecheck ]);
+
+ tricuWorkflowBundle = pkgs.runCommand "tricu-workflow-demo-bundle" {
+ nativeBuildInputs = [ tricuPackage ];
+ } ''
+ mkdir -p $out workspace
+ cp ${tricuSource}/lib/base.tri workspace/base.tri
+ cp ${tricuSource}/lib/math/native.tri workspace/native_math.tri
+ cp ${tricuSource}/demos/workflow-planning/workflow.tri workspace/workflow.tri
+ cp ${tricuSource}/demos/workflow-planning/pipeline.tri workspace/pipeline.tri
+ tricu arboricx compile --file workspace/pipeline.tri --store build-store \
+ --output $out/pipeline.arboricx --compile-timeout 120 +RTS -N1 -M1024m -RTS
+ sha256sum $out/pipeline.arboricx | cut -d' ' -f1 > $out/pipeline.sha256
+ '';
+ tricuWorkflow = pkgs.python3Packages.buildPythonPackage {
+ pname = "tricu-workflow-demo";
+ version = "2026.10.24";
+ src = ./demos/workflow-planning;
+ pyproject = true;
+ build-system = [ pkgs.python3Packages.setuptools ];
+ dependencies = [ tricuPython ];
+ postPatch = ''
+ cp ${tricuWorkflowBundle}/pipeline.arboricx tricu_workflow_demo/pipeline.arboricx
+ printf 'BUNDLE_SHA256 = "%s"\n' "$(cat ${tricuWorkflowBundle}/pipeline.sha256)" \
+ > tricu_workflow_demo/_bundle_pin.py
+ '';
+ pythonImportsCheck = [ "tricu_workflow_demo" ];
+ };
+ pythonWithWorkflow = pkgs.python3.withPackages (_: [ tricuWorkflow ]);
+
# ------------------------------------------------------------------
# PHP FFI host
# ------------------------------------------------------------------
tricuPhp = pkgs.stdenv.mkDerivation {
pname = "tricu-php";
- version = "0.1.0";
+ version = "2026.10.24";
src = ./ext/php;
- nativeBuildInputs = [ pkgs.makeWrapper phpWithFfi tricuZig ];
- buildPhase = "true";
+ nativeBuildInputs = [ pkgs.makeWrapper ];
+ dontBuild = true;
installPhase = ''
- mkdir -p $out/share/tricu-php $out/lib $out/bin
+ mkdir -p $out/share/tricu-php $out/bin
cp -r src public run.php $out/share/tricu-php/
- cp ${tricuZig}/lib/libarboricx.so $out/lib/
- cp ${tricuZig}/include/arboricx.h $out/share/tricu-php/
-
+ cp ${tricuHaskell}/include/tricu.h $out/share/tricu-php/
+ ln -s ${tricuHaskell}/lib/libtricu-haskell${pkgs.stdenv.hostPlatform.extensions.sharedLibrary} $out/share/tricu-php/
makeWrapper ${phpWithFfi}/bin/php $out/bin/tricu-php \
--add-flags "$out/share/tricu-php/run.php" \
- --set ARBORICX_LIB "$out/lib/libarboricx.so" \
- --prefix LD_LIBRARY_PATH : "$out/lib"
+ --set TRICU_LIB "${tricuHaskell}/lib/libtricu-haskell${pkgs.stdenv.hostPlatform.extensions.sharedLibrary}"
'';
};
- # ------------------------------------------------------------------
- # JS FFI host
- # ------------------------------------------------------------------
tricuJs = pkgs.buildNpmPackage {
pname = "tricu-js";
- version = "0.1.0";
+ version = "2026.10.24";
src = ./ext/js;
npmDepsHash = "sha256-81C7tsNcbyZVhm3uqiWdDQxp5LAXXO9aueHdMDztCfM=";
- nativeBuildInputs = [ pkgs.nodejs tricuZig ];
dontNpmBuild = true;
installPhase = ''
- mkdir -p $out/lib/
+ mkdir -p $out/lib/ $out/bin
cp -r . $out/lib/
- cp ${tricuZig}/lib/libarboricx.so $out/lib/src
+ ln -s ${tricuHaskell}/lib/libtricu-haskell${pkgs.stdenv.hostPlatform.extensions.sharedLibrary} $out/lib/src/
+ makeWrapper ${pkgs.nodejs}/bin/node $out/bin/arboricx-run \
+ --add-flags "$out/lib/src/cli.js"
'';
+ nativeBuildInputs = [ pkgs.makeWrapper ];
};
- # ------------------------------------------------------------------
- # JS FFI host tests (separate target)
- # ------------------------------------------------------------------
- tricuJsTests = pkgs.stdenv.mkDerivation {
- pname = "tricu-js-tests";
- version = "0.1.0";
- src = ./.;
- nativeBuildInputs = [ pkgs.nodejs tricuZig ];
- buildPhase = "true";
- doCheck = true;
- checkPhase = ''
- export ARBORICX_LIB=${tricuZig}/lib/libarboricx.so
- export LD_LIBRARY_PATH=${tricuZig}/lib:$LD_LIBRARY_PATH
- ulimit -s 32768
-
- cd ext/js
- # node_modules are pre-fetched by buildNpmPackage; copy them in
- cp -r ${tricuJs}/lib/node_modules .
- npm test
-
- mkdir -p $out
- echo "All JS tests passed" > $out/result
- '';
- };
+ tricuJsTests = pkgs.runCommand "tricu-js-tests" {
+ nativeBuildInputs = [ pkgs.nodejs ];
+ } ''
+ export TRICU_LIB=${tricuHaskell}/lib/libtricu-haskell${pkgs.stdenv.hostPlatform.extensions.sharedLibrary}
+ cp -r ${tricuSource} source
+ chmod -R u+w source
+ cd source/ext/js
+ ln -s ${tricuJs}/lib/node_modules node_modules
+ timeout 90s npm test
+ test "$(${tricuJs}/bin/arboricx-run run ../../test/fixtures/id.arboricx id hello)" = hello
+ test "$(${tricuJs}/bin/arboricx-run run ../../test/fixtures/true.arboricx true --format bool)" = true
+ if ${tricuJs}/bin/arboricx-run run ../../test/fixtures/id.arboricx missing; then exit 1; fi
+ mkdir -p $out
+ '';
- # ------------------------------------------------------------------
- # PHP FFI tests (separate target)
- # ------------------------------------------------------------------
phpWithFfi = pkgs.php.withExtensions (exts: [ pkgs.phpExtensions.ffi ]);
-
- tricuPhpTests = pkgs.stdenv.mkDerivation {
- pname = "tricu-php-tests";
- version = "0.1.0";
- src = ./.;
- nativeBuildInputs = [ phpWithFfi tricuPhp ];
- buildPhase = "true";
- doCheck = true;
- checkPhase = ''
- export ARBORICX_LIB=${tricuPhp}/lib/libarboricx.so
- export LD_LIBRARY_PATH=${tricuPhp}/lib:$LD_LIBRARY_PATH
- ulimit -s 32768
-
- php ext/php/tests/ownership.php
-
- # Run PHP host against fixture bundles
- php ext/php/run.php run test/fixtures/id.arboricx hello
- php ext/php/run.php run test/fixtures/append.arboricx "Hello, " "world!"
- php ext/php/run.php run test/fixtures/true.arboricx
- php ext/php/run.php run test/fixtures/false.arboricx
- php ext/php/run.php run test/fixtures/notQ.arboricx "t t t"
-
- mkdir -p $out
- echo "All PHP tests passed" > $out/result
- '';
- };
+ tricuPhpTests = pkgs.runCommand "tricu-php-tests" {
+ nativeBuildInputs = [ phpWithFfi pkgs.python3 ];
+ } ''
+ export TRICU_LIB=${tricuHaskell}/lib/libtricu-haskell${pkgs.stdenv.hostPlatform.extensions.sharedLibrary}
+ mkdir -p $out
+ timeout 90s php ${tricuSource}/ext/php/tests/conformance.php "$TRICU_LIB" \
+ ${tricuSource}/test/fixtures ${tricuSource}/ext/haskell/bindings/fixtures.json > $out/report.json
+ test "$(${tricuPhp}/bin/tricu-php run ${tricuSource}/test/fixtures/id.arboricx id hello)" = hello
+ test "$(${tricuPhp}/bin/tricu-php run ${tricuSource}/test/fixtures/false.arboricx false --format bool)" = false
+ if ${tricuPhp}/bin/tricu-php run ${tricuSource}/test/fixtures/id.arboricx missing; then exit 1; fi
+ timeout 60s python3 ${tricuSource}/ext/php/tests/web.py ${phpWithFfi}/bin/php \
+ ${tricuPhp}/share/tricu-php/public ${tricuSource}/test/fixtures/id.arboricx
+ '';
+ tricuPythonTests = pkgs.runCommand "tricu-python-tests" {
+ nativeBuildInputs = [ pythonWithTricu ];
+ } ''
+ export PYTHONDONTWRITEBYTECODE=1
+ mkdir -p $out
+ # Exercise installed-module discovery with no TRICU_LIB override.
+ python3 -c 'from tricu import Library; c = Library().context(); c.close()'
+ timeout 90s python3 ${tricuSource}/ext/haskell/bindings/python.py \
+ ${tricuHaskell}/lib/libtricu-haskell${pkgs.stdenv.hostPlatform.extensions.sharedLibrary} \
+ ${tricuSource}/test/fixtures ${tricuSource}/ext/haskell/bindings/fixtures.json > $out/report.json
+ '';
in {
packages.haskellWasmBrowser = import ./ext/haskell-wasm/browser.nix {
inherit pkgs;
@@ -429,6 +485,76 @@
};
packages.${packageName} = tricuPackage;
packages.default = tricuPackage;
+ packages.tricu-haskell-ffi = tricuHaskell;
+ packages.tricu-python = tricuPython;
+ packages.tricu-python-tests = tricuPythonTests;
+ packages.tricu-workflow-demo = tricuWorkflow;
+ packages.tricu-workflow-demo-test = pkgs.runCommand "tricu-workflow-demo-test" {
+ nativeBuildInputs = [ pythonWithWorkflow ];
+ } ''
+ export PYTHONDONTWRITEBYTECODE=1
+ mkdir -p $out
+ cp ${(pkgs.closureInfo { rootPaths = [ tricuWorkflow ]; })}/store-paths $out/runtime-closure
+ if grep -Fx '${tricuPackage}' $out/runtime-closure; then exit 1; fi
+ if grep -Fx '${tricuSource}' $out/runtime-closure; then exit 1; fi
+ timeout 90s python3 ${tricuSource}/test/workflow_planning.py \
+ --app ${tricuWorkflow}/bin/tricu-workflow-demo --report $out/report.json
+ '';
+ packages.tricu-filecheck = tricuFilecheck;
+ packages.tricu-filecheck-test = pkgs.runCommand "tricu-filecheck-test" {
+ nativeBuildInputs = [ pythonWithFilecheck ];
+ } ''
+ export PYTHONDONTWRITEBYTECODE=1
+ mkdir -p $out
+ # Runtime closure must contain neither the source compiler executable
+ # nor the source snapshot used to build it.
+ cp ${(pkgs.closureInfo { rootPaths = [ tricuFilecheck ]; })}/store-paths $out/runtime-closure
+ if grep -Fx '${tricuPackage}' $out/runtime-closure; then exit 1; fi
+ if grep -Fx '${tricuSource}' $out/runtime-closure; then exit 1; fi
+ timeout 120s python3 ${tricuSource}/test/python_filecheck.py \
+ --app ${tricuFilecheck}/bin/tricu-filecheck --report $out/report.json
+ '';
+
+ # Source-compiling embedding acceptance stays opt-in, like the other
+ # application compilation checks. Basic C/Python checks run in the library.
+ packages.tricu-haskell-ffi-integration = pkgs.runCommand "tricu-haskell-ffi-integration" {
+ nativeBuildInputs = [ pkgs.python3 tricuPackage ];
+ } ''
+ export PYTHONDONTWRITEBYTECODE=1
+ export PYTHONPATH=${tricuSource}/ext/haskell/python
+ mkdir -p $out workspace
+ cp ${tricuSource}/lib/base.tri workspace/base.tri
+ cp ${tricuSource}/lib/math/native.tri workspace/native_math.tri
+ cp ${tricuSource}/lib/io-v2.tri workspace/io_v2.tri
+ cp ${tricuSource}/lib/handlers.tri workspace/handlers.tri
+ cp ${tricuSource}/demos/io-v2/copy.tri workspace/copy.tri
+ tricu arboricx compile --file workspace/copy.tri --store copy-store \
+ --output $out/copy.arboricx --compile-timeout 120 +RTS -N1 -M1024m -RTS
+ library=${self.packages.${system}.tricu-haskell-ffi}/lib/libtricu-haskell${pkgs.stdenv.hostPlatform.extensions.sharedLibrary}
+ python3 ${tricuSource}/ext/haskell/test_embedding.py "$library" \
+ ${tricuSource}/test/fixtures --copy-bundle $out/copy.arboricx > $out/integration.json
+ python3 ${tricuSource}/ext/haskell/compiler_probe.py "$library" \
+ ${tricuSource}/bootstrap/build-stable.arboricx --tricu ${tricuPackage}/bin/tricu \
+ --directory compiler-probe > $out/compiler.log
+ cp compiler-probe/report.json $out/compiler.json
+ '';
+
+ packages.tricu-native-bindings-integration = pkgs.runCommand "tricu-native-bindings-integration" {
+ nativeBuildInputs = [ pythonWithTricu pkgs.nodejs phpWithFfi ];
+ } ''
+ export PYTHONDONTWRITEBYTECODE=1
+ cp -r ${tricuSource} source
+ chmod -R u+w source
+ ln -s ${tricuJs}/lib/node_modules source/ext/js/node_modules
+ mkdir -p $out
+ python3 source/ext/haskell/bindings/check.py \
+ --source "$PWD/source" \
+ --library ${tricuHaskell}/lib/libtricu-haskell${pkgs.stdenv.hostPlatform.extensions.sharedLibrary} \
+ --python ${pythonWithTricu}/bin/python3 --node ${pkgs.nodejs}/bin/node --php ${phpWithFfi}/bin/php \
+ --copier ${self.packages.${system}.tricu-haskell-ffi-integration}/copy.arboricx \
+ --report $out/report.json
+ '';
+
# Independent Haskell oracle for the opt-in published-library consumer.
# Opt-in Linux paired runtime measurements. Build first; timing runs
# explicitly outside the Nix build, never during the fast check gate.
@@ -546,6 +672,10 @@
checks.${packageName} = tricuPackageTests;
checks.default = tricuPackageTests;
+ checks.haskellEmbedding = tricuHaskell;
+ checks.pythonBinding = tricuPythonTests;
+ checks.jsBinding = tricuJsTests;
+ checks.phpBinding = tricuPhpTests;
# Fast packaging/filesystem checks only, no compiler or application runs.
checks.arboricxSite = pkgs.runCommand "arboricx-site-release-check" {
nativeBuildInputs = [ pkgs.python3 ];
diff --git a/test/python_filecheck.py b/test/python_filecheck.py
@@ -0,0 +1,203 @@
+#!/usr/bin/env python3
+"""Installed Python consumer: no checkout/compiler/cache at runtime."""
+import argparse
+from dataclasses import asdict
+import hashlib
+import json
+import os
+from pathlib import Path
+import resource
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+from unittest.mock import patch
+
+from process_rss import run_guarded
+
+
+def reuse(directory):
+ from tricu_filecheck import FileChecker, FileProblem
+ directory = Path(directory)
+ source = directory/'reused.bin'
+ body = bytes(range(256))*4
+ source.write_bytes(body)
+ started = time.monotonic()
+ with FileChecker() as checker:
+ worker = checker.worker
+ context = checker.context.identity
+ for _ in range(8):
+ checker.hash_file(source)
+ fd_count = len(list(Path('/proc/self/fd').iterdir()))
+ for _ in range(128):
+ # Native IO, not a Python hashing/read fallback. Bundle loading has
+ # already completed; Python file opens during hashing are prohibited.
+ with patch('builtins.open', side_effect=AssertionError('Python opened worker input')), patch.object(Path, 'open', side_effect=AssertionError('Python opened worker input')):
+ actual = checker.hash_file(source)
+ assert asdict(actual) == dict(bytes=len(body), sha256=hashlib.sha256(body).hexdigest())
+ try:
+ checker.hash_file(directory/'missing')
+ except FileProblem as error:
+ assert error.problem.category == 'not-found'
+ assert error.problem.operation == 'path/stat'
+ else:
+ raise AssertionError('missing file accepted')
+ assert checker.worker == worker and checker.context.identity == context
+ assert len(list(Path('/proc/self/fd').iterdir())) == fd_count
+ large = directory/'large.bin'
+ payload = bytes(range(256))*4096
+ large.write_bytes(payload)
+ assert checker.hash_file(large).sha256 == hashlib.sha256(payload).hexdigest()
+ for invalid in (-1, 2**63, 1.5, True):
+ try:
+ checker.hash_file(source, invalid)
+ except ValueError:
+ pass
+ else:
+ raise AssertionError('invalid byte limit accepted')
+ checker.close()
+ return dict(warmup_calls=8, successful_repeated_calls=128, expected_error_calls=128,
+ large_file_bytes=len(payload), seconds=time.monotonic()-started,
+ peak_rss_kib=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss,
+ descriptor_count=fd_count)
+
+
+def acceptance(args):
+ report = dict(checks=[])
+ with tempfile.TemporaryDirectory(prefix='filecheck-consumer-') as work:
+ work = Path(work)
+ data, caller, home = work/'data', work/'unrelated-cwd', work/'empty-home'
+ for directory in (data, caller, home):
+ directory.mkdir()
+ env = dict(os.environ, HOME=str(home), XDG_CACHE_HOME=str(home/'cache'),
+ PATH='/no-runtime-tools', PYTHONDONTWRITEBYTECODE='1')
+ for variable in ('TRICU_LIB', 'ARBORICX_LIB', 'PYTHONPATH', 'tricu_datadir'):
+ env.pop(variable, None)
+ # CLI runs have no source tree, tool lookup, compiler or writable cache.
+ caller.chmod(0o555)
+ home.chmod(0o555)
+ def run(*arguments, code=0, environment=None):
+ result = subprocess.run([str(args.app), *map(str, arguments)], cwd=caller,
+ env=environment or env, text=True, capture_output=True, timeout=30)
+ assert result.returncode == code, (arguments, result.returncode, result.stdout, result.stderr)
+ assert not result.stderr, result.stderr
+ return json.loads(result.stdout)
+ contents = {'empty': b'', 'boundary.bin': bytes(range(256))*16+b'\0',
+ 'Unicode 🌳\nname.bin': b'\xff\x00\x80binary\r\n'}
+ for name, body in contents.items():
+ (data/name).write_bytes(body)
+ (data/'link').symlink_to('boundary.bin')
+ contents['link'] = contents['boundary.bin']
+ manifest = data/'checks.json'
+ actual = run('record', manifest, *contents)
+ assert actual['ok'] is True
+ expected = dict(schema='tricu.filecheck.v1', algorithm='sha256', files=[
+ dict(path=name, bytes=len(body), sha256=hashlib.sha256(body).hexdigest()) for name, body in contents.items()])
+ assert json.loads(manifest.read_text()) == expected
+ assert run('verify', manifest)['ok'] is True
+ report['checks'].append('installed source-free record/verify; exact hashes and byte counts')
+ import tricu_filecheck
+ installed = Path(tricu_filecheck.__file__).parent
+ assert (installed/'worker.arboricx').is_file()
+ assert not list(installed.rglob('*.tri'))
+ original = manifest.read_bytes()
+ assert run('record', manifest, *contents, code=2)['ok'] is False
+ assert manifest.read_bytes() == original
+ assert not list(data.glob('.filecheck-*'))
+ report['checks'].append('existing manifest preserved; staging cleaned')
+ # Same-length mutations ensure verification actually compares hashes.
+ (data/'boundary.bin').write_bytes(b'x'*len(contents['boundary.bin']))
+ changed = run('verify', manifest, code=1)
+ assert [r['path'] for r in changed['files'] if r['status']=='changed'] == ['boundary.bin','link']
+ (data/'empty').unlink()
+ failed = run('verify', manifest, code=2)
+ assert failed['files'][0]['error']['category'] == 'not-found'
+ assert failed['files'][0]['error']['operation'] == 'path/stat'
+ assert failed['files'][1]['status'] == 'changed'
+ report['checks'].append('changed and missing files distinguished; remaining entries checked')
+ (data/'empty').write_bytes(b'')
+ exact = run('record', data/'exact.json', 'empty', '--max-bytes', 0)
+ assert exact['files'][0]['bytes'] == 0
+ exact_boundary = run('record', data/'exact-boundary.json', 'boundary.bin', '--max-bytes', 4097)
+ assert exact_boundary['files'][0]['bytes'] == 4097
+ limit = run('record', data/'over-limit.json', 'boundary.bin', '--max-bytes', 4096, code=2)
+ assert limit['files'][0]['error']['category'] == 'invalid-bound'
+ assert not (data/'over-limit.json').exists()
+ (data/'directory').mkdir()
+ os.mkfifo(data/'fifo')
+ for name in ('directory', 'fifo'):
+ failure = run('record', data/f'{name}.json', name, code=2)
+ assert failure['files'][0]['error']['category'] == 'invalid-argument'
+ assert os.geteuid() != 0, 'permission control requires an unprivileged test process'
+ denied = data/'denied'
+ denied.write_bytes(b'secret')
+ denied.chmod(0)
+ try:
+ failure = run('record', data/'denied.json', 'denied', code=2)
+ assert failure['files'][0]['error']['category'] == 'os-permission-denied'
+ finally:
+ denied.chmod(0o600)
+ mixed = run('record', data/'partial.json', 'empty', 'missing', code=2)
+ assert [row['status'] for row in mixed['files']] == ['ok','error']
+ assert not (data/'partial.json').exists()
+ report['checks'].append('zero/excess budget, non-files, permission failure and no partial manifest')
+ # Validate all manifest syntax/fields before executing the worker.
+ bad = data/'bad.json'
+ cases = ['{', '{"schema":1,"schema":2}', json.dumps(dict(expected, algorithm='md5')),
+ json.dumps(dict(expected, files=[expected['files'][0]]*2)),
+ json.dumps(dict(expected, files=[dict(expected['files'][0], path='/absolute')])),
+ json.dumps(dict(expected, files=[dict(expected['files'][0], path='bad\0path')])),
+ json.dumps(dict(expected, files=[dict(expected['files'][0], bytes=True)])),
+ json.dumps(dict(expected, files=[dict(expected['files'][0], sha256='no')])),
+ json.dumps(dict(expected, files=[])), ' '* (1024*1024+1)]
+ for text in cases:
+ bad.write_text(text)
+ assert run('verify', bad, code=2)['ok'] is False
+ assert run('record', data/'duplicate.json', 'empty', 'empty', code=2)['ok'] is False
+ report['checks'].append('strict manifest validation; duplicate paths/keys and size bound')
+ # Corrupt/missing packaged workers fail closed, with no source/compiler
+ # lookup fallback. The copied installed module keeps its original pin.
+ copied = work/'copied-package'
+ package = copied/'tricu_filecheck'
+ shutil.copytree(Path(tricu_filecheck.__file__).parent, package)
+ for item in [package, *package.rglob('*')]:
+ item.chmod(0o755 if item.is_dir() else 0o644)
+ bundle = package/'worker.arboricx'
+ bundle.write_bytes(bundle.read_bytes()[:32])
+ command = [sys.executable, '-c',
+ 'import sys; sys.path.insert(0,sys.argv.pop(1)); from tricu_filecheck.cli import main; sys.exit(main())',
+ str(copied), 'record', str(data/'corrupt.json'), 'empty']
+ result = subprocess.run(command, cwd=caller, text=True, capture_output=True, timeout=15)
+ assert result.returncode == 2 and 'checksum mismatch' in result.stdout
+ assert not (data/'corrupt.json').exists()
+ bundle.unlink()
+ result = subprocess.run(command, cwd=caller, text=True, capture_output=True, timeout=15)
+ assert result.returncode == 2 and not (data/'corrupt.json').exists()
+ report['checks'].append('corrupt and missing installed bundle rejected without fallback')
+ repeated, peak = run_guarded([sys.executable, str(Path(__file__).resolve()), '--reuse', str(data)],
+ memory_mib=512, seconds=90)
+ report['reuse'] = json.loads(repeated)
+ report['reuse']['sampled_peak_rss_kib'] = peak
+ assert not list(caller.iterdir()) and not list(home.iterdir())
+ caller.chmod(0o755)
+ home.chmod(0o755)
+ return report
+
+
+def main():
+ parser=argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--app', type=Path)
+ parser.add_argument('--report', type=Path)
+ parser.add_argument('--reuse', type=Path)
+ args=parser.parse_args()
+ if args.reuse:
+ print(json.dumps(reuse(args.reuse)))
+ else:
+ report=acceptance(args)
+ args.report.write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n')
+ print(json.dumps(report,ensure_ascii=False))
+
+
+if __name__=='__main__':
+ main()
diff --git a/test/workflow_planning.py b/test/workflow_planning.py
@@ -0,0 +1,200 @@
+#!/usr/bin/env python3
+"""Independent plan/trace oracles and fail-closed local authority controls."""
+import argparse
+from dataclasses import asdict
+import hashlib
+import json
+import os
+from pathlib import Path
+import resource
+import subprocess
+import tempfile
+from unittest.mock import patch
+
+from tricu_workflow_demo import Session, WorkspaceExecutor, ProtocolError
+
+
+def tree(roots, value):
+ if isinstance(value, str):
+ return roots.string(value)
+ result = roots.leaf()
+ for child in reversed(value):
+ result = roots.fork(tree(roots, child), result)
+ return result
+
+
+def identity(stages):
+ payload = json.dumps(stages, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode()
+ return hashlib.sha256(b'tricu.workflow-demo.plan.v1\0'+payload).hexdigest()
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--app', type=Path, required=True)
+ parser.add_argument('--report', type=Path, required=True)
+ args = parser.parse_args()
+ checks = []
+ text = 'built by tricu 🌳\n'
+ expected_stages = [
+ dict(name='build', action=['write','candidate.txt',text], approval=False),
+ dict(name='test', action=['check','candidate.txt',text], approval=False),
+ dict(name='deploy', action=['publish','candidate.txt','release.txt'], approval=False)]
+ protected_stages = [dict(stage, approval=stage['name']=='deploy') for stage in expected_stages]
+ expected_explanation = ('build: write candidate.txt\ntest: check candidate.txt\n'
+ 'approval required: deploy\ndeploy: publish candidate.txt -> release.txt\n')
+ with tempfile.TemporaryDirectory(prefix='workflow-lab-') as directory:
+ root = Path(directory)
+ caller = root/'unrelated-cwd'; caller.mkdir(); caller.chmod(0o555)
+ home = root/'empty-home'; home.mkdir(); home.chmod(0o555)
+ environment = dict(os.environ, HOME=str(home), XDG_CACHE_HOME=str(home/'cache'), PATH='/no-tools')
+ for name in ('TRICU_LIB','ARBORICX_LIB','PYTHONPATH'):
+ environment.pop(name,None)
+ def run(*options, code=0):
+ result = subprocess.run([str(args.app), *options], cwd=caller, env=environment,
+ capture_output=True, text=True, timeout=10)
+ assert result.returncode == code, (options,result.returncode,result.stdout,result.stderr)
+ assert not result.stderr, result.stderr
+ return json.loads(result.stdout)
+ plain = run('explain','--message',text)
+ protected = run('explain','--message',text,'--approval')
+ assert plain['stages'] == expected_stages
+ assert plain['definition_id'] == identity(expected_stages)
+ assert protected['stages'] == protected_stages
+ assert protected['definition_id'] == identity(protected_stages)
+ assert protected['explanation'] == expected_explanation
+ assert protected['definition_id'] != plain['definition_id']
+ assert not list(caller.iterdir()) and not list(home.iterdir())
+ checks.append('independent explanation, definition hash, and approval transformation')
+ capabilities = ['--allow','write','--allow','check','--allow','publish']
+ def execute(name, guarded=True, grants=(), caps=capabilities, code=1):
+ workspace = root/name; workspace.mkdir()
+ options = ['execute','--workspace',str(workspace),'--message',text,*caps]
+ if guarded: options += ['--approval']
+ for grant in grants: options += ['--approve',grant]
+ return workspace,run(*options,code=code)
+ workspace, denied = execute('no-capabilities',caps=[])
+ assert denied['events'] == [dict(stage='build',action=expected_stages[0]['action'],status='denied')]
+ assert not list(workspace.iterdir())
+ workspace, denied = execute('no-approval')
+ assert [e['action'][0] for e in denied['events']] == ['write','check','approve']
+ assert [e['status'] for e in denied['events']] == ['ok','ok','denied']
+ assert (workspace/'candidate.txt').read_text() == text and not (workspace/'release.txt').exists()
+ workspace, denied = execute('stale-approval',grants=[plain['definition_id']+':deploy'])
+ assert denied['events'][-1]['status']=='denied' and not (workspace/'release.txt').exists()
+ workspace, denied = execute('wrong-stage',grants=[protected['definition_id']+':test'])
+ assert denied['events'][-1]['status']=='denied' and not (workspace/'release.txt').exists()
+ workspace, denied = execute('no-boundary',guarded=False,grants=[plain['definition_id']+':deploy'])
+ assert denied['events'][-1]['action'][0]=='publish' and denied['events'][-1]['status']=='denied'
+ assert not (workspace/'release.txt').exists()
+ workspace, denied = execute('no-publish-capability',grants=[protected['definition_id']+':deploy'],caps=capabilities[:4])
+ assert [e['status'] for e in denied['events']] == ['ok','ok','ok','denied']
+ assert not (workspace/'release.txt').exists()
+ workspace, completed = execute('approved',grants=[protected['definition_id']+':deploy'],code=0)
+ assert completed == dict(outcome='completed',definition_id=protected['definition_id'],events=[
+ dict(stage='build',action=expected_stages[0]['action'],status='ok'),
+ dict(stage='test',action=expected_stages[1]['action'],status='ok'),
+ dict(stage='deploy',action=['approve'],status='ok'),
+ dict(stage='deploy',action=expected_stages[2]['action'],status='ok')])
+ assert (workspace/'release.txt').read_text()==text
+ checks.append('capabilities and exact-definition/stage approval are independent; no missing-gate bypass')
+ checks.append('source interpreter stops after refusal and executes approved pipeline in declared order')
+ workspace=root/'changed-payload'; workspace.mkdir()
+ changed=run('execute','--workspace',str(workspace),'--message',text+'changed','--approval',
+ *capabilities,'--approve',protected['definition_id']+':deploy',code=1)
+ assert changed['events'][-1]['status']=='denied' and not (workspace/'release.txt').exists()
+ with Session() as session:
+ with session.context.scope() as roots:
+ plan = session.plan(roots,text)
+ # Neither interpreter calls the built-in native IO driver.
+ with patch.object(session.context,'run_io',side_effect=AssertionError('native IO invoked')):
+ assert session.describe(roots,plan)['definition_id']==plain['definition_id']
+ once = session.guard(roots,plan,'deploy')
+ twice = session.guard(roots,once,'deploy')
+ assert session.describe(roots,once)==session.describe(roots,twice)
+ changed = session.plan(roots,text+'changed',True)
+ assert session.describe(roots,changed)['definition_id'] != protected['definition_id']
+ try: session.guard(roots,plan,'missing')
+ except ProtocolError: pass
+ else: raise AssertionError('missing transform target accepted')
+ noio = root/'pure-execution'; noio.mkdir()
+ with patch.object(session.context,'run_io',side_effect=AssertionError('native IO invoked')):
+ with WorkspaceExecutor(noio,('write','check','publish'),[(protected['definition_id'],'deploy')]) as executor:
+ assert session.execute(roots,once,executor)['outcome']=='completed'
+ checks.append('idempotent structural transform; payload-sensitive identity; pure interaction interpreter')
+ valid = ['stage','build',['write','candidate.txt',text]]
+ invalid = [[], [valid,valid], [['stage','bad',['shell','sh','-c']]],
+ [valid,['stage','later',['write','../escape','x']]],
+ [['stage','bad',['write','/absolute','x']]],
+ [['approval','other',valid]], [['approval','build',['approval','build',valid]]],
+ [['stage','bad',['write','candidate.txt','x'*65537]]]]
+ for index, model in enumerate(invalid):
+ workspace=root/f'invalid-{index}'; workspace.mkdir()
+ with session.context.scope() as roots, WorkspaceExecutor(workspace,('write','check','publish')) as executor:
+ try: session.execute(roots,tree(roots,model),executor)
+ except ProtocolError: pass
+ else: raise AssertionError('invalid declaration accepted')
+ assert not list(workspace.iterdir())
+ # Fabricate a dishonest execution message, independently of the
+ # production tricu interpreter: dispatcher must compare declarations.
+ workspace=root/'mismatched-request'; workspace.mkdir()
+ with session.context.scope() as roots, WorkspaceExecutor(workspace,('write','check','publish')) as executor:
+ plan=session.plan(roots,text,True)
+ dishonest=tree(roots,['request','build',['write','different.txt','oops'],[]])
+ with patch.object(session,'_start',return_value=dishonest):
+ try: session.execute(roots,plan,executor)
+ except ProtocolError: pass
+ else: raise AssertionError('undeclared effect accepted')
+ assert not list(workspace.iterdir())
+ with patch.object(session,'_start',return_value=tree(roots,['done'])):
+ try: session.execute(roots,plan,executor)
+ except ProtocolError: pass
+ else: raise AssertionError('premature completion accepted')
+ checks.append('whole-plan validation before effects; independent dishonest-request/completion controls')
+ outside=root/'outside'; outside.write_text('untouched')
+ workspace=root/'symlink'; workspace.mkdir()
+ (workspace/'candidate.txt').symlink_to(outside)
+ with session.context.scope() as roots, WorkspaceExecutor(workspace,('write','check','publish')) as executor:
+ result=session.execute(roots,session.plan(roots,text,True),executor)
+ assert result['outcome']=='failed' and result['events'][0]['status']=='error'
+ assert outside.read_text()=='untouched'
+ assert not (workspace/'release.txt').exists()
+ # Direct source-interpreter check stages exercise no-follow reads of
+ # symlinks and nonregular files without an earlier write masking it.
+ for name,kind in [('link','symlink'),('directory','directory'),('pipe','fifo')]:
+ workspace=root/name; workspace.mkdir()
+ path=workspace/'candidate.txt'
+ if kind=='symlink': path.symlink_to(outside)
+ elif kind=='directory': path.mkdir()
+ else: os.mkfifo(path)
+ with session.context.scope() as roots, WorkspaceExecutor(workspace,('check',)) as executor:
+ plan=tree(roots,[['stage','test',['check','candidate.txt','untouched']]])
+ result=session.execute(roots,plan,executor)
+ assert result['outcome']=='failed' and result['events'][0]['status']=='error'
+ checks.append('exclusive writes, anchored no-follow reads, nonregular-file rejection')
+ workspace=root/'closed-executor'; workspace.mkdir()
+ executor=WorkspaceExecutor(workspace,('write','check','publish'))
+ executor.close()
+ with session.context.scope() as roots:
+ result=session.execute(roots,session.plan(roots,text),executor)
+ assert result['events'][0]['status']=='denied' and result['reason']=='executor is closed'
+ assert not list(workspace.iterdir())
+ checks.append('closed executor cannot fall back to process-relative file access')
+ # Reuse the same definitions/context; per-run gates and handles do not
+ # inherit previous approvals or grow the descriptor registry.
+ baseline=len(list(Path('/proc/self/fd').iterdir()))
+ for index in range(32):
+ workspace=root/f'repeated-{index}'; workspace.mkdir()
+ with session.context.scope() as roots, WorkspaceExecutor(workspace,('write','check','publish')) as executor:
+ result=session.execute(roots,session.plan(roots,text,True),executor)
+ assert result['events'][-1]['action']==['approve'] and result['events'][-1]['status']=='denied'
+ assert not (workspace/'release.txt').exists()
+ assert len(list(Path('/proc/self/fd').iterdir()))==baseline
+ checks.append('32 reused-context runs with fresh approvals and stable descriptors')
+ caller.chmod(0o755); home.chmod(0o755)
+ report=dict(checks=checks,plain_definition=plain['definition_id'],guarded_definition=protected['definition_id'],
+ peak_rss_kib=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)
+ args.report.write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n')
+ print(json.dumps(report,ensure_ascii=False))
+
+
+if __name__=='__main__': main()