Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

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*) for panic|unwind|catch_unwind|extern "C" → 4 hits, none about the calls boundary:
      • conversation.md:331 — the only catch_unwind mention in the corpus, about the protocol threadpool, in a transcript aside
      • OPTIM-IDEAS-3-research.md:101 — panic=abort as a size optimization
      • OPTIM-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-Rust re:call deferred
    • ~/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)

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:

  1. Rust panics inside a callback invoked by tgcalls. The call stack is C++ → extern "C" trampoline → Rust. Unwinding past the extern "C" frame is UB — the abort-on-unwind behaviour exists precisely because there is no correct way to continue. Combined with OPTIM-IDEAS-3-research.md:101 proposing panic=abort, either a hard abort or a hard UB.
  2. C++ throws inside a call from Rust. WebRTC throws std::runtime_error/rtc::RTCError from 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 are std::function sinks. Audio level drives the mute button and the speaking indicator; polling a float for it is worse in every way.
  • group/GroupInstanceImpl.h:148 — onAudioFrame is a per-audio-frame callback. Audio has a hard deadline; polling cannot substitute.
  • Manager.h:35, VideoCaptureInterfaceImpl.h:24 — video arrives via VideoSinkInterface<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.
  • media decode is in the same process (media/src/lib.rs), and per media-decode-no-budget-untrusted-input-unnamed.md that 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.

  1. catch_unwind in every trampoline. Wrap the body of each extern "C" callback in catch_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 requires UnwindSafe handling for borrowed state.
  2. try/catch in the C++ shim + noexcept trampolines. Compile every extern "C" entry point noexcept; wrap each in try { … } 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.
  3. A macro-generated trampoline that does both. One FERA_FFI_TRAMPOLINE(...) macro emitting extern "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.
  4. 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.
  5. Do nothing. Then the first callback that panics is a process abort mid-call, with no fault attribution.

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 is noexcept, wrapped in try/catch(…), and the Rust side is wrapped in catch_unwind; all faults surface as int32_t codes.
  • 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_unwind and panic=abort cannot both be in force, and OPTIM-IDEAS-3-research.md:101 currently 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.