Skip to main content

ringo_agent/
client.rs

1//! Parent side: [`ProcessClient`] spawns an agent worker (re-execing the host
2//! binary's `agent` subcommand), performs the [`AgentConfig`] handshake, and
3//! exposes the agent's full API over the [`crate::proto`] framed protocol —
4//! fire-and-forget commands, id-correlated queries (blocking on the matching
5//! reply), an [`AppEvent`] stream, a buffer of pushed inbound INVITE headers,
6//! and live audio in/out (TTS into the call / received audio for STT).
7//!
8//! The consumer drives every agent through this, so there is no in-process
9//! baresip UA in the parent. WAV writing happens in the worker (only the paths
10//! cross the pipe); received audio for tone analysis is streamed to the parent.
11
12use crate::proto::{
13    self, AgentConfig, Command, FromWorker, Query, QueryEnvelope, ReplyResult, ToWorker,
14};
15use anyhow::{Context, Result};
16use ringo_core::AudioFrame;
17use ringo_core::event::{AppEvent, InviteHeaders, MediaStats};
18use std::collections::HashMap;
19use std::io::BufReader;
20use std::process::{Child, ChildStdin, Command as OsCommand, Stdio};
21use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
22use std::sync::mpsc::{Receiver, Sender, channel};
23use std::sync::{Arc, Mutex};
24use std::time::{Duration, Instant};
25
26/// How long to wait for a query reply before giving up. Replies are computed
27/// synchronously in the worker (no media settle-time happens here), so this only
28/// guards against a dead/stuck worker.
29const QUERY_TIMEOUT: Duration = Duration::from_secs(10);
30
31/// How long `spawn` waits for the worker's readiness ACK before giving up — a
32/// generous bound (backend init is sub-second) so a worker stuck before `Ready`
33/// fails the spawn instead of hanging it forever.
34const READY_TIMEOUT: Duration = Duration::from_secs(10);
35
36/// How long to let a worker exit gracefully on teardown before killing it.
37/// Must exceed the worker's de-REGISTER wait (see `UNREGISTER_TIMEOUT`) plus its
38/// RE-thread shutdown, or we'd kill it mid-deregister and leak the binding.
39const SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
40
41/// Handle to one agent worker process. Spawn it with [`ProcessClient::spawn`],
42/// then drive it: fire-and-forget call-control commands, blocking queries (media
43/// stats, received DTMF, tone analysis, …), and an [`AppEvent`] stream (returned
44/// by `spawn`). Dropping it deregisters and reaps the worker.
45pub struct ProcessClient {
46    name: String,
47    stdin: Mutex<ChildStdin>,
48    pending: Arc<Mutex<HashMap<u64, Sender<ReplyResult>>>>,
49    next_id: AtomicU64,
50    headers: Arc<Mutex<Option<InviteHeaders>>>,
51    child: Mutex<Option<Child>>,
52    shutdown_sent: AtomicBool,
53    /// Set by the reader thread when the worker's stdout closes (exit/crash), so
54    /// queries short-circuit instead of waiting out `QUERY_TIMEOUT`.
55    dead: Arc<AtomicBool>,
56    /// Consumer's sink for received audio frames, installed by `start_rx_audio`.
57    /// (The RX sample rate lives in the reader thread, which tags each frame.)
58    rx_audio: Arc<Mutex<Option<Sender<AudioFrame>>>>,
59}
60
61impl ProcessClient {
62    /// Spawn a worker for `config`, returning the client and the agent's event
63    /// stream (already converted to backend-neutral [`AppEvent`]s).
64    pub fn spawn(config: AgentConfig) -> Result<(Self, Receiver<AppEvent>)> {
65        let exe = std::env::current_exe().context("locate own executable")?;
66        let mut os = OsCommand::new(exe);
67        os.arg("agent")
68            .stdin(Stdio::piped())
69            .stdout(Stdio::piped())
70            .stderr(Stdio::inherit());
71        // Linux: ask the kernel to SIGTERM the worker if the parent dies, so a
72        // killed/crashed/Ctrl-C'd parent can't orphan a still-registered worker.
73        #[cfg(target_os = "linux")]
74        {
75            use std::os::unix::process::CommandExt;
76            // SAFETY: the closure runs in the child after fork, before exec, and
77            // calls only async-signal-safe libc functions.
78            unsafe {
79                os.pre_exec(|| {
80                    if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM as libc::c_ulong) != 0 {
81                        return Err(std::io::Error::last_os_error());
82                    }
83                    // Race: the parent may have already exited before prctl ran.
84                    if libc::getppid() == 1 {
85                        libc::raise(libc::SIGTERM);
86                    }
87                    Ok(())
88                });
89            }
90        }
91        let mut child = os
92            .spawn()
93            .with_context(|| format!("spawn agent worker for `{}`", config.name))?;
94
95        let mut stdin = child.stdin.take().context("worker stdin missing")?;
96        let stdout = child.stdout.take().context("worker stdout missing")?;
97
98        // Handshake: a versioned envelope carrying the config is the first
99        // control frame on stdin (keeps credentials out of argv/environ).
100        let handshake = proto::Handshake {
101            proto_version: proto::PROTO_VERSION,
102            config,
103        };
104        let cfg_bytes = serde_json::to_vec(&handshake).context("serialize handshake")?;
105        proto::write_frame(&mut stdin, proto::FRAME_CONTROL, &cfg_bytes)
106            .context("write config handshake")?;
107
108        let (ev_tx, ev_rx) = channel::<AppEvent>();
109        let pending: Arc<Mutex<HashMap<u64, Sender<ReplyResult>>>> = Arc::default();
110        let headers: Arc<Mutex<Option<InviteHeaders>>> = Arc::default();
111        let dead = Arc::new(AtomicBool::new(false));
112        let rx_audio: Arc<Mutex<Option<Sender<AudioFrame>>>> = Arc::default();
113        let rx_audio_rate = Arc::new(AtomicU32::new(0));
114        // The reader signals readiness here once the worker emits its `Ready`
115        // ACK; spawn() waits on it with a bound, so a worker stuck before `Ready`
116        // (or one that exited → reader EOF → `ready_tx` dropped) fails the spawn
117        // instead of hanging it forever.
118        let (ready_tx, ready_rx) = channel::<()>();
119
120        // Reader: demux worker stdout frames — control (readiness/events/replies/
121        // header pushes/rx-audio start) and raw received-audio frames.
122        let r_pending = Arc::clone(&pending);
123        let r_headers = Arc::clone(&headers);
124        let r_dead = Arc::clone(&dead);
125        let r_rx_audio = Arc::clone(&rx_audio);
126        let r_rx_rate = Arc::clone(&rx_audio_rate);
127        let label = handshake.config.name.clone();
128        let mut reader = BufReader::new(stdout);
129        std::thread::spawn(move || {
130            loop {
131                let (kind, payload) = match proto::read_frame(&mut reader) {
132                    Ok(Some(f)) => f,
133                    Ok(None) => break, // clean EOF
134                    Err(e) => {
135                        ringo_core::rlog!(Warn, "agent `{label}`: read error: {e}");
136                        break;
137                    }
138                };
139                if kind == proto::FRAME_AUDIO {
140                    // Received audio → consumer sink, tagged with the announced rate.
141                    let sink = r_rx_audio.lock().unwrap_or_else(|e| e.into_inner());
142                    if let Some(tx) = sink.as_ref() {
143                        let frame = AudioFrame {
144                            samples: proto::bytes_to_pcm(&payload),
145                            rate: r_rx_rate.load(Ordering::Relaxed),
146                        };
147                        let _ = tx.send(frame);
148                    }
149                    continue;
150                }
151                if kind != proto::FRAME_CONTROL {
152                    // Unknown frame kind: ignore (forward-compatible), matching the
153                    // worker's stdin loop. The version handshake makes this unlikely.
154                    continue;
155                }
156                match serde_json::from_slice::<FromWorker>(&payload) {
157                    Ok(FromWorker::Ready(_)) => {
158                        let _ = ready_tx.send(());
159                    }
160                    Ok(FromWorker::Event(w)) => {
161                        if ev_tx.send(w.into()).is_err() {
162                            break; // parent dropped the receiver
163                        }
164                    }
165                    Ok(FromWorker::Reply(r)) => {
166                        if let Some(tx) = r_pending
167                            .lock()
168                            .unwrap_or_else(|e| e.into_inner())
169                            .remove(&r.reply)
170                        {
171                            let _ = tx.send(r.result);
172                        }
173                    }
174                    Ok(FromWorker::Headers(h)) => {
175                        // Merge, don't overwrite: two pushes between the parent's
176                        // poll/take must both survive (the parent dedups by
177                        // Call-ID), or the earlier one's headers are lost.
178                        let mut buf = r_headers.lock().unwrap_or_else(|e| e.into_inner());
179                        match buf.as_mut() {
180                            Some(existing) => existing.extend(h.headers),
181                            None => *buf = Some(h.headers),
182                        }
183                    }
184                    Ok(FromWorker::RxAudioStarted(s)) => {
185                        r_rx_rate.store(s.rx_audio_rate, Ordering::Relaxed);
186                    }
187                    // Redact the payload (it may carry PII): log only the length.
188                    Err(e) => ringo_core::rlog!(
189                        Warn,
190                        "agent `{label}`: bad control frame ({} bytes): {e}",
191                        payload.len()
192                    ),
193                }
194            }
195            // Worker stdout closed (exit/crash): mark dead and release every
196            // blocked query so callers return immediately instead of waiting out
197            // QUERY_TIMEOUT. Set `dead` UNDER the pending lock so it's ordered
198            // against `query`'s under-lock re-check (closes the insert-after-clear
199            // race); the mutex provides the happens-before for the Relaxed store.
200            let mut pending = r_pending.lock().unwrap_or_else(|e| e.into_inner());
201            r_dead.store(true, Ordering::Relaxed);
202            pending.clear();
203        });
204
205        // Block (bounded) on the readiness ACK before handing back the client.
206        match ready_rx.recv_timeout(READY_TIMEOUT) {
207            Ok(()) => {}
208            Err(_) => {
209                let _ = child.kill();
210                let _ = child.wait();
211                anyhow::bail!(
212                    "agent `{}` not ready within {:?} (bad config / failed backend / exited)",
213                    handshake.config.name,
214                    READY_TIMEOUT
215                );
216            }
217        }
218
219        Ok((
220            Self {
221                name: handshake.config.name,
222                stdin: Mutex::new(stdin),
223                pending,
224                next_id: AtomicU64::new(0),
225                headers,
226                child: Mutex::new(Some(child)),
227                shutdown_sent: AtomicBool::new(false),
228                dead,
229                rx_audio,
230            },
231            ev_rx,
232        ))
233    }
234
235    /// A clone of the inbound-header buffer, for building a `header_poll` closure.
236    pub fn headers_handle(&self) -> Arc<Mutex<Option<InviteHeaders>>> {
237        Arc::clone(&self.headers)
238    }
239
240    fn send(&self, msg: ToWorker) {
241        let bytes = match serde_json::to_vec(&msg) {
242            Ok(b) => b,
243            Err(e) => {
244                ringo_core::rlog!(Warn, "agent `{}`: serialize message: {e}", self.name);
245                return;
246            }
247        };
248        let mut w = self.stdin.lock().unwrap_or_else(|e| e.into_inner());
249        if proto::write_frame(&mut *w, proto::FRAME_CONTROL, &bytes).is_err() {
250            ringo_core::rlog!(Warn, "agent `{}`: worker stdin closed", self.name);
251        }
252    }
253
254    fn cmd(&self, cmd: Command) {
255        self.send(ToWorker::Cmd(cmd));
256    }
257
258    /// Tell the worker to shut down (deregister + exit) without blocking. Sent
259    /// at most once; call this on all agents before dropping them so their
260    /// de-REGISTERs run concurrently instead of one-at-a-time on each drop.
261    pub fn request_shutdown(&self) {
262        if !self.shutdown_sent.swap(true, Ordering::Relaxed) {
263            self.cmd(Command::Shutdown);
264        }
265    }
266
267    /// Send a query and block until the matching reply (or timeout/dead worker).
268    fn query(&self, query: Query) -> Option<ReplyResult> {
269        // Don't even try if the worker is gone — its reply will never come.
270        if self.dead.load(Ordering::Relaxed) {
271            return None;
272        }
273        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
274        let (tx, rx) = channel::<ReplyResult>();
275        {
276            // Register the waiter and re-check `dead` under the same lock the
277            // reader takes to drain on EOF. Without this, the worker could die
278            // between the fast-path check above and the insert, orphaning the
279            // entry and stalling the full QUERY_TIMEOUT.
280            let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner());
281            if self.dead.load(Ordering::Relaxed) {
282                return None;
283            }
284            pending.insert(id, tx);
285        }
286        self.send(ToWorker::Query(QueryEnvelope { id, query }));
287        match rx.recv_timeout(QUERY_TIMEOUT) {
288            Ok(r) => Some(r),
289            Err(_) => {
290                self.pending
291                    .lock()
292                    .unwrap_or_else(|e| e.into_inner())
293                    .remove(&id);
294                ringo_core::rlog!(Warn, "agent `{}`: query timed out", self.name);
295                None
296            }
297        }
298    }
299
300    // ── commands (fire-and-forget) ──────────────────────────────────────────
301    /// Register the account at `aor` with the given re-register interval (s).
302    pub fn register(&self, aor: &str, regint: u32) {
303        self.cmd(Command::Register {
304            aor: aor.to_string(),
305            regint,
306        });
307    }
308    /// Place an outgoing call to `number` (a SIP URI or a bare number/extension).
309    pub fn dial(&self, number: &str) {
310        self.cmd(Command::Dial {
311            number: number.to_string(),
312        });
313    }
314    /// Hang up the active call.
315    pub fn hangup(&self) {
316        self.cmd(Command::Hangup);
317    }
318    /// Hang up all calls.
319    pub fn hangup_all(&self) {
320        self.cmd(Command::HangupAll);
321    }
322    /// Answer the incoming call.
323    pub fn accept(&self) {
324        self.cmd(Command::Accept);
325    }
326    /// Put the active call on hold.
327    pub fn hold(&self) {
328        self.cmd(Command::Hold);
329    }
330    /// Resume the held call.
331    pub fn resume(&self) {
332        self.cmd(Command::Resume);
333    }
334    /// Mute the active call's outgoing audio.
335    pub fn mute(&self) {
336        self.cmd(Command::Mute);
337    }
338    /// Send a single DTMF digit on the active call.
339    pub fn send_dtmf(&self, digit: char) {
340        self.cmd(Command::Dtmf { digit });
341    }
342    /// Not yet exposed by a scenario verb (line switching is planned).
343    #[allow(dead_code)]
344    pub fn switch_line(&self, line: usize) {
345        self.cmd(Command::SwitchLine { line });
346    }
347    /// Blind-transfer the active call to `uri`.
348    pub fn transfer(&self, uri: &str) {
349        self.cmd(Command::Transfer {
350            uri: uri.to_string(),
351        });
352    }
353    /// Start an attended transfer: call `uri` as a consultation call.
354    pub fn attended_transfer_start(&self, uri: &str) {
355        self.cmd(Command::AttendedTransferStart {
356            uri: uri.to_string(),
357        });
358    }
359    /// Complete the attended transfer (connect the two parties).
360    pub fn attended_transfer_exec(&self) {
361        self.cmd(Command::AttendedTransferExec);
362    }
363    /// Abort the attended transfer (hang up the consultation call).
364    pub fn attended_transfer_abort(&self) {
365        self.cmd(Command::AttendedTransferAbort);
366    }
367    /// Add a custom SIP header sent on subsequent requests.
368    pub fn add_header(&self, key: &str, value: &str) {
369        self.cmd(Command::AddHeader {
370            key: key.to_string(),
371            value: value.to_string(),
372        });
373    }
374    /// Not yet exposed by a scenario verb (header removal is planned).
375    #[allow(dead_code)]
376    pub fn rm_header(&self, key: &str) {
377        self.cmd(Command::RmHeader {
378            key: key.to_string(),
379        });
380    }
381    /// Switch the active call's audio source (e.g. a tone, a file, or silence).
382    pub fn set_audio_source(&self, spec: &str) {
383        self.cmd(Command::SetAudioSource {
384            spec: spec.to_string(),
385        });
386    }
387    /// Arm a fixed response (status `scode`/`reason` + extra `headers`) for the
388    /// next inbound INVITE instead of accepting it.
389    pub fn arm_invite_response(&self, scode: u16, reason: &str, headers: Vec<String>) {
390        self.cmd(Command::ArmInviteResponse {
391            scode,
392            reason: reason.to_string(),
393            headers,
394        });
395    }
396    /// Clear a previously armed invite response (accept incoming calls again).
397    pub fn disarm_invite_response(&self) {
398        self.cmd(Command::DisarmInviteResponse);
399    }
400    /// Mirror of the `Phone::deflect_incoming` default: arm a 302 with `Contact`
401    /// (and optional RFC 5806 `Diversion`).
402    pub fn deflect_incoming(&self, contact: &str, diversion: Option<&str>) {
403        let mut headers = vec![format!("Contact: <{contact}>")];
404        if let Some(div) = diversion {
405            headers.push(format!("Diversion: <{div}>"));
406        }
407        self.arm_invite_response(302, "Moved Temporarily", headers);
408    }
409
410    // ── queries (block on the worker's reply) ────────────────────────────────
411    /// RTP media stats for the active/last call, or `None` if unavailable.
412    pub fn media_stats(&self) -> Option<MediaStats> {
413        match self.query(Query::MediaStats) {
414            Some(ReplyResult::MediaStats(s)) => s.map(Into::into),
415            _ => None,
416        }
417    }
418    /// DTMF digits received on the active/last call so far, in order.
419    pub fn received_dtmf(&self) -> String {
420        match self.query(Query::ReceivedDtmf) {
421            Some(ReplyResult::Dtmf(s)) => s,
422            _ => String::new(),
423        }
424    }
425    /// Ask the worker to write its captured audio to `<prefix>-sent.wav` /
426    /// `<prefix>-recv.wav`; returns the paths written.
427    pub fn save_audio(&self, prefix: &str) -> Vec<String> {
428        match self.query(Query::SaveAudio {
429            prefix: prefix.to_string(),
430        }) {
431            Some(ReplyResult::Saved(paths)) => paths,
432            _ => Vec::new(),
433        }
434    }
435    /// Number of active calls in the worker (0 if the worker is gone).
436    pub fn call_count(&self) -> u32 {
437        match self.query(Query::CallCount) {
438            Some(ReplyResult::CallCount(n)) => n,
439            _ => 0,
440        }
441    }
442
443    // ── live audio ───────────────────────────────────────────────────────────
444    // TX (audio into the call) has no in-tree consumer yet: it's the building
445    // block for a live producer such as a TTS-driven MCP tool (`ringo-mcp`). RX
446    // (audio out of the call) is consumed by ringo-flow's tone verification.
447    /// Switch the agent's audio source to live-streamed mono s16 PCM at `rate` Hz;
448    /// feed it with [`Self::push_tx_audio`] (e.g. TTS output). Call this and feed
449    /// `push_tx_audio` from the *same* thread: the worker requires the
450    /// `StartTxAudio` control frame to arrive before any audio frame (it drops
451    /// audio that arrives first), and the stdin lock is not FIFO across threads.
452    pub fn start_tx_audio(&self, rate: u32) {
453        self.cmd(Command::StartTxAudio { rate });
454    }
455
456    /// Stream mono s16 PCM into the call as a raw audio frame (after
457    /// [`Self::start_tx_audio`], from the same thread).
458    pub fn push_tx_audio(&self, samples: &[i16]) {
459        let mut w = self.stdin.lock().unwrap_or_else(|e| e.into_inner());
460        if proto::write_frame(&mut *w, proto::FRAME_AUDIO, &proto::pcm_to_bytes(samples)).is_err() {
461            ringo_core::rlog!(
462                Warn,
463                "agent `{}`: worker stdin closed (tx audio)",
464                self.name
465            );
466        }
467    }
468
469    /// Start streaming the agent's received audio; returns a receiver of mono
470    /// [`AudioFrame`]s (e.g. to feed STT or a parent-side tone analysis). One
471    /// subscription per client.
472    pub fn start_rx_audio(&self) -> Receiver<AudioFrame> {
473        let (tx, rx) = channel();
474        *self.rx_audio.lock().unwrap_or_else(|e| e.into_inner()) = Some(tx);
475        self.cmd(Command::StartRxAudio);
476        rx
477    }
478}
479
480impl Drop for ProcessClient {
481    fn drop(&mut self) {
482        // Ask the worker to exit, then reap it (kill only if it overstays).
483        // Idempotent: a no-op if teardown already requested shutdown.
484        self.request_shutdown();
485        if let Some(mut child) = self.child.lock().unwrap_or_else(|e| e.into_inner()).take() {
486            let deadline = Instant::now() + SHUTDOWN_GRACE;
487            loop {
488                match child.try_wait() {
489                    Ok(Some(_)) => return, // exited cleanly
490                    Ok(None) if Instant::now() < deadline => {
491                        std::thread::sleep(Duration::from_millis(20))
492                    }
493                    _ => break, // overstayed its grace, or wait() errored
494                }
495            }
496            let _ = child.kill();
497            let _ = child.wait();
498        }
499    }
500}