feat(ffi): run Blockscout-rs explorer services in-process via cgo (single binary)

Make the explorer ONE binary that runs the Rust Blockscout-rs services
in-process via FFI — not by rewriting them in Go and not by exec'ing
subprocesses. The real Rust service entrypoints run on their own Tokio
runtime threads inside the Go binary.

- ffi/ — one C-ABI staticlib (crate-type=["staticlib"]) wrapping the
  service crates by path (../../explorer-rs/<svc>). One staticlib = one
  Rust std/panic runtime, no duplicate-symbol conflicts. A `service!`
  macro is the single place that bridges a service: parse Settings JSON,
  spawn a dedicated OS thread with a multi-thread Tokio runtime, run the
  service's real entrypoint. Exports lux_explorer_start_<svc>() +
  lux_explorer_start_all(). Disabled services still export their symbol
  and return LUX_FFI_ERR_DISABLED so the Go side links identically.
  sig-provider is wired and proven first (stateless, fastest to build);
  the other four services' deps/features/service! blocks are written out
  commented with confirmed entrypoint signatures for mechanical scale-out.

- ffi_on.go (//go:build ffi) — cgo preamble (-llux_explorer_ffi + the
  darwin/linux system libs the Rust graph needs) + extern decls;
  startFFIServices() launches the services at startup from main().
