Bridge internals¶
Eight C files, four responsibility groups, one contract. The user-facing
pages describe the bridge from outside – the fcj_* ABI, the two Fortran
SDK layers, the numbers. This page opens the box: what each file owns, how
the GC debt is paid, and where the Julia 1.12 C API forced workarounds. It
is written for maintainers changing src/bridge/; nothing here is needed
to use the bridge.
The architecture page gives the same system in one screen; read that first if you are new.
The eight files¶
| Group | Files | Owns |
|---|---|---|
| Core | core.c, memory.c |
lifecycle (fcj_init / fcj_init_ex / fcj_shutdown), thread pinning, the GC root set |
| Dispatch & boxing | dispatch.c, types.c |
fcj_call* / fcj_call_kwargs, the function-name cache, scalar boxing (fcj_box_*) |
| Array channel | arrays.c, extract.c |
N-D zero-copy wraps, deep copies, batch extraction to Fortran |
| Callback access | callbacks.c, accessors.c |
@cfunction trampolines, get_field / get_property, native Float64 element access |
Two headers frame them. bridge.h is the public contract: 53 fcj_
functions plus fcj_options_t – everything a Fortran bind(c) layer can
reach. bridge_internal.h is private: the native element accessors
(fcj_get_real and siblings), shared helpers, and the per-module
cache-clear hooks fcj_shutdown walks. The “~105 exported fcj_* symbols”
headline used across this site counts the whole fcj_ namespace across
these sources; the binding layer src/julia_binding.f90 declares 58 of
them (52 of the 53 public functions – fcj_ready is never bound – plus
the 6 internal element accessors), and the rest are
internal helpers that never cross the ABI.
Core: lifecycle and the root set¶
core.c owns the state machine. fcj_state is _Atomic; every entry
point checks it through fcj_ready() before touching Julia. The thread
rule from Known limitations is enforced,
not documented, in check_thread() (verbatim from src/bridge/core.c):
int check_thread() {
if (pthread_self() != main_thread_id) {
char msg[256];
snprintf(msg, sizeof(msg), "Thread Error: Julia called from thread %p, runtime owned by thread %p",
(void*)pthread_self(), (void*)main_thread_id);
set_error(msg);
return 0; // Fail
}
return 1; // OK
}
Failure semantics split by entry point: fcj_init aborts the process on
failure (the original contract), while fcj_init_ex returns an error code
and leaves the reason in fcj_get_last_error() – surfaced in Fortran as
get_last_error_message(). fcj_shutdown is one-way: the state machine
never returns to READY, and re-initialization after shutdown is rejected
loudly rather than attempted.
memory.c is the GC story, and its header comment states the design
better than a paraphrase would (from src/bridge/memory.c):
// fcj_retain/fcj_release keep their bind(C) signatures but no longer round-trip
// through Julia (retain_obj/release_obj in GCManager.jl) on the hot path.
// Retained objects live in a Julia Vector{Any} reachable through the constant
// binding `Main.FJ_ROOTS` ... bookkeeping is a pure-C ptr -> slot hash map with
// per-pointer refcounts, mirroring GCManager's REF_COUNTS/PTR_CACHE logic.
The mechanics that matter to a maintainer:
- A C static is not a GC root. Any function or value cached in a
staticmust be routed throughfcj_retain(a module binding counts; a bare static does not). The one sanctioned exception is immortal singletons kept alive by their Base binding, such as theComplexF64type object cached intypes.c. fcj_mapis open-addressing with tombstones and refcounts – deleted entries tombstone so probe chains stay intact, and a pointer retained twice occupies one slot with a count of two.- No locking anywhere in these statics. The single-thread contract
(
check_thread) is the lock. Break the contract and you have a data race, not a bug report.
Dispatch and boxing¶
dispatch.c turns names into calls. The function-name cache is a fixed
256-slot table hashed with FNV-1a; cache hits are routed through
fcj_retain so the cached jl_function_t* is rooted. Two invalidation
rules a maintainer must never undo:
- The cache is cleared first in
fcj_shutdown– before anything else could still dereference it. - Every
julia_eval_stringinvalidates the name caches. An eval can redefineffrom a function to a closure, and a stale cache entry would call the captured old object forever. (The Fast Path registry keeps its captured-object semantics by design and documents it; see Two SDKs, two speeds.)
fcj_call1_float64 through the N-argument family and fcj_call_kwargs
(the keyword-argument path) all funnel through the same
jl_call/exception-check tail. types.c boxes Fortran scalars into Julia
objects – fcj_box_float64, fcj_box_string, and the rest – plus the
cached ComplexF64 datatype noted above.
The array channel¶
arrays.c wraps Fortran memory as a Julia Array of any rank 1 through
FCJ_MAX_NDIM, zero-copy, via jl_ptr_to_array. The wrap path, verbatim
from src/bridge/arrays.c (the three guard helpers are the same shape,
elided for length):
void* fcj_wrap_array(void* data, long ndim, const long* dims) {
if (!fcj_ready()) { set_error("Julia runtime not initialized or already shut down"); return NULL; }
if (!check_thread()) return NULL;
jl_value_t *array_type = NULL;
jl_value_t *dims_tuple = NULL;
JL_GC_PUSH2(&array_type, &dims_tuple);
array_type = array_type_for_ndim(ndim, "fcj_wrap_array");
...
dims_tuple = make_dims_tuple(ndim, dims, "fcj_wrap_array");
...
jl_array_t *arr = jl_ptr_to_array(array_type, data, dims_tuple, 0);
if (check_julia_exception()) { JL_GC_POP(); return NULL; }
JL_GC_POP();
return (void*)arr;
}
Two disciplines visible in that excerpt, both load-bearing everywhere in
the bridge: local values are rooted with JL_GC_PUSH2 for the duration of
a call, and any error path set_errors a reason before returning NULL.
The deep-copy sibling fcj_copy_array allocates a fresh Julia array and
memcpys in, with an overflow-checked extent product before the allocation.
extract.c is the return leg: fcj_to_fortran (one bulk copy into a
Fortran buffer, length-checked), fcj_get_element, fcj_get_property.
The bulk path is a single memcpy from jl_array_data_ with an n == len
check – which is why the extraction channel is bit-exact and why the
acceptance gates can put a 1e-8 door in front of it
(acceptance gates).
Callback access¶
callbacks.c runs the other direction: Fortran bind(c) subroutines
wrapped into Julia-callable objects through @cfunction trampolines, with
the calling context carried opaquely. This is the machinery behind the
OrdinaryDiffEq callback demos of examples/06_sciml/1_callback.f90. The
hard rule from the user pages holds at this level too: a trampoline frame
cannot host a Julia exception, so a throwing callee is undefined behavior
– validate on the Fortran side, return sentinels.
accessors.c implements %element and %property, and here the Julia
1.12 C API forced the most instructive workarounds in the tree. The header
of extract.c records them (from src/bridge/extract.c):
// 1.12.7 API notes (verified against include/julia/julia.h):
// - jl_get_property does not exist; jl_get_field exists but only reads
// plain struct fields (no getproperty overloads, no NamedTuples), so
// the contract's fallback applies: Base.getproperty is cached once and
// jl_call-ed, matching helper_getfield semantics in Accessors.jl;
// getproperty dispatches on Symbol, so the name is interned with
// jl_symbol (immortal, globally rooted) instead of boxed as a String;
// - jl_arrayref does not exist either (removed with the Memory-backed
// array rewrite), so element boxing goes through a cached
// Base.getindex binding -- the same numeric boxing jl_arrayref used to
// perform.
Read that as the general lesson: when a jl_* convenience is missing,
cache the equivalent Base function once (rooted through fcj_retain) and
jl_call it – do not hand-roll struct offsets. bridge_internal.h adds
the native element accessors (fcj_get_real / fcj_set_real and 2- and
3-index forms): direct Float64 reads and writes into wrapped memory, no
boxing, used by the hot per-cell paths.
Signals and error transport¶
The bridge never writes jl_options.handle_signals: GC safepoint polling
depends on Julia’s own signal handlers, and disabling them crashes at a
safepoint – reliably, not occasionally (Lessons). The
handle_signals=0 option offered by fcj_options_t means “no takeover”:
after a successful init the bridge resets SIGINT/SIGTERM to SIG_DFL,
leaving the GC-critical handlers intact.
Errors travel one channel: the per-runtime last-error buffer, read through
fcj_get_last_error() and surfaced as get_last_error_message(). A
Julia-side exception is caught at the call boundary, formatted into that
buffer, and the call returns an invalid handle or NULL – the process keeps
running.
Where to go next¶
- Acceptance gates – how you prove you did not break any of this.
- Rebuild from zero – the sysimage and AOT builds that wrap this core for deployment.
- Lessons – the incidents that put these rules in writing.