Skip to main content
The FFI boundary is the most safety-critical interface in Kraken TUI. All communication between the TypeScript host layer and the Rust native core follows a strict C ABI contract.

Return Code Protocol

Every FFI function returns a status code following this convention:
CodeMeaningAction
0SuccessOperation completed successfully
-1ErrorRetrieve error message via tui_get_last_error()
-2PanicInternal panic caught at FFI boundary
const result = ffi.tui_create_node(NodeType.Box);
if (result === 0) {
  // Handle is invalid - check error
  const errorPtr = ffi.tui_get_last_error();
  const message = new CString(errorPtr).toString();
  throw new KrakenError(message, -1);
}
The TypeScript layer uses checkResult() to automatically translate error codes into KrakenError exceptions.

Handle Protocol

Handles are opaque u32 identifiers that represent native objects:
  • Valid handles: Any non-zero u32 value
  • Invalid sentinel: 0 is permanently reserved as invalid
  • Lifecycle: Handles are never recycled (sequential allocation)
  • Ownership: The Rust native core owns all data; TypeScript holds only handles
// Create a widget - returns handle
const handle = ffi.tui_create_node(NodeType.Text);
if (handle === 0) {
  throw new Error("Failed to create node");
}

// Use the handle
ffi.tui_set_content(handle, ptr, len);

// Destroy when done
ffi.tui_destroy_node(handle);
After ~4.3 billion create operations, the handle space exhausts. At 1,000 widgets/sec, this takes ~49 days of continuous operation.

String Passing

Strings cross the FFI boundary using explicit length-prefixed byte arrays:

Host to Native (Input)

const content = "Hello, world!";
const utf8 = new TextEncoder().encode(content);
const ptr = ptr(utf8);
const len = utf8.byteLength;

ffi.tui_set_content(handle, ptr, len);
// Rust copies the bytes - safe to free after call returns

Native to Host (Output)

Two-call pattern for variable-length strings:
// 1. Get required buffer size
const len = ffi.tui_get_content_len(handle);
if (len < 0) throw new Error("Invalid handle");

// 2. Allocate and read
const buffer = new Uint8Array(len);
const bytesRead = ffi.tui_get_content(handle, ptr(buffer), len);
const content = new TextDecoder().decode(buffer.slice(0, bytesRead));

Error Messages

Error strings use a special borrowed pointer:
const errPtr = ffi.tui_get_last_error();
if (errPtr) {
  const message = new CString(errPtr).toString();
  ffi.tui_clear_error(); // Clear for next error
}
The error string pointer is valid until the next error occurs. Copy the message immediately.

Safety Guarantees

The FFI boundary enforces these invariants:

1. Panic Safety (ADR-T03)

Every extern "C" entry point is wrapped in catch_unwind:
fn ffi_wrap(f: impl FnOnce() -> Result<i32, String>) -> i32 {
    match catch_unwind(AssertUnwindSafe(f)) {
        Ok(Ok(code)) => code,
        Ok(Err(msg)) => {
            set_last_error(msg);
            -1
        }
        Err(_) => {
            set_last_error("internal panic".to_string());
            -2
        }
    }
}
No panic ever crosses the FFI boundary.

2. Handle Validation

Every FFI function validates handles before use:
let ctx = context_read()?;
let node = ctx.nodes.get(&handle)
    .ok_or_else(|| format!("invalid handle: {}", handle))?;
Invalid handles return -1 with a diagnostic error message.

3. UTF-8 Validation

Incoming strings are validated before processing:
let slice = unsafe { std::slice::from_raw_parts(ptr, len as usize) };
let s = std::str::from_utf8(slice)
    .map_err(|_| "invalid UTF-8".to_string())?;
Malformed UTF-8 sequences are rejected with -1.

4. Unidirectional Control Flow

The host layer calls into the native core. The native core never calls back into the host layer.
TypeScript (Host)  ──calls──►  Rust (Native)
       ▲                            │
       └────returns codes/handles───┘
Events are delivered through explicit drain calls (tui_next_event).

5. Copy Semantics

No pointers to internal data structures cross the boundary:
  • Input: Rust copies incoming string bytes
  • Output: Rust copies into caller-provided buffers
  • No interior pointers: Only handles, codes, and copy-out buffers

Event Delivery Contract

Events use a hybrid buffer-poll model (ADR-T01):
  1. Input ingress: tui_read_input(timeout_ms) captures terminal input
  2. Buffer population: Events are classified and buffered internally
  3. Explicit drain: Host calls tui_next_event(out_ptr) repeatedly until it returns 0
// Ingest input (16ms timeout ≈ 60fps)
app.readInput(16);

// Drain all buffered events
for (const event of app.drainEvents()) {
  if (event.type === "key" && event.keyCode === KeyCode.Escape) {
    running = false;
  }
}
This eliminates callback-based FFI complexity and enforces unidirectional control flow.

Memory Management

  • Handles: Never manually free handles - use tui_destroy_node() or tui_destroy_subtree()
  • Strings: Input strings are copied; output strings use caller-provided buffers
  • State: All mutable state lives in the native core
  • Cleanup: Call tui_shutdown() to release terminal and free all resources

Thread Safety

Kraken TUI uses a single-threaded execution model (ADR-T16):
  • Global state is protected by OnceLock<RwLock<TuiContext>>
  • Each FFI call acquires the lock
  • Errors set on one thread may not be visible via tui_get_last_error() on another thread
  • Thread-local storage used for error snapshots
Multi-threaded access to FFI functions is possible but not the intended use case. Single-threaded host usage is recommended.

Build docs developers (and LLMs) love