Browse documentation

Custom bidirectional FFI ABI engine

Date: 2026-07-13 Area: ffi, runtime Issue: #827 Related: #214, #215, #216, #224, #225

FFI v2 extends GocciaScript's existing custom native-call machinery into one bidirectional ABI engine. An immutable compiled signature plan classifies every argument and return value for the current platform ABI. Outgoing calls use that plan to place scalar and aggregate bytes in registers, stack slots, indirect copies, and hidden-result storage. Native callback entries use the same plan in reverse to reconstruct JavaScript arguments and marshal the callback result. The implementation does not use libffi.

FFI type descriptors are recursive and compositional. Structures, unions, and fixed-length arrays use the platform C ABI's natural size, alignment, padding, argument, and return rules. They may nest and may appear by value in function or callback signatures. Aggregate values use ArrayBuffer storage so nested views share the same bytes. The public API deliberately excludes packed layouts, bitfields, custom alignment, and non-native calling conventions.

Native callback entry points come from a bounded table of statically compiled stubs for each supported target. Passing a JavaScript callable to a callback-typed argument creates a call-scoped callback that is closed when the native call returns. These call-scoped callbacks are non-escapable: native code must not retain or invoke their pointers after that native call returns. Calling CallbackType.create(callable) creates a persistent callback handle that remains pinned until its idempotent close() method is called. Callbacks may re-enter GocciaScript only on the runtime thread that created them. A foreign-thread invocation returns the ABI's zero value and latches a TypeError for the owning call. A JavaScript exception follows the same native-boundary rule: the first exception is retained, later callbacks in that native call return zero without invoking JavaScript, and the original exception is rethrown after native code returns. No Pascal exception may unwind through a native callback frame.

Dynamic-library lifetime uses a shared guard retained by the library object, bound functions, and symbol pointers. library.close() is a logical close: it immediately invalidates the library and every derived callable or pointer, while physical unloading waits until the last dependent releases its guard. This prevents both calls through unloaded code and premature unloading when the library object is collected before its dependents.

Alternatives considered:

  • Adopt libffi. Rejected because GocciaScript already owns working custom calling machinery across its supported targets, and FFI v2 needs one coherent plan shared by outgoing calls and reverse callback entry rather than a second foreign-call subsystem.
  • Add aggregate and callback cases to the scalar dispatcher independently. Rejected because ABI classification, hidden returns, indirect arguments, and register exhaustion interact. Per-feature branches would duplicate those rules and make outgoing and callback behavior drift.
  • Unload immediately on close(). Rejected because bound functions and raw symbol pointers would retain dangling addresses. Logical invalidation plus deferred unload preserves explicit close semantics without use-after-unload.
  • Allow callbacks from arbitrary native threads. Rejected because the evaluator, realm, garbage collector, and call context belong to one runtime thread. Returning zero and surfacing a deferred error gives a deterministic boundary without pretending the runtime is cross-thread re-entrant.

Consequences:

  • Every supported FFI target must provide matching outgoing and callback entry assembly and must be exercised by the platform CI matrix.
  • Extending the type system or adding a calling convention now happens in the compiled-plan layer rather than in JavaScript-value marshalling.
  • Persistent callbacks and native code follow an explicit ownership contract: native code must stop retaining a callback before its handle is closed.
  • FFI remains an unsafe runtime opt-in. These guards remove known lifetime and thread-boundary hazards but do not make arbitrary native code memory-safe.