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}