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