tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

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:
Ademos/python-filecheck/README.md | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ademos/python-filecheck/pyproject.toml | 19+++++++++++++++++++
Ademos/python-filecheck/tricu_filecheck/__init__.py | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ademos/python-filecheck/tricu_filecheck/cli.py | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ademos/python-filecheck/worker.tri | 37+++++++++++++++++++++++++++++++++++++
Ademos/workflow-planning/README.md | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ademos/workflow-planning/pipeline.tri | 16++++++++++++++++
Ademos/workflow-planning/pyproject.toml | 19+++++++++++++++++++
Ademos/workflow-planning/tricu_workflow_demo/__init__.py | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ademos/workflow-planning/tricu_workflow_demo/cli.py | 48++++++++++++++++++++++++++++++++++++++++++++++++
Ademos/workflow-planning/workflow.tri | 44++++++++++++++++++++++++++++++++++++++++++++
Mdocs/build-and-run.md | 25++++++++++++++++++++++++-
Mdocs/host-abi.md | 4++++
Aext/haskell/BoundaryTests.hs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell/README.md | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell/bindings/check.py | 38++++++++++++++++++++++++++++++++++++++
Aext/haskell/bindings/fixtures.json | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell/bindings/python.py | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell/build.nix | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell/compiler_probe.py | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell/include/tricu.h | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell/package_runtime.py | 29+++++++++++++++++++++++++++++
Aext/haskell/python/pyproject.toml | 12++++++++++++
Aext/haskell/python/tricu.py | 298+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell/reload.c | 24++++++++++++++++++++++++
Aext/haskell/src/Embedding.hs | 265+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell/src/init.c | 21+++++++++++++++++++++
Aext/haskell/test_embedding.py | 230+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell/test_scopes.py | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell/tests.c | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/js/README.md | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/js/package.json | 2+-
Mext/js/src/cli.js | 129+++++++++++++++++++------------------------------------------------------------
Mext/js/src/lib.js | 361+++++++++++++++++++++++++++++--------------------------------------------------
Dext/js/test/bundle.test.js | 93-------------------------------------------------------------------------------
Aext/js/test/conformance.mjs | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/js/test/native.test.js | 6++++++
Dext/js/test/ownership.test.js | 27---------------------------
Dext/js/test/reduce.test.js | 113-------------------------------------------------------------------------------
Dext/js/test/run-bundle.test.js | 125-------------------------------------------------------------------------------
Aext/js/test/runner.mjs | 2++
Aext/php/README.md | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/php/public/eval.php | 68++++++++++++++++++++++----------------------------------------------
Mext/php/public/index.php | 2++
Mext/php/run.php | 125+++++++++++++++++++------------------------------------------------------------
Mext/php/src/common.php | 85++++++++++++-------------------------------------------------------------------
Mext/php/src/ffi.php | 325+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Aext/php/tests/conformance.php | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dext/php/tests/ownership.php | 27---------------------------
Aext/php/tests/web.py | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/zig/README.md | 9++++++---
Mflake.nix | 268++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Atest/python_filecheck.py | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/workflow_planning.py | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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()