tricu

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

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:
Abench/ReducerSmoke.hs | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/build-and-run.md | 3++-
Mext/js/src/lib.js | 80+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mext/js/test/bundle.test.js | 8++++----
Aext/js/test/ownership.test.js | 27+++++++++++++++++++++++++++
Mext/js/test/reduce.test.js | 2+-
Mext/js/test/run-bundle.test.js | 2+-
Mext/php/src/ffi.php | 48+++++++++++++++++++++++++++++++++++++++---------
Aext/php/tests/ownership.php | 27+++++++++++++++++++++++++++
Aext/zig/README.md | 270+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/zig/build.zig | 19++++++++++++++++++-
Mext/zig/include/arboricx.h | 82++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mext/zig/src/arena.zig | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mext/zig/src/bundle.zig | 365++-----------------------------------------------------------------------------
Mext/zig/src/c_abi.zig | 421++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mext/zig/src/codecs.zig | 17++++++++++-------
Aext/zig/src/handles.zig | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/zig/src/indexed.zig | 408+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/zig/src/io_driver.zig | 515++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mext/zig/src/main.zig | 36+++++++++++++++++++++++-------------
Mext/zig/src/reduce.zig | 184++++++++++++++++++++++++++++++++++++++-----------------------------------------
Aext/zig/src/reduce_test.zig | 242+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/zig/src/source_text.zig | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/zig/src/tree.zig | 34++++++++++++----------------------
Mext/zig/tests/c_abi_append_test.c | 48++++++++++++++++++++++++------------------------
Mext/zig/tests/c_abi_test.c | 57+++++++++++++++++++++++++++++----------------------------
Aext/zig/tests/handles_test.c | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/zig/tests/helpers.h | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/zig/tests/io_protocol_test.c | 152++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mext/zig/tests/io_run_test.c | 117++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mext/zig/tests/native_bundle_append_test.c | 38+++++++++++++++++++-------------------
Mext/zig/tests/native_bundle_bools_test.c | 8++++----
Mext/zig/tests/native_bundle_id_test.c | 16++++++++--------
Aext/zig/tests/performance_smoke.c | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/zig/tests/performance_smoke.py | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/zig/tests/python_ffi_test.py | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mflake.nix | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Atest/reducer_comparison.py | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/zig_compiler_smoke.py | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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)