commit e68c7cfc3b7a3e3ecf8cfefbb1ec883c21abd663
parent c27ad13561e7e6bc75559c0aec75a0fe8f59aeae
Author: James Eversole <james@eversole.co>
Date: Fri, 16 Oct 2026 14:39:49 -0500
Merge Zig runtime portability and cross-runtime compiler validation
Preserve the main workspace-resolution and server-inspection commits alongside the Zig runtime, owned ABI, portable IO and compiler-reproduction history. Frozen cross-runtime acceptance remains attached to source a3b1cc2 and its retained evidence.
Combined validation: nix flake check (851 Haskell tests and other checks), workspace compilation, provenance replay, small cross-host compatibility suite, Arboricx server deployment test, and compiler retention completion check all pass. Public documentation cleanup is included.
Diffstat:
50 files changed, 4222 insertions(+), 1137 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -17,3 +17,6 @@ dist*
.pi
paseo.json
+
+# Python harness bytecode
+test/__pycache__/
diff --git a/AGENTS.md b/AGENTS.md
@@ -61,13 +61,6 @@ starting the next feature batch. If cleanup is blocked, report the blocker and
outstanding bytes as an incomplete checkpoint. Recovery archives are never routine
scratch-cleanup targets. Do not implement retention tooling in ignored scratch.
-### Host scope: Haskell only
-
-Use the Haskell host for implementation, debugging, profiling, benchmarking,
-and validation. Do not run Zig executables or cross-host test matrices; adapt
-harnesses to Haskell-only operation before use. Retain historical Zig
-observations as historical evidence.
-
### Never call `cabal` directly
> **Rule of thumb:** if it builds, links, or tests, it goes through `nix`.
diff --git a/bench/ReducerSmoke.hs b/bench/ReducerSmoke.hs
@@ -0,0 +1,120 @@
+{-# LANGUAGE BangPatterns #-}
+{-# OPTIONS_GHC -fno-full-laziness -fno-cse #-}
+-- Harness-only anti-sharing flags; production Runtime.Tree compiles normally.
+-- This deliberately uses the real, uninstrumented Haskell reducer.
+module Main where
+
+import Control.Exception (evaluate)
+import Control.Monad (foldM, unless)
+import qualified Data.ByteString as BS
+import qualified Data.Vector as V
+import GHC.Clock (getMonotonicTimeNSec)
+import Runtime
+import Wire
+import TreeObservation (forceTreeShared)
+import System.Environment (getArgs)
+import System.Exit (die)
+import System.Mem (performGC)
+import System.Posix.Resource
+import Text.Printf (printf)
+
+now :: IO Double
+now = (/ 1e9) . fromIntegral <$> getMonotonicTimeNSec
+
+-- Forcing this box allocates the application thunk but does NOT demand its
+-- result. OPAQUE prevents specialization of known constant/identity functions.
+data Suspended = Suspended T
+{-# OPAQUE suspend #-}
+suspend :: T -> T -> Suspended
+suspend f x = Suspended (apply f x)
+
+-- Inputs are finite canonical byte lists. Force their complete construction
+-- before measuring application, without running a codec to validate them.
+forceInput :: T -> ()
+forceInput Leaf = ()
+forceInput (Stem x) = forceInput x
+forceInput (Fork x y) = forceInput x `seq` forceInput y
+
+load :: FilePath -> IO T
+load path = do
+ bytes <- BS.readFile path
+ bundle <- either die pure (decodeBundle bytes)
+ either die pure (verifyBundle bundle)
+ index <- case bundleRoots bundle of
+ first:_ -> pure first
+ [] -> die "bundle has no roots"
+ let root = reconstructBundleTerms (bundleNodes bundle) V.! fromIntegral index
+ forceTreeShared root
+ pure root
+
+rss :: String -> IO Integer
+rss key = do
+ contents <- readFile "/proc/self/status"
+ case [read value | line <- lines contents, (label:value:_) <- [words line], label == key ++ ":"] of
+ [value] -> evaluate value
+ _ -> die ("cannot read " ++ key)
+
+-- Each invocation creates fresh input graphs/thunks. Round-dependent bytes
+-- match the C runner and prevent accidentally benchmarking a cached result.
+{-# NOINLINE iteration #-}
+iteration :: String -> Int -> Int -> T -> IO (Double, Double, Double)
+iteration kind n roundNo worker = do
+ let input = BS.pack [fromIntegral (97 + (j + roundNo) `mod` 26) | j <- [0..n-1]]
+ second = BS.pack [fromIntegral (65 + (j + roundNo) `mod` 26) | j <- [0..n-1]]
+ strings = kind == "id" || kind == "append"
+ if strings then evaluate (BS.length input + BS.length second) >> pure () else pure ()
+ begin <- now
+ Suspended term <- if strings then do
+ let arg = ofBytes input
+ evaluate (forceInput arg)
+ first <- evaluate (suspend worker arg)
+ if kind == "append" then do
+ let arg2 = ofBytes second
+ evaluate (forceInput arg2)
+ case first of Suspended f -> evaluate (suspend f arg2)
+ else pure first
+ else foldM (\(Suspended previous) _ ->
+ if kind == "distribution" then evaluate (suspend worker previous)
+ else evaluate (suspend (Fork Leaf previous) Leaf)) (Suspended Leaf) [1..n]
+ built <- now
+ result <- evaluate term
+ reduced <- now
+ valid <- evaluate $ if strings then case toBytes result of
+ Left _ -> False
+ Right bytes -> BS.length bytes == n * (if kind == "append" then 2 else 1)
+ && BS.take n bytes == input
+ && (kind /= "append" || BS.drop n bytes == second)
+ else case result of Leaf -> True; _ -> False
+ unless valid (die "incorrect complete result")
+ observed <- now
+ pure (built-begin, reduced-built, observed-reduced)
+
+main :: IO ()
+main = do
+ args <- getArgs
+ (kind,n,rounds,path) <- case args of
+ [k,n',r,p] | k `elem` ["id", "append", "deep", "distribution"] -> pure (k, read n', read r, p)
+ _ -> die "usage: haskell-reducer-smoke id|append|deep|distribution n rounds fixture"
+ unless (n > 0 && rounds > 0) (die "positive sizes required")
+ setResourceLimit ResourceCPUTime (ResourceLimits (ResourceLimit 60) (ResourceLimit 60))
+ setResourceLimit ResourceStackSize (ResourceLimits (ResourceLimit (8*1024*1024)) (ResourceLimit (8*1024*1024)))
+ setResourceLimit ResourceCoreFileSize (ResourceLimits (ResourceLimit 0) (ResourceLimit 0))
+ -- A bottom function must remain undemanded during application construction.
+ _ <- evaluate (suspend (error "suspend demanded function during construction") Leaf)
+ start <- now
+ worker <- if kind `elem` ["id", "append"] then load path else pure $
+ if kind == "distribution" then Fork (Stem (Stem Leaf)) (Stem Leaf) else Leaf
+ initialized <- now
+ (buildS,reduceS,observeS,gcS,mid) <- foldM (\(!b,!r,!o,!g,!mid) roundNo -> do
+ (b',r',o') <- iteration kind n roundNo worker
+ if (roundNo+1) `mod` 16 == 0 || roundNo+1 == rounds || roundNo+1 == rounds `div` 2 then do
+ before <- now
+ performGC
+ after <- now
+ mid' <- if roundNo+1 == rounds `div` 2 then rss "VmRSS" else pure mid
+ pure (b+b', r+r', o+o', g+after-before, mid')
+ else pure (b+b', r+r', o+o', g, mid)) (0,0,0,0,-1) [0..rounds-1]
+ peak <- rss "VmHWM"
+ end <- rss "VmRSS"
+ printf "{\"host_mode\":\"GHC-O2-N1\",\"kind\":\"%s\",\"n\":%d,\"rounds\":%d,\"init_s\":%.9f,\"build_s\":%.9f,\"reduce_s\":%.9f,\"observe_s\":%.9f,\"gc_s\":%.9f,\"peak_rss_kib\":%d,\"mid_rss_kib\":%d,\"end_rss_kib\":%d}\n"
+ kind n rounds (initialized-start) buildS reduceS observeS gcS peak mid end
diff --git a/docs/build-and-run.md b/docs/build-and-run.md
@@ -2,7 +2,10 @@
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.
+run through Nix. 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.
## Build the host
diff --git a/docs/compiler-rebuilding.md b/docs/compiler-rebuilding.md
@@ -57,6 +57,90 @@ uses `sourceMapBuildStable` or `sourceMapBuild`, while application selection use
the chosen application bundle. Neither path silently substitutes another backend
or the recovery frontend.
+### Execution-host selection
+
+`--execution-host zig --zig-library /nix/store/.../lib/libarboricx.so` selects
+Zig execution through the owned ABI. `--tricu` selects the Haskell runtime for
+authenticated input transport and output packaging. The selected execution host
+performs compilation and publication; the default is Haskell. `--backend`
+independently selects the lowering backend. Missing libraries or exports fail
+preparation.
+
+Provenance records the execution and transport runtimes separately. Historical
+records lacking `execution_host` describe Haskell execution. Zig records separate
+fingerprints for its shared library and Python launcher. The shared supervisor
+checks source consistency, independently audits the store, enforces publication
+and whole-job deadlines, and records completion status. Zig also applies an
+address-space ceiling, CPU ceiling, and an 8 MiB native stack.
+
+Both execution routes grant broad file IO to trusted compiler jobs. Use the
+isolated workflow below to restrict filesystem access. Small-closure validation
+uses `test/cross_host_generation.py`, documented in the
+[Zig runtime guide](../ext/zig/README.md#small-compiler-probe). Ordinary pair
+acceptance and retention toolchain roles use Haskell generations; the cross-host
+verifier compares Haskell and Zig generations.
+
+### Isolated cross-runtime fixed-point reproduction
+
+The Linux workflow builds two Haskell generations, then uses the second compiler
+to build a Haskell control and a Zig-executed third generation:
+
+```text
+seed --Haskell--> H1 --Haskell--> H2 --Haskell--> H3
+ `----Zig----> Z3
+```
+
+```sh
+nix develop -c python3 test/bootstrap_cross_host.py \
+ --source-commit COMMITTED_SOURCE --directory "$SCRATCH/cross-runtime-new" \
+ --tricu "$TRICU" --zig-library "$ZIG/lib/libarboricx.so"
+```
+
+Select exact Nix runtime paths and a new output directory. The source commit
+supplies the frozen map, sources, kit and inner generation runner. Each job uses
+a fresh store in a filesystem sandbox containing its source snapshot, selected
+compiler/adapter kit, Nix dependencies and writable work directory. Haskell
+performs common transport and packing; the selected execution host performs
+compilation and publication. Jobs run sequentially with fixed limits of 32 GiB
+and one hour per job; Haskell uses eight capabilities. See the
+[isolated probe](../ext/zig/README.md#isolated-mapped-library-probe) for namespace
+requirements and bounded validation before a full run.
+
+`experiment.json` records source/archive hashes, mapped inputs, runtime/Python
+identities, tooling and comparison boundaries. Logs, invocations and runtime
+provenance identify each execution separately. The independent verifier checks
+H1/H2 and H2/H3 acceptance, verifies that Z3 used H2's exact compiler, and compares
+the actual H2/H3/Z3 object inventories, module checkpoints/receipts and all three
+packaged compiler artifacts. `accepted-cross-host.json` records the verified
+reproduction result. `--verify-only` rechecks existing artifacts and provenance.
+
+Post-generation validation runs the H2 program smoke and checks its complete
+results on Zig:
+
+```sh
+nix develop -c python3 test/bootstrap_smoke.py "$TRICU" \
+ --generation "$EXPERIMENT/H2/generation" \
+ --acceptance "$EXPERIMENT/accepted-pair.json" \
+ --source-root "$EXPERIMENT/source" --directory "$EXPERIMENT/haskell-smoke"
+nix develop -c python3 test/bootstrap_cross_host_smoke.py "$EXPERIMENT" \
+ --library "$ZIG/lib/libarboricx.so" --directory "$EXPERIMENT/zig-program-smoke"
+nix develop -c python3 test/bootstrap_cross_host_controls.py "$EXPERIMENT" \
+ --directory "$SCRATCH/cross-host-controls-new"
+```
+
+The program smoke covers native imports, recursive lists, and invoice totals and
+decimal text against independent complete trees. Haskell executes the program's
+compilation using the bit-identical accepted compiler; both hosts execute the
+resulting program. Negative controls mutate independent copies and require the
+verifier to reject false host/runtime/freshness/compiler claims, corrupt objects,
+changed packaged artifacts and additional valid objects. Restored copies must
+pass again.
+
+Finish rolling retention with H2 as the current Haskell toolchain and an
+independently useful rollback. Protect H3/Z3, frozen inputs, cross-host reports,
+and a registered Nix root for the Zig runtime as additional evidence. Common
+preparation, transport and packaging use Haskell throughout this workflow.
+
### Explicit bootstrap kit
The repository includes the accepted kit at `bootstrap/rebuild/`. It works from
diff --git a/ext/js/src/lib.js b/ext/js/src/lib.js
@@ -66,6 +66,7 @@ function getLib() {
export function init(libPath) {
if (libPath) loadLib(libPath);
const lib = getLib();
+ if (lib.func('uint32_t arb_abi_version(void)')() !== 2) throw new Error('Arboricx ABI v2 required');
const ctx = lib.func('arb_ctx_t *arboricx_init(void)')();
if (!ctx) throw new Error('arboricx_init failed');
return ctx;
@@ -75,79 +76,98 @@ 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 = getLib().func('uint32_t arb_load_bundle(arb_ctx_t *ctx, _In_ uint8_t *bytes, size_t len, const char *name)')(ctx, bytes, bytes.length, name);
- if (result === 0) throw new Error(`arb_load_bundle failed for export "${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 = getLib().func('uint32_t arb_load_bundle_default(arb_ctx_t *ctx, _In_ uint8_t *bytes, size_t len)')(ctx, bytes, bytes.length);
- if (result === 0) throw new Error('arb_load_bundle_default failed');
+ 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) {
- const f = getLib().func('uint32_t arb_reduce(arb_ctx_t *ctx, uint32_t root, uint64_t fuel)');
- return f(ctx, root, typeof fuel === 'bigint' ? fuel : BigInt(fuel));
+ 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 getLib().func('uint32_t arb_leaf(arb_ctx_t *ctx)')(ctx);
+ return owned('uint64_t arb_leaf(arb_ctx_t *ctx)', ctx);
}
export function stem(ctx, child) {
- return getLib().func('uint32_t arb_stem(arb_ctx_t *ctx, uint32_t child)')(ctx, child);
+ return owned('uint64_t arb_stem(arb_ctx_t *ctx, uint64_t child)', ctx, child);
}
export function fork(ctx, left, right) {
- return getLib().func('uint32_t arb_fork(arb_ctx_t *ctx, uint32_t left, uint32_t right)')(ctx, left, right);
+ return owned('uint64_t arb_fork(arb_ctx_t *ctx, uint64_t left, uint64_t right)', ctx, left, right);
}
export function app(ctx, func, arg) {
- return getLib().func('uint32_t arb_app(arb_ctx_t *ctx, uint32_t func, uint32_t arg)')(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 getLib().func('uint32_t arb_of_number(arb_ctx_t *ctx, uint64_t n)')(ctx, big);
+ return owned('uint64_t arb_of_number(arb_ctx_t *ctx, uint64_t n)', ctx, big);
}
export function ofString(ctx, s) {
- return getLib().func('uint32_t arb_of_string(arb_ctx_t *ctx, const char *s)')(ctx, s);
+ return owned('uint64_t arb_of_string(arb_ctx_t *ctx, const char *s)', ctx, s);
}
export function ofBytes(ctx, bytes) {
- return getLib().func('uint32_t arb_of_bytes(arb_ctx_t *ctx, _In_ uint8_t *bytes, size_t len)')(ctx, bytes, bytes.length);
+ 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 Uint32Array(items);
- return getLib().func('uint32_t arb_of_list(arb_ctx_t *ctx, _In_ uint32_t *items, size_t len)')(ctx, arr, arr.length);
+ 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, uint32_t root, _Out_ uint64_t *out)')(ctx, root, out);
- if (!ok) throw new Error('arb_to_number failed');
+ 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, uint32_t root, _Out_ uint8_t **out_ptr, _Out_ size_t *out_len)')(ctx, root, ptrOut, lenOut);
- if (!ok) throw new Error('arb_to_string failed');
+ 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');
@@ -158,8 +178,8 @@ export function toString(ctx, root) {
export function toBytes(ctx, root) {
const ptrOut = [null];
const lenOut = [0];
- const ok = getLib().func('int arb_to_bytes(arb_ctx_t *ctx, uint32_t root, _Out_ uint8_t **out_ptr, _Out_ size_t *out_len)')(ctx, root, ptrOut, lenOut);
- if (!ok) throw new Error('arb_to_bytes failed');
+ 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]);
@@ -168,8 +188,8 @@ export function toBytes(ctx, root) {
export function toBool(ctx, root) {
const out = [0];
- const ok = getLib().func('int arb_to_bool(arb_ctx_t *ctx, uint32_t root, _Out_ int *out)')(ctx, root, out);
- if (!ok) throw new Error('arb_to_bool failed');
+ 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;
}
@@ -179,23 +199,23 @@ 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, uint32_t root, _Out_ int *out_ok, _Out_ uint32_t *out_value, _Out_ uint32_t *out_rest)')(ctx, root, outOk, outValue, outRest);
- if (!ok) throw new Error('arb_unwrap_result failed');
- return { ok: outOk[0] !== 0, value: outValue[0], rest: 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 function unwrapHostValue(ctx, root) {
const outTag = [0n];
const outPayload = [0];
- const ok = getLib().func('int arb_unwrap_host_value(arb_ctx_t *ctx, uint32_t root, _Out_ uint64_t *out_tag, _Out_ uint32_t *out_payload)')(ctx, root, outTag, outPayload);
- if (!ok) throw new Error('arb_unwrap_host_value failed');
- return { tag: outTag[0], payload: 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]) };
}
// ── Kernel ──────────────────────────────────────────────────────────────────
export function kernelRoot(ctx) {
- return getLib().func('uint32_t arb_kernel_root(arb_ctx_t *ctx)')(ctx);
+ return owned('uint64_t arb_kernel_root(arb_ctx_t *ctx)', ctx);
}
// ── High-level helpers ──────────────────────────────────────────────────────
diff --git a/ext/js/test/bundle.test.js b/ext/js/test/bundle.test.js
@@ -31,7 +31,7 @@ describe('context lifecycle', () => {
const ctx = init(libPath);
try {
const root = kernelRoot(ctx);
- ok(root > 0, 'kernel root should be a positive index');
+ ok(root > 0, 'kernel root should be a positive handle');
} finally {
free(ctx);
}
@@ -44,7 +44,7 @@ describe('bundle loading', () => {
try {
const bundle = readFileSync(`${fixtureDir}/id.arboricx`);
const root = loadBundleDefault(ctx, bundle);
- ok(root > 0, 'loaded root should be a positive index');
+ ok(root > 0, 'loaded root should be a positive handle');
} finally {
free(ctx);
}
@@ -76,7 +76,7 @@ describe('bundle loading', () => {
const ctx = init(libPath);
try {
const bundle = readFileSync(`${fixtureDir}/id.arboricx`);
- throws(() => loadBundle(ctx, bundle, 'nonexistent'), /failed/);
+ throws(() => loadBundle(ctx, bundle, 'nonexistent'), /status 5/);
} finally {
free(ctx);
}
@@ -85,7 +85,7 @@ describe('bundle loading', () => {
it('loadBundleDefault fails for invalid bytes', () => {
const ctx = init(libPath);
try {
- throws(() => loadBundleDefault(ctx, Buffer.from('not a bundle')), /failed/);
+ throws(() => loadBundleDefault(ctx, Buffer.from('not a bundle')), /status 5/);
} finally {
free(ctx);
}
diff --git a/ext/js/test/ownership.test.js b/ext/js/test/ownership.test.js
@@ -0,0 +1,27 @@
+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
@@ -21,7 +21,7 @@ import {
const libPath = findLib();
describe('tree construction', () => {
- it('leaf returns a positive index', () => {
+ it('leaf returns a positive handle', () => {
const ctx = init(libPath);
try {
const idx = leaf(ctx);
diff --git a/ext/js/test/run-bundle.test.js b/ext/js/test/run-bundle.test.js
@@ -117,7 +117,7 @@ describe('run bundle — named export', () => {
const ctx = init(libPath);
try {
const bundle = readFileSync(`${fixtureDir}/id.arboricx`);
- throws(() => loadBundle(ctx, bundle, 'nonexistent'), /failed/);
+ throws(() => loadBundle(ctx, bundle, 'nonexistent'), /status 5/);
} finally {
free(ctx);
}
diff --git a/ext/php/src/ffi.php b/ext/php/src/ffi.php
@@ -26,6 +26,7 @@ final class ArboricxFFI
];
$headerRaw = false;
foreach ($candidates as $path) {
+ if (!is_file($path)) continue;
$headerRaw = file_get_contents($path);
if ($headerRaw !== false) break;
}
@@ -37,10 +38,13 @@ final class ArboricxFFI
$header = $headerRaw;
$header = preg_replace('/#.*\n/', "\n", $header);
$header = preg_replace('/extern\s+"C"\s*\{/', '', $header);
- $header = str_replace('}', '', $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() !== 2) {
+ throw new \RuntimeException('Arboricx ABI v2 required');
+ }
}
public static function ffi(): \FFI
@@ -67,31 +71,57 @@ 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 ArboricxFFI::ffi()->arb_app($ctx, $func, $arg);
+ return owned('arb_app', $ctx, $func, $arg);
}
function reduce(\FFI\CData $ctx, int $root, int $fuel = 1_000_000_000): int
{
- return ArboricxFFI::ffi()->arb_reduce($ctx, $root, $fuel);
+ return owned('arb_reduce', $ctx, $root, $fuel);
}
function ofNumber(\FFI\CData $ctx, int $n): int
{
- return ArboricxFFI::ffi()->arb_of_number($ctx, $n);
+ return owned('arb_of_number', $ctx, $n);
}
function ofString(\FFI\CData $ctx, string $s): int
{
- return ArboricxFFI::ffi()->arb_of_string($ctx, $s);
+ 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) {
+ if ($ok !== 0) {
throw new \RuntimeException('arb_to_number failed');
}
return (int) $out->cdata;
@@ -102,7 +132,7 @@ 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) {
+ if ($ok !== 0) {
throw new \RuntimeException('arb_to_string failed');
}
$length = (int) $len->cdata;
@@ -118,7 +148,7 @@ function toBool(\FFI\CData $ctx, int $root): bool
{
$out = ArboricxFFI::ffi()->new('int');
$ok = ArboricxFFI::ffi()->arb_to_bool($ctx, $root, \FFI::addr($out));
- if (!$ok) {
+ if ($ok !== 0) {
throw new \RuntimeException('arb_to_bool failed');
}
return (bool) $out->cdata;
@@ -130,7 +160,7 @@ function loadBundleDefault(\FFI\CData $ctx, string $bytes): int
for ($i = 0; $i < strlen($bytes); $i++) {
$cdata[$i] = ord($bytes[$i]);
}
- $result = ArboricxFFI::ffi()->arb_load_bundle_default($ctx, $cdata, strlen($bytes));
+ $result = owned('arb_load_bundle_default', $ctx, $cdata, strlen($bytes));
if ($result === 0) {
throw new \RuntimeException('arb_load_bundle_default failed');
}
diff --git a/ext/php/tests/ownership.php b/ext/php/tests/ownership.php
@@ -0,0 +1,27 @@
+<?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/zig/README.md b/ext/zig/README.md
@@ -0,0 +1,284 @@
+# Zig runtime and C ABI
+
+The Zig runtime evaluates tree-calculus programs and provides IO through an
+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
+```
+
+The executable and shared library build in **ReleaseSafe**, with Zig runtime
+safety checks enabled. Reducer, collector and libuv scheduler-root tests run in
+both Debug and ReleaseSafe.
+
+[Cross-runtime compiler reproduction](../../docs/compiler-rebuilding.md#isolated-cross-runtime-fixed-point-reproduction)
+compares complete compiler generations executed by Haskell and Zig. The workflow
+includes independent object audits, program smokes and retention checks. Haskell
+supplies common artifact transport and packaging.
+
+## Basic performance smoke (Linux)
+
+```sh
+nix build .#tricu-zig-performance-smoke --no-link
+PERF=$(nix path-info .#tricu-zig-performance-smoke)
+cat "$PERF/report.json"
+```
+
+This target checks complete results for string identity, append of two
+independently constructed inputs, deep constant-selection chains, distribution
+chains, and repeated calls in one context. It records three fresh-process samples
+per case, phase timings, fixture hashes, CPU information and memory observations.
+The C driver is optimized and the linked runtime is ReleaseSafe. Each sample
+records the runtime optimization mode; mixed-mode samples fail validation.
+
+Construction, WHNF reduction, complete observation and explicit collection are
+timed separately. Lazy work may occur during observation, and automatic GC time
+is included in whichever phase triggers it. Explicit collection runs every 16
+iterations, at the midpoint, and at the end. Peak process RSS comes from Linux
+`VmHWM`; midpoint/end RSS comes from `/proc/self/statm` after collection.
+
+Each child applies a 512 MiB address-space limit, 8 MiB stack limit and 60-second
+CPU limit before initializing the runtime; the harness enforces a 65-second wall
+limit. A wrong result, crash, exhausted limit or timeout fails the smoke. Timings
+are recorded for comparison across runs.
+
+Nix may reuse the stored measurement report. For fresh measurements with the
+same built runner:
+
+```sh
+nix develop -c python3 ext/zig/tests/performance_smoke.py \
+ "$PERF/bin/performance-smoke" test/fixtures /tmp/tricu-zig-performance-smoke.json
+```
+
+## Small compiler probe
+
+`test/cross_host_generation.py` runs a two-module native map-builder job through
+the shared generation runner on both Haskell and Zig, using the authenticated
+`bootstrap/rebuild` kit. Haskell authenticates the captured input bundles. Each
+execution host applies the retained compiler and adapter and drives IO.
+Publication passes an independent store audit, and the resulting program returns
+`41` on Haskell. A second invocation checks warm reuse in a fresh runtime context.
+
+```sh
+nix build .#tricu-zig .# --no-link
+ZIG=$(nix path-info .#tricu-zig)
+HASKELL=$(nix path-info .#)
+nix develop -c python3 test/cross_host_generation.py \
+ --library "$ZIG/lib/libarboricx.so" --tricu "$HASKELL/bin/tricu" \
+ --directory /tmp/zig-compiler-probe-new
+```
+
+Choose a new output directory. Limits are 1 GiB per child and 120 seconds per
+generation invocation, with an 8 MiB stack for Zig. Cold and warm runs must
+publish identical complete receipt and object sets across hosts. The output
+records inputs, runtime hashes, logs and completion status. Unsupported IO actions
+produce an error result identifying the action tag.
+
+Add `--compatibility` for reexports, multiple exports, direct and local recursion,
+patterns, sequencing, lazy discard, UTF-8 comments, dependency changes, warm
+reuse, compiler error tags, corrupt checkpoints/executables, guarded interruption
+and restart, and missing-input controls. The supervisor checks BOMs and headers
+before execution. Runtime Debug/ReleaseSafe tests cover source/indexed IO
+permission denial and malformed inputs. The execution child records string
+diagnostics or a bounded numeric prefix for structured diagnostics.
+
+## Indexed tree transport
+
+The IO actions `readIndexedTree` (50), `hashIndexedTree` (51), and
+`writeIndexedTreeIfAbsent` (52) implement the
+[verified native transport contract](../../docs/storage.md#verified-native-tree-transport).
+They use the `arboricx.indexed-tree.v1` domain and Haskell-compatible canonical
+bytes.
+
+The `src/indexed.zig` codec uses iterative traversal, memoizes physical node
+identities, and interns exact constructor/child-ID keys. Equal finite trees
+produce the same bytes regardless of physical sharing. Serialization observes
+the tree's constructor structure. The validated reader is shared with C ABI/CLI
+bundle loading and retains DAG sharing. Transport authenticates each object's
+domain-separated digest before decoding.
+
+The IO adapters combine the codec with byte-buffer hashing and native filesystem
+operations. Reads require read permission; atomic no-replace publication requires
+both read and write permission, checked before demanding the supplied tree.
+Writes verify the expected digest before publication and compare the winning
+file byte-for-byte, including an existing winner. Atomic link publication and
+file/directory syncing use the same primitives as byte writes. A post-publication
+failure can leave a valid object visible. tricu chooses paths, creates directories,
+and manages manifests, caches and publication order.
+
+These operations occupy a synchronous scheduler turn and retain complete
+serialized buffers in memory. Apply external time and memory limits to the
+containing IO program.
+
+## Source-text IO
+
+`readSourceFile` (37) requires read permission and returns a Result containing
+both the exact raw octets and a Unicode scalar list. It preserves Unicode and
+newline sequences and rejects malformed UTF-8 and an initial BOM. Paths use
+Unicode scalars encoded as UTF-8, with embedded NUL rejected.
+
+`encodeSourceText` (38) is permission-free and returns the exact UTF-8 bytes of a
+canonical scalar list. Surrogates, out-of-range values, noncanonical numeral
+trees and malformed lists are errors. Encoding U+FEFF is allowed; initial-BOM
+rejection belongs to source-file reading.
+
+ABI string/binary codecs and `readFile` operate on bytes. Source reads run
+synchronously within one scheduler turn. Allocation failures propagate to the
+host.
+
+## ABI v2 ownership
+
+`include/arboricx.h` defines ABI v2. `arb_abi_version()` returns 2; the JS, PHP and
+Python test bindings check this version before using handles.
+
+* A handle is an opaque 64-bit integer identifying an owned root. Each successful
+ handle output owns one root. Constructors borrow their inputs.
+* `arb_retain` returns a **different**, independently owned handle to the same
+ node. `arb_release` invalidates that handle. Context destruction releases all
+ remaining handles and allocations.
+* Inspection returns independently owned child handles. Child handles remain
+ valid after releasing the parent. Handle equality compares handle identities;
+ tree equality requires structural comparison.
+* Stale, zero, and foreign-context handles return `ARB_INVALID_HANDLE`.
+ Slot reuse has a fresh process-wide generation; generation exhaustion returns
+ `ARB_HANDLE_EXHAUSTED`. Handles fit in a positive signed 64-bit integer;
+ JavaScript bindings expose them as `bigint`.
+* Operations return `ARB_OK` or an explicit status. Failure leaves output
+ parameters unchanged. Multi-handle output publication is all-or-nothing.
+* Reduction updates the shared graph in place. Aliases observe evaluation
+ progress, including partial progress after fuel or allocation failure.
+ The returned result handle and the input handle are independently owned.
+* Contexts require single-threaded, non-reentrant use. Pointers must be valid,
+ and output parameters must be distinct. Callers are responsible for native
+ memory safety; handle validation detects stale and foreign references.
+* Decoded byte/string buffers have separate ownership: free them with
+ `arboricx_free_buf` before destroying their context. `arboricx_init` returns
+ null on allocation failure; other tree operations use status/out parameters.
+
+For example (check every status in application code):
+
+```c
+arb_ctx_t *ctx = arboricx_init();
+if (!ctx) return 1;
+arb_handle_t leaf, application, result;
+if (arb_leaf(ctx, &leaf) != ARB_OK ||
+ arb_app(ctx, leaf, leaf, &application) != ARB_OK ||
+ arb_release(ctx, leaf) != ARB_OK ||
+ arb_reduce(ctx, application, 1000, &result) != ARB_OK ||
+ arb_release(ctx, application) != ARB_OK ||
+ arb_collect(ctx) != ARB_OK) {
+ arboricx_free(ctx);
+ return 1;
+}
+/* result owns Stem Leaf, including the child released above. */
+int is_stem;
+if (arb_is_stem(ctx, result, &is_stem) != ARB_OK || !is_stem) {
+ arboricx_free(ctx);
+ return 1;
+}
+if (arb_release(ctx, result) != ARB_OK) {
+ arboricx_free(ctx);
+ return 1;
+}
+arboricx_free(ctx);
+```
+
+## Reduction and collection contract
+
+The reducer is a heap-framed, in-place, call-by-need WHNF machine. It evaluates
+terms on demand. Suspended function, discriminator, argument and update demands
+use heap frames.
+
+The arena uses non-moving mark/sweep and reuses dead slots. Collection occurs at
+reducer loop safepoints, with the current term and continuation redexes rooted.
+The ABI supplies live handles and its kernel root; the CLI supplies the invocation
+root. The IO scheduler snapshots all tasks' current terms, environments, state,
+results and continuation frames for each turn. Async callbacks allocate and link
+nodes. Root scopes unwind on errors.
+
+Construction allocates and links nodes; collection runs during reduction or an
+explicit `arb_collect` call. Construction-only clients should call `arb_collect`
+periodically. Graphs unreachable from the remaining roots become eligible
+for reclamation at the next collection. Evaluation and IO operate directly on the shared graph. Internally,
+keep raw node IDs within their registered root lifetime and reacquire pointers
+into the growable node array after allocations.
+
+### Resource limits
+
+* Arena capacity and collector work buffers retain their high-water allocations.
+ Retaining handles retains their reachable graphs. Root-table rebuilding scans
+ handle slots at ABI observation/evaluation entry. Apply an external memory
+ limit to bound process usage.
+* Fuel counts application entries for one reducer invocation. Codec observations
+ use separate budgets. Apply an external deadline to bound a complete IO program.
+* Codecs, structural comparisons, parsers and renderers may use the native stack.
+ Complete observations require finite results; observing infinite results can
+ diverge.
+
+Tests cover all kernel rules, independent finite-reference comparisons,
+undemanded divergent terms, shared updates, deep evaluation, allocation-failure
+cleanup, GC slot reuse, scheduler roots, stale/foreign handles, independent
+ownership, and C/Python/JS/PHP fixture programs. `tests/helpers.h` supplies checked
+constructors for fixture tests.
+
+## Paired Haskell/Zig runtime measurements
+
+```sh
+nix build .#reducerComparison --no-link
+nix run .#reducerComparison -- /tmp/reducer-comparison.json
+```
+
+Run measurements after builds finish, with other benchmark/build workloads idle.
+This Linux harness compares the Haskell reducer (GHC `-O2`, one RTS capability)
+with Zig ReleaseSafe. Both use the same retained identity/append bundles,
+canonical byte inputs, and constant-selection/distribution workloads. Every
+iteration checks its complete result. Each case has five fresh-process samples
+per host, alternating host order, pinned to one allowed CPU. Results include
+binary/fixture hashes, compiler version, raw samples, timing ranges and process
+peak RSS. Package checks require deliberately wrong workers to fail result
+validation.
+
+The primary timing combines WHNF reduction and full output decoding and checking,
+including codecs and automatic GC. Build and explicit-GC phases are recorded
+separately and as part of the combined total. Haskell input normal forms are
+forced before application timing; opaque suspension boxes prepare unevaluated
+applications. Harness-only anti-sharing flags and varying input bytes give each
+round fresh work. Production Haskell runtime modules compile normally. Zig's
+construction includes owned-handle C ABI calls; Haskell's includes input forcing
+and suspension preparation.
+
+Haskell may collect during construction; Zig collects automatically at reduction
+safepoints. Compare both evaluation/observation and combined totals. Initialization
+contributes to process RSS and has its own timing. Common guards are 512 MiB
+sampled RSS, 60 CPU seconds, 65 wall seconds and an 8 MiB native stack per child.
+Zig also has a 512 MiB address-space cap; Haskell has a 512 MiB managed-heap cap
+and an 8 MiB RTS stack cap.
+
+### Isolated mapped-library probe
+
+`test/bootstrap_cross_host.py --probe` runs the frozen `native_math` closure on
+both hosts, with identical kits and a 1 GiB/120-second budget per job. It requires
+Linux `bwrap` and util-linux `unshare` with working user/PID/mount namespaces,
+and a Nix Python environment:
+
+```sh
+nix develop -c python3 test/bootstrap_cross_host.py --probe \
+ --source-commit COMMITTED_SOURCE --directory /tmp/cross-host-probe-new \
+ --tricu "$HASKELL/bin/tricu" --zig-library "$ZIG/lib/libarboricx.so"
+```
+
+Each sandbox mounts the read-only source snapshot and kit, Nix dependencies,
+an initially empty writable work directory, and ephemeral system directories.
+The generation supervisor runs inside the sandbox, where its RSS monitor observes
+the execution child. The probe compares complete independently audited object
+inventories. Its experiment definition records the source commit/archive,
+mapped-input hashes, runtimes, Python, tooling, limits and comparison boundaries.
+Snapshot extraction excludes the tracked build-output symlink `ext/zig/result`.
+
+The launcher creates a private PID namespace with a matching procfs mount,
+then starts the filesystem sandbox. Both isolation tool identities are recorded.
+For a full experiment stopped before a job creates its generation directory,
+`--continue-experiment` reverifies frozen sources, runtimes and completed
+generations, preserves the failed launch and original tooling, and starts the
+pending job fresh. A partially executed generation requires a new experiment.
diff --git a/ext/zig/build.zig b/ext/zig/build.zig
@@ -4,6 +4,24 @@ pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
+ const reducer_tests = b.addTest(.{ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/reduce_test.zig"),
+ .target = target,
+ .optimize = optimize,
+ }) });
+ const test_step = b.step("test", "Run reducer, collection and demand tests");
+ test_step.dependOn(&b.addRunArtifact(reducer_tests).step);
+
+ const io_tests_mod = b.createModule(.{
+ .root_source_file = b.path("src/io_driver.zig"),
+ .target = target,
+ .optimize = optimize,
+ });
+ io_tests_mod.link_libc = true;
+ io_tests_mod.linkSystemLibrary("uv", .{});
+ const io_tests = b.addTest(.{ .root_module = io_tests_mod });
+ test_step.dependOn(&b.addRunArtifact(io_tests).step);
+
// -- kernel generator tool (runs on build host) --
const gen_kernel_mod = b.createModule(.{
.root_source_file = b.path("tools/gen_kernel.zig"),
@@ -67,5 +85,4 @@ pub fn build(b: *std.Build) void {
.linkage = .dynamic,
});
b.installArtifact(shared_lib);
-
}
diff --git a/ext/zig/include/arboricx.h b/ext/zig/include/arboricx.h
@@ -9,6 +9,28 @@ extern "C" {
#endif
typedef struct arb_ctx arb_ctx_t;
+typedef uint64_t arb_handle_t;
+typedef enum {
+ ARB_OK = 0, ARB_INVALID_HANDLE = 1, ARB_OUT_OF_MEMORY = 2,
+ ARB_FUEL_EXHAUSTED = 3, ARB_TYPE_MISMATCH = 4, ARB_INVALID_DATA = 5,
+ ARB_IO_ERROR = 6, ARB_HANDLE_EXHAUSTED = 7
+} arb_status_t;
+
+/* ABI v2: each successful handle output is independently owned. Inputs are
+ * borrowed. arb_retain creates another owned handle; arb_release invalidates
+ * only that handle. Handles are opaque, context-bound and never serialize.
+ * Reduction is lazy and in place: aliases observe shared evaluation progress,
+ * including partial progress on failure. Failure leaves output parameters
+ * unchanged. Distinct outputs must not alias. All pointers must be valid;
+ * buffers/arrays may be empty but must have a non-null pointer. Contexts are
+ * single-threaded and non-reentrant. Context destruction releases all handles.
+ * Inspection does not force; returned children are owned handles too.
+ * Collection reclaims unreachable nodes, not necessarily resident OS pages.
+ */
+uint32_t arb_abi_version(void);
+arb_status_t arb_retain(arb_ctx_t* ctx, arb_handle_t handle, arb_handle_t* out);
+arb_status_t arb_release(arb_ctx_t* ctx, arb_handle_t handle);
+arb_status_t arb_collect(arb_ctx_t* ctx);
/* Context lifecycle */
arb_ctx_t* arboricx_init(void);
@@ -16,40 +38,40 @@ void arboricx_free(arb_ctx_t* ctx);
void arboricx_free_buf(arb_ctx_t* ctx, uint8_t* ptr, size_t len);
/* Tree construction */
-uint32_t arb_leaf(arb_ctx_t* ctx);
-uint32_t arb_stem(arb_ctx_t* ctx, uint32_t child);
-uint32_t arb_fork(arb_ctx_t* ctx, uint32_t left, uint32_t right);
-uint32_t arb_app(arb_ctx_t* ctx, uint32_t func, uint32_t arg);
+arb_status_t arb_leaf(arb_ctx_t* ctx, arb_handle_t* out);
+arb_status_t arb_stem(arb_ctx_t* ctx, arb_handle_t child, arb_handle_t* out);
+arb_status_t arb_fork(arb_ctx_t* ctx, arb_handle_t left, arb_handle_t right, arb_handle_t* out);
+arb_status_t arb_app(arb_ctx_t* ctx, arb_handle_t func, arb_handle_t arg, arb_handle_t* out);
/* Reduction */
-uint32_t arb_reduce(arb_ctx_t* ctx, uint32_t root, uint64_t fuel);
+arb_status_t arb_reduce(arb_ctx_t* ctx, arb_handle_t root, uint64_t fuel, arb_handle_t* out);
/* Codec constructors */
-uint32_t arb_of_number(arb_ctx_t* ctx, uint64_t n);
-uint32_t arb_of_string(arb_ctx_t* ctx, const char* s);
-uint32_t arb_of_bytes(arb_ctx_t* ctx, const uint8_t* bytes, size_t len);
-uint32_t arb_of_list(arb_ctx_t* ctx, const uint32_t* items, size_t len);
+arb_status_t arb_of_number(arb_ctx_t* ctx, uint64_t n, arb_handle_t* out);
+arb_status_t arb_of_string(arb_ctx_t* ctx, const char* s, arb_handle_t* out);
+arb_status_t arb_of_bytes(arb_ctx_t* ctx, const uint8_t* bytes, size_t len, arb_handle_t* out);
+arb_status_t arb_of_list(arb_ctx_t* ctx, const arb_handle_t* items, size_t len, arb_handle_t* out);
-/* Codec destructors (return 1 on success, 0 on failure) */
-int arb_to_number(arb_ctx_t* ctx, uint32_t root, uint64_t* out);
-int arb_to_string(arb_ctx_t* ctx, uint32_t root, uint8_t** out_ptr, size_t* out_len);
-int arb_to_bytes(arb_ctx_t* ctx, uint32_t root, uint8_t** out_ptr, size_t* out_len);
-int arb_to_bool(arb_ctx_t* ctx, uint32_t root, int* out);
+/* Codec destructors (return ARB_OK on success) */
+arb_status_t arb_to_number(arb_ctx_t* ctx, arb_handle_t root, uint64_t* out);
+arb_status_t arb_to_string(arb_ctx_t* ctx, arb_handle_t root, uint8_t** out_ptr, size_t* out_len);
+arb_status_t arb_to_bytes(arb_ctx_t* ctx, arb_handle_t root, uint8_t** out_ptr, size_t* out_len);
+arb_status_t arb_to_bool(arb_ctx_t* ctx, arb_handle_t root, int* out);
-/* Result unwrapping (return 1 on success, 0 on failure) */
-int arb_unwrap_result(arb_ctx_t* ctx, uint32_t root, int* out_ok, uint32_t* out_value, uint32_t* out_rest);
-int arb_unwrap_host_value(arb_ctx_t* ctx, uint32_t root, uint64_t* out_tag, uint32_t* out_payload);
+/* Result unwrapping (return ARB_OK on success) */
+arb_status_t arb_unwrap_result(arb_ctx_t* ctx, arb_handle_t root, int* out_ok, arb_handle_t* out_value, arb_handle_t* out_rest);
+arb_status_t arb_unwrap_host_value(arb_ctx_t* ctx, arb_handle_t root, uint64_t* out_tag, arb_handle_t* out_payload);
/* Tree inspection (Layer 1 — for custom IO drivers and non-POSIX hosts) */
-int arb_is_leaf(arb_ctx_t* ctx, uint32_t root);
-int arb_is_stem(arb_ctx_t* ctx, uint32_t root);
-int arb_is_fork(arb_ctx_t* ctx, uint32_t root);
-int arb_is_app(arb_ctx_t* ctx, uint32_t root);
-int arb_get_stem_child(arb_ctx_t* ctx, uint32_t root, uint32_t* out);
-int arb_get_fork_children(arb_ctx_t* ctx, uint32_t root,
- uint32_t* out_left, uint32_t* out_right);
-int arb_get_app_func_arg(arb_ctx_t* ctx, uint32_t root,
- uint32_t* out_func, uint32_t* out_arg);
+arb_status_t arb_is_leaf(arb_ctx_t* ctx, arb_handle_t root, int* out);
+arb_status_t arb_is_stem(arb_ctx_t* ctx, arb_handle_t root, int* out);
+arb_status_t arb_is_fork(arb_ctx_t* ctx, arb_handle_t root, int* out);
+arb_status_t arb_is_app(arb_ctx_t* ctx, arb_handle_t root, int* out);
+arb_status_t arb_get_stem_child(arb_ctx_t* ctx, arb_handle_t root, arb_handle_t* out);
+arb_status_t arb_get_fork_children(arb_ctx_t* ctx, arb_handle_t root,
+ arb_handle_t* out_left, arb_handle_t* out_right);
+arb_status_t arb_get_app_func_arg(arb_ctx_t* ctx, arb_handle_t root,
+ arb_handle_t* out_func, arb_handle_t* out_arg);
/* IO driver (Layer 2 — POSIX interaction-tree runtime) */
typedef struct {
@@ -57,14 +79,14 @@ typedef struct {
int allow_write_all;
} arb_io_perms_t;
-uint32_t arb_run_io(arb_ctx_t* ctx, uint32_t program, const arb_io_perms_t* perms);
+arb_status_t arb_run_io(arb_ctx_t* ctx, arb_handle_t program, const arb_io_perms_t* perms, arb_handle_t* out);
/* Kernel entrypoints */
-uint32_t arb_kernel_root(arb_ctx_t* ctx);
+arb_status_t arb_kernel_root(arb_ctx_t* ctx, arb_handle_t* out);
/* Native bundle loading (fast path — bypasses the Tricu kernel) */
-uint32_t arb_load_bundle(arb_ctx_t* ctx, const uint8_t* bytes, size_t len, const char* name);
-uint32_t arb_load_bundle_default(arb_ctx_t* ctx, const uint8_t* bytes, size_t len);
+arb_status_t arb_load_bundle(arb_ctx_t* ctx, const uint8_t* bytes, size_t len, const char* name, arb_handle_t* out);
+arb_status_t arb_load_bundle_default(arb_ctx_t* ctx, const uint8_t* bytes, size_t len, arb_handle_t* out);
#ifdef __cplusplus
}
diff --git a/ext/zig/src/arena.zig b/ext/zig/src/arena.zig
@@ -4,6 +4,13 @@ const tree = @import("tree.zig");
pub const Arena = struct {
allocator: std.mem.Allocator,
nodes: std.ArrayList(tree.Node),
+ // Opt-in collection: every externally retained ID must be in roots.
+ gc_enabled: bool = false,
+ roots: std.ArrayList(u32) = .empty,
+ free: std.ArrayList(u32) = .empty,
+ allocations: usize = 0,
+ gc_interval: usize = 65536,
+ collections: usize = 0,
pub fn init(allocator: std.mem.Allocator) Arena {
return .{
@@ -14,11 +21,20 @@ pub const Arena = struct {
pub fn deinit(self: *Arena) void {
self.nodes.deinit(self.allocator);
+ self.roots.deinit(self.allocator);
+ self.free.deinit(self.allocator);
}
pub fn alloc(self: *Arena, node: tree.Node) !u32 {
+ if (self.free.pop()) |idx| {
+ self.nodes.items[idx] = node;
+ self.allocations += 1;
+ return idx;
+ }
+ if (self.nodes.items.len >= std.math.maxInt(u32)) return error.OutOfMemory;
const idx: u32 = @intCast(self.nodes.items.len);
try self.nodes.append(self.allocator, node);
+ self.allocations += 1;
return idx;
}
@@ -30,7 +46,46 @@ pub const Arena = struct {
return @intCast(self.nodes.items.len);
}
- pub fn reset(self: *Arena, keep: u32) void {
- self.nodes.shrinkRetainingCapacity(keep);
+ pub fn collectionDue(self: *const Arena) bool {
+ return self.gc_enabled and self.allocations >= self.gc_interval;
+ }
+
+ /// Non-moving mark/sweep. Only reduction safepoints collect automatically;
+ /// construction never collects, so newly allocated children can be linked
+ /// without temporary root registrations. Failure before sweep changes no IDs.
+ pub fn collect(self: *Arena, temporary_roots: []const u32) !void {
+ const marks = try self.allocator.alloc(bool, self.nodes.items.len);
+ defer self.allocator.free(marks);
+ @memset(marks, false);
+ var grey: std.ArrayList(u32) = .empty;
+ defer grey.deinit(self.allocator);
+ try grey.appendSlice(self.allocator, self.roots.items);
+ try grey.appendSlice(self.allocator, temporary_roots);
+ var live: usize = 0;
+ while (grey.pop()) |idx| {
+ if (marks[idx]) continue;
+ marks[idx] = true;
+ live += 1;
+ switch (self.nodes.items[idx]) {
+ .leaf => {},
+ .stem => |s| try grey.append(self.allocator, s.child),
+ .fork => |f| try grey.appendSlice(self.allocator, &.{ f.left, f.right }),
+ .app => |a| try grey.appendSlice(self.allocator, &.{ a.func, a.arg }),
+ }
+ }
+ try self.free.ensureTotalCapacity(self.allocator, self.nodes.items.len - live);
+ self.free.clearRetainingCapacity();
+ var i = self.nodes.items.len;
+ while (i > 0) {
+ i -= 1;
+ if (!marks[i]) {
+ self.nodes.items[i] = .leaf;
+ self.free.appendAssumeCapacity(@intCast(i));
+ }
+ }
+ self.allocations = 0;
+ self.collections += 1;
+ // Amortize scanning a large live graph; this is not a hard RSS bound.
+ self.gc_interval = @max(self.gc_interval, live);
}
};
diff --git a/ext/zig/src/bundle.zig b/ext/zig/src/bundle.zig
@@ -1,363 +1,12 @@
-const std = @import("std");
-const tree = @import("tree.zig");
+//! All entrypoints share the validated indexed reader. Authentication of a
+//! particular object's bytes belongs to its transport, not to bundle parsing.
const Arena = @import("arena.zig").Arena;
+const indexed = @import("indexed.zig");
-pub const Error = error{
- InvalidMagic,
- InvalidVersion,
- Truncated,
- InvalidManifest,
- InvalidNodePayload,
- ExportNotFound,
- MissingChild,
- UnexpectedFormat,
- OutOfMemory,
-};
-
-const Parser = struct {
- bytes: []const u8,
- pos: usize,
-
- fn init(bytes: []const u8) Parser {
- return .{ .bytes = bytes, .pos = 0 };
- }
-
- fn remaining(self: *const Parser) usize {
- return self.bytes.len - self.pos;
- }
-
- fn expect(self: *Parser, n: usize) Error![]const u8 {
- if (self.remaining() < n) return error.Truncated;
- const result = self.bytes[self.pos .. self.pos + n];
- self.pos += n;
- return result;
- }
-
- fn readU8(self: *Parser) Error!u8 {
- const b = try self.expect(1);
- return b[0];
- }
-
- fn readU16(self: *Parser) Error!u16 {
- const b = try self.expect(2);
- return std.mem.readInt(u16, b[0..2], .big);
- }
-
- fn readU32(self: *Parser) Error!u32 {
- const b = try self.expect(4);
- return std.mem.readInt(u32, b[0..4], .big);
- }
-
- fn readU64(self: *Parser) Error!u64 {
- const b = try self.expect(8);
- return std.mem.readInt(u64, b[0..8], .big);
- }
-
- fn readLengthPrefixedBytes(self: *Parser, allocator: std.mem.Allocator) Error![]const u8 {
- const len = try self.readU32();
- const bytes = try self.expect(len);
- const copy = try allocator.alloc(u8, bytes.len);
- @memcpy(copy, bytes);
- return copy;
- }
-};
-
-const SectionEntry = struct {
- section_type: u32,
- offset: u64,
- length: u64,
-};
-
-fn parseHeader(p: *Parser) Error!struct { major: u16, minor: u16, section_count: u32, dir_offset: u64 } {
- const magic = try p.expect(8);
- if (!std.mem.eql(u8, magic, "ARBORICX")) return error.InvalidMagic;
-
- const major = try p.readU16();
- const minor = try p.readU16();
- const section_count = try p.readU32();
- _ = try p.readU64(); // flags
- const dir_offset = try p.readU64();
-
- if (major != 1) return error.InvalidVersion;
-
- return .{ .major = major, .minor = minor, .section_count = section_count, .dir_offset = dir_offset };
+pub fn loadBundleExport(arena: *Arena, bytes: []const u8, name: []const u8) !u32 {
+ return indexed.load(arena, bytes, name, false);
}
-fn parseSectionEntries(p: *Parser, count: u32, allocator: std.mem.Allocator) Error![]SectionEntry {
- const entries = try allocator.alloc(SectionEntry, count);
- errdefer allocator.free(entries);
-
- for (entries) |*entry| {
- entry.section_type = try p.readU32();
- _ = try p.readU16(); // section_version
- _ = try p.readU16(); // section_flags
- const compression = try p.readU16();
- _ = try p.readU16(); // reserved (was digest_alg)
- entry.offset = try p.readU64();
- entry.length = try p.readU64();
- _ = try p.readU32(); // reserved padding
-
- if (compression != 0) return error.UnexpectedFormat;
- }
- return entries;
-}
-
-fn parseManifest(p: *Parser, allocator: std.mem.Allocator) Error!struct { exports: []Export, roots: []Root } {
- const magic = try p.expect(8);
- if (!std.mem.eql(u8, magic, "ARBMNFST")) return error.InvalidManifest;
-
- const major = try p.readU16();
- _ = try p.readU16(); // minor
- if (major != 1) return error.InvalidVersion;
-
- const schema = try p.readLengthPrefixedBytes(allocator);
- defer allocator.free(schema);
- if (!std.mem.eql(u8, schema, "arboricx.bundle.manifest.v1")) return error.UnexpectedFormat;
-
- const bundle_type = try p.readLengthPrefixedBytes(allocator);
- defer allocator.free(bundle_type);
- if (!std.mem.eql(u8, bundle_type, "tree-calculus-executable-object")) return error.UnexpectedFormat;
-
- const calc = try p.readLengthPrefixedBytes(allocator);
- defer allocator.free(calc);
- if (!std.mem.eql(u8, calc, "tree-calculus.v1")) return error.UnexpectedFormat;
-
- const hash_alg = try p.readLengthPrefixedBytes(allocator);
- defer allocator.free(hash_alg);
- if (!std.mem.eql(u8, hash_alg, "indexed")) return error.UnexpectedFormat;
-
- const hash_domain = try p.readLengthPrefixedBytes(allocator);
- defer allocator.free(hash_domain);
- if (!std.mem.eql(u8, hash_domain, "arboricx.indexed.node.v1")) return error.UnexpectedFormat;
-
- const payload_type = try p.readLengthPrefixedBytes(allocator);
- defer allocator.free(payload_type);
- if (!std.mem.eql(u8, payload_type, "arboricx.indexed.payload.v1")) return error.UnexpectedFormat;
-
- const sem = try p.readLengthPrefixedBytes(allocator);
- defer allocator.free(sem);
- if (!std.mem.eql(u8, sem, "tree-calculus.v1")) return error.UnexpectedFormat;
-
- const eval_mode = try p.readLengthPrefixedBytes(allocator);
- defer allocator.free(eval_mode);
- if (!std.mem.eql(u8, eval_mode, "normal-order")) return error.UnexpectedFormat;
-
- const abi = try p.readLengthPrefixedBytes(allocator);
- defer allocator.free(abi);
- if (!std.mem.eql(u8, abi, "arboricx.abi.tree.v1")) return error.UnexpectedFormat;
-
- const cap_count = try p.readU32();
- var i: u32 = 0;
- while (i < cap_count) : (i += 1) {
- const cap = try p.readLengthPrefixedBytes(allocator);
- defer allocator.free(cap);
- if (cap.len != 0) return error.UnexpectedFormat;
- }
-
- const closure = try p.readU8();
- if (closure != 0) return error.UnexpectedFormat;
-
- const root_count = try p.readU32();
- const roots = try allocator.alloc(Root, root_count);
- errdefer allocator.free(roots);
- for (roots) |*r| {
- r.index = try p.readU32();
- r.role = try p.readLengthPrefixedBytes(allocator);
- }
-
- const export_count = try p.readU32();
- const exports = try allocator.alloc(Export, export_count);
- errdefer {
- for (exports) |*e| {
- allocator.free(e.name);
- allocator.free(e.kind);
- allocator.free(e.abi);
- }
- allocator.free(exports);
- }
- for (exports) |*e| {
- e.name = try p.readLengthPrefixedBytes(allocator);
- e.root = try p.readU32();
- e.kind = try p.readLengthPrefixedBytes(allocator);
- e.abi = try p.readLengthPrefixedBytes(allocator);
- if (!std.mem.eql(u8, e.abi, "arboricx.abi.tree.v1")) return error.UnexpectedFormat;
- }
-
- const metadata_count = try p.readU32();
- var m: u32 = 0;
- while (m < metadata_count) : (m += 1) {
- _ = try p.readU16(); // tag
- const len = try p.readU32();
- _ = try p.expect(len);
- }
-
- const ext_count = try p.readU32();
- var e_idx: u32 = 0;
- while (e_idx < ext_count) : (e_idx += 1) {
- _ = try p.readU16(); // tag
- const len = try p.readU32();
- _ = try p.expect(len);
- }
-
- return .{ .exports = exports, .roots = roots };
-}
-
-const Export = struct {
- name: []const u8,
- root: u32,
- kind: []const u8,
- abi: []const u8,
-};
-
-const Root = struct {
- index: u32,
- role: []const u8,
-};
-
-/// Parse the node section and build nodes directly into the arena.
-/// Returns a slice mapping node-section index -> arena index.
-/// The caller owns the returned slice and must free it with the arena's allocator.
-fn parseNodeSection(p: *Parser, arena: *Arena) Error![]u32 {
- const node_count = try p.readU64();
- const indices = try arena.allocator.alloc(u32, node_count);
- errdefer arena.allocator.free(indices);
-
- var i: u64 = 0;
- while (i < node_count) : (i += 1) {
- const plen = try p.readU32();
- const payload = try p.expect(plen);
-
- if (payload.len == 0) return error.InvalidNodePayload;
-
- const idx: u32 = switch (payload[0]) {
- 0x00 => blk: {
- if (plen != 1) return error.InvalidNodePayload;
- break :blk try arena.alloc(.leaf);
- },
- 0x01 => blk: {
- if (plen != 5) return error.InvalidNodePayload;
- const child_idx = std.mem.readInt(u32, payload[1..5], .big);
- if (child_idx >= i) return error.InvalidNodePayload;
- break :blk try arena.alloc(.{ .stem = .{ .child = indices[child_idx] } });
- },
- 0x02 => blk: {
- if (plen != 9) return error.InvalidNodePayload;
- const left_idx = std.mem.readInt(u32, payload[1..5], .big);
- const right_idx = std.mem.readInt(u32, payload[5..9], .big);
- if (left_idx >= i or right_idx >= i) return error.InvalidNodePayload;
- break :blk try arena.alloc(.{ .fork = .{ .left = indices[left_idx], .right = indices[right_idx] } });
- },
- else => return error.InvalidNodePayload,
- };
- indices[i] = idx;
- }
-
- return indices;
-}
-
-fn findSection(entries: []SectionEntry, section_type: u32) ?SectionEntry {
- for (entries) |entry| {
- if (entry.section_type == section_type) return entry;
- }
- return null;
-}
-
-/// Parse an Arboricx bundle and load the named export into the arena.
-/// Returns the arena index of the exported term tree.
-pub fn loadBundleExport(
- arena: *Arena,
- bundle_bytes: []const u8,
- export_name: []const u8,
-) Error!u32 {
- var p = Parser.init(bundle_bytes);
-
- const header = try parseHeader(&p);
-
- p.pos = @intCast(header.dir_offset);
- const allocator = arena.allocator;
- const entries = try parseSectionEntries(&p, header.section_count, allocator);
- defer allocator.free(entries);
-
- const manifest_section = findSection(entries, 1) orelse return error.InvalidManifest;
- const nodes_section = findSection(entries, 2) orelse return error.InvalidNodePayload;
-
- const manifest_bytes = bundle_bytes[@intCast(manifest_section.offset)..@intCast(manifest_section.offset + manifest_section.length)];
- const nodes_bytes = bundle_bytes[@intCast(nodes_section.offset)..@intCast(nodes_section.offset + nodes_section.length)];
-
- var mp = Parser.init(manifest_bytes);
- const manifest = try parseManifest(&mp, allocator);
- defer {
- for (manifest.exports) |e| {
- allocator.free(e.name);
- allocator.free(e.kind);
- allocator.free(e.abi);
- }
- allocator.free(manifest.exports);
- for (manifest.roots) |r| {
- allocator.free(r.role);
- }
- allocator.free(manifest.roots);
- }
-
- var export_root: ?u32 = null;
- for (manifest.exports) |e| {
- if (std.mem.eql(u8, e.name, export_name)) {
- export_root = e.root;
- break;
- }
- }
- const root_index = export_root orelse return error.ExportNotFound;
-
- var np = Parser.init(nodes_bytes);
- const node_indices = try parseNodeSection(&np, arena);
- defer allocator.free(node_indices);
-
- if (root_index >= node_indices.len) return error.InvalidNodePayload;
- return node_indices[root_index];
-}
-
-/// Parse an Arboricx bundle and load the default (first) root into the arena.
-pub fn loadBundleDefaultRoot(
- arena: *Arena,
- bundle_bytes: []const u8,
-) Error!u32 {
- var p = Parser.init(bundle_bytes);
-
- const header = try parseHeader(&p);
-
- p.pos = @intCast(header.dir_offset);
- const allocator = arena.allocator;
- const entries = try parseSectionEntries(&p, header.section_count, allocator);
- defer allocator.free(entries);
-
- const manifest_section = findSection(entries, 1) orelse return error.InvalidManifest;
- const nodes_section = findSection(entries, 2) orelse return error.InvalidNodePayload;
-
- const manifest_bytes = bundle_bytes[@intCast(manifest_section.offset)..@intCast(manifest_section.offset + manifest_section.length)];
- const nodes_bytes = bundle_bytes[@intCast(nodes_section.offset)..@intCast(nodes_section.offset + nodes_section.length)];
-
- var mp = Parser.init(manifest_bytes);
- const manifest = try parseManifest(&mp, allocator);
- defer {
- for (manifest.exports) |e| {
- allocator.free(e.name);
- allocator.free(e.kind);
- allocator.free(e.abi);
- }
- allocator.free(manifest.exports);
- for (manifest.roots) |r| {
- allocator.free(r.role);
- }
- allocator.free(manifest.roots);
- }
-
- if (manifest.roots.len == 0) return error.ExportNotFound;
- const root_index = manifest.roots[0].index;
-
- var np = Parser.init(nodes_bytes);
- const node_indices = try parseNodeSection(&np, arena);
- defer allocator.free(node_indices);
-
- if (root_index >= node_indices.len) return error.InvalidNodePayload;
- return node_indices[root_index];
+pub fn loadBundleDefaultRoot(arena: *Arena, bytes: []const u8) !u32 {
+ return indexed.load(arena, bytes, null, false);
}
diff --git a/ext/zig/src/c_abi.zig b/ext/zig/src/c_abi.zig
@@ -6,247 +6,236 @@ const codecs = @import("codecs.zig");
const kernel = @import("kernel.zig");
const bundle = @import("bundle.zig");
const io_driver = @import("io_driver.zig");
+const handles = @import("handles.zig");
+const Handle = handles.Handle;
+
+// Keep these values synchronized with include/arboricx.h.
+const Status = enum(c_int) {
+ ok = 0,
+ invalid_handle = 1,
+ out_of_memory = 2,
+ fuel_exhausted = 3,
+ type_mismatch = 4,
+ invalid_data = 5,
+ io_error = 6,
+ handle_exhausted = 7,
+};
+fn status(err: anyerror) Status {
+ return switch (err) {
+ error.InvalidHandle => .invalid_handle,
+ error.OutOfMemory => .out_of_memory,
+ error.FuelExhausted => .fuel_exhausted,
+ error.HandleExhausted => .handle_exhausted,
+ else => .invalid_data,
+ };
+}
-/// Opaque handle for the C API. Layout is not exposed to C.
-/// Holds a persistent arena for user-built terms and the kernel.
pub const ArbCtx = struct {
gpa: std.mem.Allocator,
arena: Arena,
kernel_root: u32,
+ handles: handles.Table = .{},
+
+ fn prepare(self: *ArbCtx) !void {
+ self.arena.roots.clearRetainingCapacity();
+ try self.arena.roots.append(self.gpa, self.kernel_root);
+ try self.handles.appendRoots(&self.arena);
+ }
+ fn publish(self: *ArbCtx, node: u32, out: *Handle) Status {
+ const handle = self.handles.create(self.gpa, node) catch |err| return status(err);
+ out.* = handle;
+ return .ok;
+ }
+ // Multi-output operations publish atomically: never leak half a pair.
+ fn publishPair(self: *ArbCtx, a: u32, b: u32, out_a: *Handle, out_b: *Handle) Status {
+ const ha = self.handles.create(self.gpa, a) catch |err| return status(err);
+ const hb = self.handles.create(self.gpa, b) catch |err| {
+ self.handles.release(ha) catch unreachable;
+ return status(err);
+ };
+ out_a.* = ha;
+ out_b.* = hb;
+ return .ok;
+ }
};
-// ---------------------------------------------------------------------------
-// Context lifecycle
-// ---------------------------------------------------------------------------
-
+export fn arb_abi_version() u32 {
+ return 2;
+}
export fn arboricx_init() ?*ArbCtx {
- const ptr = std.heap.smp_allocator.create(ArbCtx) catch return null;
- ptr.gpa = std.heap.smp_allocator;
- ptr.arena = Arena.init(std.heap.smp_allocator);
+ const gpa = std.heap.smp_allocator;
+ const ptr = gpa.create(ArbCtx) catch return null;
+ ptr.* = .{ .gpa = gpa, .arena = Arena.init(gpa), .kernel_root = undefined };
ptr.kernel_root = kernel.loadKernel(&ptr.arena) catch {
ptr.arena.deinit();
- std.heap.smp_allocator.destroy(ptr);
+ gpa.destroy(ptr);
return null;
};
+ ptr.arena.gc_enabled = true;
return ptr;
}
-
export fn arboricx_free(ctx: *ArbCtx) void {
+ ctx.handles.deinit(ctx.gpa);
ctx.arena.deinit();
ctx.gpa.destroy(ctx);
}
-
-export fn arboricx_free_buf(_: *ArbCtx, ptr: [*]u8, len: usize) void {
- std.heap.smp_allocator.free(ptr[0..len]);
-}
-
-// ---------------------------------------------------------------------------
-// Tree construction (all write into the persistent arena)
-// ---------------------------------------------------------------------------
-
-export fn arb_leaf(ctx: *ArbCtx) u32 {
- return ctx.arena.alloc(.leaf) catch 0;
-}
-
-export fn arb_stem(ctx: *ArbCtx, child: u32) u32 {
- return ctx.arena.alloc(.{ .stem = .{ .child = child } }) catch 0;
-}
-
-export fn arb_fork(ctx: *ArbCtx, left: u32, right: u32) u32 {
- return ctx.arena.alloc(.{ .fork = .{ .left = left, .right = right } }) catch 0;
-}
-
-export fn arb_app(ctx: *ArbCtx, func: u32, arg: u32) u32 {
- return ctx.arena.alloc(.{ .app = .{ .func = func, .arg = arg } }) catch 0;
-}
-
-// ---------------------------------------------------------------------------
-// Tree inspection (Layer 1 — for custom IO drivers and non-POSIX hosts)
-// All return 1 on success / true, 0 on failure / false.
-// ---------------------------------------------------------------------------
-
-export fn arb_is_leaf(ctx: *ArbCtx, root: u32) c_int {
- if (root >= ctx.arena.len()) return 0;
- return if (ctx.arena.nodes.items[root] == .leaf) 1 else 0;
-}
-
-export fn arb_is_stem(ctx: *ArbCtx, root: u32) c_int {
- if (root >= ctx.arena.len()) return 0;
- return if (ctx.arena.nodes.items[root] == .stem) 1 else 0;
-}
-
-export fn arb_is_fork(ctx: *ArbCtx, root: u32) c_int {
- if (root >= ctx.arena.len()) return 0;
- return if (ctx.arena.nodes.items[root] == .fork) 1 else 0;
-}
-
-export fn arb_is_app(ctx: *ArbCtx, root: u32) c_int {
- if (root >= ctx.arena.len()) return 0;
- return if (ctx.arena.nodes.items[root] == .app) 1 else 0;
-}
-
-export fn arb_get_stem_child(ctx: *ArbCtx, root: u32, out: *u32) c_int {
- if (root >= ctx.arena.len()) return 0;
- const node = ctx.arena.nodes.items[root];
- if (node != .stem) return 0;
- out.* = node.stem.child;
- return 1;
-}
-
-export fn arb_get_fork_children(ctx: *ArbCtx, root: u32, out_left: *u32, out_right: *u32) c_int {
- if (root >= ctx.arena.len()) return 0;
- const node = ctx.arena.nodes.items[root];
- if (node != .fork) return 0;
- out_left.* = node.fork.left;
- out_right.* = node.fork.right;
- return 1;
-}
-
-export fn arb_get_app_func_arg(ctx: *ArbCtx, root: u32, out_func: *u32, out_arg: *u32) c_int {
- if (root >= ctx.arena.len()) return 0;
- const node = ctx.arena.nodes.items[root];
- if (node != .app) return 0;
- out_func.* = node.app.func;
- out_arg.* = node.app.arg;
- return 1;
-}
-
-// ---------------------------------------------------------------------------
-// Reduction
-// ---------------------------------------------------------------------------
-/// Reduces `root` in a *fresh* scratch arena so that garbage from previous
-/// reductions never accumulates. The kernel and term are deep-copied into
-/// the scratch arena, reduced there, and the result is copied back into the
-/// persistent arena.
-// ---------------------------------------------------------------------------
-
-export fn arb_reduce(ctx: *ArbCtx, root: u32, fuel: u64) u32 {
- // 1. Fresh scratch arena
- var scratch = Arena.init(ctx.gpa);
- defer scratch.deinit();
-
- // 2. Deep-copy the term (which may reference kernel nodes) into scratch
- const scratch_root = tree.copyTree(ctx.arena.nodes.items, &scratch, root) catch return 0;
-
- // 3. Reduce in scratch
- const scratch_result = reduce.reduce(scratch_root, &scratch, fuel) catch return 0;
-
- // 4. Copy the result back to the persistent arena
- return tree.copyTree(scratch.nodes.items, &ctx.arena, scratch_result) catch 0;
-}
-
-// ---------------------------------------------------------------------------
-// Codec constructors
-// ---------------------------------------------------------------------------
-
-export fn arb_of_number(ctx: *ArbCtx, n: u64) u32 {
- return codecs.ofNumber(&ctx.arena, n) catch 0;
-}
-
-export fn arb_of_string(ctx: *ArbCtx, s: [*:0]const u8) u32 {
- const slice = std.mem.sliceTo(s, 0);
- return codecs.ofString(&ctx.arena, slice) catch 0;
-}
-
-export fn arb_of_bytes(ctx: *ArbCtx, bytes: [*]const u8, len: usize) u32 {
- return codecs.ofBytes(&ctx.arena, bytes[0..len]) catch 0;
-}
-
-export fn arb_of_list(ctx: *ArbCtx, items: [*]const u32, len: usize) u32 {
- return codecs.ofList(&ctx.arena, items[0..len]) catch 0;
-}
-
-// ---------------------------------------------------------------------------
-// Codec destructors
-// Return 1 on success, 0 on failure.
-// ---------------------------------------------------------------------------
-
-export fn arb_to_number(ctx: *ArbCtx, root: u32, out: *u64) c_int {
- const n = codecs.toNumber(&ctx.arena, root) catch return 0;
- if (n == null) return 0;
- out.* = n.?;
- return 1;
-}
-
-export fn arb_to_string(ctx: *ArbCtx, root: u32, out_ptr: **u8, out_len: *usize) c_int {
- const s = codecs.toString(&ctx.arena, root) catch return 0;
- if (s == null) return 0;
- out_ptr.* = @ptrCast(s.?.ptr);
- out_len.* = s.?.len;
- return 1;
-}
-
-export fn arb_to_bytes(ctx: *ArbCtx, root: u32, out_ptr: **u8, out_len: *usize) c_int {
+export fn arboricx_free_buf(ctx: *ArbCtx, ptr: [*]u8, len: usize) void {
+ ctx.gpa.free(ptr[0..len]);
+}
+export fn arb_release(ctx: *ArbCtx, handle: Handle) Status {
+ ctx.handles.release(handle) catch |err| return status(err);
+ return .ok;
+}
+export fn arb_retain(ctx: *ArbCtx, handle: Handle, out: *Handle) Status {
+ const node = ctx.handles.get(handle) catch |err| return status(err);
+ return ctx.publish(node, out);
+}
+export fn arb_collect(ctx: *ArbCtx) Status {
+ ctx.prepare() catch |err| return status(err);
+ ctx.arena.collect(&.{}) catch |err| return status(err);
+ return .ok;
+}
+export fn arb_leaf(ctx: *ArbCtx, out: *Handle) Status {
+ return ctx.publish(ctx.arena.alloc(.leaf) catch |err| return status(err), out);
+}
+export fn arb_stem(ctx: *ArbCtx, child: Handle, out: *Handle) Status {
+ const child_node = ctx.handles.get(child) catch |err| return status(err);
+ const node = ctx.arena.alloc(.{ .stem = .{ .child = child_node } }) catch |err| return status(err);
+ return ctx.publish(node, out);
+}
+export fn arb_fork(ctx: *ArbCtx, left: Handle, right: Handle, out: *Handle) Status {
+ const left_node = ctx.handles.get(left) catch |err| return status(err);
+ const right_node = ctx.handles.get(right) catch |err| return status(err);
+ const node = ctx.arena.alloc(.{ .fork = .{ .left = left_node, .right = right_node } }) catch |err| return status(err);
+ return ctx.publish(node, out);
+}
+export fn arb_app(ctx: *ArbCtx, func: Handle, arg: Handle, out: *Handle) Status {
+ const func_node = ctx.handles.get(func) catch |err| return status(err);
+ const arg_node = ctx.handles.get(arg) catch |err| return status(err);
+ const node = ctx.arena.alloc(.{ .app = .{ .func = func_node, .arg = arg_node } }) catch |err| return status(err);
+ return ctx.publish(node, out);
+}
+export fn arb_is_leaf(ctx: *ArbCtx, root: Handle, out: *c_int) Status {
+ const node = ctx.handles.get(root) catch |err| return status(err);
+ out.* = if (ctx.arena.get(node).* == .leaf) 1 else 0;
+ return .ok;
+}
+export fn arb_is_stem(ctx: *ArbCtx, root: Handle, out: *c_int) Status {
+ const node = ctx.handles.get(root) catch |err| return status(err);
+ out.* = if (ctx.arena.get(node).* == .stem) 1 else 0;
+ return .ok;
+}
+export fn arb_is_fork(ctx: *ArbCtx, root: Handle, out: *c_int) Status {
+ const node = ctx.handles.get(root) catch |err| return status(err);
+ out.* = if (ctx.arena.get(node).* == .fork) 1 else 0;
+ return .ok;
+}
+export fn arb_is_app(ctx: *ArbCtx, root: Handle, out: *c_int) Status {
+ const node = ctx.handles.get(root) catch |err| return status(err);
+ out.* = if (ctx.arena.get(node).* == .app) 1 else 0;
+ return .ok;
+}
+export fn arb_get_stem_child(ctx: *ArbCtx, root: Handle, out_child: *Handle) Status {
+ const idx = ctx.handles.get(root) catch |err| return status(err);
+ const node = ctx.arena.get(idx).*;
+ if (node != .stem) return .type_mismatch;
+ return ctx.publish(node.stem.child, out_child);
+}
+export fn arb_get_fork_children(ctx: *ArbCtx, root: Handle, out_left: *Handle, out_right: *Handle) Status {
+ const idx = ctx.handles.get(root) catch |err| return status(err);
+ const node = ctx.arena.get(idx).*;
+ if (node != .fork) return .type_mismatch;
+ return ctx.publishPair(node.fork.left, node.fork.right, out_left, out_right);
+}
+export fn arb_get_app_func_arg(ctx: *ArbCtx, root: Handle, out_func: *Handle, out_arg: *Handle) Status {
+ const idx = ctx.handles.get(root) catch |err| return status(err);
+ const node = ctx.arena.get(idx).*;
+ if (node != .app) return .type_mismatch;
+ return ctx.publishPair(node.app.func, node.app.arg, out_func, out_arg);
+}
+export fn arb_reduce(ctx: *ArbCtx, root: Handle, fuel: u64, out: *Handle) Status {
+ const node = ctx.handles.get(root) catch |err| return status(err);
+ ctx.prepare() catch |err| return status(err);
+ const result = reduce.reduce(node, &ctx.arena, fuel) catch |err| return status(err);
+ return ctx.publish(result, out);
+}
+export fn arb_of_number(ctx: *ArbCtx, n: u64, out: *Handle) Status {
+ return ctx.publish(codecs.ofNumber(&ctx.arena, n) catch |err| return status(err), out);
+}
+export fn arb_of_string(ctx: *ArbCtx, s: [*:0]const u8, out: *Handle) Status {
+ return ctx.publish(codecs.ofString(&ctx.arena, std.mem.sliceTo(s, 0)) catch |err| return status(err), out);
+}
+export fn arb_of_bytes(ctx: *ArbCtx, bytes: [*]const u8, len: usize, out: *Handle) Status {
+ return ctx.publish(codecs.ofBytes(&ctx.arena, bytes[0..len]) catch |err| return status(err), out);
+}
+export fn arb_of_list(ctx: *ArbCtx, items: [*]const Handle, len: usize, out: *Handle) Status {
+ const nodes = ctx.gpa.alloc(u32, len) catch |err| return status(err);
+ defer ctx.gpa.free(nodes);
+ for (items[0..len], nodes) |h, *node| node.* = ctx.handles.get(h) catch |err| return status(err);
+ return ctx.publish(codecs.ofList(&ctx.arena, nodes) catch |err| return status(err), out);
+}
+export fn arb_to_number(ctx: *ArbCtx, root: Handle, out: *u64) Status {
+ const node = ctx.handles.get(root) catch |err| return status(err);
+ ctx.prepare() catch |err| return status(err);
+ const value = (codecs.toNumber(&ctx.arena, node) catch |err| return status(err)) orelse return .type_mismatch;
+ out.* = value;
+ return .ok;
+}
+export fn arb_to_bool(ctx: *ArbCtx, root: Handle, out: *c_int) Status {
+ const node = ctx.handles.get(root) catch |err| return status(err);
+ ctx.prepare() catch |err| return status(err);
+ const value = (codecs.toBool(&ctx.arena, node) catch |err| return status(err)) orelse return .type_mismatch;
+ out.* = if (value) 1 else 0;
+ return .ok;
+}
+export fn arb_to_string(ctx: *ArbCtx, root: Handle, out_ptr: **u8, out_len: *usize) Status {
+ const node = ctx.handles.get(root) catch |err| return status(err);
+ ctx.prepare() catch |err| return status(err);
+ const value = (codecs.toString(&ctx.arena, node) catch |err| return status(err)) orelse return .type_mismatch;
+ out_ptr.* = @ptrCast(value.ptr);
+ out_len.* = value.len;
+ return .ok;
+}
+export fn arb_to_bytes(ctx: *ArbCtx, root: Handle, out_ptr: **u8, out_len: *usize) Status {
return arb_to_string(ctx, root, out_ptr, out_len);
}
-
-export fn arb_to_bool(ctx: *ArbCtx, root: u32, out: *c_int) c_int {
- const b = codecs.toBool(&ctx.arena, root) catch return 0;
- if (b == null) return 0;
- out.* = if (b.?) 1 else 0;
- return 1;
-}
-
-// ---------------------------------------------------------------------------
-// Result unwrapping
-// Return 1 on success, 0 on failure.
-// ---------------------------------------------------------------------------
-
-export fn arb_unwrap_result(ctx: *ArbCtx, root: u32, out_ok: *c_int, out_value: *u32, out_rest: *u32) c_int {
- const r = codecs.unwrapResult(&ctx.arena, root) catch return 0;
- if (r == null) return 0;
- out_ok.* = if (r.?.ok) 1 else 0;
- out_value.* = r.?.value;
- out_rest.* = r.?.rest;
- return 1;
-}
-
-export fn arb_unwrap_host_value(ctx: *ArbCtx, root: u32, out_tag: *u64, out_payload: *u32) c_int {
- const hv = codecs.unwrapHostValue(&ctx.arena, root) catch return 0;
- if (hv == null) return 0;
- out_tag.* = hv.?.tag;
- out_payload.* = hv.?.payload;
- return 1;
-}
-
-// ---------------------------------------------------------------------------
-// IO driver (Layer 2 — POSIX interaction-tree runtime)
-// ---------------------------------------------------------------------------
-
-pub const arb_io_perms_t = extern struct {
- allow_read_all: c_int,
- allow_write_all: c_int,
-};
-
-export fn arb_run_io(ctx: *ArbCtx, program: u32, perms: ?*const arb_io_perms_t) u32 {
+export fn arb_unwrap_result(ctx: *ArbCtx, root: Handle, out_ok: *c_int, out_value: *Handle, out_rest: *Handle) Status {
+ const node = ctx.handles.get(root) catch |err| return status(err);
+ ctx.prepare() catch |err| return status(err);
+ const r = (codecs.unwrapResult(&ctx.arena, node) catch |err| return status(err)) orelse return .type_mismatch;
+ const result = ctx.publishPair(r.value, r.rest, out_value, out_rest);
+ if (result == .ok) out_ok.* = if (r.ok) 1 else 0;
+ return result;
+}
+export fn arb_unwrap_host_value(ctx: *ArbCtx, root: Handle, out_tag: *u64, out_payload: *Handle) Status {
+ const node = ctx.handles.get(root) catch |err| return status(err);
+ ctx.prepare() catch |err| return status(err);
+ const hv = (codecs.unwrapHostValue(&ctx.arena, node) catch |err| return status(err)) orelse return .type_mismatch;
+ const result = ctx.publish(hv.payload, out_payload);
+ if (result == .ok) out_tag.* = hv.tag;
+ return result;
+}
+pub const arb_io_perms_t = extern struct { allow_read_all: c_int, allow_write_all: c_int };
+export fn arb_run_io(ctx: *ArbCtx, program: Handle, perms: ?*const arb_io_perms_t, out: *Handle) Status {
+ const node = ctx.handles.get(program) catch |err| return status(err);
+ ctx.prepare() catch |err| return status(err);
const zig_perms = if (perms) |p| io_driver.IOPerms{
.allow_read_all = p.allow_read_all != 0,
.allow_write_all = p.allow_write_all != 0,
} else io_driver.IOPerms{};
- return io_driver.runIO(ctx.gpa, &ctx.arena, program, zig_perms) catch 0;
+ const result = io_driver.runIO(ctx.gpa, &ctx.arena, node, zig_perms) catch |err| return switch (err) {
+ error.OutOfMemory => .out_of_memory,
+ error.FuelExhausted => .fuel_exhausted,
+ else => .io_error,
+ };
+ return ctx.publish(result, out);
}
-
-// ---------------------------------------------------------------------------
-// Kernel entrypoints
-// ---------------------------------------------------------------------------
-
-export fn arb_kernel_root(ctx: *ArbCtx) u32 {
- return ctx.kernel_root;
+export fn arb_kernel_root(ctx: *ArbCtx, out: *Handle) Status {
+ return ctx.publish(ctx.kernel_root, out);
}
-
-// ---------------------------------------------------------------------------
-// Native bundle loading (fast path — bypasses the Tricu kernel)
-// ---------------------------------------------------------------------------
-
-/// Load a named export from an Arboricx bundle directly into the arena.
-/// Returns the arena index of the exported term, or 0 on error.
-export fn arb_load_bundle(ctx: *ArbCtx, bytes: [*]const u8, len: usize, name: [*:0]const u8) u32 {
- const name_slice = std.mem.sliceTo(name, 0);
- return bundle.loadBundleExport(&ctx.arena, bytes[0..len], name_slice) catch 0;
+export fn arb_load_bundle(ctx: *ArbCtx, bytes: [*]const u8, len: usize, name: [*:0]const u8, out: *Handle) Status {
+ return ctx.publish(bundle.loadBundleExport(&ctx.arena, bytes[0..len], std.mem.sliceTo(name, 0)) catch |err| return status(err), out);
}
-
-/// Load the default root from an Arboricx bundle directly into the arena.
-/// Returns the arena index of the root term, or 0 on error.
-export fn arb_load_bundle_default(ctx: *ArbCtx, bytes: [*]const u8, len: usize) u32 {
- return bundle.loadBundleDefaultRoot(&ctx.arena, bytes[0..len]) catch 0;
+export fn arb_load_bundle_default(ctx: *ArbCtx, bytes: [*]const u8, len: usize, out: *Handle) Status {
+ return ctx.publish(bundle.loadBundleDefaultRoot(&ctx.arena, bytes[0..len]) catch |err| return status(err), out);
}
diff --git a/ext/zig/src/codecs.zig b/ext/zig/src/codecs.zig
@@ -1,5 +1,4 @@
const std = @import("std");
-const tree = @import("tree.zig");
const Arena = @import("arena.zig").Arena;
const reduce = @import("reduce.zig");
@@ -27,11 +26,12 @@ pub fn toNumber(arena: *Arena, idx: u32) !?u64 {
const bit = arena.get(bit_node);
const bit_val: u64 = switch (bit.*) {
.leaf => 0,
- .stem => |s| if (arena.get(s.child).* == .leaf) 1 else return null,
+ .stem => |s| if (arena.get(try reduce.reduce(s.child, arena, 1_000_000)).* == .leaf) 1 else return null,
else => return null,
};
const rest = try toNumber(arena, f.right) orelse return null;
- break :blk bit_val + 2 * rest;
+ const doubled = std.math.mul(u64, 2, rest) catch return null;
+ break :blk std.math.add(u64, bit_val, doubled) catch return null;
},
.app => return null,
};
@@ -61,7 +61,10 @@ pub fn toList(arena: *Arena, idx: u32) !?std.ArrayList(u32) {
const n = arena.get(node);
switch (n.*) {
.leaf => return result,
- .stem => return null,
+ .stem => {
+ result.deinit(arena.allocator);
+ return null;
+ },
.fork => |f| {
try result.append(arena.allocator, f.left);
current = f.right;
@@ -134,7 +137,7 @@ pub fn unwrapResult(arena: *Arena, idx: u32) !?UnwrapResult {
const rp = arena.get(rest_pair);
switch (rp.*) {
.fork => |rf| {
- const is_ok = tree.sameTree(arena, tag, try arena.alloc(.{ .stem = .{ .child = try arena.alloc(.leaf) } }));
+ const is_ok = try toBool(arena, tag) orelse return null;
return UnwrapResult{
.ok = is_ok,
.value = rf.left,
@@ -176,7 +179,7 @@ pub fn isBool(arena: *Arena, idx: u32) !bool {
const n = arena.get(node);
return switch (n.*) {
.leaf => true,
- .stem => |s| arena.get(s.child).* == .leaf,
+ .stem => |s| arena.get(try reduce.reduce(s.child, arena, 1_000_000)).* == .leaf,
else => false,
};
}
@@ -188,7 +191,7 @@ pub fn toBool(arena: *Arena, idx: u32) !?bool {
const n = arena.get(node);
return switch (n.*) {
.leaf => false,
- .stem => |s| if (arena.get(s.child).* == .leaf) true else null,
+ .stem => |s| if (arena.get(try reduce.reduce(s.child, arena, 1_000_000)).* == .leaf) true else null,
else => null,
};
}
diff --git a/ext/zig/src/handles.zig b/ext/zig/src/handles.zig
@@ -0,0 +1,58 @@
+const std = @import("std");
+const Arena = @import("arena.zig").Arena;
+
+pub const Handle = u64;
+// A process-wide generation prevents cross-context aliases as well as stale
+// slot reuse. Never wrap: exhaustion is an explicit error, not ABA resurrection.
+var generation = std.atomic.Value(u32).init(1);
+fn nextGeneration() !u32 {
+ var old = generation.load(.monotonic);
+ while (old != std.math.maxInt(i32)) {
+ old = generation.cmpxchgWeak(old, old + 1, .monotonic, .monotonic) orelse return old;
+ }
+ return error.HandleExhausted;
+}
+
+pub const Table = struct {
+ const Slot = struct { node: ?u32, generation: u32, next: ?u32 = null };
+ slots: std.ArrayList(Slot) = .empty,
+ free: ?u32 = null,
+
+ pub fn deinit(self: *Table, allocator: std.mem.Allocator) void {
+ self.slots.deinit(allocator);
+ }
+ pub fn create(self: *Table, allocator: std.mem.Allocator, node: u32) !Handle {
+ const gen = try nextGeneration();
+ const idx = if (self.free) |idx| blk: {
+ self.free = self.slots.items[idx].next;
+ self.slots.items[idx] = .{ .node = node, .generation = gen };
+ break :blk idx;
+ } else blk: {
+ if (self.slots.items.len >= std.math.maxInt(u32)) return error.HandleExhausted;
+ const idx: u32 = @intCast(self.slots.items.len);
+ try self.slots.append(allocator, .{ .node = node, .generation = gen });
+ break :blk idx;
+ };
+ return (@as(u64, gen) << 32) | idx;
+ }
+ pub fn get(self: *const Table, handle: Handle) !u32 {
+ const idx: u32 = @truncate(handle);
+ const gen: u32 = @intCast(handle >> 32);
+ if (idx >= self.slots.items.len) return error.InvalidHandle;
+ const slot = self.slots.items[idx];
+ if (gen != slot.generation) return error.InvalidHandle;
+ return slot.node orelse error.InvalidHandle;
+ }
+ pub fn release(self: *Table, handle: Handle) !void {
+ _ = try self.get(handle);
+ const idx: u32 = @truncate(handle);
+ self.slots.items[idx].node = null;
+ self.slots.items[idx].next = self.free;
+ self.free = idx;
+ }
+ pub fn appendRoots(self: *const Table, arena: *Arena) !void {
+ for (self.slots.items) |slot| {
+ if (slot.node) |node| try arena.roots.append(arena.allocator, node);
+ }
+ }
+};
diff --git a/ext/zig/src/indexed.zig b/ext/zig/src/indexed.zig
@@ -0,0 +1,408 @@
+//! Indexed Arboricx codec. No paths, permissions, compiler policy or IO actions.
+//! Physical IDs accelerate traversal; exact constructor/child keys define bytes.
+const std = @import("std");
+const Arena = @import("arena.zig").Arena;
+const Node = @import("tree.zig").Node;
+const reduce = @import("reduce.zig");
+pub const domain = "arboricx.indexed-tree.v1";
+const fields = [_][]const u8{ "arboricx.bundle.manifest.v1", "tree-calculus-executable-object", "tree-calculus.v1", "indexed", "arboricx.indexed.node.v1", "arboricx.indexed.payload.v1", "tree-calculus.v1", "normal-order", "arboricx.abi.tree.v1" };
+const Key = struct { tag: u8, left: u32 = 0, right: u32 = 0 };
+const Writer = struct {
+ allocator: std.mem.Allocator,
+ bytes: std.ArrayList(u8) = .empty,
+ fn raw(w: *Writer, data: []const u8) !void {
+ try w.bytes.appendSlice(w.allocator, data);
+ }
+ fn int(w: *Writer, comptime T: type, n: T) !void {
+ var data: [@sizeOf(T)]u8 = undefined;
+ std.mem.writeInt(T, &data, n, .big);
+ try w.raw(&data);
+ }
+ fn text(w: *Writer, data: []const u8) !void {
+ try w.int(u32, @intCast(data.len));
+ try w.raw(data);
+ }
+ fn deinit(w: *Writer) void {
+ w.bytes.deinit(w.allocator);
+ }
+};
+fn intern(nodes: *std.ArrayList(Key), keys: *std.AutoHashMap(Key, u32), key: Key) !u32 {
+ if (keys.get(key)) |id| return id;
+ if (nodes.items.len >= std.math.maxInt(u32)) return error.OutOfMemory;
+ const id: u32 = @intCast(nodes.items.len);
+ try nodes.append(keys.allocator, key);
+ try keys.put(key, id);
+ return id;
+}
+
+pub fn encode(arena: *Arena, root: u32) ![]u8 {
+ const allocator = arena.allocator;
+ const base = arena.roots.items.len;
+ defer arena.roots.shrinkRetainingCapacity(base);
+ try arena.roots.append(allocator, root);
+ var memo = std.AutoHashMap(u32, ?u32).init(allocator);
+ defer memo.deinit();
+ var keys = std.AutoHashMap(Key, u32).init(allocator);
+ defer keys.deinit();
+ var nodes: std.ArrayList(Key) = .empty;
+ defer nodes.deinit(allocator);
+ const Frame = union(enum) { visit: u32, finish: struct { id: u32, node: Node } };
+ var stack: std.ArrayList(Frame) = .empty;
+ defer stack.deinit(allocator);
+ try stack.append(allocator, .{ .visit = root });
+ while (stack.pop()) |frame| switch (frame) {
+ .visit => |id| {
+ if (memo.get(id)) |entry| {
+ if (entry == null) return error.CyclicTree;
+ continue;
+ }
+ const forced = try reduce.reduce(id, arena, 1_000_000);
+ const node = arena.get(forced).*;
+ // Hold memoized IDs through later reductions, even if updates cut
+ // old graph edges. Collection must not recycle a memo's identity.
+ try arena.roots.appendSlice(allocator, &.{ id, forced });
+ try memo.put(id, null);
+ try stack.append(allocator, .{ .finish = .{ .id = id, .node = node } });
+ switch (node) {
+ .leaf => {},
+ .stem => |s| try stack.append(allocator, .{ .visit = s.child }),
+ .fork => |f| {
+ try stack.append(allocator, .{ .visit = f.right });
+ try stack.append(allocator, .{ .visit = f.left });
+ },
+ .app => unreachable,
+ }
+ },
+ .finish => |f| {
+ const key: Key = switch (f.node) {
+ .leaf => .{ .tag = 0 },
+ .stem => |s| .{ .tag = 1, .left = memo.get(s.child).?.? },
+ .fork => |p| .{ .tag = 2, .left = memo.get(p.left).?.?, .right = memo.get(p.right).?.? },
+ .app => unreachable,
+ };
+ try memo.put(f.id, try intern(&nodes, &keys, key));
+ },
+ };
+ const root_index = memo.get(root).?.?;
+ var manifest = Writer{ .allocator = allocator };
+ defer manifest.deinit();
+ try manifest.raw("ARBMNFST");
+ try manifest.int(u16, 1);
+ try manifest.int(u16, 1);
+ for (fields) |field| try manifest.text(field);
+ try manifest.int(u32, 0);
+ try manifest.int(u8, 0);
+ try manifest.int(u32, 1);
+ try manifest.int(u32, root_index);
+ try manifest.text("default");
+ try manifest.int(u32, 1);
+ try manifest.text("root");
+ try manifest.int(u32, root_index);
+ try manifest.text("term");
+ try manifest.text(fields[8]);
+ try manifest.int(u32, 1);
+ try manifest.int(u16, 5);
+ try manifest.text("arboricx");
+ try manifest.int(u32, 0);
+ var node_bytes = Writer{ .allocator = allocator };
+ defer node_bytes.deinit();
+ try node_bytes.int(u64, nodes.items.len);
+ for (nodes.items) |key| {
+ try node_bytes.int(u32, 1 + 4 * @as(u32, key.tag));
+ try node_bytes.int(u8, key.tag);
+ if (key.tag >= 1) try node_bytes.int(u32, key.left);
+ if (key.tag == 2) try node_bytes.int(u32, key.right);
+ }
+ var output = Writer{ .allocator = allocator };
+ errdefer output.deinit();
+ try output.raw("ARBORICX");
+ try output.int(u16, 1);
+ try output.int(u16, 0);
+ try output.int(u32, 2);
+ try output.int(u64, 0);
+ try output.int(u64, 32);
+ var offset: u64 = 96;
+ for ([_][]const u8{ manifest.bytes.items, node_bytes.bytes.items }, 1..) |data, kind| {
+ try output.int(u32, @intCast(kind));
+ try output.int(u16, 1);
+ try output.int(u16, 1);
+ try output.int(u16, 0);
+ try output.int(u16, 0);
+ try output.int(u64, offset);
+ try output.int(u64, data.len);
+ try output.int(u32, 0);
+ offset += data.len;
+ }
+ try output.raw(manifest.bytes.items);
+ try output.raw(node_bytes.bytes.items);
+ return output.bytes.toOwnedSlice(allocator);
+}
+
+pub fn digest(bytes: []const u8) [64]u8 {
+ var hash = std.crypto.hash.sha2.Sha256.init(.{});
+ hash.update(domain ++ "\x00");
+ hash.update(bytes);
+ var raw: [32]u8 = undefined;
+ hash.final(&raw);
+ return std.fmt.bytesToHex(raw, .lower);
+}
+
+const Reader = struct {
+ bytes: []const u8,
+ pos: usize = 0,
+ fn take(r: *Reader, n: usize) ![]const u8 {
+ if (n > r.bytes.len - r.pos) return error.InvalidData;
+ const data = r.bytes[r.pos..][0..n];
+ r.pos += n;
+ return data;
+ }
+ fn int(r: *Reader, comptime T: type) !T {
+ return std.mem.readInt(T, (try r.take(@sizeOf(T)))[0..@sizeOf(T)], .big);
+ }
+ fn text(r: *Reader) ![]const u8 {
+ const data = try r.take(try r.int(u32));
+ if (!std.unicode.utf8ValidateSlice(data)) return error.InvalidData;
+ return data;
+ }
+ fn expect(r: *Reader, data: []const u8) !void {
+ if (!std.mem.eql(u8, try r.take(data.len), data)) return error.InvalidData;
+ }
+ fn end(r: *Reader) !void {
+ if (r.pos != r.bytes.len) return error.InvalidData;
+ }
+};
+fn section(bytes: []const u8, start: u64, len: u64) ![]const u8 {
+ if (start > bytes.len or len > bytes.len - start) return error.InvalidData;
+ return bytes[@intCast(start)..][0..@intCast(len)];
+}
+
+/// General validated bundle reader, also shared by the C ABI. Single-tree IO
+/// additionally requires exactly one root/export named "root". Like Haskell,
+/// read accepts valid noncanonical layouts; only encode defines canonical bytes.
+pub fn load(arena: *Arena, bytes: []const u8, name: ?[]const u8, single: bool) !u32 {
+ var header = Reader{ .bytes = bytes };
+ try header.expect("ARBORICX");
+ if (try header.int(u16) != 1) return error.InvalidData;
+ _ = try header.int(u16);
+ const count = try header.int(u32);
+ _ = try header.int(u64);
+ const start = try header.int(u64);
+ var directory = Reader{ .bytes = try section(bytes, start, @as(u64, count) * 32) };
+ var manifest: ?[]const u8 = null;
+ var payload: ?[]const u8 = null;
+ for (0..count) |_| {
+ const kind = try directory.int(u32);
+ _ = try directory.int(u16);
+ const flags = try directory.int(u16);
+ const compression = try directory.int(u16);
+ _ = try directory.int(u16);
+ const offset = try directory.int(u64);
+ const len = try directory.int(u64);
+ _ = try directory.int(u32);
+ if (kind == 1 or kind == 2) {
+ if (compression != 0) return error.InvalidData;
+ const data = try section(bytes, offset, len);
+ if (kind == 1) {
+ if (manifest != null) return error.InvalidData;
+ manifest = data;
+ } else {
+ if (payload != null) return error.InvalidData;
+ payload = data;
+ }
+ } else if (flags & 1 != 0) return error.InvalidData;
+ }
+ var m = Reader{ .bytes = manifest orelse return error.InvalidData };
+ try m.expect("ARBMNFST");
+ if (try m.int(u16) != 1 or try m.int(u16) != 1) return error.InvalidData;
+ for (fields) |field| if (!std.mem.eql(u8, try m.text(), field)) return error.InvalidData;
+ if (try m.int(u32) != 0 or try m.int(u8) != 0) return error.InvalidData;
+ const root_count = try m.int(u32);
+ if (root_count == 0 or root_count > (m.bytes.len - m.pos) / 8 or (single and root_count != 1)) return error.InvalidData;
+ var max_root: u32 = 0;
+ var first: u32 = 0;
+ for (0..root_count) |i| {
+ const root = try m.int(u32);
+ _ = try m.text();
+ if (i == 0) first = root;
+ max_root = @max(max_root, root);
+ }
+ const export_count = try m.int(u32);
+ if (export_count == 0 or export_count > (m.bytes.len - m.pos) / 16 or (single and export_count != 1)) return error.InvalidData;
+ var names = std.StringHashMap(void).init(arena.allocator);
+ defer names.deinit();
+ var selected: ?u32 = if (name == null) first else null;
+ for (0..export_count) |_| {
+ const exported = try m.text();
+ const root = try m.int(u32);
+ if (exported.len == 0 or names.contains(exported)) return error.InvalidData;
+ try names.put(exported, {});
+ if (!std.mem.eql(u8, try m.text(), "term") or !std.mem.eql(u8, try m.text(), fields[8])) return error.InvalidData;
+ if (single and (!std.mem.eql(u8, exported, "root") or root != first)) return error.InvalidData;
+ if (name) |requested| {
+ if (std.mem.eql(u8, requested, exported)) selected = root;
+ }
+ max_root = @max(max_root, root);
+ }
+ var known_metadata: u8 = 0;
+ for (0..2) |group| {
+ const tlvs = try m.int(u32);
+ if (tlvs > (m.bytes.len - m.pos) / 6) return error.InvalidData;
+ for (0..tlvs) |_| {
+ const tag = try m.int(u16);
+ const value = try m.take(try m.int(u32));
+ if (group == 0 and tag >= 1 and tag <= 5) {
+ const mask = @as(u8, 1) << @as(u3, @intCast(tag));
+ if (known_metadata & mask == 0 and !std.unicode.utf8ValidateSlice(value)) return error.InvalidData;
+ known_metadata |= mask;
+ }
+ }
+ }
+ try m.end();
+ var n = Reader{ .bytes = payload orelse return error.InvalidData };
+ const node_count = try n.int(u64);
+ if (node_count == 0 or node_count > std.math.maxInt(u32) or node_count > (n.bytes.len - n.pos) / 5 or max_root >= node_count) return error.InvalidData;
+ const ids = try arena.allocator.alloc(u32, @intCast(node_count));
+ defer arena.allocator.free(ids);
+ var keys = std.AutoHashMap(Key, void).init(arena.allocator);
+ defer keys.deinit();
+ for (ids, 0..) |*id, i| {
+ const size = try n.int(u32);
+ const tag = try n.int(u8);
+ if (tag > 2 or size != 1 + 4 * @as(u32, tag)) return error.InvalidData;
+ var key = Key{ .tag = tag };
+ if (tag >= 1) {
+ key.left = try n.int(u32);
+ if (key.left >= i) return error.InvalidData;
+ }
+ if (tag == 2) {
+ key.right = try n.int(u32);
+ if (key.right >= i) return error.InvalidData;
+ }
+ if (keys.contains(key)) return error.InvalidData;
+ try keys.put(key, {});
+ id.* = try arena.alloc(switch (tag) {
+ 0 => .leaf,
+ 1 => .{ .stem = .{ .child = ids[key.left] } },
+ 2 => .{ .fork = .{ .left = ids[key.left], .right = ids[key.right] } },
+ else => unreachable,
+ });
+ }
+ try n.end();
+ return ids[selected orelse return error.ExportNotFound];
+}
+
+test "canonical leaf matches independent writer profile and domain digest" {
+ var arena = Arena.init(std.testing.allocator);
+ defer arena.deinit();
+ const bytes = try encode(&arena, try arena.alloc(.leaf));
+ defer arena.allocator.free(bytes);
+ try std.testing.expectEqual(@as(usize, 431), bytes.len);
+ // Independently assembled by test/executable_object.py's wire profile.
+ try std.testing.expectEqualStrings("6f93378feaa8bf5ab7cefc071175a654c871bfd53f165d7d96795fb2b38483f1", &digest(bytes));
+ const decoded = try load(&arena, bytes, "root", true);
+ try std.testing.expect(arena.get(decoded).* == .leaf);
+}
+
+test "physical sharing is only an acceleration hint, and lazy children are serialized" {
+ var arena = Arena.init(std.testing.allocator);
+ defer arena.deinit();
+ const leaf = try arena.alloc(.leaf);
+ const child = try arena.alloc(.{ .stem = .{ .child = leaf } });
+ const shared = try arena.alloc(.{ .fork = .{ .left = child, .right = child } });
+ const other_leaf = try arena.alloc(.leaf);
+ const other_child = try arena.alloc(.{ .stem = .{ .child = other_leaf } });
+ const constant = try arena.alloc(.{ .fork = .{ .left = other_leaf, .right = other_child } });
+ const delayed = try arena.alloc(.{ .app = .{ .func = constant, .arg = other_leaf } });
+ const copied = try arena.alloc(.{ .fork = .{ .left = child, .right = delayed } });
+ const a = try encode(&arena, shared);
+ defer arena.allocator.free(a);
+ arena.gc_enabled = true;
+ arena.gc_interval = 1;
+ const b = try encode(&arena, copied);
+ defer arena.allocator.free(b);
+ try std.testing.expectEqualSlices(u8, a, b);
+ try std.testing.expectEqual(@as(usize, 0), arena.roots.items.len);
+ const decoded = try load(&arena, a, "root", true);
+ try std.testing.expectEqual(arena.get(decoded).fork.left, arena.get(decoded).fork.right);
+}
+
+test "deep exponentially expanded trees use iterative DAG traversal" {
+ var arena = Arena.init(std.testing.allocator);
+ defer arena.deinit();
+ var root = try arena.alloc(.leaf);
+ for (0..100000) |_| root = try arena.alloc(.{ .fork = .{ .left = root, .right = root } });
+ const bytes = try encode(&arena, root);
+ defer arena.allocator.free(bytes);
+ try std.testing.expectEqual(@as(usize, 431 + 13 * 100000), bytes.len);
+ const copied = try load(&arena, bytes, "root", true);
+ const again = try encode(&arena, copied);
+ defer arena.allocator.free(again);
+ try std.testing.expectEqualSlices(u8, bytes, again);
+}
+
+test "bundle parser rejects every truncated prefix and safely handles byte mutations" {
+ var arena = Arena.init(std.testing.allocator);
+ defer arena.deinit();
+ const bytes = try encode(&arena, try arena.alloc(.leaf));
+ defer arena.allocator.free(bytes);
+ for (0..bytes.len) |len| try std.testing.expectError(error.InvalidData, load(&arena, bytes[0..len], "root", true));
+ for (bytes) |*byte| {
+ byte.* ^= 0xff;
+ _ = load(&arena, bytes, "root", true) catch |err| switch (err) {
+ error.InvalidData, error.ExportNotFound => 0,
+ else => return err,
+ };
+ byte.* ^= 0xff;
+ }
+ // Section count, offset and size arithmetic must not overflow or slice
+ // outside input, even when the supplied bytes themselves are authenticated.
+ for ([_]usize{ 24, 44, 52, 76, 84 }) |offset| {
+ const old = std.mem.readInt(u64, bytes[offset..][0..8], .big);
+ std.mem.writeInt(u64, bytes[offset..][0..8], std.math.maxInt(u64), .big);
+ try std.testing.expectError(error.InvalidData, load(&arena, bytes, "root", true));
+ std.mem.writeInt(u64, bytes[offset..][0..8], old, .big);
+ }
+}
+
+fn allocationProbe(allocator: std.mem.Allocator) !void {
+ var arena = Arena.init(allocator);
+ defer arena.deinit();
+ const leaf = try arena.alloc(.leaf);
+ const root = try arena.alloc(.{ .fork = .{ .left = leaf, .right = leaf } });
+ const bytes = try encode(&arena, root);
+ defer allocator.free(bytes);
+ _ = try load(&arena, bytes, "root", true);
+}
+
+test "indexed codec allocation failures unwind all scratch storage" {
+ try std.testing.checkAllAllocationFailures(std.testing.allocator, allocationProbe, .{});
+}
+
+test "reader rejects duplicate constructors, forward edges and mismatched single roots" {
+ var arena = Arena.init(std.testing.allocator);
+ defer arena.deinit();
+ const leaf = try arena.alloc(.leaf);
+ const root = try arena.alloc(.{ .stem = .{ .child = leaf } });
+ const bytes = try encode(&arena, root);
+ defer arena.allocator.free(bytes);
+ const node_offset: usize = @intCast(std.mem.readInt(u64, bytes[76..84], .big));
+ const child_offset = node_offset + 8 + 5 + 5;
+ std.mem.writeInt(u32, bytes[child_offset..][0..4], 1, .big);
+ try std.testing.expectError(error.InvalidData, load(&arena, bytes, "root", true));
+ std.mem.writeInt(u32, bytes[child_offset..][0..4], 0, .big);
+ // Valid general bundle with unequal root/export indices is not a valid
+ // name-neutral single-tree object, even when both indices are in bounds.
+ const export_offset = std.mem.indexOf(u8, bytes, "root").? + 4;
+ std.mem.writeInt(u32, bytes[export_offset..][0..4], 0, .big);
+ try std.testing.expectError(error.InvalidData, load(&arena, bytes, "root", true));
+ _ = try load(&arena, bytes, "root", false);
+ const one = try encode(&arena, leaf);
+ defer arena.allocator.free(one);
+ const duplicate = try arena.allocator.alloc(u8, one.len + 5);
+ defer arena.allocator.free(duplicate);
+ @memcpy(duplicate[0..one.len], one);
+ @memcpy(duplicate[one.len..], "\x00\x00\x00\x01\x00");
+ std.mem.writeInt(u64, duplicate[84..92], 18, .big);
+ std.mem.writeInt(u64, duplicate[node_offset..][0..8], 2, .big);
+ try std.testing.expectError(error.InvalidData, load(&arena, duplicate, "root", true));
+}
diff --git a/ext/zig/src/io_driver.zig b/ext/zig/src/io_driver.zig
@@ -1,10 +1,19 @@
const std = @import("std");
const Arena = @import("arena.zig").Arena;
const codecs = @import("codecs.zig");
+const source_text = @import("source_text.zig");
+const indexed = @import("indexed.zig");
const reduce = @import("reduce.zig");
const tree = @import("tree.zig");
const c = @cImport({
+ // Import declarations, not glibc's fortified C inline wrappers. With the
+ // pinned Zig/glibc, optimized translate-c diagnoses the variadic open/openat
+ // wrapper bodies even though this driver calls uv_fs_open, not libc open.
+ // This is local to header translation: libuv and C callers retain their
+ // normal build hardening, and Zig ReleaseSafe retains runtime checks.
+ @cUndef("_FORTIFY_SOURCE");
+ @cDefine("_FORTIFY_SOURCE", "0");
@cInclude("uv.h");
});
@@ -18,6 +27,11 @@ pub const ActionTag = enum(u8) {
putStr = 10,
getLine = 11,
readFile = 20,
+ readSourceFile = 37,
+ encodeSourceText = 38,
+ readIndexedTree = 50,
+ hashIndexedTree = 51,
+ writeIndexedTreeIfAbsent = 52,
writeFile = 21,
writeBytes = 22,
renameFile = 24,
@@ -44,6 +58,11 @@ pub const Action = union(ActionTag) {
putStr: u32,
getLine,
readFile: u32,
+ readSourceFile: u32,
+ encodeSourceText: u32,
+ readIndexedTree: struct { path: u32, digest: u32 },
+ hashIndexedTree: u32,
+ writeIndexedTreeIfAbsent: struct { path: u32, digest: u32, tree: u32 },
writeFile: FileWrite,
writeBytes: FileWrite,
renameFile: struct { old: u32, new: u32 },
@@ -88,8 +107,8 @@ pub const IOPerms = struct {
// ---------------------------------------------------------------------------
pub fn isIOSentinel(arena: *Arena, root: u32) !?u32 {
- const node = arena.get(root);
- if (node.* != .fork) return null;
+ const node = arena.get(try reduce.reduce(root, arena, 1_000_000)).*;
+ if (node != .fork) return null;
const sentinel = node.fork.left;
const rest = node.fork.right;
@@ -103,8 +122,8 @@ pub fn isIOSentinel(arena: *Arena, root: u32) !?u32 {
if (sentinel_str == null) return null;
if (!std.mem.eql(u8, sentinel_str.?, "tricuIO")) return null;
- const rest_node = arena.get(rest);
- if (rest_node.* != .fork) return null;
+ const rest_node = arena.get(try reduce.reduce(rest, arena, 1_000_000)).*;
+ if (rest_node != .fork) return null;
const version_num = try codecs.toNumber(arena, rest_node.fork.left);
if (version_num == null or version_num.? != 1) return null;
@@ -117,8 +136,8 @@ pub fn isIOSentinel(arena: *Arena, root: u32) !?u32 {
// ---------------------------------------------------------------------------
pub fn decodeAction(arena: *Arena, root: u32) !?Action {
- const node = arena.get(root);
- if (node.* != .fork) return null;
+ const node = arena.get(root).*;
+ if (node != .fork) return null;
const tag_num = try codecs.toNumber(arena, node.fork.left);
if (tag_num == null) return null;
@@ -129,6 +148,11 @@ pub fn decodeAction(arena: *Arena, root: u32) !?Action {
10 => .putStr,
11 => .getLine,
20 => .readFile,
+ 37 => .readSourceFile,
+ 38 => .encodeSourceText,
+ 50 => .readIndexedTree,
+ 51 => .hashIndexedTree,
+ 52 => .writeIndexedTreeIfAbsent,
21 => .writeFile,
22 => .writeBytes,
24 => .renameFile,
@@ -160,6 +184,17 @@ pub fn decodeAction(arena: *Arena, root: u32) !?Action {
.putStr => Action{ .putStr = payload },
.getLine => Action.getLine,
.readFile => Action{ .readFile = payload },
+ .readSourceFile => Action{ .readSourceFile = payload },
+ .encodeSourceText => Action{ .encodeSourceText = payload },
+ .hashIndexedTree => Action{ .hashIndexedTree = payload },
+ .readIndexedTree, .writeIndexedTreeIfAbsent => blk: {
+ const pair = arena.get(try reduce.reduce(payload, arena, 1_000_000)).*;
+ if (pair != .fork) return null;
+ if (tag == .readIndexedTree) break :blk .{ .readIndexedTree = .{ .path = pair.fork.left, .digest = pair.fork.right } };
+ const rest = arena.get(try reduce.reduce(pair.fork.right, arena, 1_000_000)).*;
+ if (rest != .fork) return null;
+ break :blk .{ .writeIndexedTreeIfAbsent = .{ .path = pair.fork.left, .digest = rest.fork.left, .tree = rest.fork.right } };
+ },
.writeFile, .writeBytes, .writeBytesIfAbsent, .atomicWriteBytes, .renameFile => blk: {
const pair = arena.get(try reduce.reduce(payload, arena, 1_000_000));
if (pair.* != .fork) return null;
@@ -206,11 +241,32 @@ pub fn makeOkResult(arena: *Arena, val: u32) !u32 {
}
pub fn makeErrString(arena: *Arena, message: []const u8) !u32 {
- const text = try codecs.ofString(arena, message);
+ return makeErrValue(arena, try codecs.ofString(arena, message));
+}
+
+fn makeErrValue(arena: *Arena, text: u32) !u32 {
const value = try arena.alloc(.{ .fork = .{ .left = text, .right = try arena.alloc(.leaf) } });
return try arena.alloc(.{ .fork = .{ .left = try arena.alloc(.leaf), .right = value } });
}
+fn sourceReadError(arena: *Arena, path: []const u8, reason: []const u8) !u32 {
+ const message = try std.fmt.allocPrint(arena.allocator, "{s}: {s}", .{ path, reason });
+ defer arena.allocator.free(message);
+ return makeErrValue(arena, try source_text.fromUtf8(arena, message));
+}
+
+fn sourceReadResult(arena: *Arena, path: []const u8, bytes: []const u8) !u32 {
+ const scalars = source_text.fromUtf8(arena, bytes) catch |err| switch (err) {
+ error.InvalidUtf8 => return sourceReadError(arena, path, "invalid UTF-8 source"),
+ else => return err,
+ };
+ if (std.mem.startsWith(u8, bytes, "\xef\xbb\xbf"))
+ return sourceReadError(arena, path, "UTF-8 source BOM is not permitted");
+ const raw = try codecs.ofBytes(arena, bytes);
+ const pair = try arena.alloc(.{ .fork = .{ .left = raw, .right = scalars } });
+ return makeOkResult(arena, pair);
+}
+
pub fn makeErrResult(arena: *Arena, code: u64) !u32 {
// The portable IO protocol carries text errors, matching the Haskell
// driver; numeric values remain internal filesystem status codes.
@@ -222,14 +278,37 @@ pub fn makeErrResult(arena: *Arena, code: u64) !u32 {
// both link (no replace) and rename (replace) are atomic on that filesystem.
const FsWriteResult = union(enum) { written: bool, failure: u64 };
+const FsReadResult = union(enum) { bytes: []u8, failure: u64 };
+
+fn readFs(loop: *c.uv_loop_t, gpa: std.mem.Allocator, path: [:0]const u8) !FsReadResult {
+ var req: c.uv_fs_t = undefined;
+ const fd = c.uv_fs_open(loop, &req, path.ptr, c.O_RDONLY, 0, null);
+ c.uv_fs_req_cleanup(&req);
+ if (fd < 0) return .{ .failure = mapUvErr(fd) };
+ defer {
+ _ = c.uv_fs_close(loop, &req, fd, null);
+ c.uv_fs_req_cleanup(&req);
+ }
+ var bytes: std.ArrayList(u8) = .empty;
+ defer bytes.deinit(gpa);
+ var buffer: [4096]u8 = undefined;
+ var uv_buffer = c.uv_buf_init(&buffer, buffer.len);
+ while (true) {
+ const count = c.uv_fs_read(loop, &req, fd, &uv_buffer, 1, -1, null);
+ c.uv_fs_req_cleanup(&req);
+ if (count < 0) return .{ .failure = mapUvErr(count) };
+ if (count == 0) return .{ .bytes = try bytes.toOwnedSlice(gpa) };
+ try bytes.appendSlice(gpa, buffer[0..@intCast(count)]);
+ }
+}
+
fn uvFileError(rc: c_int) u64 {
return mapUvErr(rc);
}
fn syncFsPath(loop: *c.uv_loop_t, path: [*:0]const u8, directory: bool) ?u64 {
var open_req: c.uv_fs_t = undefined;
- const fd = c.uv_fs_open(loop, &open_req, path,
- c.O_RDONLY | (if (directory) @as(c_int, c.O_DIRECTORY) else 0), 0, null);
+ const fd = c.uv_fs_open(loop, &open_req, path, c.O_RDONLY | (if (directory) @as(c_int, c.O_DIRECTORY) else 0), 0, null);
c.uv_fs_req_cleanup(&open_req);
if (fd < 0) return uvFileError(fd);
var sync_req: c.uv_fs_t = undefined;
@@ -338,18 +417,6 @@ const Runtime = struct {
};
// ---------------------------------------------------------------------------
-// Helper: reduce a term in a scratch arena and copy the result back
-// ---------------------------------------------------------------------------
-
-fn reduceInScratch(gpa: std.mem.Allocator, arena: *Arena, term: u32) !u32 {
- var scratch = Arena.init(gpa);
- defer scratch.deinit();
- const scratch_root = try tree.copyTree(arena.nodes.items, &scratch, term);
- const scratch_result = try reduce.reduce(scratch_root, &scratch, std.math.maxInt(u64));
- return try tree.copyTree(scratch.nodes.items, arena, scratch_result);
-}
-
-// ---------------------------------------------------------------------------
// Task
// ---------------------------------------------------------------------------
@@ -443,6 +510,7 @@ const Scheduler = struct {
const id = self.next_id;
self.next_id += 1;
const task = try Task.init(self.gpa, id, parent, env, state, current);
+ errdefer task.deinit(self.gpa);
try self.tasks.append(self.gpa, task);
return task;
}
@@ -490,8 +558,98 @@ const Scheduler = struct {
try self.completeIO(task, try makeErrString(self.arena, fsErrorMessage(err)));
}
+ // Like the native CAS operations, source reads use synchronous libuv calls.
+ // This avoids entering callback/reducer code while local trees are unrooted;
+ // allocation and filesystem failures propagate normally to the host.
+ // A source read occupies one scheduler turn (it is not a streaming action).
+ fn readSource(self: *Scheduler, task: *Task, path_tree: u32) !void {
+ const path = source_text.toUtf8(self.arena, path_tree) catch |err| switch (err) {
+ error.InvalidScalarList => return self.fsReply(task, ERR_INVALID_STRING),
+ else => return err,
+ };
+ defer self.arena.allocator.free(path);
+ if (!self.perms.allow_read_all) return self.fsReply(task, ERR_POLICY_DENY);
+ if (std.mem.indexOfScalar(u8, path, 0) != null) return self.fsReply(task, ERR_INVALID_STRING);
+ const path_z = try self.gpa.dupeZ(u8, path);
+ defer self.gpa.free(path_z);
+ const read = try readFs(self.loop, self.gpa, path_z);
+ switch (read) {
+ .failure => |code| try self.fsReply(task, code),
+ .bytes => |bytes| {
+ defer self.gpa.free(bytes);
+ try self.completeIO(task, try sourceReadResult(self.arena, path, bytes));
+ },
+ }
+ }
+
+ // IO protocol adapters: codec and filesystem helpers know nothing about
+ // compiler manifests, cache aliases, path selection or publication order.
+ fn indexedIO(self: *Scheduler, task: *Task, path_tree: u32, expected_tree: u32, value: ?u32) !void {
+ const path = source_text.toUtf8(self.arena, path_tree) catch |err| switch (err) {
+ error.InvalidScalarList => return self.fsReply(task, ERR_INVALID_STRING),
+ else => return err,
+ };
+ defer self.arena.allocator.free(path);
+ if (std.mem.indexOfScalar(u8, path, 0) != null) return self.fsReply(task, ERR_INVALID_STRING);
+ const expected = source_text.toUtf8(self.arena, expected_tree) catch |err| switch (err) {
+ error.InvalidScalarList => return self.completeIO(task, try makeErrString(self.arena, "invalid CAS digest")),
+ else => return err,
+ };
+ defer self.arena.allocator.free(expected);
+ if (expected.len != 64) return self.completeIO(task, try makeErrString(self.arena, "invalid CAS digest"));
+ for (expected) |ch| if (!(ch >= '0' and ch <= '9' or ch >= 'a' and ch <= 'f'))
+ return self.completeIO(task, try makeErrString(self.arena, "invalid CAS digest"));
+ if (!self.perms.allow_read_all or (value != null and !self.perms.allow_write_all)) return self.fsReply(task, ERR_POLICY_DENY);
+ const path_z = try self.gpa.dupeZ(u8, path);
+ defer self.gpa.free(path_z);
+ var encoded: ?[]u8 = null;
+ defer if (encoded) |bytes| self.arena.allocator.free(bytes);
+ var fresh = false;
+ if (value) |tree_value| {
+ encoded = try indexed.encode(self.arena, tree_value);
+ if (!std.mem.eql(u8, &indexed.digest(encoded.?), expected))
+ return self.completeIO(task, try makeErrString(self.arena, "CAS digest mismatch"));
+ switch (try writeAtomicFs(self.loop, self.gpa, path_z, encoded.?, true)) {
+ .failure => |code| return self.fsReply(task, code),
+ .written => |written| fresh = written,
+ }
+ }
+ // Authenticate before decode; for publication compare the actual winner
+ // byte-for-byte, including after an EEXIST race. Never overwrite it.
+ switch (try readFs(self.loop, self.gpa, path_z)) {
+ .failure => |code| return self.fsReply(task, code),
+ .bytes => |bytes| {
+ defer self.gpa.free(bytes);
+ const result = if (encoded) |ours| blk: {
+ if (!std.mem.eql(u8, bytes, ours)) return self.completeIO(task, try makeErrString(self.arena, "CAS object exists with different bytes"));
+ break :blk if (fresh) try self.arena.alloc(.{ .stem = .{ .child = try self.arena.alloc(.leaf) } }) else try self.arena.alloc(.leaf);
+ } else blk: {
+ if (!std.mem.eql(u8, &indexed.digest(bytes), expected)) return self.completeIO(task, try makeErrString(self.arena, "CAS digest mismatch"));
+ break :blk indexed.load(self.arena, bytes, "root", true) catch |err| switch (err) {
+ error.OutOfMemory => return err,
+ else => return self.completeIO(task, try makeErrString(self.arena, "invalid indexed tree")),
+ };
+ };
+ try self.completeIO(task, try makeOkResult(self.arena, result));
+ },
+ }
+ }
+
fn stepTask(self: *Scheduler, task: *Task) !void {
- const reduced = try reduceInScratch(self.gpa, self.arena, task.current);
+ // Snapshot every scheduler-owned node for this turn. A turn may force
+ // repeatedly; frames popped during it remain rooted until it completes.
+ // Callbacks only allocate/link trees and never enter the reducer.
+ const base = self.arena.roots.items.len;
+ defer self.arena.roots.shrinkRetainingCapacity(base);
+ for (self.tasks.items) |t| {
+ try self.arena.roots.appendSlice(self.gpa, &.{ t.current, t.runtime.env, t.runtime.state });
+ if (t.result) |result| try self.arena.roots.append(self.gpa, result);
+ for (t.frames.items) |frame| switch (frame) {
+ .bind => |k| try self.arena.roots.append(self.gpa, k),
+ .local => |env| try self.arena.roots.append(self.gpa, env),
+ };
+ }
+ const reduced = try reduce.reduce(task.current, self.arena, std.math.maxInt(u64));
const decoded = try decodeAction(self.arena, reduced);
if (decoded == null) {
@@ -502,7 +660,11 @@ const Scheduler = struct {
try self.completeTask(task);
return;
}
- const err = try makeErrResult(self.arena, ERR_INVALID_ACTION);
+ const invalid = self.arena.get(reduced).*;
+ const tag = if (invalid == .fork) try codecs.toNumber(self.arena, invalid.fork.left) else null;
+ var diagnostic: [96]u8 = undefined;
+ const message = if (tag) |n| try std.fmt.bufPrint(&diagnostic, "invalid or unsupported IO action tag {d}", .{n}) else "invalid action";
+ const err = try makeErrString(self.arena, message);
if (try task.finishValue(self.arena, err)) {
try self.completeTask(task);
} else {
@@ -570,6 +732,28 @@ const Scheduler = struct {
}
},
+ .readSourceFile => |path_tree| try self.readSource(task, path_tree),
+ .readIndexedTree => |read| try self.indexedIO(task, read.path, read.digest, null),
+ .writeIndexedTreeIfAbsent => |write| try self.indexedIO(task, write.path, write.digest, write.tree),
+ .hashIndexedTree => |value| {
+ const bytes = try indexed.encode(self.arena, value);
+ defer self.arena.allocator.free(bytes);
+ const hex = indexed.digest(bytes);
+ try self.completeIO(task, try makeOkResult(self.arena, try codecs.ofString(self.arena, &hex)));
+ },
+
+ .encodeSourceText => |text| {
+ const bytes = source_text.toUtf8(self.arena, text) catch |err| switch (err) {
+ error.InvalidScalarList => {
+ try self.completeIO(task, try makeErrString(self.arena, "invalid source scalar list"));
+ return;
+ },
+ else => return err,
+ };
+ defer self.arena.allocator.free(bytes);
+ try self.completeIO(task, try makeOkResult(self.arena, try codecs.ofBytes(self.arena, bytes)));
+ },
+
.readFile => |path_tree| {
const path = try codecs.toString(self.arena, path_tree) orelse {
const err = try makeErrResult(self.arena, ERR_INVALID_STRING);
@@ -683,9 +867,7 @@ const Scheduler = struct {
switch (published) {
.failure => |code| try self.fsReply(task, code),
.written => |fresh| {
- const value = if (action == .writeBytes) try self.arena.alloc(.leaf)
- else if (fresh) try self.arena.alloc(.{ .stem = .{ .child = try self.arena.alloc(.leaf) } })
- else try self.arena.alloc(.leaf);
+ const value = if (action == .writeBytes) try self.arena.alloc(.leaf) else if (fresh) try self.arena.alloc(.{ .stem = .{ .child = try self.arena.alloc(.leaf) } }) else try self.arena.alloc(.leaf);
try self.completeIO(task, try makeOkResult(self.arena, value));
},
}
@@ -707,11 +889,11 @@ const Scheduler = struct {
const path_z = try self.gpa.dupeZ(u8, path);
defer self.gpa.free(path_z);
var req: c.uv_fs_t = undefined;
- const rc = if (is_create) c.uv_fs_mkdir(self.loop, &req, path_z.ptr, 0o755, null)
- else c.uv_fs_stat(self.loop, &req, path_z.ptr, null);
+ const rc = if (is_create) c.uv_fs_mkdir(self.loop, &req, path_z.ptr, 0o755, null) else c.uv_fs_stat(self.loop, &req, path_z.ptr, null);
c.uv_fs_req_cleanup(&req);
if (rc < 0 and (is_create and rc != c.UV_EEXIST or
- !is_create and rc != c.UV_ENOENT)) {
+ !is_create and rc != c.UV_ENOENT))
+ {
try self.fsReply(task, uvFileError(rc));
return;
}
@@ -735,9 +917,7 @@ const Scheduler = struct {
return;
}
}
- const value = if (is_create) try self.arena.alloc(.leaf)
- else if (rc == 0) try self.arena.alloc(.{ .stem = .{ .child = try self.arena.alloc(.leaf) } })
- else try self.arena.alloc(.leaf);
+ const value = if (is_create) try self.arena.alloc(.leaf) else if (rc == 0) try self.arena.alloc(.{ .stem = .{ .child = try self.arena.alloc(.leaf) } }) else try self.arena.alloc(.leaf);
try self.completeIO(task, try makeOkResult(self.arena, value));
},
@@ -788,7 +968,7 @@ const Scheduler = struct {
},
.local => |loc| {
- const new_env = try reduceInScratch(self.gpa, self.arena, try self.arena.alloc(.{ .app = .{ .func = loc.f, .arg = task.runtime.env } }));
+ const new_env = try reduce.reduce(try self.arena.alloc(.{ .app = .{ .func = loc.f, .arg = task.runtime.env } }), self.arena, std.math.maxInt(u64));
try task.frames.append(self.gpa, .{ .local = task.runtime.env });
task.runtime.env = new_env;
task.current = loc.action;
@@ -1121,6 +1301,9 @@ fn sleep_cb(handle: [*c]c.uv_timer_t) callconv(.c) void {
// ---------------------------------------------------------------------------
pub fn runIO(gpa: std.mem.Allocator, arena: *Arena, program: u32, perms: IOPerms) !u32 {
+ const base = arena.roots.items.len;
+ defer arena.roots.shrinkRetainingCapacity(base);
+ try arena.roots.append(gpa, program);
const action_tree = try isIOSentinel(arena, program) orelse {
return error.InvalidIOSentinel;
};
@@ -1141,3 +1324,269 @@ pub fn runIO(gpa: std.mem.Allocator, arena: *Arena, program: u32, perms: IOPerms
// Return the main task's result
return main_task.result orelse program;
}
+
+test "scheduler safepoint roots include blocked tasks, frames, environment, state and results" {
+ const testing = std.testing;
+ var arena = Arena.init(testing.allocator);
+ defer arena.deinit();
+ var loop: c.uv_loop_t = undefined;
+ try testing.expectEqual(@as(c_int, 0), c.uv_loop_init(&loop));
+ defer _ = c.uv_loop_close(&loop);
+ var scheduler = try Scheduler.init(testing.allocator, &loop, &arena, .{});
+ defer scheduler.deinit();
+ const leaf = try arena.alloc(.leaf);
+ const external = try codecs.ofNumber(&arena, 99);
+ try arena.roots.append(arena.allocator, external);
+ const value = try codecs.ofNumber(&arena, 42);
+ const action = try makePure(&arena, value);
+ const task = try scheduler.createTask(null, leaf, leaf, action);
+ const blocked = try scheduler.createTask(null, try codecs.ofNumber(&arena, 17), try codecs.ofNumber(&arena, 23), leaf);
+ blocked.status = .blocked;
+ blocked.result = try codecs.ofNumber(&arena, 31);
+ const saved_env = try codecs.ofNumber(&arena, 47);
+ const continuation = try codecs.ofNumber(&arena, 53);
+ try blocked.frames.append(arena.allocator, .{ .local = saved_env });
+ try blocked.frames.append(arena.allocator, .{ .bind = continuation });
+ for (0..1000) |_| _ = try arena.alloc(.leaf);
+ arena.gc_enabled = true;
+ arena.gc_interval = 1;
+ try scheduler.stepTask(task);
+ try testing.expect(arena.collections > 0);
+ try testing.expect(arena.free.items.len >= 1000);
+ try testing.expectEqual(@as(usize, 1), arena.roots.items.len);
+ // Observations below are outside a scheduler turn; explicitly hold its
+ // roots by stopping automatic collection, not by accidentally relying on
+ // an allocation threshold.
+ arena.gc_enabled = false;
+ const ids = [_]u32{ external, task.result.?, blocked.runtime.env, blocked.runtime.state, blocked.result.?, saved_env, continuation };
+ const expected = [_]u64{ 99, 42, 17, 23, 31, 47, 53 };
+ for (ids, expected) |id, n| try testing.expectEqual(n, (try codecs.toNumber(&arena, id)).?);
+}
+
+test "IO sentinel forces delayed outer and version/action pair" {
+ const testing = std.testing;
+ var arena = Arena.init(testing.allocator);
+ defer arena.deinit();
+ const leaf = try arena.alloc(.leaf);
+ const value = try codecs.ofNumber(&arena, 42);
+ const action = try makePure(&arena, value);
+ const version = try codecs.ofNumber(&arena, 1);
+ const pair = try arena.alloc(.{ .fork = .{ .left = version, .right = action } });
+ const k_pair = try arena.alloc(.{ .fork = .{ .left = leaf, .right = pair } });
+ const delayed_pair = try arena.alloc(.{ .app = .{ .func = k_pair, .arg = leaf } });
+ const marker = try codecs.ofString(&arena, "tricuIO");
+ const sentinel = try arena.alloc(.{ .fork = .{ .left = marker, .right = delayed_pair } });
+ const k_sentinel = try arena.alloc(.{ .fork = .{ .left = leaf, .right = sentinel } });
+ const program = try arena.alloc(.{ .app = .{ .func = k_sentinel, .arg = leaf } });
+ arena.gc_enabled = true;
+ arena.gc_interval = 1;
+ const result = try runIO(arena.allocator, &arena, program, .{});
+ arena.gc_enabled = false; // result is held locally, outside a registered scope
+ try testing.expectEqual(@as(u64, 42), (try codecs.toNumber(&arena, result)).?);
+ try testing.expectEqual(@as(usize, 0), arena.roots.items.len);
+}
+
+fn testAction(arena: *Arena, tag: u64, payload: u32, perms: IOPerms) !codecs.UnwrapResult {
+ const action = try arena.alloc(.{ .fork = .{ .left = try codecs.ofNumber(arena, tag), .right = payload } });
+ const rest = try arena.alloc(.{ .fork = .{ .left = try codecs.ofNumber(arena, 1), .right = action } });
+ const program = try arena.alloc(.{ .fork = .{ .left = try codecs.ofString(arena, "tricuIO"), .right = rest } });
+ arena.gc_enabled = true;
+ arena.gc_interval = 1;
+ const result = try runIO(arena.allocator, arena, program, perms);
+ arena.gc_enabled = false; // observations are local, outside the scheduler
+ return (try codecs.unwrapResult(arena, result)).?;
+}
+
+fn expectErrorText(arena: *Arena, reply: codecs.UnwrapResult, text: []const u8) !void {
+ try std.testing.expect(!reply.ok);
+ const message = try source_text.toUtf8(arena, reply.value);
+ defer arena.allocator.free(message);
+ try std.testing.expect(std.mem.indexOf(u8, message, text) != null);
+}
+
+test "encodeSourceText is strict, lazy, permission-free and distinct from byte codecs" {
+ var arena = Arena.init(std.testing.allocator);
+ defer arena.deinit();
+ const expected = "\u{feff}aé😀\x00\r\ne\u{301}";
+ const list = try source_text.fromUtf8(&arena, expected);
+ const leaf = try arena.alloc(.leaf);
+ const constant = try arena.alloc(.{ .fork = .{ .left = leaf, .right = list } });
+ const delayed = try arena.alloc(.{ .app = .{ .func = constant, .arg = leaf } });
+ const result = try testAction(&arena, 38, delayed, .{});
+ try std.testing.expect(result.ok);
+ const bytes = (try codecs.toBytes(&arena, result.value)).?;
+ defer arena.allocator.free(bytes);
+ try std.testing.expectEqualStrings(expected, bytes);
+ for ([_]u64{ 0xd800, 0x110000 }) |n| {
+ const bad = try codecs.ofList(&arena, &.{try codecs.ofNumber(&arena, n)});
+ try expectErrorText(&arena, try testAction(&arena, 38, bad, .{}), "invalid source scalar list");
+ }
+ // Noncanonical zero has the right numeric value, but is not a scalar tree.
+ const zero = try arena.alloc(.leaf);
+ const redundant = try arena.alloc(.{ .fork = .{ .left = zero, .right = zero } });
+ const bad = try codecs.ofList(&arena, &.{redundant});
+ try expectErrorText(&arena, try testAction(&arena, 38, bad, .{}), "invalid source scalar list");
+}
+
+test "readSourceFile preserves bytes and scalars, rejects malformed input, and enforces read permission" {
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const directory = try tmp.dir.realPathFileAlloc(std.testing.io, ".", std.testing.allocator);
+ defer std.testing.allocator.free(directory);
+ const path = try std.fmt.allocPrint(std.testing.allocator, "{s}/é😀.tri", .{directory});
+ defer std.testing.allocator.free(path);
+ var arena = Arena.init(std.testing.allocator);
+ defer arena.deinit();
+ var bytes: std.ArrayList(u8) = .empty;
+ defer bytes.deinit(std.testing.allocator);
+ try bytes.appendNTimes(std.testing.allocator, 'a', 4095);
+ try bytes.appendSlice(std.testing.allocator, "😀\r\né\x00\u{feff}e\u{301}");
+ for ([_][]const u8{ bytes.items, "" }) |contents| {
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "é😀.tri", .data = contents });
+ const reply = try testAction(&arena, 37, try source_text.fromUtf8(&arena, path), .{ .allow_read_all = true });
+ try std.testing.expect(reply.ok);
+ const pair = arena.get(reply.value).fork;
+ const raw = (try codecs.toBytes(&arena, pair.left)).?;
+ defer arena.allocator.free(raw);
+ const text = try source_text.toUtf8(&arena, pair.right);
+ defer arena.allocator.free(text);
+ try std.testing.expectEqualStrings(contents, raw);
+ try std.testing.expectEqualStrings(contents, text);
+ }
+ try expectErrorText(&arena, try testAction(&arena, 37, try source_text.fromUtf8(&arena, path), .{}), "permission denied");
+ for ([_][]const u8{ "\x80", "\xc0\xaf", "\xe2\x82", "\xed\xa0\x80", "\xf4\x90\x80\x80", "\xef\xbb\xbfok" }) |contents| {
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "é😀.tri", .data = contents });
+ const reply = try testAction(&arena, 37, try source_text.fromUtf8(&arena, path), .{ .allow_read_all = true });
+ try expectErrorText(&arena, reply, path);
+ try expectErrorText(&arena, reply, if (std.mem.startsWith(u8, contents, "\xef\xbb\xbf")) "BOM is not permitted" else "invalid UTF-8 source");
+ }
+ // An embedded NUL must not turn a different path into the existing file.
+ const nul_path = try std.fmt.allocPrint(std.testing.allocator, "{s}\x00ignored", .{path});
+ defer std.testing.allocator.free(nul_path);
+ try expectErrorText(&arena, try testAction(&arena, 37, try source_text.fromUtf8(&arena, nul_path), .{ .allow_read_all = true }), "invalid string");
+ try tmp.dir.deleteFile(std.testing.io, "é😀.tri");
+ try expectErrorText(&arena, try testAction(&arena, 37, try source_text.fromUtf8(&arena, path), .{ .allow_read_all = true }), "does not exist");
+ // Ordinary readFile remains a byte transport: malformed UTF-8 and BOMs
+ // are not interpreted. Use its existing byte-oriented path convention.
+ const binary = "\xff\x00\xef\xbb\xbf\r\n";
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "é😀.tri", .data = binary });
+ const reply = try testAction(&arena, 20, try codecs.ofString(&arena, path), .{ .allow_read_all = true });
+ try std.testing.expect(reply.ok);
+ const raw = (try codecs.toBytes(&arena, reply.value)).?;
+ defer arena.allocator.free(raw);
+ try std.testing.expectEqualStrings(binary, raw);
+}
+
+fn sourceIOAllocationProbe(allocator: std.mem.Allocator, path: []const u8) !void {
+ var arena = Arena.init(allocator);
+ defer arena.deinit();
+ const reply = try testAction(&arena, 37, try source_text.fromUtf8(&arena, path), .{ .allow_read_all = true });
+ try std.testing.expect(reply.ok);
+ const scalars = arena.get(reply.value).fork.right;
+ const encoded = try testAction(&arena, 38, scalars, .{});
+ try std.testing.expect(encoded.ok);
+ const raw = (try codecs.toBytes(&arena, encoded.value)).?;
+ defer allocator.free(raw);
+ try std.testing.expectEqualStrings("aé😀", raw);
+}
+
+test "source IO unwinds allocation failures without leaking tasks, buffers or requests" {
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "source.tri", .data = "aé😀" });
+ const path = try tmp.dir.realPathFileAlloc(std.testing.io, "source.tri", std.testing.allocator);
+ defer std.testing.allocator.free(path);
+ try std.testing.checkAllAllocationFailures(std.testing.allocator, sourceIOAllocationProbe, .{path});
+}
+
+fn testIndexedAction(arena: *Arena, tag: u64, path: []const u8, hash: []const u8, value: u32, perms: IOPerms) !codecs.UnwrapResult {
+ const path_tree = try source_text.fromUtf8(arena, path);
+ const digest_tree = try codecs.ofString(arena, hash);
+ const rest = if (tag == 52) try arena.alloc(.{ .fork = .{ .left = digest_tree, .right = value } }) else digest_tree;
+ const args = try arena.alloc(.{ .fork = .{ .left = path_tree, .right = rest } });
+ return testAction(arena, tag, args, perms);
+}
+
+test "indexed IO authenticates, enforces permissions before demand, and never replaces a different winner" {
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const directory = try tmp.dir.realPathFileAlloc(std.testing.io, ".", std.testing.allocator);
+ defer std.testing.allocator.free(directory);
+ const path = try std.fmt.allocPrint(std.testing.allocator, "{s}/é.arboricx", .{directory});
+ defer std.testing.allocator.free(path);
+ var arena = Arena.init(std.testing.allocator);
+ defer arena.deinit();
+ const leaf = try arena.alloc(.leaf);
+ // Test helpers turn on collection; retain the tree across independent IO calls.
+ try arena.roots.append(arena.allocator, leaf);
+ const bytes = try indexed.encode(&arena, leaf);
+ defer arena.allocator.free(bytes);
+ const hash = indexed.digest(bytes);
+ const perms = IOPerms{ .allow_read_all = true, .allow_write_all = true };
+ const hashed = try testAction(&arena, 51, leaf, .{});
+ try std.testing.expect(hashed.ok);
+ const actual = (try codecs.toString(&arena, hashed.value)).?;
+ defer arena.allocator.free(actual);
+ try std.testing.expectEqualStrings(&hash, actual);
+ for ([_]bool{ true, false }) |fresh| {
+ const result = try testIndexedAction(&arena, 52, path, &hash, leaf, perms);
+ try std.testing.expect(result.ok);
+ try std.testing.expectEqual(fresh, (try codecs.toBool(&arena, result.value)).?);
+ }
+ const read = try testIndexedAction(&arena, 50, path, &hash, leaf, .{ .allow_read_all = true });
+ try std.testing.expect(read.ok and arena.get(read.value).* == .leaf);
+ // A denied write must not try to normalize a divergent supplied tree.
+ const poison = try arena.alloc(.leaf);
+ arena.get(poison).* = .{ .app = .{ .func = poison, .arg = leaf } };
+ try arena.roots.append(arena.allocator, poison);
+ for ([_]IOPerms{ .{}, .{ .allow_read_all = true }, .{ .allow_write_all = true } }) |denied| {
+ try expectErrorText(&arena, try testIndexedAction(&arena, 52, path, &hash, poison, denied), "permission denied");
+ }
+ try expectErrorText(&arena, try testIndexedAction(&arena, 50, path, &hash, leaf, .{}), "permission denied");
+ try expectErrorText(&arena, try testIndexedAction(&arena, 50, path, "ABC", leaf, perms), "invalid CAS digest");
+ const wrong = "0" ** 64;
+ try expectErrorText(&arena, try testIndexedAction(&arena, 50, path, wrong, leaf, perms), "CAS digest mismatch");
+ try expectErrorText(&arena, try testIndexedAction(&arena, 52, path, wrong, leaf, perms), "CAS digest mismatch");
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "é.arboricx", .data = "corrupt" });
+ try expectErrorText(&arena, try testIndexedAction(&arena, 52, path, &hash, leaf, perms), "different bytes");
+ const still_corrupt = try readFsForTest(&arena, path);
+ defer arena.allocator.free(still_corrupt);
+ try std.testing.expectEqualStrings("corrupt", still_corrupt);
+ const corrupt_hash = indexed.digest("corrupt");
+ try expectErrorText(&arena, try testIndexedAction(&arena, 50, path, &corrupt_hash, leaf, perms), "invalid indexed tree");
+}
+
+fn readFsForTest(arena: *Arena, path: []const u8) ![]u8 {
+ var loop: c.uv_loop_t = undefined;
+ try std.testing.expectEqual(@as(c_int, 0), c.uv_loop_init(&loop));
+ defer _ = c.uv_loop_close(&loop);
+ const z = try arena.allocator.dupeZ(u8, path);
+ defer arena.allocator.free(z);
+ return switch (try readFs(&loop, arena.allocator, z)) {
+ .bytes => |b| b,
+ .failure => error.TestUnexpectedResult,
+ };
+}
+
+fn indexedIOAllocationProbe(allocator: std.mem.Allocator, path: []const u8) !void {
+ var arena = Arena.init(allocator);
+ defer arena.deinit();
+ const leaf = try arena.alloc(.leaf);
+ try arena.roots.append(allocator, leaf);
+ const hash = "6f93378feaa8bf5ab7cefc071175a654c871bfd53f165d7d96795fb2b38483f1";
+ const perms = IOPerms{ .allow_read_all = true, .allow_write_all = true };
+ const written = try testIndexedAction(&arena, 52, path, hash, leaf, perms);
+ try std.testing.expect(written.ok);
+ const read = try testIndexedAction(&arena, 50, path, hash, leaf, perms);
+ try std.testing.expect(read.ok and arena.get(read.value).* == .leaf);
+}
+
+test "indexed publication and reads unwind every allocator failure" {
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const directory = try tmp.dir.realPathFileAlloc(std.testing.io, ".", std.testing.allocator);
+ defer std.testing.allocator.free(directory);
+ const path = try std.fmt.allocPrint(std.testing.allocator, "{s}/object", .{directory});
+ defer std.testing.allocator.free(path);
+ try std.testing.checkAllAllocationFailures(std.testing.allocator, indexedIOAllocationProbe, .{path});
+}
diff --git a/ext/zig/src/main.zig b/ext/zig/src/main.zig
@@ -66,6 +66,11 @@ fn runNative(arena: *Arena, tag: u64, bundle_bytes: []const u8, args_raw: []cons
current = try arena.alloc(.{ .app = .{ .func = current, .arg = arg_tree } });
}
+ const base = arena.roots.items.len;
+ defer arena.roots.shrinkRetainingCapacity(base);
+ try arena.roots.append(arena.allocator, current);
+ arena.gc_enabled = true;
+ defer arena.gc_enabled = false;
const result = try reduce.reduce(current, arena, fuel);
try printNode(arena, tag, result, io);
}
@@ -79,6 +84,11 @@ fn runIO(arena: *Arena, tag: u64, bundle_bytes: []const u8, args_raw: []const []
current = try arena.alloc(.{ .app = .{ .func = current, .arg = arg_tree } });
}
+ const base = arena.roots.items.len;
+ defer arena.roots.shrinkRetainingCapacity(base);
+ try arena.roots.append(arena.allocator, current);
+ arena.gc_enabled = true;
+ defer arena.gc_enabled = false;
const reduced = try reduce.reduce(current, arena, fuel);
if (try io_driver.isIOSentinel(arena, reduced) == null) {
@@ -89,6 +99,7 @@ fn runIO(arena: *Arena, tag: u64, bundle_bytes: []const u8, args_raw: []const []
}
const result = try io_driver.runIO(arena.allocator, arena, reduced, perms);
+ try arena.roots.append(arena.allocator, result);
try printNode(arena, tag, result, io);
}
@@ -110,6 +121,11 @@ fn runBundle(arena: *Arena, tag: u64, bundle_bytes: []const u8, args_raw: []cons
const app1 = try arena.alloc(.{ .app = .{ .func = app0, .arg = bundle_tree } });
const app2 = try arena.alloc(.{ .app = .{ .func = app1, .arg = args_tree } });
+ const base = arena.roots.items.len;
+ defer arena.roots.shrinkRetainingCapacity(base);
+ try arena.roots.append(arena.allocator, app2);
+ arena.gc_enabled = true;
+ defer arena.gc_enabled = false;
const result = try reduce.reduce(app2, arena, fuel);
const unwrapped = try codecs.unwrapResult(arena, result) orelse {
@@ -187,19 +203,13 @@ pub fn main(init: std.process.Init) !void {
std.process.exit(1);
}
const type_str = args[i + 1];
- tag = if (std.mem.eql(u8, type_str, "tree")) codecs.HOST_TREE_TAG
- else if (std.mem.eql(u8, type_str, "number")) codecs.HOST_NUMBER_TAG
- else if (std.mem.eql(u8, type_str, "bool")) codecs.HOST_BOOL_TAG
- else if (std.mem.eql(u8, type_str, "string")) codecs.HOST_STRING_TAG
- else if (std.mem.eql(u8, type_str, "list")) codecs.HOST_LIST_TAG
- else if (std.mem.eql(u8, type_str, "bytes")) codecs.HOST_BYTES_TAG
- else blk: {
- var stderr = std.Io.File.stderr().writer(io, &[_]u8{});
- try stderr.interface.print("Unknown type: {s}\n", .{type_str});
- try stderr.flush();
- std.process.exit(1);
- break :blk codecs.HOST_STRING_TAG;
- };
+ tag = if (std.mem.eql(u8, type_str, "tree")) codecs.HOST_TREE_TAG else if (std.mem.eql(u8, type_str, "number")) codecs.HOST_NUMBER_TAG else if (std.mem.eql(u8, type_str, "bool")) codecs.HOST_BOOL_TAG else if (std.mem.eql(u8, type_str, "string")) codecs.HOST_STRING_TAG else if (std.mem.eql(u8, type_str, "list")) codecs.HOST_LIST_TAG else if (std.mem.eql(u8, type_str, "bytes")) codecs.HOST_BYTES_TAG else blk: {
+ var stderr = std.Io.File.stderr().writer(io, &[_]u8{});
+ try stderr.interface.print("Unknown type: {s}\n", .{type_str});
+ try stderr.flush();
+ std.process.exit(1);
+ break :blk codecs.HOST_STRING_TAG;
+ };
i += 1;
} else if (std.mem.eql(u8, args[i], "--kernel")) {
use_kernel = true;
diff --git a/ext/zig/src/reduce.zig b/ext/zig/src/reduce.zig
@@ -1,114 +1,108 @@
const std = @import("std");
-const tree = @import("tree.zig");
const Arena = @import("arena.zig").Arena;
-pub const ReduceError = error{
- FuelExhausted,
- InvalidApply,
- OutOfMemory,
+pub const ReduceError = error{ FuelExhausted, InvalidApply, OutOfMemory };
+
+const Frame = struct {
+ kind: enum { function, discriminator, argument, update },
+ redex: u32,
};
-/// Reduce a term to weak head normal form.
+/// Call-by-need WHNF machine. Every suspended demand lives on the heap, and
+/// every update targets the original application (including shared redexes).
+/// Fuel counts application entries, not observations or continuation dispatch.
+/// Collection is opt-in: callers must register all externally held node IDs.
pub fn reduce(root: u32, arena: *Arena, fuel: u64) ReduceError!u32 {
var remaining = fuel;
- return try whnf(root, arena, &remaining);
-}
-
-fn whnf(term: u32, arena: *Arena, fuel: *u64) ReduceError!u32 {
- var current = term;
+ var frames: std.ArrayList(Frame) = .empty;
+ defer frames.deinit(arena.allocator);
+ var roots: std.ArrayList(u32) = .empty;
+ defer roots.deinit(arena.allocator);
+ var current = root;
while (true) {
- switch (arena.get(current).*) {
- .leaf, .stem, .fork => return current,
- .app => |app| {
- if (fuel.* == 0) return error.FuelExhausted;
- fuel.* -= 1;
-
- const orig = current;
- const func_idx = app.func;
- const arg_idx = app.arg;
-
- // Reduce function to WHNF
- const f = try whnf(func_idx, arena, fuel);
-
- switch (arena.get(f).*) {
- // apply Leaf b = Stem b
+ // The only GC safepoint: no unlinked allocation or raw Node pointer is
+ // held here. Suspended redexes retain their function and argument trees.
+ if (arena.collectionDue()) {
+ roots.clearRetainingCapacity();
+ try roots.append(arena.allocator, root);
+ try roots.append(arena.allocator, current);
+ for (frames.items) |frame| try roots.append(arena.allocator, frame.redex);
+ try arena.collect(roots.items);
+ }
+ if (arena.get(current).* == .app) {
+ if (remaining == 0) return error.FuelExhausted;
+ remaining -= 1;
+ const func = arena.get(current).app.func;
+ try frames.append(arena.allocator, .{ .kind = .function, .redex = current });
+ current = func;
+ continue;
+ }
+ const frame = frames.pop() orelse return current;
+ const orig = frame.redex;
+ if (frame.kind == .update) {
+ arena.get(orig).* = arena.get(current).*;
+ current = orig;
+ continue;
+ }
+ const app = arena.get(orig).app;
+ switch (frame.kind) {
+ .function => switch (arena.get(current).*) {
+ .leaf => {
+ arena.get(orig).* = .{ .stem = .{ .child = app.arg } };
+ current = orig;
+ },
+ .stem => |s| {
+ arena.get(orig).* = .{ .fork = .{ .left = s.child, .right = app.arg } };
+ current = orig;
+ },
+ .fork => |f| {
+ try frames.append(arena.allocator, .{ .kind = .discriminator, .redex = orig });
+ current = f.left;
+ },
+ .app => unreachable,
+ },
+ .discriminator => {
+ const f = arena.get(app.func).fork;
+ switch (arena.get(current).*) {
.leaf => {
- arena.get(orig).* = .{ .stem = .{ .child = arg_idx } };
- return orig;
+ try frames.append(arena.allocator, .{ .kind = .update, .redex = orig });
+ current = f.right;
},
- // apply (Stem a) b = Fork a b
.stem => |s| {
- const a = s.child;
- arena.get(orig).* = .{ .fork = .{ .left = a, .right = arg_idx } };
- return orig;
+ const a = try arena.alloc(.{ .app = .{ .func = s.child, .arg = app.arg } });
+ const b = try arena.alloc(.{ .app = .{ .func = f.right, .arg = app.arg } });
+ arena.get(orig).* = .{ .app = .{ .func = a, .arg = b } };
+ current = orig;
},
- .fork => |fork_f| {
- const left_idx = fork_f.left;
- const right_idx = fork_f.right;
-
- // Reduce left child of Fork
- const left = try whnf(left_idx, arena, fuel);
-
- switch (arena.get(left).*) {
- // apply (Fork Leaf a) _ = a
- .leaf => {
- const result = try whnf(right_idx, arena, fuel);
- if (orig != result) {
- arena.get(orig).* = arena.get(result).*;
- }
- return orig;
- },
- // apply (Fork (Stem a) b) c = (a c) (b c)
- .stem => |s| {
- const a = s.child;
- const inner1 = try arena.alloc(.{ .app = .{ .func = a, .arg = arg_idx } });
- const inner2 = try arena.alloc(.{ .app = .{ .func = right_idx, .arg = arg_idx } });
- arena.get(orig).* = .{ .app = .{ .func = inner1, .arg = inner2 } };
- current = orig;
- continue;
- },
- .fork => {
- // Reduce argument
- const arg = try whnf(arg_idx, arena, fuel);
-
- switch (arena.get(arg).*) {
- // apply (Fork (Fork a b) c) Leaf = a
- .leaf => {
- const a_idx = arena.get(left).fork.left;
- const result = try whnf(a_idx, arena, fuel);
- if (orig != result) {
- arena.get(orig).* = arena.get(result).*;
- }
- return orig;
- },
- // apply (Fork (Fork a b) c) (Stem u) = b u
- .stem => |s| {
- const b_idx = arena.get(left).fork.right;
- const u = s.child;
- arena.get(orig).* = .{ .app = .{ .func = b_idx, .arg = u } };
- current = orig;
- continue;
- },
- // apply (Fork (Fork a b) c) (Fork u v) = (c u) v
- .fork => |arg_fork| {
- const c_idx = right_idx;
- const u = arg_fork.left;
- const v = arg_fork.right;
- const inner = try arena.alloc(.{ .app = .{ .func = c_idx, .arg = u } });
- arena.get(orig).* = .{ .app = .{ .func = inner, .arg = v } };
- current = orig;
- continue;
- },
- .app => return error.InvalidApply,
- }
- },
- .app => return error.InvalidApply,
- }
+ .fork => {
+ try frames.append(arena.allocator, .{ .kind = .argument, .redex = orig });
+ current = app.arg;
+ },
+ .app => unreachable,
+ }
+ },
+ .argument => {
+ const f = arena.get(app.func).fork;
+ const branches = arena.get(f.left).fork;
+ switch (arena.get(current).*) {
+ .leaf => {
+ try frames.append(arena.allocator, .{ .kind = .update, .redex = orig });
+ current = branches.left;
+ },
+ .stem => |s| {
+ arena.get(orig).* = .{ .app = .{ .func = branches.right, .arg = s.child } };
+ current = orig;
+ },
+ .fork => |arg| {
+ const inner = try arena.alloc(.{ .app = .{ .func = f.right, .arg = arg.left } });
+ arena.get(orig).* = .{ .app = .{ .func = inner, .arg = arg.right } };
+ current = orig;
},
- .app => return error.InvalidApply,
+ .app => unreachable,
}
},
+ .update => unreachable,
}
}
}
diff --git a/ext/zig/src/reduce_test.zig b/ext/zig/src/reduce_test.zig
@@ -0,0 +1,242 @@
+const std = @import("std");
+const testing = std.testing;
+const Arena = @import("arena.zig").Arena;
+const tree = @import("tree.zig");
+const machine = @import("reduce.zig");
+const codecs = @import("codecs.zig");
+
+fn stem(a: *Arena, x: u32) !u32 {
+ return a.alloc(.{ .stem = .{ .child = x } });
+}
+fn fork(a: *Arena, x: u32, y: u32) !u32 {
+ return a.alloc(.{ .fork = .{ .left = x, .right = y } });
+}
+fn app(a: *Arena, x: u32, y: u32) !u32 {
+ return a.alloc(.{ .app = .{ .func = x, .arg = y } });
+}
+fn force(a: *Arena, x: u32) !u32 {
+ return machine.reduce(x, a, 10_000_000);
+}
+fn equal(a: *Arena, x: u32, y: u32) !void {
+ const nx = a.get(try force(a, x)).*;
+ const ny = a.get(try force(a, y)).*;
+ try testing.expectEqual(std.meta.activeTag(nx), std.meta.activeTag(ny));
+ switch (nx) {
+ .leaf => {},
+ .stem => |s| try equal(a, s.child, ny.stem.child),
+ .fork => |f| {
+ try equal(a, f.left, ny.fork.left);
+ try equal(a, f.right, ny.fork.right);
+ },
+ .app => unreachable,
+ }
+}
+
+test "kernel rules, including lazy distribution and all triage arms" {
+ var a = Arena.init(testing.allocator);
+ defer a.deinit();
+ const t = try a.alloc(.leaf);
+ const u = try stem(&a, t);
+ const v = try fork(&a, u, t);
+ try equal(&a, try app(&a, t, v), try stem(&a, v));
+ try equal(&a, try app(&a, u, v), try fork(&a, t, v));
+ try equal(&a, try app(&a, try fork(&a, t, v), u), v);
+ const s = try fork(&a, try stem(&a, u), t);
+ try equal(&a, try app(&a, s, v), try app(&a, try app(&a, u, v), try app(&a, t, v)));
+ const f = try fork(&a, try fork(&a, v, u), t);
+ try equal(&a, try app(&a, f, t), v);
+ try equal(&a, try app(&a, f, u), try app(&a, u, t));
+ try equal(&a, try app(&a, f, v), try app(&a, try app(&a, t, u), t));
+}
+
+test "discarded work and constructor children are never demanded" {
+ var a = Arena.init(testing.allocator);
+ defer a.deinit();
+ const t = try a.alloc(.leaf);
+ const poison = try app(&a, t, t);
+ a.get(poison).* = .{ .app = .{ .func = poison, .arg = t } };
+ const k = try fork(&a, t, t);
+ const result = try app(&a, k, poison);
+ try testing.expectEqual(tree.NodeTag.leaf, std.meta.activeTag(a.get(try machine.reduce(result, &a, 1)).*));
+ const constructor = try app(&a, t, poison);
+ const r = try machine.reduce(constructor, &a, 1);
+ try testing.expectEqual(poison, a.get(r).stem.child);
+ try testing.expectError(error.FuelExhausted, machine.reduce(poison, &a, 100));
+}
+
+test "shared redex updated once and fuel failure can be resumed" {
+ var a = Arena.init(testing.allocator);
+ defer a.deinit();
+ const t = try a.alloc(.leaf);
+ const shared = try app(&a, t, t);
+ try testing.expectError(error.FuelExhausted, machine.reduce(shared, &a, 0));
+ _ = try machine.reduce(shared, &a, 1);
+ _ = try machine.reduce(shared, &a, 0);
+ try testing.expectEqual(t, a.get(shared).stem.child);
+}
+
+test "100000 nested function demands and selected-result updates use heap frames" {
+ var a = Arena.init(testing.allocator);
+ defer a.deinit();
+ const t = try a.alloc(.leaf);
+ const identity = try fork(&a, t, t);
+ var x = t;
+ for (0..100000) |_| x = try app(&a, try fork(&a, t, x), t);
+ try testing.expect(a.get(try force(&a, x)).* == .leaf);
+ // Left-associated applications of a constant returning itself would be
+ // cyclic; use alternating constructor applications and constant selection.
+ x = identity;
+ for (0..100000) |_| x = try app(&a, x, identity);
+ _ = try force(&a, x);
+}
+
+test "non-moving collection preserves explicit roots and reuses dead slots" {
+ var a = Arena.init(testing.allocator);
+ defer a.deinit();
+ const t = try a.alloc(.leaf);
+ const kept = try stem(&a, t);
+ try a.roots.append(a.allocator, kept);
+ for (0..1000) |_| _ = try fork(&a, t, t);
+ const before = a.len();
+ try a.collect(&.{});
+ try testing.expectEqual(@as(usize, 1000), a.free.items.len);
+ for (0..1000) |_| _ = try stem(&a, t);
+ try testing.expectEqual(before, a.len());
+ try testing.expectEqual(t, a.get(kept).stem.child);
+}
+
+fn gcWorkload(allocator: std.mem.Allocator) !void {
+ var a = Arena.init(allocator);
+ defer a.deinit();
+ const t = try a.alloc(.leaf);
+ const k = try stem(&a, t);
+ const id = try fork(&a, try stem(&a, k), k);
+ var x = t;
+ for (0..100) |_| x = try app(&a, id, x);
+ a.gc_enabled = true;
+ a.gc_interval = 8;
+ try testing.expect(a.get(try force(&a, x)).* == .leaf);
+ try testing.expect(a.collections > 1);
+ try a.collect(&.{x});
+ try testing.expectEqual(@as(usize, a.len() - 1), a.free.items.len);
+}
+
+test "GC during distribution preserves pending demands" {
+ try gcWorkload(testing.allocator);
+}
+
+test "allocation failures unwind machine and collector without leaking" {
+ try testing.checkAllAllocationFailures(testing.allocator, gcWorkload, .{});
+}
+
+test "deep shared DAG copying uses a growable work stack" {
+ var a = Arena.init(testing.allocator);
+ defer a.deinit();
+ var b = Arena.init(testing.allocator);
+ defer b.deinit();
+ var x = try a.alloc(.leaf);
+ for (0..10000) |_| x = try fork(&a, x, x);
+ const copied = try tree.copyTree(a.nodes.items, &b, x);
+ try testing.expectEqual(a.len(), b.len());
+ try testing.expectEqual(b.get(copied).fork.left, b.get(copied).fork.right);
+}
+
+test "boolean observation forces its child and rejects malformed booleans" {
+ var a = Arena.init(testing.allocator);
+ defer a.deinit();
+ const t = try a.alloc(.leaf);
+ const delayed = try app(&a, try fork(&a, t, t), t);
+ try testing.expectEqual(true, (try codecs.toBool(&a, try stem(&a, delayed))).?);
+ try testing.expectEqual(@as(?bool, null), try codecs.toBool(&a, try stem(&a, try stem(&a, t))));
+}
+
+const handles = @import("handles.zig");
+fn handleWorkload(allocator: std.mem.Allocator) !void {
+ var a = Arena.init(allocator);
+ defer a.deinit();
+ var table: handles.Table = .{};
+ defer table.deinit(allocator);
+ const t = try a.alloc(.leaf);
+ const handle = try table.create(allocator, t);
+ try table.release(handle);
+ const replacement = try table.create(allocator, t);
+ try testing.expectError(error.InvalidHandle, table.get(handle));
+ try testing.expectEqual(t, try table.get(replacement));
+ for (0..1000) |_| {
+ const node = try stem(&a, t);
+ const h = try table.create(allocator, node);
+ try table.release(h);
+ }
+ a.roots.clearRetainingCapacity();
+ try table.appendRoots(&a);
+ try a.collect(&.{});
+ try testing.expectEqual(@as(usize, 1000), a.free.items.len);
+ try testing.expectEqual(@as(usize, 2), table.slots.items.len);
+}
+
+test "handle table failure cleanup and reclamation" {
+ try testing.checkAllAllocationFailures(testing.allocator, handleWorkload, .{});
+}
+
+// Independent eager reference on finite, already-normal inputs. No application
+// nodes, mutation or continuation/update machinery are shared with the machine.
+fn reference(a: *Arena, f: u32, x: u32, fuel: *usize) error{ OutOfMemory, FuelExhausted }!u32 {
+ if (fuel.* == 0) return error.FuelExhausted;
+ fuel.* -= 1;
+ return switch (a.get(f).*) {
+ .leaf => stem(a, x),
+ .stem => |s| fork(a, s.child, x),
+ .fork => |b| switch (a.get(b.left).*) {
+ .leaf => b.right,
+ .stem => |s| reference(a, try reference(a, s.child, x, fuel), try reference(a, b.right, x, fuel), fuel),
+ .fork => |branches| switch (a.get(x).*) {
+ .leaf => branches.left,
+ .stem => |s| reference(a, branches.right, s.child, fuel),
+ .fork => |arg| reference(a, try reference(a, b.right, arg.left, fuel), arg.right, fuel),
+ .app => unreachable,
+ },
+ .app => unreachable,
+ },
+ .app => unreachable,
+ };
+}
+
+test "all pairs of depth-two values agree with an independent reference" {
+ var a = Arena.init(testing.allocator);
+ defer a.deinit();
+ const t = try a.alloc(.leaf);
+ const small = [_]u32{ t, try stem(&a, t), try fork(&a, t, t) };
+ var values: std.ArrayList(u32) = .empty;
+ defer values.deinit(a.allocator);
+ try values.append(a.allocator, t);
+ for (small) |x| {
+ try values.append(a.allocator, try stem(&a, x));
+ for (small) |y| try values.append(a.allocator, try fork(&a, x, y));
+ }
+ for (values.items) |f| for (values.items) |x| {
+ var fuel: usize = 10000;
+ const expected = try reference(&a, f, x, &fuel);
+ try equal(&a, try app(&a, f, x), expected);
+ };
+}
+
+test "result tags are demanded booleans, not arbitrary non-true error tags" {
+ var a = Arena.init(testing.allocator);
+ defer a.deinit();
+ const t = try a.alloc(.leaf);
+ const delayed = try app(&a, try fork(&a, t, t), t);
+ const payload = try fork(&a, t, t);
+ const good = try fork(&a, try stem(&a, delayed), payload);
+ try testing.expect((try codecs.unwrapResult(&a, good)).?.ok);
+ const malformed = try fork(&a, try fork(&a, t, t), payload);
+ try testing.expectEqual(@as(?codecs.UnwrapResult, null), try codecs.unwrapResult(&a, malformed));
+}
+
+test "oversized host numbers return a decode failure rather than trapping" {
+ var a = Arena.init(testing.allocator);
+ defer a.deinit();
+ const max = try codecs.ofNumber(&a, std.math.maxInt(u64));
+ try testing.expectEqual(std.math.maxInt(u64), (try codecs.toNumber(&a, max)).?);
+ const overflow = try fork(&a, try a.alloc(.leaf), max);
+ try testing.expectEqual(@as(?u64, null), try codecs.toNumber(&a, overflow));
+}
diff --git a/ext/zig/src/source_text.zig b/ext/zig/src/source_text.zig
@@ -0,0 +1,143 @@
+//! Source-only Unicode boundary. Binary/string ABI codecs remain byte-oriented.
+const std = @import("std");
+const Arena = @import("arena.zig").Arena;
+const codecs = @import("codecs.zig");
+const reduce = @import("reduce.zig");
+
+pub fn fromUtf8(arena: *Arena, bytes: []const u8) !u32 {
+ const view = std.unicode.Utf8View.init(bytes) catch return error.InvalidUtf8;
+ var iter = view.iterator();
+ var scalars: std.ArrayList(u32) = .empty;
+ defer scalars.deinit(arena.allocator);
+ while (iter.nextCodepoint()) |scalar| {
+ try scalars.append(arena.allocator, try codecs.ofNumber(arena, scalar));
+ }
+ return codecs.ofList(arena, scalars.items);
+}
+
+// Match the Haskell source boundary's canonical-tree check, not merely its
+// numeric interpretation. Reject redundant high zero bits, non-Boolean bits,
+// overlong integers, surrogates and values beyond the Unicode scalar range.
+fn scalarNumber(arena: *Arena, input: u32) !u21 {
+ var current = input;
+ var value: u32 = 0;
+ var bits: u5 = 0;
+ var high_bit: u32 = 0;
+ while (true) {
+ const node = arena.get(try reduce.reduce(current, arena, 1_000_000)).*;
+ switch (node) {
+ .leaf => {
+ if (bits != 0 and high_bit == 0) return error.InvalidScalarList;
+ if (value > 0x10ffff or (value >= 0xd800 and value <= 0xdfff)) return error.InvalidScalarList;
+ return @intCast(value);
+ },
+ .fork => |pair| {
+ if (bits == 21) return error.InvalidScalarList;
+ const bit = arena.get(try reduce.reduce(pair.left, arena, 1_000_000)).*;
+ high_bit = switch (bit) {
+ .leaf => 0,
+ .stem => |s| if (arena.get(try reduce.reduce(s.child, arena, 1_000_000)).* == .leaf) 1 else return error.InvalidScalarList,
+ else => return error.InvalidScalarList,
+ };
+ value |= high_bit << bits;
+ bits += 1;
+ current = pair.right;
+ },
+ else => return error.InvalidScalarList,
+ }
+ }
+}
+
+pub fn toUtf8(arena: *Arena, input: u32) ![]u8 {
+ const base = arena.roots.items.len;
+ defer arena.roots.shrinkRetainingCapacity(base);
+ try arena.roots.append(arena.allocator, input);
+ var bytes: std.ArrayList(u8) = .empty;
+ errdefer bytes.deinit(arena.allocator);
+ var current = input;
+ while (true) {
+ const node = arena.get(try reduce.reduce(current, arena, 1_000_000)).*;
+ switch (node) {
+ .leaf => return bytes.toOwnedSlice(arena.allocator),
+ .fork => |pair| {
+ const scalar = try scalarNumber(arena, pair.left);
+ var encoded: [4]u8 = undefined;
+ const len = try std.unicode.utf8Encode(scalar, &encoded);
+ try bytes.appendSlice(arena.allocator, encoded[0..len]);
+ current = pair.right;
+ },
+ else => return error.InvalidScalarList,
+ }
+ }
+}
+
+test "source scalar codec preserves exact UTF-8 including NUL, CRLF and noncharacters" {
+ var arena = Arena.init(std.testing.allocator);
+ defer arena.deinit();
+ const bytes = "\x00a\r\né😀\u{ffff}\u{10ffff}\u{feff}";
+ const scalars = try fromUtf8(&arena, bytes);
+ var list = (try codecs.toList(&arena, scalars)).?;
+ defer list.deinit(arena.allocator);
+ const expected = [_]u64{ 0, 0x61, 13, 10, 0xe9, 0x1f600, 0xffff, 0x10ffff, 0xfeff };
+ try std.testing.expectEqual(expected.len, list.items.len);
+ for (list.items, expected) |item, n| try std.testing.expectEqual(n, (try codecs.toNumber(&arena, item)).?);
+ arena.gc_enabled = true;
+ arena.gc_interval = 1;
+ const result = try toUtf8(&arena, scalars);
+ defer arena.allocator.free(result);
+ try std.testing.expectEqualStrings(bytes, result);
+ try std.testing.expect(arena.collections > 0);
+ try std.testing.expectEqual(@as(usize, 0), arena.roots.items.len);
+}
+
+test "source scalar codec rejects malformed UTF-8 and scalar trees" {
+ var arena = Arena.init(std.testing.allocator);
+ defer arena.deinit();
+ for ([_][]const u8{ "\x80", "\xc0\xaf", "\xe2\x82", "\xed\xa0\x80", "\xf4\x90\x80\x80", "\xff" }) |bytes| {
+ try std.testing.expectError(error.InvalidUtf8, fromUtf8(&arena, bytes));
+ }
+ for ([_]u64{ 0xd800, 0xdfff, 0x110000, std.math.maxInt(u64) }) |n| {
+ const list = try codecs.ofList(&arena, &.{try codecs.ofNumber(&arena, n)});
+ try std.testing.expectError(error.InvalidScalarList, toUtf8(&arena, list));
+ }
+ const leaf = try arena.alloc(.leaf);
+ const zero_with_bit = try arena.alloc(.{ .fork = .{ .left = leaf, .right = leaf } });
+ const bad_bit = try arena.alloc(.{ .stem = .{ .child = zero_with_bit } });
+ const malformed = try arena.alloc(.{ .fork = .{ .left = bad_bit, .right = leaf } });
+ for ([_]u32{ zero_with_bit, malformed, bad_bit }) |n| {
+ const list = try codecs.ofList(&arena, &.{n});
+ try std.testing.expectError(error.InvalidScalarList, toUtf8(&arena, list));
+ }
+ try std.testing.expectError(error.InvalidScalarList, toUtf8(&arena, bad_bit));
+ // Failure after successful UTF-8 output must free the partial buffer too.
+ const partial = try codecs.ofList(&arena, &.{ try codecs.ofNumber(&arena, 65), try codecs.ofNumber(&arena, 0xd800) });
+ try std.testing.expectError(error.InvalidScalarList, toUtf8(&arena, partial));
+}
+
+test "source scalar codec accepts lazy scalar numerals" {
+ var arena = Arena.init(std.testing.allocator);
+ defer arena.deinit();
+ const leaf = try arena.alloc(.leaf);
+ const scalar = try codecs.ofNumber(&arena, 0x1f600);
+ const constant = try arena.alloc(.{ .fork = .{ .left = leaf, .right = scalar } });
+ const delayed = try arena.alloc(.{ .app = .{ .func = constant, .arg = leaf } });
+ const list = try codecs.ofList(&arena, &.{delayed});
+ arena.gc_enabled = true;
+ arena.gc_interval = 1;
+ const bytes = try toUtf8(&arena, list);
+ defer arena.allocator.free(bytes);
+ try std.testing.expectEqualStrings("😀", bytes);
+}
+
+fn allocationProbe(allocator: std.mem.Allocator) !void {
+ var arena = Arena.init(allocator);
+ defer arena.deinit();
+ const scalars = try fromUtf8(&arena, "aé😀");
+ const bytes = try toUtf8(&arena, scalars);
+ defer allocator.free(bytes);
+ try std.testing.expectEqualStrings("aé😀", bytes);
+}
+
+test "source scalar codec cleans up on every allocation failure" {
+ try std.testing.checkAllAllocationFailures(std.testing.allocator, allocationProbe, .{});
+}
diff --git a/ext/zig/src/tree.zig b/ext/zig/src/tree.zig
@@ -73,8 +73,6 @@ pub fn sameTree(pool: anytype, a: u32, b: u32) bool {
};
}
-/// Deep-copy a term from a source node slice into a destination Arena, returning the new index.
-/// Uses recursion; assumes the tree is finite and well-formed.
const DstArena = @import("arena.zig").Arena;
/// Iterative deep-copy of a DAG from `src` into `dst`. Uses an explicit
@@ -91,19 +89,16 @@ pub fn copyTree(src: []const Node, dst: *DstArena, root: u32) !u32 {
defer dst.allocator.free(map);
@memset(std.mem.sliceAsBytes(map), 0xFF);
- var stack = try dst.allocator.alloc(Frame, src.len);
- defer dst.allocator.free(stack);
- var sp: usize = 0;
+ var stack: std.ArrayList(Frame) = .empty;
+ defer stack.deinit(dst.allocator);
+ try stack.append(dst.allocator, .{ .src = root, .state = 0 });
- stack[sp] = .{ .src = root, .state = 0 };
- sp += 1;
-
- while (sp > 0) {
- const frame = &stack[sp - 1];
+ while (stack.items.len > 0) {
+ const frame = &stack.items[stack.items.len - 1];
const src_idx = frame.src;
if (map[src_idx] != 0xFFFFFFFF) {
- sp -= 1;
+ _ = stack.pop();
continue;
}
@@ -114,32 +109,27 @@ pub fn copyTree(src: []const Node, dst: *DstArena, root: u32) !u32 {
.leaf => {}, // no children, fall through to allocation next iteration
.stem => |s| {
if (map[s.child] == 0xFFFFFFFF) {
- stack[sp] = .{ .src = s.child, .state = 0 };
- sp += 1;
+ try stack.append(dst.allocator, .{ .src = s.child, .state = 0 });
}
},
.fork => |f| {
const need_left = map[f.left] == 0xFFFFFFFF;
const need_right = map[f.right] == 0xFFFFFFFF;
if (need_right) {
- stack[sp] = .{ .src = f.right, .state = 0 };
- sp += 1;
+ try stack.append(dst.allocator, .{ .src = f.right, .state = 0 });
}
if (need_left) {
- stack[sp] = .{ .src = f.left, .state = 0 };
- sp += 1;
+ try stack.append(dst.allocator, .{ .src = f.left, .state = 0 });
}
},
.app => |a| {
const need_func = map[a.func] == 0xFFFFFFFF;
const need_arg = map[a.arg] == 0xFFFFFFFF;
if (need_arg) {
- stack[sp] = .{ .src = a.arg, .state = 0 };
- sp += 1;
+ try stack.append(dst.allocator, .{ .src = a.arg, .state = 0 });
}
if (need_func) {
- stack[sp] = .{ .src = a.func, .state = 0 };
- sp += 1;
+ try stack.append(dst.allocator, .{ .src = a.func, .state = 0 });
}
},
}
@@ -153,7 +143,7 @@ pub fn copyTree(src: []const Node, dst: *DstArena, root: u32) !u32 {
.app => |a| try dst.alloc(.{ .app = .{ .func = map[a.func], .arg = map[a.arg] } }),
};
map[src_idx] = dst_idx;
- sp -= 1;
+ _ = stack.pop();
}
}
diff --git a/ext/zig/tests/c_abi_append_test.c b/ext/zig/tests/c_abi_append_test.c
@@ -2,7 +2,7 @@
#include <stdlib.h>
#include <string.h>
#include <time.h>
-#include "../include/arboricx.h"
+#include "helpers.h"
static uint8_t *read_file(const char *path, size_t *out_len) {
FILE *f = fopen(path, "rb");
@@ -29,50 +29,50 @@ int main() {
if (!bundle) { printf("bundle not found\n"); return 1; }
printf("bundle size=%zu\n", bundle_len);
- uint32_t bundle_tree = arb_of_bytes(ctx, bundle, bundle_len);
- printf("bundle_tree=%u\n", bundle_tree);
+ arb_handle_t bundle_tree = checked_arb_of_bytes(ctx, bundle, bundle_len);
+ printf("bundle_tree=%" PRIu64 "\n", bundle_tree);
- uint32_t tag = arb_of_number(ctx, 1);
- printf("tag=%u\n", tag);
+ arb_handle_t tag = checked_arb_of_number(ctx, 1);
+ printf("tag=%" PRIu64 "\n", tag);
- uint32_t arg1 = arb_of_string(ctx, "Hello, ");
- uint32_t arg2 = arb_of_string(ctx, "world!");
- printf("arg1=%u arg2=%u\n", arg1, arg2);
+ arb_handle_t arg1 = checked_arb_of_string(ctx, "Hello, ");
+ arb_handle_t arg2 = checked_arb_of_string(ctx, "world!");
+ printf("arg1=%" PRIu64 " arg2=%" PRIu64 "\n", arg1, arg2);
- uint32_t list_tail = arb_fork(ctx, arg2, arb_leaf(ctx));
- uint32_t args_list = arb_fork(ctx, arg1, list_tail);
- printf("args_list=%u\n", args_list);
+ arb_handle_t list_tail = checked_arb_fork(ctx, arg2, checked_arb_leaf(ctx));
+ arb_handle_t args_list = checked_arb_fork(ctx, arg1, list_tail);
+ printf("args_list=%" PRIu64 "\n", args_list);
- uint32_t app0 = arb_app(ctx, arb_kernel_root(ctx), tag);
- uint32_t app1 = arb_app(ctx, app0, bundle_tree);
- uint32_t app2 = arb_app(ctx, app1, args_list);
- printf("app2=%u\n", app2);
+ arb_handle_t app0 = checked_arb_app(ctx, checked_arb_kernel_root(ctx), tag);
+ arb_handle_t app1 = checked_arb_app(ctx, app0, bundle_tree);
+ arb_handle_t app2 = checked_arb_app(ctx, app1, args_list);
+ printf("app2=%" PRIu64 "\n", app2);
printf("reducing...\n");
clock_t t2 = clock();
- uint32_t result = arb_reduce(ctx, app2, 1000000000ULL);
+ arb_handle_t result = checked_arb_reduce(ctx, app2, 1000000000ULL);
clock_t t3 = clock();
- printf("arb_reduce took %.3f ms, result=%u\n", (double)(t3 - t2) * 1000.0 / CLOCKS_PER_SEC, result);
+ printf("arb_reduce took %.3f ms, result=%" PRIu64 "\n", (double)(t3 - t2) * 1000.0 / CLOCKS_PER_SEC, result);
int ok;
- uint32_t value, rest;
- if (!arb_unwrap_result(ctx, result, &ok, &value, &rest)) {
+ arb_handle_t value, rest;
+ if (!(arb_unwrap_result(ctx, result, &ok, &value, &rest) == ARB_OK)) {
printf("unwrap_result failed\n");
return 1;
}
- printf("ok=%d value=%u\n", ok, value);
+ printf("ok=%d value=%" PRIu64 "\n", ok, value);
uint64_t htag;
- uint32_t payload;
- if (!arb_unwrap_host_value(ctx, value, &htag, &payload)) {
+ arb_handle_t payload;
+ if (!(arb_unwrap_host_value(ctx, value, &htag, &payload) == ARB_OK)) {
printf("unwrap_host_value failed\n");
return 1;
}
- printf("htag=%lu payload=%u\n", htag, payload);
+ printf("htag=%lu payload=%" PRIu64 "\n", htag, payload);
uint8_t *str_ptr;
size_t str_len;
- if (!arb_to_string(ctx, payload, &str_ptr, &str_len)) {
+ if (!(arb_to_string(ctx, payload, &str_ptr, &str_len) == ARB_OK)) {
printf("to_string failed\n");
return 1;
}
diff --git a/ext/zig/tests/c_abi_test.c b/ext/zig/tests/c_abi_test.c
@@ -1,6 +1,6 @@
#include <stdio.h>
#include <string.h>
-#include "arboricx.h"
+#include "helpers.h"
int main(void) {
arb_ctx_t* ctx = arboricx_init();
@@ -10,19 +10,19 @@ int main(void) {
}
/* Test: Leaf @ Leaf -> Stem */
- uint32_t leaf = arb_leaf(ctx);
- uint32_t app = arb_app(ctx, leaf, leaf);
- uint32_t result = arb_reduce(ctx, app, 10000);
- uint32_t stem = arb_stem(ctx, leaf);
+ arb_handle_t leaf = checked_arb_leaf(ctx);
+ arb_handle_t app = checked_arb_app(ctx, leaf, leaf);
+ arb_handle_t result = checked_arb_reduce(ctx, app, 10000);
+ arb_handle_t stem = checked_arb_stem(ctx, leaf);
/* Build expected Stem(Leaf) and compare */
- (void)result; (void)stem;
+ assert(same_value(ctx, result, stem));
printf("PASS: reduce Leaf@Leaf\n");
/* Test: number codec roundtrip */
- uint32_t num_tree = arb_of_number(ctx, 42);
+ arb_handle_t num_tree = checked_arb_of_number(ctx, 42);
uint64_t decoded_num;
- if (!arb_to_number(ctx, num_tree, &decoded_num) || decoded_num != 42) {
+ if (!(arb_to_number(ctx, num_tree, &decoded_num) == ARB_OK) || decoded_num != 42) {
fprintf(stderr, "FAIL: number roundtrip\n");
arboricx_free(ctx);
return 1;
@@ -30,10 +30,10 @@ int main(void) {
printf("PASS: number roundtrip 42\n");
/* Test: string codec roundtrip */
- uint32_t str_tree = arb_of_string(ctx, "hello");
+ arb_handle_t str_tree = checked_arb_of_string(ctx, "hello");
uint8_t* decoded_str;
size_t decoded_len;
- if (!arb_to_string(ctx, str_tree, &decoded_str, &decoded_len) ||
+ if (!(arb_to_string(ctx, str_tree, &decoded_str, &decoded_len) == ARB_OK) ||
decoded_len != 5 || memcmp(decoded_str, "hello", 5) != 0) {
fprintf(stderr, "FAIL: string roundtrip\n");
arboricx_free(ctx);
@@ -43,69 +43,70 @@ int main(void) {
printf("PASS: string roundtrip \"hello\"\n");
/* Test: kernel loaded */
- uint32_t kernel_root = arb_kernel_root(ctx);
+ arb_handle_t kernel_root = checked_arb_kernel_root(ctx);
if (kernel_root == 0) {
fprintf(stderr, "FAIL: kernel not loaded\n");
arboricx_free(ctx);
return 1;
}
- printf("PASS: kernel loaded (root=%u)\n", kernel_root);
+ printf("PASS: kernel loaded (root=%" PRIu64 ")\n", kernel_root);
/* Test: tree inspection primitives */
- uint32_t l = arb_leaf(ctx);
- uint32_t s = arb_stem(ctx, l);
- uint32_t f = arb_fork(ctx, s, l);
- uint32_t a = arb_app(ctx, f, s);
+ arb_handle_t l = checked_arb_leaf(ctx);
+ arb_handle_t s = checked_arb_stem(ctx, l);
+ arb_handle_t f = checked_arb_fork(ctx, s, l);
+ arb_handle_t a = checked_arb_app(ctx, f, s);
- if (!arb_is_leaf(ctx, l)) {
+ if (!checked_arb_is_leaf(ctx, l)) {
fprintf(stderr, "FAIL: is_leaf on leaf\n");
arboricx_free(ctx);
return 1;
}
- if (arb_is_leaf(ctx, s)) {
+ if (checked_arb_is_leaf(ctx, s)) {
fprintf(stderr, "FAIL: is_leaf on stem should be false\n");
arboricx_free(ctx);
return 1;
}
- if (!arb_is_stem(ctx, s)) {
+ if (!checked_arb_is_stem(ctx, s)) {
fprintf(stderr, "FAIL: is_stem on stem\n");
arboricx_free(ctx);
return 1;
}
- if (!arb_is_fork(ctx, f)) {
+ if (!checked_arb_is_fork(ctx, f)) {
fprintf(stderr, "FAIL: is_fork on fork\n");
arboricx_free(ctx);
return 1;
}
- if (!arb_is_app(ctx, a)) {
+ if (!checked_arb_is_app(ctx, a)) {
fprintf(stderr, "FAIL: is_app on app\n");
arboricx_free(ctx);
return 1;
}
- uint32_t child;
- if (!arb_get_stem_child(ctx, s, &child) || child != l) {
+ arb_handle_t child;
+ if (arb_get_stem_child(ctx, s, &child) != ARB_OK || !same_value(ctx, child, l)) {
fprintf(stderr, "FAIL: get_stem_child\n");
arboricx_free(ctx);
return 1;
}
- uint32_t left, right;
- if (!arb_get_fork_children(ctx, f, &left, &right) || left != s || right != l) {
+ arb_handle_t left, right;
+ if (!(arb_get_fork_children(ctx, f, &left, &right) == ARB_OK) || !same_value(ctx, left, s) || !same_value(ctx, right, l)) {
fprintf(stderr, "FAIL: get_fork_children\n");
arboricx_free(ctx);
return 1;
}
- uint32_t func, arg;
- if (!arb_get_app_func_arg(ctx, a, &func, &arg) || func != f || arg != s) {
+ arb_handle_t func, arg;
+ if (!(arb_get_app_func_arg(ctx, a, &func, &arg) == ARB_OK) || !same_value(ctx, func, f) || !same_value(ctx, arg, s)) {
fprintf(stderr, "FAIL: get_app_func_arg\n");
arboricx_free(ctx);
return 1;
}
/* Invalid index should return 0 */
- if (arb_is_leaf(ctx, 999999)) {
+ int invalid_out = 42;
+ if (arb_is_leaf(ctx, 999999, &invalid_out) != ARB_INVALID_HANDLE || invalid_out != 42) {
fprintf(stderr, "FAIL: is_leaf on invalid index should be false\n");
arboricx_free(ctx);
return 1;
diff --git a/ext/zig/tests/handles_test.c b/ext/zig/tests/handles_test.c
@@ -0,0 +1,81 @@
+#include "arboricx.h"
+#include <assert.h>
+#include <stdio.h>
+#include <string.h>
+
+#define OK(call) assert((call) == ARB_OK)
+
+int main(void) {
+ assert(arb_abi_version() == 2);
+ arb_ctx_t *ctx = arboricx_init(), *other = arboricx_init();
+ assert(ctx && other);
+ arb_handle_t leaf, alias, stem, child, app, result;
+ OK(arb_leaf(ctx, &leaf));
+ OK(arb_retain(ctx, leaf, &alias));
+ assert(leaf != alias);
+ OK(arb_stem(ctx, leaf, &stem));
+ OK(arb_release(ctx, leaf));
+ assert(arb_release(ctx, leaf) == ARB_INVALID_HANDLE);
+ arb_handle_t untouched = 123;
+ assert(arb_stem(ctx, leaf, &untouched) == ARB_INVALID_HANDLE);
+ assert(untouched == 123);
+ assert(arb_retain(other, alias, &untouched) == ARB_INVALID_HANDLE);
+ assert(untouched == 123);
+ OK(arb_collect(ctx));
+ int yes;
+ OK(arb_is_leaf(ctx, alias, &yes)); assert(yes);
+ OK(arb_get_stem_child(ctx, stem, &child));
+ OK(arb_release(ctx, stem));
+ OK(arb_release(ctx, alias));
+ OK(arb_collect(ctx));
+ OK(arb_is_leaf(ctx, child, &yes)); assert(yes);
+
+ OK(arb_app(ctx, child, child, &app));
+ assert(arb_reduce(ctx, app, 0, &untouched) == ARB_FUEL_EXHAUSTED);
+ assert(untouched == 123);
+ OK(arb_reduce(ctx, app, 1, &result));
+ OK(arb_is_stem(ctx, app, &yes)); assert(yes); /* update visible to alias */
+ OK(arb_release(ctx, app));
+ OK(arb_collect(ctx));
+ OK(arb_is_stem(ctx, result, &yes)); assert(yes);
+ arb_handle_t a = 123, b = 456;
+ assert(arb_get_fork_children(ctx, result, &a, &b) == ARB_TYPE_MISMATCH);
+ assert(a == 123 && b == 456);
+ OK(arb_release(ctx, result));
+ OK(arb_release(ctx, child));
+
+ /* Released slot reuse must never resurrect a stale generation. */
+ for (int i = 0; i < 10000; ++i) {
+ OK(arb_leaf(ctx, &a));
+ assert(a != leaf);
+ assert(arb_retain(ctx, leaf, &untouched) == ARB_INVALID_HANDLE);
+ OK(arb_release(ctx, a));
+ }
+ OK(arb_collect(ctx));
+
+ /* A nested selected result retains only its live spine; releasing inputs
+ and collecting repeatedly must not damage the eventual result. */
+ OK(arb_leaf(ctx, &leaf));
+ OK(arb_of_string(ctx, "survives collection", &a));
+ for (int i = 0; i < 10000; ++i) {
+ OK(arb_fork(ctx, leaf, a, &b));
+ OK(arb_release(ctx, a));
+ OK(arb_app(ctx, b, leaf, &a));
+ OK(arb_release(ctx, b));
+ if (i % 1000 == 0) OK(arb_collect(ctx));
+ }
+ OK(arb_reduce(ctx, a, 100000, &result));
+ OK(arb_release(ctx, a));
+ OK(arb_collect(ctx));
+ uint8_t *bytes; size_t len;
+ OK(arb_to_string(ctx, result, &bytes, &len));
+ assert(len == strlen("survives collection"));
+ assert(memcmp(bytes, "survives collection", len) == 0);
+ arboricx_free_buf(ctx, bytes, len);
+ OK(arb_release(ctx, result));
+ OK(arb_release(ctx, leaf));
+ OK(arb_collect(ctx));
+ arboricx_free(other);
+ arboricx_free(ctx);
+ puts("Owned handles, stale/cross-context rejection, in-place evaluation and GC passed");
+}
diff --git a/ext/zig/tests/helpers.h b/ext/zig/tests/helpers.h
@@ -0,0 +1,117 @@
+/* Test-only checked constructors. Each returned handle is owned until released
+ * or context teardown. Production callers use the status/out-parameter API. */
+#ifndef ARBORICX_TEST_HELPERS_H
+#define ARBORICX_TEST_HELPERS_H
+#include "arboricx.h"
+#include <assert.h>
+#include <stdlib.h>
+#include <inttypes.h>
+static inline arb_handle_t checked_arb_leaf(arb_ctx_t* ctx) {
+ arb_handle_t out;
+ arb_status_t status = arb_leaf(ctx, &out);
+ if (status != ARB_OK) abort();
+ return out;
+}
+static inline arb_handle_t checked_arb_stem(arb_ctx_t* ctx, arb_handle_t child) {
+ arb_handle_t out;
+ arb_status_t status = arb_stem(ctx, child, &out);
+ if (status != ARB_OK) abort();
+ return out;
+}
+static inline arb_handle_t checked_arb_fork(arb_ctx_t* ctx, arb_handle_t left, arb_handle_t right) {
+ arb_handle_t out;
+ arb_status_t status = arb_fork(ctx, left, right, &out);
+ if (status != ARB_OK) abort();
+ return out;
+}
+static inline arb_handle_t checked_arb_app(arb_ctx_t* ctx, arb_handle_t func, arb_handle_t arg) {
+ arb_handle_t out;
+ arb_status_t status = arb_app(ctx, func, arg, &out);
+ if (status != ARB_OK) abort();
+ return out;
+}
+static inline arb_handle_t checked_arb_reduce(arb_ctx_t* ctx, arb_handle_t root, uint64_t fuel) {
+ arb_handle_t out;
+ arb_status_t status = arb_reduce(ctx, root, fuel, &out);
+ if (status != ARB_OK) abort();
+ return out;
+}
+static inline arb_handle_t checked_arb_of_number(arb_ctx_t* ctx, uint64_t n) {
+ arb_handle_t out;
+ arb_status_t status = arb_of_number(ctx, n, &out);
+ if (status != ARB_OK) abort();
+ return out;
+}
+static inline arb_handle_t checked_arb_of_string(arb_ctx_t* ctx, const char* s) {
+ arb_handle_t out;
+ arb_status_t status = arb_of_string(ctx, s, &out);
+ if (status != ARB_OK) abort();
+ return out;
+}
+static inline arb_handle_t checked_arb_of_bytes(arb_ctx_t* ctx, const uint8_t* bytes, size_t len) {
+ arb_handle_t out;
+ arb_status_t status = arb_of_bytes(ctx, bytes, len, &out);
+ if (status != ARB_OK) abort();
+ return out;
+}
+static inline arb_handle_t checked_arb_of_list(arb_ctx_t* ctx, const arb_handle_t* items, size_t len) {
+ arb_handle_t out;
+ arb_status_t status = arb_of_list(ctx, items, len, &out);
+ if (status != ARB_OK) abort();
+ return out;
+}
+static inline arb_handle_t checked_arb_get_stem_child(arb_ctx_t* ctx, arb_handle_t root) {
+ arb_handle_t out;
+ arb_status_t status = arb_get_stem_child(ctx, root, &out);
+ if (status != ARB_OK) abort();
+ return out;
+}
+static inline arb_handle_t checked_arb_run_io(arb_ctx_t* ctx, arb_handle_t program, const arb_io_perms_t* perms) {
+ arb_handle_t out;
+ arb_status_t status = arb_run_io(ctx, program, perms, &out);
+ if (status != ARB_OK) abort();
+ return out;
+}
+static inline arb_handle_t checked_arb_kernel_root(arb_ctx_t* ctx) {
+ arb_handle_t out;
+ arb_status_t status = arb_kernel_root(ctx, &out);
+ if (status != ARB_OK) abort();
+ return out;
+}
+static inline arb_handle_t checked_arb_load_bundle(arb_ctx_t* ctx, const uint8_t* bytes, size_t len, const char* name) {
+ arb_handle_t out;
+ arb_status_t status = arb_load_bundle(ctx, bytes, len, name, &out);
+ if (status != ARB_OK) abort();
+ return out;
+}
+static inline arb_handle_t checked_arb_load_bundle_default(arb_ctx_t* ctx, const uint8_t* bytes, size_t len) {
+ arb_handle_t out;
+ arb_status_t status = arb_load_bundle_default(ctx, bytes, len, &out);
+ if (status != ARB_OK) abort();
+ return out;
+}
+static inline int checked_arb_is_leaf(arb_ctx_t* ctx, arb_handle_t h) { int out; if (arb_is_leaf(ctx, h, &out) != ARB_OK) abort(); return out; }
+static inline int checked_arb_is_stem(arb_ctx_t* ctx, arb_handle_t h) { int out; if (arb_is_stem(ctx, h, &out) != ARB_OK) abort(); return out; }
+static inline int checked_arb_is_fork(arb_ctx_t* ctx, arb_handle_t h) { int out; if (arb_is_fork(ctx, h, &out) != ARB_OK) abort(); return out; }
+static inline int checked_arb_is_app(arb_ctx_t* ctx, arb_handle_t h) { int out; if (arb_is_app(ctx, h, &out) != ARB_OK) abort(); return out; }
+static inline int same_value(arb_ctx_t* ctx, arb_handle_t a, arb_handle_t b) {
+ if (checked_arb_is_leaf(ctx, a)) return checked_arb_is_leaf(ctx, b);
+ arb_handle_t al, ar, bl, br;
+ if (checked_arb_is_stem(ctx, a)) {
+ if (!checked_arb_is_stem(ctx, b)) return 0;
+ assert(arb_get_stem_child(ctx, a, &al) == ARB_OK);
+ assert(arb_get_stem_child(ctx, b, &bl) == ARB_OK);
+ int same = same_value(ctx, al, bl);
+ assert(arb_release(ctx, al) == ARB_OK); assert(arb_release(ctx, bl) == ARB_OK);
+ return same;
+ }
+ int fork = checked_arb_is_fork(ctx, a);
+ if (fork != checked_arb_is_fork(ctx, b)) return 0;
+ assert((fork ? arb_get_fork_children(ctx, a, &al, &ar) : arb_get_app_func_arg(ctx, a, &al, &ar)) == ARB_OK);
+ assert((fork ? arb_get_fork_children(ctx, b, &bl, &br) : arb_get_app_func_arg(ctx, b, &bl, &br)) == ARB_OK);
+ int same = same_value(ctx, al, bl) && same_value(ctx, ar, br);
+ assert(arb_release(ctx, al) == ARB_OK); assert(arb_release(ctx, ar) == ARB_OK);
+ assert(arb_release(ctx, bl) == ARB_OK); assert(arb_release(ctx, br) == ARB_OK);
+ return same;
+}
+#endif
diff --git a/ext/zig/tests/io_protocol_test.c b/ext/zig/tests/io_protocol_test.c
@@ -1,6 +1,6 @@
#include <stdio.h>
#include <string.h>
-#include "arboricx.h"
+#include "helpers.h"
int main(void) {
arb_ctx_t* ctx = arboricx_init();
@@ -10,26 +10,26 @@ int main(void) {
}
/* Test: construct and verify pure action = Fork 0 Leaf */
- uint32_t leaf = arb_leaf(ctx);
- uint32_t zero = arb_of_number(ctx, 0);
- uint32_t pure_action = arb_fork(ctx, zero, leaf);
+ arb_handle_t leaf = checked_arb_leaf(ctx);
+ arb_handle_t zero = checked_arb_of_number(ctx, 0);
+ arb_handle_t pure_action = checked_arb_fork(ctx, zero, leaf);
- if (!arb_is_fork(ctx, pure_action)) {
+ if (!checked_arb_is_fork(ctx, pure_action)) {
fprintf(stderr, "FAIL: pure action should be fork\n");
arboricx_free(ctx);
return 1;
}
- uint32_t tag, payload;
- if (!arb_get_fork_children(ctx, pure_action, &tag, &payload) ||
- tag != zero || payload != leaf) {
+ arb_handle_t tag, payload;
+ if (!(arb_get_fork_children(ctx, pure_action, &tag, &payload) == ARB_OK) ||
+ !same_value(ctx, tag, zero) || !same_value(ctx, payload, leaf)) {
fprintf(stderr, "FAIL: pure action children mismatch\n");
arboricx_free(ctx);
return 1;
}
uint64_t tag_num;
- if (!arb_to_number(ctx, tag, &tag_num) || tag_num != 0) {
+ if (!(arb_to_number(ctx, tag, &tag_num) == ARB_OK) || tag_num != 0) {
fprintf(stderr, "FAIL: pure action tag should be 0\n");
arboricx_free(ctx);
return 1;
@@ -37,22 +37,22 @@ int main(void) {
printf("PASS: pure action shape\n");
/* Test: construct and verify bind action = Fork 1 (Fork left k) */
- uint32_t one = arb_of_number(ctx, 1);
- uint32_t left = arb_fork(ctx, zero, leaf); /* pure Leaf */
- uint32_t k = arb_fork(ctx, leaf, leaf); /* identity as Fork Leaf Leaf */
- uint32_t bind_pair = arb_fork(ctx, left, k);
- uint32_t bind_action = arb_fork(ctx, one, bind_pair);
-
- if (!arb_get_fork_children(ctx, bind_action, &tag, &payload) ||
- !arb_to_number(ctx, tag, &tag_num) || tag_num != 1) {
+ arb_handle_t one = checked_arb_of_number(ctx, 1);
+ arb_handle_t left = checked_arb_fork(ctx, zero, leaf); /* pure Leaf */
+ arb_handle_t k = checked_arb_fork(ctx, leaf, leaf); /* identity as Fork Leaf Leaf */
+ arb_handle_t bind_pair = checked_arb_fork(ctx, left, k);
+ arb_handle_t bind_action = checked_arb_fork(ctx, one, bind_pair);
+
+ if (!(arb_get_fork_children(ctx, bind_action, &tag, &payload) == ARB_OK) ||
+ !(arb_to_number(ctx, tag, &tag_num) == ARB_OK) || tag_num != 1) {
fprintf(stderr, "FAIL: bind action tag should be 1\n");
arboricx_free(ctx);
return 1;
}
- uint32_t bind_left, bind_k;
- if (!arb_get_fork_children(ctx, payload, &bind_left, &bind_k) ||
- bind_left != left || bind_k != k) {
+ arb_handle_t bind_left, bind_k;
+ if (!(arb_get_fork_children(ctx, payload, &bind_left, &bind_k) == ARB_OK) ||
+ !same_value(ctx, bind_left, left) || !same_value(ctx, bind_k, k)) {
fprintf(stderr, "FAIL: bind payload should be Fork left k\n");
arboricx_free(ctx);
return 1;
@@ -60,19 +60,19 @@ int main(void) {
printf("PASS: bind action shape\n");
/* Test: construct and verify IO sentinel = Fork "tricuIO" (Fork 1 action) */
- uint32_t sentinel_str = arb_of_string(ctx, "tricuIO");
- uint32_t version = arb_of_number(ctx, 1);
- uint32_t version_action_pair = arb_fork(ctx, version, pure_action);
- uint32_t io_sentinel = arb_fork(ctx, sentinel_str, version_action_pair);
+ arb_handle_t sentinel_str = checked_arb_of_string(ctx, "tricuIO");
+ arb_handle_t version = checked_arb_of_number(ctx, 1);
+ arb_handle_t version_action_pair = checked_arb_fork(ctx, version, pure_action);
+ arb_handle_t io_sentinel = checked_arb_fork(ctx, sentinel_str, version_action_pair);
- if (!arb_is_fork(ctx, io_sentinel)) {
+ if (!checked_arb_is_fork(ctx, io_sentinel)) {
fprintf(stderr, "FAIL: IO sentinel should be fork\n");
arboricx_free(ctx);
return 1;
}
- uint32_t sent_left, sent_right;
- if (!arb_get_fork_children(ctx, io_sentinel, &sent_left, &sent_right)) {
+ arb_handle_t sent_left, sent_right;
+ if (!(arb_get_fork_children(ctx, io_sentinel, &sent_left, &sent_right) == ARB_OK)) {
fprintf(stderr, "FAIL: get_fork_children on IO sentinel\n");
arboricx_free(ctx);
return 1;
@@ -81,7 +81,7 @@ int main(void) {
/* Verify sentinel string */
uint8_t* decoded_sentinel;
size_t decoded_len;
- if (!arb_to_string(ctx, sent_left, &decoded_sentinel, &decoded_len) ||
+ if (!(arb_to_string(ctx, sent_left, &decoded_sentinel, &decoded_len) == ARB_OK) ||
decoded_len != 7 || memcmp(decoded_sentinel, "tricuIO", 7) != 0) {
fprintf(stderr, "FAIL: IO sentinel string mismatch\n");
arboricx_free(ctx);
@@ -90,10 +90,10 @@ int main(void) {
arboricx_free_buf(ctx, decoded_sentinel, decoded_len);
/* Verify version = 1 and action = pure */
- uint32_t ver, act;
- if (!arb_get_fork_children(ctx, sent_right, &ver, &act) ||
- !arb_to_number(ctx, ver, &tag_num) || tag_num != 1 ||
- act != pure_action) {
+ arb_handle_t ver, act;
+ if (!(arb_get_fork_children(ctx, sent_right, &ver, &act) == ARB_OK) ||
+ !(arb_to_number(ctx, ver, &tag_num) == ARB_OK) || tag_num != 1 ||
+ !same_value(ctx, act, pure_action)) {
fprintf(stderr, "FAIL: IO sentinel version/action mismatch\n");
arboricx_free(ctx);
return 1;
@@ -101,12 +101,12 @@ int main(void) {
printf("PASS: IO sentinel shape\n");
/* Test: putStr action = Fork 10 string */
- uint32_t ten = arb_of_number(ctx, 10);
- uint32_t msg = arb_of_string(ctx, "hello");
- uint32_t putStr_action = arb_fork(ctx, ten, msg);
+ arb_handle_t ten = checked_arb_of_number(ctx, 10);
+ arb_handle_t msg = checked_arb_of_string(ctx, "hello");
+ arb_handle_t putStr_action = checked_arb_fork(ctx, ten, msg);
- if (!arb_get_fork_children(ctx, putStr_action, &tag, &payload) ||
- !arb_to_number(ctx, tag, &tag_num) || tag_num != 10) {
+ if (!(arb_get_fork_children(ctx, putStr_action, &tag, &payload) == ARB_OK) ||
+ !(arb_to_number(ctx, tag, &tag_num) == ARB_OK) || tag_num != 10) {
fprintf(stderr, "FAIL: putStr tag should be 10\n");
arboricx_free(ctx);
return 1;
@@ -114,12 +114,12 @@ int main(void) {
printf("PASS: putStr action shape\n");
/* Test: getLine action = Fork 11 Leaf */
- uint32_t eleven = arb_of_number(ctx, 11);
- uint32_t getLine_action = arb_fork(ctx, eleven, leaf);
+ arb_handle_t eleven = checked_arb_of_number(ctx, 11);
+ arb_handle_t getLine_action = checked_arb_fork(ctx, eleven, leaf);
- if (!arb_get_fork_children(ctx, getLine_action, &tag, &payload) ||
- !arb_to_number(ctx, tag, &tag_num) || tag_num != 11 ||
- payload != leaf) {
+ if (!(arb_get_fork_children(ctx, getLine_action, &tag, &payload) == ARB_OK) ||
+ !(arb_to_number(ctx, tag, &tag_num) == ARB_OK) || tag_num != 11 ||
+ !same_value(ctx, payload, leaf)) {
fprintf(stderr, "FAIL: getLine tag should be 11 with Leaf payload\n");
arboricx_free(ctx);
return 1;
@@ -127,12 +127,12 @@ int main(void) {
printf("PASS: getLine action shape\n");
/* Test: readFile action = Fork 20 path */
- uint32_t twenty = arb_of_number(ctx, 20);
- uint32_t path = arb_of_string(ctx, "/tmp/test.txt");
- uint32_t readFile_action = arb_fork(ctx, twenty, path);
+ arb_handle_t twenty = checked_arb_of_number(ctx, 20);
+ arb_handle_t path = checked_arb_of_string(ctx, "/tmp/test.txt");
+ arb_handle_t readFile_action = checked_arb_fork(ctx, twenty, path);
- if (!arb_get_fork_children(ctx, readFile_action, &tag, &payload) ||
- !arb_to_number(ctx, tag, &tag_num) || tag_num != 20) {
+ if (!(arb_get_fork_children(ctx, readFile_action, &tag, &payload) == ARB_OK) ||
+ !(arb_to_number(ctx, tag, &tag_num) == ARB_OK) || tag_num != 20) {
fprintf(stderr, "FAIL: readFile tag should be 20\n");
arboricx_free(ctx);
return 1;
@@ -140,21 +140,21 @@ int main(void) {
printf("PASS: readFile action shape\n");
/* Test: writeFile action = Fork 21 (Fork path contents) */
- uint32_t twenty_one = arb_of_number(ctx, 21);
- uint32_t contents = arb_of_string(ctx, "data");
- uint32_t write_pair = arb_fork(ctx, path, contents);
- uint32_t writeFile_action = arb_fork(ctx, twenty_one, write_pair);
+ arb_handle_t twenty_one = checked_arb_of_number(ctx, 21);
+ arb_handle_t contents = checked_arb_of_string(ctx, "data");
+ arb_handle_t write_pair = checked_arb_fork(ctx, path, contents);
+ arb_handle_t writeFile_action = checked_arb_fork(ctx, twenty_one, write_pair);
- if (!arb_get_fork_children(ctx, writeFile_action, &tag, &payload) ||
- !arb_to_number(ctx, tag, &tag_num) || tag_num != 21) {
+ if (!(arb_get_fork_children(ctx, writeFile_action, &tag, &payload) == ARB_OK) ||
+ !(arb_to_number(ctx, tag, &tag_num) == ARB_OK) || tag_num != 21) {
fprintf(stderr, "FAIL: writeFile tag should be 21\n");
arboricx_free(ctx);
return 1;
}
- uint32_t wf_path, wf_contents;
- if (!arb_get_fork_children(ctx, payload, &wf_path, &wf_contents) ||
- wf_path != path || wf_contents != contents) {
+ arb_handle_t wf_path, wf_contents;
+ if (!(arb_get_fork_children(ctx, payload, &wf_path, &wf_contents) == ARB_OK) ||
+ !same_value(ctx, wf_path, path) || !same_value(ctx, wf_contents, contents)) {
fprintf(stderr, "FAIL: writeFile payload should be Fork path contents\n");
arboricx_free(ctx);
return 1;
@@ -162,27 +162,27 @@ int main(void) {
printf("PASS: writeFile action shape\n");
/* Test: ok result = Fork (Stem Leaf) (Fork val Leaf) */
- uint32_t stem_leaf = arb_stem(ctx, leaf);
- uint32_t val_pair = arb_fork(ctx, msg, leaf);
- uint32_t ok_result = arb_fork(ctx, stem_leaf, val_pair);
+ arb_handle_t stem_leaf = checked_arb_stem(ctx, leaf);
+ arb_handle_t val_pair = checked_arb_fork(ctx, msg, leaf);
+ arb_handle_t ok_result = checked_arb_fork(ctx, stem_leaf, val_pair);
- if (!arb_is_fork(ctx, ok_result)) {
+ if (!checked_arb_is_fork(ctx, ok_result)) {
fprintf(stderr, "FAIL: ok result should be fork\n");
arboricx_free(ctx);
return 1;
}
- uint32_t ok_tag, ok_rest;
- if (!arb_get_fork_children(ctx, ok_result, &ok_tag, &ok_rest) ||
- !arb_is_stem(ctx, ok_tag)) {
+ arb_handle_t ok_tag, ok_rest;
+ if (!(arb_get_fork_children(ctx, ok_result, &ok_tag, &ok_rest) == ARB_OK) ||
+ !checked_arb_is_stem(ctx, ok_tag)) {
fprintf(stderr, "FAIL: ok result left should be stem\n");
arboricx_free(ctx);
return 1;
}
- uint32_t ok_val, ok_leaf;
- if (!arb_get_fork_children(ctx, ok_rest, &ok_val, &ok_leaf) ||
- ok_val != msg || ok_leaf != leaf) {
+ arb_handle_t ok_val, ok_leaf;
+ if (!(arb_get_fork_children(ctx, ok_rest, &ok_val, &ok_leaf) == ARB_OK) ||
+ !same_value(ctx, ok_val, msg) || !same_value(ctx, ok_leaf, leaf)) {
fprintf(stderr, "FAIL: ok result right should be Fork val Leaf\n");
arboricx_free(ctx);
return 1;
@@ -190,27 +190,27 @@ int main(void) {
printf("PASS: ok result shape\n");
/* Test: err result = Fork Leaf (Fork code Leaf) */
- uint32_t err_code = arb_of_number(ctx, 42);
- uint32_t err_pair = arb_fork(ctx, err_code, leaf);
- uint32_t err_result = arb_fork(ctx, leaf, err_pair);
+ arb_handle_t err_code = checked_arb_of_number(ctx, 42);
+ arb_handle_t err_pair = checked_arb_fork(ctx, err_code, leaf);
+ arb_handle_t err_result = checked_arb_fork(ctx, leaf, err_pair);
- if (!arb_is_fork(ctx, err_result)) {
+ if (!checked_arb_is_fork(ctx, err_result)) {
fprintf(stderr, "FAIL: err result should be fork\n");
arboricx_free(ctx);
return 1;
}
- uint32_t err_tag, err_rest;
- if (!arb_get_fork_children(ctx, err_result, &err_tag, &err_rest) ||
- !arb_is_leaf(ctx, err_tag)) {
+ arb_handle_t err_tag, err_rest;
+ if (!(arb_get_fork_children(ctx, err_result, &err_tag, &err_rest) == ARB_OK) ||
+ !checked_arb_is_leaf(ctx, err_tag)) {
fprintf(stderr, "FAIL: err result left should be leaf\n");
arboricx_free(ctx);
return 1;
}
- uint32_t err_c, err_l;
- if (!arb_get_fork_children(ctx, err_rest, &err_c, &err_l) ||
- err_c != err_code || err_l != leaf) {
+ arb_handle_t err_c, err_l;
+ if (!(arb_get_fork_children(ctx, err_rest, &err_c, &err_l) == ARB_OK) ||
+ !same_value(ctx, err_c, err_code) || !same_value(ctx, err_l, leaf)) {
fprintf(stderr, "FAIL: err result right should be Fork code Leaf\n");
arboricx_free(ctx);
return 1;
diff --git a/ext/zig/tests/io_run_test.c b/ext/zig/tests/io_run_test.c
@@ -1,17 +1,17 @@
#include <stdio.h>
#include <string.h>
-#include "arboricx.h"
+#include "helpers.h"
-static uint32_t make_pure(arb_ctx_t* ctx, uint32_t val) {
- uint32_t zero = arb_of_number(ctx, 0);
- return arb_fork(ctx, zero, val);
+static arb_handle_t make_pure(arb_ctx_t* ctx, arb_handle_t val) {
+ arb_handle_t zero = checked_arb_of_number(ctx, 0);
+ return checked_arb_fork(ctx, zero, val);
}
-static uint32_t make_io_sentinel(arb_ctx_t* ctx, uint32_t action) {
- uint32_t sentinel = arb_of_string(ctx, "tricuIO");
- uint32_t version = arb_of_number(ctx, 1);
- uint32_t version_action = arb_fork(ctx, version, action);
- return arb_fork(ctx, sentinel, version_action);
+static arb_handle_t make_io_sentinel(arb_ctx_t* ctx, arb_handle_t action) {
+ arb_handle_t sentinel = checked_arb_of_string(ctx, "tricuIO");
+ arb_handle_t version = checked_arb_of_number(ctx, 1);
+ arb_handle_t version_action = checked_arb_fork(ctx, version, action);
+ return checked_arb_fork(ctx, sentinel, version_action);
}
int main(void) {
@@ -25,11 +25,11 @@ int main(void) {
/* Test 1: pure "hello" wrapped in IO sentinel */
{
- uint32_t hello = arb_of_string(ctx, "hello");
- uint32_t pure_hello = make_pure(ctx, hello);
- uint32_t program = make_io_sentinel(ctx, pure_hello);
+ arb_handle_t hello = checked_arb_of_string(ctx, "hello");
+ arb_handle_t pure_hello = make_pure(ctx, hello);
+ arb_handle_t program = make_io_sentinel(ctx, pure_hello);
- uint32_t result = arb_run_io(ctx, program, &perms);
+ arb_handle_t result = checked_arb_run_io(ctx, program, &perms);
if (result == 0) {
fprintf(stderr, "FAIL: pure hello returned 0\n");
arboricx_free(ctx);
@@ -38,7 +38,7 @@ int main(void) {
uint8_t* decoded;
size_t decoded_len;
- if (!arb_to_string(ctx, result, &decoded, &decoded_len) ||
+ if (!(arb_to_string(ctx, result, &decoded, &decoded_len) == ARB_OK) ||
decoded_len != 5 || memcmp(decoded, "hello", 5) != 0) {
fprintf(stderr, "FAIL: pure hello result mismatch\n");
arboricx_free(ctx);
@@ -50,19 +50,19 @@ int main(void) {
/* Test 2: bind (pure "a") (\_ : pure "done") */
{
- uint32_t a = arb_of_string(ctx, "a");
- uint32_t done = arb_of_string(ctx, "done");
- uint32_t pure_a = make_pure(ctx, a);
- uint32_t pure_done = make_pure(ctx, done);
+ arb_handle_t a = checked_arb_of_string(ctx, "a");
+ arb_handle_t done = checked_arb_of_string(ctx, "done");
+ arb_handle_t pure_a = make_pure(ctx, a);
+ arb_handle_t pure_done = make_pure(ctx, done);
/* K pure_done = Fork Leaf pure_done */
- uint32_t k = arb_fork(ctx, arb_leaf(ctx), pure_done);
- uint32_t bind_pair = arb_fork(ctx, pure_a, k);
- uint32_t one = arb_of_number(ctx, 1);
- uint32_t bind_action = arb_fork(ctx, one, bind_pair);
- uint32_t program = make_io_sentinel(ctx, bind_action);
+ arb_handle_t k = checked_arb_fork(ctx, checked_arb_leaf(ctx), pure_done);
+ arb_handle_t bind_pair = checked_arb_fork(ctx, pure_a, k);
+ arb_handle_t one = checked_arb_of_number(ctx, 1);
+ arb_handle_t bind_action = checked_arb_fork(ctx, one, bind_pair);
+ arb_handle_t program = make_io_sentinel(ctx, bind_action);
- uint32_t result = arb_run_io(ctx, program, &perms);
+ arb_handle_t result = checked_arb_run_io(ctx, program, &perms);
if (result == 0) {
fprintf(stderr, "FAIL: bind returned 0\n");
arboricx_free(ctx);
@@ -71,7 +71,7 @@ int main(void) {
uint8_t* decoded;
size_t decoded_len;
- if (!arb_to_string(ctx, result, &decoded, &decoded_len) ||
+ if (!(arb_to_string(ctx, result, &decoded, &decoded_len) == ARB_OK) ||
decoded_len != 4 || memcmp(decoded, "done", 4) != 0) {
fprintf(stderr, "FAIL: bind result mismatch\n");
arboricx_free(ctx);
@@ -83,19 +83,19 @@ int main(void) {
/* Test 3: putStr "test" (no permissions needed) */
{
- uint32_t test = arb_of_string(ctx, "test");
- uint32_t ten = arb_of_number(ctx, 10);
- uint32_t putStr_action = arb_fork(ctx, ten, test);
- uint32_t program = make_io_sentinel(ctx, putStr_action);
+ arb_handle_t test = checked_arb_of_string(ctx, "test");
+ arb_handle_t ten = checked_arb_of_number(ctx, 10);
+ arb_handle_t putStr_action = checked_arb_fork(ctx, ten, test);
+ arb_handle_t program = make_io_sentinel(ctx, putStr_action);
printf("EXPECT: test\n");
- uint32_t result = arb_run_io(ctx, program, &perms);
+ arb_handle_t result = checked_arb_run_io(ctx, program, &perms);
if (result == 0) {
fprintf(stderr, "FAIL: putStr returned 0\n");
arboricx_free(ctx);
return 1;
}
- if (!arb_is_leaf(ctx, result)) {
+ if (!checked_arb_is_leaf(ctx, result)) {
fprintf(stderr, "FAIL: putStr should return Leaf\n");
arboricx_free(ctx);
return 1;
@@ -105,12 +105,12 @@ int main(void) {
/* Test 4: readFile without permission returns err */
{
- uint32_t path = arb_of_string(ctx, "/etc/passwd");
- uint32_t twenty = arb_of_number(ctx, 20);
- uint32_t readFile_action = arb_fork(ctx, twenty, path);
- uint32_t program = make_io_sentinel(ctx, readFile_action);
+ arb_handle_t path = checked_arb_of_string(ctx, "/etc/passwd");
+ arb_handle_t twenty = checked_arb_of_number(ctx, 20);
+ arb_handle_t readFile_action = checked_arb_fork(ctx, twenty, path);
+ arb_handle_t program = make_io_sentinel(ctx, readFile_action);
- uint32_t result = arb_run_io(ctx, program, &perms);
+ arb_handle_t result = checked_arb_run_io(ctx, program, &perms);
if (result == 0) {
fprintf(stderr, "FAIL: readFile denied returned 0\n");
arboricx_free(ctx);
@@ -118,17 +118,17 @@ int main(void) {
}
/* Should be an err result: Fork Leaf (Fork code Leaf) */
- uint32_t left, right;
- if (!arb_get_fork_children(ctx, result, &left, &right) ||
- !arb_is_leaf(ctx, left)) {
+ arb_handle_t left, right;
+ if (!(arb_get_fork_children(ctx, result, &left, &right) == ARB_OK) ||
+ !checked_arb_is_leaf(ctx, left)) {
fprintf(stderr, "FAIL: readFile denied should be err result\n");
arboricx_free(ctx);
return 1;
}
- uint32_t code, rest;
- if (!arb_get_fork_children(ctx, right, &code, &rest) ||
- !arb_is_leaf(ctx, rest)) {
+ arb_handle_t code, rest;
+ if (!(arb_get_fork_children(ctx, right, &code, &rest) == ARB_OK) ||
+ !checked_arb_is_leaf(ctx, rest)) {
fprintf(stderr, "FAIL: readFile denied err shape mismatch\n");
arboricx_free(ctx);
return 1;
@@ -137,7 +137,7 @@ int main(void) {
uint8_t* message;
size_t message_len;
const char* expected = "permission denied";
- if (!arb_to_string(ctx, code, &message, &message_len) ||
+ if (!(arb_to_string(ctx, code, &message, &message_len) == ARB_OK) ||
message_len != strlen(expected) ||
memcmp(message, expected, message_len) != 0) {
fprintf(stderr, "FAIL: readFile denied should carry a text error\n");
@@ -162,12 +162,12 @@ int main(void) {
fclose(f);
arb_io_perms_t unsafe_perms = { 1, 0 };
- uint32_t path = arb_of_string(ctx, tmp);
- uint32_t twenty = arb_of_number(ctx, 20);
- uint32_t readFile_action = arb_fork(ctx, twenty, path);
- uint32_t program = make_io_sentinel(ctx, readFile_action);
+ arb_handle_t path = checked_arb_of_string(ctx, tmp);
+ arb_handle_t twenty = checked_arb_of_number(ctx, 20);
+ arb_handle_t readFile_action = checked_arb_fork(ctx, twenty, path);
+ arb_handle_t program = make_io_sentinel(ctx, readFile_action);
- uint32_t result = arb_run_io(ctx, program, &unsafe_perms);
+ arb_handle_t result = checked_arb_run_io(ctx, program, &unsafe_perms);
if (result == 0) {
fprintf(stderr, "FAIL: readFile allowed returned 0\n");
arboricx_free(ctx);
@@ -175,17 +175,17 @@ int main(void) {
}
/* Should be ok result: Fork (Stem Leaf) (Fork val Leaf) */
- uint32_t ok_tag, ok_rest;
- if (!arb_get_fork_children(ctx, result, &ok_tag, &ok_rest) ||
- !arb_is_stem(ctx, ok_tag)) {
+ arb_handle_t ok_tag, ok_rest;
+ if (!(arb_get_fork_children(ctx, result, &ok_tag, &ok_rest) == ARB_OK) ||
+ !checked_arb_is_stem(ctx, ok_tag)) {
fprintf(stderr, "FAIL: readFile allowed should be ok result\n");
arboricx_free(ctx);
return 1;
}
- uint32_t val, leaf;
- if (!arb_get_fork_children(ctx, ok_rest, &val, &leaf) ||
- !arb_is_leaf(ctx, leaf)) {
+ arb_handle_t val, leaf;
+ if (!(arb_get_fork_children(ctx, ok_rest, &val, &leaf) == ARB_OK) ||
+ !checked_arb_is_leaf(ctx, leaf)) {
fprintf(stderr, "FAIL: readFile allowed ok shape mismatch\n");
arboricx_free(ctx);
return 1;
@@ -193,7 +193,7 @@ int main(void) {
uint8_t* decoded;
size_t decoded_len;
- if (!arb_to_string(ctx, val, &decoded, &decoded_len) ||
+ if (!(arb_to_string(ctx, val, &decoded, &decoded_len) == ARB_OK) ||
decoded_len != 2 || memcmp(decoded, "hi", 2) != 0) {
fprintf(stderr, "FAIL: readFile allowed contents mismatch\n");
arboricx_free(ctx);
@@ -205,9 +205,10 @@ int main(void) {
/* Test 6: invalid sentinel returns 0 */
{
- uint32_t bad = arb_fork(ctx, arb_leaf(ctx), arb_leaf(ctx));
- uint32_t result = arb_run_io(ctx, bad, &perms);
- if (result != 0) {
+ arb_handle_t bad = checked_arb_fork(ctx, checked_arb_leaf(ctx), checked_arb_leaf(ctx));
+ arb_handle_t result = 123;
+ arb_status_t failure = arb_run_io(ctx, bad, &perms, &result);
+ if (failure != ARB_IO_ERROR || result != 123) {
fprintf(stderr, "FAIL: invalid sentinel should return 0\n");
arboricx_free(ctx);
return 1;
diff --git a/ext/zig/tests/native_bundle_append_test.c b/ext/zig/tests/native_bundle_append_test.c
@@ -2,7 +2,7 @@
#include <stdlib.h>
#include <string.h>
#include <time.h>
-#include "../include/arboricx.h"
+#include "helpers.h"
static uint8_t *read_file(const char *path, size_t *out_len) {
FILE *f = fopen(path, "rb");
@@ -27,51 +27,51 @@ int main() {
printf("bundle size=%zu\n", bundle_len);
clock_t t0 = clock();
- uint32_t term = arb_load_bundle(ctx, bundle, bundle_len, "append");
+ arb_handle_t term = checked_arb_load_bundle(ctx, bundle, bundle_len, "append");
clock_t t1 = clock();
- printf("load_bundle took %.3f ms, term=%u\n", (double)(t1 - t0) * 1000.0 / CLOCKS_PER_SEC, term);
+ printf("load_bundle took %.3f ms, term=%" PRIu64 "\n", (double)(t1 - t0) * 1000.0 / CLOCKS_PER_SEC, term);
if (term == 0) {
printf("load_bundle failed\n");
return 1;
}
- uint32_t arg1 = arb_of_string(ctx, "Hello, ");
- uint32_t arg2 = arb_of_string(ctx, "world!");
- printf("arg1=%u arg2=%u\n", arg1, arg2);
+ arb_handle_t arg1 = checked_arb_of_string(ctx, "Hello, ");
+ arb_handle_t arg2 = checked_arb_of_string(ctx, "world!");
+ printf("arg1=%" PRIu64 " arg2=%" PRIu64 "\n", arg1, arg2);
- uint32_t app0 = arb_app(ctx, term, arg1);
- uint32_t app1 = arb_app(ctx, app0, arg2);
- printf("app1=%u\n", app1);
+ arb_handle_t app0 = checked_arb_app(ctx, term, arg1);
+ arb_handle_t app1 = checked_arb_app(ctx, app0, arg2);
+ printf("app1=%" PRIu64 "\n", app1);
printf("reducing...\n");
clock_t t2 = clock();
- uint32_t result = arb_reduce(ctx, app1, 1000000000ULL);
+ arb_handle_t result = checked_arb_reduce(ctx, app1, 1000000000ULL);
clock_t t3 = clock();
- printf("reduce took %.3f ms, result=%u\n", (double)(t3 - t2) * 1000.0 / CLOCKS_PER_SEC, result);
+ printf("reduce took %.3f ms, result=%" PRIu64 "\n", (double)(t3 - t2) * 1000.0 / CLOCKS_PER_SEC, result);
/* Try decoding as a plain string first (direct call, no kernel wrapper) */
uint8_t *str_ptr;
size_t str_len;
- if (arb_to_string(ctx, result, &str_ptr, &str_len)) {
+ if ((arb_to_string(ctx, result, &str_ptr, &str_len) == ARB_OK)) {
printf("RESULT: %.*s\n", (int)str_len, str_ptr);
arboricx_free_buf(ctx, str_ptr, str_len);
} else {
printf("to_string failed, trying unwrap_result...\n");
int ok;
- uint32_t value, rest;
- if (!arb_unwrap_result(ctx, result, &ok, &value, &rest)) {
+ arb_handle_t value, rest;
+ if (!(arb_unwrap_result(ctx, result, &ok, &value, &rest) == ARB_OK)) {
printf("unwrap_result also failed\n");
return 1;
}
- printf("unwrap_result: ok=%d value=%u\n", ok, value);
+ printf("unwrap_result: ok=%d value=%" PRIu64 "\n", ok, value);
uint64_t htag;
- uint32_t payload;
- if (!arb_unwrap_host_value(ctx, value, &htag, &payload)) {
+ arb_handle_t payload;
+ if (!(arb_unwrap_host_value(ctx, value, &htag, &payload) == ARB_OK)) {
printf("unwrap_host_value failed\n");
return 1;
}
- printf("htag=%lu payload=%u\n", htag, payload);
- if (arb_to_string(ctx, payload, &str_ptr, &str_len)) {
+ printf("htag=%lu payload=%" PRIu64 "\n", htag, payload);
+ if ((arb_to_string(ctx, payload, &str_ptr, &str_len) == ARB_OK)) {
printf("RESULT: %.*s\n", (int)str_len, str_ptr);
arboricx_free_buf(ctx, str_ptr, str_len);
}
diff --git a/ext/zig/tests/native_bundle_bools_test.c b/ext/zig/tests/native_bundle_bools_test.c
@@ -2,7 +2,7 @@
#include <stdlib.h>
#include <string.h>
#include <time.h>
-#include "../include/arboricx.h"
+#include "helpers.h"
static uint8_t *read_file(const char *path, size_t *out_len) {
FILE *f = fopen(path, "rb");
@@ -21,17 +21,17 @@ int test_bundle(arb_ctx_t *ctx, const char *path, const char *name, int expect_v
uint8_t *bundle = read_file(path, &bundle_len);
if (!bundle) { printf("bundle not found: %s\n", path); return 1; }
- uint32_t term = arb_load_bundle(ctx, bundle, bundle_len, name);
+ arb_handle_t term = checked_arb_load_bundle(ctx, bundle, bundle_len, name);
if (term == 0) {
printf("load_bundle failed for %s\n", path);
free(bundle);
return 1;
}
- uint32_t result = arb_reduce(ctx, term, 1000000000ULL);
+ arb_handle_t result = checked_arb_reduce(ctx, term, 1000000000ULL);
int b;
- if (!arb_to_bool(ctx, result, &b)) {
+ if (!(arb_to_bool(ctx, result, &b) == ARB_OK)) {
printf("to_bool failed for %s\n", path);
free(bundle);
return 1;
diff --git a/ext/zig/tests/native_bundle_id_test.c b/ext/zig/tests/native_bundle_id_test.c
@@ -2,7 +2,7 @@
#include <stdlib.h>
#include <string.h>
#include <time.h>
-#include "../include/arboricx.h"
+#include "helpers.h"
static uint8_t *read_file(const char *path, size_t *out_len) {
FILE *f = fopen(path, "rb");
@@ -26,26 +26,26 @@ int main() {
printf("bundle size=%zu\n", bundle_len);
clock_t t0 = clock();
- uint32_t term = arb_load_bundle(ctx, bundle, bundle_len, "id");
+ arb_handle_t term = checked_arb_load_bundle(ctx, bundle, bundle_len, "id");
clock_t t1 = clock();
- printf("load_bundle took %.3f ms, term=%u\n", (double)(t1 - t0) * 1000.0 / CLOCKS_PER_SEC, term);
+ printf("load_bundle took %.3f ms, term=%" PRIu64 "\n", (double)(t1 - t0) * 1000.0 / CLOCKS_PER_SEC, term);
if (term == 0) {
printf("load_bundle failed\n");
return 1;
}
- uint32_t arg1 = arb_of_string(ctx, "hello");
- uint32_t app0 = arb_app(ctx, term, arg1);
+ arb_handle_t arg1 = checked_arb_of_string(ctx, "hello");
+ arb_handle_t app0 = checked_arb_app(ctx, term, arg1);
printf("reducing...\n");
clock_t t2 = clock();
- uint32_t result = arb_reduce(ctx, app0, 1000000000ULL);
+ arb_handle_t result = checked_arb_reduce(ctx, app0, 1000000000ULL);
clock_t t3 = clock();
- printf("reduce took %.3f ms, result=%u\n", (double)(t3 - t2) * 1000.0 / CLOCKS_PER_SEC, result);
+ printf("reduce took %.3f ms, result=%" PRIu64 "\n", (double)(t3 - t2) * 1000.0 / CLOCKS_PER_SEC, result);
uint8_t *str_ptr;
size_t str_len;
- if (arb_to_string(ctx, result, &str_ptr, &str_len)) {
+ if ((arb_to_string(ctx, result, &str_ptr, &str_len) == ARB_OK)) {
printf("RESULT: %.*s\n", (int)str_len, str_ptr);
arboricx_free_buf(ctx, str_ptr, str_len);
} else {
diff --git a/ext/zig/tests/performance_smoke.c b/ext/zig/tests/performance_smoke.c
@@ -0,0 +1,155 @@
+/* Correctness-checked ABI v2 performance smoke. No compiler execution.
+ * Times exclude context/kernel loading; process peak RSS includes everything.
+ * The library's optimization mode is specified by the Nix package, not gcc. */
+#define _POSIX_C_SOURCE 200809L
+#ifndef ARB_HOST_MODE
+#error "Set ARB_HOST_MODE to the linked runtime's optimization mode"
+#endif
+#include "arboricx.h"
+#include <stdio.h>
+#include <stdlib.h>
+#include <stdint.h>
+#include <string.h>
+#include <time.h>
+#include <sys/resource.h>
+#include <unistd.h>
+
+#define CHECK(x) do { if (!(x)) { fprintf(stderr, "failed: %s:%d: %s\n", __FILE__, __LINE__, #x); exit(1); } } while (0)
+#define OK(x) CHECK((x) == ARB_OK)
+static double now(void) {
+ struct timespec t; CHECK(clock_gettime(CLOCK_MONOTONIC, &t) == 0);
+ return (double)t.tv_sec + (double)t.tv_nsec / 1e9;
+}
+static long rss_kib(void) {
+ FILE *f = fopen("/proc/self/statm", "r");
+ if (!f) return -1;
+ long pages = 0, resident = 0;
+ CHECK(fscanf(f, "%ld %ld", &pages, &resident) == 2);
+ fclose(f);
+ return resident * sysconf(_SC_PAGESIZE) / 1024;
+}
+static long peak_rss_kib(void) {
+ // VmHWM belongs to this executable's address space. ru_maxrss can include
+ // the launcher's pre-exec address space, obscuring small-workload peaks.
+ FILE *f = fopen("/proc/self/status", "r"); CHECK(f);
+ char line[256]; long peak = -1;
+ while (fgets(line, sizeof line, f)) {
+ if (sscanf(line, "VmHWM: %ld kB", &peak) == 1) break;
+ }
+ fclose(f); CHECK(peak >= 0); return peak;
+}
+static arb_handle_t load(arb_ctx_t *ctx, const char *path) {
+ FILE *f = fopen(path, "rb"); CHECK(f);
+ CHECK(fseek(f, 0, SEEK_END) == 0); long n = ftell(f); CHECK(n > 0);
+ rewind(f); uint8_t *buf = malloc((size_t)n); CHECK(buf);
+ CHECK(fread(buf, 1, (size_t)n, f) == (size_t)n); fclose(f);
+ arb_handle_t h; OK(arb_load_bundle_default(ctx, buf, (size_t)n, &h));
+ free(buf); return h;
+}
+int main(int argc, char **argv) {
+ CHECK(argc == 5); CHECK(arb_abi_version() == 2);
+ // Apply limits before runtime initialization without a Python preexec hook.
+ struct rlimit memory = {512 * 1024 * 1024, 512 * 1024 * 1024};
+ struct rlimit stack = {8 * 1024 * 1024, 8 * 1024 * 1024};
+ struct rlimit cpu = {60, 60}, core = {0, 0};
+ CHECK(setrlimit(RLIMIT_AS, &memory) == 0);
+ CHECK(setrlimit(RLIMIT_STACK, &stack) == 0);
+ CHECK(setrlimit(RLIMIT_CPU, &cpu) == 0);
+ CHECK(setrlimit(RLIMIT_CORE, &core) == 0);
+ const char *kind = argv[1];
+ size_t n = strtoul(argv[2], NULL, 10), rounds = strtoul(argv[3], NULL, 10);
+ CHECK(n > 0 && rounds > 0);
+ int strings = strcmp(kind, "id") == 0 || strcmp(kind, "append") == 0;
+ int append = strcmp(kind, "append") == 0;
+ int distribution = strcmp(kind, "distribution") == 0;
+ CHECK(strings || distribution || strcmp(kind, "deep") == 0);
+ double start = now();
+ arb_ctx_t *ctx = arboricx_init(); CHECK(ctx);
+ arb_handle_t leaf, function;
+ OK(arb_leaf(ctx, &leaf));
+ if (strings) {
+ function = load(ctx, argv[4]);
+ } else if (distribution) {
+ // I = Fork (Stem K) K, K = Stem Leaf; I x distributes before returning x.
+ arb_handle_t k, sk;
+ OK(arb_stem(ctx, leaf, &k)); OK(arb_stem(ctx, k, &sk));
+ OK(arb_fork(ctx, sk, k, &function));
+ OK(arb_release(ctx, sk)); OK(arb_release(ctx, k));
+ } else {
+ OK(arb_retain(ctx, leaf, &function));
+ }
+ double init_s = now() - start;
+ uint8_t *input = malloc(n), *second = malloc(n); CHECK(input && second);
+ double build_s = 0, reduce_s = 0, observe_s = 0, gc_s = 0;
+ long rss_mid = -1, rss_end = -1;
+ for (size_t r = 0; r < rounds; ++r) {
+ /* Fresh, round-dependent bytes also prevent cross-host harnesses from
+ * accidentally sharing one already-evaluated Haskell result. */
+ if (strings) for (size_t j = 0; j < n; ++j) {
+ input[j] = (uint8_t)('a' + (j + r) % 26);
+ second[j] = (uint8_t)('A' + (j + r) % 26);
+ }
+ start = now();
+ arb_handle_t term, result;
+ if (strings) {
+ arb_handle_t arg;
+ OK(arb_of_bytes(ctx, input, n, &arg));
+ OK(arb_app(ctx, function, arg, &term));
+ if (append) {
+ arb_handle_t next, arg2;
+ OK(arb_of_bytes(ctx, second, n, &arg2));
+ OK(arb_app(ctx, term, arg2, &next));
+ OK(arb_release(ctx, arg2));
+ OK(arb_release(ctx, term)); term = next;
+ }
+ OK(arb_release(ctx, arg));
+ } else {
+ OK(arb_retain(ctx, leaf, &term));
+ for (size_t j = 0; j < n; ++j) {
+ arb_handle_t next;
+ if (distribution) {
+ OK(arb_app(ctx, function, term, &next));
+ } else {
+ arb_handle_t k;
+ OK(arb_fork(ctx, leaf, term, &k));
+ OK(arb_app(ctx, k, leaf, &next));
+ OK(arb_release(ctx, k));
+ }
+ OK(arb_release(ctx, term)); term = next;
+ }
+ }
+ build_s += now() - start;
+ start = now();
+ OK(arb_reduce(ctx, term, 1000000000, &result));
+ reduce_s += now() - start;
+ OK(arb_release(ctx, term));
+ start = now();
+ if (strings) {
+ uint8_t *out; size_t len;
+ OK(arb_to_bytes(ctx, result, &out, &len));
+ CHECK(len == n * (append ? 2 : 1));
+ CHECK(memcmp(out, input, n) == 0);
+ if (append) CHECK(memcmp(out + n, second, n) == 0);
+ arboricx_free_buf(ctx, out, len);
+ } else {
+ int is_leaf; OK(arb_is_leaf(ctx, result, &is_leaf)); CHECK(is_leaf);
+ }
+ observe_s += now() - start;
+ OK(arb_release(ctx, result));
+ // Batch reclamation rather than forcing one collection per tiny call.
+ if ((r + 1) % 16 == 0 || r + 1 == rounds || r + 1 == rounds / 2) {
+ start = now(); OK(arb_collect(ctx)); gc_s += now() - start;
+ if (r + 1 == rounds / 2) rss_mid = rss_kib();
+ if (r + 1 == rounds) rss_end = rss_kib();
+ }
+ }
+ long peak = peak_rss_kib();
+ printf("{\"host_mode\":\"%s\",\"kind\":\"%s\",\"n\":%zu,\"rounds\":%zu,\"init_s\":%.9f,"
+ "\"build_s\":%.9f,\"reduce_s\":%.9f,\"observe_s\":%.9f,\"gc_s\":%.9f,"
+ "\"peak_rss_kib\":%ld,\"mid_rss_kib\":%ld,\"end_rss_kib\":%ld}\n",
+ ARB_HOST_MODE, kind, n, rounds, init_s, build_s, reduce_s, observe_s, gc_s,
+ peak, rss_mid, rss_end);
+ free(input); free(second); OK(arb_release(ctx, function)); OK(arb_release(ctx, leaf));
+ arboricx_free(ctx);
+ return 0;
+}
diff --git a/ext/zig/tests/performance_smoke.py b/ext/zig/tests/performance_smoke.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+"""Run each host smoke in a fresh, resource-limited process.
+
+Three samples are descriptive, not a statistically controlled benchmark.
+Every child validates its complete result before reporting a timing.
+"""
+import hashlib
+import json
+import os
+from pathlib import Path
+import platform
+import statistics
+import subprocess
+import sys
+import time
+
+runner, fixtures, output = sys.argv[1:]
+fixtures = Path(fixtures)
+
+
+# The C executable applies limits before initializing the runtime.
+
+cases = [(kind, n, 5) for kind in ("id", "append") for n in (256, 4096, 16384)]
+cases += [(kind, n, 1) for kind in ("deep", "distribution") for n in (1000, 10000, 100000)]
+cases += [("append", 4096, 256)]
+report = {
+ "platform": platform.platform(),
+ "machine": platform.machine(), "cpu_count": os.cpu_count(),
+ "cpu_model": next((line.split(":", 1)[1].strip() for line in
+ Path("/proc/cpuinfo").read_text().splitlines()
+ if line.startswith("model name")), "unknown"),
+ "rss_measurement": "Linux /proc/self/status VmHWM (peak), /proc/self/statm (mid/end)",
+ "limits": {"address_space_mib": 512, "stack_mib": 8, "wall_seconds": 65, "cpu_seconds": 60},
+ "samples_per_case": 3,
+ "runner": runner,
+ "fixture_sha256": {name: hashlib.sha256((fixtures / name).read_bytes()).hexdigest()
+ for name in ("id.arboricx", "append.arboricx")},
+ "cases": [],
+}
+for kind, n, rounds in cases:
+ samples = []
+ for _ in range(3):
+ fixture = fixtures / ("append.arboricx" if kind == "append" else "id.arboricx")
+ started = time.monotonic()
+ completed = subprocess.run([runner, kind, str(n), str(rounds), str(fixture)],
+ text=True, capture_output=True, timeout=65)
+ if completed.returncode:
+ raise RuntimeError(f"{kind}/{n}/{rounds}: exit {completed.returncode}\n{completed.stderr}")
+ sample = json.loads(completed.stdout)
+ if "host_mode" not in report:
+ report["host_mode"] = sample["host_mode"]
+ if sample["host_mode"] != report["host_mode"]:
+ raise RuntimeError("mixed runtime optimization modes")
+ sample["process_wall_s"] = time.monotonic() - started
+ samples.append(sample)
+ entry = {"kind": kind, "n": n, "rounds": rounds, "samples": samples}
+ entry["median_ms_per_iteration"] = {
+ field: statistics.median(s[field + "_s"] for s in samples) * 1000 / rounds
+ for field in ("build", "reduce", "observe", "gc")}
+ entry["max_peak_rss_mib"] = max(s["peak_rss_kib"] for s in samples) / 1024
+ report["cases"].append(entry)
+ print(f"{kind}/{n} x{rounds}: {entry['median_ms_per_iteration']} peak={entry['max_peak_rss_mib']:.1f} MiB", flush=True)
+Path(output).write_text(json.dumps(report, indent=2) + "\n")
diff --git a/ext/zig/tests/python_ffi_test.py b/ext/zig/tests/python_ffi_test.py
@@ -22,52 +22,78 @@ lib.arboricx_free.argtypes = [ctypes.c_void_p]
# --- Tree construction ---
lib.arb_leaf.argtypes = [ctypes.c_void_p]
-lib.arb_leaf.restype = ctypes.c_uint32
-lib.arb_stem.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
-lib.arb_stem.restype = ctypes.c_uint32
-lib.arb_fork.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32]
-lib.arb_fork.restype = ctypes.c_uint32
-lib.arb_app.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32]
-lib.arb_app.restype = ctypes.c_uint32
+lib.arb_leaf.argtypes += [ctypes.POINTER(ctypes.c_uint64)]
+lib.arb_leaf.restype = ctypes.c_int
+lib.arb_stem.argtypes = [ctypes.c_void_p, ctypes.c_uint64]
+lib.arb_stem.argtypes += [ctypes.POINTER(ctypes.c_uint64)]
+lib.arb_stem.restype = ctypes.c_int
+lib.arb_fork.argtypes = [ctypes.c_void_p, ctypes.c_uint64, ctypes.c_uint64]
+lib.arb_fork.argtypes += [ctypes.POINTER(ctypes.c_uint64)]
+lib.arb_fork.restype = ctypes.c_int
+lib.arb_app.argtypes = [ctypes.c_void_p, ctypes.c_uint64, ctypes.c_uint64]
+lib.arb_app.argtypes += [ctypes.POINTER(ctypes.c_uint64)]
+lib.arb_app.restype = ctypes.c_int
# --- Reduction ---
-lib.arb_reduce.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint64]
-lib.arb_reduce.restype = ctypes.c_uint32
+lib.arb_reduce.argtypes = [ctypes.c_void_p, ctypes.c_uint64, ctypes.c_uint64]
+lib.arb_reduce.argtypes += [ctypes.POINTER(ctypes.c_uint64)]
+lib.arb_reduce.restype = ctypes.c_int
# --- Codecs ---
lib.arb_of_number.argtypes = [ctypes.c_void_p, ctypes.c_uint64]
-lib.arb_of_number.restype = ctypes.c_uint32
+lib.arb_of_number.argtypes += [ctypes.POINTER(ctypes.c_uint64)]
+lib.arb_of_number.restype = ctypes.c_int
lib.arb_of_string.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
-lib.arb_of_string.restype = ctypes.c_uint32
+lib.arb_of_string.argtypes += [ctypes.POINTER(ctypes.c_uint64)]
+lib.arb_of_string.restype = ctypes.c_int
lib.arb_of_bytes.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t]
-lib.arb_of_bytes.restype = ctypes.c_uint32
-lib.arb_of_list.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint32), ctypes.c_size_t]
-lib.arb_of_list.restype = ctypes.c_uint32
-lib.arb_to_number.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint64)]
+lib.arb_of_bytes.argtypes += [ctypes.POINTER(ctypes.c_uint64)]
+lib.arb_of_bytes.restype = ctypes.c_int
+lib.arb_of_list.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint64), ctypes.c_size_t]
+lib.arb_of_list.argtypes += [ctypes.POINTER(ctypes.c_uint64)]
+lib.arb_of_list.restype = ctypes.c_int
+lib.arb_to_number.argtypes = [ctypes.c_void_p, ctypes.c_uint64, ctypes.POINTER(ctypes.c_uint64)]
lib.arb_to_number.restype = ctypes.c_int
-lib.arb_to_string.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(ctypes.POINTER(ctypes.c_uint8)), ctypes.POINTER(ctypes.c_size_t)]
+lib.arb_to_string.argtypes = [ctypes.c_void_p, ctypes.c_uint64, ctypes.POINTER(ctypes.POINTER(ctypes.c_uint8)), ctypes.POINTER(ctypes.c_size_t)]
lib.arb_to_string.restype = ctypes.c_int
-lib.arb_to_bool.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(ctypes.c_int)]
+lib.arb_to_bool.argtypes = [ctypes.c_void_p, ctypes.c_uint64, ctypes.POINTER(ctypes.c_int)]
lib.arb_to_bool.restype = ctypes.c_int
lib.arboricx_free_buf.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t]
# --- Result unwrapping ---
-lib.arb_unwrap_result.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32)]
+lib.arb_unwrap_result.argtypes = [ctypes.c_void_p, ctypes.c_uint64, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_uint64), ctypes.POINTER(ctypes.c_uint64)]
lib.arb_unwrap_result.restype = ctypes.c_int
-lib.arb_unwrap_host_value.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint64), ctypes.POINTER(ctypes.c_uint32)]
+lib.arb_unwrap_host_value.argtypes = [ctypes.c_void_p, ctypes.c_uint64, ctypes.POINTER(ctypes.c_uint64), ctypes.POINTER(ctypes.c_uint64)]
lib.arb_unwrap_host_value.restype = ctypes.c_int
# --- Kernel ---
lib.arb_kernel_root.argtypes = [ctypes.c_void_p]
-lib.arb_kernel_root.restype = ctypes.c_uint32
+lib.arb_kernel_root.argtypes += [ctypes.POINTER(ctypes.c_uint64)]
+lib.arb_kernel_root.restype = ctypes.c_int
# --- Native bundle loading ---
lib.arb_load_bundle.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t, ctypes.c_char_p]
-lib.arb_load_bundle.restype = ctypes.c_uint32
+lib.arb_load_bundle.argtypes += [ctypes.POINTER(ctypes.c_uint64)]
+lib.arb_load_bundle.restype = ctypes.c_int
lib.arb_load_bundle_default.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t]
-lib.arb_load_bundle_default.restype = ctypes.c_uint32
+lib.arb_load_bundle_default.argtypes += [ctypes.POINTER(ctypes.c_uint64)]
+lib.arb_load_bundle_default.restype = ctypes.c_int
+lib.arb_release.argtypes = [ctypes.c_void_p, ctypes.c_uint64]
+lib.arb_release.restype = ctypes.c_int
+lib.arb_collect.argtypes = [ctypes.c_void_p]
+lib.arb_collect.restype = ctypes.c_int
+lib.arb_abi_version.restype = ctypes.c_uint32
+assert lib.arb_abi_version() == 2
+
+def owned(name, *args):
+ out = ctypes.c_uint64()
+ status = getattr(lib, name)(*args, ctypes.byref(out))
+ if status != 0:
+ raise RuntimeError(f"{name}: status {status}")
+ return out.value
+
ctx = lib.arboricx_init()
print("ctx init ok")
@@ -88,7 +114,7 @@ def c_bytes(py_bytes):
def to_string(ctx, root):
ptr = ctypes.POINTER(ctypes.c_uint8)()
length = ctypes.c_size_t()
- if not lib.arb_to_string(ctx, root, ctypes.byref(ptr), ctypes.byref(length)):
+ if lib.arb_to_string(ctx, root, ctypes.byref(ptr), ctypes.byref(length)) != 0:
raise RuntimeError("to_string failed")
result = bytes(ptr[i] for i in range(length.value))
lib.arboricx_free_buf(ctx, ptr, length.value)
@@ -97,14 +123,14 @@ def to_string(ctx, root):
def to_number(ctx, root):
out = ctypes.c_uint64()
- if not lib.arb_to_number(ctx, root, ctypes.byref(out)):
+ if lib.arb_to_number(ctx, root, ctypes.byref(out)) != 0:
raise RuntimeError("to_number failed")
return out.value
def to_bool(ctx, root):
out = ctypes.c_int()
- if not lib.arb_to_bool(ctx, root, ctypes.byref(out)):
+ if lib.arb_to_bool(ctx, root, ctypes.byref(out)) != 0:
raise RuntimeError("to_bool failed")
return bool(out.value)
@@ -112,26 +138,26 @@ def to_bool(ctx, root):
def kernel_run(bundle_bytes, args):
"""Run via the Tricu kernel interpreter (slow, ~3s for append)."""
buf = c_bytes(bundle_bytes)
- bundle_tree = lib.arb_of_bytes(ctx, buf, len(bundle_bytes))
- tag = lib.arb_of_number(ctx, 1)
+ bundle_tree = owned("arb_of_bytes", ctx, buf, len(bundle_bytes))
+ tag = owned("arb_of_number", ctx, 1)
arg_items = []
for a in args:
- arg_items.append(lib.arb_of_string(ctx, a.encode("utf-8")))
- current = lib.arb_leaf(ctx)
+ arg_items.append(owned("arb_of_string", ctx, a.encode("utf-8")))
+ current = owned("arb_leaf", ctx)
for item in reversed(arg_items):
- current = lib.arb_fork(ctx, item, current)
- app0 = lib.arb_app(ctx, lib.arb_kernel_root(ctx), tag)
- app1 = lib.arb_app(ctx, app0, bundle_tree)
- app2 = lib.arb_app(ctx, app1, current)
- result = lib.arb_reduce(ctx, app2, 1_000_000_000)
+ current = owned("arb_fork", ctx, item, current)
+ app0 = owned("arb_app", ctx, owned("arb_kernel_root", ctx), tag)
+ app1 = owned("arb_app", ctx, app0, bundle_tree)
+ app2 = owned("arb_app", ctx, app1, current)
+ result = owned("arb_reduce", ctx, app2, 1_000_000_000)
ok = ctypes.c_int()
- value = ctypes.c_uint32()
- rest = ctypes.c_uint32()
- if not lib.arb_unwrap_result(ctx, result, ctypes.byref(ok), ctypes.byref(value), ctypes.byref(rest)):
+ value = ctypes.c_uint64()
+ rest = ctypes.c_uint64()
+ if lib.arb_unwrap_result(ctx, result, ctypes.byref(ok), ctypes.byref(value), ctypes.byref(rest)) != 0:
raise RuntimeError("unwrap_result failed")
tag_num = ctypes.c_uint64()
- payload = ctypes.c_uint32()
- if not lib.arb_unwrap_host_value(ctx, value.value, ctypes.byref(tag_num), ctypes.byref(payload)):
+ payload = ctypes.c_uint64()
+ if lib.arb_unwrap_host_value(ctx, value.value, ctypes.byref(tag_num), ctypes.byref(payload)) != 0:
raise RuntimeError("unwrap_host_value failed")
return to_string(ctx, payload.value)
@@ -139,28 +165,28 @@ def kernel_run(bundle_bytes, args):
def native_run_default(bundle_bytes, args):
"""Run via native bundle loader (fast, ~0.01s)."""
buf = c_bytes(bundle_bytes)
- term = lib.arb_load_bundle_default(ctx, buf, len(bundle_bytes))
+ term = owned("arb_load_bundle_default", ctx, buf, len(bundle_bytes))
if term == 0:
raise RuntimeError("load_bundle_default failed")
current = term
for a in args:
- arg_tree = lib.arb_of_string(ctx, a.encode("utf-8"))
- current = lib.arb_app(ctx, current, arg_tree)
- result = lib.arb_reduce(ctx, current, 1_000_000_000)
+ arg_tree = owned("arb_of_string", ctx, a.encode("utf-8"))
+ current = owned("arb_app", ctx, current, arg_tree)
+ result = owned("arb_reduce", ctx, current, 1_000_000_000)
return to_string(ctx, result)
def native_run_named(bundle_bytes, name, args):
"""Run via native bundle loader with named export (fast)."""
buf = c_bytes(bundle_bytes)
- term = lib.arb_load_bundle(ctx, buf, len(bundle_bytes), name.encode("utf-8"))
+ term = owned("arb_load_bundle", ctx, buf, len(bundle_bytes), name.encode("utf-8"))
if term == 0:
raise RuntimeError(f"load_bundle({name!r}) failed")
current = term
for a in args:
- arg_tree = lib.arb_of_string(ctx, a.encode("utf-8"))
- current = lib.arb_app(ctx, current, arg_tree)
- result = lib.arb_reduce(ctx, current, 1_000_000_000)
+ arg_tree = owned("arb_of_string", ctx, a.encode("utf-8"))
+ current = owned("arb_app", ctx, current, arg_tree)
+ result = owned("arb_reduce", ctx, current, 1_000_000_000)
return to_string(ctx, result)
@@ -227,20 +253,98 @@ print("\n--- Test 6: true / false (native path) ---")
for name, expected in [("true.arboricx", True), ("false.arboricx", False)]:
bundle = read_bundle(name)
buf = c_bytes(bundle)
- term = lib.arb_load_bundle_default(ctx, buf, len(bundle))
- result = lib.arb_reduce(ctx, term, 1_000_000_000)
+ term = owned("arb_load_bundle_default", ctx, buf, len(bundle))
+ result = owned("arb_reduce", ctx, term, 1_000_000_000)
check(f"{name} bool", to_bool(ctx, result), expected)
# Test 7: number roundtrip
print("\n--- Test 7: number roundtrip ---")
-num_tree = lib.arb_of_number(ctx, 42)
+num_tree = owned("arb_of_number", ctx, 42)
check("number 42", to_number(ctx, num_tree), 42)
# Test 8: string roundtrip
print("\n--- Test 8: string roundtrip ---")
-str_tree = lib.arb_of_string(ctx, b"hello")
+str_tree = owned("arb_of_string", ctx, b"hello")
check("string hello", to_string(ctx, str_tree), "hello")
+# Independent canonical writer oracle, exercised through the public IO ABI.
+# Build runtime graphs separately so shared/unshared equal children cannot be
+# hidden by a matching bug in the native loader and serializer.
+import hashlib
+import random
+import struct
+import tempfile
+from pathlib import Path
+sys.path.insert(0, os.path.join(ZIG_DIR, "..", "..", "test"))
+from executable_object import indexed_manifest, verify_payload, INDEXED
+
+class Perms(ctypes.Structure):
+ _fields_ = [("read", ctypes.c_int), ("write", ctypes.c_int)]
+lib.arb_run_io.argtypes = [ctypes.c_void_p, ctypes.c_uint64, ctypes.POINTER(Perms), ctypes.POINTER(ctypes.c_uint64)]
+lib.arb_run_io.restype = ctypes.c_int
+
+def pair(a, b):
+ return owned("arb_fork", ctx, a, b)
+
+def action(tag, payload):
+ program = pair(owned("arb_of_string", ctx, b"tricuIO"),
+ pair(owned("arb_of_number", ctx, 1),
+ pair(owned("arb_of_number", ctx, tag), payload)))
+ result = owned("arb_run_io", ctx, program, ctypes.byref(Perms(1, 1)))
+ ok, value, rest = ctypes.c_int(), ctypes.c_uint64(), ctypes.c_uint64()
+ assert lib.arb_unwrap_result(ctx, result, ctypes.byref(ok), ctypes.byref(value), ctypes.byref(rest)) == 0
+ assert ok.value, to_string(ctx, value.value)
+ return value.value
+
+rng = random.Random(510052)
+with tempfile.TemporaryDirectory() as directory:
+ for case in range(24):
+ model, handles = [(0,)], [owned("arb_leaf", ctx)]
+ for i in range(1, 40):
+ tag = rng.randrange(3)
+ children = tuple(rng.randrange(i) for _ in range(tag))
+ model.append((tag, *children))
+ handles.append(owned(("arb_leaf", "arb_stem", "arb_fork")[tag], ctx,
+ *(handles[c] for c in children)))
+ canonical, by_key, memo = [], {}, {}
+ def visit(i):
+ if i in memo:
+ return memo[i]
+ key = (model[i][0], *(visit(c) for c in model[i][1:]))
+ if key not in by_key:
+ by_key[key] = len(canonical)
+ canonical.append(key)
+ memo[i] = by_key[key]
+ return memo[i]
+ root = visit(len(model)-1)
+ manifest = indexed_manifest(root)
+ nodes = struct.pack('>Q', len(canonical))
+ for tag, *children in canonical:
+ nodes += struct.pack('>IB', 1 + 4*tag, tag) + b''.join(struct.pack('>I', c) for c in children)
+ expected = b'ARBORICX' + struct.pack('>HHIQQ', 1, 0, 2, 0, 32)
+ offset = 96
+ for kind, data in [(1, manifest), (2, nodes)]:
+ expected += struct.pack('>IHHHHQQI', kind, 1, 1, 0, 0, offset, len(data), 0)
+ offset += len(data)
+ expected += manifest + nodes
+ verify_payload(INDEXED, expected)
+ digest = hashlib.sha256(INDEXED.encode() + b'\0' + expected).hexdigest()
+ check(f"indexed independent hash {case}", to_string(ctx, action(51, handles[-1])), digest)
+ path = Path(directory) / str(case)
+ path_tree = owned("arb_of_string", ctx, str(path).encode())
+ hash_tree = owned("arb_of_string", ctx, digest.encode())
+ write = pair(path_tree, pair(hash_tree, handles[-1]))
+ assert to_bool(ctx, action(52, write)) is True
+ assert path.read_bytes() == expected
+ assert to_bool(ctx, action(52, write)) is False
+ decoded = action(50, pair(path_tree, hash_tree))
+ assert to_string(ctx, action(51, decoded)) == digest
+ # Malformed bundle failure must not publish or clobber the output handle.
+ bad = expected[:31]
+ out = ctypes.c_uint64(123456)
+ assert lib.arb_load_bundle_default(ctx, c_bytes(bad), len(bad), ctypes.byref(out)) == 5
+ assert out.value == 123456
+
lib.arboricx_free(ctx)
if all_ok:
diff --git a/flake.nix b/flake.nix
@@ -134,11 +134,14 @@
pname = "tricu-zig";
version = "0.1.0";
src = ./ext/zig;
+ passthru.optimizationMode = "ReleaseSafe";
nativeBuildInputs = [ pkgs.zig pkgs.pkg-config ];
buildInputs = [ pkgs.libuv ];
buildPhase = ''
export ZIG_GLOBAL_CACHE_DIR=$TMPDIR/zig-cache
- zig build
+ zig build -Doptimize=ReleaseSafe
+ zig build test -Doptimize=ReleaseSafe
+ zig build test -Doptimize=Debug
'';
installPhase = ''
mkdir -p $out/bin $out/lib $out/include
@@ -148,6 +151,26 @@
'';
};
+ # Explicit, Linux-only smoke measurements; not a timing threshold gate.
+ tricuZigPerformanceSmoke = pkgs.stdenv.mkDerivation {
+ pname = "tricu-zig-performance-smoke";
+ version = "0.1.0";
+ src = ./.;
+ nativeBuildInputs = [ pkgs.gcc pkgs.python3 ];
+ buildPhase = ''
+ gcc -O2 -Wall -Wextra -Werror ext/zig/tests/performance_smoke.c \
+ -DARB_HOST_MODE='"${tricuZig.optimizationMode}"' \
+ -I ${tricuZig}/include -L ${tricuZig}/lib -larboricx \
+ -Wl,-rpath,${tricuZig}/lib -o performance-smoke
+ '';
+ installPhase = ''
+ mkdir -p $out/bin
+ cp performance-smoke $out/bin/
+ python3 ext/zig/tests/performance_smoke.py \
+ $out/bin/performance-smoke test/fixtures $out/report.json
+ '';
+ };
+
tricuZigTests = pkgs.stdenv.mkDerivation {
pname = "tricu-zig-tests";
version = "0.1.0";
@@ -162,6 +185,14 @@
cd ext/zig
+ gcc -Wall -Wextra -Werror -o /tmp/handles_test tests/handles_test.c \
+ -I ${tricuZig}/include -L ${tricuZig}/lib -larboricx \
+ -Wl,-rpath,${tricuZig}/lib
+ /tmp/handles_test
+
+ test "$(tricu-zig --type string ../../test/fixtures/id.arboricx hello)" = hello
+ test "$(tricu-zig --type string --kernel ../../test/fixtures/append.arboricx 'Hello, ' 'world!')" = 'Hello, world!'
+
# C ABI smoke test
gcc -o /tmp/c_abi_test tests/c_abi_test.c \
-I ${tricuZig}/include -L ${tricuZig}/lib -larboricx \
@@ -267,7 +298,7 @@
cd ext/js
# node_modules are pre-fetched by buildNpmPackage; copy them in
- cp -r ${tricuJs}/lib/tricu-js/node_modules .
+ cp -r ${tricuJs}/lib/node_modules .
npm test
mkdir -p $out
@@ -292,6 +323,8 @@
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!"
@@ -307,6 +340,51 @@
packages.${packageName} = tricuPackage;
packages.default = tricuPackage;
# 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.
+ packages.reducerComparison = pkgs.stdenv.mkDerivation {
+ pname = "tricu-reducer-comparison";
+ version = "0.1.0";
+ src = ./.;
+ nativeBuildInputs = [ pkgs.gcc pkgs.makeWrapper
+ (haskellPackages.ghcWithPackages (p: [
+ p.cryptonite p.memory p.base16-bytestring p.vector
+ ])) ];
+ buildPhase = ''
+ ghc -O2 -threaded -rtsopts -XOverloadedStrings -XScopedTypeVariables \
+ -XMultiWayIf -XLambdaCase -isrc -outputdir objects \
+ bench/ReducerSmoke.hs -o haskell-reducer-smoke
+ gcc -O2 -Wall -Wextra -Werror ext/zig/tests/performance_smoke.c \
+ -DARB_HOST_MODE='"${tricuZig.optimizationMode}"' \
+ -I ${tricuZig}/include -L ${tricuZig}/lib -larboricx \
+ -Wl,-rpath,${tricuZig}/lib -o zig-reducer-smoke
+ '';
+ doCheck = true;
+ checkPhase = ''
+ for kind in id append deep distribution; do
+ fixture=test/fixtures/id.arboricx
+ if [ "$kind" = append ]; then fixture=test/fixtures/append.arboricx; fi
+ ./haskell-reducer-smoke "$kind" 64 2 "$fixture" +RTS -N1 -M512m -K8m -RTS
+ ./zig-reducer-smoke "$kind" 64 2 "$fixture"
+ done
+ # A real but wrong worker must fail complete-result validation.
+ if ./haskell-reducer-smoke id 256 1 test/fixtures/append.arboricx \
+ +RTS -N1 -M512m -K8m -RTS >bad-haskell.log 2>&1; then exit 1; fi
+ grep -q 'incorrect complete result' bad-haskell.log
+ if ./zig-reducer-smoke id 256 1 test/fixtures/append.arboricx \
+ >bad-zig.log 2>&1; then exit 1; fi
+ grep -q 'failed:' bad-zig.log
+ '';
+ installPhase = ''
+ mkdir -p $out/bin $out/libexec
+ cp haskell-reducer-smoke zig-reducer-smoke $out/bin/
+ cp test/reducer_comparison.py test/process_rss.py $out/libexec/
+ ghc --version > $out/compiler-version
+ makeWrapper ${pkgs.python3}/bin/python3 $out/bin/compare-reducers \
+ --add-flags "$out/libexec/reducer_comparison.py $out/bin/haskell-reducer-smoke $out/bin/zig-reducer-smoke ${./test/fixtures}"
+ '';
+ meta.mainProgram = "compare-reducers";
+ };
packages.inspectorOracle = pkgs.runCommand "arboricx-inspector-oracle" {
nativeBuildInputs = [ (haskellPackages.ghcWithPackages (p: [
p.cryptonite p.memory p.base16-bytestring p.vector
@@ -354,6 +432,7 @@
packages.tricu-zig = tricuZig;
packages.tricu-zig-tests = tricuZigTests;
+ packages.tricu-zig-performance-smoke = tricuZigPerformanceSmoke;
packages.tricu-php = tricuPhp;
packages.tricu-php-tests = tricuPhpTests;
packages.tricu-js = tricuJs;
diff --git a/test/bootstrap_acceptance.py b/test/bootstrap_acceptance.py
@@ -19,6 +19,43 @@ def sha(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
+def verify_generation(run, runtime, source_root=None, execution_host='haskell'):
+ """Authenticate and independently audit one full fresh compiler generation.
+
+ Execution host is caller-selected, never inferred from untrusted provenance.
+ Pair acceptance below remains explicitly Haskell-only.
+ """
+ status = json.loads((run / 'status.json').read_text())
+ inputs = json.loads((run / 'inputs.json').read_text())
+ if not status.get('complete') or status.get('backend') != 'stable':
+ raise ValueError('completed stable generations required')
+ if not inputs.get('fresh_store') or not status.get('fresh_store'):
+ raise ValueError('fresh-store execution evidence required; seeded checkpoints are not reproduction')
+ if any(status.get(key) != value for key, value in inputs.items()):
+ raise ValueError('status/input provenance mismatch')
+ if inputs.get('execution_host', 'haskell') != execution_host:
+ raise ValueError('execution host mismatch')
+ root = source_root or Path(inputs['source_root'])
+ text, entries, _ = source_map(run / 'source-map.v1', root, 'source_map')
+ actual = snapshot(text, entries, root)
+ if inputs.get('root') != 'source_map' or any(inputs.get(k) != v for k, v in actual.items()):
+ raise ValueError('frozen source/map fingerprint mismatch')
+ if sha(runtime) != inputs['runtime_sha256']:
+ raise ValueError('runtime identity mismatch')
+ if sha(run / 'compiler.arboricx') != inputs['compiler_bundle_sha256']:
+ raise ValueError('compiler bundle identity mismatch')
+ if sha(run / 'io-adapter.arboricx') != inputs['adapter_sha256']:
+ raise ValueError('adapter identity mismatch')
+ for name in ARTIFACTS:
+ if sha(run / name) != status['artifacts'][name]['sha256']:
+ raise ValueError('packaged artifact identity mismatch: ' + name)
+ command = [sys.executable, str(REPO / 'test/bootstrap_store_check.py'), str(run)]
+ if source_root:
+ command += ['--source-root', str(source_root)]
+ audit, _ = run_guarded(command, memory_mib=8192, seconds=600)
+ return inputs, status, audit
+
+
def verify(first, second, tricu, source_root=None):
first, second = first.resolve(), second.resolve()
if first == second:
@@ -26,32 +63,7 @@ def verify(first, second, tricu, source_root=None):
records = []
audits = []
for run in (first, second):
- status = json.loads((run / 'status.json').read_text())
- inputs = json.loads((run / 'inputs.json').read_text())
- if not status.get('complete') or status.get('backend') != 'stable':
- raise ValueError('completed stable generations required')
- if not inputs.get('fresh_store') or not status.get('fresh_store'):
- raise ValueError('fresh-store execution evidence required; seeded checkpoints are not reproduction')
- if any(status.get(key) != value for key, value in inputs.items()):
- raise ValueError('status/input provenance mismatch')
- root = source_root or Path(inputs['source_root'])
- text, entries, _ = source_map(run / 'source-map.v1', root, 'source_map')
- actual = snapshot(text, entries, root)
- if inputs.get('root') != 'source_map' or any(inputs.get(k) != v for k, v in actual.items()):
- raise ValueError('frozen source/map fingerprint mismatch')
- if sha(tricu) != inputs['runtime_sha256']:
- raise ValueError('runtime identity mismatch')
- if sha(run / 'compiler.arboricx') != inputs['compiler_bundle_sha256']:
- raise ValueError('compiler bundle identity mismatch')
- if sha(run / 'io-adapter.arboricx') != inputs['adapter_sha256']:
- raise ValueError('adapter identity mismatch')
- for name in ARTIFACTS:
- if sha(run / name) != status['artifacts'][name]['sha256']:
- raise ValueError('packaged artifact identity mismatch: ' + name)
- command = [sys.executable, str(REPO / 'test/bootstrap_store_check.py'), str(run)]
- if source_root:
- command += ['--source-root', str(source_root)]
- audit, _ = run_guarded(command, memory_mib=8192, seconds=600)
+ inputs, _, audit = verify_generation(run, tricu, source_root)
audits.append(audit)
records.append(inputs)
for key in ('sources', 'map_sha256', 'backend', 'root', 'runtime_sha256', 'adapter_sha256'):
diff --git a/test/bootstrap_cross_host.py b/test/bootstrap_cross_host.py
@@ -0,0 +1,234 @@
+#!/usr/bin/env python3
+"""Opt-in isolated H1 -> H2 -> {H3,Z3} reproduction, or bounded native_math probe.
+
+Every job sees only /source, /kit, /work, /nix/store and ephemeral /tmp,/proc,/dev.
+The generation supervisor runs inside isolation and monitors the execution
+child. Haskell supplies common artifact transport and packaging.
+"""
+import argparse
+import json
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import tarfile
+import time
+
+from bootstrap_acceptance import ARTIFACTS, sha, verify, verify_generation
+from bootstrap_generations import source_map, snapshot
+from bootstrap_inputs import load_kit
+from bootstrap_retention_lock import protect_generations
+from process_rss import run_guarded
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def save(path, value):
+ path.write_text(json.dumps(value, indent=2)+'\n')
+
+
+def inventory(run):
+ return {p.name:sha(p) for p in (run/'cas/objects').glob('*/*')}
+
+
+def verify_cross(first, second, control, zig, runtime, library, source):
+ if len({p.resolve() for p in (first,second,control,zig)}) != 4:
+ raise ValueError('four distinct fresh generations required')
+ pair = verify(first, second, runtime, source)
+ control_pair = verify(second, control, runtime, source)
+ zi, zs, audit = verify_generation(zig, library, source, 'zig')
+ hi = control_pair['inputs'][1]
+ for key in ('sources','map_sha256','backend','root','adapter_sha256',
+ 'compiler_bundle_sha256','compiler_tree','compiler_tree_kind',
+ 'worker_export','backend_options'):
+ if zi[key] != hi[key]:
+ raise ValueError('cross-host input mismatch: '+key)
+ if zi.get('transport_runtime_sha256') != sha(runtime) or zi.get('transport_host') != 'haskell':
+ raise ValueError('cross-host transport identity mismatch')
+ if zi.get('executor_sha256') != sha(source/'test/zig_generation_executor.py'):
+ raise ValueError('execution launcher identity mismatch')
+ request = json.loads((zig/'execution-request.json').read_text())
+ if request != dict(worker_export=zi['worker_export'],
+ compiler_bundle_sha256=zi['compiler_bundle_sha256'],
+ adapter_sha256=zi['adapter_sha256'],
+ arguments=['/source','/work/generation/overlay','/work/generation/cas',
+ zi['compiler_bundle_sha256'],(zig/'source-map.v1').read_text(),'source_map']):
+ raise ValueError('isolated Zig execution request mismatch')
+ for name in ARTIFACTS:
+ if sha(second/name) != sha(zig/name):
+ raise ValueError('cross-host packaged artifact mismatch: '+name)
+ # Byte-identical full inventories include manifests, receipts and every
+ # published indexed object, not just a subset of selected export hashes.
+ expected = inventory(second)
+ for run in (control,zig):
+ if inventory(run) != expected:
+ raise ValueError('complete canonical object/receipt inventory differs: '+str(run))
+ for alias in hi['sources']:
+ if (second/'cas/cache/source-build'/alias).read_bytes() != (run/'cas/cache/source-build'/alias).read_bytes():
+ raise ValueError('complete checkpoint mismatch: '+alias)
+ return dict(accepted=True, scope='cross-host-fixed-point-not-retention',
+ generations=list(map(str,(first,second,control,zig))),
+ haskell_pair=pair, haskell_control=control_pair,
+ zig_inputs=zi, zig_audit=audit, objects=expected,
+ artifacts={name:sha(zig/name) for name in ARTIFACTS})
+
+
+def sandbox_command(source, kit, stage, runtime, library, host, root, memory, seconds):
+ command = ['unshare','--user','--map-root-user','--mount','--pid','--fork',
+ '--kill-child','--mount-proc','--','bwrap','--die-with-parent','--unshare-all',
+ '--ro-bind','/nix/store','/nix/store','--proc','/proc','--dev','/dev',
+ '--tmpfs','/tmp','--ro-bind',str(source),'/source',
+ '--ro-bind',str(kit),'/kit','--bind',str(stage),'/work',
+ '--chdir','/source','--setenv','HOME','/tmp','--setenv','TMPDIR','/tmp',
+ '--setenv','PYTHONDONTWRITEBYTECODE','1',
+ sys.executable,'/source/test/bootstrap_generations.py','/work/generation',
+ '--bootstrap-kit','/kit','--map','/source/bootstrap/compiler-source-map.v1',
+ '--source-root','/source','--root',root,'--backend','stable','--fresh',
+ '--tricu',str(runtime),'--execution-host',host,'--capabilities','8',
+ '--memory-mib',str(memory),'--wall-seconds',str(seconds),
+ '--initial-seconds',str(min(600,seconds)),
+ '--extended-seconds',str(min(1800,seconds))]
+ if host=='zig': command += ['--zig-library',str(library)]
+ return command
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument('--directory',type=Path,required=True)
+ p.add_argument('--source-commit',required=True)
+ p.add_argument('--tricu',type=Path,required=True)
+ p.add_argument('--zig-library',type=Path,required=True)
+ p.add_argument('--probe',action='store_true',help='native_math on both hosts, 1 GiB/120s per job')
+ p.add_argument('--verify-only',action='store_true')
+ p.add_argument('--continue-experiment',action='store_true',help='re-audit completed jobs and retry pre-execution launch failures')
+ args=p.parse_args()
+ directory=args.directory.resolve()
+ runtime,library=args.tricu.resolve(),args.zig_library.resolve()
+ source=directory/'source'
+ locks=protect_generations([directory])
+ if args.verify_only:
+ evidence=verify_cross(*(directory/label/'generation' for label in ('H1','H2','H3','Z3')),
+ runtime,library,source)
+ print(json.dumps(evidence,indent=2))
+ return
+ commit=subprocess.check_output(['git','rev-parse',args.source_commit+'^{commit}'],cwd=ROOT,text=True).strip()
+ archive=directory/'source.tar'
+ if args.continue_experiment:
+ original=json.loads((directory/'experiment.json').read_text())
+ if (args.probe or original['source_commit']!=commit or original['source_archive_sha256']!=sha(archive)
+ or original['runtime']!=dict(path=str(runtime),sha256=sha(runtime))
+ or original['zig_library']!=dict(path=str(library),sha256=sha(library))):
+ raise ValueError('continuation requires identical frozen sources and runtimes')
+ with tarfile.open(archive) as tar:
+ for member in tar:
+ if member.isfile() and (source/member.name).read_bytes()!=tar.extractfile(member).read():
+ raise ValueError('frozen source changed: '+member.name)
+ # Preserve the failed attempt, including its exact original tooling.
+ shutil.copyfile(directory/'report.json',directory/('report-before-'+str(time.time_ns())+'.json'))
+ else:
+ directory.mkdir(parents=True,exist_ok=False)
+ with archive.open('wb') as out:
+ subprocess.run(['git','archive',commit],cwd=ROOT,stdout=out,check=True)
+ source.mkdir()
+ with tarfile.open(archive) as tar:
+ # Historical tracked build-output symlink, excluded by recovery
+ # archives too. Keep all other extraction safety checks.
+ tar.extractall(source,members=(m for m in tar if m.name!='ext/zig/result'),filter='data')
+ # The sandbox also mounts this snapshot read-only; never use live sources.
+ map_text,entries,required=source_map(source/'bootstrap/compiler-source-map.v1',source,'source_map')
+ attempt='continuation-'+str(time.time_ns()) if args.continue_experiment else 'harness'
+ harness=directory/attempt/'test'
+ harness.mkdir(parents=True)
+ for path in (ROOT/'test').glob('*.py'): shutil.copyfile(path,harness/path.name)
+ tooling={path.name:sha(path) for path in harness.glob('*.py')}
+ memory,seconds=(1024,120) if args.probe else (32768,3600)
+ definition=dict(schema='tricu.cross-host-experiment.v1',source_commit=commit,
+ source_archive_sha256=sha(archive), excluded_source_paths=['ext/zig/result'],
+ runtime=dict(path=str(runtime),sha256=sha(runtime)),
+ zig_library=dict(path=str(library),sha256=sha(library)),
+ python=dict(path=sys.executable,sha256=sha(Path(sys.executable))),
+ harness_sha256=sha(Path(__file__)), tooling_sha256=tooling,
+ isolation_tools={name:dict(path=str(Path(shutil.which(name)).resolve()),sha256=sha(Path(shutil.which(name)))) for name in ('unshare','bwrap')},
+ limits=dict(memory_mib=memory,wall_seconds=seconds,capabilities=8),
+ expected='H2 = H3 = Z3: complete objects, receipts, checkpoints and three bundles',
+ comparison_exclusions=['runtime/transport provenance','timings','logs','filesystem locations','invocation records'],
+ **snapshot(map_text,entries,source))
+ save(directory/(attempt+'-definition.json' if args.continue_experiment else 'experiment.json'),definition)
+ report=dict(complete=False,scope='probe' if args.probe else 'full',stages=[])
+ save(directory/'report.json',report)
+ def job(label,kit,host):
+ # Verify the kit before crossing the sandbox boundary; the shared runner
+ # independently captures/authenticates it again inside.
+ load_kit(kit,'stable')
+ stage=directory/label
+ if args.continue_experiment and stage.exists():
+ if (stage/'generation/status.json').is_file():
+ inputs,_,_=verify_generation(stage/'generation',runtime if host=='haskell' else library,source,host)
+ if inputs['compiler_bundle_sha256']!=sha(kit/'rebuilt-compiler.arboricx') or inputs['adapter_sha256']!=sha(kit/'io-adapter.arboricx'):
+ raise ValueError('completed job used a different kit')
+ report['stages'].append(label)
+ save(directory/'report.json',report)
+ print('Re-audited completed '+label,flush=True)
+ return stage/'generation'
+ if (stage/'generation').exists():
+ raise ValueError('cannot resume a partially executed generation; select a fresh experiment')
+ stage.rename(directory/(label+'-launch-failed-'+str(time.time_ns())))
+ stage.mkdir()
+ command=sandbox_command(source,kit,stage,runtime,library,host,
+ 'native_math' if args.probe else 'source_map',memory,seconds)
+ save(stage/'sandbox-command.json',command)
+ try:
+ reply,_=run_guarded(command,memory_mib=memory,seconds=seconds+30)
+ except Exception as error:
+ (stage/'supervisor.log').write_text(str(getattr(error,'output',''))+'\n'+str(error))
+ report['failure']=label
+ save(directory/'report.json',report)
+ raise
+ (stage/'supervisor.log').write_text(reply)
+ report['stages'].append(label)
+ save(directory/'report.json',report)
+ print('Completed isolated '+label,flush=True)
+ return stage/'generation'
+ def kit_from(run,label):
+ kit=directory/(label+'-kit')
+ if kit.exists():
+ load_kit(kit,'stable')
+ for name in ('rebuilt-compiler.arboricx','io-adapter.arboricx'):
+ if (kit/name).read_bytes()!=(run/name).read_bytes():
+ raise ValueError('retained kit differs from parent generation')
+ return kit
+ kit.mkdir()
+ artifacts={}
+ for role,name,export in [('compiler','rebuilt-compiler.arboricx','sourceMapBuildStable'),
+ ('adapter','io-adapter.arboricx','main')]:
+ shutil.copyfile(run/name,kit/name)
+ artifacts[role]=dict(file=name,export=export,sha256=sha(kit/name))
+ save(kit/'bootstrap-kit.json',dict(schema='tricu.bootstrap-kit.v1',backend='stable',
+ parent_generation=str(run),artifacts=artifacts))
+ return kit
+ first=job('H1',source/'bootstrap/rebuild','haskell')
+ if args.probe:
+ zig=job('Z3',source/'bootstrap/rebuild','zig')
+ if inventory(first)!=inventory(zig): raise ValueError('probe complete object inventories differ')
+ else:
+ second=job('H2',kit_from(first,'H1'),'haskell')
+ accepted=verify(first,second,runtime,source)
+ if (directory/'accepted-pair.json').exists():
+ if json.loads((directory/'accepted-pair.json').read_text())!=accepted:
+ raise ValueError('existing pair acceptance changed')
+ else:
+ save(directory/'accepted-pair.json',accepted)
+ kit=kit_from(second,'H2')
+ control=job('H3',kit,'haskell')
+ zig=job('Z3',kit,'zig')
+ save(directory/'accepted-cross-host.json',verify_cross(first,second,control,zig,runtime,library,source))
+ if tooling != {name:sha(ROOT/'test'/name) for name in tooling}:
+ raise ValueError('experiment tooling changed during execution')
+ report['complete']=True
+ save(directory/'report.json',report)
+ label='Isolated mapped-library probe' if args.probe else 'Cross-runtime compiler reproduction'
+ print(label+' verified: '+str(directory))
+
+
+if __name__=='__main__':
+ main()
diff --git a/test/bootstrap_cross_host_controls.py b/test/bootstrap_cross_host_controls.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+"""Check verifier rejection of corrupted copies of a completed experiment.
+Restore the copies and verify acceptance again.
+"""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+import shutil
+import subprocess
+from bootstrap_acceptance import sha
+from bootstrap_cross_host import verify_cross
+from native_indexed_store import indexed_bytes
+from base_workloads import encode
+
+
+def main():
+ p=argparse.ArgumentParser(description=__doc__)
+ p.add_argument('experiment',type=Path)
+ p.add_argument('--directory',type=Path,required=True)
+ a=p.parse_args(); a.directory.mkdir(parents=True,exist_ok=False)
+ e=a.experiment.resolve(); definition=json.loads((e/'experiment.json').read_text())
+ runtime=Path(definition['runtime']['path']); library=Path(definition['zig_library']['path'])
+ first,second,control,original=(e/name/'generation' for name in ('H1','H2','H3','Z3'))
+ source=e/'source'; copy=a.directory/'zig-copy'
+ shutil.copytree(original,copy)
+ report=dict(complete=False,rejected=[],acceptance_sha256=sha(e/'accepted-cross-host.json'))
+ def reject(label,expected,zig=copy):
+ try: verify_cross(first,second,control,zig,runtime,library,source)
+ except (ValueError,AssertionError,subprocess.CalledProcessError) as error:
+ if expected and expected not in str(error): raise
+ report['rejected'].append(label)
+ (a.directory/(label+'.log')).write_text(str(error)+'\n'+str(getattr(error,'output','')))
+ else: raise AssertionError('invalid experiment accepted: '+label)
+ try:
+ reject('same-directory','four distinct',zig=control)
+ for key,value,message in [('execution_host','haskell','execution host mismatch'),
+ ('runtime_sha256','0'*64,'runtime identity mismatch'),
+ ('fresh_store',False,'fresh-store execution evidence'),
+ ('compiler_bundle_sha256','0'*64,'compiler bundle identity mismatch')]:
+ saved={name:(copy/name).read_bytes() for name in ('inputs.json','status.json')}
+ for name,payload in saved.items():
+ record=json.loads(payload); record[key]=value
+ (copy/name).write_text(json.dumps(record))
+ reject(key,message)
+ for name,payload in saved.items(): (copy/name).write_bytes(payload)
+ status=json.loads((copy/'status.json').read_text())
+ digest=status['artifacts']['build-stable.arboricx']['tree']
+ obj=copy/'cas/objects'/digest[:3]/digest
+ original_object=obj.read_bytes(); obj.write_bytes(b'corrupt')
+ reject('corrupt-object',None)
+ obj.write_bytes(original_object)
+ package=copy/'build-stable.arboricx'; original_package=package.read_bytes()
+ package.write_bytes(original_package[:-1]+bytes([original_package[-1]^1]))
+ status['artifacts']['build-stable.arboricx']['sha256']=sha(package)
+ (copy/'status.json').write_text(json.dumps(status))
+ reject('different-authenticated-package','cross-host packaged artifact mismatch')
+ package.write_bytes(original_package)
+ shutil.copyfile(original/'status.json',copy/'status.json')
+ extra=indexed_bytes(encode(987654321))
+ digest=hashlib.sha256(b'arboricx.indexed-tree.v1\0'+extra).hexdigest()
+ obj=copy/'cas/objects'/digest[:3]/digest
+ assert not obj.exists(); obj.parent.mkdir(exist_ok=True); obj.write_bytes(extra)
+ reject('additional-valid-object','complete canonical object/receipt inventory differs')
+ obj.unlink()
+ # The restored copy must pass, so failure is not caused merely by relocation.
+ verify_cross(first,second,control,copy,runtime,library,source)
+ report.update(complete=True,restored_copy_passes=True)
+ print('PASS: '+str(len(report['rejected']))+' cross-host rejection controls; restored copy accepted')
+ finally: (a.directory/'report.json').write_text(json.dumps(report,indent=2)+'\n')
+
+
+if __name__=='__main__': main()
diff --git a/test/bootstrap_cross_host_smoke.py b/test/bootstrap_cross_host_smoke.py
@@ -0,0 +1,54 @@
+#!/usr/bin/env python3
+"""Observe the post-generation program on Zig against independent complete trees.
+Requires the Haskell post-acceptance smoke and cross-host reproduction evidence.
+Compilation of this program was Haskell-executed; compiler bundles are bit-identical.
+"""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+import sys
+from bootstrap_smoke import smoke_cases
+from bootstrap_acceptance import sha
+from process_rss import run_guarded
+
+ROOT=Path(__file__).resolve().parent.parent
+
+
+def main():
+ p=argparse.ArgumentParser(description=__doc__)
+ p.add_argument('experiment',type=Path)
+ p.add_argument('--library',type=Path,required=True)
+ p.add_argument('--directory',type=Path,required=True)
+ a=p.parse_args(); a.directory.mkdir(parents=True,exist_ok=False)
+ accepted=json.loads((a.experiment/'accepted-cross-host.json').read_text())
+ hs=a.experiment/'haskell-smoke'
+ previous=json.loads((hs/'report.json').read_text())
+ assert accepted['accepted'] and previous['complete']
+ assert sha(a.library)==accepted['zig_inputs']['runtime_sha256']
+ assert previous['compiler_sha256']==accepted['artifacts']['build-stable.arboricx']
+ assert sha(hs/'program.arboricx')==previous['bundle_sha256']
+ report=dict(complete=False,compiler_sha256=previous['compiler_sha256'],
+ bundle_sha256=previous['bundle_sha256'],runtime_sha256=sha(a.library),
+ acceptance_sha256=sha(a.experiment/'accepted-cross-host.json'),
+ compilation_host='haskell',execution_host='zig',
+ observer_sha256=sha(ROOT/'test/zig_observe.py'),cases=[])
+ try:
+ for i,(export,argument,expected) in enumerate(smoke_cases()):
+ input_path=a.directory/(str(i)+'.input.tree'); input_path.write_bytes(argument)
+ output=a.directory/(str(i)+'.output.tree')
+ reply,peak=run_guarded([sys.executable,str(ROOT/'test/zig_observe.py'),
+ '--library',str(a.library),'--bundle',str(hs/'program.arboricx'),
+ '--export',export,'--argument',str(input_path),'--output',str(output)],
+ memory_mib=512,seconds=65)
+ (a.directory/(str(i)+'.log')).write_text(reply)
+ assert output.read_bytes()==expected, (export,i)
+ assert sha(output)==previous['cases'][i]['output_sha256']
+ report['cases'].append(dict(export=export,input_sha256=sha(input_path),output_sha256=sha(output),peak_kib=peak))
+ report.update(complete=True,wrong_output_rejected=(output.read_bytes()!=b'\0'))
+ assert report['wrong_output_rejected']
+ print('PASS: 12 complete results on Zig, including recursive lists and invoice totals/text')
+ finally: (a.directory/'report.json').write_text(json.dumps(report,indent=2)+'\n')
+
+
+if __name__=='__main__': main()
diff --git a/test/bootstrap_generations.py b/test/bootstrap_generations.py
@@ -83,6 +83,8 @@ def main():
parser.add_argument('previous', type=Path, nargs='?', help='completed generation; omit when selecting --bootstrap-kit')
parser.add_argument('output', type=Path)
parser.add_argument('--tricu', default='result/bin/tricu')
+ parser.add_argument('--execution-host', choices=('haskell', 'zig'), default='haskell')
+ parser.add_argument('--zig-library', type=Path, help='explicit Nix-built owned-ABI library; required for Zig execution')
parser.add_argument('--backend', choices=BACKENDS, default=DEFAULT_BACKEND,
help='generation lowering backend (default: %(default)s); never inferred from previous')
parser.add_argument('--worker-bundle', type=Path, help='explicit pinned transition worker exporting the selected backend entrypoint; no fallback')
@@ -103,6 +105,10 @@ def main():
args = parser.parse_args()
if not 0 < args.memory_mib <= 65536 or args.capabilities <= 0 or not 0 < args.wall_seconds <= 7200 or not 0 < args.initial_seconds <= args.extended_seconds:
parser.error('invalid resource boundaries')
+ if (args.execution_host == 'zig') != (args.zig_library is not None):
+ parser.error('--zig-library is required exactly when --execution-host zig is selected')
+ if args.zig_library and not args.zig_library.is_file():
+ parser.error('selected Zig library is unavailable; no fallback')
previous = args.previous.resolve() if args.previous else None
output = args.output.resolve()
from bootstrap_retention_lock import protect_generations
@@ -211,7 +217,13 @@ def main():
bootstrap_kit_sha256=kit_digest,
compiler_bundle_sha256=compiler, compiler_manifest=compiler_manifest,
adapter_sha256=hashlib.sha256(adapter_bytes).hexdigest(),
- runtime_sha256=hashlib.sha256(Path(tricu).read_bytes()).hexdigest(), **inputs)
+ execution_host=args.execution_host,
+ runtime_path=str(args.zig_library.resolve()) if args.zig_library else tricu,
+ runtime_sha256=hashlib.sha256((args.zig_library or Path(tricu)).read_bytes()).hexdigest(),
+ transport_host='haskell', transport_runtime_path=tricu,
+ transport_runtime_sha256=hashlib.sha256(Path(tricu).read_bytes()).hexdigest(),
+ executor_sha256=hashlib.sha256((REPO / 'test/zig_generation_executor.py').read_bytes()).hexdigest() if args.zig_library else None,
+ **inputs)
(output / 'inputs.json').write_text(json.dumps(provenance, indent=2) + '\n')
(output / 'source-map.v1').write_text(map_text)
cmd = [tricu, 'arboricx', 'run', '--store', str(output / 'compiler-cas'),
@@ -221,6 +233,16 @@ def main():
'--arg', compiler, '--arg', map_text, '--arg', args.root,
'--io', '--unsafe-io', '--result', '--format', 'decode',
'+RTS', '-N' + str(args.capabilities), '-M' + str(args.memory_mib) + 'm', '-T', '-s', '-RTS']
+ if args.execution_host == 'zig':
+ request = dict(worker_export=worker_export, compiler_bundle_sha256=compiler,
+ adapter_sha256=provenance['adapter_sha256'],
+ arguments=[str(source_root), str(output / 'overlay'), str(output / 'cas'),
+ compiler, map_text, args.root])
+ (output / 'execution-request.json').write_text(json.dumps(request, indent=2) + '\n')
+ import math
+ cmd = [sys.executable, str(REPO / 'test/zig_generation_executor.py'),
+ '--library', str(args.zig_library.resolve()), '--output', str(output),
+ '--memory-mib', str(args.memory_mib), '--seconds', str(math.ceil(remaining()))]
(output / 'invocation.json').write_text(json.dumps(cmd, indent=2))
log = output / f'native-{time.time_ns()}.log'
(output / 'status.json').write_text(json.dumps(dict(complete=False,
diff --git a/test/bootstrap_smoke.py b/test/bootstrap_smoke.py
@@ -46,6 +46,20 @@ def sha(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
+def smoke_cases():
+ cases = [('imports', b'\0', n([23,0])), ('imports', b'\1\0', n([23,1])),
+ ('imports', b'\2\0\0', n([23,2]))]
+ for values in [[],[7],list(range(32))]:
+ cases += [('copy',n(values),n(values)),('reverseLocal',n(values),n(values[::-1]))]
+ for entries in [[],[(3,125),(2,49),(0,999),(1,1000)],[(9,9999),(11,12345)]]:
+ argument = b'\0'
+ for quantity,price in reversed(entries):
+ argument = pair(pair(n(quantity),n(price)),argument)
+ total = sum(q*p for q,p in entries)
+ cases.append(('invoice',argument,result(True,pair(n(total),n(list(str(total).encode()))))))
+ return cases
+
+
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('tricu', type=Path)
@@ -91,16 +105,7 @@ def main():
imported = runner.run('native-import', ['import','--file',bundle,'--store',runner.store])
pin = re.search(r'Created module alias .* -> ([0-9a-f]{64})', imported)[1]
assert pin == report['modules']['program']['manifest']
- cases = [('imports', b'\0', n([23,0])), ('imports', b'\1\0', n([23,1])),
- ('imports', b'\2\0\0', n([23,2]))]
- for values in [[],[7],list(range(32))]:
- cases += [('copy',n(values),n(values)),('reverseLocal',n(values),n(values[::-1]))]
- for entries in [[],[(3,125),(2,49),(0,999),(1,1000)],[(9,9999),(11,12345)]]:
- argument = b'\0'
- for quantity,price in reversed(entries):
- argument = pair(pair(n(quantity),n(price)),argument)
- total = sum(q*p for q,p in entries)
- cases.append(('invoice',argument,result(True,pair(n(total),n(list(str(total).encode()))))))
+ cases = smoke_cases()
for i,(export,argument,expected) in enumerate(cases):
path = root/'argument.tree'; path.write_bytes(argument)
actual = runner.invoke(str(i),pin,export,['--arg-tree-file',path])
diff --git a/test/cross_host_generation.py b/test/cross_host_generation.py
@@ -0,0 +1,185 @@
+#!/usr/bin/env python3
+"""Bounded compiler compatibility checks through the shared generation runner.
+Haskell supplies transport and result execution; each selected execution host
+compiles and publishes the test modules. Independent audits verify the stores.
+"""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+import sys
+import subprocess
+from process_rss import run_guarded
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument('--tricu', type=Path, required=True)
+ p.add_argument('--library', type=Path, required=True)
+ p.add_argument('--directory', type=Path, required=True)
+ p.add_argument('--compatibility', action='store_true', help='also check broader syntax, invalidation and negative controls')
+ args = p.parse_args()
+ 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')
+ source_map = directory / 'source-map.v1'
+ source_map.write_text('arboricx.source-map.v1\nnative\tdep\tdep\tdep.tri\t-\nnative\tapp\tapp\tapp.tri\tdep\n')
+ report = dict(complete=False, stages=[])
+ def save():
+ (directory / 'report.json').write_text(json.dumps(report, indent=2)+'\n')
+ def run(label, command, success=True):
+ try:
+ output, peak = run_guarded(list(map(str,command)), memory_mib=1024, seconds=150)
+ if not success:
+ raise AssertionError('unexpected success: '+label)
+ except subprocess.CalledProcessError as error:
+ (directory / (label+'.log')).write_text(error.output)
+ if success:
+ report['failure'] = label
+ save()
+ raise
+ report['stages'].append(dict(label=label, expected_failure=True))
+ save()
+ return error.output
+ except Exception as error:
+ (directory / (label+'.log')).write_text(str(getattr(error,'output',''))+'\n'+str(error))
+ report['failure'] = label
+ save()
+ raise
+ (directory / (label+'.log')).write_text(output)
+ report['stages'].append(dict(label=label,peak_kib=peak))
+ save()
+ return output
+ def checkpoints(host):
+ return {name:(directory/host/'cas/cache/source-build'/name).read_bytes() for name in ('dep','app')}
+ save()
+ commands = {}
+ for host in ('haskell','zig'):
+ output = directory / host
+ command = [sys.executable, ROOT/'test/bootstrap_generations.py', output,
+ '--bootstrap-kit', ROOT/'bootstrap/rebuild', '--tricu',args.tricu.resolve(),
+ '--execution-host',host,'--source-root',source,'--map',source_map,'--root','app',
+ '--backend','stable','--memory-mib','1024','--wall-seconds','120',
+ '--initial-seconds','60','--extended-seconds','100']
+ if host == 'zig': command += ['--zig-library',args.library.resolve()]
+ commands[host] = command
+ run(host+'-cold',command+['--fresh'])
+ status = json.loads((output/'status.json').read_text())
+ assert status['complete'] and status['execution_host']==host and status['fresh_store']
+ assert status['transport_host']=='haskell'
+ runtime = args.tricu if host == 'haskell' else args.library
+ assert status['runtime_sha256']==hashlib.sha256(runtime.read_bytes()).hexdigest()
+ invocation = json.loads((output/'invocation.json').read_text())
+ assert ('+RTS' in invocation) == (host == 'haskell')
+ cold = checkpoints(host)
+ run(host+'-warm',command)
+ assert cold == checkpoints(host)
+ pin = cold['app'].decode().split('\t')[2]
+ answer = run(host+'-result',[args.tricu,'arboricx','run','--store',output/'cas',
+ '--module',pin,'--name','main','--format','number',
+ '+RTS','-N1','-M1024m','-RTS'])
+ assert answer.strip()=='41', answer
+ assert checkpoints('haskell') == checkpoints('zig'), 'complete module receipts differ'
+ # Exact independently audited object sets, not just root result equality.
+ objects = lambda host: {p.name:p.read_bytes() for p in (directory/host/'cas/objects').glob('*/*')}
+ assert objects('haskell') == objects('zig'), 'published object sets differ'
+ if args.compatibility:
+ def same_objects():
+ assert objects('haskell') == objects('zig'), 'published object sets differ'
+ def stage(label, success=True, diagnostic=None):
+ for host, command in commands.items():
+ reply = run(host+'-'+label, command, success=success)
+ status = json.loads((directory/host/'status.json').read_text())
+ assert status['complete'] == success
+ if diagnostic:
+ assert diagnostic in reply, (host,label,reply[-3000:])
+ same_objects()
+ def result(host, name, expected):
+ pin = (directory/host/'cas/cache/source-build/app').read_text().split('\t')[2]
+ reply = run(host+'-result-'+name,[args.tricu,'arboricx','run','--store',directory/host/'cas',
+ '--module',pin,'--name',name,'--format','number',
+ '+RTS','-N1','-M1024m','-RTS'])
+ assert reply.strip()==str(expected), reply
+ dep = '!export answer\n!export extra\nanswer = 42\nextra = 99\n-- preserved UTF-8: λ 😀\n'
+ (source/'dep.tri').write_text(dep)
+ (source/'bridge.tri').write_text('!export * from dep\n')
+ source_map.write_text('arboricx.source-map.v1\nnative\tdep\tdep\tdep.tri\t-\n'
+ 'native\tbridge\tbridge\tbridge.tri\tdep\n'
+ 'native\tapp\tapp\tapp.tri\tbridge\n')
+ app = '!import bridge as D\n!export main\n!export local\n!export pinned\n!export sequenced\n!export lazy\n!export partial\nwalk t = D.answer\nwalk (t x) = walk x\nwalk _ = 0\nmain = walk (t (t t))\nlocal = (let rec\n copy [] = []\n copy [x | xs] = t x (copy xs)\n copy _ = []\n in (x : case x of\n [head | _] = head\n _ = 0) (copy [42 8]))\nchoose ^D.extra = 42\nchoose _ = 0\npinned = choose 99\npass value next = next value\nsequenced = (do pass\n value <- 42\n (x : x) value)\nlazy = (x : 42) ((x : x x) (x : x x))\nfirst x y = x\npartial = first 42 0\n'
+ (source/'app.tri').write_text(app)
+ stage('features-and-change')
+ for host in commands:
+ for name in ('main','local','pinned','sequenced','lazy','partial'):
+ result(host,name,42)
+ before = {host:checkpoints(host) for host in commands}
+ stage('features-warm')
+ assert before == {host:checkpoints(host) for host in commands}
+ # Invalid source, not merely a supervisor preflight error.
+ (source/'app.tri').write_text('!import bridge as D\n!export main\nmain = missingName\n')
+ stage('undefined-name',success=False)
+ assert json.loads((directory/'zig/execution.json').read_text())['diagnostic']=={'first_number':20}
+ assert 'error: [20, "missingName"' in (directory/'haskell-undefined-name.log').read_text()
+ (source/'app.tri').write_text(app)
+ stage('restore-source')
+ assert before == {host:checkpoints(host) for host in commands}
+ # A matching corrupt local checkpoint must fail closed, not recompile.
+ saved = {}
+ for host in commands:
+ path = directory/host/'cas/cache/source-build/app'
+ saved[host] = path.read_bytes()
+ path.write_text('corrupt')
+ stage('corrupt-checkpoint',success=False)
+ for host in commands:
+ (directory/host/'cas/cache/source-build/app').write_bytes(saved[host])
+ # Corrupt a referenced executable while keeping its digest/path.
+ for host in commands:
+ pin = saved[host].decode().split('\t')[2]
+ manifest = (directory/host/'cas/objects'/pin[:3]/pin).read_text()
+ digest = next(row.split('\t')[3] for row in manifest.splitlines()[1:] if row.split('\t')[1]=='main')
+ path = directory/host/'cas/objects'/digest[:3]/digest
+ saved[host] = (path,path.read_bytes())
+ path.write_bytes(b'corrupt')
+ stage('corrupt-executable',success=False)
+ for path,payload in saved.values(): path.write_bytes(payload)
+ stage('restore-object')
+ # An interrupted preparation must invalidate success; completed dependency
+ # publications survive and the next invocation reconstructs the root.
+ for host,command in commands.items():
+ (directory/host/'cas/cache/source-build/app').unlink()
+ run(host+'-resource-stop',command+['--memory-mib','1'],success=False)
+ assert not json.loads((directory/host/'status.json').read_text())['complete']
+ # Also stop a running Zig invocation, not only Haskell transport setup.
+ run('zig-execution-deadline',commands['zig']+['--wall-seconds','0.5'],success=False)
+ stopped = json.loads((directory/'zig/status.json').read_text())
+ assert not stopped['complete'] and stopped['reason']=='whole-job wall-time guard', stopped
+ assert 'code' in stopped, 'guard must reach the execution subprocess'
+ stage('restart')
+ assert before == {host:checkpoints(host) for host in commands}
+ # Header preflight rejects initial BOM before an invocation starts;
+ # existing status remains historical, not evidence of this failed attempt.
+ (source/'app.tri').write_bytes(b'\xef\xbb\xbf'+app.encode())
+ for host, command in commands.items():
+ reply = run(host+'-source-bom',command,success=False)
+ assert 'header mismatch' in reply
+ (source/'app.tri').write_text(app)
+ stage('final-restored')
+ for host,command in commands.items():
+ reply = run(host+'-missing-dependency',command+['--root','absent'],success=False)
+ assert 'undeclared map dependency' in reply
+ reply = run('zig-missing-library',commands['zig']+['--zig-library',str(directory/'missing.so')],success=False)
+ assert 'no fallback' in reply
+ report['compatibility'] = True
+ report['complete'] = True
+ save()
+ print('Shared-runner cold/warm parity and exact receipts/objects passed; compatibility='
+ +str(args.compatibility)+': '+str(directory))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/test/reducer_comparison.py b/test/reducer_comparison.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+"""Paired Haskell/Zig runtime measurements with complete-result checks."""
+import hashlib
+import json
+import os
+from pathlib import Path
+import platform
+import statistics
+import sys
+import time
+from process_rss import run_guarded
+
+haskell, zig, fixtures, output = sys.argv[1:]
+fixtures = Path(fixtures)
+affinity = sorted(os.sched_getaffinity(0))
+os.sched_setaffinity(0, {affinity[0]})
+cases = [(kind, n, 20) for kind in ('id', 'append') for n in (256, 4096, 16384)]
+cases += [(kind, n, 5) for kind in ('deep', 'distribution') for n in (1000, 10000, 100000)]
+cases += [('append', 4096, 256)]
+report = dict(
+ complete=False, platform=platform.platform(), machine=platform.machine(),
+ cpu_model=next(line.split(':', 1)[1].strip() for line in Path('/proc/cpuinfo').read_text().splitlines() if line.startswith('model name')),
+ pinned_cpu=affinity[0], original_affinity=affinity, samples_per_host_case=5,
+ haskell_compiler=(Path(haskell).resolve().parents[1]/'compiler-version').read_text().strip(),
+ fixture_sha256={name: hashlib.sha256((fixtures/name).read_bytes()).hexdigest() for name in ('id.arboricx', 'append.arboricx')},
+ runners={host: dict(path=path, sha256=hashlib.sha256(Path(path).read_bytes()).hexdigest()) for host,path in [('haskell',haskell),('zig',zig)]},
+ limits=dict(rss_mib=512, wall_seconds=65, cpu_seconds=60, native_stack_mib=8,
+ haskell_rts=['-N1','-M512m','-K8m'], zig_address_space_mib=512),
+ notes=[
+ 'Identical normal-form fixture functions and canonical input values; fresh application graphs each iteration.',
+ 'Primary metric includes WHNF reduction, complete output decoding/checking and automatic GC.',
+ 'Input construction is separate; Haskell forces input normal forms and uses opaque suspension boxes to prepare unevaluated applications.',
+ 'Native allocations, FFI, codec implementations and automatic GC differ. All automatic GC remains charged to its triggering phase.',
+ 'Explicit GC follows identical round cadence; total sums build, reduction, observation and explicit GC phases.',
+ 'Memory is complete-process VmHWM, including runtime initialization.',
+ 'Five fresh-process samples per case, sequential execution, alternating host order, single CPU affinity.'
+ ], cases=[])
+def save():
+ Path(output).write_text(json.dumps(report, indent=2)+'\n')
+save()
+for kind,n,rounds in cases:
+ samples = {host: [] for host in ('haskell','zig')}
+ fixture = fixtures / ('append.arboricx' if kind == 'append' else 'id.arboricx')
+ for repeat in range(5):
+ for host in (('haskell','zig') if repeat % 2 == 0 else ('zig','haskell')):
+ command = [haskell if host == 'haskell' else zig, kind, str(n), str(rounds), str(fixture)]
+ if host == 'haskell': command += ['+RTS','-N1','-M512m','-K8m','-RTS']
+ start = time.monotonic()
+ text, sampled_peak = run_guarded(command, memory_mib=512, seconds=65)
+ sample = json.loads(text)
+ assert (sample['kind'],sample['n'],sample['rounds']) == (kind,n,rounds)
+ assert 0 < sample['peak_rss_kib'] <= 512*1024
+ sample['process_wall_s'] = time.monotonic()-start
+ sample['sampled_peak_rss_kib'] = sampled_peak
+ samples[host].append(sample)
+ row = dict(kind=kind,n=n,rounds=rounds,hosts={})
+ for host,values in samples.items():
+ metrics = {}
+ for label,fields in dict(evaluate_observe=['reduce_s','observe_s'],
+ total=['build_s','reduce_s','observe_s','gc_s'],
+ build=['build_s'], whnf=['reduce_s'], observe=['observe_s'], gc=['gc_s']).items():
+ series = [sum(s[f] for f in fields)*1000/rounds for s in values]
+ metrics[label] = dict(median=statistics.median(series),min=min(series),max=max(series))
+ row['hosts'][host] = dict(samples=values, ms_per_iteration=metrics,
+ max_peak_rss_mib=max(s['peak_rss_kib'] for s in values)/1024)
+ h,z = (row['hosts'][host]['ms_per_iteration']['evaluate_observe']['median'] for host in ('haskell','zig'))
+ row['zig_over_haskell_evaluate_observe'] = z/h
+ report['cases'].append(row)
+ save()
+ print(f'{kind}/{n} x{rounds}: Haskell {h:.4f} ms, Zig {z:.4f} ms; Zig/Haskell {z/h:.2f}x', flush=True)
+report['complete'] = True
+save()
diff --git a/test/zig_generation_executor.py b/test/zig_generation_executor.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+"""Zig execution child for bootstrap_generations.
+
+The supervisor authenticates/imports captured bundles, supplies six arguments
+and independently audits all publications. This child rechecks captured bundle
+digests and runs the selected entrypoint through the owned ABI. It grants broad
+file IO to trusted compiler jobs, matching the generation runner's unsafe-IO scope.
+"""
+import argparse
+import ctypes as C
+import hashlib
+import json
+from pathlib import Path
+import resource
+import time
+
+
+def save(path, value):
+ path.write_text(json.dumps(value, indent=2) + '\n')
+
+
+def child(args):
+ # No asynchronous subprocesses in the Zig IO driver; the parent additionally
+ # guards this whole process group with a wall deadline and RSS sampling.
+ resource.setrlimit(resource.RLIMIT_AS, (args.memory_mib * 1024**2,) * 2)
+ resource.setrlimit(resource.RLIMIT_STACK, (8 * 1024**2,) * 2)
+ resource.setrlimit(resource.RLIMIT_CPU, (args.seconds, args.seconds))
+ resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
+ lib = C.CDLL(str(args.library))
+ handle, ctx_t = C.c_uint64, C.c_void_p
+ def bind(name, types, result=C.c_int):
+ f = getattr(lib, name)
+ f.argtypes, f.restype = types, result
+ return f
+ init = bind('arboricx_init', [], ctx_t)
+ free = bind('arboricx_free', [ctx_t], None)
+ version = bind('arb_abi_version', [], C.c_uint32)
+ assert version() == 2
+ load = bind('arb_load_bundle', [ctx_t, C.c_void_p, C.c_size_t, C.c_char_p, C.POINTER(handle)])
+ string = bind('arb_of_string', [ctx_t, C.c_char_p, C.POINTER(handle)])
+ app = bind('arb_app', [ctx_t, handle, handle, C.POINTER(handle)])
+ release = bind('arb_release', [ctx_t, handle])
+ reduce = bind('arb_reduce', [ctx_t, handle, C.c_uint64, C.POINTER(handle)])
+ unwrap = bind('arb_unwrap_result', [ctx_t, handle, C.POINTER(C.c_int), C.POINTER(handle), C.POINTER(handle)])
+ to_string = bind('arb_to_string', [ctx_t, handle, C.POINTER(C.c_void_p), C.POINTER(C.c_size_t)])
+ number = bind('arb_to_number', [ctx_t, handle, C.POINTER(C.c_uint64)])
+ pair = bind('arb_get_fork_children', [ctx_t, handle, C.POINTER(handle), C.POINTER(handle)])
+ free_buf = bind('arboricx_free_buf', [ctx_t, C.c_void_p, C.c_size_t], None)
+ class Perms(C.Structure):
+ _fields_ = [('read', C.c_int), ('write', C.c_int)]
+ run_io = bind('arb_run_io', [ctx_t, handle, C.POINTER(Perms), C.POINTER(handle)])
+ def check(status):
+ if status != 0:
+ raise RuntimeError(f'ABI status {status}')
+ ctx = init()
+ if not ctx:
+ raise MemoryError('context initialization')
+ def owned(f, *arguments):
+ out = handle()
+ check(f(ctx, *arguments, C.byref(out)))
+ return out.value
+ def apply(f, x):
+ result = owned(app, f, x)
+ check(release(ctx, f)); check(release(ctx, x))
+ return result
+ run = args.output
+ request = json.loads((run / 'execution-request.json').read_text())
+ started = time.monotonic()
+ try:
+ compiler_bytes = (run / 'compiler.arboricx').read_bytes()
+ adapter_bytes = (run / 'io-adapter.arboricx').read_bytes()
+ if hashlib.sha256(compiler_bytes).hexdigest() != request['compiler_bundle_sha256'] or hashlib.sha256(adapter_bytes).hexdigest() != request['adapter_sha256']:
+ raise ValueError('captured executable identity mismatch')
+ compiler = owned(load, compiler_bytes, len(compiler_bytes), request['worker_export'].encode())
+ adapter = owned(load, adapter_bytes, len(adapter_bytes), b'main')
+ current = apply(adapter, compiler)
+ for argument in request['arguments']:
+ if '\0' in argument:
+ raise ValueError('NUL in compiler argument')
+ current = apply(current, owned(string, argument.encode()))
+ prepared = time.monotonic()
+ program = owned(reduce, current, 1_000_000_000)
+ check(release(ctx, current))
+ forced = time.monotonic()
+ result = owned(run_io, program, C.byref(Perms(1, 1)))
+ check(release(ctx, program))
+ ran = time.monotonic()
+ ok, value, rest = C.c_int(), handle(), handle()
+ check(unwrap(ctx, result, C.byref(ok), C.byref(value), C.byref(rest)))
+ diagnostic = None
+ if not ok.value:
+ ptr, length = C.c_void_p(), C.c_size_t()
+ status = to_string(ctx, value, C.byref(ptr), C.byref(length))
+ if status == 0:
+ diagnostic = C.string_at(ptr, length.value).decode('utf-8', 'replace')
+ free_buf(ctx, ptr, length.value)
+ else:
+ # Preserve a useful bounded prefix of structured diagnostics.
+ # This recognizes only a generic list prefix, not compiler tags.
+ prefix, tail = handle(), handle()
+ tag = C.c_uint64()
+ normalized = owned(reduce, value.value, 1_000_000)
+ if pair(ctx, normalized, C.byref(prefix), C.byref(tail)) == 0:
+ if number(ctx, prefix, C.byref(tag)) == 0:
+ diagnostic = dict(first_number=tag.value)
+ check(release(ctx, prefix))
+ check(release(ctx, tail))
+ check(release(ctx, normalized))
+ if diagnostic is None:
+ diagnostic = f'non-string diagnostic (decode status {status})'
+ record = dict(ok=bool(ok.value), diagnostic=diagnostic,
+ prepare_seconds=prepared-started, initial_reduce_seconds=forced-prepared,
+ io_seconds=ran-forced, total_seconds=time.monotonic()-started)
+ save(run / 'execution.json', record)
+ if not ok.value:
+ raise RuntimeError(diagnostic)
+ print('ok: Zig map-builder completed', flush=True)
+ print(json.dumps(record), flush=True)
+ finally:
+ free(ctx)
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--library', type=Path, required=True)
+ parser.add_argument('--output', type=Path, required=True)
+ parser.add_argument('--memory-mib', type=int, required=True)
+ parser.add_argument('--seconds', type=int, required=True)
+ args = parser.parse_args()
+ if not 0 < args.memory_mib <= 65536 or not 0 < args.seconds <= 7200:
+ parser.error('invalid resource boundaries')
+ child(args)
diff --git a/test/zig_observe.py b/test/zig_observe.py
@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+"""Bounded test observer: invoke a named bundle export and emit its entire tree.
+Reads a flat argument tree and writes the complete result in the same format.
+"""
+import argparse
+import ctypes as C
+from pathlib import Path
+import resource
+from executable_object import FLAT, verify_payload
+
+
+def main():
+ p=argparse.ArgumentParser(description=__doc__)
+ for key in ('library','bundle','argument','output'): p.add_argument('--'+key,type=Path,required=True)
+ p.add_argument('--export',required=True)
+ a=p.parse_args()
+ for kind,limit in [(resource.RLIMIT_AS,512*1024**2),(resource.RLIMIT_STACK,8*1024**2),
+ (resource.RLIMIT_CPU,60),(resource.RLIMIT_CORE,0)]:
+ resource.setrlimit(kind,(limit,limit))
+ lib=C.CDLL(str(a.library)); H=C.c_uint64; CTX=C.c_void_p
+ def bind(name,types,result=C.c_int):
+ f=getattr(lib,name); f.argtypes=types; f.restype=result; return f
+ init=bind('arboricx_init',[],CTX); free=bind('arboricx_free',[CTX],None)
+ load=bind('arb_load_bundle',[CTX,C.c_void_p,C.c_size_t,C.c_char_p,C.POINTER(H)])
+ leaf=bind('arb_leaf',[CTX,C.POINTER(H)])
+ stem=bind('arb_stem',[CTX,H,C.POINTER(H)])
+ fork=bind('arb_fork',[CTX,H,H,C.POINTER(H)])
+ app=bind('arb_app',[CTX,H,H,C.POINTER(H)])
+ reduce=bind('arb_reduce',[CTX,H,C.c_uint64,C.POINTER(H)])
+ release=bind('arb_release',[CTX,H])
+ is_leaf=bind('arb_is_leaf',[CTX,H,C.POINTER(C.c_int)])
+ is_stem=bind('arb_is_stem',[CTX,H,C.POINTER(C.c_int)])
+ child=bind('arb_get_stem_child',[CTX,H,C.POINTER(H)])
+ children=bind('arb_get_fork_children',[CTX,H,C.POINTER(H),C.POINTER(H)])
+ ctx=init()
+ if not ctx: raise MemoryError('context')
+ def check(status):
+ if status: raise RuntimeError('ABI status '+str(status))
+ def owned(f,*args):
+ out=H(); check(f(ctx,*args,C.byref(out))); return out.value
+ try:
+ data=a.argument.read_bytes(); verify_payload(FLAT,data)
+ cursor=0
+ def construct():
+ nonlocal cursor
+ tag=data[cursor]; cursor+=1
+ args=[construct() for _ in range(tag)]
+ value=owned((leaf,stem,fork)[tag],*args)
+ for h in args: check(release(ctx,h))
+ return value
+ argument=construct(); bundle=a.bundle.read_bytes()
+ worker=owned(load,bundle,len(bundle),a.export.encode())
+ term=owned(app,worker,argument)
+ check(release(ctx,worker)); check(release(ctx,argument))
+ todo=[term]; output=bytearray()
+ while todo:
+ h=todo.pop(); normalized=owned(reduce,h,10_000_000); check(release(ctx,h)); h=normalized
+ flag=C.c_int(); check(is_leaf(ctx,h,C.byref(flag)))
+ if flag.value: output.append(0)
+ else:
+ check(is_stem(ctx,h,C.byref(flag)))
+ if flag.value:
+ output.append(1); todo.append(owned(child,h))
+ else:
+ output.append(2); left,right=H(),H()
+ check(children(ctx,h,C.byref(left),C.byref(right)))
+ todo.extend((right.value,left.value))
+ check(release(ctx,h))
+ if len(output)>1024*1024: raise ValueError('test output boundary')
+ verify_payload(FLAT,output)
+ a.output.write_bytes(output)
+ finally: free(ctx)
+
+
+if __name__=='__main__': main()