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, WireSavedAudio,
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(Command::SetRecording { on })) => {
197                    ringo_core::set_recording(&username, on);
198                }
199                Ok(ToWorker::Cmd(cmd)) => dispatch(phone.as_ref(), cmd),
200                Ok(ToWorker::Query(QueryEnvelope { id, query })) => {
201                    let result = answer(phone.as_ref(), &username, query);
202                    if tx
203                        .send(Out::Control(FromWorker::Reply(Reply { reply: id, result })))
204                        .is_err()
205                    {
206                        break;
207                    }
208                }
209                // Redact the payload (it may carry credentials/PII) — log only
210                // the parse error and the length.
211                Err(e) => ringo_core::rlog!(
212                    Warn,
213                    "ignoring malformed control frame ({} bytes): {e}",
214                    payload.len()
215                ),
216            },
217            (_kind, _) => {} // unknown frame kind: ignore
218        }
219    }
220
221    // Teardown: stop the poll/forwarder threads, drop the UA (which also drops
222    // the event sender → ends the bridge), stop the RE thread, then join writer.
223    stop.store(true, Ordering::Relaxed);
224    rx_stop.store(true, Ordering::Relaxed);
225    if let Some(h) = header_thread {
226        let _ = h.join();
227    }
228    if let Some(h) = rx_forwarder {
229        let _ = h.join();
230    }
231    drop(phone);
232    drop(handle); // schedules ua_unregister (de-REGISTER, expires=0) on the RE thread
233    // stop_re_thread waits (bounded) for the registrar to ack the de-REGISTER —
234    // is_registered() stays true until the 200 OK — before stopping the RE loop,
235    // so the binding is cleared cleanly instead of force-abandoned. No poll here.
236    ringo_core::shutdown();
237    let _ = event_bridge.join();
238    drop(tx);
239    let _ = writer.join();
240    Ok(())
241}
242
243/// Forward the agent's received audio to the parent: announce the sample rate
244/// (`RxAudioStarted`) before the first frame AND again whenever it changes (a
245/// codec renegotiation can switch e.g. 8k↔16k mid-call), then send each decoded
246/// frame as a raw audio frame. Raw frames carry no rate, so the parent tags them
247/// with the last announced rate — re-announcing keeps that tag correct. Stops on
248/// `stop` (or when the subscription/writer goes away).
249fn spawn_rx_forwarder(
250    username: &str,
251    tx: SyncSender<Out>,
252    stop: Arc<AtomicBool>,
253) -> JoinHandle<()> {
254    let rx = ringo_core::subscribe_received_audio(username);
255    std::thread::spawn(move || {
256        let mut announced_rate: Option<u32> = None;
257        while !stop.load(Ordering::Relaxed) {
258            match rx.recv_timeout(Duration::from_millis(200)) {
259                Ok(frame) => {
260                    if announced_rate != Some(frame.rate) {
261                        announced_rate = Some(frame.rate);
262                        let started = FromWorker::RxAudioStarted(RxAudioStarted {
263                            rx_audio_rate: frame.rate,
264                        });
265                        if tx.send(Out::Control(started)).is_err() {
266                            break;
267                        }
268                    }
269                    if tx.send(Out::Audio(frame.samples)).is_err() {
270                        break;
271                    }
272                }
273                Err(RecvTimeoutError::Timeout) => continue, // re-check stop
274                Err(RecvTimeoutError::Disconnected) => break,
275            }
276        }
277    })
278}
279
280/// Configure this worker's log + SIP-trace sinks from the parent's choice,
281/// inherited via env (`RINGO_AGENT_LOG` / `RINGO_AGENT_SIPTRACE`): unset = off,
282/// `-` = stderr, anything else = a file. File targets are made per-agent
283/// (`run.log` -> `run.<name>.log`) so concurrent workers don't share a file.
284fn init_logging(name: &str) {
285    if let Some(t) = std::env::var_os("RINGO_AGENT_LOG").and_then(|v| v.into_string().ok()) {
286        match t.as_str() {
287            "" => {}
288            "-" => ringo_core::log::init_stderr(),
289            path => ringo_core::log::init_file(per_agent_path(path, name, std::process::id())),
290        }
291    }
292    if let Some(t) = std::env::var_os("RINGO_AGENT_SIPTRACE").and_then(|v| v.into_string().ok()) {
293        match t.as_str() {
294            "" => {}
295            "-" => ringo_core::sip_trace_stderr(),
296            path => ringo_core::sip_trace_file(per_agent_path(path, name, std::process::id())),
297        }
298    }
299}
300
301/// Insert a per-agent tag before the file extension: `run.log` ->
302/// `run.<name>.<pid>.log`. The name is sanitised (no path separators / dots, so
303/// it can't escape the directory) and the worker PID disambiguates same-named
304/// agents across scenarios so their truncating logs don't clobber each other.
305/// Public so a parent can locate a worker's file (see `ProcessClient::pid`).
306pub fn per_agent_path(base: &str, name: &str, pid: u32) -> std::path::PathBuf {
307    let safe: String = name
308        .chars()
309        .map(|c| {
310            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
311                c
312            } else {
313                '_'
314            }
315        })
316        .collect();
317    let tag = format!("{safe}.{pid}");
318    let p = std::path::Path::new(base);
319    match p.extension().and_then(|e| e.to_str()) {
320        Some(ext) => p.with_extension(format!("{tag}.{ext}")),
321        None => p.with_extension(tag),
322    }
323}
324
325/// Apply one fire-and-forget command to the phone.
326fn dispatch(phone: &dyn Phone, cmd: Command) {
327    match cmd {
328        Command::Register { aor, regint } => phone.register(&aor, regint),
329        Command::Dial { number } => phone.dial(&number),
330        Command::Hangup => phone.hangup(),
331        Command::HangupAll => phone.hangup_all(),
332        Command::Accept => phone.accept(),
333        Command::Hold => phone.hold(),
334        Command::Resume => phone.resume(),
335        Command::Mute => phone.mute(),
336        Command::Dtmf { digit } => phone.send_dtmf(digit),
337        Command::SwitchLine { line } => phone.switch_line(line),
338        Command::Transfer { uri } => phone.transfer(&uri),
339        Command::AttendedTransferStart { uri } => phone.attended_transfer_start(&uri),
340        Command::AttendedTransferExec => phone.attended_transfer_exec(),
341        Command::AttendedTransferAbort => phone.attended_transfer_abort(),
342        Command::AddHeader { key, value } => phone.add_header(&key, &value),
343        Command::RmHeader { key } => phone.rm_header(&key),
344        Command::SetAudioSource { spec } => phone.set_audio_source(&spec),
345        Command::ArmInviteResponse {
346            scode,
347            reason,
348            headers,
349        } => phone.arm_invite_response(scode, &reason, headers),
350        Command::DisarmInviteResponse => phone.disarm_invite_response(),
351        // Audio-stream control + recording + shutdown are handled in the run
352        // loop, before dispatch (they need the username / writer, not just the
353        // phone).
354        Command::StartTxAudio { .. }
355        | Command::StartRxAudio
356        | Command::SetRecording { .. }
357        | Command::Shutdown => {}
358    }
359}
360
361/// Answer one query. WAV writing runs here (on the worker's own in-process
362/// captured buffer) so only the resulting files (plus their sizes) cross the
363/// pipe; tone analysis now happens on the parent over the streamed RX audio.
364fn answer(phone: &dyn Phone, username: &str, query: Query) -> ReplyResult {
365    match query {
366        Query::MediaStats => ReplyResult::MediaStats(phone.media_stats().map(WireMediaStats::from)),
367        Query::ReceivedDtmf => ReplyResult::Dtmf(phone.received_dtmf()),
368        Query::SaveAudio { prefix } => {
369            let mut written = Vec::new();
370            for (tag, captured) in [
371                ("sent", ringo_core::sent_audio(username)),
372                ("recv", ringo_core::received_audio(username)),
373            ] {
374                let Some((samples, srate)) = captured else {
375                    continue;
376                };
377                if samples.is_empty() {
378                    continue;
379                }
380                let truncated =
381                    samples.len() >= srate as usize * ringo_core::RECORDING_MAX_SECS as usize;
382                let path = format!("{prefix}-{tag}.wav");
383                match audio::write_wav(std::path::Path::new(&path), &samples, srate) {
384                    Ok(()) => written.push(WireSavedAudio {
385                        path,
386                        kind: tag.into(),
387                        samples: samples.len(),
388                        srate,
389                        truncated,
390                    }),
391                    Err(e) => ringo_core::rlog!(Warn, "save {path}: {e}"),
392                }
393            }
394            ReplyResult::SavedAudio(written)
395        }
396        Query::CallCount => ReplyResult::CallCount(ringo_core::call_count() as u32),
397    }
398}