TYCHO Search

Search documentation

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

    24. extern and the C boundary · 25. Typed handles · 26. FFI and concurrency

    Tycho calls C through extern functions. The boundary is deliberately narrow and fails closed: only a fixed set of types may cross, and everything that crosses is copied so that Tycho never holds a pointer into C-owned memory nor exposes its own storage to C.

    24. extern and the C boundary

    An extern function is bodyless and binds to a C symbol (§4.1.3); an optional library name (extern "m" fn cos(x: float) -> float) adds a link flag. Its name is emitted unmangled and it receives no arena argument.

    24.1 Crossable types

    Only these may appear in an extern signature; a map, an array outside the list below, or a non-POD struct is rejected at the boundary. A plain scalar-field struct is allowed by value, by return and as an inout out-parameter; a dynamic array of one is allowed as a parameter (§24.1.1). C unions are not crossable: keep the union in C and expose a tagged POD struct (the examples/gfx Event decodes SDL_Event this way).

    Every value returned from C that carries storage (string, bytes, an array) is deep-copied into the caller's storage at the call site; a program never holds a live pointer into C-owned memory.

    #### Interior NULs: a string is truncated at the boundary, silently

    A Tycho string carries an explicit length and may hold an interior 0x00 — to_bytes on an [int] is the documented way to build one (§16), and chr(0) concatenates like any other byte. A C char* carries no length: it ends at its first NUL. The two disagree, and the ABI has nowhere to put the difference.

    So wherever a string crosses the FFI as a char*, the bytes at and after the first interior NUL are invisible to the other side. This is not diagnosed — there is no error, no truncation warning, and no way to detect it after the fact. It applies in every char* direction:

    , so a C buffer holding "h\0i" becomes a Tycho string of length 1.

    Given s := "a" + chr(0) + "c", len(s) is 3 while a callee declared int64_t f(const char *) returns 1 for strlen. The trailing bytes are still in memory and still reachable by index through the borrowed pointer; what is lost is the *length*, which is the only thing telling the callee they are there.

    This follows from the borrow.

    Rationale: passing (ptr, len) per element would copy, and scanning for NUL before every call would turn an O(1) borrow into an O(total bytes) one.

    Use bytes for any payload that may hold a NUL. bytes crosses as a (pointer, length) pair in both directions and preserves interior NULs exactly — that is what the explicit length is for. Convert with to_bytes(s) at the call and to_str(b) on the way back, both zero-cost reinterprets (§16). Reserve a string parameter for what C means by a string: text that ends at its first NUL.

    A string *literal* cannot contain a NUL — \0 is not a supported escape and is rejected at compile time — so a truncating value always arrives from chr(0), from to_str over a constructed bytes, or from C.

    A package built on this boundary does not inherit the exemption. "Documented, not enforced" is a statement about the ABI, not about a library whose answer is a yes/no about untrusted bytes: such a caller must cross as bytes, or refuse the input by name. core:regex does the first — the subject crosses as (pointer, length) and REG_STARTEND bounds the match by it . core:io, core:net and core:os do the second, since a path, a host and a command line have no length-bearing syscall to cross by: each checks the argument before anything is attempted and refuses — Err(BadPath) / Err(BadAddr) where there is an error channel, and each call's documented "this did not happen" sentinel where there is not. core:path is mostly lexical, but path.real` rejects an interior NUL before crossing its C-string boundary.

    The sized-integer conversions truncate or extend by the C cast. to_uN / to_iN narrows a value to the type's low N bits; widening back with to_int sign-extends the signed types (i8/i16/i32/i64) and zero-extends the unsigned (u8/u16/u32/u64): e.g. to_i32(-1) → -1, to_i32(2^32) → 0, to_u8(-1) → 255, to_u8(256) → 0, to_i8(200) → -56. An extern parameter of a sized type requires an argument of that exact type (a bare int is a type error).

    24.1.1 Returning a payload and a classification

    An extern return is one value, and -> Result(T, E) is not a crossable return shape: Result is a Tycho aggregate with no flat C ABI, and it is absent from §24.1. So a C function whose failure has more than one *cause* — bytes that came back empty because the peer closed, because a timeout expired, or because the read failed — has one documented spelling:

    a numeric inout out-parameter carrying the classification, alongside the payload return. The classification is a plain int (a small enumerated code the package maps onto its own error enum); the payload keeps whatever crossable return type it already had.

    # corelib/net/net.ty: 0 EOF, 1 data, 2 timeout, 3 error
    extern fn netx_read(fd: int, max: int, status: inout int) -> bytes
    

    Written parameters are passed in written order, an inout becoming a T* (§24.1); a bytes or array *return* is a void function with two trailing out-params (unsigned char **out, int64_t *outlen) appended after all of them. So the classification pointer sits ahead of the payload out-params even though it is written last, and the C definition must match that order exactly (int64_t comes from <stdint.h>; shims do not include the compiler's internal typedef):

    void netx_read(int64_t fd, int64_t max, int64_t *status,
                   unsigned char **out, int64_t *outlen);
    

    Emitted prototype, for a bytes return and for a string parameter respectively:

    extern void netx_read(int64_t , int64_t , int64_t *, unsigned char **, int64_t *);
    extern void iox_read_file(char *, int64_t *, unsigned char **, int64_t *);
    

    Rules that follow from §24.1 and are not optional:

    The mirror arrangement, and why there are exactly two. The shape above puts the payload in the return and the classification in the inout. That assignment is forced, not preferred — and the constraint that forces it only exists for one kind of payload:

    # corelib/io/io.ty: the same 0..3 codes, with `mtime` as the payload
    extern fn iox_stat_mtime(path: string, mtime: inout int) -> int
    

    The C ABI for the mirror is the ordinary one, because a scalar return needs no out-params: written parameters in written order, the inout lowering to a T*, the classification as the C function's own return value. No reordering surprise — that only arises when a bytes return appends its two out-params.

    int64_t iox_stat_mtime(const char *path, int64_t *mtime);
    

    So the two arrangements are one rule seen from both sides: the classification and the payload never share a slot, and which slot each takes is settled by what can cross as an inout — never by taste. Every other rule above (numeric classification, set it first on every path, the payload valid on failure paths, the Tycho wrapper being what builds the Result) applies unchanged to both.

    When NOT to use it. If the classification and the failure share one integer and there is no separate payload, return the code directly and skip the out-param: extern fn iox_stat_kind(path: string) -> int is the same four codes as iox_read_file's with no inout, because the *kind* is the whole answer.

    24.2 Linking

    An extern "Lib" adds -lLib to the link line; a package's deps file adds pkg-config flags, and a co-located <pkg>_shim.c is compiled and linked automatically (§28). -lm (a link flag) and -fwrapv (a compile flag) are always on this single cc command line.

    25. Typed handles

    A handle declaration is a header over an indented free: line — a block, not a one-liner. It declares a nominal, affine, opaque C resource: a void* whose destructor runs automatically at scope exit (RAII).

    handle File:
        free: fh_close
    
    extern fn fh_open(path: string) -> File   # the opener
    extern fn fh_close(f: File) -> int        # the destructor, called at scope exit
    

    A handle is typically produced by an extern "opener" (the only kind of function permitted to *return* a handle) and released without explicit calls.

    26. FFI and concurrency

    The race-freedom guarantee of §20 covers Tycho values only and does not cross the FFI boundary. A C function that reads or writes process-global or static state is invisible to the compiler; two tasks calling it race exactly as the same C would. Code that calls such a C function from multiple tasks MUST make the C side thread-safe itself — the reference corelib shims do this with per-thread state (static __thread …). A conforming implementation makes no thread-safety guarantee about foreign code.