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}