- ffi_off.go (//go:build !ffi) — no-op fallback so plain `go build ./...`
  compiles WITHOUT the staticlib present.

- Makefile `make single` = cargo build --release in ffi/ then
  go build -tags ffi. ffi/README.md documents the C ABI, the protoc /
  protoc-gen-openapiv2 build-tool prereqs, the seeded Cargo.lock, and the
  one-service-at-a-time scale-out recipe.

Verified: default `go build ./...` exits 0; ffi/ staticlib compiles with
sig-provider linked (.a 7.9MB shim -> 58MB with service); cgo -tags ffi
links it into the binary (symbols present via nm); the single binary boots
and logs `[ffi] start sig-provider -> ok (0)` with the service running
in-process. go vet clean under both build tags.
This commit is contained in:
zooqueen
2026-06-23 21:06:50 -07:00
parent 345290e6df
commit f3bf55b0b9
9 changed files with 4821 additions and 0 deletions
+3
View File
@@ -35,3 +35,6 @@ QWEN.md
# Runtime
logs/
.claude/
# Rust FFI staticlib build artifacts (the .a is produced by make single)
ffi/target/
+47
View File
@@ -0,0 +1,47 @@
# Lux explorer build.
#
# Two binaries from one source tree, selected by the `ffi` build tag:
#
# make build Default Go-native explorer (indexer + graph + SPA + realtime).
# No Rust, no cgo. This is what `go build ./...` produces.
#
# make single THE single all-in-one binary: builds the Rust FFI staticlib
# (ffi/ -> liblux_explorer_ffi.a) then links it into the Go
# binary with cgo (-tags ffi), so the Blockscout-rs services
# (sig-provider, …) run IN-PROCESS. One binary runs everything.
#
# Scale services out by enabling more cargo features (see ffi/README.md):
# make single FFI_FEATURES=sig-provider,stats,visualizer
BIN ?= explorer
FFI_DIR := ffi
FFI_LIB := $(FFI_DIR)/target/release/liblux_explorer_ffi.a
# Cargo features = which Rust services get compiled into the staticlib.
# `all` = every wired service. Override on the CLI to scope the build.
FFI_FEATURES ?= all
.PHONY: build single ffi-lib clean test fmt
build:
go build -o $(BIN) .
# Step 1: build the Rust staticlib with the requested services.
ffi-lib:
cd $(FFI_DIR) && cargo build --release --features $(FFI_FEATURES)
# Step 2: link it into the Go binary via cgo. The -tags ffi build compiles
# ffi_on.go (cgo preamble + extern decls) instead of the ffi_off.go stub.
single: ffi-lib
go build -tags ffi -o $(BIN) .
@echo "built single all-in-one binary: ./$(BIN) (features: $(FFI_FEATURES))"
test:
go test ./...
fmt:
go fmt ./...
cd $(FFI_DIR) && cargo fmt
clean:
rm -f $(BIN)
cd $(FFI_DIR) && cargo clean
+4268
View File
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
[package]
name = "lux_explorer_ffi"
version = "0.1.0"
edition = "2021"
publish = false
description = "C-ABI staticlib that runs the Blockscout-rs explorer services in-process inside the Lux explorer Go binary (cgo). One Rust std copy wraps every needed service so the Go binary links a single .a."
# staticlib => a single self-contained `liblux_explorer_ffi.a` that cgo links
# directly into the explorer Go binary. We deliberately use ONE staticlib for
# ALL wrapped services (not one .a per service) so there is exactly one copy of
# the Rust std / panic runtime and no duplicate-symbol conflicts at link time.
[lib]
name = "lux_explorer_ffi"
crate-type = ["staticlib"]
[dependencies]
# Shared runtime glue. Tokio is built per-service-thread (see src/lib.rs); these
# are the only crates the FFI shim itself needs. anyhow is the common error type
# every wrapped service entrypoint returns; tokio drives the per-service runtime.
libc = "0.2"
serde_json = "1"
once_cell = "1"
anyhow = "1"
tokio = { version = "1", features = ["rt-multi-thread"] }
# --- Wrapped services (path deps into the sibling explorer-rs checkout) -------
# explorer-rs lives at ../../explorer-rs relative to this crate
# (/Users/a/work/lux/explorer/ffi -> /Users/a/work/lux/luxfi/explorer-rs).
# Each service is its OWN cargo workspace with no root workspace, so a plain
# path dep on the `*-server` crate pulls exactly that service's dependency
# graph. Each is feature-gated so a default `cargo build` (no --features) builds
# only the FFI shim + stubs and stays fast; turn services on individually.
#
# sig-provider is wired and proven first: it is stateless (no DB, no
# migrations), so it links and runs with the least build cost. Adding another
# service is a two-line change: (1) add its path dep below under a matching
# feature, (2) add a `service!{...}` entry in src/lib.rs. See README.md.
sig-provider-server = { path = "../../explorer-rs/sig-provider/sig-provider-server", optional = true }
# The remaining services are listed (commented) with their exact paths so
# scaling out is mechanical. They are NOT enabled by default because each pins a
# DIFFERENT major of blockscout-service-launcher (0.13/0.17/0.19/0.21) and pulls
# DB/migration stacks (sea-orm) — combining all of them inflates build time and
# must be done one at a time, verifying the link after each. See README.md
# "Scaling out" for the exact recipe.
#
# smart-contract-verifier-server = { path = "../../explorer-rs/smart-contract-verifier/smart-contract-verifier-server", optional = true }
# stats-server = { path = "../../explorer-rs/stats/stats-server", optional = true }
# multichain-aggregator-server = { path = "../../explorer-rs/multichain-aggregator/multichain-aggregator-server", optional = true }
# visualizer-server = { path = "../../explorer-rs/visualizer/visualizer-server", optional = true }
[features]
# Default = no service compiled in: the staticlib still builds (fast) and every
# entrypoint returns LUX_FFI_ERR_DISABLED so the Go side links and degrades
# gracefully. `make single` turns on the services it wants.
default = []
sig-provider = ["dep:sig-provider-server"]
# smart-contract-verifier = ["dep:smart-contract-verifier-server"]
# stats = ["dep:stats-server"]
# multichain-aggregator = ["dep:multichain-aggregator-server"]
# visualizer = ["dep:visualizer-server"]
# Convenience: every wired service at once.
all = ["sig-provider"]
[profile.release]
# Smaller, faster-linking staticlib. LTO off keeps incremental builds sane while
# scaling services out; flip to "thin" for a production single binary.
lto = false
codegen-units = 16
+110
View File
@@ -0,0 +1,110 @@
# lux_explorer_ffi — Blockscout-rs services, in-process, one binary
This crate makes the Lux explorer **one single binary** that runs the
[Blockscout-rs](../../explorer-rs) explorer services (sig-provider, and — as
scaled out — smart-contract-verifier, stats, multichain-aggregator, visualizer)
**in-process via cgo FFI**. No subprocesses. No Go rewrites. Each service's real
Rust entrypoint runs on its own Tokio runtime thread inside the explorer binary.
```
┌──────────────────────────── explorer (single Go binary) ────────────────────────────┐
│ main.go ─ startFFIServices() (ffi_on.go, //go:build ffi) │
│ │ cgo → liblux_explorer_ffi.a (this crate, crate-type = ["staticlib"]) │
│ │ │ lux_explorer_start_sig_provider(config_json) ─┐ │
│ │ │ lux_explorer_start_all() │ │
│ ▼ ▼ ▼ │
│ Go-native indexer/graph/SPA/realtime Rust sig-provider on its own Tokio thread │
└───────────────────────────────────────────────────────────────────────────────────────┘
```
## Why one staticlib for all services
A single `staticlib` wraps every service, so the final binary links **one** copy
of the Rust std / panic runtime. Linking several separate `.a`s would risk
duplicate-symbol conflicts. Adding a service is a path dep + a feature + one
`service!{…}` line — nothing else changes.
## C ABI
Exported from `src/lib.rs` (see the `service!` macro):
```c
// Spawn <svc> on its own Tokio runtime thread; returns 0 (LUX_FFI_OK) once
// launched. config_json is the service's Settings as JSON; "" / NULL => the
// service's upstream defaults.
int lux_explorer_start_sig_provider(const char* config_json);
// …one per wired service…
// Start every service compiled into this staticlib with default config.
int lux_explorer_start_all(void);
```
Return codes: `0` ok · `1` bad-config · `2` parse-error · `3` disabled
(service's cargo feature was off) · `4` spawn-error. Disabled services still
export their symbol and return `3`, so the Go binary links identically no matter
which features were compiled in.
## Build
From the explorer repo root:
```sh
make single # all wired services
make single FFI_FEATURES=sig-provider # scope to specific services
```
`make single` runs `cargo build --release --features <…>` here, then
`go build -tags ffi`. The default `make build` / `go build ./...` compiles the
`ffi_off.go` stub and needs **none** of this.
### Build-tool prerequisites (Rust path only)
The Blockscout-rs `*-proto` crates generate gRPC + Swagger at build time, so the
host needs:
- **protoc** — `brew install protobuf`
- **protoc-gen-openapiv2** — `go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest`
(must be on `PATH`; it lands in `$(go env GOPATH)/bin`)
These are standard upstream Blockscout-rs build deps (the prost-build panic
message itself points at `brew install protobuf`), not added by this crate.
### Cargo.lock is seeded
`ffi/Cargo.lock` is seeded from `explorer-rs/sig-provider/Cargo.lock` so the
build resolves the **same** dependency versions the service is known to build
with. Without this, re-resolving the graph as a path dep pulled in
`prost-build 0.14` against the service's `0.11` build script and failed. When
adding a service whose lock conflicts, reconcile the lock the same way (start
from that service's `Cargo.lock`, let cargo extend it).
## Scaling out (one service at a time)
Each service pins a **different** major of `blockscout-service-launcher`
(sig-provider 0.10 · visualizer 0.13 · verifier 0.17 · stats 0.19 ·
multichain-aggregator 0.21) and the DB-backed ones pull `sea-orm` + migrations.
Cargo can carry multiple majors, but the build surface grows, so enable and
**verify the link after each**:
1. **Cargo.toml** — uncomment the service's `*-server` path dep and its
`[features]` entry (both are already written out, commented).
2. **src/lib.rs** — uncomment its `service!{…}` block (the entrypoint signatures
are already filled in and confirmed against explorer-rs) and the matching
line in `lux_explorer_start_all`.
3. **ffi_on.go** — add a `start("<svc>", …)` line.
4. `make single FFI_FEATURES=<svc>` and confirm the `.a` links into the Go
binary (`nm explorer | grep lux_explorer_start_<svc>`).
Confirmed upstream entrypoints (all take a `serde::Deserialize` `Settings`):
| service | crate | entrypoint |
|-------------------------|------------------------------------|-----------------------------|
| sig-provider | `sig_provider_server` | `sig_provider(Settings)` |
| smart-contract-verifier | `smart_contract_verifier_server` | `run(Settings)` |
| stats | `stats_server` | `stats(Settings, None)` |
| multichain-aggregator | `multichain_aggregator_server` | `run(Settings)` |
| visualizer | `visualizer_server` | `run(Settings)` |
DB-backed services (stats, multichain-aggregator) also need their database URL
in the config JSON and migrations run — pass that through `startFFIServices`'s
per-service config map.
+212
View File
@@ -0,0 +1,212 @@
//! C-ABI staticlib that runs the Blockscout-rs explorer services in-process
//! inside the Lux explorer Go binary via cgo.
//!
//! Design (one way, decomplected):
//! * Each wrapped service exposes the SAME shape upstream: a `Settings` type
//! that is `serde::Deserialize` with every field `#[serde(default)]`, plus
//! an async entrypoint `fn(Settings) -> Result<(), anyhow::Error>` that
//! builds its own server and blocks. We do NOT reimplement any service —
//! we call its real entrypoint.
//! * The FFI boundary is uniform: `lux_explorer_start_<svc>(config_json)`
//! parses the JSON into that service's `Settings` (empty `{}` => upstream
//! defaults), spawns a dedicated OS thread with a multi-thread Tokio
//! runtime, and runs the service to completion on it. The call returns
//! immediately with 0 once the worker thread is launched; the service keeps
//! running for the life of the process (matching how a subprocess would).
//! * ONE staticlib wraps every service => one Rust std / panic runtime, no
//! duplicate-symbol conflicts from linking multiple separate `.a`s.
//!
//! The `service!` macro is the single place that knows how to bridge a service;
//! adding a service is one macro invocation (+ its path dep + feature in
//! Cargo.toml). Disabled services still export their symbol and return
//! `LUX_FFI_ERR_DISABLED`, so the Go side links and runs regardless of which
//! features were compiled in.
use std::ffi::CStr;
use std::os::raw::c_char;
use std::thread;
/// Worker thread launched successfully; service is running in-process.
pub const LUX_FFI_OK: i32 = 0;
/// `config_json` pointer was null or not valid UTF-8.
pub const LUX_FFI_ERR_BAD_CONFIG: i32 = 1;
/// `config_json` was non-empty but failed to deserialize into the service's
/// `Settings`.
pub const LUX_FFI_ERR_PARSE: i32 = 2;
/// This service was not compiled into the staticlib (its cargo feature was off).
pub const LUX_FFI_ERR_DISABLED: i32 = 3;
/// Failed to spawn the worker OS thread.
pub const LUX_FFI_ERR_SPAWN: i32 = 4;
/// Parse a C string config pointer into an owned JSON string.
/// Empty / null => "{}" so the service falls back to its own defaults.
fn config_str(ptr: *const c_char) -> Result<String, i32> {
if ptr.is_null() {
return Ok("{}".to_string());
}
// SAFETY: caller (cgo) guarantees a NUL-terminated C string or null.
let cstr = unsafe { CStr::from_ptr(ptr) };
match cstr.to_str() {
Ok(s) if s.trim().is_empty() => Ok("{}".to_string()),
Ok(s) => Ok(s.to_string()),
Err(_) => Err(LUX_FFI_ERR_BAD_CONFIG),
}
}
/// Spawn a dedicated OS thread running a fresh multi-thread Tokio runtime that
/// drives `fut_fn` (the service's blocking server future) to completion. The
/// thread is named so it shows up in stacks / metrics.
fn spawn_service<F, Fut>(name: &'static str, fut_fn: F) -> i32
where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = Result<(), anyhow::Error>>,
{
let builder = thread::Builder::new().name(format!("lux-svc-{name}"));
let spawned = builder.spawn(move || {
let rt = match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_name(format!("lux-{name}"))
.build()
{
Ok(rt) => rt,
Err(e) => {
eprintln!("[ffi:{name}] failed to build tokio runtime: {e}");
return;
}
};
if let Err(e) = rt.block_on(fut_fn()) {
eprintln!("[ffi:{name}] service exited with error: {e:#}");
} else {
eprintln!("[ffi:{name}] service exited");
}
});
match spawned {
Ok(_handle) => LUX_FFI_OK,
Err(e) => {
eprintln!("[ffi:{name}] failed to spawn worker thread: {e}");
LUX_FFI_ERR_SPAWN
}
}
}
/// `service!` generates the uniform C-ABI entrypoint for one wrapped service.
///
/// service!(fn_name = lux_explorer_start_sig_provider,
/// name = "sig-provider",
/// feature = "sig-provider",
/// settings = sig_provider_server::Settings,
/// run = |s| sig_provider_server::sig_provider(s));
///
/// When `feature` is enabled the body parses JSON into `settings`, spawns the
/// worker, and runs `run`. When disabled the same symbol is exported but
/// returns `LUX_FFI_ERR_DISABLED`, so the Go binary links identically either
/// way.
macro_rules! service {
(
fn_name = $fn_name:ident,
name = $name:literal,
feature = $feature:literal,
settings = $settings:path,
run = $run:expr $(,)?
) => {
#[no_mangle]
pub extern "C" fn $fn_name(config_json: *const c_char) -> i32 {
#[cfg(feature = $feature)]
{
let json = match config_str(config_json) {
Ok(j) => j,
Err(code) => return code,
};
let settings: $settings = match serde_json::from_str(&json) {
Ok(s) => s,
Err(e) => {
eprintln!(concat!("[ffi:", $name, "] bad settings JSON: {}"), e);
return LUX_FFI_ERR_PARSE;
}
};
let run = $run;
spawn_service($name, move || run(settings))
}
#[cfg(not(feature = $feature))]
{
let _ = config_json;
eprintln!(concat!(
"[ffi:", $name,
"] service not compiled in (rebuild ffi with --features ", $feature, ")"
));
LUX_FFI_ERR_DISABLED
}
}
};
}
// ---------------------------------------------------------------------------
// Wired services. Add one line per service (and the matching path dep +
// feature in Cargo.toml). Each upstream entrypoint signature was confirmed
// against explorer-rs:
// sig-provider : sig_provider_server::sig_provider(Settings)
// smart-contract-verifier : smart_contract_verifier_server::run(Settings)
// stats : stats_server::stats(Settings, None)
// multichain-aggregator : multichain_aggregator_server::run(Settings)
// visualizer : visualizer_server::run(Settings)
// ---------------------------------------------------------------------------
service!(
fn_name = lux_explorer_start_sig_provider,
name = "sig-provider",
feature = "sig-provider",
settings = sig_provider_server::Settings,
run = |s| sig_provider_server::sig_provider(s),
);
// --- Scale-out (uncomment the dep+feature in Cargo.toml, then this block) ---
//
// service!(
// fn_name = lux_explorer_start_smart_contract_verifier,
// name = "smart-contract-verifier",
// feature = "smart-contract-verifier",
// settings = smart_contract_verifier_server::Settings,
// run = |s| smart_contract_verifier_server::run(s),
// );
//
// service!(
// fn_name = lux_explorer_start_stats,
// name = "stats",
// feature = "stats",
// settings = stats_server::Settings,
// run = |s| stats_server::stats(s, None),
// );
//
// service!(
// fn_name = lux_explorer_start_multichain_aggregator,
// name = "multichain-aggregator",
// feature = "multichain-aggregator",
// settings = multichain_aggregator_server::Settings,
// run = |s| multichain_aggregator_server::run(s),
// );
//
// service!(
// fn_name = lux_explorer_start_visualizer,
// name = "visualizer",
// feature = "visualizer",
// settings = visualizer_server::Settings,
// run = |s| visualizer_server::run(s),
// );
/// Convenience: start every service compiled into this staticlib with default
/// config (`{}` => upstream defaults). Returns the bitwise-OR of the per-service
/// return codes, so 0 means every wired service launched. Disabled services
/// contribute `LUX_FFI_ERR_DISABLED`; the Go caller can treat that as "not
/// requested" and ignore it, or call the specific starters it needs instead.
#[no_mangle]
pub extern "C" fn lux_explorer_start_all() -> i32 {
let empty = b"{}\0".as_ptr() as *const c_char;
let mut rc = LUX_FFI_OK;
rc |= lux_explorer_start_sig_provider(empty);
// Mirror each service! entry here as it is enabled:
// rc |= lux_explorer_start_smart_contract_verifier(empty);
// rc |= lux_explorer_start_stats(empty);
// rc |= lux_explorer_start_multichain_aggregator(empty);
// rc |= lux_explorer_start_visualizer(empty);
rc
}
+17
View File
@@ -0,0 +1,17 @@
//go:build !ffi
// Package main — non-FFI build variant (the default).
//
// A plain `go build ./...` compiles this file and NOT ffi_on.go, so the
// explorer builds and runs WITHOUT the Rust staticlib present. The Blockscout-rs
// services are simply not started; everything else (Go-native indexer + graph +
// SPA + realtime) works exactly as before. Build the single all-in-one binary
// with `make single` (which adds -tags ffi and links liblux_explorer_ffi.a).
package main
// ffiEnabled is false in the default build; main.go logs this so it is obvious
// which variant is running.
const ffiEnabled = false
// startFFIServices is a no-op in the non-FFI build.
func startFFIServices(_ map[string]string) {}
+84
View File
@@ -0,0 +1,84 @@
//go:build ffi
// Package main — FFI build variant.
//
// When built with `-tags ffi` the explorer links the Rust staticlib
// `liblux_explorer_ffi.a` (produced by `cargo build --release` in ./ffi) and
// runs the Blockscout-rs explorer services (sig-provider, and — as they are
// scaled out — smart-contract-verifier, stats, multichain-aggregator,
// visualizer) IN-PROCESS via cgo. No subprocesses, no Go rewrites: each
// service's real Rust entrypoint runs on its own Tokio runtime thread inside
// this single binary.
//
// Build: make single (cargo build --release in ffi/ then go build -tags ffi)
// The non-FFI fallback lives in ffi_off.go (//go:build !ffi) so a plain
// `go build ./...` compiles WITHOUT the staticlib present.
package main
/*
#cgo LDFLAGS: -L${SRCDIR}/ffi/target/release -llux_explorer_ffi
// System libraries the Rust staticlib (tokio + reqwest/curl + openssl + ring)
// transitively needs. macOS frameworks cover Security/TLS + reachability;
// resolv/c++ cover DNS + the C++ runtime pulled by curl/openssl. On Linux this
// preamble is replaced by ffi_on_linux.go's cgo line.
#cgo darwin LDFLAGS: -lresolv -lc++ -framework Security -framework CoreFoundation -framework SystemConfiguration
#cgo linux LDFLAGS: -lresolv -lstdc++ -lm -ldl -lpthread -lssl -lcrypto
#include <stdint.h>
#include <stdlib.h>
// Mirrors the C ABI exported by ffi/src/lib.rs. Each starter spawns the named
// service on its own runtime thread and returns 0 (LUX_FFI_OK) once launched.
extern int lux_explorer_start_sig_provider(const char* config_json);
extern int lux_explorer_start_all(void);
*/
import "C"
import (
"log"
"unsafe"
)
// ffiEnabled is read by main.go to log which build variant is running.
const ffiEnabled = true
// ffiResult maps the C return codes from ffi/src/lib.rs to human strings.
func ffiResult(code C.int) string {
switch int(code) {
case 0:
return "ok"
case 1:
return "bad-config"
case 2:
return "parse-error"
case 3:
return "disabled"
case 4:
return "spawn-error"
default:
return "unknown"
}
}
// startFFIServices launches the in-process Rust explorer services. configJSON
// keys the per-service settings (empty => upstream defaults). It is called once
// from main() at startup in the ffi build; the non-FFI build's stub is a no-op.
//
// Each starter returns immediately after spawning the service's worker thread,
// so this does not block the Go HTTP listener.
func startFFIServices(configJSON map[string]string) {
start := func(name string, fn func(*C.char) C.int) {
cfg := configJSON[name]
var cstr *C.char
if cfg != "" {
cstr = C.CString(cfg)
defer C.free(unsafe.Pointer(cstr))
}
code := fn(cstr)
log.Printf("[ffi] start %s -> %s (%d)", name, ffiResult(code), int(code))
}
start("sig-provider", func(c *C.char) C.int { return C.lux_explorer_start_sig_provider(c) })
// As services are scaled out in ffi/, add their starters here, e.g.:
// start("stats", func(c *C.char) C.int { return C.lux_explorer_start_stats(c) })
}
+10
View File
@@ -119,6 +119,16 @@ func main() {
go registry.hub.Run(ctx)
go supervisor.Wait(ctx)
// In the `-tags ffi` build this launches the Blockscout-rs explorer
// services (sig-provider, …) in-process via cgo on their own Tokio
// runtime threads — one binary runs everything. In the default build
// startFFIServices is a no-op (see ffi_off.go). Per-service settings are
// keyed by service name; empty => the service's upstream defaults / env.
if ffiEnabled {
log.Printf("[explorer] ffi: starting in-process Rust services")
}
startFFIServices(map[string]string{})
frontend, err := NewFrontend(cfg, registry)
if err != nil {
log.Fatalf("[explorer] frontend: %v", err)