Skip to main content

ringo_agent/
worker.rs

1//! The agent worker: one process, one registered UA (with `catchall`), driven
2//! over stdio with the `proto` framed protocol. Because each worker is its own
3//! process it binds its own SIP socket, so the provider routes incoming calls to
4//! *this* registration by contact address — which in-process multi-UA cannot do
5//! (one shared socket, and the request-URI user need not identify the UA, so
6//! there's no way to demux).
7//!
8//! Channels: stdin carries the [`crate::AgentConfig`] handshake (first frame) then
9//! command/query control frames + inbound audio frames; stdout carries
10//! event/reply/header control frames + received-audio frames (a single writer
11//! thread serialises them, so frames never interleave); stderr carries logs /
12//! SIP traces.
13
14use crate::audio;
15use crate::proto::{
16    self, Command, FromWorker, Headers, Query, QueryEnvelope, Ready, Reply, ReplyResult,
17    RxAudioStarted, ToWorker, WireEvent, WireMediaStats,
18};
19use anyhow::{Context, Result, bail};
20use ringo_core::backend::{Backend, BaresipBackend};
21use ringo_core::phone::Phone;
22use std::sync::Arc;
23use std::sync::atomic::{AtomicBool, Ordering};
24use std::sync::mpsc::{RecvTimeoutError, SyncSender};
25use std::thread::JoinHandle;
26use std::time::Duration;
27
28/// An item queued for the single stdout writer: a JSON control message or a raw
29/// audio frame (mono s16 PCM, the agent's received audio).
30enum Out {
31    Control(FromWorker),
32    Audio(Vec<i16>),
33}
34
35/// How often to forward newly-seen inbound INVITE headers to the parent.
36const HEADER_POLL_INTERVAL: Duration = Duration::from_millis(150);
37
38/// Bound on the worker's outbound (stdout) message queue. Caps memory if the
39/// parent is briefly slow to drain; a full queue applies backpressure (blocking
40/// send) rather than dropping events, which the parent's state machine needs.
41const WRITER_CHANNEL_BOUND: usize = 1024;
42
43/// Run the worker: read the config handshake, spawn the backend, then pump
44/// stdin (commands/queries) and stdout (events/replies/headers) until stdin
45/// closes (EOF) or a `Shutdown` command arrives.
46pub fn run() -> Result<()> {
47    let stdin = std::io::stdin();
48    let mut stdin = stdin.lock();
49
50    // Handshake: the first frame is a control frame carrying a versioned envelope
51    // (protocol version + agent config).
52    let handshake: proto::Handshake = match proto::read_frame(&mut stdin) {
53        Ok(Some((proto::FRAME_CONTROL, payload))) => {
54            serde_json::from_slice(&payload).context("parse config handshake")?
55        }
56        Ok(Some(_)) => bail!("worker: first frame was not the config handshake"),
57        Ok(None) => bail!("worker stdin closed before config handshake"),
58        Err(e) => return Err(e).context("read config handshake"),
59    };
60    if handshake.proto_version != proto::PROTO_VERSION {
61        bail!(
62            "worker: protocol version mismatch (parent {}, worker {}); rebuild so \
63             both sides share one binary",
64            handshake.proto_version,
65            proto::PROTO_VERSION
66        );
67    }
68    let config = handshake.config;
69
70    // Logs / SIP traces go to the destination the parent chose (inherited via
71    // env); the framed stream stays reserved for the protocol. Off by default.
72    init_logging(&config.name);
73
74    let account = config.account;
75    let options = config.options;
76    let username = account.username.clone();
77
78    // The FFI backend ignores the tokio handle (it runs its own RE thread), but
79    // the trait wants one — a minimal runtime kept alive for the session suffices.
80    let rt = tokio::runtime::Builder::new_current_thread()
81        .build()
82        .context("build worker tokio runtime")?;
83    let session = BaresipBackend
84        .spawn_session(rt.handle(), &config.name, &account, &options)
85        .with_context(|| format!("spawn backend for `{}`", config.name))?;
86
87    let events = session.events;
88    let phone = session.phone;
89    let header_poll = session.header_poll;
90    let handle = session.handle;
91
92    // Single stdout writer: every outbound frame (control or audio) funnels
93    // through here so frames never interleave. Bounded so a briefly-slow parent
94    // can't grow worker memory unboundedly; a full queue blocks the sender
95    // (backpressure) — control is never dropped (the parent's state machine
96    // depends on it). Ends when all `tx` clones drop.
97    let (tx, rx) = std::sync::mpsc::sync_channel::<Out>(WRITER_CHANNEL_BOUND);
98    let writer = std::thread::spawn(move || {
99        let stdout = std::io::stdout();
100        while let Ok(msg) = rx.recv() {
101            let mut out = stdout.lock();
102            let res = match msg {
103                Out::Control(c) => match serde_json::to_vec(&c) {
104                    Ok(bytes) => proto::write_frame(&mut out, proto::FRAME_CONTROL, &bytes),
105                    Err(e) => {
106                        ringo_core::rlog!(Warn, "serialize outbound: {e}");
107                        continue;
108                    }
109                },
110                Out::Audio(samples) => {
111                    proto::write_frame(&mut out, proto::FRAME_AUDIO, &proto::pcm_to_bytes(&samples))
112                }
113            };
114            if res.is_err() {
115                break;
116            }
117        }
118    });
119
120    // Readiness handshake: the backend is up — tell the parent BEFORE anything
121    // else, so `ProcessClient::spawn` returns success only once we're live.
122    let _ = tx.send(Out::Control(FromWorker::Ready(Ready { ready: true })));
123
124    // Event bridge: backend events → stdout. Ends when the backend drops its
125    // event sender (the session handle is dropped on teardown).
126    let ev_tx = tx.clone();
127    let event_bridge = std::thread::spawn(move || {
128        while let Ok(event) = events.recv() {
129            if ev_tx
130                .send(Out::Control(FromWorker::Event(WireEvent::from(&event))))
131                .is_err()
132            {
133                break;
134            }
135        }
136    });
137
138    // Header poll: forward newly-seen inbound INVITE headers. Stops on `stop`.
139    let stop = Arc::new(AtomicBool::new(false));
140    let header_thread = header_poll.map(|poll| {
141        let hdr_tx = tx.clone();
142        let stop = Arc::clone(&stop);
143        std::thread::spawn(move || {
144            while !stop.load(Ordering::Relaxed) {
145                std::thread::sleep(HEADER_POLL_INTERVAL);
146                // poll() drains the store and returns only newly-seen INVITE
147                // headers, so forward whatever it yields (the parent dedups by
148                // Call-ID). A length/growth guard here would silently drop the
149                // 2nd+ inbound call's headers.
150                if let Some(headers) = poll() {
151                    if !headers.is_empty()
152                        && hdr_tx
153                            .send(Out::Control(FromWorker::Headers(Headers { headers })))
154                            .is_err()
155                    {
156                        break;
157                    }
158                }
159            }
160        })
161    });
162
163    // RX-audio forwarder: started on the first `StartRxAudio`.
164    let rx_stop = Arc::new(AtomicBool::new(false));
165    let mut rx_forwarder: Option<JoinHandle<()>> = None;
166
167    // Command/query/audio loop on the remaining stdin frames.
168    loop {
169        let frame = match proto::read_frame(&mut stdin) {
170            Ok(Some(f)) => f,
171            Ok(None) => break, // clean EOF
172            Err(e) => {
173                ringo_core::rlog!(Warn, "worker stdin read error: {e}");
174                break;
175            }
176        };
177        match frame {
178            // Inbound audio: play streamed PCM into the call (TTS).
179            (proto::FRAME_AUDIO, payload) => {
180                ringo_core::push_audio(&username, &proto::bytes_to_pcm(&payload));
181            }
182            (proto::FRAME_CONTROL, payload) => match serde_json::from_slice::<ToWorker>(&payload) {
183                Ok(ToWorker::Cmd(Command::Shutdown)) => break,
184                Ok(ToWorker::Cmd(Command::StartTxAudio { rate })) => {
185                    ringo_core::start_audio_stream(&username, rate);
186                }
187                Ok(ToWorker::Cmd(Command::StartRxAudio)) => {
188                    if rx_forwarder.is_none() {
189                        rx_forwarder = Some(spawn_rx_forwarder(
190                            &username,
191                            tx.clone(),
192                            Arc::clone(&rx_stop),
193                        ));
194                    }
195                }
196                Ok(ToWorker::Cmd(cmd)) => dispatch(phone.as_ref(), cmd),
197                Ok(ToWorker::Query(QueryEnvelope { id, query })) => {
198                    let result = answer(phone.as_ref(), &username, query);
199                    if tx
200                        .send(Out::Control(FromWorker::Reply(Reply { reply: id, result })))
201                        .is_err()
202                    {
203                        break;
204                    }
205                }
206                // Redact the payload (it may carry credentials/PII) — log only
207                // the parse error and the length.
208                Err(e) => ringo_core::rlog!(
209                    Warn,
210                    "ignoring malformed control frame ({} bytes): {e}",
211                    payload.len()
212                ),
213            },
214            (_kind, _) => {} // unknown frame kind: ignore
215        }
216    }
217
218    // Teardown: stop the poll/forwarder threads, drop the UA (which also drops
219    // the event sender → ends the bridge), stop the RE thread, then join writer.
220    stop.store(true, Ordering::Relaxed);
221    rx_stop.store(true, Ordering::Relaxed);
222    if let Some(h) = header_thread {
223        let _ = h.join();
224    }
225    if let Some(h) = rx_forwarder {
226        let _ = h.join();
227    }
228    drop(phone);
229    drop(handle); // schedules ua_unregister (de-REGISTER, expires=0) on the RE thread
230    // stop_re_thread waits (bounded) for the registrar to ack the de-REGISTER —
231    // is_registered() stays true until the 200 OK — before stopping the RE loop,
232    // so the binding is cleared cleanly instead of force-abandoned. No poll here.
233    ringo_core::shutdown();
234    let _ = event_bridge.join();
235    drop(tx);
236    let _ = writer.join();
237    Ok(())
238}
239
240/// Forward the agent's received audio to the parent: announce the sample rate
241/// (`RxAudioStarted`) before the first frame AND again whenever it changes (a
242/// codec renegotiation can switch e.g. 8k↔16k mid-call), then send each decoded
243/// frame as a raw audio frame. Raw frames carry no rate, so the parent tags them
244/// with the last announced rate — re-announcing keeps that tag correct. Stops on
245/// `stop` (or when the subscription/writer goes away).
246fn spawn_rx_forwarder(
247    username: &str,
248    tx: SyncSender<Out>,
249    stop: Arc<AtomicBool>,
250) -> JoinHandle<()> {
251    let rx = ringo_core::subscribe_received_audio(username);
252    std::thread::spawn(move || {
253        let mut announced_rate: Option<u32> = None;
254        while !stop.load(Ordering::Relaxed) {
255            match rx.recv_timeout(Duration::from_millis(200)) {
256                Ok(frame) => {
257                    if announced_rate != Some(frame.rate) {
258                        announced_rate = Some(frame.rate);
259                        let started = FromWorker::RxAudioStarted(RxAudioStarted {
260                            rx_audio_rate: frame.rate,
261                        });
262                        if tx.send(Out::Control(started)).is_err() {
263                            break;
264                        }
265                    }
266                    if tx.send(Out::Audio(frame.samples)).is_err() {
267                        break;
268                    }
269                }
270                Err(RecvTimeoutError::Timeout) => continue, // re-check stop
271                Err(RecvTimeoutError::Disconnected) => break,
272            }
273        }
274    })
275}
276
277/// Configure this worker's log + SIP-trace sinks from the parent's choice,
278/// inherited via env (`RINGO_AGENT_LOG` / `RINGO_AGENT_SIPTRACE`): unset = off,
279/// `-` = stderr, anything else = a file. File targets are made per-agent
280/// (`run.log` -> `run.<name>.log`) so concurrent workers don't share a file.
281fn init_logging(name: &str) {
282    if let Some(t) = std::env::var_os("RINGO_AGENT_LOG").and_then(|v| v.into_string().ok()) {
283        match t.as_str() {
284            "" => {}
285            "-" => ringo_core::log::init_stderr(),
286            path => ringo_core::log::init_file(per_agent_path(path, name)),
287        }
288    }
289    if let Some(t) = std::env::var_os("RINGO_AGENT_SIPTRACE").and_then(|v| v.into_string().ok()) {
290        match t.as_str() {
291            "" => {}
292            "-" => ringo_core::sip_trace_stderr(),
293            path => ringo_core::sip_trace_file(per_agent_path(path, name)),
294        }
295    }
296}
297
298/// Insert a per-agent tag before the file extension: `run.log` ->
299/// `run.<name>.<pid>.log`. The name is sanitised (no path separators / dots, so
300/// it can't escape the directory) and the PID disambiguates same-named agents
301/// across scenarios so their truncating logs don't clobber each other.
302fn per_agent_path(base: &str, name: &str) -> std::path::PathBuf {
303    let safe: String = name
304        .chars()
305        .map(|c| {
306            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
307                c
308            } else {
309                '_'
310            }
311        })
312        .collect();
313    let tag = format!("{safe}.{}", std::process::id());
314    let p = std::path::Path::new(base);
315    match p.extension().and_then(|e| e.to_str()) {
316        Some(ext) => p.with_extension(format!("{tag}.{ext}")),
317        None => p.with_extension(tag),
318    }
319}
320
321/// Apply one fire-and-forget command to the phone.
322fn dispatch(phone: &dyn Phone, cmd: Command) {
323    match cmd {
324        Command::Register { aor, regint } => phone.register(&aor, regint),
325        Command::Dial { number } => phone.dial(&number),
326        Command::Hangup => phone.hangup(),
327        Command::HangupAll => phone.hangup_all(),
328        Command::Accept => phone.accept(),
329        Command::Hold => phone.hold(),
330        Command::Resume => phone.resume(),
331        Command::Mute => phone.mute(),
332        Command::Dtmf { digit } => phone.send_dtmf(digit),
333        Command::SwitchLine { line } => phone.switch_line(line),
334        Command::Transfer { uri } => phone.transfer(&uri),
335        Command::AttendedTransferStart { uri } => phone.attended_transfer_start(&uri),
336        Command::AttendedTransferExec => phone.attended_transfer_exec(),
337        Command::AttendedTransferAbort => phone.attended_transfer_abort(),
338        Command::AddHeader { key, value } => phone.add_header(&key, &value),
339        Command::RmHeader { key } => phone.rm_header(&key),
340        Command::SetAudioSource { spec } => phone.set_audio_source(&spec),
341        Command::ArmInviteResponse {
342            scode,
343            reason,
344            headers,
345        } => phone.arm_invite_response(scode, &reason, headers),
346        Command::DisarmInviteResponse => phone.disarm_invite_response(),
347        // Audio-stream control + shutdown are handled in the run loop, before
348        // dispatch (they need the username / writer, not just the phone).
349        Command::StartTxAudio { .. } | Command::StartRxAudio | Command::Shutdown => {}
350    }
351}
352
353/// Answer one query. WAV writing runs here (on the worker's own in-process
354/// captured buffer) so only the resulting paths cross the pipe; tone analysis
355/// now happens on the parent over the streamed RX audio.
356fn answer(phone: &dyn Phone, username: &str, query: Query) -> ReplyResult {
357    match query {
358        Query::MediaStats => ReplyResult::MediaStats(phone.media_stats().map(WireMediaStats::from)),
359        Query::ReceivedDtmf => ReplyResult::Dtmf(phone.received_dtmf()),
360        Query::SaveAudio { prefix } => {
361            let mut written = Vec::new();
362            for (tag, captured) in [
363                ("sent", ringo_core::sent_audio(username)),
364                ("recv", ringo_core::received_audio(username)),
365            ] {
366                let Some((samples, srate)) = captured else {
367                    continue;
368                };
369                if samples.is_empty() {
370                    continue;
371                }
372                let path = format!("{prefix}-{tag}.wav");
373                match audio::write_wav(std::path::Path::new(&path), &samples, srate) {
374                    Ok(()) => written.push(path),
375                    Err(e) => ringo_core::rlog!(Warn, "save {path}: {e}"),
376                }
377            }
378            ReplyResult::Saved(written)
379        }
380        Query::CallCount => ReplyResult::CallCount(ringo_core::call_count() as u32),
381    }
382}