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    // Full nanosecond mtime, not whole seconds: a re-convert landing within the same second
218    // at the same byte length used to stamp identical and be served stale. Filesystems with
219    // coarse timestamps degrade gracefully (the nanos are just zero there). The `(0, 0)`
220    // stat-failed sentinel remains distinguishable in practice because a real artifact is
221    // never zero-length.
222    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
230// ------------------------------------------------------------------------------- client
231
232/// Try to synthesize through a resident daemon, spawning one if none is listening.
233///
234/// `Ok(None)` means "no resident path available, run inline" and is always safe; the
235/// daemon spawned in the background (if any) will serve the NEXT invocation. `Ok(Some)`
236/// is a completed synthesis; `Err` is a genuine synthesis error from the daemon, carrying
237/// the same exit-code class the inline path would have produced.
238pub fn try_synthesize(
239    bundle: &ModelBundle,
240    request: &WireRequest<'_>,
241) -> Result<Option<SynthesizedAudio>, FttsError> {
242    // JSON cannot carry NaN or infinity (serde_json writes null), so a speaker vector
243    // containing one would silently shrink in transit. The inline path passes such a
244    // vector through verbatim; parity therefore requires skipping the wire entirely.
245    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
254/// Why one connect attempt did not produce a stream — the removal decision needs the kind.
255enum ConnectFailure {
256    /// No state file exists; there is nothing to retry against and nothing to clear.
257    NoState,
258    /// The kernel actively refused: nothing is listening on the recorded port, so the state
259    /// file is provably stale and safe to clear.
260    Refused,
261    /// A timeout or any other error: the daemon may be alive but slow or mid-request, and
262    /// deleting its state file would orphan a healthy multi-gigabyte process.
263    Other,
264}
265
266fn connect(bundle: &ModelBundle) -> Option<TcpStream> {
267    // A live daemon answers immediately; under heavy load one connect can miss its
268    // timeout, and treating that as death would orphan a healthy daemon and briefly
269    // double model memory with a duplicate. Three attempts before giving up on it.
270    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; // no state file at all: nothing to retry against
280            }
281            Err(failure) => last = failure,
282        }
283    }
284    match last {
285        // Provably nothing listening: clear the stale state and spawn a fresh daemon.
286        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        // Possibly a live-but-busy daemon. Removing its state here was the ownership hole:
295        // the daemon would be orphaned (never again findable, idling ~2 GB until its timer)
296        // while a duplicate spawned beside it. Fall back to the inline engine instead and
297        // leave the daemon to answer the next run.
298        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    // Field diagnostics: the daemon's stderr is discarded by default; FTTS_RESIDENT_LOG
342    // routes it to a file instead, which is how a silent failure to serve gets a voice.
343    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        // DETACHED_PROCESS | CREATE_NO_WINDOW: outlive the console, show nothing.
353        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        // The sample count is wire data. Bound it before it sizes an allocation: at 24 kHz
397        // this cap is over two hours of audio, far past anything the engine can produce,
398        // and a corrupt or mismatched daemon claiming more falls back inline instead of
399        // driving a multi-gigabyte (or, unchecked, overflowing) allocation.
400        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    // A synthesis error is real and final; anything transport-shaped means fallback.
430    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
458// ------------------------------------------------------------------------------- daemon
459
460/// Run the resident daemon until the idle period passes without a request.
461///
462/// Binds first and loads the model lazily on the first request, so the process is
463/// connectable within milliseconds of spawning and a daemon that never gets a request
464/// costs no model memory before its idle exit.
465pub 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                // Serve strictly serially; a second client queues in the OS backlog.
492                //
493                // Isolated from panics on purpose. This daemon exists to hold a hydrated 2 GB
494                // model across many calls, so one malformed request must not cost every later
495                // caller that work: without this, a panic anywhere in request handling unwinds
496                // straight out of the accept loop and the process dies holding the only warm copy.
497                //
498                // `AssertUnwindSafe` is sound for `resident` because it is only ever replaced
499                // wholesale (`*resident = Some(...)` after the model is fully built), never mutated
500                // in place, so an unwind can leave it either untouched or fully valid — there is no
501                // half-initialized state for a later request to observe.
502                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                    // The panic hook has already printed the location; say what it cost.
507                    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
527/// Removes the state file only when it still describes THIS daemon.
528///
529/// Two `say` invocations racing on a cold start both spawn a daemon; both write the same
530/// state path and the second write wins, orphaning the first. The orphan serves nobody and
531/// idle-exits ten minutes later — and an unconditional remove at that exit would delete the
532/// SUCCESSOR's state file, making the healthy daemon undiscoverable and spawning a third
533/// copy of the model. Retirement cascades forever that way, one duplicate model per idle
534/// period. A retiring daemon therefore checks that the file still names its own port.
535fn 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    // A write timeout is the daemon's survival: replies are written inline in the accept
557    // loop, so one client that stops reading (SIGSTOP, wedged pipe) would otherwise fill
558    // the send buffer and park this thread in `write_all` forever — and every later
559    // `ftts say` would then hang against a daemon that accepts but never answers. Sixty
560    // seconds per write on loopback is indistinguishable from a dead peer.
561    let _ = stream.set_write_timeout(Some(Duration::from_secs(60)));
562    let _ = stream.set_nodelay(true);
563
564    // BOUNDED read, in bytes AND in time, and both bounds are the point. The byte cap:
565    // a peer that opens a connection and sends bytes forever (or never sends `\n`) must not
566    // drive this process to OOM — that happens BEFORE the token check, so it is reachable
567    // by any local process. The deadline: the 10 s read timeout above is per SYSCALL, so a
568    // peer trickling one byte every nine seconds held this single-threaded accept loop
569    // quasi-indefinitely, pre-auth, with bounded memory but unbounded time. A `read_line`
570    // loops internally across syscalls and never surfaces between them, which is why this
571    // is a manual chunk loop with the deadline checked at every hop.
572    //
573    // The byte cap is generous against the largest legitimate request — a 1,024-float
574    // speaker vector serialized as JSON text runs on the order of 20 KB.
575    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; // transport-shaped failure: drop, like every other read error here
584        }
585        match stream.read(&mut chunk) {
586            Ok(0) => return, // EOF without a terminated request
587            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                // A request that filled the cap without terminating is refused rather than
593                // parsed: the JSON would be truncated anyway, and saying so beats a silent
594                // disconnect.
595                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, // per-syscall timeout or hard error
603        }
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    // Token first: an unauthenticated peer learns nothing but "no". Compared in constant
615    // time (XOR-fold over the full length) so a loopback peer cannot walk the token byte by
616    // byte off an early-exit comparison — theoretical against 128 bits, free to close.
617    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        // A different binary version must not be served by this process; the client falls
636        // back inline and this daemon retires so the next spawn matches.
637        refuse(&mut stream, "version", "resident daemon version mismatch");
638        // Ownership-checked for the same reason as the idle exit: the state file may
639        // already belong to a successor daemon spawned between the client's read and now.
640        if let Some(state) = state_path(&bundle.root) {
641            remove_state_if_ours(&state, port);
642        }
643        std::process::exit(0);
644    }
645
646    // The client says which bundle root it thinks it is talking to; a daemon keyed by a
647    // colliding or stale state file must refuse rather than synthesize with the wrong
648    // model. Compared canonically so a different spelling of the same directory passes.
649    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    // A re-pulled or re-converted artifact invalidates the resident weights.
664    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    // The speaker vector is validated rather than salvaged, and both halves of that matter.
686    //
687    // `filter_map(as_f64)` used to DROP entries that were not numbers, so `[1.0, "x", 2.0]`
688    // silently became a 2-element vector — a malformed request quietly became a different,
689    // well-formed one, conditioning generation on the wrong thing.
690    //
691    // Non-finite values are worse. A NaN or infinity reaching the Q8 quantizer trips its
692    // `is_finite` assertion, and because `handle_connection` runs inline in the accept loop a
693    // panic there takes down the daemon serving every other caller. Refusing here keeps a bad
694    // request a bad request instead of an outage.
695    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    // Lazy hydration: the expensive part, done once and kept.
733    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    /// A peer that never sends a newline must not be able to grow the daemon's memory without
822    /// bound. This drives the real socket path, because the bug lived in `read_line`'s contract
823    /// rather than in any parsing we control.
824    #[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        // A client that opens a connection and streams bytes with no terminator, forever.
833        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            // Stops on the first error, which is what happens once the server hangs up.
839            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    /// Malformed speaker vectors are refused, never silently repaired.
862    ///
863    /// The old code used `filter_map(as_f64)`, so a non-numeric entry was DROPPED and the request
864    /// proceeded with a shorter vector — a malformed request quietly becoming a different,
865    /// well-formed one. Non-finite values were worse: they reach the Q8 quantizer's `is_finite`
866    /// assertion, and a panic there used to take the whole daemon down with it.
867    #[test]
868    fn speaker_vectors_are_validated_rather_than_salvaged() {
869        // Mirrors the parsing in `handle_connection` exactly.
870        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        // JSON has no NaN literal, so the reachable non-finite case is an f64 too large for f32.
891        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        // SAFETY-free env mutation: tests in this module run single-threaded per process
935        // under `cargo test` only when isolated; use the dir directly instead of the env.
936        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    /// The daemon protocol refuses a bad token and answers nothing else. Uses a raw
950    /// socket against `handle_connection` semantics through a real listener thread.
951    #[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}