/
nv-lang
/
nova
Обзор
Документация
Войти
/
nv-lang
/
nova
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
main
nova-cli/src/daemon.rs
986 строк
43 KB
Evgeniy Golovin
feat(233 §2): daemon/main.rs/imports.rs/тесты — переименование nova.lock -> nova.lock.toml, nova.local.toml -> nova.override.toml
27 июл 2026, 03:39
27 июл 2026, 03:39
6325570
Код
Авторство
О чём код?
//! Plan 219 — build-демон: резидентный cache/config-сервис для `nova build`. //! //! **Что это НЕ.** Демон не гоняет весь `cmd_build` in-process (это //! потребовало бы редиректа stdout/stderr демона обратно клиенту — //! инвазивно, риск для гейта byte-identical). Клиент (`nova build`, //! обычный short-lived процесс, код БЕЗ изменений в остальном пайплайне) //! перед дорогими шагами спрашивает демон «дай готовое состояние» одним //! IPC round-trip'ом вместо секунд пересчёта, затем продолжает СВОИМ же //! кодом — печатает как раньше, тот же процесс, тот же stdout → //! byte-identical тривиально (не другой код-путь для вывода). //! //! **Что резидентно.** (1) toolchain-конфиг (`test_runner::Toolchain`) — //! на Windows включает захваченный env `vcvars64.bat` (дорогая операция, //! `capture_vcvars_env`, вызывается заново каждым процессом `nova build` //! без демона). (2) libuv-конфиг. (3) dep-lock ledger — "видел ли демон //! уже этот entry-manifest+lock в этом сеансе" → пропустить дорогой //! `lockfile::sync` (резолв версий, git tag listing), измерено ~987мс на //! манифесте с git+path-зависимостями (`docs/plans/wip/startup-latency-research.md`). //! //! **IPC.** `TcpListener` на `127.0.0.1:0` (OS выдаёт порт) — НЕ named //! pipe: кроссплатформенно без unsafe/WinAPI и без новых crate-зависимостей //! (std::net работает одинаково на Windows/Unix). Discovery-файл //! `<repo_root>/target/.nova-daemon/daemon.json` — `{pid, port, token, //! started_at}`; `target/` уже в `.gitignore`. Один демон на workspace, //! потому что discovery-файл живёт ВНУТРИ этого workspace's `target/`. //! `token` — 128 бит, сгенерированный демоном при старте — не //! криптографический секрет, просто защита от случайного кросс-толка //! с чужим процессом на том же localhost. //! //! **Инвалидация.** Toolchain — ключ = pref+explicit_clang+explicit_vcvars+ //! env_fingerprint (хеш PATH+NOVA_CLANG+NOVA_VCVARS+ProgramFiles(x86)); //! смена любого → промах → демон детектит заново (как клиент делал бы //! сам). Dep-lock — ключ = хеш(entry `nova.toml`)+хеш(lockfile'а, если //! есть — `nova.lock.toml`, либо legacy `nova.lock`) — "по содержимому, //! не mtime" (план §2.1). **Известный OPEN** //! (не блокер Ф.1, задокументирован в `docs/plans/wip/219-impl-notes.md`): //! не покрывает правку ТРАНЗИТИВНОГО манифеста (path/git-зависимости //! другого пакета) без изменения entry-манифеста/lock — полный обход //! графа живёт в `compiler-codegen/src/lockfile.rs`, вне зоны этой волны //! (`nova-cli/src/**` only). //! //! **Lifecycle.** `nova daemon start/stop/status`. Auto-spawn на первом //! `nova build`, не нашедшем демона — ТОЛЬКО под `NOVA_DAEMON=1` (opt-in, //! НЕ default-on: демон — фоновый процесс, переживающий родителя; молча //! плодить его на каждом `nova build` — риск для CI/песочниц). Explicit //! `nova daemon start` работает всегда, независимо от env. Idle-timeout — //! `NOVA_DAEMON_IDLE_SECS` (default 1800с). //! //! **Fallback.** Любая ошибка IPC (нет discovery-файла, коннект не //! удался, timeout, битый JSON) → `None` из `try_prime`/`try_commit` — //! caller (`cmd_build`) продолжает ТЕМ ЖЕ кодом, что без демона вообще. //! Никогда не паникует, никогда не блокирует билд дольше долей секунды //! (короткие connect/read-таймауты). use serde::{Deserialize, Serialize}; use std::collections::hash_map::DefaultHasher; use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::io::{BufRead, BufReader, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use nova_codegen::test_runner; // ==================== discovery ==================== fn daemon_dir(repo_root: &Path) -> PathBuf { repo_root.join("target").join(".nova-daemon") } fn discovery_path(repo_root: &Path) -> PathBuf { daemon_dir(repo_root).join("daemon.json") } #[derive(Serialize, Deserialize, Clone)] struct DaemonInfo { pid: u32, port: u16, token: String, started_at_unix: u64, } fn read_discovery(repo_root: &Path) -> Option<DaemonInfo> { let bytes = std::fs::read(discovery_path(repo_root)).ok()?; serde_json::from_slice(&bytes).ok() } /// Атомарная запись (temp + rename) — прецедент `build_cache::store_c` / /// Plan 215 `index_cache::save`. fn write_discovery(repo_root: &Path, info: &DaemonInfo) -> std::io::Result<()> { let dir = daemon_dir(repo_root); std::fs::create_dir_all(&dir)?; let final_path = discovery_path(repo_root); let tmp_path = dir.join(format!("daemon.{}.tmp", std::process::id())); let bytes = serde_json::to_vec_pretty(info).unwrap_or_default(); std::fs::write(&tmp_path, &bytes)?; std::fs::rename(&tmp_path, &final_path) } fn remove_discovery(repo_root: &Path) { let _ = std::fs::remove_file(discovery_path(repo_root)); } fn unix_now() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } /// 128-бит токен из энтропии процесса — НЕ криптографический секрет /// (никакой `rand`-зависимости заводить не стали ради этого), только /// защита от случайного кросс-толка между демоном и посторонним /// процессом на том же localhost-порту. fn gen_token() -> String { static COUNTER: AtomicU64 = AtomicU64::new(0); let counter = COUNTER.fetch_add(1, Ordering::Relaxed); let mut out = String::with_capacity(32); for salt in 0u64..2 { let mut h = DefaultHasher::new(); salt.hash(&mut h); counter.hash(&mut h); std::process::id().hash(&mut h); if let Ok(d) = SystemTime::now().duration_since(UNIX_EPOCH) { d.as_nanos().hash(&mut h); } let stack_addr = &h as *const _ as usize; stack_addr.hash(&mut h); out.push_str(&format!("{:016x}", h.finish())); } out } // ==================== wire protocol ==================== #[derive(Serialize, Deserialize)] struct Envelope { token: String, body: RequestBody, } #[derive(Serialize, Deserialize)] enum RequestBody { Prime(PrimeRequest), Commit(CommitRequest), Status, Shutdown, } #[derive(Serialize, Deserialize, Default)] struct PrimeRequest { toolchain_pref: String, explicit_clang: Option<String>, explicit_vcvars: Option<String>, env_fingerprint: String, rt_dir: String, pkg_dir: Option<String>, dep_combined_hash: Option<String>, } #[derive(Serialize, Deserialize)] struct CommitRequest { pkg_dir: String, dep_combined_hash: String, } #[derive(Serialize, Deserialize, Default)] struct PrimeResponseWire { toolchain: Option<WireToolchain>, libuv: Option<WireLibuv>, skip_dep_lock: bool, } #[derive(Serialize, Deserialize, Clone)] struct WireToolchain { kind: String, // "clang" | "msvc" | "gcc" clang: Option<String>, gcc: Option<String>, vcvars: Option<String>, env: Vec<(String, String)>, } #[derive(Serialize, Deserialize, Clone)] struct WireLibuv { include_dir: String, lib_file: String, eventloop_src: String, } #[derive(Serialize, Deserialize)] struct StatusInfo { pid: u32, uptime_secs: u64, requests_served: u64, toolchain_cached: bool, libuv_cached: usize, dep_ledger_entries: usize, } #[derive(Serialize, Deserialize)] enum Response { Prime(PrimeResponseWire), Committed, Status(StatusInfo), ShuttingDown, Error(String), } fn toolchain_to_wire(tc: &test_runner::Toolchain) -> WireToolchain { fn env_to_wire(env: &[(std::ffi::OsString, std::ffi::OsString)]) -> Vec<(String, String)> { env.iter() .map(|(k, v)| (k.to_string_lossy().into_owned(), v.to_string_lossy().into_owned())) .collect() } match tc { test_runner::Toolchain::Clang { clang, env, vcvars } => WireToolchain { kind: "clang".to_string(), clang: Some(clang.to_string_lossy().into_owned()), gcc: None, vcvars: vcvars.as_ref().map(|p| p.to_string_lossy().into_owned()), env: env_to_wire(env), }, test_runner::Toolchain::Msvc { env, vcvars } => WireToolchain { kind: "msvc".to_string(), clang: None, gcc: None, vcvars: vcvars.as_ref().map(|p| p.to_string_lossy().into_owned()), env: env_to_wire(env), }, test_runner::Toolchain::Gcc { gcc } => WireToolchain { kind: "gcc".to_string(), clang: None, gcc: Some(gcc.to_string_lossy().into_owned()), vcvars: None, env: Vec::new(), }, } } fn wire_to_toolchain(w: &WireToolchain) -> Option<test_runner::Toolchain> { let env: Vec<(std::ffi::OsString, std::ffi::OsString)> = w .env .iter() .map(|(k, v)| (std::ffi::OsString::from(k), std::ffi::OsString::from(v))) .collect(); match w.kind.as_str() { "clang" => Some(test_runner::Toolchain::Clang { clang: PathBuf::from(w.clang.as_ref()?), env, vcvars: w.vcvars.clone().map(PathBuf::from), }), "msvc" => Some(test_runner::Toolchain::Msvc { env, vcvars: w.vcvars.clone().map(PathBuf::from), }), "gcc" => Some(test_runner::Toolchain::Gcc { gcc: PathBuf::from(w.gcc.as_ref()?) }), _ => None, } } fn libuv_to_wire(cfg: &test_runner::LibuvConfig) -> WireLibuv { WireLibuv { include_dir: cfg.include_dir.to_string_lossy().into_owned(), lib_file: cfg.lib_file.to_string_lossy().into_owned(), eventloop_src: cfg.eventloop_src.to_string_lossy().into_owned(), } } fn wire_to_libuv(w: &WireLibuv) -> test_runner::LibuvConfig { test_runner::LibuvConfig { include_dir: PathBuf::from(&w.include_dir), lib_file: PathBuf::from(&w.lib_file), eventloop_src: PathBuf::from(&w.eventloop_src), } } // ==================== client-side (used by `cmd_build`) ==================== /// Результат успешного `Prime`-round-trip'а — то, что демон УЖЕ знает /// (или только что вычислил и закэшировал у себя) для этих inputs. /// `None`-поля означают "демон не смог посчитать это сам" (тот же класс /// graceful-degrade, что `detect_or_build_libuv` возвращает `None`) — /// caller падает назад на свой обычный детект для этого конкретного поля. pub struct PrimeOutcome { pub toolchain: Option<test_runner::Toolchain>, pub libuv: Option<test_runner::LibuvConfig>, pub skip_dep_lock: bool, } fn env_fingerprint() -> String { let mut h = DefaultHasher::new(); std::env::var("PATH").unwrap_or_default().hash(&mut h); std::env::var("NOVA_CLANG").unwrap_or_default().hash(&mut h); std::env::var("NOVA_VCVARS").unwrap_or_default().hash(&mut h); std::env::var("ProgramFiles(x86)").unwrap_or_default().hash(&mut h); format!("{:016x}", h.finish()) } /// Хеш (entry `nova.toml` content) + (lockfile content, если есть) — /// "по содержимому, не mtime" (план §2.1). Публичная — используется и /// перед dep-lock (Prime-запрос) и после успешного `sync()` (Commit). /// /// Plan 233 §2: lockfile — новое имя `nova.lock.toml` /// (`nova_codegen::lockfile::LOCK_FILE_NAME`), с fallback на legacy /// `nova.lock` (`LEGACY_LOCK_FILE_NAME`), тем же приоритетом, что /// `lockfile::load` (без повторного warning здесь — это только /// cache-инвалидационный хеш, не пользовательское чтение). pub fn dep_combined_hash(pkg_dir: &Path) -> Option<String> { let toml_bytes = std::fs::read(pkg_dir.join("nova.toml")).ok()?; let mut h = DefaultHasher::new(); "nova-daemon-dep-v1".hash(&mut h); toml_bytes.hash(&mut h); let lock_bytes = std::fs::read(pkg_dir.join(nova_codegen::lockfile::LOCK_FILE_NAME)) .or_else(|_| std::fs::read(pkg_dir.join(nova_codegen::lockfile::LEGACY_LOCK_FILE_NAME))); match lock_bytes { Ok(lock_bytes) => { true.hash(&mut h); lock_bytes.hash(&mut h); } Err(_) => false.hash(&mut h), } Some(format!("{:016x}", h.finish())) } fn connect(repo_root: &Path, timeout: Duration) -> Option<(TcpStream, DaemonInfo)> { let info = read_discovery(repo_root)?; let addr: SocketAddr = format!("127.0.0.1:{}", info.port).parse().ok()?; match TcpStream::connect_timeout(&addr, timeout) { Ok(s) => Some((s, info)), Err(_) => { // Discovery-файл есть, но демон недостижим (упал/убит) — // подчищаем, чтобы следующие вызовы не платили за тот же // неудачный коннект и auto-spawn знал, что демона реально нет. remove_discovery(repo_root); None } } } fn send_request(stream: &mut TcpStream, envelope: &Envelope) -> Option<()> { let mut payload = serde_json::to_vec(envelope).ok()?; payload.push(b'\n'); stream.write_all(&payload).ok() } fn read_response(stream: TcpStream) -> Option<Response> { let mut reader = BufReader::new(stream); let mut line = String::new(); reader.read_line(&mut line).ok()?; serde_json::from_str(line.trim()).ok() } /// Один IPC round-trip: спросить демон разом за toolchain/libuv-конфиг /// (нужны позже, у detect-шага `cmd_build`) и "пропустить ли dep-lock" /// (нужно немедленно, у dep-lock шага). `pkg_dir = None` — сборка без /// пакета (одиночный `.nv` без `nova.toml`), dep-lock-поля не заполняются /// (сервер вернёт `skip_dep_lock = false`, и вызывающий код просто не /// достигнет dep-lock шага вовсе — нет пакета, нечего резолвить). /// /// Возвращает `None` при ЛЮБОЙ проблеме (нет демона, коннект не удался, /// таймаут, битый ответ) — caller обязан продолжить обычным путём. pub fn try_prime( repo_root: &Path, toolchain_pref: &str, explicit_clang: Option<&Path>, explicit_vcvars: Option<&Path>, rt_dir: &Path, pkg_dir: Option<&Path>, ) -> Option<PrimeOutcome> { let (mut stream, info) = connect(repo_root, Duration::from_millis(300))?; // Toolchain-детект на промахе (vcvars-capture) может занять пару // секунд — читаем с запасом, но не бесконечно (fallback гарантирован). stream.set_read_timeout(Some(Duration::from_secs(20))).ok()?; stream.set_write_timeout(Some(Duration::from_secs(5))).ok()?; let req = PrimeRequest { toolchain_pref: toolchain_pref.to_string(), explicit_clang: explicit_clang.map(|p| p.to_string_lossy().into_owned()), explicit_vcvars: explicit_vcvars.map(|p| p.to_string_lossy().into_owned()), env_fingerprint: env_fingerprint(), rt_dir: rt_dir.to_string_lossy().into_owned(), pkg_dir: pkg_dir.map(|p| p.to_string_lossy().into_owned()), dep_combined_hash: pkg_dir.and_then(dep_combined_hash), }; let envelope = Envelope { token: info.token, body: RequestBody::Prime(req) }; send_request(&mut stream, &envelope)?; match read_response(stream)? { Response::Prime(p) => Some(PrimeOutcome { toolchain: p.toolchain.as_ref().and_then(wire_to_toolchain), libuv: p.libuv.as_ref().map(wire_to_libuv), skip_dep_lock: p.skip_dep_lock, }), _ => None, } } /// Уведомить демон: клиент только что сам успешно прогнал реальный /// `lockfile::sync` для `pkg_dir` — дать демону новый `combined_hash`, /// чтобы СЛЕДУЮЩИЙ билд получил `skip_dep_lock=true`. Fire-and-forget — /// ошибки полностью проглатываются (это чистая оптимизация, не корректность: /// если Commit не долетел, следующий билд просто снова резолвит сам). pub fn try_commit(repo_root: &Path, pkg_dir: &Path, combined_hash: &str) { let _ = (|| -> Option<()> { let (mut stream, info) = connect(repo_root, Duration::from_millis(300))?; stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?; stream.set_write_timeout(Some(Duration::from_secs(2))).ok()?; let envelope = Envelope { token: info.token, body: RequestBody::Commit(CommitRequest { pkg_dir: pkg_dir.to_string_lossy().into_owned(), dep_combined_hash: combined_hash.to_string(), }), }; send_request(&mut stream, &envelope)?; let _ = read_response(stream); Some(()) })(); } // ==================== lifecycle ==================== /// `NOVA_DAEMON=1`/`true`/`on` — включает AUTO-SPAWN (демон стартует сам /// на первом `nova build`, если ещё не запущен). По умолчанию (env не /// задан) auto-spawn ВЫКЛЮЧЕН — сознательное отличие от Plan 218's /// default-on `NOVA_RT_ARCHIVE` (см. `daemon.rs` module doc / Ф.1 /// impl-notes): демон плодит ФОНОВЫЙ процесс, а не просто in-process /// кэш, и молчаливо оставлять detached-процессы в CI/песочницах — не /// то поведение, которое должно включаться без явного согласия. Ручной /// `nova daemon start` работает ВСЕГДА, независимо от этой переменной. fn autostart_enabled() -> bool { matches!(std::env::var("NOVA_DAEMON").as_deref(), Ok("1") | Ok("true") | Ok("on")) } fn idle_timeout() -> Duration { let secs = std::env::var("NOVA_DAEMON_IDLE_SECS") .ok() .and_then(|s| s.parse::<u64>().ok()) .unwrap_or(1800); Duration::from_secs(secs) } /// Спавнит демон в фоне для `repo_root`, ЕСЛИ (а) `NOVA_DAEMON=1` И /// (б) discovery-файла ещё нет (не пытаемся дублировать живой/только что /// заспавненный демон). Никогда не блокирует и не падает — best-effort. pub fn maybe_auto_spawn(repo_root: &Path) { if !autostart_enabled() { return; } if read_discovery(repo_root).is_some() { return; } spawn_detached(); } fn spawn_detached() { let exe = match std::env::current_exe() { Ok(e) => e, Err(_) => return, }; let mut cmd = std::process::Command::new(exe); cmd.arg("daemon").arg("serve"); cmd.stdin(std::process::Stdio::null()); cmd.stdout(std::process::Stdio::null()); cmd.stderr(std::process::Stdio::null()); #[cfg(target_os = "windows")] { use std::os::windows::process::CommandExt; // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP — не наследует // консоль родителя, не получает Ctrl+C родительской группы. const DETACHED_PROCESS: u32 = 0x0000_0008; const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); } // Unix: без session-detach (нет `libc`-зависимости ради одного вызова // setsid) — ребёнок остаётся в группе процессов родителя. Известное // упрощение V1 (основная целевая платформа этой волны — Windows, см. // impl-notes.md); не влияет на корректность, только на то, что сигнал // группе теоретически долетит и до демона. let _ = cmd.spawn(); } /// `nova daemon start` — идемпотентно: если уже запущен, сообщает и /// выходит. Иначе спавнит (независимо от `NOVA_DAEMON` — явная команда /// владельца) и коротко поллит готовность. pub fn cmd_start(repo_root: &Path) -> anyhow::Result<()> { if let Some((stream, _info)) = connect(repo_root, Duration::from_millis(300)) { drop(stream); println!("nova-daemon: already running for {}", repo_root.display()); return Ok(()); } spawn_detached(); let deadline = Instant::now() + Duration::from_secs(5); while Instant::now() < deadline { if connect(repo_root, Duration::from_millis(200)).is_some() { println!("nova-daemon: started for {}", repo_root.display()); return Ok(()); } std::thread::sleep(Duration::from_millis(150)); } println!( "nova-daemon: spawn requested but not reachable within 5s \ (check permissions / antivirus; falls back to cold builds meanwhile)" ); Ok(()) } /// `nova daemon stop` — graceful `Shutdown`; no-op (with a note) if not running. pub fn cmd_stop(repo_root: &Path) -> anyhow::Result<()> { let (mut stream, info) = match connect(repo_root, Duration::from_millis(300)) { Some(x) => x, None => { println!("nova-daemon: not running for {}", repo_root.display()); return Ok(()); } }; let envelope = Envelope { token: info.token.clone(), body: RequestBody::Shutdown }; send_request(&mut stream, &envelope); let _ = read_response(stream); remove_discovery(repo_root); println!("nova-daemon: stopped (pid {})", info.pid); Ok(()) } /// `nova daemon status` — reachability + resident cache summary. pub fn cmd_status(repo_root: &Path) -> anyhow::Result<()> { let (mut stream, info) = match connect(repo_root, Duration::from_millis(300)) { Some(x) => x, None => { println!("nova-daemon: not running for {}", repo_root.display()); return Ok(()); } }; let envelope = Envelope { token: info.token, body: RequestBody::Status }; send_request(&mut stream, &envelope); match read_response(stream) { Some(Response::Status(s)) => { println!( "nova-daemon: running (pid {}, uptime {}s, requests served {})", s.pid, s.uptime_secs, s.requests_served ); println!( " cache: toolchain={} libuv_buckets={} dep_ledger_entries={}", if s.toolchain_cached { "warm" } else { "cold" }, s.libuv_cached, s.dep_ledger_entries ); } _ => println!("nova-daemon: reachable but status query failed"), } Ok(()) } // ==================== server ==================== struct ToolchainEntry { key: String, toolchain: test_runner::Toolchain, } struct DaemonState { started_at: Instant, last_activity: Mutex<Instant>, requests_served: AtomicU64, toolchain: Mutex<Option<ToolchainEntry>>, libuv: Mutex<HashMap<String, test_runner::LibuvConfig>>, dep_ledger: Mutex<HashMap<String, String>>, } impl DaemonState { fn new() -> Self { DaemonState { started_at: Instant::now(), last_activity: Mutex::new(Instant::now()), requests_served: AtomicU64::new(0), toolchain: Mutex::new(None), libuv: Mutex::new(HashMap::new()), dep_ledger: Mutex::new(HashMap::new()), } } } fn toolchain_cache_key(req: &PrimeRequest) -> String { format!( "{}|{}|{}|{}", req.toolchain_pref, req.explicit_clang.as_deref().unwrap_or(""), req.explicit_vcvars.as_deref().unwrap_or(""), req.env_fingerprint, ) } fn handle_prime(state: &DaemonState, repo_root: &Path, req: PrimeRequest) -> PrimeResponseWire { let key = toolchain_cache_key(&req); let mut tc_guard = state.toolchain.lock().unwrap(); let need_fresh = !matches!(&*tc_guard, Some(entry) if entry.key == key); if need_fresh { let pref = test_runner::ToolchainPref::parse(&req.toolchain_pref) .unwrap_or(test_runner::ToolchainPref::Auto); let explicit_clang = req.explicit_clang.as_ref().map(PathBuf::from); let explicit_vcvars = req.explicit_vcvars.as_ref().map(PathBuf::from); let opts = test_runner::ToolchainOpts { pref, explicit_clang: explicit_clang.as_deref(), explicit_vcvars: explicit_vcvars.as_deref(), }; match test_runner::detect_toolchain(&opts) { Ok(tc) => *tc_guard = Some(ToolchainEntry { key: key.clone(), toolchain: tc }), Err(_) => *tc_guard = None, } } let toolchain_wire = tc_guard.as_ref().map(|e| toolchain_to_wire(&e.toolchain)); let vcvars_path: Option<PathBuf> = tc_guard.as_ref().and_then(|e| e.toolchain.vcvars_path().map(|p| p.to_path_buf())); drop(tc_guard); // Осторожно: `detect_or_build_libuv` делает `std::process::exit(1)` при // отсутствующем libuv submodule (FATAL — обычное поведение для // одноразового CLI-процесса). Демон — РЕЗИДЕНТНЫЙ процесс: такой exit // убил бы кэш для ВСЕХ будущих клиентов, не только текущего запроса. // Пре-проверка ниже дублирует ПЕРВУЮ проверку самой функции — если её // не пройти, тихо возвращаем `None` (клиент падает назад на свой // собственный `detect_or_build_libuv`, который честно даст тот же // FATAL exit — не хуже pre-219 поведения, просто не рушит демон). let rt_dir = PathBuf::from(&req.rt_dir); let libuv_wire = if !rt_dir.join("libuv").join("include").join("uv.h").is_file() { None } else { let vcvars_str = vcvars_path.as_ref().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default(); let lkey = format!("{}|{}", req.rt_dir, vcvars_str); let cached = state.libuv.lock().unwrap().get(&lkey).cloned(); match cached { Some(cfg) => Some(libuv_to_wire(&cfg)), None => { // detect_or_build_libuv может строить .lib (первый раз) — // не держим никакой другой лок во время этого вызова. match test_runner::detect_or_build_libuv(&rt_dir, repo_root, vcvars_path.as_deref()) { Some(cfg) => { let wire = libuv_to_wire(&cfg); state.libuv.lock().unwrap().insert(lkey, cfg); Some(wire) } None => None, } } } }; let skip_dep_lock = match (&req.pkg_dir, &req.dep_combined_hash) { (Some(pkg_dir), Some(hash)) => { state.dep_ledger.lock().unwrap().get(pkg_dir).is_some_and(|h| h == hash) } _ => false, }; PrimeResponseWire { toolchain: toolchain_wire, libuv: libuv_wire, skip_dep_lock } } fn handle_connection(mut stream: TcpStream, state: &Arc<DaemonState>, token: &str, repo_root: &Path) { let _ = stream.set_read_timeout(Some(Duration::from_secs(30))); let _ = stream.set_write_timeout(Some(Duration::from_secs(5))); let mut reader = BufReader::new(match stream.try_clone() { Ok(s) => s, Err(_) => return, }); let mut line = String::new(); if reader.read_line(&mut line).is_err() || line.trim().is_empty() { return; } let envelope: Envelope = match serde_json::from_str(line.trim()) { Ok(e) => e, Err(e) => { let _ = send_response(&mut stream, &Response::Error(format!("bad request: {}", e))); return; } }; if envelope.token != token { let _ = send_response(&mut stream, &Response::Error("bad token".to_string())); return; } *state.last_activity.lock().unwrap() = Instant::now(); state.requests_served.fetch_add(1, Ordering::Relaxed); match envelope.body { RequestBody::Prime(req) => { let resp = handle_prime(state, repo_root, req); let _ = send_response(&mut stream, &Response::Prime(resp)); } RequestBody::Commit(req) => { state.dep_ledger.lock().unwrap().insert(req.pkg_dir, req.dep_combined_hash); let _ = send_response(&mut stream, &Response::Committed); } RequestBody::Status => { let info = StatusInfo { pid: std::process::id(), uptime_secs: state.started_at.elapsed().as_secs(), requests_served: state.requests_served.load(Ordering::Relaxed), toolchain_cached: state.toolchain.lock().unwrap().is_some(), libuv_cached: state.libuv.lock().unwrap().len(), dep_ledger_entries: state.dep_ledger.lock().unwrap().len(), }; let _ = send_response(&mut stream, &Response::Status(info)); } RequestBody::Shutdown => { let _ = send_response(&mut stream, &Response::ShuttingDown); remove_discovery(repo_root); // Дать сокету время дойти до клиента до выхода процесса. std::thread::sleep(Duration::from_millis(80)); std::process::exit(0); } } } fn send_response(stream: &mut TcpStream, resp: &Response) -> std::io::Result<()> { let mut bytes = serde_json::to_vec(resp).unwrap_or_default(); bytes.push(b'\n'); stream.write_all(&bytes) } /// `nova daemon serve` — тело резидентного процесса. Блокирует навсегда /// (выход только через `Shutdown`-запрос или idle-timeout, оба зовут /// `std::process::exit` напрямую). Вызывается либо напрямую (диагностика, /// `nova daemon serve` в foreground), либо из `spawn_detached` /// (фоновый процесс). pub fn run_server(repo_root: PathBuf) -> anyhow::Result<()> { let listener = TcpListener::bind(("127.0.0.1", 0)) .map_err(|e| anyhow::anyhow!("bind daemon socket: {}", e))?; let port = listener .local_addr() .map_err(|e| anyhow::anyhow!("local_addr: {}", e))? .port(); let token = gen_token(); let info = DaemonInfo { pid: std::process::id(), port, token: token.clone(), started_at_unix: unix_now() }; write_discovery(&repo_root, &info).map_err(|e| anyhow::anyhow!("write discovery file: {}", e))?; let state = Arc::new(DaemonState::new()); let timeout = idle_timeout(); { let state = Arc::clone(&state); let repo_root = repo_root.clone(); std::thread::Builder::new() .name("nova-daemon-idle-watchdog".to_string()) .spawn(move || loop { std::thread::sleep(Duration::from_secs(5)); let last = *state.last_activity.lock().unwrap(); if last.elapsed() >= timeout { eprintln!("nova-daemon: idle {}s exceeded, exiting", timeout.as_secs()); remove_discovery(&repo_root); std::process::exit(0); } }) .ok(); } eprintln!("nova-daemon: listening on 127.0.0.1:{} for {}", port, repo_root.display()); for incoming in listener.incoming() { let stream = match incoming { Ok(s) => s, Err(_) => continue, }; let state = Arc::clone(&state); let token = token.clone(); let repo_root = repo_root.clone(); std::thread::spawn(move || handle_connection(stream, &state, &token, &repo_root)); } Ok(()) } #[cfg(test)] mod tests { use super::*; fn tmp_root(tag: &str) -> PathBuf { let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0); std::env::temp_dir().join(format!("nova_p219_{}_{}_{}", tag, std::process::id(), nanos)) } /// Plan 233 §2 (back-compat): legacy lock name `nova.lock` still /// participates in the hash (fallback), regression coverage for the /// pre-Plan-233 filename. #[test] fn dep_hash_stable_and_content_sensitive() { let root = tmp_root("dephash"); std::fs::create_dir_all(&root).unwrap(); std::fs::write(root.join("nova.toml"), "[package]\nname=\"t\"\n").unwrap(); let h1 = dep_combined_hash(&root).expect("hash"); let h2 = dep_combined_hash(&root).expect("hash"); assert_eq!(h1, h2, "identical inputs -> identical hash"); std::fs::write(root.join("nova.lock"), "# lock v1\n").unwrap(); let h3 = dep_combined_hash(&root).expect("hash"); assert_ne!(h1, h3, "adding a lock file changes the hash"); std::fs::write(root.join("nova.toml"), "[package]\nname=\"t2\"\n").unwrap(); let h4 = dep_combined_hash(&root).expect("hash"); assert_ne!(h3, h4, "manifest content change changes the hash"); let _ = std::fs::remove_dir_all(&root); } /// Plan 233 §2: NEW lock name `nova.lock.toml` participates in the /// hash too (preferred over legacy when both would exist — see /// `nova_codegen::lockfile::load`'s precedence, mirrored here via /// `or_else`). #[test] fn dep_hash_content_sensitive_new_lock_name() { let root = tmp_root("dephash_new"); std::fs::create_dir_all(&root).unwrap(); std::fs::write(root.join("nova.toml"), "[package]\nname=\"t\"\n").unwrap(); let h1 = dep_combined_hash(&root).expect("hash"); std::fs::write(root.join("nova.lock.toml"), "# lock v1\n").unwrap(); let h2 = dep_combined_hash(&root).expect("hash"); assert_ne!(h1, h2, "adding nova.lock.toml changes the hash"); std::fs::write(root.join("nova.lock.toml"), "# lock v1 changed\n").unwrap(); let h3 = dep_combined_hash(&root).expect("hash"); assert_ne!(h2, h3, "changing nova.lock.toml content changes the hash"); let _ = std::fs::remove_dir_all(&root); } #[test] fn dep_hash_none_without_manifest() { let root = tmp_root("nomanifest"); std::fs::create_dir_all(&root).unwrap(); assert!(dep_combined_hash(&root).is_none()); let _ = std::fs::remove_dir_all(&root); } #[test] fn discovery_roundtrip() { let root = tmp_root("discovery"); std::fs::create_dir_all(&root).unwrap(); assert!(read_discovery(&root).is_none(), "no file yet -> None"); let info = DaemonInfo { pid: 1234, port: 5555, token: "abc".to_string(), started_at_unix: 42 }; write_discovery(&root, &info).unwrap(); let read = read_discovery(&root).expect("roundtrip"); assert_eq!(read.pid, 1234); assert_eq!(read.port, 5555); assert_eq!(read.token, "abc"); remove_discovery(&root); assert!(read_discovery(&root).is_none(), "removed -> None again"); let _ = std::fs::remove_dir_all(&root); } #[test] fn discovery_corrupt_json_is_none() { let root = tmp_root("corrupt"); std::fs::create_dir_all(&daemon_dir(&root)).unwrap(); std::fs::write(discovery_path(&root), b"{not json").unwrap(); assert!(read_discovery(&root).is_none(), "corrupt discovery file -> None, not panic"); let _ = std::fs::remove_dir_all(&root); } #[test] fn wire_toolchain_roundtrip_clang() { let tc = test_runner::Toolchain::Clang { clang: PathBuf::from("/usr/bin/clang"), env: vec![(std::ffi::OsString::from("PATH"), std::ffi::OsString::from("/usr/bin"))], vcvars: None, }; let wire = toolchain_to_wire(&tc); let back = wire_to_toolchain(&wire).expect("roundtrip"); match back { test_runner::Toolchain::Clang { clang, env, vcvars } => { assert_eq!(clang, PathBuf::from("/usr/bin/clang")); assert_eq!(env.len(), 1); assert!(vcvars.is_none()); } _ => panic!("wrong variant"), } } #[test] fn wire_toolchain_roundtrip_gcc() { let tc = test_runner::Toolchain::Gcc { gcc: PathBuf::from("/usr/bin/gcc") }; let wire = toolchain_to_wire(&tc); let back = wire_to_toolchain(&wire).expect("roundtrip"); match back { test_runner::Toolchain::Gcc { gcc } => assert_eq!(gcc, PathBuf::from("/usr/bin/gcc")), _ => panic!("wrong variant"), } } #[test] fn wire_libuv_roundtrip() { let cfg = test_runner::LibuvConfig { include_dir: PathBuf::from("/x/include"), lib_file: PathBuf::from("/x/libuv.a"), eventloop_src: PathBuf::from("/x/eventloop.c"), }; let wire = libuv_to_wire(&cfg); let back = wire_to_libuv(&wire); assert_eq!(back.include_dir, cfg.include_dir); assert_eq!(back.lib_file, cfg.lib_file); assert_eq!(back.eventloop_src, cfg.eventloop_src); } #[test] fn toolchain_cache_key_differs_on_env_fingerprint() { let mut req = PrimeRequest { toolchain_pref: "clang".to_string(), env_fingerprint: "aaa".to_string(), ..Default::default() }; let k1 = toolchain_cache_key(&req); req.env_fingerprint = "bbb".to_string(); let k2 = toolchain_cache_key(&req); assert_ne!(k1, k2, "different env_fingerprint -> different cache key"); } /// Прямая проверка приёмочного гейта: демон-цикл (bind -> Prime без /// pkg_dir -> Status -> Shutdown) через реальный TCP-сокет на /// localhost, без реального toolchain-детекта (тест окружения может /// не иметь clang/vcvars — используем заведомо отсутствующий rt_dir /// так, что libuv/toolchain возвращают `None`/ошибку, но протокол и /// dep_ledger-путь ("нет pkg_dir" -> skip_dep_lock=false) проверяются /// целиком). #[test] fn server_round_trip_status_and_prime_without_pkg_dir() { let root = tmp_root("server_rt"); std::fs::create_dir_all(&root).unwrap(); let root_for_server = root.clone(); let handle = std::thread::spawn(move || { let _ = run_server(root_for_server); }); // Ждём discovery-файл (сервер пишет его до входа в accept-цикл). let deadline = Instant::now() + Duration::from_secs(5); let mut info = None; while Instant::now() < deadline { if let Some(i) = read_discovery(&root) { info = Some(i); break; } std::thread::sleep(Duration::from_millis(20)); } let info = info.expect("daemon discovery file appeared"); // Status let (mut stream, _) = connect(&root, Duration::from_millis(500)).expect("connect"); let envelope = Envelope { token: info.token.clone(), body: RequestBody::Status }; send_request(&mut stream, &envelope).expect("send"); match read_response(stream).expect("response") { // counter includes THIS Status request itself (incremented // before dispatch, same as every request kind) — first request // served -> 1, not 0. Response::Status(s) => assert_eq!(s.requests_served, 1), _ => panic!("expected Status"), } // Prime без pkg_dir -> skip_dep_lock всегда false (нечего резолвить) let (mut stream, _) = connect(&root, Duration::from_millis(500)).expect("connect"); let req = PrimeRequest { toolchain_pref: "auto".to_string(), env_fingerprint: env_fingerprint(), rt_dir: "__nonexistent_rt_dir_for_test__".to_string(), ..Default::default() }; let envelope = Envelope { token: info.token.clone(), body: RequestBody::Prime(req) }; send_request(&mut stream, &envelope).expect("send"); match read_response(stream).expect("response") { Response::Prime(p) => assert!(!p.skip_dep_lock, "no pkg_dir -> skip_dep_lock=false"), _ => panic!("expected Prime"), } // Bad token -> Error, not a crash. let (mut stream, _) = connect(&root, Duration::from_millis(500)).expect("connect"); let envelope = Envelope { token: "wrong-token".to_string(), body: RequestBody::Status }; send_request(&mut stream, &envelope).expect("send"); match read_response(stream).expect("response") { Response::Error(_) => {} _ => panic!("expected Error for bad token"), } // Shutdown -> сервер выходит из accept-цикла (process::exit не // вызываем в тестовом потоке напрямую — реальный `run_server` // зовёт `std::process::exit`, что убило бы тестовый процесс; // здесь просто проверяем, что Shutdown доходит и отвечает // ДО завершения, без проверки самого exit (покрыто в E2E-замере // через `nova daemon stop`, см. отчёт волны). let _ = handle; // поток либо жив (ждёт нового accept), либо уже exit'нул тестовый бинарь тестраннера — не join'им намеренно. } }