Skip to main content

ftts_cli/
resident.rs

1//! Resident engine: a per-user background process that keeps the loaded model in memory
2//! so consecutive `ftts say` invocations skip the multi-second model load.
3//!
4//! Shape: `ftts say` connects to a loopback TCP daemon (spawned on demand from the same
5//! binary) and sends one synthesis request; the daemon holds the hydrated [`LoadedModel`]
6//! and [`TtsEngine`](ftts_core::TtsEngine) between requests and exits by itself after a
7//! configurable idle period (default ten minutes). Everything else — argument handling,
8//! voice resolution, robot events, output writing — stays in the client process, so the
9//! observable contract of `ftts say` is unchanged.
10//!
11//! Loopback TCP is the one transport that behaves identically on Linux, macOS, and
12//! Windows with only the standard library. Access control is the state file, not the
13//! port: the daemon binds an ephemeral 127.0.0.1 port and writes `{port, token, …}` to a
14//! file only the invoking user can read (0600 on Unix; the per-user profile directory's
15//! ACL on Windows), and every request must present that token. Texts are sensitive, so
16//! the daemon holds no history: requests are served from memory and dropped.
17//!
18//! Failure philosophy: the resident path may only ever make `say` faster, never break
19//! it. Every transport-level problem (no daemon, stale state file, version or artifact
20//! mismatch, malformed reply) falls back to the classic in-process load. Only a genuine
21//! synthesis error crosses the wire as an error, carrying its exit-code class.
22
23use 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
37/// Default idle unload period. Overridable through `FTTS_RESIDENT_IDLE_SECS`, mostly so
38/// tests can use sub-second daemons.
39const DEFAULT_IDLE: Duration = Duration::from_secs(600);
40
41/// How long the client is willing to wait for a reply. Synthesis of a long paragraph is
42/// minutes at f32-reference speed; ten minutes matches the engine's own budget spirit.
43/// `FTTS_RESIDENT_CLIENT_TIMEOUT_SECS` overrides it, which the e2e suite uses to stay
44/// valid on machines whose debug-build synthesis is slower than the production budget.
45const 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        // Zero is rejected rather than honored: `set_read_timeout(Some(ZERO))` is an error
52        // in std, so a literal 0 would fail every connect, orphan a healthy daemon, and eat
53        // the full spawn wait on every run.
54        .filter(|&seconds| seconds > 0)
55        .map_or(DEFAULT_CLIENT_READ_TIMEOUT, Duration::from_secs)
56}
57
58/// How long the client waits for a freshly spawned daemon to write its state file and
59/// accept. The daemon binds before loading the model, so this covers process start only;
60/// thirty seconds absorbs a first-launch antivirus scan of the binary on Windows, which
61/// measured well past ten seconds on a Surface Book. `FTTS_RESIDENT_SPAWN_WAIT_SECS`
62/// overrides it.
63const 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
74/// One synthesis request as it crosses the wire. Everything the daemon needs to rebuild
75/// the exact [`SynthesisRequest`] the client would have run inline.
76pub struct WireRequest<'a> {
77    pub text: &'a str,
78    /// `NormalizeMode::as_str` form; parsed back with [`parse_normalize`].
79    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
101/// Whether the resident path is enabled at all: on by default, disabled by the `say`
102/// flag or by `FTTS_NO_RESIDENT=1` for scripts that cannot pass flags.
103pub 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
113// ---------------------------------------------------------------------------- state file
114
115fn 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)] // un-deprecated in current Rust; the lint fires on older stables
120    std::env::home_dir().map(|home| home.join(".cache/franken_tts"))
121}
122
123/// A short stable digest of the bundle root, so distinct model directories get distinct
124/// daemons. `RandomState` keys vary per process, so this hand-rolls FNV-1a instead.
125///
126/// The path is canonicalized first: `./model` from two different working directories must
127/// key two different daemons (they are different models), while `/x/m` and `/x//m` and a
128/// symlinked spelling of the same directory must share one. Hashing the raw string gave
129/// the opposite of both. A path that cannot be canonicalized (not yet created, permission)
130/// falls back to its literal spelling — resolution refuses such roots before spawn anyway.
131fn 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    // Write-then-rename so a client never reads a half-written file. The staging file is
176    // born 0600 on unix — the token is inside, so there must be no umask-window in which
177    // another user can read it before a later chmod.
178    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
197/// 128 bits of `RandomState` entropy (seeded from the OS) as the session token. The real
198/// gate is the 0600 state file; the token binds a socket connection to that file.
199fn 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
210/// The artifact identity the daemon pins at load: a re-pull or re-convert must not be
211/// served from a stale resident model.
212fn 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
218        .modified()
219        .ok()
220        .and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
221        .map_or(0, |d| d.as_secs());
222    (mtime, meta.len())
223}
224
225// ------------------------------------------------------------------------------- client
226
227/// Try to synthesize through a resident daemon, spawning one if none is listening.
228///
229/// `Ok(None)` means "no resident path available, run inline" and is always safe; the
230/// daemon spawned in the background (if any) will serve the NEXT invocation. `Ok(Some)`
231/// is a completed synthesis; `Err` is a genuine synthesis error from the daemon, carrying
232/// the same exit-code class the inline path would have produced.
233pub fn try_synthesize(
234    bundle: &ModelBundle,
235    request: &WireRequest<'_>,
236) -> Result<Option<SynthesizedAudio>, FttsError> {
237    // JSON cannot carry NaN or infinity (serde_json writes null), so a speaker vector
238    // containing one would silently shrink in transit. The inline path passes such a
239    // vector through verbatim; parity therefore requires skipping the wire entirely.
240    if request.speaker.iter().any(|value| !value.is_finite()) {
241        return Ok(None);
242    }
243    match connect(bundle) {
244        Some(stream) => roundtrip(stream, bundle, request),
245        None => Ok(None),
246    }
247}
248
249fn connect(bundle: &ModelBundle) -> Option<TcpStream> {
250    // A live daemon answers immediately; under heavy load one connect can miss its
251    // timeout, and treating that as death would orphan a healthy daemon and briefly
252    // double model memory with a duplicate. Three attempts before giving up on it.
253    for attempt in 0..3 {
254        if attempt > 0 {
255            std::thread::sleep(Duration::from_millis(300));
256        }
257        if let Some(stream) = connect_once(bundle) {
258            return Some(stream);
259        }
260        if read_state(&bundle.root).is_none() {
261            break; // no state file at all: nothing to retry against
262        }
263    }
264    // None listening: clear any stale state file and spawn one from this same binary.
265    if let Some(path) = state_path(&bundle.root)
266        && path.exists()
267    {
268        let _ = fs::remove_file(&path);
269    }
270    spawn_daemon(&bundle.root)?;
271    let deadline = Instant::now() + spawn_wait();
272    while Instant::now() < deadline {
273        if let Some(stream) = connect_once(bundle) {
274            return Some(stream);
275        }
276        std::thread::sleep(Duration::from_millis(50));
277    }
278    None
279}
280
281fn connect_once(bundle: &ModelBundle) -> Option<TcpStream> {
282    let state = read_state(&bundle.root)?;
283    let stream = TcpStream::connect_timeout(
284        &(Ipv4Addr::LOCALHOST, state.port).into(),
285        Duration::from_millis(1000),
286    )
287    .ok()?;
288    stream.set_read_timeout(Some(client_read_timeout())).ok()?;
289    stream.set_nodelay(true).ok();
290    Some(stream)
291}
292
293fn spawn_daemon(root: &Path) -> Option<()> {
294    let exe = std::env::current_exe().ok()?;
295    let mut command = Command::new(exe);
296    command
297        .arg("resident-daemon")
298        .arg("--bundle-root")
299        .arg(root)
300        .stdin(std::process::Stdio::null())
301        .stdout(std::process::Stdio::null())
302        .stderr(std::process::Stdio::null());
303    // Field diagnostics: the daemon's stderr is discarded by default; FTTS_RESIDENT_LOG
304    // routes it to a file instead, which is how a silent failure to serve gets a voice.
305    if let Ok(log) = std::env::var("FTTS_RESIDENT_LOG")
306        && let Ok(file) = fs::OpenOptions::new().create(true).append(true).open(&log)
307        && let Ok(err) = file.try_clone()
308    {
309        command.stdout(file).stderr(err);
310    }
311    #[cfg(windows)]
312    {
313        use std::os::windows::process::CommandExt;
314        // DETACHED_PROCESS | CREATE_NO_WINDOW: outlive the console, show nothing.
315        command.creation_flags(0x0000_0008 | 0x0800_0000);
316    }
317    command.spawn().ok().map(|_child| ())
318}
319
320fn roundtrip(
321    mut stream: TcpStream,
322    bundle: &ModelBundle,
323    request: &WireRequest<'_>,
324) -> Result<Option<SynthesizedAudio>, FttsError> {
325    let state = match read_state(&bundle.root) {
326        Some(state) => state,
327        None => return Ok(None),
328    };
329    let header = json!({
330        "protocol": PROTOCOL,
331        "op": "synthesize",
332        "token": state.token,
333        "version": env!("CARGO_PKG_VERSION"),
334        "bundle_root": bundle.root.to_string_lossy(),
335        "text": request.text,
336        "normalize": request.normalize,
337        "trace": request.trace,
338        "speaker": request.speaker,
339        "seed": request.seed,
340    });
341    if stream
342        .write_all(format!("{header}\n").as_bytes())
343        .and_then(|()| stream.flush())
344        .is_err()
345    {
346        return Ok(None);
347    }
348
349    let mut reader = BufReader::new(stream);
350    let mut line = String::new();
351    if reader.read_line(&mut line).is_err() || line.trim().is_empty() {
352        return Ok(None);
353    }
354    let Ok(reply) = serde_json::from_str::<Value>(&line) else {
355        return Ok(None);
356    };
357    if reply.get("ok").and_then(Value::as_bool) == Some(true) {
358        // The sample count is wire data. Bound it before it sizes an allocation: at 24 kHz
359        // this cap is over two hours of audio, far past anything the engine can produce,
360        // and a corrupt or mismatched daemon claiming more falls back inline instead of
361        // driving a multi-gigabyte (or, unchecked, overflowing) allocation.
362        const MAX_WIRE_SAMPLES: u64 = 200_000_000;
363        let samples = reply
364            .get("samples")
365            .and_then(Value::as_u64)
366            .filter(|&n| n <= MAX_WIRE_SAMPLES)
367            .and_then(|n| usize::try_from(n).ok())
368            .unwrap_or(0);
369        let mut bytes = vec![0u8; samples * 4];
370        if reader.read_exact(&mut bytes).is_err() {
371            return Ok(None);
372        }
373        let (chunks, _remainder) = bytes.as_chunks::<4>();
374        let pcm = chunks
375            .iter()
376            .map(|chunk| f32::from_le_bytes(*chunk))
377            .collect();
378        return Ok(Some(SynthesizedAudio {
379            pcm,
380            frames: reply.get("frames").and_then(Value::as_u64).unwrap_or(0),
381            prepared_token_count: reply
382                .get("prepared_token_count")
383                .and_then(Value::as_u64)
384                .unwrap_or(0) as usize,
385            ttfa: reply
386                .get("ttfa_ms")
387                .and_then(Value::as_u64)
388                .map(Duration::from_millis),
389        }));
390    }
391    // A synthesis error is real and final; anything transport-shaped means fallback.
392    match reply.get("kind").and_then(Value::as_str) {
393        Some("synthesis") => {
394            let code = reply
395                .get("exit_code")
396                .and_then(Value::as_u64)
397                .unwrap_or(FttsExitCode::Generic.as_u8().into());
398            let message = reply
399                .get("message")
400                .and_then(Value::as_str)
401                .unwrap_or("resident synthesis failed")
402                .to_owned();
403            Err(wire_error(code, message))
404        }
405        _ => Ok(None),
406    }
407}
408
409fn wire_error(exit_code: u64, message: String) -> FttsError {
410    match exit_code {
411        3 => FttsError::ModelNotFound(message),
412        4 => FttsError::Input(message),
413        5 => FttsError::BudgetTimeout(message),
414        7 => FttsError::ArtifactFormat(message),
415        8 => FttsError::EnrollmentQualityRefusal(message),
416        _ => FttsError::Generic(message),
417    }
418}
419
420// ------------------------------------------------------------------------------- daemon
421
422/// Run the resident daemon until the idle period passes without a request.
423///
424/// Binds first and loads the model lazily on the first request, so the process is
425/// connectable within milliseconds of spawning and a daemon that never gets a request
426/// costs no model memory before its idle exit.
427pub fn run_daemon(bundle_root: &Path) -> Result<(), FttsError> {
428    let bundle = ModelBundle::resolve(bundle_root)?;
429    let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
430        .map_err(|error| FttsError::Generic(format!("resident daemon cannot bind: {error}")))?;
431    let port = listener
432        .local_addr()
433        .map_err(|error| FttsError::Generic(format!("resident daemon has no address: {error}")))?
434        .port();
435    let token = fresh_token();
436    let state_file = write_state(&bundle.root, port, &token)
437        .map_err(|error| FttsError::Generic(format!("cannot write resident state: {error}")))?;
438    listener
439        .set_nonblocking(true)
440        .map_err(|error| FttsError::Generic(format!("resident daemon socket mode: {error}")))?;
441    eprintln!(
442        "resident daemon serving {} on 127.0.0.1:{port}",
443        bundle.root.display()
444    );
445
446    let idle = idle_period();
447    let mut resident: Option<(LoadedModel, ftts_core::TtsEngine, (u64, u64))> = None;
448    let mut deadline = Instant::now() + idle;
449
450    loop {
451        match listener.accept() {
452            Ok((stream, _peer)) => {
453                // Serve strictly serially; a second client queues in the OS backlog.
454                //
455                // Isolated from panics on purpose. This daemon exists to hold a hydrated 2 GB
456                // model across many calls, so one malformed request must not cost every later
457                // caller that work: without this, a panic anywhere in request handling unwinds
458                // straight out of the accept loop and the process dies holding the only warm copy.
459                //
460                // `AssertUnwindSafe` is sound for `resident` because it is only ever replaced
461                // wholesale (`*resident = Some(...)` after the model is fully built), never mutated
462                // in place, so an unwind can leave it either untouched or fully valid — there is no
463                // half-initialized state for a later request to observe.
464                let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
465                    handle_connection(stream, &bundle, &token, port, &mut resident);
466                }));
467                if outcome.is_err() {
468                    // The panic hook has already printed the location; say what it cost.
469                    eprintln!("resident daemon: request panicked; connection dropped, model kept");
470                }
471                deadline = Instant::now() + idle;
472            }
473            Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
474                if Instant::now() >= deadline {
475                    eprintln!("resident daemon idle exit");
476                    remove_state_if_ours(&state_file, port);
477                    return Ok(());
478                }
479                std::thread::sleep(Duration::from_millis(100));
480            }
481            Err(_) => {
482                remove_state_if_ours(&state_file, port);
483                return Ok(());
484            }
485        }
486    }
487}
488
489/// Removes the state file only when it still describes THIS daemon.
490///
491/// Two `say` invocations racing on a cold start both spawn a daemon; both write the same
492/// state path and the second write wins, orphaning the first. The orphan serves nobody and
493/// idle-exits ten minutes later — and an unconditional remove at that exit would delete the
494/// SUCCESSOR's state file, making the healthy daemon undiscoverable and spawning a third
495/// copy of the model. Retirement cascades forever that way, one duplicate model per idle
496/// period. A retiring daemon therefore checks that the file still names its own port.
497fn remove_state_if_ours(state_file: &Path, port: u16) {
498    let ours = fs::read_to_string(state_file)
499        .ok()
500        .and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
501        .and_then(|value| value.get("port")?.as_u64())
502        .is_some_and(|recorded| recorded == u64::from(port));
503    if ours {
504        let _ = fs::remove_file(state_file);
505    }
506}
507
508#[allow(clippy::type_complexity)]
509fn handle_connection(
510    stream: TcpStream,
511    bundle: &ModelBundle,
512    token: &str,
513    port: u16,
514    resident: &mut Option<(LoadedModel, ftts_core::TtsEngine, (u64, u64))>,
515) {
516    let _ = stream.set_nonblocking(false);
517    let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
518    // A write timeout is the daemon's survival: replies are written inline in the accept
519    // loop, so one client that stops reading (SIGSTOP, wedged pipe) would otherwise fill
520    // the send buffer and park this thread in `write_all` forever — and every later
521    // `ftts say` would then hang against a daemon that accepts but never answers. Sixty
522    // seconds per write on loopback is indistinguishable from a dead peer.
523    let _ = stream.set_write_timeout(Some(Duration::from_secs(60)));
524    let _ = stream.set_nodelay(true);
525
526    // BOUNDED read, and the bound is the point: `read_line` on a raw socket grows its String
527    // until it meets a newline, so a peer that opens a connection and sends bytes forever (or
528    // simply never sends `\n`) drives this process to OOM. That happens BEFORE the token is
529    // checked, so it is reachable by any local process, authenticated or not.
530    //
531    // The cap is generous against the largest legitimate request — a 1,024-float speaker vector
532    // serialized as JSON text runs on the order of 20 KB — and still bounds the damage.
533    const MAX_REQUEST_BYTES: u64 = 1024 * 1024;
534    let mut reader = BufReader::new(stream);
535    let mut line = String::new();
536    if (&mut reader)
537        .take(MAX_REQUEST_BYTES)
538        .read_line(&mut line)
539        .is_err()
540    {
541        return;
542    }
543    let mut stream = reader.into_inner();
544    // A request that filled the cap without terminating is refused rather than parsed: the JSON
545    // would be truncated anyway, and saying so beats a silent disconnect.
546    if line.len() as u64 >= MAX_REQUEST_BYTES {
547        let reply = json!({ "ok": false, "kind": "request", "message": "request too large" });
548        let _ = stream.write_all(format!("{reply}\n").as_bytes());
549        return;
550    }
551    let Ok(request) = serde_json::from_str::<Value>(&line) else {
552        return;
553    };
554
555    let refuse = |stream: &mut TcpStream, kind: &str, message: &str| {
556        let reply = json!({ "ok": false, "kind": kind, "message": message });
557        let _ = stream.write_all(format!("{reply}\n").as_bytes());
558    };
559
560    // Token first: an unauthenticated peer learns nothing but "no".
561    if request.get("token").and_then(Value::as_str) != Some(token) {
562        refuse(&mut stream, "auth", "bad token");
563        return;
564    }
565    if request.get("protocol").and_then(Value::as_u64) != Some(PROTOCOL)
566        || request.get("version").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION"))
567    {
568        // A different binary version must not be served by this process; the client falls
569        // back inline and this daemon retires so the next spawn matches.
570        refuse(&mut stream, "version", "resident daemon version mismatch");
571        // Ownership-checked for the same reason as the idle exit: the state file may
572        // already belong to a successor daemon spawned between the client's read and now.
573        if let Some(state) = state_path(&bundle.root) {
574            remove_state_if_ours(&state, port);
575        }
576        std::process::exit(0);
577    }
578
579    // The client says which bundle root it thinks it is talking to; a daemon keyed by a
580    // colliding or stale state file must refuse rather than synthesize with the wrong
581    // model. Compared canonically so a different spelling of the same directory passes.
582    if let Some(wire_root) = request.get("bundle_root").and_then(Value::as_str) {
583        let wire_canonical =
584            fs::canonicalize(wire_root).unwrap_or_else(|_| PathBuf::from(wire_root));
585        let own_canonical = fs::canonicalize(&bundle.root).unwrap_or_else(|_| bundle.root.clone());
586        if wire_canonical != own_canonical {
587            refuse(
588                &mut stream,
589                "request",
590                "resident daemon serves a different bundle root",
591            );
592            return;
593        }
594    }
595
596    // A re-pulled or re-converted artifact invalidates the resident weights.
597    let stamp_now = artifact_stamp(bundle);
598    if let Some((_, _, loaded_stamp)) = resident.as_ref()
599        && *loaded_stamp != stamp_now
600    {
601        refuse(&mut stream, "stale", "model artifact changed since load");
602        if let Some(state) = state_path(&bundle.root) {
603            remove_state_if_ours(&state, port);
604        }
605        std::process::exit(0);
606    }
607
608    let text = request.get("text").and_then(Value::as_str).unwrap_or("");
609    let normalize = request
610        .get("normalize")
611        .and_then(Value::as_str)
612        .and_then(parse_normalize);
613    let trace = request
614        .get("trace")
615        .and_then(Value::as_bool)
616        .unwrap_or(false);
617    let seed = request.get("seed").and_then(Value::as_u64).unwrap_or(0);
618    // The speaker vector is validated rather than salvaged, and both halves of that matter.
619    //
620    // `filter_map(as_f64)` used to DROP entries that were not numbers, so `[1.0, "x", 2.0]`
621    // silently became a 2-element vector — a malformed request quietly became a different,
622    // well-formed one, conditioning generation on the wrong thing.
623    //
624    // Non-finite values are worse. A NaN or infinity reaching the Q8 quantizer trips its
625    // `is_finite` assertion, and because `handle_connection` runs inline in the accept loop a
626    // panic there takes down the daemon serving every other caller. Refusing here keeps a bad
627    // request a bad request instead of an outage.
628    let speaker: Vec<f32> = match request.get("speaker").and_then(Value::as_array) {
629        Some(values) => {
630            let mut vector = Vec::with_capacity(values.len());
631            for value in values {
632                let Some(number) = value.as_f64() else {
633                    refuse(
634                        &mut stream,
635                        "request",
636                        "speaker vector contains a non-numeric entry",
637                    );
638                    return;
639                };
640                #[allow(clippy::cast_possible_truncation)]
641                let narrowed = number as f32;
642                if !narrowed.is_finite() {
643                    refuse(
644                        &mut stream,
645                        "request",
646                        "speaker vector contains a non-finite value",
647                    );
648                    return;
649                }
650                vector.push(narrowed);
651            }
652            vector
653        }
654        None => Vec::new(),
655    };
656    let Some(mode) = normalize else {
657        refuse(&mut stream, "request", "unknown normalize mode");
658        return;
659    };
660    if text.is_empty() || speaker.is_empty() {
661        refuse(&mut stream, "request", "empty text or speaker");
662        return;
663    }
664
665    // Lazy hydration: the expensive part, done once and kept.
666    if resident.is_none() {
667        let loaded = match LoadedModel::load(bundle) {
668            Ok(loaded) => loaded,
669            Err(error) => {
670                let reply = json!({
671                    "ok": false,
672                    "kind": "synthesis",
673                    "exit_code": error.exit_code().as_u8(),
674                    "message": error.to_string(),
675                });
676                let _ = stream.write_all(format!("{reply}\n").as_bytes());
677                return;
678            }
679        };
680        let engine = match ftts_core::TtsEngine::from_process_environment() {
681            Ok(engine) => engine,
682            Err(error) => {
683                refuse(
684                    &mut stream,
685                    "engine",
686                    &format!("engine start failed: {error}"),
687                );
688                return;
689            }
690        };
691        *resident = Some((loaded, engine, stamp_now));
692    }
693    let (loaded, engine, _) = resident.as_ref().expect("hydrated just above");
694
695    let synthesis_request = SynthesisRequest::new(text.to_owned())
696        .with_normalization_options(NormalizationOptions {
697            mode,
698            ..NormalizationOptions::default()
699        })
700        .with_normalization_trace(trace);
701    let cancellation = ftts_core::CancellationToken::new();
702    let observer = |_event: ftts_core::SynthesisEvent| {};
703    match crate::synth::synthesize(
704        loaded,
705        engine,
706        &synthesis_request,
707        &speaker,
708        seed,
709        &cancellation,
710        &observer,
711    ) {
712        Ok(audio) => {
713            let header = json!({
714                "ok": true,
715                "samples": audio.pcm.len(),
716                "frames": audio.frames,
717                "prepared_token_count": audio.prepared_token_count,
718                "ttfa_ms": audio.ttfa.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)),
719            });
720            if stream.write_all(format!("{header}\n").as_bytes()).is_err() {
721                return;
722            }
723            let mut bytes = Vec::with_capacity(audio.pcm.len() * 4);
724            for sample in &audio.pcm {
725                bytes.extend_from_slice(&sample.to_le_bytes());
726            }
727            let _ = stream.write_all(&bytes);
728            let _ = stream.flush();
729        }
730        Err(error) => {
731            let reply = json!({
732                "ok": false,
733                "kind": "synthesis",
734                "exit_code": error.exit_code().as_u8(),
735                "message": error.to_string(),
736            });
737            let _ = stream.write_all(format!("{reply}\n").as_bytes());
738        }
739    }
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745
746    #[test]
747    fn normalize_labels_round_trip() {
748        for label in ["verbatim", "conservative", "locale-aware"] {
749            assert!(parse_normalize(label).is_some(), "{label}");
750        }
751        assert!(parse_normalize("aggressive").is_none());
752    }
753
754    /// A peer that never sends a newline must not be able to grow the daemon's memory without
755    /// bound. This drives the real socket path, because the bug lived in `read_line`'s contract
756    /// rather than in any parsing we control.
757    #[test]
758    fn an_endless_request_line_is_bounded_not_fatal() {
759        use std::io::Write as _;
760        use std::net::TcpListener;
761
762        let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
763        let port = listener.local_addr().unwrap().port();
764
765        // A client that opens a connection and streams bytes with no terminator, forever.
766        let flood = std::thread::spawn(move || {
767            let Ok(mut stream) = TcpStream::connect((Ipv4Addr::LOCALHOST, port)) else {
768                return;
769            };
770            let block = vec![b'x'; 64 * 1024];
771            // Stops on the first error, which is what happens once the server hangs up.
772            while stream.write_all(&block).is_ok() {}
773        });
774
775        let (stream, _) = listener.accept().unwrap();
776        let mut reader = BufReader::new(stream);
777        let mut line = String::new();
778        const MAX: u64 = 1024 * 1024;
779        let read = (&mut reader).take(MAX).read_line(&mut line).unwrap_or(0);
780
781        assert!(
782            read as u64 <= MAX,
783            "read {read} bytes, past the {MAX}-byte cap"
784        );
785        assert!(
786            line.len() as u64 <= MAX,
787            "buffered {} bytes, past the cap",
788            line.len()
789        );
790        drop(reader);
791        let _ = flood.join();
792    }
793
794    /// Malformed speaker vectors are refused, never silently repaired.
795    ///
796    /// The old code used `filter_map(as_f64)`, so a non-numeric entry was DROPPED and the request
797    /// proceeded with a shorter vector — a malformed request quietly becoming a different,
798    /// well-formed one. Non-finite values were worse: they reach the Q8 quantizer's `is_finite`
799    /// assertion, and a panic there used to take the whole daemon down with it.
800    #[test]
801    fn speaker_vectors_are_validated_rather_than_salvaged() {
802        // Mirrors the parsing in `handle_connection` exactly.
803        fn parse(values: &[Value]) -> Result<Vec<f32>, &'static str> {
804            let mut vector = Vec::with_capacity(values.len());
805            for value in values {
806                let number = value.as_f64().ok_or("non-numeric")?;
807                let narrowed = number as f32;
808                if !narrowed.is_finite() {
809                    return Err("non-finite");
810                }
811                vector.push(narrowed);
812            }
813            Ok(vector)
814        }
815
816        assert_eq!(parse(&[json!(1.0), json!(-2.5)]).unwrap(), vec![1.0, -2.5]);
817        assert_eq!(
818            parse(&[json!(1.0), json!("x"), json!(2.0)]),
819            Err("non-numeric"),
820            "a non-numeric entry must be refused, not dropped"
821        );
822        assert_eq!(parse(&[json!(null)]), Err("non-numeric"));
823        // JSON has no NaN literal, so the reachable non-finite case is an f64 too large for f32.
824        assert_eq!(
825            parse(&[json!(1e300)]),
826            Err("non-finite"),
827            "an f64 that overflows f32 becomes infinity and must be refused"
828        );
829    }
830
831    #[test]
832    fn wire_errors_keep_their_exit_class() {
833        let cases = [
834            (3u64, FttsExitCode::ModelNotFound),
835            (4, FttsExitCode::Input),
836            (5, FttsExitCode::BudgetTimeout),
837            (7, FttsExitCode::ArtifactFormat),
838            (8, FttsExitCode::EnrollmentQualityRefusal),
839            (1, FttsExitCode::Generic),
840            (99, FttsExitCode::Generic),
841        ];
842        for (code, expected) in cases {
843            assert_eq!(wire_error(code, String::new()).exit_code(), expected);
844        }
845    }
846
847    #[test]
848    fn root_digest_distinguishes_roots_and_is_stable() {
849        let a = root_digest(Path::new("/tmp/model-a"));
850        let b = root_digest(Path::new("/tmp/model-b"));
851        assert_ne!(a, b);
852        assert_eq!(a, root_digest(Path::new("/tmp/model-a")));
853    }
854
855    #[test]
856    fn tokens_are_distinct_and_hex() {
857        let one = fresh_token();
858        let two = fresh_token();
859        assert_eq!(one.len(), 32);
860        assert!(one.bytes().all(|b| b.is_ascii_hexdigit()));
861        assert_ne!(one, two, "two RandomState-seeded tokens collided");
862    }
863
864    #[test]
865    fn state_file_round_trips_and_respects_dir_override() {
866        let dir = std::env::temp_dir().join(format!("ftts-resident-test-{}", std::process::id()));
867        // SAFETY-free env mutation: tests in this module run single-threaded per process
868        // under `cargo test` only when isolated; use the dir directly instead of the env.
869        let root = Path::new("/tmp/some-model-root");
870        let path = dir.join(format!("resident-{:016x}.json", root_digest(root)));
871        fs::create_dir_all(&dir).unwrap();
872        let body =
873            json!({"port": 45123, "token": "abc123", "pid": 1, "version": "x", "bundle_root": "y"});
874        fs::write(&path, body.to_string()).unwrap();
875        let raw = fs::read_to_string(&path).unwrap();
876        let value: Value = serde_json::from_str(&raw).unwrap();
877        assert_eq!(value.get("port").and_then(Value::as_u64), Some(45123));
878        assert_eq!(value.get("token").and_then(Value::as_str), Some("abc123"));
879        let _ = fs::remove_file(&path);
880    }
881
882    /// The daemon protocol refuses a bad token and answers nothing else. Uses a raw
883    /// socket against `handle_connection` semantics through a real listener thread.
884    #[test]
885    fn daemon_refuses_bad_token() {
886        let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
887        let port = listener.local_addr().unwrap().port();
888        let handle = std::thread::spawn(move || {
889            let (stream, _) = listener.accept().unwrap();
890            let bundle = ModelBundle {
891                root: PathBuf::from("/nonexistent"),
892                main: PathBuf::from("/nonexistent/model.safetensors"),
893                canonical_main: None,
894                codec: PathBuf::from("/nonexistent/codec"),
895            };
896            let mut resident = None;
897            handle_connection(stream, &bundle, "right-token", 0, &mut resident);
898        });
899        let mut stream = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
900        let request = json!({
901            "protocol": PROTOCOL,
902            "op": "synthesize",
903            "token": "wrong-token",
904            "version": env!("CARGO_PKG_VERSION"),
905            "text": "hi",
906            "normalize": "verbatim",
907            "speaker": [0.0],
908            "seed": 0,
909        });
910        stream.write_all(format!("{request}\n").as_bytes()).unwrap();
911        let mut reply = String::new();
912        BufReader::new(&mut stream).read_line(&mut reply).unwrap();
913        let value: Value = serde_json::from_str(&reply).unwrap();
914        assert_eq!(value.get("ok").and_then(Value::as_bool), Some(false));
915        assert_eq!(value.get("kind").and_then(Value::as_str), Some("auth"));
916        handle.join().unwrap();
917    }
918}