rstgcalls FFI Scaffold Implementation Plan
Источник:
dev/fera/rstgcalls/docs/superpowers/plans/2026-10-17-rstgcalls-ffi-scaffold.md(read-only; сгенерировано scripts/sync-docs.mjs)
rstgcalls FFI Scaffold Implementation Plan
Section titled “rstgcalls FFI Scaffold Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Stand up a compilable, documented rstgcalls Rust workspace that binds Telegram’s C++ tgcalls WebRTC stack behind a safe, idiomatic API — with unsafe confined to the -sys crate and one gated backend module, and cargo check/clippy/test green offline today (before the C++ WebRTC tree is available).
Architecture: Two crates in a Cargo workspace. rstgcalls-sys owns all unsafe and all C/C++ contact: its build.rs runs bindgen against a hand-written pure C-ABI bridge header (include/rstgcalls_bridge.h) — POD structs, an opaque rstgcalls_instance handle, and int32_t return codes — not against the tgcalls C++ classes (which drag in the entire WebRTC tree and cannot be parsed without it). A feature-gated C++ translation unit implements the bridge: ffi-stub compiles an in-memory fake (src/bridge_stub.cpp) so the whole FFI chain links and is testable with zero WebRTC; link-tgcalls (later, blocked on libwebrtc) compiles the real bridge against the submodule. rstgcalls (the safe crate) wraps the sys surface in a CallBackend trait with two impls (MockBackend, TgcallsBackend), a typestate CallManager<Idle|Active>, and value types — unsafe_code = "deny" at the crate root with a single scoped #[allow(unsafe_code)] on the gated backend module.
Tech Stack: Rust edition 2024, MSRV 1.100, toolchain nightly-2026-09-10; bindgen 0.72, cc 1.2, thiserror 2.0, trybuild 1.0 (all present in the local cargo registry cache → offline builds work); clang 23 / libclang; cmake 4.4.4. System C++ libs present: openssl 3.6.5, ffmpeg (libavcodec/format/util 63.1.102), opus 1.6.1.
Spec: ../project-design/context/ — ADR-018 (tgcalls bindings now, pure-Rust re:call deferred), ADR-014 (contract tests mandatory for any trait with >1 impl), ADR-015 (typestate/enum over boolean flags), TECHSPEC §10 item 8 + §4 (calls ship as their own nylon-ring module calls-tgcalls, isolated), QA.md cross-cutting gates. Workspace conventions mirror ../fera/CLAUDE.md: conventional commits in English, never push, green check+clippy+tests before each commit.
Global Constraints
Section titled “Global Constraints”- Never push to origin. Local commits only; the owner reviews and pushes. (project CLAUDE.md)
- Conventional commits, English. (
feat:,build:,test:,docs:,chore:). - Green before every commit:
cargo check,cargo clippy --all-targets,cargo test. All must pass offline (--offline) in this environment. unsafeis confined to: (a) the generated bindings inrstgcalls-sys, and (b) the single gatedbackendmodule in the safe crate. Nowhere else.- Lint policy (from
../fera): crate-rootunsafe_code = "deny"(NOTforbid—forbidis terminal and cannot be relaxed by the scopedallowthe backend needs; rustc E0453).clippy::all+clippy::pedantic= deny.warnings = "deny",missing_docswarn (promote to error for public API by documenting everything),unused_resultsdeny. - Edition 2024, MSRV 1.100, resolver “3”.
- No system-package installs. If a build step needs a lib that isn’t present, record it in
TODO.mdand report — do notdnf/apt install. - bindgen offline gotcha (proven): must pass explicit
--target=<TARGET>(bindgen 0.72 asserts host==target and panics otherwise) AND clang++-discovered-isystemC++ include paths (libclang does not auto-discover the C++ stdlib). Discovery is portable: runclang++ -std=c++20 -E -x c++ -v -and parse the block between#include <...> search starts here:andEnd of search list. - Cross-repo deps: committed manifests stay on git deps (the-fera org); local co-dev uses
.cargo/config.tomlpathsoverrides (gitignored).
Review Focus
Section titled “Review Focus”The spec is a vision doc; these are the inputs/failure modes it implies but no single test exercises, most-likely-to-bite first. Each is pinned to the owning task’s test step.
- A caller pushes audio before joining → must be unrepresentable, not a runtime panic. Pinned by Task 8 (trybuild compile-fail:
push_audiois only defined onCallManager<Active>). - The C++ bridge returns a non-zero error code (backend unavailable, stopped, invalid arg) → safe layer must map every documented code to a typed
Error, never silently succeed or leak a raw int. Pinned by Task 4 (error mapping tests) and Task 6 (stub returnsERR_STOPPEDafter stop; contract test asserts the mapping). - A malformed
AudioFrame(zero channels, zero sample rate, sample slice length not a multiple of channels) → must be rejected at the safe boundary before any pointer crosses FFI. Pinned by Task 4 (AudioFrame::newvalidation tests). - The real C++/WebRTC backend is absent (today’s reality) → the default feature build and its full test suite must still be green with no link step. Pinned by Task 1 + Task 5 + Task 7 (default-feature
cargo testpasses usingMockBackendonly) and Task 10 (feature-matrix verification). - The two
CallBackendimpls drift in semantics → a contract suite must run identically against both (ADR-014). Pinned by Task 5 (contract fn defined + run vsMockBackend) and Task 6 (same contract fn run vsTgcallsBackendunderffi-stub). - Stray
unsafesneaks into the safe crate outside the backend module → must fail to compile. Pinned by Task 10 (a temporary out-of-moduleunsafeblock is confirmed to be rejected by crate-rootdeny, then reverted; andgrepconfirms confinement). tgcalls_instancehandle leaks if aCallManager/TgcallsBackendis dropped withoutleave()→Dropmust stop+release. Pinned by Task 6 (stub tracks live-handle count; test asserts it returns to zero after drop).
File Structure
Section titled “File Structure”rstgcalls/├── Cargo.toml # workspace root: resolver 3, members, workspace.package, workspace.lints├── rust-toolchain.toml # nightly-2026-09-10, clippy+rustfmt, minimal profile├── clippy.toml # msrv 1.100 + thresholds (mirror ../fera)├── .gitignore # target/, .cargo/config.toml, generated├── README.md # (Task 9) build, feature matrix, system deps├── TODO.md # (Task 9) plan, blocking C++ deps, next steps├── tgcalls/ # submodule (already present @ d20de8e8)└── crates/ ├── sys/ # rstgcalls-sys — ALL unsafe + C/C++ contact │ ├── Cargo.toml │ ├── build.rs # portable clang++ include discovery → bindgen bridge.h → OUT_DIR/bindings.rs; feature-gated cc │ ├── include/ │ │ └── rstgcalls_bridge.h # pure C ABI: opaque handle, POD structs, int32_t codes, extern "C" fns │ └── src/ │ ├── lib.rs # include!(bindings.rs); allow(non_snake_case, …) │ └── bridge_stub.cpp # (Task 3) ffi-stub: in-memory fake instance, deterministic codes └── safe/ # rstgcalls — safe idiomatic wrapper ├── Cargo.toml # optional dep on sys; features default/ffi-stub/link-tgcalls ├── src/ │ ├── lib.rs # crate docs, deny(unsafe_code), re-exports │ ├── error.rs # (Task 4) Error enum (thiserror), Result │ ├── types.rs # (Task 4) ConnectionState, NetworkType, AudioFrame, InitConfig, FinalState │ ├── backend.rs # (Task 5) CallBackend trait + MockBackend │ └── ffi_backend.rs # (Task 6) #[cfg(gated)] #[allow(unsafe_code)] TgcallsBackend ├── src/manager.rs # (Task 7) CallManager<Idle|Active> typestate └── tests/ ├── contract.rs # (Task 5) run_backend_contract<B>() + per-impl invocations └── ui.rs # (Task 8) trybuild harness └── ui/*.rs,*.stderrTask 1: Workspace scaffold + lint/toolchain config
Section titled “Task 1: Workspace scaffold + lint/toolchain config”Files:
- Create:
Cargo.toml,rust-toolchain.toml,clippy.toml,.gitignore - Create:
crates/sys/Cargo.toml,crates/sys/src/lib.rs - Create:
crates/safe/Cargo.toml,crates/safe/src/lib.rs
Interfaces:
-
Consumes: nothing (first task).
-
Produces: a two-member workspace whose crates compile empty.
rstgcalls-sysdeclares featuresffi-stubandlink-tgcalls(both no-op for now).rstgcalls(safe) declares optional deprstgcalls-sysand featuresdefault(empty),ffi-stub = ["dep:rstgcalls-sys","rstgcalls-sys/ffi-stub"],link-tgcalls = ["dep:rstgcalls-sys","rstgcalls-sys/link-tgcalls"]. Later tasks add modules to these crates. -
Step 1: Write the root
Cargo.toml
[workspace]resolver = "3"members = ["crates/sys", "crates/safe"]
[workspace.package]version = "0.1.0"edition = "2024"rust-version = "1.100"license = "TBD"repository = "https://github.com/the-fera/rstgcalls"
[workspace.lints.rust]unsafe_code = "deny"warnings = "deny"rust_2018_idioms = { level = "deny", priority = -1 }unused_lifetimes = "deny"unused_qualifications = "deny"unused_results = "deny"missing_debug_implementations = "warn"missing_docs = "warn"
[workspace.lints.clippy]all = { level = "deny", priority = -1 }pedantic = { level = "deny", priority = -1 }Note: rust-version at [workspace.package] is inherited by members via rust-version.workspace = true; do not leave it unused (cargo warns on unused workspace.package fields under warnings = "deny"). Each member that inherits it must actually reference it.
- Step 2: Write
rust-toolchain.toml(mirror../fera)
[toolchain]channel = "nightly-2026-09-10"components = ["rustfmt", "clippy"]profile = "minimal"- Step 3: Write
clippy.toml(mirror../fera)
msrv = "1.100.0"avoid-breaking-exported-api = falsecognitive-complexity-threshold = 15too-many-arguments-threshold = 5too-many-lines-threshold = 150type-complexity-threshold = 200- Step 4: Write
.gitignore
/target/crates/*/target.cargo/config.toml(.cargo/config.toml holds local paths overrides for cross-repo co-dev; never committed per project CLAUDE.md.)
- Step 5: Write
crates/sys/Cargo.toml
[package]name = "rstgcalls-sys"version.workspace = trueedition.workspace = truerust-version.workspace = truelicense.workspace = truerepository.workspace = truedescription = "Raw FFI bindings to tgcalls (Telegram C++ WebRTC stack). All unsafe lives here."build = "build.rs"
[features]default = []# Compile an in-memory fake bridge so the FFI chain links and is testable with no WebRTC.ffi-stub = []# Compile the real bridge against the tgcalls submodule (requires libwebrtc; see TODO.md).link-tgcalls = []
[lints]workspace = true
[lints.rust]# This crate is the unsafe boundary; the generated bindings and any manual FFI are unsafe by nature.unsafe_code = "allow"Note: [lints.rust] unsafe_code = "allow" here is intentional and does not conflict with the workspace deny — it narrows it for the sys crate only. Document why in a comment (as above).
- Step 6: Write
crates/sys/src/lib.rs(placeholder; Task 2 replaces withinclude!)
#![allow(non_camel_case_types, non_upper_case_globals, non_snake_case)]#![allow(clippy::all, clippy::pedantic)]//! Raw FFI bindings to tgcalls.//!//! This crate is the single home of `unsafe` and all C/C++ contact. It is//! generated from a pure C-ABI bridge header by `build.rs`; consumers should//! use the safe `rstgcalls` crate instead.// Task 2 adds: include!(concat!(env!("OUT_DIR"), "/bindings.rs"));- Step 7: Write a stub
crates/sys/build.rsso the crate builds before Task 2 fleshes it out
fn main() { println!("cargo:rerun-if-changed=build.rs");}- Step 8: Write
crates/safe/Cargo.toml
[package]name = "rstgcalls"version.workspace = trueedition.workspace = truerust-version.workspace = truelicense.workspace = truerepository.workspace = truedescription = "Safe, idiomatic Rust wrapper over tgcalls (Telegram C++ WebRTC stack)."
[dependencies]rstgcalls-sys = { path = "../sys", optional = true }thiserror = "2"
[features]default = []# Link the in-memory stub backend; enables the FFI round-trip tests with no WebRTC.ffi-stub = ["dep:rstgcalls-sys", "rstgcalls-sys/ffi-stub"]# Link the real tgcalls backend (requires libwebrtc; see TODO.md).link-tgcalls = ["dep:rstgcalls-sys", "rstgcalls-sys/link-tgcalls"]
[lints]workspace = true- Step 9: Write
crates/safe/src/lib.rs(placeholder; later tasks add modules)
#![warn(missing_docs)]//! Safe, idiomatic Rust bindings to **tgcalls** — Telegram's C++ WebRTC//! calling stack — for the Fera ecosystem (see ADR-018).//!//! `unsafe` is confined to the gated `ffi_backend` module (the only FFI//! boundary) and to the `rstgcalls-sys` crate. The crate root inherits//! `unsafe_code = "deny"` from the workspace.//!//! # Backends//! - [`backend::MockBackend`]: in-process, always available, for tests/CI.//! - `ffi_backend::TgcallsBackend` (features `ffi-stub` / `link-tgcalls`)://! real bridge over the C ABI.// Modules added in later tasks: error, types, backend, manager, ffi_backend.- Step 10: Verify the empty workspace builds and lints clean
Run: cargo check --offline && cargo clippy --offline --all-targets && cargo test --offline
Expected: all three succeed; zero warnings (both crates compile with only doc comments). cargo metadata --offline lists both members.
- Step 11: Commit
git add Cargo.toml rust-toolchain.toml clippy.toml .gitignore crates/git commit -m "build: scaffold rstgcalls workspace (sys + safe crates, lints, toolchain)
Co-Authored-By: Claude Fable 5 <[email protected]>"Task 2: rstgcalls-sys C-ABI bridge header + bindgen build.rs
Section titled “Task 2: rstgcalls-sys C-ABI bridge header + bindgen build.rs”Files:
- Create:
crates/sys/include/rstgcalls_bridge.h - Modify:
crates/sys/build.rs(full bindgen driver) - Modify:
crates/sys/src/lib.rs(addinclude!of generated bindings)
Interfaces:
-
Consumes: the workspace from Task 1.
-
Produces (the C ABI every later task relies on — exact names/types):
- Opaque handle:
typedef struct rstgcalls_instance rstgcalls_instance; - Return codes (
int32_t):RSTGCALLS_OK = 0,RSTGCALLS_ERR_NULL_ARG = -1,RSTGCALLS_ERR_BACKEND_UNAVAILABLE = -2,RSTGCALLS_ERR_STOPPED = -3,RSTGCALLS_ERR_INVALID_ARG = -4. - POD structs:
rstgcalls_config { double initialization_timeout; double receive_timeout; int32_t data_saving; uint8_t enable_p2p; uint8_t allow_tcp; int32_t max_api_layer; int32_t protocol_version; },rstgcalls_audio_frame { const int16_t *samples; size_t num_samples; size_t num_channels; uint32_t samples_per_sec; int64_t elapsed_time_ms; },rstgcalls_final_state { uint64_t bytes_sent; uint64_t bytes_received; int32_t is_rating_suggested; }. extern "C"functions:rstgcalls_instance_create(const rstgcalls_config*, rstgcalls_instance** out) -> int32_t,rstgcalls_instance_push_audio(rstgcalls_instance*, const rstgcalls_audio_frame*) -> int32_t,rstgcalls_instance_receive_signaling(rstgcalls_instance*, const uint8_t* data, size_t len) -> int32_t,rstgcalls_instance_set_network_type(rstgcalls_instance*, int32_t) -> int32_t,rstgcalls_instance_set_mute(rstgcalls_instance*, uint8_t) -> int32_t,rstgcalls_instance_get_state(rstgcalls_instance*) -> int32_t,rstgcalls_instance_stop(rstgcalls_instance*, rstgcalls_final_state* out) -> int32_t.build.rsgenerates$OUT_DIR/bindings.rsand (underffi-stub/link-tgcalls) invokescc.
- Opaque handle:
-
Step 1: Write
crates/sys/include/rstgcalls_bridge.hwith the exact ABI above. Header guard#pragma once;#include <stdint.h>and<stddef.h>; wrap the whole surface in#ifdef __cplusplus extern "C" { … }. Add a#definefor each return code. Do not include any tgcalls or webrtc header — this file must parse standalone. Document each function’s contract (when it returns which code) in comments. -
Step 2: Write the failing verification test — a
build.rs-time assertion is not a cargo test; instead addcrates/sys/tests/abi_present.rs:
// Proves the generated bindings expose the expected ABI surface and that// AudioFrame's layout matches the C++ struct (56 bytes on x86_64 per tgcalls).#[test]fn abi_symbols_and_layout() { use rstgcalls_sys::*; // Types exist and are the expected size/align. assert_eq!(core::mem::size_of::<rstgcalls_audio_frame>(), 40); assert_eq!(core::mem::size_of::<rstgcalls_config>(), 40); assert_eq!(RSTGCALLS_OK, 0); assert_eq!(RSTGCALLS_ERR_STOPPED, -3); // Function pointers resolve (link-time presence checked under ffi-stub). let _f: unsafe extern "C" fn(*const rstgcalls_config, *mut *mut rstgcalls_instance) -> i32 = rstgcalls_instance_create;}Note: the exact size_of values must be computed from the real layout once generated (Task 2 step 4) and corrected here — do not trust the numbers in this plan blindly; the point of the test is that they are stable and match the C++ structs. rstgcalls_config has {double,double,i32,u8,u8,i32,i32} → 8+8+4+1+1+4+4 with padding = 32 or 40 depending on alignment; set the assertion to the actual generated value.
- Step 3: Run the test to verify it fails
Run: cargo test --offline -p rstgcalls-sys
Expected: FAIL to compile — rstgcalls_sys::rstgcalls_audio_frame etc. not found (bindings not yet generated).
- Step 4: Write
crates/sys/build.rs(portable bindgen driver)
Approach: discover C++ system include paths from clang++ at build time (no hardcoded versions), then run bindgen against the bridge header with an explicit --target. Emit bindings.rs to OUT_DIR. Under ffi-stub, cc-compile src/bridge_stub.cpp (Task 3); under link-tgcalls, cc-compile the real bridge (later). The full body:
use std::path::Path;use std::process::{Command, Stdio};
fn main() { println!("cargo:rerun-if-changed=include/rstgcalls_bridge.h"); println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=src/bridge_stub.cpp");
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR"); let target = std::env::var("TARGET").expect("TARGET");
let mut b = bindgen::Builder::default() .header("include/rstgcalls_bridge.h") // bindgen 0.72 asserts host==target and panics without an explicit target. .clang_arg(format!("--target={target}")) .clang_arg("-x").clang_arg("c++").clang_arg("-std=c++20"); for p in discover_cxx_includes() { b = b.clang_arg(format!("-isystem{p}")); } b.generate().expect("bindgen bridge.h") .write_to_file(Path::new(&out_dir).join("bindings.rs")) .expect("write bindings.rs");
if std::env::var_os("CARGO_FEATURE_FFI_STUB").is_some() { cc::Build::new().cpp(true).std("c++20").include(".") .file("src/bridge_stub.cpp").compile("rstgcalls_bridge_stub"); } // link-tgcalls branch added later (blocked on libwebrtc; see TODO.md).}
/// Parse the "#include <...> search starts here:" block from clang++ -v so we/// never hardcode C++ stdlib / clang resource-dir paths.fn discover_cxx_includes() -> Vec<String> { let out = Command::new("clang++") .args(["-std=c++20", "-E", "-x", "c++", "-v", "-"]) .stdin(Stdio::null()).output().expect("run clang++"); let stderr = String::from_utf8_lossy(&out.stderr); let mut paths = Vec::new(); let mut in_block = false; for line in stderr.lines() { if line.contains("#include <...> search starts here:") { in_block = true; continue; } if line.contains("End of search list.") { break; } if in_block { if let Some(p) = line.trim().split_whitespace().next() { paths.push(p.to_string()); } } } paths}Add to crates/sys/Cargo.toml under a new section:
[build-dependencies]bindgen = "0.72"cc = "1.2"Keep build.rs itself pedantic-clean (the discovery loop above avoids the else if/trim-before-split_whitespace clippy lints by using split_whitespace().next() on an already-trimed line — that ordering is what clippy pedantic wants; if clippy still flags, restructure to a single iterator chain).
- Step 5: Update
crates/sys/src/lib.rsto pull in the generated bindings
#![allow(non_camel_case_types, non_upper_case_globals, non_snake_case)]#![allow(clippy::all, clippy::pedantic)]//! Raw FFI bindings to tgcalls — see crate docs on the safe `rstgcalls` crate.//! All `unsafe` and C/C++ contact lives here.include!(concat!(env!("OUT_DIR"), "/bindings.rs"));- Step 6: Run the ABI test to verify it passes
Run: cargo test --offline -p rstgcalls-sys
Expected: PASS. bindings.rs generated in OUT_DIR; the abi_symbols_and_layout test compiles and passes with the actual size_of values (correct the assertions in Step 2 to match what bindgen emitted, then re-run).
- Step 7: Verify clippy is clean across the sys crate
Run: cargo clippy --offline --all-targets -p rstgcalls-sys
Expected: no warnings.
- Step 8: Commit
git add crates/sys/git commit -m "build(sys): bindgen C-ABI bridge header with portable clang++ include discovery
Co-Authored-By: Claude Fable 5 <[email protected]>"Task 3: ffi-stub C++ bridge implementation
Section titled “Task 3: ffi-stub C++ bridge implementation”Files:
- Create:
crates/sys/src/bridge_stub.cpp
Interfaces:
-
Consumes: the C ABI from Task 2 (
include/rstgcalls_bridge.h). -
Produces: a linkable in-memory implementation of every
extern "C"bridge function with deterministic, contract-honoring behavior:createallocates a heaprstgcalls_instance(a small C++ struct holdingbool stopped,bool muted,int32_t network_type, a counter of pushed audio frames, and astd::vector<uint8_t>of received signaling), writes it to*out, returnsOK; returnsERR_NULL_ARGifout == nullptr.push_audioreturnsERR_STOPPEDif the instance is stopped,ERR_NULL_ARGif either pointer is null,ERR_INVALID_ARGifnum_channels == 0 || samples_per_sec == 0, else increments the frame counter and returnsOK.receive_signalingappends[data, data+len)to the instance buffer; same null/stopped guards.set_network_type/set_mutestore the value (null/stopped guarded), returnOK.get_statereturns0(WaitInit) when not stopped,3(Failed/Stopped sentinel — document which) when stopped.stopfills*out(if non-null) with zeroedrstgcalls_final_state, marks stopped, deletes the instance, returnsOK. (The safe layer nulls its handle after stop, so delete-on-stop is safe; Task 6 relies on this for the leak test.)- A process-global
std::atomic<int> rstgcalls_stub_live_instances{0}incremented on create / decremented on stop, plusextern "C" int32_t rstgcalls_stub_live_count()— used by Task 6’s leak test. Declare it in the header under#ifdef RSTGCALLS_STUB_TEST_HOOKSor unconditionally (simplest: unconditional, documented as stub-only).
-
Step 1: Write
crates/sys/src/bridge_stub.cppimplementing the contract above. Include"include/rstgcalls_bridge.h". Use a plain C++ struct forrstgcalls_instance. Keep it self-contained (no webrtc, no tgcalls). Add#include <atomic>,<vector>,<cstring>. -
Step 2: Verify it compiles + links via the sys crate
Run: cargo build --offline -p rstgcalls-sys --features ffi-stub
Expected: cc compiles bridge_stub.cpp into librstgcalls_bridge_stub.a; build succeeds with no undefined symbols at the archive stage.
- Step 3: Commit
git add crates/sys/src/bridge_stub.cppgit commit -m "feat(sys): in-memory ffi-stub C++ bridge for offline FFI testing
Co-Authored-By: Claude Fable 5 <[email protected]>"Task 4: Safe value types + error mapping
Section titled “Task 4: Safe value types + error mapping”Files:
- Create:
crates/safe/src/error.rs,crates/safe/src/types.rs - Modify:
crates/safe/src/lib.rs(addmod error; mod types;+pub use)
Interfaces:
-
Consumes: nothing at runtime yet (pure value types); the return-code constants exist in
rstgcalls-sysbut this task defines the safe mapping table. -
Produces (types later tasks rely on — exact names):
error::Error(thiserror): variantsBackendUnavailable,Stopped,NullArg,InvalidArg,Unknown(i32).error::Result<T> = core::result::Result<T, Error>.error::Error::from_code(code: i32) -> Result<()>: mapsRSTGCALLS_OK→Ok(()),-1→NullArg,-2→BackendUnavailable,-3→Stopped,-4→InvalidArg, other→Unknown(code). (Read the actual constants fromrstgcalls_sysunder the gated feature; under default feature hardcode the documented values with a comment, since sys isn’t linked. Prefer: gate thefrom_codethat reads sys constants behindcfg(feature)and provide a default-feature path using the literal values — keep one source of truth by defining the literals intypes.rsand referencing them.)types::ConnectionStateenum mirroring tgcallsState:WaitInit, WaitInitAck, Established, Failed, Reconnecting(ADR-015: enum, not booleans).ConnectionState::from_raw(i32) -> Self.types::NetworkTypeenum mirroring tgcalls:Unknown, Gprs, Edge, ThirdGeneration, Hspa, Lte, WiFi, Ethernet, OtherHighSpeed, OtherLowSpeed, OtherMobile, Dialup.to_raw(&self) -> i32.types::DataSavingenum:Never, Mobile, Always.types::ProtocolVersionenum:V0, V1.types::AudioFrame<'a>— borrowed view{ samples: &'a [i16], channels: usize, samples_per_sec: u32, elapsed_time_ms: i64 }withAudioFrame::new(samples: &'a [i16], channels: usize, samples_per_sec: u32) -> Result<Self>validatingchannels > 0,samples_per_sec > 0,!samples.is_empty(), andsamples.len() % channels == 0(elseInvalidArg).types::InitConfig— owned builder-ish struct{ initialization_timeout: f64, receive_timeout: f64, data_saving: DataSaving, enable_p2p: bool, allow_tcp: bool, max_api_layer: i32, protocol_version: ProtocolVersion }withDefault(sensible: 30.0 / 30.0 / Never / true / false / 0 / V0) and#[must_use] fn …(self) -> Selfsetters.types::FinalState—{ bytes_sent: u64, bytes_received: u64, is_rating_suggested: bool }.
-
Step 1: Write
crates/safe/src/error.rswith theErrorenum,Resultalias, andfrom_code. Every public item documented (missing_docsis warn→ effectively required). -
Step 2: Write
crates/safe/src/types.rswith all enums/structs above. Implementfrom_raw/to_rawfor the mirror enums (exhaustive match; unknown raw → a documented fallback, e.g.ConnectionState::WaitInitor a#[non_exhaustive]-safe default). ImplementAudioFrame::newvalidation andInitConfigDefault+ setters. -
Step 3: Write the failing tests in
crates/safe/src/types.rs(#[cfg(test)] mod tests) andcrates/safe/src/error.rs:
// types.rs tests#[test] fn audio_frame_rejects_zero_channels() { assert!(matches!(AudioFrame::new(&[0i16; 480], 0, 48000), Err(Error::InvalidArg)));}#[test] fn audio_frame_rejects_zero_sample_rate() { assert!(matches!(AudioFrame::new(&[0i16; 480], 1, 0), Err(Error::InvalidArg)));}#[test] fn audio_frame_rejects_len_not_multiple_of_channels() { assert!(matches!(AudioFrame::new(&[0i16; 481], 2, 48000), Err(Error::InvalidArg)));}#[test] fn audio_frame_accepts_valid() { assert!(AudioFrame::new(&[0i16; 960], 2, 48000).is_ok());}#[test] fn init_config_default_is_sane() { let c = InitConfig::default(); assert!(c.enable_p2p && c.initialization_timeout > 0.0);}
// error.rs tests — every documented code maps, unknown does not panic#[test] fn error_from_code_maps_all() { assert!(Error::from_code(0).is_ok()); assert_eq!(Error::from_code(-1), Err(Error::NullArg)); assert_eq!(Error::from_code(-2), Err(Error::BackendUnavailable)); assert_eq!(Error::from_code(-3), Err(Error::Stopped)); assert_eq!(Error::from_code(-4), Err(Error::InvalidArg)); assert_eq!(Error::from_code(-999), Err(Error::Unknown(-999)));}- Step 4: Run tests to verify they fail, then pass
Run: cargo test --offline -p rstgcalls (first FAIL: types undefined; after implementing, PASS).
-
Step 5: Verify clippy pedantic clean —
cargo clippy --offline --all-targets -p rstgcalls. -
Step 6: Commit
git add crates/safe/src/error.rs crates/safe/src/types.rs crates/safe/src/lib.rsgit commit -m "feat(safe): value types (AudioFrame, InitConfig, ConnectionState) + error mapping
Co-Authored-By: Claude Fable 5 <[email protected]>"Task 5: CallBackend trait + MockBackend + contract suite (ADR-014)
Section titled “Task 5: CallBackend trait + MockBackend + contract suite (ADR-014)”Files:
- Create:
crates/safe/src/backend.rs - Create:
crates/safe/tests/contract.rs - Modify:
crates/safe/src/lib.rs(addmod backend;+pub use backend::{CallBackend, MockBackend};)
Interfaces:
-
Consumes:
error::{Error, Result},types::{AudioFrame, ConnectionState, NetworkType, FinalState}from Task 4. -
Produces (the trait both impls satisfy — ADR-014 requires a contract suite run against every impl):
pub trait CallBackendwith methods:fn push_audio(&mut self, frame: &AudioFrame<'_>) -> Result<()>fn receive_signaling(&mut self, data: &[u8]) -> Result<()>fn set_network_type(&mut self, nt: NetworkType) -> Result<()>fn set_mute(&mut self, muted: bool) -> Result<()>fn state(&self) -> ConnectionStatefn stop(&mut self) -> Result<FinalState>
MockBackend { stopped: bool, frames: usize, muted: bool, net: NetworkType }(fields private) implementing the trait in-process:push_audioafterstop→Err(Stopped); before stop with a valid frame →Okand incrementsframes;stopsetsstoppedand returns aFinalState. ProvideMockBackend::new()andDefault.tests/contract.rsexposesfn run_backend_contract<B>(make: impl Fn() -> Result<B>)asserting the shared semantics (see test below), and invokes it forMockBackendunconditionally and forTgcallsBackendunder#[cfg(feature = "ffi-stub")](added in Task 6).
-
Step 1: Write
crates/safe/src/backend.rs— theCallBackendtrait (documented, object-safe: no generics/Self-returning methods) andMockBackend. -
Step 2: Write
crates/safe/tests/contract.rswith the reusable contract function:
use rstgcalls::{AudioFrame, CallBackend, ConnectionState, Error, FinalState, MockBackend, NetworkType};
/// ADR-014: one contract suite, run against EVERY CallBackend impl.pub fn run_backend_contract<B: CallBackend>(make: impl Fn() -> B) { let mut b = make(); // starts not-stopped assert!(!matches!(b.state(), ConnectionState::Failed)); // push a valid frame → ok let f = AudioFrame::new(&[0i16; 960], 2, 48000).expect("valid frame"); assert!(b.push_audio(&f).is_ok()); // signaling → ok assert!(b.receive_signaling(&[1, 2, 3, 4]).is_ok()); // mute / network → ok assert!(b.set_mute(true).is_ok()); assert!(b.set_network_type(NetworkType::WiFi).is_ok()); // stop → ok, returns FinalState let fs: FinalState = b.stop().expect("stop ok"); let _ = fs.bytes_sent; // after stop: pushing audio is an error, not a panic assert!(matches!(b.push_audio(&f), Err(Error::Stopped))); assert!(matches!(b.stop(), Err(Error::Stopped)) || b.stop().is_ok()); // idempotent-or-Stopped, impl-defined but must not panic}
#[test] fn mock_backend_satisfies_contract() { run_backend_contract(MockBackend::new);}(Keep the post-stop contract precise once both impls exist: the shared invariant is “does not panic and returns Stopped or Ok”. Task 6 makes TgcallsBackend return Stopped, and this line tightens to assert!(matches!(b.push_audio(&f), Err(Error::Stopped))) for both.)
- Step 3: Run the contract test to verify it fails, then passes
Run: cargo test --offline -p rstgcalls --test contract
Expected: FAIL first (trait/impl undefined), then PASS with MockBackend.
- Step 4: Verify default-feature full suite + clippy
Run: cargo test --offline && cargo clippy --offline --all-targets
Expected: green, no warnings. This is Review Focus #4 (default build green with no C++ link).
- Step 5: Commit
git add crates/safe/src/backend.rs crates/safe/src/lib.rs crates/safe/tests/contract.rsgit commit -m "feat(safe): CallBackend trait + MockBackend with ADR-014 contract suite
Co-Authored-By: Claude Fable 5 <[email protected]>"Task 6: TgcallsBackend (gated, the only unsafe) + contract + leak test
Section titled “Task 6: TgcallsBackend (gated, the only unsafe) + contract + leak test”Files:
- Create:
crates/safe/src/ffi_backend.rs - Modify:
crates/safe/src/lib.rs(add#[cfg(any(feature="ffi-stub",feature="link-tgcalls"))] mod ffi_backend;+ gatedpub use ffi_backend::TgcallsBackend;) - Modify:
crates/safe/tests/contract.rs(run the contract againstTgcallsBackendunderffi-stub)
Interfaces:
-
Consumes:
rstgcalls_sys::*(Task 2 ABI), the stub (Task 3),error,types,CallBackend(Task 5). -
Produces:
pub struct TgcallsBackend { raw: *mut rstgcalls_sys::rstgcalls_instance }— the sole home ofunsafein the safe crate, behind#[allow(unsafe_code)]on the module.unsafe impl Send(documented: the C++ instance is thread-confined in real tgcalls; the handle is moved as a unit).TgcallsBackend::create(cfg: &InitConfig) -> Result<Self>: builds arstgcalls_configfromInitConfig, callsrstgcalls_instance_create, maps the code viaError::from_code, stores the handle (null-checksout).impl CallBackend for TgcallsBackend: each method builds the C arg (e.g.AudioFrame→rstgcalls_audio_framewithsamples.as_ptr()), calls the FFI fn, maps the code.stopcallsrstgcalls_instance_stop, fills aFinalState, then nullsself.rawso it is not double-freed.impl Drop for TgcallsBackend: ifrawis non-null, callstop(best-effort, ignore result) — guarantees no leak (Review Focus #7).
-
Step 1: Write
crates/safe/src/ffi_backend.rs. Structure:
//! Real bridge backend. THE ONLY `unsafe` in the safe crate lives here.#![cfg(any(feature = "ffi-stub", feature = "link-tgcalls"))]#[allow(unsafe_code)] // FFI boundary; crate root is deny(unsafe_code).mod inner { use crate::error::{Error, Result}; use crate::types::{AudioFrame, ConnectionState, FinalState, InitConfig, NetworkType}; use crate::backend::CallBackend; use rstgcalls_sys as sys;
/// Owns one tgcalls call instance for its lifetime. pub struct TgcallsBackend { raw: *mut sys::rstgcalls_instance } // SAFETY: the handle is moved as a unit; tgcalls confines the instance to // its own threads and all access goes through &mut self here. unsafe impl Send for TgcallsBackend {}
impl TgcallsBackend { /// Create a call instance from config. pub fn create(cfg: &InitConfig) -> Result<Self> { let c = sys::rstgcalls_config { /* fields from cfg, enums → raw */ }; let mut out: *mut sys::rstgcalls_instance = core::ptr::null_mut(); // SAFETY: `out` is a valid mutable pointer to a local; create // writes a non-null handle on OK and leaves it null otherwise. let rc = unsafe { sys::rstgcalls_instance_create(&c, &mut out) }; Error::from_code(rc)?; if out.is_null() { return Err(Error::BackendUnavailable); } Ok(Self { raw: out }) } fn guard(&self) -> Result<()> { if self.raw.is_null() { Err(Error::Stopped) } else { Ok(()) } } }
impl CallBackend for TgcallsBackend { fn push_audio(&mut self, f: &AudioFrame<'_>) -> Result<()> { self.guard()?; let cf = sys::rstgcalls_audio_frame { samples: f.samples.as_ptr(), num_samples: f.samples.len(), num_channels: f.channels, samples_per_sec: f.samples_per_sec, elapsed_time_ms: f.elapsed_time_ms, }; // SAFETY: guard ensured self.raw non-null; cf borrows f for the call. let rc = unsafe { sys::rstgcalls_instance_push_audio(self.raw, &cf) }; Error::from_code(rc) } // receive_signaling / set_network_type / set_mute / state / stop: // same guard → build C arg → unsafe call → Error::from_code. // stop() additionally nulls self.raw after a successful call. fn stop(&mut self) -> Result<FinalState> { /* call, fill FinalState, self.raw = null */ } fn state(&self) -> ConnectionState { /* get_state → ConnectionState::from_raw; Stopped→Failed */ } fn receive_signaling(&mut self, data: &[u8]) -> Result<()> { /* guard + ptr/len */ } fn set_network_type(&mut self, nt: NetworkType) -> Result<()> { /* guard + to_raw */ } fn set_mute(&mut self, muted: bool) -> Result<()> { /* guard + u8 */ } }
impl Drop for TgcallsBackend { fn drop(&mut self) { if !self.raw.is_null() { let _ = self.stop(); } } }}pub use inner::TgcallsBackend;- Step 2: Extend
crates/safe/tests/contract.rsto run the same contract againstTgcallsBackendunder the feature, plus the leak test:
#[cfg(feature = "ffi-stub")]#[test]fn tgcalls_backend_satisfies_contract() { use rstgcalls::{InitConfig, TgcallsBackend}; run_backend_contract(|| TgcallsBackend::create(&InitConfig::default()).expect("stub create ok"));}
#[cfg(feature = "ffi-stub")]#[test]fn tgcalls_backend_does_not_leak_on_drop() { use rstgcalls::{InitConfig, TgcallsBackend}; // SAFETY: stub-only test hook declared extern by bridge_stub.cpp. extern "C" { fn rstgcalls_stub_live_count() -> i32; } let before = unsafe { rstgcalls_stub_live_count() }; { let b = TgcallsBackend::create(&InitConfig::default()).expect("create"); assert_eq!(unsafe { rstgcalls_stub_live_count() }, before + 1); drop(b); // no explicit stop → Drop must release } assert_eq!(unsafe { rstgcalls_stub_live_count() }, before);}The unsafe { … } in the test file is acceptable (tests are not the shipped safe surface), but keep it minimal and #[cfg]-gated. If the workspace deny(unsafe_code) blocks it in tests, add #[allow(unsafe_code)] on the test fn with a comment.
- Step 3: Run the gated tests to verify they fail, then pass
Run: cargo test --offline -p rstgcalls --features ffi-stub
Expected: FAIL first (backend undefined / link error), then PASS — contract runs against both impls; leak test asserts live count returns to baseline. This is Review Focus #5 and #7.
- Step 4: Verify default-feature build is still green (the gated module must not compile in by default)
Run: cargo test --offline (no features) → PASS, TgcallsBackend absent.
- Step 5: Verify clippy pedantic across both feature sets
Run: cargo clippy --offline --all-targets and cargo clippy --offline --all-targets --features ffi-stub
Expected: both clean.
- Step 6: Commit
git add crates/safe/src/ffi_backend.rs crates/safe/src/lib.rs crates/safe/tests/contract.rsgit commit -m "feat(safe): gated TgcallsBackend over C ABI + contract & leak tests
Co-Authored-By: Claude Fable 5 <[email protected]>"Task 7: CallManager<Idle|Active> typestate (ADR-015)
Section titled “Task 7: CallManager<Idle|Active> typestate (ADR-015)”Files:
- Create:
crates/safe/src/manager.rs - Modify:
crates/safe/src/lib.rs(addmod manager;+pub use manager::{CallManager, Idle, Active};)
Interfaces:
-
Consumes:
CallBackend,MockBackend(Task 5), gatedTgcallsBackend(Task 6),error,types. -
Produces:
- Sealed state marker traits:
pub trait State sealed {};pub struct Idle; pub struct Active;implementing it. pub struct CallManager<S: State> { backend: Box<dyn CallBackend>, _state: PhantomData<S> }.CallManager<Idle>:pub fn new_mock() -> Self;#[cfg(gated)] pub fn new_tgcalls(cfg: &InitConfig) -> Result<Self>;pub fn join(self) -> Result<CallManager<Active>>(calls a start/handshake — for the scaffold,joinvalidates the backend is usable and transitions; real signaling wiring is future work, documented).CallManager<Active>:pub fn push_audio(&mut self, frame: &AudioFrame<'_>) -> Result<()>,pub fn receive_signaling(&mut self, data: &[u8]) -> Result<()>,pub fn set_mute(&mut self, muted: bool) -> Result<()>,pub fn set_network_type(&mut self, nt: NetworkType) -> Result<()>,pub fn state(&self) -> ConnectionState,pub fn leave(self) -> Result<FinalState>(stops backend, returns to nothing / consumes).push_audioand friends are only defined onCallManager<Active>→ calling them onIdleis a compile error (Review Focus #1, pinned by Task 8).
- Sealed state marker traits:
-
Step 1: Write
crates/safe/src/manager.rswith the sealed typestate.joinonIdleconsumes self and returnsResult<CallManager<Active>>;leaveonActiveconsumes self, callsbackend.stop(), returnsResult<FinalState>. Document thatjoin/leaveare the lifecycle edges and thatActivegates all media ops. -
Step 2: Write runtime transition tests in
crates/safe/src/manager.rs(#[cfg(test)]):
#[test] fn mock_join_push_leave() { let mut m = CallManager::new_mock().join().expect("join ok"); let f = AudioFrame::new(&[0i16; 960], 2, 48000).expect("valid"); assert!(m.push_audio(&f).is_ok()); assert!(m.set_mute(true).is_ok()); let _fs = m.leave().expect("leave ok");}#[test] fn state_is_connection_state_enum() { let m = CallManager::new_mock().join().expect("join"); assert!(matches!(m.state(), ConnectionState::WaitInit | ConnectionState::Established));}- Step 3: Run tests to verify they fail, then pass
Run: cargo test --offline -p rstgcalls --lib
Expected: FAIL then PASS.
-
Step 4: Verify clippy —
cargo clippy --offline --all-targets. -
Step 5: Commit
git add crates/safe/src/manager.rs crates/safe/src/lib.rsgit commit -m "feat(safe): CallManager<Idle|Active> typestate (ADR-015)
Co-Authored-By: Claude Fable 5 <[email protected]>"Task 8: Compile-fail tests for the typestate (trybuild)
Section titled “Task 8: Compile-fail tests for the typestate (trybuild)”Files:
- Create:
crates/safe/tests/ui.rs,crates/safe/tests/ui/push_before_join.rs,crates/safe/tests/ui/push_before_join.stderr,crates/safe/tests/ui/leave_then_push.rs,crates/safe/tests/ui/leave_then_push.stderr - Modify:
crates/safe/Cargo.toml(add[dev-dependencies] trybuild = "1.0")
Interfaces:
-
Consumes:
CallManager,Idle,Active(Task 7). -
Produces: a compile-fail suite proving invalid lifecycle use does not typecheck (Review Focus #1).
-
Step 1: Add
trybuilddev-dependency tocrates/safe/Cargo.toml. -
Step 2: Write
crates/safe/tests/ui.rs
#[test]fn typestate_compile_fail() { let t = trybuild::TestCases::new(); t.compile_fail("tests/ui/*.rs");}- Step 3: Write
crates/safe/tests/ui/push_before_join.rs(push_audio onIdlemust not compile)
fn main() { let mut m = rstgcalls::CallManager::new_mock(); // CallManager<Idle> let f = rstgcalls::AudioFrame::new(&[0i16; 960], 2, 48000).unwrap(); m.push_audio(&f).unwrap(); // ERROR: no method `push_audio` on CallManager<Idle>}- Step 4: Write
crates/safe/tests/ui/leave_then_push.rs(afterleave, theActivehandle is consumed → push is unreachable/doesn’t compile)
fn main() { let mut m = rstgcalls::CallManager::new_mock().join().unwrap(); let _fs = m.leave().unwrap(); // consumes m let f = rstgcalls::AudioFrame::new(&[0i16; 960], 2, 48000).unwrap(); m.push_audio(&f).unwrap(); // ERROR: use of moved value `m`}- Step 5: Generate the
.stderrsnapshots
Run: TRYBUILD=overwrite cargo test --offline -p rstgcalls --test ui
Expected: trybuild writes tests/ui/*.stderr matching the actual rustc diagnostics (method-not-found on CallManager<Idle>; use-after-move). Inspect the generated files — they must reference push_audio/moved value, not an unrelated error.
- Step 6: Re-run clean to confirm the suite passes
Run: cargo test --offline -p rstgcalls --test ui
Expected: PASS (1 passed), no mismatch.
- Step 7: Commit
git add crates/safe/Cargo.toml crates/safe/tests/ui.rs crates/safe/tests/ui/git commit -m "test(safe): trybuild compile-fail proofs for CallManager typestate
Co-Authored-By: Claude Fable 5 <[email protected]>"Task 9: README + TODO.md + docs
Section titled “Task 9: README + TODO.md + docs”Files:
- Create:
TODO.md - Modify:
README.md(replace the “pre-code” status with real build docs; keep the ecosystem context)
Interfaces:
-
Consumes: the finished crate surface (all prior tasks).
-
Produces: developer-facing build/feature documentation and the blocking-dependency record.
-
Step 1: Write
README.mdcovering: what the crate is (link ADR-018,re:calldeferral); the two-crate layout; the feature matrix —default(MockBackend, no C++, fully offline),ffi-stub(links the in-memory C++ stub, runs the full FFI + contract + leak tests offline),link-tgcalls(real backend; blocked, see TODO.md); build/test commands (cargo test --offline,cargo test --offline --features ffi-stub); the unsafe-isolation guarantee; system deps present vs required. -
Step 2: Write
TODO.mdwith:- Done (this scaffold): workspace, sys bindgen bridge, ffi-stub, safe types/backend/manager/typestate, contract + compile-fail + leak tests, all green offline.
- Blocking for
link-tgcalls(record, do NOT install):libwebrtcsource tree + build (tgcalls has ~250api/,rtc_base/,modules/,media/,call/,system_wrappers/,common_audio/includes across 63 files; no bundled WebRTC, no CMake — upstream is consumed via SwiftPM/tdesktop against an externalwebrtc/build/src);libyuv(video paths; not present);libtgvoip(tgcalls/legacy/InstanceImplLegacy— only needed for legacy codec support, can be excluded initially). Present already: openssl 3.6.5, ffmpeg 63.1.102, opus 1.6.1, clang 23, cmake 4.4.4. - Next steps toward a real backend: write
src/bridge_tgcalls.cppimplementing the bridge overtgcalls::Meta::Create/GroupInstanceInterface; obtain/build libwebrtc (pin a commit); wirecc+ link flags (-lwebrtc, opus, ffmpeg, ssl, crypto, yuv) underlink-tgcalls; drivejoin/leavewith real MTProto signaling viagrmrs; audio device module (FakeAudioDeviceModule for headless, real ADM for desktop); group-call (GroupInstanceDescriptor) support;nylon-ringcdylib packaging ascalls-tgcalls(TECHSPEC §4). - Design notes: bridge return-code table; why bindgen targets the C bridge not the C++ classes; the
deny+scoped-allowunsafe strategy and whyforbidwas rejected.
-
Step 3: Ensure crate/module docs are complete — run
cargo doc --offline --no-depsand confirm no missing-docs warnings for public items. -
Step 4: Verify README commands actually work — copy-paste the build/test invocations and run them; they must succeed offline.
-
Step 5: Commit
git add README.md TODO.mdgit commit -m "docs: README build/feature matrix + TODO with blocking C++ deps
Co-Authored-By: Claude Fable 5 <[email protected]>"Task 10: Final integration gate + unsafe-confinement proof
Section titled “Task 10: Final integration gate + unsafe-confinement proof”Files: none created (verification task; may add .cargo/config.toml note to .gitignore already done).
Interfaces:
-
Consumes: the whole workspace.
-
Produces: a verified green feature matrix and a proof that
unsafeis confined. -
Step 1: Full feature-matrix verification (offline)
Run each and confirm zero warnings/errors:
cargo check --offlinecargo check --offline --features ffi-stubcargo clippy --offline --all-targetscargo clippy --offline --all-targets --features ffi-stubcargo test --offlinecargo test --offline --features ffi-stubcargo fmt --all -- --checkExpected: all green. (link-tgcalls is documented as not-yet-buildable; do not run it — it will fail at C++ compile for missing webrtc, which is expected and recorded in TODO.md.)
- Step 2: Prove
unsafeconfinement (Review Focus #6)
Run: grep -rn "unsafe" crates/safe/src/ | grep -v "ffi_backend.rs"
Expected: only doc-comment mentions in lib.rs (the crate docs describing the policy) — no unsafe code outside ffi_backend.rs. Then confirm crates/safe/src/lib.rs root carries the inherited deny(unsafe_code) and ffi_backend.rs’s inner module is the only #[allow(unsafe_code)].
Temporarily append a stray pub fn leak(){ unsafe { std::ptr::null::<u8>().read() } } to crates/safe/src/backend.rs, run cargo check --offline --features ffi-stub, confirm it FAILS with usage of an 'unsafe' block (E0453/deny), then revert. This proves the guardrail is live.
- Step 3: Confirm the FFI round-trip end-to-end under
ffi-stub
Run: cargo test --offline --features ffi-stub -- --nocapture and confirm the contract suite ran against both MockBackend and TgcallsBackend, and the leak test passed.
- Step 4: Tag the milestone in a final commit if any fixups were needed
git add -Agit commit -m "chore: rstgcalls FFI scaffold green across feature matrix (offline)
Co-Authored-By: Claude Fable 5 <[email protected]>"(If Steps 1–3 required no changes, skip this commit — do not create an empty one.)
Self-Review
Section titled “Self-Review”1. Spec coverage:
- ADR-018 (tgcalls bindings, C++ isolated) → whole design; isolation via sys crate + gated backend. ✓
- ADR-014 (contract tests per trait with >1 impl) → Task 5 defines
run_backend_contract, Tasks 5+6 run it against both impls. ✓ - ADR-015 (typestate/enum over booleans) → Task 7
CallManager<Idle|Active>+ Task 4ConnectionState/NetworkTypeenums; Task 8 compile-fail proofs. ✓ - TECHSPEC §4/§10-8 (
calls-tgcallsnylon-ring module, isolated) → documented as next step in TODO.md (Task 9); scaffold is the binding-crate foundation the module builds on. ✓ (Full nylon-ring cdylib packaging is explicitly out of scope for this “compilable foundation” milestone and recorded in TODO.) - QA cross-cutting (multi-state as enum; contract suite before 2nd impl merges) → Tasks 4, 5, 6. ✓
- Workspace gates (edition 2024, MSRV 1.100, clippy all+pedantic deny, conventional commits, never push) → Global Constraints + every commit step. ✓
2. Step scan: Each step is one action with a checkable result (write file / run command / expect output). No “TBD” or “handle edge cases” placeholders. Test steps carry exact assertions with the spec’s values; code steps carry exact signatures/names. The size_of assertions in Task 2 Step 2 are explicitly flagged as “correct to the actual generated value” — that is a real decision the implementer makes against bindgen output, not a gap.
3. Type consistency: CallBackend method set is identical in Tasks 5 (trait + Mock) and 6 (Tgcalls impl); run_backend_contract<B: CallBackend> is defined in Task 5 and reused in Task 6. AudioFrame<'a>, InitConfig, ConnectionState, NetworkType, FinalState, Error, Result names are fixed in Task 4 and used verbatim in Tasks 5–8. TgcallsBackend::create(&InitConfig) (Task 6) matches CallManager::new_tgcalls(cfg) usage (Task 7). Idle/Active/CallManager (Task 7) match the trybuild files (Task 8). Return-code constants (RSTGCALLS_*) are defined once (Task 2 header) and mapped once (Error::from_code, Task 4).
4. Review Focus: all 7 lines have an owning task’s test — #1→Task 8, #2→Tasks 4+6, #3→Task 4, #4→Tasks 1/5/7/10, #5→Tasks 5+6, #6→Task 10 Step 2, #7→Task 6 leak test. ✓
5. Proportion: the plan is longer than a trivial feature because it fixes exact ABI names, return-code tables, lint policy, and two non-obvious build gotchas (bindgen --target assert; clang++ include discovery) that an implementer cannot guess and that cost me several spike iterations to find. Code blocks are signatures + the two build files whose exact content matters (build.rs discovery loop, bridge header ABI); trait/impl bodies are signatures with SAFETY-comment skeletons, not full transcripts. This is proportionate to a cross-language FFI scaffold where the build mechanics are the hard part.
Validation status: every mechanical assumption in this plan was proven in throwaway spikes before writing — bindgen offline against real tgcalls headers (15633 lines, tgcalls_AudioFrame = 56 bytes matching C++), portable clang++ include discovery, cc compiling+linking a C++ extern "C" stub, the full FFI round-trip test through the linked stub (3 passed), deny+scoped-allow unsafe pattern with stray-unsafe rejection, and trybuild compile-fail with TRYBUILD=overwrite. All dependency crates (bindgen 0.72.1, cc 1.2.x, thiserror 2.0.21, trybuild 1.0.122) are present in the local cargo registry cache.