commit 95c819bd37c5b4b3e4bbe17a3224707bc7195965
parent b99605e3168245139e3e63f1ec5878c0e8ecff26
Author: James Eversole <james@eversole.co>
Date: Fri, 16 Oct 2026 12:06:57 -0500
Modernize Zig lazy runtime, owned ABI and portable compiler IO
Validate indexed/source-text transport with audited cold and warm compiler smokes; add paired Haskell/Zig workload measurements. Full cross-runtime compiler reproduction remains unproven.
Diffstat:
39 files changed, 3490 insertions(+), 1093 deletions(-)
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,8 @@
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 experimental [Zig runtime and owned-handle C ABI](../ext/zig/README.md)
+have separate opt-in Nix validation targets; they are not compiler-toolchain acceptance.
## Build the host
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,270 @@
+# Zig runtime and C ABI
+
+This is the experimental Zig tree-calculus runtime. The supported compiler
+workflow remains the Haskell host. Zig runtime validation is opt-in:
+
+```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. These tests do not establish compiler self-hosting
+acceptance or a performance comparison with Haskell.
+
+The libuv header import locally disables `_FORTIFY_SOURCE` during C-to-Zig
+translation: the pinned importer otherwise diagnoses glibc's variadic
+`open`/`openat` inline wrapper bodies in optimized builds. The driver calls
+`uv_fs_open`, not those libc wrappers. This does not change the separately built
+libuv library or C callers' hardening, and does not disable Zig safety checks.
+
+## 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 opt-in 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; the linked runtime is **ReleaseSafe**, as above.
+The driver records the runtime optimization mode supplied by its Nix build in
+every sample, and the report rejects mixed-mode samples.
+
+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 memory is Linux `VmHWM` for the
+executable's address space (not `getrusage`'s potentially inherited pre-exec
+high-water mark); midpoint/end RSS comes from `/proc/self/statm` after collection.
+These are process-memory observations, not exact live-node counts.
+
+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. There
+are no machine-dependent timing thresholds, historical-speedup claims or compiler
+workloads. Three samples on a shared machine are descriptive, not a controlled
+benchmark.
+
+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/zig_compiler_smoke.py` runs only a two-module native map-builder job using
+the authenticated `bootstrap/rebuild` kit. It does not rebuild the compiler,
+modify retained toolchains, or promote a generation. Haskell authenticates the
+captured input bundles; Zig applies the retained compiler and adapter and drives
+IO. Successful publication must pass the independent store audit and produce
+`41` when executed by Haskell, followed by a fresh-context warm-reuse check.
+
+```sh
+nix build .#tricu-zig .# --no-link
+ZIG=$(nix path-info .#tricu-zig)
+HASKELL=$(nix path-info .#)
+nix develop -c python3 test/zig_compiler_smoke.py \
+ --library "$ZIG/lib/libarboricx.so" --tricu "$HASKELL/bin/tricu" \
+ --directory /tmp/zig-compiler-probe-new
+```
+
+The output directory must not exist. Default limits are 1 GiB and 120 seconds
+per subprocess, with an 8 MiB stack for Zig. Inputs, runtime hashes, logs and
+incomplete/success status are recorded there. This is an opt-in compatibility
+probe, not a compiler generation, bootstrap acceptance, or a comparative
+performance benchmark. Unsupported actions during a pending IO continuation
+produce an error result identifying the action tag rather than a successful
+compilation.
+
+### Indexed tree transport
+
+The existing 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 fixed `arboricx.indexed-tree.v1` domain and Haskell-compatible
+canonical bytes. No additional protocol tags or C ABI entrypoints are required.
+
+The reusable `src/indexed.zig` codec has no filesystem, permission, or compiler
+policy. It uses iterative traversal, memoizes physical node identities, and
+interns exact constructor/child-ID keys. Physical sharing affects cost, not
+identity; equal finite trees produce the same bytes. Serialization demands the
+constructor structure but never applies the supplied tree as a program.
+The validated reader is shared with C ABI/CLI bundle loading and retains DAG
+sharing. Parsing alone does not authenticate an object: transport checks its
+domain-separated digest before decoding.
+
+The IO adapters compose that codec with byte-buffer hashing and generic native
+filesystem operations, without expanding serialized bytes into tricu lists.
+Reads need read permission; no-replace publication needs 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 still chooses paths, creates directories, and owns
+manifests, caches and publication order.
+
+These operations occupy a synchronous scheduler turn and retain in-memory
+serialized buffers; they are neither streaming nor request-wide fuel-bounded.
+A public buffer-handle protocol is not introduced merely to implement these
+adapters: it would require separate resource ownership and lifetime semantics.
+
+### Source-text IO
+
+`readSourceFile` (37) requires read permission and returns a Result containing
+both the exact raw octets and a Unicode scalar list. It rejects malformed UTF-8
+and an initial BOM; it does not strip BOMs, normalize Unicode, or fold newlines.
+Paths use Unicode scalars encoded as UTF-8, with embedded NUL rejected.
+`encodeSourceText` (38) requires no filesystem permission 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.
+
+These operations do not change byte-oriented ABI string/binary codecs or
+`readFile`. Source reads, like native CAS filesystem operations, run
+synchronously within one scheduler turn; they are not streaming or globally
+fuel-bounded. Allocation failures propagate to the host rather than becoming a
+successful or partial source result.
+
+## ABI v2 ownership
+
+`include/arboricx.h` is the interface. This deliberately replaces the original
+bare-node-index ABI; callers must rebuild and migrate. `arb_abi_version()` is 2.
+The JS, PHP and Python test bindings check this version before using handles.
+
+* A handle is an opaque 64-bit integer, not a node address or serialization.
+ 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 just that handle. Context destruction releases
+ everything, including handles the caller did not explicitly release.
+* Inspection returns owned child handles. Releasing a parent does not invalidate
+ an independently held child. Comparing handles does not compare tree values.
+* Stale, zero, and foreign-context handles return `ARB_INVALID_HANDLE`.
+ Slot reuse has a fresh process-wide generation; generation exhaustion returns
+ `ARB_HANDLE_EXHAUSTED` rather than wrapping. Handles are positive signed-64-bit
+ representable values; JavaScript bindings expose them as `bigint`, never Number.
+* 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 is independently owned; it does not consume input.
+* Contexts are single-threaded and non-reentrant. Pointers must be valid, and
+ output parameters must be distinct. Handles are misuse detection, not a
+ security boundary against native code forging values or corrupting memory.
+* 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 still 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. Constructor
+children and discarded arguments are not eagerly evaluated. Suspended function,
+discriminator, argument and update demands do not use the native call stack.
+
+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 only allocate
+and link nodes; they do not force terms. Root scopes unwind on errors.
+
+Construction never collects automatically: this permits allocating/linking
+children without intermediate root registration. An allocation-heavy client
+that does not evaluate should call `arb_collect` periodically. Releasing a handle
+does not itself collect. Evaluation and IO no longer copy graphs through scratch
+arenas. Internally, raw node IDs must not escape their registered root lifetime,
+and pointers into the growable node array must not survive allocations.
+
+### Limits
+
+* This is not a hard memory limit. Arena capacity and collector work buffers can
+ reach a high-water mark; freeing nodes does not promise lower resident memory.
+ Retaining handles retains their reachable graphs. Root-table rebuilding scans
+ handle slots at ABI observation/evaluation entry.
+* Fuel counts application entries for one reducer invocation, not an entire
+ request. Codec observations still use separate budgets; IO evaluation is not
+ governed by a single request-wide fuel budget. Use external resource limits.
+* Reducer stack safety does not imply stack safety for every codec, structural
+ comparison, parser or renderer. Full observations can diverge on infinite
+ results. Native pointer misuse is outside the ABI's validation contract.
+* This runtime does not implement global application memoization, packed nodes,
+ moving collection, or full compiler/toolchain acceptance.
+
+Tests include 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` contains
+checked constructors for fixture tests only; it is not a compatibility ABI.
+
+## Paired Haskell/Zig runtime measurements
+
+```sh
+nix build .#reducerComparison --no-link
+# Run after builds finish, without other intentional benchmark/build workloads.
+nix run .#reducerComparison -- /tmp/reducer-comparison.json
+```
+
+This opt-in Linux harness compares the real 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 include deliberately wrong workers that must
+fail result validation.
+
+The primary reported timing combines WHNF reduction and full output decoding
+and checking. This is **not isolated rewrite throughput**: codecs and automatic
+GC are included. Build and explicit-GC phases are also recorded, along with their
+combined total. Haskell input normal forms are forced before application timing;
+opaque suspension boxes prepare unevaluated applications without specializing
+away known constant/identity rules. Harness-only anti-sharing flags and varying
+input bytes prevent reusing an already-evaluated result across rounds. Production
+Haskell runtime modules compile normally. Zig's construction includes owned-handle
+C ABI calls; Haskell's includes input forcing and suspension preparation. Neither
+construction cost should be mistaken for reduction cost.
+
+GC placement differs: Haskell may collect during construction whereas Zig
+collects automatically at reduction safepoints. Compare both evaluation/observation
+and combined totals before drawing conclusions. Initialization is excluded from
+phase timings but included in process RSS. 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, and Haskell has a 512 MiB managed-heap cap and an 8 MiB
+RTS stack cap. Small successful samples should remain far below these limits.
+These measurements are descriptive host smokes, not compiler or bootstrap speed
+comparisons, and no timing threshold is a test gate.
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
@@ -143,11 +143,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
@@ -157,6 +160,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";
@@ -171,6 +194,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 \
@@ -276,7 +307,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
@@ -301,6 +332,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!"
@@ -316,6 +349,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
@@ -363,6 +441,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/reducer_comparison.py b/test/reducer_comparison.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+"""Small paired runtime measurements, not compiler/bootstrapping acceptance."""
+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 is WHNF reduction plus complete output decoding/checking. Not isolated rewrite throughput.',
+ '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 includes build/reduce/observe/explicit GC, not initialization or input-byte generation.',
+ 'Memory is complete-process VmHWM, including runtime initialization; it is not live-tree size.',
+ 'Fresh processes, sequential samples, alternating host order, single CPU affinity. Five samples describe this machine, not statistical significance.'
+ ], 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_compiler_smoke.py b/test/zig_compiler_smoke.py
@@ -0,0 +1,200 @@
+#!/usr/bin/env python3
+"""Bounded Zig execution of a tiny retained map-builder job; NOT a generation.
+
+The Haskell host authenticates input transport and runs the published result.
+The existing independent store auditor checks the complete two-module closure.
+No source compiler is built and no toolchain is promoted or retained here.
+Run through nix develop; select Nix-built runtime/library paths explicitly.
+"""
+import argparse
+import ctypes as C
+import hashlib
+import json
+from pathlib import Path
+import resource
+import subprocess
+import sys
+import time
+
+from bootstrap_inputs import load_kit
+from bootstrap_generations import source_map, snapshot
+from generation_backend import BACKENDS
+from process_rss import run_guarded
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def sha(path):
+ return hashlib.sha256(path.read_bytes()).hexdigest()
+
+
+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)])
+ 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.directory / 'run'
+ started = time.monotonic()
+ try:
+ compiler_bytes = (run / 'compiler.arboricx').read_bytes()
+ adapter_bytes = (run / 'io-adapter.arboricx').read_bytes()
+ compiler = owned(load, compiler_bytes, len(compiler_bytes), b'sourceMapBuildStable')
+ adapter = owned(load, adapter_bytes, len(adapter_bytes), b'main')
+ current = apply(adapter, compiler)
+ for argument in [str(args.directory / 'source'), str(run / 'overlay'), str(run / 'cas'),
+ sha(run / 'compiler.arboricx'), (run / 'source-map.v1').read_text(), 'app']:
+ current = apply(current, owned(string, argument.encode()))
+ prepared = time.monotonic()
+ print(json.dumps({'stage': 'loaded-and-applied', 'seconds': prepared - started}), flush=True)
+ program = owned(reduce, current, 1_000_000_000)
+ check(release(ctx, current))
+ forced = time.monotonic()
+ print(json.dumps({'stage': 'io-program', 'seconds': forced - prepared}), flush=True)
+ 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:
+ 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)
+ print(json.dumps(record), flush=True)
+ if not ok.value:
+ raise RuntimeError(diagnostic)
+ finally:
+ free(ctx)
+
+
+def main(args):
+ if not 0 < args.memory_mib <= 2048 or not 0 < args.seconds <= 180:
+ raise ValueError('smoke limits: at most 2 GiB and 180 seconds per invocation')
+ kit_bytes, payloads, kit_digest = load_kit(args.kit, 'stable')
+ args.directory.mkdir(parents=True, exist_ok=False)
+ source, run = args.directory / 'source', args.directory / 'run'
+ source.mkdir(); run.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')
+ map_path = run / 'source-map.v1'
+ map_path.write_text('arboricx.source-map.v1\nnative\tdep\tdep\tdep.tri\t-\nnative\tapp\tapp\tapp.tri\tdep\n')
+ text, entries, required = source_map(map_path, source, 'app')
+ assert required == {'dep', 'app'}
+ (run / 'compiler.arboricx').write_bytes(payloads['compiler'])
+ (run / 'io-adapter.arboricx').write_bytes(payloads['adapter'])
+ (run / 'bootstrap-kit.json').write_bytes(kit_bytes)
+ export, options = BACKENDS['stable']
+ provenance = dict(scope='zig-small-compiler-smoke', root='app', backend='stable',
+ backend_options=options, worker_export=export, source_root=str(source),
+ compiler_bundle_sha256=sha(run / 'compiler.arboricx'),
+ adapter_sha256=sha(run / 'io-adapter.arboricx'), kit_sha256=kit_digest,
+ zig_library=str(args.library), zig_library_sha256=sha(args.library),
+ haskell=str(args.tricu), haskell_sha256=sha(args.tricu),
+ limits=dict(memory_mib=args.memory_mib, seconds=args.seconds, stack_mib=8),
+ **snapshot(text, entries, source))
+ save(run / 'inputs.json', provenance)
+ report = dict(complete=False, **provenance)
+ save(run / 'status.json', report)
+ def guarded(label, argv):
+ started = time.monotonic()
+ try:
+ output, peak = run_guarded(list(map(str, argv)), memory_mib=args.memory_mib, seconds=args.seconds)
+ except Exception as error:
+ output = getattr(error, 'output', '') or str(error)
+ (run / (label + '.log')).write_text(str(output))
+ report['failure'] = dict(stage=label, error=str(error), seconds=time.monotonic()-started)
+ save(run / 'status.json', report)
+ raise
+ (run / (label + '.log')).write_text(output)
+ report.setdefault('stages', {})[label] = dict(seconds=time.monotonic()-started, peak_kib=peak)
+ save(run / 'status.json', report)
+ print(label + ': ' + output.strip(), flush=True)
+ return output
+ # The C loader is not yet an authenticated transport boundary. Have the
+ # mature host validate the exact captured kit payloads before Zig loads them.
+ for filename in ('compiler.arboricx', 'io-adapter.arboricx'):
+ guarded('authenticate-' + filename, [args.tricu, 'arboricx', 'import', '--file', run / filename,
+ '--store', args.directory / 'transport', '--indexed', '+RTS', '-N1', '-RTS'])
+ command = [sys.executable, Path(__file__).resolve(), '--child', '--library', args.library,
+ '--tricu', args.tricu, '--directory', args.directory,
+ '--memory-mib', args.memory_mib, '--seconds', args.seconds]
+ guarded('zig-cold', command)
+ guarded('audit-cold', [sys.executable, ROOT / 'test/bootstrap_store_check.py', run])
+ assert snapshot(text, entries, source) == {k: provenance[k] for k in ('map_sha256', 'sources')}
+ checkpoints = {name: (run / 'cas/cache/source-build' / name).read_bytes() for name in required}
+ manifest = checkpoints['app'].decode().split('\t')[2]
+ answer = guarded('run-result', [args.tricu, 'arboricx', 'run', '--store', run / 'cas',
+ '--module', manifest, '--name', 'main', '--format', 'number', '+RTS', '-N1', '-RTS'])
+ assert answer.strip() == '41', answer
+ report['answer'] = 41
+ # Fresh Zig context, same store: demonstrate actual verified warm reuse.
+ guarded('zig-warm', command)
+ assert checkpoints == {name: (run / 'cas/cache/source-build' / name).read_bytes() for name in required}
+ guarded('audit-warm', [sys.executable, ROOT / 'test/bootstrap_store_check.py', run])
+ report['complete'] = True
+ save(run / 'status.json', report)
+ print(json.dumps(report, indent=2))
+
+
+if __name__ == '__main__':
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument('--library', type=Path, required=True)
+ p.add_argument('--tricu', type=Path, required=True)
+ p.add_argument('--directory', type=Path, required=True)
+ p.add_argument('--kit', type=Path, default=ROOT / 'bootstrap/rebuild')
+ p.add_argument('--memory-mib', type=int, default=1024)
+ p.add_argument('--seconds', type=int, default=120)
+ p.add_argument('--child', action='store_true', help=argparse.SUPPRESS)
+ args = p.parse_args()
+ for key in ('library', 'tricu', 'directory', 'kit'):
+ setattr(args, key, getattr(args, key).resolve())
+ child(args) if args.child else main(args)