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).
- Scalars
int,char,float,bool. - Sized integers. The whole fixed-width integer family —
u8/u16/u32/u64andi8/i16/i32/i64— andf32(§5.2) are first-class types that cross as themselves: anexternparameter or return of one of these takes or produces a value of that exact type, emitted as the matching fixed-width C type so the call matches the C ABI. An argument of a different type is a type error — use theto_uN/to_iN/to_f32conversion to produce the sized value. string— passed as a Cchar*, with no length alongside it. Astringreturned from C is copied into the caller's storage at the call site, so Tycho never retains a pointer into C memory. A nullable C return is declared-> Option(string). Astringholding an interiorNULdoes not survive the round trip in either direction — see "InteriorNULs" below.bytes— crosses as a(pointer, length)pair, preserving interiorNULs; abytes/array returned from C is copied into an arena and the C buffer freed.[int]and[float]— a scalar array crosses as a(const T*, int64_t)pair (likebytes), in either direction. A dynamic[POD struct]parameter crosses the same way, borrowed for the call; an array of any other element type, and any map or non-POD struct, is rejected (no flat, self-describing C ABI).[string]— parameter direction only. It crosses as a(const char *const *, int64_t)pair, the same(ptr, len)convention as[int]/[float]and not aNULL-terminatedargv; a callee wantingexecvshape appends its ownNULL. No marshalling happens: a Tycho string is NUL-terminated at its end, so the pointer handed over is the array's own element storage. The length counts *pointers*, not bytes, and no per-element length crosses — an element holding an interiorNULis therefore read short by the callee (see "InteriorNULs" below). The array is borrowed for the duration of the call — the callee may read the strings while it runs, and must not retain the pointer, any element, or write through either after returning. Nothing is copied, and nobody frees. This contract is not enforceable by the implementation; violating it is undefined behaviour. When the array is empty the pointer may be null and the length is0, so a callee must check the length before dereferencing. A[string]return is rejected: achar**out of C carries no length, the same reason aninout stringout-parameter is rejected below.ptr— an opaquevoid*Tycho never dereferences; thenullliteral andis_null(p)apply.- C callback function pointers — an
externparameter may be a function typefn(T, ...) -> Rwhen everyTandRis a scalar,ptr, orvoid. The argument must be a named Tycho function; the compiler emits a C-ABI trampoline that allocates a fresh arena for each invocation, calls the function, and releases the arena. The pointer is valid for the program's lifetime, so C may retain it and call it later or from another thread. Closures and function variables are rejected: a Tycho function value is a descriptor with an environment, and there is no C-side environment slot to carry.string,bytes, arrays, handles, maps and aggregates are also rejected as callback parameters or results because they need a length header or a Tycho descriptor, not a raw C value. - POD structs — a non-packed, non-aligned struct of numeric scalar fields. It crosses by value, as a return, and as an
inoutpointer; a dynamic array of one ([T]) crosses as(const T*, int64_t)in the parameter direction, borrowed for the call. Use fixed-width fields (i32,u8,f32) to match the C header; Tychointis 64-bit andfloatis Cdouble. - typed
handles — §25. inoutout-parameters — a numeric scalar,ptr, or a plain scalar-field struct (nopacked/align, no string/array/map fields); astring,bytes, handle, map, array, or non-POD structinoutis rejected (no trivial out-param ABI). A struct crosses asS_Name *; use fixed-width fields (i32,u8,f32) to match the C header.
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:
- a scalar
stringparameter — the callee'sstrlenreports the prefix length, notlen(s); - each element of a
[string]parameter — the length counts pointers, so no element length is available to the callee at all; - a
stringreturn from C — the arena copy isstrlen-bounded
, 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 classification parameter must be a numeric scalar or
ptr. A plain scalar-field struct is also crossable as aninout, but it is a value, not a status code; astring,bytes, handle, map, array or non-POD compositeinoutis rejected, so the classification cannot be a message; it is a code. - Set it on every path, first. The shim writes a failure code before anything can fail (
*status = TY_RD_ERR;incorelib/net/net_shim.c) so an earlyreturnfails closed rather than leaving the caller's variable untouched. - The payload must still be valid on the failure paths. Leaving
*out = NULLis correct:tycho_bytes_from_cbuilds an empty buffer and frees nothing. - The Tycho-side wrapper is what turns the pair into a
Result— the FFI never produces one.net.readreadsstatusand returnsOk(b)/Err(Eof)/Err(Timeout)/Err(Failed);io.read_bytesdoes the same overiox_read_file's codes.
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:
- A
bytesor array payload cannot be aninoutat all. The crossableinoutset is a numeric scalar,ptr, or a plain scalar-field struct (the rule above), and the compiler rejects the rest by name — its diagnostic spells out "string/bytes/handle/map/array have no trivial out-param ABI". So abytespayload has only the return available, and the classification takes theinoutbecause nothing else is left. That is §24.1.1's case. - A scalar payload can occupy either slot, so nothing above decides it and a second constraint does: the two halves must not share one integer's code space. An epoch second, a file size or a byte count can take *any* value, including every value the status codes use, so returning both as one integer is the
-1-means-error conflation this section exists to avoid. The classification therefore keeps the return — where its code space is its own — and the payload takes theinout:
# 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.
- A handle name is a type name, and the collision rule is symmetric. A handle shares the one type namespace with structs, enums and newtypes — it is written in type position and mangled to a C typedef like them — so a duplicate would otherwise reach
ccas two declarations of one name, with no Tycho-level diagnostic. The rule therefore runs in both directions: ahandle NameMUST NOT repeat a struct, enum, newtype or handle name declared earlier in the file, and astruct,enumortypedeclaration MUST NOT repeat a handle name declared earlier in the file. Either order is a compile error, reported at the second declaration. Locked bytests/reject/handle_dup_name.ty(the handle-second order) andtests/reject/handle_then_struct.ty,handle_then_enum.ty,handle_then_newtype.ty(the handle-first order). - Neither borrow mode applies. A
handleparameter MUST NOT besinkorinout. It has one owner and a scope-bound destructor, so there is nothing to consume and nothing to copy back; passing it plainly is already the borrow above. Both were accepted and silently IGNORED until 2026-08-14 — asinkcallee borrowed exactly like a default parameter — which is why they are now refused at the declaration. The same rule coversTask(T)andChannel(T). - Scope-exit free. The owning variable's destructor runs at every exit from the owning scope — block end, early
return,break,continue,or_return. Abreakorcontinueleaves only the scopes inside its loop, so it frees a handle declared in the loop body and not one owned outside the loop. - Borrow on pass. Passing a handle passes the
void*; the callee does not free it — only the owning scope does. - Affine, exactly one owner. A handle MUST NOT be copied, and a handle variable MUST NOT be reassigned — both are compile errors. It cannot be stored in an array, map, struct, tuple,
Option, orResult, captured by a closure orparallel for, or returned from a Tycho function. - An opener's result MUST be bound. A handle-typed call is legal only as the direct right-hand side of a declaration (
d := open(p)). As a bare statement, as an argument (use(open(p))), or in any other position it is a compile error: the scope-exit finalizer frees the owning *variable*, so a handle with no variable is never freed.close(open(p))is refused by thecloserule below rather than by this one, becauseclosedoes free it. - Early
close(h).close(h)runs the destructor immediately and sets the handle to null; the scope-exit finalizer is null-guarded, so the destructor runs exactly once.closerequires a handle variable (a call result has no owning scope); otherwise it is a compile error. A handle parameter cannot be closed either, and that is a compile error too: the callee holds a borrowed copy of thevoid*, soclosewould null only that copy and the caller's scope-exit free would run the destructor a second time. Close it in the owning scope. - Use after
close. Aclose(h)statement closeshfor the rest of the block it is written in, including every block nested after it. Any later mention ofhthere is a compile error — a call that passes it, a secondclose(h), andis_null(h), which could only be a constanttrue. Each such mention is on every path through the close, and a handle cannot be reassigned, so nothing can reopen it. The rule is lexical and claims only certainties: aclose(h)inside anif, a loop body or amatcharm closeshonly until the end of *that* block. A mention after theifis a maybe and compiles; so does the next loop iteration's use of a handle closed in the body. On such a path the handle may be null, and passing it to C is a logic bug, not memory corruption. Thereis_null(h)is true when the branch closed it *or* the opener failed, and nothing distinguishes the two — keep aboolbeside it if the difference matters. Locked bytests/reject/handle_use_after_close.ty,handle_close_twice.tyandhandle_is_null_after_close.ty; the branch case runs intests/ffi/main.ty.
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.