commit 3317e36e2f65cdad1c8a790df1a5c4921e0efe39
parent 3587a6ce16d5b75a59192333d5de2a065a64a674
Author: James Eversole <james@eversole.co>
Date: Fri, 15 May 2026 21:41:19 -0500
Interaction Trees in Zig and simple benchmarks
Diffstat:
17 files changed, 2160 insertions(+), 62 deletions(-)
diff --git a/bench/ApplyStats.hs b/bench/ApplyStats.hs
@@ -0,0 +1,240 @@
+{-# LANGUAGE BangPatterns #-}
+
+module ApplyStats
+ ( ApplyStats(..)
+ , emptyApplyStats
+ , emptyApplyStatsSampled
+ , applyCounted
+ , runApplyCounted
+ , runApplySampledWithProgress
+ , runApplyGlobalCounted
+ , printApplyStats
+ ) where
+
+import Research
+import qualified Data.Map.Strict as M
+import qualified Data.List as L
+import Data.Ord (comparing)
+import Data.Text (Text)
+import qualified Data.Text as T
+import Debug.Trace (trace)
+import System.IO.Unsafe (unsafePerformIO, unsafeDupablePerformIO)
+import Data.IORef
+
+-- ---------------------------------------------------------------------------
+-- Threaded stats (slow but pure)
+-- ---------------------------------------------------------------------------
+
+type Hash = Text
+type AppKey = (Hash, Hash)
+
+data ApplyStats = ApplyStats
+ { totalApplyCalls :: !Int
+ , uniqueApps :: !(M.Map AppKey Int)
+ , sampleInterval :: !Int
+ , sampleCounter :: !Int
+ , progressEvery :: !Int
+ }
+ deriving (Show)
+
+emptyApplyStats :: ApplyStats
+emptyApplyStats = emptyApplyStatsSampled 1
+
+emptyApplyStatsSampled :: Int -> ApplyStats
+emptyApplyStatsSampled n = ApplyStats
+ { totalApplyCalls = 0
+ , uniqueApps = M.empty
+ , sampleInterval = max 1 n
+ , sampleCounter = 0
+ , progressEvery = 0
+ }
+
+bump :: T -> T -> ApplyStats -> ApplyStats
+bump !f !x !st =
+ let !counter' = sampleCounter st + 1
+ !total' = totalApplyCalls st + 1
+ !stBase = st { totalApplyCalls = total'
+ , sampleCounter = counter'
+ }
+ !st' = if counter' `mod` sampleInterval st /= 0
+ then stBase
+ else let !hf = termHash f
+ !hx = termHash x
+ !k = (hf, hx)
+ !m = M.insertWith (+) k 1 (uniqueApps st)
+ in stBase { uniqueApps = m }
+ in case progressEvery st of
+ 0 -> st'
+ n | total' `mod` n == 0 ->
+ trace ("apply calls so far: " ++ show total') st'
+ _ -> st'
+
+termHash :: T -> Hash
+termHash Leaf =
+ nodeHash NLeaf
+termHash (Stem t) =
+ nodeHash (NStem (termHash t))
+termHash (Fork l r) =
+ nodeHash (NFork (termHash l) (termHash r))
+
+applyCounted :: T -> T -> ApplyStats -> (T, ApplyStats)
+applyCounted !f !x !st0 =
+ let !st1 = bump f x st0
+ in applyStepCounted f x st1
+
+applyStepCounted :: T -> T -> ApplyStats -> (T, ApplyStats)
+applyStepCounted (Fork Leaf a) _ st =
+ (a, st)
+applyStepCounted (Fork (Stem a) b) c st =
+ let (!ac, !st1) = applyCounted a c st
+ (!bc, !st2) = applyCounted b c st1
+ in applyCounted ac bc st2
+applyStepCounted (Fork (Fork a _b) _c) Leaf st =
+ (a, st)
+applyStepCounted (Fork (Fork _a b) _c) (Stem u) st =
+ applyCounted b u st
+applyStepCounted (Fork (Fork _a _b) c) (Fork u v) st =
+ let (!cu, !st1) = applyCounted c u st
+ in applyCounted cu v st1
+applyStepCounted Leaf b st =
+ (Stem b, st)
+applyStepCounted (Stem a) b st =
+ (Fork a b, st)
+
+runApplyCounted :: T -> T -> (T, ApplyStats)
+runApplyCounted !f !x =
+ applyCounted f x emptyApplyStats
+
+runApplySampled :: Int -> T -> T -> (T, ApplyStats)
+runApplySampled !n !f !x =
+ applyCounted f x (emptyApplyStatsSampled n)
+
+runApplySampledWithProgress :: Int -> Int -> T -> T -> (T, ApplyStats)
+runApplySampledWithProgress !interval !progress !f !x =
+ let st = (emptyApplyStatsSampled interval) { progressEvery = progress }
+ in applyCounted f x st
+
+-- ---------------------------------------------------------------------------
+-- Global mutable stats (fast, unsafe, single-threaded only)
+-- ---------------------------------------------------------------------------
+
+{-# NOINLINE globalTotalCount #-}
+globalTotalCount :: IORef Int
+globalTotalCount = unsafePerformIO (newIORef 0)
+
+{-# NOINLINE globalInterval #-}
+globalInterval :: IORef Int
+globalInterval = unsafePerformIO (newIORef 1)
+
+{-# NOINLINE globalMap #-}
+globalMap :: IORef (M.Map AppKey Int)
+globalMap = unsafePerformIO (newIORef M.empty)
+
+{-# NOINLINE globalProgress #-}
+globalProgress :: IORef Int
+globalProgress = unsafePerformIO (newIORef 0)
+
+resetGlobalStats :: Int -> Int -> IO ()
+resetGlobalStats !interval !progress = do
+ writeIORef globalTotalCount 0
+ writeIORef globalInterval (max 1 interval)
+ writeIORef globalMap M.empty
+ writeIORef globalProgress progress
+
+readGlobalStats :: IO ApplyStats
+readGlobalStats = do
+ total <- readIORef globalTotalCount
+ m <- readIORef globalMap
+ pure ApplyStats
+ { totalApplyCalls = total
+ , uniqueApps = m
+ , sampleInterval = 0
+ , sampleCounter = 0
+ , progressEvery = 0
+ }
+
+{-# INLINE globalBump #-}
+globalBump :: T -> T -> ()
+globalBump !f !x = unsafeDupablePerformIO $ do
+ !total <- readIORef globalTotalCount
+ let !total' = total + 1
+ writeIORef globalTotalCount total'
+ !interval <- readIORef globalInterval
+ !progress <- readIORef globalProgress
+ let !_ = if progress > 0 && total' `mod` progress == 0
+ then trace ("apply calls so far: " ++ show total') ()
+ else ()
+ if total' `mod` interval /= 0
+ then pure ()
+ else do
+ let !hf = termHash f
+ !hx = termHash x
+ !k = (hf, hx)
+ !m <- readIORef globalMap
+ writeIORef globalMap (M.insertWith (+) k 1 m)
+ pure ()
+
+applyGlobalCounted :: T -> T -> T
+applyGlobalCounted !f !x =
+ let !_ = globalBump f x
+ in applyGlobalStep f x
+
+applyGlobalStep :: T -> T -> T
+applyGlobalStep (Fork Leaf a) _ = a
+applyGlobalStep (Fork (Stem a) b) c =
+ applyGlobalCounted (applyGlobalCounted a c) (applyGlobalCounted b c)
+applyGlobalStep (Fork (Fork a _b) _c) Leaf = a
+applyGlobalStep (Fork (Fork _a b) _c) (Stem u) = applyGlobalCounted b u
+applyGlobalStep (Fork (Fork _a _b) c) (Fork u v) =
+ applyGlobalCounted (applyGlobalCounted c u) v
+applyGlobalStep Leaf b = Stem b
+applyGlobalStep (Stem a) b = Fork a b
+
+runApplyGlobalCounted :: Int -> Int -> T -> T -> IO (T, ApplyStats)
+runApplyGlobalCounted !interval !progress !f !x = do
+ resetGlobalStats interval progress
+ let !result = applyGlobalCounted f x
+ !stats <- readGlobalStats
+ pure (result, stats)
+
+-- ---------------------------------------------------------------------------
+-- Printing
+-- ---------------------------------------------------------------------------
+
+printApplyStats :: ApplyStats -> IO ()
+printApplyStats st = do
+ let !total = totalApplyCalls st
+ !uniq = M.size (uniqueApps st)
+ !ratio =
+ if uniq == 0
+ then 0 :: Double
+ else fromIntegral total / fromIntegral uniq
+
+ counts =
+ reverse
+ . L.sortBy (comparing snd)
+ . M.toList
+ $ uniqueApps st
+
+ repeated =
+ filter ((> 1) . snd) counts
+
+ top20 = take 20 repeated
+
+ putStrLn $ "total apply calls: " ++ show total
+ putStrLn $ "unique application patterns: " ++ show uniq
+ putStrLn $ "duplication ratio total/unique: " ++ show ratio
+ putStrLn $ "repeated application patterns: " ++ show (length repeated)
+
+ putStrLn "top repeated application counts:"
+ mapM_ printTop top20
+ where
+ short h = T.unpack (T.take 12 h)
+
+ printTop ((hf, hx), n) =
+ putStrLn $
+ " " ++ show n
+ ++ "x apply "
+ ++ short hf
+ ++ " "
+ ++ short hx
diff --git a/bench/Bench.hs b/bench/Bench.hs
@@ -0,0 +1,125 @@
+{-# LANGUAGE BangPatterns #-}
+module Main where
+
+import Criterion.Main
+import qualified Data.ByteString as BS
+import qualified Data.Map as Map
+
+import ApplyStats (runApplyCounted, runApplyGlobalCounted, printApplyStats)
+import Eval
+import FileEval
+import Parser
+import Research
+
+-- | Pre-process a demo file and return its AST.
+loadDemo :: FilePath -> IO [TricuAST]
+loadDemo = preprocessFile
+
+-- | Evaluate a pre-processed demo to its result term.
+runDemo :: [TricuAST] -> T
+runDemo ast = result (evalTricu Map.empty ast)
+
+-- | Build an environment from a library file.
+loadLib :: FilePath -> IO Env
+loadLib = evaluateFile
+
+main :: IO ()
+main = do
+ !equalityAst <- loadDemo "demos/equality.tri"
+ !sizeAst <- loadDemo "demos/size.tri"
+ !toSourceAst <- loadDemo "demos/toSource.tri"
+ !levelOrderAst <- loadDemo "demos/levelOrderTraversal.tri"
+ !patternAst <- loadDemo "demos/patternMatching.tri"
+ !listLib <- loadLib "lib/list.tri"
+
+ -- Stress benchmark environment: Arboricx parser + size + toSource
+ !arboricxLib <- loadLib "lib/arboricx-dispatch.tri"
+ !sizeEnv <- evaluateFileWithContext arboricxLib "demos/size.tri"
+ !toSourceEnv <- evaluateFileWithContext sizeEnv "demos/toSource.tri"
+
+ -- Print apply stats for toSource not?
+ let Just toSource = Map.lookup "toSource" toSourceEnv
+ Just notTerm = Map.lookup "not?" toSourceEnv
+ (_result, stats) = runApplyCounted toSource notTerm
+ printApplyStats stats
+
+ -- Print apply stats for readArboricxContainer against id.arboricx
+ !idBundleBytes <- BS.readFile "test/fixtures/id.arboricx"
+ let Just readContainer = Map.lookup "readArboricxContainer" sizeEnv
+ bundleTree = ofBytes idBundleBytes
+ (_result2, stats2) <- runApplyGlobalCounted 100000 1000000 readContainer bundleTree
+ printApplyStats stats2
+
+ defaultMain
+ [ bgroup "demos"
+ [ bench "equality" $ whnf runDemo equalityAst
+ , bench "size" $ whnf runDemo sizeAst
+ , bench "toSource" $ whnf runDemo toSourceAst
+ , bench "levelOrderTraversal" $ whnf runDemo levelOrderAst
+ , bench "patternMatching" $ whnf runDemo patternAst
+ ]
+
+ , bgroup "lib/list.tri"
+ [ bench "append strings" $ whnf
+ (result . evalTricu listLib . parseTricu)
+ "append \"Hello, \" \"world!\""
+ , bench "map over 3 elements" $ whnf
+ (result . evalTricu listLib . parseTricu)
+ "head (tail (map (a : (t t t)) [(t) (t) (t)]))"
+ , bench "equal? same" $ whnf
+ (result . evalTricu listLib . parseTricu)
+ "equal? (t t t) (t t t)"
+ , bench "equal? different" $ whnf
+ (result . evalTricu listLib . parseTricu)
+ "equal? (t t) (t t t)"
+ , bench "triage Leaf" $ whnf
+ (result . evalTricu listLib . parseTricu)
+ "test t"
+ , bench "triage Stem" $ whnf
+ (result . evalTricu listLib . parseTricu)
+ "test (t t)"
+ , bench "triage Fork" $ whnf
+ (result . evalTricu listLib . parseTricu)
+ "test (t t t)"
+ , bench "not? true" $ whnf
+ (result . evalTricu listLib . parseTricu)
+ "not? (t t)"
+ , bench "not? false" $ whnf
+ (result . evalTricu listLib . parseTricu)
+ "not? t"
+ ]
+
+ , bgroup "stress"
+ [ bench "size runArboricxTyped" $ whnf
+ (result . evalTricu sizeEnv . parseTricu)
+ "size runArboricxTyped"
+ , bench "equal? runArboricxTyped runArboricxTyped" $ whnf
+ (result . evalTricu sizeEnv . parseTricu)
+ "equal? runArboricxTyped runArboricxTyped"
+ , bench "size readArboricxBundle" $ whnf
+ (result . evalTricu sizeEnv . parseTricu)
+ "size readArboricxBundle"
+ , bench "equal? readArboricxBundle readArboricxBundle" $ whnf
+ (result . evalTricu sizeEnv . parseTricu)
+ "equal? readArboricxBundle readArboricxBundle"
+ ]
+
+ , bgroup "raw-apply"
+ [ bench "rule-1 (Fork Leaf a) b" $ whnf
+ (\n -> apply (Fork Leaf (ofNumber n)) (ofNumber 42))
+ 1000
+ , bench "rule-2 (Fork (Stem a) b) c" $ whnf
+ (\n -> apply (Fork (Stem (ofNumber n)) (ofNumber n)) (ofNumber 42))
+ 1000
+ , bench "rule-3a (Fork (Fork a b) c) Leaf" $ whnf
+ (\n -> apply (Fork (Fork (ofNumber n) (ofNumber n)) (ofNumber n)) Leaf)
+ 1000
+ , bench "rule-3b (Fork (Fork a b) c) (Stem u)" $ whnf
+ (\n -> apply (Fork (Fork (ofNumber n) (ofNumber n)) (ofNumber n)) (Stem Leaf))
+ 1000
+ , bench "rule-3c (Fork (Fork a b) c) (Fork u v)" $ whnf
+ (\n -> apply (Fork (Fork (ofNumber n) (ofNumber n)) (ofNumber n)) (Fork Leaf Leaf))
+ 1000
+ ]
+
+ ]
diff --git a/demos/runArboricxBundle.tri b/demos/runArboricxBundle.tri
@@ -0,0 +1,26 @@
+!import "../lib/base.tri" !Local
+!import "../lib/list.tri" !Local
+!import "../lib/io.tri" !Local
+!import "../lib/arboricx.tri" !Local
+
+-- Read an Arboricx bundle from disk and execute it.
+-- This demo loads test/fixtures/id.arboricx and applies the
+-- default export to the string "hi". The id bundle simply
+-- returns its argument, so the expected output is:
+-- hi
+--
+-- Run with --allow-read test/fixtures/id.arboricx or --unsafe-io.
+
+runBundle = (path arg :
+ bind (readFile path)
+ (result :
+ matchResult
+ (err rest : putStrLn "ERROR: Could not read bundle file")
+ (bundleBytes rest :
+ matchResult
+ (err rest : putStrLn "ERROR: Could not execute bundle")
+ (value rest : putStrLn value)
+ (runArboricx bundleBytes arg))
+ result))
+
+main = io (runBundle "test/fixtures/id.arboricx" "hi")
diff --git a/docs/zig-io.md b/docs/zig-io.md
@@ -0,0 +1,193 @@
+# Zig Interaction-Tree IO Runtime Plan
+
+## Goal
+
+Port the Haskell `IODriver` interaction-tree system into the Zig host so that:
+
+1. The Zig CLI (`tricu-zig`) can execute tricu programs with effects (`putStr`, `readFile`, `fork`, etc.).
+2. The C FFI (`libarboricx`) exposes a single `arb_run_io` call, giving every language host (C, Python, PHP, Node) turnkey IO without reimplementing the protocol.
+3. The fast native reduction path (currently ~0.005s for `id "hello"`) is used for pure computation; IO syscalls happen only at effect boundaries.
+
+## Current State
+
+| Host | Reduction Speed | IO Support |
+|------|----------------|------------|
+| Haskell interpreter | ~1.7s for `runArboricxTyped` demo | Full `IODriver.hs` with scheduler, async, permissions |
+| Zig native | ~0.005s for `append` | None — pure reduction only |
+| Zig kernel | ~0.235s for `id.arboricx` | None — runs self-hosted parser, no effects |
+| C / Python / PHP FFI | Native Zig speed | None — can construct and reduce, cannot interpret interaction trees |
+
+The Haskell `IODriver` is ~500 lines of stateful code (scheduler, frame stack, permission checks, async lifecycle). Replicating it in every host language is a maintenance hazard. We will implement it **once** in Zig and share it through the C ABI.
+
+## Architecture
+
+### Layer 1 — Tree Inspection Primitives (C FFI)
+
+Minimal functions that let C (or other FFIs) inspect raw tree shape. Used internally by the driver, and exposed for non-POSIX hosts that need custom effect handlers.
+
+```c
+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_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);
+```
+
+### Layer 2 — POSIX IO Driver (C FFI)
+
+A single high-level call that runs the full interaction-tree loop:
+
+```c
+typedef struct {
+ int allow_read_all;
+ int allow_write_all;
+ const char** allowed_read_paths;
+ size_t allowed_read_count;
+ const char** allowed_write_paths;
+ size_t allowed_write_count;
+} arb_io_perms_t;
+
+// Reduce → decode action → perform syscall → feed result → repeat until pure.
+// Returns the final pure tree value.
+uint32_t arb_run_io(arb_ctx_t* ctx, uint32_t program,
+ const arb_io_perms_t* perms);
+```
+
+This is the only call 99% of hosts need. It contains the exact same logic as `IODriver.hs`:
+
+- **Frame stack** — `BindFrame` (sequencing) and `LocalFrame` (environment scoping)
+- **Runtime** — permissions, environment tree, mutable state tree
+- **Action dispatch** — decode the tag (pure, bind, putStr, getLine, readFile, writeFile, ask, local, get, put, fork, await, yield, sleep)
+- **Scheduler** — runnable queue, blocked tasks, sleeping tasks, wake-on-completion, deadlock detection
+- **Error protocol** — ok/err pairs with numeric codes
+
+### Zig CLI Integration
+
+Add `--io` and `--unsafe-io` flags to `tricu-zig`:
+
+```bash
+# Safe mode — no filesystem access (default when --io is used)
+tricu-zig --io greet.arboricx
+
+# Unsafe mode — full POSIX access (development / local scripts)
+tricu-zig --io --unsafe-io writeThenRead.arboricx
+
+# Specific paths
+# (future: --allow-read ./foo --allow-write ./bar)
+```
+
+Under `--io`, the CLI loads the bundle, reduces it once to WHNF, then passes the root to `arb_run_io` instead of eagerly decoding the final value.
+
+## Implementation Stages
+
+### Stage 1 — Tree Inspection Primitives
+
+Add the five inspection functions to `ext/zig/src/c_abi.zig` and `ext/zig/include/arboricx.h`. No logic changes to reduction; these just read arena node tags.
+
+**Acceptance:** A C test program can walk an arbitrary tree built with `arb_fork`/`arb_stem`/`arb_leaf` without knowing the arena internals.
+
+### Stage 2 — IO Protocol Decoder
+
+Write `ext/zig/src/io_driver.zig` containing:
+
+- `decodeAction` — inspect a reduced tree and identify the action tag (pure=0, bind=1, putStr=10, …)
+- `isIOSentinel` — verify `"tricuIO"` sentinel and version
+- `makePure`, `makeOkResult`, `makeErrResult` — construct standard response trees
+
+These are pure Zig functions with no syscalls. They mirror `IODriver.hs` logic but operate on arena indices.
+
+**Acceptance:** Unit tests decode each action type correctly from trees built via codecs.
+
+### Stage 3 — Synchronous IO Loop
+
+Implement the core driver loop with a frame stack:
+
+```zig
+while (true) {
+ current = reduce.reduce(current, scratch_arena, fuel);
+ if (isIOSentinel(current)) |action| {
+ switch (decodeAction(action)) {
+ .pure => { /* pop frame or return */ },
+ .bind => { /* push BindFrame, recurse into left */ },
+ .putStr => { /* write stdout, continue with Leaf */ },
+ .getLine => { /* read stdin, continue with string */ },
+ // ... etc
+ }
+ } else {
+ return current; // pure result
+ }
+}
+```
+
+Support synchronous actions only: `pure`, `bind`, `putStr`, `getLine`, `readFile`, `writeFile`, `ask`, `local`, `get`, `put`.
+
+**Acceptance:** `greet.tri` and `writeThenRead.tri` run correctly through `tricu-zig --io`.
+
+### Stage 4 — Scheduler and Async Actions
+
+Add the task scheduler for `fork`, `await`, `yield`, `sleep`:
+
+- `Runnable` queue (FIFO)
+- `BlockedOn` map (task → blocked task ID)
+- `Sleeping` map (task → wake time)
+- Round-robin scheduling with `yield` and `sleep` support
+- Deadlock detection when no runnable tasks remain and no sleepers
+
+This mirrors `IODriver.hs` exactly, including task handle encoding (`Fork("task", n)`).
+
+**Acceptance:** `demos/interactionTrees/forkAwait.tri` and `yield.tri` pass.
+
+### Stage 5 — Permission System
+
+Port path canonicalization and permission checks from Haskell:
+
+- Syntactic normalization (resolve `.`, reject `..`)
+- `--unsafe-io` bypass (allow all)
+- `--allow-read PATH` / `--allow-write PATH` allowlists
+- Error code 20 (`errPolicyDeny`) on violation
+
+**Acceptance:** File operations outside allowed paths return err pairs, not crashes.
+
+### Stage 6 — FFI Integration and Host Rollout
+
+- Expose `arb_run_io` in the C header
+- Update Python FFI test to verify IO round-trip
+- Update PHP wrapper to support `--io`
+- Document the two-layer model for future hosts (use `arb_run_io` for POSIX, Layer 1 primitives for custom runtimes)
+
+**Acceptance:** Every existing FFI test still passes; new IO test passes in Python.
+
+## Design Decisions
+
+### Why baked-in POSIX effects?
+
+- Most hosts (C, Python, PHP, native CLI) want real stdout/stdin/files.
+- One canonical implementation avoids divergence.
+- The Haskell `IODriver.hs` remains the reference spec; the Zig driver is the production runtime.
+
+### Why not callback-based by default?
+
+Callbacks add complexity for the common case. If a non-POSIX host (e.g., browser JS) needs custom effects, it can use the Layer 1 inspection primitives to build a ~50-line shim without reimplementing the scheduler. We can add `arb_run_io_with_callbacks` later if demand exists.
+
+### Why not implement in every host language?
+
+The Haskell `IODriver` is subtle: frame stack unwinding, async lifecycle, deadlock detection, path canonicalization, error code protocol. Bugs in any reimplementation would fracture the language ecosystem. A shared native driver is the only maintainable answer.
+
+## Risks and Open Questions
+
+1. **Fuel exhaustion during IO loops** — `arb_run_io` internally calls `reduce.reduce` with a fuel parameter. Should it accept a total fuel budget, or reset fuel per reduction step? The Haskell side has no fuel limit; we may want `arb_run_io_unlimited` and `arb_run_io_fueled` variants.
+
+2. **State threading** — The Haskell driver threads an environment and mutable state tree through the runtime. These are opaque `T` values manipulated by tricu code. The Zig driver must preserve them exactly across scheduler switches.
+
+3. **Binary vs text I/O** — `readFile` currently returns bytes (via `ofBytes` / `toString` in Haskell). The Zig driver must match the encoding exactly so that tricu code sees the same values in both hosts.
+
+4. **Error parity** — Every error code (1–99) and its corresponding tree shape must match Haskell exactly. Divergence here breaks cross-host compatibility.
+
+## Success Criteria
+
+- `tricu-zig --io demos/interactionTrees/greet.tri` prints `Hello, tricu` in <10ms.
+- `tricu-zig --io --unsafe-io demos/interactionTrees/writeThenRead.tri` writes and reads back a temp file correctly.
+- `tricu-zig --io --unsafe-io demos/interactionTrees/forkAwait.tri` completes with correct async results.
+- Python FFI can call `arb_run_io` and observe stdout from a tricu program.
+- No regression in pure-reduction benchmarks (native path still ~0.005s for `id`).
diff --git a/ext/zig/build.zig b/ext/zig/build.zig
@@ -31,6 +31,8 @@ pub fn build(b: *std.Build) void {
.optimize = optimize,
});
exe_mod.addImport("kernel_embed", kernel_mod);
+ exe_mod.link_libc = true;
+ exe_mod.linkSystemLibrary("uv", .{});
const exe = b.addExecutable(.{
.name = "tricu-zig",
.root_module = exe_mod,
@@ -50,6 +52,8 @@ pub fn build(b: *std.Build) void {
});
lib_mod.pic = true;
lib_mod.addImport("kernel_embed", kernel_mod);
+ lib_mod.link_libc = true;
+ lib_mod.linkSystemLibrary("uv", .{});
const static_lib = b.addLibrary(.{
.name = "arboricx",
.root_module = lib_mod,
diff --git a/ext/zig/include/arboricx.h b/ext/zig/include/arboricx.h
@@ -40,6 +40,25 @@ int arb_to_bool(arb_ctx_t* ctx, uint32_t root, int* out);
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);
+/* 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);
+
+/* IO driver (Layer 2 — POSIX interaction-tree runtime) */
+typedef struct {
+ int allow_read_all;
+ 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);
+
/* Kernel entrypoints */
uint32_t arb_kernel_root(arb_ctx_t* ctx);
diff --git a/ext/zig/src/c_abi.zig b/ext/zig/src/c_abi.zig
@@ -5,6 +5,7 @@ const reduce = @import("reduce.zig");
const codecs = @import("codecs.zig");
const kernel = @import("kernel.zig");
const bundle = @import("bundle.zig");
+const io_driver = @import("io_driver.zig");
/// Opaque handle for the C API. Layout is not exposed to C.
/// Holds a persistent arena for user-built terms and the kernel.
@@ -60,6 +61,57 @@ export fn arb_app(ctx: *ArbCtx, func: u32, arg: u32) u32 {
}
// ---------------------------------------------------------------------------
+// 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
@@ -158,6 +210,23 @@ export fn arb_unwrap_host_value(ctx: *ArbCtx, root: u32, out_tag: *u64, out_payl
}
// ---------------------------------------------------------------------------
+// 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 {
+ 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;
+}
+
+// ---------------------------------------------------------------------------
// Kernel entrypoints
// ---------------------------------------------------------------------------
diff --git a/ext/zig/src/io_driver.zig b/ext/zig/src/io_driver.zig
@@ -0,0 +1,845 @@
+const std = @import("std");
+const Arena = @import("arena.zig").Arena;
+const codecs = @import("codecs.zig");
+const reduce = @import("reduce.zig");
+const tree = @import("tree.zig");
+
+const c = @cImport({
+ @cInclude("uv.h");
+});
+
+// ---------------------------------------------------------------------------
+// Action tag constants (must match lib/io.tri and IODriver.hs)
+// ---------------------------------------------------------------------------
+
+pub const ActionTag = enum(u8) {
+ pure = 0,
+ bind = 1,
+ putStr = 10,
+ getLine = 11,
+ readFile = 20,
+ writeFile = 21,
+ ask = 30,
+ local = 31,
+ get = 40,
+ put = 41,
+ fork = 60,
+ await = 61,
+ yield = 62,
+ sleep = 63,
+};
+
+pub const Action = union(ActionTag) {
+ pure: u32,
+ bind: struct { left: u32, k: u32 },
+ putStr: u32,
+ getLine,
+ readFile: u32,
+ writeFile: struct { path: u32, contents: u32 },
+ ask,
+ local: struct { f: u32, action: u32 },
+ get,
+ put: u32,
+ fork: u32,
+ await: u32,
+ yield,
+ sleep: u32,
+};
+
+// ---------------------------------------------------------------------------
+// Error codes (must match IODriver.hs)
+// ---------------------------------------------------------------------------
+
+const ERR_DOES_NOT_EXIST: u64 = 1;
+const ERR_PERMISSION: u64 = 2;
+const ERR_ALREADY_EXISTS: u64 = 3;
+const ERR_IO_OTHER: u64 = 4;
+const ERR_POLICY_DENY: u64 = 20;
+const ERR_INVALID_ACTION: u64 = 40;
+const ERR_INVALID_STRING: u64 = 41;
+
+// ---------------------------------------------------------------------------
+// Permissions
+// ---------------------------------------------------------------------------
+
+pub const IOPerms = struct {
+ allow_read_all: bool = false,
+ allow_write_all: bool = false,
+};
+
+// ---------------------------------------------------------------------------
+// IO sentinel detection
+// ---------------------------------------------------------------------------
+
+pub fn isIOSentinel(arena: *Arena, root: u32) !?u32 {
+ const node = arena.get(root);
+ if (node.* != .fork) return null;
+
+ const sentinel = node.fork.left;
+ const rest = node.fork.right;
+
+ const sentinel_str = try codecs.toString(arena, sentinel);
+ defer {
+ if (sentinel_str) |s| {
+ arena.allocator.free(s);
+ }
+ }
+ 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 version_num = try codecs.toNumber(arena, rest_node.fork.left);
+ if (version_num == null or version_num.? != 1) return null;
+
+ return rest_node.fork.right;
+}
+
+// ---------------------------------------------------------------------------
+// Action decoding
+// ---------------------------------------------------------------------------
+
+pub fn decodeAction(arena: *Arena, root: u32) !?Action {
+ 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;
+
+ const tag: ActionTag = switch (tag_num.?) {
+ 0 => .pure,
+ 1 => .bind,
+ 10 => .putStr,
+ 11 => .getLine,
+ 20 => .readFile,
+ 21 => .writeFile,
+ 30 => .ask,
+ 31 => .local,
+ 40 => .get,
+ 41 => .put,
+ 60 => .fork,
+ 61 => .await,
+ 62 => .yield,
+ 63 => .sleep,
+ else => return null,
+ };
+
+ const payload = node.fork.right;
+
+ return switch (tag) {
+ .pure => Action{ .pure = payload },
+ .bind => blk: {
+ const payload_node = arena.get(payload);
+ if (payload_node.* != .fork) return null;
+ break :blk Action{ .bind = .{ .left = payload_node.fork.left, .k = payload_node.fork.right } };
+ },
+ .putStr => Action{ .putStr = payload },
+ .getLine => Action.getLine,
+ .readFile => Action{ .readFile = payload },
+ .writeFile => blk: {
+ const payload_node = arena.get(payload);
+ if (payload_node.* != .fork) return null;
+ break :blk Action{ .writeFile = .{ .path = payload_node.fork.left, .contents = payload_node.fork.right } };
+ },
+ .ask => Action.ask,
+ .local => blk: {
+ const payload_node = arena.get(payload);
+ if (payload_node.* != .fork) return null;
+ break :blk Action{ .local = .{ .f = payload_node.fork.left, .action = payload_node.fork.right } };
+ },
+ .get => Action.get,
+ .put => Action{ .put = payload },
+ .fork => Action{ .fork = payload },
+ .await => Action{ .await = payload },
+ .yield => Action.yield,
+ .sleep => Action{ .sleep = payload },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Response tree constructors
+// ---------------------------------------------------------------------------
+
+pub fn makePure(arena: *Arena, val: u32) !u32 {
+ const tag = try codecs.ofNumber(arena, 0);
+ return try arena.alloc(.{ .fork = .{ .left = tag, .right = val } });
+}
+
+pub fn makeOkResult(arena: *Arena, val: u32) !u32 {
+ const ok_tag = try arena.alloc(.{ .stem = .{ .child = try arena.alloc(.leaf) } });
+ const val_pair = try arena.alloc(.{ .fork = .{ .left = val, .right = try arena.alloc(.leaf) } });
+ return try arena.alloc(.{ .fork = .{ .left = ok_tag, .right = val_pair } });
+}
+
+pub fn makeErrResult(arena: *Arena, code: u64) !u32 {
+ const code_tree = try codecs.ofNumber(arena, code);
+ const code_pair = try arena.alloc(.{ .fork = .{ .left = code_tree, .right = try arena.alloc(.leaf) } });
+ return try arena.alloc(.{ .fork = .{ .left = try arena.alloc(.leaf), .right = code_pair } });
+}
+
+// ---------------------------------------------------------------------------
+// Frame stack and runtime
+// ---------------------------------------------------------------------------
+
+const Frame = union(enum) {
+ bind: u32, // continuation k
+ local: u32, // old env
+};
+
+const Runtime = struct {
+ env: u32,
+ state: u32,
+};
+
+// ---------------------------------------------------------------------------
+// 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
+// ---------------------------------------------------------------------------
+
+const Task = struct {
+ id: u64,
+ parent: ?*Task,
+ frames: std.ArrayList(Frame),
+ runtime: Runtime,
+ current: u32,
+ status: enum { runnable, blocked, completed },
+ result: ?u32,
+ waiting_for: ?u64,
+
+ fn init(gpa: std.mem.Allocator, id: u64, parent: ?*Task, env: u32, state: u32, current: u32) !*Task {
+ const task = try gpa.create(Task);
+ task.* = .{
+ .id = id,
+ .parent = parent,
+ .frames = std.ArrayList(Frame).empty,
+ .runtime = .{ .env = env, .state = state },
+ .current = current,
+ .status = .runnable,
+ .result = null,
+ .waiting_for = null,
+ };
+ return task;
+ }
+
+ fn deinit(self: *Task, gpa: std.mem.Allocator) void {
+ self.frames.deinit(gpa);
+ gpa.destroy(self);
+ }
+
+ // finishValue processes a value through the frame stack.
+ // Returns true if the task has completed (no more frames).
+ fn finishValue(self: *Task, arena: *Arena, value: u32) !bool {
+ if (self.frames.pop()) |frame| {
+ switch (frame) {
+ .bind => |k| {
+ self.current = try arena.alloc(.{ .app = .{ .func = k, .arg = value } });
+ return false;
+ },
+ .local => |old_env| {
+ self.runtime.env = old_env;
+ self.current = try makePure(arena, value);
+ return false;
+ },
+ }
+ } else {
+ self.current = value;
+ return true;
+ }
+ }
+};
+
+// ---------------------------------------------------------------------------
+// Scheduler
+// ---------------------------------------------------------------------------
+
+const Scheduler = struct {
+ gpa: std.mem.Allocator,
+ loop: *c.uv_loop_t,
+ arena: *Arena,
+ tasks: std.ArrayList(*Task),
+ runnable: std.ArrayList(*Task),
+ next_id: u64,
+ perms: IOPerms,
+
+ fn init(gpa: std.mem.Allocator, loop: *c.uv_loop_t, arena: *Arena, perms: IOPerms) !Scheduler {
+ const sched = Scheduler{
+ .gpa = gpa,
+ .loop = loop,
+ .arena = arena,
+ .tasks = std.ArrayList(*Task).empty,
+ .runnable = std.ArrayList(*Task).empty,
+ .next_id = 1,
+ .perms = perms,
+ };
+ return sched;
+ }
+
+ fn deinit(self: *Scheduler) void {
+ for (self.tasks.items) |task| {
+ task.deinit(self.gpa);
+ }
+ self.tasks.deinit(self.gpa);
+ self.runnable.deinit(self.gpa);
+ }
+
+ fn createTask(self: *Scheduler, parent: ?*Task, env: u32, state: u32, current: u32) !*Task {
+ const id = self.next_id;
+ self.next_id += 1;
+ const task = try Task.init(self.gpa, id, parent, env, state, current);
+ try self.tasks.append(self.gpa, task);
+ return task;
+ }
+
+ fn run(self: *Scheduler) !void {
+ while (true) {
+ if (self.runnable.items.len > 0) {
+ const task = self.runnable.orderedRemove(0);
+ try self.stepTask(task);
+ } else if (self.hasPendingHandles()) {
+ _ = c.uv_run(self.loop, c.UV_RUN_ONCE);
+ } else {
+ break;
+ }
+ }
+ }
+
+ fn hasPendingHandles(self: *Scheduler) bool {
+ return c.uv_loop_alive(self.loop) != 0;
+ }
+
+ fn completeTask(self: *Scheduler, task: *Task) !void {
+ task.status = .completed;
+ task.result = task.current;
+ // Unblock any tasks waiting for this one
+ for (self.tasks.items) |t| {
+ if (t.status == .blocked and t.waiting_for == task.id) {
+ t.status = .runnable;
+ t.waiting_for = null;
+ t.current = try makePure(self.arena, task.result.?);
+ try self.runnable.append(self.gpa, t);
+ }
+ }
+ }
+
+ fn stepTask(self: *Scheduler, task: *Task) !void {
+ const reduced = try reduceInScratch(self.gpa, self.arena, task.current);
+
+ const decoded = try decodeAction(self.arena, reduced);
+ if (decoded == null) {
+ // Not a recognized action — if no frames, it's the final result.
+ // Otherwise treat as invalid.
+ if (task.frames.items.len == 0) {
+ task.current = reduced;
+ try self.completeTask(task);
+ return;
+ }
+ const err = try makeErrResult(self.arena, ERR_INVALID_ACTION);
+ if (try task.finishValue(self.arena, err)) {
+ try self.completeTask(task);
+ } else {
+ try self.runnable.append(self.gpa, task);
+ }
+ return;
+ }
+
+ switch (decoded.?) {
+ .pure => |val| {
+ if (try task.finishValue(self.arena, val)) {
+ try self.completeTask(task);
+ } else {
+ try self.runnable.append(self.gpa, task);
+ }
+ },
+
+ .bind => |b| {
+ try task.frames.append(self.gpa, .{ .bind = b.k });
+ task.current = b.left;
+ try self.runnable.append(self.gpa, task);
+ },
+
+ .putStr => |str_tree| {
+ const str = try codecs.toString(self.arena, str_tree) orelse {
+ const err = try makeErrResult(self.arena, ERR_INVALID_STRING);
+ if (try task.finishValue(self.arena, err)) {
+ try self.completeTask(task);
+ } else {
+ try self.runnable.append(self.gpa, task);
+ }
+ return;
+ };
+ defer self.gpa.free(str);
+ _ = std.c.write(1, str.ptr, str.len);
+ const leaf = try self.arena.alloc(.leaf);
+ if (try task.finishValue(self.arena, leaf)) {
+ try self.completeTask(task);
+ } else {
+ try self.runnable.append(self.gpa, task);
+ }
+ },
+
+ .getLine => {
+ var buf: [4096]u8 = undefined;
+ var len: usize = 0;
+ while (len < buf.len) {
+ const n = std.c.read(0, buf[len..].ptr, 1);
+ if (n <= 0) break;
+ if (buf[len] == '\n') break;
+ len += 1;
+ }
+ const line = buf[0..len];
+ const str_tree = try codecs.ofString(self.arena, line);
+ if (try task.finishValue(self.arena, str_tree)) {
+ try self.completeTask(task);
+ } else {
+ try self.runnable.append(self.gpa, task);
+ }
+ },
+
+ .readFile => |path_tree| {
+ const path = try codecs.toString(self.arena, path_tree) orelse {
+ const err = try makeErrResult(self.arena, ERR_INVALID_STRING);
+ if (try task.finishValue(self.arena, err)) {
+ try self.completeTask(task);
+ } else {
+ try self.runnable.append(self.gpa, task);
+ }
+ return;
+ };
+
+ if (!self.perms.allow_read_all) {
+ self.arena.allocator.free(path);
+ const err = try makeErrResult(self.arena, ERR_POLICY_DENY);
+ if (try task.finishValue(self.arena, err)) {
+ try self.completeTask(task);
+ } else {
+ try self.runnable.append(self.gpa, task);
+ }
+ return;
+ }
+
+ const ctx = try self.gpa.create(FileReadCtx);
+ ctx.* = .{
+ .scheduler = self,
+ .task = task,
+ .arena = self.arena,
+ .gpa = self.gpa,
+ .fd = -1,
+ .buf = std.ArrayList(u8).empty,
+ .path = path,
+ .req = undefined,
+ .read_buf = null,
+ };
+ ctx.req.data = ctx;
+ _ = c.uv_fs_open(self.loop, &ctx.req, ctx.path.ptr, c.O_RDONLY, 0, file_open_cb);
+ },
+
+ .writeFile => |wf| {
+ const path = try codecs.toString(self.arena, wf.path) orelse {
+ const err = try makeErrResult(self.arena, ERR_INVALID_STRING);
+ if (try task.finishValue(self.arena, err)) {
+ try self.completeTask(task);
+ } else {
+ try self.runnable.append(self.gpa, task);
+ }
+ return;
+ };
+
+ const contents = try codecs.toString(self.arena, wf.contents) orelse {
+ self.arena.allocator.free(path);
+ const err = try makeErrResult(self.arena, ERR_INVALID_STRING);
+ if (try task.finishValue(self.arena, err)) {
+ try self.completeTask(task);
+ } else {
+ try self.runnable.append(self.gpa, task);
+ }
+ return;
+ };
+
+ if (!self.perms.allow_write_all) {
+ self.arena.allocator.free(path);
+ self.arena.allocator.free(contents);
+ const err = try makeErrResult(self.arena, ERR_POLICY_DENY);
+ if (try task.finishValue(self.arena, err)) {
+ try self.completeTask(task);
+ } else {
+ try self.runnable.append(self.gpa, task);
+ }
+ return;
+ }
+
+ const ctx = try self.gpa.create(FileWriteCtx);
+ ctx.* = .{
+ .scheduler = self,
+ .task = task,
+ .arena = self.arena,
+ .gpa = self.gpa,
+ .fd = -1,
+ .path = path,
+ .contents = contents,
+ .written = false,
+ .req = undefined,
+ };
+ ctx.req.data = ctx;
+ const flags = c.O_WRONLY | c.O_CREAT | c.O_TRUNC;
+ _ = c.uv_fs_open(self.loop, &ctx.req, ctx.path.ptr, flags, 0o644, file_write_open_cb);
+ },
+
+ .ask => {
+ if (try task.finishValue(self.arena, task.runtime.env)) {
+ try self.completeTask(task);
+ } else {
+ try self.runnable.append(self.gpa, task);
+ }
+ },
+
+ .local => |loc| {
+ const new_env = try reduceInScratch(self.gpa, self.arena, try self.arena.alloc(.{ .app = .{ .func = loc.f, .arg = task.runtime.env } }));
+ try task.frames.append(self.gpa, .{ .local = task.runtime.env });
+ task.runtime.env = new_env;
+ task.current = loc.action;
+ try self.runnable.append(self.gpa, task);
+ },
+
+ .get => {
+ if (try task.finishValue(self.arena, task.runtime.state)) {
+ try self.completeTask(task);
+ } else {
+ try self.runnable.append(self.gpa, task);
+ }
+ },
+
+ .put => |new_state| {
+ task.runtime.state = new_state;
+ const leaf = try self.arena.alloc(.leaf);
+ if (try task.finishValue(self.arena, leaf)) {
+ try self.completeTask(task);
+ } else {
+ try self.runnable.append(self.gpa, task);
+ }
+ },
+
+ .fork => |action| {
+ const child = try self.createTask(task, task.runtime.env, task.runtime.state, action);
+ try self.runnable.append(self.gpa, child);
+ const handle = try codecs.ofNumber(self.arena, child.id);
+ if (try task.finishValue(self.arena, handle)) {
+ try self.completeTask(task);
+ } else {
+ try self.runnable.append(self.gpa, task);
+ }
+ },
+
+ .await => |handle_tree| {
+ const handle = try codecs.toNumber(self.arena, handle_tree) orelse {
+ const err = try makeErrResult(self.arena, ERR_INVALID_ACTION);
+ if (try task.finishValue(self.arena, err)) {
+ try self.completeTask(task);
+ } else {
+ try self.runnable.append(self.gpa, task);
+ }
+ return;
+ };
+ var found: ?*Task = null;
+ for (self.tasks.items) |t| {
+ if (t.id == handle) {
+ found = t;
+ break;
+ }
+ }
+ if (found == null) {
+ const err = try makeErrResult(self.arena, ERR_INVALID_ACTION);
+ if (try task.finishValue(self.arena, err)) {
+ try self.completeTask(task);
+ } else {
+ try self.runnable.append(self.gpa, task);
+ }
+ return;
+ }
+ if (found.?.status == .completed) {
+ const result = found.?.result.?;
+ if (try task.finishValue(self.arena, result)) {
+ try self.completeTask(task);
+ } else {
+ try self.runnable.append(self.gpa, task);
+ }
+ } else {
+ task.status = .blocked;
+ task.waiting_for = handle;
+ // Task remains out of runnable until child completes
+ }
+ },
+
+ .yield => {
+ const leaf = try self.arena.alloc(.leaf);
+ if (try task.finishValue(self.arena, leaf)) {
+ try self.completeTask(task);
+ } else {
+ try self.runnable.append(self.gpa, task);
+ }
+ },
+
+ .sleep => |ms_tree| {
+ const ms = try codecs.toNumber(self.arena, ms_tree) orelse 0;
+ const ctx = try self.gpa.create(SleepCtx);
+ ctx.* = .{
+ .scheduler = self,
+ .task = task,
+ .arena = self.arena,
+ .timer = undefined,
+ };
+ ctx.timer.data = ctx;
+ _ = c.uv_timer_init(self.loop, &ctx.timer);
+ _ = c.uv_timer_start(&ctx.timer, sleep_cb, @intCast(ms), 0);
+ },
+ }
+ }
+};
+
+// ---------------------------------------------------------------------------
+// Async file read
+// ---------------------------------------------------------------------------
+
+const FileReadCtx = struct {
+ scheduler: *Scheduler,
+ task: *Task,
+ arena: *Arena,
+ gpa: std.mem.Allocator,
+ fd: c_int,
+ buf: std.ArrayList(u8),
+ path: []const u8,
+ req: c.uv_fs_t,
+ read_buf: ?[]u8,
+};
+
+fn mapUvErr(uv_err: c_int) u64 {
+ return switch (uv_err) {
+ c.UV_ENOENT => ERR_DOES_NOT_EXIST,
+ c.UV_EACCES => ERR_PERMISSION,
+ c.UV_EEXIST => ERR_ALREADY_EXISTS,
+ else => ERR_IO_OTHER,
+ };
+}
+
+fn file_open_cb(req: [*c]c.uv_fs_t) callconv(.c) void {
+ const ctx = @as(*FileReadCtx, @ptrCast(@alignCast(req.*.data)));
+ const result = req.*.result;
+ c.uv_fs_req_cleanup(req);
+ if (result < 0) {
+ const err = makeErrResult(ctx.arena, mapUvErr(@intCast(-result))) catch {
+ ctx.gpa.destroy(ctx);
+ return;
+ };
+ if (ctx.task.finishValue(ctx.arena, err) catch false) {
+ ctx.scheduler.completeTask(ctx.task) catch {};
+ } else {
+ ctx.scheduler.runnable.append(ctx.scheduler.gpa, ctx.task) catch {};
+ }
+ ctx.buf.deinit(ctx.gpa);
+ ctx.gpa.free(ctx.path);
+ ctx.gpa.destroy(ctx);
+ return;
+ }
+ ctx.fd = @intCast(result);
+ const read_buf = ctx.gpa.alloc(u8, 4096) catch unreachable;
+ ctx.read_buf = read_buf;
+ var uv_buf = c.uv_buf_init(@ptrCast(read_buf.ptr), @intCast(read_buf.len));
+ _ = c.uv_fs_read(ctx.scheduler.loop, req, ctx.fd, &uv_buf, 1, -1, file_read_cb);
+}
+
+fn file_read_cb(req: [*c]c.uv_fs_t) callconv(.c) void {
+ const ctx = @as(*FileReadCtx, @ptrCast(@alignCast(req.*.data)));
+ const nread = req.*.result;
+ c.uv_fs_req_cleanup(req);
+ if (nread < 0) {
+ _ = c.uv_fs_close(ctx.scheduler.loop, req, ctx.fd, null);
+ const err = makeErrResult(ctx.arena, mapUvErr(@intCast(-nread))) catch {
+ ctx.gpa.destroy(ctx);
+ return;
+ };
+ if (ctx.task.finishValue(ctx.arena, err) catch false) {
+ ctx.scheduler.completeTask(ctx.task) catch {};
+ } else {
+ ctx.scheduler.runnable.append(ctx.scheduler.gpa, ctx.task) catch {};
+ }
+ if (ctx.read_buf) |b| ctx.gpa.free(b);
+ ctx.buf.deinit(ctx.gpa);
+ ctx.gpa.free(ctx.path);
+ ctx.gpa.destroy(ctx);
+ return;
+ }
+ if (nread == 0) {
+ // EOF
+ _ = c.uv_fs_close(ctx.scheduler.loop, req, ctx.fd, null);
+ const bytes_tree = codecs.ofBytes(ctx.arena, ctx.buf.items) catch {
+ ctx.gpa.destroy(ctx);
+ return;
+ };
+ const ok = makeOkResult(ctx.arena, bytes_tree) catch {
+ ctx.gpa.destroy(ctx);
+ return;
+ };
+ if (ctx.task.finishValue(ctx.arena, ok) catch false) {
+ ctx.scheduler.completeTask(ctx.task) catch {};
+ } else {
+ ctx.scheduler.runnable.append(ctx.scheduler.gpa, ctx.task) catch {};
+ }
+ if (ctx.read_buf) |b| ctx.gpa.free(b);
+ ctx.buf.deinit(ctx.gpa);
+ ctx.gpa.free(ctx.path);
+ ctx.gpa.destroy(ctx);
+ return;
+ }
+ const data = ctx.read_buf.?[0..@intCast(nread)];
+ ctx.buf.appendSlice(ctx.gpa, data) catch unreachable;
+ const read_buf = ctx.gpa.alloc(u8, 4096) catch unreachable;
+ ctx.read_buf = read_buf;
+ var uv_buf = c.uv_buf_init(@ptrCast(read_buf.ptr), @intCast(read_buf.len));
+ _ = c.uv_fs_read(ctx.scheduler.loop, req, ctx.fd, &uv_buf, 1, -1, file_read_cb);
+}
+
+// ---------------------------------------------------------------------------
+// Async file write
+// ---------------------------------------------------------------------------
+
+const FileWriteCtx = struct {
+ scheduler: *Scheduler,
+ task: *Task,
+ arena: *Arena,
+ gpa: std.mem.Allocator,
+ fd: c_int,
+ path: []const u8,
+ contents: []const u8,
+ written: bool,
+ req: c.uv_fs_t,
+};
+
+fn file_write_open_cb(req: [*c]c.uv_fs_t) callconv(.c) void {
+ const ctx = @as(*FileWriteCtx, @ptrCast(@alignCast(req.*.data)));
+ const result = req.*.result;
+ c.uv_fs_req_cleanup(req);
+ if (result < 0) {
+ const err = makeErrResult(ctx.arena, mapUvErr(@intCast(-result))) catch {
+ ctx.gpa.destroy(ctx);
+ return;
+ };
+ if (ctx.task.finishValue(ctx.arena, err) catch false) {
+ ctx.scheduler.completeTask(ctx.task) catch {};
+ } else {
+ ctx.scheduler.runnable.append(ctx.scheduler.gpa, ctx.task) catch {};
+ }
+ ctx.gpa.free(ctx.path);
+ ctx.gpa.free(ctx.contents);
+ ctx.gpa.destroy(ctx);
+ return;
+ }
+ ctx.fd = @intCast(result);
+ var uv_buf = c.uv_buf_init(@ptrCast(@constCast(ctx.contents.ptr)), @intCast(ctx.contents.len));
+ _ = c.uv_fs_write(ctx.scheduler.loop, req, ctx.fd, &uv_buf, 1, 0, file_write_cb);
+}
+
+fn file_write_cb(req: [*c]c.uv_fs_t) callconv(.c) void {
+ const ctx = @as(*FileWriteCtx, @ptrCast(@alignCast(req.*.data)));
+ const nwrite = req.*.result;
+ c.uv_fs_req_cleanup(req);
+ if (nwrite < 0) {
+ _ = c.uv_fs_close(ctx.scheduler.loop, req, ctx.fd, null);
+ const err = makeErrResult(ctx.arena, mapUvErr(@intCast(-nwrite))) catch {
+ ctx.gpa.destroy(ctx);
+ return;
+ };
+ if (ctx.task.finishValue(ctx.arena, err) catch false) {
+ ctx.scheduler.completeTask(ctx.task) catch {};
+ } else {
+ ctx.scheduler.runnable.append(ctx.scheduler.gpa, ctx.task) catch {};
+ }
+ ctx.gpa.free(ctx.path);
+ ctx.gpa.free(ctx.contents);
+ ctx.gpa.destroy(ctx);
+ return;
+ }
+ _ = c.uv_fs_close(ctx.scheduler.loop, req, ctx.fd, file_write_close_cb);
+}
+
+fn file_write_close_cb(req: [*c]c.uv_fs_t) callconv(.c) void {
+ const ctx = @as(*FileWriteCtx, @ptrCast(@alignCast(req.*.data)));
+ c.uv_fs_req_cleanup(req);
+ const leaf = ctx.arena.alloc(.leaf) catch {
+ ctx.gpa.destroy(ctx);
+ return;
+ };
+ const ok = makeOkResult(ctx.arena, leaf) catch {
+ ctx.gpa.destroy(ctx);
+ return;
+ };
+ if (ctx.task.finishValue(ctx.arena, ok) catch false) {
+ ctx.scheduler.completeTask(ctx.task) catch {};
+ } else {
+ ctx.scheduler.runnable.append(ctx.scheduler.gpa, ctx.task) catch {};
+ }
+ ctx.gpa.free(ctx.path);
+ ctx.gpa.free(ctx.contents);
+ ctx.gpa.destroy(ctx);
+}
+
+// ---------------------------------------------------------------------------
+// Async sleep
+// ---------------------------------------------------------------------------
+
+const SleepCtx = struct {
+ scheduler: *Scheduler,
+ task: *Task,
+ arena: *Arena,
+ timer: c.uv_timer_t,
+};
+
+fn sleep_cb(handle: [*c]c.uv_timer_t) callconv(.c) void {
+ const ctx = @as(*SleepCtx, @ptrCast(@alignCast(handle.*.data)));
+ defer ctx.scheduler.gpa.destroy(ctx);
+ const leaf = ctx.arena.alloc(.leaf) catch {
+ ctx.scheduler.runnable.append(ctx.scheduler.gpa, ctx.task) catch {};
+ return;
+ };
+ if (ctx.task.finishValue(ctx.arena, leaf) catch false) {
+ ctx.scheduler.completeTask(ctx.task) catch {};
+ } else {
+ ctx.scheduler.runnable.append(ctx.scheduler.gpa, ctx.task) catch {};
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Public entry point
+// ---------------------------------------------------------------------------
+
+pub fn runIO(gpa: std.mem.Allocator, arena: *Arena, program: u32, perms: IOPerms) !u32 {
+ const action_tree = try isIOSentinel(arena, program) orelse {
+ return error.InvalidIOSentinel;
+ };
+
+ var loop: c.uv_loop_t = undefined;
+ const rc = c.uv_loop_init(&loop);
+ if (rc != 0) return error.LoopInitFailed;
+ defer _ = c.uv_loop_close(&loop);
+
+ var scheduler = try Scheduler.init(gpa, &loop, arena, perms);
+ defer scheduler.deinit();
+
+ const main_task = try scheduler.createTask(null, try arena.alloc(.leaf), try arena.alloc(.leaf), action_tree);
+ try scheduler.runnable.append(gpa, main_task);
+
+ try scheduler.run();
+
+ // Return the main task's result
+ return main_task.result orelse program;
+}
diff --git a/ext/zig/src/main.zig b/ext/zig/src/main.zig
@@ -5,24 +5,15 @@ const reduce = @import("reduce.zig");
const codecs = @import("codecs.zig");
const kernel = @import("kernel.zig");
const bundle = @import("bundle.zig");
+const io_driver = @import("io_driver.zig");
-fn runNative(arena: *Arena, tag: u64, bundle_bytes: []const u8, args_raw: []const []const u8, fuel: u64, io: std.Io) !void {
- const term = try bundle.loadBundleDefaultRoot(arena, bundle_bytes);
-
- var current = term;
- for (args_raw) |arg| {
- const arg_tree = try parseArg(arena, io, arg);
- current = try arena.alloc(.{ .app = .{ .func = current, .arg = arg_tree } });
- }
-
- const result = try reduce.reduce(current, arena, fuel);
-
+fn printNode(arena: *Arena, tag: u64, node: u32, io: std.Io) !void {
var stdout_buf: [4096]u8 = undefined;
var stdout = std.Io.File.stdout().writer(io, &stdout_buf);
switch (tag) {
codecs.HOST_STRING_TAG => {
- const s = try codecs.toString(arena, result) orelse {
+ const s = try codecs.toString(arena, node) orelse {
try stdout.interface.writeAll("Error: failed to decode string result\n");
try stdout.flush();
return error.DecodeFailed;
@@ -32,11 +23,11 @@ fn runNative(arena: *Arena, tag: u64, bundle_bytes: []const u8, args_raw: []cons
try stdout.interface.writeAll("\n");
},
codecs.HOST_NUMBER_TAG => {
- const n = try codecs.toNumber(arena, result) orelse 0;
+ const n = try codecs.toNumber(arena, node) orelse 0;
try stdout.interface.print("{d}\n", .{n});
},
codecs.HOST_BOOL_TAG => {
- const b = try codecs.toBool(arena, result) orelse {
+ const b = try codecs.toBool(arena, node) orelse {
try stdout.interface.writeAll("Error: failed to decode bool result\n");
try stdout.flush();
return error.DecodeFailed;
@@ -44,18 +35,53 @@ fn runNative(arena: *Arena, tag: u64, bundle_bytes: []const u8, args_raw: []cons
try stdout.interface.writeAll(if (b) "true\n" else "false\n");
},
codecs.HOST_TREE_TAG => {
- try tree.formatTree(&stdout.interface, arena, result, 0);
+ try tree.formatTree(&stdout.interface, arena, node, 0);
try stdout.interface.writeAll("\n");
},
else => {
try stdout.interface.print("(tag={d}, payload=", .{tag});
- try tree.formatTree(&stdout.interface, arena, result, 0);
+ try tree.formatTree(&stdout.interface, arena, node, 0);
try stdout.interface.writeAll(")\n");
},
}
try stdout.flush();
}
+fn runNative(arena: *Arena, tag: u64, bundle_bytes: []const u8, args_raw: []const []const u8, fuel: u64, io: std.Io) !void {
+ const term = try bundle.loadBundleDefaultRoot(arena, bundle_bytes);
+
+ var current = term;
+ for (args_raw) |arg| {
+ const arg_tree = try parseArg(arena, io, arg);
+ current = try arena.alloc(.{ .app = .{ .func = current, .arg = arg_tree } });
+ }
+
+ const result = try reduce.reduce(current, arena, fuel);
+ try printNode(arena, tag, result, io);
+}
+
+fn runIO(arena: *Arena, tag: u64, bundle_bytes: []const u8, args_raw: []const []const u8, fuel: u64, perms: io_driver.IOPerms, io: std.Io) !void {
+ const term = try bundle.loadBundleDefaultRoot(arena, bundle_bytes);
+
+ var current = term;
+ for (args_raw) |arg| {
+ const arg_tree = try parseArg(arena, io, arg);
+ current = try arena.alloc(.{ .app = .{ .func = current, .arg = arg_tree } });
+ }
+
+ const reduced = try reduce.reduce(current, arena, fuel);
+
+ if (try io_driver.isIOSentinel(arena, reduced) == null) {
+ var stderr = std.Io.File.stderr().writer(io, &[_]u8{});
+ try stderr.interface.writeAll("Error: reduced term is not a valid IO program\n");
+ try stderr.flush();
+ std.process.exit(1);
+ }
+
+ const result = try io_driver.runIO(arena.allocator, arena, reduced, perms);
+ try printNode(arena, tag, result, io);
+}
+
fn runBundle(arena: *Arena, tag: u64, bundle_bytes: []const u8, args_raw: []const []const u8, fuel: u64, io: std.Io) !void {
const kernel_root = try kernel.loadKernel(arena);
@@ -98,43 +124,7 @@ fn runBundle(arena: *Arena, tag: u64, bundle_bytes: []const u8, args_raw: []cons
return error.InvalidHostValue;
};
- var stdout_buf: [4096]u8 = undefined;
- var stdout = std.Io.File.stdout().writer(io, &stdout_buf);
-
- switch (hv.tag) {
- codecs.HOST_STRING_TAG => {
- const s = try codecs.toString(arena, hv.payload) orelse {
- try stdout.interface.writeAll("Error: failed to decode string payload\n");
- try stdout.flush();
- return error.DecodeFailed;
- };
- defer arena.allocator.free(s);
- try stdout.interface.writeAll(s);
- try stdout.interface.writeAll("\n");
- },
- codecs.HOST_NUMBER_TAG => {
- const n = try codecs.toNumber(arena, hv.payload) orelse 0;
- try stdout.interface.print("{d}\n", .{n});
- },
- codecs.HOST_BOOL_TAG => {
- const b = try codecs.toBool(arena, hv.payload) orelse {
- try stdout.interface.writeAll("Error: failed to decode bool payload\n");
- try stdout.flush();
- return error.DecodeFailed;
- };
- try stdout.interface.writeAll(if (b) "true\n" else "false\n");
- },
- codecs.HOST_TREE_TAG => {
- try tree.formatTree(&stdout.interface, arena, hv.payload, 0);
- try stdout.interface.writeAll("\n");
- },
- else => {
- try stdout.interface.print("(tag={d}, payload=", .{hv.tag});
- try tree.formatTree(&stdout.interface, arena, hv.payload, 0);
- try stdout.interface.writeAll(")\n");
- },
- }
- try stdout.flush();
+ try printNode(arena, hv.tag, hv.payload, io);
}
fn parseArg(arena: *Arena, io: std.Io, s: []const u8) !u32 {
@@ -162,7 +152,7 @@ pub fn main(init: std.process.Init) !void {
const args = try init.minimal.args.toSlice(init.arena.allocator());
if (args.len < 2) {
var stderr = std.Io.File.stderr().writer(io, &[_]u8{});
- try stderr.interface.writeAll("Usage: tricu-zig [--type TYPE] [--kernel] [--fuel N] <bundle.arboricx> [arg1 arg2 ...]\n");
+ try stderr.interface.writeAll("Usage: tricu-zig [--type TYPE] [--kernel] [--io] [--unsafe-io] [--fuel N] <bundle.arboricx> [arg1 arg2 ...]\n");
try stderr.flush();
std.process.exit(1);
}
@@ -173,6 +163,8 @@ pub fn main(init: std.process.Init) !void {
var arg_start: usize = 2;
var use_kernel = false;
+ var use_io = false;
+ var io_perms = io_driver.IOPerms{};
var fuel: u64 = std.math.maxInt(u64);
var i: usize = 1;
@@ -180,7 +172,7 @@ pub fn main(init: std.process.Init) !void {
if (std.mem.eql(u8, args[i], "--type")) {
if (i + 1 >= args.len) {
var stderr = std.Io.File.stderr().writer(io, &[_]u8{});
- try stderr.interface.writeAll("Usage: tricu-zig --type <tree|number|bool|string|list|bytes> [--fuel N] <bundle> [args...]\n");
+ try stderr.interface.writeAll("Usage: tricu-zig --type <tree|number|bool|string|list|bytes> [--io] [--unsafe-io] [--fuel N] <bundle> [args...]\n");
try stderr.flush();
std.process.exit(1);
}
@@ -201,10 +193,15 @@ pub fn main(init: std.process.Init) !void {
i += 1;
} else if (std.mem.eql(u8, args[i], "--kernel")) {
use_kernel = true;
+ } else if (std.mem.eql(u8, args[i], "--io")) {
+ use_io = true;
+ } else if (std.mem.eql(u8, args[i], "--unsafe-io")) {
+ io_perms.allow_read_all = true;
+ io_perms.allow_write_all = true;
} else if (std.mem.eql(u8, args[i], "--fuel")) {
if (i + 1 >= args.len) {
var stderr = std.Io.File.stderr().writer(io, &[_]u8{});
- try stderr.interface.writeAll("Usage: tricu-zig --fuel <N> <bundle> [args...]\n");
+ try stderr.interface.writeAll("Usage: tricu-zig --fuel <N> [--io] [--unsafe-io] <bundle> [args...]\n");
try stderr.flush();
std.process.exit(1);
}
@@ -225,7 +222,7 @@ pub fn main(init: std.process.Init) !void {
if (bundle_idx >= args.len) {
var stderr = std.Io.File.stderr().writer(io, &[_]u8{});
- try stderr.interface.writeAll("Usage: tricu-zig [--type TYPE] [--kernel] [--fuel N] <bundle.arboricx> [arg1 arg2 ...]\n");
+ try stderr.interface.writeAll("Usage: tricu-zig [--type TYPE] [--kernel] [--io] [--unsafe-io] [--fuel N] <bundle.arboricx> [arg1 arg2 ...]\n");
try stderr.flush();
std.process.exit(1);
}
@@ -239,7 +236,14 @@ pub fn main(init: std.process.Init) !void {
const call_args = if (arg_start < args.len) args[arg_start..] else &[_][]const u8{};
- if (use_kernel) {
+ if (use_io) {
+ runIO(&arena, tag, bundle_bytes, call_args, fuel, io_perms, io) catch |err| {
+ var stderr = std.Io.File.stderr().writer(io, &[_]u8{});
+ try stderr.interface.print("Execution failed: {s}\n", .{@errorName(err)});
+ try stderr.flush();
+ std.process.exit(1);
+ };
+ } else if (use_kernel) {
runBundle(&arena, tag, bundle_bytes, call_args, fuel, io) catch |err| {
var stderr = std.Io.File.stderr().writer(io, &[_]u8{});
try stderr.interface.print("Execution failed: {s}\n", .{@errorName(err)});
diff --git a/ext/zig/tests/c_abi_test.c b/ext/zig/tests/c_abi_test.c
@@ -51,6 +51,68 @@ int main(void) {
}
printf("PASS: kernel loaded (root=%u)\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);
+
+ if (!arb_is_leaf(ctx, l)) {
+ fprintf(stderr, "FAIL: is_leaf on leaf\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+ if (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)) {
+ fprintf(stderr, "FAIL: is_stem on stem\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+ if (!arb_is_fork(ctx, f)) {
+ fprintf(stderr, "FAIL: is_fork on fork\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+ if (!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) {
+ 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) {
+ 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) {
+ fprintf(stderr, "FAIL: get_app_func_arg\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+
+ /* Invalid index should return 0 */
+ if (arb_is_leaf(ctx, 999999)) {
+ fprintf(stderr, "FAIL: is_leaf on invalid index should be false\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+
+ printf("PASS: tree inspection primitives\n");
+
arboricx_free(ctx);
printf("\nAll C ABI tests passed.\n");
return 0;
diff --git a/ext/zig/tests/io_protocol_test.c b/ext/zig/tests/io_protocol_test.c
@@ -0,0 +1,223 @@
+#include <stdio.h>
+#include <string.h>
+#include "arboricx.h"
+
+int main(void) {
+ arb_ctx_t* ctx = arboricx_init();
+ if (!ctx) {
+ fprintf(stderr, "Failed to initialize Arboricx context\n");
+ return 1;
+ }
+
+ /* 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);
+
+ if (!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) {
+ 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) {
+ fprintf(stderr, "FAIL: pure action tag should be 0\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+ 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) {
+ 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) {
+ fprintf(stderr, "FAIL: bind payload should be Fork left k\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+ 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);
+
+ if (!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)) {
+ fprintf(stderr, "FAIL: get_fork_children on IO sentinel\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+
+ /* Verify sentinel string */
+ uint8_t* decoded_sentinel;
+ size_t decoded_len;
+ if (!arb_to_string(ctx, sent_left, &decoded_sentinel, &decoded_len) ||
+ decoded_len != 7 || memcmp(decoded_sentinel, "tricuIO", 7) != 0) {
+ fprintf(stderr, "FAIL: IO sentinel string mismatch\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+ 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) {
+ fprintf(stderr, "FAIL: IO sentinel version/action mismatch\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+ 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);
+
+ if (!arb_get_fork_children(ctx, putStr_action, &tag, &payload) ||
+ !arb_to_number(ctx, tag, &tag_num) || tag_num != 10) {
+ fprintf(stderr, "FAIL: putStr tag should be 10\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+ 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);
+
+ if (!arb_get_fork_children(ctx, getLine_action, &tag, &payload) ||
+ !arb_to_number(ctx, tag, &tag_num) || tag_num != 11 ||
+ payload != leaf) {
+ fprintf(stderr, "FAIL: getLine tag should be 11 with Leaf payload\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+ 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);
+
+ if (!arb_get_fork_children(ctx, readFile_action, &tag, &payload) ||
+ !arb_to_number(ctx, tag, &tag_num) || tag_num != 20) {
+ fprintf(stderr, "FAIL: readFile tag should be 20\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+ 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);
+
+ if (!arb_get_fork_children(ctx, writeFile_action, &tag, &payload) ||
+ !arb_to_number(ctx, tag, &tag_num) || 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) {
+ fprintf(stderr, "FAIL: writeFile payload should be Fork path contents\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+ 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);
+
+ if (!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)) {
+ 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) {
+ fprintf(stderr, "FAIL: ok result right should be Fork val Leaf\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+ 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);
+
+ if (!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)) {
+ 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) {
+ fprintf(stderr, "FAIL: err result right should be Fork code Leaf\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+ printf("PASS: err result shape\n");
+
+ arboricx_free(ctx);
+ printf("\nAll IO protocol tests passed.\n");
+ return 0;
+}
diff --git a/ext/zig/tests/io_run_test.c b/ext/zig/tests/io_run_test.c
@@ -0,0 +1,217 @@
+#include <stdio.h>
+#include <string.h>
+#include "arboricx.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 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);
+}
+
+int main(void) {
+ arb_ctx_t* ctx = arboricx_init();
+ if (!ctx) {
+ fprintf(stderr, "Failed to initialize Arboricx context\n");
+ return 1;
+ }
+
+ arb_io_perms_t perms = { 0, 0 };
+
+ /* 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);
+
+ uint32_t result = arb_run_io(ctx, program, &perms);
+ if (result == 0) {
+ fprintf(stderr, "FAIL: pure hello returned 0\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+
+ uint8_t* decoded;
+ size_t decoded_len;
+ if (!arb_to_string(ctx, result, &decoded, &decoded_len) ||
+ decoded_len != 5 || memcmp(decoded, "hello", 5) != 0) {
+ fprintf(stderr, "FAIL: pure hello result mismatch\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+ arboricx_free_buf(ctx, decoded, decoded_len);
+ printf("PASS: pure hello\n");
+ }
+
+ /* 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);
+
+ /* 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);
+
+ uint32_t result = arb_run_io(ctx, program, &perms);
+ if (result == 0) {
+ fprintf(stderr, "FAIL: bind returned 0\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+
+ uint8_t* decoded;
+ size_t decoded_len;
+ if (!arb_to_string(ctx, result, &decoded, &decoded_len) ||
+ decoded_len != 4 || memcmp(decoded, "done", 4) != 0) {
+ fprintf(stderr, "FAIL: bind result mismatch\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+ arboricx_free_buf(ctx, decoded, decoded_len);
+ printf("PASS: bind pure\n");
+ }
+
+ /* 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);
+
+ printf("EXPECT: test\n");
+ uint32_t result = 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)) {
+ fprintf(stderr, "FAIL: putStr should return Leaf\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+ printf("PASS: putStr\n");
+ }
+
+ /* 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);
+
+ uint32_t result = arb_run_io(ctx, program, &perms);
+ if (result == 0) {
+ fprintf(stderr, "FAIL: readFile denied returned 0\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+
+ /* 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)) {
+ 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)) {
+ fprintf(stderr, "FAIL: readFile denied err shape mismatch\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+
+ uint64_t code_num;
+ if (!arb_to_number(ctx, code, &code_num) || code_num != 20) {
+ fprintf(stderr, "FAIL: readFile denied code should be 20, got %llu\n",
+ (unsigned long long)code_num);
+ arboricx_free(ctx);
+ return 1;
+ }
+ printf("PASS: readFile denied\n");
+ }
+
+ /* Test 5: readFile with permission succeeds */
+ {
+ /* Create a temp file first */
+ const char* tmp = "/tmp/tricu_io_test.txt";
+ FILE* f = fopen(tmp, "w");
+ if (!f) {
+ fprintf(stderr, "FAIL: could not create temp file\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+ fprintf(f, "hi");
+ 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);
+
+ uint32_t result = arb_run_io(ctx, program, &unsafe_perms);
+ if (result == 0) {
+ fprintf(stderr, "FAIL: readFile allowed returned 0\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+
+ /* 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)) {
+ 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)) {
+ fprintf(stderr, "FAIL: readFile allowed ok shape mismatch\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+
+ uint8_t* decoded;
+ size_t decoded_len;
+ if (!arb_to_string(ctx, val, &decoded, &decoded_len) ||
+ decoded_len != 2 || memcmp(decoded, "hi", 2) != 0) {
+ fprintf(stderr, "FAIL: readFile allowed contents mismatch\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+ arboricx_free_buf(ctx, decoded, decoded_len);
+ printf("PASS: readFile allowed\n");
+ }
+
+ /* 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) {
+ fprintf(stderr, "FAIL: invalid sentinel should return 0\n");
+ arboricx_free(ctx);
+ return 1;
+ }
+ printf("PASS: invalid sentinel\n");
+ }
+
+ arboricx_free(ctx);
+ printf("\nAll IO run tests passed.\n");
+ return 0;
+}
diff --git a/flake.nix b/flake.nix
@@ -26,6 +26,18 @@
haskellPackages.callCabal2nix packageName self {}
);
+ tricuBench =
+ hsLib.overrideCabal
+ (hsLib.doBenchmark (
+ haskellPackages.callCabal2nix packageName self {}
+ ))
+ (oldAttrs: {
+ postInstall = (oldAttrs.postInstall or "") + ''
+ mkdir -p $out/bin
+ cp dist/build/tricu-bench/tricu-bench $out/bin/
+ '';
+ });
+
customGHC = haskellPackages.ghcWithPackages (hpkgs: with hpkgs; [
megaparsec
]);
@@ -37,7 +49,8 @@
pname = "tricu-zig";
version = "0.1.0";
src = ./ext/zig;
- nativeBuildInputs = [ pkgs.zig ];
+ nativeBuildInputs = [ pkgs.zig pkgs.pkg-config ];
+ buildInputs = [ pkgs.libuv ];
buildPhase = ''
export ZIG_GLOBAL_CACHE_DIR=$TMPDIR/zig-cache
zig build
@@ -55,6 +68,7 @@
version = "0.1.0";
src = ./.;
nativeBuildInputs = [ pkgs.gcc pkgs.python3 tricuZig ];
+ buildInputs = [ pkgs.libuv ];
buildPhase = "true";
doCheck = true;
checkPhase = ''
@@ -69,6 +83,18 @@
-Wl,-rpath,${tricuZig}/lib
/tmp/c_abi_test
+ # IO protocol shape test
+ gcc -o /tmp/io_protocol_test tests/io_protocol_test.c \
+ -I ${tricuZig}/include -L ${tricuZig}/lib -larboricx \
+ -Wl,-rpath,${tricuZig}/lib
+ /tmp/io_protocol_test
+
+ # IO run test (synchronous driver)
+ gcc -o /tmp/io_run_test tests/io_run_test.c \
+ -I ${tricuZig}/include -L ${tricuZig}/lib -larboricx \
+ -Wl,-rpath,${tricuZig}/lib
+ /tmp/io_run_test
+
# Kernel path append test
gcc -o /tmp/c_abi_append_test tests/c_abi_append_test.c \
-I ${tricuZig}/include -L ${tricuZig}/lib -larboricx \
@@ -195,6 +221,7 @@
in {
packages.${packageName} = tricuPackage;
packages.default = tricuPackage;
+ packages.tricu-bench = tricuBench;
packages.tricu-zig = tricuZig;
packages.tricu-zig-tests = tricuZigTests;
packages.tricu-php = tricuPhp;
diff --git a/src/IODriver.hs b/src/IODriver.hs
@@ -8,7 +8,8 @@ module IODriver
, runIOWith
) where
-import Research (T(..), apply, toString, toNumber, ofString, ofNumber)
+import Research (T(..), apply, toString, toNumber, ofString, ofNumber, ofBytes)
+import qualified Data.ByteString as BS
import System.IO (putStr, getLine)
import qualified System.IO as IO
import Control.Exception (try, IOException, SomeException)
@@ -487,9 +488,9 @@ stepMachine machine =
in path == prefix || prefix' `isPrefixOf` path
tryReadFile path = do
- result <- try (IO.readFile path) :: IO (Either IOException String)
+ result <- try (BS.readFile path) :: IO (Either IOException BS.ByteString)
case result of
- Right content -> return $ okResult (ofString content)
+ Right content -> return $ okResult (ofBytes content)
Left e -> return $ errResult (ioErrorCode e)
tryWriteFile path contents = do
diff --git a/test/fixtures/greet-io.aboricx b/test/fixtures/greet-io.aboricx
Binary files differ.
diff --git a/test/fixtures/runArboricxTyped.arboricx b/test/fixtures/runArboricxTyped.arboricx
Binary files differ.
diff --git a/tricu.cabal b/tricu.cabal
@@ -78,6 +78,49 @@ executable tricu
Wire
default-language: Haskell2010
+benchmark tricu-bench
+ type: exitcode-stdio-1.0
+ main-is: Bench.hs
+ hs-source-dirs: bench, src
+ default-extensions:
+ LambdaCase
+ MultiWayIf
+ OverloadedStrings
+ ScopedTypeVariables
+ build-depends:
+ base >=4.7
+ , ansi-terminal
+ , base16-bytestring
+ , base64-bytestring
+ , bytestring
+ , containers
+ , criterion
+ , cryptonite
+ , directory
+ , exceptions
+ , filepath
+ , megaparsec
+ , memory
+ , mtl
+ , sqlite-simple
+ , text
+ , time
+ , transformers
+ , vector
+ , zlib
+ default-language: Haskell2010
+ other-modules:
+ ApplyStats
+ ContentStore
+ Eval
+ FileEval
+ IODriver
+ Lexer
+ Parser
+ Paths_tricu
+ Research
+ Wire
+
test-suite tricu-tests
type: exitcode-stdio-1.0
main-is: Spec.hs