Calls FFI has no fault-propagation policy — the polling design defers the problem to exactly where it becomes unavoidable
Источник:
dev/fera-research/docs/oc5/calls-ffi-no-fault-propagation-policy.md(read-only; сгенерировано scripts/sync-docs.mjs)
Calls FFI has no fault-propagation policy — the polling design defers the problem to exactly where it becomes unavoidable
Section titled “Calls FFI has no fault-propagation policy — the polling design defers the problem to exactly where it becomes unavoidable”- kind: risk
- area: calls / FFI / modules
- severity: high
- evidence:
- grep of
*.md(ADR/RFC/TECHSPEC/QA/OQ/conversation/OPTIM-IDEAS*) forpanic|unwind|catch_unwind|extern "C"→ 4 hits, none about the calls boundary:conversation.md:331— the onlycatch_unwindmention in the corpus, about the protocol threadpool, in a transcript asideOPTIM-IDEAS-3-research.md:101—panic=abortas a size optimizationOPTIM-IDEAS-4-oq24.md:89— the corpus’s strongest ABI statement: “Нельзя строить долгоживущий ABI наextern "Rust"… нужен узкий версионированный C ABI:extern "C", фиксированные integer types,#[repr(C)]POD-структуры, opaque handles и явныеcreate/destroyв одном владельце памяти” — about layout, not fault propagation
~/dev/fera/project-design/context/ADR.md:711-724(ADR-018) — official tgcalls bindings in v1; pure-Rustre:calldeferred~/dev/fera/project-design/context/QA.md:172-176— the only calls gate: “bindings are isolated behind their own module boundary; a build without the calls module compiles and runs the rest of the client normally”. Module isolation, not fault isolation.~/dev/fera/fera/core/crates/media/src/lib.rs:1-5— placeholder; calls backend not yet in the repo- upstream callback sinks that would invoke Rust, verified in the submodule:
~/dev/fera/rstgcalls/tgcalls/Manager.h:66—std::function<void(State)> _stateUpdated;~/dev/fera/rstgcalls/tgcalls/Manager.h:72—std::function<void(float)> _audioLevelUpdated;~/dev/fera/rstgcalls/tgcalls/group/GroupInstanceImpl.h:148—std::function<void(uint32_t, const AudioFrame &)> onAudioFrame;~/dev/fera/rstgcalls/tgcalls/Manager.h:35+VideoCaptureInterfaceImpl.h:24—rtc::VideoSinkInterface<webrtc::VideoFrame>sinks (per-frame)
- grep of
Substrate
Section titled “Substrate”Credit where due: OPTIM-IDEAS-4-oq24.md:89 is exactly the right
instinct — a narrow extern "C" ABI with POD structs, opaque handles,
fixed integer types, and explicit create/destroy in a single memory
owner, and explicitly not the Rust ABI. That is the correct call, and
it is already made.
It is a layout guarantee, though. Layout is not fault propagation, and the calls boundary has no fault policy at all. Two directions of failure, both undefined behaviour:
- Rust panics inside a callback invoked by tgcalls. The call stack
is
C++ → extern "C" trampoline → Rust. Unwinding past theextern "C"frame is UB — the abort-on-unwind behaviour exists precisely because there is no correct way to continue. Combined withOPTIM-IDEAS-3-research.md:101proposingpanic=abort, either a hard abort or a hard UB. - C++ throws inside a call from Rust. WebRTC throws
std::runtime_error/rtc::RTCErrorfrom several code paths. An exception propagating into a Rust frame is UB, not catchable.
Neither direction has a stated mitigation anywhere in the corpus. The
one catch_unwind in the entire design corpus is about the protocol
threadpool (conversation.md:331), in a transcript aside, not an ADR.
The polling design defers this rather than avoiding it
Section titled “The polling design defers this rather than avoiding it”The planned bridge surface (per the rstgcalls FFI scaffold plan,
docs/superpowers/plans/2026-10-17-rstgcalls-ffi-scaffold.md) is
push-only with polled state: seven extern "C" functions —
create, push_audio, receive_signaling, set_network_type,
set_mute, get_state, stop — all returning int32_t, with
get_state polling for state rather than receiving a callback.
That is a genuinely good design for a first cut, and it does keep the fault boundary one-directional (Rust→C++ only) for the initial scope. But it is a scope decision, not a policy one, and the direction it points at is already fixed by upstream:
Manager.h:66,72— state updates and audio-level updates arestd::functionsinks. Audio level drives the mute button and the speaking indicator; polling a float for it is worse in every way.group/GroupInstanceImpl.h:148—onAudioFrameis a per-audio-frame callback. Audio has a hard deadline; polling cannot substitute.Manager.h:35,VideoCaptureInterfaceImpl.h:24— video arrives viaVideoSinkInterface<VideoFrame>sinks, per frame. There is no polled alternative for video.
So the moment calls become real — group calls, incoming audio, incoming video, which is the entire feature — the boundary becomes bidirectional and the missing policy becomes load-bearing. It will be discovered at the point where it is most expensive to fix, because by then the safe crate, the trampoline, and the actor plumbing all exist and assume the wrong shape.
Why this is the worst instance of the class
Section titled “Why this is the worst instance of the class”no-panic-containment-at-dylib-boundary.md argues that Fera has no
panic policy for native modules. The calls path is the sharpest version
of that, because it combines everything:
- No sandbox (ADR-002 tier 1, by design, for performance).
- A C++ counterparty with its own exception model, unlike every other native module which is Rust-to-Rust.
- Remote, attacker-influenceable input: negotiated media arrives from a peer, not from us.
- A hard real-time deadline — audio/video callbacks that block or panic land inside a frame budget.
mediadecode is in the same process (media/src/lib.rs), and permedia-decode-no-budget-untrusted-input-unnamed.mdthat path has no allocation budget either.
Any one of these is a normal engineering concern. All five on one boundary, with no stated policy, is the finding.
Options (tradebacks)
Section titled “Options (tradebacks)”catch_unwindin every trampoline. Wrap the body of eachextern "C"callback incatch_unwind, map the payload to an error code, never unwind across the frame. Cheapest correct answer for direction 1. Cost: does not help direction 2 (a C++ throw still unwinds into Rust), and requiresUnwindSafehandling for borrowed state.try/catchin the C++ shim +noexcepttrampolines. Compile everyextern "C"entry pointnoexcept; wrap each intry { … } catch (...) { return error_code; }. Solves direction 2 at the C++ layer. Cost: needs the C++ side to own the policy, which means the rule lives in the bridge header and is easy to miss on a new entry point — mitigate with a macro that generates the wrapper.- A macro-generated trampoline that does both. One
FERA_FFI_TRAMPOLINE(...)macro emittingextern "C" noexcept+try/catch+catch_unwind+ error-code mapping. Cost: a small codegen layer; the benefit is that new entry points are correct by construction rather than by review. - Keep the one-way boundary permanently. Only poll, never accept callbacks; implement audio/video paths in C++ and expose buffers Rust reads. Cost: gives up the Rust-native media path and the perf story ADR-018 is buying; and it is contradicted by upstream’s sink-based design, so it means writing shims around tgcalls.
- Do nothing. Then the first callback that panics is a process abort mid-call, with no fault attribution.
Proposed solution
Section titled “Proposed solution”Options 1 + 2, wrapped by 3, specified now as an ADR.
- Add a calls-boundary ADR (or an amendment to ADR-018) that states the
fault policy explicitly: no Rust unwind and no C++ exception may cross
the bridge in either direction; every
extern "C"entry point isnoexcept, wrapped intry/catch(…), and the Rust side is wrapped incatch_unwind; all faults surface asint32_tcodes. - Generate the trampolines with a macro so a new entry point cannot be
added without the guards — this is the same argument as
no-panic-containment-at-dylib-boundary.md’s “option 4: make it a checked build contract”, applied to the boundary that cannot be statically linked. - Pair with the panic-strategy decision in
no-panic-containment-at-dylib-boundary.md:catch_unwindandpanic=abortcannot both be in force, andOPTIM-IDEAS-3-research.md:101currently asks for the latter. - Add a QA gate beside
QA.md:172-176— which today covers module isolation only: “a fault injected at a calls boundary (Rust panic in a callback, C++ throw from tgcalls) is contained: the call returns an error code, the client survives, and the call is torn down rather than left half-open.” - Note the sequencing: this is the one finding where implementing the cheap first cut (option 4) is actively harmful, because a callback-free bridge makes the missing policy invisible right up until group calls land. Specify the policy before the first callback is written, not when the first one panics.
re-call (pure-Rust, deferred) would sidestep all of this, which is
worth recording as an argument for keeping ADR-018’s deferral decision
under review — but it is not a mitigation available in v1.