This commit is contained in:
coral 2026-05-25 10:15:17 -07:00
parent 67bdb7f033
commit cfbaf93831
16 changed files with 27 additions and 176 deletions

View file

@ -11,6 +11,7 @@
mod pjsua { mod pjsua {
#![allow(unnecessary_transmutes)] #![allow(unnecessary_transmutes)]
#![allow(unsafe_op_in_unsafe_fn)]
include!(concat!(env!("OUT_DIR"), "/bindings.rs")); include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
} }

View file

@ -1,27 +1,17 @@
//! Audio parsing utilities //! Audio parsing utilities for WAV and FLAC, used by the sound module.
//!
//! This module provides audio file parsing for WAV and FLAC formats.
//! Used by the `sound` module for loading audio files from disk.
pub mod flac; pub mod flac;
pub mod simd; pub mod simd;
pub mod wav; pub mod wav;
/// Errors that can occur while parsing a WAV or FLAC file.
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum AudioParseError { pub enum AudioParseError {
/// File header, chunk structure, or sample data was malformed for the
/// format implied by the magic bytes. Carries a short human-readable
/// reason (chunk name, byte offset, etc.).
#[error("malformed audio data: {0}")] #[error("malformed audio data: {0}")]
Malformed(String), Malformed(String),
/// Audio format is recognised but not supported by this parser (e.g.
/// non-PCM WAV, or a FLAC stream with an exotic bit depth).
#[error("unsupported audio: {0}")] #[error("unsupported audio: {0}")]
Unsupported(String), Unsupported(String),
/// Underlying claxon FLAC decoder error.
#[error("FLAC decode error: {0}")] #[error("FLAC decode error: {0}")]
Flac(#[from] claxon::Error), Flac(#[from] claxon::Error),
} }

View file

@ -120,8 +120,7 @@ impl EnvConfig {
Ok(()) Ok(())
} }
/// Access the global `EnvConfig` (panics if `init()` was not called — a /// Access the global `EnvConfig`. Panics if `init()` was not called.
/// programmer error, not a recoverable condition).
pub fn global() -> &'static EnvConfig { pub fn global() -> &'static EnvConfig {
ENV_CONFIG.get().unwrap_or_else(|| { ENV_CONFIG.get().unwrap_or_else(|| {
panic!("EnvConfig not initialized — call EnvConfig::init() first") panic!("EnvConfig not initialized — call EnvConfig::init() first")
@ -335,8 +334,7 @@ impl AppConfig {
}) })
} }
/// Get the global application config (panics if not initialized — a /// Get the global application config. Panics if `AppConfig::load(...)` was not called.
/// programmer error: caller must `AppConfig::load(...)` first).
pub fn global() -> &'static AppConfig { pub fn global() -> &'static AppConfig {
APP_CONFIG.get().unwrap_or_else(|| { APP_CONFIG.get().unwrap_or_else(|| {
panic!("AppConfig not initialized — call AppConfig::load() first") panic!("AppConfig not initialized — call AppConfig::load() first")

View file

@ -1,8 +1,4 @@
//! Top-level error type for the `sipcord-bridge` crate. //! Top-level error type for the `sipcord-bridge` crate.
//!
//! [`BridgeError`] aggregates every subsystem error so callers (main binaries,
//! adapter crates) can use a single `Result` type and rely on `?` propagation
//! via `#[from]` conversions.
use crate::audio::AudioParseError; use crate::audio::AudioParseError;
use crate::config::ConfigError; use crate::config::ConfigError;
@ -12,7 +8,6 @@ use crate::services::sound::SoundError;
use crate::transport::discord::DiscordError; use crate::transport::discord::DiscordError;
use crate::transport::sip::error::SipError; use crate::transport::sip::error::SipError;
/// Umbrella error for the entire bridge crate.
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum BridgeError { pub enum BridgeError {
#[error(transparent)] #[error(transparent)]
@ -36,8 +31,6 @@ pub enum BridgeError {
#[error(transparent)] #[error(transparent)]
AudioParse(#[from] AudioParseError), AudioParse(#[from] AudioParseError),
/// Generic I/O at the top level (file ops in main, etc.) that aren't tied
/// to a particular subsystem.
#[error("I/O ({context}): {source}")] #[error("I/O ({context}): {source}")]
Io { Io {
context: String, context: String,

View file

@ -17,24 +17,14 @@ pub mod session;
pub mod spandsp; pub mod spandsp;
pub mod tiff_decoder; pub mod tiff_decoder;
/// Errors from the fax subsystem.
///
/// Variants are intentionally coarse — fax flows are end-to-end best-effort
/// (a missed page or codec mismatch logs and aborts the session) and the
/// detailed `String` payloads carry enough context for triage. Where a more
/// structured upstream type already exists (`serenity::Error`, `io::Error`),
/// we wrap it via `#[from]` / `#[source]`.
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum FaxError { pub enum FaxError {
/// Discord REST / gateway error while posting or editing fax status.
#[error("Discord post failed: {0}")] #[error("Discord post failed: {0}")]
Discord(#[from] serenity::Error), Discord(#[from] serenity::Error),
/// Token parsing failure when constructing the fax-posting client.
#[error("invalid Discord bot token: {0}")] #[error("invalid Discord bot token: {0}")]
InvalidToken(String), InvalidToken(String),
/// I/O error reading/writing TIFFs or working with paths.
#[error("fax I/O ({context}): {source}")] #[error("fax I/O ({context}): {source}")]
Io { Io {
context: String, context: String,
@ -42,24 +32,18 @@ pub enum FaxError {
source: std::io::Error, source: std::io::Error,
}, },
/// Path couldn't be converted to UTF-8 for the SpanDSP / TIFF API.
#[error("path is not valid UTF-8: {0}")] #[error("path is not valid UTF-8: {0}")]
NonUtf8Path(String), NonUtf8Path(String),
/// SpanDSP FFI returned an error from one of its setters or state-init
/// functions.
#[error("SpanDSP ({operation}): {detail}")] #[error("SpanDSP ({operation}): {detail}")]
SpanDsp { SpanDsp {
operation: &'static str, operation: &'static str,
detail: String, detail: String,
}, },
/// TIFF parsing / decoding failure. Carries a human-readable reason.
#[error("TIFF decode: {0}")] #[error("TIFF decode: {0}")]
Tiff(String), Tiff(String),
/// A received fax produced no pages (decoder bail-out, session closed
/// before any page was completed, etc.).
#[error("no pages in received fax")] #[error("no pages in received fax")]
NoPages, NoPages,
} }

View file

@ -76,8 +76,6 @@ fn configure_t30(
tiff_path: &str, tiff_path: &str,
callback_state: &mut FaxCallbackState, callback_state: &mut FaxCallbackState,
) -> Result<(), FaxError> { ) -> Result<(), FaxError> {
/// Local macro: tag a SpanDSP error with the operation name. Avoids
/// boilerplate at every setter call site.
macro_rules! spandsp_err { macro_rules! spandsp_err {
($op:expr) => { ($op:expr) => {
|e| FaxError::SpanDsp { |e| FaxError::SpanDsp {

View file

@ -12,8 +12,6 @@ use std::path::Path;
use std::sync::OnceLock; use std::sync::OnceLock;
use tracing::debug; use tracing::debug;
/// Convenience: most failures in this module are malformed-TIFF conditions
/// that map cleanly onto `FaxError::Tiff(String)`.
macro_rules! tiff_bail { macro_rules! tiff_bail {
($($arg:tt)*) => { ($($arg:tt)*) => {
return Err(FaxError::Tiff(format!($($arg)*))) return Err(FaxError::Tiff(format!($($arg)*)))

View file

@ -8,10 +8,6 @@
//! and authentication. A built-in `StaticBackend` (TOML dialplan) is included. //! and authentication. A built-in `StaticBackend` (TOML dialplan) is included.
#![feature(portable_simd)] #![feature(portable_simd)]
// Lock down the no-unwrap policy. Test modules opt out via the
// `#[cfg_attr(test, allow(...))]` shim at their boundary (or `#[allow]` at
// the test fn level for isolated cases). See feedback memories
// `feedback-no-unwrap-in-production` and `feedback-fix-clippy-at-source`.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))] #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
pub mod audio; pub mod audio;

View file

@ -20,9 +20,6 @@ use sipcord_bridge::transport::sip::SipTransport;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), BridgeError> { async fn main() -> Result<(), BridgeError> {
// Pre-init failures here are programmer errors (missing rustls feature
// flag, double-init of the global crypto provider) — panicking is the
// right behaviour and there's no caller that could recover.
if rustls::crypto::ring::default_provider() if rustls::crypto::ring::default_provider()
.install_default() .install_default()
.is_err() .is_err()

View file

@ -18,10 +18,8 @@ use tracing::{debug, info, warn};
pub use streaming::{StreamingError, StreamingPlayer}; pub use streaming::{StreamingError, StreamingPlayer};
/// Errors raised by sound loading and parsing.
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum SoundError { pub enum SoundError {
/// Failed to read the sound file from disk.
#[error("failed to read sound file {path:?}: {source}")] #[error("failed to read sound file {path:?}: {source}")]
Read { Read {
path: PathBuf, path: PathBuf,
@ -29,7 +27,6 @@ pub enum SoundError {
source: std::io::Error, source: std::io::Error,
}, },
/// Parse failure from the WAV/FLAC decoder.
#[error("failed to parse audio for {name}: {source}")] #[error("failed to parse audio for {name}: {source}")]
Parse { Parse {
name: String, name: String,
@ -37,7 +34,6 @@ pub enum SoundError {
source: AudioParseError, source: AudioParseError,
}, },
/// Sound's sample rate didn't match the bridge's configured rate.
#[error("sound {name} has wrong sample rate: {got} Hz (expected {expected} Hz)")] #[error("sound {name} has wrong sample rate: {got} Hz (expected {expected} Hz)")]
WrongSampleRate { WrongSampleRate {
name: String, name: String,
@ -45,11 +41,9 @@ pub enum SoundError {
expected: u32, expected: u32,
}, },
/// File header doesn't match any supported format (WAV / FLAC).
#[error("unknown audio format for {name}: header bytes {header:02x?}")] #[error("unknown audio format for {name}: header bytes {header:02x?}")]
UnknownFormat { name: String, header: Vec<u8> }, UnknownFormat { name: String, header: Vec<u8> },
/// Streaming player setup failure.
#[error(transparent)] #[error(transparent)]
Streaming(#[from] StreamingError), Streaming(#[from] StreamingError),
} }

View file

@ -16,7 +16,6 @@ use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions; use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint; use symphonia::core::probe::Hint;
/// Errors raised by the streaming player.
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum StreamingError { pub enum StreamingError {
#[error("failed to open streaming file {path:?}: {source}")] #[error("failed to open streaming file {path:?}: {source}")]

View file

@ -179,10 +179,7 @@ fn create_resampler() -> Async<f64> {
window: WindowFunction::BlackmanHarris2, window: WindowFunction::BlackmanHarris2,
}; };
// 16kHz to 48kHz = ratio of 3.0, mono input, 320 samples per chunk (20ms at 16kHz). // 16kHz → 48kHz, mono, 320 samples per chunk (20ms at 16kHz).
// Params are static and known-good; if rubato rejects them it's a programmer
// error (e.g. ratio constants changed inconsistently) and the program can't
// run anyway — panic explicitly with the rubato error for diagnostics.
Async::new_sinc( Async::new_sinc(
48000.0 / 16000.0, 48000.0 / 16000.0,
1.1, 1.1,
@ -675,9 +672,7 @@ impl DiscordVoiceConnection {
// This allows the pjsua audio thread to bypass tokio entirely // This allows the pjsua audio thread to bypass tokio entirely
let audio_sender = RegisteredAudioSender::new(channel_id, producer); let audio_sender = RegisteredAudioSender::new(channel_id, producer);
// Create shared timestamp for health tracking. The system // Create shared timestamp for health tracking
// clock would have to be set before 1970 to produce Err
// here; default to 0 in that case rather than panic.
let now_ms = SystemTime::now() let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()

View file

@ -181,10 +181,8 @@ impl Read for StreamingAudioSource {
return Ok(buf.len()); return Ok(buf.len());
} }
// Read samples from ring buffer directly into output buffer.
// `samples_to_read <= samples_available` by construction, so this // `samples_to_read <= samples_available` by construction, so this
// should never error; if rtrb's state ever desyncs, log + silence // should never error; on rtrb desync, silence rather than panic.
// rather than panic on the audio thread.
let chunk = match consumer.read_chunk(samples_to_read) { let chunk = match consumer.read_chunk(samples_to_read) {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {

View file

@ -1,19 +1,4 @@
//! Typed error types for the SIP transport layer. //! Typed error types for the SIP transport layer.
//!
//! Three sibling enums cover the three phases of the SIP path:
//!
//! - [`SipInitError`] — startup: pjsua create/init/start, transports, codecs,
//! account registration. One-shot failures that take down the process.
//! - [`SipResponseError`] — building or sending an individual SIP response
//! (401/302/200 etc.) from inside an FFI callback. Per-call, recoverable
//! in the sense that we log and continue.
//! - [`SipAudioError`] — runtime audio plumbing: hooking players into a
//! call's conference port. Surfaces when media isn't ready yet, when a
//! port name has an interior NUL, or when pjsua refuses a connect.
//!
//! [`SipError`] is the umbrella for callers that want to handle any of them
//! uniformly. Conversion is via `#[from]`, so `?` propagation works through
//! the hierarchy without explicit `map_err`.
use std::ffi::NulError; use std::ffi::NulError;
@ -36,8 +21,6 @@ pub enum SipError {
/// Errors raised by outbound-call setup (`make_outbound_call`). /// Errors raised by outbound-call setup (`make_outbound_call`).
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum SipCallError { pub enum SipCallError {
/// A URI / display-name string couldn't be converted to CString because
/// of an interior NUL byte.
#[error("invalid {field} for outbound call: {source}")] #[error("invalid {field} for outbound call: {source}")]
InvalidString { InvalidString {
field: &'static str, field: &'static str,
@ -45,7 +28,6 @@ pub enum SipCallError {
source: NulError, source: NulError,
}, },
/// `pjsua_call_make_call` returned non-success.
#[error("pjsua_call_make_call failed (status {0})")] #[error("pjsua_call_make_call failed (status {0})")]
MakeCall(i32), MakeCall(i32),
} }
@ -54,25 +36,21 @@ pub enum SipCallError {
/// `process_pjsua_events`, and friends. /// `process_pjsua_events`, and friends.
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum SipInitError { pub enum SipInitError {
/// A pjsua API returned a non-success status code. `operation` names the /// A pjsua API returned a non-success status code; `operation` names the
/// specific call (`"pjsua_create"`, `"pjsua_init"`, `"pjsua_start"`, /// specific call (e.g. `"pjsua_init"`, `"pjsua_acc_add"`).
/// `"pjsua_acc_add"`, `"pjsua_set_null_snd_dev"`, `"pjsua_handle_events"`,
/// etc.).
#[error("pjsua {operation} failed (status {status})")] #[error("pjsua {operation} failed (status {status})")]
Pjsua { Pjsua {
operation: &'static str, operation: &'static str,
status: i32, status: i32,
}, },
/// `pjsua_transport_create` failed for `kind` ("UDP", "TCP", or "TLS"). /// `pjsua_transport_create` failed; `kind` is `"UDP"`, `"TCP"`, or `"TLS"`.
#[error("transport create ({kind}) failed (status {status})")] #[error("transport create ({kind}) failed (status {status})")]
TransportCreate { TransportCreate {
kind: &'static str, kind: &'static str,
status: i32, status: i32,
}, },
/// A configuration string (host name, URI, etc.) couldn't be converted
/// to a `CString` because of an interior NUL byte.
#[error("invalid {field} string for FFI: {source}")] #[error("invalid {field} string for FFI: {source}")]
InvalidString { InvalidString {
field: &'static str, field: &'static str,
@ -80,83 +58,59 @@ pub enum SipInitError {
source: NulError, source: NulError,
}, },
/// A `Path` to be passed into pjsua wasn't valid UTF-8.
#[error("{field} path is not valid UTF-8")] #[error("{field} path is not valid UTF-8")]
NonUtf8Path { field: &'static str }, NonUtf8Path { field: &'static str },
} }
/// Errors raised by audio-port plumbing (`play_audio_to_call_direct`, /// Errors raised by audio-port plumbing (players, conf port hookup).
/// `start_loop`, `start_test_tone_to_call`, etc.) and the helpers in
/// `frame_utils`.
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum SipAudioError { pub enum SipAudioError {
/// The call doesn't have a conference port yet — media negotiation is /// Media negotiation hasn't produced a conference port yet (or the call
/// still in progress, or the call has just ended. Caller can retry or /// has just ended). Caller may retry or drop the audio.
/// drop the audio.
#[error("no conference port for call {call_id} (media not ready yet)")] #[error("no conference port for call {call_id} (media not ready yet)")]
NoConfPort { call_id: super::ffi::types::CallId }, NoConfPort { call_id: super::ffi::types::CallId },
/// A port name (used to identify the player in pjsua's mixer) contains
/// an interior NUL.
#[error("invalid port name: {0}")] #[error("invalid port name: {0}")]
InvalidPortName(#[from] NulError), InvalidPortName(#[from] NulError),
/// `pjsua_conf_add_port`, `pjsua_conf_connect`, etc. returned non-success.
#[error("pjsua conf {operation} failed (status {status})")] #[error("pjsua conf {operation} failed (status {status})")]
Pjsua { Pjsua {
operation: &'static str, operation: &'static str,
status: i32, status: i32,
}, },
/// Frame size / port count mismatch between the audio source and the
/// pjsua port.
#[error("frame mismatch: {0}")] #[error("frame mismatch: {0}")]
FrameMismatch(String), FrameMismatch(String),
/// Failure setting up a streaming player (file read, decoder, etc.).
#[error(transparent)] #[error(transparent)]
Streaming(#[from] crate::services::sound::StreamingError), Streaming(#[from] crate::services::sound::StreamingError),
} }
/// Errors that can occur while building or sending a SIP response. /// Errors raised while building or sending a SIP response from inside an
/// /// FFI callback. The typical caller logs and continues.
/// Surfaces failures from the pjsua/pjsip FFI surface — CString conversion,
/// pool allocation, header creation, and the final stateless / transactional
/// send. Variants stay coarse-grained because the typical caller is a pjsip
/// callback that can only log and continue.
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum SipResponseError { pub enum SipResponseError {
/// A runtime string contained an interior NUL byte and could not be
/// converted to `CString`.
#[error("CString conversion failed (interior NUL)")] #[error("CString conversion failed (interior NUL)")]
CStringNul(#[from] NulError), CStringNul(#[from] NulError),
/// `pjsua_pool_create` returned null — out of memory or pjsua not
/// initialised.
#[error("pjsua pool allocation failed")] #[error("pjsua pool allocation failed")]
PoolAlloc, PoolAlloc,
/// `pjsip_generic_string_hdr_create` returned null.
#[error("pjsip header creation failed")] #[error("pjsip header creation failed")]
HeaderCreate, HeaderCreate,
/// `pjsua_get_pjsip_endpt` returned null — pjsua not initialised.
#[error("pjsip endpoint is null (pjsua not initialised)")] #[error("pjsip endpoint is null (pjsua not initialised)")]
EndpointNull, EndpointNull,
/// `pjsip_endpt_respond_stateless` returned a non-success pj status code.
#[error("pjsip stateless send failed (status {0})")] #[error("pjsip stateless send failed (status {0})")]
StatelessSend(i32), StatelessSend(i32),
/// `pjsip_tsx_create_uas2` returned a non-success pj status code.
#[error("pjsip UAS transaction creation failed (status {0})")] #[error("pjsip UAS transaction creation failed (status {0})")]
TsxCreate(i32), TsxCreate(i32),
/// `pjsip_endpt_create_response` returned a non-success pj status code.
#[error("pjsip response build failed (status {0})")] #[error("pjsip response build failed (status {0})")]
ResponseBuild(i32), ResponseBuild(i32),
/// `pjsua_call_answer` returned a non-success pj status code.
#[error("pjsua_call_answer failed (status {0})")] #[error("pjsua_call_answer failed (status {0})")]
CallAnswer(i32), CallAnswer(i32),
} }

View file

@ -1,22 +1,5 @@
//! Safe(r) helpers around the pjsua/pjsip C-string and header-building idioms //! Helpers around the pjsua/pjsip C-string and header-building idioms that
//! that recur across the SIP transport layer. //! recur across the SIP transport layer.
//!
//! Before this module existed, every callback that built a SIP header
//! re-implemented the same pattern:
//!
//! ```ignore
//! let name = CString::new("Contact").unwrap();
//! let value = CString::new(runtime_str).unwrap();
//! let name_pj = pj_str(name.as_ptr() as *mut c_char);
//! let value_pj = pj_str(value.as_ptr() as *mut c_char);
//! let hdr = pjsip_generic_string_hdr_create(pool, &name_pj, &value_pj);
//! // ...
//! ```
//!
//! That sprouted two unwraps per header (so any header value containing a NUL
//! byte from upstream data would panic), repeated lifetime traps, and zero
//! shared failure handling. The helpers in this module turn those calls into
//! a single fallible call returning [`SipResponseError`].
use crate::transport::sip::error::SipResponseError; use crate::transport::sip::error::SipResponseError;
use pjsua::*; use pjsua::*;
@ -26,22 +9,13 @@ use std::ptr;
/// Convert a [`CStr`] (typically a `c"..."` literal) into a [`pj_str_t`]. /// Convert a [`CStr`] (typically a `c"..."` literal) into a [`pj_str_t`].
/// ///
/// Zero-cost — `pj_str` just wraps the pointer and length. The caller must /// Caller must keep `s` alive for the resulting `pj_str_t`'s usage window.
/// keep the `CStr` alive for the `pj_str_t`'s usage window. For `&'static`
/// literals (the common case) that's trivially satisfied.
#[inline] #[inline]
pub unsafe fn pj_str_from_cstr(s: &CStr) -> pj_str_t { pub unsafe fn pj_str_from_cstr(s: &CStr) -> pj_str_t {
unsafe { pj_str(s.as_ptr() as *mut c_char) } unsafe { pj_str(s.as_ptr() as *mut c_char) }
} }
// A `pj_str_owned(&str) -> Result<(CString, pj_str_t), _>` helper was considered /// Initialise a `pjsip_hdr` as an empty list head.
// but turned out unused: every runtime-string call site in this codebase ends
// up either inside `make_string_hdr` (which does the conversion internally) or
// in a function that already chains `CString::new(...).context("...")?` for a
// site-specific error message. Add it back if a true caller appears.
/// Initialise a `pjsip_hdr` as an empty list head (equivalent to the
/// `pj_list_init` C macro).
#[inline] #[inline]
pub unsafe fn pj_list_init_hdr(hdr: *mut pjsip_hdr) { pub unsafe fn pj_list_init_hdr(hdr: *mut pjsip_hdr) {
unsafe { unsafe {
@ -52,11 +26,8 @@ pub unsafe fn pj_list_init_hdr(hdr: *mut pjsip_hdr) {
/// Create a generic string header in `pool`. /// Create a generic string header in `pool`.
/// ///
/// `name` is a static `CStr` (use `c"Contact"` etc); `value` is a runtime /// pjsip duplicates `name` and `value` into the pool, so the temporary
/// string that gets converted to a `CString` and duplicated into the pool /// `CString` for `value` is dropped before this returns.
/// by pjsip. The temporary `CString` is dropped before this returns;
/// `pjsip_generic_string_hdr_create` uses `pj_strdup` internally to copy
/// the bytes.
pub unsafe fn make_string_hdr( pub unsafe fn make_string_hdr(
pool: *mut pj_pool_t, pool: *mut pj_pool_t,
name: &CStr, name: &CStr,
@ -93,17 +64,11 @@ pub unsafe fn append_tdata_hdr(
/// Answer a pjsua call with N custom headers attached to the response. /// Answer a pjsua call with N custom headers attached to the response.
/// ///
/// Wraps the recurring `pjsua_msg_data_init` + pool + header build + /// The pool is intentionally NOT released — pjsua continues referencing the
/// `pjsua_call_answer` dance used in 401 / 302 / 4xx code paths. /// header data after `pjsua_call_answer` returns, so releasing here triggers
/// use-after-free. Each call leaks ~512 bytes, reclaimed when pjsua shuts down.
/// ///
/// **The pool is intentionally NOT released.** pjsua may continue to reference /// The caller is responsible for `pjsua_call_hangup` on Err.
/// the header data after `pjsua_call_answer` returns; releasing the pool here
/// triggers use-after-free. Each call leaks ~512 bytes that's reclaimed when
/// pjsua shuts down. (Tracking pools per-call and releasing them on call-end
/// would be a cleaner fix; not in scope here.)
///
/// On error, the caller typically follows up with `pjsua_call_hangup` — this
/// helper does not hang up on its own so the caller can choose the strategy.
pub unsafe fn answer_call_with_headers( pub unsafe fn answer_call_with_headers(
call_id: i32, call_id: i32,
status_code: u32, status_code: u32,
@ -120,7 +85,6 @@ pub unsafe fn answer_call_with_headers(
if pool.is_null() { if pool.is_null() {
return Err(SipResponseError::PoolAlloc); return Err(SipResponseError::PoolAlloc);
} }
// Intentionally leaked — see doc comment above.
for (name, value) in headers { for (name, value) in headers {
let hdr = make_string_hdr(pool, name, value)?; let hdr = make_string_hdr(pool, name, value)?;
@ -141,12 +105,7 @@ pub unsafe fn answer_call_with_headers(
/// Send a stateless SIP response with N string headers. /// Send a stateless SIP response with N string headers.
/// ///
/// Wraps the recurring `pjsua_pool_create` → list-head alloc → header /// `reason` is the SIP reason phrase (e.g. `Some(c"Unauthorized")`); pass
/// build → `pjsip_endpt_respond_stateless` → `pj_pool_release` dance. Each
/// header in `headers` is a `(name, value)` pair where `name` is typically
/// a `c"..."` literal and `value` is any runtime string.
///
/// `reason` is the SIP reason phrase (e.g. `Some(c"Unauthorized")`) or
/// `None` to let pjsip pick the default for `status_code`. /// `None` to let pjsip pick the default for `status_code`.
pub unsafe fn respond_stateless_with_headers( pub unsafe fn respond_stateless_with_headers(
rdata: *mut pjsip_rx_data, rdata: *mut pjsip_rx_data,
@ -165,8 +124,7 @@ pub unsafe fn respond_stateless_with_headers(
return Err(SipResponseError::PoolAlloc); return Err(SipResponseError::PoolAlloc);
} }
// Belt-and-braces: ensure the pool is released even if a step // Closure so the pool gets released even if a step `?`-returns.
// between here and the send returns Err via `?`.
let result = let result =
(|| -> Result<i32, SipResponseError> { (|| -> Result<i32, SipResponseError> {
let hdr_list = let hdr_list =

View file

@ -207,8 +207,6 @@ impl SipTransport {
} }
}); });
// JoinError -> log only; pjsua loop errors are already logged inside the
// spawned task.
if let Err(e) = pjsua_handle.await { if let Err(e) = pjsua_handle.await {
tracing::error!("pjsua event loop join error: {}", e); tracing::error!("pjsua event loop join error: {}", e);
} }