mirror_bridge: What Happens When Things Go Wrong
My previous posts showed mirror_bridge generating bindings and outrunning JIT compilers. This one is about what happens when things go wrong, because the quality of a binding library is also measured by how much it helps you when things don’t go as expected.
The Three Failure Modes
When you’re crossing language boundaries, there are exactly three places things break:
- Compile time — Your C++ class has a type that the binding library can’t handle
- Runtime (C++ side) — Your C++ code throws an exception
- Runtime (language side) — The Python/Lua/JS caller passes invalid data
Let me show you how mirror_bridge handles all three, and why it matters.
Failure Mode 1: Unconvertible Types
Suppose you have a class like this:
struct Processor {
int id = 0;
std::string name = "default";
void (*callback)(int); // function pointer — not convertible
};
I compiled this exact class with both pybind11 (v3.0.3) and mirror_bridge to see what each produces. Here’s the pybind11 binding:
py::class_<Processor>(m, "Processor")
.def(py::init<>())
.def_readwrite("id", &Processor::id)
.def_readwrite("name", &Processor::name)
.def_readwrite("callback", &Processor::callback);
The result: 73 lines of error output, starting deep in pybind11/cast.h with messages like variable 'ptr' with type 'const auto *' has incompatible initializer of type 'element_type *' (aka 'void (*)(int)') followed by a chain of template instantiation notes spanning type_caster_base.h, pybind11.h, and several helper classes. None of the 73 lines say “function pointers aren’t supported” or suggest what to do about it.
Here’s the mirror_bridge version — no binding code needed, just bind_class<Processor>(m, "Processor"):
error: static assertion failed:
bind_class<T>: T contains members with types that mirror_bridge
cannot convert. Mark unconvertible members with [[=exclude{}]]
or add a custom type converter.
Supported: arithmetic, std::string, containers, smart pointers,
std::optional, std::expected, enums, and nested bindable classes.
3 error lines. The first one tells you the problem (unconvertible member type) and how to fix it (exclude it or add a converter). No digging through template instantiation chains.
This is possible because C++26 reflection lets us walk the member list at compile time and validate each type before attempting conversion. The validation runs automatically in bind_class<T>() across all backends. You can also use it standalone:
MIRROR_BRIDGE_VALIDATE(MyClass); // fires at compile time
Failure Mode 2: C++ Exceptions
Here’s a class that can throw:
struct MathService {
double safe_divide(double a, double b) {
if (b == 0.0) throw std::runtime_error("division by zero");
return a / b;
}
};
In early mirror_bridge, if safe_divide threw, it would crash the host runtime — the Lua VM, the Node.js process, or the Python interpreter. Any binding library that doesn’t catch exceptions at the language boundary has this problem.
Now, every method call in every language backend is wrapped in try/catch. The exception becomes a native error in each language:
Python:
try:
svc.safe_divide(10.0, 0.0)
except RuntimeError as e:
print(e) # "division by zero"
Lua:
local ok, err = pcall(function()
svc:safe_divide(10.0, 0.0)
end)
print(err) -- "C++ exception: division by zero"
JavaScript:
try {
svc.safe_divide(10.0, 0.0);
} catch (e) {
console.error(e.message); // "division by zero"
}
This covers instance methods, static methods, constructors, and property accessors. If C++ throws anywhere in the binding boundary, the host language gets a catchable error — not a segfault.
“Doesn’t try/catch in every call hurt performance?” No. Clang uses table-based exception handling: the try block emits zero extra instructions on the happy path. The compiler generates a side table (in .gcc_except_table) that’s only consulted during stack unwinding. I measured it:
ns/call
Without try/catch 1.64
With try/catch 1.72
Overhead 0.08 ns
For context:
PyFloat_FromDouble + Py_DECREF 4.5 ns (56x the try/catch overhead)
Full binding call (parse + box) 30-100 ns
try/catch overhead 0.08 ns
The safety is free.
std::expected: The Modern Alternative
What if your C++ method returns std::expected<T, E> instead of throwing? mirror_bridge converts it to whatever is idiomatic in the target language, with no annotation or registration:
struct MathService {
std::expected<double, std::string> safe_divide(double a, double b) {
if (b == 0.0) return std::unexpected("division by zero");
return a / b;
}
};
| Language | Success | Error |
|---|---|---|
| Python | Returns the value | Raises ValueError |
| Lua | (value, nil) |
(nil, "division by zero") |
| JavaScript | Returns the value | Throws Error |
result = svc.safe_divide(10.0, 2.0) # 5.0
svc.safe_divide(10.0, 0.0) # raises ValueError("division by zero")
local result, err = svc:safe_divide(10.0, 2.0) -- 5.0, nil
local result, err = svc:safe_divide(10.0, 0.0) -- nil, "division by zero"
const result = svc.safe_divide(10.0, 2.0); // 5.0
svc.safe_divide(10.0, 0.0); // throws Error("division by zero")
Just return std::expected from your C++ method and the binding does the right thing.
Failure Mode 3: The Round-Trip Problem
Here’s a subtle one. You have a C++ class with a vector<float>:
struct Signal {
std::vector<float> samples;
};
For performance, mirror_bridge converts numeric vectors to Python’s array.array using a single memcpy instead of creating N individual Python float objects. For 10,000 floats, this is ~30x faster.
But what happens when the user writes back?
sig = Signal()
data = sig.samples # Returns array.array('f', [...])
sig.samples = data # Does this work?
If from_python only accepted list, this round-trip would silently fail. We fixed this by accepting any Python sequence type — list, tuple, array.array, even NumPy arrays — using the sequence protocol instead of a PyList_Check gate:
if (!PySequence_Check(obj) || PyUnicode_Check(obj) || PyBytes_Check(obj)) {
return false;
}
This means you can do:
import array
sig.samples = [1.0, 2.0, 3.0] # list — works
sig.samples = array.array('f', [1.0, 2.0, 3.0]) # array — works
sig.samples = (1.0, 2.0, 3.0) # tuple — works
Round-trip correctness matters. If get and set don’t speak the same language, your users will file bugs.
What’s Next
mirror_bridge now generates bindings for Python, Lua, and JavaScript (Rust is in beta), handles errors cleanly across all of them, and validates types at compile time.
The roadmap includes:
- Rust
std::expected→Result<T, E>conversion (the Rust backend currently handles FFI generation but not error mapping) std::spanand range view support for zero-copy accessmirror_bridge scout— profile a Python project, find hotspots, auto-generate C++ replacements
If you want to try it:
git clone https://github.com/FranciscoThiesen/mirror_bridge
cd mirror_bridge && ./start_dev_container.sh
./tests/run_all_tests.sh
mirror_bridge is Apache 2.0 licensed. Star it on GitHub if you find it useful.