TYCHO Search

Search documentation

    ↑ ↓ to move · Enter to open · Esc to close

    FFI & Threading — design review and improvement plan

    Status: original read-only review (citations to path:line); most recommendations have since shipped — re-verify against the compilers before treating any item below as open.

    The review below is preserved as the original analysis.

    This document responds to two pieces of feedback:

    Both critiques are largely fair against the current implementation. This review enumerates the concrete pain points with evidence, then proposes prioritized improvements that fit Tycho's value-semantics + implicit-arena model. Each recommendation states whether it is an incremental change or a fundamental one, and the risk it poses to the value-semantic invariant.


    Summary

    FFI. The boundary is deliberately tiny: only scalars, NUL-terminated string, and an opaque ptr cross it (docs/reference/ffi.md:61-86, src/tychoc.c:3210-3256). That keeps the *language* sound — no foreign pointer enters Tycho's owned world — but it pushes real cost onto users:

    1. No composite types cross (rejected at parse, src/tychoc.c).
    2. No byte-buffer / (ptr,len) story. A string cannot hold 0x00 (corelib/hex/hex.ty:7-11), so all binary is marshaled as hex — see the crypto package marshaling *every* key/nonce/ciphertext/digest as a hex string (corelib/crypto/crypto.ty:11-18, corelib/crypto/crypto_shim.c:1-27).
    3. ptr is fully opaque and unsafe: no type tag, no compiler-known destructor, so handles leak and nothing prevents use-after-free or passing the wrong handle type (docs/reference/ffi.md:107-109; no destructor exists — verified by searching src/tychoc.c for destructor/drop/dealloc/free).
    4. String lifetime rules have a subtle read-once-borrow footgun (src/tychoc.c).
    5. Every out-param / callback API needs a hand-written C shim (docs/reference/ffi.md:130-141, examples/sqlite/, corelib/crypto/crypto_shim.c).
    6. No variadics, no callbacks into Tycho (docs/reference/ffi.md).

    Threading. "Race-free by construction" (README.md:16-18, docs/reference/concurrency.md:5-9) is *true for pure Tycho values* and false the moment FFI, process-global C state, or a panic is involved. It is also heavy: every spawn is one OS thread via pthread_create (runtime/tycho_rt.c:316-318), parallel for fans out ncpu threads with a full deep-copy of captures per chunk (src/tychoc.c:5086-5099), there is no thread pool or work-stealing (docs/reference/concurrency.md:137-139), spawning is unbounded, and a panic/abort in any task exit(1)s the whole process (docs/reference/concurrency.md:141, runtime/tycho_rt.c:1169-1182).

    Top recommendations (ranked, both areas):

    1. First-class immutable bytes type crossing as (ptr,len) — removes the hex-marshaling tax, the single biggest FFI usability win. Fundamental (new type) but fits value semantics cleanly.
    2. Tighten the safety claim in docs — cheap, high trust value. State exactly what "race-free by construction" covers and what it does not (FFI, global C state, panic). Add an FFI-from-threads guidance section.
    3. Typed handles with a compiler-known destructor (handle "Lib" Db) — fixes leaks + wrong-handle bugs. Incremental on top of ptr.
    4. Bounded worker pool for spawn / parallel for — caps the thread-explosion and thread-creation cost. Runtime-only, no language change.
    5. Auto-shim / out-param sugar — reduces hand-written C. Incremental, medium effort.

    FFI — pain points (with citations)

    The boundary today

    extern fn is bodyless and calls a C symbol directly; the type checker rejects anything outside the scalar/string/ptr table, failing closed:

    Pain point 1 — no composite types cross

    Arrays, maps, structs, Option/Result, tuples are all rejected (src/tychoc.c; docs/reference/ffi.md:77-86). Rationale is sound: those have Tycho-internal C layouts, not a stable ABI. But it means any aggregate must be flattened into scalars/strings/ptr or carried through a hand-written shim. For a struct-heavy C API this is a lot of boilerplate.

    Pain point 2 — no byte buffer; binary marshaled as hex (the biggest one)

    A Tycho string is a NUL-terminated char * (docs/reference/ffi.md:73), so it cannot carry an interior 0x00. corelib/hex/hex.ty:7-11 documents this directly: decode("00") is "" — a NUL byte is dropped. Consequence: the entire crypto package marshals all binary data as lowercase hex (corelib/crypto/crypto.ty:11-18, shim header corelib/crypto/crypto_shim.c:1-17):

    Pain point 3 — ptr is opaque and unsafe

    ptr is void * with only three operations: pass back to C, compare (==/!= vs another ptr or null), and is_null (docs/reference/ffi.md:107-109; E_NULL → T_PTR at src/tychoc.c:4298; literal at :2140). Three distinct hazards, none mitigated:

    Pain point 4 — string-lifetime footguns

    The rule (docs/reference/ffi.md:88-105): a returned string is copied into the caller's arena; NULL becomes "". An optimization — the read-once borrow — skips the copy when the result is the *direct* argument of len()/print()/println() (src/tychoc.c@is_extern_str_call, applied at src/tychoc.c:14447 for len, :14626 and :14633 for print/println). Footguns:

    Pain point 5 — manual shims for every out-param / callback

    Out-parameter constructors (the dominant C idiom, e.g. sqlite3_open(path, &db)) cannot be expressed; the user writes a C wrapper that returns the handle and passes it with --shim (docs/reference/ffi.md:130-141, examples/sqlite/). Same for any function whose result comes back through a pointer argument. The crypto package is essentially one large shim (corelib/crypto/crypto_shim.c).

    Pain point 6 — no variadics, no callbacks into Tycho

    printf-style variadics need a fixed-arity C wrapper (docs/reference/ffi.md). A Tycho function value is a non-C-ABI fat pointer, so C cannot call back into Tycho (docs/reference/ffi.md) — qsort-comparator / event-callback APIs are unreachable without a C trampoline. These are documented as by-design non-goals; listed here for completeness, not necessarily to fix.

    FFI — ranked recommendations

    Ranked by value / effort.

    R1 (highest value). First-class immutable bytes type, crosses as (ptr,len).

    R2. Typed handle wrapper over ptr, with an optional compiler-known free.

    R3. len-carrying / nullable string variants, or an explicit "no read-once" opt-out.

    R4. Auto-shim generation for out-parameter constructors.

    R5 (lowest priority). Variadics / callbacks-into-Tycho.


    Threading — cost analysis + safety envelope (with citations)

    Why it is heavy

    The measured numbers are good for the *intended* shape (parreduce at C parity, pipeline beating Go — docs/reference/concurrency.md:122-132), which is why this is a cost-model issue, not a correctness one. The heaviness bites a workload of many small or short-lived tasks.

    Where it is actually unsafe (despite copy-in/copy-out)

    The claim "race-free by construction" (README.md:16-18, docs/reference/concurrency.md:5-9) holds for pure Tycho values — after copy-in a task shares zero bytes (runtime/tycho_rt.c:387-396). It does not hold in these cases, and the docs only partially flag them:

    1. FFI into non-thread-safe or stateful C. Tycho's value isolation says nothing about C's global state. Two tasks each calling into the same C library hit whatever sharing that library has. The repo's own crypto shim is a live example to scrutinize:
    1. Channels are shared mutable state — by design. The channel is "the ONE intentionally shared object" (runtime/tycho_rt.c:396-410). It is internally synchronized, so it is safe, but it *is* a shared-state mechanism, so "no shared state" is an overstatement; "the only shared state is the internally-synchronized channel" is the accurate phrasing.
    2. Unbounded spawning / resource exhaustion. Nothing caps the number of live threads. A spawn in a loop, or recursive spawning, creates threads until pthread_create fails, at which point the runtime prints and exit(1)s (runtime/tycho_rt.c:349-351). That is a fail-stop, not memory corruption, but it is a trivial fork-bomb / resource-exhaustion vector and is not mentioned as a limit.
    3. Panic/abort in a task kills the whole process. Any runtime error in a task — bounds check, pop from empty, OOM, divide, exit(1) paths throughout runtime/tycho_rt.c (e.g. :1127-1142, :166, :1165-1166) — takes down every other task with it. Documented (docs/reference/concurrency.md:141) but worth elevating: there is no task-level isolation of failure, unlike Erlang processes, which the intro compares Tycho to (docs/reference/concurrency.md:8-9).
    4. Affine/implicit-join edge cases. The affine rules are enforced and look sound (double-wait dies loudly, runtime/tycho_rt.c:327-348; implicit join at every scope exit, :390-395; parallel for rejects captured tasks / inout captures / cross-chunk mutation, src/tychoc.c:5158-5250). No unsafety found here — call out as *verified sound*, not a gap.

    Threading — ranked recommendations

    R1 (highest value, lowest risk). Make the safety claim honest in the docs.

    Pure Tycho values crossing a thread boundary are race-free by construction (deep copy = zero sharing). This does not extend to: FFI calls into C (the C library's own thread-safety applies — see below), the channel itself (shared but internally synchronized), or failure isolation (a panic in any task aborts the whole process).

    R2. Bounded worker pool / thread cap for spawn and parallel for.

    R3. Optional bounded-concurrency primitive for fan-out loops.

    R4 (document, do not necessarily build). Failure isolation.


    Suggested next steps (in order)

    1. Docs honesty pass (Threading R1 + FFI pain-point notes). Lowest effort, highest trust return; directly addresses the "unsafe / overclaiming" feedback. Scope the race-free claim, add FFI-from-threads guidance, document the unbounded-spawn and panic-kills-process limits as first-class.
    2. bytes type (FFI R1). The biggest usability win; removes the hex-marshaling tax that the crypto package demonstrates. Prototype the (ptr,len) lowering against a binary-data library (e.g. re-bind a hash function to take bytes directly and compare to the hex path).
    3. Bounded worker pool (Threading R2). Runtime-only; resolves "very heavy" and the exhaustion vector. Watch the pooled-blocking-task starvation caveat.
    4. Typed handles with destructors (FFI R2). Makes ptr safe-by-default for the common handle libraries; reuses the affine + finalizer machinery that already exists for tasks.
    5. Auto-shim out-params + nullable string return (FFI R3/R4). Ergonomics polish once the foundational types/handles land.

    Everything above preserves the two pillars (value semantics + implicit arenas). The bytes type and the worker pool are pure additions to those pillars; typed handles are the one place foreign-resource ownership is *not* value-semantic, so they must be affine (single-owner, no copy) exactly as tasks already are.