1use std::fs;
24use std::hash::{BuildHasher, Hasher, RandomState};
25use std::io::{BufRead, BufReader, Read, Write};
26use std::net::{Ipv4Addr, TcpListener, TcpStream};
27use std::path::{Path, PathBuf};
28use std::process::Command;
29use std::time::{Duration, Instant, SystemTime};
30
31use serde_json::{Value, json};
32
33use crate::error::{FttsError, FttsExitCode};
34use crate::synth::{LoadedModel, ModelBundle, SynthesizedAudio};
35use ftts_core::{NormalizationMode, NormalizationOptions, SynthesisRequest};
36
37const DEFAULT_IDLE: Duration = Duration::from_secs(600);
40
41const DEFAULT_CLIENT_READ_TIMEOUT: Duration = Duration::from_secs(600);
46
47fn client_read_timeout() -> Duration {
48 std::env::var("FTTS_RESIDENT_CLIENT_TIMEOUT_SECS")
49 .ok()
50 .and_then(|value| value.parse::<u64>().ok())
51 .filter(|&seconds| seconds > 0)
55 .map_or(DEFAULT_CLIENT_READ_TIMEOUT, Duration::from_secs)
56}
57
58const DEFAULT_SPAWN_WAIT: Duration = Duration::from_secs(30);
64
65fn spawn_wait() -> Duration {
66 std::env::var("FTTS_RESIDENT_SPAWN_WAIT_SECS")
67 .ok()
68 .and_then(|value| value.parse::<u64>().ok())
69 .map_or(DEFAULT_SPAWN_WAIT, Duration::from_secs)
70}
71
72const PROTOCOL: u64 = 1;
73
74pub struct WireRequest<'a> {
77 pub text: &'a str,
78 pub normalize: &'a str,
80 pub trace: bool,
81 pub speaker: &'a [f32],
82 pub seed: u64,
83}
84
85fn parse_normalize(label: &str) -> Option<NormalizationMode> {
86 match label {
87 "verbatim" => Some(NormalizationMode::Verbatim),
88 "conservative" => Some(NormalizationMode::Conservative),
89 "locale-aware" => Some(NormalizationMode::LocaleAware),
90 _ => None,
91 }
92}
93
94fn idle_period() -> Duration {
95 std::env::var("FTTS_RESIDENT_IDLE_SECS")
96 .ok()
97 .and_then(|value| value.parse::<u64>().ok())
98 .map_or(DEFAULT_IDLE, Duration::from_secs)
99}
100
101pub fn enabled(no_resident_flag: bool) -> bool {
104 if no_resident_flag {
105 return false;
106 }
107 !matches!(
108 std::env::var("FTTS_NO_RESIDENT").ok().as_deref(),
109 Some("1") | Some("true")
110 )
111}
112
113fn resident_dir() -> Option<PathBuf> {
116 if let Ok(dir) = std::env::var("FTTS_RESIDENT_DIR") {
117 return Some(PathBuf::from(dir));
118 }
119 #[allow(deprecated)] std::env::home_dir().map(|home| home.join(".cache/franken_tts"))
121}
122
123fn root_digest(root: &Path) -> u64 {
132 let canonical = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
133 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
134 for byte in canonical.to_string_lossy().as_bytes() {
135 hash ^= u64::from(*byte);
136 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
137 }
138 hash
139}
140
141fn state_path(root: &Path) -> Option<PathBuf> {
142 resident_dir().map(|dir| dir.join(format!("resident-{:016x}.json", root_digest(root))))
143}
144
145struct DaemonState {
146 port: u16,
147 token: String,
148}
149
150fn read_state(root: &Path) -> Option<DaemonState> {
151 let path = state_path(root)?;
152 let raw = fs::read_to_string(path).ok()?;
153 let value: Value = serde_json::from_str(&raw).ok()?;
154 Some(DaemonState {
155 port: u16::try_from(value.get("port")?.as_u64()?).ok()?,
156 token: value.get("token")?.as_str()?.to_owned(),
157 })
158}
159
160fn write_state(root: &Path, port: u16, token: &str) -> std::io::Result<PathBuf> {
161 let path = state_path(root).ok_or_else(|| {
162 std::io::Error::other("no home directory and no FTTS_RESIDENT_DIR; cannot go resident")
163 })?;
164 if let Some(parent) = path.parent() {
165 fs::create_dir_all(parent)?;
166 }
167 let body = json!({
168 "port": port,
169 "token": token,
170 "pid": std::process::id(),
171 "version": env!("CARGO_PKG_VERSION"),
172 "bundle_root": root.to_string_lossy(),
173 })
174 .to_string();
175 let staging = path.with_extension("json.tmp");
179 #[cfg(unix)]
180 {
181 use std::io::Write as _;
182 use std::os::unix::fs::OpenOptionsExt;
183 let mut file = fs::OpenOptions::new()
184 .write(true)
185 .create(true)
186 .truncate(true)
187 .mode(0o600)
188 .open(&staging)?;
189 file.write_all(body.as_bytes())?;
190 }
191 #[cfg(not(unix))]
192 fs::write(&staging, &body)?;
193 fs::rename(&staging, &path)?;
194 Ok(path)
195}
196
197fn fresh_token() -> String {
200 let mut token = String::with_capacity(32);
201 for _ in 0..2 {
202 let mut hasher = RandomState::new().build_hasher();
203 hasher.write_u128(std::time::UNIX_EPOCH.elapsed().map_or(0, |d| d.as_nanos()));
204 hasher.write_u32(std::process::id());
205 token.push_str(&format!("{:016x}", hasher.finish()));
206 }
207 token
208}
209
210fn artifact_stamp(bundle: &ModelBundle) -> (u64, u64) {
213 let path = bundle.canonical_main.as_deref().unwrap_or(&bundle.main);
214 let Ok(meta) = fs::metadata(path) else {
215 return (0, 0);
216 };
217 let mtime = meta
223 .modified()
224 .ok()
225 .and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
226 .map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX));
227 (mtime, meta.len())
228}
229
230pub fn try_synthesize(
239 bundle: &ModelBundle,
240 request: &WireRequest<'_>,
241) -> Result<Option<SynthesizedAudio>, FttsError> {
242 if request.speaker.iter().any(|value| !value.is_finite()) {
246 return Ok(None);
247 }
248 match connect(bundle) {
249 Some(stream) => roundtrip(stream, bundle, request),
250 None => Ok(None),
251 }
252}
253
254enum ConnectFailure {
256 NoState,
258 Refused,
261 Other,
264}
265
266fn connect(bundle: &ModelBundle) -> Option<TcpStream> {
267 let mut last = ConnectFailure::NoState;
271 for attempt in 0..3 {
272 if attempt > 0 {
273 std::thread::sleep(Duration::from_millis(300));
274 }
275 match connect_once(bundle) {
276 Ok(stream) => return Some(stream),
277 Err(ConnectFailure::NoState) => {
278 last = ConnectFailure::NoState;
279 break; }
281 Err(failure) => last = failure,
282 }
283 }
284 match last {
285 ConnectFailure::Refused => {
287 if let Some(path) = state_path(&bundle.root)
288 && path.exists()
289 {
290 let _ = fs::remove_file(&path);
291 }
292 }
293 ConnectFailure::NoState => {}
294 ConnectFailure::Other => return None,
299 }
300 spawn_daemon(&bundle.root)?;
301 let deadline = Instant::now() + spawn_wait();
302 while Instant::now() < deadline {
303 if let Ok(stream) = connect_once(bundle) {
304 return Some(stream);
305 }
306 std::thread::sleep(Duration::from_millis(50));
307 }
308 None
309}
310
311fn connect_once(bundle: &ModelBundle) -> Result<TcpStream, ConnectFailure> {
312 let state = read_state(&bundle.root).ok_or(ConnectFailure::NoState)?;
313 let stream = TcpStream::connect_timeout(
314 &(Ipv4Addr::LOCALHOST, state.port).into(),
315 Duration::from_millis(1000),
316 )
317 .map_err(|error| {
318 if error.kind() == std::io::ErrorKind::ConnectionRefused {
319 ConnectFailure::Refused
320 } else {
321 ConnectFailure::Other
322 }
323 })?;
324 stream
325 .set_read_timeout(Some(client_read_timeout()))
326 .map_err(|_| ConnectFailure::Other)?;
327 stream.set_nodelay(true).ok();
328 Ok(stream)
329}
330
331fn spawn_daemon(root: &Path) -> Option<()> {
332 let exe = std::env::current_exe().ok()?;
333 let mut command = Command::new(exe);
334 command
335 .arg("resident-daemon")
336 .arg("--bundle-root")
337 .arg(root)
338 .stdin(std::process::Stdio::null())
339 .stdout(std::process::Stdio::null())
340 .stderr(std::process::Stdio::null());
341 if let Ok(log) = std::env::var("FTTS_RESIDENT_LOG")
344 && let Ok(file) = fs::OpenOptions::new().create(true).append(true).open(&log)
345 && let Ok(err) = file.try_clone()
346 {
347 command.stdout(file).stderr(err);
348 }
349 #[cfg(windows)]
350 {
351 use std::os::windows::process::CommandExt;
352 command.creation_flags(0x0000_0008 | 0x0800_0000);
354 }
355 command.spawn().ok().map(|_child| ())
356}
357
358fn roundtrip(
359 mut stream: TcpStream,
360 bundle: &ModelBundle,
361 request: &WireRequest<'_>,
362) -> Result<Option<SynthesizedAudio>, FttsError> {
363 let state = match read_state(&bundle.root) {
364 Some(state) => state,
365 None => return Ok(None),
366 };
367 let header = json!({
368 "protocol": PROTOCOL,
369 "op": "synthesize",
370 "token": state.token,
371 "version": env!("CARGO_PKG_VERSION"),
372 "bundle_root": bundle.root.to_string_lossy(),
373 "text": request.text,
374 "normalize": request.normalize,
375 "trace": request.trace,
376 "speaker": request.speaker,
377 "seed": request.seed,
378 });
379 if stream
380 .write_all(format!("{header}\n").as_bytes())
381 .and_then(|()| stream.flush())
382 .is_err()
383 {
384 return Ok(None);
385 }
386
387 let mut reader = BufReader::new(stream);
388 let mut line = String::new();
389 if reader.read_line(&mut line).is_err() || line.trim().is_empty() {
390 return Ok(None);
391 }
392 let Ok(reply) = serde_json::from_str::<Value>(&line) else {
393 return Ok(None);
394 };
395 if reply.get("ok").and_then(Value::as_bool) == Some(true) {
396 const MAX_WIRE_SAMPLES: u64 = 200_000_000;
401 let samples = reply
402 .get("samples")
403 .and_then(Value::as_u64)
404 .filter(|&n| n <= MAX_WIRE_SAMPLES)
405 .and_then(|n| usize::try_from(n).ok())
406 .unwrap_or(0);
407 let mut bytes = vec![0u8; samples * 4];
408 if reader.read_exact(&mut bytes).is_err() {
409 return Ok(None);
410 }
411 let (chunks, _remainder) = bytes.as_chunks::<4>();
412 let pcm = chunks
413 .iter()
414 .map(|chunk| f32::from_le_bytes(*chunk))
415 .collect();
416 return Ok(Some(SynthesizedAudio {
417 pcm,
418 frames: reply.get("frames").and_then(Value::as_u64).unwrap_or(0),
419 prepared_token_count: reply
420 .get("prepared_token_count")
421 .and_then(Value::as_u64)
422 .unwrap_or(0) as usize,
423 ttfa: reply
424 .get("ttfa_ms")
425 .and_then(Value::as_u64)
426 .map(Duration::from_millis),
427 }));
428 }
429 match reply.get("kind").and_then(Value::as_str) {
431 Some("synthesis") => {
432 let code = reply
433 .get("exit_code")
434 .and_then(Value::as_u64)
435 .unwrap_or(FttsExitCode::Generic.as_u8().into());
436 let message = reply
437 .get("message")
438 .and_then(Value::as_str)
439 .unwrap_or("resident synthesis failed")
440 .to_owned();
441 Err(wire_error(code, message))
442 }
443 _ => Ok(None),
444 }
445}
446
447fn wire_error(exit_code: u64, message: String) -> FttsError {
448 match exit_code {
449 3 => FttsError::ModelNotFound(message),
450 4 => FttsError::Input(message),
451 5 => FttsError::BudgetTimeout(message),
452 7 => FttsError::ArtifactFormat(message),
453 8 => FttsError::EnrollmentQualityRefusal(message),
454 _ => FttsError::Generic(message),
455 }
456}
457
458pub fn run_daemon(bundle_root: &Path) -> Result<(), FttsError> {
466 let bundle = ModelBundle::resolve(bundle_root)?;
467 let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
468 .map_err(|error| FttsError::Generic(format!("resident daemon cannot bind: {error}")))?;
469 let port = listener
470 .local_addr()
471 .map_err(|error| FttsError::Generic(format!("resident daemon has no address: {error}")))?
472 .port();
473 let token = fresh_token();
474 let state_file = write_state(&bundle.root, port, &token)
475 .map_err(|error| FttsError::Generic(format!("cannot write resident state: {error}")))?;
476 listener
477 .set_nonblocking(true)
478 .map_err(|error| FttsError::Generic(format!("resident daemon socket mode: {error}")))?;
479 eprintln!(
480 "resident daemon serving {} on 127.0.0.1:{port}",
481 bundle.root.display()
482 );
483
484 let idle = idle_period();
485 let mut resident: Option<(LoadedModel, ftts_core::TtsEngine, (u64, u64))> = None;
486 let mut deadline = Instant::now() + idle;
487
488 loop {
489 match listener.accept() {
490 Ok((stream, _peer)) => {
491 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
503 handle_connection(stream, &bundle, &token, port, &mut resident);
504 }));
505 if outcome.is_err() {
506 eprintln!("resident daemon: request panicked; connection dropped, model kept");
508 }
509 deadline = Instant::now() + idle;
510 }
511 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
512 if Instant::now() >= deadline {
513 eprintln!("resident daemon idle exit");
514 remove_state_if_ours(&state_file, port);
515 return Ok(());
516 }
517 std::thread::sleep(Duration::from_millis(100));
518 }
519 Err(_) => {
520 remove_state_if_ours(&state_file, port);
521 return Ok(());
522 }
523 }
524 }
525}
526
527fn remove_state_if_ours(state_file: &Path, port: u16) {
536 let ours = fs::read_to_string(state_file)
537 .ok()
538 .and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
539 .and_then(|value| value.get("port")?.as_u64())
540 .is_some_and(|recorded| recorded == u64::from(port));
541 if ours {
542 let _ = fs::remove_file(state_file);
543 }
544}
545
546#[allow(clippy::type_complexity)]
547fn handle_connection(
548 stream: TcpStream,
549 bundle: &ModelBundle,
550 token: &str,
551 port: u16,
552 resident: &mut Option<(LoadedModel, ftts_core::TtsEngine, (u64, u64))>,
553) {
554 let _ = stream.set_nonblocking(false);
555 let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
556 let _ = stream.set_write_timeout(Some(Duration::from_secs(60)));
562 let _ = stream.set_nodelay(true);
563
564 const MAX_REQUEST_BYTES: usize = 1024 * 1024;
576 const REQUEST_DEADLINE: Duration = Duration::from_secs(30);
577 let mut stream = stream;
578 let deadline = Instant::now() + REQUEST_DEADLINE;
579 let mut buffer: Vec<u8> = Vec::new();
580 let mut chunk = [0_u8; 4096];
581 let newline = loop {
582 if Instant::now() >= deadline {
583 return; }
585 match stream.read(&mut chunk) {
586 Ok(0) => return, Ok(count) => {
588 buffer.extend_from_slice(&chunk[..count]);
589 if let Some(position) = buffer.iter().position(|&byte| byte == b'\n') {
590 break position;
591 }
592 if buffer.len() >= MAX_REQUEST_BYTES {
596 let reply =
597 json!({ "ok": false, "kind": "request", "message": "request too large" });
598 let _ = stream.write_all(format!("{reply}\n").as_bytes());
599 return;
600 }
601 }
602 Err(_) => return, }
604 };
605 let Ok(request) = serde_json::from_slice::<Value>(&buffer[..newline]) else {
606 return;
607 };
608
609 let refuse = |stream: &mut TcpStream, kind: &str, message: &str| {
610 let reply = json!({ "ok": false, "kind": kind, "message": message });
611 let _ = stream.write_all(format!("{reply}\n").as_bytes());
612 };
613
614 let token_matches = |candidate: &str| {
618 let (a, b) = (candidate.as_bytes(), token.as_bytes());
619 if a.len() != b.len() {
620 return false;
621 }
622 a.iter().zip(b).fold(0_u8, |acc, (x, y)| acc | (x ^ y)) == 0
623 };
624 if !request
625 .get("token")
626 .and_then(Value::as_str)
627 .is_some_and(token_matches)
628 {
629 refuse(&mut stream, "auth", "bad token");
630 return;
631 }
632 if request.get("protocol").and_then(Value::as_u64) != Some(PROTOCOL)
633 || request.get("version").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION"))
634 {
635 refuse(&mut stream, "version", "resident daemon version mismatch");
638 if let Some(state) = state_path(&bundle.root) {
641 remove_state_if_ours(&state, port);
642 }
643 std::process::exit(0);
644 }
645
646 if let Some(wire_root) = request.get("bundle_root").and_then(Value::as_str) {
650 let wire_canonical =
651 fs::canonicalize(wire_root).unwrap_or_else(|_| PathBuf::from(wire_root));
652 let own_canonical = fs::canonicalize(&bundle.root).unwrap_or_else(|_| bundle.root.clone());
653 if wire_canonical != own_canonical {
654 refuse(
655 &mut stream,
656 "request",
657 "resident daemon serves a different bundle root",
658 );
659 return;
660 }
661 }
662
663 let stamp_now = artifact_stamp(bundle);
665 if let Some((_, _, loaded_stamp)) = resident.as_ref()
666 && *loaded_stamp != stamp_now
667 {
668 refuse(&mut stream, "stale", "model artifact changed since load");
669 if let Some(state) = state_path(&bundle.root) {
670 remove_state_if_ours(&state, port);
671 }
672 std::process::exit(0);
673 }
674
675 let text = request.get("text").and_then(Value::as_str).unwrap_or("");
676 let normalize = request
677 .get("normalize")
678 .and_then(Value::as_str)
679 .and_then(parse_normalize);
680 let trace = request
681 .get("trace")
682 .and_then(Value::as_bool)
683 .unwrap_or(false);
684 let seed = request.get("seed").and_then(Value::as_u64).unwrap_or(0);
685 let speaker: Vec<f32> = match request.get("speaker").and_then(Value::as_array) {
696 Some(values) => {
697 let mut vector = Vec::with_capacity(values.len());
698 for value in values {
699 let Some(number) = value.as_f64() else {
700 refuse(
701 &mut stream,
702 "request",
703 "speaker vector contains a non-numeric entry",
704 );
705 return;
706 };
707 #[allow(clippy::cast_possible_truncation)]
708 let narrowed = number as f32;
709 if !narrowed.is_finite() {
710 refuse(
711 &mut stream,
712 "request",
713 "speaker vector contains a non-finite value",
714 );
715 return;
716 }
717 vector.push(narrowed);
718 }
719 vector
720 }
721 None => Vec::new(),
722 };
723 let Some(mode) = normalize else {
724 refuse(&mut stream, "request", "unknown normalize mode");
725 return;
726 };
727 if text.is_empty() || speaker.is_empty() {
728 refuse(&mut stream, "request", "empty text or speaker");
729 return;
730 }
731
732 if resident.is_none() {
734 let loaded = match LoadedModel::load(bundle) {
735 Ok(loaded) => loaded,
736 Err(error) => {
737 let reply = json!({
738 "ok": false,
739 "kind": "synthesis",
740 "exit_code": error.exit_code().as_u8(),
741 "message": error.to_string(),
742 });
743 let _ = stream.write_all(format!("{reply}\n").as_bytes());
744 return;
745 }
746 };
747 let engine = match ftts_core::TtsEngine::from_process_environment() {
748 Ok(engine) => engine,
749 Err(error) => {
750 refuse(
751 &mut stream,
752 "engine",
753 &format!("engine start failed: {error}"),
754 );
755 return;
756 }
757 };
758 *resident = Some((loaded, engine, stamp_now));
759 }
760 let (loaded, engine, _) = resident.as_ref().expect("hydrated just above");
761
762 let synthesis_request = SynthesisRequest::new(text.to_owned())
763 .with_normalization_options(NormalizationOptions {
764 mode,
765 ..NormalizationOptions::default()
766 })
767 .with_normalization_trace(trace);
768 let cancellation = ftts_core::CancellationToken::new();
769 let observer = |_event: ftts_core::SynthesisEvent| {};
770 match crate::synth::synthesize(
771 loaded,
772 engine,
773 &synthesis_request,
774 &speaker,
775 seed,
776 &cancellation,
777 &observer,
778 ) {
779 Ok(audio) => {
780 let header = json!({
781 "ok": true,
782 "samples": audio.pcm.len(),
783 "frames": audio.frames,
784 "prepared_token_count": audio.prepared_token_count,
785 "ttfa_ms": audio.ttfa.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)),
786 });
787 if stream.write_all(format!("{header}\n").as_bytes()).is_err() {
788 return;
789 }
790 let mut bytes = Vec::with_capacity(audio.pcm.len() * 4);
791 for sample in &audio.pcm {
792 bytes.extend_from_slice(&sample.to_le_bytes());
793 }
794 let _ = stream.write_all(&bytes);
795 let _ = stream.flush();
796 }
797 Err(error) => {
798 let reply = json!({
799 "ok": false,
800 "kind": "synthesis",
801 "exit_code": error.exit_code().as_u8(),
802 "message": error.to_string(),
803 });
804 let _ = stream.write_all(format!("{reply}\n").as_bytes());
805 }
806 }
807}
808
809#[cfg(test)]
810mod tests {
811 use super::*;
812
813 #[test]
814 fn normalize_labels_round_trip() {
815 for label in ["verbatim", "conservative", "locale-aware"] {
816 assert!(parse_normalize(label).is_some(), "{label}");
817 }
818 assert!(parse_normalize("aggressive").is_none());
819 }
820
821 #[test]
825 fn an_endless_request_line_is_bounded_not_fatal() {
826 use std::io::Write as _;
827 use std::net::TcpListener;
828
829 let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
830 let port = listener.local_addr().unwrap().port();
831
832 let flood = std::thread::spawn(move || {
834 let Ok(mut stream) = TcpStream::connect((Ipv4Addr::LOCALHOST, port)) else {
835 return;
836 };
837 let block = vec![b'x'; 64 * 1024];
838 while stream.write_all(&block).is_ok() {}
840 });
841
842 let (stream, _) = listener.accept().unwrap();
843 let mut reader = BufReader::new(stream);
844 let mut line = String::new();
845 const MAX: u64 = 1024 * 1024;
846 let read = (&mut reader).take(MAX).read_line(&mut line).unwrap_or(0);
847
848 assert!(
849 read as u64 <= MAX,
850 "read {read} bytes, past the {MAX}-byte cap"
851 );
852 assert!(
853 line.len() as u64 <= MAX,
854 "buffered {} bytes, past the cap",
855 line.len()
856 );
857 drop(reader);
858 let _ = flood.join();
859 }
860
861 #[test]
868 fn speaker_vectors_are_validated_rather_than_salvaged() {
869 fn parse(values: &[Value]) -> Result<Vec<f32>, &'static str> {
871 let mut vector = Vec::with_capacity(values.len());
872 for value in values {
873 let number = value.as_f64().ok_or("non-numeric")?;
874 let narrowed = number as f32;
875 if !narrowed.is_finite() {
876 return Err("non-finite");
877 }
878 vector.push(narrowed);
879 }
880 Ok(vector)
881 }
882
883 assert_eq!(parse(&[json!(1.0), json!(-2.5)]).unwrap(), vec![1.0, -2.5]);
884 assert_eq!(
885 parse(&[json!(1.0), json!("x"), json!(2.0)]),
886 Err("non-numeric"),
887 "a non-numeric entry must be refused, not dropped"
888 );
889 assert_eq!(parse(&[json!(null)]), Err("non-numeric"));
890 assert_eq!(
892 parse(&[json!(1e300)]),
893 Err("non-finite"),
894 "an f64 that overflows f32 becomes infinity and must be refused"
895 );
896 }
897
898 #[test]
899 fn wire_errors_keep_their_exit_class() {
900 let cases = [
901 (3u64, FttsExitCode::ModelNotFound),
902 (4, FttsExitCode::Input),
903 (5, FttsExitCode::BudgetTimeout),
904 (7, FttsExitCode::ArtifactFormat),
905 (8, FttsExitCode::EnrollmentQualityRefusal),
906 (1, FttsExitCode::Generic),
907 (99, FttsExitCode::Generic),
908 ];
909 for (code, expected) in cases {
910 assert_eq!(wire_error(code, String::new()).exit_code(), expected);
911 }
912 }
913
914 #[test]
915 fn root_digest_distinguishes_roots_and_is_stable() {
916 let a = root_digest(Path::new("/tmp/model-a"));
917 let b = root_digest(Path::new("/tmp/model-b"));
918 assert_ne!(a, b);
919 assert_eq!(a, root_digest(Path::new("/tmp/model-a")));
920 }
921
922 #[test]
923 fn tokens_are_distinct_and_hex() {
924 let one = fresh_token();
925 let two = fresh_token();
926 assert_eq!(one.len(), 32);
927 assert!(one.bytes().all(|b| b.is_ascii_hexdigit()));
928 assert_ne!(one, two, "two RandomState-seeded tokens collided");
929 }
930
931 #[test]
932 fn state_file_round_trips_and_respects_dir_override() {
933 let dir = std::env::temp_dir().join(format!("ftts-resident-test-{}", std::process::id()));
934 let root = Path::new("/tmp/some-model-root");
937 let path = dir.join(format!("resident-{:016x}.json", root_digest(root)));
938 fs::create_dir_all(&dir).unwrap();
939 let body =
940 json!({"port": 45123, "token": "abc123", "pid": 1, "version": "x", "bundle_root": "y"});
941 fs::write(&path, body.to_string()).unwrap();
942 let raw = fs::read_to_string(&path).unwrap();
943 let value: Value = serde_json::from_str(&raw).unwrap();
944 assert_eq!(value.get("port").and_then(Value::as_u64), Some(45123));
945 assert_eq!(value.get("token").and_then(Value::as_str), Some("abc123"));
946 let _ = fs::remove_file(&path);
947 }
948
949 #[test]
952 fn daemon_refuses_bad_token() {
953 let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
954 let port = listener.local_addr().unwrap().port();
955 let handle = std::thread::spawn(move || {
956 let (stream, _) = listener.accept().unwrap();
957 let bundle = ModelBundle {
958 root: PathBuf::from("/nonexistent"),
959 main: PathBuf::from("/nonexistent/model.safetensors"),
960 canonical_main: None,
961 codec: PathBuf::from("/nonexistent/codec"),
962 };
963 let mut resident = None;
964 handle_connection(stream, &bundle, "right-token", 0, &mut resident);
965 });
966 let mut stream = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
967 let request = json!({
968 "protocol": PROTOCOL,
969 "op": "synthesize",
970 "token": "wrong-token",
971 "version": env!("CARGO_PKG_VERSION"),
972 "text": "hi",
973 "normalize": "verbatim",
974 "speaker": [0.0],
975 "seed": 0,
976 });
977 stream.write_all(format!("{request}\n").as_bytes()).unwrap();
978 let mut reply = String::new();
979 BufReader::new(&mut stream).read_line(&mut reply).unwrap();
980 let value: Value = serde_json::from_str(&reply).unwrap();
981 assert_eq!(value.get("ok").and_then(Value::as_bool), Some(false));
982 assert_eq!(value.get("kind").and_then(Value::as_str), Some("auth"));
983 handle.join().unwrap();
984 }
985}